Skip to content

feat(python/agent): add RendererState; build validate_payload on it - #3268

Open
nan-yu wants to merge 20 commits into
perf/python-core-incremental-component-checksfrom
feat/python-agent-renderer-state
Open

nan-yu wants to merge 20 commits into
perf/python-core-incremental-component-checksfrom
feat/python-agent-renderer-state

Conversation

@nan-yu

@nan-yu nan-yu commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

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 core MessageProcessor check each updateComponents against 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 the a2ui-core floor in pyproject.toml; this PR can't, because the workspace builds a2ui-core as a dev version that a >=0.4.1 floor would exclude.

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.

This PR adds RendererState, the agent's mirror of the surfaces one renderer holds, exported from a2ui.utils. It owns one MessageProcessor for its whole life, created once with the linked catalogs.

  1. 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.
  2. 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.
  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 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.
  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. A v0.8 surface whose updates are still held when check_topology runs is rejected, as a v0.9 update to a surface that was never created is (blueprint). The payload state behind validate_payload is 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):

  1. Messages are split into the core's operations, and each applied operation records what it replaced. An updateComponents records 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 with insert_component. The core itself still moves a replaced component to the end, as a renderer does. An updateDataModel records 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.
  2. A deleted surface is the one change recorded whole. It is restored straight into the model (_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 [] or 0. It doesn't go through the processor's checks, which could reject state the processor already accepted.
  3. The state's own bookkeeping goes through the log too: the components changed since the last tree check, what check_topology marked checked, the assumed surfaces, and the v0.8 buffer.
  4. A transaction or apply is 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 deleteSurface restores 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.apply on 400 one-component messages, one apply per message, best of three:

  • before: 398 ms (11.6 times n=100)
  • after: 68 ms (4.1 times n=100)

The same on 400 messages inside one transaction:

  • before: 467 ms (14.4 times n=100)
  • after: 65 ms (3.6 times n=100)

validate_payload

validate_payload now 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 a deleteSurface of its surface is now buffered, as a v0.8 renderer does, instead of rejected. It moves into renderer_state.py, next to RendererState, and utils/validation.py is removed; it is still exported from a2ui.utils. A payload may now be any iterable of messages, such as a generator. Its surface_catalog_ids argument keeps working here; the next PR in the stack removes it, once the parser checks against a RendererState instead.

Nothing calls RendererState yet apart from validate_payload. The parser and the request processor use it in the next PRs.

Testing

  1. test_renderer_state.py covers atomic apply, 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 what check_topology marked 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 for beginRendering. 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.
  2. test_renderer_state_timing.py, marked performance, checks that applying n messages takes linear time, with and without a transaction. It fails on the snapshot implementation (ratios 9.6 to 11).
  3. 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.
  4. uv run mypy .: no issues.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread python/a2ui_agent/src/a2ui/utils/renderer_state.py
Comment thread python/a2ui_agent/src/a2ui/utils/renderer_state.py Outdated
Comment thread python/a2ui_agent/src/a2ui/utils/renderer_state.py Outdated
@nan-yu
nan-yu added this pull request to stack #3275 October 16, 2026 19:09
Base automatically changed from test/conformance-fixtures to main October 16, 2026 22:34
`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.
@nan-yu
nan-yu force-pushed the feat/python-agent-renderer-state branch from 7ff0284 to ea17be5 Compare October 16, 2026 23:07

@ditman ditman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small comment about the type of the payload parameter in validate_payload.

Comment thread python/a2ui_agent/src/a2ui/utils/renderer_state.py
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 gspencergoog left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread python/a2ui_agent/src/a2ui/utils/renderer_state.py Outdated
Comment thread python/a2ui_agent/src/a2ui/utils/renderer_state.py Outdated
Comment thread python/a2ui_agent/tests/utils/test_renderer_state.py

@ditman ditman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Some extra nitpickis)

Comment thread python/a2ui_agent/CHANGELOG.md Outdated
Comment thread python/a2ui_agent/src/a2ui/utils/renderer_state.py Outdated
Comment thread python/a2ui_agent/src/a2ui/utils/renderer_state.py Outdated
Comment thread python/a2ui_agent/src/a2ui/utils/renderer_state.py Outdated
- 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>
@nan-yu
nan-yu force-pushed the feat/python-agent-renderer-state branch from aea51dd to 1f987a7 Compare October 19, 2026 17:01
…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>
…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.
nan-yu added a commit that referenced this pull request Oct 20, 2026
…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.
@nan-yu
nan-yu removed this pull request from stack #3275 October 20, 2026 17:42
@nan-yu
nan-yu changed the base branch from main to perf/python-core-incremental-component-checks October 20, 2026 17:43
@nan-yu
nan-yu added this pull request to stack #3340 October 20, 2026 17:47
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 polina-c left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread python/a2ui_agent/src/a2ui/utils/renderer_state.py
Comment thread python/a2ui_agent/src/a2ui/utils/renderer_state.py Outdated
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 polina-c left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_component and 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 deleteSurface exception, so the description should too.

  • Could the timings be lists rather than a table? For example:

    RendererState.apply on 400 one-component messages, one apply per 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/tests gives 2992 passed, and the performance run gives 5 passed, not 2803 and 4.

🤖 Agent-generated

Comment thread python/a2ui_agent/CHANGELOG.md Outdated
Comment thread python/a2ui_agent/CHANGELOG.md Outdated
Comment thread python/a2ui_agent/src/a2ui/utils/renderer_state.py
…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 polina-c left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve after small fixes

This branch has not been deployed

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

4 participants