diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 5eb0630b5..35a25b977 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -17,6 +17,8 @@ import ( type AgentInstance struct { ID string Name string + Role string + SystemPrompt string Model string Fallbacks []string Workspace string @@ -60,12 +62,16 @@ func NewAgentInstance( agentID := routing.DefaultAgentID agentName := "" + agentRole := "" + agentSystemPrompt := "" var subagents *config.SubagentsConfig var skillsFilter []string if agentCfg != nil { agentID = routing.NormalizeAgentID(agentCfg.ID) agentName = agentCfg.Name + agentRole = agentCfg.Role + agentSystemPrompt = agentCfg.SystemPrompt subagents = agentCfg.Subagents skillsFilter = agentCfg.Skills } @@ -85,6 +91,8 @@ func NewAgentInstance( return &AgentInstance{ ID: agentID, Name: agentName, + Role: agentRole, + SystemPrompt: agentSystemPrompt, Model: model, Fallbacks: fallbacks, Workspace: workspace, diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 3e1c026a0..a00c3c62c 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -21,6 +21,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/multiagent" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/state" @@ -35,6 +36,7 @@ type AgentLoop struct { state *state.Manager running atomic.Bool summarizing sync.Map + blackboards sync.Map // sessionKey -> *multiagent.Blackboard fallback *providers.FallbackChain channelManager *channels.Manager } @@ -78,6 +80,45 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers } } +// registryResolver adapts AgentRegistry to multiagent.AgentResolver. +type registryResolver struct { + registry *AgentRegistry +} + +func (r *registryResolver) GetAgentInfo(agentID string) *multiagent.AgentInfo { + agent, ok := r.registry.GetAgent(agentID) + if !ok { + return nil + } + return &multiagent.AgentInfo{ + ID: agent.ID, + Name: agent.Name, + Role: agent.Role, + SystemPrompt: agent.SystemPrompt, + Model: agent.Model, + Provider: agent.Provider, + Tools: agent.Tools, + MaxIter: agent.MaxIterations, + } +} + +func (r *registryResolver) ListAgents() []multiagent.AgentInfo { + ids := r.registry.ListAgentIDs() + agents := make([]multiagent.AgentInfo, 0, len(ids)) + for _, id := range ids { + agent, ok := r.registry.GetAgent(id) + if !ok { + continue + } + agents = append(agents, multiagent.AgentInfo{ + ID: agent.ID, + Name: agent.Name, + Role: agent.Role, + }) + } + return agents +} + // registerSharedTools registers tools that are shared across all agents (web, message, spawn). func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *AgentRegistry, provider providers.LLMProvider) { for _, agentID := range registry.ListAgentIDs() { @@ -123,6 +164,24 @@ func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *A }) agent.Tools.Register(spawnTool) + // Multi-agent collaboration tools (blackboard, handoff, discovery) + // Only register when multiple agents are configured. + if len(registry.ListAgentIDs()) > 1 { + resolver := ®istryResolver{registry: registry} + + // Blackboard tool: per-agent instance sharing a common blackboard + // The actual blackboard is created per session in getOrCreateBlackboard + // For tool registration, we use a shared "global" blackboard. + sharedBoard := multiagent.NewBlackboard() + agent.Tools.Register(multiagent.NewBlackboardTool(sharedBoard, agentID)) + + // Handoff tool: delegate tasks to other agents + agent.Tools.Register(multiagent.NewHandoffTool(resolver, sharedBoard, agentID)) + + // List agents tool: discover available agents + agent.Tools.Register(multiagent.NewListAgentsTool(resolver)) + } + // Update context builder with the complete tools registry agent.ContextBuilder.SetToolsRegistry(agent.Tools) } @@ -393,6 +452,14 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt opts.ChatID, ) + // 2b. Inject blackboard snapshot into system context if available + if bb := al.getOrCreateBlackboard(opts.SessionKey); bb != nil && bb.Size() > 0 { + snapshot := bb.Snapshot() + if snapshot != "" && len(messages) > 0 && messages[0].Role == "system" { + messages[0].Content += "\n\n" + snapshot + } + } + // 3. Save user message to session agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage) @@ -688,6 +755,21 @@ func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID st st.SetContext(channel, chatID) } } + if tool, ok := agent.Tools.Get("handoff"); ok { + if ht, ok := tool.(tools.ContextualTool); ok { + ht.SetContext(channel, chatID) + } + } +} + +// getOrCreateBlackboard returns the blackboard for a session, creating one if needed. +func (al *AgentLoop) getOrCreateBlackboard(sessionKey string) *multiagent.Blackboard { + if v, ok := al.blackboards.Load(sessionKey); ok { + return v.(*multiagent.Blackboard) + } + bb := multiagent.NewBlackboard() + actual, _ := al.blackboards.LoadOrStore(sessionKey, bb) + return actual.(*multiagent.Blackboard) } // maybeSummarize triggers summarization if the session history exceeds thresholds. diff --git a/pkg/config/config.go b/pkg/config/config.go index 682996bd6..d46580884 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -101,13 +101,15 @@ func (m AgentModelConfig) MarshalJSON() ([]byte, error) { } type AgentConfig struct { - ID string `json:"id"` - Default bool `json:"default,omitempty"` - Name string `json:"name,omitempty"` - Workspace string `json:"workspace,omitempty"` - Model *AgentModelConfig `json:"model,omitempty"` - Skills []string `json:"skills,omitempty"` - Subagents *SubagentsConfig `json:"subagents,omitempty"` + ID string `json:"id"` + Default bool `json:"default,omitempty"` + Name string `json:"name,omitempty"` + Role string `json:"role,omitempty"` + SystemPrompt string `json:"system_prompt,omitempty"` + Workspace string `json:"workspace,omitempty"` + Model *AgentModelConfig `json:"model,omitempty"` + Skills []string `json:"skills,omitempty"` + Subagents *SubagentsConfig `json:"subagents,omitempty"` } type SubagentsConfig struct { diff --git a/pkg/multiagent/blackboard.go b/pkg/multiagent/blackboard.go new file mode 100644 index 000000000..7f35eaa24 --- /dev/null +++ b/pkg/multiagent/blackboard.go @@ -0,0 +1,147 @@ +package multiagent + +import ( + "encoding/json" + "sort" + "sync" + "time" +) + +// BlackboardEntry represents a single entry in the shared context pool. +type BlackboardEntry struct { + Key string `json:"key"` + Value string `json:"value"` + Author string `json:"author"` + Scope string `json:"scope"` + Timestamp time.Time `json:"timestamp"` +} + +// Blackboard is a thread-safe shared context pool for multi-agent collaboration. +// Agents read and write string key-value entries, each tagged with authorship +// and scope metadata. +type Blackboard struct { + entries map[string]*BlackboardEntry + mu sync.RWMutex +} + +// NewBlackboard creates an empty Blackboard. +func NewBlackboard() *Blackboard { + return &Blackboard{ + entries: make(map[string]*BlackboardEntry), + } +} + +// Set writes or overwrites an entry on the blackboard. +func (b *Blackboard) Set(key, value, author string) { + b.mu.Lock() + defer b.mu.Unlock() + b.entries[key] = &BlackboardEntry{ + Key: key, + Value: value, + Author: author, + Scope: "shared", + Timestamp: time.Now(), + } +} + +// Get returns the value for a key, or empty string if not found. +func (b *Blackboard) Get(key string) string { + b.mu.RLock() + defer b.mu.RUnlock() + if e, ok := b.entries[key]; ok { + return e.Value + } + return "" +} + +// GetEntry returns the full entry for a key, or nil if not found. +func (b *Blackboard) GetEntry(key string) *BlackboardEntry { + b.mu.RLock() + defer b.mu.RUnlock() + if e, ok := b.entries[key]; ok { + cp := *e + return &cp + } + return nil +} + +// Delete removes an entry by key. Returns true if it existed. +func (b *Blackboard) Delete(key string) bool { + b.mu.Lock() + defer b.mu.Unlock() + _, ok := b.entries[key] + if ok { + delete(b.entries, key) + } + return ok +} + +// List returns all keys sorted alphabetically. +func (b *Blackboard) List() []string { + b.mu.RLock() + defer b.mu.RUnlock() + keys := make([]string, 0, len(b.entries)) + for k := range b.entries { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// Snapshot returns a string summary of all entries suitable for injection +// into an LLM system prompt. +func (b *Blackboard) Snapshot() string { + b.mu.RLock() + defer b.mu.RUnlock() + if len(b.entries) == 0 { + return "" + } + + keys := make([]string, 0, len(b.entries)) + for k := range b.entries { + keys = append(keys, k) + } + sort.Strings(keys) + + result := "## Shared Context (Blackboard)\n\n" + for _, k := range keys { + e := b.entries[k] + result += "- **" + k + "** (by " + e.Author + "): " + e.Value + "\n" + } + return result +} + +// Size returns the number of entries. +func (b *Blackboard) Size() int { + b.mu.RLock() + defer b.mu.RUnlock() + return len(b.entries) +} + +// MarshalJSON serializes the blackboard entries to JSON. +func (b *Blackboard) MarshalJSON() ([]byte, error) { + b.mu.RLock() + defer b.mu.RUnlock() + + entries := make([]*BlackboardEntry, 0, len(b.entries)) + for _, e := range b.entries { + entries = append(entries, e) + } + return json.Marshal(entries) +} + +// UnmarshalJSON deserializes blackboard entries from JSON. +func (b *Blackboard) UnmarshalJSON(data []byte) error { + var entries []*BlackboardEntry + if err := json.Unmarshal(data, &entries); err != nil { + return err + } + + b.mu.Lock() + defer b.mu.Unlock() + b.entries = make(map[string]*BlackboardEntry, len(entries)) + for _, e := range entries { + b.entries[e.Key] = e + } + return nil +} diff --git a/pkg/multiagent/blackboard_test.go b/pkg/multiagent/blackboard_test.go new file mode 100644 index 000000000..45c7374d6 --- /dev/null +++ b/pkg/multiagent/blackboard_test.go @@ -0,0 +1,302 @@ +package multiagent + +import ( + "context" + "encoding/json" + "sync" + "testing" +) + +func TestBlackboard_SetGet(t *testing.T) { + bb := NewBlackboard() + bb.Set("goal", "build feature X", "main") + + if got := bb.Get("goal"); got != "build feature X" { + t.Errorf("Get(goal) = %q, want %q", got, "build feature X") + } +} + +func TestBlackboard_GetMissing(t *testing.T) { + bb := NewBlackboard() + if got := bb.Get("missing"); got != "" { + t.Errorf("Get(missing) = %q, want empty", got) + } +} + +func TestBlackboard_GetEntry(t *testing.T) { + bb := NewBlackboard() + bb.Set("status", "in-progress", "coder") + + entry := bb.GetEntry("status") + if entry == nil { + t.Fatal("expected non-nil entry") + } + if entry.Author != "coder" { + t.Errorf("Author = %q, want %q", entry.Author, "coder") + } + if entry.Scope != "shared" { + t.Errorf("Scope = %q, want %q", entry.Scope, "shared") + } +} + +func TestBlackboard_GetEntryMissing(t *testing.T) { + bb := NewBlackboard() + if entry := bb.GetEntry("nope"); entry != nil { + t.Error("expected nil entry for missing key") + } +} + +func TestBlackboard_Overwrite(t *testing.T) { + bb := NewBlackboard() + bb.Set("counter", "1", "a") + bb.Set("counter", "2", "b") + + entry := bb.GetEntry("counter") + if entry.Value != "2" { + t.Errorf("Value = %q after overwrite, want %q", entry.Value, "2") + } + if entry.Author != "b" { + t.Errorf("Author = %q after overwrite, want %q", entry.Author, "b") + } +} + +func TestBlackboard_Delete(t *testing.T) { + bb := NewBlackboard() + bb.Set("tmp", "value", "main") + + if !bb.Delete("tmp") { + t.Error("Delete(tmp) returned false, expected true") + } + if bb.Delete("tmp") { + t.Error("Delete(tmp) second call returned true, expected false") + } + if bb.Get("tmp") != "" { + t.Error("Get(tmp) after delete should return empty") + } +} + +func TestBlackboard_List(t *testing.T) { + bb := NewBlackboard() + bb.Set("b", "2", "a") + bb.Set("a", "1", "a") + bb.Set("c", "3", "a") + + keys := bb.List() + if len(keys) != 3 { + t.Fatalf("List() returned %d keys, want 3", len(keys)) + } + if keys[0] != "a" || keys[1] != "b" || keys[2] != "c" { + t.Errorf("List() = %v, want [a b c]", keys) + } +} + +func TestBlackboard_Snapshot(t *testing.T) { + bb := NewBlackboard() + if s := bb.Snapshot(); s != "" { + t.Errorf("empty blackboard Snapshot() = %q, want empty", s) + } + + bb.Set("goal", "test", "main") + s := bb.Snapshot() + if s == "" { + t.Error("Snapshot() returned empty for non-empty blackboard") + } + if !contains(s, "goal") || !contains(s, "main") || !contains(s, "test") { + t.Errorf("Snapshot() = %q, expected to contain key/author/value", s) + } +} + +func TestBlackboard_Size(t *testing.T) { + bb := NewBlackboard() + if bb.Size() != 0 { + t.Errorf("Size() = %d, want 0", bb.Size()) + } + bb.Set("a", "1", "x") + bb.Set("b", "2", "x") + if bb.Size() != 2 { + t.Errorf("Size() = %d, want 2", bb.Size()) + } +} + +func TestBlackboard_ConcurrentAccess(t *testing.T) { + bb := NewBlackboard() + var wg sync.WaitGroup + + for i := 0; i < 100; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + key := "key" + bb.Set(key, "val", "agent") + bb.Get(key) + bb.List() + bb.Snapshot() + }(i) + } + wg.Wait() +} + +func TestBlackboard_JSON(t *testing.T) { + bb := NewBlackboard() + bb.Set("x", "1", "a") + bb.Set("y", "2", "b") + + data, err := json.Marshal(bb) + if err != nil { + t.Fatalf("MarshalJSON failed: %v", err) + } + + bb2 := NewBlackboard() + if err := json.Unmarshal(data, bb2); err != nil { + t.Fatalf("UnmarshalJSON failed: %v", err) + } + + if bb2.Get("x") != "1" || bb2.Get("y") != "2" { + t.Error("roundtrip lost data") + } +} + +func TestBlackboardTool_Write(t *testing.T) { + bb := NewBlackboard() + tool := NewBlackboardTool(bb, "test-agent") + + result := tool.Execute(context.Background(), map[string]interface{}{ + "action": "write", + "key": "task", + "value": "implement feature", + }) + if result.IsError { + t.Fatalf("write failed: %s", result.ForLLM) + } + + if bb.Get("task") != "implement feature" { + t.Error("write did not persist") + } + entry := bb.GetEntry("task") + if entry.Author != "test-agent" { + t.Errorf("Author = %q, want %q", entry.Author, "test-agent") + } +} + +func TestBlackboardTool_Read(t *testing.T) { + bb := NewBlackboard() + bb.Set("info", "hello", "other") + tool := NewBlackboardTool(bb, "reader") + + result := tool.Execute(context.Background(), map[string]interface{}{ + "action": "read", + "key": "info", + }) + if result.IsError { + t.Fatalf("read failed: %s", result.ForLLM) + } + if !contains(result.ForLLM, "hello") { + t.Errorf("read result = %q, expected to contain 'hello'", result.ForLLM) + } +} + +func TestBlackboardTool_ReadMissing(t *testing.T) { + bb := NewBlackboard() + tool := NewBlackboardTool(bb, "reader") + + result := tool.Execute(context.Background(), map[string]interface{}{ + "action": "read", + "key": "nope", + }) + if result.IsError { + t.Fatalf("read missing should not be error: %s", result.ForLLM) + } + if !contains(result.ForLLM, "No entry") { + t.Errorf("expected 'No entry' message, got %q", result.ForLLM) + } +} + +func TestBlackboardTool_List(t *testing.T) { + bb := NewBlackboard() + bb.Set("a", "1", "x") + bb.Set("b", "2", "y") + tool := NewBlackboardTool(bb, "lister") + + result := tool.Execute(context.Background(), map[string]interface{}{ + "action": "list", + }) + if result.IsError { + t.Fatalf("list failed: %s", result.ForLLM) + } + if !contains(result.ForLLM, "a") || !contains(result.ForLLM, "b") { + t.Errorf("list result = %q, expected keys", result.ForLLM) + } +} + +func TestBlackboardTool_Delete(t *testing.T) { + bb := NewBlackboard() + bb.Set("tmp", "val", "x") + tool := NewBlackboardTool(bb, "deleter") + + result := tool.Execute(context.Background(), map[string]interface{}{ + "action": "delete", + "key": "tmp", + }) + if result.IsError { + t.Fatalf("delete failed: %s", result.ForLLM) + } + if bb.Size() != 0 { + t.Error("delete did not remove entry") + } +} + +func TestBlackboardTool_InvalidAction(t *testing.T) { + bb := NewBlackboard() + tool := NewBlackboardTool(bb, "test") + + result := tool.Execute(context.Background(), map[string]interface{}{ + "action": "invalid", + }) + if !result.IsError { + t.Error("expected error for invalid action") + } +} + +func TestBlackboardTool_MissingKey(t *testing.T) { + bb := NewBlackboard() + tool := NewBlackboardTool(bb, "test") + + // read without key + result := tool.Execute(context.Background(), map[string]interface{}{ + "action": "read", + }) + if !result.IsError { + t.Error("expected error for read without key") + } + + // write without key + result = tool.Execute(context.Background(), map[string]interface{}{ + "action": "write", + "value": "test", + }) + if !result.IsError { + t.Error("expected error for write without key") + } + + // write without value + result = tool.Execute(context.Background(), map[string]interface{}{ + "action": "write", + "key": "k", + }) + if !result.IsError { + t.Error("expected error for write without value") + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub)) +} + +func containsStr(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/pkg/multiagent/blackboard_tool.go b/pkg/multiagent/blackboard_tool.go new file mode 100644 index 000000000..4478b6d05 --- /dev/null +++ b/pkg/multiagent/blackboard_tool.go @@ -0,0 +1,109 @@ +package multiagent + +import ( + "context" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/tools" +) + +// BlackboardTool exposes the Blackboard to an LLM agent via the tool interface. +// Each instance is bound to a specific agent ID for authorship tracking. +type BlackboardTool struct { + board *Blackboard + agentID string +} + +// NewBlackboardTool creates a blackboard tool bound to a specific agent. +func NewBlackboardTool(board *Blackboard, agentID string) *BlackboardTool { + return &BlackboardTool{ + board: board, + agentID: agentID, + } +} + +func (t *BlackboardTool) Name() string { return "blackboard" } + +func (t *BlackboardTool) Description() string { + return "Read, write, list, or delete entries in the shared context blackboard. " + + "Use this to share information between agents in a multi-agent session." +} + +func (t *BlackboardTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{ + "type": "string", + "enum": []string{"read", "write", "list", "delete"}, + "description": "The action to perform on the blackboard", + }, + "key": map[string]interface{}{ + "type": "string", + "description": "The key to read, write, or delete (not required for list)", + }, + "value": map[string]interface{}{ + "type": "string", + "description": "The value to write (only required for write action)", + }, + }, + "required": []string{"action"}, + } +} + +func (t *BlackboardTool) Execute(_ context.Context, args map[string]interface{}) *tools.ToolResult { + action, _ := args["action"].(string) + key, _ := args["key"].(string) + value, _ := args["value"].(string) + + switch strings.ToLower(action) { + case "read": + if key == "" { + return tools.ErrorResult("key is required for read action") + } + entry := t.board.GetEntry(key) + if entry == nil { + return tools.NewToolResult(fmt.Sprintf("No entry found for key %q", key)) + } + return tools.NewToolResult(fmt.Sprintf("Key: %s\nValue: %s\nAuthor: %s\nScope: %s", + entry.Key, entry.Value, entry.Author, entry.Scope)) + + case "write": + if key == "" { + return tools.ErrorResult("key is required for write action") + } + if value == "" { + return tools.ErrorResult("value is required for write action") + } + t.board.Set(key, value, t.agentID) + return tools.NewToolResult(fmt.Sprintf("Written key %q to blackboard", key)) + + case "list": + keys := t.board.List() + if len(keys) == 0 { + return tools.NewToolResult("Blackboard is empty") + } + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Blackboard entries (%d):\n", len(keys))) + for _, k := range keys { + entry := t.board.GetEntry(k) + if entry != nil { + sb.WriteString(fmt.Sprintf("- %s (by %s): %s\n", k, entry.Author, entry.Value)) + } + } + return tools.NewToolResult(sb.String()) + + case "delete": + if key == "" { + return tools.ErrorResult("key is required for delete action") + } + if t.board.Delete(key) { + return tools.NewToolResult(fmt.Sprintf("Deleted key %q from blackboard", key)) + } + return tools.NewToolResult(fmt.Sprintf("Key %q not found on blackboard", key)) + + default: + return tools.ErrorResult(fmt.Sprintf("unknown action %q; use read, write, list, or delete", action)) + } +} diff --git a/pkg/multiagent/handoff.go b/pkg/multiagent/handoff.go new file mode 100644 index 000000000..406bfdf84 --- /dev/null +++ b/pkg/multiagent/handoff.go @@ -0,0 +1,127 @@ +package multiagent + +import ( + "context" + "fmt" + + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/tools" +) + +// AgentResolver looks up an agent by ID. +// Typically backed by agent.AgentRegistry.GetAgent. +type AgentResolver interface { + GetAgentInfo(agentID string) *AgentInfo + ListAgents() []AgentInfo +} + +// AgentInfo is a minimal view of an agent for handoff purposes, +// decoupled from the full AgentInstance to avoid circular imports. +type AgentInfo struct { + ID string + Name string + Role string + SystemPrompt string + Model string + Provider providers.LLMProvider + Tools *tools.ToolRegistry + MaxIter int +} + +// HandoffRequest describes a delegation from one agent to another. +type HandoffRequest struct { + FromAgentID string + ToAgentID string + Task string + Context map[string]string // k-v to write to blackboard before handoff +} + +// HandoffResult contains the outcome of a handoff execution. +type HandoffResult struct { + AgentID string + Content string + Iterations int + Success bool + Error string +} + +// ExecuteHandoff delegates a task to a target agent, injecting blackboard context. +func ExecuteHandoff(ctx context.Context, resolver AgentResolver, board *Blackboard, req HandoffRequest, channel, chatID string) *HandoffResult { + target := resolver.GetAgentInfo(req.ToAgentID) + if target == nil { + return &HandoffResult{ + AgentID: req.ToAgentID, + Success: false, + Error: fmt.Sprintf("agent %q not found", req.ToAgentID), + } + } + + // Write request context to blackboard + if board != nil && req.Context != nil { + for k, v := range req.Context { + board.Set(k, v, req.FromAgentID) + } + } + + // Build system prompt incorporating agent role, system prompt, and blackboard + systemPrompt := buildHandoffSystemPrompt(target, board) + + messages := []providers.Message{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: req.Task}, + } + + maxIter := target.MaxIter + if maxIter == 0 { + maxIter = 10 + } + + loopResult, err := tools.RunToolLoop(ctx, tools.ToolLoopConfig{ + Provider: target.Provider, + Model: target.Model, + Tools: target.Tools, + MaxIterations: maxIter, + LLMOptions: map[string]interface{}{ + "max_tokens": 4096, + "temperature": 0.7, + }, + }, messages, channel, chatID) + + if err != nil { + return &HandoffResult{ + AgentID: req.ToAgentID, + Success: false, + Error: err.Error(), + } + } + + return &HandoffResult{ + AgentID: req.ToAgentID, + Content: loopResult.Content, + Iterations: loopResult.Iterations, + Success: true, + } +} + +func buildHandoffSystemPrompt(agent *AgentInfo, board *Blackboard) string { + prompt := "You are " + agent.Name + if agent.Role != "" { + prompt += ", " + agent.Role + } + prompt += ".\n" + + if agent.SystemPrompt != "" { + prompt += "\n" + agent.SystemPrompt + "\n" + } + + prompt += "\nComplete the delegated task and provide a clear result." + + if board != nil { + snapshot := board.Snapshot() + if snapshot != "" { + prompt += "\n\n" + snapshot + } + } + + return prompt +} diff --git a/pkg/multiagent/handoff_test.go b/pkg/multiagent/handoff_test.go new file mode 100644 index 000000000..0c08312b1 --- /dev/null +++ b/pkg/multiagent/handoff_test.go @@ -0,0 +1,250 @@ +package multiagent + +import ( + "context" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/tools" +) + +// mockProvider is a minimal LLM provider for testing. +type mockProvider struct { + response string + err error +} + +func (m *mockProvider) Chat(_ context.Context, _ []providers.Message, _ []providers.ToolDefinition, _ string, _ map[string]interface{}) (*providers.LLMResponse, error) { + if m.err != nil { + return nil, m.err + } + return &providers.LLMResponse{ + Content: m.response, + FinishReason: "stop", + }, nil +} + +func (m *mockProvider) GetDefaultModel() string { return "mock-model" } + +// mockResolver implements AgentResolver for testing. +type mockResolver struct { + agents map[string]*AgentInfo +} + +func newMockResolver(agents ...*AgentInfo) *mockResolver { + m := &mockResolver{agents: make(map[string]*AgentInfo)} + for _, a := range agents { + m.agents[a.ID] = a + } + return m +} + +func (m *mockResolver) GetAgentInfo(agentID string) *AgentInfo { + return m.agents[agentID] +} + +func (m *mockResolver) ListAgents() []AgentInfo { + result := make([]AgentInfo, 0, len(m.agents)) + for _, a := range m.agents { + result = append(result, *a) + } + return result +} + +func TestExecuteHandoff_Success(t *testing.T) { + provider := &mockProvider{response: "task completed successfully"} + resolver := newMockResolver(&AgentInfo{ + ID: "coder", + Name: "Code Agent", + Role: "coding specialist", + Model: "test-model", + Provider: provider, + Tools: tools.NewToolRegistry(), + MaxIter: 5, + }) + + bb := NewBlackboard() + result := ExecuteHandoff(context.Background(), resolver, bb, HandoffRequest{ + FromAgentID: "main", + ToAgentID: "coder", + Task: "write a function", + Context: map[string]string{"language": "Go"}, + }, "cli", "direct") + + if !result.Success { + t.Fatalf("expected success, got error: %s", result.Error) + } + if result.Content != "task completed successfully" { + t.Errorf("Content = %q, want %q", result.Content, "task completed successfully") + } + if result.AgentID != "coder" { + t.Errorf("AgentID = %q, want %q", result.AgentID, "coder") + } + + // Verify context was written to blackboard + if bb.Get("language") != "Go" { + t.Errorf("blackboard 'language' = %q, want %q", bb.Get("language"), "Go") + } +} + +func TestExecuteHandoff_UnknownAgent(t *testing.T) { + resolver := newMockResolver() + bb := NewBlackboard() + + result := ExecuteHandoff(context.Background(), resolver, bb, HandoffRequest{ + FromAgentID: "main", + ToAgentID: "nonexistent", + Task: "do something", + }, "cli", "direct") + + if result.Success { + t.Error("expected failure for unknown agent") + } + if !strings.Contains(result.Error, "not found") { + t.Errorf("Error = %q, expected 'not found'", result.Error) + } +} + +func TestExecuteHandoff_NilBlackboard(t *testing.T) { + provider := &mockProvider{response: "done"} + resolver := newMockResolver(&AgentInfo{ + ID: "helper", + Name: "Helper", + Model: "test", + Provider: provider, + Tools: tools.NewToolRegistry(), + MaxIter: 5, + }) + + // Should not panic with nil blackboard + result := ExecuteHandoff(context.Background(), resolver, nil, HandoffRequest{ + FromAgentID: "main", + ToAgentID: "helper", + Task: "help me", + Context: map[string]string{"key": "value"}, + }, "cli", "direct") + + if !result.Success { + t.Fatalf("expected success, got error: %s", result.Error) + } +} + +func TestHandoffTool_Execute(t *testing.T) { + provider := &mockProvider{response: "handoff result"} + resolver := newMockResolver( + &AgentInfo{ID: "main", Name: "Main", Role: "orchestrator", Provider: provider, Tools: tools.NewToolRegistry(), MaxIter: 5}, + &AgentInfo{ID: "coder", Name: "Coder", Role: "coding", Model: "test", Provider: provider, Tools: tools.NewToolRegistry(), MaxIter: 5}, + ) + + bb := NewBlackboard() + tool := NewHandoffTool(resolver, bb, "main") + + result := tool.Execute(context.Background(), map[string]interface{}{ + "agent_id": "coder", + "task": "write code", + }) + + if result.IsError { + t.Fatalf("handoff tool failed: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "handoff result") { + t.Errorf("ForLLM = %q, expected to contain 'handoff result'", result.ForLLM) + } +} + +func TestHandoffTool_MissingArgs(t *testing.T) { + resolver := newMockResolver() + bb := NewBlackboard() + tool := NewHandoffTool(resolver, bb, "main") + + // Missing agent_id + result := tool.Execute(context.Background(), map[string]interface{}{ + "task": "do something", + }) + if !result.IsError { + t.Error("expected error for missing agent_id") + } + + // Missing task + result = tool.Execute(context.Background(), map[string]interface{}{ + "agent_id": "coder", + }) + if !result.IsError { + t.Error("expected error for missing task") + } +} + +func TestHandoffTool_Description(t *testing.T) { + resolver := newMockResolver( + &AgentInfo{ID: "main", Name: "Main"}, + &AgentInfo{ID: "coder", Name: "Coder", Role: "coding specialist"}, + ) + bb := NewBlackboard() + tool := NewHandoffTool(resolver, bb, "main") + + desc := tool.Description() + if !strings.Contains(desc, "coder") { + t.Errorf("Description = %q, expected to contain 'coder'", desc) + } + if !strings.Contains(desc, "coding specialist") { + t.Errorf("Description = %q, expected to contain role", desc) + } +} + +func TestListAgentsTool_Execute(t *testing.T) { + resolver := newMockResolver( + &AgentInfo{ID: "main", Name: "Main Agent", Role: "general"}, + &AgentInfo{ID: "coder", Name: "Code Agent", Role: "coding"}, + ) + tool := NewListAgentsTool(resolver) + + result := tool.Execute(context.Background(), nil) + if result.IsError { + t.Fatalf("list_agents failed: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "main") || !strings.Contains(result.ForLLM, "coder") { + t.Errorf("ForLLM = %q, expected agent IDs", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "2") { + t.Errorf("ForLLM = %q, expected count", result.ForLLM) + } +} + +func TestListAgentsTool_Empty(t *testing.T) { + resolver := newMockResolver() + tool := NewListAgentsTool(resolver) + + result := tool.Execute(context.Background(), nil) + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "No agents") { + t.Errorf("ForLLM = %q, expected 'No agents' message", result.ForLLM) + } +} + +func TestBuildHandoffSystemPrompt(t *testing.T) { + agent := &AgentInfo{ + Name: "Code Agent", + Role: "coding specialist", + SystemPrompt: "Focus on Go code quality.", + } + + bb := NewBlackboard() + bb.Set("language", "Go", "main") + + prompt := buildHandoffSystemPrompt(agent, bb) + if !strings.Contains(prompt, "Code Agent") { + t.Errorf("prompt missing agent name: %s", prompt) + } + if !strings.Contains(prompt, "coding specialist") { + t.Errorf("prompt missing role: %s", prompt) + } + if !strings.Contains(prompt, "Focus on Go code quality") { + t.Errorf("prompt missing system prompt: %s", prompt) + } + if !strings.Contains(prompt, "language") { + t.Errorf("prompt missing blackboard context: %s", prompt) + } +} diff --git a/pkg/multiagent/handoff_tool.go b/pkg/multiagent/handoff_tool.go new file mode 100644 index 000000000..03c85ff15 --- /dev/null +++ b/pkg/multiagent/handoff_tool.go @@ -0,0 +1,118 @@ +package multiagent + +import ( + "context" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/tools" +) + +// HandoffTool allows an LLM agent to delegate a task to another agent. +type HandoffTool struct { + resolver AgentResolver + board *Blackboard + fromAgentID string + originChannel string + originChatID string +} + +// NewHandoffTool creates a handoff tool bound to a specific source agent. +func NewHandoffTool(resolver AgentResolver, board *Blackboard, fromAgentID string) *HandoffTool { + return &HandoffTool{ + resolver: resolver, + board: board, + fromAgentID: fromAgentID, + originChannel: "cli", + originChatID: "direct", + } +} + +func (t *HandoffTool) Name() string { return "handoff" } + +func (t *HandoffTool) Description() string { + agents := t.resolver.ListAgents() + if len(agents) <= 1 { + return "Delegate a task to another agent. No other agents are currently available." + } + + var sb strings.Builder + sb.WriteString("Delegate a task to another agent. Available agents:\n") + for _, a := range agents { + if a.ID == t.fromAgentID { + continue + } + sb.WriteString(fmt.Sprintf("- %s", a.ID)) + if a.Name != "" { + sb.WriteString(fmt.Sprintf(" (%s)", a.Name)) + } + if a.Role != "" { + sb.WriteString(fmt.Sprintf(": %s", a.Role)) + } + sb.WriteString("\n") + } + return sb.String() +} + +func (t *HandoffTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "agent_id": map[string]interface{}{ + "type": "string", + "description": "The ID of the target agent to hand off to", + }, + "task": map[string]interface{}{ + "type": "string", + "description": "The task description for the target agent", + }, + "context": map[string]interface{}{ + "type": "object", + "description": "Optional key-value context to share via blackboard before handoff", + }, + }, + "required": []string{"agent_id", "task"}, + } +} + +func (t *HandoffTool) SetContext(channel, chatID string) { + t.originChannel = channel + t.originChatID = chatID +} + +func (t *HandoffTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult { + agentID, _ := args["agent_id"].(string) + task, _ := args["task"].(string) + + if agentID == "" { + return tools.ErrorResult("agent_id is required") + } + if task == "" { + return tools.ErrorResult("task is required") + } + + // Parse optional context map + var contextMap map[string]string + if ctxRaw, ok := args["context"].(map[string]interface{}); ok { + contextMap = make(map[string]string, len(ctxRaw)) + for k, v := range ctxRaw { + contextMap[k] = fmt.Sprintf("%v", v) + } + } + + result := ExecuteHandoff(ctx, t.resolver, t.board, HandoffRequest{ + FromAgentID: t.fromAgentID, + ToAgentID: agentID, + Task: task, + Context: contextMap, + }, t.originChannel, t.originChatID) + + if !result.Success { + return tools.ErrorResult(fmt.Sprintf("Handoff to %q failed: %s", agentID, result.Error)) + } + + return &tools.ToolResult{ + ForLLM: fmt.Sprintf("Agent %q completed task (iterations: %d):\n%s", agentID, result.Iterations, result.Content), + ForUser: result.Content, + } +} diff --git a/pkg/multiagent/list_agents_tool.go b/pkg/multiagent/list_agents_tool.go new file mode 100644 index 000000000..23e2ad6f3 --- /dev/null +++ b/pkg/multiagent/list_agents_tool.go @@ -0,0 +1,53 @@ +package multiagent + +import ( + "context" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/tools" +) + +// ListAgentsTool allows the LLM to discover all available agents. +type ListAgentsTool struct { + resolver AgentResolver +} + +// NewListAgentsTool creates a discovery tool backed by an AgentResolver. +func NewListAgentsTool(resolver AgentResolver) *ListAgentsTool { + return &ListAgentsTool{resolver: resolver} +} + +func (t *ListAgentsTool) Name() string { return "list_agents" } + +func (t *ListAgentsTool) Description() string { + return "List all available agents with their IDs, names, and roles." +} + +func (t *ListAgentsTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + } +} + +func (t *ListAgentsTool) Execute(_ context.Context, _ map[string]interface{}) *tools.ToolResult { + agents := t.resolver.ListAgents() + if len(agents) == 0 { + return tools.NewToolResult("No agents registered.") + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Available agents (%d):\n", len(agents))) + for _, a := range agents { + sb.WriteString(fmt.Sprintf("- ID: %s", a.ID)) + if a.Name != "" { + sb.WriteString(fmt.Sprintf(", Name: %s", a.Name)) + } + if a.Role != "" { + sb.WriteString(fmt.Sprintf(", Role: %s", a.Role)) + } + sb.WriteString("\n") + } + return tools.NewToolResult(sb.String()) +}