4b8f9aa39b
Build & push image / build-and-push (push) Successful in 33s
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>
149 lines
5.0 KiB
Go
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
|
|
}
|