From b2dc430a5efdabc3d3db6e149630e36f9622cc25 Mon Sep 17 00:00:00 2001 From: Anton Bogdanovich <27antonb@gmail.com> Date: Mon, 11 May 2026 12:35:02 -0700 Subject: [PATCH] feat(spawn): add async delivery mode control --- pkg/tools/spawn.go | 35 ++++++++++++++++++++++++-- pkg/tools/spawn_test.go | 56 +++++++++++++++++++++++++++++++++++++++++ pkg/tools/subagent.go | 7 +++--- 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go index ed32b6335..69953c81e 100644 --- a/pkg/tools/spawn.go +++ b/pkg/tools/spawn.go @@ -61,7 +61,7 @@ func (t *SpawnTool) Name() string { } func (t *SpawnTool) Description() string { - return "Spawn a subagent to handle a task in the background. Use this for complex or time-consuming tasks that can run independently. The subagent will complete the task and report back when done." + return "Spawn a subagent to handle a task in the background. Use this for complex or time-consuming tasks that can run independently. The subagent will complete the task and report back when done. Optional delivery_mode controls whether the final async result goes to the user, the parent agent, or both." } func (t *SpawnTool) Parameters() map[string]any { @@ -80,6 +80,15 @@ func (t *SpawnTool) Parameters() map[string]any { "type": "string", "description": "Optional target agent ID to delegate the task to", }, + "delivery_mode": map[string]any{ + "type": "string", + "description": "Optional async result routing policy: user_only, parent_only, or user_and_parent. Defaults to user_only.", + "enum": []string{ + string(AsyncDeliveryUserOnly), + string(AsyncDeliveryParentOnly), + string(AsyncDeliveryUserAndParent), + }, + }, }, "required": []string{"task"}, } @@ -116,6 +125,10 @@ func (t *SpawnTool) execute( label, _ := args["label"].(string) agentID, _ := args["agent_id"].(string) targetAgentID := strings.TrimSpace(agentID) + deliveryMode, err := parseSpawnDeliveryMode(args["delivery_mode"]) + if err != nil { + return ErrorResult(err.Error()).WithError(err) + } // Check allowlist if targeting a specific agent if targetAgentID != "" && t.allowlistCheck != nil { @@ -131,7 +144,7 @@ func (t *SpawnTool) execute( if cb != nil { wrappedCallback = func(cbCtx context.Context, res *ToolResult) { if res != nil { - res.WithAsyncDelivery(AsyncDeliveryUserOnly) + res.WithAsyncDelivery(deliveryMode) } cb(cbCtx, res) } @@ -155,6 +168,24 @@ func (t *SpawnTool) execute( return ErrorResult("Subagent manager not configured") } +func parseSpawnDeliveryMode(raw any) (AsyncDeliveryMode, error) { + if raw == nil { + return AsyncDeliveryUserOnly, nil + } + value, ok := raw.(string) + if !ok { + return "", fmt.Errorf("delivery_mode must be a string") + } + switch AsyncDeliveryMode(strings.TrimSpace(value)) { + case AsyncDeliveryUserOnly, AsyncDeliveryParentOnly, AsyncDeliveryUserAndParent: + return AsyncDeliveryMode(strings.TrimSpace(value)), nil + case "": + return AsyncDeliveryUserOnly, nil + default: + return "", fmt.Errorf("delivery_mode must be one of: user_only, parent_only, user_and_parent") + } +} + func buildSpawnSystemPrompt(task, label string) string { if label != "" { return fmt.Sprintf( diff --git a/pkg/tools/spawn_test.go b/pkg/tools/spawn_test.go index 5c366769e..671a61f22 100644 --- a/pkg/tools/spawn_test.go +++ b/pkg/tools/spawn_test.go @@ -195,3 +195,59 @@ func TestSpawnTool_ExecuteAsync_MarksCallbackResultUserOnly(t *testing.T) { t.Fatal("timed out waiting for spawn callback result") } } + +func TestSpawnTool_ExecuteAsync_RespectsExplicitDeliveryMode(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + tool := NewSpawnTool(manager) + spawner := &mockSpawner{} + tool.SetSpawner(spawner) + + done := make(chan *ToolResult, 1) + result := tool.ExecuteAsync(context.Background(), map[string]any{ + "task": "Write a haiku about coding", + "delivery_mode": string(AsyncDeliveryUserAndParent), + }, func(_ context.Context, res *ToolResult) { + done <- res + }) + + if result == nil || !result.Async { + t.Fatal("expected async acknowledgment result") + } + + select { + case cbResult := <-done: + if cbResult == nil { + t.Fatal("expected callback result") + } + if cbResult.AsyncDelivery != AsyncDeliveryUserAndParent { + t.Fatalf("AsyncDelivery = %q, want %q", cbResult.AsyncDelivery, AsyncDeliveryUserAndParent) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for spawn callback result") + } +} + +func TestSpawnTool_Execute_InvalidDeliveryMode(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test") + tool := NewSpawnTool(manager) + + tests := []map[string]any{ + {"task": "test", "delivery_mode": 123}, + {"task": "test", "delivery_mode": "wrong"}, + } + + for _, args := range tests { + result := tool.Execute(context.Background(), args) + if result == nil { + t.Fatal("expected result") + } + if !result.IsError { + t.Fatalf("expected error for args=%v", args) + } + if !strings.Contains(result.ForLLM, "delivery_mode") { + t.Fatalf("expected delivery_mode error, got: %s", result.ForLLM) + } + } +} diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index feeabe536..e921a477a 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -2,6 +2,7 @@ package tools import ( "context" + "errors" "fmt" "sync" "sync/atomic" @@ -273,14 +274,14 @@ After completing the task, provide a clear summary of what was done.` if err != nil { task.Status = "failed" task.Result = fmt.Sprintf("Error: %v", err) - // Check if it was canceled - if ctx.Err() != nil { + // Only report cancellation when cancellation is the actual cause. + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { task.Status = "canceled" task.Result = "Task canceled during execution" } result = &ToolResult{ ForLLM: task.Result, - ForUser: "", + ForUser: task.Result, Silent: false, IsError: true, Async: false,