analysis_claude_code/docs/en/s04-subagent.md
CrazyBoyM c6a27ef1d7 feat: build an AI agent from 0 to 1 -- 11 progressive sessions
- 11 sessions from basic agent loop to autonomous teams
- Python MVP implementations for each session
- Mental-model-first docs in en/zh/ja
- Interactive web platform with step-through visualizations
- Incremental architecture: each session adds one mechanism
2026-02-21 17:02:43 +08:00

5.5 KiB

s04: Subagents

A subagent runs with a fresh messages list, shares the filesystem with the parent, and returns only a summary -- keeping the parent context clean.

The Problem

As the agent works, its messages array grows. Every tool call, every file read, every bash output accumulates. After 20-30 tool calls, the context window is crowded with irrelevant history. Reading a 500-line file to answer a quick question permanently adds 500 lines to the context.

This is particularly bad for exploratory tasks. "What testing framework does this project use?" might require reading 5 files, but the parent agent does not need all 5 file contents in its history -- it just needs the answer: "pytest with conftest.py configuration."

The solution is process isolation: spawn a child agent with messages=[]. The child explores, reads files, runs commands. When it finishes, only its final text response returns to the parent. The child's entire message history is discarded.

The Solution

Parent agent                     Subagent
+------------------+             +------------------+
| messages=[...]   |             | messages=[]      | <-- fresh
|                  |  dispatch   |                  |
| tool: task       | ---------->| while tool_use:  |
|   prompt="..."   |            |   call tools     |
|                  |  summary   |   append results |
|   result = "..." | <--------- | return last text |
+------------------+             +------------------+
          |
Parent context stays clean.
Subagent context is discarded.

How It Works

  1. The parent agent gets a task tool that triggers subagent spawning. The child gets all base tools except task (no recursive spawning).
PARENT_TOOLS = CHILD_TOOLS + [
    {"name": "task",
     "description": "Spawn a subagent with fresh context.",
     "input_schema": {
         "type": "object",
         "properties": {
             "prompt": {"type": "string"},
             "description": {"type": "string"},
         },
         "required": ["prompt"],
     }},
]
  1. The subagent starts with a fresh messages list containing only the delegated prompt. It shares the same filesystem.
def run_subagent(prompt: str) -> str:
    sub_messages = [{"role": "user", "content": prompt}]
    for _ in range(30):  # safety limit
        response = client.messages.create(
            model=MODEL, system=SUBAGENT_SYSTEM,
            messages=sub_messages,
            tools=CHILD_TOOLS, max_tokens=8000,
        )
        sub_messages.append({
            "role": "assistant", "content": response.content
        })
        if response.stop_reason != "tool_use":
            break
        # execute tools, append results...
  1. Only the final text returns to the parent. The child's 30+ tool call history is discarded.
    return "".join(
        b.text for b in response.content if hasattr(b, "text")
    ) or "(no summary)"
  1. The parent receives this summary as a normal tool_result.
if block.name == "task":
    output = run_subagent(block.input["prompt"])
results.append({
    "type": "tool_result",
    "tool_use_id": block.id,
    "content": str(output),
})

Key Code

The subagent function (from agents/s04_subagent.py, lines 110-128):

def run_subagent(prompt: str) -> str:
    sub_messages = [{"role": "user", "content": prompt}]
    for _ in range(30):
        response = client.messages.create(
            model=MODEL, system=SUBAGENT_SYSTEM,
            messages=sub_messages,
            tools=CHILD_TOOLS, max_tokens=8000,
        )
        sub_messages.append({"role": "assistant",
                             "content": response.content})
        if response.stop_reason != "tool_use":
            break
        results = []
        for block in response.content:
            if block.type == "tool_use":
                handler = TOOL_HANDLERS.get(block.name)
                output = handler(**block.input)
                results.append({"type": "tool_result",
                    "tool_use_id": block.id,
                    "content": str(output)[:50000]})
        sub_messages.append({"role": "user", "content": results})
    return "".join(
        b.text for b in response.content if hasattr(b, "text")
    ) or "(no summary)"

What Changed From s03

Component Before (s03) After (s04)
Tools 5 5 (base) + task (parent)
Context Single shared Parent + child isolation
Subagent None run_subagent() function
Return value N/A Summary text only
Todo system TodoManager Removed (not needed here)

Design Rationale

Process isolation gives context isolation for free. A fresh messages[] means the subagent cannot be confused by the parent's conversation history. The tradeoff is communication overhead -- results must be compressed back to the parent, losing detail. This is the same tradeoff as OS process isolation: safety and cleanliness in exchange for serialization cost. Limiting subagent depth (no recursive spawning) prevents unbounded resource consumption, and a max iteration count ensures runaway children terminate.

Try It

cd learn-claude-code
python agents/s04_subagent.py

Example prompts to try:

  1. Use a subtask to find what testing framework this project uses
  2. Delegate: read all .py files and summarize what each one does
  3. Use a task to create a new module, then verify it from here