EditFile replaces old_string with new_string in path. old_string must match EXACTLY ONCE; otherwise the file is untouched and an error string is returned so the model sees the failure and reacts, same convention as bash/WriteFile. Empty old_string is rejected (no anchor, every position matches); ol
(path, oldString, newString string)
| 13 | // Empty old_string is rejected (no anchor, every position matches); |
| 14 | // old_string == new_string is rejected as a no-op turn-waster. |
| 15 | func EditFile(path, oldString, newString string) string { |
| 16 | if path == "" { |
| 17 | return "(empty path)" |
| 18 | } |
| 19 | if oldString == "" { |
| 20 | return "(empty old_string)" |
| 21 | } |
| 22 | if oldString == newString { |
| 23 | return "(no change: old_string equals new_string)" |
| 24 | } |
| 25 | // Same guard as ReadFile: open(2) on a FIFO blocks forever and Ctrl+C |
| 26 | // can't unblock it, leaking the tool goroutine. Stat never blocks. The same |
| 27 | // Stat also size-gates the whole-file read below: os.ReadFile on a multi-GB |
| 28 | // log would OOM-kill the TUI, the hazard bash's capture cap already stops. |
| 29 | if info, err := os.Stat(path); err == nil { |
| 30 | if !info.Mode().IsRegular() && !info.IsDir() { |
| 31 | return fmt.Sprintf("(read error: %s is not a regular file)", path) |
| 32 | } |
| 33 | if info.Mode().IsRegular() && info.Size() > maxFileBytes { |
| 34 | return fmt.Sprintf("(too large: %s is %d bytes, over edit_file's %dMB cap - edit a file this size with bash instead: sed -i or a python one-liner)", path, info.Size(), maxFileBytes>>20) |
| 35 | } |
| 36 | } |
| 37 | raw, err := os.ReadFile(path) |
| 38 | if err != nil { |
| 39 | return fmt.Sprintf("(read error: %v)", err) |
| 40 | } |
| 41 | content := string(raw) |
| 42 | n := strings.Count(content, oldString) |
| 43 | if n == 0 { |
| 44 | // A near-miss that differs only in whitespace (wrong indentation, tabs vs |
| 45 | // spaces) is the most common edit_file failure for an LLM; each one costs |
| 46 | // a re-read round plus a failure-streak entry. When the near-miss is a |
| 47 | // run of WHOLE lines matching exactly once, apply it: the uniqueness gate |
| 48 | // preserves the exactly-once guarantee, and the spliced bytes are the |
| 49 | // model's own new_string, exactly what an exact match would have written. |
| 50 | // Anything looser (mid-line fragments, 0 or 2+ fuzzy matches) still fails |
| 51 | // with a message that names the recovery. |
| 52 | if out, ok := fuzzyWhitespaceEdit(path, content, oldString, newString); ok { |
| 53 | return out |
| 54 | } |
| 55 | if differsOnlyInWhitespace(content, oldString) { |
| 56 | return fmt.Sprintf("(not found: no exact match in %s - a block there differs only in whitespace (indentation/tabs/newlines); copy the exact bytes, including indentation)", path) |
| 57 | } |
| 58 | return fmt.Sprintf("(not found: old_string does not appear in %s - read the exact bytes back with read_file before retrying, don't retype them from memory)", path) |
| 59 | } |
| 60 | if n > 1 { |
| 61 | return fmt.Sprintf("(ambiguous: old_string appears %d times (lines %s) - provide more context to make it unique)", n, matchLines(content, oldString)) |
| 62 | } |
| 63 | // strings.Count only counts non-overlapping occurrences, so a self- |
| 64 | // overlapping old_string ("==" in "a === b") passes n == 1 yet matches at |
| 65 | // two positions with different results. Catch the overlapping second match |