Files
oh-my-pi/packages/coding-agent/src/tools/ast-edit.ts
T
can1357 6da102a11b feat(coding-agent): pass reason to apply/reject callbacks in PendingAction
PendingAction.apply() and reject() now receive the reason string that was
passed to resolve(). This lets custom tools surface the agent's rationale
in their apply/discard output or use it for logging.

- PendingAction interface: apply(reason) and reject?(reason)
- CustomToolPendingAction: same signatures, reject is optional
- CustomToolLoader: threads reject through when building PendingAction
- AstEditTool: accepts _reason (unused, reserved for future tracing)
- resolve.test: covers reason forwarding on apply and reject paths,
  and verifies reject return value replaces the default discard message
- docs/resolve-tool-runtime.md: updated interface table, built-in
  producer description, usage example, and developer guidance
2026-03-01 03:36:43 +01:00

481 lines
18 KiB
TypeScript

import * as path from "node:path";
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { type AstReplaceChange, astEdit } from "@oh-my-pi/pi-natives";
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 { computeLineHash } from "../patch/hashline";
import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import { resolveFileDisplayMode } from "../utils/file-display-mode";
import type { ToolSession } from ".";
import type { OutputMeta } from "./output-meta";
import { hasGlobPathChars, parseSearchPath, resolveToCwd } from "./path-utils";
import {
formatCount,
formatEmptyMessage,
formatErrorMessage,
formatParseErrors,
PARSE_ERRORS_LIMIT,
PREVIEW_LIMITS,
} from "./render-utils";
import { ToolError } from "./tool-errors";
import { toolResult } from "./tool-result";
const astEditOpSchema = Type.Object({
pat: Type.String({ description: "AST pattern to match" }),
out: Type.String({ description: "Replacement template" }),
});
const astEditSchema = Type.Object({
ops: Type.Array(astEditOpSchema, {
description: "Rewrite ops as [{ pat, out }]",
}),
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" })),
limit: Type.Optional(Type.Number({ description: "Max total replacements" })),
});
export interface AstEditToolDetails {
totalReplacements: number;
filesTouched: number;
filesSearched: number;
applied: boolean;
limitReached: boolean;
parseErrors?: string[];
scopePath?: string;
files?: string[];
fileReplacements?: Array<{ path: string; count: number }>;
meta?: OutputMeta;
}
export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolDetails> {
readonly name = "ast_edit";
readonly label = "AST Edit";
readonly description: string;
readonly parameters = astEditSchema;
readonly strict = true;
readonly deferrable = true;
constructor(private readonly session: ToolSession) {
this.description = renderPromptTemplate(astEditDescription);
}
async execute(
_toolCallId: string,
params: Static<typeof astEditSchema>,
signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<AstEditToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<AstEditToolDetails>> {
return untilAborted(signal, async () => {
const ops = params.ops.map((entry, index) => {
if (entry.pat.length === 0) {
throw new ToolError(`\`ops[${index}].pat\` must be a non-empty pattern`);
}
return [entry.pat, entry.out] as const;
});
if (ops.length === 0) {
throw new ToolError("`ops` must include at least one op entry");
}
const seenPatterns = new Set<string>();
for (const [pat] of ops) {
if (seenPatterns.has(pat)) {
throw new ToolError(`Duplicate rewrite pattern: ${pat}`);
}
seenPatterns.add(pat);
}
const normalizedRewrites = Object.fromEntries(ops);
const maxReplacements = params.limit !== undefined ? Math.floor(params.limit) : undefined;
if (maxReplacements !== undefined && (!Number.isFinite(maxReplacements) || maxReplacements < 1)) {
throw new ToolError("limit must be a positive number");
}
const maxFiles = parseInt(process.env.PI_MAX_AST_FILES ?? "", 10) || 1000;
let searchPath: string | undefined;
let globFilter: string | undefined;
const rawPath = params.path?.trim();
if (rawPath) {
const internalRouter = this.session.internalRouter;
if (internalRouter?.canHandle(rawPath)) {
if (hasGlobPathChars(rawPath)) {
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
}
const resource = await internalRouter.resolve(rawPath);
if (!resource.sourcePath) {
throw new ToolError(`Cannot rewrite internal URL without backing file: ${rawPath}`);
}
searchPath = resource.sourcePath;
} else {
const parsedPath = parseSearchPath(rawPath);
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
globFilter = parsedPath.glob;
}
}
const resolvedSearchPath = searchPath ?? resolveToCwd(".", this.session.cwd);
const scopePath = path.relative(this.session.cwd, resolvedSearchPath).replace(/\\/g, "/") || ".";
let isDirectory: boolean;
try {
const stat = await Bun.file(resolvedSearchPath).stat();
isDirectory = stat.isDirectory();
} catch {
throw new ToolError(`Path not found: ${resolvedSearchPath}`);
}
const result = await astEdit({
rewrites: normalizedRewrites,
lang: params.lang?.trim(),
path: resolvedSearchPath,
glob: globFilter,
selector: params.selector?.trim(),
dryRun: true,
maxReplacements,
maxFiles,
failOnParseError: false,
signal,
});
const formatPath = (filePath: string): string => {
const cleanPath = filePath.startsWith("/") ? filePath.slice(1) : filePath;
if (isDirectory) {
return cleanPath.replace(/\\/g, "/");
}
return path.basename(cleanPath);
};
const files = new Set<string>();
const fileList: string[] = [];
const fileReplacementCounts = new Map<string, number>();
const changesByFile = new Map<string, AstReplaceChange[]>();
const recordFile = (relativePath: string) => {
if (!files.has(relativePath)) {
files.add(relativePath);
fileList.push(relativePath);
}
};
for (const fileChange of result.fileChanges) {
const relativePath = formatPath(fileChange.path);
recordFile(relativePath);
fileReplacementCounts.set(relativePath, (fileReplacementCounts.get(relativePath) ?? 0) + fileChange.count);
}
for (const change of result.changes) {
const relativePath = formatPath(change.path);
recordFile(relativePath);
if (!changesByFile.has(relativePath)) {
changesByFile.set(relativePath, []);
}
changesByFile.get(relativePath)!.push(change);
}
const baseDetails: AstEditToolDetails = {
totalReplacements: result.totalReplacements,
filesTouched: result.filesTouched,
filesSearched: result.filesSearched,
applied: result.applied,
limitReached: result.limitReached,
parseErrors: result.parseErrors,
scopePath,
files: fileList,
fileReplacements: [],
};
if (result.totalReplacements === 0) {
const parseMessage = result.parseErrors?.length
? `\n${formatParseErrors(result.parseErrors).join("\n")}`
: "";
return toolResult(baseDetails).text(`No replacements made${parseMessage}`).done();
}
const useHashLines = resolveFileDisplayMode(this.session).hashLines;
const outputLines: string[] = [];
const renderChangesForFile = (relativePath: string) => {
const fileChanges = changesByFile.get(relativePath) ?? [];
const lineWidth =
fileChanges.length > 0 ? Math.max(...fileChanges.map(change => change.startLine.toString().length)) : 1;
for (const change of fileChanges) {
const beforeFirstLine = change.before.split("\n", 1)[0] ?? "";
const afterFirstLine = change.after.split("\n", 1)[0] ?? "";
const beforeLine = beforeFirstLine.slice(0, 120);
const afterLine = afterFirstLine.slice(0, 120);
const beforeRef = useHashLines
? `${change.startLine}#${computeLineHash(change.startLine, beforeFirstLine)}`
: `${change.startLine.toString().padStart(lineWidth, " ")}:${change.startColumn}`;
const afterRef = useHashLines
? `${change.startLine}#${computeLineHash(change.startLine, afterFirstLine)}`
: `${change.startLine.toString().padStart(lineWidth, " ")}:${change.startColumn}`;
const lineSeparator = useHashLines ? ":" : " ";
outputLines.push(`-${beforeRef}${lineSeparator}${beforeLine}`);
outputLines.push(`+${afterRef}${lineSeparator}${afterLine}`);
}
};
if (isDirectory) {
const filesByDirectory = new Map<string, string[]>();
for (const relativePath of fileList) {
const directory = path.dirname(relativePath).replace(/\\/g, "/");
if (!filesByDirectory.has(directory)) {
filesByDirectory.set(directory, []);
}
filesByDirectory.get(directory)!.push(relativePath);
}
for (const [directory, directoryFiles] of filesByDirectory) {
if (directory === ".") {
for (const relativePath of directoryFiles) {
if (outputLines.length > 0) {
outputLines.push("");
}
const count = fileReplacementCounts.get(relativePath) ?? 0;
outputLines.push(`# ${path.basename(relativePath)} (${formatCount("replacement", count)})`);
renderChangesForFile(relativePath);
}
continue;
}
if (outputLines.length > 0) {
outputLines.push("");
}
outputLines.push(`# ${directory}`);
for (const relativePath of directoryFiles) {
const count = fileReplacementCounts.get(relativePath) ?? 0;
outputLines.push(`## └─ ${path.basename(relativePath)} (${formatCount("replacement", count)})`);
renderChangesForFile(relativePath);
}
}
} else {
for (const relativePath of fileList) {
renderChangesForFile(relativePath);
}
}
const fileReplacements = fileList.map(filePath => ({
path: filePath,
count: fileReplacementCounts.get(filePath) ?? 0,
}));
if (result.limitReached) {
outputLines.push("", "Limit reached; narrow path or increase limit.");
}
if (result.parseErrors?.length) {
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?.push({
label: `AST Edit: ${result.totalReplacements} replacement${previewReplacementPlural} in ${result.filesTouched} file${previewFilePlural}`,
sourceToolName: this.name,
apply: async (_reason: string) => {
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,
};
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();
},
});
}
const details: AstEditToolDetails = {
...baseDetails,
fileReplacements,
};
return toolResult(details).text(outputLines.join("\n")).done();
});
}
}
// =============================================================================
// TUI Renderer
// =============================================================================
interface AstEditRenderArgs {
ops?: Array<{ pat?: string; out?: string }>;
lang?: string;
path?: string;
selector?: string;
limit?: number;
}
const COLLAPSED_CHANGE_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
export const astEditToolRenderer = {
inline: true,
renderCall(args: AstEditRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
const meta: string[] = [];
if (args.lang) meta.push(`lang:${args.lang}`);
if (args.path) meta.push(`in ${args.path}`);
if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
const rewriteCount = args.ops?.length ?? 0;
if (rewriteCount > 1) meta.push(`${rewriteCount} rewrites`);
const description = rewriteCount === 1 ? args.ops?.[0]?.pat : rewriteCount ? `${rewriteCount} rewrites` : "?";
const text = renderStatusLine({ icon: "pending", title: "AST Edit", description, meta }, uiTheme);
return new Text(text, 0, 0);
},
renderResult(
result: { content: Array<{ type: string; text?: string }>; details?: AstEditToolDetails; isError?: boolean },
options: RenderResultOptions,
uiTheme: Theme,
args?: AstEditRenderArgs,
): Component {
const details = result.details;
if (result.isError) {
const errorText = result.content?.find(c => c.type === "text")?.text || "Unknown error";
return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
}
const totalReplacements = details?.totalReplacements ?? 0;
const filesTouched = details?.filesTouched ?? 0;
const filesSearched = details?.filesSearched ?? 0;
const limitReached = details?.limitReached ?? false;
if (totalReplacements === 0) {
const rewriteCount = args?.ops?.length ?? 0;
const description = rewriteCount === 1 ? args?.ops?.[0]?.pat : undefined;
const meta = ["0 replacements"];
if (details?.scopePath) meta.push(`in ${details.scopePath}`);
if (filesSearched > 0) meta.push(`searched ${filesSearched}`);
const header = renderStatusLine({ icon: "warning", title: "AST Edit", description, meta }, uiTheme);
const lines = [header, formatEmptyMessage("No replacements made", uiTheme)];
if (details?.parseErrors?.length) {
const capped = details.parseErrors.slice(0, PARSE_ERRORS_LIMIT);
for (const err of capped) {
lines.push(uiTheme.fg("warning", ` - ${err}`));
}
if (details.parseErrors.length > PARSE_ERRORS_LIMIT) {
lines.push(uiTheme.fg("dim", ` … ${details.parseErrors.length - PARSE_ERRORS_LIMIT} more`));
}
}
return new Text(lines.join("\n"), 0, 0);
}
const summaryParts = [formatCount("replacement", totalReplacements), formatCount("file", filesTouched)];
const meta = [...summaryParts];
if (details?.scopePath) meta.push(`in ${details.scopePath}`);
meta.push(`searched ${filesSearched}`);
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 textContent = result.content?.find(c => c.type === "text")?.text ?? "";
const rawLines = textContent.split("\n");
const hasSeparators = rawLines.some(line => line.trim().length === 0);
const allGroups: string[][] = [];
if (hasSeparators) {
let current: string[] = [];
for (const line of rawLines) {
if (line.trim().length === 0) {
if (current.length > 0) {
allGroups.push(current);
current = [];
}
continue;
}
current.push(line);
}
if (current.length > 0) allGroups.push(current);
} else {
const nonEmpty = rawLines.filter(line => line.trim().length > 0);
if (nonEmpty.length > 0) {
allGroups.push(nonEmpty);
}
}
const changeGroups = allGroups.filter(
group => !group[0]?.startsWith("Safety cap reached") && !group[0]?.startsWith("Parse issues:"),
);
const getCollapsedChangeLimit = (groups: string[][], maxLines: number): number => {
if (groups.length === 0) return 0;
let usedLines = 0;
let count = 0;
for (const group of groups) {
if (count > 0 && usedLines + group.length > maxLines) break;
usedLines += group.length;
count += 1;
if (usedLines >= maxLines) break;
}
return count;
};
const badge = { label: "proposed", color: "warning" as const };
const header = renderStatusLine(
{ icon: limitReached ? "warning" : "success", title: "AST Edit", description, badge, meta },
uiTheme,
);
const extraLines: string[] = [];
if (limitReached) {
extraLines.push(uiTheme.fg("warning", "limit reached; narrow path or increase limit"));
}
if (details?.parseErrors?.length) {
const total = details.parseErrors.length;
const label =
total > PARSE_ERRORS_LIMIT
? `${PARSE_ERRORS_LIMIT} / ${total} parse issues`
: `${total} parse issue${total !== 1 ? "s" : ""}`;
extraLines.push(uiTheme.fg("warning", label));
}
let cached: RenderCache | undefined;
return {
render(width: number): string[] {
const { expanded } = options;
const key = new Hasher().bool(expanded).u32(width).digest();
if (cached?.key === key) return cached.lines;
const maxCollapsed = expanded
? changeGroups.length
: getCollapsedChangeLimit(changeGroups, COLLAPSED_CHANGE_LIMIT);
const changeLines = renderTreeList(
{
items: changeGroups,
expanded,
maxCollapsed,
itemType: "change",
renderItem: group =>
group.map(line => {
if (line.startsWith("## ")) return uiTheme.fg("dim", line);
if (line.startsWith("# ")) return uiTheme.fg("accent", line);
if (line.startsWith("+")) return uiTheme.fg("toolDiffAdded", line);
if (line.startsWith("-")) return uiTheme.fg("toolDiffRemoved", line);
return uiTheme.fg("toolOutput", line);
}),
},
uiTheme,
);
const rendered = [header, ...changeLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
cached = { key, lines: rendered };
return rendered;
},
invalidate() {
cached = undefined;
},
};
},
mergeCallAndResult: true,
};