feat: add multi-agent collaboration framework (#294)

Implements the base multi-agent collaboration framework:

Phase 1 - Config extension:
- Add Role and SystemPrompt fields to AgentConfig
- Wire them into AgentInstance for per-agent identity

Phase 2 - Blackboard shared context pool:
- pkg/multiagent/blackboard.go: thread-safe key-value store with
  authorship tracking, scope metadata, and JSON serialization
- pkg/multiagent/blackboard_tool.go: LLM tool for read/write/list/delete

Phase 3 - Handoff mechanism and agent discovery:
- pkg/multiagent/handoff.go: ExecuteHandoff delegates tasks between
  agents via RunToolLoop, injecting blackboard context
- pkg/multiagent/handoff_tool.go: LLM tool with dynamic agent listing
- pkg/multiagent/list_agents_tool.go: discovery tool for LLM

Phase 4 - AgentLoop integration:
- registryResolver adapter bridges AgentRegistry to multiagent.AgentResolver
- blackboard/handoff/list_agents tools registered for multi-agent configs
- Per-session blackboard via sync.Map, snapshot injected into system prompt
- Handoff tool context propagation for channel/chatID

Design decisions:
- String values only (natural language agent communication)
- Scope field defaults to "shared", extensible for #119 identity model
- Author field tracks which agent wrote (maps to future S-id)
- Multi-agent tools only registered when >1 agent configured (zero overhead)
- ~2.3KB per session memory budget

Closes: #294
This commit is contained in:
Leandro Barbosa 2026-02-18 11:16:52 -03:00
parent 587ef4d033
commit f5c22691b2
10 changed files with 1205 additions and 7 deletions

View file

@ -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,

View file

@ -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 := &registryResolver{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.

View file

@ -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 {

View file

@ -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
}

View file

@ -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
}

View file

@ -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))
}
}

127
pkg/multiagent/handoff.go Normal file
View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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,
}
}

View file

@ -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())
}