pi-mono/packages/coding-agent/src/core/tools/edit.ts
Petr Houška d1e8703375 fix(coding-agent): read and edit handle UTF-16 and non-UTF-8 files without corrupting them
One helper, tools/file-encoding.ts, detects UTF-16 by byte order mark
and decodes text for read (UTF-8 as before otherwise) and losslessly for
edit. Edit writes files back in the encoding they were read with; files
that are not valid UTF-8 are decoded as Latin-1 so ASCII edits splice
exactly their bytes, and non-ASCII edits, fuzzy matches and changes to
the byte after a non-ASCII byte are refused. EditOperations gains an
optional writeFileBytes for these files.

fixes #10349
fixes #5797
2026-11-05 23:46:22 +01:00

250 lines
9.3 KiB
TypeScript

import { constants } from "fs";
import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises";
import { type Static, Type } from "typebox";
import type { ExtensionContext, ToolDefinition } from "../extensions/types.ts";
import {
applyEditsToNormalizedContent,
detectLineEnding,
type Edit,
generateDiffString,
generateUnifiedPatch,
normalizeToLF,
restoreLineEndings,
} from "./edit-diff.ts";
import { type DecodedFile, decodeFile, encodeNonUtf8File } from "./file-encoding.ts";
import { withFileMutationQueue } from "./file-mutation-queue.ts";
import { resolveToCwd } from "./path-utils.ts";
import { type EditRenderState, editRenderers } from "./renderers/edit.ts";
import { type TypeBoxAgentTool, wrapToolDefinition } from "./tool-definition-wrapper.ts";
const replaceEditSchema = Type.Object(
{
oldText: Type.String({
description:
"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.",
}),
newText: Type.String({ description: "Replacement text for this targeted edit." }),
},
{},
);
const editSchema = Type.Object(
{
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
edits: Type.Array(replaceEditSchema, {
description:
"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.",
}),
},
{},
);
export const editToolSystemPromptContribution = {
snippet: "Make precise file edits with exact text replacement, including multiple disjoint edits in one call",
guidelines: [
"Use edit for targeted changes. Batch changes to one file in a single call.",
"Each edits[].oldText must exactly and uniquely match the original file. Keep matches short and non-overlapping; merge nearby changes without including large unchanged regions.",
],
} as const;
export type EditToolInput = Static<typeof editSchema>;
type LegacyEditToolInput = EditToolInput & {
oldText?: unknown;
newText?: unknown;
};
type SingleEditInput = { oldText: string; newText: string };
function isSingleEditInput(value: unknown): value is SingleEditInput {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const edit = value as Record<string, unknown>;
return typeof edit.oldText === "string" && typeof edit.newText === "string";
}
export interface EditToolDetails {
/** Display-oriented diff of the changes made */
diff: string;
/** Standard unified patch of the changes made */
patch: string;
/** Line number of the first change in the new file (for editor navigation) */
firstChangedLine?: number;
}
/**
* Pluggable operations for the edit tool.
* Override these to delegate file editing to remote systems (for example SSH).
*/
export interface EditOperations {
/** Read file contents as a Buffer */
readFile: (absolutePath: string) => Promise<Buffer>;
/** Write content to a file */
writeFile: (absolutePath: string, content: string) => Promise<void>;
/**
* Write raw bytes to a file. Used for files that are not UTF-8 (UTF-16 with BOM, or invalid UTF-8 such as
* legacy encodings), which `writeFile` cannot reproduce. Without it, edits to such files are refused.
*/
writeFileBytes?: (absolutePath: string, content: Buffer) => Promise<void>;
/** Check if file is readable and writable (throw if not) */
access: (absolutePath: string) => Promise<void>;
}
const defaultEditOperations: EditOperations = {
readFile: (path) => fsReadFile(path),
writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),
writeFileBytes: (path, content) => fsWriteFile(path, content),
access: (path) => fsAccess(path, constants.R_OK | constants.W_OK),
};
export interface EditToolOptions {
/** Custom operations for file editing. Default: local filesystem */
operations?: EditOperations;
}
function prepareEditArguments(input: unknown): EditToolInput {
if (!input || typeof input !== "object") {
return input as EditToolInput;
}
const args = input as Record<string, unknown>;
// Some models (Opus 4.6, GLM-5.1) send edits as a JSON string instead of an array.
// Others send a single edit object instead of a one-element edits array.
if (typeof args.edits === "string") {
try {
const parsed = JSON.parse(args.edits);
if (Array.isArray(parsed)) {
args.edits = parsed;
} else if (isSingleEditInput(parsed)) {
args.edits = [parsed];
}
} catch {}
} else if (isSingleEditInput(args.edits)) {
args.edits = [args.edits];
}
const legacy = args as LegacyEditToolInput;
if (typeof legacy.oldText !== "string" || typeof legacy.newText !== "string") {
return args as EditToolInput;
}
const edits = Array.isArray(legacy.edits) ? [...legacy.edits] : [];
edits.push({ oldText: legacy.oldText, newText: legacy.newText });
const { oldText: _oldText, newText: _newText, ...rest } = legacy;
return { ...rest, edits } as EditToolInput;
}
function validateEditInput(input: EditToolInput): { path: string; edits: Edit[] } {
if (!Array.isArray(input.edits) || input.edits.length === 0) {
throw new Error("Edit tool input is invalid. edits must contain at least one replacement.");
}
return { path: input.path, edits: input.edits };
}
export function createEditToolDefinition(
cwd: string,
options?: EditToolOptions,
): ToolDefinition<typeof editSchema, EditToolDetails | undefined, EditRenderState> {
const ops = options?.operations ?? defaultEditOperations;
return {
name: "edit",
label: "edit",
description:
"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.",
promptSnippet: editToolSystemPromptContribution.snippet,
promptGuidelines: [...editToolSystemPromptContribution.guidelines],
parameters: editSchema,
constrainedSampling: { type: "json_schema", strict: "prefer" },
renderShell: "self",
prepareArguments: prepareEditArguments,
async execute(_toolCallId, input: EditToolInput, signal?: AbortSignal, _onUpdate?, ctx?: ExtensionContext) {
const { path, edits } = validateEditInput(input);
const absolutePath = resolveToCwd(path, ctx?.cwd || cwd);
return withFileMutationQueue(absolutePath, async () => {
// Do not reject from an abort event listener here: that would release the
// mutation queue while an in-flight filesystem operation may still finish.
// Checking signal.aborted after each await observes the same aborts while
// keeping the queue locked until the current operation has settled.
const throwIfAborted = (): void => {
if (signal?.aborted) throw new Error("Operation aborted");
};
throwIfAborted();
// Check if file exists.
try {
await ops.access(absolutePath);
} catch (error: unknown) {
throwIfAborted();
const errorMessage =
error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error);
throw new Error(`Could not edit file: ${path}. ${errorMessage}.`);
}
throwIfAborted();
// Read the file. The decoded text has no BOM: the model will not include an invisible BOM in oldText.
const file = decodeFile(await ops.readFile(absolutePath), path);
throwIfAborted();
const writeText = getTextWriter(ops, absolutePath, path, file);
const originalEnding = detectLineEnding(file.text);
const normalizedContent = normalizeToLF(file.text);
const { baseContent, newContent } = applyEditsToNormalizedContent(
normalizedContent,
edits,
path,
file.encoding,
);
throwIfAborted();
await writeText(restoreLineEndings(newContent, originalEnding));
throwIfAborted();
const diffResult = generateDiffString(baseContent, newContent);
const patch = generateUnifiedPatch(path, baseContent, newContent);
return {
content: [
{
type: "text",
text: `Successfully replaced ${edits.length} block(s) in ${path}.`,
},
],
details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
};
});
},
...editRenderers,
};
}
/**
* Writer of edited text in the encoding, and with the BOM, the file was read with.
* Throws when the operations cannot write that encoding.
*/
function getTextWriter(
ops: EditOperations,
absolutePath: string,
path: string,
file: DecodedFile,
): (text: string) => Promise<void> {
if (file.encoding === "utf-8") {
return (text) => ops.writeFile(absolutePath, (file.bom ? "\uFEFF" : "") + text);
}
const { encoding } = file;
const { writeFileBytes } = ops;
if (!writeFileBytes) {
const description = encoding === "bytes" ? "not valid UTF-8" : encoding.toUpperCase();
throw new Error(
`Could not edit file: ${path}. The file is ${description}, and the configured edit operations can only write UTF-8 text. The file was left unchanged.`,
);
}
// Call as a method: custom operations may be class instances.
return (text) => writeFileBytes.call(ops, absolutePath, encodeNonUtf8File(encoding, text));
}
export function createEditTool(cwd: string, options?: EditToolOptions): TypeBoxAgentTool<typeof editSchema> {
return wrapToolDefinition(createEditToolDefinition(cwd, options));
}