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. |
| 27 | if info, err := os.Stat(path); err == nil && !info.Mode().IsRegular() && !info.IsDir() { |
| 28 | return fmt.Sprintf("(read error: %s is not a regular file)", path) |
| 29 | } |
| 30 | raw, err := os.ReadFile(path) |
| 31 | if err != nil { |
| 32 | return fmt.Sprintf("(read error: %v)", err) |
| 33 | } |
| 34 | content := string(raw) |
| 35 | n := strings.Count(content, oldString) |
| 36 | if n == 0 { |
| 37 | // A near-miss that differs only in whitespace (wrong indentation, tabs vs |
| 38 | // spaces) is the most common edit_file failure for an LLM. Name it so the |
| 39 | // model fixes the bytes instead of burning retries toward the failure nudge. |
| 40 | // Detection only; never auto-apply a fuzzy match, or the exact-match-once |
| 41 | // safety the caller relies on is gone. |
| 42 | if differsOnlyInWhitespace(content, oldString) { |
| 43 | 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) |
| 44 | } |
| 45 | return fmt.Sprintf("(not found: old_string does not appear in %s)", path) |
| 46 | } |
| 47 | if n > 1 { |
| 48 | return fmt.Sprintf("(ambiguous: old_string appears %d times - provide more context to make it unique)", n) |
| 49 | } |
| 50 | // strings.Count only counts non-overlapping occurrences, so a self- |
| 51 | // overlapping old_string ("==" in "a === b") passes n == 1 yet matches at |
| 52 | // two positions with different results. Catch the overlapping second match |
| 53 | // so the exactly-once guarantee holds. |
| 54 | if idx := strings.Index(content, oldString); strings.Contains(content[idx+1:], oldString) { |
| 55 | return "(ambiguous: old_string overlaps itself - provide more context to make it unique)" |
| 56 | } |
| 57 | updated := strings.Replace(content, oldString, newString, 1) |
| 58 | if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { |
| 59 | return fmt.Sprintf("(write error: %v)", err) |
| 60 | } |
| 61 | return fmt.Sprintf("edited %s: -%d +%d bytes", path, len(oldString), len(newString)) |
| 62 | } |
| 63 | |
| 64 | // differsOnlyInWhitespace reports whether oldString matches content at exactly |
| 65 | // one spot once every whitespace run is collapsed: i.e. the sole mismatch is |