← Back to Guides
3

Series

Building Real-World Agentic AI Systems with LangGraph· Part 3

GuideFor: AI Engineers, ML Engineers, Platform Engineers, AI Systems Architects

LangGraph create_agent: The Graph Isn't the Runtime

Bind three tools or three hundred - the rendered diagram is byte-identical. The constraints that actually decide the next step live in middleware nobody draws.

#langgraph#langchain#create-agent#middleware#mcp#tool-calling#agent-architecture

Atlas has twenty-three tools now, all bound through LangChain's create_agent. One of them is issue_refund.

Someone asks for the architecture diagram before the change goes out. You run the thing every LangGraph tutorial tells you to run:

code
print(agent.get_graph().draw_mermaid())

Four nodes. __start__, model, tools, __end__.

It had four nodes back when Atlas had one tool. It has four nodes now. I checked this on langchain 1.3.14 with one tool, three tools, and twenty tools, and the rendered output is not similar across the three - it is byte-identical. Same node count, same edges, same string.

So the diagram in the review is technically accurate and tells your reviewer nothing about the only question that matters: under what conditions can this agent move money.

The graph create_agent renders is not the transition relation you get

Part 1 argued that a while loop is a runtime whose guarantees were never specified, and that an explicit graph specifies them by constraining which transitions are legal from which state. That argument holds. This part is about what happens to it the moment you adopt create_agent, which is the default path in every LangChain 1.x codebase I have reviewed this year.

In create_agent, the rendered graph and the real transition relation are two different objects. The rendered graph has exactly three routing destinations, permanently. The real transition relation is assembled from three things that no artifact your framework renders will show you: the tool set resolved inside wrap_model_call on each individual model call, the refusals in wrap_tool_call, and the nesting that your middleware list order produces over both. Those are guards written in Python, evaluated per call, and drawn by nothing.

The consensus this challenges is quieter than "graphs are good". The LangChain docs describe create_agent plainly, as a model calling tools in a loop. The diagram says something grander, and people believe the diagram. The picture shows the loop's skeleton. Everything load-bearing moved out of frame.

None of this argues against create_agent. I use it. It is the best default abstraction LangChain has shipped. The problem is narrower: the review artifact you have been trusting stopped covering the thing you were using it to cover, and nobody announced the change.

Identifiers used but not defined below live in the companion repository: the tool implementations kb_lookup, ticket_lookup, order_status and issue_refund, the kb, ticket_api and billing handles, Evidence, and the AtlasState schema Part 1 established. All code was checked against langchain 1.3.14, langchain-core 1.5.1, langgraph 1.2.9 and langchain-mcp-adapters 0.3.0 in July 2026.

What create_agent compiles to

create_agent is the successor to create_react_agent, which it replaces as the documented entry point in LangChain 1.x. It returns a CompiledStateGraph, so every mechanic from Part 2 applies unchanged. It imports ToolNode straight from langgraph.prebuilt.tool_node. The node set is small and fixed:

  • model
  • tools, added only when tools exist
  • one node per before_agent, before_model, after_model, and after_agent hook, named f"{middleware.name}.{hook}"

Nothing else gets added, on 1.3.14, including with response_format set - which is the configuration most likely to break that claim, so I checked it rather than assuming.

The routing vocabulary is equally fixed. From langchain/agents/middleware/types.py:

code
JumpTo = Literal["tools", "model", "end"]

Three destinations. No jump target names a specific tool, however many you bind. The graph's transition targets and the agent's action space are different sets, and only the smaller one is addressable by the routing layer.

One more thing, before you design around it:

code
>>> import inspect>>> from langchain.agents import create_agent>>> "tool_choice" in inspect.signature(create_agent).parametersFalse

create_agent takes no tool_choice parameter at all. Part 1 conceded that tool_choice narrows the next-step space without a graph. In LangChain 1.x that concession routes through middleware, because ModelRequest.tool_choice is the only place create_agent's own API surface exposes it. Provider model_kwargs is a way around that, and response_format sets it behind your back, which comes up later.

The wrong way: a long tool list and a prompt that asks nicely

This is the shape most Atlas-like agents ship in.

code
from langchain.agents import create_agentagent = create_agent(    "anthropic:claude-sonnet-5",    tools=[kb_lookup, ticket_lookup, order_status, issue_refund],    system_prompt=(        "You are Atlas, a customer support agent. "        "Only call issue_refund after you have confirmed the ticket exists "        "and the customer is eligible."    ),)

Read the system prompt as a specification and it says something precise: issue_refund is reachable only from a state where the ticket is confirmed and eligibility is checked. That is a shape bound. It is exactly the constraint the graph in Part 1 enforced with a guarded edge.

Except nothing here enforces it. Here is a trajectory this agent will produce, violating the specification on turn one. I have not shipped an agent that did this to a customer. I built one that would, which is why the transcript below is constructed rather than pulled from an incident report:

code
HumanMessage:  "refund my last order, ticket 8812"AIMessage:     tool_calls=[issue_refund(ticket_id="8812", cents=4999)]ToolMessage:   "REFUNDED 4999 on 8812"AIMessage:     "Done - I've refunded $49.99 to your original payment method."

No ticket_lookup ran, and eligibility was never checked. refund_allowed was never written, because nothing ever called billing.refund_allowed. The model read a ticket number out of the user's message, decided it had what it needed, and moved money.

Nothing in the run failed. There was no exception to catch and nothing to retry. The checkpointer recorded every step faithfully, which is Part 1's Backward-Only Durability doing exactly what it promises and nothing more. The graph rendered fine before the deploy and renders fine after.

Part 1 proposed a demarcation criterion for this, and it is the right test to apply:

A mechanism specifies the runtime if the constraint holds regardless of whether the model complies.

A system prompt fails that test on the first word. This is the advisory-versus-enforced line in a different costume, and it does not move just because the guidance is now called a system prompt. The model decides whether to follow it, on every call, and the failure is silent. Meanwhile the tool list passes a much weaker version of the test, and it passes it at dispatch rather than at emission: the runtime will not execute a name that is not in ToolNode's registry. That distinction looks pedantic here. Two sections from now it is the whole argument. Binding enforces something. What it enforces is much weaker than what the prompt describes.

Alphabet bound: a third kind of bound on your tool list

Part 1 separated a length bound (recursion_limit caps how far a run goes, never where) from a shape bound (which transitions are legal from a given state). A static tool list is neither. It deserves its own name, and the name is already standard vocabulary in formal language theory, so I am not coining anything here - I am borrowing it:

A static tool list is an alphabet bound. It is the closed action vocabulary a tool registry gives you, and it fixes which actions exist.

Two alphabets are about to come apart, so name them now. The dispatch alphabet is ToolNode's registry: static, fixed at construction, and it holds whatever the model does. The offered alphabet is request.tools on one model call, and it does not. They are the same set until you add middleware, and the gap between them is most of this article. It says nothing about when any of them may be taken, in what order, or how many times.

Bind {kb_lookup, ticket_lookup, order_status, issue_refund} and you have specified an alphabet of four symbols. Every string over that alphabet is legal. issue_refund on the first turn, before any lookup, is a legal string. issue_refund eleven times is a legal string. The alphabet bound rules out one thing only: a symbol that is not in the alphabet.

This is the precise sense in which Part 1's claim that a tool-calling loop's transition relation is "complete over the bound tool set" survives the move to create_agent. The prebuilt agent added ergonomics and durability, plus a picture that looks like a specification.

The three bounds, side by side:

BoundMechanismConstrainsHolds without model compliance
Lengthrecursion_limithow many superstepsYes
Alphabetthe bound tool listwhich actions the runtime will dispatch at allYes
Shape, execution-sidea guarded edge, or a wrap_tool_call refusalwhich action is legal from which stateYes
Shape, emission-siderequest.override(tools=...)which action the model is offeredNo - a replayed or re-emitted call still dispatches
None of the abovesystem prompt, tool docstringnothingNo

Only the last row is advisory, and it is where most teams write their refund policy.

Run any constraint you think you have through this:

mermaid
flowchart TD
    Q1{"Will the runtime refuse it<br/>when the model ignores it?"}
    Q1 -- No --> Q4{"Does it change what<br/>the model is offered?"}
    Q4 -- No --> ADV["ADVISORY<br/>system prompt, docstring<br/>not a constraint"]
    Q4 -- Yes --> EMIT["EMISSION-SIDE ONLY<br/>request.override(tools=...)<br/>a replayed call still dispatches"]
    Q1 -- Yes --> Q2{"Does it depend<br/>on current state?"}
    Q2 -- No --> Q3{"Does it limit<br/>actions or steps?"}
    Q3 -- "actions" --> ALPHA["ALPHABET BOUND<br/>the bound tool list"]
    Q3 -- "steps" --> LEN["LENGTH BOUND<br/>recursion_limit"]
    Q2 -- Yes --> SHAPE["SHAPE BOUND<br/>guarded edge, or<br/>a wrap_tool_call refusal"]
    SHAPE --> WARN["and if it lives in middleware,<br/>it is an Unrendered Edge"]
    EMIT --> WARN

    style Q1 fill:#7B68EE,color:#FFFFFF
    style Q2 fill:#7B68EE,color:#FFFFFF
    style Q3 fill:#7B68EE,color:#FFFFFF
    style Q4 fill:#7B68EE,color:#FFFFFF
    style ADV fill:#E74C3C,color:#FFFFFF
    style EMIT fill:#E74C3C,color:#FFFFFF
    style ALPHA fill:#FFD93D,color:#2C2C2A
    style LEN fill:#FFD93D,color:#2C2C2A
    style SHAPE fill:#6BCF7F,color:#2C2C2A
    style WARN fill:#FFA07A,color:#2C2C2A

The two red boxes are where reviews go wrong, mine included. A tool list feels like a constraint because you wrote it down yourself, and because deleting an entry really does change behaviour. override(tools=...) feels like one for the same reason, and it is the more expensive mistake of the two, because it looks like a guard in code review.

The right way: pair wrap_model_call with a wrap_tool_call refusal

create_agent does give you a state-dependent offered alphabet, in one line of documented, first-party API, and it lives in wrap_model_call. Here is the version I wrote first. It is wrong, and the way it is wrong took me an experiment to see:

code
class RefundGate(AgentMiddleware):    """First attempt. Narrows what the model is offered. Not a guard."""    def wrap_model_call(self, request, handler):        tools = request.tools or []        if not _refund_reachable(request.state):            tools = [t for t in tools if getattr(t, "name", None) != "issue_refund"]        return handler(request.override(tools=tools))

Apply the demarcation criterion and it looks like it passes. When _refund_reachable returns False, issue_refund is not bound for that call, and a model cannot emit a call for a tool it was never given.

Then check what the compiled graph still holds:

code
>>> agent = create_agent("anthropic:claude-sonnet-5", tools=[kb_lookup, issue_refund],...                      middleware=[RefundGate()])>>> sorted(agent.nodes["tools"].bound.tools_by_name)['issue_refund', 'kb_lookup']

ToolNode is built once, from the static tools= argument. request.override(tools=...) changes what goes to the provider on one call. It does not change the dispatch table. So if an issue_refund call reaches the tools node by any route other than this model call, it runs. I drove the agent's own compiled ToolNode with a hand-built AIMessage carrying that call:

code
ToolMessage content: 'REFUNDED 4999 on 8812'

The refund executed, with the gate installed and returning False.

Those routes are not hypothetical, and the two strongest are first-party. jump_to="tools" is a documented routing target, established four sections up, with no per-tool granularity: it enters the dispatch table without a model call happening at all, so wrap_model_call never fires. A checkpoint resumed with a pending tool task does the same thing for the same reason. Beyond those, a model can re-emit a tool name it can see in messages from an earlier turn, and by turn seven Atlas has a successful issue_refund call in its own history - I have not measured how often that happens, so treat it as a mechanism rather than a rate. And this article's MCP section is about tool descriptions being attacker-reachable text.

So override(tools=...) is an emission-side narrowing. It changes what the model is offered. Part 1's criterion asks what happens when the model does not comply, and the honest answer for this pattern is: nothing stops it. Part 1 drew a line between invalid action masking, which lives inside the model, and an edge set, which is what the runtime will refuse to execute, and said the gap "is why one of them survives a jailbreak." The first version above is masking wearing an edge's clothes.

The pattern that actually satisfies the criterion is the pair, and the second half is a gated execution layer rebuilt inside middleware:

code
from langchain.agents.middleware import AgentMiddlewarefrom langchain_core.messages import ToolMessageclass RefundGate(AgentMiddleware):    """Emission-side narrowing AND execution-side refusal. Both halves needed.    Ordering is load-bearing and it pulls in two directions.    `wrap_model_call` wants to be LAST: `wrap_*` nests first-outermost, so a    later middleware sits closer to the model and gets the final word on    `request.tools`.    `wrap_tool_call` wants to be FIRST: a handler may return a ToolMessage    without calling handler(), so any OUTER wrap_tool_call that short-circuits    (a cache, a replay shim, a rate limiter) bypasses an inner refusal entirely.    With exactly one wrap_tool_call middleware in the list, position does not    matter for the refusal. With more than one, split this class in two and put    the refusal at the outer end.    """    def wrap_model_call(self, request, handler):        """Keeps the tool out of the prompt, so the model rarely asks."""        tools = request.tools or []        if not _refund_reachable(request.state):            tools = [t for t in tools if getattr(t, "name", None) != "issue_refund"]        return handler(request.override(tools=tools))    def wrap_tool_call(self, request, handler):        """Refuses the call if it arrives anyway. This is the actual guard.        Reads the call's own arguments, not just state. A state-only check        answers "was some refund authorised?" - never "was *this* refund        authorised?"        """        if request.tool_call["name"] == "issue_refund":            args, state = request.tool_call["args"], request.state            authorised = (                state.get("refund_allowed") is True                and state.get("refund_checked_for")                == (args.get("ticket_id"), args.get("cents"))            )            if not authorised:                return ToolMessage(                    "refund not permitted: entitlement not verified "                    "for this ticket and amount",                    tool_call_id=request.tool_call["id"],                )        return handler(request)def _refund_reachable(state) -> bool:    """Part 1's guard - without Part 1's mandatory predecessor.    Part 1's guarded mandatory predecessor needed two halves: a single inbound    source, and an edge guarded by state. `create_agent` gives you no mandatory    predecessor at all, since `tools` is reachable from `model` unconditionally.    Only the guard half survives, which is exactly why it needs the    execution-side check above.    """    ticket = state.get("ticket_id")    return (        ticket is not None        and state.get("refund_allowed") is True        and state.get("refund_checked_for") == (ticket, state.get("refund_cents"))    )

Injecting that same issue_refund call into both versions, across four states:

GateStateResult
emission-side onlynot authorisedREFUNDED 4999 on 8812
emission + execution pairnever checkedrefund not permitted: entitlement not verified for this ticket and amount
emission + execution pairauthorised for this ticket and amountREFUNDED 4999 on 8812
emission + execution pairrefund_allowed still True from an earlier ticketrefund not permitted: entitlement not verified for this ticket and amount
state-only wrap_tool_callentitlement checked for ticket A, call is for ticket BREFUNDED 25000 on B
args-only wrap_tool_callbilling.refund_allowed returned False, call matches what was checkedREFUNDED 4999 on 8812

Row three matters as much as row two. A guard that refuses everything is not a guard, it is an outage, and the pair still lets a properly checked refund through.

Row five is my third wrong answer in this section, and the worst one. My wrap_tool_call originally called _refund_reachable(request.state) and nothing else. That predicate compares state against state. It never sees the arguments of the call it is judging. So it answers "was some refund authorised on this thread?" when the only question worth asking is "was this refund authorised?" Check entitlement on ticket A for 4999, let the model then call issue_refund(ticket_id="B", cents=25000), and the guard opens: a different ticket, five times the money, on somebody else's authorisation.

The fixed version compares refund_checked_for against the call's own args. That also closes most of the tool-argument gap I flag below, because a model-supplied cents that does not match the checked amount now fails the comparison rather than sailing past it.

Row six is the fourth wrong answer, and it is the one I find most instructive, because I caused it by fixing row five. When I added the argument comparison I replaced the state check instead of adding to it. The guard then asked only "do these arguments match something that was checked?" and never "did the check say yes?" Since check_entitlement originally wrote refund_checked_for whether or not billing approved, a denial left a perfectly matching pair sitting in state, and the refund went through on an explicit no.

The shipped version requires both: refund_allowed is True and the arguments matching what was authorised. check_entitlement also stops recording a check that denied. Two conditions, because the guard has to be self-sufficient - the entire argument of this section is that it cannot lean on the emission side having run.

Row four is the staleness case. That third condition in the predicate is not decoration. Part 1 warned that refund_cents, refund_allowed, and receipt_id persist on the thread, so a later turn "has to treat every refund-path field as stale unless the current turn set it." My first predicate read refund_allowed and ignored that warning. Turn two refunds ticket A legitimately and leaves refund_allowed=True on the thread. Turn seven asks about ticket B, nothing re-runs the entitlement check, and the gate opens on a stale authorisation for a different ticket. billing.refund_allowed(customer_id, ticket_id, cents) is amount-scoped in Part 1, so a persisted True does not even authorise the same ticket at a different amount. refund_checked_for is a new AtlasState key holding the (ticket_id, refund_cents) pair the check was computed for. Store it as a string like f"{ticket_id}:{cents}" if your checkpointer's serialiser does not preserve tuples. A tuple that returns as a list compares unequal forever, and the guard then fails closed permanently, which is the outage failure mode again. LangGraph's default serde preserves tuples; a JSON-backed one does not. Change all three sites together if you do it - the writer in check_entitlement, the predicate, and the guard - because an encoding that matches in two places out of three fails closed. Part 1 wrote refund_allowed from a check_entitlement node, and Part 3's whole point is that create_agent has no such node to write from. The writer here has to be a tool:

code
@tooldef check_entitlement(ticket_id: str, runtime: ToolRuntime) -> Command:    """Verify the customer may be refunded for this ticket."""    cents = ticket_api.get(ticket_id)["amount_cents"]    allowed = billing.refund_allowed(runtime.state["customer_id"], ticket_id, cents)    update = {        "refund_allowed": allowed,        "refund_cents": cents,        "messages": [ToolMessage(str(allowed), tool_call_id=runtime.tool_call_id)],    }    if allowed:        # Only record a check that actually authorised something. Writing this        # on a denial would leave a matching pair in state for the guard to find.        update["refund_checked_for"] = (ticket_id, cents)    return Command(update=update)

That is the Command(update=...) handshake this article gets to properly two sections from now. Note what moved: Part 1 could put an entitlement check in a node the graph guaranteed you passed through, and create_agent gives you a tool the model chooses to call. The guard above exists precisely because that choice is the model's.

Before taking the middleware prescription, weigh the obvious alternative. Part 1 conceded four mechanisms that narrow the next-step space without a graph, and one was an in-body precondition guard. Put the entitlement check at the top of issue_refund itself and it satisfies the demarcation criterion exactly as well as wrap_tool_call does. It sits with the action, it shows up in that tool's own diff, and it needs no ordering rule.

Use it when you own the tool. It stops working for tools you did not author, which is the whole MCP section below, and it makes you repeat the predicate once per gated tool with nowhere to enumerate what is gated. wrap_tool_call buys you that one place. That is its entire advantage over an if statement, and it is worth having only past about three gated actions.

One thing the pair still does not close. The guard now binds the refund to an amount the ticket API computed, but nothing binds check_entitlement's own ticket_id argument, which the model chooses. So the model still selects what gets authorised, even though it can no longer alter the amount afterwards. Part 1 handled this in the schema, where refund_cents is written by lookup_ticket from ticket["amount_cents"] and never by the model. create_agent gives you no equivalent, so tool-argument authority is a second axis you have to close yourself, with a ToolRuntime that reads the amount from state rather than accepting it as a parameter. The alphabet bound is not the whole story even when you enforce it properly.

On the emission-side axis create_agent beats a hand-built graph, because a compiled edge cannot recompute itself per call. On the execution-side axis it gives you nothing the graph gave you, and you have to rebuild it in wrap_tool_call.

And then you render it.

code
graph TD;	__start__([__start__]):::first	model(model)	tools(tools)	Audit.after_model(Audit.after_model)	__end__([__end__]):::last	__start__ --> model;	model --> Audit.after_model;	Audit.after_model -.-> tools;	Audit.after_model -.-> __end__;	tools -.-> model;

Audit, a middleware whose after_model hook returns None and does nothing at all, is a named node in the diagram. RefundGate, the middleware that decides whether this agent can move money, appears nowhere. Its name is not in the node list, and no edge or label carries it either.

The check is three lines. Run it against your own agent:

code
nodes = agent.get_graph().nodesassert "Audit.after_model" in nodes        # does nothing, is drawnassert not any("RefundGate" in n for n in nodes)   # decides everything, is not

The Unrendered Edge: what wrap_model_call and wrap_tool_call hide

I need a name for that asymmetry.

An Unrendered Edge is a state-dependent constraint on which action a transition may carry, enforced by the runtime on either the emission side (which action the model is offered) or the execution side (which action the runtime will dispatch), that appears on no rendered artifact of the graph it constrains.

Only the execution-side half survives a model that does not comply. Both halves are unrendered, which is why they share a name; only one of them is a guard, which is why the distinction below is the important part of this section.

Two honest qualifications. Static analysis has called this family implicit control flow for decades, and AgentFlow describes the same thing as dependencies "expressed as framework-induced semantics, such as tool decorators." The specialisation I am claiming is narrower than the general term: implicit control flow is about what an external analyser cannot recover, while an Unrendered Edge is about what the framework's own shipped drawing declines to say about itself. Second, "edge" is a slight abuse. It is a guard on the label set of an existing model to tools transition, not a new arc between two nodes. I am keeping the word because the paradox in it is the point.

create_agent gives you two ways to make one:

  1. wrap_model_call returning handler(request.override(tools=...)). This one acts before emission. It removes the action from the offered alphabet and leaves the dispatch alphabet untouched, so the model usually never proposes it and there is nothing to refuse. There is nothing in the thread that says the constraint exists. The checkpointed message list is byte-identical whether the guard fired or not, and the checkpoint is the durable artifact.
  2. wrap_tool_call rejecting or rewriting a call. Acts after emission. The model proposes, the guard refuses, and the refusal shows up as a tool result that any reader of the thread can see.

Be careful with the first one. A full model-call trace does carry the resolved tool list in its invocation_params, so LangSmith users can recover it, and reading the bound tool list off a run is ordinary practice when debugging why a tool never fires. Recoverable if you captured traces, if that call was sampled, and if you still have them. Retention is usually days. The checkpoint is forever.

The documentation recommends the first form for tool selection, and it is the form that leaves nothing in the durable record.

before_model and after_model hooks are rendered, as named nodes, in list order. Reordering them changes the picture. What is never rendered is the wrap_* nesting depth and the tool set that nesting resolves. Anyone who tells you middleware is invisible in LangGraph is overstating it. Half of it is a node. The half that constrains the action space is not.

request.override(tools=...) is the correct way to build a state-dependent alphabet. I would rather have it than not, and I am not asking for it back.

The artifact is what broke, and not in the way I first wrote it. My draft said the rendering was no longer sound. That is false, and Part 1 contains the sentence that refutes it: imprecision inside an upper bound is safe. request.override(tools=...) only ever removes behaviours from the real relation, and removing behaviours cannot break an upper bound.

The rendering is still sound. That is the problem. It is a sound upper bound on a relation Part 1 already showed is complete over the bound tool set, so it bounds nothing you did not know before you drew it. Soundness at node granularity stopped being informative the moment the interesting relation became one over tool actions, and the render has no vocabulary for those.

Before anyone asks: get_graph(xray=True) does not help. Part 1 made xray a stated precondition of its soundness claim, because create_agent embeds as a subgraph and one tidy node can hide a loop. But xray expands subgraphs, and wrap_* handlers are not nodes at any depth. There is nothing for it to expand.

code
assert agent.get_graph().nodes == agent.get_graph(xray=True).nodes

Whether the render could be made informative without LangChain emitting the composed middleware stack itself, I do not know. Nothing I have tried gets there from outside the library.

mermaid
flowchart TD
    START([__start__]) --> BM["Budget.before_model"]
    BM --> WMC["wrap_model_call chain<br/>resolves this call's offered alphabet"]
    WMC --> MODEL["model"]
    MODEL --> AM["Audit.after_model"]
    AM -.-> WTC["wrap_tool_call chain<br/>may reject after emission"]
    WTC --> TOOLS["tools"]
    TOOLS -.-> BM
    AM -.-> ENDN([__end__])

    style START fill:#98D8C8,color:#2C2C2A
    style ENDN fill:#98D8C8,color:#2C2C2A
    style BM fill:#4A90E2,color:#FFFFFF
    style AM fill:#4A90E2,color:#FFFFFF
    style MODEL fill:#4A90E2,color:#FFFFFF
    style TOOLS fill:#4A90E2,color:#FFFFFF
    style WMC fill:#E74C3C,color:#FFFFFF
    style WTC fill:#E74C3C,color:#FFFFFF

Blue is what draw_mermaid() prints. Red is what decides the next step, and those boxes exist in this diagram only because I drew them by hand.

What runtime-governance research already covers, and what create_agent still hides

Other people are already in this territory.

Kaptein, Khan and Podstavnychy published Runtime Governance for AI Agents: Policies on Paths in March 2026. They argue that the execution path is the central object of runtime governance, and that static access control is "a limited special case that ignores execution context". That is the academic form of "an alphabet bound is weaker than a shape bound", four months ahead of this article. Their answer is runtime path evaluation. I care about the review-time question instead: what a diff and a diagram can show a human before the deploy.

55% of workflows in the Agentproof benchmark violated a human-gate policy once someone wrote the policy down and model-checked the graph against it. The benchmark is 18 workflows, so that is roughly ten of them, and I am quoting a percentage off a denominator small enough that it should be stated rather than hidden. Xavier and co-authors (March 2026) got that number by mechanising static verification over LangGraph, CrewAI, AutoGen and ADK graphs. It is Part 1's over-approximation argument turned into a tool, and that 55% is the best evidence I have seen that refund-path gates are widely believed in and rarely enforced.

AgentFlow (Wang and co-authors, July 2026) comes at it from static analysis. They build an Agent Dependency Graph over 5,399 real agent programs. Their point is that the dependencies are invisible to conventional static analysis, because they live in framework semantics like tool decorators. I read that as the same finding arriving from the other side. They are trying to see the dependency without running the program. I gave up on that and went looking for what a reviewer could hold in their hand.

For the MCP half of this article, the reviewability argument is not mine either. A post at mcpindex.ai on 19 July 2026 proposed an mcp.lock file of per-tool integrity hashes, and stated the problem better than I would have: "Your agent re-fetches that description every time it connects, so its behavior just changed without a single line of your code changing." Microsoft's incident response team made the same point on 30 June 2026, recommending that tool descriptions be treated as equivalent to system prompts requiring change review. Invariant Labs named the attack classes back in April 2025: tool poisoning, shadowing, rug pull. The defensive side of that, from the server author's chair, is designing secure MCP servers. Simon Willison's "lethal trifecta" owns the security framing.

Agentproof asks the review-time question about topology, and answers it. What nothing in this corpus asks about is the per-call resolved tool set, what wrap_tool_call will refuse, and the composed wrap_* stack over both - which is where create_agent actually puts the constraint: the composed order of your middleware, the per-call resolved tool set, and what wrap_tool_call will refuse, together are the actual transition relation, and no artifact in your repository renders any of them.

Three built-in middleware: HumanInTheLoopMiddleware, LLMToolSelectorMiddleware, ToolCallLimitMiddleware

Before writing your own, know that LangChain ships three that sit in exactly these slots, and each one makes the argument better than my example does.

HumanInTheLoopMiddleware takes interrupt_on={"issue_refund": {"allowed_decisions": ["approve", "edit", "reject"]}}. That is the Atlas refund gate, first-party, per-tool, and it is the checkpoint membrane pattern with a config dict in front of it.

I assumed it would be invisible like RefundGate and I was wrong. It is an after_model hook, so it does get a node:

code
>>> sorted(agent.get_graph().nodes)['HumanInTheLoopMiddleware.after_model', '__end__', '__start__', 'model', 'tools']

Which is worse in an interesting way. The box is there, so the diagram looks like it covers the approval gate. No node name mentions issue_refund, and none can, because interrupt_on is a dict of tool names collapsed into one anonymous node. A reviewer sees that a human-in-the-loop step exists and cannot see which of your twenty-three tools it gates, or that removing one key from that dict silently ungates a refund. A rendered node that misstates its own scope is not obviously better than no node at all.

It works through interrupt(), which is Part 4's mechanism, so I am deferring the mechanics there by name rather than half-explaining them here.

LLMToolSelectorMiddleware takes max_tools=3 and narrows the tool list for you. Both occupants of the wrap_model_call slot are emission-side, so neither survives a model that does not comply. The difference is what does the narrowing. request.override(tools=...) narrows by a Python predicate a reviewer can read; LLMToolSelectorMiddleware narrows by a model call, which makes it unreviewable as well as unrendered. If you would not accept "another model decided" as an answer in a refund post-mortem, keep it off the refund path.

ToolCallLimitMiddleware takes thread_limit=20 or a per-tool tool_name="search", thread_limit=5. In Part 1's vocabulary that is a length bound scoped per tool, which is a row the taxonomy above does not have and a genuinely useful one: it bounds how many times, while saying nothing about when.

Middleware order is a concurrency policy

Middleware execution order is documented. I spent two evenings reconstructing the wrap_* composition rule from _chain_model_call_handlers in factory.py, convinced it was source-only and about to write that in this article, before I found the page that states it in three lines. Those three lines, verbatim:

before_* hooks: First to last after_* hooks: Last to first (reverse) wrap_* hooks: Nested (first middleware wraps all others)

Those rules collide with Part 2.

Part 2 established that a reducer is the concurrency-control policy for a state key, hidden inside a type annotation where a reviewer, a diagram, and a type checker all miss it. Middleware can declare a state_schema. So middleware can declare reducers. And schema resolution walks the middleware list.

code
import operatorfrom typing import Annotated, NotRequiredfrom langchain.agents.middleware import AgentStatedef keep_max(a: int, b: int) -> int:    """Part 2's named commutative wrapper - max() itself has no signature."""    return max(a, b)class AppendState(AgentState):    # Part 2 wrote this nesting the other way round, as    # Annotated[NotRequired[int], operator.add]. Both resolve, and nothing    # below depends on which you pick - only on the middleware order.    risk: NotRequired[Annotated[int, operator.add]]class MaxState(AgentState):    risk: NotRequired[Annotated[int, keep_max]]class AppendMw(AgentMiddleware):    name = "AppendMw"    state_schema = AppendState    def before_model(self, state, runtime):        return Noneclass MaxMw(AgentMiddleware):    name = "MaxMw"    state_schema = MaxState    def before_model(self, state, runtime):        return None

Two middleware, each annotating risk, differing only in merge policy. Now build the same agent twice, changing nothing but the order of two list entries:

code
a = create_agent(model, tools=tools, middleware=[AppendMw(), MaxMw()])b = create_agent(model, tools=tools, middleware=[MaxMw(), AppendMw()])

Verified on langchain 1.3.14:

code
middleware=[AppendMw(), MaxMw()]  -> risk reducer = BinaryOperatorAggregate(keep_max)middleware=[MaxMw(), AppendMw()]  -> risk reducer = BinaryOperatorAggregate(add)

The two agents agree on tools, model, hooks and rendered node set. They disagree on exactly one thing: what risk is worth when two writers land in one superstep.

Part 2 named the hazard where a state value depends on the order its writes are folded in Order-Coupled State, and was careful that both halves mattered: the value depends on an order, and that order comes from the runtime rather than from anything semantic in the data.

Apply both halves here and the obvious name does not quite fit. A middleware list you typed by hand is semantic authoring. Reordering it is your decision, and a documented rule then tells you what that means. That is a hazard in Part 2's sense - unrendered, easy to trip over - but it is not the runtime choosing for you.

So define the term over the shape that survives a patch, the way Part 2 defined its own so it would outlive a change to the sort key. Order-Coupled Policy is a merge policy whose identity is decided by an assembly order that was authored for a different purpose and is rendered nowhere.

That is the durable part. You order a middleware list to get the hook nesting you want. The same ordering silently decides which reducer governs a state key, which is a question about concurrency and has nothing to do with nesting. One decision, two consequences, and no artifact showing the second.

LangChain issue #38720 aggravates that rather than causing it. When _resolve_schemas collapses a repeated schema to its first position, the deciding order is a dict.fromkeys insertion artifact: not what you wrote, not what the docs promise. Patch it and the coupling is untouched - your authored order still decides your concurrency policy, and still renders nowhere. The bug removes your ability to predict the coupling. It did not create it.

It is worse than that right now. LangChain issue #38720, filed on 8 July 2026 and still open, reports that _resolve_schemas builds a dict comprehension over the schema list, and Python dicts keep the first insertion position when a key is re-inserted. The documented guarantee is that a type appearing more than once is processed at its last position, so an explicitly passed base schema wins conflicts. It does not. I reproduced it:

code
>>> import typing>>> from langchain.agents.factory import _resolve_schemas>>> resolved, _, _ = _resolve_schemas([AppendState, MaxState, AppendState])>>> typing.get_type_hints(resolved, include_extras=True)["risk"]NotRequired[Annotated[int, <function keep_max at 0x...>]]   # address elided

AppendState annotates risk with operator.add and appears at the last position, so the documented rule says it should win. keep_max wins anyway.

One data point I cannot decode, offered as exactly that: langgraph 1.2.3 was yanked from PyPI on 1 June 2026, and the yank reason in full is unintended merging strategy regression. "Merging strategy" could mean message merging, config merging, or dict merging in RunnableConfig, none of which are reducers. I could not establish which, so I am not going to present it as a cost estimate for this class of bug. It is its own small comment on how legible this area is.

When tools write state, the model picks the fold order

Part 2 closed by asking, for every key in your schema, who can write it and what happens when two do. Inside the agent loop, the answer gains a writer that Part 2's graph did not have: tools.

The docs are unusually direct about the consequence:

When tools update state variables, consider defining a reducer for those fields. Since LLMs can call multiple tools in parallel, a reducer determines how to resolve conflicts when the same state field is updated by concurrent tool calls.

That is the framework saying the write relation extends inside the loop. A tool writes state by returning a Command, and in create_agent it must carry a matching ToolMessage or ToolNode refuses the update outright:

code
from langchain_core.messages import ToolMessagefrom langchain_core.tools import toolfrom langchain.tools import ToolRuntimefrom langgraph.types import Command@tooldef kb_lookup(query: str, runtime: ToolRuntime) -> Command:    """Search the support knowledge base."""    hits = kb.search(query)          # -> [(text, score), ...]    return Command(        update={            "evidence": [                Evidence(source="kb", text=text, rank=score) for text, score in hits            ],            "messages": [ToolMessage(str(hits), tool_call_id=runtime.tool_call_id)],        }    )

Evidence and evidence are both Part 2's, carried forward unchanged: class Evidence(TypedDict): source: str; text: str; rank: float, and the state key is declared Annotated[NotRequired[list[Evidence]], operator.add]. The rank field is not decoration - it is Part 2's own second-choice fix, and this tool emits it so that the recommendation at the end of this section is one the code here already follows. operator.add on a list is not commutative, which Part 2 established makes the key an order-coupled hazard. Two tool calls in one model turn are two concurrent writers. The hazard is now live.

The question Part 2 answers for graph nodes is what determines the fold order. For edge-triggered nodes it is codepoint-sorted node name. That is deterministic, framework-chosen, and knowable before you run anything. Inside the agent loop, for parallel tool calls, it is something else.

I built the experiment to distinguish the two candidate answers. Two tools that both append to evidence. The first one the model emits sleeps for 250 milliseconds; the second returns immediately. If the fold order followed completion, the fast tool would land first.

code
slow emitted first     -> evidence = ['kb(slow)', 'ticket(fast)']fast emitted first     -> evidence = ['ticket(fast)', 'kb(slow)']

The slow tool folds first when it is emitted first. Completion order does not decide it. The fold order is the order the model emitted the tool calls in.

Two disclosures about how that was measured. There is no model in it: the emission order is fixed because I hand-built the AIMessage and drove the agent's compiled ToolNode through a small graph, which is the only way to control which tool call comes first. And the mechanism is not Part 2's. Part 2's determinism comes from apply_writes sorting tasks by path; here all the parallel calls run inside the single tools task and _combine_tool_outputs merges them before any write is emitted, so apply_writes sees one task with an ordered list. The order comes from executor.map and asyncio.gather preserving argument order over message.tool_calls.

This does not contradict Part 2. Part 2 took the position that merge order is deterministic rather than timing-dependent, and the 250ms sleep confirms it. What moved is the source of the order. Outside the loop it comes from node names in your code. Inside the loop it comes from token order in a model output, which makes your merge order a sampled quantity.

Part 2 already found this shape and put it in its audit questions: is any fold order derived from model output? It was asking about a Send list built from a structured response. Parallel tool calls are that same case one level down. Even at temperature zero this is not a guarantee, for reasons Part 7 gets into.

The fix is the one Part 2 already ranked first, and it is unchanged: use a reducer whose result does not depend on order. If evidence needs an order, carry an ordering key in the data and sort at the point of use. Do not let the model's token order be the tiebreak on anything a downstream node reads. Sorting at the point of use costs nothing until evidence gets large, at which point you will want that ordering key indexed. I have not needed that yet, so I cannot tell you where the line is.

MCP tools: the alphabet is fetched at runtime, not committed to your repo

Everything so far assumed the tool list is at least in your repository. MCP removes that assumption.

langchain-mcp-adapters 0.3.0 does four things worth knowing about, all in client.py:

  • get_tools() contacts the servers at call time, fanning out with asyncio.gather().
  • There is no caching layer. Every call is a fresh fetch.
  • There is no handling of notifications/tools/list_changed anywhere in the client.
  • The default is stateless: a new session is created for each tool call.

The agent's alphabet is whatever the servers returned at the moment your process last called get_tools(). The protocol has no tool-schema version field either. A schema_version field is one of three mitigations proposed in MCP spec issue #2808, an issue mostly about tool-schema token overhead. It was filed on 28 May 2026 and closed under fourteen hours later as completed, with zero comments. The freshness metadata that did ship, ttlMs and cacheScope, came through a different proposal; schema_version was not adopted. The 2026-07-28 release candidate - still a release candidate as this is published - adds ttlMs and cacheScope to list results, modelled on HTTP Cache-Control. It is the same revision that takes the protocol stateless. That concedes the point precisely: the tool list is a cacheable resource with a freshness window, and a freshness window is not a contract.

I have written "versioned by nobody, reviewed in no diff" about MCP servers. The sweeping version of that is false, and the one real incident is what proves it.

The only confirmed malicious MCP server in the wild I can find is postmark-mcp. Version 1.0.16, published 17 September 2025, added a single line that blind-copied every outgoing email to an attacker-controlled domain. Koi Security put the reach at roughly 1,643 downloads before removal. That change shipped through npm. It had a version number. It was in a diff. It was reviewable. Nobody looked.

The phrase holds for remote HTTP servers and breaks for stdio servers distributed through npm or PyPI. So ask which transport a server uses before anything else. For a package you install, pin the version and read the diff, the same as any other npm dependency. For an HTTP endpoint you connect to, no artifact exists that anyone else produces for you. You have to manufacture it, which is precisely mcp.lock's argument and precisely why it remains a proposal.

There is a quieter failure mode here. In langchain-mcp-adapters issue #492, open since 24 April 2026, get_tools() gathers across servers without return_exceptions=True. One server fails. The reported trigger is a missing npx for a stdio server. The first exception propagates out of gather() before any sibling result is collected, so get_tools() yields nothing from any server, including the healthy ones. Whether that surfaces or disappears is down to your startup path rather than the library - the reporter's swallowed it. The dispatch alphabet becomes the empty set and the graph does not change. Atlas stops being able to do anything and answers from the model alone.

For an agent whose alphabet includes a money-moving tool, that failure at least fails safe. The unlucky direction is available too, and it is why a rug pull matters: a tool description is fetched fresh on every connect, and a description is an instruction the model reads. Microsoft's guidance is the right operational conclusion: treat tool descriptions as system prompts that require change review. The gap is that no MCP client I know of gives you a way to detect that one changed. If you have solved this for remote servers without proxying every connection, I would like to hear how.

How many tools before tool selection degrades

Alphabet size also degrades the thing you bought the model for.

Anthropic's own documentation is blunt: "Claude's ability to pick the right tool degrades once you exceed 30 to 50 available tools." The same page notes that a typical multi-server setup across GitHub, Slack, Sentry, Grafana and Splunk consumes about 55,000 tokens in tool definitions before the model does any work at all.

Atlas is at twenty-three. That looks like comfortable headroom, and I read it that way for longer than I should have. Then I counted where the twenty-three come from. Four arrive from MCP servers whose tool counts I do not control, fetched fresh on every connect. One upstream release that splits a tool into three puts Atlas over the line, and no file in my repository changes.

The research agrees on direction, with numbers:

SourceDateFinding
RAG-MCP (arXiv 2505.03275)May 2025Tool-selection accuracy 13.62% to 43.13% with retrieval over the tool registry; prompt tokens cut by more than half
Repantis et al. (arXiv 2605.24660)May 2026On a 370-tool benchmark, showing about 7 tools reaches 90.3% coverage against 90.8% for always showing 50. On a 3,251-tool benchmark in the same paper, adaptive selection is worse: 61.9% against 64.7% for a fixed 5
Gillespie and Perry (arXiv 2606.17519)June 2026Deployed enterprise system, up to 584 tools: routing F1 on under-specified requests drops 16 to 23 percentage points going from 10 to 110 agents

Those numbers disagree on more than the benchmark, and I would not port any of them to Atlas. One of them disagrees on direction: adaptive selection loses on ToolBench. The benchmarks, models and definitions of a correct selection are all different, and I included the row that undercuts the tidy story because leaving it out is how a table like this becomes an argument. What I took from them is that the failure is gradual. There is no error and no threshold crossing to alert on. The agent just picks slightly worse tools slightly more often, which is why teams discover it through a customer complaint rather than a dashboard.

And the ecosystem is moving toward exactly the tools where this matters most. A study of 177,436 MCP tools tracked what people call. Usage of "action" tools (the ones that change something, not read something) rose from 27% to 65% between November 2024 and February 2026. The authors call out financial transactions as the high-stakes case. An agent that issues refunds is no longer an unusual thing to be building. Whether it is the median one, that study cannot say, because it counts tool calls and not deployments.

There is one interaction to know about before you reach for response_format as a fix. When you pass a response_format, create_agent wraps it in a strategy that forces tool_choice="any" where structured tools exist. One keyword argument silently rewrites your tool-choice policy.

A June 2026 study tested that combination on open-weight models. Turn on tool calling and JSON Schema together and several of them stopped calling tools at all, while still emitting perfectly schema-compliant JSON. The authors name it Tool Suppression and blame grammar-based token masks: the tool-call tokens become unreachable during decoding.

Be careful how much weight you put on this. The authors are explicit that the mechanism is a hypothesis rather than verified internal behaviour, and the abstract publishes no per-model numbers. Treat the size of the effect as unknown. The existence of the interaction is still enough reason not to turn on structured output and assume tool calling is unaffected.

How to audit an agent's real tool surface

Part 2 shipped print_merge_policy() because the merge policy was invisible. Part 3 needs the same thing one level up, for the same reason: the artifact you have does not cover the thing you are reviewing. This is an addition to Part 2's tool, not a replacement for it.

code
from langchain.agents.middleware import AgentMiddlewarefrom langgraph.channels.binop import BinaryOperatorAggregatefrom langgraph.channels.last_value import LastValuedef _implements(mw, hook: str) -> bool:    """True when this middleware overrides the base no-op.    Resolves on the instance first, so decorator-created middleware that    assigns the hook to the object is detected as well as subclasses that    override it on the class. Bound methods are unwrapped before comparison.    """    base = getattr(AgentMiddleware, hook, None)    own = getattr(mw, hook, None)    return getattr(own, "__func__", own) is not getattr(base, "__func__", base)def _merge_policy(channel) -> str:    if isinstance(channel, BinaryOperatorAggregate):        return f"reducer={getattr(channel.operator, '__name__', channel.operator)}"    if isinstance(channel, LastValue):        return "LAST-WRITE-WINS (no reducer)"    return type(channel).__name__def print_agent_surface(agent, middleware) -> None:    """Print what draw_mermaid() does not: the dispatch table, the composition    order, and the merge policy. `middleware` is required - the compiled agent    does not expose it, and defaulting it to () would report an agent full of    rewriters as if it had none.    """    node = agent.nodes.get("tools")    registry = getattr(getattr(node, "bound", None), "tools_by_name", None)    tool_names = sorted(registry) if registry else []    print("RENDERED:", sorted(n for n in agent.get_graph().nodes if not n.startswith("__")))    print("ROUTING TARGETS: ['tools', 'model', 'end']  (fixed)")    print(f"\nDISPATCH TABLE - what ToolNode will execute ({len(tool_names)}):")    for name in tool_names:        print(f"    {name}")    rewriters = [m.name for m in middleware if _implements(m, "wrap_model_call")]    if rewriters:        print("\n  THIS CALL'S REQUEST MAY BE REWRITTEN BY:")        for name in rewriters:            print(f"    {name}")        print("    (may rewrite tools, prompt, model or tool_choice - which one is not knowable here)")    interceptors = [m.name for m in middleware if _implements(m, "wrap_tool_call")]    if interceptors:        print("\n  CALLS MAY BE REFUSED AFTER EMISSION by:")        for name in interceptors:            print(f"    {name}")    elif tool_names:        print("\n  NO wrap_tool_call MIDDLEWARE: every tool above is dispatchable")        print("  from any state, whatever wrap_model_call removes from the prompt.")    print("\nCOMPOSITION ORDER:")    for hook in ("before_agent", "before_model"):        names = [m.name for m in middleware if _implements(m, hook)]        if names:            print(f"    {hook:14s} {' -> '.join(names)}   (list order)")    for hook in ("after_model", "after_agent"):        names = [m.name for m in middleware if _implements(m, hook)]        if names:            print(f"    {hook:14s} {' -> '.join(reversed(names))}   (REVERSED)")    for hook in ("wrap_model_call", "wrap_tool_call"):        names = [m.name for m in middleware if _implements(m, hook)]        if names:            print(f"    {hook:14s} {' ( '.join(names)} ( handler{' )' * len(names)}   (NESTED)")    print("\nMERGE POLICY PER KEY:")    for key, channel in agent.channels.items():        if not key.startswith(("__", "branch:")):            print(f"    {key:22s} {_merge_policy(channel)}")

Run it against an Atlas with three tools and middleware=[Budget(), Audit(), RefundGate()], where Budget carries a state_schema contributing risk and evidence:

code
RENDERED: ['Audit.after_model', 'Budget.before_model', 'model', 'tools']ROUTING TARGETS: ['tools', 'model', 'end']  (fixed)DISPATCH TABLE - what ToolNode will execute (3):    issue_refund    kb_lookup    ticket_lookup  THIS CALL'S REQUEST MAY BE REWRITTEN BY:    RefundGate    (may rewrite tools, prompt, model or tool_choice - which one is not knowable here)  CALLS MAY BE REFUSED AFTER EMISSION by:    RefundGateCOMPOSITION ORDER:    before_model   Budget   (list order)    after_model    Audit   (REVERSED)    wrap_model_call RefundGate ( handler )   (NESTED)    wrap_tool_call RefundGate ( handler )   (NESTED)MERGE POLICY PER KEY:    messages               reducer=_add_messages    jump_to                EphemeralValue    structured_response    LAST-WRITE-WINS (no reducer)    risk                   reducer=keep_max    evidence               reducer=add

The DISPATCH TABLE line is the one to read first. It says issue_refund is executable, and it says that whatever RefundGate removes from the prompt. That is the gap wrap_tool_call exists to close, and the report tells you when nobody has closed it.

Four things this does not do, all of which matter:

It reports that RefundGate can rewrite the request. It cannot tell you which offered alphabet you get in which state, because that is a Python predicate over runtime state and no static tool will evaluate it for you. This is a conservative capability report, not Part 2's coverage-bounded under-approximation - Part 2's recorder ran the graph and missed paths it never exercised, while this one runs nothing and over-reports instead. That is the safe direction of error, which is the opposite of the trap Part 2 described.

It prints the merge policy without a verdict on it. Part 2's print_merge_policy() tested commutativity by running the reducer on sampled values and reported an unsampled key as unchecked rather than safe. Keep using it. This does not replace it.

It reaches three attributes into agent.nodes["tools"].bound.tools_by_name, which is not a documented surface, and create_agent compiles with transformers=[...] that could wrap that runnable at any minor version. Hence the getattr guard, and the same warning Part 2 gave about builder.channels: this is a debugging aid, not something to build on. It also reads agent.channels rather than Part 2's preferred compiled.builder.channels, which is a deliberate change - the compiled view is what actually runs.

It needs you to hand it the middleware list, because the compiled agent has no accessor for it. That limitation is itself the thesis: the object that decides the next step cannot tell you what decides the next step.

Pair it with a test that asserts the predicate directly:

code
def test_refund_unreachable_before_eligibility_check():    state = {"ticket_id": "T-1", "refund_allowed": None}    assert not _refund_reachable(state)

That one tests the emission-side predicate, which this section has just finished arguing is not the guard. So test the guard too:

code
def test_refund_refused_when_entitlement_denied_and_args_match():    gate = RefundGate()    state = {"refund_allowed": False, "refund_checked_for": ("8812", 4999)}    request = _tool_call_request("issue_refund", {"ticket_id": "8812", "cents": 4999}, state)    message = gate.wrap_tool_call(request, lambda r: pytest.fail("dispatched"))    assert "not permitted" in message.content

That is the test a reviewer should be reading, and it is the one that would have caught both of my wrong answers above. Both still only cover the states you thought to write down, which is the same weakness every guard test has ever had.

A checklist for auditing a create_agent deployment

For any agent built with create_agent:

  1. Classify every constraint you believe you have. Length bound, alphabet bound, shape bound, or advisory. Anything living in a system prompt or a tool docstring is advisory. Write the classification down; the disagreements surface fast.
  2. Guard every irreversible action on both sides. Remove the tool from request.tools in wrap_model_call so the model is not offered it, and refuse it in wrap_tool_call so the runtime will not dispatch it. Emission-side narrowing alone is an Unrendered Edge that a re-emitted tool name walks straight through. A prompt does not do either.
  3. Ship print_agent_surface() output in the pull request, next to the diagram, not instead of it. The diagram is accurate about the loop and silent about the alphabet.
  4. Treat middleware list order as a versioned interface, and know that it pulls two ways. wrap_* nests first-outermost, so a wrap_model_call narrowing wants to be last (closest to the model, final word on request.tools), while a wrap_tool_call refusal wants to be first, because any outer handler that short-circuits without calling handler() bypasses an inner refusal. One wrap_tool_call in the list and it does not matter; more than one and it decides whether your guard runs. Add a comment at the list saying so. Reordering is a semantic change whether or not #38720 is open, because order also decides which state_schema wins; the bug only makes the resulting order unpredictable as well.
  5. Give every state key a reducer whose result does not depend on order, especially any key a tool writes. Inside the agent loop the fold order is chosen by the model's token order, which is sampled.
  6. Classify your MCP servers by transport. Installed package: ordinary supply-chain controls, pin the version, review the diff. Remote HTTP endpoint: no artifact exists, so record the tool schemas you reviewed and diff against them on startup yourself.
  7. Count your tools. Past 30 to 50, expect selection quality to degrade, and narrow per call rather than binding everything.
  8. Do not enable response_format and assume tool calling is unaffected. Test that tools still fire.

What Part 4 covers: persistence and irreversible actions

This part deliberately kept every side effect hypothetical. issue_refund was a tool that we prevented from being reached, and nothing here addressed what happens when it is reached twice.

Part 4 covers state, persistence, and durability: checkpointers and threads, at-least-once node execution and why that is what makes idempotence matter, the lost-step window in naive checkpointing, max_steps resetting to zero on resume, and pausing an agent for human approval. Part 1 named all of those and deferred them; Part 2 deferred idempotence to the same place. That is where the refund firing twice gets dealt with properly.

The through-line so far is simple enough to state in one line. Part 1: the transition relation lives on edges, and a loop's edges are all of them. Part 2: the write relation lives in type annotations, where nobody reads it. This part is the untidy version of both. Under create_agent they move into a per-call closure, a refusal handler, and a list order, and unlike the first two I cannot hand you a clean artifact for any of them.

References

Verified against langchain 1.3.14, langchain-core 1.5.1, langgraph 1.2.9, langchain-mcp-adapters 0.3.0. Every mechanical claim in this article was run, not read.


Agentic AI

Follow for more technical deep dives on AI/ML systems, production engineering, and building real-world applications:


Books by Ranjan Kumar

Building Real-World Agentic AI Systems with LangGraph cover

Building Real-World Agentic AI Systems

The Chat Templates Handbook cover

The Chat Templates Handbook

Comments