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:
parent
587ef4d033
commit
f5c22691b2
10 changed files with 1205 additions and 7 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -104,6 +104,8 @@ type AgentConfig struct {
|
|||
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"`
|
||||
|
|
|
|||
147
pkg/multiagent/blackboard.go
Normal file
147
pkg/multiagent/blackboard.go
Normal 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
|
||||
}
|
||||
302
pkg/multiagent/blackboard_test.go
Normal file
302
pkg/multiagent/blackboard_test.go
Normal 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
|
||||
}
|
||||
109
pkg/multiagent/blackboard_tool.go
Normal file
109
pkg/multiagent/blackboard_tool.go
Normal 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
127
pkg/multiagent/handoff.go
Normal 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
|
||||
}
|
||||
250
pkg/multiagent/handoff_test.go
Normal file
250
pkg/multiagent/handoff_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
118
pkg/multiagent/handoff_tool.go
Normal file
118
pkg/multiagent/handoff_tool.go
Normal 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,
|
||||
}
|
||||
}
|
||||
53
pkg/multiagent/list_agents_tool.go
Normal file
53
pkg/multiagent/list_agents_tool.go
Normal 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())
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue