package tools import ( "context" "fmt" "sync" "sync/atomic" "time" "github.com/sipeed/picoclaw/pkg/providers" ) // 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 { 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 InitialTokenBudget *atomic.Int64 // Shared token budget for team members; nil if no budget } type SubagentTask struct { ID string Task string Label string AgentID string OriginChannel string OriginChatID string Status string Result string Created int64 } type SpawnSubTurnFunc func( ctx context.Context, task, label, agentID string, tools *ToolRegistry, maxTokens int, temperature float64, hasMaxTokens, hasTemperature bool, ) (*ToolResult, error) type SubagentManager struct { 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 spawner SpawnSubTurnFunc } func NewSubagentManager( provider providers.LLMProvider, defaultModel, workspace string, ) *SubagentManager { return &SubagentManager{ tasks: make(map[string]*SubagentTask), provider: provider, defaultModel: defaultModel, workspace: workspace, tools: NewToolRegistry(), maxIterations: 10, nextID: 1, } } func (sm *SubagentManager) SetSpawner(spawner SpawnSubTurnFunc) { sm.mu.Lock() defer sm.mu.Unlock() sm.spawner = spawner } // 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 } // 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) } func (sm *SubagentManager) Spawn( ctx context.Context, task, label, agentID, originChannel, originChatID string, callback AsyncCallback, ) (string, error) { sm.mu.Lock() defer sm.mu.Unlock() taskID := fmt.Sprintf("subagent-%d", sm.nextID) sm.nextID++ subagentTask := &SubagentTask{ ID: taskID, Task: task, Label: label, AgentID: agentID, OriginChannel: originChannel, OriginChatID: originChatID, Status: "running", Created: time.Now().UnixMilli(), } sm.tasks[taskID] = subagentTask // Start task in background with context cancellation support go sm.runTask(ctx, subagentTask, callback) if label != "" { return fmt.Sprintf("Spawned subagent '%s' for task: %s", label, task), nil } return fmt.Sprintf("Spawned subagent for task: %s", task), nil } func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) { task.Status = "running" task.Created = time.Now().UnixMilli() // Check if context is already canceled before starting select { case <-ctx.Done(): sm.mu.Lock() task.Status = "canceled" task.Result = "Task canceled before execution" sm.mu.Unlock() return default: } sm.mu.RLock() spawner := sm.spawner tools := sm.tools maxIter := sm.maxIterations maxTokens := sm.maxTokens temperature := sm.temperature hasMaxTokens := sm.hasMaxTokens hasTemperature := sm.hasTemperature sm.mu.RUnlock() var result *ToolResult var err error if spawner != nil { result, err = spawner(ctx, task.Task, task.Label, task.AgentID, tools, maxTokens, temperature, hasMaxTokens, hasTemperature) } 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}, } var llmOptions map[string]any if hasMaxTokens || hasTemperature { llmOptions = map[string]any{} if hasMaxTokens { llmOptions["max_tokens"] = maxTokens } if hasTemperature { llmOptions["temperature"] = temperature } } var loopResult *ToolLoopResult loopResult, err = RunToolLoop(ctx, ToolLoopConfig{ Provider: sm.provider, Model: sm.defaultModel, Tools: tools, MaxIterations: maxIter, LLMOptions: llmOptions, }, messages, task.OriginChannel, task.OriginChatID) 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, } } } sm.mu.Lock() defer func() { sm.mu.Unlock() // Call callback if provided and result is set if callback != nil && result != nil { callback(ctx, result) } }() if err != nil { task.Status = "failed" task.Result = fmt.Sprintf("Error: %v", err) // Check if it was canceled if ctx.Err() != nil { task.Status = "canceled" task.Result = "Task canceled during execution" } result = &ToolResult{ ForLLM: task.Result, ForUser: "", Silent: false, IsError: true, Async: false, Err: err, } } else { task.Status = "completed" task.Result = result.ForLLM } } func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) { sm.mu.RLock() defer sm.mu.RUnlock() task, ok := sm.tasks[taskID] return task, ok } // 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 } 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 } // 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 } // SubagentTool executes a subagent task synchronously and returns the result. // It directly calls SubTurnSpawner with Async=false for synchronous execution. type SubagentTool struct { spawner SubTurnSpawner defaultModel string maxTokens int temperature float64 } func NewSubagentTool(manager *SubagentManager) *SubagentTool { if manager == nil { return &SubagentTool{} } return &SubagentTool{ defaultModel: manager.defaultModel, maxTokens: manager.maxTokens, temperature: manager.temperature, } } // SetSpawner sets the SubTurnSpawner for direct sub-turn execution. func (t *SubagentTool) SetSpawner(spawner SubTurnSpawner) { t.spawner = spawner } 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]any { return map[string]any{ "type": "object", "properties": map[string]any{ "task": map[string]any{ "type": "string", "description": "The task for subagent to complete", }, "label": map[string]any{ "type": "string", "description": "Optional short label for the task (for display)", }, }, "required": []string{"task"}, } } func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult { task, ok := args["task"].(string) if !ok { return ErrorResult("task is required").WithError(fmt.Errorf("task parameter is required")) } label, _ := args["label"].(string) // Build system prompt for subagent systemPrompt := fmt.Sprintf(`You are a subagent. Complete the given task independently and provide a clear, concise result. Task: %s`, task) if label != "" { systemPrompt = fmt.Sprintf(`You are a subagent labeled "%s". Complete the given task independently and provide a clear, concise result. Task: %s`, label, task) } // 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 }) if err != nil { return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err) } // Format result for display userContent := result.ForLLM if result.ForUser != "" { userContent = result.ForUser } maxUserLen := 500 if len(userContent) > maxUserLen { userContent = userContent[:maxUserLen] + "..." } labelStr := label if labelStr == "" { labelStr = "(unnamed)" } llmContent := fmt.Sprintf("Subagent task completed:\nLabel: %s\nResult: %s", labelStr, result.ForLLM) return &ToolResult{ ForLLM: llmContent, ForUser: userContent, Silent: false, IsError: result.IsError, Async: false, } } // Fallback: spawner not configured return ErrorResult("Subagent manager not configured").WithError(fmt.Errorf("spawner not set")) }