Merge fix/subagent-provider-dispatch: per-agent subagent dispatch, allowlist self-spawn, response attribution
This commit is contained in:
commit
fe32c4cf2b
6 changed files with 205 additions and 87 deletions
|
|
@ -85,13 +85,13 @@ func NewAgentLoop(
|
||||||
) *AgentLoop {
|
) *AgentLoop {
|
||||||
registry := NewAgentRegistry(cfg, provider)
|
registry := NewAgentRegistry(cfg, provider)
|
||||||
|
|
||||||
// Register shared tools to all agents
|
|
||||||
registerSharedTools(cfg, msgBus, registry, provider)
|
|
||||||
|
|
||||||
// Set up shared fallback chain
|
// Set up shared fallback chain
|
||||||
cooldown := providers.NewCooldownTracker()
|
cooldown := providers.NewCooldownTracker()
|
||||||
fallbackChain := providers.NewFallbackChain(cooldown)
|
fallbackChain := providers.NewFallbackChain(cooldown)
|
||||||
|
|
||||||
|
// Register shared tools to all agents
|
||||||
|
registerSharedTools(cfg, msgBus, registry, provider, dispatcher, fallbackChain)
|
||||||
|
|
||||||
// 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 := registry.GetDefaultAgent()
|
||||||
var stateManager *state.Manager
|
var stateManager *state.Manager
|
||||||
|
|
@ -119,6 +119,8 @@ func registerSharedTools(
|
||||||
msgBus *bus.MessageBus,
|
msgBus *bus.MessageBus,
|
||||||
registry *AgentRegistry,
|
registry *AgentRegistry,
|
||||||
provider providers.LLMProvider,
|
provider providers.LLMProvider,
|
||||||
|
dispatcher *providers.ProviderDispatcher,
|
||||||
|
fallbackChain *providers.FallbackChain,
|
||||||
) {
|
) {
|
||||||
for _, agentID := range registry.ListAgentIDs() {
|
for _, agentID := range registry.ListAgentIDs() {
|
||||||
agent, ok := registry.GetAgent(agentID)
|
agent, ok := registry.GetAgent(agentID)
|
||||||
|
|
@ -228,10 +230,31 @@ func registerSharedTools(
|
||||||
// Spawn tool with allowlist checker
|
// Spawn tool with allowlist checker
|
||||||
if cfg.Tools.IsToolEnabled("spawn") {
|
if cfg.Tools.IsToolEnabled("spawn") {
|
||||||
if cfg.Tools.IsToolEnabled("subagent") {
|
if cfg.Tools.IsToolEnabled("subagent") {
|
||||||
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
|
currentAgentID := agentID
|
||||||
|
// Build a resolver so the subagent manager can look up any target
|
||||||
|
// agent's candidates without importing the agent package from tools.
|
||||||
|
candidateResolver := func(targetAgentID string) ([]providers.FallbackCandidate, bool) {
|
||||||
|
target, ok := registry.GetAgent(targetAgentID)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if len(target.Candidates) == 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return target.Candidates, true
|
||||||
|
}
|
||||||
|
subagentManager := tools.NewSubagentManager(tools.SubagentManagerConfig{
|
||||||
|
Provider: provider,
|
||||||
|
DefaultModel: agent.Model,
|
||||||
|
Workspace: agent.Workspace,
|
||||||
|
Dispatcher: dispatcher,
|
||||||
|
Fallback: fallbackChain,
|
||||||
|
SelfCandidates: agent.Candidates,
|
||||||
|
CallerAgentID: currentAgentID,
|
||||||
|
CandidateResolver: candidateResolver,
|
||||||
|
})
|
||||||
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
||||||
spawnTool := tools.NewSpawnTool(subagentManager)
|
spawnTool := tools.NewSpawnTool(subagentManager)
|
||||||
currentAgentID := agentID
|
|
||||||
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
||||||
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
|
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
|
||||||
})
|
})
|
||||||
|
|
@ -407,8 +430,10 @@ func (al *AgentLoop) ReloadProviderAndConfig(
|
||||||
return fmt.Errorf("context canceled after registry creation: %w", err)
|
return fmt.Errorf("context canceled after registry creation: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure shared tools are re-registered on the new registry
|
// Ensure shared tools are re-registered on the new registry.
|
||||||
registerSharedTools(cfg, al.bus, registry, provider)
|
// Build a fresh fallback chain for the new registry's subagent managers.
|
||||||
|
newFallbackChain := providers.NewFallbackChain(providers.NewCooldownTracker())
|
||||||
|
registerSharedTools(cfg, al.bus, registry, provider, al.dispatcher, newFallbackChain)
|
||||||
|
|
||||||
// Atomically swap the config and registry under write lock
|
// Atomically swap the config and registry under write lock
|
||||||
// This ensures readers see a consistent pair
|
// This ensures readers see a consistent pair
|
||||||
|
|
|
||||||
|
|
@ -72,10 +72,16 @@ func (t *SpawnTool) execute(ctx context.Context, args map[string]any, cb AsyncCa
|
||||||
label, _ := args["label"].(string)
|
label, _ := args["label"].(string)
|
||||||
agentID, _ := args["agent_id"].(string)
|
agentID, _ := args["agent_id"].(string)
|
||||||
|
|
||||||
// Check allowlist if targeting a specific agent
|
// Check allowlist for both self-spawn (agentID == "") and targeted spawns.
|
||||||
if agentID != "" && t.allowlistCheck != nil {
|
// For self-spawn we pass the manager's callerAgentID so that operators can
|
||||||
if !t.allowlistCheck(agentID) {
|
// restrict agents to only spawning specific peers (not themselves).
|
||||||
return ErrorResult(fmt.Sprintf("not allowed to spawn agent '%s'", agentID))
|
if t.allowlistCheck != nil {
|
||||||
|
checkID := agentID
|
||||||
|
if checkID == "" && t.manager != nil {
|
||||||
|
checkID = t.manager.callerAgentID
|
||||||
|
}
|
||||||
|
if checkID != "" && !t.allowlistCheck(checkID) {
|
||||||
|
return ErrorResult(fmt.Sprintf("not allowed to spawn agent '%s'", checkID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,11 @@ import (
|
||||||
|
|
||||||
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")
|
manager := NewSubagentManager(SubagentManagerConfig{
|
||||||
|
Provider: provider,
|
||||||
|
DefaultModel: "test-model",
|
||||||
|
Workspace: "/tmp/test",
|
||||||
|
})
|
||||||
tool := NewSpawnTool(manager)
|
tool := NewSpawnTool(manager)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -42,7 +46,11 @@ 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")
|
manager := NewSubagentManager(SubagentManagerConfig{
|
||||||
|
Provider: provider,
|
||||||
|
DefaultModel: "test-model",
|
||||||
|
Workspace: "/tmp/test",
|
||||||
|
})
|
||||||
tool := NewSpawnTool(manager)
|
tool := NewSpawnTool(manager)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,22 @@ package tools
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// attributedContent prepends "AgentName: " to content when agentID is non-empty.
|
||||||
|
func attributedContent(agentID, content string) string {
|
||||||
|
if strings.TrimSpace(agentID) == "" {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
name := strings.ToUpper(agentID[:1]) + agentID[1:]
|
||||||
|
return "**" + name + ":** " + content
|
||||||
|
}
|
||||||
|
|
||||||
type SubagentTask struct {
|
type SubagentTask struct {
|
||||||
ID string
|
ID string
|
||||||
Task string
|
Task string
|
||||||
|
|
@ -34,23 +44,97 @@ type SubagentManager struct {
|
||||||
hasMaxTokens bool
|
hasMaxTokens bool
|
||||||
hasTemperature bool
|
hasTemperature bool
|
||||||
nextID int
|
nextID int
|
||||||
|
|
||||||
|
// Per-agent dispatch fields. When dispatcher is set, subagent LLM calls
|
||||||
|
// are routed through the dispatcher using selfCandidates (for self-spawns)
|
||||||
|
// or the target agent's candidates (resolved via candidateResolver).
|
||||||
|
dispatcher *providers.ProviderDispatcher
|
||||||
|
fallback *providers.FallbackChain
|
||||||
|
selfCandidates []providers.FallbackCandidate
|
||||||
|
callerAgentID string
|
||||||
|
candidateResolver func(agentID string) ([]providers.FallbackCandidate, bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSubagentManager(
|
// SubagentManagerConfig holds all configuration for constructing a SubagentManager.
|
||||||
provider providers.LLMProvider,
|
type SubagentManagerConfig struct {
|
||||||
defaultModel, workspace string,
|
// Provider is the fallback LLM provider used when dispatcher lookup fails.
|
||||||
) *SubagentManager {
|
Provider providers.LLMProvider
|
||||||
|
// DefaultModel is the model name used when no candidates are resolved.
|
||||||
|
DefaultModel string
|
||||||
|
// Workspace is the agent's working directory.
|
||||||
|
Workspace string
|
||||||
|
// Dispatcher dispatches LLM calls per-candidate. Optional.
|
||||||
|
Dispatcher *providers.ProviderDispatcher
|
||||||
|
// Fallback is the chain used when multiple candidates are configured. Optional.
|
||||||
|
Fallback *providers.FallbackChain
|
||||||
|
// SelfCandidates are the calling agent's model candidates for self-spawns.
|
||||||
|
SelfCandidates []providers.FallbackCandidate
|
||||||
|
// CallerAgentID is the ID of the agent that owns this manager (for allowlist).
|
||||||
|
CallerAgentID string
|
||||||
|
// CandidateResolver resolves model candidates for a named target agent.
|
||||||
|
// Returns false if the agent is unknown.
|
||||||
|
CandidateResolver func(agentID string) ([]providers.FallbackCandidate, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSubagentManager(cfg SubagentManagerConfig) *SubagentManager {
|
||||||
return &SubagentManager{
|
return &SubagentManager{
|
||||||
tasks: make(map[string]*SubagentTask),
|
tasks: make(map[string]*SubagentTask),
|
||||||
provider: provider,
|
provider: cfg.Provider,
|
||||||
defaultModel: defaultModel,
|
defaultModel: cfg.DefaultModel,
|
||||||
workspace: workspace,
|
workspace: cfg.Workspace,
|
||||||
tools: NewToolRegistry(),
|
tools: NewToolRegistry(),
|
||||||
maxIterations: 10,
|
maxIterations: 10,
|
||||||
nextID: 1,
|
nextID: 1,
|
||||||
|
dispatcher: cfg.Dispatcher,
|
||||||
|
fallback: cfg.Fallback,
|
||||||
|
selfCandidates: cfg.SelfCandidates,
|
||||||
|
callerAgentID: cfg.CallerAgentID,
|
||||||
|
candidateResolver: cfg.CandidateResolver,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveLoopConfig builds a ToolLoopConfig for the given target agent ID.
|
||||||
|
// When agentID is empty it is treated as a self-spawn and uses selfCandidates.
|
||||||
|
// When agentID is non-empty and a candidateResolver is set, it resolves the
|
||||||
|
// target agent's candidates. Falls back to provider+defaultModel when no
|
||||||
|
// dispatch metadata is available.
|
||||||
|
func (sm *SubagentManager) resolveLoopConfig(agentID string) ToolLoopConfig {
|
||||||
|
candidates := sm.selfCandidates
|
||||||
|
if agentID != "" && sm.candidateResolver != nil {
|
||||||
|
if resolved, ok := sm.candidateResolver(agentID); ok {
|
||||||
|
candidates = resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := ToolLoopConfig{
|
||||||
|
Provider: sm.provider,
|
||||||
|
Model: sm.defaultModel,
|
||||||
|
MaxIterations: sm.maxIterations,
|
||||||
|
Tools: sm.tools,
|
||||||
|
Dispatcher: sm.dispatcher,
|
||||||
|
Fallback: sm.fallback,
|
||||||
|
Candidates: candidates,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override Model from first candidate when available.
|
||||||
|
if len(candidates) > 0 {
|
||||||
|
cfg.Model = candidates[0].Model
|
||||||
|
}
|
||||||
|
|
||||||
|
if sm.hasMaxTokens || sm.hasTemperature {
|
||||||
|
opts := map[string]any{}
|
||||||
|
if sm.hasMaxTokens {
|
||||||
|
opts["max_tokens"] = sm.maxTokens
|
||||||
|
}
|
||||||
|
if sm.hasTemperature {
|
||||||
|
opts["temperature"] = sm.temperature
|
||||||
|
}
|
||||||
|
cfg.LLMOptions = opts
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
// SetLLMOptions sets max tokens and temperature for subagent LLM calls.
|
// SetLLMOptions sets max tokens and temperature for subagent LLM calls.
|
||||||
func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) {
|
func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) {
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
|
|
@ -139,34 +223,11 @@ After completing the task, provide a clear summary of what was done.`
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run tool loop with access to tools
|
|
||||||
sm.mu.RLock()
|
sm.mu.RLock()
|
||||||
tools := sm.tools
|
loopCfg := sm.resolveLoopConfig(task.AgentID)
|
||||||
maxIter := sm.maxIterations
|
|
||||||
maxTokens := sm.maxTokens
|
|
||||||
temperature := sm.temperature
|
|
||||||
hasMaxTokens := sm.hasMaxTokens
|
|
||||||
hasTemperature := sm.hasTemperature
|
|
||||||
sm.mu.RUnlock()
|
sm.mu.RUnlock()
|
||||||
|
|
||||||
var llmOptions map[string]any
|
loopResult, err := RunToolLoop(ctx, loopCfg, messages, task.OriginChannel, task.OriginChatID)
|
||||||
if hasMaxTokens || hasTemperature {
|
|
||||||
llmOptions = map[string]any{}
|
|
||||||
if hasMaxTokens {
|
|
||||||
llmOptions["max_tokens"] = maxTokens
|
|
||||||
}
|
|
||||||
if hasTemperature {
|
|
||||||
llmOptions["temperature"] = temperature
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{
|
|
||||||
Provider: sm.provider,
|
|
||||||
Model: sm.defaultModel,
|
|
||||||
Tools: tools,
|
|
||||||
MaxIterations: maxIter,
|
|
||||||
LLMOptions: llmOptions,
|
|
||||||
}, messages, task.OriginChannel, task.OriginChatID)
|
|
||||||
|
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
var result *ToolResult
|
var result *ToolResult
|
||||||
|
|
@ -204,7 +265,7 @@ After completing the task, provide a clear summary of what was done.`
|
||||||
loopResult.Iterations,
|
loopResult.Iterations,
|
||||||
loopResult.Content,
|
loopResult.Content,
|
||||||
),
|
),
|
||||||
ForUser: loopResult.Content,
|
ForUser: attributedContent(task.AgentID, loopResult.Content),
|
||||||
Silent: false,
|
Silent: false,
|
||||||
IsError: false,
|
IsError: false,
|
||||||
Async: false,
|
Async: false,
|
||||||
|
|
@ -275,6 +336,7 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
}
|
}
|
||||||
|
|
||||||
label, _ := args["label"].(string)
|
label, _ := args["label"].(string)
|
||||||
|
agentID, _ := args["agent_id"].(string)
|
||||||
|
|
||||||
if t.manager == nil {
|
if t.manager == nil {
|
||||||
return ErrorResult("Subagent manager not configured").WithError(fmt.Errorf("manager is nil"))
|
return ErrorResult("Subagent manager not configured").WithError(fmt.Errorf("manager is nil"))
|
||||||
|
|
@ -292,28 +354,13 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use RunToolLoop to execute with tools (same as async SpawnTool)
|
// Use RunToolLoop to execute with tools (same as async SpawnTool).
|
||||||
|
// SubagentTool always performs a self-spawn (no explicit agent_id).
|
||||||
sm := t.manager
|
sm := t.manager
|
||||||
sm.mu.RLock()
|
sm.mu.RLock()
|
||||||
tools := sm.tools
|
loopCfg := sm.resolveLoopConfig("")
|
||||||
maxIter := sm.maxIterations
|
|
||||||
maxTokens := sm.maxTokens
|
|
||||||
temperature := sm.temperature
|
|
||||||
hasMaxTokens := sm.hasMaxTokens
|
|
||||||
hasTemperature := sm.hasTemperature
|
|
||||||
sm.mu.RUnlock()
|
sm.mu.RUnlock()
|
||||||
|
|
||||||
var llmOptions map[string]any
|
|
||||||
if hasMaxTokens || hasTemperature {
|
|
||||||
llmOptions = map[string]any{}
|
|
||||||
if hasMaxTokens {
|
|
||||||
llmOptions["max_tokens"] = maxTokens
|
|
||||||
}
|
|
||||||
if hasTemperature {
|
|
||||||
llmOptions["temperature"] = temperature
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to "cli"/"direct" for non-conversation callers (e.g., CLI, tests)
|
// Fall back to "cli"/"direct" for non-conversation callers (e.g., CLI, tests)
|
||||||
// to preserve the same defaults as the original NewSubagentTool constructor.
|
// to preserve the same defaults as the original NewSubagentTool constructor.
|
||||||
channel := ToolChannel(ctx)
|
channel := ToolChannel(ctx)
|
||||||
|
|
@ -325,13 +372,7 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
chatID = "direct"
|
chatID = "direct"
|
||||||
}
|
}
|
||||||
|
|
||||||
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{
|
loopResult, err := RunToolLoop(ctx, loopCfg, messages, channel, chatID)
|
||||||
Provider: sm.provider,
|
|
||||||
Model: sm.defaultModel,
|
|
||||||
Tools: tools,
|
|
||||||
MaxIterations: maxIter,
|
|
||||||
LLMOptions: llmOptions,
|
|
||||||
}, messages, channel, chatID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err)
|
return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err)
|
||||||
}
|
}
|
||||||
|
|
@ -353,7 +394,7 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
|
|
||||||
return &ToolResult{
|
return &ToolResult{
|
||||||
ForLLM: llmContent,
|
ForLLM: llmContent,
|
||||||
ForUser: userContent,
|
ForUser: attributedContent(agentID, userContent),
|
||||||
Silent: false,
|
Silent: false,
|
||||||
IsError: false,
|
IsError: false,
|
||||||
Async: false,
|
Async: false,
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,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")
|
manager := NewSubagentManager(SubagentManagerConfig{Provider: provider, DefaultModel: "test-model", Workspace: "/tmp/test"})
|
||||||
manager.SetLLMOptions(2048, 0.6)
|
manager.SetLLMOptions(2048, 0.6)
|
||||||
tool := NewSubagentTool(manager)
|
tool := NewSubagentTool(manager)
|
||||||
|
|
||||||
|
|
@ -72,7 +72,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")
|
manager := NewSubagentManager(SubagentManagerConfig{Provider: provider, DefaultModel: "test-model", Workspace: "/tmp/test"})
|
||||||
tool := NewSubagentTool(manager)
|
tool := NewSubagentTool(manager)
|
||||||
|
|
||||||
if tool.Name() != "subagent" {
|
if tool.Name() != "subagent" {
|
||||||
|
|
@ -83,7 +83,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")
|
manager := NewSubagentManager(SubagentManagerConfig{Provider: provider, DefaultModel: "test-model", Workspace: "/tmp/test"})
|
||||||
tool := NewSubagentTool(manager)
|
tool := NewSubagentTool(manager)
|
||||||
|
|
||||||
desc := tool.Description()
|
desc := tool.Description()
|
||||||
|
|
@ -98,7 +98,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")
|
manager := NewSubagentManager(SubagentManagerConfig{Provider: provider, DefaultModel: "test-model", Workspace: "/tmp/test"})
|
||||||
tool := NewSubagentTool(manager)
|
tool := NewSubagentTool(manager)
|
||||||
|
|
||||||
params := tool.Parameters()
|
params := tool.Parameters()
|
||||||
|
|
@ -148,7 +148,7 @@ func TestSubagentTool_Parameters(t *testing.T) {
|
||||||
// TestSubagentTool_Execute_Success tests successful execution
|
// TestSubagentTool_Execute_Success tests successful execution
|
||||||
func TestSubagentTool_Execute_Success(t *testing.T) {
|
func TestSubagentTool_Execute_Success(t *testing.T) {
|
||||||
provider := &MockLLMProvider{}
|
provider := &MockLLMProvider{}
|
||||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
manager := NewSubagentManager(SubagentManagerConfig{Provider: provider, DefaultModel: "test-model", Workspace: "/tmp/test"})
|
||||||
tool := NewSubagentTool(manager)
|
tool := NewSubagentTool(manager)
|
||||||
|
|
||||||
ctx := WithToolContext(context.Background(), "telegram", "chat-123")
|
ctx := WithToolContext(context.Background(), "telegram", "chat-123")
|
||||||
|
|
@ -202,7 +202,7 @@ func TestSubagentTool_Execute_Success(t *testing.T) {
|
||||||
// TestSubagentTool_Execute_NoLabel tests execution without label
|
// TestSubagentTool_Execute_NoLabel tests execution without label
|
||||||
func TestSubagentTool_Execute_NoLabel(t *testing.T) {
|
func TestSubagentTool_Execute_NoLabel(t *testing.T) {
|
||||||
provider := &MockLLMProvider{}
|
provider := &MockLLMProvider{}
|
||||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
manager := NewSubagentManager(SubagentManagerConfig{Provider: provider, DefaultModel: "test-model", Workspace: "/tmp/test"})
|
||||||
tool := NewSubagentTool(manager)
|
tool := NewSubagentTool(manager)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -225,7 +225,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")
|
manager := NewSubagentManager(SubagentManagerConfig{Provider: provider, DefaultModel: "test-model", Workspace: "/tmp/test"})
|
||||||
tool := NewSubagentTool(manager)
|
tool := NewSubagentTool(manager)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -275,7 +275,7 @@ func TestSubagentTool_Execute_NilManager(t *testing.T) {
|
||||||
// TestSubagentTool_Execute_ContextPassing verifies context is properly used
|
// TestSubagentTool_Execute_ContextPassing verifies context is properly used
|
||||||
func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
|
func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
|
||||||
provider := &MockLLMProvider{}
|
provider := &MockLLMProvider{}
|
||||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
manager := NewSubagentManager(SubagentManagerConfig{Provider: provider, DefaultModel: "test-model", Workspace: "/tmp/test"})
|
||||||
tool := NewSubagentTool(manager)
|
tool := NewSubagentTool(manager)
|
||||||
|
|
||||||
channel := "test-channel"
|
channel := "test-channel"
|
||||||
|
|
@ -300,7 +300,7 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
|
||||||
func TestSubagentTool_ForUserTruncation(t *testing.T) {
|
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{}
|
||||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
manager := NewSubagentManager(SubagentManagerConfig{Provider: provider, DefaultModel: "test-model", Workspace: "/tmp/test"})
|
||||||
tool := NewSubagentTool(manager)
|
tool := NewSubagentTool(manager)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,14 @@ type ToolLoopConfig struct {
|
||||||
Tools *ToolRegistry
|
Tools *ToolRegistry
|
||||||
MaxIterations int
|
MaxIterations int
|
||||||
LLMOptions map[string]any
|
LLMOptions map[string]any
|
||||||
|
|
||||||
|
// Per-candidate dispatch: when Dispatcher and Candidates are set, RunToolLoop
|
||||||
|
// selects the provider for each LLM call through the dispatcher using the
|
||||||
|
// candidate list, falling back through candidates on error via Fallback.
|
||||||
|
// If Fallback is nil, only the first candidate is tried.
|
||||||
|
Dispatcher *providers.ProviderDispatcher
|
||||||
|
Candidates []providers.FallbackCandidate
|
||||||
|
Fallback *providers.FallbackChain
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToolLoopResult contains the result of running the tool loop.
|
// ToolLoopResult contains the result of running the tool loop.
|
||||||
|
|
@ -63,8 +71,38 @@ func RunToolLoop(
|
||||||
if llmOpts == nil {
|
if llmOpts == nil {
|
||||||
llmOpts = map[string]any{}
|
llmOpts = map[string]any{}
|
||||||
}
|
}
|
||||||
// 3. Call LLM
|
// 3. Call LLM — use per-candidate dispatch when configured, else fall back
|
||||||
response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts)
|
// to the plain Provider/Model pair.
|
||||||
|
var response *providers.LLMResponse
|
||||||
|
var err error
|
||||||
|
if config.Dispatcher != nil && len(config.Candidates) > 0 {
|
||||||
|
if config.Fallback != nil && len(config.Candidates) > 1 {
|
||||||
|
fbResult, fbErr := config.Fallback.Execute(
|
||||||
|
ctx,
|
||||||
|
config.Candidates,
|
||||||
|
func(ctx context.Context, providerName, model string) (*providers.LLMResponse, error) {
|
||||||
|
if p, perr := config.Dispatcher.Get(providerName, model); perr == nil {
|
||||||
|
return p.Chat(ctx, messages, providerToolDefs, model, llmOpts)
|
||||||
|
}
|
||||||
|
return config.Provider.Chat(ctx, messages, providerToolDefs, model, llmOpts)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if fbErr != nil {
|
||||||
|
err = fbErr
|
||||||
|
} else {
|
||||||
|
response = fbResult.Response
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
first := config.Candidates[0]
|
||||||
|
if p, perr := config.Dispatcher.Get(first.Provider, first.Model); perr == nil {
|
||||||
|
response, err = p.Chat(ctx, messages, providerToolDefs, first.Model, llmOpts)
|
||||||
|
} else {
|
||||||
|
response, err = config.Provider.Chat(ctx, messages, providerToolDefs, first.Model, llmOpts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
response, err = config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("toolloop", "LLM call failed",
|
logger.ErrorCF("toolloop", "LLM call failed",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue