feat(state): read a state key as it stood at an earlier turn - #114
Draft
ciaransweet wants to merge 5 commits into
Draft
feat(state): read a state key as it stood at an earlier turn#114ciaransweet wants to merge 5 commits into
ciaransweet wants to merge 5 commits into
Conversation
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
force-pushed
the
feat/inspect-state-across-turns
branch
from
September 2, 2026 20:43
6b7bfbf to
cf3fd95
Compare
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
force-pushed
the
feat/inspect-state-across-turns
branch
from
September 2, 2026 21:14
bce8450 to
18a30e9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 away to ask for one.
What changed
inspect_state(key, turn=N)reads the key as that turn ended, counting theuser's questions from 1. It answers three ways, and the third is the point:
the value;
no_such_turnwith how many the thread has had; orturn_no_longer_retained, meaning the value existed and was pruned.make_inspect_state(keys, history=None)takes an optionalThreadHistory— one async
snapshots(thread_id)returning{turn: state}plus atotal.with_session_statepassesCheckpointHistory(checkpointer)whenever it isgiven one, so hosts using it get this for free. Without a history the tool
behaves as before and
turn=says the deployment keeps none.StateEntrygainsturnandturns_written, both stamped during captureand merge.
turns_writtencounts turns, not writes: two calls in one turnadd 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:
[state updated: …]breadcrumb now names the turn this write displaced.This one matters most — passing
@state:<key>resolves to the present andtriggers no read at all, so the breadcrumb is the only warning that lands;
written in N turns.The wire carries it too.
state_metadataaddsturnsWritten(omitted when1, so its presence is the signal) and
turn. The example panel renderswritten in 2 turnson the row.Turn derivation moved from
mcp_agent_api.historytomcp_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_stateis now async. A host invoking its graph synchronously hasto switch. Nothing here or in dss does.
as JSON. Same shape as the breadcrumb already on the result.
Testing it
examples/agui-events, four prompts in one thread: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:Prompt 4 gets
no_such_turnwithturns_so_far, not an invented value.Worth knowing: the model reaches for
turn=when the answer needs it, notreflexively. 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 identicalturns, 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