package tools import ( "context" "fmt" "os" "strings" ) // EditArgs is the edit_file input. type EditArgs struct { Path string `json:"path" jsonschema_description:"Path to the file, absolute or relative to the working root"` OldStr string `json:"old_str" jsonschema_description:"Exact text to replace, including indentation. Must appear exactly once unless replace_all is set"` NewStr string `json:"new_str" jsonschema_description:"Text to replace it with. Use an empty string to delete"` ReplaceAll bool `json:"replace_all,omitempty" jsonschema_description:"Replace every occurrence instead of requiring a unique match"` } // NewEdit builds the edit_file tool. track must be the same tracker read_file // writes to. func NewEdit(root string, track FileTracker) Tool { return New("edit_file", strings.TrimSpace(` Replace exact text in a file you have already read. old_str must match the file byte for byte, including indentation, and must be unique — include surrounding lines to disambiguate rather than hoping. Returns a unified diff of what changed, which you should check against what you meant to do.`), func(_ context.Context, in EditArgs) (string, error) { path, err := resolve(root, in.Path) if err != nil { return "", err } if in.OldStr == "" { return "", fmt.Errorf("old_str is required; use write_file to create a new file") } if in.OldStr == in.NewStr { return "", fmt.Errorf("old_str and new_str are identical, so this edit would do nothing") } // Existence is checked before the read-before-edit rule, so a // wrong path reports the wrong path rather than "you have not read // this file", which would send the model looking for the wrong bug. raw, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return "", fmt.Errorf("no such file: %s", in.Path) } return "", err } if err := checkRead(track, path, in.Path); err != nil { return "", err } content, crlf := normalizeEndings(string(raw)) oldText, newText := normalizeInput(in.OldStr), normalizeInput(in.NewStr) switch n := strings.Count(content, oldText); { case n == 0: return "", missReport(in.Path, content, oldText) case n > 1 && !in.ReplaceAll: return "", ambiguityReport(in.Path, content, oldText, n) } updated := content count := 1 if in.ReplaceAll { count = strings.Count(content, oldText) updated = strings.ReplaceAll(content, oldText, newText) } else { updated = strings.Replace(content, oldText, newText, 1) } info, err := os.Stat(path) perm := os.FileMode(0o644) if err == nil { perm = info.Mode().Perm() } if err := os.WriteFile(path, []byte(restoreEndings(updated, crlf)), perm); err != nil { return "", err } // The file just changed, so the recorded read is stale by // definition. Re-point it at what is now on disk. if info, err := os.Stat(path); err == nil { track.MarkRead(path, info.ModTime()) } return editSummary(in.Path, content, oldText, newText, count), nil }) } // checkRead enforces the read-before-edit rule, and forces a re-read when the // file has changed since. This single rule kills a large class of edits against // remembered rather than observed content. func checkRead(track FileTracker, path, display string) error { if track == nil { return nil } rec, ok := track.LastRead(path) if !ok { return fmt.Errorf("%s has not been read this session; read_file it first so you are editing what is actually there", display) } info, err := os.Stat(path) if err != nil { return nil // the read below will produce the real error } if info.ModTime().After(rec.Mod) { return fmt.Errorf("%s changed on disk since you read it; read_file it again before editing", display) } return nil } // missReport turns "not found" into something recoverable. A bare miss costs // several turns of blind retrying; a miss carrying the actual text costs one. func missReport(display, content, oldText string) error { oldLines := strings.Split(oldText, "\n") lines := strings.Split(content, "\n") // Whitespace is the usual culprit, so try again ignoring it. if start, end, ok := findNormalized(lines, oldLines); ok { var b strings.Builder fmt.Fprintf(&b, "old_str was not found in %s, but the same text is there with different whitespace at lines %d-%d. The file actually contains:\n\n", display, start+1, end) for i := start; i < end; i++ { fmt.Fprintf(&b, "%6d\t%s\n", i+1, capLine(lines[i])) } b.WriteString("\nCopy that text exactly, including its indentation.") return fmt.Errorf("%s", b.String()) } // Otherwise show the neighbourhood of the closest line we can find. best := closestLine(lines, oldLines[0]) if best < 0 { return fmt.Errorf("old_str was not found in %s, and nothing in the file resembles it. Read the file again — you may have the wrong path", display) } lo, hi := best-10, best+10 if lo < 0 { lo = 0 } if hi > len(lines) { hi = len(lines) } var b strings.Builder fmt.Fprintf(&b, "old_str was not found in %s. The closest thing in the file is around line %d:\n\n", display, best+1) for i := lo; i < hi; i++ { fmt.Fprintf(&b, "%6d\t%s\n", i+1, capLine(lines[i])) } b.WriteString("\nUse text copied from there, or read_file a wider range if the target is elsewhere.") return fmt.Errorf("%s", b.String()) } // ambiguityReport refuses a non-unique match and says where every occurrence // is, so the next attempt can be made unique. Silently taking the first match // would corrupt the file in a way nobody notices for a week. func ambiguityReport(display, content, oldText string, n int) error { var b strings.Builder fmt.Fprintf(&b, "old_str appears %d times in %s, at lines: %s.\n\n", n, display, joinInts(occurrenceLines(content, oldText))) b.WriteString("Add surrounding lines to old_str so it matches exactly one place, or set replace_all if every occurrence should change.") return fmt.Errorf("%s", b.String()) } // editSummary renders the applied change as a unified diff. func editSummary(display, content, oldText, newText string, count int) string { idx := strings.Index(content, oldText) startLine := strings.Count(content[:idx], "\n") lines := strings.Split(content, "\n") oldRegion := strings.Split(oldText, "\n") newRegion := strings.Split(newText, "\n") end := startLine + len(oldRegion) if end > len(lines) { end = len(lines) } before, after := contextAround(lines, startLine, end) out := unifiedDiff(display, before, oldRegion, newRegion, after, startLine+1) if count > 1 { return fmt.Sprintf("Replaced %d occurrences. First one:\n\n%s", count, out) } return out } // findNormalized looks for the target lines ignoring leading, trailing and // repeated internal whitespace. Returns a half-open [start, end) line range. func findNormalized(lines, target []string) (int, int, bool) { if len(target) == 0 || len(target) > len(lines) { return 0, 0, false } norm := make([]string, len(lines)) for i, l := range lines { norm[i] = collapse(l) } want := make([]string, len(target)) for i, l := range target { want[i] = collapse(l) } for i := 0; i+len(want) <= len(norm); i++ { match := true for j := range want { if norm[i+j] != want[j] { match = false break } } if match { return i, i + len(want), true } } return 0, 0, false } // closestLine scores every line against want and returns the best index, or -1 // when nothing is even loosely similar. func closestLine(lines []string, want string) int { want = collapse(want) if want == "" { return -1 } wantTokens := strings.Fields(want) best, bestScore := -1, 0.0 for i, l := range lines { score := similarity(strings.Fields(collapse(l)), wantTokens) if score > bestScore { best, bestScore = i, score } } if bestScore < 0.34 { return -1 } return best } // similarity is the fraction of want's tokens present in have. func similarity(have, want []string) float64 { if len(want) == 0 { return 0 } seen := make(map[string]int, len(have)) for _, t := range have { seen[t]++ } var hits int for _, t := range want { if seen[t] > 0 { seen[t]-- hits++ } } return float64(hits) / float64(len(want)) } func collapse(s string) string { return strings.Join(strings.Fields(s), " ") } // occurrenceLines returns the 1-indexed line of each occurrence of sub. func occurrenceLines(content, sub string) []int { var out []int for off := 0; ; { i := strings.Index(content[off:], sub) if i < 0 { return out } out = append(out, strings.Count(content[:off+i], "\n")+1) off += i + len(sub) } } func joinInts(ns []int) string { parts := make([]string, len(ns)) for i, n := range ns { parts[i] = fmt.Sprint(n) } return strings.Join(parts, ", ") } // normalizeEndings converts CRLF to LF and reports whether the file used CRLF, // so the original ending can be restored on write. func normalizeEndings(s string) (string, bool) { if strings.Contains(s, "\r\n") { return strings.ReplaceAll(s, "\r\n", "\n"), true } return s, false } func restoreEndings(s string, crlf bool) string { if !crlf { return s } return strings.ReplaceAll(s, "\n", "\r\n") } // normalizeInput strips CR from model-supplied text, which otherwise fails to // match a file we have already normalized. func normalizeInput(s string) string { return strings.ReplaceAll(s, "\r\n", "\n") }