2026-02-04 11:06:13 +00:00
package tools
import (
"context"
"fmt"
"sync"
2026-03-19 04:38:18 +00:00
"sync/atomic"
2026-02-04 11:06:13 +00:00
"time"
2026-02-10 08:05:23 +00:00
"github.com/sipeed/picoclaw/pkg/providers"
2026-02-04 11:06:13 +00:00
)
2026-03-17 04:50:32 +00:00
// SubTurnSpawner is an interface for spawning sub-turns.
// This avoids circular dependency between tools and agent packages.
type SubTurnSpawner interface {
SpawnSubTurn ( ctx context . Context , cfg SubTurnConfig ) ( * ToolResult , error )
}
// SubTurnConfig holds configuration for spawning a sub-turn.
type SubTurnConfig struct {
2026-03-19 02:15:00 +00:00
Model string
Tools [ ] Tool
SystemPrompt string
MaxTokens int
Temperature float64
Async bool // true for async (spawn), false for sync (subagent)
Critical bool // continue running after parent finishes gracefully
Timeout time . Duration // 0 = use default (5 minutes)
MaxContextRunes int // 0 = auto, -1 = no limit, >0 = explicit limit
ActualSystemPrompt string
InitialMessages [ ] providers . Message
2026-03-19 04:38:18 +00:00
InitialTokenBudget * atomic . Int64 // Shared token budget for team members; nil if no budget
2026-04-15 13:27:13 +00:00
TargetAgentID string // If set, run as this agent (its workspace, model, tools)
2026-03-17 04:50:32 +00:00
}
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
}
2026-03-16 09:27:04 +00:00
type SpawnSubTurnFunc func (
ctx context . Context ,
task , label , agentID string ,
tools * ToolRegistry ,
maxTokens int ,
temperature float64 ,
hasMaxTokens , hasTemperature bool ,
) ( * ToolResult , error )
2026-02-04 11:06:13 +00:00
type SubagentManager struct {
2026-02-19 18:16:37 +00:00
tasks map [ string ] * SubagentTask
mu sync . RWMutex
provider providers . LLMProvider
defaultModel string
workspace string
tools * ToolRegistry
maxIterations int
maxTokens int
temperature float64
hasMaxTokens bool
hasTemperature bool
nextID int
2026-03-16 09:27:04 +00:00
spawner SpawnSubTurnFunc
2026-04-01 13:32:10 +00:00
// mediaResolver resolves media:// refs in tool-loop messages before
// each LLM call in the legacy RunToolLoop fallback path.
// This lets subagents reuse the same media handling behavior as the
// main agent loop without importing pkg/agent and creating a cycle.
mediaResolver func ( [ ] providers . Message ) [ ] providers . Message
2026-02-04 11:06:13 +00:00
}
2026-02-18 19:48:23 +00:00
func NewSubagentManager (
provider providers . LLMProvider ,
defaultModel , workspace string ,
) * 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 ,
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-03-16 09:27:04 +00:00
func ( sm * SubagentManager ) SetSpawner ( spawner SpawnSubTurnFunc ) {
sm . mu . Lock ( )
defer sm . mu . Unlock ( )
sm . spawner = spawner
}
2026-04-01 13:32:10 +00:00
// SetMediaResolver injects a message preprocessor that resolves media:// refs
// into LLM-ready content before each tool-loop iteration.
// This is only used by the legacy RunToolLoop fallback path.
func ( sm * SubagentManager ) SetMediaResolver (
resolver func ( [ ] providers . Message ) [ ] providers . Message ,
) {
sm . mu . Lock ( )
defer sm . mu . Unlock ( )
sm . mediaResolver = resolver
}
2026-02-19 18:16:37 +00:00
// SetLLMOptions sets max tokens and temperature for subagent LLM calls.
func ( sm * SubagentManager ) SetLLMOptions ( maxTokens int , temperature float64 ) {
sm . mu . Lock ( )
defer sm . mu . Unlock ( )
sm . maxTokens = maxTokens
sm . hasMaxTokens = true
sm . temperature = temperature
sm . hasTemperature = true
}
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-18 19:48:23 +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-03-21 09:12:45 +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-03-22 11:21:58 +00:00
// TODO(eventbus): once subagents are modeled as child turns inside
// pkg/agent, emit SubTurnEnd and SubTurnResultDelivered from the parent
// AgentLoop instead of this legacy manager.
2026-02-04 11:06:13 +00:00
2026-02-25 09:58:49 +00:00
// Check if context is already canceled before starting
2026-02-12 17:59:50 +00:00
select {
case <- ctx . Done ( ) :
sm . mu . Lock ( )
2026-02-25 09:58:49 +00:00
task . Status = "canceled"
task . Result = "Task canceled before execution"
2026-02-12 17:59:50 +00:00
sm . mu . Unlock ( )
return
default :
}
2026-02-13 06:39:39 +00:00
sm . mu . RLock ( )
2026-03-16 09:27:04 +00:00
spawner := sm . spawner
2026-02-13 06:39:39 +00:00
tools := sm . tools
maxIter := sm . maxIterations
2026-02-19 18:16:37 +00:00
maxTokens := sm . maxTokens
temperature := sm . temperature
hasMaxTokens := sm . hasMaxTokens
hasTemperature := sm . hasTemperature
2026-04-01 13:32:10 +00:00
mediaResolver := sm . mediaResolver
2026-02-13 06:39:39 +00:00
sm . mu . RUnlock ( )
2026-03-16 09:27:04 +00:00
var result * ToolResult
var err error
if spawner != nil {
2026-03-21 09:12:45 +00:00
result , err = spawner (
ctx ,
task . Task ,
task . Label ,
task . AgentID ,
tools ,
maxTokens ,
temperature ,
hasMaxTokens ,
hasTemperature ,
)
2026-03-16 09:27:04 +00:00
} else {
// Fallback to legacy RunToolLoop
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 . `
messages := [ ] providers . Message {
{ Role : "system" , Content : systemPrompt } ,
{ Role : "user" , Content : task . Task } ,
2026-02-19 18:16:37 +00:00
}
2026-03-16 09:27:04 +00:00
var llmOptions map [ string ] any
if hasMaxTokens || hasTemperature {
llmOptions = map [ string ] any { }
if hasMaxTokens {
llmOptions [ "max_tokens" ] = maxTokens
}
if hasTemperature {
llmOptions [ "temperature" ] = temperature
}
2026-02-19 18:16:37 +00:00
}
2026-03-16 09:27:04 +00:00
var loopResult * ToolLoopResult
loopResult , err = RunToolLoop ( ctx , ToolLoopConfig {
Provider : sm . provider ,
Model : sm . defaultModel ,
Tools : tools ,
MaxIterations : maxIter ,
LLMOptions : llmOptions ,
2026-04-01 13:32:10 +00:00
MediaResolver : mediaResolver ,
2026-03-16 09:27:04 +00:00
} , messages , task . OriginChannel , task . OriginChatID )
2026-03-19 02:15:00 +00:00
2026-03-16 09:27:04 +00:00
if err == nil {
result = & ToolResult {
ForLLM : fmt . Sprintf (
"Subagent '%s' completed (iterations: %d): %s" ,
task . Label ,
loopResult . Iterations ,
loopResult . Content ,
) ,
ForUser : loopResult . Content ,
Silent : false ,
IsError : false ,
Async : false ,
}
}
}
2026-02-04 11:06:13 +00:00
sm . mu . Lock ( )
2026-02-12 17:59:50 +00:00
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-25 09:58:49 +00:00
// Check if it was canceled
2026-02-12 17:59:50 +00:00
if ctx . Err ( ) != nil {
2026-02-25 09:58:49 +00:00
task . Status = "canceled"
task . Result = "Task canceled during execution"
2026-02-12 17:59:50 +00:00
}
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-03-16 09:27:04 +00:00
task . Result = result . ForLLM
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
}
2026-03-17 06:41:43 +00:00
// GetTaskCopy returns a copy of the task with the given ID, taken under the
// read lock, so the caller receives a consistent snapshot with no data race.
func ( sm * SubagentManager ) GetTaskCopy ( taskID string ) ( SubagentTask , bool ) {
sm . mu . RLock ( )
defer sm . mu . RUnlock ( )
task , ok := sm . tasks [ taskID ]
if ! ok {
return SubagentTask { } , false
}
return * task , true
}
2026-02-04 11:06:13 +00:00
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
2026-03-17 06:41:43 +00:00
// ListTaskCopies returns value copies of all tasks, taken under the read lock,
// so callers receive consistent snapshots with no data race.
func ( sm * SubagentManager ) ListTaskCopies ( ) [ ] SubagentTask {
sm . mu . RLock ( )
defer sm . mu . RUnlock ( )
copies := make ( [ ] SubagentTask , 0 , len ( sm . tasks ) )
for _ , task := range sm . tasks {
copies = append ( copies , * task )
}
return copies
}
2026-02-12 12:14:21 +00:00
// SubagentTool executes a subagent task synchronously and returns the result.
2026-03-17 04:50:32 +00:00
// It directly calls SubTurnSpawner with Async=false for synchronous execution.
2026-02-12 12:14:21 +00:00
type SubagentTool struct {
2026-03-17 04:50:32 +00:00
spawner SubTurnSpawner
defaultModel string
maxTokens int
temperature float64
2026-02-12 12:14:21 +00:00
}
func NewSubagentTool ( manager * SubagentManager ) * SubagentTool {
2026-03-19 05:51:11 +00:00
if manager == nil {
return & SubagentTool { }
}
2026-02-12 12:14:21 +00:00
return & SubagentTool {
2026-03-17 04:50:32 +00:00
defaultModel : manager . defaultModel ,
maxTokens : manager . maxTokens ,
temperature : manager . temperature ,
2026-02-12 12:14:21 +00:00
}
}
2026-03-17 04:50:32 +00:00
// SetSpawner sets the SubTurnSpawner for direct sub-turn execution.
func ( t * SubagentTool ) SetSpawner ( spawner SubTurnSpawner ) {
t . spawner = spawner
}
2026-02-12 12:14:21 +00:00
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."
}
2026-02-18 19:48:23 +00:00
func ( t * SubagentTool ) Parameters ( ) map [ string ] any {
return map [ string ] any {
2026-02-12 12:14:21 +00:00
"type" : "object" ,
2026-02-18 19:48:23 +00:00
"properties" : map [ string ] any {
"task" : map [ string ] any {
2026-02-12 12:14:21 +00:00
"type" : "string" ,
"description" : "The task for subagent to complete" ,
} ,
2026-02-18 19:48:23 +00:00
"label" : map [ string ] any {
2026-02-12 12:14:21 +00:00
"type" : "string" ,
"description" : "Optional short label for the task (for display)" ,
} ,
} ,
"required" : [ ] string { "task" } ,
}
}
2026-02-18 19:48:23 +00:00
func ( t * SubagentTool ) Execute ( ctx context . Context , args map [ string ] any ) * ToolResult {
2026-02-12 12:14:21 +00:00
task , ok := args [ "task" ] . ( string )
if ! ok {
return ErrorResult ( "task is required" ) . WithError ( fmt . Errorf ( "task parameter is required" ) )
}
2026-06-08 09:25:19 +00:00
label , ok := args [ "label" ] . ( string )
if ! ok {
label = ""
}
2026-02-12 12:14:21 +00:00
2026-03-17 04:50:32 +00:00
// Build system prompt for subagent
2026-03-21 09:12:45 +00:00
systemPrompt := fmt . Sprintf (
` You are a subagent . Complete the given task independently and provide a clear , concise result .
2026-03-17 04:50:32 +00:00
2026-03-21 09:12:45 +00:00
Task : % s ` ,
task ,
)
2026-03-17 04:50:32 +00:00
if label != "" {
2026-03-21 09:12:45 +00:00
systemPrompt = fmt . Sprintf (
` You are a subagent labeled "%s" . Complete the given task independently and provide a clear , concise result .
2026-03-17 04:50:32 +00:00
2026-03-21 09:12:45 +00:00
Task : % s ` ,
label ,
task ,
)
2026-02-12 12:14:21 +00:00
}
2026-03-17 04:50:32 +00:00
// Use spawner if available (direct SpawnSubTurn call)
if t . spawner != nil {
result , err := t . spawner . SpawnSubTurn ( ctx , SubTurnConfig {
Model : t . defaultModel ,
Tools : nil , // Will inherit from parent via context
SystemPrompt : systemPrompt ,
MaxTokens : t . maxTokens ,
Temperature : t . temperature ,
Async : false , // Synchronous execution
} )
2026-03-16 09:27:04 +00:00
if err != nil {
return ErrorResult ( fmt . Sprintf ( "Subagent execution failed: %v" , err ) ) . WithError ( err )
}
2026-03-17 04:50:32 +00:00
// Format result for display
userContent := result . ForLLM
if result . ForUser != "" {
userContent = result . ForUser
2026-03-16 09:27:04 +00:00
}
maxUserLen := 500
if len ( userContent ) > maxUserLen {
userContent = userContent [ : maxUserLen ] + "..."
}
2026-03-17 04:50:32 +00:00
2026-03-16 09:27:04 +00:00
labelStr := label
if labelStr == "" {
labelStr = "(unnamed)"
}
llmContent := fmt . Sprintf ( "Subagent task completed:\nLabel: %s\nResult: %s" ,
2026-03-17 04:50:32 +00:00
labelStr , result . ForLLM )
2026-03-16 09:27:04 +00:00
return & ToolResult {
2026-03-17 04:50:32 +00:00
ForLLM : llmContent ,
2026-03-16 09:27:04 +00:00
ForUser : userContent ,
2026-03-17 04:50:32 +00:00
Silent : false ,
IsError : result . IsError ,
Async : false ,
2026-02-19 18:16:37 +00:00
}
}
2026-03-17 04:50:32 +00:00
// Fallback: spawner not configured
2026-03-19 05:51:11 +00:00
return ErrorResult ( "Subagent manager not configured" ) . WithError ( fmt . Errorf ( "spawner not set" ) )
2026-02-12 12:14:21 +00:00
}