From d8255f1660ede6840203ee04ad459865dcc66c8a Mon Sep 17 00:00:00 2001 From: Edouard CLAUDE Date: Wed, 18 Feb 2026 21:59:55 +0400 Subject: [PATCH] feat: enable capability-based routing in HandoffTool The LLM can now delegate tasks by capability instead of requiring a specific agent_id. The handoff tool resolves the first matching agent via FindAgentsByCapability. - HandoffTool.Execute: accept "capability" as alternative to "agent_id" - HandoffTool.Description: display agent capabilities in tool listing - HandoffTool.Parameters: "task" is the only required field now - 4 new tests (route by capability, not found, no target, description) --- pkg/multiagent/handoff_test.go | 67 ++++++++++++++++++++++++++++++++++ pkg/multiagent/handoff_tool.go | 37 ++++++++++++------- 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/pkg/multiagent/handoff_test.go b/pkg/multiagent/handoff_test.go index 970a42f7f..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"}, 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 {