feat(spawn): add async delivery mode control

This commit is contained in:
Anton Bogdanovich 2026-05-11 12:35:02 -07:00
parent d37c44db90
commit b2dc430a5e
3 changed files with 93 additions and 5 deletions

View file

@ -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(

View file

@ -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)
}
}
}

View file

@ -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,