Files
goclaw/internal/tools/subagent_spawn_tool.go
T
Viet Tran 9a9744077e refactor(teams): v2 system cleanup — remove legacy tools, fix followup, add events API (#210)
Major refactoring of the team system with multiple improvements:

## Removed legacy delegation tools
- Delete `delegate.go`, `delegate_async.go`, `delegate_sync.go`, `delegate_events.go`,
  `delegate_policy.go`, `delegate_prep.go`, `delegate_state.go`, `delegate_search_tool.go`
- Delete `evaluate_loop_tool.go`, `handoff_tool.go`
- Remove all references and registrations from tool manager and policy
- Clean up TEAM_PLAYBOOK_IDEAS.md and TEAM_SYSTEM.md (moved to docs)

## Rename await_reply → ask_user
- Rename action `await_reply` → `ask_user`, `clear_followup` → `clear_ask_user`
- Rename functions `executeAwaitReply` → `executeAskUser`, `executeClearFollowup` → `executeClearAskUser`
- Update system prompt with stronger wording to prevent model misuse
- Model was confusing "await_reply" with general waiting; "ask_user" is unambiguous

## Fix auto-followup false positives
- Add `HasActiveMemberTasks(ctx, teamID, excludeAgentID)` store method
- Guard `autoSetFollowup()` in consumer: skip when lead has active member tasks
- Prevents auto-followup when lead is orchestrating teammates (not waiting for user)

## Task identifier zero-padding
- Change format from `T-1-xxxx` → `T-001-xxxx` (3-digit minimum)

## Refactor workspace WS handlers to filesystem-only
- Rewrite `teams.workspace.list/read/delete` to use pure filesystem (os.ReadDir/ReadFile/Remove)
- Remove DB dependency from workspace WS handlers
- Consistent with storage handler and workspace tools
- Simplify TeamWorkspaceFile type and frontend hook

## Add team events listing API
- New WS method `teams.events.list` with team_id, limit, offset params
- New HTTP endpoint `GET /v1/teams/{id}/events` with bearer auth
- New `ListTeamEvents(ctx, teamID, limit, offset)` store method
- JOIN with team_tasks for team-wide event filtering

## Extract team access policy
- New `team_access_policy.go` — centralized team tool access control

## Migration 000019: team_id columns
- Add team_id foreign key columns to relevant tables

## Other improvements
- Add team_id propagation through agent loop, tracing, sessions
- Update i18n locale files (en/vi/zh) for new tool labels
- Update frontend builtin-tools page and require-setup component
- Bump RequiredSchemaVersion for migration 000019
2026-03-15 14:53:19 +07:00

283 lines
8.0 KiB
Go

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
}