Subagents Mechanism
Chapter 4 gave the agent a plan, and the plan works. It also made a second problem easy to see: everything the agent reads stays in the thread forever. Check one fact in a file, and that file rides along in every later turn of the session. Ask three questions that each need a sweep of the codebase, and the context window is mostly file dumps with your conversation somewhere underneath.
This chapter adds the mechanism that fixes it. Before the demo, the concept in full, so the rest of the chapter is just watching it happen.
What a subagent is. A second instance of the loop we have been building since chapter 1 (chat, tools, router), started by the parent on an empty message list, with its own system prompt, its own tool set, and its own model. The parent starts it the way it does everything: by emitting a tool call, task(description, prompt, subagent_type). The subagent runs its whole ReAct loop to completion, and exactly one thing crosses back: its final message, delivered to the parent as an ordinary ToolMessage. To the parent it is a tool that happens to think.
Why our harness needs it. Three benefits, and each maps to a piece of this chapter:
- Context isolation. The subagent's greps and file reads land in its message list, which is thrown away when it finishes. The parent pays one report per question instead of the whole investigation, and since the parent re-sends its entire list on every turn, whatever stays out of it stays out for the rest of the session. This is the reason subagents exist; the other two are side effects.
- Parallelism. Independent questions become independent graphs. Two
taskcalls in one message run as two subagents in the same superstep, courtesy of LangGraph'sSend, with no threads in our code. - Specialization. Each subagent type is its own configuration of the loop.
exploreholds only read-only tools, skips the approval node entirely, and can run on a cheaper model;generalcan edit files and keeps human approval. The tool surface, not a promise in a prompt, is what limits what a child can do.
The price. The subagent cannot see the parent's conversation, so the brief must be complete on its own; there is no follow-up channel to a finished subagent, only a new task; and the parent gets a report it did not watch being produced, so it should verify anything it acts on. The task docstring and the system prompt spend most of their words on exactly these three points.
(Chapter 4 promised plan mode next. This came first because it solves the problem the planning layer exposed, and plan mode will turn out to be one configuration of what we build here.)
- subagent
- The same ReAct loop, run on a fresh message list, returning one report to the parent.
- context isolation
- The child's tool output lands in the child's messages, never the parent's.
- task
- A Pydantic schema bound as a tool. The model calls it, but no function runs; the graph dispatches it.
- Send
- An edge return that starts one node instance with its own input. Several in one step run in parallel.
- subgraph
- A compiled graph invoked from inside a node with that node's config. It inherits the checkpointer, and its interrupts bubble up.
- step cap
- A limit on chat turns, after which the child gets one tool-free call to summarize what it has.
1. One request, two subagents#
Two questions about this codebase, unrelated to each other, each needing several files: how a tool result travels to the terminal, and where configuration is read.
Three message lists are on screen. The parent on the left is the conversation you are having. The two lanes on the right are the children: each is a separate graph with its own message list, and each header counts what is in it. Hit send to start the run, or step with the arrows. The note above the lanes says what is happening at each step, and the toggle at the bottom shows the run the way the parent model sees it.
Nothing has run yet. The parent holds its system prompt and the request is still in the composer. Hit send (or press Enter) to run it.
empty message list
empty message list
Two things to take from that run before we open any code.
The report is the interface. Nothing crosses from a child lane to the parent lane except the child's last message. Not the greps, not the file reads, not the child's own system prompt. That is what context isolation means in practice: the parent gets a conclusion and pays for a conclusion.
The parent's list only ever grows by reports. This run is deliberately tiny, so the counters stay close; point either child at a real sweep and its lane climbs into the thousands of characters while the parent still gains exactly one ToolMessage per task. And because every later turn re-sends the whole list, what stays out of the parent stays out of every turn that follows.
2. The graph#
One new node, and one new kind of edge. Everything else is chapter 4's graph.
The mechanism in one place, so you can come back to it cold:
taskis a Pydantic schema bound to the parent's LLM as a tool. The model calls it like any tool;tool_nodenever runs it.dispatchis the edge afterhuman_approval. Approvedtaskcalls becomeSend("subagent", tool_call), one per call, all in the same superstep. Any other approved call adds"tools". If everything was rejected it returns"chat"so the model can replan.subagentis a node that receives one tool call, picks the compiled child graph bysubagent_type, invokes it with the node's ownconfig, and returns the child's final message as aToolMessage. Exceptions become"Subagent failed: …"; interrupts are re-raised so they bubble up.- The child is
build_graph(...)with a different LLM, tool list, prompt, and a step cap.exploreis read-only with no approval step;generalcan edit and keeps approval. Neither hastask, so the depth is one. - Both edges into
chat, fromtoolsand fromsubagent, deliverToolMessages, andadd_messagesmerges them. The parent cannot tell which path a result took, and does not need to.
Five pieces get us there:
| Piece | Where |
|---|---|
task schema | tools/task_tool.py |
dispatch router and Send | agent/graph.py |
subagent node | agent/graph.py |
build_graph factory | agent/graph.py |
explore / general configuration | agent/subagents.py |
3. task is a schema, not a tool#
Every tool so far has been a Python function under @tool. task breaks the pattern on purpose:
class task(BaseModel):
"""Delegate a self-contained piece of work to a subagent with its own context window.
The subagent starts fresh: it sees only the prompt you give it, works with
its own tools, and returns a single text report. None of its intermediate
tool output lands in your context -- that is the point. ...
When NOT to use:
- A single-fact lookup where you already know the file or symbol.
- Work that depends on your conversation history. The subagent cannot
see it, so anything it needs must be in the prompt.
- Follow-up questions to a finished subagent. There is no channel back;
start a new task with a sharper prompt instead.
...
"""
description: str = Field(description="Three to five word label shown to the user while the subagent runs, e.g. 'map auth flow'.")
prompt: str = Field(description="The full, self-contained brief for the subagent. Include where to look and what exact form the answer should take.")
subagent_type: Literal["explore", "general"] = Field(description="'explore' is read-only (list_dir, read_file, glob, grep, web_search) and runs on a cheaper model; use it for research and search. 'general' can also edit and write files; use it for delegated changes.")There is no function body because nothing here is called. bind_tools accepts a Pydantic class as readily as a decorated function, and the model sees the same thing either way: a name, a description, a JSON schema for the arguments. Chapter 4 made the point that a tool has two consumers, the model that reads the schema and the runtime that calls the function. task has only the first consumer. The runtime's half of the job is done by the graph.
Two lines elsewhere complete the picture:
llm_with_tools = llm.bind_tools([*tools, task] if subagents else tools)The schema is bound only when the graph has subagents to dispatch to, which is how the children end up without it (section 6). And in tool_node:
for tc in pending_tool_calls(state):
if tc["name"] == TASK: # NEW: dispatched to the subagent node via Send
continueIf you wrote task as a @tool with a body that raises "never called", it would work, and it would also be a lie in the one place the model reads carefully. The schema says what it is.
The docstring is doing real work. "Work that depends on your conversation history" and "there is no channel back" are the two things models get wrong about delegation, and the prompt is the only place to say so.
4. Dispatch: one Send per call#
Chapter 3 wired human_approval → tools as a plain edge. It becomes a conditional edge that returns a list:
def pending_tool_calls(state: State) -> list[dict]:
ai = next(m for m in reversed(state["messages"]) if getattr(m, "tool_calls", None))
answered = {m.tool_call_id for m in state["messages"] if isinstance(m, ToolMessage)}
return [tc for tc in ai.tool_calls if tc["id"] not in answered]
def dispatch(state: State) -> list:
pending = pending_tool_calls(state)
targets: list = [Send("subagent", tc) for tc in pending if tc["name"] == TASK]
if any(tc["name"] != TASK for tc in pending):
targets.append("tools")
return targets or ["chat"] # everything rejected -> let the model replanpending_tool_calls is the same filter tool_node has used since chapter 3, pulled out so both can use it: the calls in the last AIMessage that do not already have a ToolMessage. A rejection in human_approval answers a call early, which is how rejected task calls never become Sends.
Then the split, by name. Three kinds of destination can come out of one router call:
Send("subagent", tc)for eachtaskcall. ASendis not an edge to a node; it is an instruction to run one instance of a node with this input. Twotaskcalls produce twoSends, so twosubagentinstances, each holding a different tool call. The node's input is the payload, not the graph state."tools", once, if any ordinary call remains.tool_nodereads the state as usual and runs everything that is nottask."chat", only when the list would otherwise be empty. The user rejected everything, the rejections are already in the thread, and the model should see them and try again.
LangGraph runs everything the router returns in the same superstep. That is the entire parallelism story: the two children in section 1 ran at the same time because dispatch returned two Sends, and tools would have run alongside them if the model had asked for a read_file in the same message. No threads in our code, no executor, no gather.
The other side of the superstep is the reducer. tools returns ToolMessages. Each subagent returns one ToolMessage. All of them land on messages, and add_messages appends them in whatever order they finish. Then every branch edges back to chat, which sees a thread where every call has an answer.
Wiring it up:
builder.add_conditional_edges("human_approval", dispatch, ["tools", "chat", "subagent"])
builder.add_edge("subagent", "chat")The list of possible targets is for the compiler and the drawing; the Send objects bypass it.
5. The subagent node#
The node that actually runs a child is short, and one argument in it carries most of the weight:
def subagent_node(tc: dict, config: RunnableConfig) -> dict:
args = tc["args"]
try:
if args["subagent_type"] not in subagents:
raise ValueError(
f"Unknown subagent_type {args['subagent_type']!r}. "
f"Use one of: {', '.join(subagents)}."
)
sub = subagents[args["subagent_type"]]
result = sub["graph"].invoke(
{
"messages": [SystemMessage(content=sub["prompt"]), HumanMessage(content=args["prompt"])],
"description": args["description"],
"steps": 0,
"todos": [],
},
config,
)
content = result["messages"][-1].content
except GraphBubbleUp: # interrupt() inside the subgraph -> let it propagate
raise
except Exception as e:
content = f"Subagent failed: {e!r}"
return {"messages": [ToolMessage(content=content, tool_call_id=tc["id"], name=TASK)]}Read it top to bottom.
The input is a tool call, not the state. Because this node is only ever reached through Send, its first argument is whatever the Send carried: one tc dict with name, args, id. The node never sees the parent's messages, which is a nice property to get for free. It could not leak the parent's context into the child even by accident.
The child's message list is built right here. Two messages: the child type's system prompt, and the brief from the task call as a HumanMessage. That is the fresh start you watched in section 1. There is no third message.
config is passed through, and this is the line that matters. A compiled graph invoked with the config of the node that is running it becomes a subgraph: it inherits the parent's checkpointer, gets its own namespace under the parent's checkpoint, and, crucially, its interrupt() calls reach the outside world. Pass a fresh {"configurable": {"thread_id": ...}} instead and the child believes it is a root graph. Its interrupt() then returns an __interrupt__ marker inside the child's result, the parent never pauses, and the general child's edit goes ahead without asking anyone. Nothing errors. The approval prompt just never appears.
GraphBubbleUp is re-raised. An interrupt propagates as an exception, and it inherits from Exception. The generic handler that turns a crashed child into a "Subagent failed" message would otherwise swallow it. Order of the except clauses is not cosmetic.
Only the last message comes back. result["messages"][-1].content is the child's final answer, or its forced summary if the cap hit (section 7). Wrapped in a ToolMessage with the original tool_call_id, it is indistinguishable, to the parent, from a very informative tool.
That leaves the case the demo did not show: a general child that wants to edit a file. Its own human_approval node calls interrupt(), the exception climbs out of sub["graph"].invoke(...), through the re-raise, and into the parent's run, which pauses exactly as chapter 3 described. In the terminal it looks like this, with the child's label on the prompt so you know who is asking:
[task→general] rename helper
│ Rename fmt_value to format_value in src/agent/cli.py and update its callers.
[rename helper] [tool] grep(pattern='fmt_value', path='src')
[rename helper] [tool] edit_file(path='src/agent/cli.py', old_string='def fmt_value(', new_string='def format_value(')
approve> [rename helper] edit_file
path: src/agent/cli.py
old_string: def fmt_value(
new_string: def format_value(
approve? [y/N]:Approve, and the child resumes from its own checkpoint. Reject, and the rejection becomes a ToolMessage in the child's list, which then decides what to do about it, exactly as the parent would. /auto is inherited: it lives in config, and config is what we just passed down.
6. One loop, three configurations#
The children are not a second implementation. They are the graph from chapters 1 to 4, built with different arguments. To make that literally true, graph.py stops defining LLM and TOOLS at module level and becomes a factory:
def build_graph(
llm: BaseChatModel,
tools: list,
hitl: bool,
max_steps: int | None = None,
subagents: dict | None = None,
checkpointer=None,
):
tools_by_name = {t.name: t for t in tools} # NEW: was module-level
llm_with_tools = llm.bind_tools([*tools, task] if subagents else tools) # NEW
def chat_node(state: State) -> dict: ...
def human_approval_node(state: State, config: RunnableConfig) -> dict: ...
def tool_node(state: State) -> dict: ...
def subagent_node(tc: dict, config: RunnableConfig) -> dict: ...
def dispatch(state: State) -> list: ...
def route_after_chat(state: State) -> list | str:
if not state["messages"][-1].tool_calls:
return END
return "human_approval" if hitl else dispatch(state) # NEW: no approval node -> dispatch directly
dispatch_targets = ["tools", "chat"] + (["subagent"] if subagents else [])
builder = StateGraph(State)
builder.add_node("chat", chat_node)
builder.add_node("tools", tool_node)
builder.add_edge(START, "chat")
builder.add_edge("tools", "chat")
if subagents: # NEW
builder.add_node("subagent", subagent_node)
builder.add_edge("subagent", "chat")
if hitl: # NEW
builder.add_node("human_approval", human_approval_node)
builder.add_conditional_edges("chat", route_after_chat, ["human_approval", END])
builder.add_conditional_edges("human_approval", dispatch, dispatch_targets)
else:
builder.add_conditional_edges("chat", route_after_chat, [*dispatch_targets, END])
return builder.compile(checkpointer=checkpointer)The nodes become closures over llm_with_tools and tools_by_name; nothing inside them changed except the two # NEW lines from sections 3 and 7. Two switches shape the topology: hitl decides whether human_approval exists, and subagents decides whether subagent exists. When there is no approval node, route_after_chat calls dispatch itself, so the Send logic is the same function either way.
Then the three configurations, in one file:
READ_ONLY_TOOLS = [list_dir, read_file, glob, grep, web_search]
WRITE_TOOLS = [edit_file, write_file]
PLANNING_TOOLS = [write_todos, read_todos]
def build_subagents() -> dict:
cfg = get_config()
return {
"explore": {
"graph": build_graph(get_llm(cfg.llm_explore_model), READ_ONLY_TOOLS, hitl=False, max_steps=MAX_STEPS),
"prompt": load_prompt("explore"),
},
"general": {
"graph": build_graph(get_llm(), READ_ONLY_TOOLS + WRITE_TOOLS, hitl=True, max_steps=MAX_STEPS),
"prompt": load_prompt("general"),
},
}
def build_agent():
return build_graph(
get_llm(),
READ_ONLY_TOOLS + WRITE_TOOLS + PLANNING_TOOLS,
hitl=True,
subagents=build_subagents(),
checkpointer=InMemorySaver(),
)Side by side:
| parent | explore | general | |
|---|---|---|---|
| tools | all, plus task | read-only + web_search | read-only + edit_file, write_file |
human_approval | yes | no | yes, inherits /auto |
| model | LLM_MODEL | LLM_EXPLORE_MODEL, else LLM_MODEL | LLM_MODEL |
| step cap | none | 20 | 20 |
| checkpointer | InMemorySaver | inherited | inherited |
| system prompt | system.md | explore.md | general.md |
Three rows deserve a sentence each.
No task for children. The schema is bound only when subagents is passed, and the children are built without it. That is the whole recursion policy: depth one, enforced by what the model can see rather than by a counter.
explore has no approval node. Every tool it holds is read-only, so there is nothing to approve, and a child that stops to ask would defeat the point of sending it away. This is also the first time the series has built a graph without human_approval since chapter 3, and it took one boolean.
explore can run on a cheaper model. get_llm(model=None) gained an override, and LLM_EXPLORE_MODEL is optional so that LM Studio users, who have one model loaded, change nothing. The explore prompt says "ask for facts, not judgement" for the same reason: the cheap model is doing retrieval, the expensive one is doing the thinking.
The parent's own system prompt grew a Delegation section, quoted in the demo's first message. It is the mirror image of the task docstring: when to reach for a subagent, and when not to.
7. The step cap#
A child that cannot find what it was asked for will keep looking. Nothing in a ReAct loop makes it stop, and unlike the parent it has no human in the loop to get bored. So the child's chat node counts:
STEP_LIMIT_PROMPT = (
"Step limit reached. Do not call any more tools. Summarize what you found "
"or changed so far and state clearly what is left unfinished."
)
def chat_node(state: State) -> dict:
steps = state.get("steps", 0) + 1 # NEW
if max_steps is not None and steps > max_steps: # NEW
nudge = HumanMessage(content=STEP_LIMIT_PROMPT)
summary = llm.invoke([*state["messages"], nudge]) # no tools bound
return {"messages": [nudge, summary], "steps": steps}
return {"messages": [llm_with_tools.invoke(state["messages"])], "steps": steps}steps is a new channel on State, without a reducer, so each write replaces it, and the parent never sets max_steps so it never trips. On the twenty-first call the child gets a HumanMessage telling it to stop, and the model is invoked without tools bound. That second part is what makes the cap a cap: a model with tools available will, if asked to summarize, quite often call one more tool instead. Remove the option and the only thing it can produce is text. The router sees no tool_calls, the child reaches END, and the summary is what section 5's node lifts out.
Why count turns instead of tokens? Tokens are what actually cost money, but counting them means provider-specific usage metadata, a running total in state, and a threshold nobody can pick well. Turns are visible in the trace, identical across providers, and twenty of them is a budget you can reason about: the children in section 1 needed one and two. The task docstring tells the parent the report may be partial, so a cap that trips is information, not an error.
8. Decisions#
Every one of these had a reasonable alternative. The chosen column is what the code does; the rejected column is what it would have looked like instead.
| Decision | Chosen | Rejected | Why |
|---|---|---|---|
| Where subagents live | A tool-shaped call, executed as a node | A supervisor topology: chat emits work items, a dispatch node fans out to worker nodes, results reduce into the parent state | The tool shape keeps "when to delegate" a decision the model makes mid-turn like any other, and reducing worker output into the parent's messages is the pollution subagents exist to prevent. |
How task is exposed | Pydantic schema | @tool function with a body that is never called | The model reads the schema; a fake function body misdescribes what happens. |
| Parallelism | One Send per call, same superstep | A thread pool inside tool_node | Send gives parallel nodes with proper checkpoints and interrupt propagation for free; threads inside a tool bury the child where neither works. |
| Recursion | Depth one | Children can spawn children | Nobody has yet shown a use for it that a better brief does not cover, and unbounded fan-out is unbounded cost. Enforced by not binding task in children. |
| Budget | Chat-turn cap with a tool-free summary | Token counting | Turns are visible and provider-neutral; twenty is a number you can reason about. |
| Model | Cheaper model for explore, same for general | One model everywhere | Retrieval tolerates a weaker model; edits do not. Optional, so a single local model still works. |
Approval inside general | Inherit the parent's /auto setting | Always ask, or never ask | One switch with one meaning. The task call itself was already approved by the user. |
The first row is the big one, and it is the same shape as chapter 4's decision to make write_todos a no-op: put the intelligence where the model already is, and keep the graph as plumbing.
9. Up next#
The agent can now keep its own context clean while it works. What it cannot do is remember anything once the process exits: the InMemorySaver from chapter 1 is still the only memory it has, and thread_id="1" dies with the REPL.
Next chapter: memory. What to keep between sessions, where to keep it, and how a coding agent decides which of the last hundred messages were worth remembering.