feat(agent): create per-agent provider from agent model config

When an agent in agents.list specifies a model that requires a different
provider type than the global default (e.g. claude-cli, codex-cli), the
agent now gets its own provider instance created from its model config.
The agent workspace is injected into the model config so CLI-based
providers run in the correct project directory.

Previously all agents shared a single global provider, causing the model
string to be forwarded to the wrong backend (e.g. an HTTP API receiving
"claude-code" as a model ID).

Adds TestProcessMessage_PerAgentProviderUsedForNamedAgent to verify that
a binding-routed agent uses its own provider while the default agent
continues to use the global one.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dmitrii Mukhutdinov 2026-03-27 17:27:41 -03:00
parent 3b3f95c44c
commit 04a00c6859
2 changed files with 306 additions and 0 deletions

View file

@ -167,6 +167,19 @@ func NewAgentInstance(
summarizeTokenPercent = 75 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 // Resolve fallback candidates
candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks) candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks)

View file

@ -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 // TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound
func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) { func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*") tmpDir, err := os.MkdirTemp("", "agent-test-*")