Repository navigation
Conversation
Add RendererState, the agent's mirror of the surfaces a renderer holds. It owns one MessageProcessor for its whole life, so each message is checked against everything applied before it: - apply checks and applies messages atomically, rolling the touched surfaces back if one is rejected. An update to a surface the state never saw created is rejected, as a renderer would reject it. - transaction makes several calls atomic. - checkTopology checks the root, the references and the reachability of every component added or replaced since the last check. A component that an update detached is accepted, since the protocol cannot remove one. Replace the processor-per-call validatePayloads with validatePayload, which checks one payload on a short-lived RendererState that assumes the surfaces the payload only updates exist. A2uiRequestProcessor checks its examples with it, and checks a parsed response as a whole against a new state. Part of #3263 Signed-off-by: Nan Yu <nanyu@google.com>
…State to A2uiRequestProcessor Make the Parser stateless and move streaming to an object that lives for one response: - Parser.compile only translates. DirectJsonParser.compile reads JSON into typed messages and no longer checks them against the catalogs. - Parser.parseResponse(content, wrapped:, state:) compiles every block, applies the messages inside a transaction on the given RendererState, or a new one, and then checks the trees of the surfaces they touched. Express output is checked the same way as direct JSON. - Parser.openStream(wrapped:, state:) returns a ResponseStream with feed and close. DirectJsonResponseStream applies each message to the state as it emits it, so streamed responses get the whole-surface checks too. It holds createSurface and deleteSurface until their object closes, and close emits held-back text, throws if a block is still open, and checks the tree of an unwrapped stream. Parser.parseChunk and the stream fields on the parser are removed. - A2uiRequestProcessor reuses one parser, adds createState and an optional bound state (also through A2uiGenerator.createProcessor) that must hold the active catalogs, and adds openStream. Without any state, each call checks the response against a new, empty state. The conformance runners read parse_response and streaming cases as one chat turn against a new state, and drive streaming cases through openStream, feed and close. Compile and round-trip cases check the compiled block on its own with validatePayload. The streaming and Express known gaps that now pass are removed. The eval bridge checks a parsed response against a RendererState instead of a strict MessageProcessor, and the README, the example and the e2e test use the new API. BREAKING CHANGE: Parser.parseChunk is removed (use openStream, feed and close); Parser.compile no longer checks messages against the catalogs; Parser subclasses implement a catalogs getter; parseResponse without a state rejects an update to a surface the response does not create. Part of #3262 Part of #3263 Signed-off-by: Nan Yu <nanyu@google.com>
…all its updates - validatePayload assumed a surface that the payload updates without creating on the first catalog that accepted the first update. The placeholder is now probed against every update to the surface, up to the next message that creates or deletes it, as in Python. - A payload state no longer assumes a surface that an earlier message of the payload deleted: a renderer rejects updates to it. - validatePayload checks the catalogs of an empty payload too, so no catalogs is an A2uiCatalogError as documented.
Add RendererState, the agent's mirror of the surfaces a renderer holds. It wraps one web_core MessageProcessor, created once with the linked catalogs and an in-progress validation config, and checks each outbound message against everything applied before it. - apply(payload) checks and applies messages atomically, restoring a snapshot of every surface it changed when a message is rejected. - transaction(fn) extends that to everything fn applies. - checkTopology(surfaceIds) checks the root and references of each surface, and that every component added or replaced since the last check is reachable. Components an update detached are accepted. - An update to a surface the state never saw created is rejected. validatePayload now runs on a short-lived subclass that assumes a surface the payload only updates already exists, instead of building a new MessageProcessor for the created surfaces and another per candidate catalog of each updated surface. RendererState, validatePayload and the SurfaceModel type are exported from the package root. Part of #3263 Signed-off-by: Nan Yu <nanyu@google.com>
RendererState.transaction now types fn so that a function returning a promise doesn't compile. The runtime check stays, for JavaScript callers. Signed-off-by: Nan Yu <nanyu@google.com>
…ate to A2uiRequestProcessor
Make the Parser stateless and move streaming to an object that lives for
one response:
- Parser.compile only translates. DirectJsonParser.compile reads a block
with a JSON reader ported from Python and no longer validates it.
- Parser.parseResponse(content, {wrapped, state}) compiles every block,
applies the messages inside a transaction on the given RendererState,
or a new one, and then checks the trees of the surfaces they touched.
Express output is checked the same way as Direct JSON.
- Parser.openStream({wrapped, state}) returns a ResponseStream with feed
and close. DirectJsonResponseStream, a port of Python's, applies each
message to the state as it emits it, holds createSurface and
deleteSurface until their object closes, and keeps no surface state of
its own. close emits held-back text, throws if a block is still open,
and checks the tree of an unwrapped stream. Validation always runs.
- DirectJsonParser.unwrap reads tags as Python's does: a response without
tags is text, and an unterminated block is a part that is not final.
- A2uiRequestProcessor drops its long-lived MessageProcessor. It reuses
one parser, adds createState and an optional bound state (also through
A2uiGenerator.createProcessor) that must hold the active catalogs, and
adds openStream. Without any state, each call checks the response
against a new, empty one. createProcessor checks examples with
validatePayload.
The conformance runners check a compiled block on its own with
validatePayload, as Python's do, read parse_response cases and drive
streaming cases through openStream, feed and close against a new, empty
state. Every v0.9 and v1.0 case of direct_json/response_streaming.yaml
and the four Direct JSON unwrap gaps now pass, and their known-failure
entries are removed.
The eval TS runner checks a parsed response against a RendererState
instead of a second strict MessageProcessor, and the Node sample streams
each reply through processor.openStream.
BREAKING CHANGE: Parser.parseChunk, Parser.parseStream and the
DirectJsonStreamProcessor types are removed (use openStream, feed and
close); parseResponse takes an options object; Parser.compile no longer
validates; Parser subclasses implement catalogs; parseResponse without a
state rejects an update to a surface the response does not create;
DirectJsonFormat and DirectJsonFormatFactory take {progressiveKeys}.
Part of #3262
Part of #3263
Signed-off-by: Nan Yu <nanyu@google.com>
…l its updates; check the version first - validatePayload assumed a surface that the payload updates without creating on the first catalog that accepted the first update. The placeholder is now probed against every update to the surface, up to the next message that creates or deletes it, as in Python. - A payload state no longer assumes a surface that an earlier message of the payload deleted: a renderer rejects updates to it. - checkMessage checks the stated version before the envelope schema. The schemas require their own version, so the version messages were unreachable; a wrong version now reports what the catalogs accept.
A chunk could apply messages, for example by closing a block, and then fail later in the same chunk. feed threw without returning their parts, but the state kept them. feed now runs in a transaction, so the state holds only what the stream emitted, as in Python.
…s in linear time 1. cleanStart strips an opening fence only when the block starts with three backticks, as clean does, and returns nothing while only the start of a fence has arrived. This matches the Python stream. 2. possibleTagStart and scanBlock match the start of a tag at its position with sticky regexes instead of copying the rest of the text for every '<'. 3. The JSON reader compares a partial keyword against at most as many characters as the keyword has. Signed-off-by: Nan Yu <nanyu@google.com>
A chunk could apply messages, for example by closing a block, and then fail later in the same chunk. feed threw without returning their parts, but the state kept them. feed now runs in a transaction, so the state holds only what the stream emitted, as in Python.
Match RendererState.apply, which already takes Iterable<AgentToRendererMessage>. Signed-off-by: Nan Yu <nanyu@google.com>
…ePayload Match the Python and Dart SDKs, where a payload may be any iterable of messages such as a generator. Signed-off-by: Nan Yu <nanyu@google.com>
…osed direct JSON blocks
…esponse-stream' into feat/agent-cache-processor-work
…oss A2uiGenerator and A2uiRequestProcessor
There was a problem hiding this comment.
Code Review
This pull request introduces RendererState and ResponseStream to separate translation from validation and support stateful conversation tracking for canvas agents. It also implements caching of prompt snippets, parsers, and example validation to optimize performance. The review feedback highlights several critical improvements: in Python, the _ProcessorCacheEntry should retain a reference to the factory to prevent garbage collection and subsequent memory address reuse issues; in TypeScript, the stream's error handling should rethrow unexpected runtime errors instead of swallowing them, and structuredClone calls on optional metadata fields should be defensively guarded to prevent crashes when they are undefined.
| catalogs=active_catalogs, | ||
| examples=self.examples, | ||
| ) | ||
| cache_entry = _ProcessorCacheEntry(fmt) |
There was a problem hiding this comment.
Pass factory to _ProcessorCacheEntry so that the cache entry retains a reference to it, preventing the transient factory from being garbage-collected and its memory address (id()) from being reused.
| cache_entry = _ProcessorCacheEntry(fmt) | |
| cache_entry = _ProcessorCacheEntry(fmt, factory) |
| class _ProcessorCacheEntry: | ||
| """Shared per-(catalogs, format) resources cached by A2uiGenerator.""" | ||
|
|
||
| __slots__ = ("format", "parser", "prompt_snippet") | ||
|
|
||
| def __init__(self, fmt: InferenceFormat) -> None: | ||
| self.format = fmt | ||
| self.parser: Parser | None = None | ||
| self.prompt_snippet: str | None = None |
There was a problem hiding this comment.
Update _ProcessorCacheEntry to accept and store factory in its slots to prevent the factory from being garbage-collected and its memory address (id()) from being reused.
| class _ProcessorCacheEntry: | |
| """Shared per-(catalogs, format) resources cached by A2uiGenerator.""" | |
| __slots__ = ("format", "parser", "prompt_snippet") | |
| def __init__(self, fmt: InferenceFormat) -> None: | |
| self.format = fmt | |
| self.parser: Parser | None = None | |
| self.prompt_snippet: str | None = None | |
| class _ProcessorCacheEntry: | |
| """Shared per-(catalogs, format) resources cached by A2uiGenerator.""" | |
| __slots__ = ("format", "parser", "prompt_snippet", "factory") | |
| def __init__(self, fmt: InferenceFormat, factory: InferenceFormatFactory) -> None: | |
| self.format = fmt | |
| self.factory = factory | |
| self.parser: Parser | None = None | |
| self.prompt_snippet: str | None = None |
| try { | ||
| ready.push(...this.apply(index, envelope)); | ||
| } catch (e) { | ||
| if (e instanceof A2uiError && this.raisesWhileArriving(envelope)) throw e; | ||
| break; | ||
| } |
There was a problem hiding this comment.
The catch (e) block currently catches all errors and breaks the loop silently if the error is not an A2uiError or if raisesWhileArriving is false. This can silently swallow unexpected runtime errors (such as TypeError or ReferenceError), making debugging extremely difficult.
Only A2uiError instances should be caught and handled for flow control; any other unexpected errors should be rethrown immediately.
| try { | |
| ready.push(...this.apply(index, envelope)); | |
| } catch (e) { | |
| if (e instanceof A2uiError && this.raisesWhileArriving(envelope)) throw e; | |
| break; | |
| } | |
| try { | |
| ready.push(...this.apply(index, envelope)); | |
| } catch (e) { | |
| if (!(e instanceof A2uiError)) throw e; | |
| if (this.raisesWhileArriving(envelope)) throw e; | |
| break; | |
| } |
| return [surfaceId, (CREATE_ACTIONS as readonly string[]).includes(action)]; | ||
| } |
There was a problem hiding this comment.
Calling structuredClone(surface.metadata) directly is unsafe because surface.metadata is optional and can be undefined. In some JavaScript environments, passing undefined to structuredClone throws a TypeError or DOMException.
Guard the clone defensively by checking if surface.metadata is defined first.
surface.metadata ? structuredClone(surface.metadata) : undefined,| * that creates or deletes the surface. | ||
| */ | ||
| function surfaceUpdates(surfaceId: string, payload: Message[], index: number): Message[] { | ||
| const updates: Message[] = []; |
There was a problem hiding this comment.
Calling structuredClone(component.metadata) directly is unsafe because component.metadata is optional and can be undefined. In some JavaScript environments, passing undefined to structuredClone throws a TypeError or DOMException.
Guard the clone defensively by checking if component.metadata is defined first.
component.metadata ? structuredClone(component.metadata) : undefined,
Summary
Eliminates redundant per-request validation, schema pruning/decompilation, and parser instantiation across Python (
python/a2ui_agent), Dart (dart/a2ui_agent), and TypeScript (typescript/agents/a2ui_agent):prompt_snippet/promptSnippetonA2uiRequestProcessor:processor.prompt_snippet/processor.promptSnippetmultiple times on the same processor instance now returns the cached prompt snippet rather than rebuilding and re-decompiling schemas and examples on every read.A2uiGenerator.create_processor/createProcessorcalls:A2uiGeneratorcaches a shared processor cache entry per negotiated registered catalog set ((catalog_id, protocol_version)pairs) andformat_factory/formatFactory.examples,InferenceFormat, statelessParser, andprompt_snippet/promptSnippetare validated and built once per negotiated catalog combination and reused across requests, while keeping per-requestRendererStateisolated on eachA2uiRequestProcessor.Test Plan
pytest python/a2ui_agent/tests/processor/test_request_processor.py(31 passed),mypy(0 issues)dart testindart/a2ui_agent(58 passed),dart analyze(No issues found!)npx vitest run tests/unit/processor/generator.test.ts tests/unit/processor/processor.test.ts(15 passed),yarn lint,yarn build