fix(orch): improve spawn/subagent tool calling adoption for less capable models
Replace pseudo-syntax examples with JSON Tool:/Arguments: format in system prompt and orchestration reminders. Add parameter hints to tool summaries, improve error messages with usage examples, and strengthen delegation nudges. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
25e614abe2
commit
e92ff7d3e6
7 changed files with 195 additions and 43 deletions
|
|
@ -21,21 +21,29 @@ const orchestrationGuidance = `## Orchestration
|
||||||
|
|
||||||
You are the conductor, not the performer. **Your primary job is to delegate, not to implement.**
|
You are the conductor, not the performer. **Your primary job is to delegate, not to implement.**
|
||||||
|
|
||||||
### spawn (non-blocking) — default choice
|
### spawn (non-blocking) — DEFAULT choice
|
||||||
- Tasks that can run in parallel or in the background
|
Returns immediately. Use for any task that can run independently.
|
||||||
- Multiple independent tasks — spawn each one simultaneously
|
Call the spawn tool with JSON arguments like this:
|
||||||
- Long-running operations (implementation, test suites, analysis)
|
|
||||||
- Any work that involves more than 2-3 tool calls
|
|
||||||
|
|
||||||
### subagent (blocking) — when you need the answer now
|
Tool: spawn
|
||||||
- You need the result before deciding the next step
|
Arguments: {"task": "Examine pkg/auth/ and report middleware pattern", "preset": "scout", "label": "auth-scout"}
|
||||||
- Correctness of your next action depends on the outcome
|
|
||||||
|
|
||||||
### Inline — only for trivial operations
|
Tool: spawn
|
||||||
- A single fast tool call (read one file, quick search)
|
Arguments: {"task": "Implement rate limiter in pkg/ratelimit/ with tests", "preset": "coder", "label": "rate-limiter"}
|
||||||
- Overhead of delegation clearly outweighs the benefit
|
|
||||||
|
|
||||||
### Presets
|
### subagent (blocking) — only when you need the answer NOW
|
||||||
|
Blocks until the subagent finishes. Use only when you cannot proceed without the result.
|
||||||
|
Does not take a preset — it runs with default tools.
|
||||||
|
|
||||||
|
Tool: subagent
|
||||||
|
Arguments: {"task": "Read pkg/config/config.go and list all SubagentsConfig fields", "label": "config-check"}
|
||||||
|
|
||||||
|
### When to use which
|
||||||
|
- spawn: parallel tasks, independent work, implementation, long analysis, >2 tool calls
|
||||||
|
- subagent: you need the result before your next decision
|
||||||
|
- inline: single quick tool call where delegation overhead is wasteful
|
||||||
|
|
||||||
|
### Presets (for spawn only)
|
||||||
| preset | role | can write | can exec |
|
| preset | role | can write | can exec |
|
||||||
|--------|------|-----------|----------|
|
|--------|------|-----------|----------|
|
||||||
| scout | explore, investigate | no | no |
|
| scout | explore, investigate | no | no |
|
||||||
|
|
@ -44,20 +52,14 @@ You are the conductor, not the performer. **Your primary job is to delegate, not
|
||||||
| worker | build + install | yes (sandbox) | build/package mgr |
|
| worker | build + install | yes (sandbox) | build/package mgr |
|
||||||
| coordinator | orchestrate others | yes (sandbox) | general + spawn |
|
| coordinator | orchestrate others | yes (sandbox) | general + spawn |
|
||||||
|
|
||||||
### Examples
|
### Parallel spawning
|
||||||
|
Spawn multiple independent tasks at once — do NOT wait between them:
|
||||||
|
|
||||||
Investigate code structure:
|
Tool: spawn
|
||||||
spawn(task: "Examine pkg/auth/ and report middleware pattern and entry points", preset: "scout", label: "auth-scout")
|
Arguments: {"task": "Analyze error handling patterns in pkg/providers/", "preset": "analyst", "label": "error-patterns"}
|
||||||
|
|
||||||
Implement a feature:
|
Tool: spawn
|
||||||
spawn(task: "Implement rate limiter in pkg/ratelimit/ with tests. Run go test to verify.", preset: "coder", label: "rate-limiter")
|
Arguments: {"task": "List all HTTP endpoints in pkg/miniapp/", "preset": "scout", "label": "endpoints"}
|
||||||
|
|
||||||
Get a blocking answer:
|
|
||||||
subagent(task: "Read pkg/config/config.go and list all SubagentsConfig fields", label: "config-check")
|
|
||||||
|
|
||||||
Parallel exploration:
|
|
||||||
spawn(task: "Analyze error handling patterns in pkg/providers/", preset: "analyst", label: "error-patterns")
|
|
||||||
spawn(task: "List all HTTP endpoints in pkg/miniapp/", preset: "scout", label: "endpoints")
|
|
||||||
|
|
||||||
After spawning, record the assignment in ## Orchestration > Delegated in MEMORY.md.
|
After spawning, record the assignment in ## Orchestration > Delegated in MEMORY.md.
|
||||||
When results come back, synthesize findings and decide the next fork.`
|
When results come back, synthesize findings and decide the next fork.`
|
||||||
|
|
|
||||||
|
|
@ -1270,9 +1270,15 @@ func buildOrchReminder(iteration int) (providers.Message, bool) {
|
||||||
if iteration != 1 && iteration%3 != 0 {
|
if iteration != 1 && iteration%3 != 0 {
|
||||||
return providers.Message{}, false
|
return providers.Message{}, false
|
||||||
}
|
}
|
||||||
content := "[System] ORCHESTRATION mode active. Delegate plan steps to subagents using spawn (async) or subagent (blocking). " +
|
content := `[System] ORCHESTRATION mode active. You MUST delegate plan steps to subagents.
|
||||||
"Do NOT implement steps inline unless they are trivial single-tool-call tasks. " +
|
Use spawn (non-blocking, returns immediately) or subagent (blocking, waits for result).
|
||||||
"Spawn multiple independent steps in parallel for maximum throughput."
|
Do NOT implement steps inline unless they are a single trivial tool call.
|
||||||
|
|
||||||
|
To delegate, call the tool with JSON arguments:
|
||||||
|
Tool: spawn Arguments: {"task": "...", "preset": "scout", "label": "..."}
|
||||||
|
Tool: subagent Arguments: {"task": "...", "label": "..."}
|
||||||
|
|
||||||
|
Spawn multiple independent steps in parallel for maximum throughput.`
|
||||||
return providers.Message{Role: "user", Content: content}, true
|
return providers.Message{Role: "user", Content: content}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -227,8 +227,45 @@ func (r *ToolRegistry) GetRuntimeStatus() string {
|
||||||
return strings.Join(parts, "\n\n")
|
return strings.Join(parts, "\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildParamHint extracts parameter names from a JSON schema and returns
|
||||||
|
// a hint string like "(task, label?, preset?)". Required params are bare,
|
||||||
|
// optional params have a trailing "?".
|
||||||
|
func buildParamHint(schema map[string]any) string {
|
||||||
|
props, _ := schema["properties"].(map[string]any)
|
||||||
|
if len(props) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
reqSlice, _ := schema["required"].([]string)
|
||||||
|
reqSet := make(map[string]bool, len(reqSlice))
|
||||||
|
for _, r := range reqSlice {
|
||||||
|
reqSet[r] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make([]string, 0, len(props))
|
||||||
|
for name := range props {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
parts := make([]string, 0, len(names))
|
||||||
|
// Required params first, then optional
|
||||||
|
for _, name := range names {
|
||||||
|
if reqSet[name] {
|
||||||
|
parts = append(parts, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range names {
|
||||||
|
if !reqSet[name] {
|
||||||
|
parts = append(parts, name+"?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "(" + strings.Join(parts, ", ") + ")"
|
||||||
|
}
|
||||||
|
|
||||||
// GetSummaries returns human-readable summaries of all registered tools.
|
// GetSummaries returns human-readable summaries of all registered tools.
|
||||||
// Returns a slice of "name - description" strings.
|
// Returns a slice of "- `name`(params) - description" strings.
|
||||||
func (r *ToolRegistry) GetSummaries() []string {
|
func (r *ToolRegistry) GetSummaries() []string {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
@ -237,7 +274,8 @@ func (r *ToolRegistry) GetSummaries() []string {
|
||||||
summaries := make([]string, 0, len(sorted))
|
summaries := make([]string, 0, len(sorted))
|
||||||
for _, name := range sorted {
|
for _, name := range sorted {
|
||||||
tool := r.tools[name]
|
tool := r.tools[name]
|
||||||
summaries = append(summaries, fmt.Sprintf("- `%s` - %s", tool.Name(), tool.Description()))
|
hint := buildParamHint(tool.Parameters())
|
||||||
|
summaries = append(summaries, fmt.Sprintf("- `%s`%s - %s", tool.Name(), hint, tool.Description()))
|
||||||
}
|
}
|
||||||
return summaries
|
return summaries
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -337,6 +337,78 @@ func TestToolRegistry_Count(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildParamHint(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
schema map[string]any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "required and optional",
|
||||||
|
schema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"task": map[string]any{"type": "string"},
|
||||||
|
"label": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
"required": []string{"task"},
|
||||||
|
},
|
||||||
|
want: "(task, label?)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all required",
|
||||||
|
schema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"command": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
"required": []string{"command"},
|
||||||
|
},
|
||||||
|
want: "(command)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no properties",
|
||||||
|
schema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty schema",
|
||||||
|
schema: map[string]any{},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil schema",
|
||||||
|
schema: nil,
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple optional sorted",
|
||||||
|
schema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"task": map[string]any{"type": "string"},
|
||||||
|
"preset": map[string]any{"type": "string"},
|
||||||
|
"label": map[string]any{"type": "string"},
|
||||||
|
"agent_id": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
"required": []string{"task"},
|
||||||
|
},
|
||||||
|
want: "(task, agent_id?, label?, preset?)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := buildParamHint(tt.schema)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("buildParamHint() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestToolRegistry_GetSummaries(t *testing.T) {
|
func TestToolRegistry_GetSummaries(t *testing.T) {
|
||||||
r := NewToolRegistry()
|
r := NewToolRegistry()
|
||||||
r.Register(newMockTool("read_file", "Reads a file"))
|
r.Register(newMockTool("read_file", "Reads a file"))
|
||||||
|
|
@ -353,6 +425,32 @@ func TestToolRegistry_GetSummaries(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestToolRegistry_GetSummaries_WithParamHint(t *testing.T) {
|
||||||
|
r := NewToolRegistry()
|
||||||
|
r.Register(&mockRegistryTool{
|
||||||
|
name: "spawn",
|
||||||
|
desc: "Spawn a subagent",
|
||||||
|
params: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"task": map[string]any{"type": "string"},
|
||||||
|
"preset": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
"required": []string{"task"},
|
||||||
|
},
|
||||||
|
result: SilentResult("ok"),
|
||||||
|
})
|
||||||
|
|
||||||
|
summaries := r.GetSummaries()
|
||||||
|
if len(summaries) != 1 {
|
||||||
|
t.Fatalf("expected 1 summary, got %d", len(summaries))
|
||||||
|
}
|
||||||
|
// Should contain param hint
|
||||||
|
if !strings.Contains(summaries[0], "(task, preset?)") {
|
||||||
|
t.Errorf("expected param hint in summary, got %q", summaries[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestToolToSchema(t *testing.T) {
|
func TestToolToSchema(t *testing.T) {
|
||||||
tool := newMockTool("demo", "demo tool")
|
tool := newMockTool("demo", "demo tool")
|
||||||
schema := ToolToSchema(tool)
|
schema := ToolToSchema(tool)
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,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 that runs NON-BLOCKING in the background and returns immediately. Prefer this over subagent for any task that can run independently. Use preset to control capabilities (scout, analyst, coder, worker, coordinator)."
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *SpawnTool) Parameters() map[string]any {
|
func (t *SpawnTool) Parameters() map[string]any {
|
||||||
|
|
@ -73,7 +73,7 @@ func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) {
|
||||||
func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
task, ok := args["task"].(string)
|
task, ok := args["task"].(string)
|
||||||
if !ok || strings.TrimSpace(task) == "" {
|
if !ok || strings.TrimSpace(task) == "" {
|
||||||
return ErrorResult("task is required and must be a non-empty string")
|
return ErrorResult(`Required parameter "task" (string) is missing. Example: {"task": "describe what you need done", "preset": "scout"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
label, _ := args["label"].(string)
|
label, _ := args["label"].(string)
|
||||||
|
|
@ -87,12 +87,12 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResul
|
||||||
}
|
}
|
||||||
if checkTarget != "" && t.allowlistCheck != nil {
|
if checkTarget != "" && t.allowlistCheck != nil {
|
||||||
if !t.allowlistCheck(checkTarget) {
|
if !t.allowlistCheck(checkTarget) {
|
||||||
return ErrorResult(fmt.Sprintf("not allowed to spawn agent '%s' or preset '%s'", agentID, preset))
|
return ErrorResult(fmt.Sprintf("preset %q is not allowed. Available presets: scout, analyst, coder, worker, coordinator", preset))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.manager == nil {
|
if t.manager == nil {
|
||||||
return ErrorResult("Subagent manager not configured")
|
return ErrorResult("spawn tool is not available in this session (orchestration may be disabled)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass callback to manager for async completion notification
|
// Pass callback to manager for async completion notification
|
||||||
|
|
|
||||||
|
|
@ -384,7 +384,7 @@ func (t *SubagentTool) Name() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *SubagentTool) Description() string {
|
func (t *SubagentTool) Description() string {
|
||||||
return "Execute a subagent task synchronously and return the result. Use this for delegating specific tasks to an independent agent instance. Returns execution summary to user and full details to LLM."
|
return "Run a task in a subagent and BLOCK until it completes, returning the result directly. Use when you need the answer before deciding your next step. For background/parallel tasks, use spawn instead."
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *SubagentTool) Parameters() map[string]any {
|
func (t *SubagentTool) Parameters() map[string]any {
|
||||||
|
|
@ -412,13 +412,15 @@ func (t *SubagentTool) SetContext(channel, chatID string) {
|
||||||
func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
task, ok := args["task"].(string)
|
task, ok := args["task"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ErrorResult("task is required").WithError(fmt.Errorf("task parameter is required"))
|
return ErrorResult(`Required parameter "task" (string) is missing. Example: {"task": "describe what you need done"}`).
|
||||||
|
WithError(fmt.Errorf("task parameter is required"))
|
||||||
}
|
}
|
||||||
|
|
||||||
label, _ := args["label"].(string)
|
label, _ := args["label"].(string)
|
||||||
|
|
||||||
if t.manager == nil {
|
if t.manager == nil {
|
||||||
return ErrorResult("Subagent manager not configured").WithError(fmt.Errorf("manager is nil"))
|
return ErrorResult("subagent tool is not available in this session (orchestration may be disabled)").
|
||||||
|
WithError(fmt.Errorf("manager is nil"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build messages for subagent
|
// Build messages for subagent
|
||||||
|
|
|
||||||
|
|
@ -93,8 +93,11 @@ func TestSubagentTool_Description(t *testing.T) {
|
||||||
if desc == "" {
|
if desc == "" {
|
||||||
t.Error("Description should not be empty")
|
t.Error("Description should not be empty")
|
||||||
}
|
}
|
||||||
if !strings.Contains(desc, "subagent") {
|
if !strings.Contains(desc, "BLOCK") {
|
||||||
t.Errorf("Description should mention 'subagent', got: %s", desc)
|
t.Errorf("Description should mention 'BLOCK', got: %s", desc)
|
||||||
|
}
|
||||||
|
if !strings.Contains(desc, "spawn") {
|
||||||
|
t.Errorf("Description should contrast with spawn, got: %s", desc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -259,9 +262,12 @@ func TestSubagentTool_Execute_MissingTask(t *testing.T) {
|
||||||
t.Error("Expected error for missing task parameter")
|
t.Error("Expected error for missing task parameter")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ForLLM should contain error message
|
// ForLLM should contain helpful error with example
|
||||||
if !strings.Contains(result.ForLLM, "task is required") {
|
if !strings.Contains(result.ForLLM, `"task"`) {
|
||||||
t.Errorf("Error message should mention 'task is required', got: %s", result.ForLLM)
|
t.Errorf("Error message should mention '\"task\"', got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "Example") {
|
||||||
|
t.Errorf("Error message should include usage example, got: %s", result.ForLLM)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Err should be set
|
// Err should be set
|
||||||
|
|
@ -286,8 +292,8 @@ func TestSubagentTool_Execute_NilManager(t *testing.T) {
|
||||||
t.Error("Expected error for nil manager")
|
t.Error("Expected error for nil manager")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(result.ForLLM, "Subagent manager not configured") {
|
if !strings.Contains(result.ForLLM, "not available in this session") {
|
||||||
t.Errorf("Error message should mention manager not configured, got: %s", result.ForLLM)
|
t.Errorf("Error message should mention 'not available in this session', got: %s", result.ForLLM)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue