Skip to content

docs(blueprints): RendererState and ResponseStream without quadratic streaming (alternative to #3283) - #3322

Draft
polina-c wants to merge 1 commit into
mainfrom
blueprints-renderer-state-perf
Draft

polina-c wants to merge 1 commit into
mainfrom
blueprints-renderer-state-perf

Conversation

@polina-c

Copy link
Copy Markdown
Collaborator

This is an alternative to #3283. It takes #3283's blueprint text as of d26cf5ac0 (the RendererState, the ResponseStream and the stateless Parser) and changes how it specifies rollback and streaming. With those changes, an SDK that follows the blueprint keeps passing the timing suites added in #3314. The rest of #3283 is unchanged here.

The problem in #3283

#3283 says that apply is atomic (L505-L518) and that "a rollback restores copies of the surfaces that the frame changed" (L541). A stream applies each message as it is emitted (L564). A state that follows all three copies a surface for every message it applies. A response of n messages to one surface then takes time that grows with the square of n. The stream paragraph (L1103) also leaves open whether a chunk is read and checked for what it adds or together with everything before it.

The SDK PRs for #3283 do both:

  1. Dart feat(dart/agent): add RendererState; build validatePayload on it #3276 copies a surface the first time a transaction or an apply changes it (_save), and every apply opens one.
  2. Python and TypeScript copy the surfaces the same way (_save, save). They also read each chunk inside a transaction (Python, TypeScript), so every chunk copies the surfaces it touches.
  3. Dart feat(dart/agent)!: make the Parser stateless; add ResponseStream; bind RendererState to A2uiRequestProcessor #3277 also scans and reads the whole open block on every chunk (L152, L199). It applies a growing message whole every time it grows.

Measured

The numbers come from a port of #3276 and #3277 onto main's Dart agent, run against the timing suites. The port is in two variants:

Each number is the median time at 800 items divided by the median time at 200. A linear cost gives about 4, a quadratic one about 16, and a case fails above 8.

Parsing 800 messages of one component each whole, against 200:

  • before: 8.75, fails
  • after: 3.7

Streaming 800 components that heal as they arrive, to a surface an earlier turn created, against 200:

  • before: 8.34, fails
  • after: 4.3

Streaming a surface of 800 components, against 200:

  • before: 6.75
  • after: 4.0

Streaming 800 messages of one component each, against 200:

  • before: 6.65
  • after: 4.8

The time to stream a surface of 400 components, divided by the time to parse it whole:

  • before: 4.71
  • after: 2.74

Changes from #3283

  1. RendererState undoes a change at the cost of what changed, not the size of the surface:

    • The processor checks a message whole before it changes a surface, so a rejected message changes nothing.
    • Each applied message records what it replaced.
    • A deleted surface is replayed through the processor rather than copied.
    • A state must not copy a surface on each apply or each transaction.
    • The apply and transaction docstrings say so.
  2. ResponseStream reads each chunk for what it adds. feed says that a chunk that raises returns nothing and that what it applied is undone, which is what the SDKs already do.

  3. A new section, "Cost of a response", states what keeps parsing and streaming linear and names the suites that check it:

    • A stream reads each chunk once.
    • A growing message is checked for what it gains.
    • apply and transaction cost what they change.
    • The core processor checks an updateComponents against what it changes.
    • Only check_topology walks a whole surface.
  4. The DirectJsonResponseStream paragraph now says which messages are emitted while they grow and what happens to a message the state rejects, and it requires reading each chunk once.

  5. The agent conformance table lists <format>/scaling.yaml.

  6. The core blueprint lists what core supplies for undoing:

    • The check-before-change in processMessages.
    • SurfaceComponentsModel.get and removeComponent.
    • DataModel.get, set and delete.
    • Replay for a deleted surface.

    It drops addSurface and deleteSurface as the way to restore a surface. It also says that a processor under a deferring ValidationConfig checks an updateComponents without walking the whole surface. Its conformance section names message_processor_scaling.yaml.

Testing

  1. python3 blueprints/validate_blueprints.py passes.
  2. With the undo log, the Dart port passes all a2ui_agent tests and both timing suites, dart test -P performance in a2ui_agent and a2ui_core.

🤖 Agent-generated

…streaming

An alternative to #3283. Takes its blueprint text for RendererState,
ResponseStream and the stateless Parser, and changes how rollback and
streaming are specified so that parsing and streaming a response take
time in proportion to the response:

1. RendererState undoes a change at the cost of what changed: each
   applied message records what it replaced, and a deleted surface is
   replayed through the processor rather than copied. A state must not
   copy a surface on each apply or transaction.
2. ResponseStream reads each chunk for what it adds, and a chunk that
   raises returns nothing.
3. A new "Cost of a response" section names the timing suites.
4. The DirectJsonResponseStream paragraph states which messages are
   emitted while they grow and what happens to a rejected one.
5. The core blueprint lists what core supplies for undoing, and the
   incremental updateComponents check.

Co-authored-by: Nan Yu <nanyu@google.com>
@github-project-automation github-project-automation Bot moved this to Todo in A2UI Oct 19, 2026
@polina-c
polina-c marked this pull request as draft October 19, 2026 19:23

@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 updates the a2ui_agent and a2ui_core blueprints to introduce a stateful streaming and validation architecture, replacing the stateless parse_chunk with a RendererState and ResponseStream model. This allows outbound messages to be validated incrementally against the session's current surface state, with full topology checks deferred until the response is complete. The review feedback suggests minor corrections to the blueprints: removing an inaccurate reference to a delete method on DataModel, correcting the terminology 'v0.8 catalogs' to 'v0.8 capability resolution', and updating the streaming example code to properly iterate over and send individual ResponsePart elements.

An agent does not drive `MessageProcessor` directly. The [Agent SDK](a2ui_agent.blueprint.md)'s `RendererState` owns one, and adds what checking outbound messages needs on top of it: atomic application with rollback, a tree check once a response is complete, and a lifetime the agent chooses (one state per chat turn, or one per canvas conversation). Core supplies the pieces it builds on:

1. `processMessages` checks a message whole before it changes a surface, so a message it rejects leaves the surface as it was.
2. `SurfaceComponentsModel.get` and `removeComponent`, and `DataModel.get`, `set` and `delete`, through which a state reverts what an applied message replaced. A deleted surface is restored by replaying its components and data model through `processMessages`, which also restores the children its components find through other catalogs. A state does not copy surfaces.

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.

medium

The DataModel class interface defined in this blueprint (around line 1310) does not include a delete method. Instead, deletion of keys is handled by calling set with undefined or null (as described in the JSON Pointer implementation rules). Consider removing the reference to delete here to align with the actual API definition.

the model reads about.
- From v0.9 the capabilities are keyed by protocol version (`{"v0.9": {...}}`). The
v0.8 specification sends the entry itself (`{"supportedCatalogIds": [...]}`), so
v0.8 catalogs read that shape as well as the `"v0.8"` key. Capabilities count as a

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.

medium

In this context, 'v0.8 catalogs' is conceptually incorrect because catalogs are passive data structures that do not perform operations. It should refer to 'v0.8 capability resolution' or 'the resolver' instead.

# Streaming instead of step 4: each part is sent as soon as it is checked.
stream = processor.open_stream()
for chunk in myagent.stream_llm(processor.prompt_snippet, request_context):
send(stream.feed(chunk))

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.

medium

Since stream.feed(chunk) and stream.close() return a collection of ResponsePart elements, passing their return values directly to send (e.g., send(stream.feed(chunk))) might be incorrect if send expects a single ResponsePart. It would be more robust to iterate over the returned collection of parts and send each part individually.

References
  1. Avoid using language-specific syntax (such as Python's keyword-only argument * or type hints like list[ResponsePart]) in language-agnostic blueprint specifications to prevent confusion.

@nan-yu

nan-yu commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

Thanks, @polina-c. The measurements are convincing, and I agree with the direction: undo instead of copying, and a stated cost for a response. I'd like to fold this PR into #3283 because #3283 holds the review and the references. Your ten inline comments are on #3283, and the SDK PRs (#3268–#3279) link to it. Addressing them in one place keeps the threads and the history together.

I'll consolidate changes in this PR to #3283 and address your review comments there.

nan-yu added a commit that referenced this pull request Oct 19, 2026
…streaming

From #3322 by Polina Cherkasova (commit 26b4cc9), applied on top of
#3283. It changes how rollback and streaming are specified so that
parsing and streaming a response take time in proportion to the
response:

1. RendererState undoes a change at the cost of what changed: each
   applied message records what it replaced, and a deleted surface is
   replayed through the processor rather than copied. A state must not
   copy a surface on each apply or transaction.
2. ResponseStream reads each chunk for what it adds, and a chunk that
   raises returns nothing.
3. A new "Cost of a response" section names the timing suites.
4. The DirectJsonResponseStream paragraph states which messages are
   emitted while they grow and what happens to a rejected one.
5. The core blueprint lists what core supplies for undoing, and the
   incremental updateComponents check.

Co-authored-by: Polina Cherkasova <polina.c@live.com>
Co-authored-by: Nan Yu <nanyu@google.com>
Signed-off-by: Nan Yu <nanyu@google.com>

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.

2 participants