diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 880725660..9abb09cac 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -167,6 +167,19 @@ func NewAgentInstance( summarizeTokenPercent = 75 } + // Try to create a provider specific to this agent's model. This allows agents + // configured with a different provider type (e.g. claude-cli, codex-cli) to + // work independently of the global default HTTP provider. + // The agent's workspace is injected so CLI-based providers run in the right directory. + if agentModelCfg, err := resolvedModelConfig(cfg, model, workspace); err == nil { + if agentProvider, _, err := providers.CreateProviderFromConfig(agentModelCfg); err == nil { + provider = agentProvider + } else { + logger.WarnCF("agent", "Per-agent provider init failed; using global provider", + map[string]any{"agent_id": agentID, "model": model, "error": err.Error()}) + } + } + // Resolve fallback candidates candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks) diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 9513d8aca..f1c23abdf 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -1839,6 +1839,299 @@ func TestProcessMessage_ModelRoutingUsesLightProvider(t *testing.T) { } } +// TestProcessMessage_PerAgentProviderUsedForNamedAgent verifies that a named agent configured +// with a different model uses its own provider rather than the global default one. +func TestProcessMessage_PerAgentProviderUsedForNamedAgent(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + globalCalls := 0 + globalModel := "" + globalServer := newChatCompletionTestServer(t, "global", "global reply", &globalCalls, &globalModel) + defer globalServer.Close() + + agentCalls := 0 + agentModel := "" + agentServer := newChatCompletionTestServer(t, "agent", "agent reply", &agentCalls, &agentModel) + defer agentServer.Close() + + cfg := &config.Config{ + Bindings: []config.AgentBinding{ + { + AgentID: "special", + Match: config.BindingMatch{ + Channel: "telegram", + AccountID: "*", + Peer: &config.PeerMatch{Kind: "direct", ID: "agent-user"}, + }, + }, + }, + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "global-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + List: []config.AgentConfig{ + { + ID: "main", + Default: true, + Model: &config.AgentModelConfig{Primary: "global-model"}, + }, + { + ID: "special", + Model: &config.AgentModelConfig{Primary: "agent-model"}, + }, + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "global-model", + Model: "openai/global-v1", + APIBase: globalServer.URL, + APIKeys: config.SimpleSecureStrings("global-key"), + }, + { + ModelName: "agent-model", + Model: "openai/agent-v1", + APIBase: agentServer.URL, + APIKeys: config.SimpleSecureStrings("agent-key"), + }, + }, + } + + msgBus := bus.NewMessageBus() + globalProvider, _, err := providers.CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + al := NewAgentLoop(cfg, msgBus, globalProvider) + helper := testHelper{al: al} + + // Message from a regular user goes to global provider. + resp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "default-user", + ChatID: "chat-default", + Content: "hello from default", + Peer: bus.Peer{Kind: "direct", ID: "default-user"}, + }) + if resp != "global reply" { + t.Fatalf("default agent response = %q, want %q", resp, "global reply") + } + if globalCalls != 1 { + t.Fatalf("global calls = %d, want 1", globalCalls) + } + if agentCalls != 0 { + t.Fatalf("agent calls after default message = %d, want 0", agentCalls) + } + + // Message routed to the named agent uses the agent-specific provider. + resp2 := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "agent-user", + ChatID: "chat-special", + Content: "hello from special peer", + Peer: bus.Peer{Kind: "direct", ID: "agent-user"}, + }) + if resp2 != "agent reply" { + t.Fatalf("named agent response = %q, want %q", resp2, "agent reply") + } + if agentCalls != 1 { + t.Fatalf("agent calls = %d, want 1", agentCalls) + } + if globalCalls != 1 { + t.Fatalf("global calls after named-agent message = %d, want 1", globalCalls) + } + if agentModel != "agent-v1" { + t.Fatalf("agent model = %q, want %q", agentModel, "agent-v1") + } +} + defer os.RemoveAll(tmpDir) + +<<<<<<< HEAD + heavyCalls := 0 + heavyServer := newStrictChatCompletionTestServer( + t, + "heavy", + "gemini-2.5-flash", + "heavy reply", + &heavyCalls, + ) + defer heavyServer.Close() + + lightCalls := 0 + lightServer := newStrictChatCompletionTestServer( + t, + "light", + "qwen2.5:0.5b", + "light reply", + &lightCalls, + ) + defer lightServer.Close() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "gemini-main", + MaxTokens: 4096, + MaxToolIterations: 10, + Routing: &config.RoutingConfig{ + Enabled: true, + LightModel: "qwen-light", + Threshold: 0.99, +======= + globalCalls := 0 + globalModel := "" + globalServer := newChatCompletionTestServer(t, "global", "global reply", &globalCalls, &globalModel) + defer globalServer.Close() + + agentCalls := 0 + agentModel := "" + agentServer := newChatCompletionTestServer(t, "agent", "agent reply", &agentCalls, &agentModel) + defer agentServer.Close() + + cfg := &config.Config{ + Bindings: []config.AgentBinding{ + { + AgentID: "special", + Match: config.BindingMatch{ + Channel: "telegram", + AccountID: "*", + Peer: &config.PeerMatch{Kind: "direct", ID: "agent-user"}, + }, + }, + }, + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "global-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + List: []config.AgentConfig{ + { + ID: "main", + Default: true, + Model: &config.AgentModelConfig{Primary: "global-model"}, + }, + { + ID: "special", + Model: &config.AgentModelConfig{Primary: "agent-model"}, +>>>>>>> 2ef9d1a (feat(agent): create per-agent provider from agent model config) + }, + }, + }, + ModelList: []*config.ModelConfig{ + { +<<<<<<< HEAD + ModelName: "gemini-main", + Model: "gemini/gemini-2.5-flash", + APIBase: heavyServer.URL, + APIKeys: config.SimpleSecureStrings("heavy-key"), + }, + { + ModelName: "qwen-light", + Model: "ollama/qwen2.5:0.5b", + APIBase: lightServer.URL, + APIKeys: config.SimpleSecureStrings("light-key"), +======= + ModelName: "global-model", + Model: "openai/global-v1", + APIBase: globalServer.URL, + APIKeys: config.SimpleSecureStrings("global-key"), + }, + { + ModelName: "agent-model", + Model: "openai/agent-v1", + APIBase: agentServer.URL, + APIKeys: config.SimpleSecureStrings("agent-key"), +>>>>>>> 2ef9d1a (feat(agent): create per-agent provider from agent model config) + }, + }, + } + + msgBus := bus.NewMessageBus() +<<<<<<< HEAD + provider, _, err := providers.CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + resp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "hi", + Peer: bus.Peer{ + Kind: "direct", + ID: "user1", + }, + }) + if resp != "light reply" { + t.Fatalf("response = %q, want %q", resp, "light reply") + } + if heavyCalls != 0 { + t.Fatalf("heavy calls = %d, want 0", heavyCalls) + } + if lightCalls != 1 { + t.Fatalf("light calls = %d, want 1", lightCalls) +======= + globalProvider, _, err := providers.CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + al := NewAgentLoop(cfg, msgBus, globalProvider) + helper := testHelper{al: al} + + // Message from a regular user goes to global provider. + resp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "default-user", + ChatID: "chat-default", + Content: "hello from default", + Peer: bus.Peer{Kind: "direct", ID: "default-user"}, + }) + if resp != "global reply" { + t.Fatalf("default agent response = %q, want %q", resp, "global reply") + } + if globalCalls != 1 { + t.Fatalf("global calls = %d, want 1", globalCalls) + } + if agentCalls != 0 { + t.Fatalf("agent calls after default message = %d, want 0", agentCalls) + } + + // Message routed to the named agent uses the agent-specific provider. + resp2 := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "agent-user", + ChatID: "chat-special", + Content: "hello from special peer", + Peer: bus.Peer{Kind: "direct", ID: "agent-user"}, + }) + if resp2 != "agent reply" { + t.Fatalf("named agent response = %q, want %q", resp2, "agent reply") + } + if agentCalls != 1 { + t.Fatalf("agent calls = %d, want 1", agentCalls) + } + if globalCalls != 1 { + t.Fatalf("global calls after named-agent message = %d, want 1", globalCalls) + } + if agentModel != "agent-v1" { + t.Fatalf("agent model = %q, want %q", agentModel, "agent-v1") +>>>>>>> 2ef9d1a (feat(agent): create per-agent provider from agent model config) + } +} + // TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*")