Skip to content

refactor(samples): per-request processors in restaurant_finder and custom-components-example - #3272

Open
nan-yu wants to merge 17 commits into
docs/python-agent-renderer-statefrom
refactor/samples-core-per-request-processors
Open

nan-yu wants to merge 17 commits into
docs/python-agent-renderer-statefrom
refactor/samples-core-per-request-processors

Conversation

@nan-yu

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

Copy link
Copy Markdown
Collaborator

Stacked on #3271. Needs #3266, since these agents no longer reshape the client's capabilities.

restaurant_finder and custom-components-example kept a default processor, cached one stateful parser per session, and reshaped the client's capabilities before negotiating catalogs. They now follow the per-request model from #3269 and #3270, and speak A2UI v1.0 as well as v0.8 and v0.9.

  1. Each request gets its own A2uiRequestProcessor, created by the generator for its protocol version from the capabilities the client sent, unchanged. A client that activates the A2UI extension but sends no capabilities, or none the agent can use, gets a text answer from the text runner.
  2. The system prompt is built from that processor. restaurant_finder gives its examples to the generator (agent.py), so the prompt snippet lists them and creating the processor checks them against the request's catalogs.
  3. Both agents read useStreaming from the request. Responses are read with processor.open_stream() when streaming and processor.parse_response() otherwise, and turned into A2A parts with stream_response_to_parts and to_a2a_parts. When streaming, the parts go out in working status updates and the final message has none; otherwise the final message holds them. On a validation error, or in restaurant_finder a response without A2UI messages, the agent asks the model to try again.
  4. custom-components-example keeps a canvas RendererState per session (agent.py), since its turns update the surfaces earlier turns created. It starts a new state when processor.accepts_state(state) says the client's catalogs changed.
  5. Each README gains a "How it works" section, and the unused jsonschema dependency is dropped (with the matching uv.lock entries).

Surfaces that the client already holds

From v0.9 on, creating a surface the renderer already holds is an integrity error, and both samples use fixed surface ids.

  1. custom-components-example lists the surfaces in the session's state in the system prompt and in the retry message (prompt_builder.py), and tells the model to update them, or to send deleteSurface before it creates one again. Without this, a second lookup or a second "send message" in one conversation failed on both attempts. The action UIs delete their surface before they create it again, and closing the floor plan sends nothing when it isn't open.
  2. restaurant_finder checks each response on its own, and remembers which surfaces each conversation's client holds. It sends deleteSurface before a response creates one of them again (a second search), and before a retry it deletes the surfaces that the failed streamed attempt created. Text that a failed streamed attempt already sent can't be taken back.

v1.0 support

  1. Both agents add a v1.0 generator on the v1.0 basic catalog and advertise the v1.0 extension in their agent card. For v1.0 the executors read the renderer capabilities from a2uiRendererCapabilities (A2UI_RENDERER_CAPABILITIES_KEY in a2ui.constants, added in docs(python/agent): document per-request processors, RendererState and streaming #3271), as the v1.0 A2A extension defines; v0.8 and v0.9 keep a2uiClientCapabilities. Both accept action events sent as v0.9 or v1.0, as a single message, a list of messages, or the {"messages": [...]} wrapper; custom-components-example previously handled only v0.8 userAction. The agents still send each outbound A2UI message in its own data part, as the SDK's to_a2a_parts does.
  2. Every v0.9 example has a v1.0 copy: restaurant_finder/examples/1.0 and custom-components-example/examples/1.0. They write "version": "v1.0", bind data with {"@path": ...}, and drop theme. Two differences follow from v1.0: the v1.0 basic Text has no h1 to h5 variants, so headings are Markdown (## ...), and component ids use _ instead of -, since the SDK requires v1.0 ids to be UAX Initial commit for a2ui_restaurant_finder and a generic a2UI extension. #31 identifiers.
  3. custom-components-example mixes catalogs on its v1.0 surfaces. Its v1.0 inline catalog, inline_catalog_1.0.json, defines only OrgChart and WebFrame, under its own catalog id (also its $id), rather than repeating the basic catalog as the v0.9 one must. The v1.0 surfaces use the basic catalog as their default, and each OrgChart and WebFrame names the custom catalog in its catalogId. examples/1.0/floor_plan.json shows it: a basic Card, Column, Text and Button around a custom WebFrame. The floor plan UI that a2ui_examples.py builds for the view_location action does the same; the send-message UI (examples/1.0/action_confirmation.json) uses only the basic catalog.
  4. Running on v1.0 exposed two bugs, fixed in both agents:
    1. ADK read {XID_Start} in the v1.0 prompt as a session state variable and failed every request with KeyError. The instruction is now passed as a function, which skips state injection, and restaurant_finder's "expression": "{expression}" workaround is gone.
    2. The token stream also yielded ADK's final aggregated event, so the parser read the whole response twice. v1.0 rejected the second createSurface, so the first attempt always failed. The aggregated event is now skipped, which also fixes doubled text in restaurant_finder's text-only answers.
  5. Both READMEs explain how to run with a v1.0 client: activate .../a2ui/v1.0 in X-A2A-Extensions and send a2uiRendererCapabilities in the message metadata.

Testing

  1. uv run pytest samples/agent/adk/tests, which validates the v1.0 examples too. The new test_sample_agents.py replays the examples through each agent with a fake ADK runner and mirrors the renderer's surfaces: two lookups and two send_message actions in one custom-components session, a retry after a partly streamed failure, and a restaurant_finder streaming retry and repeat search, for v0.9 and v1.0, checking that no createSurface reaches a surface the client holds. It also checks the action shapes the executors accept.
  2. uv run pytest python/a2ui_agent/tests.
  3. A script that creates a processor for v0.8, v0.9 and v1.0 in each sample, builds the prompt snippet, and parses every v1.0 example with parse_response (custom-components-example's later-turn example against the state its earlier turn left).
  4. A real v1.0 request to each agent with gemini-3.6-flash: every returned part stated v1.0 and passed validate_payload. This was before the surface handling above, which was tested only with the fake runner.
  5. pyink --check and ruff check on the changed files.

@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 custom components and restaurant finder agent samples to utilize A2uiGenerator and A2uiRequestProcessor for negotiating client capabilities, validating streamed responses, and managing renderer states. It also removes the direct jsonschema dependency. The review feedback identifies several critical issues: missing imports for InMemoryArtifactService in both agents' code, a potential NameError in the custom components executor due to an uninitialized client_ui_capabilities variable, a memory leak risk from uncleaned self._renderer_states sessions, and a type annotation mismatch for self._generators.

Comment thread samples/agent/adk/custom-components-example/agent.py
Comment thread samples/agent/adk/restaurant_finder/agent.py
Comment thread samples/agent/adk/custom-components-example/agent_executor.py Outdated
Comment thread samples/agent/adk/custom-components-example/agent.py
Comment thread samples/agent/adk/restaurant_finder/agent.py Outdated
@nan-yu
nan-yu added this pull request to stack #3275 October 16, 2026 19:09
@nan-yu
nan-yu force-pushed the refactor/samples-core-per-request-processors branch from 30a80d5 to f120575 Compare October 16, 2026 19:34
@nan-yu
nan-yu force-pushed the refactor/samples-core-per-request-processors branch from f120575 to bd5ef66 Compare October 16, 2026 19:51
@nan-yu
nan-yu force-pushed the refactor/samples-core-per-request-processors branch from bd5ef66 to f5dc548 Compare October 16, 2026 19:53
@nan-yu
nan-yu force-pushed the refactor/samples-core-per-request-processors branch 3 times, most recently from 00b77d5 to 101677d Compare October 16, 2026 20:23
@nan-yu
nan-yu force-pushed the refactor/samples-core-per-request-processors branch from 101677d to 03b3802 Compare October 16, 2026 22:57
…stom-components-example

The samples kept a default processor, cached one stateful parser per
session, and reshaped the client's capabilities before negotiating
catalogs. They now follow the SDK's per-request model.

1. Each request gets its own `A2uiRequestProcessor`, created by the
   generator for its protocol version from the `a2uiClientCapabilities`
   the client sent, unchanged. A client that sends none gets a text
   answer.
2. The system prompt is built from that processor. restaurant_finder
   gives its examples to the generator, so the prompt snippet lists them
   and creating the processor checks them against the request's catalogs.
3. Responses are read with `processor.open_stream()` when streaming and
   `processor.parse_response()` otherwise, and turned into A2A parts with
   `stream_response_to_parts` and `to_a2a_parts`. On a validation error
   the agent asks the model to try again.
4. custom-components-example keeps a canvas `RendererState` per session,
   since its turns update the surfaces earlier turns created.
5. Each README gains a "How it works" section, and the unused
   `jsonschema` dependency is dropped.

Requires the sample clients to send keyed capabilities.

Signed-off-by: Nan Yu <nanyu@google.com>
…urant_finder's generators

1. custom-components-example keeps the renderer states of at most 1000
   conversations, dropping the least recently used, as the samples did
   for their parsers before.
2. restaurant_finder's generators are keyed by ProtocolVersion in their
   annotation, as they are in the code.

Signed-off-by: Nan Yu <nanyu@google.com>
…' into refactor/samples-core-per-request-processors

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

The processor now tells whether a kept renderer state fits its catalogs,
so the sample drops its own _catalog_keys copy of that check.

Signed-off-by: Nan Yu <nanyu@google.com>
…' into refactor/samples-core-per-request-processors

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

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

Adds a v1.0 generator (v1.0 basic catalog) to both ADK samples, with
examples/1.0 converted from examples/0.9 and an inline_catalog_1.0.json
for the custom components. The executors read a2uiRendererCapabilities
for v1.0 and accept {"version": "v1.0", "action": ...} events.

The runners now pass the instruction through a function so ADK does not
treat {XID_Start} in the v1.0 schemas as a state variable, and the token
streams skip ADK's final aggregated SSE event, which repeated the whole
response and made v1.0 reject the second createSurface.

Signed-off-by: Nan Yu <nanyu@google.com>
…custom-components-example

v1.0 lets one surface use several catalogs, so the v1.0 inline catalog no
longer repeats the basic catalog. inline_catalog_1.0.json now defines only
OrgChart and WebFrame, under its own catalog id. The v1.0 examples and the
floor plan and send-message UIs use the basic catalog as the surface's
default, and each OrgChart and WebFrame names the custom catalog in its
catalogId. The floor plan shows the mix: a basic Card, Column, Text and
Button around a custom WebFrame.

Signed-off-by: Nan Yu <nanyu@google.com>
The v1.0 A2A extension sends the renderer capabilities under
a2uiRendererCapabilities. Both ADK samples defined the key themselves.

Signed-off-by: Nan Yu <nanyu@google.com>
…t's surfaces

The agent keeps a RendererState per conversation, so a second lookup or a
second send_message re-created a surface the client already showed, and
both attempts were rejected.

- The system prompt and the retry message list the surfaces in the state,
  and tell the model to update them or delete one before re-creating it.
- The action UIs delete their surface before re-creating it, and closing
  the floor plan sends nothing when it isn't open.
- A client without usable capabilities gets a text answer, not an apology.
- useStreaming is read as in restaurant_finder; without streaming the
  response is parsed whole and sent in the final message.
- The executor finds v1.0 actions sent as a list or {"messages": [...]},
  and uses A2UI_RENDERER_CAPABILITIES_KEY. stream() takes the same
  parameters as restaurant_finder's.
- a2ui_examples.py derives the catalog ids, is typed with ProtocolVersion,
  and drops the fallback UIs for the tracked action_confirmation.json.
- inline_catalog_1.0.json gets a $id equal to its catalogId.

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

A streamed attempt that failed left its surfaces on the client, and the
retry sent createSurface for them again, as did a second search in the
same conversation. v1.0 renderers reject both.

- The agent remembers each conversation's surfaces and sends deleteSurface
  before a response creates one of them again (v0.9 and later).
- Before a retry it deletes the surfaces the failed attempt created.
- A response with no A2UI messages is retried again, as before the
  per-request refactor.
- A client without usable capabilities gets a text answer.
- The executor finds v1.0 actions sent as a list or {"messages": [...]},
  and uses A2UI_RENDERER_CAPABILITIES_KEY.

Signed-off-by: Nan Yu <nanyu@google.com>
Two lookups and two send_message actions in one custom-components
session, a retry after a partly streamed failure, and a restaurant_finder
streaming retry and repeat search, for v0.9 and v1.0, against a mirror of
the renderer's surfaces. Also uses to_version_number for the examples
paths.

Signed-off-by: Nan Yu <nanyu@google.com>
nan-yu added a commit that referenced this pull request Oct 19, 2026
The v1.0 A2A extension sends the renderer capabilities under
a2uiRendererCapabilities; v0.8 and v0.9 use a2uiClientCapabilities. The
sample PRs stacked on this one (#3272, #3274) each added the constant, so
it lands here once.

Signed-off-by: Nan Yu <nanyu@google.com>
…' into refactor/samples-core-per-request-processors

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

Signed-off-by: Nan Yu <nanyu@google.com>
…-core-per-request-processors

Signed-off-by: Nan Yu <nanyu@google.com>
@nan-yu
nan-yu removed this pull request from stack #3275 October 20, 2026 17:42
@nan-yu
nan-yu added this pull request to stack #3340 October 20, 2026 17:47
…-core-per-request-processors

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.

1 participant