Files
oh-my-pi/packages/coding-agent/src/edit/index.ts
T
can1357 85003caaaf feat(coding-agent): added hashline entrypoint and redirected callers
- Added `./hashline` package exports and redirected callers to the new hashline entrypoint.
- Moved hashline logic out of `edit/` to `src/hashline` and removed `edit/modes/hashline`/`edit/line-hash` paths.
- Added hashline parsers, anchors, types, and diff helpers with stricter input and mismatch validation.
- Implemented preflight and cache-recovery execution flows to reapply edits and handle stale anchor mismatches.
- Documented the hashline API relocation as breaking changes in `CHANGELOG.md`.
2026-05-09 23:10:02 +02:00

500 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;
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}`);
}
if (!isLast && onUpdate) {
onUpdate({
content: [{ type: "text", text: contentTexts.join("\n") }],
details: {
diff: diffTexts.join("\n"),
firstChangedLine,
},
});
}
}
return {
content: [{ type: "text", text: contentTexts.join("\n") }],
details: {
diff: diffTexts.join("\n"),
firstChangedLine,
},
};
}
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(),
});
}
}