Files
gadfly/cmd/gadfly/auto.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

149 lines
5.0 KiB
Go

package main
import (
"context"
"fmt"
"os"
"sort"
"strings"
"gitea.stevedudenhoeffer.com/steve/majordomo"
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
)
// maxAutoSpecialists bounds how many lenses dynamic selection may run, so a
// runaway selector can't blow up cost.
const maxAutoSpecialists = 6
// autoCustom is an ad-hoc specialist the selector may propose when the change
// needs a lens the catalog lacks (e.g. DB migrations, i18n).
type autoCustom struct {
Name string `json:"name" description:"short lowercase id, e.g. migrations"`
Title string `json:"title" description:"human label for the section, e.g. 🗃️ DB migrations"`
Focus string `json:"focus" description:"one or two sentences telling the reviewer what to look for"`
}
// autoSelection is the structured result the selector returns.
type autoSelection struct {
Specialists []string `json:"specialists" description:"names of EXISTING catalog specialists that materially apply to this change"`
Custom []autoCustom `json:"custom" description:"ad-hoc specialists to add ONLY when the catalog lacks a clearly-needed lens; usually empty"`
}
// resolveSelectorModel returns the model that picks specialists in auto mode:
// GADFLY_SELECTOR_MODEL (a cheap tier is ideal) if set, else the review model.
func resolveSelectorModel(fallback llm.Model) (llm.Model, error) {
spec := strings.TrimSpace(os.Getenv("GADFLY_SELECTOR_MODEL"))
if spec == "" {
return fallback, nil
}
provider := strings.TrimSpace(os.Getenv("GADFLY_PROVIDER"))
if provider == "" {
provider = defaultProvider
}
return majordomo.Parse(buildSpec(provider, spec))
}
// autoSelectSpecialists asks the selector model which lenses a given diff needs.
// It picks from the registry and may invent a few ad-hoc lenses, capped and
// de-duplicated. On any failure it returns an error and the caller falls back
// to the default suite.
func autoSelectSpecialists(ctx context.Context, selector llm.Model, title, body, diff string, registry map[string]Specialist) ([]Specialist, error) {
system := "You are the router for an adversarial code review. Given a pull request's " +
"changed files and description plus a catalog of available review specialists (lenses), " +
"choose the SMALLEST set that materially applies to THIS change — usually 2 to 4. Skip " +
"lenses the diff doesn't touch (e.g. no 'performance' for a docs-only change). Strongly " +
"prefer existing catalog lenses; only propose a custom one when the change clearly needs a " +
"lens the catalog lacks (e.g. database migrations, i18n, build/CI). Be conservative."
user := fmt.Sprintf("PR title: %s\n\nPR description:\n%s\n\nChanged files:\n%s\n\nAvailable specialists (name — focus):\n%s\n\nSelect the lenses to run.",
strings.TrimSpace(title), strings.TrimSpace(body), changedFilesList(diff), catalog(registry))
sel, err := majordomo.Generate[autoSelection](ctx, selector, majordomo.Request{
System: system,
Messages: []llm.Message{llm.UserText(user)},
})
if err != nil {
return nil, fmt.Errorf("auto-select: %w", err)
}
var out []Specialist
seen := map[string]bool{}
add := func(sp Specialist) {
if sp.Name == "" || seen[sp.Name] || len(out) >= maxAutoSpecialists {
return
}
seen[sp.Name] = true
out = append(out, sp)
}
for _, name := range sel.Specialists {
if sp, ok := registry[strings.ToLower(strings.TrimSpace(name))]; ok {
add(sp)
}
}
for _, c := range sel.Custom {
name := strings.ToLower(strings.TrimSpace(c.Name))
if name == "" || strings.TrimSpace(c.Focus) == "" {
continue
}
if sp, ok := registry[name]; ok { // prefer the catalog definition if it exists
add(sp)
continue
}
add(Specialist{Name: name, Title: titleOr(c.Title, name), Focus: c.Focus})
}
if len(out) == 0 {
return nil, fmt.Errorf("auto-select returned no usable specialists")
}
return out, nil
}
// catalog renders the registry as a compact name — focus list for the selector.
func catalog(registry map[string]Specialist) string {
names := make([]string, 0, len(registry))
for k := range registry {
names = append(names, k)
}
sort.Strings(names)
var b strings.Builder
for _, n := range names {
fmt.Fprintf(&b, "- %s — %s\n", n, firstSentence(registry[n].Focus))
}
return b.String()
}
// changedFilesList extracts the changed file paths from a unified diff.
func changedFilesList(diff string) string {
var files []string
seen := map[string]bool{}
for _, line := range strings.Split(diff, "\n") {
if !strings.HasPrefix(line, "+++ ") && !strings.HasPrefix(line, "--- ") {
continue
}
p := strings.TrimSpace(line[4:])
if p == "/dev/null" {
continue
}
p = strings.TrimPrefix(strings.TrimPrefix(p, "a/"), "b/")
if p != "" && !seen[p] {
seen[p] = true
files = append(files, p)
}
}
if len(files) == 0 {
return "(could not parse file list; review the whole diff)"
}
return "- " + strings.Join(files, "\n- ")
}
func firstSentence(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '.'); i > 0 && i < 140 {
return s[:i]
}
if len(s) > 140 {
return s[:140] + "…"
}
return s
}