feat(api)!: patch the shared state object rather than replace it - #88
Open
ciaransweet wants to merge 1 commit into
Open
feat(api)!: patch the shared state object rather than replace it#88ciaransweet wants to merge 1 commit into
ciaransweet wants to merge 1 commit into
Conversation
AG-UI's `state` is shared with the client, and a STATE_SNAPSHOT replaces the whole of it. This server sent one on every tool write, so anything a client kept in `state` was wiped several times a turn — a caveat that could only be documented, and dss's docs/API.md had to. State now moves as STATE_DELTA, every operation naming a path under `toolState`: - a run opens with one `add` of the whole namespace, which cannot fail on an object, leaves the client's keys alone, and is the resynchronisation point for a client that lost track - after that, one `add` or `remove` per key that moved - state that has not moved sends nothing A key leaving state is expressible now, which a merge could not say. Made possible by seeding `stream_turn`'s running total from the thread. `StateChanged` claimed to carry "the whole of it"; on a second turn it did not, so a turn writing one key announced it as though the thread held nothing else — which is why the example client merged rather than applied. It uses the state snapshot already read for the message-id check. Keys are `toolset/name` and `/` separates pointer segments, so `raster-ops/dataset` goes out as `/toolState/raster-ops~1dataset`. RFC 6901, `~` escaped first. Unescaped, fast-json-patch validates and rejects the whole patch, leaving a console warning as the only symptom. Verified by running it: applying every delta of a real turn to a state object that already held two client keys leaves those keys untouched and lands on exactly what GET /threads serves — six keys with their `seq`. 357 tests, lint clean, example client builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
AG-UI's
stateobject is shared — the protocol has it hold what a client and an agent both keep. 0.6.0 moved our metadata undertoolStateso a client had somewhere of its own to put things. It could not use it: aSTATE_SNAPSHOTreplaces the whole object, and this server sent one on every tool write. Anything a client kept instatewas wiped several times a turn.That is a caveat you can only document, and dss had to:
docs/API.mdtells integrators to mergetoolStatein by hand rather than adopt the snapshot. This removes the reason for the warning.What goes out now
STATE_DELTA, and every operation names a path undertoolState.addof the whole namespace.addon an object member replaces or creates it and leaves its siblings alone, so it cannot fail and cannot touch the client's keys. It is also the resynchronisation point: a client that lost track gets the map back at the top of every run.addorremoveper key that moved.A key leaving state is now expressible, which a snapshot merge could not say: eviction used to leave a dead key in the panel naming a value the state route answers 404 for.
The change that made it possible
StateChangedsays it carries "the whole of it, accumulated". On a second turn it did not: the running total started empty, so a turn writing one key announced that key as though the thread held nothing else. Every consumer had to merge to undo it — which is exactly why the example client merged.It is now seeded from the thread, using the state snapshot
stream_turnalready reads for the message-id check. So the docstring is true, the openingaddof a run can carry the thread's whole state rather than this turn's writes, and per-key operations after it are unambiguous.Verified live on a second turn that wrote nothing of its own:
Escaping, because a state key contains the pointer's separator
Keys are
toolset/nameand/separates JSON Pointer segments, soraster-ops/datasetgoes out as/toolState/raster-ops~1dataset— RFC 6901,~escaped first because the other escape introduces one. Unescaped it would address adatasetmember of araster-opsobject that does not exist, andfast-json-patchvalidates, so the whole patch would be rejected and the state channel would go quiet with a console warning as its only symptom.Verified by running it, not by building it
One turn against a real model on the example service, applying each delta to a state object that already held two keys of the client's own:
The equality is the check that matters: replaying the wire from empty lands on exactly the state the read route serves, six keys with their
seq, so a client following the patches and a client refetching agree.Breaking
Read
STATE_DELTArather thanSTATE_SNAPSHOT.defaultApplyEventsapplies the patch for you; the example client keeps its own panel and applies them in twelve lines, which is the reference for anyone doing it by hand.Releases as 0.7.0 — consumers pin
<0.7.0, so nobody picks it up until they widen it deliberately. dss'sdocs/API.mdneeds its "Snapshots" section updated when it does, and gets to delete the merge warning.Tests
357 passing, lint clean, example client builds. Five new: pointer escaping, silence when nothing moved, removal of a vanished key, the opening whole-namespace
add, and a second run naming the whole thread. Five rewritten — they pinned the old wire, which is them working.The state tests now assert on the state a client ends up holding rather than on the operations, by applying the deltas the way a client does.