@@ -166,40 +166,109 @@ type EditFileArgs struct {
166166 Edits []Edit `json:"edits" jsonschema:"Array of edit operations"`
167167}
168168
169- // UnmarshalJSON handles LLM-generated arguments where "edits" may be
170- // a JSON string instead of a JSON array (double-serialized).
171- func (a * EditFileArgs ) UnmarshalJSON (data []byte ) error {
169+ // ParseEditFileArgs parses LLM-generated edit_file arguments, handling two
170+ // common failure modes:
171+ // 1. The outer JSON itself is malformed — typically extra closing braces/brackets
172+ // or stray escape sequences caused by the model losing track of nesting depth
173+ // when the text payload contains structural characters (e.g. YAML, Dockerfiles).
174+ // 2. The "edits" field is double-serialized (a JSON string instead of an array).
175+ func ParseEditFileArgs (data []byte ) (EditFileArgs , error ) {
172176 var raw struct {
173177 Path string `json:"path"`
174178 Edits json.RawMessage `json:"edits"`
175179 }
180+
176181 if err := json .Unmarshal (data , & raw ); err != nil {
177- return fmt .Errorf ("failed to parse edit_file arguments: %w" , err )
182+ repaired , ok := tryRepairEditFileJSON (data )
183+ if ! ok {
184+ return EditFileArgs {}, fmt .Errorf ("failed to parse edit_file arguments: %w" , err )
185+ }
186+ if err := json .Unmarshal (repaired , & raw ); err != nil {
187+ return EditFileArgs {}, fmt .Errorf ("failed to parse edit_file arguments after repair: %w" , err )
188+ }
189+ slog .Debug ("Repaired malformed edit_file JSON arguments" )
178190 }
179191
180- a . Path = raw .Path
192+ args := EditFileArgs { Path : raw .Path }
181193
182194 // When edits is missing or null (e.g. during argument streaming in
183195 // the TUI, or partial tool calls), accept the partial result.
184196 if len (raw .Edits ) == 0 || string (raw .Edits ) == "null" {
185- return nil
197+ return args , nil
186198 }
187199
188200 // Try parsing edits as an array first (normal case).
189- if err := json .Unmarshal (raw .Edits , & a .Edits ); err == nil {
190- return nil
201+ if err := json .Unmarshal (raw .Edits , & args .Edits ); err == nil {
202+ return args , nil
191203 }
192204
193205 // Try unwrapping a double-serialized JSON string.
194206 var editsStr string
195207 if err := json .Unmarshal (raw .Edits , & editsStr ); err != nil {
196- return fmt .Errorf ("edits field is neither an array nor a JSON string: %w" , err )
197- }
198- if err := json .Unmarshal ([]byte (editsStr ), & a .Edits ); err != nil {
199- return fmt .Errorf ("failed to parse double-serialized edits string: %w" , err )
208+ return EditFileArgs {}, fmt .Errorf ("edits field is neither an array nor a JSON string: %w" , err )
209+ }
210+ if err := json .Unmarshal ([]byte (editsStr ), & args .Edits ); err != nil {
211+ return EditFileArgs {}, fmt .Errorf ("failed to parse double-serialized edits string: %w" , err )
212+ }
213+
214+ return args , nil
215+ }
216+
217+ // tryRepairEditFileJSON attempts to fix common LLM JSON malformations by
218+ // iteratively removing the offending character(s) at each json.SyntaxError
219+ // offset. Observed failure modes from production sessions:
220+ //
221+ // - Extra '}' — model loses brace count (e.g. "}}]}" instead of "}]}")
222+ // - Extra ']' — model adds a spurious array wrapper
223+ // - Stray '\' — model emits an escape sequence outside of a string value
224+ // (e.g. literal \n between tokens, or \" where " is expected)
225+ func tryRepairEditFileJSON (data []byte ) ([]byte , bool ) {
226+ current := append ([]byte (nil ), data ... ) // defensive copy
227+ for range 3 {
228+ var synErr * json.SyntaxError
229+ if err := json .Unmarshal (current , & json.RawMessage {}); err == nil {
230+ return current , true
231+ } else if ! errors .As (err , & synErr ) {
232+ return nil , false
233+ }
234+
235+ // json.SyntaxError.Offset is 1-based.
236+ offset := int (synErr .Offset ) - 1
237+ if offset < 0 || offset >= len (current ) {
238+ return nil , false
239+ }
240+
241+ ch := current [offset ]
242+ removeCount := 1
243+
244+ switch ch {
245+ case '}' , ']' :
246+ // Extra closing delimiter — just remove it.
247+ case '\\' :
248+ // Stray escape sequence outside a string value. For \n, \t, \r
249+ // both characters are garbage so remove them. For \" the quote
250+ // is a valid structural character (string delimiter), so only
251+ // strip the backslash.
252+ if offset + 1 < len (current ) {
253+ switch current [offset + 1 ] {
254+ case 'n' , 't' , 'r' :
255+ removeCount = 2
256+ }
257+ }
258+ default :
259+ return nil , false
260+ }
261+
262+ repaired := make ([]byte , 0 , len (current )- removeCount )
263+ repaired = append (repaired , current [:offset ]... )
264+ repaired = append (repaired , current [offset + removeCount :]... )
265+ current = repaired
200266 }
201267
202- return nil
268+ if json .Valid (current ) {
269+ return current , true
270+ }
271+ return nil , false
203272}
204273
205274func (t * FilesystemTool ) Tools (context.Context ) ([]tools.Tool , error ) {
@@ -245,7 +314,7 @@ func (t *FilesystemTool) Tools(context.Context) ([]tools.Tool, error) {
245314 Description : "Make line-based edits to a text file. Each edit replaces exact line sequences with new content." ,
246315 Parameters : tools .MustSchemaFor [EditFileArgs ](),
247316 OutputSchema : tools .MustSchemaFor [string ](),
248- Handler : tools . NewHandler ( t . handleEditFile ),
317+ Handler : t . editFileHandler ( ),
249318 Annotations : tools.ToolAnnotations {
250319 Title : "Edit" ,
251320 },
@@ -466,6 +535,24 @@ func countTreeNodes(node *fsx.TreeNode) (files, dirs int) {
466535 return files , dirs
467536}
468537
538+ // editFileHandler returns a ToolHandler that parses edit_file arguments with
539+ // repair logic for malformed JSON, then delegates to handleEditFile.
540+ // This bypasses tools.NewHandler because Go's json.Unmarshal scanner rejects
541+ // structurally invalid JSON before calling any custom UnmarshalJSON method.
542+ func (t * FilesystemTool ) editFileHandler () tools.ToolHandler {
543+ return func (ctx context.Context , toolCall tools.ToolCall ) (* tools.ToolCallResult , error ) {
544+ data := toolCall .Function .Arguments
545+ if data == "" {
546+ data = "{}"
547+ }
548+ args , err := ParseEditFileArgs ([]byte (data ))
549+ if err != nil {
550+ return nil , err
551+ }
552+ return t .handleEditFile (ctx , args )
553+ }
554+ }
555+
469556func (t * FilesystemTool ) handleEditFile (ctx context.Context , args EditFileArgs ) (* tools.ToolCallResult , error ) {
470557 resolvedPath := t .resolvePath (args .Path )
471558
0 commit comments