package tools import ( "context" "fmt" "strings" "time" ) // SpawnTool spawns subagent clones to handle tasks in the background. // // Routing: // - mode="async" (default): return immediately, subagent announces result when done // - mode="sync": block until done, return result inline type SpawnTool struct { subagentMgr *SubagentManager parentID string depth int } func NewSpawnTool(manager *SubagentManager, parentID string, depth int) *SpawnTool { return &SpawnTool{ subagentMgr: manager, parentID: parentID, depth: depth, } } func (t *SpawnTool) Name() string { return "spawn" } func (t *SpawnTool) Description() string { return "Spawn a subagent to handle a task in the background. The subagent runs independently and reports back when done." } func (t *SpawnTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "action": map[string]any{ "type": "string", "description": "'spawn' (default), 'list', 'cancel', or 'steer'", }, "task": map[string]any{ "type": "string", "description": "The task to complete (required for action=spawn)", }, "mode": map[string]any{ "type": "string", "description": "'async' (default, returns immediately) or 'sync' (blocks until done)", }, "label": map[string]any{ "type": "string", "description": "Short label for the task (for display)", }, "model": map[string]any{ "type": "string", "description": "Optional model override (e.g. 'anthropic/claude-sonnet-4-5-20250929')", }, "id": map[string]any{ "type": "string", "description": "Task ID for cancel/steer. For cancel: use 'all' to cancel all or 'last' for most recent", }, "message": map[string]any{ "type": "string", "description": "New instructions (required for action=steer)", }, }, "required": []string{"task"}, } } func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *Result { action, _ := args["action"].(string) if action == "" { action = "spawn" } switch action { case "list": return t.executeList(ctx) case "cancel": return t.executeCancel(ctx, args) case "steer": return t.executeSteer(ctx, args) default: return t.executeSpawn(ctx, args) } } func (t *SpawnTool) executeSpawn(ctx context.Context, args map[string]any) *Result { // Reject legacy "agent" parameter — delegation was removed. // Guide the LLM to use team_tasks for team coordination. if agentKey, _ := args["agent"].(string); agentKey != "" { return ErrorResult(fmt.Sprintf( "spawn does not accept 'agent' parameter. spawn is for self-clone subagent only. "+ "To delegate work to team member %q, use: team_tasks(action=\"create\", subject=\"...\", description=\"...\", assignee=%q)", agentKey, agentKey)) } task, _ := args["task"].(string) if task == "" { return ErrorResult("task parameter is required") } mode, _ := args["mode"].(string) if mode == "sync" { return t.executeSubagentSync(ctx, args, task) } return t.executeSubagentAsync(ctx, args, task) } // executeSubagentAsync spawns an async self-clone. func (t *SpawnTool) executeSubagentAsync(ctx context.Context, args map[string]any, task string) *Result { label, _ := args["label"].(string) modelOverride, _ := args["model"].(string) channel := ToolChannelFromCtx(ctx) chatID := ToolChatIDFromCtx(ctx) peerKind := ToolPeerKindFromCtx(ctx) callback := ToolAsyncCBFromCtx(ctx) parentID := ToolAgentKeyFromCtx(ctx) if parentID == "" { parentID = t.parentID } msg, err := t.subagentMgr.Spawn(ctx, parentID, t.depth, task, label, modelOverride, channel, chatID, peerKind, callback) if err != nil { return ErrorResult(err.Error()) } forLLM := fmt.Sprintf(`{"status":"accepted","label":%q} %s After all spawn tool calls in this turn are complete, briefly tell the user what tasks you've started. Subagents will announce results when done — do NOT wait or poll.`, label, msg) return AsyncResult(forLLM) } // executeSubagentSync runs a sync self-clone. func (t *SpawnTool) executeSubagentSync(ctx context.Context, args map[string]any, task string) *Result { label, _ := args["label"].(string) if label == "" { label = truncate(task, 50) } channel := ToolChannelFromCtx(ctx) chatID := ToolChatIDFromCtx(ctx) parentID := ToolAgentKeyFromCtx(ctx) if parentID == "" { parentID = t.parentID } result, iterations, err := t.subagentMgr.RunSync(ctx, parentID, t.depth, task, label, channel, chatID) if err != nil { return ErrorResult(fmt.Sprintf("Subagent '%s' failed: %v", label, err)) } forUser := fmt.Sprintf("Subagent '%s' completed.", label) if len(result) > 500 { forUser += "\n" + result[:500] + "..." } else { forUser += "\n" + result } forLLM := fmt.Sprintf("Subagent '%s' completed in %d iterations.\n\nFull result:\n%s", label, iterations, result) return &Result{ForLLM: forLLM, ForUser: forUser} } // executeList shows active subagent tasks. func (t *SpawnTool) executeList(ctx context.Context) *Result { parentID := ToolAgentKeyFromCtx(ctx) if parentID == "" { parentID = t.parentID } tasks := t.subagentMgr.ListTasks(parentID) if len(tasks) == 0 { return &Result{ForLLM: "No active tasks found."} } var lines []string running, completed, cancelled := 0, 0, 0 for _, task := range tasks { switch task.Status { case "running": running++ case "completed": completed++ case "cancelled": cancelled++ } line := fmt.Sprintf("- [%s] %s (id=%s, status=%s)", task.Label, truncate(task.Task, 60), task.ID, task.Status) if task.CompletedAt > 0 { dur := time.Duration(task.CompletedAt-task.CreatedAt) * time.Millisecond line += fmt.Sprintf(", took %s", dur.Round(time.Millisecond)) } lines = append(lines, line) } return &Result{ForLLM: fmt.Sprintf("Subagent tasks: %d running, %d completed, %d cancelled\n%s", running, completed, cancelled, strings.Join(lines, "\n"))} } // executeCancel cancels a subagent task by ID. func (t *SpawnTool) executeCancel(ctx context.Context, args map[string]any) *Result { id, _ := args["id"].(string) if id == "" { return ErrorResult("id is required for action=cancel") } if t.subagentMgr.CancelTask(id) { return &Result{ForLLM: fmt.Sprintf("Task '%s' cancelled.", id)} } return ErrorResult(fmt.Sprintf("Task '%s' not found or not running.", id)) } // executeSteer redirects a running subagent with new instructions. func (t *SpawnTool) executeSteer(ctx context.Context, args map[string]any) *Result { id, _ := args["id"].(string) if id == "" { return ErrorResult("id is required for action=steer") } message, _ := args["message"].(string) if message == "" { return ErrorResult("message is required for action=steer") } msg, err := t.subagentMgr.Steer(ctx, id, message, nil) if err != nil { return ErrorResult(err.Error()) } return &Result{ForLLM: msg} } // SetContext is a no-op; channel/chatID are now read from ctx (thread-safe). func (t *SpawnTool) SetContext(channel, chatID string) {} // SetPeerKind is a no-op; peerKind is now read from ctx (thread-safe). func (t *SpawnTool) SetPeerKind(peerKind string) {} // SetCallback is a no-op; callback is now read from ctx (thread-safe). func (t *SpawnTool) SetCallback(cb AsyncCallback) {} // --- Helpers moved from old subagent_tool.go --- // FilterDenyList returns tool names from the registry excluding denied tools. func FilterDenyList(reg *Registry, denyList []string) []string { deny := make(map[string]bool, len(denyList)) for _, n := range denyList { deny[n] = true } var allowed []string for _, name := range reg.List() { if !deny[name] { allowed = append(allowed, name) } } return allowed } // IsSubagentDenied checks if a tool name is in the subagent deny list. func IsSubagentDenied(toolName string, depth, maxDepth int) bool { for _, d := range SubagentDenyAlways { if strings.EqualFold(toolName, d) { return true } } if depth >= maxDepth { for _, d := range SubagentDenyLeaf { if strings.EqualFold(toolName, d) { return true } } } return false }