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:
parent
df1b53fdf9
commit
33723ea4ff
14 changed files with 733 additions and 512 deletions
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
180
pkg/providers/registry.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue