Skip to content

feat(state): read a state key as it stood at an earlier turn - #114

Draft
ciaransweet wants to merge 5 commits into
mainfrom
feat/inspect-state-across-turns
Draft

feat(state): read a state key as it stood at an earlier turn#114
ciaransweet wants to merge 5 commits into
mainfrom
feat/inspect-state-across-turns

Conversation

@ciaransweet

@ciaransweet ciaransweet commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Closes #101.

A key holds one value, so a second call to the same tool replaces what the
first published. Ask the model how a result compares with the one it found
earlier and it reads the key, gets a well-formed value, and compares that value
with itself. Nothing fails, and the answer is confidently wrong.

The checkpointer already holds every past tool_state. This gives the model a
way to ask for one.

What changed

inspect_state(key, turn=N) reads the key as that turn ended, counting the
user's questions from 1. It answers three ways, and the third is the point:
the value; no_such_turn with how many the thread has had; or
turn_no_longer_retained, meaning the value existed and was pruned.

make_inspect_state(keys, history=None) takes an optional ThreadHistory
— one async snapshots(thread_id) returning {turn: state} plus a total.
with_session_state passes CheckpointHistory(checkpointer) whenever it is
given one, so hosts using it get this for free. Without a history the tool
behaves as before and turn= says the deployment keeps none.

StateEntry gains turn and turns_written, both stamped during capture
and merge. turns_written counts turns, not writes: two calls in one turn
add one, because a turn-scoped read resolves to what the turn ended with.

Three surfaces tell the model an earlier value exists, since a read of a
rewritten key is otherwise indistinguishable from a read of one written once:

  • the [state updated: …] breadcrumb now names the turn this write displaced.
    This one matters most — passing @state:<key> resolves to the present and
    triggers no read at all, so the breadcrumb is the only warning that lands;
  • a read of a key more than one turn wrote ends with a line saying so;
  • the refusal listing marks it written in N turns.

The wire carries it too. state_metadata adds turnsWritten (omitted when
1, so its presence is the signal) and turn. The example panel renders
written in 2 turns on the row.

Turn derivation moved from mcp_agent_api.history to mcp_agent.history,
re-exported under the old names, so the tool and the HTTP routes share one
definition. It no longer assumes the saver walks newest-first — the base
contract does not promise an order, and the old rule would serve each turn's
starting state on an ascending walk.

Compatibility

  • inspect_state is now async. A host invoking its graph synchronously has
    to switch. Nothing here or in dss does.
  • A read appends its note after the JSON, so that output is no longer parseable
    as JSON. Same shape as the breadcrumb already on the result.

Testing it

examples/agui-events, four prompts in one thread:

sketch a rough boundary around the Severn catchment and call it Severn
now sketch one around the Thames and call it Thames
confirm from state what area each of the two sketches came out at
what did that key hold at turn 7?

Prompt 2's breadcrumb names turn 1, and the panel row gains written in 2 turns. Prompt 3 returns two reads, each labelled with its turn:

74352.9
[sketch-ops/sketch_area/area_km2 as it stood at the end of turn 1.]

Prompt 4 gets no_such_turn with turns_so_far, not an invented value.

Worth knowing: the model reaches for turn= when the answer needs it, not
reflexively. In this example the earlier value is still in the transcript as
tool-result text, so it often answers from that instead. The failure this
closes bites where the value is not recoverable from the transcript.

Verification

402 tests and lint green at each of the five commits. Exercised against a real
PostgreSQL 17 AsyncPostgresSaver: saver and graph walks derive identical
turns, each turn ends holding its own value, and a live inspect_state(turn=1)
returns turn 1's. That walk contains checkpoints with tied message counts, so
the id tiebreak runs in practice.

🤖 Generated with Claude Code

https://claude.ai/code/session_016K5NB5A3a8LWuxMAQQyUNJ

A key holds one value. When a later call republishes it the earlier value
leaves state, so a model asked "how does this compare with the one you found
first" reads the key, receives a well-formed value, and compares the current
value with itself. The read succeeds, which is what makes it worth fixing.

Nothing new has to be stored for that: a host running the agent under a
checkpointer already retains every past `tool_state`. What was missing was a
way to ask for one in terms `mcp_state` can express. So `inspect_state` takes
`turn=<n>`, and `make_inspect_state` an optional `ThreadHistory` — one async
method returning what state held at each retained turn, plus `total`, which is
what separates a turn the thread never had from one that has been pruned. That
second answer is the one worth carrying: "the earlier value is no longer
retained" is a good answer, and comparing a value with itself is not.

`mcp_agent.history.CheckpointHistory` satisfies the protocol from a LangGraph
saver, and `with_session_state` wires it whenever it is given a checkpointer.
It takes the saver rather than the agent because tools are built before the
graph they run in. A deployment without a checkpointer changes nothing: the
tool behaves exactly as before and `turn=` answers that this deployment keeps
no turn history.

The turn derivation moves from `mcp_agent_api.history` down to
`mcp_agent.history`, re-exported under the old names, because the model and
the HTTP routes now want the same answer and two definitions of what a turn is
would be one too many. It also stops trusting the walk's order: within a turn
the message list only grows, so more messages is later, and equal lists fall
back to the checkpoint id, which LangGraph mints time-ordered. The base saver
contract promises an iterator and nothing more, and handed an ascending one
the old first-seen rule kept each turn's *starting* state and served it as
what the turn produced — a well-formed wrong value, which is the failure this
exists to remove.

`inspect_state` is now async, so a host driving its graph synchronously has to
move to the async entry points.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016K5NB5A3a8LWuxMAQQyUNJ
@ciaransweet
ciaransweet force-pushed the feat/inspect-state-across-turns branch from 6b7bfbf to cf3fd95 Compare September 2, 2026 20:43
ciaransweet and others added 4 commits September 2, 2026 22:14
Reading a key as of a turn is only useful if the model knows there is an
earlier turn worth asking for, and a read of a rewritten key is
indistinguishable from a read of one written once — the value is well-formed
either way. So an entry now carries how many turns wrote its key, and the
signal reaches the model on the three surfaces it actually touches:

- the `[state updated: …]` breadcrumb names the earlier turn this write just
  displaced, and how to read it. This is the one surface every capture
  reaches, and it carries the most: a model moving a value between tools
  passes `@state:<key>`, which resolves to the present, so the read that would
  have warned it never happens.
- a read of a key more than one turn wrote ends with a line saying so, and
  pointing at `turn=<n>`.
- the listing a refusal puts up marks it `written in N turns`, since that
  listing is where a handle gets chosen.

Turns, not writes. Turns are the unit a turn-scoped read addresses: a key
written twice inside one turn ends that turn holding the second value, and the
first is reachable by nothing, so counting it would name a version that cannot
be fetched. `StateCaptureMiddleware` stamps the turn on each entry and
`merge_tool_state` compares them — which is also why a host driving capture
outside a graph keeps a signal instead of going quiet.

And not values. Whether two turns hold *different* values needs the value each
turn ended with, which is history an entry does not hold. Deciding it from the
previous write alone is wrong in both directions inside a turn: it flags a key
that changed and changed back, and stays silent on one that repeated the
previous turn's value before moving, which is the original failure walking
back in. So the note claims only that an earlier turn may differ, and reading
it is what settles that.

`SESSION_STATE_PROMPT` explains all three in one place, and
`docs/SESSION-STATE.md` walks a single value's replacement through them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016K5NB5A3a8LWuxMAQQyUNJ
Its opening paragraph contrasted itself with `mcp_state.injection` by saying
injection needs a server-side `Kind` tag. `Kind` was removed in 0.8, and
injection now narrows a parameter the server declared `NotAuthored` — so the
sentence sent a reader looking for a marker that no longer exists. Docstring
only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016K5NB5A3a8LWuxMAQQyUNJ
`sketch_area` publishes under one key, so sketching twice means the second
boundary replaces the first — the ordinary shape of session state, and what
makes "how does this compare with the one you found first" unanswerable from
state alone. Three prompts reach it against the example's own service, and the
README says what the read comes back with and what to watch for: a handle
still resolving to the present whatever the note said, and a small model
landing the note without acting on it until the question names the turn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016K5NB5A3a8LWuxMAQQyUNJ
The state channel described a key as `{tool, bytes, seq, inputs}`, all of it
about the value currently there — so a key a later turn replaced looked
exactly like one written once, and the panel showing it had nothing to say
otherwise. That is the same blindness the model had, seen from the client
side, and the entry already knows the answer.

`state_metadata` now carries `turnsWritten` and `turn`. `turnsWritten` is
omitted when it is one, which is the ordinary case and the one worth no
pixels, so its presence is itself the signal rather than something a reader
compares against. `turn` rides along because "which turn" is the next question
asked, and it is what `?turn=N` takes.

The example panel renders it as `written in 2 turns` beside the size and the
publishing tool, weighted rather than badged: it belongs in the line of dim
metadata a reader skims, and the point is that it is noticed there rather than
that it competes with the key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016K5NB5A3a8LWuxMAQQyUNJ
@ciaransweet
ciaransweet force-pushed the feat/inspect-state-across-turns branch from bce8450 to 18a30e9 Compare September 2, 2026 21:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant