Files
gadfly/cmd/gadfly/tools.go
T
Steve Dudenhoeffer 4b8f9aa39b
Build & push image / build-and-push (push) Successful in 33s
feat: dynamic auto specialist selection + worker-tier delegation
Two Phase-2 swarm upgrades:

- auto.go: GADFLY_SPECIALISTS=auto routes the review — a selector model
  (GADFLY_SELECTOR_MODEL, else the review model) reads the changed files + PR
  description and picks the smallest relevant lens set from the catalog, and may
  propose ad-hoc lenses for gaps (e.g. migrations). Structured output via
  majordomo.Generate[T]; capped + de-duped; falls back to the default suite.
- delegate.go: GADFLY_WORKER_MODEL adds a delegate_investigation tool so the
  reviewer offloads mechanical legwork (trace callers, gather usages) to a cheap
  worker sub-agent that returns an evidence-cited digest — the top model reasons
  over summaries, not raw file dumps. Workers get an fs-only toolbox (no
  sub-delegation). Unset = off.

resolveSpecialists now also returns the registry + an auto flag. Docs (README
Specialists + config table, CLAUDE.md, main.go header) + tests updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:35:59 -04:00

411 lines
12 KiB
Go

package main
import (
"bufio"
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
)
// Tool output bounds. The reviewer is a chat agent with a finite context, so
// every tool caps how much it can pull in one call — a runaway read_file or
// grep would blow the window and stall the loop.
const (
maxFileBytes = 64 * 1024 // per read_file call
maxReadLines = 800 // per read_file call
maxGrepResults = 200 // per grep call
maxFindResults = 200 // per find_files call
maxLineLen = 400 // truncate any single returned line to this
)
// skipDirs are never descended into by grep / find_files — noise and bulk that
// a code reviewer never needs and that would swamp the results.
var skipDirs = map[string]bool{
".git": true,
"node_modules": true,
"vendor": true,
}
// repoFS is a read-only, sandboxed view of the checked-out repository. Every
// path argument from the model is resolved against root and rejected if it
// escapes (symlink or `..` traversal), so a hostile diff can never make the
// reviewer read outside the checkout.
type repoFS struct {
root string // absolute, symlink-resolved repo root
diff string // the full PR unified diff (served by get_diff)
worker llm.Model // optional cheap model for delegate_investigation; nil = no delegation
}
// newRepoFS resolves root to an absolute, symlink-free path.
func newRepoFS(root, diff string) (*repoFS, error) {
abs, err := filepath.Abs(root)
if err != nil {
return nil, fmt.Errorf("resolve repo dir: %w", err)
}
// EvalSymlinks so prefix containment checks survive a symlinked root
// (e.g. macOS /tmp -> /private/tmp).
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
abs = resolved
}
info, err := os.Stat(abs)
if err != nil {
return nil, fmt.Errorf("repo dir %q: %w", root, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("repo dir %q is not a directory", root)
}
return &repoFS{root: abs, diff: diff}, nil
}
// resolve maps a model-supplied relative path to an absolute path inside the
// sandbox, rejecting anything that escapes root. An empty path means root.
func (r *repoFS) resolve(rel string) (string, error) {
rel = strings.TrimSpace(rel)
rel = strings.TrimPrefix(rel, "./")
if rel == "" || rel == "." {
return r.root, nil
}
if filepath.IsAbs(rel) {
// Allow an absolute path only if it already points inside the sandbox.
clean := filepath.Clean(rel)
if err := r.contains(clean); err != nil {
return "", err
}
return clean, nil
}
joined := filepath.Clean(filepath.Join(r.root, rel))
if err := r.contains(joined); err != nil {
return "", err
}
return joined, nil
}
// contains verifies abs is root or lives beneath it.
func (r *repoFS) contains(abs string) error {
if abs == r.root {
return nil
}
if !strings.HasPrefix(abs, r.root+string(os.PathSeparator)) {
return fmt.Errorf("path escapes the repository sandbox")
}
return nil
}
// fsTools is the set of read-only repository tools.
func (r *repoFS) fsTools() []llm.Tool {
return []llm.Tool{
r.readFileTool(),
r.listDirTool(),
r.grepTool(),
r.findFilesTool(),
r.getDiffTool(),
}
}
// toolbox builds the reviewer's toolbox: the read-only repo tools, plus the
// delegate_investigation tool when a worker model is configured.
func (r *repoFS) toolbox() (*llm.Toolbox, error) {
box := llm.NewToolbox("gadfly")
tools := r.fsTools()
if r.worker != nil {
tools = append(tools, r.delegateTool())
}
for _, t := range tools {
if err := box.Add(t); err != nil {
return nil, fmt.Errorf("add tool %q: %w", t.Name, err)
}
}
return box, nil
}
// workerToolbox is the toolbox handed to a delegated worker sub-agent: the
// read-only repo tools only (no delegate tool — workers don't sub-delegate).
func (r *repoFS) workerToolbox() (*llm.Toolbox, error) {
box := llm.NewToolbox("gadfly-worker")
for _, t := range r.fsTools() {
if err := box.Add(t); err != nil {
return nil, fmt.Errorf("add tool %q: %w", t.Name, err)
}
}
return box, nil
}
type readFileArgs struct {
Path string `json:"path" description:"Repository-relative path of the file to read, e.g. pkg/logic/agentexec/pipeline.go"`
StartLine int `json:"start_line,omitempty" description:"Optional 1-based line to start from (default 1)."`
Limit int `json:"limit,omitempty" description:"Optional max number of lines to return (default/maximum 800)."`
}
func (r *repoFS) readFileTool() llm.Tool {
return llm.DefineTool[readFileArgs](
"read_file",
"Read a file from the repository at its current checked-out state, with line numbers. Use this to verify the surrounding code, imports, and symbols a diff hunk touches before reporting an issue.",
func(_ context.Context, args readFileArgs) (any, error) {
abs, err := r.resolve(args.Path)
if err != nil {
return nil, err
}
info, err := os.Stat(abs)
if err != nil {
return nil, fmt.Errorf("stat %q: %w", args.Path, err)
}
if info.IsDir() {
return nil, fmt.Errorf("%q is a directory; use list_dir", args.Path)
}
f, err := os.Open(abs)
if err != nil {
return nil, fmt.Errorf("open %q: %w", args.Path, err)
}
defer f.Close()
start := args.StartLine
if start < 1 {
start = 1
}
limit := args.Limit
if limit <= 0 || limit > maxReadLines {
limit = maxReadLines
}
var b strings.Builder
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
lineNo := 0
emitted := 0
for sc.Scan() {
lineNo++
if lineNo < start {
continue
}
if emitted >= limit || b.Len() >= maxFileBytes {
fmt.Fprintf(&b, "... (truncated at line %d; call read_file again with start_line=%d for more)\n", lineNo, lineNo)
break
}
line := sc.Text()
if len(line) > maxLineLen {
line = line[:maxLineLen] + "…"
}
fmt.Fprintf(&b, "%d\t%s\n", lineNo, line)
emitted++
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("read %q: %w", args.Path, err)
}
if emitted == 0 {
return fmt.Sprintf("(%s has no lines at/after %d; file has %d lines)", args.Path, start, lineNo), nil
}
return b.String(), nil
},
)
}
type listDirArgs struct {
Path string `json:"path,omitempty" description:"Optional repository-relative directory (default: repo root)."`
}
func (r *repoFS) listDirTool() llm.Tool {
return llm.DefineTool[listDirArgs](
"list_dir",
"List the entries of a directory in the repository (directories marked with a trailing /). Use it to discover where code lives before reading.",
func(_ context.Context, args listDirArgs) (any, error) {
abs, err := r.resolve(args.Path)
if err != nil {
return nil, err
}
entries, err := os.ReadDir(abs)
if err != nil {
return nil, fmt.Errorf("list %q: %w", args.Path, err)
}
names := make([]string, 0, len(entries))
for _, e := range entries {
name := e.Name()
if e.IsDir() {
name += "/"
}
names = append(names, name)
}
sort.Strings(names)
if len(names) == 0 {
return "(empty directory)", nil
}
return strings.Join(names, "\n"), nil
},
)
}
type grepArgs struct {
Pattern string `json:"pattern" description:"A Go (RE2) regular expression to search for."`
Path string `json:"path,omitempty" description:"Optional repository-relative file or subdirectory to scope the search (default: whole repo)."`
MaxResults int `json:"max_results,omitempty" description:"Optional cap on matching lines returned (default/maximum 200)."`
}
func (r *repoFS) grepTool() llm.Tool {
return llm.DefineTool[grepArgs](
"grep",
"Search the repository's text files for a regular expression and return matching `path:line: text`. Use it to check whether a symbol, import, or call exists elsewhere before claiming a cross-file problem.",
func(_ context.Context, args grepArgs) (any, error) {
if strings.TrimSpace(args.Pattern) == "" {
return nil, fmt.Errorf("pattern is required")
}
re, err := regexp.Compile(args.Pattern)
if err != nil {
return nil, fmt.Errorf("invalid regexp: %w", err)
}
base, err := r.resolve(args.Path)
if err != nil {
return nil, err
}
limit := args.MaxResults
if limit <= 0 || limit > maxGrepResults {
limit = maxGrepResults
}
var out []string
truncated := false
walkErr := filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil // skip unreadable entries
}
if d.IsDir() {
if skipDirs[d.Name()] && path != base {
return filepath.SkipDir
}
return nil
}
if len(out) >= limit {
truncated = true
return filepath.SkipAll
}
matchesInFile(path, r.root, re, limit, &out)
return nil
})
if walkErr != nil {
return nil, fmt.Errorf("search: %w", walkErr)
}
if len(out) > limit {
out = out[:limit]
truncated = true
}
if len(out) == 0 {
return "(no matches)", nil
}
res := strings.Join(out, "\n")
if truncated {
res += fmt.Sprintf("\n... (truncated at %d matches; narrow the pattern or path)", limit)
}
return res, nil
},
)
}
// matchesInFile appends "relpath:line: text" for each regexp match in a single
// text file, stopping once the global cap is reached. Binary files (NUL in the
// first chunk) and oversized files are skipped.
func matchesInFile(path, root string, re *regexp.Regexp, limit int, out *[]string) {
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
rel, relErr := filepath.Rel(root, path)
if relErr != nil {
rel = path
}
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
lineNo := 0
for sc.Scan() {
if len(*out) >= limit {
return
}
lineNo++
line := sc.Text()
if lineNo == 1 && strings.IndexByte(line, 0) >= 0 {
return // looks binary
}
if re.MatchString(line) {
trimmed := strings.TrimSpace(line)
if len(trimmed) > maxLineLen {
trimmed = trimmed[:maxLineLen] + "…"
}
*out = append(*out, fmt.Sprintf("%s:%d: %s", rel, lineNo, trimmed))
}
}
}
type findFilesArgs struct {
Name string `json:"name" description:"Case-insensitive substring of the file path to match, e.g. \"pipeline.go\" or \"agentexec/\"."`
MaxResults int `json:"max_results,omitempty" description:"Optional cap on paths returned (default/maximum 200)."`
}
func (r *repoFS) findFilesTool() llm.Tool {
return llm.DefineTool[findFilesArgs](
"find_files",
"Find files whose repository-relative path contains a case-insensitive substring. Use it to locate a file by name when you don't know its directory.",
func(_ context.Context, args findFilesArgs) (any, error) {
needle := strings.ToLower(strings.TrimSpace(args.Name))
if needle == "" {
return nil, fmt.Errorf("name is required")
}
limit := args.MaxResults
if limit <= 0 || limit > maxFindResults {
limit = maxFindResults
}
var out []string
truncated := false
_ = filepath.WalkDir(r.root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
if skipDirs[d.Name()] && path != r.root {
return filepath.SkipDir
}
return nil
}
if len(out) >= limit {
truncated = true
return filepath.SkipAll
}
rel, relErr := filepath.Rel(r.root, path)
if relErr != nil {
return nil
}
if strings.Contains(strings.ToLower(rel), needle) {
out = append(out, rel)
}
return nil
})
sort.Strings(out)
if len(out) == 0 {
return "(no files matched)", nil
}
res := strings.Join(out, "\n")
if truncated {
res += fmt.Sprintf("\n... (truncated at %d files; narrow the name)", limit)
}
return res, nil
},
)
}
func (r *repoFS) getDiffTool() llm.Tool {
return llm.DefineTool[struct{}](
"get_diff",
"Return the complete unified diff under review. The diff is also included (possibly truncated) in the task message; call this to get the full, untruncated text.",
func(_ context.Context, _ struct{}) (any, error) {
if strings.TrimSpace(r.diff) == "" {
return "(empty diff)", nil
}
return r.diff, nil
},
)
}