package tools
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"github.com/bmatcuk/doublestar/v4"
)
// maxResults caps how many paths or matches a search tool returns. The count of
// what was suppressed goes back too, so the model knows to narrow the pattern
// rather than assume it has seen everything.
const maxResults = 100
// skipDirs are never descended into. They are where a repo keeps things the
// agent did not write and should not be reading by accident.
var skipDirs = map[string]bool{
".git": true, ".hg": true, ".svn": true,
"node_modules": true, "vendor": true, "target": true,
".venv": true, "venv": true, "__pycache__": true,
".harness": true, ".next": true, ".cache": true,
}
// GlobArgs is the glob input.
type GlobArgs struct {
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"`
Path string `json:"path,omitempty" jsonschema:"description=Directory to search under; defaults to the working root"`
}
// NewGlob builds the glob tool.
func NewGlob(root string) Tool {
return New("glob", strings.TrimSpace(`
Find files by name. Returns paths relative to the working root, most recently
modified first. "*.go" matches Go files at any depth; "internal/**/*_test.go"
matches within a subtree. Use this instead of guessing where a file lives.`),
func(ctx context.Context, in GlobArgs) (string, error) {
if in.Pattern == "" {
return "", fmt.Errorf("pattern is required")
}
base := resolveBase(root, in.Path)
if info, err := os.Stat(base); err != nil || !info.IsDir() {
return "", fmt.Errorf("no such directory: %s", displayPath(root, base))
}
type hit struct {
path string
mod int64
}
var hits []hit
var scanned int
err := filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil // unreadable entries are skipped, not fatal
}
if ctx.Err() != nil {
return ctx.Err()
}
if d.IsDir() {
if p != base && shouldSkip(d.Name(), in.Pattern) {
return fs.SkipDir
}
return nil
}
scanned++
rel, relErr := filepath.Rel(base, p)
if relErr != nil {
return nil
}
if !matchPath(in.Pattern, rel) {
return nil
}
var mod int64
if info, statErr := d.Info(); statErr == nil {
mod = info.ModTime().UnixNano()
}
hits = append(hits, hit{path: displayPath(root, p), mod: mod})
return nil
})
if err != nil {
return "", err
}
if len(hits) == 0 {
return fmt.Sprintf("No files match %q under %s (%d files scanned).",
in.Pattern, displayPath(root, base), scanned), nil
}
// Most recently modified first: in a repo being worked on, that is
// usually the file the task is about.
sort.Slice(hits, func(i, j int) bool {
if hits[i].mod != hits[j].mod {
return hits[i].mod > hits[j].mod
}
return hits[i].path < hits[j].path
})
var b strings.Builder
shown := hits
if len(shown) > maxResults {
shown = shown[:maxResults]
}
for _, h := range shown {
b.WriteString(h.path)
b.WriteByte('\n')
}
if len(hits) > len(shown) {
fmt.Fprintf(&b, "\n(%d of %d shown; narrow the pattern to see the rest)", len(shown), len(hits))
}
return b.String(), nil
})
}
// matchPath applies the pattern to the relative path, and — when the pattern
// names no directory — also to the bare file name, so "*.go" finds Go files at
// any depth rather than only at the top level.
func matchPath(pattern, rel string) bool {
rel = filepath.ToSlash(rel)
if ok, err := doublestar.Match(pattern, rel); err == nil && ok {
return true
}
if !strings.Contains(pattern, "/") {
if ok, err := doublestar.Match(pattern, filepath.Base(rel)); err == nil && ok {
return true
}
}
return false
}
// shouldSkip prunes noise directories, unless the pattern explicitly names one.
func shouldSkip(name, pattern string) bool {
return skipDirs[name] && !strings.Contains(pattern, name)
}
// displayPath prefers a path relative to the working root: shorter to read and
// shorter in the context window.
func displayPath(root, p string) string {
if root == "" {
return p
}
rel, err := filepath.Rel(root, p)
if err != nil || strings.HasPrefix(rel, "..") {
return p
}
return filepath.ToSlash(rel)
}