2026-02-04 11:06:13 +00:00
package tools
import (
"context"
"fmt"
"sync"
"time"
2026-02-10 08:05:23 +00:00
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/providers"
2026-02-04 11:06:13 +00:00
)
type SubagentTask struct {
ID string
Task string
Label string
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
AgentID string
2026-02-04 11:06:13 +00:00
OriginChannel string
OriginChatID string
Status string
Result string
Created int64
}
type SubagentManager struct {
2026-02-13 09:51:47 +00:00
tasks map [ string ] * SubagentTask
mu sync . RWMutex
provider providers . LLMProvider
defaultModel string
bus * bus . MessageBus
workspace string
tools * ToolRegistry
2026-02-13 06:39:39 +00:00
maxIterations int
2026-02-13 09:51:47 +00:00
nextID int
2026-02-04 11:06:13 +00:00
}
2026-02-13 03:13:32 +00:00
func NewSubagentManager ( provider providers . LLMProvider , defaultModel , workspace string , bus * bus . MessageBus ) * SubagentManager {
2026-02-04 11:06:13 +00:00
return & SubagentManager {
2026-02-13 09:51:47 +00:00
tasks : make ( map [ string ] * SubagentTask ) ,
provider : provider ,
defaultModel : defaultModel ,
bus : bus ,
workspace : workspace ,
tools : NewToolRegistry ( ) ,
2026-02-13 06:39:39 +00:00
maxIterations : 10 ,
2026-02-13 09:51:47 +00:00
nextID : 1 ,
2026-02-04 11:06:13 +00:00
}
}
2026-02-13 06:39:39 +00:00
// SetTools sets the tool registry for subagent execution.
// If not set, subagent will have access to the provided tools.
func ( sm * SubagentManager ) SetTools ( tools * ToolRegistry ) {
sm . mu . Lock ( )
defer sm . mu . Unlock ( )
sm . tools = tools
}
// RegisterTool registers a tool for subagent execution.
func ( sm * SubagentManager ) RegisterTool ( tool Tool ) {
sm . mu . Lock ( )
defer sm . mu . Unlock ( )
sm . tools . Register ( tool )
}
2026-02-13 15:24:26 +00:00
func ( sm * SubagentManager ) Spawn ( ctx context . Context , task , label , agentID , originChannel , originChatID string , callback AsyncCallback ) ( string , error ) {
2026-02-04 11:06:13 +00:00
sm . mu . Lock ( )
defer sm . mu . Unlock ( )
taskID := fmt . Sprintf ( "subagent-%d" , sm . nextID )
sm . nextID ++
subagentTask := & SubagentTask {
ID : taskID ,
Task : task ,
Label : label ,
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
AgentID : agentID ,
2026-02-04 11:06:13 +00:00
OriginChannel : originChannel ,
OriginChatID : originChatID ,
Status : "running" ,
Created : time . Now ( ) . UnixMilli ( ) ,
}
sm . tasks [ taskID ] = subagentTask
2026-02-12 17:59:50 +00:00
// Start task in background with context cancellation support
go sm . runTask ( ctx , subagentTask , callback )
2026-02-04 11:06:13 +00:00
if label != "" {
return fmt . Sprintf ( "Spawned subagent '%s' for task: %s" , label , task ) , nil
}
return fmt . Sprintf ( "Spawned subagent for task: %s" , task ) , nil
}
2026-02-12 17:59:50 +00:00
func ( sm * SubagentManager ) runTask ( ctx context . Context , task * SubagentTask , callback AsyncCallback ) {
2026-02-04 11:06:13 +00:00
task . Status = "running"
task . Created = time . Now ( ) . UnixMilli ( )
2026-02-13 06:39:39 +00:00
// Build system prompt for subagent
systemPrompt := ` You are a subagent . Complete the given task independently and report the result .
You have access to tools - use them as needed to complete your task .
After completing the task , provide a clear summary of what was done . `
2026-02-10 08:05:23 +00:00
messages := [ ] providers . Message {
2026-02-04 11:06:13 +00:00
{
Role : "system" ,
2026-02-13 06:39:39 +00:00
Content : systemPrompt ,
2026-02-04 11:06:13 +00:00
} ,
{
Role : "user" ,
Content : task . Task ,
} ,
}
2026-02-12 17:59:50 +00:00
// Check if context is already cancelled before starting
select {
case <- ctx . Done ( ) :
sm . mu . Lock ( )
task . Status = "cancelled"
task . Result = "Task cancelled before execution"
sm . mu . Unlock ( )
return
default :
}
2026-02-13 06:39:39 +00:00
// Run tool loop with access to tools
sm . mu . RLock ( )
tools := sm . tools
maxIter := sm . maxIterations
sm . mu . RUnlock ( )
loopResult , err := RunToolLoop ( ctx , ToolLoopConfig {
Provider : sm . provider ,
Model : sm . defaultModel ,
Tools : tools ,
MaxIterations : maxIter ,
LLMOptions : map [ string ] any {
"max_tokens" : 4096 ,
"temperature" : 0.7 ,
} ,
} , messages , task . OriginChannel , task . OriginChatID )
2026-02-04 11:06:13 +00:00
sm . mu . Lock ( )
2026-02-12 17:59:50 +00:00
var result * ToolResult
defer func ( ) {
sm . mu . Unlock ( )
// Call callback if provided and result is set
if callback != nil && result != nil {
callback ( ctx , result )
}
} ( )
2026-02-04 11:06:13 +00:00
if err != nil {
task . Status = "failed"
task . Result = fmt . Sprintf ( "Error: %v" , err )
2026-02-12 17:59:50 +00:00
// Check if it was cancelled
if ctx . Err ( ) != nil {
task . Status = "cancelled"
task . Result = "Task cancelled during execution"
}
result = & ToolResult {
ForLLM : task . Result ,
ForUser : "" ,
Silent : false ,
IsError : true ,
Async : false ,
Err : err ,
}
2026-02-04 11:06:13 +00:00
} else {
task . Status = "completed"
2026-02-13 06:39:39 +00:00
task . Result = loopResult . Content
2026-02-12 17:59:50 +00:00
result = & ToolResult {
2026-02-13 06:39:39 +00:00
ForLLM : fmt . Sprintf ( "Subagent '%s' completed (iterations: %d): %s" , task . Label , loopResult . Iterations , loopResult . Content ) ,
ForUser : loopResult . Content ,
2026-02-12 17:59:50 +00:00
Silent : false ,
IsError : false ,
Async : false ,
}
2026-02-04 11:06:13 +00:00
}
2026-02-10 08:05:23 +00:00
// Send announce message back to main agent
if sm . bus != nil {
announceContent := fmt . Sprintf ( "Task '%s' completed.\n\nResult:\n%s" , task . Label , task . Result )
sm . bus . PublishInbound ( bus . InboundMessage {
Channel : "system" ,
SenderID : fmt . Sprintf ( "subagent:%s" , task . ID ) ,
// Format: "original_channel:original_chat_id" for routing back
ChatID : fmt . Sprintf ( "%s:%s" , task . OriginChannel , task . OriginChatID ) ,
Content : announceContent ,
} )
}
2026-02-04 11:06:13 +00:00
}
func ( sm * SubagentManager ) GetTask ( taskID string ) ( * SubagentTask , bool ) {
sm . mu . RLock ( )
defer sm . mu . RUnlock ( )
task , ok := sm . tasks [ taskID ]
return task , ok
}
func ( sm * SubagentManager ) ListTasks ( ) [ ] * SubagentTask {
sm . mu . RLock ( )
defer sm . mu . RUnlock ( )
tasks := make ( [ ] * SubagentTask , 0 , len ( sm . tasks ) )
for _ , task := range sm . tasks {
tasks = append ( tasks , task )
}
return tasks
}
2026-02-12 12:14:21 +00:00
// SubagentTool executes a subagent task synchronously and returns the result.
// Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion
// and returns the result directly in the ToolResult.
type SubagentTool struct {
manager * SubagentManager
originChannel string
originChatID string
}
func NewSubagentTool ( manager * SubagentManager ) * SubagentTool {
return & SubagentTool {
manager : manager ,
originChannel : "cli" ,
originChatID : "direct" ,
}
}
func ( t * SubagentTool ) Name ( ) string {
return "subagent"
}
func ( t * SubagentTool ) Description ( ) string {
return "Execute a subagent task synchronously and return the result. Use this for delegating specific tasks to an independent agent instance. Returns execution summary to user and full details to LLM."
}
func ( t * SubagentTool ) Parameters ( ) map [ string ] interface { } {
return map [ string ] interface { } {
"type" : "object" ,
"properties" : map [ string ] interface { } {
"task" : map [ string ] interface { } {
"type" : "string" ,
"description" : "The task for subagent to complete" ,
} ,
"label" : map [ string ] interface { } {
"type" : "string" ,
"description" : "Optional short label for the task (for display)" ,
} ,
} ,
"required" : [ ] string { "task" } ,
}
}
func ( t * SubagentTool ) SetContext ( channel , chatID string ) {
t . originChannel = channel
t . originChatID = chatID
}
func ( t * SubagentTool ) Execute ( ctx context . Context , args map [ string ] interface { } ) * ToolResult {
task , ok := args [ "task" ] . ( string )
if ! ok {
return ErrorResult ( "task is required" ) . WithError ( fmt . Errorf ( "task parameter is required" ) )
}
label , _ := args [ "label" ] . ( string )
if t . manager == nil {
return ErrorResult ( "Subagent manager not configured" ) . WithError ( fmt . Errorf ( "manager is nil" ) )
}
2026-02-13 07:05:16 +00:00
// Build messages for subagent
2026-02-12 12:14:21 +00:00
messages := [ ] providers . Message {
{
Role : "system" ,
Content : "You are a subagent. Complete the given task independently and provide a clear, concise result." ,
} ,
{
Role : "user" ,
Content : task ,
} ,
}
2026-02-13 07:05:16 +00:00
// Use RunToolLoop to execute with tools (same as async SpawnTool)
sm := t . manager
sm . mu . RLock ( )
tools := sm . tools
maxIter := sm . maxIterations
sm . mu . RUnlock ( )
loopResult , err := RunToolLoop ( ctx , ToolLoopConfig {
Provider : sm . provider ,
Model : sm . defaultModel ,
Tools : tools ,
MaxIterations : maxIter ,
LLMOptions : map [ string ] any {
"max_tokens" : 4096 ,
"temperature" : 0.7 ,
} ,
} , messages , t . originChannel , t . originChatID )
2026-02-12 12:14:21 +00:00
if err != nil {
return ErrorResult ( fmt . Sprintf ( "Subagent execution failed: %v" , err ) ) . WithError ( err )
}
// ForUser: Brief summary for user (truncated if too long)
2026-02-13 07:05:16 +00:00
userContent := loopResult . Content
2026-02-12 12:14:21 +00:00
maxUserLen := 500
if len ( userContent ) > maxUserLen {
userContent = userContent [ : maxUserLen ] + "..."
}
// ForLLM: Full execution details
2026-02-13 07:05:16 +00:00
labelStr := label
if labelStr == "" {
labelStr = "(unnamed)"
}
llmContent := fmt . Sprintf ( "Subagent task completed:\nLabel: %s\nIterations: %d\nResult: %s" ,
labelStr , loopResult . Iterations , loopResult . Content )
2026-02-12 12:14:21 +00:00
return & ToolResult {
ForLLM : llmContent ,
2026-02-13 07:05:16 +00:00
ForUser : userContent ,
Silent : false ,
IsError : false ,
Async : false ,
2026-02-12 12:14:21 +00:00
}
}