1
import type { AgentTool } from "@earendil-works/pi-agent-core";2
import { constants } from "fs";3
import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises";4
import { type Static, Type } from "typebox";5
import { splitBom } from "../../utils/text.ts";6
import type { ExtensionContext, ToolDefinition } from "../extensions/types.ts";7
import {8
applyEditsToNormalizedContent,9
detectLineEnding,10
type Edit,11
generateDiffString,12
generateUnifiedPatch,13
normalizeToLF,14
restoreLineEndings,15
} from "./edit-diff.ts";16
import { withFileMutationQueue } from "./file-mutation-queue.ts";17
import { resolveToCwd } from "./path-utils.ts";18
import { type EditRenderState, editRenderers } from "./renderers/edit.ts";19
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";21
const replaceEditSchema = Type.Object(22
{23
oldText: Type.String({24
description:25
"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.",26
}),27
newText: Type.String({ description: "Replacement text for this targeted edit." }),28
},29
{},30
);32
const editSchema = Type.Object(33
{34
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),35
edits: Type.Array(replaceEditSchema, {36
description:37
"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.",38
}),39
},40
{},41
);43
export const editToolSystemPromptContribution = {44
snippet: "Make precise file edits with exact text replacement, including multiple disjoint edits in one call",45
guidelines: [46
"Use edit for precise changes (edits[].oldText must match exactly)",47
"When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls",48
"Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.",49
"Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.",50
],51
} as const;53
export type EditToolInput = Static<typeof editSchema>;54
type LegacyEditToolInput = EditToolInput & {55
oldText?: unknown;56
newText?: unknown;57
};59
type SingleEditInput = { oldText: string; newText: string };61
function isSingleEditInput(value: unknown): value is SingleEditInput {62
if (!value || typeof value !== "object" || Array.isArray(value)) {63
return false;64
}66
const edit = value as Record<string, unknown>;67
return typeof edit.oldText === "string" && typeof edit.newText === "string";68
}70
export interface EditToolDetails {71
/** Display-oriented diff of the changes made */72
diff: string;73
/** Standard unified patch of the changes made */74
patch: string;75
/** Line number of the first change in the new file (for editor navigation) */76
firstChangedLine?: number;77
}79
/**80
* Pluggable operations for the edit tool.81
* Override these to delegate file editing to remote systems (for example SSH).82
*/83
export interface EditOperations {84
/** Read file contents as a Buffer */85
readFile: (absolutePath: string) => Promise<Buffer>;86
/** Write content to a file */87
writeFile: (absolutePath: string, content: string) => Promise<void>;88
/** Check if file is readable and writable (throw if not) */89
access: (absolutePath: string) => Promise<void>;90
}92
const defaultEditOperations: EditOperations = {93
readFile: (path) => fsReadFile(path),94
writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),95
access: (path) => fsAccess(path, constants.R_OK | constants.W_OK),96
};98
export interface EditToolOptions {99
/** Custom operations for file editing. Default: local filesystem */100
operations?: EditOperations;101
}103
function prepareEditArguments(input: unknown): EditToolInput {104
if (!input || typeof input !== "object") {105
return input as EditToolInput;106
}108
const args = input as Record<string, unknown>;110
// Some models (Opus 4.6, GLM-5.1) send edits as a JSON string instead of an array.111
// Others send a single edit object instead of a one-element edits array.112
if (typeof args.edits === "string") {113
try {114
const parsed = JSON.parse(args.edits);115
if (Array.isArray(parsed)) {116
args.edits = parsed;117
} else if (isSingleEditInput(parsed)) {118
args.edits = [parsed];119
}120
} catch {}121
} else if (isSingleEditInput(args.edits)) {122
args.edits = [args.edits];123
}125
const legacy = args as LegacyEditToolInput;126
if (typeof legacy.oldText !== "string" || typeof legacy.newText !== "string") {127
return args as EditToolInput;128
}130
const edits = Array.isArray(legacy.edits) ? [...legacy.edits] : [];131
edits.push({ oldText: legacy.oldText, newText: legacy.newText });132
const { oldText: _oldText, newText: _newText, ...rest } = legacy;133
return { ...rest, edits } as EditToolInput;134
}136
function validateEditInput(input: EditToolInput): { path: string; edits: Edit[] } {137
if (!Array.isArray(input.edits) || input.edits.length === 0) {138
throw new Error("Edit tool input is invalid. edits must contain at least one replacement.");139
}140
return { path: input.path, edits: input.edits };141
}143
export function createEditToolDefinition(144
cwd: string,145
options?: EditToolOptions,146
): ToolDefinition<typeof editSchema, EditToolDetails | undefined, EditRenderState> {147
const ops = options?.operations ?? defaultEditOperations;148
return {149
name: "edit",150
label: "edit",151
description:152
"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.",153
promptSnippet: editToolSystemPromptContribution.snippet,154
promptGuidelines: [...editToolSystemPromptContribution.guidelines],155
parameters: editSchema,156
constrainedSampling: { type: "json_schema", strict: "prefer" },157
renderShell: "self",158
prepareArguments: prepareEditArguments,159
async execute(_toolCallId, input: EditToolInput, signal?: AbortSignal, _onUpdate?, ctx?: ExtensionContext) {160
const { path, edits } = validateEditInput(input);161
const absolutePath = resolveToCwd(path, ctx?.cwd || cwd);163
return withFileMutationQueue(absolutePath, async () => {164
// Do not reject from an abort event listener here: that would release the165
// mutation queue while an in-flight filesystem operation may still finish.166
// Checking signal.aborted after each await observes the same aborts while167
// keeping the queue locked until the current operation has settled.168
const throwIfAborted = (): void => {169
if (signal?.aborted) throw new Error("Operation aborted");170
};172
throwIfAborted();174
// Check if file exists.175
try {176
await ops.access(absolutePath);177
} catch (error: unknown) {178
throwIfAborted();179
const errorMessage =180
error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error);181
throw new Error(`Could not edit file: ${path}. ${errorMessage}.`);182
}183
throwIfAborted();185
// Read the file.186
const buffer = await ops.readFile(absolutePath);187
const rawContent = buffer.toString("utf-8");188
throwIfAborted();190
// Strip BOM before matching. The model will not include an invisible BOM in oldText.191
const { bom, text: content } = splitBom(rawContent);192
const originalEnding = detectLineEnding(content);193
const normalizedContent = normalizeToLF(content);194
const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);195
throwIfAborted();197
const finalContent = bom + restoreLineEndings(newContent, originalEnding);198
await ops.writeFile(absolutePath, finalContent);199
throwIfAborted();201
const diffResult = generateDiffString(baseContent, newContent);202
const patch = generateUnifiedPatch(path, baseContent, newContent);203
return {204
content: [205
{206
type: "text",207
text: `Successfully replaced ${edits.length} block(s) in ${path}.`,208
},209
],210
details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },211
};212
});213
},214
...editRenderers,215
};216
}218
export function createEditTool(cwd: string, options?: EditToolOptions): AgentTool<typeof editSchema> {219
return wrapToolDefinition(createEditToolDefinition(cwd, options));220
}