What happens
A key holds one value. When a later turn's tool call republishes it, the
earlier value leaves session state, and the model has no way to reach it —
or to notice it is gone.
Turn 1 get_aoi("Copenhagen") -> gazet/get_aoi/bbox = [12.4, 55.5, 12.7, 55.8] seq 1
Turn 3 get_aoi("Aarhus") -> gazet/get_aoi/bbox = [9.9, 56.1, 10.3, 56.2] seq 2
Turn 3, the only state tool the model has:
inspect_state("gazet/get_aoi/bbox") -> [9.9, 56.1, 10.3, 56.2]
inspect_state("*") -> {"gazet/get_aoi/bbox": "list[4] of 9.9"}
Ask "how does the area you found first compare with this one" and the agent
reads the key, gets a plausible bounding box, and answers about Aarhus twice.
Nothing in either response says a value was replaced.
Why
Two mechanisms, each correct alone.
merge_tool_state is last-write-wins per key (merged[key] = entry). Session
state holds one value per key, not a history. It stamps a rising seq, but
seq is not surfaced to the model and would not carry the old value anyway.
inspect_state(key, pattern, path) is the model's only window onto state, and
it reads runtime.state — always the merged present. It has no turn
argument. with_session_state gives the agent the MCP tools plus exactly
this one state tool, so there is no other route.
The failure is quiet in the worst way: the read succeeds, the value is
well-formed, and the wrongness is only visible to someone who remembers turn 1.
The data already exists
This is not a storage problem. From mcp_agent_api/history.py:
LangGraph writes an immutable checkpoint per super-step, each carrying the
whole graph state, tool_state included — so every past value is already
retained and reachable through aget_state_history.
A client can already do exactly what the model cannot:
GET /threads/{id}/state/gazet/get_aoi/bbox?turn=1
GET /threads/{id}/state/gazet/get_aoi/bbox?turn=3
whose docstring names this case outright — "Without it a key a later turn
overwrote reads back as the later value, which is right for 'what is in state'
and wrong for 'what did this turn run on'."
So the capability is built, tested and on the wire. It is available to a UI and
not to the agent, and the gap between those two is this issue.
Proposal
Let the model read a key as of a turn, and tell it when a key has been
rewritten. Two parts, and the second is what makes the first get used.
1. inspect_state(key, turn=N)
Same semantics as ?turn=N on the read route, including its three outcomes,
which are already distinguished and worth keeping distinct for a model too:
- the value, as it stood at the end of turn
N
- 404 — a turn the thread never had (the model miscounted)
- 410 — a turn the checkpointer has pruned. The value existed; it is gone.
Retention belongs to the deployment: an in-process saver keeps everything for
the life of the process, a PostgreSQL one keeps what it has not pruned.
The 410 matters more for a model than for a UI. "I cannot answer that because
the earlier value is no longer retained" is a good answer. Silently comparing a
value with itself is not.
2. Say that a key was rewritten
A model will not ask for turn 1 if nothing suggests turn 1 differs. Cheapest
signal that changes behaviour: when a listing entry has been overwritten within
the thread, mark it — for example
gazet/get_aoi/bbox: list[4] of 9.9 — rewritten, 2 versions
Deriving that needs the same history walk, so it should be behind the same
seam. An alternative worth weighing: keep a small per-key version count on the
entry at merge time, so the listing is free and only a real read pays.
How to wire it without teaching mcp_state about checkpoints
First, a correction to how I framed this: the dependency to avoid is not
LangGraph. mcp_state already imports langgraph.prebuilt.InjectedState and
langgraph.types.Command, and [state] pulls langchain in anyway. What it
has never known about is persistence: threads, turns, checkpoints, retention.
It knows a dict of entries and the tools that read them. That is the boundary
worth keeping, and it is narrower and easier to hold than "no LangGraph".
A protocol in mcp_state's own vocabulary
class ThreadHistory(Protocol):
"""What some host can tell mcp_state about a thread's past."""
async def snapshots(self, thread_id: str) -> Snapshots: ...
class Snapshots(NamedTuple):
#: Retained turns, 1-based, each the state as that turn ended.
turns: Mapping[int, Mapping[str, StateEntry]]
#: How many turns the thread has had. Greater than len(turns) means
#: the checkpointer has pruned some.
total: int
Keys and StateEntry only — no checkpoint, no graph, no saver. total
alongside turns is what lets mcp_state tell "turn 4 never happened" from
"turn 1 was pruned" without knowing what a checkpointer is, which is the
whole point: the 404/410 distinction is decided in the layer that reports it.
One method rather than a read-at-turn plus a version-count, because both
answers come from the same walk and that walk is already whole-history —
turns_of documents the cost as "proportional to the length of the
conversation rather than to what is being asked for". A second method would
double the walks without narrowing either.
make_inspect_state(allowed_keys, history=None)
history=None is the default and everything behaves as it does today; turn=
then answers "this deployment does not retain turn history" rather than
raising. A host embedding mcp_state without a checkpointer keeps working
untouched.
inspect_state has to become async
It is def, and the protocol is async. Every path in this repo drives the
agent asynchronously — run_turn is async and calls agent.ainvoke, and
there is no sync invocation anywhere — so making the tool async def is safe
here. read_state_key stays sync and stays exported, so an external caller
using it directly is unaffected; only the tool wrapper changes.
The chicken-and-egg dissolves — build the adapter on the checkpointer
turns_of(agent, thread_id) walks agent.aget_state_history, and the agent
does not exist when its tools are constructed. That looked like the blocking
problem. It is not, because the same history is available from the
checkpointer alone, and the checkpointer is passed into with_session_state
already.
Verified rather than assumed — two turns overwriting one key, read both ways:
--- via the GRAPH (aget_state_history), what turns_of uses ---
humans=2 gazet/get_aoi/bbox = [9.9, 56.1, 10.3, 56.2]
humans=2 gazet/get_aoi/bbox = [12.4, 55.5, 12.7, 55.8]
humans=1 gazet/get_aoi/bbox = [12.4, 55.5, 12.7, 55.8]
humans=1 gazet/get_aoi/bbox = [12.4, 55.5, 12.7, 55.8]
humans=1 gazet/get_aoi/bbox = None
humans=0 gazet/get_aoi/bbox = None
--- via the CHECKPOINTER alone (saver.alist) ---
... identical, all six rows
So the adapter takes a BaseCheckpointSaver, not a graph, and
with_session_state can construct it from the saver it is already handed. No
late-bound holder, no mutable box filled after compilation.
Two things to confirm before relying on this. The check above used
InMemorySaver on a graph with no interrupts; aget_state_history also applies
pending writes and resolves tasks, which raw checkpoint tuples do not. For
reading tool_state at a completed turn that difference should not bite, but
it is not general equivalence and I am not claiming it. And it wants repeating
against AsyncPostgresSaver, which is what dss actually runs.
Where the turn-derivation lives
turns_of is in mcp_agent_api/history.py and is not re-exported from that
package's __all__, so it is not advertised public API and can move. The
dependency direction is mcp_agent_api -> mcp_agent -> mcp_state, so
lowering it to mcp_agent is with the grain: mcp_agent gains the adapter and
supplies it, mcp_agent_api imports the same helper instead of owning it, and
its route keeps behaving exactly as now.
That also removes a real duplication. Today the HTTP route and the proposed
tool would answer the same question — "this key, as of that turn" — from two
implementations. One walk, two callers, one definition of what a turn is.
Related, and deliberately not folded in
Three-part keys namespace by tool, not by call, so two get_aoi calls
collide however different the places are. Qualifying the key per call would
stop the overwrite happening at all — a different fix, a fourth key-shape
change one release after the last, and it does not help the case where a
comparison across turns is genuinely what was asked for. Left as its own
question.
Notes
- Reproduced against 0.8.0 by running the reducer and
read_state_key directly;
the transcript above is real output, not illustrative.
- Cost of the history walk is proportional to conversation length, which
turns_of already documents as the honest price of deriving turns from a
structure that does not record them.
Related
🤖 Drafted by Claude Opus 5 via Claude Code,
including the two reproductions above, which are real output rather than
illustrative. Reviewed and filed by @ciaransweet.
What happens
A key holds one value. When a later turn's tool call republishes it, the
earlier value leaves session state, and the model has no way to reach it —
or to notice it is gone.
Ask "how does the area you found first compare with this one" and the agent
reads the key, gets a plausible bounding box, and answers about Aarhus twice.
Nothing in either response says a value was replaced.
Why
Two mechanisms, each correct alone.
merge_tool_stateis last-write-wins per key (merged[key] = entry). Sessionstate holds one value per key, not a history. It stamps a rising
seq, butseqis not surfaced to the model and would not carry the old value anyway.inspect_state(key, pattern, path)is the model's only window onto state, andit reads
runtime.state— always the merged present. It has noturnargument.
with_session_stategives the agent the MCP tools plus exactlythis one state tool, so there is no other route.
The failure is quiet in the worst way: the read succeeds, the value is
well-formed, and the wrongness is only visible to someone who remembers turn 1.
The data already exists
This is not a storage problem. From
mcp_agent_api/history.py:A client can already do exactly what the model cannot:
whose docstring names this case outright — "Without it a key a later turn
overwrote reads back as the later value, which is right for 'what is in state'
and wrong for 'what did this turn run on'."
So the capability is built, tested and on the wire. It is available to a UI and
not to the agent, and the gap between those two is this issue.
Proposal
Let the model read a key as of a turn, and tell it when a key has been
rewritten. Two parts, and the second is what makes the first get used.
1.
inspect_state(key, turn=N)Same semantics as
?turn=Non the read route, including its three outcomes,which are already distinguished and worth keeping distinct for a model too:
NRetention belongs to the deployment: an in-process saver keeps everything for
the life of the process, a PostgreSQL one keeps what it has not pruned.
The 410 matters more for a model than for a UI. "I cannot answer that because
the earlier value is no longer retained" is a good answer. Silently comparing a
value with itself is not.
2. Say that a key was rewritten
A model will not ask for turn 1 if nothing suggests turn 1 differs. Cheapest
signal that changes behaviour: when a listing entry has been overwritten within
the thread, mark it — for example
Deriving that needs the same history walk, so it should be behind the same
seam. An alternative worth weighing: keep a small per-key version count on the
entry at merge time, so the listing is free and only a real read pays.
How to wire it without teaching
mcp_stateabout checkpointsFirst, a correction to how I framed this: the dependency to avoid is not
LangGraph.
mcp_statealready importslanggraph.prebuilt.InjectedStateandlanggraph.types.Command, and[state]pullslangchainin anyway. What ithas never known about is persistence: threads, turns, checkpoints, retention.
It knows a dict of entries and the tools that read them. That is the boundary
worth keeping, and it is narrower and easier to hold than "no LangGraph".
A protocol in
mcp_state's own vocabularyKeys and
StateEntryonly — no checkpoint, no graph, no saver.totalalongside
turnsis what letsmcp_statetell "turn 4 never happened" from"turn 1 was pruned" without knowing what a checkpointer is, which is the
whole point: the 404/410 distinction is decided in the layer that reports it.
One method rather than a read-at-turn plus a version-count, because both
answers come from the same walk and that walk is already whole-history —
turns_ofdocuments the cost as "proportional to the length of theconversation rather than to what is being asked for". A second method would
double the walks without narrowing either.
history=Noneis the default and everything behaves as it does today;turn=then answers "this deployment does not retain turn history" rather than
raising. A host embedding
mcp_statewithout a checkpointer keeps workinguntouched.
inspect_statehas to become asyncIt is
def, and the protocol isasync. Every path in this repo drives theagent asynchronously —
run_turnisasyncand callsagent.ainvoke, andthere is no sync invocation anywhere — so making the tool
async defis safehere.
read_state_keystays sync and stays exported, so an external callerusing it directly is unaffected; only the tool wrapper changes.
The chicken-and-egg dissolves — build the adapter on the checkpointer
turns_of(agent, thread_id)walksagent.aget_state_history, and the agentdoes not exist when its tools are constructed. That looked like the blocking
problem. It is not, because the same history is available from the
checkpointer alone, and the checkpointer is passed into
with_session_statealready.
Verified rather than assumed — two turns overwriting one key, read both ways:
So the adapter takes a
BaseCheckpointSaver, not a graph, andwith_session_statecan construct it from the saver it is already handed. Nolate-bound holder, no mutable box filled after compilation.
Two things to confirm before relying on this. The check above used
InMemorySaveron a graph with no interrupts;aget_state_historyalso appliespending writes and resolves tasks, which raw checkpoint tuples do not. For
reading
tool_stateat a completed turn that difference should not bite, butit is not general equivalence and I am not claiming it. And it wants repeating
against
AsyncPostgresSaver, which is what dss actually runs.Where the turn-derivation lives
turns_ofis inmcp_agent_api/history.pyand is not re-exported from thatpackage's
__all__, so it is not advertised public API and can move. Thedependency direction is
mcp_agent_api->mcp_agent->mcp_state, solowering it to
mcp_agentis with the grain:mcp_agentgains the adapter andsupplies it,
mcp_agent_apiimports the same helper instead of owning it, andits route keeps behaving exactly as now.
That also removes a real duplication. Today the HTTP route and the proposed
tool would answer the same question — "this key, as of that turn" — from two
implementations. One walk, two callers, one definition of what a turn is.
Related, and deliberately not folded in
Three-part keys namespace by tool, not by call, so two
get_aoicallscollide however different the places are. Qualifying the key per call would
stop the overwrite happening at all — a different fix, a fourth key-shape
change one release after the last, and it does not help the case where a
comparison across turns is genuinely what was asked for. Left as its own
question.
Notes
read_state_keydirectly;the transcript above is real output, not illustrative.
turns_ofalready documents as the honest price of deriving turns from astructure that does not record them.
Related
inspect_stategains a second reason to grow an argument alongside An optional declared parameter that state cannot fill is dropped silently #83.keys landed in feat(state)!: remove Kind; a name is the contract #99, not as a regression —
Kind(BBOX)was strictly worse —but as the ambiguity that fix did not cover.
🤖 Drafted by Claude Opus 5 via Claude Code,
including the two reproductions above, which are real output rather than
illustrative. Reviewed and filed by @ciaransweet.