S06. Subagent — Break Large Tasks into Small Ones with Clean Context
S06. Subagent — Break Large Tasks into Small Ones with Clean Context
The Problem
The Agent is fixing a bug. It reads many files to trace the call chain, and every tool call and result stays in the parent's messages[]. Once the call chain is understood, most of those intermediate details are no longer needed, but they still occupy context.
The Solution
Calling task synchronously runs a nested agent loop with a fresh messages[]. When that loop finishes, its final text becomes the tool result in the parent conversation.
This is message isolation, not process or filesystem isolation. Parent and subagent run in the same Python process and share WORKDIR, so writes and commands still affect the same workspace. The subagent has the five base tools but no task, and its tool calls use the same permission and lifecycle hooks as the parent.
How It Works
run_subagent creates the fresh message list, runs the nested loop, and returns the final text:
SUB_TOOLS = list(BASE_TOOLS) # no task tool
def run_subagent(prompt: str) -> str:
messages = [{"role": "user", "content": prompt}]
for _ in range(30):
response = client.messages.create(
model=MODEL, system=SUB_SYSTEM,
messages=messages, tools=SUB_TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return extract_text(response.content) or "(no summary)"
results = []
for block in response.content:
if block.type == "tool_use":
output = execute_tool(block, SUB_HANDLERS)
results.append({... "content": output})
messages.append({"role": "user", "content": results})
return "Subagent stopped after 30 turns without a final answer."The main Agent calls it just like any other tool:
TASK_TOOL = {
"name": "task",
"description": "Run a subagent with fresh conversation context and return its final text.",
"input_schema": {
"type": "object",
"properties": {"prompt": {"type": "string"}},
"required": ["prompt"],
},
}
TOOLS = [*BASE_TOOLS, TASK_TOOL]
TOOL_HANDLERS = {**BASE_HANDLERS, "task": run_subagent}The boundary is:
| Decision | Choice | Reason | |----------|--------|--------| | Conversation | Fresh messages[] | Parent history is not copied into the subagent | | Execution | Same process and WORKDIR | Filesystem changes remain visible to both loops | | Return value | Final text only | Child tool calls and results are not copied into parent messages | | Delegation depth | No task in SUB_TOOLS | This lesson permits one delegation level | | Tool policy | Shared Hooks | Parent and subagent use the same permission checks |
The parent dispatches task through the same handler map as its other tools. The subagent uses SUB_SYSTEM, SUB_TOOLS, and its own local messages list.
Try It
cd learn-claude-code
python s06_subagent/code.pyTry these prompts:
Use a subtask to find what testing framework this project uses(sub-Agent reads files, main Agent receives only the conclusion)Delegate: read all .py files in agents/ and summarize what each one doesUse a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent
What to watch for: Do [Subagent started] / [Subagent done] appear? Do subagent tool calls print as [sub] ...? Does the parent continue with only the final text returned by task?
What's Next
The Agent can now break tasks apart. But different tasks require different knowledge: editing frontend components needs React conventions, writing SQL needs table schemas. Stuffing all this knowledge into the system prompt would blow up the context.
→ s07 Skill Loading: Inject skills on demand instead of piling documents into the system prompt. Load only when needed, as natural as reading a file.
S06 — Complete teaching code
#!/usr/bin/env python3
"""
s06_subagent.py - Subagents
The task tool runs a second agent loop with a fresh message list. Both
loops share the working directory, but only the final text returns to
the parent conversation.
Parent agent Subagent
+------------------+ +------------------+
| messages=[...] | | messages=[prompt]|
| | task | |
| tool: task | ---------> | own agent loop |
| | | base tools only |
| tool_result | <--------- | final text |
+------------------+ +------------------+
The subagent has no task tool, so it cannot delegate again.
"""
import os
import subprocess
from pathlib import Path
try:
import readline
readline.parse_and_bind('set bind-tty-special-chars off')
readline.parse_and_bind('set input-meta on')
readline.parse_and_bind('set output-meta on')
readline.parse_and_bind('set convert-meta off')
except ImportError:
pass
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
SYSTEM = (
f"You are a coding agent at {WORKDIR}. "
"Use task for focused exploration or a self-contained subtask."
)
SUB_SYSTEM = (
f"You are a coding agent at {WORKDIR}. "
"Complete the given task, then return a concise final answer."
)
# -- Base tools --
def run_bash(command: str) -> str:
try:
result = subprocess.run(
command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120,
)
output = (result.stdout + result.stderr).strip()
return output[:50000] if output else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int | None = None) -> str:
try:
lines = (WORKDIR / path).resolve().read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
return "\n".join(lines)
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
file_path = (WORKDIR / path).resolve()
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content)
return f"Wrote {len(content)} bytes to {path}"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
file_path = (WORKDIR / path).resolve()
text = file_path.read_text()
if old_text not in text:
return f"Error: text not found in {path}"
file_path.write_text(text.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
def run_glob(pattern: str) -> str:
import glob
try:
matches = []
for match in glob.glob(pattern, root_dir=WORKDIR):
if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
matches.append(match)
return "\n".join(matches) if matches else "(no matches)"
except Exception as e:
return f"Error: {e}"
BASE_TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to a file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in a file once.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern.",
"input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
]
BASE_HANDLERS = {
"bash": run_bash,
"read_file": run_read,
"write_file": run_write,
"edit_file": run_edit,
"glob": run_glob,
}
# -- Hooks --
HOOKS = {"UserPromptSubmit": [], "PreToolUse": [], "PostToolUse": [], "Stop": []}
def register_hook(event: str, callback):
HOOKS[event].append(callback)
def trigger_hooks(event: str, *args):
for callback in HOOKS[event]:
result = callback(*args)
if result is not None:
return result
return None
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
def permission_hook(block):
"""PreToolUse: block denied operations and ask about risky ones."""
if block.name == "bash":
command = block.input.get("command", "")
for pattern in DENY_LIST:
if pattern in command:
print(f"\n\033[31m[blocked] '{pattern}'\033[0m")
return "Permission denied by deny list"
for keyword in DESTRUCTIVE:
if keyword in command:
print("\n\033[33m[permission] Potentially destructive command\033[0m")
print(f" Tool: {block.name}({block.input})")
choice = input(" Allow? [y/N] ").strip().lower()
if choice not in ("y", "yes"):
return "Permission denied by user"
if block.name in ("read_file", "write_file", "edit_file"):
path = block.input.get("path", "")
if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):
print("\n\033[33m[permission] Access outside workspace\033[0m")
print(f" Tool: {block.name}({block.input})")
choice = input(" Allow? [y/N] ").strip().lower()
if choice not in ("y", "yes"):
return "Permission denied by user"
return None
def log_hook(block):
"""PreToolUse: log every tool call."""
args_preview = str(list(block.input.values())[:2])[:60]
print(f"\033[90m[HOOK] {block.name}({args_preview})\033[0m")
return None
def large_output_hook(block, output):
"""PostToolUse: warn on large output."""
if len(str(output)) > 100000:
print(f"\033[33m[HOOK] Large output from {block.name}: {len(str(output))} chars\033[0m")
return None
def context_inject_hook(query: str):
"""UserPromptSubmit: log the working directory."""
print(f"\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\033[0m")
return None
def summary_hook(messages: list):
"""Stop: print the number of tool results in this message list."""
tool_count = sum(
1
for message in messages
for block in (
message.get("content")
if isinstance(message.get("content"), list)
else []
)
if isinstance(block, dict) and block.get("type") == "tool_result"
)
print(f"\033[90m[HOOK] Stop: session used {tool_count} tool calls\033[0m")
return None
register_hook("UserPromptSubmit", context_inject_hook)
register_hook("PreToolUse", permission_hook)
register_hook("PreToolUse", log_hook)
register_hook("PostToolUse", large_output_hook)
register_hook("Stop", summary_hook)
def execute_tool(block, handlers: dict) -> str:
blocked = trigger_hooks("PreToolUse", block)
if blocked:
return str(blocked)
handler = handlers.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown: {block.name}"
except Exception as e:
output = f"Error: {e}"
trigger_hooks("PostToolUse", block, output)
return str(output)
# -- New in s06: a nested agent loop with fresh messages --
SUB_TOOLS = list(BASE_TOOLS)
SUB_HANDLERS = dict(BASE_HANDLERS)
def extract_text(content) -> str:
if not isinstance(content, list):
return str(content)
return "\n".join(
getattr(block, "text", "")
for block in content
if getattr(block, "type", None) == "text"
)
def run_subagent(prompt: str) -> str:
print("\n\033[35m[Subagent started]\033[0m")
messages = [{"role": "user", "content": prompt}]
for _ in range(30):
response = client.messages.create(
model=MODEL,
system=SUB_SYSTEM,
messages=messages,
tools=SUB_TOOLS,
max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
force = trigger_hooks("Stop", messages)
if force:
messages.append({"role": "user", "content": force})
continue
print("\033[35m[Subagent done]\033[0m")
return extract_text(response.content) or "(no summary)"
results = []
for block in response.content:
if block.type != "tool_use":
continue
output = execute_tool(block, SUB_HANDLERS)
print(f" \033[90m[sub] {block.name}: {output[:100]}\033[0m")
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
print("\033[35m[Subagent stopped]\033[0m")
return "Subagent stopped after 30 turns without a final answer."
TASK_TOOL = {
"name": "task",
"description": "Run a subagent with fresh conversation context and return its final text.",
"input_schema": {
"type": "object",
"properties": {"prompt": {"type": "string", "minLength": 1}},
"required": ["prompt"],
},
}
TOOLS = [*BASE_TOOLS, TASK_TOOL]
TOOL_HANDLERS = {**BASE_HANDLERS, "task": run_subagent}
# -- Parent agent loop --
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL,
system=SYSTEM,
messages=messages,
tools=TOOLS,
max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
force = trigger_hooks("Stop", messages)
if force:
messages.append({"role": "user", "content": force})
continue
return
results = []
for block in response.content:
if block.type != "tool_use":
continue
output = execute_tool(block, TOOL_HANDLERS)
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
print("s06: Subagent - fresh messages, final text returns")
print("Enter a question, press Enter to send. Type q to quit.\n")
history = []
while True:
try:
query = input("\033[36ms06 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
trigger_hooks("UserPromptSubmit", query)
history.append({"role": "user", "content": query})
agent_loop(history)
for block in history[-1]["content"]:
if getattr(block, "type", None) == "text":
print(block.text)
print()
Try it — Subagent scenario
The task tool runs a nested agent loop with fresh messages and returns its final text to the parent.