diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 9785567af..ed001840f 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -28,6 +28,7 @@ type AgentInstance struct { Sessions *session.SessionManager ContextBuilder *ContextBuilder Tools *tools.ToolRegistry + Capabilities []string Subagents *config.SubagentsConfig SkillsFilter []string Candidates []providers.FallbackCandidate @@ -67,6 +68,7 @@ func NewAgentInstance( agentSystemPrompt := "" var subagents *config.SubagentsConfig var skillsFilter []string + var capabilities []string if agentCfg != nil { agentID = routing.NormalizeAgentID(agentCfg.ID) @@ -75,6 +77,7 @@ func NewAgentInstance( agentSystemPrompt = agentCfg.SystemPrompt subagents = agentCfg.Subagents skillsFilter = agentCfg.Skills + capabilities = agentCfg.Capabilities } maxIter := defaults.MaxToolIterations @@ -103,6 +106,7 @@ func NewAgentInstance( Sessions: sessionsManager, ContextBuilder: contextBuilder, Tools: toolsRegistry, + Capabilities: capabilities, Subagents: subagents, SkillsFilter: skillsFilter, Candidates: candidates, diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index bb923136d..12ccb0745 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -99,6 +99,7 @@ func (r *registryResolver) GetAgentInfo(agentID string) *multiagent.AgentInfo { Provider: agent.Provider, Tools: agent.Tools, MaxIter: agent.MaxIterations, + Capabilities: agent.Capabilities, } } @@ -111,9 +112,10 @@ func (r *registryResolver) ListAgents() []multiagent.AgentInfo { continue } agents = append(agents, multiagent.AgentInfo{ - ID: agent.ID, - Name: agent.Name, - Role: agent.Role, + ID: agent.ID, + Name: agent.Name, + Role: agent.Role, + Capabilities: agent.Capabilities, }) } return agents diff --git a/pkg/config/config.go b/pkg/config/config.go index d46580884..22ec0cd8a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -109,6 +109,7 @@ type AgentConfig struct { Workspace string `json:"workspace,omitempty"` Model *AgentModelConfig `json:"model,omitempty"` Skills []string `json:"skills,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` Subagents *SubagentsConfig `json:"subagents,omitempty"` } diff --git a/pkg/multiagent/handoff.go b/pkg/multiagent/handoff.go index cab5a9489..44ad707da 100644 --- a/pkg/multiagent/handoff.go +++ b/pkg/multiagent/handoff.go @@ -3,6 +3,7 @@ package multiagent import ( "context" "fmt" + "slices" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/tools" @@ -26,6 +27,18 @@ type AgentInfo struct { Provider providers.LLMProvider Tools *tools.ToolRegistry MaxIter int + Capabilities []string // optional tags for capability-based routing (e.g. "coding", "research") +} + +// FindAgentsByCapability returns agents that advertise the given capability. +func FindAgentsByCapability(resolver AgentResolver, capability string) []AgentInfo { + var matches []AgentInfo + for _, a := range resolver.ListAgents() { + if slices.Contains(a.Capabilities, capability) { + matches = append(matches, a) + } + } + return matches } // HandoffRequest describes a delegation from one agent to another. diff --git a/pkg/multiagent/handoff_test.go b/pkg/multiagent/handoff_test.go index 7b535ae29..b46a8c6c9 100644 --- a/pkg/multiagent/handoff_test.go +++ b/pkg/multiagent/handoff_test.go @@ -192,6 +192,73 @@ func TestHandoffTool_Description(t *testing.T) { } } +func TestHandoffTool_Description_WithCapabilities(t *testing.T) { + resolver := newMockResolver( + &AgentInfo{ID: "main", Name: "Main"}, + &AgentInfo{ID: "coder", Name: "Coder", Role: "coding", Capabilities: []string{"coding", "review"}}, + ) + bb := NewBlackboard() + tool := NewHandoffTool(resolver, bb, "main") + + desc := tool.Description() + if !strings.Contains(desc, "coding, review") { + t.Errorf("Description = %q, expected capabilities", desc) + } +} + +func TestHandoffTool_ExecuteByCapability(t *testing.T) { + provider := &mockProvider{response: "capability result"} + resolver := newMockResolver( + &AgentInfo{ID: "main", Name: "Main", Provider: provider, Tools: tools.NewToolRegistry(), MaxIter: 5}, + &AgentInfo{ID: "coder", Name: "Coder", Capabilities: []string{"coding"}, Provider: provider, Tools: tools.NewToolRegistry(), MaxIter: 5}, + ) + bb := NewBlackboard() + tool := NewHandoffTool(resolver, bb, "main") + + result := tool.Execute(context.Background(), map[string]any{ + "capability": "coding", + "task": "write a function", + }) + + if result.IsError { + t.Fatalf("handoff by capability failed: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "capability result") { + t.Errorf("ForLLM = %q, expected 'capability result'", result.ForLLM) + } +} + +func TestHandoffTool_ExecuteByCapability_NotFound(t *testing.T) { + resolver := newMockResolver( + &AgentInfo{ID: "main", Name: "Main"}, + ) + bb := NewBlackboard() + tool := NewHandoffTool(resolver, bb, "main") + + result := tool.Execute(context.Background(), map[string]any{ + "capability": "nonexistent", + "task": "do something", + }) + + if !result.IsError { + t.Error("expected error for unknown capability") + } +} + +func TestHandoffTool_ExecuteNoAgentNoCapability(t *testing.T) { + resolver := newMockResolver() + bb := NewBlackboard() + tool := NewHandoffTool(resolver, bb, "main") + + result := tool.Execute(context.Background(), map[string]any{ + "task": "do something", + }) + + if !result.IsError { + t.Error("expected error when neither agent_id nor capability provided") + } +} + func TestListAgentsTool_Execute(t *testing.T) { resolver := newMockResolver( &AgentInfo{ID: "main", Name: "Main Agent", Role: "general"}, @@ -224,6 +291,70 @@ func TestListAgentsTool_Empty(t *testing.T) { } } +func TestFindAgentsByCapability(t *testing.T) { + resolver := newMockResolver( + &AgentInfo{ID: "coder", Name: "Coder", Capabilities: []string{"coding", "review"}}, + &AgentInfo{ID: "researcher", Name: "Researcher", Capabilities: []string{"research", "web_search"}}, + &AgentInfo{ID: "generalist", Name: "Generalist"}, + ) + + // Find coding agents + matches := FindAgentsByCapability(resolver, "coding") + if len(matches) != 1 || matches[0].ID != "coder" { + t.Errorf("FindAgentsByCapability(coding) = %v, want [coder]", matches) + } + + // Find research agents + matches = FindAgentsByCapability(resolver, "research") + if len(matches) != 1 || matches[0].ID != "researcher" { + t.Errorf("FindAgentsByCapability(research) = %v, want [researcher]", matches) + } + + // No match + matches = FindAgentsByCapability(resolver, "design") + if len(matches) != 0 { + t.Errorf("FindAgentsByCapability(design) = %v, want empty", matches) + } +} + +func TestFindAgentsByCapability_Multiple(t *testing.T) { + resolver := newMockResolver( + &AgentInfo{ID: "a", Capabilities: []string{"coding"}}, + &AgentInfo{ID: "b", Capabilities: []string{"coding", "review"}}, + &AgentInfo{ID: "c", Capabilities: []string{"research"}}, + ) + + matches := FindAgentsByCapability(resolver, "coding") + if len(matches) != 2 { + t.Errorf("expected 2 matches, got %d", len(matches)) + } +} + +func TestFindAgentsByCapability_Empty(t *testing.T) { + resolver := newMockResolver() + matches := FindAgentsByCapability(resolver, "anything") + if len(matches) != 0 { + t.Errorf("expected empty, got %v", matches) + } +} + +func TestAgentInfo_Capabilities(t *testing.T) { + agent := &AgentInfo{ + ID: "coder", + Name: "Code Agent", + Capabilities: []string{"coding", "review", "testing"}, + } + if len(agent.Capabilities) != 3 { + t.Errorf("Capabilities len = %d, want 3", len(agent.Capabilities)) + } + + // Nil capabilities should not panic + agent2 := &AgentInfo{ID: "basic"} + if agent2.Capabilities != nil { + t.Error("expected nil Capabilities for unset agent") + } +} + func TestBuildHandoffSystemPrompt(t *testing.T) { agent := &AgentInfo{ Name: "Code Agent", diff --git a/pkg/multiagent/handoff_tool.go b/pkg/multiagent/handoff_tool.go index 9f31dff33..c97c8bbf4 100644 --- a/pkg/multiagent/handoff_tool.go +++ b/pkg/multiagent/handoff_tool.go @@ -51,6 +51,9 @@ func (t *HandoffTool) Description() string { if a.Role != "" { fmt.Fprintf(&sb, ": %s", a.Role) } + if len(a.Capabilities) > 0 { + fmt.Fprintf(&sb, " [%s]", strings.Join(a.Capabilities, ", ")) + } sb.WriteString("\n") } return sb.String() @@ -63,7 +66,11 @@ func (t *HandoffTool) Parameters() map[string]any { "properties": map[string]any{ "agent_id": map[string]any{ "type": "string", - "description": "The ID of the target agent to hand off to", + "description": "The ID of the target agent to hand off to (required if capability is not set)", + }, + "capability": map[string]any{ + "type": "string", + "description": "Route to an agent with this capability instead of by ID (e.g. \"coding\", \"research\")", }, "task": map[string]any{ "type": "string", @@ -74,7 +81,7 @@ func (t *HandoffTool) Parameters() map[string]any { "description": "Optional key-value context to share via blackboard before handoff", }, }, - "required": []string{"agent_id", "task"}, + "required": []string{"task"}, } } @@ -86,22 +93,26 @@ func (t *HandoffTool) SetContext(channel, chatID string) { // Execute delegates a task to the specified target agent. func (t *HandoffTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { - agentID, ok := args["agent_id"].(string) - if !ok { - agentID = "" - } - task, ok := args["task"].(string) - if !ok { - task = "" - } + agentID, _ := args["agent_id"].(string) + capability, _ := args["capability"].(string) + task, _ := args["task"].(string) - if agentID == "" { - return tools.ErrorResult("agent_id is required") - } if task == "" { return tools.ErrorResult("task is required") } + // Resolve agent: by ID or by capability + if agentID == "" && capability != "" { + matches := FindAgentsByCapability(t.resolver, capability) + if len(matches) == 0 { + return tools.ErrorResult(fmt.Sprintf("no agent found with capability %q", capability)) + } + agentID = matches[0].ID + } + if agentID == "" { + return tools.ErrorResult("agent_id or capability is required") + } + // Parse optional context map var contextMap map[string]string if ctxRaw, ok := args["context"].(map[string]any); ok {