import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { prompt } from "@oh-my-pi/pi-utils"; import type * as z from "zod/v4"; 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$`, `$HOP_*$`, `$HOP_CHARS$`, and `$HFILE$` 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 = z.infer; 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) => void, ) => Promise>; }; 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>; }[], outerBatchRequest: LspBatchRequest | undefined, onUpdate?: (partialResult: AgentToolResult) => void, ): Promise> { 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: details?.path ?? path, diff: details?.diff ?? "", firstChangedLine: details?.firstChangedLine, diagnostics: details?.diagnostics, op: details?.op, move: details?.move, meta: details?.meta, oldText: details?.oldText, newText: details?.newText, }); 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>)[], outerBatchRequest: LspBatchRequest | undefined, onUpdate?: (partialResult: AgentToolResult) => void, ): Promise> { if (runs.length === 1) { return runs[0](outerBatchRequest); } const contentTexts: string[] = []; const diffTexts: string[] = []; let firstChangedLine: number | undefined; let errorCount = 0; let metadataPath: string | undefined; let hasFirstOldText = false; let firstOldText: string | undefined; let hasLastNewText = false; let lastNewText: string | undefined; 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; if (details?.path) { metadataPath ??= details.path; } if (details && "oldText" in details && !hasFirstOldText) { firstOldText = details.oldText; hasFirstOldText = true; } if (details && "newText" in details) { lastNewText = details.newText; hasLastNewText = true; } 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, path: metadataPath ?? path, ...(hasFirstOldText ? { oldText: firstOldText } : {}), ...(hasLastNewText ? { newText: lastNewText } : {}), }, // 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 { 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(); 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, context?: AgentToolContext, ): Promise> { 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) => 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) => 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) => 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) => 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) => void, ) => { const handleUpdate = onUpdate ? (partialResult: AgentToolResult) => { onUpdate(partialResult as AgentToolResult); } : undefined; return (await tool.#vimTool.execute( "edit", params as VimParams, signal, handleUpdate, )) as AgentToolResult; }, }, }[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(), }); } }