Chapter 6 of 8 · 14 min read
The exit cost is the state format and how far it spread
On this page
Exit cost is the amount of work standing between you and running the same product without a particular dependency. For an agent framework it is routinely estimated by looking at the wrong artefact. The visible coupling is the control flow — the graph, the nodes, the edges — and that is genuinely cheap to replace, because the control flow of an agent is model, then tools, then model, and the tool functions do not care who calls them.
What is expensive is everything that accumulated around the control flow while nobody was watching: state that only one engine can interpret, an observability surface built on names that engine emitted, and the engine's types spreading through modules that have nothing to do with agents. Those three are the exit cost, they can be measured today with commands you already have, and the moment when measuring them is nearly free is before anything has been built on top. A decision record — the short file kept beside the code stating what was chosen, what was rejected and what the choice commits the team to — is where the measurements go.
What the state format actually contains
The sentence "our state is portable, it is just messages" survives exactly one look at a stored checkpoint. Here is one row from the table, at step 5 of a paused run, with the message bodies elided.
{
"v": 4,
"id": "1ef4a2c3-0d15-6e00-8004-9a3f2b7c1d02",
"ts": "2025-05-13T14:22:01.442Z",
"channel_values": {
"messages": ["…12 message objects…"],
"budget_cents": 34800,
"__pregel_pull_tools": null
},
"channel_versions": {
"messages": "00000000000000000000000000000006.0.417",
"budget_cents": "00000000000000000000000000000004.0.111",
"start:model": "00000000000000000000000000000006.0.902"
},
"versions_seen": {
"model": {"branch:to:model": "…000005.0.774"},
"tools": {"branch:to:tools": "…000006.0.318"},
"__interrupt__": {}
},
"pending_sends": [{"node": "tools", "arg": {"tool_call_id": "call_8Kx2"}}]
}The message list is portable. Nothing else in that document is, and the reason is worth deriving rather than asserting, because it is the same reason for every runtime of this shape.
Resuming a run means answering one question: which node runs next. That
answer cannot come from the message list, since a message list records what
was said and not what the scheduler had decided to do about it. So it has to
be stored separately, and the fields above are how this engine stores it.
channel_versions and versions_seen are the runtime's mechanism for
deciding which nodes still have work to do: the first records a version
stamp for each channel — each named slot in the state — and the second
records, per node, which version of each channel that node has already
processed. A node has work to do when a channel it reads has a version newer
than the one it has seen. That comparison is the scheduler, encoded in a
scheme with no specification outside the source, and pending_sends holds a
dispatch that was scheduled but has not run.
Follow that to the consequence. A replacement engine reading this document
sees a message list it can use and two dictionaries whose meaning is defined
by comparison rules that exist in one codebase and no specification. It
cannot resume the run without reimplementing those semantics exactly,
including whatever the string format encodes and whatever __interrupt__
and __pregel_pull_tools mean. The state format is therefore not a
serialisation of your agent; it is a serialisation of the framework's
scheduler with your agent's data embedded in it.
That has a direct consequence for what a migration can do. Reading the
message list out is straightforward. Reconstructing "this run was
interrupted after the model asked for search_orders and before the tool
node dispatched it" requires understanding the version vectors, and getting
it subtly wrong resumes the run at the wrong point — dropping a tool call,
or issuing one twice. The runs you can migrate mechanically are the ones
that have not started and the ones that have finished, and neither of those
is a run.
The belief that fails here is worth naming because it is held sincerely and it is half right. "It is just messages" is true of the part of the state anybody ever looks at. Message lists are what appear in transcripts, in evaluation sets, in support tickets and in every debugging session, so the part of the document a team has read is portable, and the part nobody has ever had a reason to open is the part that decides where a run resumes. A portability claim built on the readable fields is a claim about a sample chosen by convenience.
Measuring the spread before it is a surprise
The second axis is how far the framework's types have travelled into code that is not about agents, and unlike the state format this one is measurable with a command in the repository today. Running it on the day you adopt gives you a baseline; running it monthly turns exit cost into a metric that moves rather than a surprise.
# how many modules import the framework at all
rg -l "^from (langgraph|langchain)" --type py src/ | wc -l # 84 of 210
# which of them are outside the agent package — the ones that hurt
rg -l "^from (langgraph|langchain)" --type py src/ | grep -v "^src/agents/"
# src/reporting/transcript.py
# src/slack/stream_adapter.py
# src/testing/prompt_fixtures.py
# which framework types are load-bearing in your own signatures
rg -n "def .*\((.*)(AIMessage|BaseMessage|RunnableConfig)" --type py src/ | wc -l # 61Read those three outputs as gaps rather than as levels. Eighty-four modules of two hundred and ten import the framework, which sounds alarming and is not: the second command shows that all but three of them are inside the agent package, where importing an agent framework is what the package is for. The number that measures spread is the second one, and the distance between it and the first is the whole point of running both.
The last number is the one that predicts pain, because a framework type in a function signature is a coupling that propagates: every caller now handles that type, every test constructs one, and every replacement has to produce something that quacks the same way. Sixty-one signatures is a week of mechanical work and a fortnight of subtle behaviour differences. Note that it is a different quantity from the other two and cannot be compared with them — a module can import the framework without exposing any of its types, and one module can contribute many signatures — so the three are three separate measurements rather than three views of one.
The containment strategy is unglamorous and effective: define your own message and config types at the edge of the agent package, convert once on the way in and once on the way out, and let no framework type past that boundary. It costs perhaps 150 lines and makes the spread column of the exit cost approximately zero forever. The reason it works is that a boundary you convert at is a boundary you can replace behind, and the reason it has to be declared early is that converting one type in one place is cheap while unpicking sixty-one signatures is not.
Prompt and tool abstractions spread differently from types, and they are harder to spot because they look like configuration. A prompt template class that supports partial binding, a tool decorator that derives a JSON schema from type hints, a structured-output parser that retries the model when validation fails — each of these encodes behaviour that your prompts have come to rely on, and none of it moves with a copy-paste.
The decorator's schema generation is the most consequential, and the causal
chain is short enough to check. The schema is part of the prompt, in the
literal sense that it is serialised into the request the model reads. Two
generators that describe the same Python function will not produce identical
JSON — one orders properties by declaration and another alphabetically, one
describes optional fields as a nullable type and another omits them from
required. The model conditions on those bytes. So swapping the generator
changes the model's input on every request, and tool-call accuracy shifts by
a few percent in a direction nobody predicted. Migrating tool definitions is
therefore an evaluation exercise rather than a refactor, and budgeting it as
a refactor is how a two-week migration becomes a two-month one.
Tracing is the third axis and the one people underrate, because it does not feel like coupling. Dashboards, alerts and the queries in the runbook are all written against span names, attribute keys and a trace structure the runtime chose. Replace the runtime and every one of them stops matching, so the migration includes rebuilding the observability that the migration itself needs in order to be safe. That circularity is what makes this axis worse than its size suggests: the tooling you would use to tell whether the cutover was going well is the tooling the cutover breaks. The mitigation is the same shape as the containment above — emit your own spans with your own names for the things you care about, treat the framework's spans as extra detail rather than as the source of truth, and never let an alert depend on a name you did not choose.
Runs in flight are the migration
With the three axes named, the remaining question is mechanical: what happens to work that is already underway on cutover day. There are three strategies and they trade differently.
| Strategy | What it costs | What it risks | When it fits |
|---|---|---|---|
| Drain | A freeze window on new runs plus the length of the longest run; usually days | Runs that outlive the window need a second plan anyway | Runs measured in seconds or minutes, with no human pauses |
| Translate | An exporter and an importer, plus tests that resume a real paused run correctly | Reimplementing scheduler semantics; wrong resumption points issue duplicate effects | A small number of long-lived runs whose value is high |
| Dual-run | Both engines in production, routed by a flag, for weeks | Two sets of behaviour, two on-call surfaces, and a state store per engine | Long runs, human pauses, or a rollback requirement you cannot waive |
Which row applies is decided by the distribution of run lengths and not by how many runs are open, and that distinction is where migration plans usually go wrong. A count of open threads is compatible with every distribution that sums to it: the same total is a drain of ten minutes if the runs are all seconds from finishing, and a translate-or-wait problem if a tenth of them are parked on a human who is away. So the count is worth measuring only as an input to the histogram, and the histogram — how many threads have been open for minutes, hours, days — is what selects the strategy.
Most teams end up with drain plus a translate path for the tail, and the shape of that cutover is worth writing down before it is needed, including the direction that reverses it.
Two details in that diagram carry most of the safety. Resuming at the last tool boundary rather than at the exact interrupted instruction is what makes translation tractable, and the reason is that a tool boundary is a point both engines can describe: it is a position in the message list, which is the one structure they share, rather than a position in a scheduler, which is the structure they do not. The effect log from the durability work then makes it safe to re-enter a step whose effects are already recorded, which is what lets a coarse resumption point be correct rather than merely convenient. Keeping engine A's state read-only for thirty days rather than dropping it is what makes the rollback real; a rollback that cannot restore state is a rollback in name only, and the cost of retaining a few gigabytes of checkpoints for a month is not worth arguing about.
The section of the decision record that people skip
An adoption decision record that only argues for adoption is a marketing document. The version worth having spends most of its length on the exit, because that is the part nobody can reconstruct later. Four headings do the work.
## Persisted state format
Where run state lives (Postgres schema `agent_state`, ~11 GB), what is in it beyond
messages (channel versions, versions_seen, pending_sends), and what it takes to read it
without the framework. Owner: platform. Verified by: an export script in `tools/`
that dumps one paused thread to a portable message log, run in CI weekly.
## Tracing dependency
Which dashboards and alerts depend on framework-emitted span names (currently 31 panels,
9 alerts). Mitigation: agent-owned spans `agent.step`, `agent.tool`, `agent.checkpoint`
are the alerting contract; framework spans are supplementary and unalertable.
## Spread
Framework imports outside `src/agents/` (target: 0; today: 3 modules). Framework types
in public signatures (target: 0; today: 61). Reviewed monthly; regressions block merge.
## Migration and rollback for runs in flight
Drain window 72h. Translate path for threads paused on human approval, resuming at the
last tool boundary and consulting `agent_effects` before re-issuing. Rollback: engine A
stays read-write behind a routing flag for 14 days, state retained 30 days.What makes that document useful is that every section names a measurement
rather than an intention. "We will keep the framework contained" is
unfalsifiable; "framework imports outside src/agents/ must be zero,
checked by a lint rule" is a thing that either holds or fails a build. The
same is true of the export script: a claim that state is portable which is
never executed decays into a claim that state was portable at the time of
writing, and a weekly CI job that dumps a real paused thread keeps it true.
A hosted platform changes two of those four sections and makes them sharper. When the runtime is a service rather than a library — the pattern behind AgentKit and the managed agent platforms — the state format is not a schema you can read at all, and the only exit is whatever the export API offers. That turns the first heading into three concrete questions with answers you can obtain before signing anything: what does the export endpoint return for a run that is paused mid-tool-call, how long is state retained after an account is closed, and is there a rate limit that makes exporting several hundred thousand runs take a week. The spread section gets easier, because a network boundary is a natural containment line and nobody accidentally imports a REST API into a report generator. The tracing section gets harder, because the traces live in the vendor's console and typically cannot be exported in bulk, so the observability you would need in order to run a migration safely is the observability you are migrating away from.
Timing is the part that decides whether any of this gets written. The answers cost an hour during evaluation, when you are already reading the source to understand the checkpointer and someone is asking questions in a review. They cost a week eighteen months later, when the person who chose it has left and the export script has never been run. The record should be signed off by whoever carries the pager for the service, since the sections are all about operational consequences, and it should be revisited when the numbers in it change rather than on a calendar — a jump from three modules to eleven outside the agent package is the event that matters, not the quarter turning over.
A high exit cost is not an argument against adopting
A high exit cost that you priced deliberately, for a system where durable execution and human pauses are the core requirement, is a reasonable trade. The 4,127 paused runs above are evidence that the framework was doing something valuable, since a system with no long-lived runs would have had nothing to migrate, and a team whose agent parks work on human approval has bought exactly the capability that made the migration hard. The failure mode is not high exit cost; it is unmeasured exit cost, discovered at the point when circumstances have already decided you need to leave.
The measurements also decay differently, which is worth knowing before you schedule the review. Spread grows monotonically unless somebody stops it, because every new module is another chance to import a type, so it needs a lint rule rather than a reminder. The state format changes only when the framework's own version changes, so it needs re-checking on upgrades. And the tracing dependency grows every time somebody builds a dashboard, which is usually during an incident, which is the worst moment to be asked whether the span name is one you own.
None of this is a general theory of when a framework is right, either. Exit cost is one input, and it only becomes decisive once the framework's execution model fits the agent in the first place. A containment plan is worth nothing on an agent whose control flow the runtime cannot express, since the workaround code is itself a form of coupling that no boundary type contains.
There are situations where the fit is wrong from the start and no amount of careful containment helps. They are worth stating concretely, because each of them can be checked before any code is written, which makes them the only part of this argument that can be settled in a design review rather than after one.
Agent frameworks — read the current version at https://daviddominguez.dev/learn/en/agent-frameworks/the-exit-cost-is-the-state-format-and-how-far-it-spread