← Back to Guides
6

Series

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

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

LangGraph's recursion_limit Bounds Depth, Not Width

A limit of 4 let 1,000 workers run. The bound is real, it is enforced, and it counts the one axis the runtime knows how to count.

#langgraph#multi-agent#map-reduce#orchestration#cost-control

Here is a LangGraph multi-agent run with a recursion_limit of 4 - the tightest depth that lets it run at all. It fanned out to 1,000 parallel workers and did not raise.

code
n=1000   limit=4   ->  workers_ran=1000   max_langgraph_step=3

Nothing was broken. No exception was swallowed. The limit was set, it was honoured, and it was enforced: drop it to 3 and this very graph raises GraphRecursionError. The bound works. It was just counting something other than what I was afraid of.

That gap is the subject of this part. In Part 1 I called recursion_limit a length bound - it caps how far a run goes, never where. In Part 4 I showed the same limit arriving back at full on resume, because it is re-derived from checkpointed state rather than stored as a spent total. Both of those are about a single agent moving through time.

This series has been about a single agent until now. Multi-agent systems add a second axis. A supervisor fans out to specialists. A map-reduce step dispatches a worker per item. A planner decomposes a task and spawns one subagent per piece. The number of tasks one superstep dispatches is the run's width. It determines your bill, your rate-limit exposure, and the number of writers landing in one superstep.

The thesis: recursion_limit bounds depth, not width

recursion_limit bounds a run's depth and says nothing about its width.

In a Send fan-out, width is a list length derived from state. When that state came from a model call, the model chose the width. A fan-out of any size executes as a single superstep, so the one number LangGraph gives you to bound a run never counts the tasks inside it.

I am going to call this a Width-Blind Bound, and define it properly further down.

The consensus belief I am arguing against is not that width is free. Everyone knows parallel work costs money. My target is narrower and much more common.

The belief is that setting recursion_limit is the act of bounding a LangGraph run. You can watch it operate in the standard advice given when someone reports runaway cost: raise or lower the recursion limit, and add max_concurrency. Both pieces of that advice are wrong for this failure, and wrong in different ways. Setting them both leaves you with two numbers in your config, a run that reports success, and no bound on the quantity that determines your bill.

There is a reason the framework's own guidance sounds sufficient here. The docs are accurate about the mechanism. "The maximum number of super-steps the graph can execute" is a true and complete description of what the limit does. The gap is not in the documentation of the mechanism. Nothing in the framework documents the axis for which no mechanism exists, and an absence is the one thing a reference page never lists.

Why this matters more in 2026 than it did in 2025

Two changes brought it forward.

First, model-planned decomposition became the default way to build multi-agent systems. The pattern is no longer "I wrote a fan-out over my seven data sources." It is "the model reads the task, decides which sources are relevant, and returns a list." The list length moved from your code into the model's output.

Second, competing harnesses started shipping width bounds as first-class primitives, which makes the gap visible by contrast.

Claude Code now ships three separately named limits. From its subagent documentation: by default, when 20 subagents are running in a session, spawning another fails with Concurrent subagent limit reached, configurable through CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS and requiring version 2.1.217 or later. Separately, at most 200 subagents may be spawned per session, through CLAUDE_CODE_MAX_SUBAGENTS_PER_SESSION, from version 2.1.212. Separately again, CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH caps nesting. The documentation states plainly that three separate limits control subagent use, each with its own variable.

Concurrent count, total count, nesting depth. Each has its own environment variable and its own default.

LangGraph exists to give you explicit, reviewable control over what an agent does next, which is what Part 1 argued it earns. It ships one number that measures depth, and a throttle.

The failure this produces has no attacker in it

The clearest public example is not from LangGraph. In claude-code#15487, filed 27 December 2025, a user running on a Linode virtual private server with 2 vCPUs and 4 GB of memory reported that Claude Code spawned 24 parallel subagent processes inside a two-minute window. The evidence was 24 agent-*.jsonl files created between 00:19 and 00:21 UTC. Disk input/output spiked 17.3 times over baseline, at 152.80 blocks per second against 8.83. The machine became unresponsive and needed a hard reboot. The request was for a maxParallelAgents setting. It was closed as not planned. The concurrent-subagent limit described above shipped later, in 2.1.217.

Read what is absent from that story. No prompt injection. No infinite loop. No runaway depth. The run would have terminated correctly if the host had survived. The model simply decided that 24 was the right number, and 24 was more than the host could carry.

Anthropic documented the same class of decision inside their own production research system. Their engineering write-up on how they built it, published 13 June 2025, lists among early failures that agents "made errors like spawning 50 subagents for simple queries, scouring the web endlessly for nonexistent sources." Their reported fix at the time was prompt-level guidance on how many subagents suit which query shape: roughly one agent with 3 to 10 tool calls for simple fact-finding, 2 to 4 subagents for direct comparisons, more than 10 for complex research. The same document reports that multi-agent systems use about 15 times more tokens than chat interactions, and that their multi-agent configuration beat single-agent Claude Opus 4 by 90.2% on their internal research evaluation.

So the sequence is: the model picks a width, the width is sometimes absurd, the first mitigation is a prompt, and the runtime primitive arrives a year later. Part 1 has a name for the first mitigation. By the demarcation criterion, a constraint that only holds when the model complies does not specify the runtime at all.

What recursion_limit actually counts

The official Graph application programming interface (API) documentation produces the whole effect in two sentences. The recursion limit "sets the maximum number of super-steps the graph can execute during a single execution." And a superstep is "a single iteration over the graph nodes," where "nodes that run in parallel are part of the same super-step, while nodes that run sequentially belong to separate super-steps."

In the source, on the version this series is pinned to, the loop computes its ceiling on entry:

code
# langgraph/pregel/_loop.py - SyncPregelLoop.__enter__ (and the async twin)self.step = self.checkpoint_metadata["step"] + 1self.stop = self.step + self.config["recursion_limit"] + 1

and halts on a step comparison:

code
# PregelLoop.tick()if self.step > self.stop:    self.status = "out_of_steps"    return False

self.step is incremented in _put_checkpoint(), which runs once per superstep. Not once per task. RemainingSteps, the managed value you can read from inside a node, is derived from the same pair: scratchpad.stop - scratchpad.step.

On the task side, the fan-out is built by iterating the tasks channel with no cardinality check of any kind:

code
# langgraph/pregel/_algo.py - prepare_next_tasks# SEND tasks, executed in superstep n+1for idx, _ in enumerate(tasks_channel.get()):    if task := prepare_single_task(        (PUSH, idx),        ...

The list length is never checked.

Measuring it

Reading source is not the same as knowing. Here is the experiment, on langgraph 1.2.9 and langchain-core 1.5.1, with no model calls and no network, because I am measuring the runtime's accounting, and a live model call would add latency noise I cannot control.

code
import operatorfrom typing import Annotated, TypedDictfrom langgraph.graph import StateGraph, START, ENDfrom langgraph.types import Sendclass S(TypedDict):    n: int    results: Annotated[list, operator.add]    steps: Annotated[list, operator.add]def start_node(state, config):    return {"steps": [("start", config["metadata"]["langgraph_step"])]}def route(state):    # The fan-out width is a list length derived from state.    return [Send("worker", {"i": i}) for i in range(state["n"])]def worker(payload, config):    return {        "results": [payload["i"]],        "steps": [("worker", config["metadata"]["langgraph_step"])],    }def collect(state, config):    return {"steps": [("collect", config["metadata"]["langgraph_step"])]}g = StateGraph(S)g.add_node("start", start_node)g.add_node("worker", worker)g.add_node("collect", collect)g.add_edge(START, "start")g.add_conditional_edges("start", route, ["worker"])g.add_edge("worker", "collect")app = g.compile()

Running it across four widths:

code
n=1      limit=None   -> workers_ran=1      max_langgraph_step=3   worker_steps=[2]   wall=0.014sn=10     limit=None   -> workers_ran=10     max_langgraph_step=3   worker_steps=[2]   wall=0.013sn=100    limit=None   -> workers_ran=100    max_langgraph_step=3   worker_steps=[2]   wall=0.084sn=1000   limit=None   -> workers_ran=1000   max_langgraph_step=3   worker_steps=[2]   wall=1.430s

The step counter is 3 at every width. Every worker executes at step 2. A thousandfold increase in work costs zero supersteps, and takes 100 times the wall-clock time.

Then the experiment that matters:

code
n=1000   limit=4      -> workers_ran=1000   max_langgraph_step=3   wall=4.582s   (limit sweep, instrumented)n=1000   limit=3      -> GraphRecursionError

A depth bound set to the tightest value this graph will run under, against a width of 1,000. All thousand workers ran. One notch tighter and the same graph fails on depth. So the limit is not slack - it is fully engaged, and it is measuring a different axis.

For contrast, a genuine depth violation - a node that loops back to itself 50 times, at limit 5:

code
depth loop: GraphRecursionError -> Recursion limit of 5 reached without hitting a stop condition.

That is the documented GRAPH_RECURSION_LIMIT error, behaving as specified.

It would be easy to read this article as "the recursion limit is broken." It is not broken. It fired exactly when it should, on the axis it measures. Both results come from the same limit on the same runtime in the same script. A Width-Blind Bound is not a defective bound. It is a correct one aimed at a single axis.

The two axes, drawn against the Atlas graph this part builds:

mermaid
flowchart TD
    T["triage<br/>superstep 1"] --> P["plan_research<br/>superstep 2<br/>model chooses N"]
    P -.->|"Send x N"| W1["research 1"]
    P -.->|"Send x N"| W2["research 2"]
    P -.->|"Send x N"| WN["research N"]
    W1 --> C["synthesise<br/>superstep 4"]
    W2 --> C
    WN --> C
    C --> E["END"]

    DEPTH["DEPTH - recursion_limit counts this<br/>four supersteps, whatever N is<br/>the runtime counts it"] -.- T
    WIDTH["WIDTH - nothing counts this<br/>N workers, all inside superstep 3<br/>the model chooses it"] -.- W2

    style T fill:#4A90E2,color:#FFFFFF
    style P fill:#FFD93D,color:#2C2C2A
    style W1 fill:#6BCF7F,color:#2C2C2A
    style W2 fill:#6BCF7F,color:#2C2C2A
    style WN fill:#6BCF7F,color:#2C2C2A
    style C fill:#4A90E2,color:#FFFFFF
    style E fill:#95A5A6,color:#FFFFFF
    style DEPTH fill:#7B68EE,color:#FFFFFF
    style WIDTH fill:#E74C3C,color:#FFFFFF

The step counter cannot see the horizontal axis. Neither can the checkpoint table, and neither can the diagram.

The checkpoint count is width-blind as well

If the step counter cannot see width, the natural next question is whether persistence can. The hope is reasonable. The checkpointer writes on every superstep, and Part 4 showed how much can be recovered from what it stores. So I ran the same fan-out against a SqliteSaver, with each worker contributing one modest evidence record, and measured the database.

code
  width  ckpt rows  write rows    db bytes  bytes/worker      1          5           8       28672         28672     10          5          35       40960          4096     50          5         155      102400          2048    200          5         605      311296          1556

The checkpoints table holds 5 rows at every width. One worker or two hundred, the count is identical, for the same reason the step counter is: a checkpoint is written per superstep, and the fan-out is one superstep.

Width appears in exactly one place, the writes table, at roughly three rows per worker. Total bytes grow accordingly, from 28 kilobytes to 304 kilobytes. Per-worker cost falls as fixed overhead amortises, which is why the last column shrinks while the bill grows.

So the step counter and the checkpoint table both miss it. The checkpoint history does carry width, buried in pending write rows nobody counts. The invoice carries it plainly.

The width is not on the diagram either

Part 1 already said what Send removes from the picture: not a node, but cardinality - one drawn edge can mean N concurrent instances. What is new here is the measurement, the one renderer line that explains it, and an audit function that recovers the surface even though the value is unrecoverable.

Part 1 also established that draw_mermaid() gives you a sound over-approximation of control flow when every routing target is declared: it may show paths that cannot happen, but it will not hide one that can. That property is what lets a printed graph support a safety argument at all. Part 3 then showed the rendering going byte-identical across 1, 3 and 20 tools, and named the gap an Unrendered Edge.

The width value is worse than unrendered. It is unrenderable.

The graph above prints this, at every value of n:

code
graph TD;	__start__([__start__]):::first	start(start)	worker(worker)	collect(collect)	__end__([__end__]):::last	__start__ --> start;	start -.-> worker;	worker --> collect;	collect --> __end__;

369 characters, with the config header and the three trailing classDef lines elided from the block above. One dotted edge from start to worker. No count anywhere. In langchain-core's renderer the edge style branches on exactly one thing:

code
# langchain_core/runnables/graph_mermaid.pyedge_label = " -.-> " if edge.conditional else " --> "

And structurally it cannot do better. draw_mermaid() runs on the compiled graph, before any state exists. The width is a function of state that does not exist yet, so no pre-run artifact can carry its value. The existence of a width surface is another matter, and statically visible even though its value is not - which is what makes the audit function below possible.

One narrowing, because a reader with a tracing tool open will otherwise object correctly: this claim is about the graph artifact, not about all observability. A trace of a completed run does contain the runtime tasks, so LangSmith or LangGraph Studio will show you that 400 workers ran. That reporting is post-hoc: it tells you the width after you have paid for it. draw_mermaid() will never state how wide the graph may go, though a max_length on the planner's schema does state it for the one fan-out it governs.

Wrong way: setting recursion_limit and calling a Send fan-out bounded

Here is the shape I keep seeing, written against the running example from this series. Atlas is a customer-support agent. It has been acquiring capabilities part by part. In Part 2 it gained an evidence key with an operator.add reducer, so that several researchers could contribute findings.

Now the model decides which sources to research.

code
class Evidence(TypedDict):          # unchanged since Part 2    source: str    text: str    rank: floatclass ResearchPlan(BaseModel):    targets: list[str]              # the model decides how manyclass AtlasState(TypedDict):    customer_id: str                # caller-supplied; the model never writes it    ticket_body: str                # caller-supplied    intent: NotRequired[Literal["refund", "status", "question"]]    ticket_id: NotRequired[str]    research_targets: NotRequired[list[str]]    evidence: Annotated[NotRequired[list[Evidence]], operator.add]def plan_research(state: AtlasState) -> dict:    """The model reads the ticket and decides what to investigate."""    plan = planner.with_structured_output(ResearchPlan).invoke(        [{"role": "user", "content": state["ticket_body"]}]    )    return {"research_targets": plan.targets}   # length is whatever the model saiddef fan_out(state: AtlasState) -> list[Send]:    return [Send("research", {"target": t}) for t in state["research_targets"]]graph = (    StateGraph(AtlasState)    .add_node("triage", triage)    .add_node("plan_research", plan_research)    .add_node("research", research)    .add_node("synthesise", synthesise)    .add_edge(START, "triage")    .add_edge("triage", "plan_research")    .add_conditional_edges("plan_research", fan_out, ["research"])    .add_edge("research", "synthesise")    .add_edge("synthesise", END)    .compile(checkpointer=saver))# The line that creates the false sense of safety.graph.invoke(payload, config={"recursion_limit": 25, "configurable": {"thread_id": tid}})

Read fan_out and ask the Part 1 question: does this constraint hold regardless of whether the model complies? There is no constraint. plan.targets is a list whose length the model chose, and fan_out faithfully turns each element into a dispatched task.

The graph is four nodes deep, so by the off-by-one above it needs a limit of 5. A recursion_limit of 25 is five times more headroom than it can use. It will never fire. If the planner returns 400 targets, you make 400 model calls in one superstep, and the run completes successfully, at step 4, under a limit of 25.

There is a second-order effect that Part 2 predicts precisely. That part drew a distinction between a hazard and a defect: a non-commutative reducer makes a key Order-Coupled State, an order-coupled hazard, and two further conditions promote it to a defect - two writers co-active in one superstep, and something downstream that reads the order. evidence uses operator.add, which has no order-independence property. With 400 targets you have 400 writers landing in a single superstep, folded in a runtime-determined order.

Part 2 did not leave that hanging. It added rank to Evidence precisely so that nothing downstream reads the fold order, and said so plainly: the reducer stays non-commutative, and it no longer matters. Evidence carries that field into this part unchanged, so the second promotion condition is not met and width does not create Part 2's defect here.

What width decides is how much weight Part 2's fix is carrying. At one researcher the ordering key was defensive. At 400 it is the only thing standing between you and Part 2's defect, and it now depends on every one of 400 model-chosen researchers actually emitting a rank. A model-chosen width sets the number of independent writers your fix has to hold against, which makes it a correctness decision and not only a cost one.

Why max_concurrency is a throttle, not a width bound

The first response is usually max_concurrency, a RunnableConfig field documented as the maximum number of parallel calls to make. It sounds like the missing bound. It is not.

I measured all four combinations with 200 workers, each sleeping 50 milliseconds, on an 8-core machine:

code
SYNC  max_concurrency=None   peak_simultaneous=12    workers_EXECUTED=200   wall=0.90sSYNC  max_concurrency=10     peak_simultaneous=10    workers_EXECUTED=200   wall=1.04sASYNC max_concurrency=None   peak_simultaneous=200   workers_EXECUTED=200   wall=0.09sASYNC max_concurrency=10     peak_simultaneous=10    workers_EXECUTED=200   wall=1.26s

The right-hand column never changes. Two hundred workers executed in every configuration, so if those workers are model calls you pay for 200 of them at every setting. max_concurrency decides how many run at once. For a run that completes it never decides how many run - which is why width is a count of dispatches, not of simultaneity. It changes the count only when something cuts the run short, and that is not a budget to rely on. Use it to keep a burst off a downstream connection pool. Do not count it as cost control.

The synchronous and asynchronous rows each hide something.

The synchronous row with no setting peaks at 12, which is min(32, 8 + 4). Python's ThreadPoolExecutor default worker count is leaking through as observable behaviour, because LangGraph's BackgroundExecutor passes max_concurrency straight into ContextThreadPoolExecutor(max_workers=...) and gets the standard-library default when it is absent. So synchronous graphs have an accidental cap that nobody chose and that varies with the host's core count. This is consistent with langgraph#3329, filed 6 February 2025, where a user reported issuing 21 Sends and observing only about 16 run in parallel. I cannot verify their core count, so treat that as consistent with the mechanism rather than proof of it.

The asynchronous row with no setting peaks at 200. All of them. That is AsyncBackgroundExecutor, which gates on a semaphore only when you supply the number:

code
if max_concurrency := config.get("max_concurrency"):    self.semaphore = asyncio.Semaphore(max_concurrency)else:    self.semaphore = None

None means no gating. So does 0, since that line is a truthiness test - worth knowing if you compute the value. The fastest row is the asynchronous ungated one, at 0.09 seconds, ten times quicker than the synchronous default. The ungated async path benchmarks best and hits provider rate limits hardest. It is also the default for anyone who wrote ainvoke and never read RunnableConfig.

The Width-Blind Bound

Naming the pattern, so it can be checked:

A Width-Blind Bound is a run-level limit that is correctly specified and correctly enforced over one axis of execution, deployed in a system where an unbounded second axis is chosen by the model. The bound's existence is what makes it dangerous, because a limit that is present and honoured reads as a limit that is sufficient.

Three properties. The measurements above establish the first two for recursion_limit; the third is why the pattern is worth naming at all:

  1. It is real. It fires. The depth-loop experiment proves it.
  2. Its unit is not the quantity the model chose. It counts supersteps. The model chose tasks-per-superstep. Both axes are model-driven in an agent loop - that is what When Agents Do Not Stop documents for depth - but only one of them has a counter.
  3. It reads as total. It is called a recursion limit, it lives in the run config beside the thread identifier, and it raises a loud error when it fires. Every signal says "this is the thing that bounds the run."

Readers will collide with two recent papers that use "width" for adjacent ideas.

DeepWideSearch (arXiv 2510.20168, October 2025) defines search width as "the number of information units to be searched" and search depth as "average search steps for each unit." Those are properties of the task - how much there is to find. Mine is a property of the runtime - how many workers were dispatched in one superstep. The paper makes no claims about parallelism, step budgets, or recursion limits, and its headline result is that the best agents reach a 2.39% average success rate over 220 questions.

WideSeek-R1 (arXiv 2602.04634, February 2026) is the closer case. It defines "width scaling" as parallel subagent orchestration, contrasted with depth scaling in a single agent over many turns, and reports consistent gains as the number of parallel subagents increases. That is almost exactly my referent, with the opposite intent: there, width is a capability to scale up. My contribution is not the word width. It is the bound half of the compound, which that paper never attempts.

One shipped mechanism comes close, and the series has already used it. ToolCallLimitMiddleware and ModelCallLimitMiddleware both import on this pin, and Part 3 introduced the first of them. They count calls and refuse past a cap, which is a real cumulative bound over the tool-call width surface. Two things keep them from closing this gap. They bound a cumulative total rather than the cardinality of one dispatch, so they cannot say "no more than 12 at once." And Part 4 established that ToolCallLimitMiddleware keeps its counter in graph state, which makes it Part 4's Halfway pattern: the count survives a clean resume and rolls back with the checkpoint on a crash, so it is a per-thread estimate rather than a per-thread guarantee. Part 4 recommended it anyway, and I am not withdrawing that either. Neither has any equivalent on the Send surface.

Which puts the term in a specific place in this series' vocabulary. Part 1 named a length bound and a shape bound. Part 3 borrowed the alphabet bound from formal language theory - which actions exist, saying nothing about when. A width bound is the fourth axis: how many tasks one dispatch may create. Note that the two terms in this article are not the same object. A width bound is the mechanism that does not exist. A Width-Blind Bound is the mechanism that does, aimed elsewhere. LangGraph ships mechanisms for the first three. For the fourth it ships a cumulative call counter on the agent surface, nothing that bounds cardinality, and nothing at all on the Send surface.

What the literature already owns, and what it does not

A lot of 2026 work is close to this, and one paper is very close.

The closest prior art states half of my thesis outright. From Agent Loops to Structured Graphs: A Scheduler-Theoretic Framework for LLM Agent Execution (arXiv 2604.11378, April 2026) says, in section 3.2:

"In our framework, this corresponds to |U| > 1 but with a non-deterministic P - the set of parallel operations is determined by the LLM, not by an explicit scheduling policy. This distinction is crucial: even with parallel tool calls, the Agent Loop lacks structural parallelism guarantees."

That is the model choosing the parallel set, named plainly, three months before this article. I am not claiming that observation. What I will claim is what that paper does not do with it. It says this of the bare agent loop, and classes LangGraph separately as a "multi-ready-unit scheduler with a semi-deterministic P," attributing the non-determinism to conditional-edge routing. Fan-out cardinality never enters. It never mentions queue limits or worker pools. And it frames model-chosen parallelism as a verifiability and determinism problem, never connecting it to recursion_limit or any run budget. The paper is also explicit that it presents no empirical results.

The honest statement of what is new here is narrow: not that the model picks the parallel set, but that the runtime ships a bound over the other axis and none over this one, and that the asymmetry is invisible on every artifact the framework prints.

Depth is already policed, and that literature is not mine. When Agents Do Not Stop: Uncovering Infinite Agentic Loops in LLM Agents (arXiv 2607.01641, July 2026) coins Infinite Agentic Loops, defined as a case where "an agentic feedback path repeatedly triggers model calls, tool invocations, agent executions, or workflow transitions without an effective termination condition." Their scanner ran over 6,549 repositories and confirmed 68 findings across 47 projects at 91.9% precision. LangGraph accounts for 23 of those confirmed findings, 33.8%, across 16 projects. That is real experimental coverage of LangGraph, not a citation-table mention.

It is also exclusively about sequential feedback cycles on a single execution path. The paper contains no treatment of parallel dispatch, fan-out, breadth, or Send. The two failures are orthogonal in the cleanest possible way. An Infinite Agentic Loop never terminates, and their scanner would find nothing in the graph I measured above, because a fan-out terminates perfectly and exhausts you anyway. Their subject is a run that will not stop. Mine is a run that stops on schedule after doing a thousand things you did not budget for.

Token Budgets (arXiv 2606.04056, June 2026) catalogs 63 production incidents across 21 frameworks, including an M-delegation-fanout cluster of 11 rows across 6 frameworks. The name is close enough to this article's subject that I went and read the cluster's definition rather than guessing at it. It is a check-then-act race on a shared budget:

"A shared mutable RacyBudget with no lock; can_admit(estimate) checked before the LLM await, record_spend(actual) called after. This is the M-delegation-fanout pattern: under asyncio.gather, the LLM await yields control mid-trial and sibling children pass can_admit on the same pre-spend state before any of them records."

So the failure is siblings racing on one allowance, which the paper elsewhere describes as "not a missing check but a correctly-intended check that races or is bypassed under concurrency." How many siblings there were, and who decided that, is not part of it. The paper's own remedy is explicitly width-agnostic: it reserves against a global pool so that "the global cap binds regardless of fan-out width." That is a spend cap doing the work a width bound would do, which is a reasonable engineering answer and a different one from this article's. Worth noting the paper's own caution about the taxonomy: the eight clusters were assigned by a single primary rater, with a blind second-rater pass giving cluster-assignment agreement at Cohen's kappa 0.44, and the paper presents them as "an exploratory, descriptive organization of the corpus."

The security standards are more interesting for what they leave out. OWASP's LLM10:2025 Unbounded Consumption covers exactly this resource shape - denial of service, denial of wallet, service degradation - but defines it as occurring "when a Large Language Model (LLM) application allows users to conduct excessive and uncontrolled inferences," and all seven of its listed vectors are attacker-driven. In the 50-subagent case quoted earlier, the lead agent picked 50 because 50 seemed right to it. No adversary appears anywhere in that story, so the standard's own framing puts it outside scope.

That "no attacker needed" move is not mine either, and it has a named precedent: No Attacker Needed: Unintentional Cross-User Contamination in Shared-State LLM Agents (arXiv 2604.01350, April 2026) makes the same rhetorical turn for shared memory, and reports that "benign interactions alone produce contamination rates of 57-71%."

When Child Inherits (arXiv 2605.08460, May 2026) models what a subagent receives at spawn - memory, capabilities, termination authority - and lists weak resource control among spawn risks. Its subject is what the child inherits, not how many children there are, and it assumes one agent is already compromised.

Right way: bound the width where the width is chosen

The fix repeats the move Part 3 made for the tool alphabet: put the constraint at the point where the model's output becomes the runtime's action, and make it hold whether or not the model cooperates.

There are two places, and the cheaper one comes first.

Constrain the planner's output schema. targets: conlist(str, max_length=12) makes an over-wide plan a validation failure before it ever reaches state. Part 1 already listed a schema among the mechanisms that specify the runtime, enforced by the validator rather than by model compliance, so this clears the demarcation criterion on its own. Use it. It is one line and it is the highest-leverage width bound available.

It does not finish the job, for four reasons. It bounds one planner's output, so a width derived from a tool result, a retrieval hit list, or caller-supplied input walks straight past it. It is per-call, not a cumulative per-run budget. It fails with a ValidationError you have to catch and route, and a provider that silently retries structured output can turn a max_length violation into re-prompting you never see. And it does nothing for a Command(goto=[Send(...)]) emitted from a node body.

Then bound the dispatch site. The routing function is the one piece of code that sees the list, whatever produced it, before the runtime turns it into tasks.

code
MAX_FANOUT = 12               # per superstepHARD_CEILING = 40             # a plan wider than this is a planning failure, not a big jobTHREAD_WIDTH_BUDGET = 60      # cumulative, for the life of the thread - not one turnclass WidthRefused(Exception):    """The planner asked for a width no configuration would permit."""def bounded_fan_out(state: AtlasState) -> list[Send]:    """Turn a model-chosen target list into a bounded, auditable dispatch."""    requested = list(state.get("research_targets", []))    if len(requested) > HARD_CEILING:        # Refuse rather than truncate; see below for why.        raise WidthRefused(            f"planner requested {len(requested)} targets, ceiling is {HARD_CEILING}"        )    if not requested:        # An empty Send list ends the run - see the measurement below.        return [Send("synthesise_degraded", {"reason": "planner returned no targets"})]    dispatched = requested[:MAX_FANOUT]    return [        Send("research", {"target": t, "width_requested": len(requested),                          "width_dispatched": len(dispatched)})        for t in dispatched    ]

It refuses above a ceiling instead of truncating. A planner emitting hundreds of targets has a defect, and silently cutting its plan to 12 produces a run that looks healthy while answering a different question. The trace should say so. Be deliberate about the shape of that refusal, though: an exception raised from a routing function fails the run at the planner's superstep and discards that task's writes, so a resume re-invokes the planner and may produce the same oversized plan again. If you want the refusal durable and inspectable rather than fatal, route it to synthesise_degraded with the requested count recorded, exactly as the budget path does, and keep the exception for the case you genuinely want paged. Between MAX_FANOUT and HARD_CEILING truncation is reasonable, because a plan of 20 when you allow 12 is just a big job.

It carries the width into each worker's payload. This is the part that addresses the artifact problem. The rendered graph cannot show width, and it never will. But the width can be pushed into the data, where a trace, a log line, or a state inspection will show it. width_requested next to width_dispatched turns an invisible property into a recorded one, and the gap between the two numbers is the alert you actually want. A Send payload is the task's input rather than a state write, so look for it in a trace or in the pending-write rows, not in get_state().

It bounds one superstep. One superstep is the wrong unit for a bill, so a cumulative budget is needed too - and the obvious way to build one is wrong.

That degraded route needs registering, and the corrected builder is:

code
graph = (    StateGraph(AtlasState)    .add_node("triage", triage)    .add_node("plan_research", plan_research)    .add_node("research", research)    .add_node("synthesise", synthesise)    .add_node("synthesise_degraded", synthesise_degraded)   # new    .add_edge(START, "triage")    .add_edge("triage", "plan_research")    .add_conditional_edges("plan_research", bounded_fan_out,                           ["research", "synthesise_degraded"])   # both targets declared    .add_edge("research", "synthesise")    .add_edge("synthesise", END)    .add_edge("synthesise_degraded", END)                   # or the run ends without END    .compile(checkpointer=saver))

Declaring the second target is not optional bookkeeping. Branch._finish passes Send objects through without consulting the path map, so an undeclared target still runs and appears on no rendered artifact. That is a transition the picture does not contain, which is Part 1's unannotated Command(goto=...) failure arriving through a different door - and it is the mirror image of Part 3's Unrendered Edge. Part 3's hides a constraint, which leaves the diagram a sound over-approximation. This hides a transition, which destroys soundness outright.

One more thing the payload does not carry. A Send payload replaces the task's input rather than merging into state, so synthesise_degraded receives only what you put in it. Pass the identifiers it needs, or it will fail on the first AtlasState field it reads.

One more measured detail, because I got it wrong first and the comment in my own draft asserted the opposite. An empty Send list does not fall through to the fan-in. It ends the run. Measured on a minimal plan -> research -> synthesise probe whose router returns the raw Send list, the way the wrong-way fan_out does rather than the way bounded_fan_out does:

code
n=2  -> marks=['plan', 'research', 'research', 'synthesise']   synthesise_ran=Truen=0  -> marks=['plan']                                          synthesise_ran=False

No tasks are created, so nothing writes, so the next tick finds no work and the loop stops. invoke returns the state as of plan_research with no synthesis, no END, and no error. That is the same silent-success shape this whole article is about, and it fires on the perfectly ordinary case of a planner returning zero targets. Route to a real node instead of returning an empty list.

An in-state width counter is a Self-Restoring Bound

The obvious extension is a cumulative counter. One superstep of 12 is fine; thirty supersteps of 12 is 360 model calls. So track the total and refuse when it is spent:

code
# DO NOT SHIP THIS - it is Part 4's Halfway pattern with a new name on it.# Named differently from the real bounded_fan_out on purpose.class AtlasState(TypedDict):    # ... every field from above, plus:    width_spent: NotRequired[int]def bounded_fan_out_in_state(state: AtlasState) -> list[Send]:    requested = list(state.get("research_targets", []))    spent = state.get("width_spent", 0)    allowed = min(MAX_FANOUT, THREAD_WIDTH_BUDGET - spent)    dispatched = requested[:max(0, allowed)]    return [Send("research", {"target": t}) for t in dispatched]    # and somewhere a node writes {"width_spent": spent + len(dispatched)}

Part 4 already measured what that counter does, and it is worth being exact rather than dramatic about it. A running total in state is not a Self-Restoring Bound - Part 4's definition of that is an allowance re-derived on every entry rather than stored as a spent total, and width_spent is stored. Part 4 called this shape the Halfway pattern, and measured it: the total carries across a clean resume, which is precisely why it beats recursion_limit.

What it does not survive is everything else. width_spent lives in AtlasState, and AtlasState is inside the rollback surface. Crash under the default durability="async" and it comes back as whatever the last durable checkpoint said, which may be several supersteps behind where the process actually got to. Fork from an earlier checkpoint and it comes back as whatever that checkpoint said. Part 4's verdict transfers unchanged: you asked for a budget of 60 and you have a budget somewhere in [60, 60 + whatever the crashes ate], with nothing reporting the difference. An approximate width budget is not a width budget, which is why the real one goes outside the rollback surface.

Part 4's rule applies unchanged: the grant identifier may live in state, the consumption record may not. So the cumulative width budget belongs in the same place Part 4 put the refund ledger - a row outside the rollback surface, keyed on the thread, updated with a compare-and-swap:

code
CREATE TABLE IF NOT EXISTS width_ledger (  thread_id  TEXT PRIMARY KEY,  spent      INTEGER NOT NULL DEFAULT 0);-- One row per claim key. A replayed attempt finds its row and inserts nothing.CREATE TABLE IF NOT EXISTS width_claims (  thread_id     TEXT NOT NULL,  checkpoint_ns TEXT NOT NULL,  step          INTEGER NOT NULL,  granted       INTEGER NOT NULL,  PRIMARY KEY (thread_id, checkpoint_ns, step));
code
def claim_width(conn, thread_id: str, ns: str, step: int, want: int, budget: int) -> int:    """Claim up to `want` units of fan-out width for this thread.    Lives outside the checkpointed state on purpose: a resume must not    re-grant a budget this thread has already spent (see Part 4).    """    conn.execute("BEGIN IMMEDIATE")          # the SELECT must be in the transaction    try:        # Idempotency first: a replayed task returns its original grant.        prior = conn.execute(            "SELECT granted FROM width_claims "            "WHERE thread_id = ? AND checkpoint_ns = ? AND step = ?",            (thread_id, ns, step),        ).fetchone()        if prior is not None:            conn.execute("COMMIT")            return prior[0]        conn.execute(            "INSERT INTO width_ledger (thread_id, spent) VALUES (?, 0) "            "ON CONFLICT (thread_id) DO NOTHING", (thread_id,))        # Compare-and-swap: the guard is in the WHERE clause, not in Python.        cur = conn.execute(            "UPDATE width_ledger SET spent = spent + ? "            "WHERE thread_id = ? AND spent + ? <= ?",            (want, thread_id, want, budget))        granted = want if cur.rowcount == 1 else 0        conn.execute(            "INSERT INTO width_claims (thread_id, checkpoint_ns, step, granted) "            "VALUES (?, ?, ?, ?)", (thread_id, ns, step, granted))        conn.execute("COMMIT")        return granted    except Exception:        conn.execute("ROLLBACK")        raise

Note the unit before you copy it. spent is keyed on thread_id and never resets, so this is a budget for the life of a conversation, not for one turn. A thread that exhausts it degrades on every subsequent turn, quietly, which is the exact failure shape this article is named after. If you want a per-turn budget, key the ledger on (thread_id, run_id) with a caller-supplied run id and say where that id comes from. Whichever you choose, decide what an operator does when a legitimate long conversation runs out.

Four things in that function are there because Part 4 got them wrong first, or because review caught them here. The guard is a WHERE clause, so two concurrent callers cannot both read spent = 0 and both grant. BEGIN IMMEDIATE is explicit, because Python's sqlite3 in legacy isolation mode opens a transaction before a write but not before a SELECT - with conn: alone would leave the read outside it. The claim is keyed on (thread_id, checkpoint_ns, step), which is Part 4's rule about deriving grant identifiers deterministically - and it is worth knowing exactly what that key does, so I printed it from inside the routing function:

code
clean run          ('T1', 'plan:22892c88-...-d775a01394ef', 1)fork from earlier  ('T1', 'plan:544e8d54-...-298130e485ae', 2)second turn        ('T1', 'plan:99a824fe-...-d6d6a1ce0344', 7)

checkpoint_ns is node:task_id, and the task id is derived from the checkpoint. So a retry of the same task re-derives the same id and returns the original grant, which is what the key is for. A fork produces a different task id and therefore a fresh charge. That is defensible - a fork is new work - but it is a decision, not an accident, and a fork-heavy workflow will spend the budget faster than a linear one. Do not read this key as "charge once per logical plan." And this is an all-or-nothing claim rather than a partial one, so a caller either gets the width it asked for or gets none.

One practical note the code cannot express: a sqlite3.Connection belongs to the thread that created it, and a routing function runs on a worker thread. Use a per-thread connection factory, or check_same_thread=False behind a lock, or the same Postgres pool Part 4 used.

Then the routing function claims before it dispatches:

code
def bounded_fan_out(state: AtlasState) -> list[Send]:    requested = list(state.get("research_targets", []))    if len(requested) > HARD_CEILING:        raise WidthRefused(f"planner requested {len(requested)} targets")    if not requested:        return [Send("synthesise_degraded", {"reason": "planner returned no targets",                                             "ticket_id": state.get("ticket_id")})]    cfg = get_config()["configurable"]    grant = claim_width(ledger(), cfg["thread_id"], cfg.get("checkpoint_ns", ""),                        get_config()["metadata"]["langgraph_step"],                        min(len(requested), MAX_FANOUT), THREAD_WIDTH_BUDGET)    if grant == 0:        # An empty Send list dispatches nothing, so the fan-in never fires and the        # run ends here. Route explicitly instead - measurement above.        return [Send("synthesise_degraded", {"reason": "width budget exhausted",                                             "ticket_id": state.get("ticket_id")})]    dispatched = requested[:grant]    return [Send("research", {"target": t, "width_requested": len(requested),                              "width_dispatched": len(dispatched)}) for t in dispatched]

The ordering here is the opposite of Part 4's. Part 4 argued for consume-before-act on a refund, by the general rule prefer the direction whose error is detectable and reversible: a duplicate refund is the company's money, and a stranded customer is recoverable. Here the claim also happens before the work, but for a different reason. Claiming before the work costs you a research pass that never ran, which the trace shows and a retry recovers - and it only recovers because the claim is idempotent on (thread_id, checkpoint_ns, step). Without that key a retry would re-charge the ledger. Recording after the work costs you an unbounded fan-out whenever the run dies between dispatch and record. Same direction as Part 4, different justification, and the rule was re-derived here, not inherited.

get_config() rather than a Runtime attribute, incidentally, because Part 4 established that Runtime has no .config on this version.

Finding the width surfaces you already have

Every part of this series ships one audit function, because a property you cannot enumerate is a property you will not maintain. Part 2 shipped print_merge_policy() and Part 3 shipped print_agent_surface(). Here is the width equivalent.

code
from langgraph.pregel import Pregeldef print_width_surfaces(compiled) -> None:    """Report the conditional-edge routers that can emit Send, and the nested graphs.    Over-reports on purpose: a router that merely references Send is flagged    even if it happens to return a plain node name at runtime. It does NOT    inspect node bodies, so a Command(goto=[Send(...)]) returned from a node    is missed - that is a real gap, not a conservative one.    """    builder = compiled.builder    print("WIDTH SURFACES")    def emits_send(code) -> bool:        # Send can hide in a nested code object: a generator expression is never        # inlined, and a list comprehension is only inlined from Python 3.12 (PEP 709).        if "Send" in set(code.co_names):            return True        return any(emits_send(c) for c in code.co_consts if hasattr(c, "co_names"))    found = False    for source, branches in builder.branches.items():        for name, branch in branches.items():            # .func is None for an async router - fall through to .afunc, do not            # rely on getattr's default, which never fires when the attr exists.            inner = (getattr(branch.path, "func", None)                     or getattr(branch.path, "afunc", None) or branch.path)            code = getattr(inner, "__code__", None)            if code and emits_send(code):                found = True                print(f"  [FAN-OUT] {source} -> {getattr(inner, '__name__', name)}() "                      f"can emit Send; cardinality unbounded by the runtime")    if not found:        print("  (no routing function references Send)")    nested = [n for n, spec in builder.nodes.items()              if isinstance(getattr(spec, "runnable", None), Pregel)]    for n in nested:        print(f"  [NESTING] node '{n}' is a compiled graph: it re-grants the "              f"full recursion_limit and costs the parent 1 superstep")    if not nested:        print("  (no nested compiled graphs)")    print("  NOTE: recursion_limit bounds supersteps only. None of the above is "          "counted by it.")

Run against the Atlas graph above, with a research -> synthesise conditional router and a compiled team subgraph added alongside (both added for this run, so the graph differs slightly from the listing above):

code
WIDTH SURFACES  [FAN-OUT] plan_research -> bounded_fan_out() can emit Send; cardinality unbounded by the runtime  [NESTING] node 'team' is a compiled graph: it re-grants the full recursion_limit and costs the parent 1 superstep  NOTE: recursion_limit bounds supersteps only. None of the above is counted by it.

The report omits the research -> synthesise router, because that router's code object never references Send. The heuristic discriminates rather than flagging every branch.

It has two further limits beyond the one its docstring already names. It inspects a code object's names, so a routing function that builds Send objects in a helper module it imports will be missed; treat the output as a starting inventory. It also reports surfaces rather than widths, for the reason given above.

Nested subgraphs re-grant the whole recursion_limit

There is a second width surface, and it is the one with no public answer.

Subgraphs are how LangGraph composes multi-agent systems. A specialist team is a compiled graph added as a node in the parent. The question every practitioner eventually asks is what happens to recursion_limit across that boundary. I found two separate LangChain forum threads asking it - one from 18 July 2025 with ten numbered assumptions and no replies, one from 30 October 2025 asking whether the limit can be set per-subgraph, also unanswered - and a GitHub discussion whose URL no longer resolves, so I cannot cite it. The official subgraphs documentation says nothing about recursion limits, step counting, or supersteps at all.

Two threads asking, neither answered, is itself a fact about the state of the world. So I measured it.

The setup: a parent graph with recursion_limit=10 that burns three supersteps before calling a subgraph, and a subgraph that needs three internal supersteps. Both read langgraph_step and RemainingSteps.

code
parent (node, step, remaining):  ('p1', 1, 9)  ('p2', 2, 8)  ('p3', 3, 7)  ('p_after', 5, 5)child  (step, remaining):  (1, 9)  (2, 8)  (3, 7)

The parent was at step 3 with 7 remaining when it dispatched the subgraph. The child's first internal step reports 9 remaining, which is exactly what the parent had at its own first step.

The invariant behind those numbers is worth stating directly: across every run I measured, langgraph_step + remaining_steps == recursion_limit, at every step and at both limits I tried. So remaining_steps is the limit minus the step counter, and the child's counter starts over. The invariant is per-invocation: it does not survive a resume, because Part 4 showed self.stop being recomputed from the resumed checkpoint's metadata, so remaining_steps arrives back near full while langgraph_step keeps climbing. That is Part 4's finding restated in this part's units.

That reconciles with the + 1 in the constructor rather than contradicting it. The loop's internal counter runs one ahead of the langgraph_step stamped into a node's metadata, and the offset absorbs the extra tick. It also explains the headline experiment: a graph whose deepest node reports step 3 needs recursion_limit=4, not 3, because the loop has to tick once more to discover there is no work left.

The child reads the parent's recursion_limit value and computes a fresh allowance from it, with its own counter restarting at 1. The parent's remaining budget never reaches it. That follows from the source line quoted earlier: the child runs its own PregelLoop, and self.stop = self.step + recursion_limit + 1 is evaluated against the child's own step counter, seeded from the child's own checkpoint namespace.

Meanwhile the parent's counter goes from 3 at p3 to 5 at p_after. Step 4 was the entire subgraph.

  • The allowance is re-granted per nesting level, not divided across it. Each level can spend a full L before its own halt fires, and each level's entire spend costs its parent one superstep. I measured one level; the multiplicative growth below follows arithmetically from that, and is an upper bound reachable only if each level actually re-invokes its child repeatedly. So the reachable total grows multiplicatively with nesting depth rather than being shared out of one pool. Take the old default of 25 across three levels of nesting: the number that reads like a cap on the run bounds only the outermost graph, and the system can reach the order of 25 cubed supersteps beneath it. Nobody wrote 15,625 anywhere.
  • The parent's remaining budget cannot see the child's work. A parent that has spent 3 of its 10 supersteps still hands the child a fresh 10, and can call it again next superstep. The parent's value does constrain each child invocation. Its remainder constrains nothing, and the running total is bounded by neither.

The single-level behaviour is measured, not read from the source, and I could not find it stated anywhere public. That is what is new here. Not that nesting exists, but that the number practitioners set as a run budget is a per-level quantity, and that two separate people asked which it was and got no answer.

And the child's limit is enforced on its own terms. Setting the parent to 6 while the child needs 8 raises GraphRecursionError from inside the child task, quoting the parent's number:

code
langgraph.errors.GraphRecursionError: Recursion limit of 6 reached without hitting a stop condition.During task with name 'sub' and id '68517ae1-...'

This is the spatial analogue of Part 4. Same source line, same re-derivation. Part 4 hit it across a resume; this part hits it across a compile boundary.

The harness built on top of LangGraph has a documented failure in the same area. deepagents#1698, filed 7 March 2026 and since fixed in pull request 2194, reported that SubAgentMiddleware did not propagate recursion_limit to subagent graphs at all, because it called await subagent.ainvoke(subagent_state) without passing config. Subagents silently used the old default of 25. The reporter described a production legal-document analyser where a subagent exhausted that default, and the resulting GraphRecursionError surfaced as asyncio.CancelledError that cascaded to its sibling parallel subagents. Their step arithmetic counted model calls and tool calls, which is the conflation this whole article is about, so take the cascade rather than the number.

I am not claiming that cascade as a general property of the primitives. asyncio.gather with the default return_exceptions=False propagates the first exception to the awaiter without cancelling its siblings; asyncio.TaskGroup does cancel them. In a Send fan-out you do not write either one - AsyncBackgroundExecutor does, and what it does with outstanding tasks on a first exception is worth reading before you rely on a wide fan-out degrading gracefully.

The default limit is now effectively a sentinel

One more measurement, because it changes how much protection the depth bound is even offering.

code
>>> from langgraph._internal._config import DEFAULT_RECURSION_LIMIT>>> DEFAULT_RECURSION_LIMIT10007

That is measured on langgraph 1.2.9. The official Graph API documentation states that "starting in version 1.0.6, the default recursion limit is set to 1000 steps." Those disagree. The shipped constant on this version is 10007, the documentation says 1000, and the historical value older material still quotes is 25. Trust the constant you print, not the page.

The history is in langgraph#7313, opened by LangChain maintainer Eugene Yurtsev on 27 March 2026, titled "Update recursion limit magic number and explore changing to a sentinel value." It notes the magic number was 10,000, that 25 could reset it during config merging, and that the intent was to move to a sentinel.

A sentinel would matter here, because a sentinel is a value something else interprets - if the loop mapped 10007 back to 1000, the documentation would be right and the constant would be a red herring. So I ran a graph that never terminates, with no recursion_limit in the config at all:

code
Recursion limit of 10007 reached without hitting a stop condition.supersteps actually executed before halt: 10007

It is a real limit, not a sentinel the loop reinterprets. The run executed 10,007 supersteps in 8.1 seconds and halted on that number. The shipped constant wins and the documentation is stale.

The source reads it through getenv, so print what your process actually uses.

The practical consequence is that if you do not set recursion_limit, your depth bound is a number chosen to be recognisably artificial. Treat it as a tripwire for a runaway loop.

The maintainers know the shape of this. langgraph#5883, opened by LangGraph maintainer Sydney Runkle on 12 August 2025 and still open and unassigned, states that "right now recursion limit is enforced as supersteps but not on a node by node basis," and asks where per-node call metadata should live. That is per-node depth accounting, not width.

The subgraph shared-key double count

While measuring the step accounting above I hit a defect that belongs in its own section, because it is pure Part 2 territory and I have not seen it written down.

My first probe used one trace key shared between parent and subgraph. The output contained the parent's three entries twice. I assumed my probe was broken. It was not.

code
class Sub2(TypedDict):    shared: Annotated[list, operator.add]# parent writes one item, child writes one itemr = parent_app.invoke({"shared": []})
code
final shared = ['from_parent', 'from_parent', 'from_child']length = 3   (would be 2 if the child's write were additive only)'from_parent' appears 2 time(s)

The mechanism: a subgraph added as a node with a shared reduced key receives the parent's accumulated value as its input. Its output write is then folded into the parent's existing value by the same reducer. Anything the child passes back through - including the parent's own history, which it received and did not remove - gets counted a second time.

operator.add on a list makes this visible immediately. On a counter it doubles silently. On an idempotent reducer - set union, dictionary merge, a max wrapper - the round-trip is harmless, because folding the same value in twice changes nothing. That is worth knowing, because it tells you the property that saves you is idempotence, not the boundary. Part 2 argued that the reducer is the concurrency-control policy for a state key, and that it is executable code hiding inside a type annotation where reviewers, diagrams and type checkers all miss it. A subgraph boundary is a second place that same annotation gets applied, and the boundary is not visible in the annotation either.

The official documentation already implies the fix without spelling out this consequence. For a subgraph added as a node, either give the child its own keys and transform at the boundary, or have the child return only its own contribution. What you must not do is let a reduced key round-trip through the child.

Do you actually need more than one agent?

Width reframes the one-agent-or-many argument around a different question.

The two poles were published one day apart. Cognition's Don't Build Multi-Agents, 12 June 2025, argues two principles: share context, and share full agent traces rather than individual messages; and actions carry implicit decisions, so conflicting decisions produce bad results. Its conclusion is that in multi-agent systems "the decision-making ends up being too dispersed and context isn't able to be shared thoroughly enough between the agents." Anthropic's write-up, one day later, reports the 90.2% and 15x figures quoted earlier.

Both are honest reports from production. They disagree because they are describing different work. Cognition's failing example is parallel writing - two subagents building incompatible halves of one artifact. Anthropic's succeeding example is parallel reading - subagents gathering information that a lead agent then synthesises.

Cognition's own 2026 update, Multi-Agents: What's Actually Working from 22 April 2026, lands on exactly that line: multiple agents contributing intelligence to a task works when writes stay single-threaded, and unstructured parallel writing does not. They report a dedicated review agent catching an average of 2 bugs per pull request, 58% of them severe, and describe asymmetric delegation to a weaker primary agent as still an open problem.

LangChain's own current guidance is blunter than the discourse: "not every complex task requires this approach - a single agent with the right (sometimes dynamic) tools and prompt can often achieve similar results." The named-pattern libraries have already moved. langgraph-supervisor (0.0.31, November 2025) carries a soft-deprecation on its own package page, recommending the supervisor pattern be built directly with tools instead, and the current official taxonomy no longer uses the words supervisor or swarm - it lists subagents, handoffs, skills, router, and custom workflow.

Here is the decision rule I apply.

Do not ask "one agent or many." Ask which bounds you lose at the boundary, and whether you can re-establish them:

BoundSurvives the boundary?What to do
Length (depth)No. Re-granted per nesting level, and one parent superstep per child graph.Set it explicitly at every level. The parent's value constrains each child invocation; the parent's remainder constrains nothing.
ShapePartially. Each graph's edge set still holds inside it, but the composition is a new relation nobody printed.Print each participant's graph and reason about the join by hand.
AlphabetNo. Middleware is registered per agent, so a handoff is entry into a differently-guarded tool set.Re-register execution-side guards on every agent that can reach the effect.
WidthNot at the dispatch site. A schema constraint bounds one planner's output; nothing bounds the Send list.Bound it at every routing function that turns model output into Send calls.

If the honest answer for a given split is that you gain specialised context and lose nothing you cannot re-establish, split it. If the answer is that you gain a tidier diagram and lose every bound in that table, you have bought a diagram and given up the bounds.

And there is a strong empirical case for the least exotic topology. Benchmarking Multi-Agent LLM Architectures for Financial Document Processing (arXiv 2603.22651, March 2026) ran four orchestration patterns over 10,000 filings from the United States Securities and Exchange Commission (SEC) across 25 field types. A reflexive self-correcting pattern reached F1 0.943 at 2.3 times the sequential baseline cost; a hierarchical supervisor-worker pattern reached F1 0.921 at 1.4 times, which the authors describe as the most favourable point on the cost-accuracy frontier. The abstract gives no separate figures for the parallel fan-out arm and does not say who chose the fan-out degree, so I am not citing fan-out numbers from it.

Cross-thread memory is shared state with no reducer

Part 5 deferred cross-thread memory to this part.

Cross-thread memory - the store, as distinct from the checkpointer - is how agents share knowledge outside a single thread. Part 5 established two properties of it by measurement: the store sits outside the rollback surface, so a write survives a fork that rolls the thread back and is visible from a brand-new thread identifier; and BaseStore.search() is prefix-scoped with a default limit=10, so a naive sweep under-deletes in silence.

Put a multi-agent system on top of that and the picture from Part 2 applies with every guarantee removed. Graph state has a declared schema, a per-key merge policy, and a rollback surface. Store keys have none of them. Two agents writing the same namespace have no reducer mediating them, no InvalidUpdateError when they collide in a superstep, and no checkpoint to roll back to when one of them writes something wrong.

No Attacker Needed, cited earlier for its rhetorical move, measures the consequence directly: one agent serving multiple users over a shared knowledge layer, where information valid for one user degrades another's outcomes when reapplied out of scope.

Width multiplies the number of unmediated writers, and that number is one the model chose. The practical rule: namespace store writes by the thread or task that produced them, and promote to the shared namespace from the fan-in node, where exactly one writer runs.

I have not measured this one, which is why it is a short section and not one with a table in it. Treat it as the shape of the problem and not as a result.

Checklist: bounding width and cost before it bounds you

Work through this on any graph where a model's output determines how many things run.

Find the width surfaces.

  • Grep for Send( and read every routing function that returns a list. For each, answer: is this list length a constant, a length of caller-supplied input, or a length derived from a model call?
  • Grep for tool-calling agents that can emit multiple parallel calls in one message. There the width is the number of tool calls in one assistant message, not a list length.
  • List every compiled subgraph used as a node. Each is a nesting level that re-grants the full recursion_limit.

Bound each one where the width is chosen.

  • Cap per-superstep fan-out in the routing function, not downstream.
  • Refuse, do not truncate, above a ceiling that indicates a planning failure. Truncation hides a bad plan behind a healthy-looking run.
  • Keep the cumulative width budget outside the checkpointed state. In state it is a Self-Restoring Bound and a resume re-grants it.
  • Decide whether that budget is per thread or per turn, and say so in the name. A per-thread budget with no reset silently degrades every later turn once it is spent.
  • Set recursion_limit explicitly at every nesting level. The child gets the value, not the remainder.

Make the width visible, since the graph will not show it.

  • Carry width_requested and width_dispatched in each worker's payload, and alert on the gap.
  • Print the live default rather than trusting documentation: the shipped constant and the documented value currently disagree. Read it from a run rather than importing langgraph._internal._config, which is private and may move.
  • Assert on fan-out width in tests. A graph whose planner returns 3 targets in the test suite and 300 in production has no test coverage of its width behaviour.

Do not mistake the throttle for the bound.

  • Set max_concurrency to protect provider rate limits, database pools and connection pools. That is what it is for.
  • Do not record it as a cost control. Every worker still runs; they merely queue.
  • If you run async, set it explicitly. Unset means no gating at all, and the ungated path is the fastest one in a benchmark.

Then re-ask the split question. For each agent boundary, name which of length, shape, alphabet and width you lose, and where you re-establish it. If the answer for any of them is "nowhere", that boundary is not ready.

The bound was working the whole time

The limit was set. It was honoured. A thousand workers ran underneath it.

That is what makes a Width-Blind Bound worth naming. An absent bound announces itself the first time something runs away. This one produces a run that terminates cleanly, reports success, stays inside its declared limit, and bills you for every call in a fan-out you never sized. The number in your config was doing its job the whole time.

LangGraph's case for existing, the one Part 1 accepted, is that it makes the model's unreliability boundable, observable and recoverable. Width is currently the weakest of the three: bounded only where you bolt a schema constraint onto one planner's output, unbounded at the Send dispatch site itself, invisible on every rendered artifact, and re-granted at each level of nesting. Other harnesses have started shipping the missing bound with three separate names for its three separate axes. Until this one does, the width bound is yours to write, and every place model output becomes a dispatch is where it goes: the planner's output schema, the routing function, a Command(goto=[...]) in a node body, and the tool-call list in a single assistant message.

Part 7 of this series turns to production engineering, where a run that fanned out four hundred ways has to be traced, costed and debugged by whoever is on call.

References

Official documentation

Source read at langgraph==1.2.9

  • langgraph/pregel/_loop.py - self.stop = self.step + self.config["recursion_limit"] + 1
  • langgraph/pregel/_algo.py - prepare_next_tasks, the PUSH task loop
  • langgraph/pregel/_executor.py - BackgroundExecutor, AsyncBackgroundExecutor
  • langgraph/managed/is_last_step.py - scratchpad.stop - scratchpad.step
  • langgraph/_internal/_config.py - DEFAULT_RECURSION_LIMIT
  • langchain_core/runnables/graph_mermaid.py - conditional edge rendering

Issues and threads

Papers

  • Wei, H. (2026). From Agent Loops to Structured Graphs: A Scheduler-Theoretic Framework for LLM Agent Execution. arXiv:2604.11378. https://arxiv.org/abs/2604.11378
  • Hou, X., Wang, S., Zhao, Y., & Wang, H. (2026). When Agents Do Not Stop: Uncovering Infinite Agentic Loops in LLM Agents. arXiv:2607.01641. https://arxiv.org/abs/2607.01641
  • Khan, S. (2026). Token Budgets: An Empirical Catalog of 63 LLM-Agent Budget-Overrun Incidents. arXiv:2606.04056. https://arxiv.org/abs/2606.04056
  • Yang, T., Li, J., Nian, Y., Dong, S., Xu, R., Rossi, R., Ding, K., & Zhao, Y. (2026). No Attacker Needed: Unintentional Cross-User Contamination in Shared-State LLM Agents. arXiv:2604.01350. https://arxiv.org/abs/2604.01350
  • Cai, Z., Zhang, Y., & Hei, X. (2026). When Child Inherits: Modeling and Exploiting Subagent Spawn in Multi-Agent Networks. arXiv:2605.08460. https://arxiv.org/abs/2605.08460
  • Xu, Z., et al. (2026). WideSeek-R1: Exploring Width Scaling for Broad Information Seeking via Multi-Agent Reinforcement Learning. arXiv:2602.04634. https://arxiv.org/abs/2602.04634
  • Lan, T., et al. (2025). DeepWideSearch: Benchmarking Depth and Width in Agentic Information Seeking. arXiv:2510.20168. https://arxiv.org/abs/2510.20168
  • Kulkarni, S., & Kulkarni, Y. (2026). Benchmarking Multi-Agent LLM Architectures for Financial Document Processing. arXiv:2603.22651. https://arxiv.org/abs/2603.22651

Industry


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