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..970a42f7f 100644 --- a/pkg/multiagent/handoff_test.go +++ b/pkg/multiagent/handoff_test.go @@ -224,6 +224,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",