feat(spawn): add async delivery mode control
This commit is contained in:
parent
d37c44db90
commit
b2dc430a5e
3 changed files with 93 additions and 5 deletions
|
|
@ -61,7 +61,7 @@ func (t *SpawnTool) Name() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *SpawnTool) Description() 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 {
|
func (t *SpawnTool) Parameters() map[string]any {
|
||||||
|
|
@ -80,6 +80,15 @@ func (t *SpawnTool) Parameters() map[string]any {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional target agent ID to delegate the task to",
|
"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"},
|
"required": []string{"task"},
|
||||||
}
|
}
|
||||||
|
|
@ -116,6 +125,10 @@ func (t *SpawnTool) execute(
|
||||||
label, _ := args["label"].(string)
|
label, _ := args["label"].(string)
|
||||||
agentID, _ := args["agent_id"].(string)
|
agentID, _ := args["agent_id"].(string)
|
||||||
targetAgentID := strings.TrimSpace(agentID)
|
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
|
// Check allowlist if targeting a specific agent
|
||||||
if targetAgentID != "" && t.allowlistCheck != nil {
|
if targetAgentID != "" && t.allowlistCheck != nil {
|
||||||
|
|
@ -131,7 +144,7 @@ func (t *SpawnTool) execute(
|
||||||
if cb != nil {
|
if cb != nil {
|
||||||
wrappedCallback = func(cbCtx context.Context, res *ToolResult) {
|
wrappedCallback = func(cbCtx context.Context, res *ToolResult) {
|
||||||
if res != nil {
|
if res != nil {
|
||||||
res.WithAsyncDelivery(AsyncDeliveryUserOnly)
|
res.WithAsyncDelivery(deliveryMode)
|
||||||
}
|
}
|
||||||
cb(cbCtx, res)
|
cb(cbCtx, res)
|
||||||
}
|
}
|
||||||
|
|
@ -155,6 +168,24 @@ func (t *SpawnTool) execute(
|
||||||
return ErrorResult("Subagent manager not configured")
|
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 {
|
func buildSpawnSystemPrompt(task, label string) string {
|
||||||
if label != "" {
|
if label != "" {
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
|
|
|
||||||
|
|
@ -195,3 +195,59 @@ func TestSpawnTool_ExecuteAsync_MarksCallbackResultUserOnly(t *testing.T) {
|
||||||
t.Fatal("timed out waiting for spawn callback result")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package tools
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
@ -273,14 +274,14 @@ After completing the task, provide a clear summary of what was done.`
|
||||||
if err != nil {
|
if err != nil {
|
||||||
task.Status = "failed"
|
task.Status = "failed"
|
||||||
task.Result = fmt.Sprintf("Error: %v", err)
|
task.Result = fmt.Sprintf("Error: %v", err)
|
||||||
// Check if it was canceled
|
// Only report cancellation when cancellation is the actual cause.
|
||||||
if ctx.Err() != nil {
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
task.Status = "canceled"
|
task.Status = "canceled"
|
||||||
task.Result = "Task canceled during execution"
|
task.Result = "Task canceled during execution"
|
||||||
}
|
}
|
||||||
result = &ToolResult{
|
result = &ToolResult{
|
||||||
ForLLM: task.Result,
|
ForLLM: task.Result,
|
||||||
ForUser: "",
|
ForUser: task.Result,
|
||||||
Silent: false,
|
Silent: false,
|
||||||
IsError: true,
|
IsError: true,
|
||||||
Async: false,
|
Async: false,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue