refactor: registry-driven provider resolution

Replace scattered provider references with a ModelRegistry as the single
source of truth for provider instances.

Previously AgentInstance held a Provider field, SubagentManager held a
provider reference, and FallbackCandidate carried a provider string.
Model switching at runtime could leave stale provider references in any
of those locations, causing requests to go to the wrong provider.

The registry is the only place a provider lives. All other code stores
a model name (the user-facing registry key, e.g. 'gpt-4o') and resolves
the provider at call time via registry.Get(modelName) → entry.Provider.

Model name (registry key) is always distinct from ModelID (the
protocol-stripped API identifier passed to Chat, e.g. 'gpt-4o' vs
'openai/gpt-4o'). NewModelRegistryFromProvider strips the protocol so
the registry key is never a protocol-prefixed string.

pkg/providers/registry.go (new)
- ModelRegistry maps model_name → {Provider, ModelID, ProviderKey}
- Providers with identical config share one instance (provider cache)
- NewModelRegistryFromProvider strips protocol prefix from modelID so
  registry key is always the user-facing name

pkg/providers/fallback.go
- FallbackCandidate reduced to {Model string} (registry key only)
- FallbackChain.WithProviderKeyFn groups cooldowns by provider so all
  models on the same provider share one cooldown bucket

pkg/agent/instance.go
- AgentInstance.Provider removed entirely
- Model field now stores registry key (model name), not ModelID
- sync.RWMutex added protecting Model, Candidates, SubagentMgr
- getModelSnapshot() / switchModel() enforce safe concurrent access

pkg/agent/loop.go
- All provider calls go through registry.Get(model) → entry.Provider.Chat
- Per-iteration snapshot via getModelSnapshot() prevents a concurrent
  /switch model from corrupting a running LLM iteration
- switchModel() atomically updates Model + Candidates + SubagentMgr
  and returns the old model name (eliminating the previously racy read)
- agentEntry() reads Model under RLock before registry lookup

pkg/tools/subagent.go
- SubagentManager no longer holds a provider reference
- Takes *ModelRegistry + defaultModelName; resolves provider at spawn time
- UpdateModel(name) replaces UpdateProvider(provider, id)

cmd/picoclaw/internal/{agent,gateway}/helpers.go
- Both entry points build NewModelRegistry(cfg) at startup and pass to loop

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
jrussellsmyth 2026-02-28 08:00:57 +00:00
parent df1b53fdf9
commit 33723ea4ff
14 changed files with 733 additions and 512 deletions

View file

@ -37,19 +37,16 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
cfg.Agents.Defaults.ModelName = model cfg.Agents.Defaults.ModelName = model
} }
provider, modelID, err := providers.CreateProvider(cfg) // Build model registry — same startup path as gatewayCmd
modelRegistry, err := providers.NewModelRegistry(cfg)
if err != nil { if err != nil {
return fmt.Errorf("error creating provider: %w", err) return fmt.Errorf("error creating model registry: %w", err)
}
// Use the resolved model ID from provider creation
if modelID != "" {
cfg.Agents.Defaults.ModelName = modelID
} }
defer modelRegistry.Close()
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
defer msgBus.Close() defer msgBus.Close()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) agentLoop := agent.NewAgentLoop(cfg, msgBus, modelRegistry)
// Print agent startup info (only for interactive mode) // Print agent startup info (only for interactive mode)
startupInfo := agentLoop.GetStartupInfo() startupInfo := agentLoop.GetStartupInfo()

View file

@ -49,18 +49,15 @@ func gatewayCmd(debug bool) error {
return fmt.Errorf("error loading config: %w", err) return fmt.Errorf("error loading config: %w", err)
} }
provider, modelID, err := providers.CreateProvider(cfg) // Build model registry — the single source of truth for all providers.
// Providers are cached internally; models with identical configs share one.
modelRegistry, err := providers.NewModelRegistry(cfg)
if err != nil { if err != nil {
return fmt.Errorf("error creating provider: %w", err) return fmt.Errorf("error creating model registry: %w", err)
}
// Use the resolved model ID from provider creation
if modelID != "" {
cfg.Agents.Defaults.ModelName = modelID
} }
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) agentLoop := agent.NewAgentLoop(cfg, msgBus, modelRegistry)
// Print agent startup info // Print agent startup info
fmt.Println("\n📦 Agent Status:") fmt.Println("\n📦 Agent Status:")
@ -188,9 +185,7 @@ func gatewayCmd(debug bool) error {
<-sigChan <-sigChan
fmt.Println("\nShutting down...") fmt.Println("\nShutting down...")
if cp, ok := provider.(providers.StatefulProvider); ok { modelRegistry.Close()
cp.Close()
}
cancel() cancel()
msgBus.Close() msgBus.Close()

View file

@ -7,8 +7,10 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"sync"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/session"
@ -18,9 +20,13 @@ import (
// AgentInstance represents a fully configured agent with its own workspace, // AgentInstance represents a fully configured agent with its own workspace,
// session manager, context builder, and tool registry. // session manager, context builder, and tool registry.
type AgentInstance struct { type AgentInstance struct {
// mu protects Model, Candidates and SubagentMgr which can be changed at
// runtime by the /switch model command while the LLM loop is running.
mu sync.RWMutex
ID string ID string
Name string Name string
Model string Model string // registry key (model name)
Fallbacks []string Fallbacks []string
Workspace string Workspace string
MaxIterations int MaxIterations int
@ -29,32 +35,38 @@ type AgentInstance struct {
ContextWindow int ContextWindow int
SummarizeMessageThreshold int SummarizeMessageThreshold int
SummarizeTokenPercent int SummarizeTokenPercent int
Provider providers.LLMProvider
Sessions *session.SessionManager Sessions *session.SessionManager
ContextBuilder *ContextBuilder ContextBuilder *ContextBuilder
Tools *tools.ToolRegistry Tools *tools.ToolRegistry
Subagents *config.SubagentsConfig Subagents *config.SubagentsConfig
SkillsFilter []string SkillsFilter []string
Candidates []providers.FallbackCandidate Candidates []providers.FallbackCandidate
SubagentMgr *tools.SubagentManager // updated on /switch model
AllowReadPaths []*regexp.Regexp // compiled path whitelist for reads
AllowWritePaths []*regexp.Regexp // compiled path whitelist for writes
} }
// NewAgentInstance creates an agent instance from config. // NewAgentInstance creates an agent instance from config.
// The ModelRegistry is used to resolve the provider and model ID.
func NewAgentInstance( func NewAgentInstance(
agentCfg *config.AgentConfig, agentCfg *config.AgentConfig,
defaults *config.AgentDefaults, defaults *config.AgentDefaults,
cfg *config.Config, cfg *config.Config,
provider providers.LLMProvider, modelRegistry *providers.ModelRegistry,
) *AgentInstance { ) *AgentInstance {
workspace := resolveAgentWorkspace(agentCfg, defaults) workspace := resolveAgentWorkspace(agentCfg, defaults)
os.MkdirAll(workspace, 0o755) os.MkdirAll(workspace, 0o755)
model := resolveAgentModel(agentCfg, defaults) modelName := resolveAgentModel(agentCfg, defaults)
fallbacks := resolveAgentFallbacks(agentCfg, defaults) fallbacks := resolveAgentFallbacks(agentCfg, defaults)
// Model stays as the model name (registry key); provider is resolved at call time.
model := modelName
restrict := defaults.RestrictToWorkspace restrict := defaults.RestrictToWorkspace
readRestrict := restrict && !defaults.AllowReadOutsideWorkspace readRestrict := restrict && !defaults.AllowReadOutsideWorkspace
// Compile path whitelist patterns from config. // Compile path whitelist patterns once and store on the instance.
allowReadPaths := compilePatterns(cfg.Tools.AllowReadPaths) allowReadPaths := compilePatterns(cfg.Tools.AllowReadPaths)
allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths) allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths)
@ -113,53 +125,28 @@ func NewAgentInstance(
summarizeTokenPercent = 75 summarizeTokenPercent = 75
} }
// Resolve fallback candidates // Build fallback candidates from the registry using model names as keys.
modelCfg := providers.ModelConfig{ allModels := append([]string{modelName}, fallbacks...)
Primary: model, seen := make(map[string]bool)
Fallbacks: fallbacks, var candidates []providers.FallbackCandidate
} for _, name := range allModels {
resolveFromModelList := func(raw string) (string, bool) { name = strings.TrimSpace(name)
ensureProtocol := func(model string) string { if name == "" || seen[name] {
model = strings.TrimSpace(model)
if model == "" {
return ""
}
if strings.Contains(model, "/") {
return model
}
return "openai/" + model
}
raw = strings.TrimSpace(raw)
if raw == "" {
return "", false
}
if cfg != nil {
if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" {
return ensureProtocol(mc.Model), true
}
for i := range cfg.ModelList {
fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
if fullModel == "" {
continue continue
} }
if fullModel == raw { seen[name] = true
return ensureProtocol(fullModel), true if _, ok := modelRegistry.Get(name); ok {
} candidates = append(candidates, providers.FallbackCandidate{Model: name})
_, modelID := providers.ExtractProtocol(fullModel) } else {
if modelID == raw { logger.WarnCF("agent", "Model not found in registry, skipping",
return ensureProtocol(fullModel), true map[string]any{"model": name, "agent_id": agentID})
} }
} }
if len(candidates) == 0 {
logger.WarnCF("agent", "Agent has no valid candidates; will use registry default",
map[string]any{"agent_id": agentID, "configured_model": modelName})
} }
return "", false
}
candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
return &AgentInstance{ return &AgentInstance{
ID: agentID, ID: agentID,
Name: agentName, Name: agentName,
@ -172,13 +159,14 @@ func NewAgentInstance(
ContextWindow: maxTokens, ContextWindow: maxTokens,
SummarizeMessageThreshold: summarizeMessageThreshold, SummarizeMessageThreshold: summarizeMessageThreshold,
SummarizeTokenPercent: summarizeTokenPercent, SummarizeTokenPercent: summarizeTokenPercent,
Provider: provider,
Sessions: sessionsManager, Sessions: sessionsManager,
ContextBuilder: contextBuilder, ContextBuilder: contextBuilder,
Tools: toolsRegistry, Tools: toolsRegistry,
Subagents: subagents, Subagents: subagents,
SkillsFilter: skillsFilter, SkillsFilter: skillsFilter,
Candidates: candidates, Candidates: candidates,
AllowReadPaths: allowReadPaths,
AllowWritePaths: allowWritePaths,
} }
} }
@ -237,3 +225,27 @@ func expandHome(path string) string {
} }
return path return path
} }
// getModelSnapshot returns a consistent snapshot of the mutable routing fields.
// Use this at the start of each LLM iteration rather than reading fields directly.
func (a *AgentInstance) getModelSnapshot() (model string, candidates []providers.FallbackCandidate) {
a.mu.RLock()
defer a.mu.RUnlock()
model = a.Model
candidates = make([]providers.FallbackCandidate, len(a.Candidates))
copy(candidates, a.Candidates)
return
}
// switchModel atomically updates Model.
// Returns the previous model name.
func (a *AgentInstance) switchModel(model string) string {
a.mu.Lock()
defer a.mu.Unlock()
old := a.Model
a.Model = model
if a.SubagentMgr != nil {
a.SubagentMgr.UpdateModel(model)
}
return old
}

View file

@ -5,6 +5,7 @@ import (
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
) )
func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) { func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) {
@ -29,7 +30,7 @@ func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) {
cfg.Agents.Defaults.Temperature = &configuredTemp cfg.Agents.Defaults.Temperature = &configuredTemp
provider := &mockProvider{} provider := &mockProvider{}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, mockRegistry(provider))
if agent.MaxTokens != 1234 { if agent.MaxTokens != 1234 {
t.Fatalf("MaxTokens = %d, want %d", agent.MaxTokens, 1234) t.Fatalf("MaxTokens = %d, want %d", agent.MaxTokens, 1234)
@ -61,7 +62,7 @@ func TestNewAgentInstance_DefaultsTemperatureWhenZero(t *testing.T) {
cfg.Agents.Defaults.Temperature = &configuredTemp cfg.Agents.Defaults.Temperature = &configuredTemp
provider := &mockProvider{} provider := &mockProvider{}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, mockRegistry(provider))
if agent.Temperature != 0.0 { if agent.Temperature != 0.0 {
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.0) t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.0)
@ -87,7 +88,7 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
} }
provider := &mockProvider{} provider := &mockProvider{}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, mockRegistry(provider))
if agent.Temperature != 0.7 { if agent.Temperature != 0.7 {
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7) t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7)
@ -95,34 +96,6 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
} }
func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
tests := []struct {
name string
aliasName string
modelName string
apiBase string
wantProvider string
wantModel string
}{
{
name: "alias with provider prefix",
aliasName: "step-3.5-flash",
modelName: "openrouter/stepfun/step-3.5-flash:free",
apiBase: "https://openrouter.ai/api/v1",
wantProvider: "openrouter",
wantModel: "stepfun/step-3.5-flash:free",
},
{
name: "alias without provider prefix",
aliasName: "glm-5",
modelName: "glm-5",
apiBase: "https://api.z.ai/api/coding/paas/v4",
wantProvider: "openai",
wantModel: "glm-5",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
if err != nil { if err != nil {
t.Fatalf("Failed to create temp dir: %v", err) t.Fatalf("Failed to create temp dir: %v", err)
@ -133,30 +106,65 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
Model: tt.aliasName, Model: "step-3.5-flash",
}, },
}, },
ModelList: []config.ModelConfig{ ModelList: []config.ModelConfig{
{ {
ModelName: tt.aliasName, ModelName: "step-3.5-flash",
Model: tt.modelName, Model: "openrouter/stepfun/step-3.5-flash:free",
APIBase: tt.apiBase, APIBase: "https://openrouter.ai/api/v1",
}, },
}, },
} }
provider := &mockProvider{} reg, err := providers.NewModelRegistry(cfg)
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) if err != nil {
t.Fatalf("NewModelRegistry: %v", err)
}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, reg)
if len(agent.Candidates) != 1 { if len(agent.Candidates) != 1 {
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates)) t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
} }
if agent.Candidates[0].Provider != tt.wantProvider { if agent.Candidates[0].Model != "step-3.5-flash" {
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, tt.wantProvider) t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "step-3.5-flash")
} }
if agent.Candidates[0].Model != tt.wantModel { }
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, tt.wantModel)
} func TestNewAgentInstance_ResolveCandidatesFromModelListAliasWithoutProtocol(t *testing.T) {
}) tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "glm-5",
},
},
ModelList: []config.ModelConfig{
{
ModelName: "glm-5",
Model: "glm-5",
APIBase: "https://api.z.ai/api/coding/paas/v4",
},
},
}
reg, err := providers.NewModelRegistry(cfg)
if err != nil {
t.Fatalf("NewModelRegistry: %v", err)
}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, reg)
if len(agent.Candidates) != 1 {
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
}
if agent.Candidates[0].Model != "glm-5" {
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "glm-5")
} }
} }

View file

@ -37,6 +37,7 @@ type AgentLoop struct {
bus *bus.MessageBus bus *bus.MessageBus
cfg *config.Config cfg *config.Config
registry *AgentRegistry registry *AgentRegistry
modelRegistry *providers.ModelRegistry
state *state.Manager state *state.Manager
running atomic.Bool running atomic.Bool
summarizing sync.Map summarizing sync.Map
@ -60,22 +61,25 @@ type processOptions struct {
const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json." const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json."
func NewAgentLoop( // NewAgentLoop creates an agent loop. The ModelRegistry is the single source
cfg *config.Config, // of truth for all model→provider mappings.
msgBus *bus.MessageBus, func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, modelRegistry *providers.ModelRegistry) *AgentLoop {
provider providers.LLMProvider, agentRegistry := NewAgentRegistry(cfg, modelRegistry)
) *AgentLoop {
registry := NewAgentRegistry(cfg, provider)
// Register shared tools to all agents // Register shared tools to all agents
registerSharedTools(cfg, msgBus, registry, provider) registerSharedTools(cfg, msgBus, agentRegistry, modelRegistry)
// Set up shared fallback chain // Set up shared fallback chain
cooldown := providers.NewCooldownTracker() cooldown := providers.NewCooldownTracker()
fallbackChain := providers.NewFallbackChain(cooldown) fallbackChain := providers.NewFallbackChain(cooldown).WithProviderKeyFn(func(model string) string {
if entry, ok := modelRegistry.Get(model); ok {
return entry.ProviderKey
}
return model
})
// Create state manager using default agent's workspace for channel recording // Create state manager using default agent's workspace for channel recording
defaultAgent := registry.GetDefaultAgent() defaultAgent := agentRegistry.GetDefaultAgent()
var stateManager *state.Manager var stateManager *state.Manager
if defaultAgent != nil { if defaultAgent != nil {
stateManager = state.NewManager(defaultAgent.Workspace) stateManager = state.NewManager(defaultAgent.Workspace)
@ -84,7 +88,8 @@ func NewAgentLoop(
return &AgentLoop{ return &AgentLoop{
bus: msgBus, bus: msgBus,
cfg: cfg, cfg: cfg,
registry: registry, registry: agentRegistry,
modelRegistry: modelRegistry,
state: stateManager, state: stateManager,
summarizing: sync.Map{}, summarizing: sync.Map{},
fallback: fallbackChain, fallback: fallbackChain,
@ -96,7 +101,7 @@ func registerSharedTools(
cfg *config.Config, cfg *config.Config,
msgBus *bus.MessageBus, msgBus *bus.MessageBus,
registry *AgentRegistry, registry *AgentRegistry,
provider providers.LLMProvider, modelRegistry *providers.ModelRegistry,
) { ) {
for _, agentID := range registry.ListAgentIDs() { for _, agentID := range registry.ListAgentIDs() {
agent, ok := registry.GetAgent(agentID) agent, ok := registry.GetAgent(agentID)
@ -161,9 +166,10 @@ func registerSharedTools(
agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache)) agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache))
agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace)) agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace))
// Spawn tool with allowlist checker // Spawn tool with allowlist checker — each agent uses its own provider
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus) subagentManager := tools.NewSubagentManager(modelRegistry, agent.Model, agent.Workspace, msgBus)
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
agent.SubagentMgr = subagentManager
spawnTool := tools.NewSpawnTool(subagentManager) spawnTool := tools.NewSpawnTool(subagentManager)
currentAgentID := agentID currentAgentID := agentID
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool { spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
@ -317,6 +323,23 @@ func (al *AgentLoop) Stop() {
al.running.Store(false) al.running.Store(false)
} }
// agentEntry resolves the registry entry for an agent's current model.
// All LLM calls should go through this to ensure provider is always derived from the registry.
func (al *AgentLoop) agentEntry(agent *AgentInstance) (*providers.ModelEntry, error) {
agent.mu.RLock()
model := agent.Model
agent.mu.RUnlock()
if al.modelRegistry != nil {
if entry, ok := al.modelRegistry.Get(model); ok {
return entry, nil
}
if entry, ok := al.modelRegistry.GetDefault(); ok {
return entry, nil
}
}
return nil, fmt.Errorf("no provider found for model %q", model)
}
func (al *AgentLoop) RegisterTool(tool tools.Tool) { func (al *AgentLoop) RegisterTool(tool tools.Tool) {
for _, agentID := range al.registry.ListAgentIDs() { for _, agentID := range al.registry.ListAgentIDs() {
if agent, ok := al.registry.GetAgent(agentID); ok { if agent, ok := al.registry.GetAgent(agentID); ok {
@ -450,6 +473,18 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
return al.processSystemMessage(ctx, msg) return al.processSystemMessage(ctx, msg)
} }
// Reset message-tool sentInRound before any early-return paths (including
// handleCommand) so that a stale true from the previous LLM round never
// causes the command response to be silently dropped in the Run loop's
// alreadySent check (which would leave the typing indicator stuck on).
if defaultAgent := al.registry.GetDefaultAgent(); defaultAgent != nil {
if tool, ok := defaultAgent.Tools.Get("message"); ok {
if mt, ok := tool.(tools.ContextualTool); ok {
mt.SetContext(msg.Channel, msg.ChatID)
}
}
}
// Check for commands // Check for commands
if response, handled := al.handleCommand(ctx, msg); handled { if response, handled := al.handleCommand(ctx, msg); handled {
return response, nil return response, nil
@ -731,6 +766,10 @@ func (al *AgentLoop) runLLMIteration(
for iteration < agent.MaxIterations { for iteration < agent.MaxIterations {
iteration++ iteration++
// Snapshot mutable routing fields once per iteration so that a concurrent
// /switch model command cannot cause a mid-iteration inconsistency.
currentModel, currentCandidates := agent.getModelSnapshot()
logger.DebugCF("agent", "LLM iteration", logger.DebugCF("agent", "LLM iteration",
map[string]any{ map[string]any{
"agent_id": agent.ID, "agent_id": agent.ID,
@ -746,7 +785,7 @@ func (al *AgentLoop) runLLMIteration(
map[string]any{ map[string]any{
"agent_id": agent.ID, "agent_id": agent.ID,
"iteration": iteration, "iteration": iteration,
"model": agent.Model, "model": currentModel,
"messages_count": len(messages), "messages_count": len(messages),
"tools_count": len(providerToolDefs), "tools_count": len(providerToolDefs),
"max_tokens": agent.MaxTokens, "max_tokens": agent.MaxTokens,
@ -767,38 +806,39 @@ func (al *AgentLoop) runLLMIteration(
var err error var err error
callLLM := func() (*providers.LLMResponse, error) { callLLM := func() (*providers.LLMResponse, error) {
if len(agent.Candidates) > 1 && al.fallback != nil { if len(currentCandidates) > 1 && al.fallback != nil {
fbResult, fbErr := al.fallback.Execute( fbResult, fbErr := al.fallback.Execute(ctx, currentCandidates,
ctx, func(ctx context.Context, model string) (*providers.LLMResponse, error) {
agent.Candidates, entry, ok := al.modelRegistry.Get(model)
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { if !ok {
return agent.Provider.Chat( return nil, fmt.Errorf("model %q not found in registry", model)
ctx, }
messages, return entry.Provider.Chat(ctx, messages, providerToolDefs, entry.ModelID, map[string]any{
providerToolDefs,
model,
map[string]any{
"max_tokens": agent.MaxTokens, "max_tokens": agent.MaxTokens,
"temperature": agent.Temperature, "temperature": agent.Temperature,
"prompt_cache_key": agent.ID, "prompt_cache_key": agent.ID,
}, })
)
}, },
) )
if fbErr != nil { if fbErr != nil {
return nil, fbErr return nil, fbErr
} }
if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { if fbResult.Model != "" && len(fbResult.Attempts) > 0 {
logger.InfoCF( logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts",
"agent",
fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts",
fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1),
map[string]any{"agent_id": agent.ID, "iteration": iteration}, map[string]any{"agent_id": agent.ID, "iteration": iteration})
)
} }
return fbResult.Response, nil return fbResult.Response, nil
} }
return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{ entry, ok := al.modelRegistry.Get(currentModel)
if !ok {
var defOk bool
entry, defOk = al.modelRegistry.GetDefault()
if !defOk {
return nil, fmt.Errorf("no provider found for model %q", currentModel)
}
}
return entry.Provider.Chat(ctx, messages, providerToolDefs, entry.ModelID, map[string]any{
"max_tokens": agent.MaxTokens, "max_tokens": agent.MaxTokens,
"temperature": agent.Temperature, "temperature": agent.Temperature,
"prompt_cache_key": agent.ID, "prompt_cache_key": agent.ID,
@ -1286,17 +1326,23 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
s1, s1,
s2, s2,
) )
resp, err := agent.Provider.Chat( resp, err := func() (*providers.LLMResponse, error) {
entry, err := al.agentEntry(agent)
if err != nil {
return nil, err
}
return entry.Provider.Chat(
ctx, ctx,
[]providers.Message{{Role: "user", Content: mergePrompt}}, []providers.Message{{Role: "user", Content: mergePrompt}},
nil, nil,
agent.Model, entry.ModelID,
map[string]any{ map[string]any{
"max_tokens": 1024, "max_tokens": 1024,
"temperature": 0.3, "temperature": 0.3,
"prompt_cache_key": agent.ID, "prompt_cache_key": agent.ID,
}, },
) )
}()
if err == nil { if err == nil {
finalSummary = resp.Content finalSummary = resp.Content
} else { } else {
@ -1339,11 +1385,15 @@ func (al *AgentLoop) summarizeBatch(
} }
prompt := sb.String() prompt := sb.String()
response, err := agent.Provider.Chat( entry, err := al.agentEntry(agent)
if err != nil {
return "", err
}
response, err := entry.Provider.Chat(
ctx, ctx,
[]providers.Message{{Role: "user", Content: prompt}}, []providers.Message{{Role: "user", Content: prompt}},
nil, nil,
agent.Model, entry.ModelID,
map[string]any{ map[string]any{
"max_tokens": 1024, "max_tokens": 1024,
"temperature": 0.3, "temperature": 0.3,
@ -1409,7 +1459,22 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
} }
switch args[0] { switch args[0] {
case "models": case "models":
return "Available models: configured in config.json per agent", true defaultAgent := al.registry.GetDefaultAgent()
currentModel := ""
if defaultAgent != nil {
currentModel, _ = defaultAgent.getModelSnapshot()
}
var names []string
for _, m := range al.modelRegistry.ModelNames() {
if currentModel == m {
m += " ✅"
}
names = append(names, m)
}
if len(names) == 0 {
return "No models configured in model_list", true
}
return fmt.Sprintf("Available models:\n• %s\n\nUse /switch model to <name>", strings.Join(names, "\n• ")), true
case "channels": case "channels":
if al.channelManager == nil { if al.channelManager == nil {
return "Channel manager not initialized", true return "Channel manager not initialized", true
@ -1439,8 +1504,13 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
if defaultAgent == nil { if defaultAgent == nil {
return "No default agent configured", true return "No default agent configured", true
} }
oldModel := defaultAgent.Model if al.modelRegistry == nil {
defaultAgent.Model = value return "Model registry not available", true
}
if _, ok := al.modelRegistry.Get(value); !ok {
return fmt.Sprintf("Unknown model '%s'. Available: %s", value, strings.Join(al.modelRegistry.ModelNames(), ", ")), true
}
oldModel := defaultAgent.switchModel(value)
return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true
case "channel": case "channel":
if al.channelManager == nil { if al.channelManager == nil {

View file

@ -5,15 +5,12 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strings"
"testing" "testing"
"time" "time"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/tools"
) )
@ -29,15 +26,21 @@ func (f *fakeChannel) IsAllowed(string) bool {
func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true } func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true }
func (f *fakeChannel) ReasoningChannelID() string { return f.id } func (f *fakeChannel) ReasoningChannelID() string { return f.id }
func newTestAgentLoop( // mockRegistry wraps a mockProvider into a minimal ModelRegistry for tests.
t *testing.T, func mockRegistry(p providers.LLMProvider) *providers.ModelRegistry {
) (al *AgentLoop, cfg *config.Config, msgBus *bus.MessageBus, provider *mockProvider, cleanup func()) { return providers.NewModelRegistryFromProvider(p, "mock-model")
t.Helper() }
func TestRecordLastChannel(t *testing.T) {
// Create temp workspace
tmpDir, err := os.MkdirTemp("", "agent-test-*") tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil { if err != nil {
t.Fatalf("Failed to create temp dir: %v", err) t.Fatalf("Failed to create temp dir: %v", err)
} }
cfg = &config.Config{ defer os.RemoveAll(tmpDir)
// Create test config
cfg := &config.Config{
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{ Defaults: config.AgentDefaults{
Workspace: tmpDir, Workspace: tmpDir,
@ -47,43 +50,74 @@ func newTestAgentLoop(
}, },
}, },
} }
msgBus = bus.NewMessageBus()
provider = &mockProvider{}
al = NewAgentLoop(cfg, msgBus, provider)
return al, cfg, msgBus, provider, func() { os.RemoveAll(tmpDir) }
}
func TestRecordLastChannel(t *testing.T) { // Create agent loop
al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t) msgBus := bus.NewMessageBus()
defer cleanup() provider := &mockProvider{}
al := NewAgentLoop(cfg, msgBus, mockRegistry(provider))
// Test RecordLastChannel
testChannel := "test-channel" testChannel := "test-channel"
if err := al.RecordLastChannel(testChannel); err != nil { err = al.RecordLastChannel(testChannel)
if err != nil {
t.Fatalf("RecordLastChannel failed: %v", err) t.Fatalf("RecordLastChannel failed: %v", err)
} }
if got := al.state.GetLastChannel(); got != testChannel {
t.Errorf("Expected channel '%s', got '%s'", testChannel, got) // Verify channel was saved
lastChannel := al.state.GetLastChannel()
if lastChannel != testChannel {
t.Errorf("Expected channel '%s', got '%s'", testChannel, lastChannel)
} }
al2 := NewAgentLoop(cfg, msgBus, provider)
if got := al2.state.GetLastChannel(); got != testChannel { // Verify persistence by creating a new agent loop
t.Errorf("Expected persistent channel '%s', got '%s'", testChannel, got) al2 := NewAgentLoop(cfg, msgBus, mockRegistry(provider))
if al2.state.GetLastChannel() != testChannel {
t.Errorf("Expected persistent channel '%s', got '%s'", testChannel, al2.state.GetLastChannel())
} }
} }
func TestRecordLastChatID(t *testing.T) { func TestRecordLastChatID(t *testing.T) {
al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t) // Create temp workspace
defer cleanup() tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create test config
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
// Create agent loop
msgBus := bus.NewMessageBus()
provider := &mockProvider{}
al := NewAgentLoop(cfg, msgBus, mockRegistry(provider))
// Test RecordLastChatID
testChatID := "test-chat-id-123" testChatID := "test-chat-id-123"
if err := al.RecordLastChatID(testChatID); err != nil { err = al.RecordLastChatID(testChatID)
if err != nil {
t.Fatalf("RecordLastChatID failed: %v", err) t.Fatalf("RecordLastChatID failed: %v", err)
} }
if got := al.state.GetLastChatID(); got != testChatID {
t.Errorf("Expected chat ID '%s', got '%s'", testChatID, got) // Verify chat ID was saved
lastChatID := al.state.GetLastChatID()
if lastChatID != testChatID {
t.Errorf("Expected chat ID '%s', got '%s'", testChatID, lastChatID)
} }
al2 := NewAgentLoop(cfg, msgBus, provider)
if got := al2.state.GetLastChatID(); got != testChatID { // Verify persistence by creating a new agent loop
t.Errorf("Expected persistent chat ID '%s', got '%s'", testChatID, got) al2 := NewAgentLoop(cfg, msgBus, mockRegistry(provider))
if al2.state.GetLastChatID() != testChatID {
t.Errorf("Expected persistent chat ID '%s', got '%s'", testChatID, al2.state.GetLastChatID())
} }
} }
@ -110,7 +144,7 @@ func TestNewAgentLoop_StateInitialized(t *testing.T) {
// Create agent loop // Create agent loop
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
provider := &mockProvider{} provider := &mockProvider{}
al := NewAgentLoop(cfg, msgBus, provider) al := NewAgentLoop(cfg, msgBus, mockRegistry(provider))
// Verify state manager is initialized // Verify state manager is initialized
if al.state == nil { if al.state == nil {
@ -145,7 +179,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
provider := &mockProvider{} provider := &mockProvider{}
al := NewAgentLoop(cfg, msgBus, provider) al := NewAgentLoop(cfg, msgBus, mockRegistry(provider))
// Register a custom tool // Register a custom tool
customTool := &mockCustomTool{} customTool := &mockCustomTool{}
@ -158,7 +192,13 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
toolsList := toolsInfo["names"].([]string) toolsList := toolsInfo["names"].([]string)
// Check that our custom tool name is in the list // Check that our custom tool name is in the list
found := slices.Contains(toolsList, "mock_custom") found := false
for _, name := range toolsList {
if name == "mock_custom" {
found = true
break
}
}
if !found { if !found {
t.Error("Expected custom tool to be registered") t.Error("Expected custom tool to be registered")
} }
@ -185,7 +225,7 @@ func TestToolContext_Updates(t *testing.T) {
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
provider := &simpleMockProvider{response: "OK"} provider := &simpleMockProvider{response: "OK"}
_ = NewAgentLoop(cfg, msgBus, provider) _ = NewAgentLoop(cfg, msgBus, mockRegistry(provider))
// Verify that ContextualTool interface is defined and can be implemented // Verify that ContextualTool interface is defined and can be implemented
// This test validates the interface contract exists // This test validates the interface contract exists
@ -216,7 +256,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
provider := &mockProvider{} provider := &mockProvider{}
al := NewAgentLoop(cfg, msgBus, provider) al := NewAgentLoop(cfg, msgBus, mockRegistry(provider))
// Register a test tool and verify it shows up in startup info // Register a test tool and verify it shows up in startup info
testTool := &mockCustomTool{} testTool := &mockCustomTool{}
@ -227,7 +267,13 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
toolsList := toolsInfo["names"].([]string) toolsList := toolsInfo["names"].([]string)
// Check that our custom tool name is in the list // Check that our custom tool name is in the list
found := slices.Contains(toolsList, "mock_custom") found := false
for _, name := range toolsList {
if name == "mock_custom" {
found = true
break
}
}
if !found { if !found {
t.Error("Expected custom tool to be registered") t.Error("Expected custom tool to be registered")
} }
@ -254,7 +300,7 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) {
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
provider := &mockProvider{} provider := &mockProvider{}
al := NewAgentLoop(cfg, msgBus, provider) al := NewAgentLoop(cfg, msgBus, mockRegistry(provider))
info := al.GetStartupInfo() info := al.GetStartupInfo()
@ -301,7 +347,7 @@ func TestAgentLoop_Stop(t *testing.T) {
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
provider := &mockProvider{} provider := &mockProvider{}
al := NewAgentLoop(cfg, msgBus, provider) al := NewAgentLoop(cfg, msgBus, mockRegistry(provider))
// Note: running is only set to true when Run() is called // Note: running is only set to true when Run() is called
// We can't test that without starting the event loop // We can't test that without starting the event loop
@ -429,7 +475,7 @@ func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
provider := &simpleMockProvider{response: "File operation complete"} provider := &simpleMockProvider{response: "File operation complete"}
al := NewAgentLoop(cfg, msgBus, provider) al := NewAgentLoop(cfg, msgBus, mockRegistry(provider))
helper := testHelper{al: al} helper := testHelper{al: al}
// ReadFileTool returns SilentResult, which should not send user message // ReadFileTool returns SilentResult, which should not send user message
@ -471,7 +517,7 @@ func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) {
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
provider := &simpleMockProvider{response: "Command output: hello world"} provider := &simpleMockProvider{response: "Command output: hello world"}
al := NewAgentLoop(cfg, msgBus, provider) al := NewAgentLoop(cfg, msgBus, mockRegistry(provider))
helper := testHelper{al: al} helper := testHelper{al: al}
// ExecTool returns UserResult, which should send user message // ExecTool returns UserResult, which should send user message
@ -550,7 +596,7 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
successResp: "Recovered from context error", successResp: "Recovered from context error",
} }
al := NewAgentLoop(cfg, msgBus, provider) al := NewAgentLoop(cfg, msgBus, mockRegistry(provider))
// Inject some history to simulate a full context // Inject some history to simulate a full context
sessionKey := "test-session-context" sessionKey := "test-session-context"
@ -621,7 +667,7 @@ func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
}, },
} }
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) al := NewAgentLoop(cfg, bus.NewMessageBus(), mockRegistry(&mockProvider{}))
chManager, err := channels.NewManager(&config.Config{}, bus.NewMessageBus(), nil) chManager, err := channels.NewManager(&config.Config{}, bus.NewMessageBus(), nil)
if err != nil { if err != nil {
t.Fatalf("Failed to create channel manager: %v", err) t.Fatalf("Failed to create channel manager: %v", err)
@ -691,7 +737,7 @@ func TestHandleReasoning(t *testing.T) {
}, },
} }
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
return NewAgentLoop(cfg, msgBus, &mockProvider{}), msgBus return NewAgentLoop(cfg, msgBus, mockRegistry(&mockProvider{})), msgBus
} }
t.Run("skips when any required field is empty", func(t *testing.T) { t.Run("skips when any required field is empty", func(t *testing.T) {
@ -810,142 +856,3 @@ func TestHandleReasoning(t *testing.T) {
} }
}) })
} }
func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) {
store := media.NewFileMediaStore()
dir := t.TempDir()
// Create a minimal valid PNG (8-byte header is enough for filetype detection)
pngPath := filepath.Join(dir, "test.png")
// PNG magic: 0x89 P N G \r \n 0x1A \n + minimal IHDR
pngHeader := []byte{
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
0x00, 0x00, 0x00, 0x0D, // IHDR length
0x49, 0x48, 0x44, 0x52, // "IHDR"
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, // 1x1 RGB
0x00, 0x00, 0x00, // no interlace
0x90, 0x77, 0x53, 0xDE, // CRC
}
if err := os.WriteFile(pngPath, pngHeader, 0o644); err != nil {
t.Fatal(err)
}
ref, err := store.Store(pngPath, media.MediaMeta{}, "test")
if err != nil {
t.Fatal(err)
}
messages := []providers.Message{
{Role: "user", Content: "describe this", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
if len(result[0].Media) != 1 {
t.Fatalf("expected 1 resolved media, got %d", len(result[0].Media))
}
if !strings.HasPrefix(result[0].Media[0], "data:image/png;base64,") {
t.Fatalf("expected data:image/png;base64, prefix, got %q", result[0].Media[0][:40])
}
}
func TestResolveMediaRefs_SkipsOversizedFile(t *testing.T) {
store := media.NewFileMediaStore()
dir := t.TempDir()
bigPath := filepath.Join(dir, "big.png")
// Write PNG header + padding to exceed limit
data := make([]byte, 1024+1) // 1KB + 1 byte
copy(data, []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A})
if err := os.WriteFile(bigPath, data, 0o644); err != nil {
t.Fatal(err)
}
ref, _ := store.Store(bigPath, media.MediaMeta{}, "test")
messages := []providers.Message{
{Role: "user", Content: "hi", Media: []string{ref}},
}
// Use a tiny limit (1KB) so the file is oversized
result := resolveMediaRefs(messages, store, 1024)
if len(result[0].Media) != 0 {
t.Fatalf("expected 0 media (oversized), got %d", len(result[0].Media))
}
}
func TestResolveMediaRefs_SkipsUnknownType(t *testing.T) {
store := media.NewFileMediaStore()
dir := t.TempDir()
txtPath := filepath.Join(dir, "readme.txt")
if err := os.WriteFile(txtPath, []byte("hello world"), 0o644); err != nil {
t.Fatal(err)
}
ref, _ := store.Store(txtPath, media.MediaMeta{}, "test")
messages := []providers.Message{
{Role: "user", Content: "hi", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
if len(result[0].Media) != 0 {
t.Fatalf("expected 0 media (unknown type), got %d", len(result[0].Media))
}
}
func TestResolveMediaRefs_PassesThroughNonMediaRefs(t *testing.T) {
messages := []providers.Message{
{Role: "user", Content: "hi", Media: []string{"https://example.com/img.png"}},
}
result := resolveMediaRefs(messages, nil, config.DefaultMaxMediaSize)
if len(result[0].Media) != 1 || result[0].Media[0] != "https://example.com/img.png" {
t.Fatalf("expected passthrough of non-media:// URL, got %v", result[0].Media)
}
}
func TestResolveMediaRefs_DoesNotMutateOriginal(t *testing.T) {
store := media.NewFileMediaStore()
dir := t.TempDir()
pngPath := filepath.Join(dir, "test.png")
pngHeader := []byte{
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02,
0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE,
}
os.WriteFile(pngPath, pngHeader, 0o644)
ref, _ := store.Store(pngPath, media.MediaMeta{}, "test")
original := []providers.Message{
{Role: "user", Content: "hi", Media: []string{ref}},
}
originalRef := original[0].Media[0]
resolveMediaRefs(original, store, config.DefaultMaxMediaSize)
if original[0].Media[0] != originalRef {
t.Fatal("resolveMediaRefs mutated original message slice")
}
}
func TestResolveMediaRefs_UsesMetaContentType(t *testing.T) {
store := media.NewFileMediaStore()
dir := t.TempDir()
// File with JPEG content but stored with explicit content type
jpegPath := filepath.Join(dir, "photo")
jpegHeader := []byte{0xFF, 0xD8, 0xFF, 0xE0} // JPEG magic bytes
os.WriteFile(jpegPath, jpegHeader, 0o644)
ref, _ := store.Store(jpegPath, media.MediaMeta{ContentType: "image/jpeg"}, "test")
messages := []providers.Message{
{Role: "user", Content: "hi", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
if len(result[0].Media) != 1 {
t.Fatalf("expected 1 media, got %d", len(result[0].Media))
}
if !strings.HasPrefix(result[0].Media[0], "data:image/jpeg;base64,") {
t.Fatalf("expected jpeg prefix, got %q", result[0].Media[0][:30])
}
}

View file

@ -17,9 +17,10 @@ type AgentRegistry struct {
} }
// NewAgentRegistry creates a registry from config, instantiating all agents. // NewAgentRegistry creates a registry from config, instantiating all agents.
// Each agent resolves its own provider + modelID from the ModelRegistry.
func NewAgentRegistry( func NewAgentRegistry(
cfg *config.Config, cfg *config.Config,
provider providers.LLMProvider, modelRegistry *providers.ModelRegistry,
) *AgentRegistry { ) *AgentRegistry {
registry := &AgentRegistry{ registry := &AgentRegistry{
agents: make(map[string]*AgentInstance), agents: make(map[string]*AgentInstance),
@ -32,14 +33,14 @@ func NewAgentRegistry(
ID: "main", ID: "main",
Default: true, Default: true,
} }
instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider) instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, modelRegistry)
registry.agents["main"] = instance registry.agents["main"] = instance
logger.InfoCF("agent", "Created implicit main agent (no agents.list configured)", nil) logger.InfoCF("agent", "Created implicit main agent (no agents.list configured)", nil)
} else { } else {
for i := range agentConfigs { for i := range agentConfigs {
ac := &agentConfigs[i] ac := &agentConfigs[i]
id := routing.NormalizeAgentID(ac.ID) id := routing.NormalizeAgentID(ac.ID)
instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider) instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, modelRegistry)
registry.agents[id] = instance registry.agents[id] = instance
logger.InfoCF("agent", "Registered agent", logger.InfoCF("agent", "Registered agent",
map[string]any{ map[string]any{

View file

@ -24,6 +24,14 @@ func (m *mockRegistryProvider) GetDefaultModel() string {
return "mock-model" return "mock-model"
} }
// mockModelRegistry creates a ModelRegistry backed by a mockRegistryProvider.
// Maps "gpt-4" (the default in testCfg) and "claude-opus" (used in model tests).
func mockModelRegistry() *providers.ModelRegistry {
p := &mockRegistryProvider{}
reg := providers.NewModelRegistryFromProvider(p, "gpt-4")
return reg
}
func testCfg(agents []config.AgentConfig) *config.Config { func testCfg(agents []config.AgentConfig) *config.Config {
return &config.Config{ return &config.Config{
Agents: config.AgentsConfig{ Agents: config.AgentsConfig{
@ -40,7 +48,7 @@ func testCfg(agents []config.AgentConfig) *config.Config {
func TestNewAgentRegistry_ImplicitMain(t *testing.T) { func TestNewAgentRegistry_ImplicitMain(t *testing.T) {
cfg := testCfg(nil) cfg := testCfg(nil)
registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) registry := NewAgentRegistry(cfg, mockModelRegistry())
ids := registry.ListAgentIDs() ids := registry.ListAgentIDs()
if len(ids) != 1 || ids[0] != "main" { if len(ids) != 1 || ids[0] != "main" {
@ -61,7 +69,7 @@ func TestNewAgentRegistry_ExplicitAgents(t *testing.T) {
{ID: "sales", Default: true, Name: "Sales Bot"}, {ID: "sales", Default: true, Name: "Sales Bot"},
{ID: "support", Name: "Support Bot"}, {ID: "support", Name: "Support Bot"},
}) })
registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) registry := NewAgentRegistry(cfg, mockModelRegistry())
ids := registry.ListAgentIDs() ids := registry.ListAgentIDs()
if len(ids) != 2 { if len(ids) != 2 {
@ -86,7 +94,7 @@ func TestAgentRegistry_GetAgent_Normalize(t *testing.T) {
cfg := testCfg([]config.AgentConfig{ cfg := testCfg([]config.AgentConfig{
{ID: "my-agent", Default: true}, {ID: "my-agent", Default: true},
}) })
registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) registry := NewAgentRegistry(cfg, mockModelRegistry())
agent, ok := registry.GetAgent("My-Agent") agent, ok := registry.GetAgent("My-Agent")
if !ok || agent == nil { if !ok || agent == nil {
@ -102,7 +110,7 @@ func TestAgentRegistry_GetDefaultAgent(t *testing.T) {
{ID: "alpha"}, {ID: "alpha"},
{ID: "beta", Default: true}, {ID: "beta", Default: true},
}) })
registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) registry := NewAgentRegistry(cfg, mockModelRegistry())
// GetDefaultAgent first checks for "main", then returns any // GetDefaultAgent first checks for "main", then returns any
agent := registry.GetDefaultAgent() agent := registry.GetDefaultAgent()
@ -124,7 +132,7 @@ func TestAgentRegistry_CanSpawnSubagent(t *testing.T) {
{ID: "child2"}, {ID: "child2"},
{ID: "restricted"}, {ID: "restricted"},
}) })
registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) registry := NewAgentRegistry(cfg, mockModelRegistry())
if !registry.CanSpawnSubagent("parent", "child1") { if !registry.CanSpawnSubagent("parent", "child1") {
t.Error("expected parent to be allowed to spawn child1") t.Error("expected parent to be allowed to spawn child1")
@ -151,7 +159,7 @@ func TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) {
}, },
{ID: "any-agent"}, {ID: "any-agent"},
}) })
registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) registry := NewAgentRegistry(cfg, mockModelRegistry())
if !registry.CanSpawnSubagent("admin", "any-agent") { if !registry.CanSpawnSubagent("admin", "any-agent") {
t.Error("expected wildcard to allow spawning any agent") t.Error("expected wildcard to allow spawning any agent")
@ -166,7 +174,7 @@ func TestAgentInstance_Model(t *testing.T) {
cfg := testCfg([]config.AgentConfig{ cfg := testCfg([]config.AgentConfig{
{ID: "custom", Default: true, Model: model}, {ID: "custom", Default: true, Model: model},
}) })
registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) registry := NewAgentRegistry(cfg, mockModelRegistry())
agent, _ := registry.GetAgent("custom") agent, _ := registry.GetAgent("custom")
if agent.Model != "claude-opus" { if agent.Model != "claude-opus" {
@ -179,7 +187,7 @@ func TestAgentInstance_FallbackInheritance(t *testing.T) {
{ID: "inherit", Default: true}, {ID: "inherit", Default: true},
}) })
cfg.Agents.Defaults.ModelFallbacks = []string{"openai/gpt-4o-mini", "anthropic/haiku"} cfg.Agents.Defaults.ModelFallbacks = []string{"openai/gpt-4o-mini", "anthropic/haiku"}
registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) registry := NewAgentRegistry(cfg, mockModelRegistry())
agent, _ := registry.GetAgent("inherit") agent, _ := registry.GetAgent("inherit")
if len(agent.Fallbacks) != 2 { if len(agent.Fallbacks) != 2 {
@ -196,7 +204,7 @@ func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) {
{ID: "no-fallback", Default: true, Model: model}, {ID: "no-fallback", Default: true, Model: model},
}) })
cfg.Agents.Defaults.ModelFallbacks = []string{"should-not-inherit"} cfg.Agents.Defaults.ModelFallbacks = []string{"should-not-inherit"}
registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) registry := NewAgentRegistry(cfg, mockModelRegistry())
agent, _ := registry.GetAgent("no-fallback") agent, _ := registry.GetAgent("no-fallback")
if len(agent.Fallbacks) != 0 { if len(agent.Fallbacks) != 0 {

View file

@ -10,11 +10,11 @@ import (
// FallbackChain orchestrates model fallback across multiple candidates. // FallbackChain orchestrates model fallback across multiple candidates.
type FallbackChain struct { type FallbackChain struct {
cooldown *CooldownTracker cooldown *CooldownTracker
providerKeyFn func(model string) string // optional: derives a stable provider key for cooldown tracking
} }
// FallbackCandidate represents one model/provider to try. // FallbackCandidate represents one model to try, identified by its registry name.
type FallbackCandidate struct { type FallbackCandidate struct {
Provider string
Model string Model string
} }
@ -41,6 +41,22 @@ func NewFallbackChain(cooldown *CooldownTracker) *FallbackChain {
return &FallbackChain{cooldown: cooldown} return &FallbackChain{cooldown: cooldown}
} }
// WithProviderKeyFn sets a function that maps a model name to a stable provider key
// used for cooldown tracking. Multiple models on the same provider share one cooldown bucket.
// If not set, the model name itself is used as the key.
func (fc *FallbackChain) WithProviderKeyFn(fn func(model string) string) *FallbackChain {
fc.providerKeyFn = fn
return fc
}
// providerKey returns the cooldown key for a given model.
func (fc *FallbackChain) providerKey(model string) string {
if fc.providerKeyFn != nil {
return fc.providerKeyFn(model)
}
return model
}
// ResolveCandidates parses model config into a deduplicated candidate list. // ResolveCandidates parses model config into a deduplicated candidate list.
func ResolveCandidates(cfg ModelConfig, defaultProvider string) []FallbackCandidate { func ResolveCandidates(cfg ModelConfig, defaultProvider string) []FallbackCandidate {
return ResolveCandidatesWithLookup(cfg, defaultProvider, nil) return ResolveCandidatesWithLookup(cfg, defaultProvider, nil)
@ -72,7 +88,6 @@ func ResolveCandidatesWithLookup(
} }
seen[key] = true seen[key] = true
candidates = append(candidates, FallbackCandidate{ candidates = append(candidates, FallbackCandidate{
Provider: ref.Provider,
Model: ref.Model, Model: ref.Model,
}) })
} }
@ -101,7 +116,7 @@ func ResolveCandidatesWithLookup(
func (fc *FallbackChain) Execute( func (fc *FallbackChain) Execute(
ctx context.Context, ctx context.Context,
candidates []FallbackCandidate, candidates []FallbackCandidate,
run func(ctx context.Context, provider, model string) (*LLMResponse, error), run func(ctx context.Context, model string) (*LLMResponse, error),
) (*FallbackResult, error) { ) (*FallbackResult, error) {
if len(candidates) == 0 { if len(candidates) == 0 {
return nil, fmt.Errorf("fallback: no candidates configured") return nil, fmt.Errorf("fallback: no candidates configured")
@ -117,17 +132,19 @@ func (fc *FallbackChain) Execute(
return nil, context.Canceled return nil, context.Canceled
} }
pk := fc.providerKey(candidate.Model)
// Check cooldown. // Check cooldown.
if !fc.cooldown.IsAvailable(candidate.Provider) { if !fc.cooldown.IsAvailable(pk) {
remaining := fc.cooldown.CooldownRemaining(candidate.Provider) remaining := fc.cooldown.CooldownRemaining(pk)
result.Attempts = append(result.Attempts, FallbackAttempt{ result.Attempts = append(result.Attempts, FallbackAttempt{
Provider: candidate.Provider, Provider: pk,
Model: candidate.Model, Model: candidate.Model,
Skipped: true, Skipped: true,
Reason: FailoverRateLimit, Reason: FailoverRateLimit,
Error: fmt.Errorf( Error: fmt.Errorf(
"provider %s in cooldown (%s remaining)", "provider %s in cooldown (%s remaining)",
candidate.Provider, pk,
remaining.Round(time.Second), remaining.Round(time.Second),
), ),
}) })
@ -136,14 +153,14 @@ func (fc *FallbackChain) Execute(
// Execute the run function. // Execute the run function.
start := time.Now() start := time.Now()
resp, err := run(ctx, candidate.Provider, candidate.Model) resp, err := run(ctx, candidate.Model)
elapsed := time.Since(start) elapsed := time.Since(start)
if err == nil { if err == nil {
// Success. // Success.
fc.cooldown.MarkSuccess(candidate.Provider) fc.cooldown.MarkSuccess(pk)
result.Response = resp result.Response = resp
result.Provider = candidate.Provider result.Provider = pk
result.Model = candidate.Model result.Model = candidate.Model
return result, nil return result, nil
} }
@ -151,7 +168,7 @@ func (fc *FallbackChain) Execute(
// Context cancellation: abort immediately, no fallback. // Context cancellation: abort immediately, no fallback.
if ctx.Err() == context.Canceled { if ctx.Err() == context.Canceled {
result.Attempts = append(result.Attempts, FallbackAttempt{ result.Attempts = append(result.Attempts, FallbackAttempt{
Provider: candidate.Provider, Provider: pk,
Model: candidate.Model, Model: candidate.Model,
Error: err, Error: err,
Duration: elapsed, Duration: elapsed,
@ -160,24 +177,24 @@ func (fc *FallbackChain) Execute(
} }
// Classify the error. // Classify the error.
failErr := ClassifyError(err, candidate.Provider, candidate.Model) failErr := ClassifyError(err, pk, candidate.Model)
if failErr == nil { if failErr == nil {
// Unclassifiable error: do not fallback, return immediately. // Unclassifiable error: do not fallback, return immediately.
result.Attempts = append(result.Attempts, FallbackAttempt{ result.Attempts = append(result.Attempts, FallbackAttempt{
Provider: candidate.Provider, Provider: pk,
Model: candidate.Model, Model: candidate.Model,
Error: err, Error: err,
Duration: elapsed, Duration: elapsed,
}) })
return nil, fmt.Errorf("fallback: unclassified error from %s/%s: %w", return nil, fmt.Errorf("fallback: unclassified error from %s/%s: %w",
candidate.Provider, candidate.Model, err) pk, candidate.Model, err)
} }
// Non-retriable error: abort immediately. // Non-retriable error: abort immediately.
if !failErr.IsRetriable() { if !failErr.IsRetriable() {
result.Attempts = append(result.Attempts, FallbackAttempt{ result.Attempts = append(result.Attempts, FallbackAttempt{
Provider: candidate.Provider, Provider: pk,
Model: candidate.Model, Model: candidate.Model,
Error: failErr, Error: failErr,
Reason: failErr.Reason, Reason: failErr.Reason,
@ -187,9 +204,9 @@ func (fc *FallbackChain) Execute(
} }
// Retriable error: mark failure and continue to next candidate. // Retriable error: mark failure and continue to next candidate.
fc.cooldown.MarkFailure(candidate.Provider, failErr.Reason) fc.cooldown.MarkFailure(pk, failErr.Reason)
result.Attempts = append(result.Attempts, FallbackAttempt{ result.Attempts = append(result.Attempts, FallbackAttempt{
Provider: candidate.Provider, Provider: pk,
Model: candidate.Model, Model: candidate.Model,
Error: failErr, Error: failErr,
Reason: failErr.Reason, Reason: failErr.Reason,
@ -212,7 +229,7 @@ func (fc *FallbackChain) Execute(
func (fc *FallbackChain) ExecuteImage( func (fc *FallbackChain) ExecuteImage(
ctx context.Context, ctx context.Context,
candidates []FallbackCandidate, candidates []FallbackCandidate,
run func(ctx context.Context, provider, model string) (*LLMResponse, error), run func(ctx context.Context, model string) (*LLMResponse, error),
) (*FallbackResult, error) { ) (*FallbackResult, error) {
if len(candidates) == 0 { if len(candidates) == 0 {
return nil, fmt.Errorf("image fallback: no candidates configured") return nil, fmt.Errorf("image fallback: no candidates configured")
@ -227,20 +244,22 @@ func (fc *FallbackChain) ExecuteImage(
return nil, context.Canceled return nil, context.Canceled
} }
pk := fc.providerKey(candidate.Model)
start := time.Now() start := time.Now()
resp, err := run(ctx, candidate.Provider, candidate.Model) resp, err := run(ctx, candidate.Model)
elapsed := time.Since(start) elapsed := time.Since(start)
if err == nil { if err == nil {
result.Response = resp result.Response = resp
result.Provider = candidate.Provider result.Provider = pk
result.Model = candidate.Model result.Model = candidate.Model
return result, nil return result, nil
} }
if ctx.Err() == context.Canceled { if ctx.Err() == context.Canceled {
result.Attempts = append(result.Attempts, FallbackAttempt{ result.Attempts = append(result.Attempts, FallbackAttempt{
Provider: candidate.Provider, Provider: pk,
Model: candidate.Model, Model: candidate.Model,
Error: err, Error: err,
Duration: elapsed, Duration: elapsed,
@ -252,7 +271,7 @@ func (fc *FallbackChain) ExecuteImage(
errMsg := strings.ToLower(err.Error()) errMsg := strings.ToLower(err.Error())
if IsImageDimensionError(errMsg) || IsImageSizeError(errMsg) { if IsImageDimensionError(errMsg) || IsImageSizeError(errMsg) {
result.Attempts = append(result.Attempts, FallbackAttempt{ result.Attempts = append(result.Attempts, FallbackAttempt{
Provider: candidate.Provider, Provider: pk,
Model: candidate.Model, Model: candidate.Model,
Error: err, Error: err,
Reason: FailoverFormat, Reason: FailoverFormat,
@ -260,7 +279,7 @@ func (fc *FallbackChain) ExecuteImage(
}) })
return nil, &FailoverError{ return nil, &FailoverError{
Reason: FailoverFormat, Reason: FailoverFormat,
Provider: candidate.Provider, Provider: pk,
Model: candidate.Model, Model: candidate.Model,
Wrapped: err, Wrapped: err,
} }
@ -268,7 +287,7 @@ func (fc *FallbackChain) ExecuteImage(
// Any other error: record and try next. // Any other error: record and try next.
result.Attempts = append(result.Attempts, FallbackAttempt{ result.Attempts = append(result.Attempts, FallbackAttempt{
Provider: candidate.Provider, Provider: pk,
Model: candidate.Model, Model: candidate.Model,
Error: err, Error: err,
Duration: elapsed, Duration: elapsed,

View file

@ -7,12 +7,12 @@ import (
"time" "time"
) )
func makeCandidate(provider, model string) FallbackCandidate { func makeCandidate(model string) FallbackCandidate {
return FallbackCandidate{Provider: provider, Model: model} return FallbackCandidate{Model: model}
} }
func successRun(content string) func(ctx context.Context, provider, model string) (*LLMResponse, error) { func successRun(content string) func(ctx context.Context, model string) (*LLMResponse, error) {
return func(ctx context.Context, provider, model string) (*LLMResponse, error) { return func(ctx context.Context, model string) (*LLMResponse, error) {
return &LLMResponse{Content: content, FinishReason: "stop"}, nil return &LLMResponse{Content: content, FinishReason: "stop"}, nil
} }
} }
@ -21,7 +21,7 @@ func TestFallback_SingleCandidate_Success(t *testing.T) {
ct := NewCooldownTracker() ct := NewCooldownTracker()
fc := NewFallbackChain(ct) fc := NewFallbackChain(ct)
candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")} candidates := []FallbackCandidate{makeCandidate("gpt-4")}
result, err := fc.Execute(context.Background(), candidates, successRun("hello")) result, err := fc.Execute(context.Background(), candidates, successRun("hello"))
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@ -29,8 +29,8 @@ func TestFallback_SingleCandidate_Success(t *testing.T) {
if result.Response.Content != "hello" { if result.Response.Content != "hello" {
t.Errorf("content = %q, want hello", result.Response.Content) t.Errorf("content = %q, want hello", result.Response.Content)
} }
if result.Provider != "openai" || result.Model != "gpt-4" { if result.Model != "gpt-4" {
t.Errorf("provider/model = %s/%s, want openai/gpt-4", result.Provider, result.Model) t.Errorf("model = %s, want gpt-4", result.Model)
} }
} }
@ -39,12 +39,12 @@ func TestFallback_SecondCandidateSuccess(t *testing.T) {
fc := NewFallbackChain(ct) fc := NewFallbackChain(ct)
candidates := []FallbackCandidate{ candidates := []FallbackCandidate{
makeCandidate("openai", "gpt-4"), makeCandidate("gpt-4"),
makeCandidate("anthropic", "claude-opus"), makeCandidate("claude-opus"),
} }
attempt := 0 attempt := 0
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { run := func(ctx context.Context, model string) (*LLMResponse, error) {
attempt++ attempt++
if attempt == 1 { if attempt == 1 {
return nil, errors.New("rate limit exceeded") return nil, errors.New("rate limit exceeded")
@ -56,8 +56,8 @@ func TestFallback_SecondCandidateSuccess(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if result.Provider != "anthropic" { if result.Model != "claude-opus" {
t.Errorf("provider = %q, want anthropic", result.Provider) t.Errorf("model = %q, want claude-opus", result.Model)
} }
if result.Response.Content != "from claude" { if result.Response.Content != "from claude" {
t.Errorf("content = %q, want 'from claude'", result.Response.Content) t.Errorf("content = %q, want 'from claude'", result.Response.Content)
@ -72,12 +72,12 @@ func TestFallback_AllFail(t *testing.T) {
fc := NewFallbackChain(ct) fc := NewFallbackChain(ct)
candidates := []FallbackCandidate{ candidates := []FallbackCandidate{
makeCandidate("openai", "gpt-4"), makeCandidate("gpt-4"),
makeCandidate("anthropic", "claude"), makeCandidate("claude"),
makeCandidate("groq", "llama"), makeCandidate("llama"),
} }
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { run := func(ctx context.Context, model string) (*LLMResponse, error) {
return nil, errors.New("rate limit exceeded") return nil, errors.New("rate limit exceeded")
} }
@ -100,12 +100,12 @@ func TestFallback_ContextCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
candidates := []FallbackCandidate{ candidates := []FallbackCandidate{
makeCandidate("openai", "gpt-4"), makeCandidate("gpt-4"),
makeCandidate("anthropic", "claude"), makeCandidate("claude"),
} }
attempt := 0 attempt := 0
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { run := func(ctx context.Context, model string) (*LLMResponse, error) {
attempt++ attempt++
if attempt == 1 { if attempt == 1 {
cancel() // cancel context cancel() // cancel context
@ -126,12 +126,12 @@ func TestFallback_NonRetriableError(t *testing.T) {
fc := NewFallbackChain(ct) fc := NewFallbackChain(ct)
candidates := []FallbackCandidate{ candidates := []FallbackCandidate{
makeCandidate("openai", "gpt-4"), makeCandidate("gpt-4"),
makeCandidate("anthropic", "claude"), makeCandidate("claude"),
} }
attempt := 0 attempt := 0
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { run := func(ctx context.Context, model string) (*LLMResponse, error) {
attempt++ attempt++
return nil, errors.New("string should match pattern") return nil, errors.New("string should match pattern")
} }
@ -157,17 +157,17 @@ func TestFallback_CooldownSkip(t *testing.T) {
ct, _ := newTestTracker(now) ct, _ := newTestTracker(now)
fc := NewFallbackChain(ct) fc := NewFallbackChain(ct)
// Put openai in cooldown // Put gpt-4 in cooldown
ct.MarkFailure("openai", FailoverRateLimit) ct.MarkFailure("gpt-4", FailoverRateLimit)
candidates := []FallbackCandidate{ candidates := []FallbackCandidate{
makeCandidate("openai", "gpt-4"), makeCandidate("gpt-4"),
makeCandidate("anthropic", "claude"), makeCandidate("claude"),
} }
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { run := func(ctx context.Context, model string) (*LLMResponse, error) {
if provider == "openai" { if model == "gpt-4" {
t.Error("should not call openai (in cooldown)") t.Error("should not call gpt-4 (in cooldown)")
} }
return &LLMResponse{Content: "claude response", FinishReason: "stop"}, nil return &LLMResponse{Content: "claude response", FinishReason: "stop"}, nil
} }
@ -176,8 +176,8 @@ func TestFallback_CooldownSkip(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if result.Provider != "anthropic" { if result.Model != "claude" {
t.Errorf("provider = %q, want anthropic", result.Provider) t.Errorf("model = %q, want claude", result.Model)
} }
// Should have 1 skipped attempt // Should have 1 skipped attempt
skipped := 0 skipped := 0
@ -195,17 +195,17 @@ func TestFallback_AllInCooldown(t *testing.T) {
ct := NewCooldownTracker() ct := NewCooldownTracker()
fc := NewFallbackChain(ct) fc := NewFallbackChain(ct)
// Put all providers in cooldown // Put all models in cooldown
ct.MarkFailure("openai", FailoverRateLimit) ct.MarkFailure("gpt-4", FailoverRateLimit)
ct.MarkFailure("anthropic", FailoverBilling) ct.MarkFailure("claude", FailoverBilling)
candidates := []FallbackCandidate{ candidates := []FallbackCandidate{
makeCandidate("openai", "gpt-4"), makeCandidate("gpt-4"),
makeCandidate("anthropic", "claude"), makeCandidate("claude"),
} }
_, err := fc.Execute(context.Background(), candidates, _, err := fc.Execute(context.Background(), candidates,
func(ctx context.Context, provider, model string) (*LLMResponse, error) { func(ctx context.Context, model string) (*LLMResponse, error) {
t.Error("should not call any provider (all in cooldown)") t.Error("should not call any provider (all in cooldown)")
return nil, nil return nil, nil
}) })
@ -234,7 +234,7 @@ func TestFallback_EmptyFallbacks(t *testing.T) {
ct := NewCooldownTracker() ct := NewCooldownTracker()
fc := NewFallbackChain(ct) fc := NewFallbackChain(ct)
candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")} candidates := []FallbackCandidate{makeCandidate("gpt-4")}
result, err := fc.Execute(context.Background(), candidates, successRun("ok")) result, err := fc.Execute(context.Background(), candidates, successRun("ok"))
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@ -249,12 +249,12 @@ func TestFallback_UnclassifiedError(t *testing.T) {
fc := NewFallbackChain(ct) fc := NewFallbackChain(ct)
candidates := []FallbackCandidate{ candidates := []FallbackCandidate{
makeCandidate("openai", "gpt-4"), makeCandidate("gpt-4"),
makeCandidate("anthropic", "claude"), makeCandidate("claude"),
} }
attempt := 0 attempt := 0
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { run := func(ctx context.Context, model string) (*LLMResponse, error) {
attempt++ attempt++
return nil, errors.New("completely unknown internal error") return nil, errors.New("completely unknown internal error")
} }
@ -272,13 +272,13 @@ func TestFallback_SuccessResetsCooldown(t *testing.T) {
ct := NewCooldownTracker() ct := NewCooldownTracker()
fc := NewFallbackChain(ct) fc := NewFallbackChain(ct)
candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")} candidates := []FallbackCandidate{makeCandidate("gpt-4")}
attempt := 0 attempt := 0
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { run := func(ctx context.Context, model string) (*LLMResponse, error) {
attempt++ attempt++
if attempt == 1 { if attempt == 1 {
ct.MarkFailure("openai", FailoverRateLimit) // simulate failure tracked elsewhere ct.MarkFailure("gpt-4", FailoverRateLimit) // simulate failure tracked elsewhere
} }
return &LLMResponse{Content: "ok", FinishReason: "stop"}, nil return &LLMResponse{Content: "ok", FinishReason: "stop"}, nil
} }
@ -287,7 +287,7 @@ func TestFallback_SuccessResetsCooldown(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if !ct.IsAvailable("openai") { if !ct.IsAvailable("gpt-4") {
t.Error("success should reset cooldown") t.Error("success should reset cooldown")
} }
} }
@ -298,7 +298,7 @@ func TestImageFallback_Success(t *testing.T) {
ct := NewCooldownTracker() ct := NewCooldownTracker()
fc := NewFallbackChain(ct) fc := NewFallbackChain(ct)
candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4o")} candidates := []FallbackCandidate{makeCandidate("gpt-4o")}
result, err := fc.ExecuteImage(context.Background(), candidates, successRun("image result")) result, err := fc.ExecuteImage(context.Background(), candidates, successRun("image result"))
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@ -313,12 +313,12 @@ func TestImageFallback_DimensionError(t *testing.T) {
fc := NewFallbackChain(ct) fc := NewFallbackChain(ct)
candidates := []FallbackCandidate{ candidates := []FallbackCandidate{
makeCandidate("openai", "gpt-4o"), makeCandidate("gpt-4o"),
makeCandidate("anthropic", "claude"), makeCandidate("claude"),
} }
attempt := 0 attempt := 0
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { run := func(ctx context.Context, model string) (*LLMResponse, error) {
attempt++ attempt++
return nil, errors.New("image dimensions exceed max 4096x4096") return nil, errors.New("image dimensions exceed max 4096x4096")
} }
@ -337,12 +337,12 @@ func TestImageFallback_SizeError(t *testing.T) {
fc := NewFallbackChain(ct) fc := NewFallbackChain(ct)
candidates := []FallbackCandidate{ candidates := []FallbackCandidate{
makeCandidate("openai", "gpt-4o"), makeCandidate("gpt-4o"),
makeCandidate("anthropic", "claude"), makeCandidate("claude"),
} }
attempt := 0 attempt := 0
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { run := func(ctx context.Context, model string) (*LLMResponse, error) {
attempt++ attempt++
return nil, errors.New("image exceeds 20 mb") return nil, errors.New("image exceeds 20 mb")
} }
@ -361,12 +361,12 @@ func TestImageFallback_RetryOnOtherErrors(t *testing.T) {
fc := NewFallbackChain(ct) fc := NewFallbackChain(ct)
candidates := []FallbackCandidate{ candidates := []FallbackCandidate{
makeCandidate("openai", "gpt-4o"), makeCandidate("gpt-4o"),
makeCandidate("anthropic", "claude-sonnet"), makeCandidate("claude-sonnet"),
} }
attempt := 0 attempt := 0
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { run := func(ctx context.Context, model string) (*LLMResponse, error) {
attempt++ attempt++
if attempt == 1 { if attempt == 1 {
return nil, errors.New("rate limit exceeded") return nil, errors.New("rate limit exceeded")
@ -378,8 +378,8 @@ func TestImageFallback_RetryOnOtherErrors(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if result.Provider != "anthropic" { if result.Model != "claude-sonnet" {
t.Errorf("provider = %q, want anthropic", result.Provider) t.Errorf("model = %q, want claude-sonnet", result.Model)
} }
} }
@ -406,14 +406,14 @@ func TestResolveCandidates_Simple(t *testing.T) {
t.Fatalf("candidates = %d, want 3", len(candidates)) t.Fatalf("candidates = %d, want 3", len(candidates))
} }
if candidates[0].Provider != "openai" || candidates[0].Model != "gpt-4" { if candidates[0].Model != "gpt-4" {
t.Errorf("candidate[0] = %s/%s, want openai/gpt-4", candidates[0].Provider, candidates[0].Model) t.Errorf("candidate[0].Model = %q, want gpt-4", candidates[0].Model)
} }
if candidates[1].Provider != "anthropic" || candidates[1].Model != "claude-opus" { if candidates[1].Model != "claude-opus" {
t.Errorf("candidate[1] = %s/%s, want anthropic/claude-opus", candidates[1].Provider, candidates[1].Model) t.Errorf("candidate[1].Model = %q, want claude-opus", candidates[1].Model)
} }
if candidates[2].Provider != "groq" || candidates[2].Model != "llama-3" { if candidates[2].Model != "llama-3" {
t.Errorf("candidate[2] = %s/%s, want groq/llama-3", candidates[2].Provider, candidates[2].Model) t.Errorf("candidate[2].Model = %q, want llama-3", candidates[2].Model)
} }
} }
@ -470,9 +470,6 @@ func TestResolveCandidatesWithLookup_AliasResolvesToNestedModel(t *testing.T) {
if len(candidates) != 1 { if len(candidates) != 1 {
t.Fatalf("candidates = %d, want 1", len(candidates)) t.Fatalf("candidates = %d, want 1", len(candidates))
} }
if candidates[0].Provider != "openrouter" {
t.Fatalf("provider = %q, want openrouter", candidates[0].Provider)
}
if candidates[0].Model != "stepfun/step-3.5-flash:free" { if candidates[0].Model != "stepfun/step-3.5-flash:free" {
t.Fatalf("model = %q, want stepfun/step-3.5-flash:free", candidates[0].Model) t.Fatalf("model = %q, want stepfun/step-3.5-flash:free", candidates[0].Model)
} }
@ -514,9 +511,6 @@ func TestResolveCandidatesWithLookup_AliasWithoutProtocolUsesDefaultProvider(t *
if len(candidates) != 1 { if len(candidates) != 1 {
t.Fatalf("candidates = %d, want 1", len(candidates)) t.Fatalf("candidates = %d, want 1", len(candidates))
} }
if candidates[0].Provider != "openai" {
t.Fatalf("provider = %q, want openai", candidates[0].Provider)
}
if candidates[0].Model != "glm-5" { if candidates[0].Model != "glm-5" {
t.Fatalf("model = %q, want glm-5", candidates[0].Model) t.Fatalf("model = %q, want glm-5", candidates[0].Model)
} }

180
pkg/providers/registry.go Normal file
View file

@ -0,0 +1,180 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package providers
import (
"fmt"
"github.com/sipeed/picoclaw/pkg/config"
)
// ModelEntry holds the resolved provider and model ID for a configured model.
type ModelEntry struct {
Provider LLMProvider
ModelID string // Protocol-stripped model ID sent to Chat()
ProviderKey string // Protocol string used for cooldown tracking (e.g. "openai", "anthropic")
}
// ModelRegistry is the single source of truth for all configured models.
// It maps model_name → (provider, modelID). Providers with identical configs
// (protocol + api_base + api_key + auth_method) share a single instance.
type ModelRegistry struct {
models map[string]*ModelEntry
defaultModelName string
}
// NewModelRegistry builds a registry from config, creating providers for
// every model_list entry. It is built once at startup and passed through
// to the agent layer.
func NewModelRegistry(cfg *config.Config) (*ModelRegistry, error) {
// Ensure model_list is populated from legacy providers config
if cfg.HasProvidersConfig() {
providerModels := config.ConvertProvidersToModelList(cfg)
existingModelNames := make(map[string]bool)
for _, m := range cfg.ModelList {
existingModelNames[m.ModelName] = true
}
for _, pm := range providerModels {
if !existingModelNames[pm.ModelName] {
cfg.ModelList = append(cfg.ModelList, pm)
}
}
}
if len(cfg.ModelList) == 0 {
return nil, fmt.Errorf("no providers configured. Please add entries to model_list in your config")
}
reg := &ModelRegistry{
models: make(map[string]*ModelEntry),
defaultModelName: cfg.Agents.Defaults.GetModelName(),
}
// Provider cache: models sharing protocol+apiBase+apiKey+authMethod reuse one provider.
type providerKey struct {
protocol string
apiBase string
apiKey string
authMethod string
}
cache := make(map[providerKey]LLMProvider)
for i := range cfg.ModelList {
mc := &cfg.ModelList[i]
if mc.Model == "" || mc.ModelName == "" {
continue
}
protocol, modelID := ExtractProtocol(mc.Model)
apiBase := mc.APIBase
if apiBase == "" {
apiBase = getDefaultAPIBase(protocol)
}
key := providerKey{
protocol: protocol,
apiBase: apiBase,
apiKey: mc.APIKey,
authMethod: mc.AuthMethod,
}
provider, cached := cache[key]
if !cached {
var err error
provider, _, err = CreateProviderFromConfig(mc)
if err != nil {
return nil, fmt.Errorf("failed to create provider for model %q: %w", mc.ModelName, err)
}
cache[key] = provider
}
providerKey := protocol
if providerKey == "" {
providerKey = "openai"
}
reg.models[mc.ModelName] = &ModelEntry{
Provider: provider,
ModelID: modelID,
ProviderKey: providerKey,
}
}
if len(reg.models) == 0 {
return nil, fmt.Errorf("no valid models in model_list")
}
// Resolve default model name to model ID so the agent gets the right value
if entry, ok := reg.models[reg.defaultModelName]; ok {
_ = entry // default exists, good
}
return reg, nil
}
// Get returns the entry for a model_name.
func (r *ModelRegistry) Get(modelName string) (*ModelEntry, bool) {
entry, ok := r.models[modelName]
return entry, ok
}
// GetDefault returns the entry for the configured default model.
func (r *ModelRegistry) GetDefault() (*ModelEntry, bool) {
return r.Get(r.defaultModelName)
}
// DefaultModelName returns the configured default model name.
func (r *ModelRegistry) DefaultModelName() string {
return r.defaultModelName
}
// ModelNames returns all registered model names.
func (r *ModelRegistry) ModelNames() []string {
names := make([]string, 0, len(r.models))
for name := range r.models {
names = append(names, name)
}
return names
}
// NewModelRegistryFromProvider wraps a single provider into a minimal
// ModelRegistry. Useful for tests and the CLI agent path where a full
// config-driven registry isn't needed.
//
// modelID may include a protocol prefix (e.g. "anthropic/claude-3-5-sonnet").
// The protocol is stripped to form both the registry key (user-facing model name)
// and the ModelID passed to Chat().
func NewModelRegistryFromProvider(provider LLMProvider, modelID string) *ModelRegistry {
protocol, stripped := ExtractProtocol(modelID)
name := stripped
if name == "" {
name = "default"
}
providerKey := protocol
if providerKey == "" {
providerKey = "openai"
}
return &ModelRegistry{
models: map[string]*ModelEntry{
name: {Provider: provider, ModelID: stripped, ProviderKey: providerKey},
},
defaultModelName: name,
}
}
// Close closes any stateful providers in the registry.
func (r *ModelRegistry) Close() {
closed := make(map[LLMProvider]bool)
for _, entry := range r.models {
if closed[entry.Provider] {
continue
}
if sp, ok := entry.Provider.(StatefulProvider); ok {
sp.Close()
}
closed[entry.Provider] = true
}
}

View file

@ -4,11 +4,13 @@ import (
"context" "context"
"strings" "strings"
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/providers"
) )
func TestSpawnTool_Execute_EmptyTask(t *testing.T) { func TestSpawnTool_Execute_EmptyTask(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) manager := NewSubagentManager(providers.NewModelRegistryFromProvider(provider, "test-model"), "test-model", "/tmp/test", nil)
tool := NewSpawnTool(manager) tool := NewSpawnTool(manager)
ctx := context.Background() ctx := context.Background()
@ -42,7 +44,7 @@ func TestSpawnTool_Execute_EmptyTask(t *testing.T) {
func TestSpawnTool_Execute_ValidTask(t *testing.T) { func TestSpawnTool_Execute_ValidTask(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) manager := NewSubagentManager(providers.NewModelRegistryFromProvider(provider, "test-model"), "test-model", "/tmp/test", nil)
tool := NewSpawnTool(manager) tool := NewSpawnTool(manager)
ctx := context.Background() ctx := context.Background()

View file

@ -25,8 +25,8 @@ type SubagentTask struct {
type SubagentManager struct { type SubagentManager struct {
tasks map[string]*SubagentTask tasks map[string]*SubagentTask
mu sync.RWMutex mu sync.RWMutex
provider providers.LLMProvider registry *providers.ModelRegistry
defaultModel string defaultModelName string // registry key; looked up at spawn time
bus *bus.MessageBus bus *bus.MessageBus
workspace string workspace string
tools *ToolRegistry tools *ToolRegistry
@ -39,14 +39,14 @@ type SubagentManager struct {
} }
func NewSubagentManager( func NewSubagentManager(
provider providers.LLMProvider, registry *providers.ModelRegistry,
defaultModel, workspace string, defaultModelName, workspace string,
bus *bus.MessageBus, bus *bus.MessageBus,
) *SubagentManager { ) *SubagentManager {
return &SubagentManager{ return &SubagentManager{
tasks: make(map[string]*SubagentTask), tasks: make(map[string]*SubagentTask),
provider: provider, registry: registry,
defaultModel: defaultModel, defaultModelName: defaultModelName,
bus: bus, bus: bus,
workspace: workspace, workspace: workspace,
tools: NewToolRegistry(), tools: NewToolRegistry(),
@ -65,7 +65,13 @@ func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) {
sm.hasTemperature = true sm.hasTemperature = true
} }
// SetTools sets the tool registry for subagent execution. // UpdateModel updates the model used for new subagent spawns.
// Called when the user switches models at runtime.
func (sm *SubagentManager) UpdateModel(modelName string) {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.defaultModelName = modelName
}
// If not set, subagent will have access to the provided tools. // If not set, subagent will have access to the provided tools.
func (sm *SubagentManager) SetTools(tools *ToolRegistry) { func (sm *SubagentManager) SetTools(tools *ToolRegistry) {
sm.mu.Lock() sm.mu.Lock()
@ -151,8 +157,21 @@ After completing the task, provide a clear summary of what was done.`
temperature := sm.temperature temperature := sm.temperature
hasMaxTokens := sm.hasMaxTokens hasMaxTokens := sm.hasMaxTokens
hasTemperature := sm.hasTemperature hasTemperature := sm.hasTemperature
registry := sm.registry
modelName := sm.defaultModelName
sm.mu.RUnlock() sm.mu.RUnlock()
entry, ok := registry.Get(modelName)
if !ok {
if entry, ok = registry.GetDefault(); !ok {
sm.mu.Lock()
task.Status = "failed"
task.Result = fmt.Sprintf("model %q not found in registry", modelName)
sm.mu.Unlock()
return
}
}
var llmOptions map[string]any var llmOptions map[string]any
if hasMaxTokens || hasTemperature { if hasMaxTokens || hasTemperature {
llmOptions = map[string]any{} llmOptions = map[string]any{}
@ -165,8 +184,8 @@ After completing the task, provide a clear summary of what was done.`
} }
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{ loopResult, err := RunToolLoop(ctx, ToolLoopConfig{
Provider: sm.provider, Provider: entry.Provider,
Model: sm.defaultModel, Model: entry.ModelID,
Tools: tools, Tools: tools,
MaxIterations: maxIter, MaxIterations: maxIter,
LLMOptions: llmOptions, LLMOptions: llmOptions,
@ -328,8 +347,17 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
temperature := sm.temperature temperature := sm.temperature
hasMaxTokens := sm.hasMaxTokens hasMaxTokens := sm.hasMaxTokens
hasTemperature := sm.hasTemperature hasTemperature := sm.hasTemperature
registry := sm.registry
modelName := sm.defaultModelName
sm.mu.RUnlock() sm.mu.RUnlock()
entry, ok := registry.Get(modelName)
if !ok {
if entry, ok = registry.GetDefault(); !ok {
return ErrorResult(fmt.Sprintf("model %q not found in registry", modelName)).WithError(fmt.Errorf("model not found"))
}
}
var llmOptions map[string]any var llmOptions map[string]any
if hasMaxTokens || hasTemperature { if hasMaxTokens || hasTemperature {
llmOptions = map[string]any{} llmOptions = map[string]any{}
@ -342,8 +370,8 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
} }
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{ loopResult, err := RunToolLoop(ctx, ToolLoopConfig{
Provider: sm.provider, Provider: entry.Provider,
Model: sm.defaultModel, Model: entry.ModelID,
Tools: tools, Tools: tools,
MaxIterations: maxIter, MaxIterations: maxIter,
LLMOptions: llmOptions, LLMOptions: llmOptions,

View file

@ -47,7 +47,7 @@ func (m *MockLLMProvider) GetContextWindow() int {
func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) { func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) manager := NewSubagentManager(providers.NewModelRegistryFromProvider(provider, "test-model"), "test-model", "/tmp/test", nil)
manager.SetLLMOptions(2048, 0.6) manager.SetLLMOptions(2048, 0.6)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
tool.SetContext("cli", "direct") tool.SetContext("cli", "direct")
@ -74,7 +74,7 @@ func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {
// TestSubagentTool_Name verifies tool name // TestSubagentTool_Name verifies tool name
func TestSubagentTool_Name(t *testing.T) { func TestSubagentTool_Name(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) manager := NewSubagentManager(providers.NewModelRegistryFromProvider(provider, "test-model"), "test-model", "/tmp/test", nil)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
if tool.Name() != "subagent" { if tool.Name() != "subagent" {
@ -85,7 +85,7 @@ func TestSubagentTool_Name(t *testing.T) {
// TestSubagentTool_Description verifies tool description // TestSubagentTool_Description verifies tool description
func TestSubagentTool_Description(t *testing.T) { func TestSubagentTool_Description(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) manager := NewSubagentManager(providers.NewModelRegistryFromProvider(provider, "test-model"), "test-model", "/tmp/test", nil)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
desc := tool.Description() desc := tool.Description()
@ -100,7 +100,7 @@ func TestSubagentTool_Description(t *testing.T) {
// TestSubagentTool_Parameters verifies tool parameters schema // TestSubagentTool_Parameters verifies tool parameters schema
func TestSubagentTool_Parameters(t *testing.T) { func TestSubagentTool_Parameters(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) manager := NewSubagentManager(providers.NewModelRegistryFromProvider(provider, "test-model"), "test-model", "/tmp/test", nil)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
params := tool.Parameters() params := tool.Parameters()
@ -150,7 +150,7 @@ func TestSubagentTool_Parameters(t *testing.T) {
// TestSubagentTool_SetContext verifies context setting // TestSubagentTool_SetContext verifies context setting
func TestSubagentTool_SetContext(t *testing.T) { func TestSubagentTool_SetContext(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) manager := NewSubagentManager(providers.NewModelRegistryFromProvider(provider, "test-model"), "test-model", "/tmp/test", nil)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
tool.SetContext("test-channel", "test-chat") tool.SetContext("test-channel", "test-chat")
@ -164,7 +164,7 @@ func TestSubagentTool_SetContext(t *testing.T) {
func TestSubagentTool_Execute_Success(t *testing.T) { func TestSubagentTool_Execute_Success(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) manager := NewSubagentManager(providers.NewModelRegistryFromProvider(provider, "test-model"), "test-model", "/tmp/test", msgBus)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
tool.SetContext("telegram", "chat-123") tool.SetContext("telegram", "chat-123")
@ -220,7 +220,7 @@ func TestSubagentTool_Execute_Success(t *testing.T) {
func TestSubagentTool_Execute_NoLabel(t *testing.T) { func TestSubagentTool_Execute_NoLabel(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) manager := NewSubagentManager(providers.NewModelRegistryFromProvider(provider, "test-model"), "test-model", "/tmp/test", msgBus)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
ctx := context.Background() ctx := context.Background()
@ -243,7 +243,7 @@ func TestSubagentTool_Execute_NoLabel(t *testing.T) {
// TestSubagentTool_Execute_MissingTask tests error handling for missing task // TestSubagentTool_Execute_MissingTask tests error handling for missing task
func TestSubagentTool_Execute_MissingTask(t *testing.T) { func TestSubagentTool_Execute_MissingTask(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) manager := NewSubagentManager(providers.NewModelRegistryFromProvider(provider, "test-model"), "test-model", "/tmp/test", nil)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
ctx := context.Background() ctx := context.Background()
@ -294,7 +294,7 @@ func TestSubagentTool_Execute_NilManager(t *testing.T) {
func TestSubagentTool_Execute_ContextPassing(t *testing.T) { func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) manager := NewSubagentManager(providers.NewModelRegistryFromProvider(provider, "test-model"), "test-model", "/tmp/test", msgBus)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
// Set context // Set context
@ -323,7 +323,7 @@ func TestSubagentTool_ForUserTruncation(t *testing.T) {
// Create a mock provider that returns very long content // Create a mock provider that returns very long content
provider := &MockLLMProvider{} provider := &MockLLMProvider{}
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) manager := NewSubagentManager(providers.NewModelRegistryFromProvider(provider, "test-model"), "test-model", "/tmp/test", msgBus)
tool := NewSubagentTool(manager) tool := NewSubagentTool(manager)
ctx := context.Background() ctx := context.Background()