Skip to content

feat(api)!: patch the shared state object rather than replace it - #88

Open
ciaransweet wants to merge 1 commit into
mainfrom
feat/state-delta
Open

feat(api)!: patch the shared state object rather than replace it#88
ciaransweet wants to merge 1 commit into
mainfrom
feat/state-delta

Conversation

@ciaransweet

Copy link
Copy Markdown
Contributor

AG-UI's state object is shared — the protocol has it hold what a client and an agent both keep. 0.6.0 moved our metadata under toolState so a client had somewhere of its own to put things. It could not use it: a STATE_SNAPSHOT replaces the whole object, and this server sent one on every tool write. Anything a client kept in state was wiped several times a turn.

That is a caveat you can only document, and dss had to: docs/API.md tells integrators to merge toolState in 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 under toolState.

  • A run opens with one add of the whole namespace. add on 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.
  • After that, one add or remove per key that moved.
  • State that has not moved sends nothing at all.

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

StateChanged says 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_turn already reads for the message-id check. So the docstring is true, the opening add of 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:

first op: add /toolState
keys it names: dataset-search/datasets, dataset-search/geometry,
               raster-ops/bounds, raster-ops/dataset,
               raster-ops/geometry, raster-ops/vertices

Escaping, because a state key contains the pointer's separator

Keys are toolset/name and / separates JSON Pointer segments, so raster-ops/dataset goes out as /toolState/raster-ops~1dataset — RFC 6901, ~ escaped first because the other escape introduces one. Unescaped it would address a dataset member of a raster-ops object that does not exist, and fast-json-patch validates, 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:

STATE_DELTA  add /toolState                      2 keys
STATE_DELTA  add /toolState/raster-ops~1dataset  and 3 more
STATE_DELTA  add ... x6                          the merged state, with seq

client keys after the run: selectedLayer=era5, ui={panel: open}
applied deltas == GET /threads/delta-1 state: True

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_DELTA rather than STATE_SNAPSHOT. defaultApplyEvents applies 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's docs/API.md needs 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.

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

1 participant