Skip to content

perf(agent): cache example validation, prompt snippet, and parser in A2uiGenerator and A2uiRequestProcessor - #3300

Open
nan-yu wants to merge 19 commits into
feat/python-agent-processor-statefrom
feat/agent-cache-processor-work
Open

nan-yu wants to merge 19 commits into
feat/python-agent-processor-statefrom
feat/agent-cache-processor-work

Conversation

@nan-yu

@nan-yu nan-yu commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator

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):

  1. Cache prompt_snippet / promptSnippet on A2uiRequestProcessor:
    • Accessing processor.prompt_snippet / processor.promptSnippet multiple times on the same processor instance now returns the cached prompt snippet rather than rebuilding and re-decompiling schemas and examples on every read.
  2. Share cached resources across A2uiGenerator.create_processor / createProcessor calls:
    • A2uiGenerator caches a shared processor cache entry per negotiated registered catalog set ((catalog_id, protocol_version) pairs) and format_factory / formatFactory.
    • Static few-shot examples, InferenceFormat, stateless Parser, and prompt_snippet / promptSnippet are validated and built once per negotiated catalog combination and reused across requests, while keeping per-request RendererState isolated on each A2uiRequestProcessor.
    • Requests that include per-request inline catalogs bypass the generator cache so transient schemas are not retained.

Note: This branch merges the stacked RendererState / ResponseStream branches across Python (#3270), Dart (#3277), and TypeScript (#3279) so all three language SDKs are updated in lockstep in a single PR.

Test Plan

  • Python: pytest python/a2ui_agent/tests/processor/test_request_processor.py (31 passed), mypy (0 issues)
  • Dart: dart test in dart/a2ui_agent (58 passed), dart analyze (No issues found!)
  • TypeScript: npx vitest run tests/unit/processor/generator.test.ts tests/unit/processor/processor.test.ts (15 passed), yarn lint, yarn build

nan-yu added 19 commits October 16, 2026 23:03
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>
…esponse-stream' into feat/agent-cache-processor-work

@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 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)

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.

high

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.

Suggested change
cache_entry = _ProcessorCacheEntry(fmt)
cache_entry = _ProcessorCacheEntry(fmt, factory)

Comment on lines +34 to +42
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

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.

high

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.

Suggested change
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

Comment on lines +194 to +199
try {
ready.push(...this.apply(index, envelope));
} catch (e) {
if (e instanceof A2uiError && this.raisesWhileArriving(envelope)) throw e;
break;
}

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.

high

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.

Suggested change
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;
}

Comment on lines +516 to +517
return [surfaceId, (CREATE_ACTIONS as readonly string[]).includes(action)];
}

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

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[] = [];

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

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,

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.

1 participant