Files
oh-my-pi/packages/coding-agent/src/session/prewalk.ts
T
2026-08-17 18:28:23 -07:00

338 lines
13 KiB
TypeScript

import type { Agent, AgentMessage, AgentToolResult, AgentTurnEndContext } from "@oh-my-pi/pi-agent-core";
import { invalidateMessageCache } from "@oh-my-pi/pi-agent-core/compaction";
import type { Model, ToolResultMessage } from "@oh-my-pi/pi-ai";
import { prompt } from "@oh-my-pi/pi-utils";
import type { LocalProtocolOptions } from "../internal-urls";
import { resolveApprovedPlan } from "../plan-mode/approved-plan";
import { listPlanFiles, readPlanFile } from "../plan-mode/plan-files";
import type { PlanModeState } from "../plan-mode/state";
import planYoloHandoffPrompt from "../prompts/system/plan-yolo-handoff.md" with { type: "text" };
import prewalkChecklistPrompt from "../prompts/system/prewalk-checklist.md" with { type: "text" };
import prewalkContinuePrompt from "../prompts/system/prewalk-continue.md" with { type: "text" };
import prewalkPlanPrompt from "../prompts/system/prewalk-plan.md" with { type: "text" };
import { type ConfiguredThinkingLevel, prewalkWouldBeNoop } from "../thinking";
import type { PlanProposalHandler } from "../tools/resolve";
import { isMCPToolName } from "../tools/builtin-names";
import { ToolError } from "../tools/tool-errors";
import type { PlanYolo, Prewalk } from "./agent-session-types";
import { PREWALK_PLAN_MESSAGE_TYPE } from "./messages";
import type { SessionManager } from "./session-manager";
const PREWALK_CONTINUE_MESSAGE_TYPE = "prewalk-continue";
const PREWALK_CHECKLIST_MESSAGE_TYPE = "prewalk-checklist";
/** Hidden plan steering is consumed within the live run and must not reappear after a context rebuild. */
export function isPrewalkPlanNudge(message: AgentMessage): boolean {
return message.role === "custom" && message.customType === PREWALK_PLAN_MESSAGE_TYPE;
}
const PREWALK_ACTION_TOOLS: Record<string, true> = {
edit: true,
write: true,
};
const PLAN_YOLO_HANDOFF_MESSAGE_TYPE = "plan-yolo-handoff";
/**
* Whether a completed tool result is the first workspace-mutating action that
* arms the prewalk hand-off. A direct `edit`/`write` call always counts; a
* `write` that dispatched an `xd://` device (e.g. `lsp`, `ast_edit`, `debug`)
* counts only when the wrapped tool resolved to a `write`/`exec` approval tier.
* Read-only device calls — LSP navigation, `debug` inspection, `ast_edit` on
* internal URLs, help lookups — leave the tier `read` (or absent) and must not
* switch the model mid-investigation (issue #7312).
*/
function isPrewalkImplementationAction(result: ToolResultMessage): boolean {
if (!PREWALK_ACTION_TOOLS[result.toolName]) return false;
const details = result.details;
// A direct filesystem edit/write carries no `xd://` dispatch metadata.
if (!details || typeof details !== "object" || !("xdev" in details) || !details.xdev) return true;
const xdev = details.xdev;
// Device dispatch: switch only on a genuine mutation tier. An absent tier
// (help lookup, unresolved approval) declines the switch, matching the
// reporter's "stay on the large model a couple turns longer" preference.
if (typeof xdev !== "object" || !("tier" in xdev)) return false;
return xdev.tier === "write" || xdev.tier === "exec";
}
/** Capabilities the prewalk coordinator borrows from its owning session. */
export interface PrewalkCoordinatorHost {
agent: Agent;
sessionManager: SessionManager;
model(): Model | undefined;
configuredThinkingLevel(): ConfiguredThinkingLevel | undefined;
emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void;
setModelTemporary(
model: Model,
thinkingLevel?: ConfiguredThinkingLevel,
options?: { ephemeral?: boolean },
): Promise<void>;
setActiveToolsByName(names: string[]): Promise<void>;
setActiveToolPresentation(toolNames: string[], mountedToolNames: string[]): Promise<void>;
runToolRegistryMutation<T>(mutation: () => Promise<T>): Promise<T>;
getActiveToolNames(): string[];
getEnabledToolNames(): string[];
getSelectedMCPToolNames(): string[];
getMountedXdevToolNames(): string[];
hasBuiltInTool(name: string): boolean;
getPlanModeState(): PlanModeState | undefined;
setPlanModeState(state: PlanModeState | undefined): void;
getPlanReferencePath(): string;
setPlanProposalHandler(handler: PlanProposalHandler | null): void;
waitForSessionMessagePersistence(message: AgentMessage): Promise<void>;
localProtocolOptions(): LocalProtocolOptions;
}
/** Initial state for prewalk and plan-yolo startup flows. */
export interface PrewalkCoordinatorOptions {
prewalk?: Prewalk;
planYolo?: PlanYolo;
}
/** Coordinates one-way model prewalks and automatic plan-yolo handoffs. */
export class PrewalkCoordinator {
readonly #host: PrewalkCoordinatorHost;
#prewalk: Prewalk | undefined;
#planInjected = false;
#continuePending = false;
#todoSeen = false;
#planYolo: PlanYolo | undefined;
#planYoloPreviousNonMCPPresentation: { enabled: string[]; mounted: string[] } | undefined;
#planYoloArmed = false;
constructor(host: PrewalkCoordinatorHost, options: PrewalkCoordinatorOptions = {}) {
this.#host = host;
this.#prewalk = options.prewalk;
this.#planYolo = options.planYolo;
}
/** Current prewalk target, if the one-way switch remains armed. */
get state(): Prewalk | undefined {
return this.#prewalk;
}
#isNoop(prewalk: Prewalk): boolean {
return prewalkWouldBeNoop(
this.#host.model(),
this.#host.configuredThinkingLevel(),
prewalk.target,
prewalk.thinkingLevel,
);
}
#clearPrewalkState(): void {
this.#prewalk = undefined;
this.#planInjected = false;
this.#continuePending = false;
this.#todoSeen = false;
}
#disarmNoop(prewalk: Prewalk): void {
this.#clearPrewalkState();
this.#host.emitNotice(
"info",
`Prewalk: target ${prewalk.target.provider}/${prewalk.target.id} already matches the active model and thinking level; nothing to switch.`,
"prewalk",
);
}
/** Advances the one-way prewalk switch at a completed assistant-turn boundary. */
async advanceAtTurnEnd(liveMessages: AgentMessage[], context: AgentTurnEndContext | undefined): Promise<void> {
const prewalk = this.#prewalk;
if (!prewalk || context?.message.role !== "assistant") return;
if (this.#isNoop(prewalk)) {
this.#scrubPlanNudge(liveMessages);
this.#disarmNoop(prewalk);
return;
}
if (context.toolResults.some(result => result.toolName === "todo" && !result.isError)) this.#todoSeen = true;
const hasToolResults = context.toolResults.length > 0;
if (this.#planInjected && hasToolResults) {
this.#continuePending = true;
} else if (this.#continuePending) {
this.#continuePending = false;
this.#host.agent.steer({
role: "custom",
customType: PREWALK_CONTINUE_MESSAGE_TYPE,
content: prewalkContinuePrompt,
attribution: "agent",
display: false,
timestamp: Date.now(),
});
}
const todoGateOpen = this.#todoSeen || !this.#host.getActiveToolNames().includes("todo");
const action = todoGateOpen
? context.toolResults.find(result => isPrewalkImplementationAction(result))
: undefined;
if (!action) {
if (!this.#planInjected) {
this.#planInjected = true;
this.#continuePending = true;
this.#host.agent.steer({
role: "custom",
customType: PREWALK_PLAN_MESSAGE_TYPE,
content: prewalkPlanPrompt,
display: false,
attribution: "agent",
timestamp: Date.now(),
});
this.#host.emitNotice("info", "Prewalk: injected deep-plan nudge.", "prewalk");
}
return;
}
await this.#host.waitForSessionMessagePersistence(context.message);
for (const toolResult of context.toolResults) {
await this.#host.waitForSessionMessagePersistence(toolResult);
}
this.#scrubPlanNudge(liveMessages);
const target = prewalk.target;
if (this.#isNoop(prewalk)) {
this.#disarmNoop(prewalk);
return;
}
await this.#host.setModelTemporary(target, prewalk.thinkingLevel, { ephemeral: true });
this.#clearPrewalkState();
this.#host.emitNotice(
"info",
`Prewalk: switched to ${target.provider}/${target.id} after first ${action.toolName} call.`,
"prewalk",
);
this.#host.agent.steer({
role: "custom",
customType: PREWALK_CHECKLIST_MESSAGE_TYPE,
content: prewalkChecklistPrompt,
attribution: "agent",
display: false,
timestamp: Date.now(),
});
}
/** Arms a prewalk immediately for an explicit slash-command request. */
arm(target: Model, thinkingLevel?: ConfiguredThinkingLevel): boolean {
const active = this.#prewalk;
if (active) {
this.#host.emitNotice(
"info",
`Prewalk: already armed for ${active.target.provider}/${active.target.id}, waiting for the first edit/write.`,
"prewalk",
);
return (
active.target.provider === target.provider &&
active.target.id === target.id &&
active.thinkingLevel === thinkingLevel
);
}
const candidate = { target, thinkingLevel };
if (this.#isNoop(candidate)) {
this.#disarmNoop(candidate);
return false;
}
this.#prewalk = candidate;
this.#planInjected = true;
this.#continuePending = true;
this.#todoSeen = false;
this.#host.agent.steer({
role: "custom",
customType: PREWALK_PLAN_MESSAGE_TYPE,
content: prewalkPlanPrompt,
display: false,
attribution: "agent",
timestamp: Date.now(),
});
this.#host.emitNotice(
"info",
`Prewalk: armed for ${target.provider}/${target.id} — will switch at the first edit/write once the todo list exists.`,
"prewalk",
);
return true;
}
/** Lazily enables plan-yolo's plan phase before the first prompt is built. */
async armPlanYoloIfNeeded(): Promise<void> {
if (!this.#planYolo || this.#planYoloArmed) return;
this.#planYoloArmed = true;
const previousEnabledTools = this.#host.getEnabledToolNames();
const previousMountedTools = this.#host.getMountedXdevToolNames();
const augmentations = this.#host.hasBuiltInTool("write") ? ["write"] : [];
await this.#host.setActiveToolsByName([...new Set([...previousEnabledTools, ...augmentations])]);
this.#planYoloPreviousNonMCPPresentation = {
enabled: previousEnabledTools.filter(name => !isMCPToolName(name)),
mounted: previousMountedTools.filter(name => !isMCPToolName(name)),
};
this.#host.setPlanModeState({
enabled: true,
planFilePath: this.#host.getPlanReferencePath() || "local://PLAN.md",
workflow: "parallel",
});
this.#host.setPlanProposalHandler(title => this.#finalizePlanYoloProposal(title));
}
#scrubPlanNudge(liveMessages: AgentMessage[]): void {
if (!this.#planInjected) return;
const isPlanNudge = isPrewalkPlanNudge;
for (let index = liveMessages.length - 1; index >= 0; index--) {
if (!isPlanNudge(liveMessages[index])) continue;
invalidateMessageCache(liveMessages[index]);
liveMessages.splice(index, 1);
}
const stateMessages = this.#host.agent.state.messages;
const filtered = stateMessages.filter(message => !isPlanNudge(message));
if (filtered.length !== stateMessages.length) this.#host.agent.replaceMessages(filtered);
}
async #finalizePlanYoloProposal(title: string): Promise<AgentToolResult<unknown>> {
const planYolo = this.#planYolo;
const state = this.#host.getPlanModeState();
if (!planYolo || !state?.enabled) throw new ToolError("Plan mode is not active.");
const { planFilePath, title: resolvedTitle } = await resolveApprovedPlan({
suppliedTitle: title,
statePlanFilePath: state.planFilePath,
readPlan: url =>
readPlanFile(url, {
localProtocolOptions: this.#host.localProtocolOptions(),
cwd: this.#host.sessionManager.getCwd(),
}),
listPlanFiles: () => listPlanFiles({ localProtocolOptions: this.#host.localProtocolOptions() }),
});
this.#host.setPlanModeState(undefined);
const previousPresentation = this.#planYoloPreviousNonMCPPresentation;
try {
if (previousPresentation) {
await this.#host.runToolRegistryMutation(async () => {
const liveMCP = this.#host.getSelectedMCPToolNames();
const liveMountedMCP = this.#host.getMountedXdevToolNames().filter(isMCPToolName);
await this.#host.setActiveToolPresentation(
[...new Set([...previousPresentation.enabled, ...liveMCP])],
[...new Set([...previousPresentation.mounted, ...liveMountedMCP])],
);
});
}
} catch (error) {
this.#host.setPlanModeState(state);
throw error;
}
this.#host.setPlanProposalHandler(null);
this.#planYolo = undefined;
this.#planYoloPreviousNonMCPPresentation = undefined;
await this.#host.setModelTemporary(planYolo.target, planYolo.thinkingLevel, { ephemeral: true });
this.#host.emitNotice(
"info",
`Plan-yolo: plan approved, switched to ${planYolo.target.provider}/${planYolo.target.id} to implement "${resolvedTitle}".`,
"plan-yolo",
);
this.#host.agent.steer({
role: "custom",
customType: PLAN_YOLO_HANDOFF_MESSAGE_TYPE,
content: prompt.render(planYoloHandoffPrompt, { planFilePath, title: resolvedTitle }),
attribution: "agent",
display: false,
timestamp: Date.now(),
});
return {
content: [{ type: "text", text: `Plan approved. Implementing now with ${planYolo.target.id}.` }],
details: { planFilePath, title: resolvedTitle, planExists: true },
};
}
}