Skip to content

Commit 1388ac4

Browse files
halfaipgclaude
andcommitted
feat: add dispatch_agent (subagent) tool for isolated research
Read-only research subagent that runs in its own context window. The parent agent can spawn subagents to investigate questions without polluting its main conversation history. - Read-only tools only (read_file, list_files, search_files, shell) - Max 15 turns, own compaction at 80% threshold - Max depth 1 (no sub-sub-agents) - Returns text summary to parent - Runs concurrently (parallel-safe) CLI now has full feature parity with the web app's agent: 8 tools, parallel execution, compaction, subagents, project awareness, session persistence. 38 tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2e7949c commit 1388ac4

4 files changed

Lines changed: 224 additions & 5 deletions

File tree

agent.go

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ Available tools:
6060
- multi_edit: Batch multiple edits across files. Per-file atomicity with rollback.
6161
- list_files: List directory contents or glob for files (e.g. "**/*.go").
6262
- search_files: Regex search across files (powered by ripgrep). Find definitions, usages, etc.
63+
- dispatch_agent: Spawn a read-only research subagent to investigate questions in isolated context.
6364
- shell: Run any shell command. Use for builds, tests, git, package management.
6465
6566
Guidelines:
@@ -349,7 +350,24 @@ func (a *Agent) executeToolCalls(toolCalls []ToolCall, consecutiveErrors *int) {
349350
wg.Add(1)
350351
go func(idx int, tc ToolCall, args map[string]any) {
351352
defer wg.Done()
352-
output, success := ExecuteTool(tc.Function.Name, tc.Function.Arguments, a.workDir)
353+
var output string
354+
var success bool
355+
if tc.Function.Name == "dispatch_agent" {
356+
task := ""
357+
if args != nil {
358+
task, _ = args["task"].(string)
359+
}
360+
res, err := RunSubagent(a.client, a.workDir, task)
361+
if err != nil {
362+
output = fmt.Sprintf("Subagent error: %v", err)
363+
success = false
364+
} else {
365+
output = res
366+
success = true
367+
}
368+
} else {
369+
output, success = ExecuteTool(tc.Function.Name, tc.Function.Arguments, a.workDir)
370+
}
353371
results[idx] = result{tc: tc, args: args, output: output, success: success}
354372
}(i, tc, argsMap)
355373
}
@@ -389,7 +407,24 @@ func (a *Agent) executeToolCalls(toolCalls []ToolCall, consecutiveErrors *int) {
389407
Args: argsMap,
390408
}
391409

392-
output, success := ExecuteTool(tc.Function.Name, tc.Function.Arguments, a.workDir)
410+
var output string
411+
var success bool
412+
if tc.Function.Name == "dispatch_agent" {
413+
task := ""
414+
if argsMap != nil {
415+
task, _ = argsMap["task"].(string)
416+
}
417+
res, err := RunSubagent(a.client, a.workDir, task)
418+
if err != nil {
419+
output = fmt.Sprintf("Subagent error: %v", err)
420+
success = false
421+
} else {
422+
output = res
423+
success = true
424+
}
425+
} else {
426+
output, success = ExecuteTool(tc.Function.Name, tc.Function.Arguments, a.workDir)
427+
}
393428

394429
if success {
395430
allErrors = false

render.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ func renderToolBlock(toolName string, args map[string]any, output string, state
6767
body = renderListResult(args, output, state, innerW)
6868
case "search_files":
6969
body = renderSearchResult(args, output, state, innerW)
70+
case "dispatch_agent":
71+
body = renderSubagentResult(args, output, state, innerW)
7072
case "shell":
7173
body = renderShellResult(args, output, state, innerW)
7274
default:
@@ -188,6 +190,30 @@ func renderSearchResult(args map[string]any, output string, state string, width
188190
return styleMuted.Render(" " + output)
189191
}
190192

193+
// ── Subagent result ──────────────────────────────────────────
194+
195+
func renderSubagentResult(args map[string]any, output string, state string, width int) string {
196+
if state == "pending" {
197+
task := ""
198+
if args != nil {
199+
task, _ = args["task"].(string)
200+
}
201+
if len(task) > 60 {
202+
task = task[:57] + "..."
203+
}
204+
if task != "" {
205+
return styleMuted.Render(" " + task)
206+
}
207+
return ""
208+
}
209+
lines := strings.Split(output, "\n")
210+
count := len(lines)
211+
if count > 3 {
212+
return styleMuted.Render(fmt.Sprintf(" %d lines of research findings", count))
213+
}
214+
return truncateLines(output, 3, width)
215+
}
216+
191217
// ── Shell result ─────────────────────────────────────────────
192218

193219
func renderShellResult(args map[string]any, output string, state string, width int) string {

subagent.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
// ──────────────────────────────────────────────────────────────
9+
// Subagent — read-only research agent in isolated context
10+
//
11+
// Modeled after the web app's dispatch_agent tool. Spawns a
12+
// separate agent loop with its own conversation history and
13+
// read-only tools. Returns only the final text summary.
14+
// Max depth 1 (subagents cannot spawn sub-subagents).
15+
// ──────────────────────────────────────────────────────────────
16+
17+
const subagentMaxTurns = 15
18+
19+
const subagentSystemPrompt = `You are a focused research assistant. You help gather information by reading files, searching code, and listing directories.
20+
21+
You have read-only access to the project. You CANNOT modify any files.
22+
23+
Available tools: read_file, list_files, search_files, shell
24+
25+
Guidelines:
26+
- Be thorough but efficient — gather what's needed, then summarize
27+
- Use search_files to find relevant code quickly
28+
- Use list_files to explore project structure
29+
- Use shell for read-only commands only (ls, cat, grep, git log, etc.)
30+
- When done, provide a clear, concise summary of your findings`
31+
32+
// subagentToolDefs contains only read-only tools.
33+
var subagentToolDefs []ToolDef
34+
35+
func init() {
36+
for _, td := range toolDefs {
37+
switch td.Function.Name {
38+
case "read_file", "list_files", "search_files", "shell":
39+
subagentToolDefs = append(subagentToolDefs, td)
40+
}
41+
}
42+
}
43+
44+
// RunSubagent executes a read-only research subagent and returns its final text.
45+
func RunSubagent(client *LLMClient, workDir, task string) (string, error) {
46+
sysContent := subagentSystemPrompt + fmt.Sprintf("\n\nWorking directory: %s", workDir)
47+
48+
history := []ChatMessage{
49+
{Role: "system", Content: strPtr(sysContent)},
50+
{Role: "user", Content: strPtr(task)},
51+
}
52+
53+
var finalText strings.Builder
54+
55+
for turn := 1; turn <= subagentMaxTurns; turn++ {
56+
// Check compaction
57+
if needsCompaction(history, client.Model) {
58+
compacted, ok := compactHistory(client, history)
59+
if ok {
60+
history = compacted
61+
}
62+
}
63+
64+
// Stream LLM call
65+
streamCh := make(chan StreamEvent, 64)
66+
go client.StreamChat(history, subagentToolDefs, streamCh)
67+
68+
var textContent strings.Builder
69+
var toolCalls []ToolCall
70+
71+
for evt := range streamCh {
72+
switch evt.Type {
73+
case StreamText:
74+
textContent.WriteString(evt.Text)
75+
case StreamToolCalls:
76+
toolCalls = evt.ToolCalls
77+
case StreamError:
78+
return "", fmt.Errorf("subagent LLM error: %v", evt.Error)
79+
case StreamDone:
80+
// handled below
81+
}
82+
}
83+
84+
// Build assistant message
85+
assistantMsg := ChatMessage{Role: "assistant"}
86+
txt := textContent.String()
87+
if txt != "" {
88+
assistantMsg.Content = strPtr(txt)
89+
}
90+
if len(toolCalls) > 0 {
91+
assistantMsg.ToolCalls = toolCalls
92+
}
93+
history = append(history, assistantMsg)
94+
95+
// If no tool calls, we're done
96+
if len(toolCalls) == 0 {
97+
finalText.WriteString(txt)
98+
break
99+
}
100+
101+
// Execute read-only tools (all parallel-safe)
102+
for _, tc := range toolCalls {
103+
// Safety: only allow read-only tools
104+
switch tc.Function.Name {
105+
case "read_file", "list_files", "search_files", "shell":
106+
// shell is allowed but subagent should only use read-only commands
107+
default:
108+
history = append(history, ChatMessage{
109+
Role: "tool",
110+
ToolCallID: tc.ID,
111+
Name: tc.Function.Name,
112+
Content: strPtr(fmt.Sprintf("Error: tool %q is not available in read-only mode", tc.Function.Name)),
113+
})
114+
continue
115+
}
116+
117+
output, _ := ExecuteTool(tc.Function.Name, tc.Function.Arguments, workDir)
118+
history = append(history, ChatMessage{
119+
Role: "tool",
120+
ToolCallID: tc.ID,
121+
Name: tc.Function.Name,
122+
Content: strPtr(output),
123+
})
124+
}
125+
}
126+
127+
result := finalText.String()
128+
if result == "" {
129+
result = "Subagent completed without producing a summary."
130+
}
131+
132+
return result, nil
133+
}

tools.go

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,27 @@ var toolDefs = []ToolDef{
192192
},
193193
},
194194
},
195+
{
196+
Type: "function",
197+
Function: ToolDefFunction{
198+
Name: "dispatch_agent",
199+
Description: "Spawn a focused research subagent to investigate a specific question or gather information. " +
200+
"The subagent runs in its own isolated context with read-only tools (read_file, list_files, search_files, shell). " +
201+
"It cannot modify files. Use this to explore large codebases, find patterns across many files, " +
202+
"or answer complex questions without polluting your main context window. " +
203+
"The subagent returns a text summary of its findings.",
204+
Parameters: map[string]interface{}{
205+
"type": "object",
206+
"properties": map[string]interface{}{
207+
"task": map[string]interface{}{
208+
"type": "string",
209+
"description": "A clear description of what to research or investigate. Be specific about what information you need.",
210+
},
211+
},
212+
"required": []string{"task"},
213+
},
214+
},
215+
},
195216
{
196217
Type: "function",
197218
Function: ToolDefFunction{
@@ -216,9 +237,10 @@ var toolDefs = []ToolDef{
216237

217238
// parallelSafeTools lists tools that are safe to run concurrently (read-only, no side effects).
218239
var parallelSafeTools = map[string]bool{
219-
"read_file": true,
220-
"list_files": true,
221-
"search_files": true,
240+
"read_file": true,
241+
"list_files": true,
242+
"search_files": true,
243+
"dispatch_agent": true,
222244
}
223245

224246
// IsParallelSafe returns whether a tool can run concurrently with other tools.
@@ -273,6 +295,9 @@ func ExecuteTool(name string, argsJSON string, workDir string) (string, bool) {
273295
return toolListFiles(args, workDir)
274296
case "search_files":
275297
return toolSearchFiles(args, workDir)
298+
case "dispatch_agent":
299+
// dispatch_agent is handled directly in the agent loop (needs LLM client)
300+
return "Error: dispatch_agent must be called through the agent loop", false
276301
case "shell":
277302
return toolShell(args, workDir)
278303
default:

0 commit comments

Comments
 (0)