Deep Agents, from the inside · Part 3
How a tool call works, and one turn of the loop
Every feature in Deep Agents, from files to subagents to approvals, reaches the model through one mechanism, the tool call. This part builds that mechanism from first principles, then follows a single request around the agent loop and names the six places where the harness can step in.
Part 3 of 16 in Deep Agents, from the inside
- What Deep Agents is, and where it came from
- Your first deep agent, and the prompt it really sees
- How a tool call works, and one turn of the loop
- How middleware wraps every call17 Nov 2026
- The tools the model gets, from files to MCP and planning20 Nov 2026
- State, checkpoints, and why big results go to files24 Nov 2026
- Summarization, overflow recovery and prompt caching27 Nov 2026
- Delegation with the task tool and your own subagents1 Dec 2026
- Parallel, async and streamed subagents4 Dec 2026
- Storage, backends and routing8 Dec 2026
- Interrupts, approvals and filesystem permissions11 Dec 2026
- Skills and memory15 Dec 2026
- Profiles, rubric grading and writing your own middleware18 Dec 2026
- Recipes, guardrails and the code interpreter22 Dec 2026
- Shipping it, with deployment, observability and the usual gotchas26 Dec 2026
- Reading the repo, with its tests, a reading path and a glossary29 Dec 2026
Written against deepagents 0.7.35, langchain 1.4.10 and langgraph 1.2.20, read from main at commit 31649f8 on 12 Nov 2026. The project ships several releases a week, so if something here disagrees with the code you have, trust the code.
A model can't run anything
It's easy to say "the agent searched the web" or "the agent wrote a file", and it's easy to forget that the model did neither. A language model takes text in and produces text out. It cannot open a socket, read a disk or run a function.
What a model can do is ask. Show it a list of tools, each with a name, a description and a schema for its arguments, and it can answer with a structured request, something like "please call get_weather with city="Oslo"." Your program reads that request, runs the real function, and sends the result back as a new message. The model then continues, now knowing the answer. Research showed early on that models could learn when and how to call tools like this (Schick et al., "Toolformer", Feb 2023), and today every major provider exposes it as a first-class API feature.
LangChain gives that exchange two message types, an AIMessage that carries tool_calls, and a ToolMessage that carries a result. create_agent wires them into a LangGraph graph using exactly the conditional-edge pattern you may already have written by hand. The animation follows one question, "What's the weather in Oslo?", through that graph. Watch the left side (the graph) and the right side (the messages it produces) at the same time.
- START, the question enters stateThe run begins with the user's message in
state["messages"]. That list is the whole memory of the conversation, and every step below appends to it. - The model node calls the model, with the tool schemasThe model node sends the messages to the chat model, together with every tool's name, description and argument schema. The schema at the bottom right is what the model is shown for
get_weather, generated from the function's type hints and docstring. - The model replies with a request, not a resultThe reply is an
AIMessagewhosetool_callseach carry anid, a toolnameandargs. Nothing has run yet. The model has only written down what it would like to happen. - A conditional edge decides where to goThe routing function looks at the last message. It has tool calls, so the run goes to the tools node. This is the same
if last_message.tool_callsrouting you'd write yourself in LangGraph. - The tools node runs the functionThe tools node first checks the arguments against the schema, then calls
get_weather, then appends aToolMessagewith the sametool_call_id. That shared id is how the model matches each result to its request, which matters when it asked for several things at once. - Back to the model, which can now see the resultA plain edge leads from the tools node back to the model node. On this second call the history contains the request and its result.
- No more tool calls, so the run endsThis time the reply is ordinary text with no tool calls. The conditional edge sends the run to
END, and the caller gets the final state, with all four messages in it.
That is all there is to it. An "agent" is this loop running until the model stops asking for things. Everything else is about what the model is shown, which tools exist, and what happens around each call.
Writing a tool
Now that the exchange is clear, defining a tool is mostly about deciding what the model will be shown. Below are two of them. One returns a plain value, and the other also changes graph state.
from langchain.tools import tool, ToolRuntime
from langgraph.types import Command
from langchain_core.messages import ToolMessage
@tool
def get_weather(city: str, units: str = "metric") -> str:
"""Current weather for a city. Use for questions about today's weather."""
return weather_api(city, units) # the return value becomes ToolMessage.content
@tool
def remember_city(city: str, runtime: ToolRuntime) -> Command:
"""Remember the user's home city for later turns."""
return Command(update={
"home_city": city, # write to graph state
"messages": [ToolMessage(f"Saved {city}.", tool_call_id=runtime.tool_call_id)],
})
Each detail of those two functions maps to something the model or the graph relies on.
- The schema comes from the signature. The function name, type hints and docstring become the name, argument schema and description the model sees. Treat the docstring as a prompt that says when to use the tool as well as what it does. For full control, pass a Pydantic model as
args_schema. - Some arguments are hidden from the model. A parameter typed
ToolRuntimenever appears in the schema. It's filled in at call time and gives the tool access to state, context, the store, a stream writer and its owntool_call_id. - Changing state means returning a
Command.remember_cityreturnsCommand(update=...), and its update must include aToolMessagewith the matching id, because every tool call needs a result. Leave it out and the history has a request with no answer. - Several calls can run at once. One
AIMessagecan contain several tool calls. The tools node runs them concurrently and appends oneToolMessagefor each. - Errors split two ways. If the arguments fail schema validation, the error goes back to the model as a
ToolMessage, so it can correct itself and retry. Any other exception propagates and fails the whole run, unless you return an error string yourself or addToolRetryMiddlewareorToolErrorMiddleware. - Some tools run on the provider's side. Provider-native tools, such as a hosted web search, are passed as plain dicts in
tools=. Each provider defines its own dict shape.
Everything Deep Agents gives the model arrives through this one mechanism. The file tools are tools. task, which launches a subagent, is a tool. And an approval is nothing more than a pause between the AIMessage and the tools node.
One turn of the loop, with the hooks showing
The graph above had two nodes. That's the skeleton. What create_agent really compiles has six hooks around those nodes. These are fixed points where middleware can run code before, after or around the work. Every Deep Agents feature is middleware attached at one or more of these points, so learning them is like learning the floor plan of a building before you go looking for a room.
The next animation follows a request through two model calls, one that asks for a tool and one that answers. The pill-shaped boxes are the hooks (the legend at the top left shows one).
- Input arrivesA request comes in with
messages, and optionallyfilesto seed the virtual filesystem (the trick from part 2). before_agent, once per runThis hook runs a single time at the start.PatchToolCallsrepairs any tool calls left without results (say, from a crashed earlier run),MemoryloadsAGENTS.md, andSkillsscans the skill folders. Memory and skills only load the first time in a thread.before_model, before every model callFrom here on, hooks run on every lap.Skillsinjects pinned skills here, and async subagents pull in any results that have finished.wrap_model_call, around the modelThe busiest hook. It can rewrite the request before the model sees it by summarizing history past 85% of the window, adding the skills and memory sections, marking cache points and filtering tools. Then the model is called.after_model, after the model asks for a toolThe model's reply has tool calls. If any of those tools is listed ininterrupt_on, this is where the run pauses for a person. Nothing has executed yet, so a person can still approve, edit or reject.wrap_tool_call, around each toolEach tool call is wrapped individually.Filesystemchecks permissions here, and if a result is bigger than 20,000 tokens, moves it into a file and gives the model a short pointer instead. Several calls in one message run in parallel.- Results appended, back to
before_modelThe tool results are added tomessagesand the loop goes round again, throughbefore_modela second time. - The second model call returns an answerThrough
wrap_model_callandafter_modelonce more, but this time the model answers without asking for any tools, so the run heads for the exit. after_agent, once at the endThe closing hook runs a single time. If you addedRubricMiddleware, it can grade the answer here and, if it falls short, send the agent back to work.- Output, the final stateThe run returns its final state with
messages,files(with the default backend) andstructured_responseif you passedresponse_format.
Two of those hooks run once per run, and four run on every lap. That difference shapes where things belong. Loading memory belongs at the start, once. Summarizing belongs around every model call, because the history grows with each lap. Approvals belong right after the model speaks and right before anything executes.
The table below is the same floor plan written out, with the Deep Agents middleware that lives on each hook. You don't need to memorize it. Come back to it when a later part says "this runs in wrap_tool_call" and you want to place it.
| Hook | Runs | Can do | Deep Agents middleware using it |
|---|---|---|---|
before_agent | once per invoke | update state | PatchToolCalls, Memory (load), Skills (scan), async subagents, Rubric |
before_model | before each model call | update state, jump | Skills (pinned skills), async subagents (pull results) |
wrap_model_call | around each model call | rewrite the request, retry, replace the response | Filesystem, SubAgent, Summarization, Skills, Memory, prompt caching, unsupported content, refusal, tool exclusion, parent notifier, async subagents |
after_model | after each model call | update state, interrupt, jump | Human in the loop, async subagents |
wrap_tool_call | around each tool call | rewrite the call, short-circuit, transform the result | Filesystem (permissions, eviction), Human in the loop (edits), Skills (skill tools), tool exclusion |
after_agent | once at the end | update state, jump back | Rubric, parent notifier |
Look at how crowded wrap_model_call is. Eleven pieces of middleware all want a say in what the model sees. When that many layers wrap the same call, the order they nest in starts to matter a great deal. A summary written before the memory section is added produces a different request from one written after. A cache marker placed before a section that changes on every call is wasted.
Next
That ordering problem is the subject of part 4. We'll see how middleware nests like the layers of an onion around each call, why the response travels back out through the same layers in reverse, and the exact order of the full Deep Agents stack for the main agent and its subagents.