Skip to content

docs(blueprints): describe RendererState, ResponseStream and the stateless Parser - #3283

Open
nan-yu wants to merge 7 commits into
mainfrom
docs/blueprints-renderer-state
Open

nan-yu wants to merge 7 commits into
mainfrom
docs/blueprints-renderer-state

Conversation

@nan-yu

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

Copy link
Copy Markdown
Collaborator

Updates the agent and core module blueprints for the agent SDK redesign in #3262 and #3263, so that they describe the interface the Python (#3268–#3270), TypeScript (#3278, #3279) and Dart (#3276, #3277) PRs implement. Includes the undo log, the stream emission rules and the "Cost of a response" section from @polina-c's #3322 (her commit is 67b7d5990, with her as author), and addresses her review of this PR.

Every behavioral rule was checked against the three SDK PRs. Where they disagree with each other or with the rule, the blueprint states the intended rule and lists the disagreement under "Where the SDKs differ", so the SDK fixes can be scheduled.

a2ui_agent.blueprint.md

  1. The Parser holds no per-response state and gains a catalogs property. compile(content, is_final) only translates; a block cut off by the end of the response compiles as far as it goes in Express and raises in Direct JSON. parse_response(content, wrapped, state=) compiles every block, applies them to a RendererState inside a transaction, and checks the trees of the surfaces they touched; a cut block is applied, and its surfaces skip the check and are marked checked. A failure is an A2uiError carrying partial_results. open_stream(wrapped=, state=) returns a ResponseStream with state, feed and close. parse_chunk is removed.
  2. ResponseStream: each feed runs in a transaction, so a failing chunk returns nothing and undoes only what it applied. A failed stream marks the surfaces it emitted to as checked.
  3. New RendererState section: apply (atomic), transaction (nests; the callback form returns the callback's result and must be synchronous), check_topology (only components added or replaced since the last check must be reachable; duplicate, unknown, deleted and assumed ids are handled), mark_checked, and rejection of updates to unknown surfaces.
  4. Rollback is an undo log (from docs(blueprints): RendererState and ResponseStream without quadratic streaming (alternative to #3283) #3322): each message records what it replaced, a deleted surface is replayed rather than copied, and a state never copies a surface per message or transaction. The log also records the state's own bookkeeping, copies the properties an in-place update replaces, and treats a nested transaction as a position in the log.
  5. v0.8: the state buffers updates before beginRendering, checks them again against the catalog it names, and rejects a response that leaves updates buffered.
  6. New validate_payload section: one payload on its own (one message or a list), on a short-lived state that assumes a surface the payload only updates exists, and how the catalog of such a surface is chosen.
  7. New "Chat and canvas" section: the caller chooses how long a state lives.
  8. New "Cost of a response" section (from docs(blueprints): RendererState and ResponseStream without quadratic streaming (alternative to #3283) #3322): parsing and streaming take time in proportion to the response, with three requirements for every SDK: core checks an updateComponents against what it changes, RendererState keeps an undo log, and a stream reads each chunk once. The timing suites from Fix performance and create conformance tests. #3314 check it.
  9. New "Where the SDKs differ" section: ten gaps between the SDK PRs and the rules, such as the surface copies in all three SDKs and the per-chunk re-reads in Python and TypeScript.
  10. A2uiRequestProcessor reuses one parser (parser), adds create_state(), accepts_state(), a bound state and open_stream, and A2uiGenerator.create_processor takes state. A state must hold the same catalogs in the same order, since the order is the priority the state resolves catalogs in. The code example checks the conversation state with accepts_state before using it.
  11. resolve_catalogs reads the unkeyed v0.8 capabilities shape (feat(python/agent): read v0.8 capabilities in the shape the v0.8 spec defines #3265).
  12. The directory layout, the Direct JSON stream (which messages are emitted while they grow, and what happens to a rejected one), Express (open_stream raises), and the conformance notes follow. The notes say that the streaming action parse_chunk runs open_stream, feed and close, and that no suite covers RendererState, canvas turns or validate_payload yet (a follow-up).

a2ui_core.blueprint.md

The "Renderer vs. Agent Execution Patterns" section describes the agent's MessageProcessor as the one RendererState wraps, and lists what core supplies to it: the check-before-change in processMessages, the component and data model accessors an undo log reverts through, validateTopology and detectCycles, and a deferring ValidationConfig under which updateComponents must be checked against what it changes. The example roots the tree check at surface.rootId.

Conformance

The schema, conformance/README.md and the header of response_streaming.yaml say that the parse_chunk action runs open_stream, then feed for each step, then close, and that a case ending with expect_error does not call close. The 118 cases keep their action name.

Not changed

The codebase blueprints under blueprints/codebases/ still name their current module_blueprint_commit and describe the code on main, which they remain accurate for. Each SDK's codebase blueprint can move to this commit once its PR above lands.

Testing

  1. python3 blueprints/validate_blueprints.py passes.
  2. prettier --check on the changed Markdown, JSON and YAML.

…eless Parser

Update the agent and core module blueprints for the agent SDK redesign
(#3262, #3263).

a2ui_agent.blueprint.md:
1. The Parser holds no per-response state. compile only translates;
   parse_response(content, wrapped, state=) checks a whole response
   against a RendererState inside a transaction; open_stream returns a
   ResponseStream with feed and close. parse_chunk is gone.
2. New RendererState (apply, transaction, check_topology) and the
   stateless validate_payload, with the chat and canvas lifetimes.
3. A2uiRequestProcessor reuses one parser, creates and binds a
   RendererState, and adds open_stream. create_processor takes state.
4. resolve_catalogs reads the unkeyed v0.8 capabilities shape.
5. The directory layout, the Direct JSON stream, Express, the workflow
   example and the conformance notes follow.

a2ui_core.blueprint.md: the agent execution pattern describes the
MessageProcessor that RendererState wraps, with the graph checks
deferred until a response is complete, and the negotiated catalogs.

Signed-off-by: Nan Yu <nanyu@google.com>

@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 refactors the A2UI agent and core blueprints to introduce a decoupled, stateless parser architecture. It replaces the incremental parse_chunk method with open_stream, which returns a stateful ResponseStream, and introduces RendererState to mirror the renderer's surfaces and manage validation state. The feedback suggests removing Python-specific keyword-only argument syntax (*) from the blueprint specifications to ensure they remain language-agnostic and clear for developers implementing the SDK in other languages like TypeScript or Dart.

Comment thread blueprints/modules/a2ui_agent.blueprint.md
Comment thread blueprints/modules/a2ui_agent.blueprint.md
Comment thread blueprints/modules/a2ui_agent.blueprint.md
Comment thread blueprints/modules/a2ui_agent.blueprint.md
Comment thread blueprints/modules/a2ui_agent.blueprint.md
Comment thread blueprints/modules/a2ui_agent.blueprint.md
…eprint

The signatures no longer use Python's '*' marker. A sentence says that
the optional state argument is meant to be passed by name in languages
that have named arguments.

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.

Splitting the stateless Parser from a per-response ResponseStream, separating translation from checking, and letting the caller choose how long a RendererState lives fix real problems in all three SDKs (#3262, #3263). I agree with the direction.

Two things should change before this lands:

  1. As written, the blueprint makes streaming quadratic. A rollback restores copies of surfaces, apply is atomic, and a stream applies each message on its own, so a state copies a surface for every message. The Python, TypeScript and Dart PRs all do this. The comment on L541 has measurements and a suggested fix.
  2. In several places, the blueprint describes less than the SDK PRs implement, or something else. The inline comments list them: is_final, mark_checked, accepts_state, the stream's emission rules, v0.8, and the placeholder catalogs of validate_payload.

About Dart: #3276 and #3277 are based on main from before #3242 (agent v1.0) and #3314 (linear streaming). They throw UnimplementedError for v1.0, conflict with main in ten files, and bring back the per-chunk re-reads that #3314 removed. The design itself fits Dart: transactions as synchronous callbacks, and named parameters. A port of both PRs onto main, with v1.0 and an undo log in place of the surface copies, passes all Dart tests and the timing suites.

🤖 Agent-generated

pass
```

The processor inside the state applies messages with references, a missing root and orphans allowed, and unknown components and functions still rejected; `check_topology` runs the graph checks the protocol requires once a response is complete. A rollback restores copies of the surfaces that the frame changed.

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.

"A rollback restores copies of the surfaces that the frame changed" makes streaming quadratic:

  1. apply is atomic (L505-L518).
  2. A stream applies each message on its own (L564).

So a state copies a surface for every message it applies. The SDK PRs do exactly that: Dart's _save/_cloneSurface, Python's _save/_clone_surface and TypeScript's save/cloneSurface, with each feed also inside a transaction.

On the timing suites from #3314, time at 800 items divided by time at 200, limit 8, for parsing messages of one component each whole:

  • copying surfaces: 8.75, fails
  • undo log: 3.7

The core already checks a message whole before it changes a surface, so a state can record what each message replaced and undo that in reverse:

Suggested change
The processor inside the state applies messages with references, a missing root and orphans allowed, and unknown components and functions still rejected; `check_topology` runs the graph checks the protocol requires once a response is complete. A rollback restores copies of the surfaces that the frame changed.
The processor inside the state applies messages with references, a missing root and orphans allowed, and unknown components and functions still rejected; `check_topology` runs the graph checks the protocol requires once a response is complete.
Undoing costs what the undone messages changed, not the size of the surfaces they touched:
1. The processor checks a message whole before it changes a surface, so a rejected message changes nothing.
2. Each applied message records what it replaced: the components it added or replaced, and the data model value at its path. A failed transaction or `apply` reverts those records in reverse order.
3. A deleted surface is the one change recorded whole. It is restored by replaying it through the processor, which also restores the children its components find through other catalogs.
A state must not copy a surface each time a message is applied or a transaction opens. A stream applies each message on its own and reads each chunk inside a transaction, so such a copy makes a response of n messages to one surface take time that grows with the square of n.

🤖 Agent-generated

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Taken from #3322: the undo log text came in with your commit, cherry-picked as 67b7d59 with you as author. 927050c extends it (L604-L613):

  1. An in-place update (same id, type and catalog) records a copy of the previous properties, since core changes the object in place.
  2. Replaying a deleted surface keeps each component's own catalogId and the v0.8 updates buffered for it.
  3. The log also records the state's bookkeeping: the components changed since the last tree check, mark_checked, assumed surfaces, and the v0.8 buffer.
  4. A nested transaction is a position in the log, so a failed inner one reverts only to its own mark.

"Cost of a response" now states the three linear-cost rules (core, undo log, one read per chunk) as requirements for every SDK (L649-L659). The new "Where the SDKs differ" list names the surface copies in all three SDKs as gap 1 (L661-L674).

pass
```

`DirectJsonResponseStream` heals the partial JSON of each chunk, healing the values of `progressive_keys` in real time, and emits the messages that read whole and that its state accepts. It checks each message against the state rather than building a processor per message, and keeps no copy of the surfaces of its own. A `createSurface` or `deleteSurface` is held until its object closes, so it is emitted once, and a message for a surface that does not exist yet is held back, as a renderer would reject it. When a block closes, the trees of the surfaces it touched are checked.

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.

Three things in this paragraph differ from the SDKs, or leave the cost open:

  1. A message is emitted once a healed prefix reads and the state accepts it, and again each time it grows, not once it "reads whole".
  2. Only updateComponents and updateDataModel are emitted while they grow. Every other message waits for its object to close, callRendererFunction included, not only createSurface and deleteSurface.
  3. A rejected message is held back with the messages after it until the block closes, and then the block raises. From v1.0, a rejected updateComponents whose components have closed raises on that chunk.

The paragraph also doesn't require a chunk to be read for what it adds. #3277 scans and reads the whole open block on every chunk and re-applies a growing message whole, which reverts what #3314 fixed in Dart. A version that states these rules:

Suggested change
`DirectJsonResponseStream` heals the partial JSON of each chunk, healing the values of `progressive_keys` in real time, and emits the messages that read whole and that its state accepts. It checks each message against the state rather than building a processor per message, and keeps no copy of the surfaces of its own. A `createSurface` or `deleteSurface` is held until its object closes, so it is emitted once, and a message for a surface that does not exist yet is held back, as a renderer would reject it. When a block closes, the trees of the surfaces it touched are checked.
`DirectJsonResponseStream` heals the partial JSON of each chunk, healing the values of `progressive_keys` in real time. It emits a message once the message reads and its state accepts it, and again each time a later chunk changes it. Only `updateComponents` and `updateDataModel` are emitted while they grow. Any other message, such as `createSurface`, `deleteSurface` or `callRendererFunction`, is held until its object closes, so it is emitted once. A message the state rejects, such as an update to a surface that does not exist yet, is held back with the messages after it, and the block raises when it closes. From v1.0 a component is held until its object closes, so an `updateComponents` whose components have closed cannot change, and the chunk that closes a rejected one raises.
The stream checks each message against the state rather than building a processor per message, and keeps no copy of the surfaces of its own. It reads each chunk once, so a stream takes time in proportion to the response: the scan for the close tag and the read of the open block resume where the previous chunk left them, a message that has closed is not read or checked again, and a message that grows is checked for what it gains. When a block closes, the messages it holds are applied in full and the trees of the surfaces it touched are checked.

🤖 Agent-generated

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Your paragraph came in with 67b7d59. I checked it against the three SDKs.

  • Dart follows it.
  • Python and TypeScript emit every message except createSurface, deleteSurface and beginRendering while it grows, callRendererFunction included. I kept your rule and listed this as gap 4 in "Where the SDKs differ".
  • The rule on rejected messages, and the v1.0 rule that a rejected updateComponents whose components have closed raises on that chunk, hold in all three.

927050c adds the v0.8 names (surfaceUpdate/dataModelUpdate grow, beginRendering waits) and that the messages after a held one wait with it (L1231). The one-read-per-chunk rule is now requirement 3 of "Cost of a response". Gap 3 records where each SDK still re-reads.

def feed(self, chunk: str) -> list[ResponsePart]:
"""Reads the next chunk and returns the parts that became ready since the last one.

If it raises, the stream has ended: a response cannot be read further once part of

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.

All three SDKs run each feed inside a transaction. A chunk that raises therefore returns nothing, and what it applied, including a block that closed earlier in the same chunk, is undone. "A streamed response cannot be rolled back" (L406) needs that qualifier. The error a later feed raises differs too: ValueError in Python, Error in TypeScript, StateError in Dart. "Raises" without a type would fit all three.

🤖 Agent-generated

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 927050c (L430-L460).

  • The class docstring now says that what earlier chunks emitted cannot be rolled back. Each feed runs in a transaction, so a failure undoes only what the failing chunk applied, and that chunk returns nothing.
  • feed mentions a block that closed earlier in the same chunk.
  • A later feed "raises with an error that says the stream has ended", with no type, since the three SDKs raise ValueError, Error and StateError.

I also wrote down the failed-stream marking: the surfaces emitted to before the failing chunk are marked checked.

@@ -283,7 +301,12 @@ class Parser(ABC):

@abstractmethod
def compile(self, format_content: str) -> list[AgentToRendererMessage]:

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.

Python and TypeScript take is_final in compile, and Python's direct JSON compile returns mappings rather than messages. The rule behind the flag isn't here either:

  • An Express block cut off by the end of the response is compiled as far as it goes and applied.
  • Its surfaces skip check_topology and are marked checked.
  • Direct JSON raises instead.

🤖 Agent-generated

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 927050c.

  • compile takes is_final (L305-L330). Express compiles a cut block as far as it goes. Direct JSON raises A2uiParseError.
  • parse_response describes the cut block (L357-L362): it is applied, its surfaces skip check_topology and are marked checked, and a surface a complete block also touched skips the check too.

Two gaps go in "Where the SDKs differ" (gap 5):

  1. Dart's compile has no is_final, so a cut Express block raises there.
  2. Python's Direct JSON compile returns mappings.

"""
pass

def check_topology(self, surface_ids: Iterable[str]) -> None:

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.

All SDKs ship mark_checked. parse_response uses it for a truncated block, and the stream uses it when it fails after emitting. The blueprint doesn't name it.

check_topology also has a few behaviours worth one sentence:

  • It removes duplicate ids.
  • It skips ids that are unknown, deleted or placeholders.
  • It marks the rest checked.

🤖 Agent-generated

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 927050c.

  • mark_checked is a method now, with its two callers and the note that a failed transaction undoes it (L590-L599).
  • check_topology now says it checks each id once, skips unknown, deleted and assumed ids, and marks every id that is not assumed as checked (L584-L586).

The three SDKs agree on all of this.

processor = generator.create_processor(renderer_capabilities)
# 2. In Request Handler: Negotiate this request's processor from the capabilities the client sent.
# A canvas agent binds the RendererState it keeps for the conversation; a chat agent binds none.
processor = generator.create_processor(renderer_capabilities, state=conversation_state)

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.

An agent creates a processor for every request because the client may change its catalogs (L785). A state bound with create_processor(..., state=conversation_state) then raises A2uiCatalogError on the first request after such a change. The SDKs add accepts_state for this, so the agent can check the state and make a new one with create_state(). The blueprint, and the example at L1162, should show that.

🤖 Agent-generated

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 927050c.

  • A2uiRequestProcessor.accepts_state is a method now (L979-L993). It compares catalog ids and protocol versions in order, with v0.9 and v0.9.1 matching, as RendererState treats them.
  • The code example creates the processor without a state, checks the conversation state, starts a new one with create_state() when it no longer fits, and passes it to parse_response and open_stream (L1290-L1320).
  • "Chat and canvas" and the create_processor docstring say the same.

TypeScript and Dart compare raw version strings (gap 10).

List of ResponsePart objects (TextPart / A2uiPart).

Raises:
A2uiError: If a block cannot be read or a message is rejected. Any other failure

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 wording only fits Python:

  1. "Any other failure to compile is raised as a compilation error" is Python's A2uiCompilationError. Dart and TypeScript don't wrap errors.
  2. NotImplementedError (L391): TypeScript throws Error and Dart UnsupportedError.
  3. ContextManager (L520): the callback form should say that it returns the callback's result and must be synchronous. Dart rejects a callback that returns a Future.
  4. "Passed by name" (L242): in Dart, every optional argument is already named.

🤖 Agent-generated

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 927050c:

  1. parse_response now says every failure is an A2uiError, an error of another type from compiling is wrapped in A2uiCompilationError, and the error carries partial_results (L370-L380). Python and TypeScript both wrap. Dart lets e.g. a StateError through and has no partial_results (gap 6).
  2. open_stream raises "the language's unsupported-operation error". A note under Parser says that a Python language error named in a docstring stands for each language's own (L242-L244).
  3. The callback form of transaction returns the callback's result and must be synchronous. A callback that returns a promise or future is rejected and undone (L559-L571). TypeScript rejects it; Dart does not check yet (gap 7).
  4. Named arguments: keyword-only in Python, a named optional parameter in Dart, an options object where the language has neither.


Where a suite fixes something this document leaves open, the decision is stated in that suite's header rather than left for a reader to infer from the cases.

A `compile` or `decompile` case checks a block on its own, the way `validate_payload` does, so a surface the block only updates is assumed to exist. A `parse_response` or streaming case is read as one chat turn, against a renderer that holds no surfaces, so a case that updates a surface creates it in its input. A streaming case ends with `close()`, which must return nothing more.

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.

  1. The streaming action is still parse_chunk in response_streaming.yaml, the schema and the README, while the table says open_stream. Either rename it everywhere, or say in the schema that parse_chunk names open_stream, then feed for each step, then close.
  2. The close() rule is in the blueprint and the harnesses, but not in the suite header, although L1199 says decisions go there. A case that ends with expect_error doesn't call close().
  3. No suite covers RendererState, canvas turns or validate_payload yet.

🤖 Agent-generated

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

  1. I kept the name and documented it, rather than renaming 119 cases (b4dbcc0). The schema's ParseChunkTest, the conformance README and the suite header now say that parse_chunk means open_stream, then feed for each step, then close (README L139, suite header L24-L30). The blueprint's conformance section says the same (L1340).
  2. The suite header now holds the close() rule, including that a case ending with expect_error does not call close (every such step is the last of its case).
  3. The blueprint now says that no suite covers RendererState, canvas turns or validate_payload yet, and that adding them is a follow-up (L1342).

In a renderer, `MessageProcessor` receives incoming protocol messages and updates the local layout and data state. In an agent, it is an optional helper for checking model-generated messages against catalogs, verifying data paths, and preparing payloads for transmission. Both reach validation through it, because it is what holds every supported catalog and can therefore resolve the one each item belongs to:
In a renderer, `MessageProcessor` receives incoming protocol messages and updates the local layout and data state. In an agent, it mirrors the surfaces the renderer holds, so that model-generated messages are checked against everything the renderer has already received before they are sent. Both reach validation through it, because it is what holds every supported catalog and can therefore resolve the one each item belongs to.

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: `SurfaceGroupModel.addSurface` and `deleteSurface` to restore a surface, `SurfaceComponentsModel.getAll` and `validateTopology` for the tree check, and a `ValidationConfig` that defers the graph checks.

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.

addSurface and deleteSurface can only restore a surface that has been copied, and core has no copy. A copy built from the surface's default catalog also loses the reference fields that the processor widens for v1.0 surfaces using several catalogs. The tree check also needs the set of components the root reaches, which detectCycles returns, rather than getAll. A version built around undoing and the check-before-change in processMessages:

Suggested change
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: `SurfaceGroupModel.addSurface` and `deleteSurface` to restore a surface, `SurfaceComponentsModel.getAll` and `validateTopology` for the tree check, and a `ValidationConfig` that defers the graph checks.
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.
3. `validateTopology`, and `detectCycles`, which returns the components the root reaches, for the tree check.
4. A `ValidationConfig` that defers the graph checks. Under a config that allows dangling references, and orphans or a missing root, a processor checks each `updateComponents` against the components it changes rather than walking the whole surface, so a surface that arrives in many small messages takes time in proportion to its size.
Applying a response message by message, as a stream does, therefore costs time in proportion to the response. `conformance/core/message_processor_scaling.yaml` times it.

🤖 Agent-generated

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Your text came in with 67b7d59. I checked the core pieces in web_core: SurfaceComponentsModel.get/removeComponent, DataModel.get/set/delete, and detectCycles returning the visited set.

9f95b62 adds two things (L743-L750):

  • Core updates properties in place, so a state records a copy of them.
  • Item 4 is a requirement ("must not copy ... or walk the whole surface"). Dart meets it. The Python and TypeScript processors still copy every component and check the whole graph on each updateComponents, and the text says so.

Comment on lines +771 to +773
agentProcessor.model
.getSurface(surfaceId)
?.componentsModel.validateTopology({...STRICT_VALIDATION, allowOrphanComponents: true});

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.

validateTopology roots the check at config.rootId ?? 'root', and the TypeScript processor itself passes surface.rootId.

Suggested change
agentProcessor.model
.getSurface(surfaceId)
?.componentsModel.validateTopology({...STRICT_VALIDATION, allowOrphanComponents: true});
const surface = agentProcessor.model.getSurface(surfaceId);
surface?.componentsModel.validateTopology({
...STRICT_VALIDATION,
rootId: surface.rootId,
allowOrphanComponents: true,
});

🤖 Agent-generated

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Applied in 9f95b62 (L776-L787): the example passes rootId: surface.rootId, with a comment that validateTopology otherwise roots the check at 'root'.

nan-yu and others added 5 commits October 19, 2026 20:53
Signed-off-by: Nan Yu <nanyu@google.com>
…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>
The streaming cases keep the action name `parse_chunk` from the method
they were first written against. The schema, the conformance README and
the header of direct_json/response_streaming.yaml now say that a harness
runs it as `Parser.open_stream`, then `ResponseStream.feed` for each
step, then `close`, which must return nothing; and that a case whose last
step expects an error ends there, without `close`. The `parse_chunk`
operation of the timing suites reads a response the same way.

Signed-off-by: Nan Yu <nanyu@google.com>
The core example of an agent's tree check now passes `surface.rootId`
to `validateTopology`, which otherwise roots the check at 'root', as the
TypeScript processor itself does. The list of what core supplies to a
RendererState now says that core updates a component's properties in
place, so a state records a copy of them, and makes the incremental
`updateComponents` check a requirement, noting that the Python and
TypeScript processors do not meet it yet.

Signed-off-by: Nan Yu <nanyu@google.com>
… SDKs

Addresses the review of #3283, checking each rule against the Python
(#3270), TypeScript (#3279) and Dart (#3277) pull requests:

1. `compile` takes `is_final`; a cut block is applied and its surfaces
   skip `check_topology` and are marked checked, and a cut Direct JSON
   block raises. `parse_response` errors carry `partial_results`.
2. Language-neutral wording for named arguments, the unsupported-stream
   error, and the callback form of `transaction` (returns the callback's
   result, must be synchronous).
3. A failing `feed` undoes only what that chunk applied; a failed stream
   marks the surfaces it emitted to as checked.
4. `RendererState.mark_checked`, and what `check_topology` does with
   duplicate, unknown, deleted and assumed ids.
5. The undo log records the state's own bookkeeping, copies the
   properties an in-place update replaces, keeps each component's
   `catalogId` and the v0.8 buffer when replaying a deleted surface, and
   treats a nested transaction as a position in the log.
6. How a v0.8 state buffers updates before `beginRendering`.
7. How `validate_payload` picks the catalog of an assumed surface, and
   that it accepts one message on its own.
8. The three linear-cost rules are requirements for every SDK.
9. `A2uiRequestProcessor.accepts_state`, used in the code example.
10. The conformance plan says what `parse_chunk` runs and that no suite
    covers RendererState, canvas turns or validate_payload yet.
11. A "Where the SDKs differ" list of the gaps the SDKs still have.

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