Multi-Agent & Deep Agents
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against langchain 1.4.3, langchain-core 1.6.5, langgraph 1.2.12 and deepagents 0.7.19 on
Python 3.12, as verified on 2026-09-29. The running example is DocsAssistant, which answers questions over this
site’s documentation using Ollama’s llama3.1:8b chat model and the nomic-embed-text embedding model over the
pgvector store built in Integrating with LangChain. Ollama
was not available while the snippets were checked, so every output shown here was produced by running the code
against the real packages with a scripted fake chat model (the helper listed below) and one-line stubs in place of
the pgvector retriever; no output comes from a live model. The fake replays the messages it was given, so the outputs
prove the wiring (which tools were offered, which state key changed, which message crossed a boundary), not the
quality of any model’s answers.
Most tasks do not need more than one agent. A single agent with the right tools and prompt is simpler to build, cheaper to run and easier to debug. The official guidance names three reasons to go multi-agent anyway: context management (specialised knowledge that would not fit in one prompt), distributed development (teams own components independently) and parallelisation (workers run concurrently). The framework-neutral theory — topologies, shared versus isolated state, when coordination costs more than it saves — is in Multi-Agent Systems and is not repeated here. This page is the LangChain and LangGraph implementation: five patterns from the official multi-agent guide, the subgraph mechanics underneath them, and the Deep Agents harness that packages several of them.
The helper and the choice of pattern
Every example below runs against the same scripted fake. bind_tools returns the model itself because agents call it
to register tools, and call builds an AIMessage that requests one tool call. The helper is kept in a module
called fake.py.
# fake.py
import itertools
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage
class ScriptedChat(GenericFakeChatModel):
"""Replays scripted AIMessages and tolerates bind_tools()."""
def bind_tools(self, tools, **kwargs):
return self
def scripted(*messages):
"""Replay these messages once, one per model call."""
return ScriptedChat(messages=iter(messages))
def always(text):
"""Answer the same text on every call (for agents invoked more than once)."""
return ScriptedChat(messages=itertools.cycle([AIMessage(content=text)]))
def call(name, _id, **args):
"""An AIMessage that asks for one tool call."""
return AIMessage(content="", tool_calls=[{"name": name, "args": args, "id": _id, "type": "tool_call"}])
The official multi-agent overview lists four patterns and a fifth, custom workflows built directly on LangGraph. They differ in who decides the next step and whether the sub-component talks to the user:
| Pattern | Who decides the next step | Strength | Talks to the user |
|---|---|---|---|
Subagents (supervisor) |
A main agent, by calling subagents as tools |
Context isolation, parallel workers, distributed development |
Only the main agent |
Handoffs |
Tools that update a state variable |
Sequential constraints, multi-stage conversations |
Yes, whichever agent is active |
Skills |
One agent, by loading prompts on demand |
Many specialisations without many agents |
Yes |
Router |
A classification step, before the agents run |
Parallel queries over distinct verticals, then synthesis |
Through the router or a wrapping agent |
Custom workflow |
Your LangGraph code |
Deterministic control mixed with agentic steps |
As designed |
The rest of this page walks the patterns in that order after the mechanics they all share: subgraphs.
Subgraphs
A subgraph is a compiled graph used as a node of another graph. Every multi-agent pattern that keeps agents inside
one LangGraph graph is built from this, and an agent made by create_agent is itself a compiled graph, so it can be
a node without any adapter (see LangGraph Fundamentals for graphs,
state and reducers). How the parent and the subgraph communicate depends on their state schemas.
import operator
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
class Shared(TypedDict):
question: str
notes: Annotated[list[str], operator.add]
def rewrite(state: Shared):
return {"notes": [f"rewritten: {state['question'].lower()}"]}
def retrieve(state: Shared):
return {"notes": ["retrieved: ann-indexes.adoc"]}
retrieval = (StateGraph(Shared)
.add_node("rewrite", rewrite).add_node("retrieve", retrieve)
.add_edge(START, "rewrite").add_edge("rewrite", "retrieve")
.compile())
def answer(state: Shared):
return {"notes": [f"answer from {len(state['notes'])} notes"]}
parent = (StateGraph(Shared)
.add_node("retrieval", retrieval) # the compiled graph itself is the node
.add_node("answer", answer)
.add_edge(START, "retrieval").add_edge("retrieval", "answer")
.compile())
print(parent.invoke({"question": "What is HNSW?", "notes": []})["notes"])
Output:
['rewritten: what is hnsw?', 'retrieved: ann-indexes.adoc', 'answer from 2 notes']
The subgraph reads and writes the parent’s channels directly, which is how agents usually share a messages key.
When the schemas do not share keys — typically because each agent should keep a private message history — call the
subgraph from inside a node and translate the state in both directions:
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START
class Private(TypedDict):
query: str
hits: list[str]
def search(state: Private):
return {"hits": [f"hit for {state['query']}"]}
inner = (StateGraph(Private).add_node("search", search)
.add_edge(START, "search").compile())
class Outer(TypedDict):
question: str
context: list[str]
def call_inner(state: Outer):
out = inner.invoke({"query": state["question"], "hits": []}) # parent state -> subgraph input
return {"context": out["hits"]} # subgraph output -> parent state
outer = (StateGraph(Outer).add_node("call_inner", call_inner)
.add_edge(START, "call_inner").compile())
print(outer.invoke({"question": "ef_search", "context": []}))
Output:
{'question': 'ef_search', 'context': ['hit for ef_search']}
Distributed development is the third reason to use subgraphs: as long as a team keeps the input and output schemas stable, the parent graph can be built without knowing what happens inside. See LangGraph — Subgraphs for the full guide, including viewing subgraph state and streaming subgraph output.
Subgraph persistence
What a subgraph remembers between calls is controlled by the checkpointer argument of its compile(). The parent
must be compiled with a checkpointer for any of the modes to matter (see
Short-Term Memory & Checkpointers).
import operator
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph, START
class Sub(TypedDict):
question: str
asked: Annotated[list[str], operator.add]
def remember(state: Sub):
return {"asked": [state["question"]]}
class Parent(TypedDict):
question: str
count: int
def build(sub_checkpointer):
sub = (StateGraph(Sub).add_node("remember", remember)
.add_edge(START, "remember").compile(checkpointer=sub_checkpointer))
def call_sub(state: Parent):
out = sub.invoke({"question": state["question"]})
return {"count": len(out["asked"])}
return (StateGraph(Parent).add_node("call_sub", call_sub)
.add_edge(START, "call_sub").compile(checkpointer=InMemorySaver()))
for label, mode in [("per-invocation (None)", None), ("per-thread (True)", True), ("stateless (False)", False)]:
graph = build(mode)
config = {"configurable": {"thread_id": "alice-1"}}
counts = [graph.invoke({"question": q}, config)["count"] for q in ("one", "two", "three")]
print(f"{label:22} -> {counts}")
Output:
per-invocation (None) -> [1, 1, 1]
per-thread (True) -> [1, 2, 3]
stateless (False) -> [1, 1, 1]
| Mode | checkpointer= |
Behaviour |
|---|---|---|
Per-invocation |
|
Each call starts fresh, but inherits the parent’s checkpointer so interrupts and durable execution work inside one call. |
Per-thread |
|
State accumulates across calls on the same thread; the subgraph resumes where it left off. |
Stateless |
|
No checkpointing at all, like a plain function call. No interrupts or durable execution inside it. |
Per-invocation is the right default for subagents that handle independent requests. Choose per-thread only when a subagent needs its own multi-turn memory, and avoid parallel calls to the same per-thread subagent because they would write conflicting checkpoints. See LangGraph — Subgraphs.
Subagents as tools: the supervisor pattern
In the subagents pattern a main agent (often called the supervisor) calls specialised agents as if they were tools. The main agent decides which subagent to invoke, with what input, and how to combine the results; subagents return to the main agent, never to the user. By default they are stateless, so the main agent holds all conversation memory and each subagent invocation starts in a clean context window. That isolation is the point: a research subagent can burn thirty tool calls and only its final message crosses back.
The mechanism is a @tool whose body invokes another agent:
from langchain.agents import create_agent
from langchain.tools import tool
from fake import scripted, call, AIMessage
@tool
def search_docs(query: str) -> str:
"""Search the documentation site and return matching sections."""
return "ann-indexes.adoc :: HNSW :: ef_search trades recall for latency." # pgvector in DocsAssistant
# The worker: its own model, tools and prompt, and its own clean context window
researcher = create_agent(
scripted(call("search_docs", "r1", query="HNSW ef_search"),
AIMessage(content="ef_search sets the candidate list size (ann-indexes.adoc).")),
tools=[search_docs],
system_prompt="You research the docs. Reply in at most three lines and name the page.",
)
@tool("research", description="Research a documentation question in depth and return a short, cited summary.")
def call_researcher(query: str) -> str:
result = researcher.invoke({"messages": [{"role": "user", "content": query}]})
return result["messages"][-1].content # only the final message crosses back
supervisor = create_agent(
scripted(call("research", "s1", query="What does HNSW ef_search do?"),
AIMessage(content="ef_search is the candidate list size; higher means better recall, slower queries.")),
tools=[call_researcher],
system_prompt="You are DocsAssistant. Delegate documentation research to the research tool.",
)
out = supervisor.invoke({"messages": [{"role": "user", "content": "Explain ef_search"}]})
for m in out["messages"]:
print(type(m).__name__, m.tool_calls[0]["name"] if getattr(m, "tool_calls", None) else m.content)
Output:
HumanMessage Explain ef_search
AIMessage research
ToolMessage ef_search sets the candidate list size (ann-indexes.adoc).
AIMessage ef_search is the candidate list size; higher means better recall, slower queries.
The supervisor’s history holds four messages; the researcher’s search call and result never appear in it. The subagent’s name and description are the only thing the supervisor sees, so they are prompting levers: make the description say what tasks the subagent handles and when to use it.
See LangChain — Subagents for the pattern, its sync-versus-async trade-off (a background job started by one tool and polled by two more, for work the user should not wait on) and the tutorial that builds a personal assistant this way.
One dispatch tool for many subagents
Wrapping every subagent in its own tool gives the most control over each one’s input and output. When there are
many agents, or teams add agents without touching the coordinator, use a single parameterised task tool that
looks the subagent up by name. Constraining the name with a Literal puts the valid values into the tool’s JSON
Schema as an enum, which is the type-safe way to enumerate a small, static registry.
task tool dispatching to a registry, with a parallel fan-outfrom typing import Literal
from langchain.agents import create_agent
from langchain.tools import tool
from fake import scripted, AIMessage
def make_agent(name, reply):
return create_agent(scripted(AIMessage(content=reply)), tools=[], system_prompt=f"You are the {name} specialist.")
SUBAGENTS = {
"research": make_agent("research", "ef_search: candidate list size (ann-indexes.adoc)."),
"writer": make_agent("writer", "Draft: ef_search trades recall for latency."),
}
@tool
def task(agent_name: Literal["research", "writer"], description: str) -> str:
"""Launch an ephemeral subagent for one self-contained task.
Available agents:
- research: documentation fact-finding, returns cited findings
- writer: turns findings into a short draft answer
"""
result = SUBAGENTS[agent_name].invoke({"messages": [{"role": "user", "content": description}]})
return result["messages"][-1].content
print(task.args_schema.model_json_schema()["properties"]["agent_name"])
both = AIMessage(content="", tool_calls=[
{"name": "task", "args": {"agent_name": "research", "description": "Find ef_search"}, "id": "a", "type": "tool_call"},
{"name": "task", "args": {"agent_name": "writer", "description": "Draft a 1-line answer about ef_search"}, "id": "b", "type": "tool_call"},
])
coordinator = create_agent(scripted(both, AIMessage(content="Done: research and draft collected.")),
tools=[task], system_prompt="You coordinate subagents through the task tool.")
out = coordinator.invoke({"messages": [{"role": "user", "content": "Answer: what is ef_search?"}]})
for m in out["messages"]:
print(type(m).__name__, [c["args"]["agent_name"] for c in m.tool_calls] if getattr(m, "tool_calls", None) else m.content)
Output:
{'enum': ['research', 'writer'], 'title': 'Agent Name', 'type': 'string'}
HumanMessage Answer: what is ef_search?
AIMessage ['research', 'writer']
ToolMessage ef_search: candidate list size (ann-indexes.adoc).
ToolMessage Draft: ef_search trades recall for latency.
AIMessage Done: research and draft collected.
The model asked for both subagents in one turn, which is the parallel-execution characteristic of the pattern: the
main agent can invoke several subagents in a single step. For larger or dynamic registries, list the agents through a
discovery tool instead of the system prompt or an enum. Note that the description string of task doubles as the
agent list the model reads, so keep it in step with the registry.
Two design choices shape how well the pattern behaves. Inputs: by default a subagent gets only the query the supervisor wrote; pass more of the conversation when the subagent genuinely needs it, at the cost of a larger context. Outputs: return the subagent’s result rather than its whole conversation, so the supervisor’s context stays small (see Context Engineering for Agents). A supervisor whose subagents are themselves supervisors gives the hierarchical topology described in Multi-Agent Systems; nothing in the mechanism changes.
Least privilege applies per subagent: give each one only the tools its job needs. DocsAssistant’s destructive tool, opening a GitHub issue, belongs on exactly one agent behind an approval gate (see Human-in-the-Loop & Time Travel), not on every worker that happens to share a tool list. See LangChain — Subagents.
Handoffs
In the handoffs pattern behaviour changes according to state. A tool updates a state variable (current_step,
active_agent) that persists across turns, and the system reads the variable to apply a different configuration or
route to a different agent. Unlike a subagent, the active component talks to the user directly. The pattern suits
conversations that must collect information in a fixed order and support tickets that change hands.
The transition is a tool that returns a Command. The ToolMessage in the update is not optional: the model that
requested the tool call expects a matching response, and without one the history is malformed.
There are two ways to build it. The simpler one is a single agent with middleware: a wrap_model_call hook (see
Middleware) reads the step and overrides the system prompt and the tool list for
each model call.
from typing import Callable
from langchain.agents import AgentState, create_agent
from langchain.agents.middleware import ModelRequest, ModelResponse, wrap_model_call
from langchain.messages import ToolMessage
from langchain.tools import ToolRuntime, tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
from typing_extensions import NotRequired
from fake import ScriptedChat, call, AIMessage
class SupportState(AgentState):
current_step: NotRequired[str] # absent until the first handoff; the hook defaults it to "triage"
component: NotRequired[str | None]
@tool
def record_component(component: str, runtime: ToolRuntime) -> Command:
"""Record which documentation component the question is about, then move to the answer step."""
return Command(update={
"messages": [ToolMessage(content=f"Component recorded: {component}", tool_call_id=runtime.tool_call_id)],
"component": component,
"current_step": "answer",
})
@tool
def search_docs(query: str) -> str:
"""Search the documentation site and return matching sections."""
return "ann-indexes.adoc :: HNSW :: ef_search trades recall for latency."
STEPS = {
"triage": {"prompt": "Ask which documentation component the user means, then record it.", "tools": [record_component]},
"answer": {"prompt": "Answer from search_docs for the {component} component.", "tools": [search_docs]},
}
@wrap_model_call
def apply_step(request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]) -> ModelResponse:
step = STEPS[request.state.get("current_step", "triage")]
request = request.override(
system_prompt=step["prompt"].format(component=request.state.get("component")),
tools=step["tools"],
)
return handler(request)
seen = [] # what the model was offered on each call
class Recorder(ScriptedChat):
def bind_tools(self, tools, **kwargs):
seen.append([t.name for t in tools])
return self
agent = create_agent(
Recorder(messages=iter([
call("record_component", "h1", component="vector-rag"),
call("search_docs", "h2", query="ef_search"),
AIMessage(content="ef_search trades recall for latency (ann-indexes.adoc)."),
])),
tools=[record_component, search_docs],
state_schema=SupportState,
middleware=[apply_step],
checkpointer=InMemorySaver(),
)
out = agent.invoke({"messages": [{"role": "user", "content": "What is ef_search?"}]},
{"configurable": {"thread_id": "alice-1"}})
print("tools offered per model call:", seen)
print("current_step:", out["current_step"], "| component:", out["component"])
print(out["messages"][-1].content)
Output:
tools offered per model call: [['record_component'], ['search_docs'], ['search_docs']]
current_step: answer | component: vector-rag
ef_search trades recall for latency (ann-indexes.adoc).
Before the handoff the model was offered only record_component; afterwards only search_docs. The step is part of
the state, so a checkpointer keeps it across turns and the conversation resumes in the right step. Because the tools
of the later step are not even offered before the precondition is met, the ordering is enforced by construction, not
by asking the model to behave.
The second way is multiple agent subgraphs: distinct agents are nodes of one graph and the handoff tool uses
Command.PARENT to name the next node. Use it when a node is a complex graph of its own (reflection, retrieval
steps). It needs deliberate context engineering: the update must carry both the AIMessage that requested the handoff
and the acknowledging ToolMessage, or the receiving agent sees an unpaired tool call.
Command.PARENT handoffsfrom typing import Literal
from langchain.agents import AgentState, create_agent
from langchain.messages import AIMessage, ToolMessage
from langchain.tools import ToolRuntime, tool
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command
from typing_extensions import NotRequired
from fake import call, scripted
class TeamState(AgentState):
active_agent: NotRequired[str]
def handoff(target: str, description: str):
@tool(f"transfer_to_{target}", description=description)
def transfer(runtime: ToolRuntime) -> Command:
last_ai = next(m for m in reversed(runtime.state["messages"]) if isinstance(m, AIMessage))
ack = ToolMessage(content=f"Transferred to {target}", tool_call_id=runtime.tool_call_id)
return Command(goto=target, update={"active_agent": target, "messages": [last_ai, ack]},
graph=Command.PARENT)
return transfer
docs_agent = create_agent(
scripted(call("transfer_to_bug_agent", "t1"),
AIMessage(content="(never reached: the handoff ends this agent's run)")),
tools=[handoff("bug_agent", "Hand the conversation to the bug-report agent when the user reports a docs bug.")],
system_prompt="You answer questions about the documentation.",
)
bug_agent = create_agent(
scripted(AIMessage(content="I can file that. Which page has the wrong ef_search default?")),
tools=[handoff("docs_agent", "Hand back to the docs agent for ordinary questions.")],
system_prompt="You collect details for a documentation bug report.",
)
def route_after(state: TeamState) -> Literal["docs_agent", "bug_agent", "__end__"]:
last = state["messages"][-1]
if isinstance(last, AIMessage) and not last.tool_calls:
return END
return state.get("active_agent") or "docs_agent"
builder = StateGraph(TeamState)
builder.add_node("docs_agent", lambda s: docs_agent.invoke(s))
builder.add_node("bug_agent", lambda s: bug_agent.invoke(s))
builder.add_conditional_edges(START, lambda s: s.get("active_agent") or "docs_agent", ["docs_agent", "bug_agent"])
for node in ("docs_agent", "bug_agent"):
builder.add_conditional_edges(node, route_after, ["docs_agent", "bug_agent", END])
team = builder.compile()
out = team.invoke({"messages": [{"role": "user", "content": "The ef_search default in your page is wrong."}]})
for m in out["messages"]:
print(type(m).__name__, [c["name"] for c in m.tool_calls] if getattr(m, "tool_calls", None) else m.content)
print("active_agent:", out["active_agent"])
Output:
HumanMessage The ef_search default in your page is wrong.
AIMessage ['transfer_to_bug_agent']
ToolMessage Transferred to bug_agent
AIMessage I can file that. Which page has the wrong ef_search default?
active_agent: bug_agent
The conditional edge after each node ends the run when the last message is a plain answer and otherwise follows
active_agent, which is how control returns to the user after the bug agent replies. The official guidance is to
prefer the single-agent-with-middleware form for most handoffs and reach for subgraph handoffs only when a node needs
its own graph. The design of the state machine itself — steps, transitions, escape hatches — is the same
agent-swarm topology discussed in Multi-Agent Systems.
See LangChain — Handoffs for both forms and the customer-support tutorial.
Router
A router is a routing step that classifies the input and dispatches it to one or more specialised agents whose
answers are then synthesised. It fits distinct verticals — separate knowledge domains that each deserve their own
agent — and it is the natural shape for querying several sources in parallel. Use Command(goto=…) to send the
query to a single agent and Send to fan out to several, exactly as in the orchestrator-worker pattern of
LangGraph Workflows.
Command(goto=…)from typing import Literal, TypedDict
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command
class State(TypedDict):
query: str
answer: str
def route_query(state: State) -> Command[Literal["vector_rag", "spring_ai"]]:
"""Single-agent routing: Command(goto=...) names the next node and can update state at the same time."""
target = "spring_ai" if "spring" in state["query"].lower() else "vector_rag"
return Command(goto=target)
def vector_rag(state: State):
return {"answer": "vector-rag agent handled it"}
def spring_ai(state: State):
return {"answer": "spring-ai agent handled it"}
graph = (StateGraph(State)
.add_node("route_query", route_query)
.add_node("vector_rag", vector_rag).add_node("spring_ai", spring_ai)
.add_edge(START, "route_query")
.add_edge("vector_rag", END).add_edge("spring_ai", END)
.compile())
print(graph.invoke({"query": "PgVectorStore in Spring AI"})["answer"])
print(graph.invoke({"query": "HNSW ef_search"})["answer"])
Output:
spring-ai agent handled it
vector-rag agent handled it
To fan out to several agents at once, return a list of Send objects from the routing function instead. The next
example does that, with two DocsAssistant verticals, each an agent built with create_agent, and a synthesis node that
merges their answers through a reducer.
Sendimport operator
from typing import Annotated, TypedDict
from langchain.agents import create_agent
from langgraph.graph import END, START, StateGraph
from langgraph.types import Send
from fake import always
class Classification(TypedDict):
agent: str
query: str
class RouterState(TypedDict):
query: str
results: Annotated[list[str], operator.add]
final_answer: str
class AgentInput(TypedDict):
query: str
vector_rag = create_agent(always("pgvector: ef_search trades recall for latency."), tools=[],
system_prompt="You answer from the vector-rag documentation.")
spring_ai = create_agent(always("Spring AI: PgVectorStore exposes the same search request."), tools=[],
system_prompt="You answer from the Spring AI documentation.")
def classify(query: str) -> list[Classification]:
"""A real router asks the model (structured output); keywords keep this example deterministic."""
picked = []
if "pgvector" in query or "ef_search" in query:
picked.append({"agent": "vector_rag", "query": query})
if "spring" in query.lower():
picked.append({"agent": "spring_ai", "query": query})
return picked or [{"agent": "vector_rag", "query": query}]
def route_query(state: RouterState):
return [Send(c["agent"], {"query": c["query"]}) for c in classify(state["query"])]
def run(agent, label):
def node(state: AgentInput):
answer = agent.invoke({"messages": [{"role": "user", "content": state["query"]}]})["messages"][-1].content
return {"results": [f"[{label}] {answer}"]}
return node
def synthesize(state: RouterState):
return {"final_answer": "\n".join(state["results"])}
builder = StateGraph(RouterState)
builder.add_node("vector_rag", run(vector_rag, "vector-rag"))
builder.add_node("spring_ai", run(spring_ai, "spring-ai"))
builder.add_node("synthesize", synthesize)
builder.add_conditional_edges(START, route_query, ["vector_rag", "spring_ai"])
builder.add_edge("vector_rag", "synthesize")
builder.add_edge("spring_ai", "synthesize")
builder.add_edge("synthesize", END)
router = builder.compile()
print(router.invoke({"query": "Tune ef_search with pgvector, and show it in Spring AI", "results": []})["final_answer"])
print("---")
print(router.invoke({"query": "How do I tune ef_search?", "results": []})["final_answer"])
Output:
[vector-rag] pgvector: ef_search trades recall for latency.
[spring-ai] Spring AI: PgVectorStore exposes the same search request.
---
[vector-rag] pgvector: ef_search trades recall for latency.
The first query matched both verticals and produced two parallel results; the second matched one. The order of
parallel results is not guaranteed. In production classify is a model call with structured output (see
Structured Output) that returns the list of Classification items.
A bare router is stateless: each request is routed independently. For multi-turn use, the simplest fix is to wrap the whole router graph as a tool that a conversational agent calls, so the agent keeps the memory and the router stays stateless:
from langchain.agents import create_agent
from langchain.tools import tool
from fake import scripted, call, AIMessage
@tool
def search_docs(query: str) -> str:
"""Search across the documentation verticals and return a combined, cited answer."""
return router.invoke({"query": query, "results": []})["final_answer"] # the router graph built above
chat = create_agent(
scripted(call("search_docs", "q1", query="ef_search in pgvector"),
AIMessage(content="Higher ef_search improves recall at the cost of latency.")),
tools=[search_docs],
system_prompt="You are DocsAssistant. Use search_docs to answer.",
)
print(chat.invoke({"messages": [{"role": "user", "content": "ef_search?"}]})["messages"][2].content)
Output:
[vector-rag] pgvector: ef_search trades recall for latency.
The alternative, a router that persists its own history, needs custom history management and tends to feel inconsistent when agents have different tones; the official guide recommends the handoffs or subagents patterns for multi-turn conversations instead. A router is a preprocessing step, whereas a supervisor is a full agent that keeps context and chooses across several turns; pick the router when the input categories are clear and cheap classification is enough. See LangChain — Router.
Skills
The skills pattern packages specialised capabilities as prompts that one agent loads on demand. The agent stays in control and talks to the user throughout; a skill is a specialisation, lighter than a whole subagent. It is a form of progressive disclosure: the system prompt names the available skills, and the full instructions enter the context only when the agent asks for them. It is the same idea as the Agent Skills format used by coding assistants (compare Customizing AI Workflows).
load_skill tool for on-demand promptsfrom langchain.agents import create_agent
from langchain.tools import tool
from fake import call, scripted, AIMessage
SKILLS = {
"write_issue": "Draft a GitHub issue: title, steps to reproduce, expected vs actual, page path. Never open it yourself.",
"review_page": "Review an .adoc page: check xrefs, code blocks that run, and links to official docs.",
}
@tool
def load_skill(skill_name: str) -> str:
"""Load a specialised skill prompt by name.
Available skills:
- write_issue: draft a documentation bug report
- review_page: review an AsciiDoc page
"""
return SKILLS.get(skill_name, f"Unknown skill {skill_name!r}; choose one of {sorted(SKILLS)}")
agent = create_agent(
scripted(call("load_skill", "k1", skill_name="write_issue"),
AIMessage(content="Title: ef_search default is wrong on ann-indexes.adoc ...")),
tools=[load_skill],
system_prompt="You are DocsAssistant. You have two skills, write_issue and review_page; call load_skill to read one before using it.",
)
out = agent.invoke({"messages": [{"role": "user", "content": "The ef_search default is wrong, please file it"}]})
for m in out["messages"]:
print(type(m).__name__, m.tool_calls[0]["args"] if getattr(m, "tool_calls", None) else m.content)
Output:
HumanMessage The ef_search default is wrong, please file it
AIMessage {'skill_name': 'write_issue'}
ToolMessage Draft a GitHub issue: title, steps to reproduce, expected vs actual, page path. Never open it yourself.
AIMessage Title: ef_search default is wrong on ann-indexes.adoc ...
The skill body is returned as a tool result, so it costs tokens only after the agent chose it. The official guide lists
three extensions of the basic pattern: registering new tools when a skill loads (a skill can bring its own tools, using
the state and Command mechanics from the handoffs section), hierarchical skills that expose sub-skills in a tree, and
reference awareness, where a skill’s prompt points at files, scripts or templates the agent reads only when needed.
Skills are prompts, so treat skill text from an untrusted source as untrusted input; a skill that says "never open it
yourself" is a request to the model, not a control — the approval gate belongs on the tool. Deep Agents ships
skills as a built-in feature; see below. See LangChain — Skills.
Deep Agents
Deep Agents (deepagents) is a standalone library built on LangChain’s agent building blocks and run by the
LangGraph runtime. The official docs call it an agent harness: the same tool-calling loop as create_agent, shipped
with the capabilities long-running agents need so you do not assemble them yourself. Install it with
pip install deepagents; this page used 0.7.19.
The harness groups its capabilities into four areas: an execution environment (tools, a virtual filesystem behind a
pluggable backend, optional sandbox and permissions), context management (skills, memory, summarisation, context
offloading, prompt caching), delegation (planning and subagents) and steering (human-in-the-loop approvals through
interrupt_on, the LangGraph interrupt mechanism covered in
Human-in-the-Loop & Time Travel). The function that builds
one is create_deep_agent; it returns a compiled LangGraph graph, so it streams, checkpoints and deploys like any
other agent (see Streaming).
Deep Agents is a fit for long, open-ended tasks with many steps and large intermediate outputs, such as research or
code changes, where a plain agent’s context would fill up. For a short question-answering agent like DocsAssistant’s
chat loop, create_agent is the right size; the two compose well (see the
Agents with create_agent page for the smaller harness).
The planning tool
Planning is a lightweight to-do list kept in agent state. In the 0.7 line it is opt-in: since v0.7 the
write_todos tool is no longer added by default, and you enable it by passing LangChain’s TodoListMiddleware. The
list is worth having for long tasks, for less capable models that benefit from an accountability tool, and for
interfaces that show progress from state. Tasks have the status pending, in_progress or completed and live in
the todos key of the state.
from deepagents import create_deep_agent
from langchain.agents.middleware import TodoListMiddleware
from fake import scripted, AIMessage
def tools_of(agent):
return sorted(agent.get_graph().nodes["tools"].data.tools_by_name)
print("default :", tools_of(create_deep_agent(model=scripted(AIMessage(content="x")))))
print("with todos:", tools_of(create_deep_agent(model=scripted(AIMessage(content="x")),
middleware=[TodoListMiddleware()])))
Output:
default : ['delete', 'edit_file', 'execute', 'glob', 'grep', 'ls', 'read_file', 'task', 'write_file']
with todos: ['delete', 'edit_file', 'execute', 'glob', 'grep', 'ls', 'read_file', 'task', 'write_file', 'write_todos']
The default toolset is the filesystem tools plus the task tool for subagents; write_todos appears only with the
middleware. According to the docs the execute tool works when the backend is a sandbox or the local shell backend;
do not rely on it with the default in-memory backend. See
Deep Agents — Overview.
The virtual filesystem
The agent works with files instead of stuffing everything into the conversation: it writes notes, reads them back,
and keeps large tool outputs on the side. The default backend, StateBackend, stores the files in the graph state, so
they are scoped to a thread and persist across turns through the checkpointer. The next snippet shows planning and
the filesystem together in one run.
from deepagents import create_deep_agent
from langchain.agents.middleware import TodoListMiddleware
from fake import scripted, call, AIMessage
model = scripted(
call("write_todos", "c1", todos=[
{"content": "Read the ANN index page", "status": "in_progress"},
{"content": "Write the summary", "status": "pending"}]),
call("write_file", "c2", file_path="/notes/ann.md", content="HNSW ef_search trades recall for latency."),
call("read_file", "c3", file_path="/notes/ann.md"),
call("ls", "c4", path="/notes"),
AIMessage(content="Saved and re-read /notes/ann.md."),
)
agent = create_deep_agent(model=model, system_prompt="You are DocsAssistant.", middleware=[TodoListMiddleware()])
out = agent.invoke({"messages": [{"role": "user", "content": "Summarise the ANN page"}]})
for m in out["messages"]:
print(type(m).__name__, (m.content or m.tool_calls)[:120] if isinstance(m.content, str) else m.content)
print(out.keys())
print(out["todos"])
print(list(out["files"]))
Output:
HumanMessage Summarise the ANN page
AIMessage [{'name': 'write_todos', 'args': {'todos': [{'content': 'Read the ANN index page', 'status': 'in_progress'}, {'content': 'Write the summary', 'status': 'pending'}]}, 'id': 'c1', 'type': 'tool_call'}]
ToolMessage Updated todo list to [{'content': 'Read the ANN index page', 'status': 'in_progress'}, {'content': 'Write the summary', 'status': 'pending'}]
AIMessage [{'name': 'write_file', 'args': {'file_path': '/notes/ann.md', 'content': 'HNSW ef_search trades recall for latency.'}, 'id': 'c2', 'type': 'tool_call'}]
ToolMessage Updated file /notes/ann.md
AIMessage [{'name': 'read_file', 'args': {'file_path': '/notes/ann.md'}, 'id': 'c3', 'type': 'tool_call'}]
ToolMessage @@ lines 1-1 of 1 @@
HNSW ef_search trades recall for latency.
AIMessage [{'name': 'ls', 'args': {'path': '/notes'}, 'id': 'c4', 'type': 'tool_call'}]
ToolMessage ['/notes/ann.md']
AIMessage Saved and re-read /notes/ann.md.
dict_keys(['messages', 'files', 'todos'])
[{'content': 'Read the ANN index page', 'status': 'in_progress'}, {'content': 'Write the summary', 'status': 'pending'}]
['/notes/ann.md']
The state carries two extra keys, todos and files, next to messages. Files live in the graph state, so they show
up in the streamed state and the checkpoint without any disk access.
The backend is a pluggable object. Besides StateBackend there are FilesystemBackend (local disk; the docs advise
virtual_mode=True with root_dir so paths cannot escape the root), StoreBackend (a LangGraph store, shared
across threads), sandboxes and a local shell backend. A CompositeBackend routes path prefixes to different backends,
which is the standard way to get both: thread-scoped scratch space and durable, cross-thread /memories/. A
FilesystemPermission rule then restricts what the agent may touch, for example making a policies directory
read-only.
/memories/ across threads, and a write-protected /policies/from deepagents import create_deep_agent, FilesystemPermission
from deepagents.backends import CompositeBackend, StateBackend, StoreBackend
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore
from fake import scripted, call, AIMessage
store = InMemoryStore()
def build(model):
return create_deep_agent(
model=model,
backend=CompositeBackend(
default=StateBackend(),
routes={"/memories/": StoreBackend(namespace=lambda _rt: ("docsassistant", "memories"))},
),
permissions=[FilesystemPermission(operations=["write"], paths=["/policies/**"], mode="deny")],
store=store,
checkpointer=InMemorySaver(),
)
a1 = build(scripted(
call("write_file", "c1", file_path="/scratch/draft.md", content="thread-only draft"),
call("write_file", "c2", file_path="/memories/style.md", content="Cite the page path."),
call("write_file", "c3", file_path="/policies/rules.md", content="nope"),
AIMessage(content="done"),
))
out = a1.invoke({"messages": [{"role": "user", "content": "save notes"}]}, {"configurable": {"thread_id": "t1"}})
print([m.content for m in out["messages"] if m.type == "tool"])
print("state files:", list(out["files"]))
print("store items:", [i.key for i in store.search(("docsassistant", "memories"))])
a2 = build(scripted(call("ls", "c1", path="/"), call("read_file", "c2", file_path="/memories/style.md"),
AIMessage(content="ok")))
out = a2.invoke({"messages": [{"role": "user", "content": "what do you remember?"}]}, {"configurable": {"thread_id": "t2"}})
print([m.content for m in out["messages"] if m.type == "tool"])
Output:
['Updated file /scratch/draft.md', 'Updated file /memories/style.md', 'Error: permission denied for write on /policies/rules.md']
state files: ['/scratch/draft.md']
store items: ['/style.md']
['/memories/']
@@ lines 1-1 of 1 @@
Cite the page path.
The scratch file stayed in the state of thread t1, the memory file went to the store, and the write to /policies/
was refused with an error the model can read. A second agent on a different thread listed /memories/ and read the
note back. The store here is in memory; production uses a persistent store (see
Long-Term Memory Stores). Paths under the composite backend’s routes
are what separates the two kinds of memory: the same distinction the AI Agents page draws in
Memory. See
Deep Agents — Backends and
Deep Agents — Permissions.
Subagents in Deep Agents
Every deep agent has a built-in task tool that spawns an ephemeral subagent for an isolated, long-running or
parallel subtask. A general-purpose subagent is available by default. Add specialised ones with the subagents
parameter, either as dictionaries or as pre-built graphs. The subagent gets a fresh context, works autonomously and
returns one final report; the main agent’s context never sees its intermediate tool calls.
A dictionary subagent has these fields: name, description (the text the main agent uses to decide to delegate) and
system_prompt are required for the default isolated mode; tools, model, middleware, interrupt_on,
skills, permissions, response_format and mode are optional. A subagent that does not set tools inherits the
main agent’s, so list only the tools it needs. permissions replaces the parent’s rules, and skills are not
inherited by custom subagents.
task toolfrom deepagents import create_deep_agent
from langchain_core.tools import tool
from fake import scripted, call, AIMessage
@tool
def search_docs(query: str) -> str:
"""Search the documentation site."""
return "ann-indexes.adoc :: HNSW :: ef_search trades recall for latency."
docs_researcher = {
"name": "docs-researcher",
"description": "Searches the documentation site in depth and returns a short, cited summary.",
"system_prompt": "You search the docs. Reply with at most five lines and name each page.",
"tools": [search_docs],
}
model = scripted(
call("task", "c1", description="Find what HNSW ef_search does", subagent_type="docs-researcher"), # main agent
call("search_docs", "s1", query="HNSW ef_search"), # subagent
AIMessage(content="ef_search sets the candidate list size (ann-indexes.adoc)."), # subagent final
AIMessage(content="HNSW ef_search: candidate list size; see ann-indexes.adoc."), # main final
)
agent = create_deep_agent(model=model, subagents=[docs_researcher], system_prompt="You are DocsAssistant.")
out = agent.invoke({"messages": [{"role": "user", "content": "What does ef_search do?"}]})
for m in out["messages"]:
print(type(m).__name__, m.tool_calls if getattr(m, "tool_calls", None) else m.content)
Output:
HumanMessage What does ef_search do?
AIMessage [{'name': 'task', 'args': {'description': 'Find what HNSW ef_search does', 'subagent_type': 'docs-researcher'}, 'id': 'c1', 'type': 'tool_call'}]
ToolMessage ef_search sets the candidate list size (ann-indexes.adoc).
AIMessage HNSW ef_search: candidate list size; see ann-indexes.adoc.
The main agent’s history has the task call and the subagent’s final report, but not the search_docs call the
subagent made: that is the context isolation the harness exists to provide. The task tool takes a description and
the name of the subagent in subagent_type.
When the subagent needs more than a prompt and a tool list, wrap any compiled LangGraph graph (for example the
corrective RAG graph from LangGraph Workflows) as a CompiledSubAgent
with a name, a description and a runnable:
from deepagents import CompiledSubAgent, create_deep_agent
from langchain.agents import create_agent
from fake import AIMessage, call, scripted
corrective_rag = create_agent(
scripted(AIMessage(content="Corrective RAG result: ef_search (ann-indexes.adoc).")),
tools=[], system_prompt="Answer only from retrieved documentation.")
rag_subagent = CompiledSubAgent(
name="corrective-rag",
description="Answers documentation questions with a graded, self-correcting retrieval loop.",
runnable=corrective_rag,
)
agent = create_deep_agent(
model=scripted(call("task", "c1", description="What does ef_search do?", subagent_type="corrective-rag"),
AIMessage(content="ef_search sets the HNSW candidate list size.")),
subagents=[rag_subagent],
)
out = agent.invoke({"messages": [{"role": "user", "content": "What does ef_search do?"}]})
print([m.content for m in out["messages"] if m.type == "tool"])
print(out["messages"][-1].content)
Output:
['Corrective RAG result: ef_search (ann-indexes.adoc).']
ef_search sets the HNSW candidate list size.
Both forms accept mode="fork" (the default is "isolated"; forking is in beta and needs deepagents 0.7.13 or later), which makes the subagent inherit the parent’s
conversation instead of seeing only the delegated task; use it when the subagent genuinely needs the context and
accept the larger prompt. For work that should run in the background while the main agent keeps talking, the library
also exports AsyncSubAgent; the official async subagents page covers it. The best-practice advice on the
subagents page matches the tool design rules of Tool Design: write specific
descriptions, keep system prompts detailed, minimise each subagent’s tool set, choose a cheaper model per task where
it is enough, and have subagents return concise results. Subagent and main-agent activity can be told apart in
streams and in traces by the subagent name (see Observability with LangSmith).
Skills in Deep Agents
Deep Agents implements the skills pattern with the Agent Skills layout: a directory per skill containing a SKILL.md
file with name and description frontmatter, followed by instructions. The skills parameter takes a list of
source directories, each one a container of skill directories. At startup the harness puts only each skill’s name and
description into the system prompt; the agent reads the full SKILL.md with read_file when a task matches. With
the default StateBackend, provide the skill files through the files input, formatted with create_file_data.
from deepagents import create_deep_agent
from deepagents.backends.utils import create_file_data
from fake import ScriptedChat, call, AIMessage
SKILL = """---
name: docs-bug-report
description: Use when the user reports a wrong or missing statement in the documentation and wants an issue drafted.
---
# docs-bug-report
1. Name the page path and section.
2. Quote the wrong statement and the correct one.
3. Draft the issue text; never open it without approval.
"""
prompts = []
class Spy(ScriptedChat):
def _generate(self, messages, *a, **k):
prompts.append(messages[0].content if isinstance(messages[0].content, str) else str(messages[0].content))
return super()._generate(messages, *a, **k)
agent = create_deep_agent(
model=Spy(messages=iter([call("read_file", "k1", file_path="/skills/docs-bug-report/SKILL.md"),
AIMessage(content="Drafted the issue following the skill.")])),
skills=["/skills/"],
)
out = agent.invoke({
"messages": [{"role": "user", "content": "The ef_search default is wrong, draft an issue"}],
"files": {"/skills/docs-bug-report/SKILL.md": create_file_data(SKILL)},
})
p = prompts[0]
i = p.find("docs-bug-report")
print(p[i-40:i+200].replace("\n", " | "))
print([m.content[:60] for m in out["messages"] if m.type == "tool"])
Output:
achine.\n\n**Available Skills:**\n\n- **docs-bug-report**: Use when the user reports a wrong or missing statement in the documentation and wants an issue drafted.\n -> Read `/skills/docs-bug-report/SKILL.md` for full instructions\n\n**How
['@@ lines 1-8 of 8 @@\n---\nname: docs-bug-report\ndescription: Use when the user reports a wrong or missing statement in the documentation and wants an issue drafted.\n']
The system prompt carried the one-line description only; the body came in through read_file after the agent chose the
skill. See Deep Agents — Skills.
Deep Agents or your own graph
Use create_agent when one agent with a few tools is enough, plain LangGraph when you need to control the flow, and
create_deep_agent when a task is long enough that planning, offloaded files and delegated subagents pay for
themselves. Everything in Deep Agents is built from pieces this section has already covered: write_todos is a middleware
(see Middleware), the filesystem is state or a store, and task is the
single-dispatch subagent tool from earlier, with the registry built in. That also means the security rules are the same:
scope tools per subagent, use permissions for write access, and put interrupt_on approval in front of destructive
tools.
Summary
| Concept | LangChain / LangGraph | Deep Agents |
|---|---|---|
Isolated worker |
A |
|
Registry of workers |
One |
|
State-driven stages |
|
Not a built-in concept |
Classification then dispatch |
|
Not a built-in concept |
On-demand specialisation |
A |
|
Planning |
Your own state and tools |
|
Working memory outside the prompt |
State, or a store through tools |
A virtual filesystem with pluggable backends and permissions |
More patterns and the reasoning behind them are in Multi-Agent Systems, the graph mechanics in LangGraph Fundamentals, and the production concerns in Guardrails & Security.