edit_file is the centerpiece and gets the failure modes spelled out: - Not read this session, or changed on disk since, is a refusal. This is the rule that kills edits made against remembered rather than observed content. - Zero matches never returns a bare "not found". It retries with whitespace collapsed and, when that hits, names the line range and prints what the file actually contains; otherwise it scores every line against the target and shows twenty lines around the best candidate. A miss carrying context is fixed in one turn. - More than one match is refused with the line number of every occurrence. Silently taking the first is how a file gets quietly corrupted. - Success returns a unified diff from a line-level LCS, not "OK", so the model can check the change against what it intended. - Line endings and file mode are preserved, and the read record is refreshed so consecutive edits are not blocked by the mtime the previous edit caused. Existence is checked before the read-before-edit rule: a wrong path should say "no such file", not "you have not read this file". bash runs in its own process group and kills the group on timeout — killing only the leader leaves orphans holding the pipes and the loop hangs. Output keeps the first and last hundred lines; head-only truncation would cut off the end of a test run, which is where the failure is. A non-zero exit comes back as an error with the output attached. write_file stays separate from edit_file so a whole-file rewrite succeeding cannot flatter the edit success rate, and it applies the same read-before- write rule when replacing an existing file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing 9 changed files with 1126 additions and 9 deletions.
| @@ -76,6 +76,9 @@ func run() error { | ||
| 76 | 76 | tools.NewRead(root, state), |
| 77 | 77 | tools.NewGlob(root), |
| 78 | 78 | tools.NewGrep(root), |
| 79 | + tools.NewEdit(root, state), | |
| 80 | + tools.NewWrite(root, state), | |
| 81 | + tools.NewBash(root), | |
| 79 | 82 | ) |
| 80 | 83 | |
| 81 | 84 | render := &ui.Renderer{W: os.Stdout, Verbose: *verbose} |
| @@ -9,15 +9,12 @@ import ( | ||
| 9 | 9 | "time" |
| 10 | 10 | |
| 11 | 11 | "harness/internal/llm" |
| 12 | + "harness/internal/tools" | |
| 12 | 13 | ) |
| 13 | 14 | |
| 14 | - // ReadRecord is when a file was last read and what its mtime was at the time. | |
| 15 | - // Phase 2's edit_file uses this to refuse edits to files the model has not read | |
| 16 | - // and to force a re-read when the file has changed underneath it. | |
| 17 | - type ReadRecord struct { | |
| 18 | - At time.Time | |
| 19 | - Mod time.Time | |
| 20 | - } | |
| 15 | + // ReadRecord lives in tools so the edit tool can query the tracker without | |
| 16 | + // importing this package. | |
| 17 | + type ReadRecord = tools.ReadRecord | |
| 21 | 18 | |
| 22 | 19 | // State is the conversation history plus the per-session file-read tracking. |
| 23 | 20 | type State struct { |
| @@ -0,0 +1,103 @@ | ||
| 1 | + package tools | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "bytes" | |
| 5 | + "context" | |
| 6 | + "fmt" | |
| 7 | + "os/exec" | |
| 8 | + "strings" | |
| 9 | + "syscall" | |
| 10 | + "time" | |
| 11 | + ) | |
| 12 | + | |
| 13 | + const ( | |
| 14 | + defaultBashTimeout = 60 * time.Second | |
| 15 | + maxBashTimeout = 10 * time.Minute | |
| 16 | + // Output is capped head and tail. Head-only truncation would cut off the | |
| 17 | + // end of a test run, which is exactly where the failure is. | |
| 18 | + bashHeadLines = 100 | |
| 19 | + bashTailLines = 100 | |
| 20 | + ) | |
| 21 | + | |
| 22 | + // BashArgs is the bash input. | |
| 23 | + type BashArgs struct { | |
| 24 | + Command string `json:"command" jsonschema:"description=Shell command to run, from the working root"` | |
| 25 | + TimeoutMS int `json:"timeout_ms,omitempty" jsonschema:"description=Timeout in milliseconds; defaults to 60000, maximum 600000"` | |
| 26 | + } | |
| 27 | + | |
| 28 | + // NewBash builds the bash tool. | |
| 29 | + func NewBash(root string) Tool { | |
| 30 | + return New("bash", strings.TrimSpace(` | |
| 31 | + Run a shell command from the working root and return its exit code, stdout and | |
| 32 | + stderr. Use it to run the project's tests and build. Output is capped, keeping | |
| 33 | + the first and last hundred lines. Prefer glob and grep over find and cat: they | |
| 34 | + are faster and their output is bounded.`), | |
| 35 | + func(ctx context.Context, in BashArgs) (string, error) { | |
| 36 | + if strings.TrimSpace(in.Command) == "" { | |
| 37 | + return "", fmt.Errorf("command is required") | |
| 38 | + } | |
| 39 | + timeout := defaultBashTimeout | |
| 40 | + if in.TimeoutMS > 0 { | |
| 41 | + timeout = time.Duration(in.TimeoutMS) * time.Millisecond | |
| 42 | + } | |
| 43 | + if timeout > maxBashTimeout { | |
| 44 | + timeout = maxBashTimeout | |
| 45 | + } | |
| 46 | + | |
| 47 | + ctx, cancel := context.WithTimeout(ctx, timeout) | |
| 48 | + defer cancel() | |
| 49 | + | |
| 50 | + cmd := exec.CommandContext(ctx, "bash", "-c", in.Command) | |
| 51 | + cmd.Dir = resolveBase(root, "") | |
| 52 | + // A new process group, so a command that spawns children can be | |
| 53 | + // killed whole. Killing only the leader leaves orphans holding the | |
| 54 | + // pipes open and the loop hangs waiting on them. | |
| 55 | + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |
| 56 | + cmd.Cancel = func() error { | |
| 57 | + if cmd.Process == nil { | |
| 58 | + return nil | |
| 59 | + } | |
| 60 | + return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | |
| 61 | + } | |
| 62 | + cmd.WaitDelay = 2 * time.Second | |
| 63 | + | |
| 64 | + var stdout, stderr bytes.Buffer | |
| 65 | + cmd.Stdout = &stdout | |
| 66 | + cmd.Stderr = &stderr | |
| 67 | + | |
| 68 | + start := time.Now() | |
| 69 | + runErr := cmd.Run() | |
| 70 | + elapsed := time.Since(start) | |
| 71 | + | |
| 72 | + exitCode := cmd.ProcessState.ExitCode() | |
| 73 | + timedOut := ctx.Err() == context.DeadlineExceeded | |
| 74 | + | |
| 75 | + var b strings.Builder | |
| 76 | + switch { | |
| 77 | + case timedOut: | |
| 78 | + fmt.Fprintf(&b, "timed out after %s (killed)\n", timeout) | |
| 79 | + case runErr != nil && exitCode < 0: | |
| 80 | + fmt.Fprintf(&b, "failed to run: %v\n", runErr) | |
| 81 | + default: | |
| 82 | + fmt.Fprintf(&b, "exit code: %d (%s)\n", exitCode, elapsed.Round(time.Millisecond)) | |
| 83 | + } | |
| 84 | + writeStream(&b, "stdout", stdout.String()) | |
| 85 | + writeStream(&b, "stderr", stderr.String()) | |
| 86 | + | |
| 87 | + out := strings.TrimRight(b.String(), "\n") | |
| 88 | + // A non-zero exit is reported as an error so the model sees it as a | |
| 89 | + // failure it has to respond to, with the output still attached. | |
| 90 | + if timedOut || exitCode != 0 { | |
| 91 | + return "", fmt.Errorf("%s", out) | |
| 92 | + } | |
| 93 | + return out, nil | |
| 94 | + }) | |
| 95 | + } | |
| 96 | + | |
| 97 | + func writeStream(b *strings.Builder, label, s string) { | |
| 98 | + s = strings.TrimRight(s, "\n") | |
| 99 | + if s == "" { | |
| 100 | + return | |
| 101 | + } | |
| 102 | + fmt.Fprintf(b, "\n--- %s ---\n%s\n", label, headTail(s, bashHeadLines, bashTailLines)) | |
| 103 | + } | |
| @@ -0,0 +1,113 @@ | ||
| 1 | + package tools | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "fmt" | |
| 5 | + "strings" | |
| 6 | + ) | |
| 7 | + | |
| 8 | + // diffContext is how many unchanged lines to show either side of a change. | |
| 9 | + const diffContext = 3 | |
| 10 | + | |
| 11 | + type diffOp struct { | |
| 12 | + kind byte // ' ' keep, '-' remove, '+' add | |
| 13 | + text string | |
| 14 | + } | |
| 15 | + | |
| 16 | + // diffLines is a line-level LCS diff. The regions being diffed are edit-sized, | |
| 17 | + // so the quadratic table is not a problem; the guard below is for the | |
| 18 | + // pathological case of a whole-file replacement. | |
| 19 | + func diffLines(a, b []string) []diffOp { | |
| 20 | + if len(a)*len(b) > 4_000_000 { | |
| 21 | + ops := make([]diffOp, 0, len(a)+len(b)) | |
| 22 | + for _, l := range a { | |
| 23 | + ops = append(ops, diffOp{'-', l}) | |
| 24 | + } | |
| 25 | + for _, l := range b { | |
| 26 | + ops = append(ops, diffOp{'+', l}) | |
| 27 | + } | |
| 28 | + return ops | |
| 29 | + } | |
| 30 | + | |
| 31 | + // lcs[i][j] is the length of the longest common subsequence of a[i:] and b[j:]. | |
| 32 | + lcs := make([][]int, len(a)+1) | |
| 33 | + for i := range lcs { | |
| 34 | + lcs[i] = make([]int, len(b)+1) | |
| 35 | + } | |
| 36 | + for i := len(a) - 1; i >= 0; i-- { | |
| 37 | + for j := len(b) - 1; j >= 0; j-- { | |
| 38 | + if a[i] == b[j] { | |
| 39 | + lcs[i][j] = lcs[i+1][j+1] + 1 | |
| 40 | + } else if lcs[i+1][j] >= lcs[i][j+1] { | |
| 41 | + lcs[i][j] = lcs[i+1][j] | |
| 42 | + } else { | |
| 43 | + lcs[i][j] = lcs[i][j+1] | |
| 44 | + } | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + var ops []diffOp | |
| 49 | + i, j := 0, 0 | |
| 50 | + for i < len(a) && j < len(b) { | |
| 51 | + switch { | |
| 52 | + case a[i] == b[j]: | |
| 53 | + ops = append(ops, diffOp{' ', a[i]}) | |
| 54 | + i++ | |
| 55 | + j++ | |
| 56 | + case lcs[i+1][j] >= lcs[i][j+1]: | |
| 57 | + ops = append(ops, diffOp{'-', a[i]}) | |
| 58 | + i++ | |
| 59 | + default: | |
| 60 | + ops = append(ops, diffOp{'+', b[j]}) | |
| 61 | + j++ | |
| 62 | + } | |
| 63 | + } | |
| 64 | + for ; i < len(a); i++ { | |
| 65 | + ops = append(ops, diffOp{'-', a[i]}) | |
| 66 | + } | |
| 67 | + for ; j < len(b); j++ { | |
| 68 | + ops = append(ops, diffOp{'+', b[j]}) | |
| 69 | + } | |
| 70 | + return ops | |
| 71 | + } | |
| 72 | + | |
| 73 | + // unifiedDiff renders the change to a region as a unified diff hunk. startLine | |
| 74 | + // is the 1-indexed line in the original file where oldRegion begins, and before | |
| 75 | + // and after are the surrounding context lines. | |
| 76 | + func unifiedDiff(path string, before, oldRegion, newRegion, after []string, startLine int) string { | |
| 77 | + ops := diffLines(oldRegion, newRegion) | |
| 78 | + | |
| 79 | + oldStart := startLine - len(before) | |
| 80 | + if oldStart < 1 { | |
| 81 | + oldStart = 1 | |
| 82 | + } | |
| 83 | + oldCount := len(before) + len(oldRegion) + len(after) | |
| 84 | + newCount := len(before) + len(newRegion) + len(after) | |
| 85 | + | |
| 86 | + var b strings.Builder | |
| 87 | + fmt.Fprintf(&b, "--- %s\n+++ %s\n", path, path) | |
| 88 | + fmt.Fprintf(&b, "@@ -%d,%d +%d,%d @@\n", oldStart, oldCount, oldStart, newCount) | |
| 89 | + for _, l := range before { | |
| 90 | + fmt.Fprintf(&b, " %s\n", l) | |
| 91 | + } | |
| 92 | + for _, op := range ops { | |
| 93 | + fmt.Fprintf(&b, "%c%s\n", op.kind, op.text) | |
| 94 | + } | |
| 95 | + for _, l := range after { | |
| 96 | + fmt.Fprintf(&b, " %s\n", l) | |
| 97 | + } | |
| 98 | + return strings.TrimRight(b.String(), "\n") | |
| 99 | + } | |
| 100 | + | |
| 101 | + // contextAround returns up to diffContext lines either side of [start, end) in | |
| 102 | + // lines, where start and end are 0-indexed. | |
| 103 | + func contextAround(lines []string, start, end int) (before, after []string) { | |
| 104 | + lo := start - diffContext | |
| 105 | + if lo < 0 { | |
| 106 | + lo = 0 | |
| 107 | + } | |
| 108 | + hi := end + diffContext | |
| 109 | + if hi > len(lines) { | |
| 110 | + hi = len(lines) | |
| 111 | + } | |
| 112 | + return lines[lo:start], lines[end:hi] | |
| 113 | + } | |
| @@ -0,0 +1,288 @@ | ||
| 1 | + package tools | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "os" | |
| 7 | + "strings" | |
| 8 | + ) | |
| 9 | + | |
| 10 | + // EditArgs is the edit_file input. | |
| 11 | + type EditArgs struct { | |
| 12 | + Path string `json:"path" jsonschema:"description=Path to the file, absolute or relative to the working root"` | |
| 13 | + OldStr string `json:"old_str" jsonschema:"description=Exact text to replace, including indentation. Must appear exactly once unless replace_all is set"` | |
| 14 | + NewStr string `json:"new_str" jsonschema:"description=Text to replace it with. Use an empty string to delete"` | |
| 15 | + ReplaceAll bool `json:"replace_all,omitempty" jsonschema:"description=Replace every occurrence instead of requiring a unique match"` | |
| 16 | + } | |
| 17 | + | |
| 18 | + // NewEdit builds the edit_file tool. track must be the same tracker read_file | |
| 19 | + // writes to. | |
| 20 | + func NewEdit(root string, track FileTracker) Tool { | |
| 21 | + return New("edit_file", strings.TrimSpace(` | |
| 22 | + Replace exact text in a file you have already read. old_str must match the file | |
| 23 | + byte for byte, including indentation, and must be unique — include surrounding | |
| 24 | + lines to disambiguate rather than hoping. Returns a unified diff of what | |
| 25 | + changed, which you should check against what you meant to do.`), | |
| 26 | + func(_ context.Context, in EditArgs) (string, error) { | |
| 27 | + path, err := resolve(root, in.Path) | |
| 28 | + if err != nil { | |
| 29 | + return "", err | |
| 30 | + } | |
| 31 | + if in.OldStr == "" { | |
| 32 | + return "", fmt.Errorf("old_str is required; use write_file to create a new file") | |
| 33 | + } | |
| 34 | + if in.OldStr == in.NewStr { | |
| 35 | + return "", fmt.Errorf("old_str and new_str are identical, so this edit would do nothing") | |
| 36 | + } | |
| 37 | + // Existence is checked before the read-before-edit rule, so a | |
| 38 | + // wrong path reports the wrong path rather than "you have not read | |
| 39 | + // this file", which would send the model looking for the wrong bug. | |
| 40 | + raw, err := os.ReadFile(path) | |
| 41 | + if err != nil { | |
| 42 | + if os.IsNotExist(err) { | |
| 43 | + return "", fmt.Errorf("no such file: %s", in.Path) | |
| 44 | + } | |
| 45 | + return "", err | |
| 46 | + } | |
| 47 | + if err := checkRead(track, path, in.Path); err != nil { | |
| 48 | + return "", err | |
| 49 | + } | |
| 50 | + content, crlf := normalizeEndings(string(raw)) | |
| 51 | + oldText, newText := normalizeInput(in.OldStr), normalizeInput(in.NewStr) | |
| 52 | + | |
| 53 | + switch n := strings.Count(content, oldText); { | |
| 54 | + case n == 0: | |
| 55 | + return "", missReport(in.Path, content, oldText) | |
| 56 | + case n > 1 && !in.ReplaceAll: | |
| 57 | + return "", ambiguityReport(in.Path, content, oldText, n) | |
| 58 | + } | |
| 59 | + | |
| 60 | + updated := content | |
| 61 | + count := 1 | |
| 62 | + if in.ReplaceAll { | |
| 63 | + count = strings.Count(content, oldText) | |
| 64 | + updated = strings.ReplaceAll(content, oldText, newText) | |
| 65 | + } else { | |
| 66 | + updated = strings.Replace(content, oldText, newText, 1) | |
| 67 | + } | |
| 68 | + | |
| 69 | + info, err := os.Stat(path) | |
| 70 | + perm := os.FileMode(0o644) | |
| 71 | + if err == nil { | |
| 72 | + perm = info.Mode().Perm() | |
| 73 | + } | |
| 74 | + if err := os.WriteFile(path, []byte(restoreEndings(updated, crlf)), perm); err != nil { | |
| 75 | + return "", err | |
| 76 | + } | |
| 77 | + // The file just changed, so the recorded read is stale by | |
| 78 | + // definition. Re-point it at what is now on disk. | |
| 79 | + if info, err := os.Stat(path); err == nil { | |
| 80 | + track.MarkRead(path, info.ModTime()) | |
| 81 | + } | |
| 82 | + | |
| 83 | + return editSummary(in.Path, content, oldText, newText, count), nil | |
| 84 | + }) | |
| 85 | + } | |
| 86 | + | |
| 87 | + // checkRead enforces the read-before-edit rule, and forces a re-read when the | |
| 88 | + // file has changed since. This single rule kills a large class of edits against | |
| 89 | + // remembered rather than observed content. | |
| 90 | + func checkRead(track FileTracker, path, display string) error { | |
| 91 | + if track == nil { | |
| 92 | + return nil | |
| 93 | + } | |
| 94 | + rec, ok := track.LastRead(path) | |
| 95 | + if !ok { | |
| 96 | + return fmt.Errorf("%s has not been read this session; read_file it first so you are editing what is actually there", display) | |
| 97 | + } | |
| 98 | + info, err := os.Stat(path) | |
| 99 | + if err != nil { | |
| 100 | + return nil // the read below will produce the real error | |
| 101 | + } | |
| 102 | + if info.ModTime().After(rec.Mod) { | |
| 103 | + return fmt.Errorf("%s changed on disk since you read it; read_file it again before editing", display) | |
| 104 | + } | |
| 105 | + return nil | |
| 106 | + } | |
| 107 | + | |
| 108 | + // missReport turns "not found" into something recoverable. A bare miss costs | |
| 109 | + // several turns of blind retrying; a miss carrying the actual text costs one. | |
| 110 | + func missReport(display, content, oldText string) error { | |
| 111 | + oldLines := strings.Split(oldText, "\n") | |
| 112 | + lines := strings.Split(content, "\n") | |
| 113 | + | |
| 114 | + // Whitespace is the usual culprit, so try again ignoring it. | |
| 115 | + if start, end, ok := findNormalized(lines, oldLines); ok { | |
| 116 | + var b strings.Builder | |
| 117 | + 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", | |
| 118 | + display, start+1, end) | |
| 119 | + for i := start; i < end; i++ { | |
| 120 | + fmt.Fprintf(&b, "%6d\t%s\n", i+1, capLine(lines[i])) | |
| 121 | + } | |
| 122 | + b.WriteString("\nCopy that text exactly, including its indentation.") | |
| 123 | + return fmt.Errorf("%s", b.String()) | |
| 124 | + } | |
| 125 | + | |
| 126 | + // Otherwise show the neighbourhood of the closest line we can find. | |
| 127 | + best := closestLine(lines, oldLines[0]) | |
| 128 | + if best < 0 { | |
| 129 | + 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) | |
| 130 | + } | |
| 131 | + lo, hi := best-10, best+10 | |
| 132 | + if lo < 0 { | |
| 133 | + lo = 0 | |
| 134 | + } | |
| 135 | + if hi > len(lines) { | |
| 136 | + hi = len(lines) | |
| 137 | + } | |
| 138 | + var b strings.Builder | |
| 139 | + 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) | |
| 140 | + for i := lo; i < hi; i++ { | |
| 141 | + fmt.Fprintf(&b, "%6d\t%s\n", i+1, capLine(lines[i])) | |
| 142 | + } | |
| 143 | + b.WriteString("\nUse text copied from there, or read_file a wider range if the target is elsewhere.") | |
| 144 | + return fmt.Errorf("%s", b.String()) | |
| 145 | + } | |
| 146 | + | |
| 147 | + // ambiguityReport refuses a non-unique match and says where every occurrence | |
| 148 | + // is, so the next attempt can be made unique. Silently taking the first match | |
| 149 | + // would corrupt the file in a way nobody notices for a week. | |
| 150 | + func ambiguityReport(display, content, oldText string, n int) error { | |
| 151 | + var b strings.Builder | |
| 152 | + fmt.Fprintf(&b, "old_str appears %d times in %s, at lines: %s.\n\n", n, display, joinInts(occurrenceLines(content, oldText))) | |
| 153 | + b.WriteString("Add surrounding lines to old_str so it matches exactly one place, or set replace_all if every occurrence should change.") | |
| 154 | + return fmt.Errorf("%s", b.String()) | |
| 155 | + } | |
| 156 | + | |
| 157 | + // editSummary renders the applied change as a unified diff. | |
| 158 | + func editSummary(display, content, oldText, newText string, count int) string { | |
| 159 | + idx := strings.Index(content, oldText) | |
| 160 | + startLine := strings.Count(content[:idx], "\n") | |
| 161 | + | |
| 162 | + lines := strings.Split(content, "\n") | |
| 163 | + oldRegion := strings.Split(oldText, "\n") | |
| 164 | + newRegion := strings.Split(newText, "\n") | |
| 165 | + end := startLine + len(oldRegion) | |
| 166 | + if end > len(lines) { | |
| 167 | + end = len(lines) | |
| 168 | + } | |
| 169 | + before, after := contextAround(lines, startLine, end) | |
| 170 | + | |
| 171 | + out := unifiedDiff(display, before, oldRegion, newRegion, after, startLine+1) | |
| 172 | + if count > 1 { | |
| 173 | + return fmt.Sprintf("Replaced %d occurrences. First one:\n\n%s", count, out) | |
| 174 | + } | |
| 175 | + return out | |
| 176 | + } | |
| 177 | + | |
| 178 | + // findNormalized looks for the target lines ignoring leading, trailing and | |
| 179 | + // repeated internal whitespace. Returns a half-open [start, end) line range. | |
| 180 | + func findNormalized(lines, target []string) (int, int, bool) { | |
| 181 | + if len(target) == 0 || len(target) > len(lines) { | |
| 182 | + return 0, 0, false | |
| 183 | + } | |
| 184 | + norm := make([]string, len(lines)) | |
| 185 | + for i, l := range lines { | |
| 186 | + norm[i] = collapse(l) | |
| 187 | + } | |
| 188 | + want := make([]string, len(target)) | |
| 189 | + for i, l := range target { | |
| 190 | + want[i] = collapse(l) | |
| 191 | + } | |
| 192 | + for i := 0; i+len(want) <= len(norm); i++ { | |
| 193 | + match := true | |
| 194 | + for j := range want { | |
| 195 | + if norm[i+j] != want[j] { | |
| 196 | + match = false | |
| 197 | + break | |
| 198 | + } | |
| 199 | + } | |
| 200 | + if match { | |
| 201 | + return i, i + len(want), true | |
| 202 | + } | |
| 203 | + } | |
| 204 | + return 0, 0, false | |
| 205 | + } | |
| 206 | + | |
| 207 | + // closestLine scores every line against want and returns the best index, or -1 | |
| 208 | + // when nothing is even loosely similar. | |
| 209 | + func closestLine(lines []string, want string) int { | |
| 210 | + want = collapse(want) | |
| 211 | + if want == "" { | |
| 212 | + return -1 | |
| 213 | + } | |
| 214 | + wantTokens := strings.Fields(want) | |
| 215 | + best, bestScore := -1, 0.0 | |
| 216 | + for i, l := range lines { | |
| 217 | + score := similarity(strings.Fields(collapse(l)), wantTokens) | |
| 218 | + if score > bestScore { | |
| 219 | + best, bestScore = i, score | |
| 220 | + } | |
| 221 | + } | |
| 222 | + if bestScore < 0.34 { | |
| 223 | + return -1 | |
| 224 | + } | |
| 225 | + return best | |
| 226 | + } | |
| 227 | + | |
| 228 | + // similarity is the fraction of want's tokens present in have. | |
| 229 | + func similarity(have, want []string) float64 { | |
| 230 | + if len(want) == 0 { | |
| 231 | + return 0 | |
| 232 | + } | |
| 233 | + seen := make(map[string]int, len(have)) | |
| 234 | + for _, t := range have { | |
| 235 | + seen[t]++ | |
| 236 | + } | |
| 237 | + var hits int | |
| 238 | + for _, t := range want { | |
| 239 | + if seen[t] > 0 { | |
| 240 | + seen[t]-- | |
| 241 | + hits++ | |
| 242 | + } | |
| 243 | + } | |
| 244 | + return float64(hits) / float64(len(want)) | |
| 245 | + } | |
| 246 | + | |
| 247 | + func collapse(s string) string { return strings.Join(strings.Fields(s), " ") } | |
| 248 | + | |
| 249 | + // occurrenceLines returns the 1-indexed line of each occurrence of sub. | |
| 250 | + func occurrenceLines(content, sub string) []int { | |
| 251 | + var out []int | |
| 252 | + for off := 0; ; { | |
| 253 | + i := strings.Index(content[off:], sub) | |
| 254 | + if i < 0 { | |
| 255 | + return out | |
| 256 | + } | |
| 257 | + out = append(out, strings.Count(content[:off+i], "\n")+1) | |
| 258 | + off += i + len(sub) | |
| 259 | + } | |
| 260 | + } | |
| 261 | + | |
| 262 | + func joinInts(ns []int) string { | |
| 263 | + parts := make([]string, len(ns)) | |
| 264 | + for i, n := range ns { | |
| 265 | + parts[i] = fmt.Sprint(n) | |
| 266 | + } | |
| 267 | + return strings.Join(parts, ", ") | |
| 268 | + } | |
| 269 | + | |
| 270 | + // normalizeEndings converts CRLF to LF and reports whether the file used CRLF, | |
| 271 | + // so the original ending can be restored on write. | |
| 272 | + func normalizeEndings(s string) (string, bool) { | |
| 273 | + if strings.Contains(s, "\r\n") { | |
| 274 | + return strings.ReplaceAll(s, "\r\n", "\n"), true | |
| 275 | + } | |
| 276 | + return s, false | |
| 277 | + } | |
| 278 | + | |
| 279 | + func restoreEndings(s string, crlf bool) string { | |
| 280 | + if !crlf { | |
| 281 | + return s | |
| 282 | + } | |
| 283 | + return strings.ReplaceAll(s, "\n", "\r\n") | |
| 284 | + } | |
| 285 | + | |
| 286 | + // normalizeInput strips CR from model-supplied text, which otherwise fails to | |
| 287 | + // match a file we have already normalized. | |
| 288 | + func normalizeInput(s string) string { return strings.ReplaceAll(s, "\r\n", "\n") } | |
| @@ -0,0 +1,324 @@ | ||
| 1 | + package tools | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "os" | |
| 7 | + "path/filepath" | |
| 8 | + "strings" | |
| 9 | + "sync" | |
| 10 | + "testing" | |
| 11 | + "time" | |
| 12 | + ) | |
| 13 | + | |
| 14 | + // memTracker is a FileTracker backed by a map. | |
| 15 | + type memTracker struct { | |
| 16 | + mu sync.Mutex | |
| 17 | + recs map[string]ReadRecord | |
| 18 | + } | |
| 19 | + | |
| 20 | + func newTracker() *memTracker { return &memTracker{recs: map[string]ReadRecord{}} } | |
| 21 | + | |
| 22 | + func (m *memTracker) MarkRead(path string, mod time.Time) { | |
| 23 | + m.mu.Lock() | |
| 24 | + defer m.mu.Unlock() | |
| 25 | + m.recs[path] = ReadRecord{At: time.Now(), Mod: mod} | |
| 26 | + } | |
| 27 | + | |
| 28 | + func (m *memTracker) LastRead(path string) (ReadRecord, bool) { | |
| 29 | + m.mu.Lock() | |
| 30 | + defer m.mu.Unlock() | |
| 31 | + r, ok := m.recs[path] | |
| 32 | + return r, ok | |
| 33 | + } | |
| 34 | + | |
| 35 | + // editFixture writes a file and marks it read, which is the normal precondition. | |
| 36 | + func editFixture(t *testing.T, content string) (root string, track *memTracker, tool Tool) { | |
| 37 | + t.Helper() | |
| 38 | + root = t.TempDir() | |
| 39 | + path := writeFile(t, root, "sample.go", content) | |
| 40 | + track = newTracker() | |
| 41 | + info, err := os.Stat(path) | |
| 42 | + if err != nil { | |
| 43 | + t.Fatal(err) | |
| 44 | + } | |
| 45 | + track.MarkRead(path, info.ModTime()) | |
| 46 | + return root, track, NewEdit(root, track) | |
| 47 | + } | |
| 48 | + | |
| 49 | + func edit(t *testing.T, tool Tool, args EditArgs) (string, error) { | |
| 50 | + t.Helper() | |
| 51 | + raw, err := json.Marshal(args) | |
| 52 | + if err != nil { | |
| 53 | + t.Fatal(err) | |
| 54 | + } | |
| 55 | + return tool.Execute(context.Background(), raw) | |
| 56 | + } | |
| 57 | + | |
| 58 | + const sample = `package main | |
| 59 | + | |
| 60 | + import "fmt" | |
| 61 | + | |
| 62 | + func greet(name string) { | |
| 63 | + fmt.Println("hello", name) | |
| 64 | + } | |
| 65 | + | |
| 66 | + func main() { | |
| 67 | + greet("world") | |
| 68 | + } | |
| 69 | + ` | |
| 70 | + | |
| 71 | + func TestEditExactlyOneMatchAppliesAndReturnsADiff(t *testing.T) { | |
| 72 | + root, _, tool := editFixture(t, sample) | |
| 73 | + | |
| 74 | + out, err := edit(t, tool, EditArgs{ | |
| 75 | + Path: "sample.go", | |
| 76 | + OldStr: ` fmt.Println("hello", name)`, | |
| 77 | + NewStr: ` fmt.Printf("hello %s\n", name)`, | |
| 78 | + }) | |
| 79 | + if err != nil { | |
| 80 | + t.Fatalf("edit: %v", err) | |
| 81 | + } | |
| 82 | + got, err := os.ReadFile(filepath.Join(root, "sample.go")) | |
| 83 | + if err != nil { | |
| 84 | + t.Fatal(err) | |
| 85 | + } | |
| 86 | + if !strings.Contains(string(got), `fmt.Printf("hello %s\n", name)`) { | |
| 87 | + t.Errorf("file was not changed:\n%s", got) | |
| 88 | + } | |
| 89 | + if strings.Contains(string(got), `fmt.Println("hello", name)`) { | |
| 90 | + t.Errorf("old text still present:\n%s", got) | |
| 91 | + } | |
| 92 | + | |
| 93 | + // The diff is what the model uses to confirm it did what it meant. | |
| 94 | + for _, want := range []string{"--- sample.go", "@@ -", `- fmt.Println("hello", name)`, `+ fmt.Printf`, " func greet(name string) {"} { | |
| 95 | + if !strings.Contains(out, want) { | |
| 96 | + t.Errorf("diff missing %q:\n%s", want, out) | |
| 97 | + } | |
| 98 | + } | |
| 99 | + } | |
| 100 | + | |
| 101 | + func TestEditZeroMatchesCarriesRecoveryContext(t *testing.T) { | |
| 102 | + tests := []struct { | |
| 103 | + name string | |
| 104 | + content string | |
| 105 | + oldStr string | |
| 106 | + want []string | |
| 107 | + }{ | |
| 108 | + { | |
| 109 | + name: "indentation differs: say so and show the real line", | |
| 110 | + content: sample, | |
| 111 | + oldStr: ` fmt.Println("hello", name)`, // spaces, but the file uses a tab | |
| 112 | + want: []string{"different whitespace at lines 6-6", "Copy that text exactly", ` fmt.Println("hello", name)`}, | |
| 113 | + }, | |
| 114 | + { | |
| 115 | + name: "internal whitespace differs beyond recognition", | |
| 116 | + content: sample, | |
| 117 | + oldStr: `fmt.Println( "hello", name )`, | |
| 118 | + want: []string{"nothing in the file resembles it"}, | |
| 119 | + }, | |
| 120 | + { | |
| 121 | + name: "near miss shows the neighbourhood with line numbers", | |
| 122 | + content: sample, | |
| 123 | + oldStr: ` fmt.Println("goodbye", name)`, | |
| 124 | + want: []string{"closest thing in the file is around line", " 6\t", "read_file a wider range"}, | |
| 125 | + }, | |
| 126 | + { | |
| 127 | + name: "nothing similar at all", | |
| 128 | + content: sample, | |
| 129 | + oldStr: "zzz qqq vvv nothing like this", | |
| 130 | + want: []string{"nothing in the file resembles it"}, | |
| 131 | + }, | |
| 132 | + } | |
| 133 | + for _, tc := range tests { | |
| 134 | + t.Run(tc.name, func(t *testing.T) { | |
| 135 | + root, _, tool := editFixture(t, tc.content) | |
| 136 | + _, err := edit(t, tool, EditArgs{Path: "sample.go", OldStr: tc.oldStr, NewStr: "replacement"}) | |
| 137 | + if err == nil { | |
| 138 | + t.Fatal("want an error") | |
| 139 | + } | |
| 140 | + for _, w := range tc.want { | |
| 141 | + if !strings.Contains(err.Error(), w) { | |
| 142 | + t.Errorf("error missing %q:\n%s", w, err) | |
| 143 | + } | |
| 144 | + } | |
| 145 | + after, _ := os.ReadFile(filepath.Join(root, "sample.go")) | |
| 146 | + if string(after) != tc.content { | |
| 147 | + t.Error("a failed edit must leave the file untouched") | |
| 148 | + } | |
| 149 | + }) | |
| 150 | + } | |
| 151 | + } | |
| 152 | + | |
| 153 | + func TestEditMultipleMatchesRefusesAndListsEveryLine(t *testing.T) { | |
| 154 | + const dup = "a := 1\nb := 2\na := 1\nc := 3\na := 1\n" | |
| 155 | + root, _, tool := editFixture(t, dup) | |
| 156 | + | |
| 157 | + _, err := edit(t, tool, EditArgs{Path: "sample.go", OldStr: "a := 1", NewStr: "a := 9"}) | |
| 158 | + if err == nil { | |
| 159 | + t.Fatal("want a refusal: silently taking the first match corrupts files") | |
| 160 | + } | |
| 161 | + for _, want := range []string{"appears 3 times", "lines: 1, 3, 5", "replace_all"} { | |
| 162 | + if !strings.Contains(err.Error(), want) { | |
| 163 | + t.Errorf("error missing %q:\n%s", want, err) | |
| 164 | + } | |
| 165 | + } | |
| 166 | + after, _ := os.ReadFile(filepath.Join(root, "sample.go")) | |
| 167 | + if string(after) != dup { | |
| 168 | + t.Error("an ambiguous edit must change nothing") | |
| 169 | + } | |
| 170 | + } | |
| 171 | + | |
| 172 | + func TestEditReplaceAll(t *testing.T) { | |
| 173 | + root, _, tool := editFixture(t, "a := 1\nb := 2\na := 1\n") | |
| 174 | + out, err := edit(t, tool, EditArgs{Path: "sample.go", OldStr: "a := 1", NewStr: "a := 9", ReplaceAll: true}) | |
| 175 | + if err != nil { | |
| 176 | + t.Fatalf("edit: %v", err) | |
| 177 | + } | |
| 178 | + got, _ := os.ReadFile(filepath.Join(root, "sample.go")) | |
| 179 | + if string(got) != "a := 9\nb := 2\na := 9\n" { | |
| 180 | + t.Errorf("got:\n%q", got) | |
| 181 | + } | |
| 182 | + if !strings.Contains(out, "Replaced 2 occurrences") { | |
| 183 | + t.Errorf("summary should say how many changed:\n%s", out) | |
| 184 | + } | |
| 185 | + } | |
| 186 | + | |
| 187 | + func TestEditRequiresAReadFirst(t *testing.T) { | |
| 188 | + root := t.TempDir() | |
| 189 | + writeFile(t, root, "unread.go", sample) | |
| 190 | + track := newTracker() | |
| 191 | + | |
| 192 | + _, err := edit(t, NewEdit(root, track), EditArgs{Path: "unread.go", OldStr: "package main", NewStr: "package x"}) | |
| 193 | + if err == nil || !strings.Contains(err.Error(), "has not been read this session") { | |
| 194 | + t.Fatalf("error = %v, want a read-before-edit refusal", err) | |
| 195 | + } | |
| 196 | + if got, _ := os.ReadFile(filepath.Join(root, "unread.go")); string(got) != sample { | |
| 197 | + t.Error("file must be untouched") | |
| 198 | + } | |
| 199 | + } | |
| 200 | + | |
| 201 | + func TestEditForcesReReadWhenTheFileChangedUnderneath(t *testing.T) { | |
| 202 | + root, track, tool := editFixture(t, sample) | |
| 203 | + path := filepath.Join(root, "sample.go") | |
| 204 | + | |
| 205 | + // Something else wrote the file after the model read it. | |
| 206 | + track.MarkRead(path, time.Now().Add(-time.Hour)) | |
| 207 | + os.WriteFile(path, []byte(sample+"\n// appended\n"), 0o644) | |
| 208 | + | |
| 209 | + _, err := edit(t, tool, EditArgs{Path: "sample.go", OldStr: "package main", NewStr: "package x"}) | |
| 210 | + if err == nil || !strings.Contains(err.Error(), "changed on disk since you read it") { | |
| 211 | + t.Fatalf("error = %v, want a stale-read refusal", err) | |
| 212 | + } | |
| 213 | + } | |
| 214 | + | |
| 215 | + func TestEditPreservesCRLF(t *testing.T) { | |
| 216 | + root, track, _ := editFixture(t, "") | |
| 217 | + path := writeFile(t, root, "crlf.go", "package main\r\n\r\nfunc main() {}\r\n") | |
| 218 | + info, _ := os.Stat(path) | |
| 219 | + track.MarkRead(path, info.ModTime()) | |
| 220 | + | |
| 221 | + out, err := edit(t, NewEdit(root, track), EditArgs{ | |
| 222 | + Path: "crlf.go", | |
| 223 | + OldStr: "func main() {}", | |
| 224 | + NewStr: "func main() {\n\tprintln(\"hi\")\n}", | |
| 225 | + }) | |
| 226 | + if err != nil { | |
| 227 | + t.Fatalf("edit: %v", err) | |
| 228 | + } | |
| 229 | + got, _ := os.ReadFile(path) | |
| 230 | + if strings.Contains(out, "\r") { | |
| 231 | + t.Error("the diff shown to the model should not carry carriage returns") | |
| 232 | + } | |
| 233 | + if want := "package main\r\n\r\nfunc main() {\r\n\tprintln(\"hi\")\r\n}\r\n"; string(got) != want { | |
| 234 | + t.Errorf("CRLF not preserved:\ngot %q\nwant %q", got, want) | |
| 235 | + } | |
| 236 | + if strings.Contains(strings.ReplaceAll(string(got), "\r\n", ""), "\n") { | |
| 237 | + t.Error("mixed line endings were introduced") | |
| 238 | + } | |
| 239 | + } | |
| 240 | + | |
| 241 | + func TestEditPreservesFileMode(t *testing.T) { | |
| 242 | + root, track, _ := editFixture(t, "") | |
| 243 | + path := writeFile(t, root, "run.sh", "#!/bin/sh\necho hi\n") | |
| 244 | + os.Chmod(path, 0o755) | |
| 245 | + info, _ := os.Stat(path) | |
| 246 | + track.MarkRead(path, info.ModTime()) | |
| 247 | + | |
| 248 | + if _, err := edit(t, NewEdit(root, track), EditArgs{Path: "run.sh", OldStr: "echo hi", NewStr: "echo bye"}); err != nil { | |
| 249 | + t.Fatalf("edit: %v", err) | |
| 250 | + } | |
| 251 | + after, _ := os.Stat(path) | |
| 252 | + if after.Mode().Perm() != 0o755 { | |
| 253 | + t.Errorf("mode = %v, want 0755 preserved", after.Mode().Perm()) | |
| 254 | + } | |
| 255 | + } | |
| 256 | + | |
| 257 | + func TestEditRefreshesTheReadRecord(t *testing.T) { | |
| 258 | + root, track, tool := editFixture(t, sample) | |
| 259 | + path := filepath.Join(root, "sample.go") | |
| 260 | + | |
| 261 | + if _, err := edit(t, tool, EditArgs{Path: "sample.go", OldStr: "package main", NewStr: "package other"}); err != nil { | |
| 262 | + t.Fatalf("first edit: %v", err) | |
| 263 | + } | |
| 264 | + // A second edit must not be blocked by the mtime change the first one caused. | |
| 265 | + if _, err := edit(t, tool, EditArgs{Path: "sample.go", OldStr: `import "fmt"`, NewStr: `import "os"`}); err != nil { | |
| 266 | + t.Fatalf("second edit should be allowed: %v", err) | |
| 267 | + } | |
| 268 | + rec, ok := track.LastRead(path) | |
| 269 | + if !ok { | |
| 270 | + t.Fatal("read record disappeared") | |
| 271 | + } | |
| 272 | + info, _ := os.Stat(path) | |
| 273 | + if !rec.Mod.Equal(info.ModTime()) { | |
| 274 | + t.Error("read record should track the file as the edit left it") | |
| 275 | + } | |
| 276 | + } | |
| 277 | + | |
| 278 | + func TestEditArgumentValidation(t *testing.T) { | |
| 279 | + _, _, tool := editFixture(t, sample) | |
| 280 | + tests := []struct { | |
| 281 | + name, want string | |
| 282 | + args EditArgs | |
| 283 | + }{ | |
| 284 | + {name: "missing path", want: "path is required", args: EditArgs{OldStr: "a", NewStr: "b"}}, | |
| 285 | + {name: "missing old_str", want: "old_str is required", args: EditArgs{Path: "sample.go", NewStr: "b"}}, | |
| 286 | + {name: "no-op edit", want: "identical", args: EditArgs{Path: "sample.go", OldStr: "a", NewStr: "a"}}, | |
| 287 | + {name: "missing file", want: "no such file", args: EditArgs{Path: "ghost.go", OldStr: "a", NewStr: "b"}}, | |
| 288 | + } | |
| 289 | + for _, tc := range tests { | |
| 290 | + t.Run(tc.name, func(t *testing.T) { | |
| 291 | + if _, err := edit(t, tool, tc.args); err == nil || !strings.Contains(err.Error(), tc.want) { | |
| 292 | + t.Errorf("error = %v, want %q", err, tc.want) | |
| 293 | + } | |
| 294 | + }) | |
| 295 | + } | |
| 296 | + } | |
| 297 | + | |
| 298 | + func TestEditDeletion(t *testing.T) { | |
| 299 | + root, _, tool := editFixture(t, "keep\ndrop me\nkeep\n") | |
| 300 | + if _, err := edit(t, tool, EditArgs{Path: "sample.go", OldStr: "drop me\n", NewStr: ""}); err != nil { | |
| 301 | + t.Fatalf("edit: %v", err) | |
| 302 | + } | |
| 303 | + got, _ := os.ReadFile(filepath.Join(root, "sample.go")) | |
| 304 | + if string(got) != "keep\nkeep\n" { | |
| 305 | + t.Errorf("got %q", got) | |
| 306 | + } | |
| 307 | + } | |
| 308 | + | |
| 309 | + func TestUnifiedDiffShape(t *testing.T) { | |
| 310 | + got := unifiedDiff("f.go", []string{"ctx"}, []string{"a", "b"}, []string{"a", "c"}, []string{"tail"}, 5) | |
| 311 | + want := strings.Join([]string{ | |
| 312 | + "--- f.go", | |
| 313 | + "+++ f.go", | |
| 314 | + "@@ -4,4 +4,4 @@", | |
| 315 | + " ctx", | |
| 316 | + " a", | |
| 317 | + "-b", | |
| 318 | + "+c", | |
| 319 | + " tail", | |
| 320 | + }, "\n") | |
| 321 | + if got != want { | |
| 322 | + t.Errorf("got:\n%s\nwant:\n%s", got, want) | |
| 323 | + } | |
| 324 | + } | |
| @@ -0,0 +1,197 @@ | ||
| 1 | + package tools | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "fmt" | |
| 7 | + "os" | |
| 8 | + "path/filepath" | |
| 9 | + "strings" | |
| 10 | + "testing" | |
| 11 | + "time" | |
| 12 | + ) | |
| 13 | + | |
| 14 | + func run(t *testing.T, tool Tool, args any) (string, error) { | |
| 15 | + t.Helper() | |
| 16 | + raw, err := json.Marshal(args) | |
| 17 | + if err != nil { | |
| 18 | + t.Fatal(err) | |
| 19 | + } | |
| 20 | + return tool.Execute(context.Background(), raw) | |
| 21 | + } | |
| 22 | + | |
| 23 | + func TestBashSuccess(t *testing.T) { | |
| 24 | + dir := t.TempDir() | |
| 25 | + writeFile(t, dir, "hello.txt", "hi\n") | |
| 26 | + out, err := run(t, NewBash(dir), BashArgs{Command: "cat hello.txt"}) | |
| 27 | + if err != nil { | |
| 28 | + t.Fatalf("bash: %v", err) | |
| 29 | + } | |
| 30 | + if !strings.Contains(out, "exit code: 0") || !strings.Contains(out, "--- stdout ---\nhi") { | |
| 31 | + t.Errorf("got:\n%s", out) | |
| 32 | + } | |
| 33 | + if strings.Contains(out, "stderr") { | |
| 34 | + t.Errorf("empty streams should be omitted:\n%s", out) | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + func TestBashNonZeroExitIsAnErrorCarryingOutput(t *testing.T) { | |
| 39 | + _, err := run(t, NewBash(t.TempDir()), BashArgs{Command: "echo out; echo problem >&2; exit 3"}) | |
| 40 | + if err == nil { | |
| 41 | + t.Fatal("a failing command must come back as an error the model reacts to") | |
| 42 | + } | |
| 43 | + for _, want := range []string{"exit code: 3", "--- stdout ---\nout", "--- stderr ---\nproblem"} { | |
| 44 | + if !strings.Contains(err.Error(), want) { | |
| 45 | + t.Errorf("missing %q in:\n%s", want, err) | |
| 46 | + } | |
| 47 | + } | |
| 48 | + } | |
| 49 | + | |
| 50 | + func TestBashCapsOutputKeepingBothEnds(t *testing.T) { | |
| 51 | + out, err := run(t, NewBash(t.TempDir()), BashArgs{Command: "seq 1 5000"}) | |
| 52 | + if err != nil { | |
| 53 | + t.Fatalf("bash: %v", err) | |
| 54 | + } | |
| 55 | + if !strings.Contains(out, "\n1\n") { | |
| 56 | + t.Error("head was dropped") | |
| 57 | + } | |
| 58 | + if !strings.Contains(out, "5000") { | |
| 59 | + t.Error("tail was dropped — that is where test failures live") | |
| 60 | + } | |
| 61 | + if !strings.Contains(out, "lines elided") { | |
| 62 | + t.Error("truncation should be announced") | |
| 63 | + } | |
| 64 | + if n := strings.Count(out, "\n"); n > bashHeadLines+bashTailLines+10 { | |
| 65 | + t.Errorf("output not capped: %d lines", n) | |
| 66 | + } | |
| 67 | + } | |
| 68 | + | |
| 69 | + func TestBashTimeoutKillsTheProcessGroup(t *testing.T) { | |
| 70 | + if testing.Short() { | |
| 71 | + t.Skip("waits on a real child process") | |
| 72 | + } | |
| 73 | + dir := t.TempDir() | |
| 74 | + marker := filepath.Join(dir, "child-survived") | |
| 75 | + // The child outlives its parent shell unless the whole group is killed. | |
| 76 | + cmd := fmt.Sprintf(`(sleep 2; touch %q) & sleep 5`, marker) | |
| 77 | + | |
| 78 | + start := time.Now() | |
| 79 | + _, err := run(t, NewBash(dir), BashArgs{Command: cmd, TimeoutMS: 300}) | |
| 80 | + if err == nil || !strings.Contains(err.Error(), "timed out") { | |
| 81 | + t.Fatalf("error = %v, want a timeout", err) | |
| 82 | + } | |
| 83 | + if elapsed := time.Since(start); elapsed > 3*time.Second { | |
| 84 | + t.Errorf("took %s: the timeout did not take effect promptly", elapsed) | |
| 85 | + } | |
| 86 | + | |
| 87 | + time.Sleep(3 * time.Second) | |
| 88 | + if _, err := os.Stat(marker); err == nil { | |
| 89 | + t.Error("the orphaned child kept running: the process group was not killed") | |
| 90 | + } | |
| 91 | + } | |
| 92 | + | |
| 93 | + func TestBashRunsInTheWorkingRoot(t *testing.T) { | |
| 94 | + dir := t.TempDir() | |
| 95 | + out, err := run(t, NewBash(dir), BashArgs{Command: "pwd"}) | |
| 96 | + if err != nil { | |
| 97 | + t.Fatalf("bash: %v", err) | |
| 98 | + } | |
| 99 | + resolved, _ := filepath.EvalSymlinks(dir) | |
| 100 | + if !strings.Contains(out, dir) && !strings.Contains(out, resolved) { | |
| 101 | + t.Errorf("pwd = %s, want the working root %s", out, dir) | |
| 102 | + } | |
| 103 | + } | |
| 104 | + | |
| 105 | + func TestBashRespectsCancellation(t *testing.T) { | |
| 106 | + raw, _ := json.Marshal(BashArgs{Command: "sleep 10"}) | |
| 107 | + ctx, cancel := context.WithCancel(context.Background()) | |
| 108 | + go func() { time.Sleep(100 * time.Millisecond); cancel() }() | |
| 109 | + | |
| 110 | + start := time.Now() | |
| 111 | + if _, err := NewBash(t.TempDir()).Execute(ctx, raw); err == nil { | |
| 112 | + t.Fatal("want an error") | |
| 113 | + } | |
| 114 | + if elapsed := time.Since(start); elapsed > 3*time.Second { | |
| 115 | + t.Errorf("took %s: cancellation did not propagate", elapsed) | |
| 116 | + } | |
| 117 | + } | |
| 118 | + | |
| 119 | + func TestBashEmptyCommand(t *testing.T) { | |
| 120 | + if _, err := run(t, NewBash(t.TempDir()), BashArgs{Command: " "}); err == nil || | |
| 121 | + !strings.Contains(err.Error(), "command is required") { | |
| 122 | + t.Errorf("error = %v", err) | |
| 123 | + } | |
| 124 | + } | |
| 125 | + | |
| 126 | + func TestWriteFileCreatesIncludingParents(t *testing.T) { | |
| 127 | + dir := t.TempDir() | |
| 128 | + out, err := run(t, NewWrite(dir, newTracker()), WriteArgs{ | |
| 129 | + Path: "internal/new/thing.go", Content: "package thing\n", | |
| 130 | + }) | |
| 131 | + if err != nil { | |
| 132 | + t.Fatalf("write: %v", err) | |
| 133 | + } | |
| 134 | + got, err := os.ReadFile(filepath.Join(dir, "internal/new/thing.go")) | |
| 135 | + if err != nil { | |
| 136 | + t.Fatalf("file not created: %v", err) | |
| 137 | + } | |
| 138 | + if string(got) != "package thing\n" { | |
| 139 | + t.Errorf("content = %q", got) | |
| 140 | + } | |
| 141 | + if !strings.Contains(out, "Created") || !strings.Contains(out, "14 bytes") { | |
| 142 | + t.Errorf("summary = %q", out) | |
| 143 | + } | |
| 144 | + } | |
| 145 | + | |
| 146 | + func TestWriteFileRefusesToClobberAnUnreadFile(t *testing.T) { | |
| 147 | + dir := t.TempDir() | |
| 148 | + writeFile(t, dir, "existing.go", "package existing\n") | |
| 149 | + | |
| 150 | + _, err := run(t, NewWrite(dir, newTracker()), WriteArgs{Path: "existing.go", Content: "package gone\n"}) | |
| 151 | + if err == nil || !strings.Contains(err.Error(), "has not been read this session") { | |
| 152 | + t.Fatalf("error = %v, want a read-before-write refusal", err) | |
| 153 | + } | |
| 154 | + if !strings.Contains(err.Error(), "replaces the whole file") { | |
| 155 | + t.Errorf("error should say why that matters here: %v", err) | |
| 156 | + } | |
| 157 | + got, _ := os.ReadFile(filepath.Join(dir, "existing.go")) | |
| 158 | + if string(got) != "package existing\n" { | |
| 159 | + t.Error("file must be untouched") | |
| 160 | + } | |
| 161 | + } | |
| 162 | + | |
| 163 | + func TestWriteFileReplacesAReadFile(t *testing.T) { | |
| 164 | + dir := t.TempDir() | |
| 165 | + path := writeFile(t, dir, "existing.go", "package existing\n") | |
| 166 | + track := newTracker() | |
| 167 | + info, _ := os.Stat(path) | |
| 168 | + track.MarkRead(path, info.ModTime()) | |
| 169 | + | |
| 170 | + out, err := run(t, NewWrite(dir, track), WriteArgs{Path: "existing.go", Content: "package replaced\n"}) | |
| 171 | + if err != nil { | |
| 172 | + t.Fatalf("write: %v", err) | |
| 173 | + } | |
| 174 | + if !strings.Contains(out, "Replaced") { | |
| 175 | + t.Errorf("summary = %q, want it to distinguish a rewrite from a create", out) | |
| 176 | + } | |
| 177 | + got, _ := os.ReadFile(path) | |
| 178 | + if string(got) != "package replaced\n" { | |
| 179 | + t.Errorf("content = %q", got) | |
| 180 | + } | |
| 181 | + } | |
| 182 | + | |
| 183 | + func TestWriteFilePreservesCRLFOnRewrite(t *testing.T) { | |
| 184 | + dir := t.TempDir() | |
| 185 | + path := writeFile(t, dir, "win.txt", "one\r\ntwo\r\n") | |
| 186 | + track := newTracker() | |
| 187 | + info, _ := os.Stat(path) | |
| 188 | + track.MarkRead(path, info.ModTime()) | |
| 189 | + | |
| 190 | + if _, err := run(t, NewWrite(dir, track), WriteArgs{Path: "win.txt", Content: "three\nfour\n"}); err != nil { | |
| 191 | + t.Fatalf("write: %v", err) | |
| 192 | + } | |
| 193 | + got, _ := os.ReadFile(path) | |
| 194 | + if string(got) != "three\r\nfour\r\n" { | |
| 195 | + t.Errorf("got %q, want CRLF preserved", got) | |
| 196 | + } | |
| 197 | + } | |
| @@ -13,12 +13,24 @@ import ( | ||
| 13 | 13 | // ask for a window. |
| 14 | 14 | const defaultReadLimit = 2000 |
| 15 | 15 | |
| 16 | - // ReadTracker records that a file was read, so edit_file (Phase 2) can refuse | |
| 17 | - // to edit a file the model has not looked at. agent.State implements it. | |
| 16 | + // ReadRecord is when a file was last read and what its mtime was at the time. | |
| 17 | + type ReadRecord struct { | |
| 18 | + At time.Time | |
| 19 | + Mod time.Time | |
| 20 | + } | |
| 21 | + | |
| 22 | + // ReadTracker records that a file was read. | |
| 18 | 23 | type ReadTracker interface { |
| 19 | 24 | MarkRead(path string, mod time.Time) |
| 20 | 25 | } |
| 21 | 26 | |
| 27 | + // FileTracker additionally answers what was read, which is what lets edit_file | |
| 28 | + // refuse to edit a file the model has not looked at. agent.State implements it. | |
| 29 | + type FileTracker interface { | |
| 30 | + ReadTracker | |
| 31 | + LastRead(path string) (ReadRecord, bool) | |
| 32 | + } | |
| 33 | + | |
| 22 | 34 | // ReadArgs is the read_file input. |
| 23 | 35 | type ReadArgs struct { |
| 24 | 36 | Path string `json:"path" jsonschema:"description=Path to the file, absolute or relative to the working root"` |
| @@ -0,0 +1,80 @@ | ||
| 1 | + package tools | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "os" | |
| 7 | + "path/filepath" | |
| 8 | + "strings" | |
| 9 | + ) | |
| 10 | + | |
| 11 | + // WriteArgs is the write_file input. | |
| 12 | + type WriteArgs struct { | |
| 13 | + Path string `json:"path" jsonschema:"description=Path to write, absolute or relative to the working root"` | |
| 14 | + Content string `json:"content" jsonschema:"description=Full contents of the file"` | |
| 15 | + } | |
| 16 | + | |
| 17 | + // NewWrite builds the write_file tool. It is kept separate from edit_file so | |
| 18 | + // the two do not blur together in the metrics: a whole-file rewrite succeeding | |
| 19 | + // says nothing about whether targeted edits work. | |
| 20 | + func NewWrite(root string, track FileTracker) Tool { | |
| 21 | + return New("write_file", strings.TrimSpace(` | |
| 22 | + Write a complete file, creating it or replacing it entirely. Use this for new | |
| 23 | + files. To change part of an existing file use edit_file instead — a full rewrite | |
| 24 | + discards anything you did not think to include.`), | |
| 25 | + func(_ context.Context, in WriteArgs) (string, error) { | |
| 26 | + path, err := resolve(root, in.Path) | |
| 27 | + if err != nil { | |
| 28 | + return "", err | |
| 29 | + } | |
| 30 | + | |
| 31 | + info, statErr := os.Stat(path) | |
| 32 | + existed := statErr == nil | |
| 33 | + if existed && info.IsDir() { | |
| 34 | + return "", fmt.Errorf("%s is a directory", in.Path) | |
| 35 | + } | |
| 36 | + | |
| 37 | + content := in.Content | |
| 38 | + perm := os.FileMode(0o644) | |
| 39 | + if existed { | |
| 40 | + // Overwriting is a destructive act on content the model may | |
| 41 | + // only be guessing at, so the same read-before-write rule as | |
| 42 | + // edit_file applies. | |
| 43 | + if err := checkRead(track, path, in.Path); err != nil { | |
| 44 | + return "", fmt.Errorf("%s (write_file replaces the whole file)", err) | |
| 45 | + } | |
| 46 | + perm = info.Mode().Perm() | |
| 47 | + if prev, readErr := os.ReadFile(path); readErr == nil { | |
| 48 | + if _, crlf := normalizeEndings(string(prev)); crlf { | |
| 49 | + content = restoreEndings(normalizeInput(content), true) | |
| 50 | + } | |
| 51 | + } | |
| 52 | + } else if dir := filepath.Dir(path); dir != "." { | |
| 53 | + if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 54 | + return "", err | |
| 55 | + } | |
| 56 | + } | |
| 57 | + | |
| 58 | + if err := os.WriteFile(path, []byte(content), perm); err != nil { | |
| 59 | + return "", err | |
| 60 | + } | |
| 61 | + if track != nil { | |
| 62 | + if info, err := os.Stat(path); err == nil { | |
| 63 | + track.MarkRead(path, info.ModTime()) | |
| 64 | + } | |
| 65 | + } | |
| 66 | + | |
| 67 | + verb := "Created" | |
| 68 | + if existed { | |
| 69 | + verb = "Replaced" | |
| 70 | + } | |
| 71 | + return fmt.Sprintf("%s %s (%d bytes, %d lines).", verb, in.Path, len(content), countLines(content)), nil | |
| 72 | + }) | |
| 73 | + } | |
| 74 | + | |
| 75 | + func countLines(s string) int { | |
| 76 | + if s == "" { | |
| 77 | + return 0 | |
| 78 | + } | |
| 79 | + return len(splitLines(s)) | |
| 80 | + } | |