feat: add router foundation with task classification and arm selection

internal/router/ — core routing layer:
- Task classification: 10 types (boilerplate, generation, refactor,
  review, unit_test, planning, orchestration, security_review, debug,
  explain) with keyword heuristics and complexity scoring
- Arm registry: provider+model pairs with capabilities and cost
- Limit pools: shared resource budgets with scarcity multipliers,
  optimistic reservation, use-it-or-lose-it discounting
- Heuristic selector: score = (quality × value) / effective_cost
  Prefers tools, thinking for planning, penalizes small models on
  complex tasks
- Router: Select() picks best feasible arm, ForceArm() for CLI override

Engine now routes through router.Select() when configured.
Wired into CLI — arm registered per --provider/--model flags.

20 router tests. 173 tests total across 13 packages.
This commit is contained in:
2026-04-03 14:23:15 +02:00
parent 33dec722b8
commit b9faa30ea8
9 changed files with 1114 additions and 10 deletions

View File

@@ -7,13 +7,15 @@ import (
"somegit.dev/Owlibou/gnoma/internal/message"
"somegit.dev/Owlibou/gnoma/internal/provider"
"somegit.dev/Owlibou/gnoma/internal/router"
"somegit.dev/Owlibou/gnoma/internal/security"
"somegit.dev/Owlibou/gnoma/internal/tool"
)
// Config holds engine configuration.
type Config struct {
Provider provider.Provider
Provider provider.Provider // direct provider (used if Router is nil)
Router *router.Router // nil = use Provider directly
Tools *tool.Registry
Firewall *security.Firewall // nil = no scanning
System string // system prompt

View File

@@ -7,6 +7,7 @@ import (
"somegit.dev/Owlibou/gnoma/internal/message"
"somegit.dev/Owlibou/gnoma/internal/provider"
"somegit.dev/Owlibou/gnoma/internal/router"
"somegit.dev/Owlibou/gnoma/internal/stream"
)
@@ -38,16 +39,50 @@ func (e *Engine) runLoop(ctx context.Context, cb Callback) (*Turn, error) {
// Build provider request (gates tools on model capabilities)
req := e.buildRequest(ctx)
e.logger.Debug("streaming request",
"provider", e.cfg.Provider.Name(),
"model", req.Model,
"messages", len(req.Messages),
"tools", len(req.Tools),
"round", turn.Rounds,
)
// Route and stream
var s stream.Stream
var err error
// Stream from provider
s, err := e.cfg.Provider.Stream(ctx, req)
if e.cfg.Router != nil {
// Classify task from the latest user message
prompt := ""
for i := len(e.history) - 1; i >= 0; i-- {
if e.history[i].Role == message.RoleUser {
prompt = e.history[i].TextContent()
break
}
}
task := router.ClassifyTask(prompt)
task.EstimatedTokens = 4000 // rough default
e.logger.Debug("routing request",
"task_type", task.Type,
"complexity", task.ComplexityScore,
"round", turn.Rounds,
)
var arm *router.Arm
s, arm, err = e.cfg.Router.Stream(ctx, task, req)
if arm != nil {
e.logger.Debug("streaming request",
"provider", arm.Provider.Name(),
"model", arm.ModelName,
"arm", arm.ID,
"messages", len(req.Messages),
"tools", len(req.Tools),
"round", turn.Rounds,
)
}
} else {
e.logger.Debug("streaming request",
"provider", e.cfg.Provider.Name(),
"model", req.Model,
"messages", len(req.Messages),
"tools", len(req.Tools),
"round", turn.Rounds,
)
s, err = e.cfg.Provider.Stream(ctx, req)
}
if err != nil {
return nil, fmt.Errorf("provider stream: %w", err)
}