Files
vikingowl 43ea2e562d feat(engine): two-stage tool routing for small local arms
Plan A from docs/superpowers/plans/2026-05-19-post-slm-unlock.md.

Small local SLMs (<=16k context) waste ~1500 tokens per turn on the
full tool catalogue. Two-stage routing replaces round-1 tools with a
single synthetic select_category schema; round-2+ sends only the
selected category's real tool schemas plus select_category for
re-selection.

- internal/tool/category.go: Category type, optional Categorized
  interface, CategoryOf() with meta fallback. fs.read/fs.ls -> read,
  fs.write/fs.edit -> write, fs.glob/fs.grep -> search, bash -> exec.
- internal/engine/twostage.go: synthetic select_category tool,
  intercept helper, per-turn selectedCategory state under e.mu.
- Engine round 1 forces ToolChoiceRequired so SLMs don't fall back to
  prose. State resets at the top and end of every runLoop.
- Activates automatically on a forced local arm with ContextWindow
  <=16384, or via [router].force_two_stage TOML key.
- Integration test drives a 3-round trip and asserts: round 1 emits
  exactly one schema (synthetic) with ToolChoiceRequired, round 2
  contains only write-category schemas + select_category, real
  fs.write executes. Invalid-category fallback round-trips back to
  round-1 mode.
2026-05-19 20:53:21 +02:00

71 lines
1.7 KiB
Go

package tool
import (
"slices"
"testing"
)
type categorizedStub struct {
stubTool
cat Category
}
func (c *categorizedStub) Category() Category { return c.cat }
func TestCategoryOf_DefaultIsMeta(t *testing.T) {
plain := &stubTool{name: "plain"}
if got := CategoryOf(plain); got != CategoryMeta {
t.Errorf("CategoryOf(plain) = %q, want %q", got, CategoryMeta)
}
}
func TestCategoryOf_DeclaredCategory(t *testing.T) {
cases := []struct {
name string
cat Category
}{
{"read", CategoryRead},
{"write", CategoryWrite},
{"search", CategorySearch},
{"exec", CategoryExec},
{"meta", CategoryMeta},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := &categorizedStub{stubTool: stubTool{name: tc.name}, cat: tc.cat}
if got := CategoryOf(s); got != tc.cat {
t.Errorf("CategoryOf(%s) = %q, want %q", tc.name, got, tc.cat)
}
})
}
}
func TestCategoryOf_InvalidFallsBackToMeta(t *testing.T) {
s := &categorizedStub{stubTool: stubTool{name: "bogus"}, cat: Category("not-a-real-category")}
if got := CategoryOf(s); got != CategoryMeta {
t.Errorf("CategoryOf(invalid) = %q, want %q", got, CategoryMeta)
}
}
func TestIsValidCategory(t *testing.T) {
for _, c := range AllCategories() {
if !IsValidCategory(c) {
t.Errorf("IsValidCategory(%q) = false, want true", c)
}
}
if IsValidCategory(Category("")) {
t.Error("empty category should be invalid")
}
if IsValidCategory(Category("nope")) {
t.Error("unknown category should be invalid")
}
}
func TestAllCategoriesStable(t *testing.T) {
want := []Category{CategoryRead, CategoryWrite, CategorySearch, CategoryExec, CategoryMeta}
got := AllCategories()
if !slices.Equal(got, want) {
t.Errorf("AllCategories() = %v, want %v", got, want)
}
}