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
|
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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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-*")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue