Vectrum
Sign In

Phase 2: glob and grep

Given only read_file, the agent had no way to answer "what does main.go
do?" in a repo with no main.go at the root except to guess paths. A live
run spent three turns and nine reads, six of them misses, on exactly that.
glob("main.go") answers it in one call.

grep shells out to ripgrep when present and walks in pure Go otherwise.
Matches stay structured until output and are sorted by path and line, so
both engines return byte-identical results: ripgrep walks in parallel and
its file order is nondeterministic, which would otherwise surface as eval
noise and as prompt cache misses depending on whether the machine has rg.

Both prune .git, node_modules and friends, cap at 100 results with a count
of what was suppressed, and skip binary files. A bare glob pattern with no
slash matches the file name at any depth, since that is what "*.go" is
always meant to mean.

The system prompt now says not to guess paths, which is only honest advice
now that there is something to guess with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jjonicommitted 1 parent ad96f09commitdb315e4

Showing 8 changed files with 663 additions and 8 deletions.

+2−0cmd/harness/main.goView File
@@ -74,6 +74,8 @@ func run() error {
7474 state := agent.NewState()
7575 reg := tools.NewRegistry(log).Register(
7676 tools.NewRead(root, state),
77+ tools.NewGlob(root),
78+ tools.NewGrep(root),
7779 )
7880
7981 render := &ui.Renderer{W: os.Stdout, Verbose: *verbose}
+1−0go.modView File
@@ -6,6 +6,7 @@ require github.com/invopop/jsonschema v0.14.0
66
77 require (
88 github.com/bahlo/generic-list-go v0.2.0 // indirect
9+ github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
910 github.com/buger/jsonparser v1.1.2 // indirect
1011 github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
1112 go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
+2−0go.sumView File
@@ -1,5 +1,7 @@
11 github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
22 github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
3+ github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
4+ github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
35 github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
46 github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
57 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+8−3internal/agent/loop.goView File
@@ -24,9 +24,14 @@ var ErrMaxTurns = errors.New("turn limit reached")
2424 // every single request.
2525 const defaultSystem = `You are a coding agent operating on a local repository through tools.
2626
27- Work by reading before you act. Prefer reading the specific file or region you
28- need over dumping whole directories. When you change something, verify it by
29- running the project's own tests rather than asserting that it works.
27+ Never guess at file paths. If you do not know where something lives, find it
28+ with glob or grep first — one search costs less than a run of wrong guesses,
29+ and a wrong guess tells you nothing about where the file actually is. Once you
30+ know the path, read the specific file or region you need rather than dumping
31+ whole directories.
32+
33+ When you change something, verify it by running the project's own tests rather
34+ than asserting that it works.
3035
3136 If a tool returns an error, read it: the error text usually says exactly what to
3237 do differently. Do not repeat a failing call unchanged.
+150−0internal/tools/glob.goaddedView File
@@ -0,0 +1,150 @@
1+ package tools
2+
3+ import (
4+ "context"
5+ "fmt"
6+ "io/fs"
7+ "os"
8+ "path/filepath"
9+ "sort"
10+ "strings"
11+
12+ "github.com/bmatcuk/doublestar/v4"
13+ )
14+
15+ // maxResults caps how many paths or matches a search tool returns. The count of
16+ // what was suppressed goes back too, so the model knows to narrow the pattern
17+ // rather than assume it has seen everything.
18+ const maxResults = 100
19+
20+ // skipDirs are never descended into. They are where a repo keeps things the
21+ // agent did not write and should not be reading by accident.
22+ var skipDirs = map[string]bool{
23+ ".git": true, ".hg": true, ".svn": true,
24+ "node_modules": true, "vendor": true, "target": true,
25+ ".venv": true, "venv": true, "__pycache__": true,
26+ ".harness": true, ".next": true, ".cache": true,
27+ }
28+
29+ // GlobArgs is the glob input.
30+ type GlobArgs struct {
31+ Pattern string `json:"pattern" jsonschema:"description=Glob pattern. ** matches across directories; a pattern with no slash is matched against the file name at any depth"`
32+ Path string `json:"path,omitempty" jsonschema:"description=Directory to search under; defaults to the working root"`
33+ }
34+
35+ // NewGlob builds the glob tool.
36+ func NewGlob(root string) Tool {
37+ return New("glob", strings.TrimSpace(`
38+ Find files by name. Returns paths relative to the working root, most recently
39+ modified first. "*.go" matches Go files at any depth; "internal/**/*_test.go"
40+ matches within a subtree. Use this instead of guessing where a file lives.`),
41+ func(ctx context.Context, in GlobArgs) (string, error) {
42+ if in.Pattern == "" {
43+ return "", fmt.Errorf("pattern is required")
44+ }
45+ base := resolveBase(root, in.Path)
46+ if info, err := os.Stat(base); err != nil || !info.IsDir() {
47+ return "", fmt.Errorf("no such directory: %s", displayPath(root, base))
48+ }
49+
50+ type hit struct {
51+ path string
52+ mod int64
53+ }
54+ var hits []hit
55+ var scanned int
56+
57+ err := filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
58+ if err != nil {
59+ return nil // unreadable entries are skipped, not fatal
60+ }
61+ if ctx.Err() != nil {
62+ return ctx.Err()
63+ }
64+ if d.IsDir() {
65+ if p != base && shouldSkip(d.Name(), in.Pattern) {
66+ return fs.SkipDir
67+ }
68+ return nil
69+ }
70+ scanned++
71+ rel, relErr := filepath.Rel(base, p)
72+ if relErr != nil {
73+ return nil
74+ }
75+ if !matchPath(in.Pattern, rel) {
76+ return nil
77+ }
78+ var mod int64
79+ if info, statErr := d.Info(); statErr == nil {
80+ mod = info.ModTime().UnixNano()
81+ }
82+ hits = append(hits, hit{path: displayPath(root, p), mod: mod})
83+ return nil
84+ })
85+ if err != nil {
86+ return "", err
87+ }
88+ if len(hits) == 0 {
89+ return fmt.Sprintf("No files match %q under %s (%d files scanned).",
90+ in.Pattern, displayPath(root, base), scanned), nil
91+ }
92+
93+ // Most recently modified first: in a repo being worked on, that is
94+ // usually the file the task is about.
95+ sort.Slice(hits, func(i, j int) bool {
96+ if hits[i].mod != hits[j].mod {
97+ return hits[i].mod > hits[j].mod
98+ }
99+ return hits[i].path < hits[j].path
100+ })
101+
102+ var b strings.Builder
103+ shown := hits
104+ if len(shown) > maxResults {
105+ shown = shown[:maxResults]
106+ }
107+ for _, h := range shown {
108+ b.WriteString(h.path)
109+ b.WriteByte('\n')
110+ }
111+ if len(hits) > len(shown) {
112+ fmt.Fprintf(&b, "\n(%d of %d shown; narrow the pattern to see the rest)", len(shown), len(hits))
113+ }
114+ return b.String(), nil
115+ })
116+ }
117+
118+ // matchPath applies the pattern to the relative path, and — when the pattern
119+ // names no directory — also to the bare file name, so "*.go" finds Go files at
120+ // any depth rather than only at the top level.
121+ func matchPath(pattern, rel string) bool {
122+ rel = filepath.ToSlash(rel)
123+ if ok, err := doublestar.Match(pattern, rel); err == nil && ok {
124+ return true
125+ }
126+ if !strings.Contains(pattern, "/") {
127+ if ok, err := doublestar.Match(pattern, filepath.Base(rel)); err == nil && ok {
128+ return true
129+ }
130+ }
131+ return false
132+ }
133+
134+ // shouldSkip prunes noise directories, unless the pattern explicitly names one.
135+ func shouldSkip(name, pattern string) bool {
136+ return skipDirs[name] && !strings.Contains(pattern, name)
137+ }
138+
139+ // displayPath prefers a path relative to the working root: shorter to read and
140+ // shorter in the context window.
141+ func displayPath(root, p string) string {
142+ if root == "" {
143+ return p
144+ }
145+ rel, err := filepath.Rel(root, p)
146+ if err != nil || strings.HasPrefix(rel, "..") {
147+ return p
148+ }
149+ return filepath.ToSlash(rel)
150+ }
+234−0internal/tools/grep.goaddedView File
@@ -0,0 +1,234 @@
1+ package tools
2+
3+ import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "fmt"
8+ "io/fs"
9+ "os"
10+ "os/exec"
11+ "path/filepath"
12+ "regexp"
13+ "sort"
14+ "strconv"
15+ "strings"
16+ )
17+
18+ // ripgrep is the path to rg, or empty when it is not installed. It is a
19+ // variable so tests can exercise the pure-Go fallback on a machine that has rg.
20+ var ripgrep = func() string {
21+ p, err := exec.LookPath("rg")
22+ if err != nil {
23+ return ""
24+ }
25+ return p
26+ }()
27+
28+ // match is one hit, kept structured until output so results from either engine
29+ // can be sorted the same way. ripgrep walks in parallel and returns files in a
30+ // nondeterministic order; without this, the same search would produce different
31+ // bytes on different machines, which would show up as eval noise and as prompt
32+ // cache misses.
33+ type match struct {
34+ path string
35+ line int
36+ text string
37+ }
38+
39+ func (m match) String() string { return fmt.Sprintf("%s:%d: %s", m.path, m.line, m.text) }
40+
41+ func sortMatches(ms []match) {
42+ sort.Slice(ms, func(i, j int) bool {
43+ if ms[i].path != ms[j].path {
44+ return ms[i].path < ms[j].path
45+ }
46+ return ms[i].line < ms[j].line
47+ })
48+ }
49+
50+ // GrepArgs is the grep input.
51+ type GrepArgs struct {
52+ Pattern string `json:"pattern" jsonschema:"description=Regular expression to search for"`
53+ Path string `json:"path,omitempty" jsonschema:"description=File or directory to search; defaults to the working root"`
54+ Glob string `json:"glob,omitempty" jsonschema:"description=Only search files matching this glob, e.g. *.go"`
55+ IgnoreCase bool `json:"ignore_case,omitempty" jsonschema:"description=Match case-insensitively"`
56+ }
57+
58+ // NewGrep builds the grep tool. It shells out to ripgrep when available and
59+ // falls back to a pure-Go walk otherwise; the output format is the same either
60+ // way, so the model cannot tell which ran.
61+ func NewGrep(root string) Tool {
62+ return New("grep", strings.TrimSpace(`
63+ Search file contents by regular expression. Returns "path:line: text" for each
64+ match. Narrow with glob (e.g. "*.go") or path. This is how you find code you
65+ have not been shown — reach for it before guessing at file names.`),
66+ func(ctx context.Context, in GrepArgs) (string, error) {
67+ if in.Pattern == "" {
68+ return "", fmt.Errorf("pattern is required")
69+ }
70+ re, err := compile(in.Pattern, in.IgnoreCase)
71+ if err != nil {
72+ return "", err
73+ }
74+ base := resolveBase(root, in.Path)
75+ if _, err := os.Stat(base); err != nil {
76+ return "", fmt.Errorf("no such file or directory: %s", displayPath(root, base))
77+ }
78+
79+ var matches []match
80+ if ripgrep != "" {
81+ matches, err = grepRipgrep(ctx, root, base, in)
82+ } else {
83+ matches, err = grepWalk(ctx, root, base, in, re)
84+ }
85+ if err != nil {
86+ return "", err
87+ }
88+ if len(matches) == 0 {
89+ where := displayPath(root, base)
90+ if in.Glob != "" {
91+ where += " matching " + in.Glob
92+ }
93+ return fmt.Sprintf("No matches for %q in %s.", in.Pattern, where), nil
94+ }
95+
96+ sortMatches(matches)
97+ shown := matches
98+ if len(shown) > maxResults {
99+ shown = shown[:maxResults]
100+ }
101+ lines := make([]string, 0, len(shown))
102+ for _, m := range shown {
103+ lines = append(lines, m.String())
104+ }
105+ out := strings.Join(lines, "\n")
106+ if len(matches) > len(shown) {
107+ out += fmt.Sprintf("\n\n(%d of %d matches shown; narrow the pattern, path or glob to see the rest)",
108+ len(shown), len(matches))
109+ }
110+ return out, nil
111+ })
112+ }
113+
114+ // compile validates the pattern up front so a bad regex comes back as a clear
115+ // error the model can fix, rather than as ripgrep's exit status.
116+ func compile(pattern string, ignoreCase bool) (*regexp.Regexp, error) {
117+ if ignoreCase {
118+ pattern = "(?i)" + pattern
119+ }
120+ re, err := regexp.Compile(pattern)
121+ if err != nil {
122+ return nil, fmt.Errorf("invalid regular expression: %w", err)
123+ }
124+ return re, nil
125+ }
126+
127+ func grepRipgrep(ctx context.Context, root, base string, in GrepArgs) ([]match, error) {
128+ args := []string{"--line-number", "--no-heading", "--color=never", "--no-messages"}
129+ if in.IgnoreCase {
130+ args = append(args, "--ignore-case")
131+ }
132+ if in.Glob != "" {
133+ args = append(args, "--glob", in.Glob)
134+ }
135+ for dir := range skipDirs {
136+ args = append(args, "--glob", "!"+dir+"/")
137+ }
138+ args = append(args, "--regexp", in.Pattern, base)
139+
140+ cmd := exec.CommandContext(ctx, ripgrep, args...)
141+ out, err := cmd.Output()
142+ if err != nil {
143+ // rg exits 1 when there are simply no matches; that is not a failure.
144+ var exitErr *exec.ExitError
145+ if !bytes.Contains(out, []byte(":")) && errorsAs(err, &exitErr) && exitErr.ExitCode() == 1 {
146+ return nil, nil
147+ }
148+ if ctx.Err() != nil {
149+ return nil, ctx.Err()
150+ }
151+ if !errorsAs(err, &exitErr) {
152+ return nil, fmt.Errorf("grep: %w", err)
153+ }
154+ }
155+
156+ var matches []match
157+ sc := bufio.NewScanner(bytes.NewReader(out))
158+ sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
159+ for sc.Scan() {
160+ if m, ok := parseRipgrepLine(root, sc.Text()); ok {
161+ matches = append(matches, m)
162+ }
163+ }
164+ return matches, nil
165+ }
166+
167+ // parseRipgrepLine splits "abs/path:12:text" into a match.
168+ func parseRipgrepLine(root, line string) (match, bool) {
169+ parts := strings.SplitN(line, ":", 3)
170+ if len(parts) < 3 {
171+ return match{}, false
172+ }
173+ n, err := strconv.Atoi(parts[1])
174+ if err != nil {
175+ return match{}, false
176+ }
177+ return match{path: displayPath(root, parts[0]), line: n, text: capLine(strings.TrimSpace(parts[2]))}, true
178+ }
179+
180+ func grepWalk(ctx context.Context, root, base string, in GrepArgs, re *regexp.Regexp) ([]match, error) {
181+ var matches []match
182+ err := filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
183+ if err != nil {
184+ return nil
185+ }
186+ if ctx.Err() != nil {
187+ return ctx.Err()
188+ }
189+ if d.IsDir() {
190+ if p != base && shouldSkip(d.Name(), in.Glob) {
191+ return fs.SkipDir
192+ }
193+ return nil
194+ }
195+ if in.Glob != "" {
196+ rel, relErr := filepath.Rel(base, p)
197+ if relErr != nil || !matchPath(in.Glob, rel) {
198+ return nil
199+ }
200+ }
201+ data, readErr := os.ReadFile(p)
202+ if readErr != nil || isBinary(data) {
203+ return nil
204+ }
205+ for i, line := range strings.Split(string(data), "\n") {
206+ if re.MatchString(line) {
207+ matches = append(matches, match{
208+ path: displayPath(root, p),
209+ line: i + 1,
210+ text: capLine(strings.TrimSpace(line)),
211+ })
212+ }
213+ }
214+ return nil
215+ })
216+ return matches, err
217+ }
218+
219+ // isBinary treats a NUL byte in the first block as the signal, which is what
220+ // grep and ripgrep both do.
221+ func isBinary(data []byte) bool {
222+ if len(data) > 8000 {
223+ data = data[:8000]
224+ }
225+ return bytes.IndexByte(data, 0) >= 0
226+ }
227+
228+ func errorsAs(err error, target **exec.ExitError) bool {
229+ e, ok := err.(*exec.ExitError)
230+ if ok {
231+ *target = e
232+ }
233+ return ok
234+ }
+15−5internal/tools/read.goView File
@@ -98,16 +98,26 @@ func splitLines(s string) []string {
9898 return lines
9999 }
100100
101- // resolve anchors a relative path to root. The real path jail lands in Phase 6.
101+ // resolve anchors a relative path to root, requiring one. The real path jail
102+ // lands in Phase 6.
102103 func resolve(root, path string) (string, error) {
103104 if path == "" {
104105 return "", fmt.Errorf("path is required")
105106 }
106- if filepath.IsAbs(path) {
107- return filepath.Clean(path), nil
108- }
107+ return resolveBase(root, path), nil
108+ }
109+
110+ // resolveBase is resolve for the search tools, where an omitted path means the
111+ // working root rather than an error.
112+ func resolveBase(root, path string) string {
109113 if root == "" {
110114 root = "."
111115 }
112- return filepath.Join(root, path), nil
116+ if path == "" {
117+ return root
118+ }
119+ if filepath.IsAbs(path) {
120+ return filepath.Clean(path)
121+ }
122+ return filepath.Join(root, path)
113123 }
+251−0internal/tools/search_test.goaddedView File
@@ -0,0 +1,251 @@
1+ package tools
2+
3+ import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+ )
12+
13+ // searchRepo builds a small tree with the shapes that matter: nesting, a
14+ // pruned directory, and a binary file.
15+ func searchRepo(t *testing.T) string {
16+ t.Helper()
17+ dir := t.TempDir()
18+ writeFile(t, dir, "go.mod", "module harness\n")
19+ writeFile(t, dir, "cmd/harness/main.go", "package main\n\nfunc main() { run() }\n")
20+ writeFile(t, dir, "internal/agent/loop.go", "package agent\n\nfunc Run() {}\n")
21+ writeFile(t, dir, "internal/agent/loop_test.go", "package agent\n\nfunc TestRun(t *testing.T) {}\n")
22+ writeFile(t, dir, "README.md", "# harness\n\nRun it.\n")
23+ writeFile(t, dir, "node_modules/junk/index.js", "function run() {}\n")
24+ writeFile(t, dir, ".git/config", "[core]\n\trun = true\n")
25+ if err := os.WriteFile(filepath.Join(dir, "bin.dat"), []byte{0x00, 0x01, 'r', 'u', 'n'}, 0o644); err != nil {
26+ t.Fatal(err)
27+ }
28+ return dir
29+ }
30+
31+ func call(t *testing.T, tool Tool, args any) string {
32+ t.Helper()
33+ raw, err := json.Marshal(args)
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ out, err := tool.Execute(context.Background(), raw)
38+ if err != nil {
39+ t.Fatalf("%s: %v", tool.Name(), err)
40+ }
41+ return out
42+ }
43+
44+ func TestGlob(t *testing.T) {
45+ dir := searchRepo(t)
46+ tool := NewGlob(dir)
47+
48+ tests := []struct {
49+ name string
50+ args GlobArgs
51+ want []string
52+ unwanted []string
53+ }{
54+ {
55+ name: "bare pattern matches at any depth",
56+ args: GlobArgs{Pattern: "*.go"},
57+ want: []string{"cmd/harness/main.go", "internal/agent/loop.go", "internal/agent/loop_test.go"},
58+ unwanted: []string{"go.mod", "README.md"},
59+ },
60+ {
61+ name: "pattern with a slash is anchored to the subtree",
62+ args: GlobArgs{Pattern: "internal/**/*_test.go"},
63+ want: []string{"internal/agent/loop_test.go"},
64+ unwanted: []string{"cmd/harness/main.go"},
65+ },
66+ {
67+ name: "path narrows the search",
68+ args: GlobArgs{Pattern: "*.go", Path: "cmd"},
69+ want: []string{"cmd/harness/main.go"},
70+ unwanted: []string{"internal/agent/loop.go"},
71+ },
72+ {
73+ name: "noise directories are pruned",
74+ args: GlobArgs{Pattern: "*.js"},
75+ unwanted: []string{"node_modules"},
76+ },
77+ {
78+ name: "exact name",
79+ args: GlobArgs{Pattern: "go.mod"},
80+ want: []string{"go.mod"},
81+ },
82+ }
83+ for _, tc := range tests {
84+ t.Run(tc.name, func(t *testing.T) {
85+ out := call(t, tool, tc.args)
86+ for _, w := range tc.want {
87+ if !strings.Contains(out, w) {
88+ t.Errorf("missing %q in:\n%s", w, out)
89+ }
90+ }
91+ for _, u := range tc.unwanted {
92+ if strings.Contains(out, u) {
93+ t.Errorf("unexpected %q in:\n%s", u, out)
94+ }
95+ }
96+ })
97+ }
98+ }
99+
100+ func TestGlobNoMatchesSaysSoUsefully(t *testing.T) {
101+ out := call(t, NewGlob(searchRepo(t)), GlobArgs{Pattern: "*.rs"})
102+ if !strings.Contains(out, "No files match") || !strings.Contains(out, "files scanned") {
103+ t.Errorf("a miss should report the scan, got: %s", out)
104+ }
105+ }
106+
107+ func TestGlobSortsNewestFirst(t *testing.T) {
108+ dir := searchRepo(t)
109+ newest := filepath.Join(dir, "internal/agent/loop.go")
110+ if err := os.Chtimes(newest, timeNow(), timeNow()); err != nil {
111+ t.Fatal(err)
112+ }
113+ out := call(t, NewGlob(dir), GlobArgs{Pattern: "*.go"})
114+ if first := strings.SplitN(out, "\n", 2)[0]; first != "internal/agent/loop.go" {
115+ t.Errorf("first result = %q, want the most recently modified file", first)
116+ }
117+ }
118+
119+ func TestGlobErrors(t *testing.T) {
120+ tool := NewGlob(searchRepo(t))
121+ for _, tc := range []struct {
122+ name, want string
123+ args GlobArgs
124+ }{
125+ {name: "no pattern", want: "pattern is required", args: GlobArgs{}},
126+ {name: "bad path", want: "no such directory", args: GlobArgs{Pattern: "*.go", Path: "nowhere"}},
127+ } {
128+ t.Run(tc.name, func(t *testing.T) {
129+ raw, _ := json.Marshal(tc.args)
130+ if _, err := tool.Execute(context.Background(), raw); err == nil || !strings.Contains(err.Error(), tc.want) {
131+ t.Errorf("error = %v, want %q", err, tc.want)
132+ }
133+ })
134+ }
135+ }
136+
137+ func TestGrep(t *testing.T) {
138+ dir := searchRepo(t)
139+ tests := []struct {
140+ name string
141+ args GrepArgs
142+ want []string
143+ unwanted []string
144+ }{
145+ {
146+ name: "finds a symbol with path and line number",
147+ args: GrepArgs{Pattern: `func Run\(\)`},
148+ want: []string{"internal/agent/loop.go:3: func Run() {}"},
149+ },
150+ {
151+ name: "glob narrows to a file type",
152+ args: GrepArgs{Pattern: "Run", Glob: "*.md"},
153+ want: []string{"README.md"},
154+ unwanted: []string{".go:"},
155+ },
156+ {
157+ name: "case sensitive by default",
158+ args: GrepArgs{Pattern: "RUN"},
159+ unwanted: []string{".go:", ".md:"},
160+ },
161+ {
162+ name: "ignore_case",
163+ args: GrepArgs{Pattern: "RUN", IgnoreCase: true},
164+ want: []string{"README.md"},
165+ },
166+ {
167+ name: "noise directories and binaries are skipped",
168+ args: GrepArgs{Pattern: "run"},
169+ unwanted: []string{"node_modules", ".git/config", "bin.dat"},
170+ },
171+ {
172+ name: "path narrows the search",
173+ args: GrepArgs{Pattern: "package", Path: "cmd"},
174+ want: []string{"cmd/harness/main.go"},
175+ },
176+ }
177+ // Both engines must produce identical output, or eval numbers would depend
178+ // on whether the machine has ripgrep installed.
179+ for _, engine := range []string{"ripgrep", "walk"} {
180+ t.Run(engine, func(t *testing.T) {
181+ if engine == "walk" {
182+ saved := ripgrep
183+ ripgrep = ""
184+ t.Cleanup(func() { ripgrep = saved })
185+ } else if ripgrep == "" {
186+ t.Skip("ripgrep not installed")
187+ }
188+ tool := NewGrep(dir)
189+ for _, tc := range tests {
190+ t.Run(tc.name, func(t *testing.T) {
191+ out := call(t, tool, tc.args)
192+ for _, w := range tc.want {
193+ if !strings.Contains(out, w) {
194+ t.Errorf("missing %q in:\n%s", w, out)
195+ }
196+ }
197+ for _, u := range tc.unwanted {
198+ if strings.Contains(out, u) {
199+ t.Errorf("unexpected %q in:\n%s", u, out)
200+ }
201+ }
202+ })
203+ }
204+ })
205+ }
206+ }
207+
208+ func TestGrepEnginesAgree(t *testing.T) {
209+ if ripgrep == "" {
210+ t.Skip("ripgrep not installed")
211+ }
212+ dir := searchRepo(t)
213+ withRG := call(t, NewGrep(dir), GrepArgs{Pattern: "func"})
214+
215+ saved := ripgrep
216+ ripgrep = ""
217+ defer func() { ripgrep = saved }()
218+ withWalk := call(t, NewGrep(dir), GrepArgs{Pattern: "func"})
219+
220+ if withRG != withWalk {
221+ t.Errorf("engines disagree:\nripgrep:\n%s\nwalk:\n%s", withRG, withWalk)
222+ }
223+ }
224+
225+ func TestGrepNoMatchIsNotAnError(t *testing.T) {
226+ for _, engine := range []string{"ripgrep", "walk"} {
227+ t.Run(engine, func(t *testing.T) {
228+ if engine == "walk" {
229+ saved := ripgrep
230+ ripgrep = ""
231+ t.Cleanup(func() { ripgrep = saved })
232+ } else if ripgrep == "" {
233+ t.Skip("ripgrep not installed")
234+ }
235+ out := call(t, NewGrep(searchRepo(t)), GrepArgs{Pattern: "zzzznotpresent"})
236+ if !strings.Contains(out, "No matches") {
237+ t.Errorf("want an explicit no-match message, got %q", out)
238+ }
239+ })
240+ }
241+ }
242+
243+ func TestGrepInvalidRegexIsReported(t *testing.T) {
244+ raw, _ := json.Marshal(GrepArgs{Pattern: "func ("})
245+ _, err := NewGrep(searchRepo(t)).Execute(context.Background(), raw)
246+ if err == nil || !strings.Contains(err.Error(), "invalid regular expression") {
247+ t.Errorf("error = %v, want a regex error the model can fix", err)
248+ }
249+ }
250+
251+ func timeNow() time.Time { return time.Now().Add(time.Hour) }