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:
parent
3b3f95c44c
commit
04a00c6859
2 changed files with 306 additions and 0 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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-*")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue