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的任务描述,无上下文

Context Isolation(上下文隔离)

  • 第一层:父Agent上下文,直接隔离
  • 第二层:运行时状态,选择性隔离
    • 工具权限、文件读取缓存、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