feat(coding-agent): added resolve-based plan approval flow in coding-agent
- Removed ExitPlanModeTool and deleted exit-plan-mode docs/tests, dropping the old approval contract outputs. - Replaced plan-mode approval flow from exit_plan_mode to resolve across session, SDK, controllers, and discovery. - Added standing resolve handler accessors and updated resolve routing for queued or standing approval handlers. - Added PlanApprovalDetails and enforced normalized, validated approval titles with readable plan-file requirements. - Extended resolve schema and invocation signatures with optional extra metadata and reason trimming behavior updates. - Updated plan and resolve prompts and changelog guidance to require resolve action, reason, and extra.title for apply/discard.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1155,16 +1155,6 @@
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderExitPlanMode(name, args, result, ctx) {
|
||||
const badges = args.title ? [String(args.title)] : null;
|
||||
let html = toolHead('exit_plan_mode', '', badges);
|
||||
if (result) {
|
||||
const output = ctx.getResultText();
|
||||
if (output) html += formatExpandableOutput(output, 8);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderResolve(name, args, result, ctx) {
|
||||
const action = str(args.action) || '?';
|
||||
let html = toolHead('resolve', '', [action]);
|
||||
@@ -1562,7 +1552,6 @@
|
||||
inspect_image: renderInspectImage,
|
||||
generate_image: renderGenerateImage,
|
||||
ask: renderAsk,
|
||||
exit_plan_mode: renderExitPlanMode,
|
||||
resolve: renderResolve,
|
||||
github: renderGh,
|
||||
render_mermaid: renderMermaid,
|
||||
|
||||
@@ -13,10 +13,11 @@ import { ToolExecutionComponent } from "../../modes/components/tool-execution";
|
||||
import { TtsrNotificationComponent } from "../../modes/components/ttsr-notification";
|
||||
import { getSymbolTheme, theme } from "../../modes/theme/theme";
|
||||
import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
|
||||
import type { PlanApprovalDetails } from "../../plan-mode/approved-plan";
|
||||
import type { AgentSessionEvent } from "../../session/agent-session";
|
||||
import { calculatePromptTokens } from "../../session/compaction/compaction";
|
||||
import { isSilentAbort, readPendingDisplayTag } from "../../session/messages";
|
||||
import type { ExitPlanModeDetails } from "../../tools";
|
||||
import type { ResolveToolDetails } from "../../tools/resolve";
|
||||
|
||||
type AgentSessionEventKind = AgentSessionEvent["type"];
|
||||
|
||||
@@ -546,10 +547,13 @@ export class EventController {
|
||||
`Todo update failed${textContent ? `: ${textContent}` : ". Progress may be stale until todo_write succeeds."}`,
|
||||
);
|
||||
}
|
||||
if (event.toolName === "exit_plan_mode" && !event.isError) {
|
||||
const details = event.result.details as ExitPlanModeDetails | undefined;
|
||||
if (details) {
|
||||
await this.ctx.handleExitPlanModeTool(details);
|
||||
if (event.toolName === "resolve" && !event.isError) {
|
||||
const details = event.result.details as ResolveToolDetails | undefined;
|
||||
if (details?.sourceToolName === "plan_approval" && details.action === "apply") {
|
||||
const planDetails = details.sourceResultDetails as PlanApprovalDetails | undefined;
|
||||
if (planDetails) {
|
||||
await this.ctx.handlePlanApproval(planDetails);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ import type { CompactOptions } from "../extensibility/extensions/types";
|
||||
import { BUILTIN_SLASH_COMMANDS, loadSlashCommands } from "../extensibility/slash-commands";
|
||||
import { resolveLocalUrlToPath } from "../internal-urls";
|
||||
import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "../lsp/startup-events";
|
||||
import { renameApprovedPlanFile } from "../plan-mode/approved-plan";
|
||||
import { normalizePlanTitle, type PlanApprovalDetails, renameApprovedPlanFile } from "../plan-mode/approved-plan";
|
||||
import planModeApprovedPrompt from "../prompts/system/plan-mode-approved.md" with { type: "text" };
|
||||
import planModeCompactInstructionsPrompt from "../prompts/system/plan-mode-compact-instructions.md" with {
|
||||
type: "text",
|
||||
@@ -50,9 +50,11 @@ import { HistoryStorage } from "../session/history-storage";
|
||||
import type { SessionContext, SessionManager } from "../session/session-manager";
|
||||
import { getRecentSessions } from "../session/session-manager";
|
||||
import { STTController, type SttState } from "../stt";
|
||||
import type { ExitPlanModeDetails, LspStartupServerInfo } from "../tools";
|
||||
import type { LspStartupServerInfo } from "../tools";
|
||||
import { normalizeLocalScheme } from "../tools/path-utils";
|
||||
import { runResolveInvocation } from "../tools/resolve";
|
||||
import { formatPhaseDisplayName } from "../tools/todo-write";
|
||||
import { ToolError } from "../tools/tool-errors";
|
||||
import type { EventBus } from "../utils/event-bus";
|
||||
import { getEditorCommand, openInEditor } from "../utils/external-editor";
|
||||
import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color";
|
||||
@@ -930,8 +932,8 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
|
||||
const planFilePath = options?.planFilePath ?? (await this.#getPlanFilePath());
|
||||
const previousTools = this.session.getActiveToolNames();
|
||||
const hasExitTool = this.session.getToolByName("exit_plan_mode") !== undefined;
|
||||
const planTools = hasExitTool ? [...previousTools, "exit_plan_mode"] : previousTools;
|
||||
const hasResolveTool = this.session.getToolByName("resolve") !== undefined;
|
||||
const planTools = hasResolveTool ? [...previousTools, "resolve"] : previousTools;
|
||||
const uniquePlanTools = [...new Set(planTools)];
|
||||
|
||||
this.#planModePreviousTools = previousTools;
|
||||
@@ -945,6 +947,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
workflow: options?.workflow ?? "parallel",
|
||||
reentry: this.#planModeHasEntered,
|
||||
});
|
||||
this.session.setStandingResolveHandler?.(input => this.#runPlanApprovalResolve(input));
|
||||
if (this.session.isStreaming) {
|
||||
await this.session.sendPlanModeContext({ deliverAs: "steer" });
|
||||
}
|
||||
@@ -955,6 +958,50 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
this.showStatus(`Plan mode enabled. Plan file: ${planFilePath}`);
|
||||
}
|
||||
|
||||
/** Standing resolve dispatcher registered while plan mode is active. The agent
|
||||
* submits the finalized plan by calling `resolve { action: "apply", extra: { title } }`;
|
||||
* this handler validates the plan file exists, normalizes the title, and shapes the
|
||||
* payload that `event-controller` forwards to `handlePlanApproval`. */
|
||||
#runPlanApprovalResolve(input: unknown): Promise<{
|
||||
content: Array<{ type: string; text: string }>;
|
||||
details?: unknown;
|
||||
}> {
|
||||
return runResolveInvocation(input as Parameters<typeof runResolveInvocation>[0], {
|
||||
sourceToolName: "plan_approval",
|
||||
label: "Plan ready for approval",
|
||||
apply: async (_reason, extra) => {
|
||||
const state = this.session.getPlanModeState?.();
|
||||
if (!state?.enabled) {
|
||||
throw new ToolError("Plan mode is not active.");
|
||||
}
|
||||
const title = extra?.title;
|
||||
if (typeof title !== "string" || title.trim() === "") {
|
||||
throw new ToolError(
|
||||
'Plan approval requires `extra: { title: "<PLAN_TITLE>" }`. Provide a title with letters, numbers, underscores, or hyphens only.',
|
||||
);
|
||||
}
|
||||
const normalized = normalizePlanTitle(title);
|
||||
const planFilePath = state.planFilePath;
|
||||
const planContent = await this.#readPlanFile(planFilePath);
|
||||
if (planContent === null) {
|
||||
throw new ToolError(
|
||||
`Plan file not found at ${planFilePath}. Write the finalized plan to ${planFilePath} before requesting approval.`,
|
||||
);
|
||||
}
|
||||
const details: PlanApprovalDetails = {
|
||||
planFilePath,
|
||||
finalPlanFilePath: `local://${normalized.fileName}`,
|
||||
title: normalized.title,
|
||||
planExists: true,
|
||||
};
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Plan ready for approval." }],
|
||||
details,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async #exitPlanMode(options?: { silent?: boolean; paused?: boolean }): Promise<void> {
|
||||
if (!this.planModeEnabled) {
|
||||
return;
|
||||
@@ -990,6 +1037,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
}
|
||||
}
|
||||
}
|
||||
this.session.setStandingResolveHandler?.(null);
|
||||
this.session.setPlanModeState(undefined);
|
||||
this.planModeEnabled = false;
|
||||
this.planModePaused = options?.paused ?? false;
|
||||
@@ -1230,16 +1278,16 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
}
|
||||
}
|
||||
|
||||
async handleExitPlanModeTool(details: ExitPlanModeDetails): Promise<void> {
|
||||
async handlePlanApproval(details: PlanApprovalDetails): Promise<void> {
|
||||
if (!this.planModeEnabled) {
|
||||
this.showWarning("Plan mode is not active.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort the agent to prevent it from continuing (e.g., calling exit_plan_mode
|
||||
// again) while the popup is showing. The event listener fires asynchronously
|
||||
// (agent's #emit is fire-and-forget), so without this the model sees "Plan
|
||||
// ready for approval." and immediately calls exit_plan_mode in a loop.
|
||||
// Abort the agent to prevent it from continuing (e.g., re-submitting the
|
||||
// plan) while the popup is showing. The event listener fires asynchronously
|
||||
// (agent's #emit is fire-and-forget), so without this the model sees
|
||||
// "Plan ready for approval." and immediately re-invokes `resolve` in a loop.
|
||||
await this.session.abort();
|
||||
|
||||
const planFilePath = details.planFilePath || this.planModePlanFilePath || (await this.#getPlanFilePath());
|
||||
|
||||
@@ -11,11 +11,12 @@ import type {
|
||||
} from "../extensibility/extensions";
|
||||
import type { CompactOptions } from "../extensibility/extensions/types";
|
||||
import type { MCPManager } from "../mcp";
|
||||
import type { PlanApprovalDetails } from "../plan-mode/approved-plan";
|
||||
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
|
||||
import type { CompactionOutcome } from "../session/compaction";
|
||||
import type { HistoryStorage } from "../session/history-storage";
|
||||
import type { SessionContext, SessionManager } from "../session/session-manager";
|
||||
import type { ExitPlanModeDetails, LspStartupServerInfo } from "../tools";
|
||||
import type { LspStartupServerInfo } from "../tools";
|
||||
import type { AssistantMessageComponent } from "./components/assistant-message";
|
||||
import type { BashExecutionComponent } from "./components/bash-execution";
|
||||
import type { CustomEditor } from "./components/custom-editor";
|
||||
@@ -260,7 +261,7 @@ export interface InteractiveModeContext {
|
||||
handleLoopCommand(args?: string): Promise<void>;
|
||||
disableLoopMode(): void;
|
||||
pauseLoop(): void;
|
||||
handleExitPlanModeTool(details: ExitPlanModeDetails): Promise<void>;
|
||||
handlePlanApproval(details: PlanApprovalDetails): Promise<void>;
|
||||
|
||||
// Hook UI methods
|
||||
initHooksAndCustomTools(): Promise<void>;
|
||||
|
||||
@@ -2,6 +2,40 @@ import * as fs from "node:fs/promises";
|
||||
import { isEnoent } from "@oh-my-pi/pi-utils";
|
||||
import { resolveLocalUrlToPath } from "../internal-urls";
|
||||
import { normalizeLocalScheme } from "../tools/path-utils";
|
||||
import { ToolError } from "../tools/tool-errors";
|
||||
|
||||
/** Shape forwarded from the plan-mode resolve handler to InteractiveMode's
|
||||
* approval popup. Populated by the standing handler that the resolve tool
|
||||
* dispatches to when the agent submits `resolve { action: "apply" }`. */
|
||||
export interface PlanApprovalDetails {
|
||||
planFilePath: string;
|
||||
finalPlanFilePath: string;
|
||||
title: string;
|
||||
planExists: boolean;
|
||||
}
|
||||
|
||||
/** Validate the agent-supplied plan title and derive the destination filename.
|
||||
* Filename uses the title with a `.md` suffix; characters are restricted to
|
||||
* letters, numbers, underscores, and hyphens so the value is safe to splice
|
||||
* into a `local://` URL without escaping. */
|
||||
export function normalizePlanTitle(title: string): { title: string; fileName: string } {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) {
|
||||
throw new ToolError("Plan title is required and must not be empty.");
|
||||
}
|
||||
|
||||
if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("..")) {
|
||||
throw new ToolError("Plan title must not contain path separators or '..'.");
|
||||
}
|
||||
|
||||
const withExtension = trimmed.toLowerCase().endsWith(".md") ? trimmed : `${trimmed}.md`;
|
||||
if (!/^[A-Za-z0-9_-]+\.md$/.test(withExtension)) {
|
||||
throw new ToolError("Plan title may only contain letters, numbers, underscores, or hyphens.");
|
||||
}
|
||||
|
||||
const normalizedTitle = withExtension.slice(0, -3);
|
||||
return { title: normalizedTitle, fileName: withExtension };
|
||||
}
|
||||
|
||||
interface RenameApprovedPlanFileOptions {
|
||||
planFilePath: string;
|
||||
@@ -36,7 +70,7 @@ export async function renameApprovedPlanFile(options: RenameApprovedPlanFileOpti
|
||||
const destinationStat = await fs.stat(resolvedDestination);
|
||||
if (destinationStat.isFile()) {
|
||||
throw new Error(
|
||||
`Plan destination already exists at ${finalPlanFilePath}. Choose a different title and call exit_plan_mode again.`,
|
||||
`Plan destination already exists at ${finalPlanFilePath}. Choose a different title and submit the plan for approval again.`,
|
||||
);
|
||||
}
|
||||
throw new Error(`Plan destination exists but is not a file: ${finalPlanFilePath}`);
|
||||
|
||||
@@ -6,9 +6,9 @@ You NEVER:
|
||||
- Run state-changing commands (git commit, npm install, etc.)
|
||||
- Make any system changes
|
||||
|
||||
To implement: call `{{exitToolName}}` → user approves an execution option → full write access is restored.
|
||||
To implement: call `resolve` with `action: "apply"`, a `reason`, and `extra: { title: "<PLAN_TITLE>" }` → user approves an execution option → full write access is restored. `<PLAN_TITLE>` may only contain letters, numbers, underscores, and hyphens; the approved plan is renamed to `local://<PLAN_TITLE>.md`.
|
||||
|
||||
You NEVER ask the user to exit plan mode for you; you MUST call `{{exitToolName}}` yourself.
|
||||
You NEVER ask the user to exit plan mode for you; you MUST call `resolve` yourself.
|
||||
</critical>
|
||||
|
||||
## Plan File
|
||||
@@ -39,7 +39,7 @@ You MUST still make the plan file self-contained: include requirements, decision
|
||||
3. Decide:
|
||||
- **Different task** → Overwrite plan
|
||||
- **Same task, continuing** → Update and clean outdated sections
|
||||
4. Call `{{exitToolName}}` when complete
|
||||
4. Call `resolve` with `action: "apply"` and `extra: { title }` when complete
|
||||
</procedure>
|
||||
{{/if}}
|
||||
|
||||
@@ -109,8 +109,8 @@ You MUST ask questions throughout. You NEVER make large assumptions about user i
|
||||
<critical>
|
||||
Your turn ends ONLY by:
|
||||
1. Using `{{askToolName}}` to gather information, OR
|
||||
2. Calling `{{exitToolName}}` when ready — this triggers user approval, then implementation with full tool access
|
||||
2. Calling `resolve` with `action: "apply"`, `reason`, and `extra: { title: "<PLAN_TITLE>" }` when ready — this triggers user approval, then implementation with full tool access
|
||||
|
||||
You NEVER ask plan approval via text or `{{askToolName}}`; you MUST use `{{exitToolName}}`.
|
||||
You NEVER ask plan approval via text or `{{askToolName}}`; you MUST use `resolve`.
|
||||
You MUST keep going until complete.
|
||||
</critical>
|
||||
|
||||
@@ -3,7 +3,7 @@ Plan mode turn ended without a required tool call.
|
||||
|
||||
You MUST choose exactly one next action now:
|
||||
1. Call `{{askToolName}}` to gather required clarification, OR
|
||||
2. Call `{{exitToolName}}` to finish planning and request approval
|
||||
2. Call `resolve` with `action: "apply"`, `reason`, and `extra: { title: "<PLAN_TITLE>" }` to finish planning and request approval
|
||||
|
||||
You NEVER output plain text in this turn.
|
||||
</system-reminder>
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
Submits a finalized implementation plan for user approval.
|
||||
|
||||
Write the plan to `local://PLAN.md` first, then call this with `title` (e.g. `WP_MIGRATION_PLAN`); on approval the file is renamed to `local://<title>.md` and full tool access is restored.
|
||||
- Use only after planning implementation steps; not for pure research.
|
||||
- NEVER call before the plan file exists.
|
||||
- NEVER use `ask` to request plan approval — this tool does that.
|
||||
@@ -1,8 +1,9 @@
|
||||
Resolves a pending preview action by either applying or discarding it.
|
||||
Resolves a pending action by either applying or discarding it.
|
||||
- `action` is required:
|
||||
- `"apply"` persists the pending changes.
|
||||
- `"discard"` rejects the pending changes.
|
||||
- `reason` is required and must explain why you chose to apply or discard.
|
||||
- `"apply"` persists / submits the pending action.
|
||||
- `"discard"` rejects the pending action.
|
||||
- `reason` is required and must briefly explain why you chose to apply or discard.
|
||||
- `extra` (optional) is free-form metadata passed to the resolving tool. Schema depends on context:
|
||||
|
||||
Only valid when a pending action exists (typically after a preview step).
|
||||
Valid whenever a pending action exists — either a preview-style staging (e.g. `ast_edit`) or a long-lived approval gate.
|
||||
Call fails with an error when no pending action exists.
|
||||
|
||||
@@ -1078,6 +1078,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
peekQueueInvoker: () => session.peekQueueInvoker(),
|
||||
peekStandingResolveHandler: () => session.peekStandingResolveHandler(),
|
||||
setStandingResolveHandler: handler => session.setStandingResolveHandler(handler),
|
||||
allocateOutputArtifact: async toolType => {
|
||||
try {
|
||||
return await sessionManager.allocateArtifactPath(toolType);
|
||||
@@ -1503,7 +1505,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
(options.toolNames ? [...new Set(options.toolNames.map(name => name.toLowerCase()))] : undefined) ??
|
||||
toolNamesFromRegistry;
|
||||
const normalizedRequested = requestedToolNames.filter(name => toolRegistry.has(name));
|
||||
const includeExitPlanMode = requestedToolNames.includes("exit_plan_mode");
|
||||
// Effective discovery mode: tools.discoveryMode takes precedence; mcp.discoveryMode is back-compat alias.
|
||||
const toolsDiscoveryModeSetting = settings.get("tools.discoveryMode");
|
||||
const effectiveDiscoveryMode: "off" | "mcp-only" | "all" =
|
||||
@@ -1516,9 +1517,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
const defaultInactiveToolNames = new Set(
|
||||
registeredTools.filter(tool => tool.definition.defaultInactive).map(tool => tool.definition.name),
|
||||
);
|
||||
const requestedActiveToolNames = includeExitPlanMode
|
||||
? normalizedRequested
|
||||
: normalizedRequested.filter(name => name !== "exit_plan_mode");
|
||||
const requestedActiveToolNames = normalizedRequested;
|
||||
const initialRequestedActiveToolNames = options.toolNames
|
||||
? requestedActiveToolNames
|
||||
: requestedActiveToolNames.filter(name => !defaultInactiveToolNames.has(name));
|
||||
|
||||
@@ -959,6 +959,19 @@ export class AgentSession {
|
||||
return this.#toolChoiceQueue.peekInFlightInvoker();
|
||||
}
|
||||
|
||||
/** Standing (long-lived) handler the `resolve` tool falls back to when no
|
||||
* queue invoker is in flight. Used by plan mode so the agent can submit
|
||||
* approval via `resolve` without forcing the tool choice every turn. */
|
||||
#standingResolveHandler: ((input: unknown) => Promise<unknown> | unknown) | undefined;
|
||||
|
||||
peekStandingResolveHandler(): ((input: unknown) => Promise<unknown> | unknown) | undefined {
|
||||
return this.#standingResolveHandler;
|
||||
}
|
||||
|
||||
setStandingResolveHandler(handler: ((input: unknown) => Promise<unknown> | unknown) | null): void {
|
||||
this.#standingResolveHandler = handler ?? undefined;
|
||||
}
|
||||
|
||||
/** Provider-scoped mutable state store for transport/session caches. */
|
||||
get providerSessionState(): Map<string, ProviderSessionState> {
|
||||
return this.#providerSessionState;
|
||||
@@ -2691,7 +2704,7 @@ export class AgentSession {
|
||||
|
||||
/** Collect built-in tools the model can discover via search_tool_bm25. Restricted to tool
|
||||
* definitions whose `loadMode === "discoverable"`. This keeps hidden/internal tools
|
||||
* (resolve, yield, exit_plan_mode, report_finding, report_tool_issue) out of the index
|
||||
* (resolve, yield, report_finding, report_tool_issue) out of the index
|
||||
* and avoids mislabeling extension/custom default-inactive tools as built-ins. */
|
||||
#collectDiscoverableBuiltinTools(): DiscoverableTool[] {
|
||||
const activeNames = new Set(this.getActiveToolNames());
|
||||
@@ -3405,7 +3418,6 @@ export class AgentSession {
|
||||
askToolName: "ask",
|
||||
writeToolName: "write",
|
||||
editToolName: "edit",
|
||||
exitToolName: "exit_plan_mode",
|
||||
reentry: state.reentry ?? false,
|
||||
iterative: state.workflow === "iterative",
|
||||
});
|
||||
@@ -5308,14 +5320,14 @@ export class AgentSession {
|
||||
}
|
||||
|
||||
const calledRequiredTool = assistantMessage.content.some(
|
||||
content => content.type === "toolCall" && (content.name === "ask" || content.name === "exit_plan_mode"),
|
||||
content => content.type === "toolCall" && (content.name === "ask" || content.name === "resolve"),
|
||||
);
|
||||
if (calledRequiredTool) {
|
||||
return;
|
||||
}
|
||||
const hasRequiredTools = this.#toolRegistry.has("ask") && this.#toolRegistry.has("exit_plan_mode");
|
||||
const hasRequiredTools = this.#toolRegistry.has("ask") && this.#toolRegistry.has("resolve");
|
||||
if (!hasRequiredTools) {
|
||||
logger.warn("Plan mode enforcement skipped because ask/exit tools are unavailable", {
|
||||
logger.warn("Plan mode enforcement skipped because ask/resolve tools are unavailable", {
|
||||
activeToolNames: this.agent.state.tools.map(tool => tool.name),
|
||||
});
|
||||
return;
|
||||
@@ -5323,7 +5335,6 @@ export class AgentSession {
|
||||
|
||||
const reminder = prompt.render(planModeToolDecisionReminderPrompt, {
|
||||
askToolName: "ask",
|
||||
exitToolName: "exit_plan_mode",
|
||||
});
|
||||
|
||||
await this.prompt(reminder, {
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
||||
import { isEnoent, prompt } from "@oh-my-pi/pi-utils";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import exitPlanModeDescription from "../prompts/tools/exit-plan-mode.md" with { type: "text" };
|
||||
import type { ToolSession } from ".";
|
||||
import { resolvePlanPath } from "./plan-mode-guard";
|
||||
import { ToolError } from "./tool-errors";
|
||||
|
||||
const exitPlanModeSchema = Type.Object({
|
||||
title: Type.String({ description: "final plan title", examples: ["WP_MIGRATION_PLAN"] }),
|
||||
});
|
||||
|
||||
type ExitPlanModeParams = Static<typeof exitPlanModeSchema>;
|
||||
|
||||
function normalizePlanTitle(title: string): { title: string; fileName: string } {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) {
|
||||
throw new ToolError("Title is required and must not be empty.");
|
||||
}
|
||||
|
||||
if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("..")) {
|
||||
throw new ToolError("Title must not contain path separators or '..'.");
|
||||
}
|
||||
|
||||
const withExtension = trimmed.toLowerCase().endsWith(".md") ? trimmed : `${trimmed}.md`;
|
||||
if (!/^[A-Za-z0-9_-]+\.md$/.test(withExtension)) {
|
||||
throw new ToolError("Title may only contain letters, numbers, underscores, or hyphens.");
|
||||
}
|
||||
|
||||
const normalizedTitle = withExtension.slice(0, -3);
|
||||
return { title: normalizedTitle, fileName: withExtension };
|
||||
}
|
||||
|
||||
export interface ExitPlanModeDetails {
|
||||
planFilePath: string;
|
||||
planExists: boolean;
|
||||
title: string;
|
||||
finalPlanFilePath: string;
|
||||
}
|
||||
|
||||
export class ExitPlanModeTool implements AgentTool<typeof exitPlanModeSchema, ExitPlanModeDetails> {
|
||||
readonly name = "exit_plan_mode";
|
||||
readonly label = "ExitPlanMode";
|
||||
readonly description: string;
|
||||
readonly parameters = exitPlanModeSchema;
|
||||
readonly strict = true;
|
||||
readonly concurrency = "exclusive";
|
||||
readonly intent = (): string => "present plan";
|
||||
|
||||
constructor(private readonly session: ToolSession) {
|
||||
this.description = prompt.render(exitPlanModeDescription);
|
||||
}
|
||||
|
||||
async execute(
|
||||
_toolCallId: string,
|
||||
params: ExitPlanModeParams,
|
||||
_signal?: AbortSignal,
|
||||
_onUpdate?: AgentToolUpdateCallback<ExitPlanModeDetails>,
|
||||
_context?: AgentToolContext,
|
||||
): Promise<AgentToolResult<ExitPlanModeDetails>> {
|
||||
const state = this.session.getPlanModeState?.();
|
||||
if (!state?.enabled) {
|
||||
throw new ToolError("Plan mode is not active.");
|
||||
}
|
||||
|
||||
const normalized = normalizePlanTitle(params.title);
|
||||
const finalPlanFilePath = `local://${normalized.fileName}`;
|
||||
const resolvedPlanPath = resolvePlanPath(this.session, state.planFilePath);
|
||||
resolvePlanPath(this.session, finalPlanFilePath);
|
||||
let planExists = false;
|
||||
try {
|
||||
const stat = await fs.stat(resolvedPlanPath);
|
||||
planExists = stat.isFile();
|
||||
} catch (error) {
|
||||
if (!isEnoent(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!planExists) {
|
||||
throw new ToolError(
|
||||
`Plan file not found at ${state.planFilePath}. Write the finalized plan to ${state.planFilePath} before calling exit_plan_mode.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: "Plan ready for approval." }],
|
||||
details: {
|
||||
planFilePath: state.planFilePath,
|
||||
planExists,
|
||||
title: normalized.title,
|
||||
finalPlanFilePath,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@ import { CalculatorTool } from "./calculator";
|
||||
import { type CheckpointState, CheckpointTool, RewindTool } from "./checkpoint";
|
||||
import { DebugTool } from "./debug";
|
||||
import { EvalTool } from "./eval";
|
||||
import { ExitPlanModeTool } from "./exit-plan-mode";
|
||||
import { FindTool } from "./find";
|
||||
import { GithubTool } from "./gh";
|
||||
import { HindsightRecallTool } from "./hindsight-recall";
|
||||
@@ -70,7 +69,6 @@ export * from "./calculator";
|
||||
export * from "./checkpoint";
|
||||
export * from "./debug";
|
||||
export * from "./eval";
|
||||
export * from "./exit-plan-mode";
|
||||
export * from "./find";
|
||||
export * from "./gh";
|
||||
export * from "./hindsight-recall";
|
||||
@@ -220,6 +218,12 @@ export interface ToolSession {
|
||||
steer?(message: { customType: string; content: string; details?: unknown }): void;
|
||||
/** Peek the currently in-flight tool-choice queue directive's invocation handler. Used by the `resolve` tool to dispatch to the pending action. */
|
||||
peekQueueInvoker?(): ((input: unknown) => Promise<unknown> | unknown) | undefined;
|
||||
/** Peek the long-lived "standing" resolve handler registered by a mode (e.g. plan mode).
|
||||
* Consulted by the `resolve` tool as a fallback when no queue invoker is in flight,
|
||||
* letting modes accept `resolve` invocations without forcing the tool choice every turn. */
|
||||
peekStandingResolveHandler?(): ((input: unknown) => Promise<unknown> | unknown) | undefined;
|
||||
/** Register or clear the standing resolve handler. Passing `null` clears it. */
|
||||
setStandingResolveHandler?(handler: ((input: unknown) => Promise<unknown> | unknown) | null): void;
|
||||
/** Get active checkpoint state if any. */
|
||||
getCheckpointState?: () => CheckpointState | undefined;
|
||||
/** Set or clear active checkpoint state. */
|
||||
@@ -303,7 +307,6 @@ export const HIDDEN_TOOLS: Record<string, ToolFactory> = {
|
||||
yield: s => new YieldTool(s),
|
||||
report_finding: () => reportFindingTool,
|
||||
report_tool_issue: s => createReportToolIssueTool(s),
|
||||
exit_plan_mode: s => new ExitPlanModeTool(s),
|
||||
resolve: s => new ResolveTool(s),
|
||||
};
|
||||
|
||||
@@ -353,10 +356,6 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
||||
const enableLsp = session.enableLsp ?? true;
|
||||
const requestedTools =
|
||||
toolNames && toolNames.length > 0 ? [...new Set(toolNames.map(name => name.toLowerCase()))] : undefined;
|
||||
const planEnabled = session.settings.get("plan.enabled");
|
||||
if (planEnabled && requestedTools && !requestedTools.includes("exit_plan_mode")) {
|
||||
requestedTools.push("exit_plan_mode");
|
||||
}
|
||||
const backends = resolveEvalBackends(session);
|
||||
const allowPython = backends.python;
|
||||
const allowJs = backends.js;
|
||||
@@ -428,7 +427,6 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
||||
|
||||
const allTools: Record<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };
|
||||
const isToolAllowed = (name: string) => {
|
||||
if (name === "exit_plan_mode") return planEnabled;
|
||||
if (name === "lsp") return enableLsp && session.settings.get("lsp.enabled");
|
||||
if (name === "bash") return true;
|
||||
if (name === "eval") return allowEval;
|
||||
@@ -478,7 +476,6 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
||||
.filter(([name]) => isToolAllowed(name))
|
||||
.map(([name, factory]) => [name, factory] as const),
|
||||
...(includeYield ? ([["yield", HIDDEN_TOOLS.yield]] as const) : []),
|
||||
...(planEnabled ? ([["exit_plan_mode", HIDDEN_TOOLS.exit_plan_mode]] as const) : []),
|
||||
];
|
||||
|
||||
const baseResults = await Promise.all(
|
||||
@@ -488,8 +485,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
||||
}),
|
||||
);
|
||||
const tools = baseResults.filter((r): r is Tool => r !== null);
|
||||
const hasDeferrableTools = tools.some(tool => tool.deferrable === true);
|
||||
if (hasDeferrableTools && !tools.some(tool => tool.name === "resolve")) {
|
||||
if (!tools.some(tool => tool.name === "resolve")) {
|
||||
const resolveTool = await logger.time("createTools:resolve", HIDDEN_TOOLS.resolve, session);
|
||||
if (resolveTool) {
|
||||
tools.push(wrapToolWithMetaNotice(resolveTool));
|
||||
|
||||
@@ -14,6 +14,12 @@ import { ToolError } from "./tool-errors";
|
||||
const resolveSchema = Type.Object({
|
||||
action: Type.Union([Type.Literal("apply"), Type.Literal("discard")]),
|
||||
reason: Type.String({ description: "reason for action", examples: ["approved by user"] }),
|
||||
extra: Type.Optional(
|
||||
Type.Record(Type.String(), Type.Unknown(), {
|
||||
description:
|
||||
'Free-form metadata interpreted by the resolving tool (e.g. plan-mode approval requires `{ title: "<PLAN_TITLE>" }`).',
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type ResolveParams = Static<typeof resolveSchema>;
|
||||
@@ -21,17 +27,12 @@ type ResolveParams = Static<typeof resolveSchema>;
|
||||
export interface ResolveToolDetails {
|
||||
action: "apply" | "discard";
|
||||
reason: string;
|
||||
extra?: Record<string, unknown>;
|
||||
sourceToolName?: string;
|
||||
label?: string;
|
||||
sourceResultDetails?: unknown;
|
||||
}
|
||||
|
||||
function resolveReasonPreview(reason?: string): string | undefined {
|
||||
const trimmed = reason?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return truncateToWidth(trimmed, 72, Ellipsis.Omit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a resolve-protocol handler on the tool-choice queue. Forces the next
|
||||
* LLM call to invoke the hidden `resolve` tool, wraps the caller's apply/reject
|
||||
@@ -47,49 +48,25 @@ export function queueResolveHandler(
|
||||
options: {
|
||||
label: string;
|
||||
sourceToolName: string;
|
||||
apply(reason: string): Promise<AgentToolResult<unknown>>;
|
||||
reject?(reason: string): Promise<AgentToolResult<unknown> | undefined>;
|
||||
apply(reason: string, extra?: Record<string, unknown>): Promise<AgentToolResult<unknown>>;
|
||||
reject?(reason: string, extra?: Record<string, unknown>): Promise<AgentToolResult<unknown> | undefined>;
|
||||
},
|
||||
): void {
|
||||
const queue = session.getToolChoiceQueue?.();
|
||||
const forced = session.buildToolChoice?.("resolve");
|
||||
if (!queue || !forced || typeof forced === "string") return;
|
||||
|
||||
const detailsFor = (params: ResolveParams): ResolveToolDetails => ({
|
||||
action: params.action,
|
||||
reason: params.reason,
|
||||
sourceToolName: options.sourceToolName,
|
||||
label: options.label,
|
||||
});
|
||||
|
||||
queue.pushOnce(forced, {
|
||||
label: `pending-action:${options.sourceToolName}`,
|
||||
now: true,
|
||||
onRejected: () => "requeue",
|
||||
onInvoked: async (input: unknown) => {
|
||||
const params = input as ResolveParams;
|
||||
const withResolveDetails = (result: AgentToolResult<unknown>): AgentToolResult<ResolveToolDetails> => ({
|
||||
...result,
|
||||
details: {
|
||||
...detailsFor(params),
|
||||
...(result.details != null ? { sourceResultDetails: result.details } : {}),
|
||||
},
|
||||
});
|
||||
if (params.action === "apply") {
|
||||
const result = await options.apply(params.reason);
|
||||
return withResolveDetails(result);
|
||||
}
|
||||
if (params.action === "discard" && options.reject != null) {
|
||||
const result = await options.reject(params.reason);
|
||||
if (result != null) {
|
||||
return withResolveDetails(result);
|
||||
}
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Discarded: ${options.label}. Reason: ${params.reason}` }],
|
||||
details: detailsFor(params),
|
||||
};
|
||||
},
|
||||
onInvoked: async (input: unknown) =>
|
||||
runResolveInvocation(input as ResolveParams, {
|
||||
sourceToolName: options.sourceToolName,
|
||||
label: options.label,
|
||||
apply: options.apply,
|
||||
reject: options.reject,
|
||||
}),
|
||||
});
|
||||
|
||||
session.steer?.({
|
||||
@@ -103,6 +80,57 @@ export function queueResolveHandler(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared invocation runner used by both queued (in-flight) handlers and
|
||||
* standing handlers (e.g. plan-mode approval). Discriminates on action,
|
||||
* routes through the caller's apply/reject, and wraps the resulting tool
|
||||
* payload with `ResolveToolDetails` so the renderer and event-controller
|
||||
* see a consistent shape.
|
||||
*/
|
||||
export async function runResolveInvocation(
|
||||
params: ResolveParams,
|
||||
options: {
|
||||
sourceToolName: string;
|
||||
label: string;
|
||||
apply(reason: string, extra?: Record<string, unknown>): Promise<AgentToolResult<unknown>>;
|
||||
reject?(reason: string, extra?: Record<string, unknown>): Promise<AgentToolResult<unknown> | undefined>;
|
||||
},
|
||||
): Promise<AgentToolResult<ResolveToolDetails>> {
|
||||
const baseDetails: ResolveToolDetails = {
|
||||
action: params.action,
|
||||
reason: params.reason,
|
||||
sourceToolName: options.sourceToolName,
|
||||
label: options.label,
|
||||
...(params.extra != null ? { extra: params.extra } : {}),
|
||||
};
|
||||
if (params.action === "apply") {
|
||||
const result = await options.apply(params.reason, params.extra);
|
||||
return {
|
||||
...result,
|
||||
details: {
|
||||
...baseDetails,
|
||||
...(result.details != null ? { sourceResultDetails: result.details } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (params.action === "discard" && options.reject != null) {
|
||||
const result = await options.reject(params.reason, params.extra);
|
||||
if (result != null) {
|
||||
return {
|
||||
...result,
|
||||
details: {
|
||||
...baseDetails,
|
||||
...(result.details != null ? { sourceResultDetails: result.details } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Discarded: ${options.label}. Reason: ${params.reason}` }],
|
||||
details: baseDetails,
|
||||
};
|
||||
}
|
||||
|
||||
export class ResolveTool implements AgentTool<typeof resolveSchema, ResolveToolDetails> {
|
||||
readonly name = "resolve";
|
||||
readonly label = "Resolve";
|
||||
@@ -112,10 +140,9 @@ export class ResolveTool implements AgentTool<typeof resolveSchema, ResolveToolD
|
||||
readonly strict = true;
|
||||
readonly intent = (args: Partial<ResolveParams>) => {
|
||||
if (args.action === "discard") {
|
||||
return args.reason ? `discarding: ${args.reason}` : "aiscarding changes";
|
||||
} else {
|
||||
return args.reason ? `accepting: ${args.reason}` : "accepting changes";
|
||||
return args.reason ? `discarding: ${args.reason}` : "discarding changes";
|
||||
}
|
||||
return args.reason ? `accepting: ${args.reason}` : "accepting changes";
|
||||
};
|
||||
|
||||
constructor(private readonly session: ToolSession) {
|
||||
@@ -130,7 +157,7 @@ export class ResolveTool implements AgentTool<typeof resolveSchema, ResolveToolD
|
||||
_context?: AgentToolContext,
|
||||
): Promise<AgentToolResult<ResolveToolDetails>> {
|
||||
return untilAborted(signal, async () => {
|
||||
const invoker = this.session.peekQueueInvoker?.();
|
||||
const invoker = this.session.peekQueueInvoker?.() ?? this.session.peekStandingResolveHandler?.();
|
||||
if (!invoker) {
|
||||
throw new ToolError("No pending action to resolve. Nothing to apply or discard.");
|
||||
}
|
||||
@@ -142,7 +169,8 @@ export class ResolveTool implements AgentTool<typeof resolveSchema, ResolveToolD
|
||||
|
||||
export const resolveToolRenderer = {
|
||||
renderCall(args: ResolveParams, _options: RenderResultOptions, uiTheme: Theme): Component {
|
||||
const reason = resolveReasonPreview(args.reason);
|
||||
const reasonTrimmed = args.reason?.trim();
|
||||
const reason = reasonTrimmed ? truncateToWidth(reasonTrimmed, 72, Ellipsis.Omit) : undefined;
|
||||
const text = renderStatusLine(
|
||||
{
|
||||
icon: "pending",
|
||||
|
||||
Reference in New Issue
Block a user