返回源码地图

packages/coding-agent/src/core/tools/edit.ts

v1.0.0 · a13d35a742c6 · 08:批量替换、行尾和队列

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1import type { AgentTool } from "@earendil-works/pi-agent-core";
2import { constants } from "fs";
3import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises";
4import { type Static, Type } from "typebox";
5import { splitBom } from "../../utils/text.ts";
6import type { ExtensionContext, ToolDefinition } from "../extensions/types.ts";
7import {
8 applyEditsToNormalizedContent,
9 detectLineEnding,
10 type Edit,
11 generateDiffString,
12 generateUnifiedPatch,
13 normalizeToLF,
14 restoreLineEndings,
15} from "./edit-diff.ts";
16import { withFileMutationQueue } from "./file-mutation-queue.ts";
17import { resolveToCwd } from "./path-utils.ts";
18import { type EditRenderState, editRenderers } from "./renderers/edit.ts";
19import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
20
21const 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);
31
32const 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);
42
43export 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;
52
53export type EditToolInput = Static<typeof editSchema>;
54type LegacyEditToolInput = EditToolInput & {
55 oldText?: unknown;
56 newText?: unknown;
57};
58
59type SingleEditInput = { oldText: string; newText: string };
60
61function isSingleEditInput(value: unknown): value is SingleEditInput {
62 if (!value || typeof value !== "object" || Array.isArray(value)) {
63 return false;
64 }
65
66 const edit = value as Record<string, unknown>;
67 return typeof edit.oldText === "string" && typeof edit.newText === "string";
68}
69
70export 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}
78
79/**
80 * Pluggable operations for the edit tool.
81 * Override these to delegate file editing to remote systems (for example SSH).
82 */
83export 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}
91
92const 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};
97
98export interface EditToolOptions {
99 /** Custom operations for file editing. Default: local filesystem */
100 operations?: EditOperations;
101}
102
103function prepareEditArguments(input: unknown): EditToolInput {
104 if (!input || typeof input !== "object") {
105 return input as EditToolInput;
106 }
107
108 const args = input as Record<string, unknown>;
109
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 }
124
125 const legacy = args as LegacyEditToolInput;
126 if (typeof legacy.oldText !== "string" || typeof legacy.newText !== "string") {
127 return args as EditToolInput;
128 }
129
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}
135
136function 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}
142
143export 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);
162
163 return withFileMutationQueue(absolutePath, async () => {
164 // Do not reject from an abort event listener here: that would release the
165 // mutation queue while an in-flight filesystem operation may still finish.
166 // Checking signal.aborted after each await observes the same aborts while
167 // keeping the queue locked until the current operation has settled.
168 const throwIfAborted = (): void => {
169 if (signal?.aborted) throw new Error("Operation aborted");
170 };
171
172 throwIfAborted();
173
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();
184
185 // Read the file.
186 const buffer = await ops.readFile(absolutePath);
187 const rawContent = buffer.toString("utf-8");
188 throwIfAborted();
189
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();
196
197 const finalContent = bom + restoreLineEndings(newContent, originalEnding);
198 await ops.writeFile(absolutePath, finalContent);
199 throwIfAborted();
200
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}
217
218export function createEditTool(cwd: string, options?: EditToolOptions): AgentTool<typeof editSchema> {
219 return wrapToolDefinition(createEditToolDefinition(cwd, options));
220}