1- import fs from "node:fs/promises" ;
21import os from "node:os" ;
32import path from "node:path" ;
43import type { AgentToolResult , AgentToolUpdateCallback } from "@mariozechner/pi-agent-core" ;
54import type { AnyAgentTool } from "./pi-tools.types.js" ;
65
7- /** Resolve path for host edit: expand ~ and resolve relative paths against root. */
8- function resolveHostEditPath ( root : string , pathParam : string ) : string {
6+ type EditToolRecoveryOptions = {
7+ root : string ;
8+ readFile : ( absolutePath : string ) => Promise < string > ;
9+ } ;
10+
11+ type EditToolParams = {
12+ pathParam ?: string ;
13+ oldText ?: string ;
14+ newText ?: string ;
15+ } ;
16+
17+ const EDIT_MISMATCH_MESSAGE = "Could not find the exact text in" ;
18+ const EDIT_MISMATCH_HINT_LIMIT = 800 ;
19+
20+ /** Resolve path for edit recovery: expand ~ and resolve relative paths against root. */
21+ function resolveEditPath ( root : string , pathParam : string ) : string {
922 const expanded =
1023 pathParam . startsWith ( "~/" ) || pathParam === "~"
1124 ? pathParam . replace ( / ^ ~ / , os . homedir ( ) )
1225 : pathParam ;
1326 return path . isAbsolute ( expanded ) ? path . resolve ( expanded ) : path . resolve ( root , expanded ) ;
1427}
1528
29+ function readStringParam ( record : Record < string , unknown > | undefined , ...keys : string [ ] ) {
30+ for ( const key of keys ) {
31+ const value = record ?. [ key ] ;
32+ if ( typeof value === "string" ) {
33+ return value ;
34+ }
35+ }
36+ return undefined ;
37+ }
38+
39+ function readEditToolParams ( params : unknown ) : EditToolParams {
40+ const record =
41+ params && typeof params === "object" ? ( params as Record < string , unknown > ) : undefined ;
42+ return {
43+ pathParam : readStringParam ( record , "path" , "file_path" , "file" ) ,
44+ oldText : readStringParam ( record , "oldText" , "old_string" , "old_text" , "oldString" ) ,
45+ newText : readStringParam ( record , "newText" , "new_string" , "new_text" , "newString" ) ,
46+ } ;
47+ }
48+
49+ function normalizeToLF ( value : string ) : string {
50+ return value . replace ( / \r \n ? / g, "\n" ) ;
51+ }
52+
53+ function removeExactOccurrences ( content : string , needle : string ) : string {
54+ return needle . length > 0 ? content . split ( needle ) . join ( "" ) : content ;
55+ }
56+
57+ function didEditLikelyApply ( params : {
58+ originalContent ?: string ;
59+ currentContent : string ;
60+ oldText ?: string ;
61+ newText : string ;
62+ } ) {
63+ const normalizedCurrent = normalizeToLF ( params . currentContent ) ;
64+ const normalizedNew = normalizeToLF ( params . newText ) ;
65+ const normalizedOld =
66+ typeof params . oldText === "string" && params . oldText . length > 0
67+ ? normalizeToLF ( params . oldText )
68+ : undefined ;
69+ const normalizedOriginal =
70+ typeof params . originalContent === "string" ? normalizeToLF ( params . originalContent ) : undefined ;
71+
72+ if ( normalizedOriginal !== undefined && normalizedOriginal === normalizedCurrent ) {
73+ return false ;
74+ }
75+
76+ if ( normalizedNew . length > 0 && ! normalizedCurrent . includes ( normalizedNew ) ) {
77+ return false ;
78+ }
79+
80+ const withoutInsertedNewText =
81+ normalizedNew . length > 0
82+ ? removeExactOccurrences ( normalizedCurrent , normalizedNew )
83+ : normalizedCurrent ;
84+ if ( normalizedOld && withoutInsertedNewText . includes ( normalizedOld ) ) {
85+ return false ;
86+ }
87+
88+ return true ;
89+ }
90+
91+ function buildEditSuccessResult ( pathParam : string ) : AgentToolResult < unknown > {
92+ return {
93+ isError : false ,
94+ content : [
95+ {
96+ type : "text" ,
97+ text : `Successfully replaced text in ${ pathParam } .` ,
98+ } ,
99+ ] ,
100+ details : { diff : "" , firstChangedLine : undefined } ,
101+ } as AgentToolResult < unknown > ;
102+ }
103+
104+ function shouldAddMismatchHint ( error : unknown ) {
105+ return error instanceof Error && error . message . includes ( EDIT_MISMATCH_MESSAGE ) ;
106+ }
107+
108+ function appendMismatchHint ( error : Error , currentContent : string ) : Error {
109+ const snippet =
110+ currentContent . length <= EDIT_MISMATCH_HINT_LIMIT
111+ ? currentContent
112+ : `${ currentContent . slice ( 0 , EDIT_MISMATCH_HINT_LIMIT ) } \n... (truncated)` ;
113+ const enhanced = new Error ( `${ error . message } \nCurrent file contents:\n${ snippet } ` ) ;
114+ enhanced . stack = error . stack ;
115+ return enhanced ;
116+ }
117+
16118/**
17- * When the upstream edit tool throws after having already written (e.g. generateDiffString fails),
18- * the file may be correctly updated but the tool reports failure. This wrapper catches errors and
19- * if the target file on disk contains the intended newText, returns success so we don't surface
20- * a false "edit failed" to the user (fixes #32333, same pattern as #30773 for write).
119+ * Recover from two edit-tool failure classes without changing edit semantics:
120+ * - exact-match mismatch errors become actionable by including current file contents
121+ * - post-write throws are converted back to success only if the file actually changed
21122 */
22- export function wrapHostEditToolWithPostWriteRecovery (
123+ export function wrapEditToolWithRecovery (
23124 base : AnyAgentTool ,
24- root : string ,
125+ options : EditToolRecoveryOptions ,
25126) : AnyAgentTool {
26127 return {
27128 ...base ,
@@ -31,50 +132,54 @@ export function wrapHostEditToolWithPostWriteRecovery(
31132 signal : AbortSignal | undefined ,
32133 onUpdate ?: AgentToolUpdateCallback < unknown > ,
33134 ) => {
135+ const { pathParam, oldText, newText } = readEditToolParams ( params ) ;
136+ const absolutePath =
137+ typeof pathParam === "string" ? resolveEditPath ( options . root , pathParam ) : undefined ;
138+ let originalContent : string | undefined ;
139+
140+ if ( absolutePath && newText !== undefined ) {
141+ try {
142+ originalContent = await options . readFile ( absolutePath ) ;
143+ } catch {
144+ // Best-effort snapshot only; recovery should still proceed without it.
145+ }
146+ }
147+
34148 try {
35149 return await base . execute ( toolCallId , params , signal , onUpdate ) ;
36150 } catch ( err ) {
37- const record =
38- params && typeof params === "object" ? ( params as Record < string , unknown > ) : undefined ;
39- const pathParam = record && typeof record . path === "string" ? record . path : undefined ;
40- const newText =
41- record && typeof record . newText === "string"
42- ? record . newText
43- : record && typeof record . new_string === "string"
44- ? record . new_string
45- : undefined ;
46- const oldText =
47- record && typeof record . oldText === "string"
48- ? record . oldText
49- : record && typeof record . old_string === "string"
50- ? record . old_string
51- : undefined ;
52- if ( ! pathParam || ! newText ) {
151+ if ( ! absolutePath ) {
53152 throw err ;
54153 }
154+
155+ let currentContent : string | undefined ;
55156 try {
56- const absolutePath = resolveHostEditPath ( root , pathParam ) ;
57- const content = await fs . readFile ( absolutePath , "utf-8" ) ;
58- // Only recover when the replacement likely occurred: newText is present and oldText
59- // is no longer present. This avoids false success when upstream threw before writing
60- // (e.g. oldText not found) but the file already contained newText (review feedback).
61- const hasNew = content . includes ( newText ) ;
62- const stillHasOld =
63- oldText !== undefined && oldText . length > 0 && content . includes ( oldText ) ;
64- if ( hasNew && ! stillHasOld ) {
65- return {
66- content : [
67- {
68- type : "text" ,
69- text : `Successfully replaced text in ${ pathParam } .` ,
70- } ,
71- ] ,
72- details : { diff : "" , firstChangedLine : undefined } ,
73- } as AgentToolResult < unknown > ;
74- }
157+ currentContent = await options . readFile ( absolutePath ) ;
75158 } catch {
76- // File read failed or path invalid; rethrow original error.
159+ // Fall through to the original error if readback fails.
160+ }
161+
162+ if ( typeof currentContent === "string" && newText !== undefined ) {
163+ if (
164+ didEditLikelyApply ( {
165+ originalContent,
166+ currentContent,
167+ oldText,
168+ newText,
169+ } )
170+ ) {
171+ return buildEditSuccessResult ( pathParam ?? absolutePath ) ;
172+ }
77173 }
174+
175+ if (
176+ typeof currentContent === "string" &&
177+ err instanceof Error &&
178+ shouldAddMismatchHint ( err )
179+ ) {
180+ throw appendMismatchHint ( err , currentContent ) ;
181+ }
182+
78183 throw err ;
79184 }
80185 } ,
0 commit comments