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:
can1357
2026-05-14 05:33:30 +02:00
parent 0b62f2971b
commit ef5cbef51f
22 changed files with 257 additions and 394 deletions
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());
+3 -2
View File
@@ -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.
+3 -4
View File
@@ -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,
},
};
}
}
+7 -11
View File
@@ -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));
+72 -44
View File
@@ -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",