feat(coding-agent): introduced resolve tool for AST edit preview confirmation with reasoning
- Added `resolve` tool to apply or discard pending AST edit previews with required reasoning. - Changed `ast_edit` tool to always return previews by default; removed `preview` parameter. - Added `getToolChoice` callback option to dynamically override tool choice per LLM call. - Implemented PendingActionStore for managing deferred tool action state across agent sessions. - Updated agent loop to support dynamic tool choice resolution via optional callback.
This commit is contained in:
@@ -38,7 +38,6 @@ const astEditSchema = Type.Object({
|
||||
lang: Type.Optional(Type.String({ description: "Language override" })),
|
||||
path: Type.Optional(Type.String({ description: "File, directory, or glob pattern to rewrite (default: cwd)" })),
|
||||
selector: Type.Optional(Type.String({ description: "Optional selector for contextual pattern mode" })),
|
||||
preview: Type.Optional(Type.Boolean({ description: "Preview only (default: true)" })),
|
||||
limit: Type.Optional(Type.Number({ description: "Max total replacements" })),
|
||||
});
|
||||
|
||||
@@ -134,7 +133,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
|
||||
path: resolvedSearchPath,
|
||||
glob: globFilter,
|
||||
selector: params.selector?.trim(),
|
||||
dryRun: params.preview,
|
||||
dryRun: true,
|
||||
maxReplacements,
|
||||
maxFiles,
|
||||
failOnParseError: false,
|
||||
@@ -266,6 +265,44 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
|
||||
outputLines.push("", ...formatParseErrors(result.parseErrors));
|
||||
}
|
||||
|
||||
// Register pending action so `resolve` can apply or discard these previewed changes
|
||||
if (!result.applied && result.totalReplacements > 0) {
|
||||
const previewReplacementPlural = result.totalReplacements !== 1 ? "s" : "";
|
||||
const previewFilePlural = result.filesTouched !== 1 ? "s" : "";
|
||||
this.session.pendingActionStore?.set({
|
||||
label: `AST Edit: ${result.totalReplacements} replacement${previewReplacementPlural} in ${result.filesTouched} file${previewFilePlural}`,
|
||||
sourceToolName: this.name,
|
||||
apply: async () => {
|
||||
const applyResult = await astEdit({
|
||||
rewrites: normalizedRewrites,
|
||||
lang: params.lang?.trim(),
|
||||
path: resolvedSearchPath,
|
||||
glob: globFilter,
|
||||
selector: params.selector?.trim(),
|
||||
dryRun: false,
|
||||
maxReplacements,
|
||||
maxFiles,
|
||||
failOnParseError: false,
|
||||
});
|
||||
const appliedDetails: AstEditToolDetails = {
|
||||
totalReplacements: applyResult.totalReplacements,
|
||||
filesTouched: applyResult.filesTouched,
|
||||
filesSearched: applyResult.filesSearched,
|
||||
applied: applyResult.applied,
|
||||
limitReached: applyResult.limitReached,
|
||||
parseErrors: applyResult.parseErrors,
|
||||
scopePath,
|
||||
files: fileList,
|
||||
fileReplacements: details.fileReplacements,
|
||||
};
|
||||
const appliedReplacementPlural = applyResult.totalReplacements !== 1 ? "s" : "";
|
||||
const appliedFilePlural = applyResult.filesTouched !== 1 ? "s" : "";
|
||||
const text = `Applied ${applyResult.totalReplacements} replacement${appliedReplacementPlural} in ${applyResult.filesTouched} file${appliedFilePlural}.`;
|
||||
return toolResult(appliedDetails).text(text).done();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return toolResult(details).text(outputLines.join("\n")).done();
|
||||
});
|
||||
}
|
||||
@@ -280,7 +317,6 @@ interface AstEditRenderArgs {
|
||||
lang?: string;
|
||||
path?: string;
|
||||
selector?: string;
|
||||
preview?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@@ -292,7 +328,6 @@ export const astEditToolRenderer = {
|
||||
const meta: string[] = [];
|
||||
if (args.lang) meta.push(`lang:${args.lang}`);
|
||||
if (args.path) meta.push(`in ${args.path}`);
|
||||
if (args.preview !== false) meta.push("preview");
|
||||
if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
|
||||
const rewriteCount = args.ops?.length ?? 0;
|
||||
if (rewriteCount > 1) meta.push(`${rewriteCount} rewrites`);
|
||||
@@ -318,7 +353,6 @@ export const astEditToolRenderer = {
|
||||
const totalReplacements = details?.totalReplacements ?? 0;
|
||||
const filesTouched = details?.filesTouched ?? 0;
|
||||
const filesSearched = details?.filesSearched ?? 0;
|
||||
const applied = details?.applied ?? false;
|
||||
const limitReached = details?.limitReached ?? false;
|
||||
|
||||
if (totalReplacements === 0) {
|
||||
@@ -348,11 +382,8 @@ export const astEditToolRenderer = {
|
||||
if (limitReached) meta.push(uiTheme.fg("warning", "limit reached"));
|
||||
const rewriteCount = args?.ops?.length ?? 0;
|
||||
const description = rewriteCount === 1 ? args?.ops?.[0]?.pat : undefined;
|
||||
const badge = applied
|
||||
? { label: "applied", color: "success" as const }
|
||||
: { label: "preview", color: "warning" as const };
|
||||
const header = renderStatusLine(
|
||||
{ icon: limitReached ? "warning" : "success", title: "AST Edit", description, badge, meta },
|
||||
{ icon: limitReached ? "warning" : "success", title: "AST Edit", description, meta },
|
||||
uiTheme,
|
||||
);
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import { NotebookTool } from "./notebook";
|
||||
import { wrapToolWithMetaNotice } from "./output-meta";
|
||||
import { PythonTool } from "./python";
|
||||
import { ReadTool } from "./read";
|
||||
import { ResolveTool } from "./resolve";
|
||||
import { reportFindingTool } from "./review";
|
||||
import { loadSshTool } from "./ssh";
|
||||
import { SubmitResultTool } from "./submit-result";
|
||||
@@ -59,8 +60,10 @@ export * from "./find";
|
||||
export * from "./gemini-image";
|
||||
export * from "./grep";
|
||||
export * from "./notebook";
|
||||
export * from "./pending-action";
|
||||
export * from "./python";
|
||||
export * from "./read";
|
||||
export * from "./resolve";
|
||||
export * from "./review";
|
||||
export * from "./ssh";
|
||||
export * from "./submit-result";
|
||||
@@ -138,6 +141,8 @@ export interface ToolSession {
|
||||
getTodoPhases?: () => TodoPhase[];
|
||||
/** Replace cached todo phases for this session. */
|
||||
setTodoPhases?: (phases: TodoPhase[]) => void;
|
||||
/** Pending action store for preview/apply workflows */
|
||||
pendingActionStore?: import("./pending-action").PendingActionStore;
|
||||
}
|
||||
|
||||
type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool | null>;
|
||||
@@ -170,6 +175,7 @@ export const HIDDEN_TOOLS: Record<string, ToolFactory> = {
|
||||
submit_result: s => new SubmitResultTool(s),
|
||||
report_finding: () => reportFindingTool,
|
||||
exit_plan_mode: s => new ExitPlanModeTool(s),
|
||||
resolve: s => new ResolveTool(s),
|
||||
};
|
||||
|
||||
export type ToolName = keyof typeof BUILTIN_TOOLS;
|
||||
@@ -214,6 +220,9 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
||||
if (requestedTools && !requestedTools.includes("exit_plan_mode")) {
|
||||
requestedTools.push("exit_plan_mode");
|
||||
}
|
||||
if (requestedTools && !requestedTools.includes("resolve")) {
|
||||
requestedTools.push("resolve");
|
||||
}
|
||||
const pythonMode = getPythonModeFromEnv() ?? session.settings.get("python.toolMode");
|
||||
const skipPythonPreflight = session.skipPythonPreflight === true;
|
||||
let pythonAvailable = true;
|
||||
@@ -301,6 +310,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
||||
...Object.entries(BUILTIN_TOOLS).filter(([name]) => isToolAllowed(name)),
|
||||
...(includeSubmitResult ? ([["submit_result", HIDDEN_TOOLS.submit_result]] as const) : []),
|
||||
...([["exit_plan_mode", HIDDEN_TOOLS.exit_plan_mode]] as const),
|
||||
...([["resolve", HIDDEN_TOOLS.resolve]] as const),
|
||||
];
|
||||
|
||||
const results = await Promise.all(
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
||||
|
||||
export interface PendingAction {
|
||||
label: string;
|
||||
sourceToolName: string;
|
||||
apply(): Promise<AgentToolResult<unknown>>;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
export class PendingActionStore {
|
||||
#action: PendingAction | null = null;
|
||||
|
||||
set(action: PendingAction): void {
|
||||
this.#action = action;
|
||||
}
|
||||
|
||||
get(): PendingAction | null {
|
||||
return this.#action;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.#action = null;
|
||||
}
|
||||
|
||||
get hasPending(): boolean {
|
||||
return this.#action !== null;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { grepToolRenderer } from "./grep";
|
||||
import { notebookToolRenderer } from "./notebook";
|
||||
import { pythonToolRenderer } from "./python";
|
||||
import { readToolRenderer } from "./read";
|
||||
import { resolveToolRenderer } from "./resolve";
|
||||
import { sshToolRenderer } from "./ssh";
|
||||
import { todoWriteToolRenderer } from "./todo-write";
|
||||
import { writeToolRenderer } from "./write";
|
||||
@@ -51,6 +52,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
|
||||
lsp: lspToolRenderer as ToolRenderer,
|
||||
notebook: notebookToolRenderer as ToolRenderer,
|
||||
read: readToolRenderer as ToolRenderer,
|
||||
resolve: resolveToolRenderer as ToolRenderer,
|
||||
ssh: sshToolRenderer as ToolRenderer,
|
||||
task: taskToolRenderer as ToolRenderer,
|
||||
todo_write: todoWriteToolRenderer as ToolRenderer,
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
||||
import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import { Text } from "@oh-my-pi/pi-tui";
|
||||
import { untilAborted } from "@oh-my-pi/pi-utils";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { renderPromptTemplate } from "../config/prompt-templates";
|
||||
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
||||
import type { Theme } from "../modes/theme/theme";
|
||||
import resolveDescription from "../prompts/tools/resolve.md" with { type: "text" };
|
||||
import { CachedOutputBlock, Ellipsis, renderStatusLine, truncateToWidth } from "../tui";
|
||||
import type { ToolSession } from ".";
|
||||
import { replaceTabs } from "./render-utils";
|
||||
import { ToolError } from "./tool-errors";
|
||||
import { toolResult } from "./tool-result";
|
||||
|
||||
const resolveSchema = Type.Object({
|
||||
action: Type.Union([Type.Literal("apply"), Type.Literal("discard")]),
|
||||
reason: Type.String({ description: "Why you're applying or discarding" }),
|
||||
});
|
||||
|
||||
type ResolveParams = Static<typeof resolveSchema>;
|
||||
|
||||
export interface ResolveToolDetails {
|
||||
action: "apply" | "discard";
|
||||
reason: string;
|
||||
sourceToolName?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function resolveReasonPreview(reason?: string): string | undefined {
|
||||
const trimmed = reason?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return truncateToWidth(trimmed, 72, Ellipsis.Omit);
|
||||
}
|
||||
|
||||
function actionBadge(action: "apply" | "discard", theme: Theme): string {
|
||||
const color = action === "apply" ? "success" : "warning";
|
||||
const left = theme.format.bracketLeft;
|
||||
const right = theme.format.bracketRight;
|
||||
return theme.fg(color, `${left}${action}${right}`);
|
||||
}
|
||||
export class ResolveTool implements AgentTool<typeof resolveSchema, ResolveToolDetails> {
|
||||
readonly name = "resolve";
|
||||
readonly label = "Resolve";
|
||||
readonly hidden = true;
|
||||
readonly description: string;
|
||||
readonly parameters = resolveSchema;
|
||||
readonly strict = true;
|
||||
|
||||
constructor(private readonly session: ToolSession) {
|
||||
this.description = renderPromptTemplate(resolveDescription);
|
||||
}
|
||||
|
||||
async execute(
|
||||
_toolCallId: string,
|
||||
params: ResolveParams,
|
||||
signal?: AbortSignal,
|
||||
_onUpdate?: AgentToolUpdateCallback<ResolveToolDetails>,
|
||||
_context?: AgentToolContext,
|
||||
): Promise<AgentToolResult<ResolveToolDetails>> {
|
||||
return untilAborted(signal, async () => {
|
||||
const store = this.session.pendingActionStore;
|
||||
if (!store || !store.hasPending) {
|
||||
throw new ToolError("No pending action to resolve. Nothing to apply or discard.");
|
||||
}
|
||||
|
||||
const pendingAction = store.get();
|
||||
if (!pendingAction) {
|
||||
throw new ToolError("No pending action to resolve. Nothing to apply or discard.");
|
||||
}
|
||||
|
||||
store.clear();
|
||||
|
||||
const resolveDetails: ResolveToolDetails = {
|
||||
action: params.action,
|
||||
reason: params.reason,
|
||||
sourceToolName: pendingAction.sourceToolName,
|
||||
label: pendingAction.label,
|
||||
};
|
||||
|
||||
if (params.action === "apply") {
|
||||
const applyResult = await pendingAction.apply();
|
||||
const appliedText = applyResult.content
|
||||
.filter(part => part.type === "text")
|
||||
.map(part => part.text)
|
||||
.filter(text => text != null && text.length > 0)
|
||||
.join("\n");
|
||||
const baseResult = toolResult()
|
||||
.text(appliedText || `Applied: ${pendingAction.label}.`)
|
||||
.done();
|
||||
return { ...baseResult, details: resolveDetails };
|
||||
}
|
||||
|
||||
const discardResult = toolResult().text(`Discarded: ${pendingAction.label}. Reason: ${params.reason}`).done();
|
||||
return { ...discardResult, details: resolveDetails };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveToolRenderer = {
|
||||
renderCall(args: ResolveParams, _options: RenderResultOptions, uiTheme: Theme): Component {
|
||||
const reason = resolveReasonPreview(args.reason);
|
||||
const text = renderStatusLine(
|
||||
{
|
||||
icon: "pending",
|
||||
title: "Resolve",
|
||||
description: args.action,
|
||||
badge: { label: args.action, color: args.action === "apply" ? "success" : "warning" },
|
||||
meta: reason ? [uiTheme.fg("muted", reason)] : undefined,
|
||||
},
|
||||
uiTheme,
|
||||
);
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(
|
||||
result: { content: Array<{ type: string; text?: string }>; details?: ResolveToolDetails; isError?: boolean },
|
||||
_options: RenderResultOptions,
|
||||
uiTheme: Theme,
|
||||
): Component {
|
||||
const details = result.details;
|
||||
const label = details?.label ?? "pending action";
|
||||
const reason = details?.reason?.trim() || "No reason provided";
|
||||
const textContent = result.content.find(part => part.type === "text")?.text?.trim();
|
||||
const action = details?.action ?? "apply";
|
||||
const state = result.isError ? "error" : action === "discard" ? "warning" : "success";
|
||||
const headerMeta = details?.sourceToolName ? `from ${details.sourceToolName}` : undefined;
|
||||
const actionLine = `${uiTheme.bold("Action")}: ${actionBadge(action, uiTheme)}`;
|
||||
const targetLine = `${uiTheme.bold("Target")}: ${uiTheme.fg("accent", replaceTabs(label))}`;
|
||||
const reasonLine = `${uiTheme.bold("Reason")}: ${replaceTabs(reason)}`;
|
||||
const fallbackEffect = result.isError
|
||||
? "Resolve failed. No pending action was changed."
|
||||
: action === "discard"
|
||||
? `Discarded preview for ${label}.`
|
||||
: `Applied preview for ${label}.`;
|
||||
const effectLines = (textContent || fallbackEffect).split("\n").map(line => replaceTabs(line.trimEnd()));
|
||||
const outputBlock = new CachedOutputBlock();
|
||||
|
||||
return {
|
||||
render(width: number) {
|
||||
return outputBlock.render(
|
||||
{
|
||||
header: `${uiTheme.bold("Resolve")}`,
|
||||
headerMeta,
|
||||
state,
|
||||
width,
|
||||
sections: [
|
||||
{
|
||||
label: uiTheme.fg("toolTitle", "Decision"),
|
||||
lines: [actionLine, targetLine, reasonLine],
|
||||
},
|
||||
{
|
||||
label: uiTheme.fg("toolTitle", result.isError ? "Error" : "Effect"),
|
||||
lines: effectLines,
|
||||
},
|
||||
],
|
||||
},
|
||||
uiTheme,
|
||||
);
|
||||
},
|
||||
invalidate() {
|
||||
outputBlock.invalidate();
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
mergeCallAndResult: true,
|
||||
};
|
||||
Reference in New Issue
Block a user