Files
gnoma/internal/router/selector.go
vikingowl cb2d63d06f feat: Ollama/gemma4 compat — /init flow, stream filter, safety fixes
provider/openai:
- Fix doubled tool call args (argsComplete flag): Ollama sends complete
  args in the first streaming chunk then repeats them as delta, causing
  doubled JSON and 400 errors in elfs
- Handle fs: prefix (gemma4 uses fs:grep instead of fs.grep)
- Add Reasoning field support for Ollama thinking output

cmd/gnoma:
- Early TTY detection so logger is created with correct destination
  before any component gets a reference to it (fixes slog WARN bleed
  into TUI textarea)

permission:
- Exempt spawn_elfs and agent tools from safety scanner: elf prompt
  text may legitimately mention .env/.ssh/credentials patterns and
  should not be blocked

tui/app:
- /init retry chain: no-tool-calls → spawn_elfs nudge → write nudge
  (ask for plain text output) → TUI fallback write from streamBuf
- looksLikeAgentsMD + extractMarkdownDoc: validate and clean fallback
  content before writing (reject refusals, strip narrative preambles)
- Collapse thinking output to 3 lines; ctrl+o to expand (live stream
  and committed messages)
- Stream-level filter for model pseudo-tool-call blocks: suppresses
  <<tool_code>>...</tool_code>> and <<function_call>>...<tool_call|>
  from entering streamBuf across chunk boundaries
- sanitizeAssistantText regex covers both block formats
- Reset streamFilterClose at every turn start
2026-04-05 19:24:51 +02:00

204 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package router
import (
"math"
)
// Strategy identifies how a task should be executed.
type Strategy int
const (
StrategySingleArm Strategy = iota
// Future (M9): StrategyCascade, StrategyParallelEnsemble, StrategyMultiRound
)
// RoutingDecision is the result of arm selection.
type RoutingDecision struct {
Strategy Strategy
Arm *Arm // primary arm
Error error
reservations []*Reservation // pool reservations held until commit/rollback
}
// Commit finalizes the routing decision, recording actual token consumption.
// Must be called when the request completes successfully.
func (d RoutingDecision) Commit(actualTokens int) {
for _, r := range d.reservations {
r.Commit(actualTokens)
}
}
// Rollback releases the routing decision's pool reservations without recording usage.
// Must be called when the request fails before any tokens are consumed.
func (d RoutingDecision) Rollback() {
for _, r := range d.reservations {
r.Rollback()
}
}
// selectBest picks the highest-scoring feasible arm using heuristic scoring.
// No bandit learning — that's M9. Just smart defaults based on model size,
// locality, task type, cost, and pool scarcity.
func selectBest(arms []*Arm, task Task) *Arm {
if len(arms) == 0 {
return nil
}
var best *Arm
bestScore := math.Inf(-1)
for _, arm := range arms {
score := scoreArm(arm, task)
if score > bestScore {
bestScore = score
best = arm
}
}
return best
}
// scoreArm computes a heuristic quality/cost score for an arm.
// Score = (quality × value) / effective_cost
func scoreArm(arm *Arm, task Task) float64 {
quality := heuristicQuality(arm, task)
value := task.ValueScore()
cost := effectiveCost(arm, task)
if cost <= 0 {
cost = 0.001 // prevent division by zero for free local models
}
return (quality * value) / cost
}
// heuristicQuality estimates arm quality without historical data.
func heuristicQuality(arm *Arm, task Task) float64 {
score := 0.5 // base
// Larger context window = better for complex tasks
if arm.Capabilities.ContextWindow >= 100000 {
score += 0.1
}
if arm.Capabilities.ContextWindow >= 200000 {
score += 0.05
}
// Thinking capability valuable for planning/orchestration/security
if arm.Capabilities.Thinking {
switch task.Type {
case TaskPlanning, TaskOrchestration, TaskSecurityReview:
score += 0.2
case TaskDebug, TaskRefactor:
score += 0.1
}
}
// Tool support required — arm without tools gets heavy penalty
if task.RequiresTools && !arm.SupportsTools() {
score *= 0.1
}
// Local models get a small boost (no network latency, privacy)
if arm.IsLocal {
score += 0.05
}
// Complexity adjustment — complex tasks penalize small/local models
if task.ComplexityScore > 0.7 && arm.IsLocal {
score *= 0.7
}
// Clamp
if score > 1.0 {
score = 1.0
}
if score < 0.0 {
score = 0.0
}
return score
}
// effectiveCost returns the base cost inflated by pool scarcity.
func effectiveCost(arm *Arm, task Task) float64 {
base := arm.EstimateCost(task.EstimatedTokens)
if base <= 0 {
base = 0.001 // local models are ~free but not zero for scoring
}
// Apply maximum scarcity multiplier across all pools
maxMultiplier := 1.0
for _, pool := range arm.Pools {
m := pool.ScarcityMultiplier()
if m > maxMultiplier {
maxMultiplier = m
}
}
return base * maxMultiplier
}
// filterFeasible returns arms that can handle the task (tools, pool capacity, quality).
// Arms that pass tool and pool checks but fall below the task's minimum quality threshold
// are collected separately and used as a last resort if no arm meets the threshold.
func filterFeasible(arms []*Arm, task Task) []*Arm {
threshold := DefaultThresholds[task.Type]
var feasible []*Arm
var belowQuality []*Arm // passed tool+pool but scored below minimum quality
for _, arm := range arms {
// Must support tools if task requires them
if task.RequiresTools && !arm.SupportsTools() {
continue
}
// Check all pools have capacity
poolsOK := true
for _, pool := range arm.Pools {
pool.CheckReset()
if !pool.CanAfford(arm.ID, task.EstimatedTokens) {
poolsOK = false
break
}
}
if !poolsOK {
continue
}
// Quality floor: arms below minimum are set aside, not discarded
if heuristicQuality(arm, task) < threshold.Minimum {
belowQuality = append(belowQuality, arm)
continue
}
feasible = append(feasible, arm)
}
// Degrade gracefully: if no arm meets quality threshold, use below-quality ones
if len(feasible) == 0 && len(belowQuality) > 0 {
return belowQuality
}
// If still empty and task requires tools, relax pool checks (last resort)
if len(feasible) == 0 && task.RequiresTools {
for _, arm := range arms {
if !arm.Capabilities.ToolUse {
continue
}
poolsOK := true
for _, pool := range arm.Pools {
if !pool.CanAfford(arm.ID, task.EstimatedTokens) {
poolsOK = false
break
}
}
if poolsOK {
feasible = append(feasible, arm)
}
}
}
return feasible
}