feat: add base multi-agent collaboration framework (#294)
Introduce the foundation for multi-agent collaboration in a new pkg/agent/multi package. This is a non-invasive addition that creates new abstractions without modifying any existing code. - Agent interface with Name, Role, SystemPrompt, Capabilities, Tools - BaseAgent embeddable struct for concrete agent implementations - SharedContext blackboard for inter-agent data sharing and event log - AgentRegistry for lifecycle management and capability-based routing - HandoffRequest/HandoffResult for task delegation between agents - Comprehensive test suite (24 tests) including concurrency and integration
This commit is contained in:
parent
3390576eea
commit
7ab8431b33
4 changed files with 1161 additions and 0 deletions
124
pkg/agent/multi/agent.go
Normal file
124
pkg/agent/multi/agent.go
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
// Package multi provides the foundation for multi-agent collaboration.
|
||||
// It defines the Agent interface, shared context, and agent registry
|
||||
// that enable multiple specialized agents to work together within
|
||||
// a single PicoClaw session.
|
||||
//
|
||||
// This package is designed to be non-invasive: it introduces new
|
||||
// abstractions without modifying the existing AgentLoop or SubagentManager.
|
||||
// The existing subagent system can be gradually migrated to use these
|
||||
// interfaces.
|
||||
package multi
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
// Agent defines the interface that all agents must implement.
|
||||
// Each agent has a unique name, a role description, a system prompt,
|
||||
// and a set of capabilities that determine which tasks it can handle.
|
||||
type Agent interface {
|
||||
// Name returns the unique identifier for this agent.
|
||||
Name() string
|
||||
|
||||
// Role returns a human-readable description of what this agent does.
|
||||
Role() string
|
||||
|
||||
// SystemPrompt returns the system prompt used when this agent
|
||||
// interacts with the LLM.
|
||||
SystemPrompt() string
|
||||
|
||||
// Capabilities returns the list of capability tags this agent supports.
|
||||
// These are used by the registry to match agents to tasks.
|
||||
// Example: ["code", "search", "file_operations"]
|
||||
Capabilities() []string
|
||||
|
||||
// Tools returns the tool registry available to this agent.
|
||||
// Each agent can have a different set of tools.
|
||||
Tools() *tools.ToolRegistry
|
||||
|
||||
// Execute runs the agent on the given task within the provided context.
|
||||
// The shared context allows reading/writing data visible to other agents.
|
||||
// Returns the agent's response content and any error.
|
||||
Execute(ctx context.Context, task string, shared *SharedContext) (string, error)
|
||||
}
|
||||
|
||||
// AgentConfig holds configuration for creating a BaseAgent.
|
||||
type AgentConfig struct {
|
||||
// Name is the unique identifier for the agent.
|
||||
Name string
|
||||
|
||||
// Role describes what this agent specializes in.
|
||||
Role string
|
||||
|
||||
// SystemPrompt is the prompt sent to the LLM.
|
||||
SystemPrompt string
|
||||
|
||||
// Capabilities lists the capability tags.
|
||||
Capabilities []string
|
||||
}
|
||||
|
||||
// BaseAgent provides a minimal Agent implementation that can be embedded
|
||||
// in concrete agent types. It handles the common fields (name, role, prompt,
|
||||
// capabilities) and leaves Execute to be implemented by the concrete type.
|
||||
type BaseAgent struct {
|
||||
config AgentConfig
|
||||
tools *tools.ToolRegistry
|
||||
}
|
||||
|
||||
// NewBaseAgent creates a new BaseAgent with the given configuration.
|
||||
func NewBaseAgent(cfg AgentConfig, registry *tools.ToolRegistry) *BaseAgent {
|
||||
if registry == nil {
|
||||
registry = tools.NewToolRegistry()
|
||||
}
|
||||
return &BaseAgent{
|
||||
config: cfg,
|
||||
tools: registry,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *BaseAgent) Name() string { return a.config.Name }
|
||||
func (a *BaseAgent) Role() string { return a.config.Role }
|
||||
func (a *BaseAgent) SystemPrompt() string { return a.config.SystemPrompt }
|
||||
func (a *BaseAgent) Capabilities() []string { return a.config.Capabilities }
|
||||
func (a *BaseAgent) Tools() *tools.ToolRegistry { return a.tools }
|
||||
|
||||
// HandoffRequest represents a request to delegate a task from one agent
|
||||
// to another. It carries the task description and optional metadata
|
||||
// for routing.
|
||||
type HandoffRequest struct {
|
||||
// FromAgent is the name of the agent delegating the task.
|
||||
FromAgent string
|
||||
|
||||
// ToAgent is the name of the target agent. If empty, the registry
|
||||
// will select the best agent based on RequiredCapability.
|
||||
ToAgent string
|
||||
|
||||
// RequiredCapability is used for capability-based routing when
|
||||
// ToAgent is not specified.
|
||||
RequiredCapability string
|
||||
|
||||
// Task is the description of what needs to be done.
|
||||
Task string
|
||||
|
||||
// Context carries additional key-value data for the target agent.
|
||||
Context map[string]interface{}
|
||||
}
|
||||
|
||||
// HandoffResult contains the outcome of a hand-off operation.
|
||||
type HandoffResult struct {
|
||||
// AgentName is the name of the agent that handled the task.
|
||||
AgentName string
|
||||
|
||||
// Content is the response produced by the agent.
|
||||
Content string
|
||||
|
||||
// Err is set if the hand-off or execution failed.
|
||||
Err error
|
||||
}
|
||||
151
pkg/agent/multi/context.go
Normal file
151
pkg/agent/multi/context.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package multi
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SharedContext implements a thread-safe blackboard pattern where multiple
|
||||
// agents can read from and write to a common session context.
|
||||
//
|
||||
// It provides:
|
||||
// - Key-value storage for arbitrary data sharing between agents
|
||||
// - An append-only event log for agent activity tracking
|
||||
// - Thread-safe access via read-write mutex
|
||||
//
|
||||
// This is intentionally simple and in-memory. Future iterations may add
|
||||
// persistence, TTL, or namespace isolation.
|
||||
type SharedContext struct {
|
||||
mu sync.RWMutex
|
||||
data map[string]interface{}
|
||||
events []Event
|
||||
}
|
||||
|
||||
// Event records an action taken by an agent within the shared context.
|
||||
// Events are append-only and provide an audit trail of agent activity.
|
||||
type Event struct {
|
||||
// Agent is the name of the agent that produced this event.
|
||||
Agent string
|
||||
|
||||
// Type categorizes the event (e.g., "handoff", "result", "error").
|
||||
Type string
|
||||
|
||||
// Content is the event payload.
|
||||
Content string
|
||||
}
|
||||
|
||||
// NewSharedContext creates a new empty SharedContext.
|
||||
func NewSharedContext() *SharedContext {
|
||||
return &SharedContext{
|
||||
data: make(map[string]interface{}),
|
||||
events: make([]Event, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// Set stores a value in the shared context under the given key.
|
||||
// Overwrites any existing value for the same key.
|
||||
func (sc *SharedContext) Set(key string, value interface{}) {
|
||||
sc.mu.Lock()
|
||||
defer sc.mu.Unlock()
|
||||
sc.data[key] = value
|
||||
}
|
||||
|
||||
// Get retrieves a value from the shared context.
|
||||
// Returns the value and true if found, nil and false otherwise.
|
||||
func (sc *SharedContext) Get(key string) (interface{}, bool) {
|
||||
sc.mu.RLock()
|
||||
defer sc.mu.RUnlock()
|
||||
v, ok := sc.data[key]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// GetString retrieves a string value from the shared context.
|
||||
// Returns empty string if the key doesn't exist or isn't a string.
|
||||
func (sc *SharedContext) GetString(key string) string {
|
||||
v, ok := sc.Get(key)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// Delete removes a key from the shared context.
|
||||
func (sc *SharedContext) Delete(key string) {
|
||||
sc.mu.Lock()
|
||||
defer sc.mu.Unlock()
|
||||
delete(sc.data, key)
|
||||
}
|
||||
|
||||
// Keys returns all keys currently stored in the shared context.
|
||||
func (sc *SharedContext) Keys() []string {
|
||||
sc.mu.RLock()
|
||||
defer sc.mu.RUnlock()
|
||||
keys := make([]string, 0, len(sc.data))
|
||||
for k := range sc.data {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// AddEvent appends an event to the shared context's event log.
|
||||
func (sc *SharedContext) AddEvent(agent, eventType, content string) {
|
||||
sc.mu.Lock()
|
||||
defer sc.mu.Unlock()
|
||||
sc.events = append(sc.events, Event{
|
||||
Agent: agent,
|
||||
Type: eventType,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
// Events returns a copy of all events in the shared context.
|
||||
func (sc *SharedContext) Events() []Event {
|
||||
sc.mu.RLock()
|
||||
defer sc.mu.RUnlock()
|
||||
cp := make([]Event, len(sc.events))
|
||||
copy(cp, sc.events)
|
||||
return cp
|
||||
}
|
||||
|
||||
// EventsByAgent returns all events produced by the given agent.
|
||||
func (sc *SharedContext) EventsByAgent(agent string) []Event {
|
||||
sc.mu.RLock()
|
||||
defer sc.mu.RUnlock()
|
||||
var filtered []Event
|
||||
for _, e := range sc.events {
|
||||
if e.Agent == agent {
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// EventsByType returns all events of the given type.
|
||||
func (sc *SharedContext) EventsByType(eventType string) []Event {
|
||||
sc.mu.RLock()
|
||||
defer sc.mu.RUnlock()
|
||||
var filtered []Event
|
||||
for _, e := range sc.events {
|
||||
if e.Type == eventType {
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// Snapshot returns a shallow copy of the entire data map.
|
||||
// Useful for debugging or serialization.
|
||||
func (sc *SharedContext) Snapshot() map[string]interface{} {
|
||||
sc.mu.RLock()
|
||||
defer sc.mu.RUnlock()
|
||||
snap := make(map[string]interface{}, len(sc.data))
|
||||
for k, v := range sc.data {
|
||||
snap[k] = v
|
||||
}
|
||||
return snap
|
||||
}
|
||||
633
pkg/agent/multi/multi_test.go
Normal file
633
pkg/agent/multi/multi_test.go
Normal file
|
|
@ -0,0 +1,633 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package multi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
// mockAgent is a simple Agent implementation for testing.
|
||||
type mockAgent struct {
|
||||
name string
|
||||
role string
|
||||
systemPrompt string
|
||||
capabilities []string
|
||||
toolRegistry *tools.ToolRegistry
|
||||
executeFunc func(ctx context.Context, task string, shared *SharedContext) (string, error)
|
||||
}
|
||||
|
||||
func newMockAgent(name, role string, capabilities []string) *mockAgent {
|
||||
return &mockAgent{
|
||||
name: name,
|
||||
role: role,
|
||||
systemPrompt: fmt.Sprintf("You are %s, a %s agent.", name, role),
|
||||
capabilities: capabilities,
|
||||
toolRegistry: tools.NewToolRegistry(),
|
||||
executeFunc: func(ctx context.Context, task string, shared *SharedContext) (string, error) {
|
||||
return fmt.Sprintf("[%s] completed: %s", name, task), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockAgent) Name() string { return m.name }
|
||||
func (m *mockAgent) Role() string { return m.role }
|
||||
func (m *mockAgent) SystemPrompt() string { return m.systemPrompt }
|
||||
func (m *mockAgent) Capabilities() []string { return m.capabilities }
|
||||
func (m *mockAgent) Tools() *tools.ToolRegistry { return m.toolRegistry }
|
||||
func (m *mockAgent) Execute(ctx context.Context, task string, shared *SharedContext) (string, error) {
|
||||
return m.executeFunc(ctx, task, shared)
|
||||
}
|
||||
|
||||
// --- SharedContext Tests ---
|
||||
|
||||
func TestSharedContext_SetGet(t *testing.T) {
|
||||
sc := NewSharedContext()
|
||||
|
||||
sc.Set("key1", "value1")
|
||||
sc.Set("key2", 42)
|
||||
|
||||
v1, ok := sc.Get("key1")
|
||||
if !ok || v1 != "value1" {
|
||||
t.Errorf("expected key1=value1, got %v (ok=%v)", v1, ok)
|
||||
}
|
||||
|
||||
v2, ok := sc.Get("key2")
|
||||
if !ok || v2 != 42 {
|
||||
t.Errorf("expected key2=42, got %v (ok=%v)", v2, ok)
|
||||
}
|
||||
|
||||
_, ok = sc.Get("nonexistent")
|
||||
if ok {
|
||||
t.Error("expected nonexistent key to return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedContext_GetString(t *testing.T) {
|
||||
sc := NewSharedContext()
|
||||
|
||||
sc.Set("str", "hello")
|
||||
sc.Set("num", 42)
|
||||
|
||||
if s := sc.GetString("str"); s != "hello" {
|
||||
t.Errorf("expected 'hello', got %q", s)
|
||||
}
|
||||
|
||||
if s := sc.GetString("num"); s != "" {
|
||||
t.Errorf("expected empty string for non-string, got %q", s)
|
||||
}
|
||||
|
||||
if s := sc.GetString("missing"); s != "" {
|
||||
t.Errorf("expected empty string for missing key, got %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedContext_Delete(t *testing.T) {
|
||||
sc := NewSharedContext()
|
||||
|
||||
sc.Set("key", "value")
|
||||
sc.Delete("key")
|
||||
|
||||
_, ok := sc.Get("key")
|
||||
if ok {
|
||||
t.Error("expected deleted key to return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedContext_Keys(t *testing.T) {
|
||||
sc := NewSharedContext()
|
||||
|
||||
sc.Set("a", 1)
|
||||
sc.Set("b", 2)
|
||||
sc.Set("c", 3)
|
||||
|
||||
keys := sc.Keys()
|
||||
if len(keys) != 3 {
|
||||
t.Errorf("expected 3 keys, got %d", len(keys))
|
||||
}
|
||||
|
||||
keySet := make(map[string]bool)
|
||||
for _, k := range keys {
|
||||
keySet[k] = true
|
||||
}
|
||||
for _, expected := range []string{"a", "b", "c"} {
|
||||
if !keySet[expected] {
|
||||
t.Errorf("expected key %q in keys", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedContext_Events(t *testing.T) {
|
||||
sc := NewSharedContext()
|
||||
|
||||
sc.AddEvent("agent1", "handoff", "delegating to agent2")
|
||||
sc.AddEvent("agent2", "result", "task completed")
|
||||
sc.AddEvent("agent1", "error", "something failed")
|
||||
|
||||
events := sc.Events()
|
||||
if len(events) != 3 {
|
||||
t.Fatalf("expected 3 events, got %d", len(events))
|
||||
}
|
||||
|
||||
if events[0].Agent != "agent1" || events[0].Type != "handoff" {
|
||||
t.Errorf("unexpected first event: %+v", events[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedContext_EventsByAgent(t *testing.T) {
|
||||
sc := NewSharedContext()
|
||||
|
||||
sc.AddEvent("agent1", "handoff", "task1")
|
||||
sc.AddEvent("agent2", "result", "done")
|
||||
sc.AddEvent("agent1", "result", "task2")
|
||||
|
||||
agent1Events := sc.EventsByAgent("agent1")
|
||||
if len(agent1Events) != 2 {
|
||||
t.Errorf("expected 2 events for agent1, got %d", len(agent1Events))
|
||||
}
|
||||
|
||||
agent2Events := sc.EventsByAgent("agent2")
|
||||
if len(agent2Events) != 1 {
|
||||
t.Errorf("expected 1 event for agent2, got %d", len(agent2Events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedContext_EventsByType(t *testing.T) {
|
||||
sc := NewSharedContext()
|
||||
|
||||
sc.AddEvent("a1", "result", "r1")
|
||||
sc.AddEvent("a2", "error", "e1")
|
||||
sc.AddEvent("a3", "result", "r2")
|
||||
|
||||
results := sc.EventsByType("result")
|
||||
if len(results) != 2 {
|
||||
t.Errorf("expected 2 result events, got %d", len(results))
|
||||
}
|
||||
|
||||
errors := sc.EventsByType("error")
|
||||
if len(errors) != 1 {
|
||||
t.Errorf("expected 1 error event, got %d", len(errors))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedContext_Snapshot(t *testing.T) {
|
||||
sc := NewSharedContext()
|
||||
|
||||
sc.Set("key", "value")
|
||||
snap := sc.Snapshot()
|
||||
|
||||
// Modify original
|
||||
sc.Set("key", "changed")
|
||||
|
||||
// Snapshot should be independent
|
||||
if snap["key"] != "value" {
|
||||
t.Errorf("snapshot should be independent, got %v", snap["key"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedContext_ConcurrentAccess(t *testing.T) {
|
||||
sc := NewSharedContext()
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Concurrent writes
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
sc.Set(fmt.Sprintf("key-%d", i), i)
|
||||
sc.AddEvent(fmt.Sprintf("agent-%d", i), "write", "data")
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Concurrent reads
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
sc.Get(fmt.Sprintf("key-%d", i))
|
||||
sc.Keys()
|
||||
sc.Events()
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
keys := sc.Keys()
|
||||
if len(keys) != 100 {
|
||||
t.Errorf("expected 100 keys after concurrent writes, got %d", len(keys))
|
||||
}
|
||||
}
|
||||
|
||||
// --- BaseAgent Tests ---
|
||||
|
||||
func TestBaseAgent_Fields(t *testing.T) {
|
||||
registry := tools.NewToolRegistry()
|
||||
agent := NewBaseAgent(AgentConfig{
|
||||
Name: "coder",
|
||||
Role: "Code generation and review",
|
||||
SystemPrompt: "You are a coding agent.",
|
||||
Capabilities: []string{"code", "review"},
|
||||
}, registry)
|
||||
|
||||
if agent.Name() != "coder" {
|
||||
t.Errorf("expected name 'coder', got %q", agent.Name())
|
||||
}
|
||||
if agent.Role() != "Code generation and review" {
|
||||
t.Errorf("unexpected role: %q", agent.Role())
|
||||
}
|
||||
if agent.SystemPrompt() != "You are a coding agent." {
|
||||
t.Errorf("unexpected system prompt: %q", agent.SystemPrompt())
|
||||
}
|
||||
caps := agent.Capabilities()
|
||||
if len(caps) != 2 || caps[0] != "code" || caps[1] != "review" {
|
||||
t.Errorf("unexpected capabilities: %v", caps)
|
||||
}
|
||||
if agent.Tools() != registry {
|
||||
t.Error("expected same tool registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAgent_NilRegistry(t *testing.T) {
|
||||
agent := NewBaseAgent(AgentConfig{Name: "test"}, nil)
|
||||
if agent.Tools() == nil {
|
||||
t.Error("expected non-nil default tool registry")
|
||||
}
|
||||
}
|
||||
|
||||
// --- AgentRegistry Tests ---
|
||||
|
||||
func TestAgentRegistry_Register(t *testing.T) {
|
||||
r := NewAgentRegistry()
|
||||
agent := newMockAgent("coder", "coding", []string{"code"})
|
||||
|
||||
err := r.Register(agent)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Duplicate registration
|
||||
err = r.Register(agent)
|
||||
if err == nil {
|
||||
t.Error("expected error on duplicate registration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistry_Unregister(t *testing.T) {
|
||||
r := NewAgentRegistry()
|
||||
agent := newMockAgent("coder", "coding", []string{"code"})
|
||||
r.Register(agent)
|
||||
|
||||
err := r.Unregister("coder")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify agent is gone
|
||||
if r.Get("coder") != nil {
|
||||
t.Error("expected nil after unregister")
|
||||
}
|
||||
|
||||
// Unregister nonexistent
|
||||
err = r.Unregister("nonexistent")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent agent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistry_Get(t *testing.T) {
|
||||
r := NewAgentRegistry()
|
||||
agent := newMockAgent("searcher", "search", []string{"search"})
|
||||
r.Register(agent)
|
||||
|
||||
got := r.Get("searcher")
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil agent")
|
||||
}
|
||||
if got.Name() != "searcher" {
|
||||
t.Errorf("expected 'searcher', got %q", got.Name())
|
||||
}
|
||||
|
||||
if r.Get("nonexistent") != nil {
|
||||
t.Error("expected nil for nonexistent agent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistry_List(t *testing.T) {
|
||||
r := NewAgentRegistry()
|
||||
r.Register(newMockAgent("a", "role-a", nil))
|
||||
r.Register(newMockAgent("b", "role-b", nil))
|
||||
r.Register(newMockAgent("c", "role-c", nil))
|
||||
|
||||
names := r.List()
|
||||
if len(names) != 3 {
|
||||
t.Errorf("expected 3 agents, got %d", len(names))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistry_FindByCapability(t *testing.T) {
|
||||
r := NewAgentRegistry()
|
||||
r.Register(newMockAgent("coder", "coding", []string{"code", "review"}))
|
||||
r.Register(newMockAgent("searcher", "searching", []string{"search", "web"}))
|
||||
r.Register(newMockAgent("reviewer", "reviewing", []string{"review"}))
|
||||
|
||||
codeAgents := r.FindByCapability("code")
|
||||
if len(codeAgents) != 1 {
|
||||
t.Errorf("expected 1 agent with 'code', got %d", len(codeAgents))
|
||||
}
|
||||
|
||||
reviewAgents := r.FindByCapability("review")
|
||||
if len(reviewAgents) != 2 {
|
||||
t.Errorf("expected 2 agents with 'review', got %d", len(reviewAgents))
|
||||
}
|
||||
|
||||
noneAgents := r.FindByCapability("nonexistent")
|
||||
if len(noneAgents) != 0 {
|
||||
t.Errorf("expected 0 agents, got %d", len(noneAgents))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistry_SharedContext(t *testing.T) {
|
||||
r := NewAgentRegistry()
|
||||
sc := r.SharedContext()
|
||||
|
||||
if sc == nil {
|
||||
t.Fatal("expected non-nil shared context")
|
||||
}
|
||||
|
||||
// Verify it's the same instance
|
||||
if r.SharedContext() != sc {
|
||||
t.Error("expected same shared context instance")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Handoff Tests ---
|
||||
|
||||
func TestAgentRegistry_Handoff_DirectRouting(t *testing.T) {
|
||||
r := NewAgentRegistry()
|
||||
r.Register(newMockAgent("coder", "coding", []string{"code"}))
|
||||
|
||||
result := r.Handoff(context.Background(), HandoffRequest{
|
||||
FromAgent: "main",
|
||||
ToAgent: "coder",
|
||||
Task: "write a function",
|
||||
})
|
||||
|
||||
if result.Err != nil {
|
||||
t.Fatalf("unexpected error: %v", result.Err)
|
||||
}
|
||||
if result.AgentName != "coder" {
|
||||
t.Errorf("expected agent 'coder', got %q", result.AgentName)
|
||||
}
|
||||
if result.Content != "[coder] completed: write a function" {
|
||||
t.Errorf("unexpected content: %q", result.Content)
|
||||
}
|
||||
|
||||
// Verify events were recorded
|
||||
events := r.SharedContext().Events()
|
||||
if len(events) < 2 {
|
||||
t.Fatalf("expected at least 2 events, got %d", len(events))
|
||||
}
|
||||
if events[0].Type != "handoff" {
|
||||
t.Errorf("expected first event type 'handoff', got %q", events[0].Type)
|
||||
}
|
||||
if events[1].Type != "result" {
|
||||
t.Errorf("expected second event type 'result', got %q", events[1].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistry_Handoff_CapabilityRouting(t *testing.T) {
|
||||
r := NewAgentRegistry()
|
||||
r.Register(newMockAgent("coder", "coding", []string{"code"}))
|
||||
r.Register(newMockAgent("searcher", "searching", []string{"search", "web"}))
|
||||
|
||||
result := r.Handoff(context.Background(), HandoffRequest{
|
||||
FromAgent: "main",
|
||||
RequiredCapability: "search",
|
||||
Task: "find documentation",
|
||||
})
|
||||
|
||||
if result.Err != nil {
|
||||
t.Fatalf("unexpected error: %v", result.Err)
|
||||
}
|
||||
if result.AgentName != "searcher" {
|
||||
t.Errorf("expected agent 'searcher', got %q", result.AgentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistry_Handoff_NotFound(t *testing.T) {
|
||||
r := NewAgentRegistry()
|
||||
|
||||
// Target agent not found
|
||||
result := r.Handoff(context.Background(), HandoffRequest{
|
||||
FromAgent: "main",
|
||||
ToAgent: "nonexistent",
|
||||
Task: "do something",
|
||||
})
|
||||
if result.Err == nil {
|
||||
t.Error("expected error for nonexistent agent")
|
||||
}
|
||||
|
||||
// Capability not found
|
||||
result = r.Handoff(context.Background(), HandoffRequest{
|
||||
FromAgent: "main",
|
||||
RequiredCapability: "nonexistent",
|
||||
Task: "do something",
|
||||
})
|
||||
if result.Err == nil {
|
||||
t.Error("expected error for nonexistent capability")
|
||||
}
|
||||
|
||||
// Neither ToAgent nor RequiredCapability
|
||||
result = r.Handoff(context.Background(), HandoffRequest{
|
||||
FromAgent: "main",
|
||||
Task: "do something",
|
||||
})
|
||||
if result.Err == nil {
|
||||
t.Error("expected error when neither routing field is set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistry_Handoff_ExecutionError(t *testing.T) {
|
||||
r := NewAgentRegistry()
|
||||
|
||||
failAgent := newMockAgent("failer", "failing", []string{"fail"})
|
||||
failAgent.executeFunc = func(ctx context.Context, task string, shared *SharedContext) (string, error) {
|
||||
return "", fmt.Errorf("execution failed: %s", task)
|
||||
}
|
||||
r.Register(failAgent)
|
||||
|
||||
result := r.Handoff(context.Background(), HandoffRequest{
|
||||
FromAgent: "main",
|
||||
ToAgent: "failer",
|
||||
Task: "break things",
|
||||
})
|
||||
|
||||
if result.Err == nil {
|
||||
t.Fatal("expected execution error")
|
||||
}
|
||||
if result.AgentName != "failer" {
|
||||
t.Errorf("expected agent 'failer', got %q", result.AgentName)
|
||||
}
|
||||
|
||||
// Verify error event was recorded
|
||||
errorEvents := r.SharedContext().EventsByType("error")
|
||||
if len(errorEvents) == 0 {
|
||||
t.Error("expected error event to be recorded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistry_Handoff_ContextPassing(t *testing.T) {
|
||||
r := NewAgentRegistry()
|
||||
|
||||
// Agent that reads from shared context
|
||||
reader := newMockAgent("reader", "reading", []string{"read"})
|
||||
reader.executeFunc = func(ctx context.Context, task string, shared *SharedContext) (string, error) {
|
||||
val := shared.GetString("input_data")
|
||||
return fmt.Sprintf("read: %s", val), nil
|
||||
}
|
||||
r.Register(reader)
|
||||
|
||||
result := r.Handoff(context.Background(), HandoffRequest{
|
||||
FromAgent: "main",
|
||||
ToAgent: "reader",
|
||||
Task: "process data",
|
||||
Context: map[string]interface{}{"input_data": "hello world"},
|
||||
})
|
||||
|
||||
if result.Err != nil {
|
||||
t.Fatalf("unexpected error: %v", result.Err)
|
||||
}
|
||||
if result.Content != "read: hello world" {
|
||||
t.Errorf("unexpected content: %q", result.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistry_Handoff_AgentStateTransition(t *testing.T) {
|
||||
r := NewAgentRegistry()
|
||||
|
||||
// Agent that checks its own state via a channel
|
||||
stateChecked := make(chan AgentState, 1)
|
||||
statefulAgent := newMockAgent("stateful", "checking", []string{"check"})
|
||||
statefulAgent.executeFunc = func(ctx context.Context, task string, shared *SharedContext) (string, error) {
|
||||
state, _ := r.GetAgentState("stateful")
|
||||
stateChecked <- state
|
||||
return "done", nil
|
||||
}
|
||||
r.Register(statefulAgent)
|
||||
|
||||
// Before hand-off: idle
|
||||
state, ok := r.GetAgentState("stateful")
|
||||
if !ok || state != AgentIdle {
|
||||
t.Errorf("expected idle state before handoff, got %v", state)
|
||||
}
|
||||
|
||||
r.Handoff(context.Background(), HandoffRequest{
|
||||
FromAgent: "main",
|
||||
ToAgent: "stateful",
|
||||
Task: "check state",
|
||||
})
|
||||
|
||||
// During execution: should have been active
|
||||
duringState := <-stateChecked
|
||||
if duringState != AgentActive {
|
||||
t.Errorf("expected active state during execution, got %v", duringState)
|
||||
}
|
||||
|
||||
// After hand-off: idle again
|
||||
state, _ = r.GetAgentState("stateful")
|
||||
if state != AgentIdle {
|
||||
t.Errorf("expected idle state after handoff, got %v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistry_Handoff_ContextCancellation(t *testing.T) {
|
||||
r := NewAgentRegistry()
|
||||
|
||||
// Agent that respects context cancellation
|
||||
cancelAgent := newMockAgent("cancellable", "cancelling", []string{"cancel"})
|
||||
cancelAgent.executeFunc = func(ctx context.Context, task string, shared *SharedContext) (string, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
default:
|
||||
return "completed before cancel", nil
|
||||
}
|
||||
}
|
||||
r.Register(cancelAgent)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
result := r.Handoff(ctx, HandoffRequest{
|
||||
FromAgent: "main",
|
||||
ToAgent: "cancellable",
|
||||
Task: "should be cancelled",
|
||||
})
|
||||
|
||||
if result.Err == nil {
|
||||
// The agent might complete before checking ctx, which is fine
|
||||
// Just verify it ran
|
||||
if result.Content == "" {
|
||||
t.Error("expected some content")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Integration Test ---
|
||||
|
||||
func TestMultiAgent_Integration(t *testing.T) {
|
||||
r := NewAgentRegistry()
|
||||
|
||||
// Register a chain of agents
|
||||
analyzer := newMockAgent("analyzer", "analysis", []string{"analyze"})
|
||||
analyzer.executeFunc = func(ctx context.Context, task string, shared *SharedContext) (string, error) {
|
||||
shared.Set("analysis_result", "code needs refactoring")
|
||||
return "Analysis complete", nil
|
||||
}
|
||||
|
||||
coder := newMockAgent("coder", "coding", []string{"code"})
|
||||
coder.executeFunc = func(ctx context.Context, task string, shared *SharedContext) (string, error) {
|
||||
analysis := shared.GetString("analysis_result")
|
||||
return fmt.Sprintf("Applied fix based on: %s", analysis), nil
|
||||
}
|
||||
|
||||
r.Register(analyzer)
|
||||
r.Register(coder)
|
||||
|
||||
// Step 1: Analyze
|
||||
result1 := r.Handoff(context.Background(), HandoffRequest{
|
||||
FromAgent: "main",
|
||||
RequiredCapability: "analyze",
|
||||
Task: "review the codebase",
|
||||
})
|
||||
if result1.Err != nil {
|
||||
t.Fatalf("analysis failed: %v", result1.Err)
|
||||
}
|
||||
|
||||
// Step 2: Code fix based on analysis result (already in shared context)
|
||||
result2 := r.Handoff(context.Background(), HandoffRequest{
|
||||
FromAgent: "main",
|
||||
RequiredCapability: "code",
|
||||
Task: "fix the issues found",
|
||||
})
|
||||
if result2.Err != nil {
|
||||
t.Fatalf("coding failed: %v", result2.Err)
|
||||
}
|
||||
|
||||
if result2.Content != "Applied fix based on: code needs refactoring" {
|
||||
t.Errorf("unexpected content: %q", result2.Content)
|
||||
}
|
||||
|
||||
// Verify full event trail
|
||||
events := r.SharedContext().Events()
|
||||
if len(events) != 4 { // 2 handoffs + 2 results
|
||||
t.Errorf("expected 4 events, got %d", len(events))
|
||||
}
|
||||
}
|
||||
253
pkg/agent/multi/registry.go
Normal file
253
pkg/agent/multi/registry.go
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package multi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
// AgentState represents the current state of an agent in the registry.
|
||||
type AgentState int
|
||||
|
||||
const (
|
||||
// AgentIdle means the agent is registered but not currently executing.
|
||||
AgentIdle AgentState = iota
|
||||
|
||||
// AgentActive means the agent is currently executing a task.
|
||||
AgentActive
|
||||
)
|
||||
|
||||
// agentEntry holds an agent and its runtime state within the registry.
|
||||
type agentEntry struct {
|
||||
agent Agent
|
||||
state AgentState
|
||||
}
|
||||
|
||||
// AgentRegistry manages the lifecycle of agents and provides capability-based
|
||||
// routing for hand-off requests. It is the central coordinator for multi-agent
|
||||
// collaboration within a session.
|
||||
//
|
||||
// Thread-safe: all operations are protected by a read-write mutex.
|
||||
type AgentRegistry struct {
|
||||
mu sync.RWMutex
|
||||
agents map[string]*agentEntry
|
||||
shared *SharedContext
|
||||
}
|
||||
|
||||
// NewAgentRegistry creates a new AgentRegistry with a fresh SharedContext.
|
||||
func NewAgentRegistry() *AgentRegistry {
|
||||
return &AgentRegistry{
|
||||
agents: make(map[string]*agentEntry),
|
||||
shared: NewSharedContext(),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds an agent to the registry. Returns an error if an agent
|
||||
// with the same name is already registered.
|
||||
func (r *AgentRegistry) Register(agent Agent) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
name := agent.Name()
|
||||
if _, exists := r.agents[name]; exists {
|
||||
return fmt.Errorf("agent %q already registered", name)
|
||||
}
|
||||
|
||||
r.agents[name] = &agentEntry{
|
||||
agent: agent,
|
||||
state: AgentIdle,
|
||||
}
|
||||
|
||||
logger.InfoCF("multi", "Agent registered",
|
||||
map[string]interface{}{
|
||||
"name": name,
|
||||
"role": agent.Role(),
|
||||
"capabilities": agent.Capabilities(),
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unregister removes an agent from the registry.
|
||||
// Returns an error if the agent is currently active.
|
||||
func (r *AgentRegistry) Unregister(name string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
entry, exists := r.agents[name]
|
||||
if !exists {
|
||||
return fmt.Errorf("agent %q not found", name)
|
||||
}
|
||||
|
||||
if entry.state == AgentActive {
|
||||
return fmt.Errorf("cannot unregister active agent %q", name)
|
||||
}
|
||||
|
||||
delete(r.agents, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the agent with the given name, or nil if not found.
|
||||
func (r *AgentRegistry) Get(name string) Agent {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
entry, ok := r.agents[name]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return entry.agent
|
||||
}
|
||||
|
||||
// List returns the names of all registered agents.
|
||||
func (r *AgentRegistry) List() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
names := make([]string, 0, len(r.agents))
|
||||
for name := range r.agents {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// FindByCapability returns all agents that have the specified capability.
|
||||
func (r *AgentRegistry) FindByCapability(capability string) []Agent {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
var matches []Agent
|
||||
for _, entry := range r.agents {
|
||||
for _, cap := range entry.agent.Capabilities() {
|
||||
if cap == capability {
|
||||
matches = append(matches, entry.agent)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
// SharedContext returns the registry's shared context instance.
|
||||
func (r *AgentRegistry) SharedContext() *SharedContext {
|
||||
return r.shared
|
||||
}
|
||||
|
||||
// Handoff delegates a task to another agent based on the HandoffRequest.
|
||||
//
|
||||
// Routing logic:
|
||||
// 1. If ToAgent is specified, route directly to that agent.
|
||||
// 2. If RequiredCapability is specified, find the first idle agent
|
||||
// with that capability.
|
||||
// 3. If no suitable agent is found, return an error.
|
||||
//
|
||||
// The hand-off records events in the shared context for traceability.
|
||||
func (r *AgentRegistry) Handoff(ctx context.Context, req HandoffRequest) *HandoffResult {
|
||||
// Inject hand-off context data into shared context
|
||||
if req.Context != nil {
|
||||
for k, v := range req.Context {
|
||||
r.shared.Set(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Record the hand-off request as an event
|
||||
r.shared.AddEvent(req.FromAgent, "handoff",
|
||||
fmt.Sprintf("delegating task to %s (capability: %s): %s",
|
||||
req.ToAgent, req.RequiredCapability, req.Task))
|
||||
|
||||
// Resolve target agent
|
||||
target, err := r.resolveTarget(req)
|
||||
if err != nil {
|
||||
r.shared.AddEvent(req.FromAgent, "error", err.Error())
|
||||
return &HandoffResult{Err: err}
|
||||
}
|
||||
|
||||
// Mark agent as active
|
||||
r.setAgentState(target.Name(), AgentActive)
|
||||
defer r.setAgentState(target.Name(), AgentIdle)
|
||||
|
||||
logger.InfoCF("multi", "Executing hand-off",
|
||||
map[string]interface{}{
|
||||
"from": req.FromAgent,
|
||||
"to": target.Name(),
|
||||
"task_len": len(req.Task),
|
||||
"capability": req.RequiredCapability,
|
||||
})
|
||||
|
||||
// Execute the target agent
|
||||
content, execErr := target.Execute(ctx, req.Task, r.shared)
|
||||
|
||||
// Record the result
|
||||
eventType := "result"
|
||||
eventContent := content
|
||||
if execErr != nil {
|
||||
eventType = "error"
|
||||
eventContent = execErr.Error()
|
||||
}
|
||||
r.shared.AddEvent(target.Name(), eventType, eventContent)
|
||||
|
||||
return &HandoffResult{
|
||||
AgentName: target.Name(),
|
||||
Content: content,
|
||||
Err: execErr,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveTarget finds the appropriate agent for a hand-off request.
|
||||
func (r *AgentRegistry) resolveTarget(req HandoffRequest) (Agent, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
// Direct routing by name
|
||||
if req.ToAgent != "" {
|
||||
entry, ok := r.agents[req.ToAgent]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("target agent %q not found", req.ToAgent)
|
||||
}
|
||||
return entry.agent, nil
|
||||
}
|
||||
|
||||
// Capability-based routing: find first idle agent with the capability
|
||||
if req.RequiredCapability != "" {
|
||||
for _, entry := range r.agents {
|
||||
if entry.state != AgentIdle {
|
||||
continue
|
||||
}
|
||||
for _, cap := range entry.agent.Capabilities() {
|
||||
if cap == req.RequiredCapability {
|
||||
return entry.agent, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no idle agent found with capability %q", req.RequiredCapability)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("hand-off request must specify ToAgent or RequiredCapability")
|
||||
}
|
||||
|
||||
// setAgentState updates the state of an agent in the registry.
|
||||
func (r *AgentRegistry) setAgentState(name string, state AgentState) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if entry, ok := r.agents[name]; ok {
|
||||
entry.state = state
|
||||
}
|
||||
}
|
||||
|
||||
// GetAgentState returns the current state of an agent.
|
||||
func (r *AgentRegistry) GetAgentState(name string) (AgentState, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
entry, ok := r.agents[name]
|
||||
if !ok {
|
||||
return AgentIdle, false
|
||||
}
|
||||
return entry.state, true
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue