Files
goclaw/internal/tools/subagent_spawn_tool_actions.go
Duc Nguyenandntduc bb7712a9ff fix(collaboration): harden delegated task isolation (#1486)
* feat(collaboration): isolate delegated artifacts and child runs

Isolate delegated inputs and outputs behind secure artifact exchange lifecycles. Scope Agent Link tasks by tenant and root agent, and enforce delegation spawn-tree boundaries. Add process-wide child-run admission and preserve logical media paths across native, MCP, and sandbox execution.

* fix(collaboration): harden delegated task isolation

Enforce tenant and root-agent task scope across migrations and stores. Add exactly-once async completion delivery, delegated sandbox boundaries, and confined artifact and media recovery across runtime surfaces.

* fix(collaboration): recover interrupted async tasks

* fix(collaboration): normalize persisted child-run status

---------

Co-authored-by: ntduc <ntduc@cpp.ai.vn>
2026-07-30 14:17:40 +07:00

157 lines
4.3 KiB
Go

package tools
import (
"context"
"fmt"
"strings"
"time"
)
// executeList shows running and retained subagent tasks.
func (t *SpawnTool) executeList(ctx context.Context) *Result {
scope := t.scopeFromContext(ctx)
tasks := t.subagentMgr.ListTasks(scope, "")
if len(tasks) == 0 {
return &Result{ForLLM: "No subagent tasks found."}
}
var lines []string
queued, running, completed, failed, cancelled := 0, 0, 0, 0, 0
for _, task := range tasks {
switch task.Status {
case TaskStatusQueued:
queued++
case TaskStatusRunning:
running++
case TaskStatusWaiting:
running++
case TaskStatusCompleted:
completed++
case TaskStatusFailed:
failed++
case TaskStatusCancelled:
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 queued, %d active, %d completed, %d failed, %d cancelled (%d retained total)\n%s",
queued, running, completed, failed, cancelled, len(tasks), 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(t.scopeFromContext(ctx), 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, t.scopeFromContext(ctx), id, message, nil)
if err != nil {
return ErrorResult(err.Error())
}
return &Result{ForLLM: msg}
}
// executeWait blocks until all children of the calling agent complete or timeout.
func (t *SpawnTool) executeWait(ctx context.Context, args map[string]any) *Result {
timeout := 300
if v, ok := args["timeout"].(float64); ok && v > 0 {
timeout = int(v)
}
tasks, err := t.subagentMgr.WaitForChildren(ctx, t.scopeFromContext(ctx), "", timeout)
return t.formatWaitResult(tasks, err)
}
func (t *SpawnTool) formatWaitResult(tasks []*SubagentTask, waitErr error) *Result {
if len(tasks) == 0 {
msg := "No subagent tasks found."
if waitErr != nil {
msg += " Error: " + waitErr.Error()
}
return &Result{ForLLM: msg}
}
var sb strings.Builder
completed, failed := 0, 0
var totalIn, totalOut int64
for _, task := range tasks {
switch task.Status {
case TaskStatusCompleted:
completed++
case TaskStatusFailed:
failed++
}
totalIn += task.TotalInputTokens
totalOut += task.TotalOutputTokens
sb.WriteString(fmt.Sprintf("- [%s] %s: %s\n", task.Status, task.Label, truncate(task.Result, 200)))
}
header := fmt.Sprintf("All %d subagent tasks finished (%d completed, %d failed). Total tokens: %d in / %d out\n\n",
len(tasks), completed, failed, totalIn, totalOut)
if waitErr != nil {
header = fmt.Sprintf("Wait ended: %s. %d tasks total.\n\n", waitErr.Error(), len(tasks))
}
return &Result{ForLLM: header + sb.String()}
}
// 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
}