- Stored a failure counter during single-path edit execution and set isError on aggregate results when any entry edit failed. - Set streaming-edit handling to always evaluate auto-generated-file checks, but only primed the file cache when edit.streamingAbort was enabled.
508 lines
17 KiB
TypeScript
508 lines
17 KiB
TypeScript
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
import { prompt } from "@oh-my-pi/pi-utils";
|
|
import type { Static } from "@sinclair/typebox";
|
|
import {
|
|
executeHashlineSingle,
|
|
HashlineMismatchError,
|
|
type HashlineParams,
|
|
hashlineEditParamsSchema,
|
|
} from "../hashline";
|
|
import hashlineGrammarTemplate from "../hashline/grammar.lark" with { type: "text" };
|
|
import { resolveHashlineGrammarPlaceholders } from "../hashline/hash";
|
|
import {
|
|
createLspWritethrough,
|
|
type FileDiagnosticsResult,
|
|
type WritethroughCallback,
|
|
type WritethroughDeferredHandle,
|
|
writethroughNoop,
|
|
} from "../lsp";
|
|
import applyPatchDescription from "../prompts/tools/apply-patch.md" with { type: "text" };
|
|
import hashlineDescription from "../prompts/tools/hashline.md" with { type: "text" };
|
|
import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
|
|
import replaceDescription from "../prompts/tools/replace.md" with { type: "text" };
|
|
import type { ToolSession } from "../tools";
|
|
import { VimTool, vimSchema } from "../tools/vim";
|
|
import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
|
|
import type { VimToolDetails } from "../vim/types";
|
|
import { type ApplyPatchParams, applyPatchSchema, expandApplyPatchToEntries } from "./modes/apply-patch";
|
|
import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
|
|
import { executePatchSingle, type PatchEditEntry, type PatchParams, patchEditSchema } from "./modes/patch";
|
|
import { executeReplaceSingle, type ReplaceEditEntry, type ReplaceParams, replaceEditSchema } from "./modes/replace";
|
|
import { type EditToolDetails, type EditToolPerFileResult, getLspBatchRequest, type LspBatchRequest } from "./renderer";
|
|
|
|
export { DEFAULT_EDIT_MODE, type EditMode, normalizeEditMode } from "../utils/edit-mode";
|
|
export * from "./apply-patch";
|
|
export * from "./diff";
|
|
export * from "./file-read-cache";
|
|
|
|
// Resolve the `$HFMT$` and `$HSEP$` placeholders in the hashline Lark grammar.
|
|
const hashlineGrammar = resolveHashlineGrammarPlaceholders(hashlineGrammarTemplate);
|
|
|
|
export * from "../hashline";
|
|
export * from "./modes/apply-patch";
|
|
export * from "./modes/patch";
|
|
export * from "./modes/replace";
|
|
export * from "./normalize";
|
|
export * from "./renderer";
|
|
export * from "./streaming";
|
|
|
|
type TInput =
|
|
| typeof replaceEditSchema
|
|
| typeof patchEditSchema
|
|
| typeof hashlineEditParamsSchema
|
|
| typeof vimSchema
|
|
| typeof applyPatchSchema;
|
|
|
|
type VimParams = Static<typeof vimSchema>;
|
|
type EditParams = ReplaceParams | PatchParams | HashlineParams | VimParams | ApplyPatchParams;
|
|
type EditToolResultDetails = EditToolDetails | VimToolDetails;
|
|
|
|
type EditModeDefinition = {
|
|
description: (session: ToolSession) => string;
|
|
parameters: TInput;
|
|
execute: (
|
|
tool: EditTool,
|
|
params: EditParams,
|
|
signal: AbortSignal | undefined,
|
|
batchRequest: LspBatchRequest | undefined,
|
|
onUpdate?: (partialResult: AgentToolResult<EditToolResultDetails, TInput>) => void,
|
|
) => Promise<AgentToolResult<EditToolResultDetails, TInput>>;
|
|
};
|
|
|
|
function resolveConfiguredEditMode(rawEditMode: string): EditMode | undefined {
|
|
if (!rawEditMode || rawEditMode === "auto") {
|
|
return undefined;
|
|
}
|
|
|
|
const editMode = normalizeEditMode(rawEditMode);
|
|
if (!editMode) {
|
|
throw new Error(`Invalid PI_EDIT_VARIANT: ${rawEditMode}`);
|
|
}
|
|
|
|
return editMode;
|
|
}
|
|
|
|
function resolveAllowFuzzy(session: ToolSession, rawValue: string): boolean {
|
|
switch (rawValue) {
|
|
case "true":
|
|
case "1":
|
|
return true;
|
|
case "false":
|
|
case "0":
|
|
return false;
|
|
case "auto":
|
|
return session.settings.get("edit.fuzzyMatch");
|
|
default:
|
|
throw new Error(`Invalid PI_EDIT_FUZZY: ${rawValue}`);
|
|
}
|
|
}
|
|
|
|
function resolveFuzzyThreshold(session: ToolSession, rawValue: string): number {
|
|
if (rawValue === "auto") {
|
|
return session.settings.get("edit.fuzzyThreshold");
|
|
}
|
|
|
|
const threshold = Number.parseFloat(rawValue);
|
|
if (Number.isNaN(threshold) || threshold < 0 || threshold > 1) {
|
|
throw new Error(`Invalid PI_EDIT_FUZZY_THRESHOLD: ${rawValue}`);
|
|
}
|
|
|
|
return threshold;
|
|
}
|
|
|
|
function createEditWritethrough(session: ToolSession): WritethroughCallback {
|
|
const enableLsp = session.enableLsp ?? true;
|
|
const enableDiagnostics = enableLsp && session.settings.get("lsp.diagnosticsOnEdit");
|
|
const enableFormat = enableLsp && session.settings.get("lsp.formatOnWrite");
|
|
return enableLsp ? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics }) : writethroughNoop;
|
|
}
|
|
|
|
/** Run apply_patch file operations and aggregate their multi-file result. */
|
|
async function executeApplyPatchPerFile(
|
|
fileEntries: {
|
|
path: string;
|
|
run: (batchRequest: LspBatchRequest | undefined) => Promise<AgentToolResult<EditToolDetails>>;
|
|
}[],
|
|
outerBatchRequest: LspBatchRequest | undefined,
|
|
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
|
): Promise<AgentToolResult<EditToolDetails, TInput>> {
|
|
if (fileEntries.length === 1) {
|
|
// Single file — just run directly, no wrapping
|
|
return fileEntries[0].run(outerBatchRequest);
|
|
}
|
|
|
|
const perFileResults: EditToolPerFileResult[] = [];
|
|
const contentTexts: string[] = [];
|
|
|
|
for (let i = 0; i < fileEntries.length; i++) {
|
|
const { path, run } = fileEntries[i];
|
|
const isLast = i === fileEntries.length - 1;
|
|
const batchRequest: LspBatchRequest | undefined = outerBatchRequest
|
|
? { id: outerBatchRequest.id, flush: isLast && outerBatchRequest.flush }
|
|
: undefined;
|
|
|
|
try {
|
|
const result = await run(batchRequest);
|
|
const details = result.details;
|
|
perFileResults.push({
|
|
path,
|
|
diff: details?.diff ?? "",
|
|
firstChangedLine: details?.firstChangedLine,
|
|
diagnostics: details?.diagnostics,
|
|
op: details?.op,
|
|
move: details?.move,
|
|
meta: details?.meta,
|
|
});
|
|
const text = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
if (text) contentTexts.push(text);
|
|
} catch (err) {
|
|
const errorText = err instanceof Error ? err.message : String(err);
|
|
const displayErrorText = err instanceof HashlineMismatchError ? err.displayMessage : undefined;
|
|
perFileResults.push({ path, diff: "", isError: true, errorText, displayErrorText });
|
|
contentTexts.push(`Error editing ${path}: ${errorText}`);
|
|
}
|
|
|
|
// Emit partial result after each file so UI shows progressive completion
|
|
if (!isLast && onUpdate) {
|
|
onUpdate({
|
|
content: [{ type: "text", text: contentTexts.join("\n") }],
|
|
details: {
|
|
diff: perFileResults
|
|
.map(r => r.diff)
|
|
.filter(Boolean)
|
|
.join("\n"),
|
|
firstChangedLine: perFileResults.find(r => r.firstChangedLine)?.firstChangedLine,
|
|
perFileResults: [...perFileResults],
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
content: [{ type: "text", text: contentTexts.join("\n") }],
|
|
details: {
|
|
diff: perFileResults
|
|
.map(r => r.diff)
|
|
.filter(Boolean)
|
|
.join("\n"),
|
|
firstChangedLine: perFileResults.find(r => r.firstChangedLine)?.firstChangedLine,
|
|
perFileResults,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function executeSinglePathEntries(
|
|
path: string,
|
|
runs: ((batchRequest: LspBatchRequest | undefined) => Promise<AgentToolResult<EditToolDetails>>)[],
|
|
outerBatchRequest: LspBatchRequest | undefined,
|
|
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
|
): Promise<AgentToolResult<EditToolDetails, TInput>> {
|
|
if (runs.length === 1) {
|
|
return runs[0](outerBatchRequest);
|
|
}
|
|
|
|
const contentTexts: string[] = [];
|
|
const diffTexts: string[] = [];
|
|
let firstChangedLine: number | undefined;
|
|
let errorCount = 0;
|
|
|
|
for (let i = 0; i < runs.length; i++) {
|
|
const isLast = i === runs.length - 1;
|
|
const batchRequest: LspBatchRequest | undefined = outerBatchRequest
|
|
? { id: outerBatchRequest.id, flush: isLast && outerBatchRequest.flush }
|
|
: undefined;
|
|
|
|
try {
|
|
const result = await runs[i](batchRequest);
|
|
const details = result.details;
|
|
if (details?.diff) diffTexts.push(details.diff);
|
|
firstChangedLine ??= details?.firstChangedLine;
|
|
const text = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
if (text) contentTexts.push(text);
|
|
} catch (err) {
|
|
const errorText = err instanceof Error ? err.message : String(err);
|
|
contentTexts.push(`Error editing ${path}: ${errorText}`);
|
|
errorCount++;
|
|
}
|
|
|
|
if (!isLast && onUpdate) {
|
|
onUpdate({
|
|
content: [{ type: "text", text: contentTexts.join("\n") }],
|
|
details: {
|
|
diff: diffTexts.join("\n"),
|
|
firstChangedLine,
|
|
},
|
|
...(errorCount > 0 ? { isError: true } : {}),
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
content: [{ type: "text", text: contentTexts.join("\n") }],
|
|
details: {
|
|
diff: diffTexts.join("\n"),
|
|
firstChangedLine,
|
|
},
|
|
// Any per-entry failure marks the aggregate result as an error so the
|
|
// renderer takes the error branch instead of falling through to the
|
|
// streaming-edit preview (which displays the *proposed* diff and looks
|
|
// indistinguishable from success).
|
|
...(errorCount > 0 ? { isError: true } : {}),
|
|
};
|
|
}
|
|
|
|
export class EditTool implements AgentTool<TInput> {
|
|
readonly name = "edit";
|
|
readonly label = "Edit";
|
|
readonly loadMode = "essential";
|
|
readonly nonAbortable = true;
|
|
readonly concurrency = "exclusive";
|
|
readonly strict = true;
|
|
|
|
readonly #allowFuzzy: boolean;
|
|
readonly #fuzzyThreshold: number;
|
|
readonly #writethrough: WritethroughCallback;
|
|
readonly #editMode?: EditMode;
|
|
readonly #vimTool: VimTool;
|
|
readonly #pendingDeferredFetches = new Map<string, AbortController>();
|
|
|
|
constructor(private readonly session: ToolSession) {
|
|
const {
|
|
PI_EDIT_FUZZY: editFuzzy = "auto",
|
|
PI_EDIT_FUZZY_THRESHOLD: editFuzzyThreshold = "auto",
|
|
PI_EDIT_VARIANT: envEditVariant = "auto",
|
|
} = Bun.env;
|
|
|
|
this.#editMode = resolveConfiguredEditMode(envEditVariant);
|
|
this.#allowFuzzy = resolveAllowFuzzy(session, editFuzzy);
|
|
this.#fuzzyThreshold = resolveFuzzyThreshold(session, editFuzzyThreshold);
|
|
this.#writethrough = createEditWritethrough(session);
|
|
this.#vimTool = new VimTool(session);
|
|
}
|
|
|
|
get mode(): EditMode {
|
|
if (this.#editMode) return this.#editMode;
|
|
return resolveEditMode(this.session);
|
|
}
|
|
|
|
get description(): string {
|
|
return this.#getModeDefinition().description(this.session);
|
|
}
|
|
|
|
get parameters(): TInput {
|
|
return this.#getModeDefinition().parameters;
|
|
}
|
|
|
|
/**
|
|
* When in `apply_patch` mode, expose the Codex Lark grammar so providers
|
|
* that support OpenAI-style custom tools can emit a grammar-constrained
|
|
* variant. Providers that don't support custom tools ignore this field
|
|
* and fall back to emitting a JSON function tool from `parameters`.
|
|
*/
|
|
get customFormat(): { syntax: "lark"; definition: string } | undefined {
|
|
if (this.mode === "apply_patch") return { syntax: "lark", definition: applyPatchGrammar };
|
|
if (this.mode === "hashline") return { syntax: "lark", definition: hashlineGrammar };
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Wire-level tool name used when the custom-tool variant is active. GPT-5+
|
|
* is trained on the literal name `apply_patch`; internally this is just a
|
|
* mode of the `edit` tool. The agent-loop dispatcher matches both the
|
|
* internal `name` and `customWireName`, so returned calls route correctly.
|
|
*/
|
|
get customWireName(): string | undefined {
|
|
if (this.mode !== "apply_patch") return undefined;
|
|
return "apply_patch";
|
|
}
|
|
|
|
async execute(
|
|
_toolCallId: string,
|
|
params: EditParams,
|
|
signal?: AbortSignal,
|
|
onUpdate?: AgentToolUpdateCallback<EditToolResultDetails, TInput>,
|
|
context?: AgentToolContext,
|
|
): Promise<AgentToolResult<EditToolResultDetails, TInput>> {
|
|
const modeDefinition = this.#getModeDefinition();
|
|
return modeDefinition.execute(this, params, signal, getLspBatchRequest(context?.toolCall), onUpdate);
|
|
}
|
|
|
|
#getModeDefinition(): EditModeDefinition {
|
|
return {
|
|
patch: {
|
|
description: () => prompt.render(patchDescription),
|
|
parameters: patchEditSchema,
|
|
execute: (
|
|
tool: EditTool,
|
|
params: EditParams,
|
|
signal: AbortSignal | undefined,
|
|
batchRequest: LspBatchRequest | undefined,
|
|
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
|
) => {
|
|
const { edits, path } = params as PatchParams;
|
|
const runs = (edits as PatchEditEntry[]).map(
|
|
entry => (br: LspBatchRequest | undefined) =>
|
|
executePatchSingle({
|
|
session: tool.session,
|
|
path,
|
|
params: entry,
|
|
signal,
|
|
batchRequest: br,
|
|
allowFuzzy: tool.#allowFuzzy,
|
|
fuzzyThreshold: tool.#fuzzyThreshold,
|
|
writethrough: tool.#writethrough,
|
|
beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
|
|
}),
|
|
);
|
|
return executeSinglePathEntries(path, runs, batchRequest, onUpdate);
|
|
},
|
|
},
|
|
apply_patch: {
|
|
description: () => prompt.render(applyPatchDescription),
|
|
parameters: applyPatchSchema,
|
|
execute: (
|
|
tool: EditTool,
|
|
params: EditParams,
|
|
signal: AbortSignal | undefined,
|
|
batchRequest: LspBatchRequest | undefined,
|
|
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
|
) => {
|
|
const entries = expandApplyPatchToEntries(params as ApplyPatchParams);
|
|
const perFile = entries.map(entry => {
|
|
const { path, ...patchParams } = entry;
|
|
return {
|
|
path,
|
|
run: (br: LspBatchRequest | undefined) =>
|
|
executePatchSingle({
|
|
session: tool.session,
|
|
path,
|
|
params: patchParams,
|
|
signal,
|
|
batchRequest: br,
|
|
allowFuzzy: tool.#allowFuzzy,
|
|
fuzzyThreshold: tool.#fuzzyThreshold,
|
|
writethrough: tool.#writethrough,
|
|
beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
|
|
}),
|
|
};
|
|
});
|
|
return executeApplyPatchPerFile(perFile, batchRequest, onUpdate);
|
|
},
|
|
},
|
|
hashline: {
|
|
description: () => prompt.render(hashlineDescription),
|
|
parameters: hashlineEditParamsSchema,
|
|
execute: (
|
|
tool: EditTool,
|
|
params: EditParams,
|
|
signal: AbortSignal | undefined,
|
|
batchRequest: LspBatchRequest | undefined,
|
|
_onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
|
) => {
|
|
const { input, path } = params as HashlineParams & { path?: string };
|
|
return executeHashlineSingle({
|
|
session: tool.session,
|
|
input,
|
|
path,
|
|
signal,
|
|
batchRequest,
|
|
writethrough: tool.#writethrough,
|
|
beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
|
|
});
|
|
},
|
|
},
|
|
replace: {
|
|
description: () => prompt.render(replaceDescription),
|
|
parameters: replaceEditSchema,
|
|
execute: (
|
|
tool: EditTool,
|
|
params: EditParams,
|
|
signal: AbortSignal | undefined,
|
|
batchRequest: LspBatchRequest | undefined,
|
|
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
|
) => {
|
|
const { edits, path } = params as ReplaceParams;
|
|
const runs = (edits as ReplaceEditEntry[]).map(
|
|
entry => (br: LspBatchRequest | undefined) =>
|
|
executeReplaceSingle({
|
|
session: tool.session,
|
|
path,
|
|
params: entry,
|
|
signal,
|
|
batchRequest: br,
|
|
allowFuzzy: tool.#allowFuzzy,
|
|
fuzzyThreshold: tool.#fuzzyThreshold,
|
|
writethrough: tool.#writethrough,
|
|
beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
|
|
}),
|
|
);
|
|
return executeSinglePathEntries(path, runs, batchRequest, onUpdate);
|
|
},
|
|
},
|
|
vim: {
|
|
description: () => this.#vimTool.description,
|
|
parameters: vimSchema,
|
|
execute: async (
|
|
tool: EditTool,
|
|
params: EditParams,
|
|
signal: AbortSignal | undefined,
|
|
_batchRequest: LspBatchRequest | undefined,
|
|
onUpdate?: (partialResult: AgentToolResult<EditToolResultDetails, TInput>) => void,
|
|
) => {
|
|
const handleUpdate = onUpdate
|
|
? (partialResult: AgentToolResult<VimToolDetails>) => {
|
|
onUpdate(partialResult as AgentToolResult<EditToolResultDetails, TInput>);
|
|
}
|
|
: undefined;
|
|
return (await tool.#vimTool.execute(
|
|
"edit",
|
|
params as VimParams,
|
|
signal,
|
|
handleUpdate,
|
|
)) as AgentToolResult<EditToolResultDetails, TInput>;
|
|
},
|
|
},
|
|
}[this.mode];
|
|
}
|
|
|
|
#beginDeferredDiagnosticsForPath(path: string): WritethroughDeferredHandle {
|
|
const existingDeferred = this.#pendingDeferredFetches.get(path);
|
|
if (existingDeferred) {
|
|
existingDeferred.abort();
|
|
this.#pendingDeferredFetches.delete(path);
|
|
}
|
|
|
|
const deferredController = new AbortController();
|
|
return {
|
|
onDeferredDiagnostics: (lateDiagnostics: FileDiagnosticsResult) => {
|
|
this.#pendingDeferredFetches.delete(path);
|
|
this.#injectLateDiagnostics(path, lateDiagnostics);
|
|
},
|
|
signal: deferredController.signal,
|
|
finalize: (diagnostics: FileDiagnosticsResult | undefined) => {
|
|
if (!diagnostics) {
|
|
this.#pendingDeferredFetches.set(path, deferredController);
|
|
} else {
|
|
deferredController.abort();
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
#injectLateDiagnostics(path: string, diagnostics: FileDiagnosticsResult): void {
|
|
const summary = diagnostics.summary ?? "";
|
|
const lines = diagnostics.messages ?? [];
|
|
const body = [`Late LSP diagnostics for ${path} (arrived after the edit tool returned):`, summary, ...lines]
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
|
|
this.session.queueDeferredMessage?.({
|
|
role: "custom",
|
|
customType: "lsp-late-diagnostic",
|
|
content: body,
|
|
display: false,
|
|
timestamp: Date.now(),
|
|
});
|
|
}
|
|
}
|