mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 22:17:36 +00:00
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>
This commit is contained in:
1 parent
496b7ffce6
commit
bb7712a9ff
186 files changed
+18858
-1233
No files matched your search
@@ -2,6 +2,7 @@ package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -39,7 +40,7 @@ func (t *SpawnTool) Parameters() map[string]any {
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{
|
||||
"type": "string",
|
||||
"description": "'spawn' (default), 'list', 'cancel', 'steer', or 'wait'",
|
||||
"description": "'spawn' (default), 'get', 'list', 'cancel', 'steer', or 'wait'",
|
||||
},
|
||||
"task": map[string]any{
|
||||
"type": "string",
|
||||
@@ -61,6 +62,10 @@ func (t *SpawnTool) Parameters() map[string]any {
|
||||
"type": "string",
|
||||
"description": "Task ID for cancel/steer. For cancel: use 'all' to cancel all or 'last' for most recent",
|
||||
},
|
||||
"completion_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Durable completion UUID returned by async spawn (required for action=get)",
|
||||
},
|
||||
"message": map[string]any{
|
||||
"type": "string",
|
||||
"description": "New instructions (required for action=steer)",
|
||||
@@ -70,7 +75,6 @@ func (t *SpawnTool) Parameters() map[string]any {
|
||||
"description": "Timeout in seconds for action=wait (default 300)",
|
||||
},
|
||||
},
|
||||
"required": []string{"task"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +85,8 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *Result {
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "get":
|
||||
return t.executeGet(ctx, args)
|
||||
case "list":
|
||||
return t.executeList(ctx)
|
||||
case "cancel":
|
||||
@@ -89,11 +95,34 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *Result {
|
||||
return t.executeSteer(ctx, args)
|
||||
case "wait":
|
||||
return t.executeWait(ctx, args)
|
||||
default:
|
||||
case "spawn":
|
||||
return t.executeSpawn(ctx, args)
|
||||
default:
|
||||
return ErrorResult(fmt.Sprintf("unknown spawn action %q", action))
|
||||
}
|
||||
}
|
||||
|
||||
func (t *SpawnTool) executeGet(ctx context.Context, args map[string]any) *Result {
|
||||
rawID, _ := args["completion_id"].(string)
|
||||
completionID, err := uuid.Parse(rawID)
|
||||
if err != nil {
|
||||
return ErrorResult("completion_id must be a valid UUID")
|
||||
}
|
||||
scope := subagentScopeFromContext(ctx)
|
||||
task, err := t.subagentMgr.GetPersistedTask(ctx, scope, completionID)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
if task == nil {
|
||||
return ErrorResult("subagent completion not found")
|
||||
}
|
||||
payload, err := json.Marshal(persistedCompletionPayload(task))
|
||||
if err != nil {
|
||||
return ErrorResult("failed to encode subagent completion")
|
||||
}
|
||||
return NewResult(string(payload))
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -116,6 +145,12 @@ func (t *SpawnTool) executeSpawn(ctx context.Context, args map[string]any) *Resu
|
||||
}
|
||||
|
||||
mode, _ := args["mode"].(string)
|
||||
if mode == "" {
|
||||
mode = "async"
|
||||
}
|
||||
if err := validateDelegationChildRunMode(ctx, "spawn", mode); err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
if mode == "sync" {
|
||||
return t.executeSubagentSync(ctx, args, task)
|
||||
}
|
||||
@@ -137,19 +172,56 @@ func (t *SpawnTool) executeSubagentAsync(ctx context.Context, args map[string]an
|
||||
parentID = t.parentID
|
||||
}
|
||||
|
||||
msg, err := t.subagentMgr.Spawn(ctx, parentID, t.depth, task, label, modelOverride,
|
||||
receipt, err := t.subagentMgr.SpawnWithReceipt(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}
|
||||
accepted := map[string]any{
|
||||
"status": "accepted",
|
||||
"label": label,
|
||||
"task_id": receipt.TaskID,
|
||||
}
|
||||
if receipt.CompletionID != uuid.Nil {
|
||||
accepted["completion_id"] = receipt.CompletionID.String()
|
||||
}
|
||||
acceptedJSON, _ := json.Marshal(accepted)
|
||||
forLLM := fmt.Sprintf(`%s
|
||||
%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)
|
||||
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. If an announcement is missed, retrieve the durable result with spawn(action="get", completion_id="..."). Do NOT wait or poll while the task is running.`, acceptedJSON, receipt.Message)
|
||||
|
||||
return AsyncResult(forLLM)
|
||||
}
|
||||
|
||||
func persistedCompletionPayload(task *store.SubagentTaskData) map[string]any {
|
||||
payload := map[string]any{
|
||||
"completion_id": task.ID.String(),
|
||||
"status": task.Status,
|
||||
"subject": task.Subject,
|
||||
"created_at": task.CreatedAt,
|
||||
"updated_at": task.UpdatedAt,
|
||||
}
|
||||
if task.Result != nil {
|
||||
payload["result"] = *task.Result
|
||||
}
|
||||
if task.CompletedAt != nil {
|
||||
payload["completed_at"] = *task.CompletedAt
|
||||
}
|
||||
if task.Metadata != nil {
|
||||
if runtimeID, ok := task.Metadata[asyncCompletionRuntimeIDKey].(string); ok && runtimeID != "" {
|
||||
payload["task_id"] = runtimeID
|
||||
}
|
||||
if delivery, ok := task.Metadata[asyncCompletionDeliveryKey].(string); ok && delivery != "" {
|
||||
payload[asyncCompletionDeliveryKey] = delivery
|
||||
}
|
||||
if media := persistedCompletionMediaPayload(task.Metadata[asyncCompletionMediaKey]); len(media) > 0 {
|
||||
payload["media"] = media
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// executeSubagentSync runs a sync self-clone.
|
||||
func (t *SpawnTool) executeSubagentSync(ctx context.Context, args map[string]any, task string) *Result {
|
||||
label, _ := args["label"].(string)
|
||||
@@ -166,7 +238,7 @@ func (t *SpawnTool) executeSubagentSync(ctx context.Context, args map[string]any
|
||||
parentID = t.parentID
|
||||
}
|
||||
|
||||
result, iterations, err := t.subagentMgr.RunSync(ctx, parentID, t.depth, task, label, modelOverride,
|
||||
result, media, iterations, err := t.subagentMgr.RunSync(ctx, parentID, t.depth, task, label, modelOverride,
|
||||
channel, chatID)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("Subagent '%s' failed: %v", label, err))
|
||||
@@ -182,7 +254,7 @@ func (t *SpawnTool) executeSubagentSync(ctx context.Context, args map[string]any
|
||||
forLLM := fmt.Sprintf("Subagent '%s' completed in %d iterations.\n\nFull result:\n%s",
|
||||
label, iterations, result)
|
||||
|
||||
return &Result{ForLLM: forLLM, ForUser: forUser}
|
||||
return &Result{ForLLM: forLLM, ForUser: forUser, Media: media}
|
||||
}
|
||||
|
||||
// SetContext is a no-op; channel/chatID are now read from ctx (thread-safe).
|
||||
@@ -193,3 +265,11 @@ 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) {}
|
||||
|
||||
func (t *SpawnTool) scopeFromContext(ctx context.Context) TaskScope {
|
||||
scope := subagentScopeFromContext(ctx)
|
||||
if scope.RootAgentKey == "" {
|
||||
scope.RootAgentKey = t.parentID
|
||||
}
|
||||
return scope
|
||||
}
|
||||
Reference in new issue
Block a user