Repository navigation
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces the RendererState class to a2ui.utils, which mirrors the surfaces a renderer holds and validates outbound messages against previous state to catch issues like dangling references, cycles, and unreachable components. It also refactors validate_payload to utilize this new state representation. The review feedback suggests several improvements to robustness: handling callable providers and general iterables more flexibly when processing catalogs and messages, and deep-copying component properties during surface cloning to prevent side-effects during transaction rollbacks.
`validate_payload` built a new `MessageProcessor` inside every call: one for the surfaces the payload creates, and one more for each surface the payload only updates, for each catalog that could hold it. The streaming parser calls it for every message on every chunk, so one streamed response built and discarded many processors, and nothing could check a payload against the surfaces earlier payloads created. `RendererState` is the agent's mirror of the surfaces one renderer holds. It owns one `MessageProcessor` for its whole life. 1. `apply(payload)` checks the messages against everything applied before and applies them. The call is atomic: if a message is rejected, the state is left as it was. 2. `transaction()` extends that to several calls. 3. `check_topology(surface_ids)` checks the trees of the surfaces a response touched: the root exists, every reference resolves, and every component added or replaced since the last check is reachable. A component that an update detached, by rewriting its parent's children, is accepted, since the protocol has no message that removes one component. 4. An update to a surface the state never saw created is rejected, as the renderer would reject it. v0.8 updates that arrive before `beginRendering` are held and checked once the surface begins rendering, the way a v0.8 renderer buffers them. `validate_payload` now runs on a short-lived `RendererState` that still assumes unknown surfaces exist, so its results are unchanged. `surface_catalog_ids` keeps working until the parser stops needing it. Part of #3263. Signed-off-by: Nan Yu <nanyu@google.com>
`validate_payload` is now a few lines on top of `RendererState`, so it lives in the same module and `utils/validation.py` is removed. It is still exported from `a2ui.utils`, so callers are unchanged. A payload may also be any iterable of messages, such as a generator, not only a sequence. A message model, a mapping, a string or bytes is still read as one message. Signed-off-by: Nan Yu <nanyu@google.com>
…s all its updates validate_payload assumed a surface that the payload updates without creating on the first catalog that accepted the first update, so a later update that only another catalog accepts was rejected. The placeholder is now probed against every update to the surface, up to the next message that creates or deletes it. A payload state also assumed a surface again after an earlier message of the payload deleted it. A renderer rejects updates to it, so the update now fails. v0.8 keeps buffering updates after a delete, as a v0.8 renderer does.
7ff0284 to
ea17be5
Compare
ditman
left a comment
There was a problem hiding this comment.
Small comment about the type of the payload parameter in validate_payload.
Define _MessagePayload for a single message, a message list wrapper, or any iterable of messages, and share it across RendererState.apply, validate_payload and _extract_messages. Signed-off-by: Nan Yu <nanyu@google.com>
gspencergoog
left a comment
There was a problem hiding this comment.
This PR adds RendererState to a2ui.utils, a long-lived mirror of a renderer's surfaces with atomic apply, nested transaction, v0.8 pending-update buffering, and a check_topology reachability pass, and rebuilds validate_payload on a short-lived _PayloadState subclass. The rollback design holds up on a read-through: snapshots are cloned on restore, _clone_surface covers every attribute the processor sets, multi-action messages are rejected by the adapter before any state changes, and the v0.9 and v0.8 ordering behaviors of the deleted module are preserved. CHANGELOG and README match the exported symbols.
Three medium items are left inline: a _changed bookkeeping gap in check_topology for v0.8 surfaces buffered before beginRendering, a docstring that sends canvas callers to apply + check_topology without mentioning transaction(), and a nested-rollback test that never exercises the shared-snapshot clone. None blocks merging. Tier 1 checks (ruff check, pyink --check, python-ci) are green; the failing Evaluate Python SDK job is the LLM eval, whose failures are model syntax errors unrelated to this diff.
🤖 Agent-generated
- Don't assume a surface that a later message of the payload creates, so validate_payload reports 'Surface not found' for an update sent before its createSurface instead of 'already exists'. - check_topology keeps the changed components of an assumed v0.8 surface, so they are checked once its beginRendering arrives. - Document that a v0.8 canvas must begin each surface once, that surface_ids has no order, and that apply and check_topology belong inside transaction(). - Make the nested rollback test exercise the shared snapshot. - Note the v0.8 delete-then-update change in the CHANGELOG. Signed-off-by: Nan Yu <nanyu@google.com>
aea51dd to
1f987a7
Compare
…derer-state Signed-off-by: Nan Yu <nanyu@google.com>
…derer-state Signed-off-by: Nan Yu <nanyu@google.com>
…t/python-agent-renderer-state Signed-off-by: Nan Yu <nanyu@google.com>
…ace snapshots Each applied operation now records what it replaced: the components an updateComponents adds or replaces (with a copy of the properties of a component updated in place), and the data model value at the path an updateDataModel sets. A deleted surface is recorded whole and restored by replaying it through the processor, keeping each component's own catalog. The state's own bookkeeping (changed components, assumed surfaces, the v0.8 buffer, what check_topology marked checked) is logged the same way. An apply or transaction is a position in the log, so a failed inner transaction reverts only to its own position. No surface is copied when a message is applied or a transaction opens, so applying n one-component messages to one surface takes time in proportion to n (ratio 400/100 drops from about 12 to about 4). check_topology now rejects a v0.8 surface whose updates are still buffered when the response is checked, except in the payload state that validate_payload uses. Signed-off-by: Nan Yu <nanyu@google.com>
…ponent-checks' into HEAD
…ent tests The a2ui_agent timing tests use the `timing` fixture too, so the conftest moves back to python/, the common parent of both test directories.
…te where the SDKs differ - a2ui_core: RendererState's atomic application undoes a rejected message from an undo log rather than rolling back to a copy. Requirement 4 now states only what a processor must do under a ValidationConfig that defers the graph checks, with no SDK status. - a2ui_core: the agent example shows the agent using RendererState (apply, then checkTopology) and, separately, the MessageProcessor calls RendererState makes inside, since an agent does not call MessageProcessor itself. - a2ui_agent: "Where the SDKs differ" assumes the pending Python (#3326, #3268-#3271) and TypeScript (#3327, #3278, #3279) pull requests. Both meet the three requirements of "Cost of a response"; the remaining entries are Dart's, plus Python being the only SDK with a v0.8 state. Signed-off-by: Nan Yu <nanyu@google.com>
…t/python-agent-renderer-state #3326 now keeps the timing harness package-local in python/a2ui_core/tests/timing_harness.py. Drop python/conftest.py, which this branch had restored, and add python/a2ui_agent/tests/conftest.py, which loads that module by path and exposes the `timing` fixture to the agent's `performance` tests.
python/a2ui_agent/tests/conftest.py loaded a2ui_core's timing_harness.py when it was imported, from a path resolved through symlinks. Under a test runner that runs from a runfiles tree, resolving follows the link out of that tree, and a harness missing from the test's inputs failed the collection of every agent test. The path is now taken from __file__ without resolving it, and the timing fixture loads the harness on first use. A missing harness raises FileNotFoundError in the timing tests only. Signed-off-by: Nan Yu <nanyu@google.com>
polina-c
left a comment
There was a problem hiding this comment.
I reviewed commit 2d40f74, the undo log. I found two rollback bugs, which the inline comments explain. Both reproduce at the PR head and are regressions from that commit: the same repros give the right result on the snapshot version at 2d40f74^.
At the head, uv run pytest python/ samples/agent/adk/tests passes (2803 passed, 15 skipped, 5 xfailed), and so does uv run pytest -m performance python/a2ui_core/tests python/a2ui_agent/tests (4 passed). I also fuzzed rollback with random transactions that fail at the end, on v0.8, v0.9, multi-catalog v0.9 and a v1.0 catalog with allowedParents. After each rollback I compared the state deeply with the state before it: properties, catalog ids, list padding and created containers, the v0.8 buffer and placeholder, and the assumed and changed sets always matched. I found no per-message cost that is still O(surface).
One more small difference, which no check depends on: undoing the deletion of a data model key (updateDataModel of /a to None) puts the key back at the end of its map ({"a": 1, "b": 2} comes back as {"b": 2, "a": 1}), in _child_restorer.
🤖 Agent-generated
Adds a component at a position in the model's order, which checks such as allowedParents read. add_component still appends and now delegates to it. Signed-off-by: Nan Yu <nanyu@google.com>
- Undoing a type or catalog change puts the old component back at its position instead of the end, so allowedParents resolves as before. - An in-place update undone after a deleteSurface undo updates the restored copy in place instead of moving it to the end. - Undoing a deleteSurface restores the surface straight into the model, without re-running the processor's checks, and restores any data model root, including [], 0, False and "". - Undoing a data model key deletion puts the key back at its position. - A rollback finishes even if one undo step fails, and the caller's error propagates. Signed-off-by: Nan Yu <nanyu@google.com>
…t/python-agent-renderer-state Signed-off-by: Nan Yu <nanyu@google.com>
…t/python-agent-renderer-state Signed-off-by: Nan Yu <nanyu@google.com>
…t/python-agent-renderer-state Signed-off-by: Nan Yu <nanyu@google.com>
Signed-off-by: Nan Yu <nanyu@google.com>
polina-c
left a comment
There was a problem hiding this comment.
I reviewed the head, 2850068, and the commits since 91ff57b. The two rollback bugs from my last review are fixed, and their repros are now regression tests.
I fuzzed rollback again at the head: 15,000 random cases on v0.8, v0.9 and v1.0, with two catalogs and allowedParents. Each case applied a prefix, rolled back a failed transaction, then sent more messages. After each rollback I compared the state with its state before the transaction, and compared each later message's result with a state that never rolled back. The comparison covered component order, type, catalog and properties, the data model with key order, the root, theme and catalogs, and _assumed, _pending and _changed. Nothing differed, and no undo step logged a failure.
I also checked the bugs that #3347 fixes in Dart. Component order is fixed here. The depth-limit replay can't happen, since the surface is restored straight into the model. Non-string keys are fine, since copy.deepcopy handles them.
Inline comments: the a2ui-core version floor (the agent now needs insert_component), two missing CHANGELOG entries, and one untested branch of _may_replace.
The PR description needs a refresh:
-
The IMPORTANT note says the core part of the diff disappears once #3326 lands. This PR now adds
SurfaceComponentsModel.insert_componentand an a2ui-core CHANGELOG entry of its own, so please say so. -
"Undo log" item 2 says a deleted surface is replayed through the processor (
_replay_operations), but ddbcb51 replaced that with_surface_restorer. Item 1 should say that an update that may replace a component also records its position. The links still point at 2d40f74. -
"validate_payload" says "its results are unchanged". The CHANGELOG lists the v0.8
deleteSurfaceexception, so the description should too. -
Could the timings be lists rather than a table? For example:
RendererState.applyon 400 one-component messages, oneapplyper message, best of three:- before: 398 ms
- after: 68 ms
The same on 400 messages inside one transaction:
- before: 467 ms
- after: 65 ms
-
Testing: at the head,
uv run pytest python/ samples/agent/adk/testsgives 2992 passed, and the performance run gives 5 passed, not 2803 and 4.
🤖 Agent-generated
…talog; complete the changelog - New test: undoing an update that moves a v1.0 component to another catalog, or back to the default catalog, keeps its position. It fails with a type-only replacement check, which no test caught before. - CHANGELOG: RendererState needs SurfaceComponentsModel.insert_component from the next a2ui-core release, which must raise the a2ui-core floor; validate_payload accepts any iterable of messages; a2ui.utils.validation is removed. Signed-off-by: Nan Yu <nanyu@google.com>
polina-c
left a comment
There was a problem hiding this comment.
Approve after small fixes
Stacked on #3326 (incremental component checks in
a2ui_core); review only the changes on top of it.Part of #3263. Based on main (#3267, which it was stacked on, has merged).
Important
This PR needs the core change in #3326 (
perf/python-core-incremental-component-checks), which makes the coreMessageProcessorcheck eachupdateComponentsagainst the components it changes, and adds the Python timing harness. Its branch is merged into this one, so the diff here includes it until #3326 lands. This PR also has a core change of its own:SurfaceComponentsModel.insert_component, with an a2ui-core CHANGELOG entry, which the undo log uses to put a replaced component back at its position. The agent therefore needs the next a2ui-core release. The a2ui-agent CHANGELOG says that release must raise thea2ui-corefloor inpyproject.toml; this PR can't, because the workspace builds a2ui-core as a dev version that a>=0.4.1floor would exclude.validate_payloadbuilt a newMessageProcessorinside every call: one for the surfaces the payload creates, and one more for each surface the payload only updates, for each catalog that could hold it. The streaming parser calls it for every message on every chunk, so one streamed response built and discarded many processors, and nothing could check a payload against the surfaces earlier payloads created.This PR adds
RendererState, the agent's mirror of the surfaces one renderer holds, exported froma2ui.utils. It owns oneMessageProcessorfor its whole life, created once with the linked catalogs.apply(payload)checks the messages against everything applied before and applies them, and returns the surfaces they touch. The call is atomic: if a message is rejected, the state is left as it was.transaction()extends that to several calls, for a response parsed whole. Transactions nest, and a failed inner transaction undoes only what was applied inside it.check_topology(surface_ids)checks the trees of the surfaces a response touched: the root exists, every reference resolves, and every component added or replaced since the last check is reachable from the root. A component that an update detached, by rewriting its parent's children, is accepted, since the protocol has no message that removes one component.beginRenderingare held and checked once the surface begins rendering, the way a v0.8 renderer buffers them. A v0.8 surface whose updates are still held whencheck_topologyruns is rejected, as a v0.9 update to a surface that was never created is (blueprint). The payload state behindvalidate_payloadis the exception.Undo log
A state must not copy a surface when a message is applied or a transaction opens: a stream applies each message on its own and reads each chunk inside a transaction, so a copy per call makes a response of n messages take time that grows with the square of n (blueprint). The state keeps an undo log instead (
_process_operation):updateComponentsrecords the components it adds or replaces, with a copy of the previous properties of a component the core updates in place (_components_undo). An update that may replace a component, because it changes the component's type or catalog (_may_replace), also records the component's position, and the undo puts it back there withinsert_component. The core itself still moves a replaced component to the end, as a renderer does. AnupdateDataModelrecords the value at the first segment of its path that changes, so the containers and list padding that setting the path creates are undone too._surface_restorer): the same catalogs, theme, root and protocol version, its components in their order with each one's own catalog, and its data model root as it was, including a root such as[]or0. It doesn't go through the processor's checks, which could reject state the processor already accepted.check_topologymarked checked, the assumed surfaces, and the v0.8 buffer.applyis a position in the log. A failure reverts the log to that position, latest change first. When the outermost one ends, the log is cleared.An undo finds its surface again by id, since undoing a later
deleteSurfacerestores the surface into new objects. An undo step that fails is logged and the rest of the log is still undone, so the caller gets the original error.RendererState.applyon 400 one-component messages, oneapplyper message, best of three:The same on 400 messages inside one transaction:
validate_payload
validate_payloadnow runs on a short-lived subclass that still assumes unknown surfaces exist, so its results are unchanged, with one exception: a v0.8 update that follows adeleteSurfaceof its surface is now buffered, as a v0.8 renderer does, instead of rejected. It moves intorenderer_state.py, next toRendererState, andutils/validation.pyis removed; it is still exported froma2ui.utils. A payload may now be any iterable of messages, such as a generator. Itssurface_catalog_idsargument keeps working here; the next PR in the stack removes it, once the parser checks against aRendererStateinstead.Nothing calls
RendererStateyet apart fromvalidate_payload. The parser and the request processor use it in the next PRs.Testing
test_renderer_state.pycovers atomicapply,check_topology(missing root, dangling references, new unreachable components, detached components), unknown surfaces and v0.8 buffering. New cases cover a failed inner transaction inside a successful outer one, rollback of a component updated in place, of a component replaced by another type, of a surface deleted and created again, of data model paths, of whatcheck_topologymarked checked, of updates made before a delete, of a multi-catalog v1.0 surface, and of the v0.8 buffer and placeholder, and the rejection of a v0.8 surface still waiting forbeginRendering. Later cases cover the review findings: the position of a component replaced by another type, moved to another catalog, or several replaced at once; component order after a restored delete; falsy data model roots; the position of a deleted data model key; and an undo step that fails.test_renderer_state_timing.py, markedperformance, checks that applying n messages takes linear time, with and without a transaction. It fails on the snapshot implementation (ratios 9.6 to 11).uv run pytest python/ samples/agent/adk/tests: 2994 passed.uv run pytest -m performance python/a2ui_core/tests python/a2ui_agent/tests: 5 passed.uv run mypy .: no issues.