Skip to content

A model cannot compare a state value with its own earlier version #101

Description

@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.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions