learn-claude-code/s06_subagent at main · shareAI-lab/learn-claude-code
我们不希望上下文中有太多探索的记录,这会成为噪音
因此对于Agent,可以生成subagent,专门去在一个任务上充分探索
最后返回结果给主agent即可
- 突破上下文长度
- 保持上下文整洁

实际上会把subagent的创建也作为一个工具提供
为了控制不进行递归,subagent的工具列表没有这个工具
一般还会去除
task类的工具例如:
todo write
1#!/usr/bin/env python3
2"""
3s06: Subagent — spawn sub-agents with fresh messages[] for context isolation.
4
5 Parent Agent Subagent
6 +------------------+ +------------------+
7 | messages=[...] | | messages=[task] | <-- fresh
8 | | dispatch | |
9 | tool: task | ---------------> | own while loop |
10 | prompt="..." | | bash/read/... |
11 | | summary only | (max 30 turns) |
12 | result = "..." | <--------------- | return last text |
13 +------------------+ +------------------+
14 ^ |
15 | intermediate results DISCARDED |
16 +--------------------------------------+
17
18 Subagent tools: bash, read, write, edit, glob (NO task — no recursion)
19
20Changes from s05:
21 + task tool + spawn_subagent() with fresh messages[]
22 + Safety limit: max 30 turns per subagent
23 + extract_text() helper
24 Subagent cannot spawn sub-subagents (no task tool in sub_tools).
25 Main loop unchanged: task auto-dispatches via TOOL_HANDLERS.
26
27Run: python s06_subagent/code.py
28Needs: pip install anthropic python-dotenv + ANTHROPIC_API_KEY in .env
29"""
30
31import os, subprocess
32from pathlib import Path
33
34try:
35 import readline
36 readline.parse_and_bind('set bind-tty-special-chars off')
37except ImportError:
38 pass
39
40from anthropic import Anthropic
41from dotenv import load_dotenv
42
43load_dotenv(override=True)
44if os.getenv("ANTHROPIC_BASE_URL"):
45 os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
46
47WORKDIR = Path.cwd()
48client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
49MODEL = os.environ["MODEL_ID"]
50CURRENT_TODOS: list[dict] = []
51
52SYSTEM = (
53 f"You are a coding agent at {WORKDIR}. "
54 "For complex sub-problems, use the task tool to spawn a subagent."
55)
56
57# s06: subagent gets its own system prompt — no task, no recursion
58SUB_SYSTEM = (
59 f"You are a coding agent at {WORKDIR}. "
60 "Complete the task you were given, then return a concise summary. "
61 "Do not delegate further."
62)
63
64
65# ═══════════════════════════════════════════════════════════
66# FROM s02-s05 (unchanged): Tool Implementations
67# ═══════════════════════════════════════════════════════════
68
69def safe_path(p: str) -> Path:
70 path = (WORKDIR / p).resolve()
71 if not path.is_relative_to(WORKDIR):
72 raise ValueError(f"Path escapes workspace: {p}")
73 return path
74
75def run_bash(command: str) -> str:
76 try:
77 r = subprocess.run(command, shell=True, cwd=WORKDIR,
78 capture_output=True, text=True, timeout=120)
79 out = (r.stdout + r.stderr).strip()
80 return out[:50000] if out else "(no output)"
81 except subprocess.TimeoutExpired:
82 return "Error: Timeout (120s)"
83
84def run_read(path: str, limit: int | None = None) -> str:
85 try:
86 lines = safe_path(path).read_text().splitlines()
87 if limit and limit < len(lines):
88 lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
89 return "\n".join(lines)
90 except Exception as e:
91 return f"Error: {e}"
92
93def run_write(path: str, content: str) -> str:
94 try:
95 file_path = safe_path(path)
96 file_path.parent.mkdir(parents=True, exist_ok=True)
97 file_path.write_text(content)
98 return f"Wrote {len(content)} bytes to {path}"
99 except Exception as e:
100 return f"Error: {e}"
101
102def run_edit(path: str, old_text: str, new_text: str) -> str:
103 try:
104 file_path = safe_path(path)
105 text = file_path.read_text()
106 if old_text not in text:
107 return f"Error: text not found in {path}"
108 file_path.write_text(text.replace(old_text, new_text, 1))
109 return f"Edited {path}"
110 except Exception as e:
111 return f"Error: {e}"
112
113def run_glob(pattern: str) -> str:
114 import glob as g
115 try:
116 results = []
117 for match in g.glob(pattern, root_dir=WORKDIR):
118 if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
119 results.append(match)
120 return "\n".join(results) if results else "(no matches)"
121 except Exception as e:
122 return f"Error: {e}"
123
124def run_todo_write(todos: list) -> str:
125 global CURRENT_TODOS
126 for i, t in enumerate(todos):
127 if "content" not in t or "status" not in t:
128 return f"Error: todos[{i}] missing 'content' or 'status'"
129 if t["status"] not in ("pending", "in_progress", "completed"):
130 return f"Error: todos[{i}] has invalid status '{t['status']}'"
131 CURRENT_TODOS = todos
132 lines = ["\n\033[33m## Current Tasks\033[0m"]
133 for t in CURRENT_TODOS:
134 icon = {"pending": " ", "in_progress": "\033[36m▸\033[0m", "completed": "\033[32m✓\033[0m"}[t["status"]]
135 lines.append(f" [{icon}] {t['content']}")
136 print("\n".join(lines))
137 return f"Updated {len(CURRENT_TODOS)} tasks"
138
139TOOLS = [
140 {"name": "bash", "description": "Run a shell command.",
141 "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
142 {"name": "read_file", "description": "Read file contents.",
143 "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
144 {"name": "write_file", "description": "Write content to a file.",
145 "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
146 {"name": "edit_file", "description": "Replace exact text in a file once.",
147 "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
148 {"name": "glob", "description": "Find files matching a glob pattern.",
149 "input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
150 {"name": "todo_write", "description": "Create and manage a task list for your current coding session.",
151 "input_schema": {"type": "object", "properties": {"todos": {"type": "array", "items": {"type": "object", "properties": {"content": {"type": "string"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}}, "required": ["content", "status"]}}}, "required": ["todos"]}},
152]
153
154TOOL_HANDLERS = {
155 "bash": run_bash, "read_file": run_read, "write_file": run_write,
156 "edit_file": run_edit, "glob": run_glob, "todo_write": run_todo_write,
157}
158
159
160# ═══════════════════════════════════════════════════════════
161# NEW in s06: Subagent — fresh messages[], summary only
162# ═══════════════════════════════════════════════════════════
163
164SUB_TOOLS = [
165 {"name": "bash", "description": "Run a shell command.",
166 "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
167 {"name": "read_file", "description": "Read file contents.",
168 "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
169 {"name": "write_file", "description": "Write content to a file.",
170 "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
171 {"name": "edit_file", "description": "Replace exact text in a file once.",
172 "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
173 {"name": "glob", "description": "Find files matching a glob pattern.",
174 "input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
175]
176# NO "task" tool — prevent recursive spawning
177
178SUB_HANDLERS = {
179 "bash": run_bash, "read_file": run_read, "write_file": run_write,
180 "edit_file": run_edit, "glob": run_glob,
181}
182
183def extract_text(content) -> str:
184 """Extract text from message content blocks."""
185 if not isinstance(content, list):
186 return str(content)
187 return "\n".join(getattr(b, "text", "") for b in content if getattr(b, "type", None) == "text")
188
189def spawn_subagent(description: str) -> str:
190 """Spawn a subagent with fresh messages[], return summary only."""
191 print(f"\n\033[35m[Subagent spawned]\033[0m")
192 messages = [{"role": "user", "content": description}] # fresh context
193
194 for _ in range(30): # safety limit
195 response = client.messages.create(
196 model=MODEL, system=SUB_SYSTEM,
197 messages=messages, tools=SUB_TOOLS, max_tokens=8000,
198 )
199 messages.append({"role": "assistant", "content": response.content})
200 if response.stop_reason != "tool_use":
201 break
202 results = []
203 for block in response.content:
204 if block.type == "tool_use":
205 # Issue 1: subagent also runs hooks (permissions apply)
206 blocked = trigger_hooks("PreToolUse", block)
207 if blocked:
208 results.append({"type": "tool_result", "tool_use_id": block.id,
209 "content": str(blocked)})
210 continue
211 handler = SUB_HANDLERS.get(block.name)
212 output = handler(**block.input) if handler else f"Unknown: {block.name}"
213 trigger_hooks("PostToolUse", block, output)
214 print(f" \033[90m[sub] {block.name}: {str(output)[:100]}\033[0m")
215 results.append({"type": "tool_result", "tool_use_id": block.id,
216 "content": output})
217 messages.append({"role": "user", "content": results})
218
219 # Issue 5: fallback if safety limit hit during tool_use
220 result = extract_text(messages[-1]["content"])
221 if not result:
222 # last message is tool_result, look backwards for assistant text
223 for msg in reversed(messages):
224 if msg["role"] == "assistant":
225 result = extract_text(msg["content"])
226 if result:
227 break
228 if not result:
229 result = "Subagent stopped after 30 turns without final answer."
230 print(f"\033[35m[Subagent done]\033[0m")
231 return result # only summary, entire message history discarded
232
233# Add task tool to parent's tools
234TOOLS.append({
235 "name": "task",
236 "description": "Launch a subagent to handle a complex subtask. Returns only the final conclusion.",
237 "input_schema": {"type": "object", "properties": {"description": {"type": "string"}}, "required": ["description"]},
238})
239TOOL_HANDLERS["task"] = spawn_subagent
240
241
242# ═══════════════════════════════════════════════════════════
243# FROM s04 (unchanged): Hook System
244# ═══════════════════════════════════════════════════════════
245
246HOOKS = {"UserPromptSubmit": [], "PreToolUse": [], "PostToolUse": [], "Stop": []}
247
248def register_hook(event: str, callback):
249 HOOKS[event].append(callback)
250
251def trigger_hooks(event: str, *args):
252 for callback in HOOKS[event]:
253 result = callback(*args)
254 if result is not None:
255 return result
256 return None
257
258DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
259
260def permission_hook(block):
261 """PreToolUse: deny list check."""
262 if block.name == "bash":
263 for p in DENY_LIST:
264 if p in block.input.get("command", ""):
265 print(f"\n\033[31m⛔ Blocked: '{p}'\033[0m")
266 return "Permission denied"
267 return None
268
269def log_hook(block):
270 """PreToolUse: log tool calls."""
271 print(f"\033[90m[HOOK] {block.name}\033[0m")
272 return None
273
274def context_inject_hook(query: str):
275 """UserPromptSubmit: log working directory."""
276 print(f"\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\033[0m")
277 return None
278
279def summary_hook(messages: list):
280 """Stop: print tool call count."""
281 tool_count = sum(1 for m in messages
282 for b in (m.get("content") if isinstance(m.get("content"), list) else [])
283 if isinstance(b, dict) and b.get("type") == "tool_result")
284 print(f"\033[90m[HOOK] Stop: session used {tool_count} tool calls\033[0m")
285 return None
286
287register_hook("UserPromptSubmit", context_inject_hook)
288register_hook("PreToolUse", permission_hook)
289register_hook("PreToolUse", log_hook)
290register_hook("Stop", summary_hook)
291
292
293# ═══════════════════════════════════════════════════════════
294# agent_loop — same as s05 + nag reminder, task auto-dispatches
295# ═══════════════════════════════════════════════════════════
296
297rounds_since_todo = 0
298
299def agent_loop(messages: list):
300 global rounds_since_todo
301 while True:
302 # s05: nag reminder
303 if rounds_since_todo >= 3 and messages:
304 messages.append({"role": "user",
305 "content": "<reminder>Update your todos.</reminder>"})
306 rounds_since_todo = 0
307
308 response = client.messages.create(
309 model=MODEL, system=SYSTEM, messages=messages,
310 tools=TOOLS, max_tokens=8000,
311 )
312 messages.append({"role": "assistant", "content": response.content})
313
314 if response.stop_reason != "tool_use":
315 force = trigger_hooks("Stop", messages)
316 if force:
317 messages.append({"role": "user", "content": force})
318 continue
319 return
320
321 rounds_since_todo += 1
322 results = []
323 for block in response.content:
324 if block.type != "tool_use":
325 continue
326
327 blocked = trigger_hooks("PreToolUse", block)
328 if blocked:
329 results.append({"type": "tool_result", "tool_use_id": block.id,
330 "content": str(blocked)})
331 continue
332
333 handler = TOOL_HANDLERS.get(block.name)
334 output = handler(**block.input) if handler else f"Unknown: {block.name}"
335
336 trigger_hooks("PostToolUse", block, output)
337
338 if block.name == "todo_write":
339 rounds_since_todo = 0
340
341 results.append({"type": "tool_result", "tool_use_id": block.id,
342 "content": output})
343
344 messages.append({"role": "user", "content": results})
345
346
347if __name__ == "__main__":
348 print("s06: Subagent — spawn sub-agents with fresh context, summary only")
349 print("Type a question, press Enter. Type q to quit.\n")
350
351 history = []
352 while True:
353 try:
354 query = input("\033[36ms06 >> \033[0m")
355 except (EOFError, KeyboardInterrupt):
356 break
357 if query.strip().lower() in ("q", "exit", ""):
358 break
359 trigger_hooks("UserPromptSubmit", query)
360 history.append({"role": "user", "content": query})
361 agent_loop(history)
362 for block in history[-1]["content"]:
363 if getattr(block, "type", None) == "text":
364 print(block.text)
365 print()
Claude Code
Subagent Type
前面的代码对subagent只提供任务描述这一块内容
1 messages = [{"role": "user", "content": description}] # fresh context
CC有三种模式
- Normal Subagent:和上面差不多
- 核心价值就是上下文隔离
- Fork Subagent:fork出一个新的分支进行探索,触发prompt cache
- 尽量让子 Agent 的前半部分上下文和父 Agent 的前半部分保持字节级一致,触发缓存命中
- 从当前终端的某个状态复制出一个分支,进行复用
- General-Purpose:和Normal一样
- 差别:Normal Subagent会指定
debugger或其他具体的agent类型,对于subagent的系统提示词、工具配置会更专门 - General的系统提示词、工具配置更通用,解决通用问题
- 但不过两者接受的输入:父Agent的任务描述,无上下文
- 差别:Normal Subagent会指定
Context Isolation(上下文隔离)
- 第一层:父Agent上下文,直接隔离
- 第二层:运行时状态,选择性隔离
- 工具权限、文件读取缓存、UI 状态、取消信号、统计信息、agent id、query tracking
- 会污染主循环的状态要隔离
- 执行上必须协作的状态要共享或克隆
- 工具权限、文件读取缓存、UI 状态、取消信号、统计信息、agent id、query tracking
- 第三层:外部文件系统,不隔离
- 父Agent能看见子Agent修改、创建的文件
递归保护
如果是fork模式,改变子Agent中的工具列表去限制递归
就会导致无法命中缓存
因此实现方法是,如果触发了fork,如果已经在fork child了,直接拒绝就好了
但如果由于一些复杂的创建/恢复/桥接/特殊执行,没能成功保留状态
进入第二道机制:
每次fork的时候,在任务描述中插入提示词:
1<fork-boilerplate> STOP. READ THIS FIRST. You are a forked worker process. You are NOT the main agent.
并且警告模型不要fork
后续从消息中检索是否存在<fork-boilerplate>即可
但是容易被压缩
但是第一道关卡是抗压缩的,因此形成互补
Others
- Fork Agent 的
permissionMode: 'bubble'(forkSubagent.ts:67)意味着子 Agent 的权限弹窗冒泡到父终端,用户在主终端里审批子 Agent 的操作 - CC 还支持异步路径(
AgentTool.tsx:686-764):run_in_background: true时异步启动,返回{ status: 'async_launched' }立即给父 Agent,子 Agent 完成后通过通知机制告知父 Agent