Merge pull request #1 from edouard-claude/feat/multi-agent-enhancements
feat: add Capabilities field for capability-based agent routing (#294)
This commit is contained in:
commit
c5145f1ade
6 changed files with 178 additions and 16 deletions
|
|
@ -28,6 +28,7 @@ type AgentInstance struct {
|
||||||
Sessions *session.SessionManager
|
Sessions *session.SessionManager
|
||||||
ContextBuilder *ContextBuilder
|
ContextBuilder *ContextBuilder
|
||||||
Tools *tools.ToolRegistry
|
Tools *tools.ToolRegistry
|
||||||
|
Capabilities []string
|
||||||
Subagents *config.SubagentsConfig
|
Subagents *config.SubagentsConfig
|
||||||
SkillsFilter []string
|
SkillsFilter []string
|
||||||
Candidates []providers.FallbackCandidate
|
Candidates []providers.FallbackCandidate
|
||||||
|
|
@ -67,6 +68,7 @@ func NewAgentInstance(
|
||||||
agentSystemPrompt := ""
|
agentSystemPrompt := ""
|
||||||
var subagents *config.SubagentsConfig
|
var subagents *config.SubagentsConfig
|
||||||
var skillsFilter []string
|
var skillsFilter []string
|
||||||
|
var capabilities []string
|
||||||
|
|
||||||
if agentCfg != nil {
|
if agentCfg != nil {
|
||||||
agentID = routing.NormalizeAgentID(agentCfg.ID)
|
agentID = routing.NormalizeAgentID(agentCfg.ID)
|
||||||
|
|
@ -75,6 +77,7 @@ func NewAgentInstance(
|
||||||
agentSystemPrompt = agentCfg.SystemPrompt
|
agentSystemPrompt = agentCfg.SystemPrompt
|
||||||
subagents = agentCfg.Subagents
|
subagents = agentCfg.Subagents
|
||||||
skillsFilter = agentCfg.Skills
|
skillsFilter = agentCfg.Skills
|
||||||
|
capabilities = agentCfg.Capabilities
|
||||||
}
|
}
|
||||||
|
|
||||||
maxIter := defaults.MaxToolIterations
|
maxIter := defaults.MaxToolIterations
|
||||||
|
|
@ -103,6 +106,7 @@ func NewAgentInstance(
|
||||||
Sessions: sessionsManager,
|
Sessions: sessionsManager,
|
||||||
ContextBuilder: contextBuilder,
|
ContextBuilder: contextBuilder,
|
||||||
Tools: toolsRegistry,
|
Tools: toolsRegistry,
|
||||||
|
Capabilities: capabilities,
|
||||||
Subagents: subagents,
|
Subagents: subagents,
|
||||||
SkillsFilter: skillsFilter,
|
SkillsFilter: skillsFilter,
|
||||||
Candidates: candidates,
|
Candidates: candidates,
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@ func (r *registryResolver) GetAgentInfo(agentID string) *multiagent.AgentInfo {
|
||||||
Provider: agent.Provider,
|
Provider: agent.Provider,
|
||||||
Tools: agent.Tools,
|
Tools: agent.Tools,
|
||||||
MaxIter: agent.MaxIterations,
|
MaxIter: agent.MaxIterations,
|
||||||
|
Capabilities: agent.Capabilities,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,6 +115,7 @@ func (r *registryResolver) ListAgents() []multiagent.AgentInfo {
|
||||||
ID: agent.ID,
|
ID: agent.ID,
|
||||||
Name: agent.Name,
|
Name: agent.Name,
|
||||||
Role: agent.Role,
|
Role: agent.Role,
|
||||||
|
Capabilities: agent.Capabilities,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return agents
|
return agents
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@ type AgentConfig struct {
|
||||||
Workspace string `json:"workspace,omitempty"`
|
Workspace string `json:"workspace,omitempty"`
|
||||||
Model *AgentModelConfig `json:"model,omitempty"`
|
Model *AgentModelConfig `json:"model,omitempty"`
|
||||||
Skills []string `json:"skills,omitempty"`
|
Skills []string `json:"skills,omitempty"`
|
||||||
|
Capabilities []string `json:"capabilities,omitempty"`
|
||||||
Subagents *SubagentsConfig `json:"subagents,omitempty"`
|
Subagents *SubagentsConfig `json:"subagents,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package multiagent
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
|
@ -26,6 +27,18 @@ type AgentInfo struct {
|
||||||
Provider providers.LLMProvider
|
Provider providers.LLMProvider
|
||||||
Tools *tools.ToolRegistry
|
Tools *tools.ToolRegistry
|
||||||
MaxIter int
|
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.
|
// HandoffRequest describes a delegation from one agent to another.
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestListAgentsTool_Execute(t *testing.T) {
|
||||||
resolver := newMockResolver(
|
resolver := newMockResolver(
|
||||||
&AgentInfo{ID: "main", Name: "Main Agent", Role: "general"},
|
&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) {
|
func TestBuildHandoffSystemPrompt(t *testing.T) {
|
||||||
agent := &AgentInfo{
|
agent := &AgentInfo{
|
||||||
Name: "Code Agent",
|
Name: "Code Agent",
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,9 @@ func (t *HandoffTool) Description() string {
|
||||||
if a.Role != "" {
|
if a.Role != "" {
|
||||||
fmt.Fprintf(&sb, ": %s", a.Role)
|
fmt.Fprintf(&sb, ": %s", a.Role)
|
||||||
}
|
}
|
||||||
|
if len(a.Capabilities) > 0 {
|
||||||
|
fmt.Fprintf(&sb, " [%s]", strings.Join(a.Capabilities, ", "))
|
||||||
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
return sb.String()
|
return sb.String()
|
||||||
|
|
@ -63,7 +66,11 @@ func (t *HandoffTool) Parameters() map[string]any {
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"agent_id": map[string]any{
|
"agent_id": map[string]any{
|
||||||
"type": "string",
|
"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{
|
"task": map[string]any{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
@ -74,7 +81,7 @@ func (t *HandoffTool) Parameters() map[string]any {
|
||||||
"description": "Optional key-value context to share via blackboard before handoff",
|
"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.
|
// Execute delegates a task to the specified target agent.
|
||||||
func (t *HandoffTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
|
func (t *HandoffTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
|
||||||
agentID, ok := args["agent_id"].(string)
|
agentID, _ := args["agent_id"].(string)
|
||||||
if !ok {
|
capability, _ := args["capability"].(string)
|
||||||
agentID = ""
|
task, _ := args["task"].(string)
|
||||||
}
|
|
||||||
task, ok := args["task"].(string)
|
|
||||||
if !ok {
|
|
||||||
task = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if agentID == "" {
|
|
||||||
return tools.ErrorResult("agent_id is required")
|
|
||||||
}
|
|
||||||
if task == "" {
|
if task == "" {
|
||||||
return tools.ErrorResult("task is required")
|
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
|
// Parse optional context map
|
||||||
var contextMap map[string]string
|
var contextMap map[string]string
|
||||||
if ctxRaw, ok := args["context"].(map[string]any); ok {
|
if ctxRaw, ok := args["context"].(map[string]any); ok {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue