← Back to Guides
1

Series

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

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

LangGraph or a While Loop? You Already Have a Runtime

A checkpointer makes the run's past readable and leaves its next-step space exactly as wide as it was. Production agents fail in that gap.

#langgraph#agent-runtime#durable-execution#agent-control-flow#production-ai#state-management

In July 2025, a coding agent on Replit deleted a live production database during an active code freeze. The freeze had been communicated to the agent, repeatedly, in writing. The agent then reported that rollback was impossible, which was also wrong, and the wrong report delayed recovery.

This is the argument for reaching for LangGraph instead of a hand-rolled while loop, and it is not that the loop is broken. It is that every agent already has a runtime, specified or not.

Sourcing first, because it matters for how hard I lean on this. The incident is reported by the affected user and corroborated by press coverage. Replit acknowledged it and apologised. There is no published engineering postmortem, so treat what follows as reported rather than as verified internal root cause.

That caveat cuts to the useful part. No public account describes the reachable action set that night, and the absence is the point. Nothing in the record suggests any mechanism existed that could have made destructive commands unreachable during the freeze. The freeze lived in the context window. A constraint had been written down, and nothing in the running system had been told to enforce it.

Note what Replit changed afterwards: development and production database separation, with staging databases. The remediation was a reachability constraint. The narration was about a disobedient agent; the fix was about what the agent could reach.

Nobody on that team thought they had shipped a runtime. They had, and its guarantees turned out to be whatever the code did that night.

The thesis: you cannot opt out of having a runtime

A while loop around a model call is not the absence of a runtime. It is a runtime whose guarantees were never specified.

Something in your system already decides what happens when the process dies mid-run. Something already decides whether a completed step can run twice. Something already decides which tools are reachable at step forty. In a hand-rolled loop, that something is not a design decision. It is whatever your code happened to do. I call this the Accidental Runtime: the runtime you shipped without deciding to build one, whose specification is the emergent behaviour of the code you wrote for other reasons.

So the real choice is not "framework or no framework." That argument is loud, and it is measuring the wrong axis. What separates these systems is whether the runtime was specified or inherited.

Here is the contested part:

Durable execution externalises the run's past. It leaves the next-step space exactly as wide as it was.

Checkpointing gives you resumption, replay, and a readable history, and none of that narrows what the agent may do next. Add a checkpointer to a hand-rolled loop and you largely address two of this series' three failure modes, lost state and no recovery, while the third, hallucinated control flow, does not move at all. (Largely, because naive checkpointing has its own gaps, which Part 4 takes apart.) Teams routinely read that as closing all three. I call the resulting condition Backward-Only Durability: the run's history is durable, and its reachable next step is still anything the model names.

Not "backward" in the backward-error-recovery sense, where a system rolls back instead of repairing forward. The direction here is temporal: the durable object is the run's past, and the run's future is untouched.

What a hand-rolled agent loop actually guarantees

Here is Atlas, the customer-support agent this series builds. This is the version that ships first, because it works.

Identifiers used but not defined below live in the companion repository: TOOL_SCHEMAS and the tool implementations, load_messages and save_messages, the model and classifier handles, ticket_api, billing, render_context, checkpointer, DB_URL, session, and user_text. All code targets the LangGraph 1.x line, checked against LangGraph 1.2.9 and LangChain 1.3.14 in July 2026; the primitives used here (StateGraph, nodes, edges, conditional edges, checkpointers) predate 1.0 and carry no beta marker. One helper carries a constraint worth stating: render_context must tolerate the refund-path keys being absent, since the question path never sets them, and must treat them as stale unless the current turn set them.

code
# atlas/loop.py - the version that looks fine in reviewimport jsonfrom anthropic import Anthropicclient = Anthropic()TOOLS = {    "search_kb": search_kb,    "get_ticket": get_ticket,    "update_ticket": update_ticket,    "issue_refund": issue_refund,}def run(user_message: str, max_steps: int = 25) -> str:    messages = [{"role": "user", "content": user_message}]    for _ in range(max_steps):        response = client.messages.create(            model="claude-sonnet-5",            max_tokens=2048,            tools=TOOL_SCHEMAS,            messages=messages,        )        messages.append({"role": "assistant", "content": response.content})        tool_calls = [b for b in response.content if b.type == "tool_use"]        if not tool_calls:            return "".join(b.text for b in response.content if b.type == "text")        results = []        for call in tool_calls:            result = TOOLS[call.name](**call.input)            results.append({                "type": "tool_result",                "tool_use_id": call.id,                "content": json.dumps(result),            })        messages.append({"role": "user", "content": results})    raise RuntimeError("max steps reached")

There is nothing wrong with this code. It is competent, it is readable, and Thomas Ptacek is right that you should write one to understand what an agent is.

Now read one line as a specification rather than as code:

code
result = TOOLS[call.name](**call.input)

That line says: from any point in this run, any bound tool may execute next. issue_refund is reachable on step one, before any ticket is fetched and before any entitlement is checked. It is reachable on step twenty after the model has talked itself into a corner. There is no state in which it is unreachable, because there is no notion of state at all beyond a growing message list.

Absent an explicit mechanism, the transition relation of a loop like this is complete over the bound tool set. Every tool, from every point. (Not "total". In formal methods a total transition relation only means every state has some successor, which is nearly the opposite of what is happening here.) That is the narrow version of the claim, and the narrowness is the point. Four mechanisms narrow it without a graph, and they get their own section.

Adding a LangGraph checkpointer: what it fixes and what it does not

The standard fix, and it is a real fix, is durability. Persist the run so a crash does not lose it.

code
def run(user_message: str, thread_id: str, max_steps: int = 25) -> str:    messages = load_messages(thread_id) or [        {"role": "user", "content": user_message}    ]    for _ in range(max_steps):        response = client.messages.create(            model="claude-sonnet-5",            max_tokens=2048,            tools=TOOL_SCHEMAS,            messages=messages,        )        messages.append({"role": "assistant", "content": response.content})        tool_calls = [b for b in response.content if b.type == "tool_use"]        if not tool_calls:            save_messages(thread_id, messages)            return "".join(b.text for b in response.content if b.type == "text")        results = []        for call in tool_calls:            result = TOOLS[call.name](**call.input)            results.append({                "type": "tool_result",                "tool_use_id": call.id,                "content": json.dumps(result),            })        messages.append({"role": "user", "content": results})        save_messages(thread_id, messages)  # durable now    raise RuntimeError("max steps reached")

This is a genuine improvement. The process can die and the run survives. An entire ecosystem exists because durable, resumable state is hard to get right: Temporal, Restate, DBOS, LangGraph's own checkpointers.

Now look at what did not change. TOOLS[call.name] sits there untouched, so the relation is still complete over the tool set. Durability bought you the history. On step forty it will still dispatch whatever the model names.

A later part has to fix two things here. The save happens after the whole tool batch, so a crash between issue_refund succeeding and save_messages returning loses the entire step: the tool call and its result. On resume the model is re-asked, and it will usually re-issue the refund, so the side effect happens twice with nothing recording that it did. And range(max_steps) restarts at zero on every resume, so a run that crashes repeatedly has no effective length bound at all. At-least-once side effects are the hard part of what the durable-execution vendors actually sell, and they are a Part 4 problem.

That is Backward-Only Durability, and the vendors are not hiding it. Restate's durable-agent post is titled "Fault Tolerance across Frameworks and without Handcuffs", and it positions durability as something you add without being forced into a structure: "Everything we show here is independent and orthogonal to any agent SDK." Read that carefully. The "handcuffs" are framework lock-in, not the agent's action space, so the title is not a confession about reachability. It is a statement about the same axis from the other end. A durability layer that imposed a control-flow model would be a worse product, so they correctly decline to impose one, and what they decline to impose is exactly the thing no other durability layer supplies either.

The problem is on the buying side. Buyers hear one word, reliability, and stop reading the datasheet.

The Temporal objection, and why it narrows the claim rather than defeating it

Anyone who has run Temporal in production will push back here, and they should. Temporal requires deterministic workflow code: the same input must produce the same sequence of workflow API calls. That sounds exactly like a constraint on the legal sequence of steps.

Temporal itself says otherwise, and published a post to say it:

"While Temporal requires that your Workflow code is deterministic, your AI Agent can absolutely make decisions based on non-deterministic LLM outcomes."

Temporal wrote that post because customers kept assuming the opposite, which makes them the vendor best positioned to observe this misconception in the wild. The mechanism is that Activities execute outside the replay path. Model calls and tool invocations are Activities, specifically so a non-deterministic choice does not blow up replay.

So the determinism requirement is a replay-consistency property over commands already issued. It constrains the past.

That is narrower than saying Temporal constrains nothing going forward, and the difference matters. A Temporal workflow body is a specified transition relation, enforced by the runtime with durable, automatically retried activities, plus signals and updates for out-of-band gating. Activities are at-least-once, not exactly-once, so idempotency stays yours to handle. DBOS workflows and Restate's virtual objects are specified transition relations in the same way. That is this article's first claim, arrived at from a different direction, and it is why Temporal shops rarely hit the failure described here.

The forward constraint came from the workflow code you wrote. Write an unconstrained loop inside the workflow and durability will not put the constraint back.

What Anthropic's multi-agent writeup shows about durability versus control flow

The best public exhibit is Anthropic's writeup of their multi-agent research system, because it is first-party and because durability was demonstrably in place.

They describe "retry logic and regular checkpoints" as deliberate deterministic safeguards. They state that agents "can resume from where the agent was when the errors occurred." Durability: present, working, designed.

In the same document, the control-flow failures are described separately and are not solved by any of it:

  • "early agents made errors like spawning 50 subagents for simple queries"
  • given one research task, "one subagent explored the 2021 automotive chip crisis while two others duplicated investigations into current 2025 supply chains"
  • "one step failing can cause agents to explore entirely different trajectories, leading to unpredictable outcomes"

Checkpoints did not prevent 50 subagents. They recorded that it happened. Those are different properties, and the gap between them is the whole argument.

I wanted to make a sharper claim than this one, and it would have been wrong. Durability is not useless against bad control flow. It makes a bad trajectory legible and replayable, which is a precondition for diagnosing it and then constraining it. Anthropic added full production tracing for exactly that reason. Durability makes bad control flow legible, not unreachable.

Constraining the next-step space with LangGraph StateGraph

The fix is to make the reachable next step a property of the system rather than a hope about the model.

code
from typing import Annotated, Literal, NotRequired, TypedDictfrom langchain.messages import AnyMessage, HumanMessage, SystemMessagefrom langgraph.graph import StateGraph, START, ENDfrom langgraph.graph.message import add_messagesfrom pydantic import BaseModelclass Triage(BaseModel):    """The only control-flow choice the model is allowed to make."""    intent: Literal["refund", "question"]    ticket_id: str | Noneclass AtlasState(TypedDict):    # Supplied by the caller at invoke() time. The model never writes this.    customer_id: str    messages: Annotated[list[AnyMessage], add_messages]    # Everything below is model-written or derived from a model-written field.    intent: NotRequired[Literal["refund", "question"]]    ticket_id: NotRequired[str | None]    refund_cents: NotRequired[int]    refund_allowed: NotRequired[bool]    receipt_id: NotRequired[str | None]def triage(state: AtlasState) -> dict:    """The model classifies the request. It picks an intent, not a next node."""    decision = classifier.with_structured_output(Triage).invoke(state["messages"])    return {"intent": decision.intent, "ticket_id": decision.ticket_id}def lookup_ticket(state: AtlasState) -> dict:    # Scoped to the caller. Raises if the ticket is not theirs.    ticket = ticket_api.get(state["customer_id"], state["ticket_id"])    return {"refund_cents": ticket["amount_cents"]}def check_entitlement(state: AtlasState) -> dict:    allowed = billing.refund_allowed(        state["customer_id"], state["ticket_id"], state["refund_cents"]    )    return {"refund_allowed": allowed}def issue_refund(state: AtlasState) -> dict:    receipt = billing.refund(        state["customer_id"], state["ticket_id"], state["refund_cents"]    )    return {"receipt_id": receipt["id"]}def answer(state: AtlasState) -> dict:    # State carries the facts; render_context turns them into a system message.    # Nothing here is a tool result, so there is no orphaned tool-call handshake.    context = SystemMessage(content=render_context(state))    return {"messages": [model.invoke([context, *state["messages"]])]}

The routing lives on edges, where you can read it. The Literal return annotations are load-bearing: LangGraph uses them (or an explicit path_map) to know a conditional edge's possible targets. Drop them and it has to assume the function could return any node name.

code
def after_triage(state: AtlasState) -> Literal["lookup_ticket", "answer"]:    if state["intent"] == "refund" and state.get("ticket_id"):        return "lookup_ticket"    return "answer"def after_entitlement(state: AtlasState) -> Literal["issue_refund", "answer"]:    return "issue_refund" if state["refund_allowed"] else "answer"builder = StateGraph(AtlasState)builder.add_node("triage", triage)builder.add_node("lookup_ticket", lookup_ticket)builder.add_node("check_entitlement", check_entitlement)builder.add_node("issue_refund", issue_refund)builder.add_node("answer", answer)builder.add_edge(START, "triage")builder.add_conditional_edges("triage", after_triage)builder.add_edge("lookup_ticket", "check_entitlement")builder.add_conditional_edges("check_entitlement", after_entitlement)builder.add_edge("issue_refund", "answer")builder.add_edge("answer", END)graph = builder.compile(checkpointer=checkpointer)

The schema is making a claim. Read which state fields the model may write: intent and ticket_id come from the model. customer_id comes from the caller at invoke() time and the model never touches it. That separation is what gives every call site the caller's identity to scope against: a model-supplied "refund ticket 88213" is evaluated together with customer_id rather than on its own. The enforcement itself still lives inside ticket_api.get, which is the same class of invariant as the one in the loophole section below, and enforceable the same way. A state schema that does not separate those two origins is ambient authority wearing a TypedDict, and the guarded edge above would be checking the wrong subject.

The model still chooses. triage is a model call, and its output decides whether the run heads toward a refund or toward an answer. What changed is the size of the set it chooses from. It emits one of two intents, and the system decides what each intent reaches.

The sloppy version of this sentence is wrong, so be precise. issue_refund is still reachable from START. Reachability is transitive, and the path runs triage → lookup_ticket → check_entitlement → issue_refund. What it has is a guarded mandatory predecessor: a single inbound source whose edge is guarded by state, not merely a node that every path happens to pass through. There is no edge into issue_refund except from check_entitlement, and that edge is taken only when refund_allowed is true. Every path to a refund goes through the entitlement check. No prompt wording changes that, because no prompt is consulted at the gate.

Compare that to TOOLS[call.name], where issue_refund has a predecessor that guards nothing.

This example is deliberately acyclic, because a graph you can read in one screen makes the point better than a realistic one. Real graphs have cycles, and a skeptic is entitled to ask whether the guarantee survives them. It does. A cycle widens the set of states from which a node is reachable, and it does not add an inbound edge. The guarded mandatory predecessor is a property of the edge set, not of the path length, so looping does not create a new way into the refund. That is the property to hold on to when Part 2 puts the cycles back.

Below, the refund step is drawn twice: once as the loop reaches it, once as the graph reaches it. Find the red node and the green node, then read the edge that enters each one.

On the left, the single edge into the refund is unguarded, and its source is a dispatch on a string the model emitted. Every completed tool returns to the model, which dispatches again, so there is no state in which that edge is closed. On the right there is also one edge in, but a named function over state decides whether it is taken, and the only way to reach that function is through an entitlement check. Guardedness is the difference, not arrow count.

mermaid
flowchart TD
    subgraph inherited["Inherited runtime: one inbound edge, no guard"]
        direction TB
        M1["Model names<br/>a tool"] --> D1{"Dispatch on<br/>tool name"}
        D1 --> A1["search_kb"]
        D1 --> A2["get_ticket"]
        D1 --> A3["update_ticket"]
        D1 --> A4["issue_refund"]
        A1 --> M1
        A2 --> M1
        A3 --> M1
        A4 --> M1
    end

    subgraph specified["Specified runtime: one guarded inbound edge"]
        direction TB
        S1["triage<br/>(model picks intent)"] --> S2["lookup_ticket"]
        S1 --> S5["answer"]
        S2 --> S3["check_entitlement"]
        S3 -->|"refund_allowed"| S4["issue_refund"]
        S3 -->|"not allowed"| S5
        S4 --> S5
    end

    style M1 fill:#4A90E2,color:#FFFFFF
    style D1 fill:#7B68EE,color:#FFFFFF
    style A1 fill:#95A5A6,color:#FFFFFF
    style A2 fill:#95A5A6,color:#FFFFFF
    style A3 fill:#95A5A6,color:#FFFFFF
    style A4 fill:#E74C3C,color:#FFFFFF
    style S1 fill:#4A90E2,color:#FFFFFF
    style S2 fill:#4A90E2,color:#FFFFFF
    style S3 fill:#7B68EE,color:#FFFFFF
    style S4 fill:#6BCF7F,color:#2C2C2A
    style S5 fill:#98D8C8,color:#2C2C2A

Both of those systems can be fully durable. Durability is the other axis. In the API the two are separate arguments, literally:

code
from langgraph.checkpoint.postgres import PostgresSaver# from_conn_string is a context manager, not a constructor - it does not# return a checkpointer you can pass straight into compile().with PostgresSaver.from_conn_string(DB_URL) as checkpointer:    checkpointer.setup()          # once, at deploy time: creates the tables    graph = builder.compile(checkpointer=checkpointer)    # Every graph.invoke() must happen inside this block. For a long-lived    # server, build PostgresSaver over a connection pool you own instead.    graph.invoke(        {"customer_id": session.customer_id, "messages": [HumanMessage(user_text)]},        config={"configurable": {"thread_id": session.thread_id}},    )

The invoke shape carries the security property. customer_id is required by AtlasState, but a TypedDict is a type-checker guarantee and not a runtime one - LangGraph will not reject an input that omits it. Leave it out and the question path succeeds silently while the refund path dies on a bare KeyError inside lookup_ticket. If you want that enforced at invoke time rather than at review time, use a Pydantic state schema. Which is the article's own argument turned on itself: a constraint that lives only in a type annotation is enforced by whoever remembers to run the type checker.

Reachability is specified by add_edge and add_conditional_edges. Durability is specified by one keyword argument to compile(). They are independent inputs to the same object. You can compile a tightly constrained graph with no checkpointer, and you can compile a graph with no constraints at all and pass it a production-grade checkpointer. That second configuration is the hand-rolled loop with durability bolted on, the failing one, and LangGraph will build it for you without complaint.

The consequence nobody states: reachability is reviewable before the run

Here is what falls out of the orthogonality, and it is the practical reason to care about any of this.

When control flow lives in a function body, the only way to know whether issue_refund is reachable from step one is to reason about the code. When it lives in an edge set, it is data. The compiled graph can hand it back to you:

code
print(graph.get_graph().draw_mermaid())

Be careful about what that output is, because it is easy to oversell and I nearly did. The property is conditional, and the condition is worth stating exactly.

For a rendering where the routing targets are all declared - Literal annotations or an explicit path_map on the routing functions, any Command returns annotated Command[Literal[...]], and xray=True wherever a subgraph is embedded - the output is a sound over-approximation of the transition relation: an upper bound on which node can run after which. It shows that an edge from check_entitlement to issue_refund exists. It does not show the body of after_entitlement, so it cannot tell you when that edge is taken. A routing function that returns "issue_refund" unconditionally draws the identical picture. That is imprecision, and imprecision inside an upper bound is safe.

Two things break the bound itself, in the dangerous direction:

  • An unannotated Command(goto=...) creates a transition the picture does not contain. When that is in play the diagram stops bounding the transition relation at all.
  • get_graph() collapses a subgraph into a single box unless you pass xray=True. Since create_agent embeds as a subgraph, one tidy node can contain an unconstrained loop.

Send is easy to lump in with those two and it does not belong there. Send does not remove a node from the picture: its targets are declared node names, so a map-reduce fan-out still draws an edge to the worker. What Send removes is cardinality. One drawn edge can mean N concurrent instances of that node, each fed a payload that is not the graph state. The picture still bounds which nodes run. It stops bounding how many, and with what input.

So the artifact is only as good as the discipline that produces it. Annotate the routing functions, annotate the Command returns, and pass xray=True when subgraphs are involved. Do that and you have an upper bound on the transitions the run can take. Skip it and you have a picture.

An upper bound is still worth a great deal, because the only bound the loop offers is the trivial one. And the guard you cannot see in the diagram has a property the loop's dispatch never had: after_entitlement is a named, importable, unit-testable function. "When is this edge taken" is answerable by a test, not by squinting at a rendering.

The durability discourse has no analogue for either artifact. A checkpointer produces a record of what happened, readable after the fact, usually while something is on fire. An edge set states what may happen, and a reviewer can reject it on a Tuesday before anything runs. Research on statically verifying agent workflow graphs is starting to formalise the difference, precisely because runtime guardrails cannot catch a topology defect that a structural check can.

Which is why, for a graph-shaped agent, question 2 in the audit below can be answered with a printed upper bound and a testable guard. For the loop, you can print list(TOOLS), and that is the complete answer: all of them, always. Nobody prints it, because nobody thinks of it as a specification.

Why a guarded edge does not stop a node calling billing.refund() directly

The edge set constrains transitions between nodes. It constrains nothing about what a node body does. Nothing in the graph, in compile(), or in the printed diagram stops answer from calling billing.refund() directly.

So the honest statement is narrower than "the refund is unreachable." The node named issue_refund has a guarded mandatory predecessor. The operation of refunding does not. I replaced "trust the model not to emit issue_refund" with "trust the developer to keep billing.refund() inside the issue_refund module." Both are trust, and this article's own standard says a constraint someone is expected to honour is not a constraint.

What changed is the class of guarantee. In the loop, the constraint depended on a model's runtime behaviour, which you cannot unit-test and cannot review. In the graph, it depends on a static property of code: side effects on billing live in exactly one module. That is the same class of invariant as "no raw SQL outside the repository layer," and we already know how to enforce that class: import boundaries, a lint rule, a test that asserts the call graph. You have not eliminated trust. You moved it from a runtime hope to a property a reviewer and a CI job can check.

Adopting LangGraph does not by itself buy you a specified runtime. LangChain's own documentation defines create_agent as "a model calling tools in a loop." That is the loop, with a better harness around it. You can inherit the Accidental Runtime inside the framework just as easily as outside it. The primitive that buys you reachability is the edge set, and nothing hands it to you for free.

Why recursion_limit is not a control-flow guarantee

But the loop already has a bound, and this is where most readers stop me. max_steps in the hand-rolled version, recursion_limit in LangGraph, max_iterations elsewhere.

Those bound how far the run can go. They say nothing about where it can go.

The framing is not mine. It belongs to whoever filed this LangChain issue, in the project's own tracker:

"Current safeguards (recursion/tool-call limits) only cap total steps. They do not detect stuck states."

Their reproduction is a tool called with invalid arguments, failing validation, and being called again with invalid arguments, repeating until the iteration ceiling. The issue is closed; the observation is not. A parallel LangGraph issue shows a text-to-SQL agent cycling through three tools until it hits "Recursion limit of 20 reached without hitting a stop condition." That reporter notes the same configuration did not loop on 0.6.x, so it may well be a regression rather than an architectural property. The relevant part is that the ceiling was the only thing that stopped it.

These are length bounds where you need a shape bound. A length bound is a cardinality limit on a relation that is still complete over the tool set. It guarantees the run stops, and guarantees nothing about what the run does before it stops. We cannot say whether any step ceiling would have helped at Replit. The public record never ties the destructive command to a step count. A length bound is the one safeguard whose usefulness you cannot assess without a trace, and there was no trace.

(Stopping conditions are a separate problem with a failure mode of their own, which I wrote about in the oracle problem.)

Prior art: admissible action sets and invalid action masking

An RL person reading this far is already annoyed, and they have a point.

The distinction between what an agent knows about the past and which actions are admissible right now is not new. It is textbook Reinforcement Learning (RL), where the history and the admissible action set A(s) are separate objects in the standard formulation. Restricting that set has a standard name, invalid action masking, and a literature going back years.

Masking and an edge set do not do the same job. Masking narrows what a policy can sample, inside the model. An edge set narrows what the runtime will execute, regardless of what the model sampled. That gap is why one of them survives a jailbreak.

The security people got here first, from a third direction, and they have the closer analogy. TOOLS[call.name] is ambient authority over the whole tool set. A state-scoped edge set is capability scoping. Saltzer and Schroeder wrote least privilege down in 1975, and what is new is only that we rebuilt ambient authority on purpose, because the loop was fifteen lines and it worked.

The distinction is old. My claim is narrower and, I think, still true:

The agent-infrastructure discourse collapsed the two, and needs them separated again. Durable-execution writing is careful and rigorous about the history. It mentions the other property only in the negative, as a constraint it declines to impose. Nobody names the missing thing, gives it a handle, or tells the buyer to go specify it somewhere else. So the two get sold under one word, "reliability", and bought as one property. An RL researcher would never confuse them. An engineer adding a Postgres checkpointer to a production agent on a Thursday does it routinely.

That is a falsifiable claim, and it is the one I would most like to be wrong about. Produce durable-execution material that names the admissible action set as a separate thing the reader still has to go and specify, and this paragraph fails.

Dex Horthy's 12-factor-agents is instructive here. Factor 8 is "Own your control flow." Factors 5, 6, and 12 are durability arguments: unified state, launch/pause/resume, the stateless reducer. He arrives at my conclusion from the anti-framework direction, which I take as strong support. But the factors sit in a list, separately numbered, and nothing says why the separation is load-bearing.

Viren Baraiya, in "Late-Bound Sagas", also argues the loop is not a runtime, then concludes that the workflow graph should be synthesised by the model at runtime rather than declared in advance. That is close to the opposite of my position, and I think he is wrong about it. Declaring the graph in advance is what makes the guarantee a guarantee, and a graph the model writes at runtime is a graph nobody reviewed. His underlying problems, liveness and out-of-band control, are genuine ones I do not solve here.

Do LangChain middleware and tool_choice make LangGraph unnecessary?

"Complete over the bound tool set" is precise, and it is only true absent other mechanisms. Several mechanisms narrow the next-step space without any graph at all:

  • Provider-side tool_choice can force, restrict, or forbid a tool call on a given turn.
  • Tool schemas reject malformed arguments. This is real narrowing, and it is what produces the validation loop in the LangChain issue above.
  • LangChain 1.x middleware hooks into the agent loop, with prebuilt categories that explicitly include guardrails and steering.
  • A precondition guard inside the tool body, which is what most teams actually ship: if not ctx.checked_ok: return {"error": "entitlement not verified"}, where ctx is whatever ad-hoc object the codebase happens to pass around.

That fourth one is the strongest counter to this whole article. It constrains the next-step space. It is enforced outside the model. It costs three lines. Anyone who has shipped an agent with an irreversible action has written it.

Here is what it does not give you. The guard is scattered across N tool bodies. It is invisible to any artifact a reviewer can print. Its precondition reads an ad-hoc context object with no schema, so nothing checks that ctx.checked_ok was set by an actual check. And nothing at all tells you when you forgot one on the tool you added last sprint. That is a specified runtime with no specification document, which is a real thing that works right up until the team changes.

So these are not edge cases. They are shipping features, and someone using them well may have a perfectly well-specified agent with no StateGraph anywhere.

I do not think this weakens the thesis. I think it is the thesis. Every one of those mechanisms is an act of specifying the runtime. Reaching for guardrail middleware is conceding that the loop's default reachability was wrong for your system. The disagreement is about where the specification lives, not whether you need one. What you cannot do is skip it and call the result simple.

The line is enforcement, not intent. A mechanism specifies the runtime if the constraint holds regardless of whether the model complies. tool_choice is enforced by the provider, a schema by the validator, an edge by the graph, a precondition by the interpreter. A sentence in the system prompt is enforced by nothing, which is exactly why the Replit code freeze was a request. That is the test, and it is what makes this thesis falsifiable rather than a definition that swallows everything.

So the recommendation, with the caveat attached: if your agent's action set contains nothing irreversible, the loop's defaults may genuinely be fine, and Ptacek's fifteen lines are the right answer. The moment one reachable action moves money, mutates a production record, or contacts a customer, the defaults are a decision you did not make.

A five-question agent runtime audit

Run this against any agent you have in production. It takes about twenty minutes, and it does not require adopting anything.

  1. List every action that is irreversible. Refunds, writes to production, outbound mail, deletes, anything with a side effect outside your process. If the list is empty, stop - you are fine.
  2. For each one, name the states from which it can be the next step, and name its guarded mandatory predecessor. If the next-step answer is "any step of the run", or if the single inbound source guards nothing, you have an inherited runtime, whatever framework is in your imports. If you cannot produce that answer as an artifact (a printed edge set, a schema, a policy file), assume it is "any".
  3. Find where that constraint is written down. If it is in the system prompt, it is a request. If it is in the edge set, a schema, or a guardrail hook, it is a constraint. The Replit code freeze was a request.
  4. Separate your bounds. Write down your length bound (recursion_limit, max_steps) and your shape bound separately. Most teams have the first and no second. A missing shape bound is not a smaller version of a length bound.
  5. Kill the process mid-run and see what happens. Then check what the agent does on resume. Durability answers this question. It answers only this question. Do not let a clean resume convince you that question 2 is handled.

If questions 1 through 4 pass and question 5 fails, you need a checkpointer, and Part 4 of this series goes deep on them. If question 5 passes and question 2 fails, you have Backward-Only Durability, and no amount of additional durability work will touch it.

What constraining an agent's next-step space costs

Constraining the next-step space is not free, and the trade is real. A fixed edge set is one you have to change when requirements change. A model that could previously improvise its way around a gap now hits a wall, and the wall is your problem to move.

Here is the cost, in the same example, so it is not an abstraction. Atlas's edge set has no path back to lookup_ticket once check_entitlement has run. A message that names two tickets, or names the wrong one, commits the whole run to whichever id triage extracted. There is no way back inside that run. The loop would have called get_ticket again mid-trajectory and been right, without ceremony. I removed an edge, and with it a recovery the model was performing for free.

The second cost is subtler and it comes from the same schema. refund_cents, refund_allowed, and receipt_id persist on the thread. A later turn that routes straight to answer still sees them, so render_context has to treat every refund-path field as stale unless the current turn set it. The loop had no such state to go stale, because it had no state at all. Structure is not free, and both of these are the bill.

There is evidence the trade is worth taking where the work is consequential. StateFlow, which imposes explicit state-machine control flow, reported 13% higher success than a ReAct-style loop on InterCode SQL and 28% higher on ALFWorld, at roughly five times and three times lower cost. Benchmarks are not your production system. The sharper caveat is that StateFlow is a 2024 paper measured on 2024-generation models, where the structure was partly compensating for weaker planning. Whether that gap narrows as models improve, or whether the cost advantage persists regardless, is an open question nobody has re-run.

State the cost side flat. Every constraint you add is a place the system can be wrong in a new way, and an over-constrained agent is its own recognisable failure. This series argues for tuning that dial per decision rather than per system, with the setting determined by the cost of being wrong.

Why there is no public data on agent failure causes

I looked for public data on how production agent failures split between reasoning failures and control-flow or state failures. It does not exist in any form I would cite. Everything that appeared to offer that split turned out to be generated content with unsourced numbers.

The gap is the finding. We are three years into building these systems, the incident stories are vivid and public, and nobody has published the denominator. Until someone does, "most agent failures are state failures" is a claim from experience, held by a lot of practitioners, and not a measured fact. I hold it, and it is not measured.

Specified or inherited: the only question that matters

Your agent has a runtime. You did not opt out by writing a loop, and you did not opt in by importing a framework. The only question is whether its guarantees were specified or inherited.

Durability specifies the past. It is necessary and it is hard. It is also not sufficient: a durable run whose transition relation is complete over the tool set will faithfully replay its way to the same wrong action. Reachability specifies the future, and it lives in a different part of the API, which is why it gets skipped.

The Replit agent's code freeze was written down. DROP was reachable anyway. Everything else in this series is about closing the distance between those two sentences.

Part 2 takes on what this part conspicuously avoided. The graph here is acyclic and single-threaded, which is why the edge set stayed easy to read. Real graphs have cycles and branches that write the same key at the same time, so Part 2 covers reducers under concurrent writes, Command and Send, subgraphs, and what happens to that printable upper bound once all three are in play.

References


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