





















@@ -1,4 +1,5 @@
11import path from "node:path";
2+import { fileURLToPath } from "node:url";
23import type { AgentToolResult, AgentToolUpdateCallback } from "@earendil-works/pi-agent-core";
34import { expandHomePrefix, resolveOsHomeDir } from "../infra/home-dir.js";
45import { getToolParamsRecord } from "./pi-tools.params.js";
@@ -9,6 +10,30 @@ type EditToolRecoveryOptions = {
910readFile: (absolutePath: string) => Promise<string>;
1011};
111213+type WriteToolRecoveryOptions = {
14+root: string;
15+readFile: (absolutePath: string) => Promise<string>;
16+statFile?: (absolutePath: string) => Promise<WriteToolFileStat | null>;
17+};
18+19+type WriteToolParams = {
20+pathParam?: string;
21+content?: string;
22+};
23+24+type WriteToolFileStat = {
25+type: "file" | "directory" | "other";
26+size: number;
27+mtimeMs?: number;
28+};
29+30+type WriteToolOriginalState = "different" | "same" | "unknown";
31+32+type WriteToolPrecheck = {
33+state: WriteToolOriginalState;
34+beforeStat?: WriteToolFileStat | null;
35+};
36+1237type EditToolParams = {
1338pathParam?: string;
1439edits: EditReplacement[];
@@ -21,10 +46,28 @@ type EditReplacement = {
21462247const EDIT_MISMATCH_MESSAGE = "Could not find the exact text in";
2348const EDIT_MISMATCH_HINT_LIMIT = 800;
49+const WRITE_PRECHECK_READ_LIMIT_BYTES = 1024 * 1024;
50+const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
245125-function resolveEditPath(root: string, pathParam: string): string {
52+function normalizeMutationPathLikeUpstreamWrite(pathParam: string): string {
53+let normalized = pathParam.replace(UNICODE_SPACES, " ");
54+if (normalized.startsWith("@")) {
55+normalized = normalized.slice(1);
56+}
2657const home = resolveOsHomeDir();
27-const expanded = home ? expandHomePrefix(pathParam, { home }) : pathParam;
58+const expanded = home ? expandHomePrefix(normalized, { home }) : normalized;
59+if (expanded.startsWith("file://")) {
60+try {
61+return fileURLToPath(expanded);
62+} catch {
63+return expanded;
64+}
65+}
66+return expanded;
67+}
68+69+function resolveFileMutationPath(root: string, pathParam: string): string {
70+const expanded = normalizeMutationPathLikeUpstreamWrite(pathParam);
2871return path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(root, expanded);
2972}
3073@@ -57,6 +100,14 @@ function readEditReplacements(record: Record<string, unknown> | undefined): Edit
57100});
58101}
59102103+function readWriteToolParams(params: unknown): WriteToolParams {
104+const record = getToolParamsRecord(params);
105+return {
106+pathParam: readStringParam(record, "path", "file_path", "filePath", "filepath", "file"),
107+content: typeof record?.content === "string" ? record.content : undefined,
108+};
109+}
110+60111function readEditToolParams(params: unknown): EditToolParams {
61112const record = getToolParamsRecord(params);
62113return {
@@ -128,6 +179,19 @@ function buildEditSuccessResult(pathParam: string, editCount: number): AgentTool
128179} as AgentToolResult<unknown>;
129180}
130181182+function buildWriteSuccessResult(pathParam: string, content: string): AgentToolResult<unknown> {
183+return {
184+isError: false,
185+content: [
186+{
187+type: "text",
188+text: `Successfully wrote ${content.length} bytes to ${pathParam}`,
189+},
190+],
191+details: undefined,
192+} as AgentToolResult<unknown>;
193+}
194+131195function shouldAddMismatchHint(error: unknown) {
132196return error instanceof Error && error.message.includes(EDIT_MISMATCH_MESSAGE);
133197}
@@ -142,6 +206,88 @@ function appendMismatchHint(error: Error, currentContent: string): Error {
142206return enhanced;
143207}
144208209+function isWriteRecoveryCandidate(error: unknown, signal: AbortSignal | undefined): boolean {
210+if (signal?.aborted) {
211+return true;
212+}
213+if (!(error instanceof Error)) {
214+return false;
215+}
216+const message = error.message.toLowerCase();
217+return (
218+error.name === "AbortError" ||
219+error.name === "TimeoutError" ||
220+message.includes("timed out") ||
221+message.includes("timeout")
222+);
223+}
224+225+function isMissingFileError(error: unknown): boolean {
226+if (!error || typeof error !== "object") {
227+return false;
228+}
229+if ("code" in error && (error as { code?: unknown }).code === "ENOENT") {
230+return true;
231+}
232+return error instanceof Error && error.message.includes("No such file or directory");
233+}
234+235+async function readOriginalWriteState(
236+absolutePath: string,
237+content: string,
238+options: WriteToolRecoveryOptions,
239+): Promise<WriteToolPrecheck> {
240+if (!options.statFile) {
241+return { state: "unknown" };
242+}
243+const contentBytes = Buffer.byteLength(content, "utf8");
244+let stat: WriteToolFileStat | null;
245+try {
246+stat = await options.statFile(absolutePath);
247+} catch (err) {
248+return { state: isMissingFileError(err) ? "different" : "unknown" };
249+}
250+if (!stat) {
251+return { state: "different", beforeStat: stat };
252+}
253+if (stat.type !== "file") {
254+return { state: "unknown", beforeStat: stat };
255+}
256+if (stat.size !== contentBytes) {
257+return { state: "different", beforeStat: stat };
258+}
259+if (stat.size > WRITE_PRECHECK_READ_LIMIT_BYTES) {
260+return { state: "unknown", beforeStat: stat };
261+}
262+263+try {
264+const originalContent = await options.readFile(absolutePath);
265+return { state: originalContent === content ? "same" : "different", beforeStat: stat };
266+} catch {
267+return { state: "unknown", beforeStat: stat };
268+}
269+}
270+271+async function didWriteMetadataChange(
272+absolutePath: string,
273+beforeStat: WriteToolFileStat | null | undefined,
274+options: WriteToolRecoveryOptions,
275+): Promise<boolean> {
276+if (!beforeStat || !options.statFile) {
277+return false;
278+}
279+let afterStat: WriteToolFileStat | null;
280+try {
281+afterStat = await options.statFile(absolutePath);
282+} catch {
283+return false;
284+}
285+if (!afterStat || afterStat.type !== "file") {
286+return false;
287+}
288+return afterStat.size !== beforeStat.size || afterStat.mtimeMs !== beforeStat.mtimeMs;
289+}
290+145291/**
146292 * Recover from two edit-tool failure classes without changing edit semantics:
147293 * - exact-match mismatch errors become actionable by including current file contents
@@ -161,7 +307,9 @@ export function wrapEditToolWithRecovery(
161307) => {
162308const { pathParam, edits } = readEditToolParams(params);
163309const absolutePath =
164-typeof pathParam === "string" ? resolveEditPath(options.root, pathParam) : undefined;
310+typeof pathParam === "string"
311+ ? resolveFileMutationPath(options.root, pathParam)
312+ : undefined;
165313let originalContent: string | undefined;
166314167315if (absolutePath && edits.length > 0) {
@@ -211,3 +359,59 @@ export function wrapEditToolWithRecovery(
211359},
212360};
213361}
362+363+/**
364+ * Recover write calls that complete the disk write but abort before returning.
365+ * Readback is the source of truth; argument-derived paths never prove success.
366+ */
367+export function wrapWriteToolWithRecovery(
368+base: AnyAgentTool,
369+options: WriteToolRecoveryOptions,
370+): AnyAgentTool {
371+return {
372+ ...base,
373+execute: async (
374+toolCallId: string,
375+params: unknown,
376+signal: AbortSignal | undefined,
377+onUpdate?: AgentToolUpdateCallback<unknown>,
378+) => {
379+const { pathParam, content } = readWriteToolParams(params);
380+const absolutePath =
381+typeof pathParam === "string" && typeof content === "string"
382+ ? resolveFileMutationPath(options.root, pathParam)
383+ : undefined;
384+const precheck: WriteToolPrecheck =
385+absolutePath && typeof content === "string"
386+ ? await readOriginalWriteState(absolutePath, content, options)
387+ : { state: "unknown" };
388+389+try {
390+return await base.execute(toolCallId, params, signal, onUpdate);
391+} catch (err) {
392+if (
393+!isWriteRecoveryCandidate(err, signal) ||
394+typeof absolutePath !== "string" ||
395+typeof pathParam !== "string" ||
396+typeof content !== "string"
397+) {
398+throw err;
399+}
400+let currentContent: string | undefined;
401+try {
402+currentContent = await options.readFile(absolutePath);
403+} catch {
404+// Fall through to the original abort if readback fails.
405+}
406+const changed =
407+precheck.state === "different" ||
408+(precheck.state === "unknown" &&
409+(await didWriteMetadataChange(absolutePath, precheck.beforeStat, options)));
410+if (currentContent === content && changed) {
411+return buildWriteSuccessResult(pathParam, content);
412+}
413+throw err;
414+}
415+},
416+};
417+}
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。