Skip to content

feat(samples): multi-catalog v1.0 agent and Angular client - #3287

Draft
nan-yu wants to merge 8 commits into
refactor/samples-core-per-request-processorsfrom
feat/samples-multi-catalog
Draft

nan-yu wants to merge 8 commits into
refactor/samples-core-per-request-processorsfrom
feat/samples-multi-catalog

Conversation

@nan-yu

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

Copy link
Copy Markdown
Collaborator

Summary

This PR adds a sample that runs the Python agent SDK end to end with two catalogs mixed on one surface, using A2UI v1.0, in both streaming and non-streaming mode. In v0.9 each surface is bound to one catalog. v1.0 lets a component name its own catalogId, so one surface can mix catalogs.

  1. An ADK agent in samples/agent/adk/multi_catalog. It answers with one dashboard surface. The basic catalog is the surface's default catalog, for the layout, the text and a Refresh button. Each StatCard and ProgressBar names the sample's dashboard catalog in its own catalogId.
  2. An Angular client in samples/client/angular/projects/multi_catalog. It registers the v1.0 basic catalog and the dashboard catalog with provideA2UI from @a2ui/angular.

Part of #3262 and #3263. The PR is stacked on #3272.

How the agent uses the SDK

  1. One A2uiGenerator holds CatalogConfig(BasicCatalog(V1_0)), CatalogConfig.from_path("catalogs/1.0/dashboard_catalog.json"), and the example turns in examples/1.0/.
  2. Each request gets a processor from generator.create_processor(capabilities). The capabilities are the keyed a2uiRendererCapabilities in the message metadata, the key the v1.0 A2A extension defines. The system prompt is processor.prompt_snippet plus the sample's rules.
  3. The client sets useStreaming in the message metadata.
    • Streaming: stream_response_to_parts(processor.open_stream(), tokens, version=...) validates each message before it is sent. Each component is checked against the catalog it resolves to.
    • Non-streaming: to_a2a_parts(processor.parse_response(text)) sends the whole response in the final message.
  4. The Refresh button sends a refresh_dashboard action as a {"version": "v1.0", "action": ...} data part, and the agent shows the dashboard again.

Other things the sample handles:

  1. In SSE mode, ADK sends the text in partial events and then the whole text again in a final event. The agent passes on only the partial text, so the parser sees each character once.
  2. The v1.0 schemas in the prompt contain {XID_Start}, which ADK reads as a session state variable in a string instruction. The agent passes the instruction as a callable, which ADK uses as is.
  3. A model error, such as a 503 under load, ends the task with a message instead of dropping the connection.

Client notes

  1. The v1.0 renderer draws the basic catalog with universal web components. It wraps the dashboard's Angular components as custom elements and mounts them inside the basic catalog's shadow roots, so they use ViewEncapsulation.ShadowDom to keep their styles.
  2. The app keeps the surfaces in a signal fed by onSurfaceCreated and onSurfaceDeleted, so a surface renders while the rest of the response streams.
  3. The agent, the client's server and the browser console log each A2UI message as JSON.

Verification

With gemini-3.6-flash:

  1. uv run check_e2e.py sends a question and a Refresh action over A2A in each mode. For each response it checks that there is one surface, that its components resolve to both catalogs, and that validate_payload accepts the messages against both catalogs. All checks passed for Europe and North America, for example:

    Mode Request A2UI messages Events with UI
    Non-streaming Question 3 1
    Non-streaming Action 3 1
    Streaming Question 15 15
    Streaming Action 17 17

    In each response, the basic catalog held Button, Column, Row and Text, and the dashboard catalog held ProgressBar and StatCard.

  2. In headless Chromium, the built client (ENABLE_STREAMING=true and false) rendered the dashboard for the question and again after clicking Refresh, with no browser errors. The dashboard components render as a2ui-ng-statcard and a2ui-ng-progressbar inside the basic catalog's a2ui-basic-row.

  3. ng build multi_catalog, eslint, prettier --check, pyink --check, and pytest samples/agent/adk/tests pass.

Notes

  1. The examples use both catalogs, so create_processor fails for a client that supports only one of them. The agent then answers with a text message.
  2. The SDK's generic prompt doesn't say that a component from a non-default catalog must set catalogId. The dashboard catalog's instructions and the sample's UI description say so, and the examples show it.

@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 a new multi-catalog sales dashboard sample, featuring a Python ADK agent and an Angular client that render UI from two catalogs simultaneously. The review feedback focuses on enhancing the robustness of the implementation through defensive programming. Key suggestions include handling alternative line endings in stream parsing, using optional chaining to guard against missing properties in TypeScript, validating input types before executing string or dictionary operations, and resolving a variable shadowing issue to improve code maintainability.

Comment on lines +98 to +99
const events = buffer.split('\n\n');
buffer = events.pop() ?? '';

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 stream parser splits the buffer using buffer.split('\\n\\n'). If a network proxy, load balancer, or server middleware normalizes the line endings to \\r\\n\\r\\n, the literal \\n\\n sequence will not be present, causing the split to fail and the stream parsing to break completely. Using a regular expression split like buffer.split(/\\r?\\n\\r?\\n/) ensures robust handling of both \\n\\n and \\r\\n\\r\\n line endings across different network environments.

Suggested change
const events = buffer.split('\n\n');
buffer = events.pop() ?? '';
const events = buffer.split(/\r?\n\r?\n/);
buffer = events.pop() ?? '';

if (event.kind !== 'task' && event.kind !== 'status-update') {
continue;
}
const parts = event.status.message?.parts ?? [];

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

Defensively guard against cases where event.status might be undefined or null. Using optional chaining event.status?.message prevents potential runtime TypeError crashes if the status is missing.

Suggested change
const parts = event.status.message?.parts ?? [];
const parts = event.status?.message?.parts ?? [];
References
  1. In TypeScript, when handling partial objects that may not fully implement an interface, defensively default their properties (e.g., using nullish coalescing or logical OR) to prevent runtime TypeErrors.

return;
}
const result = (response as SendMessageSuccessResponse).result as Task;
const parts = result.kind === 'task' ? (result.status.message?.parts ?? []) : [];

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

Defensively guard against cases where result.status might be undefined or null. Using optional chaining result.status?.message prevents potential runtime TypeError crashes if the status is missing.

Suggested change
const parts = result.kind === 'task' ? (result.status.message?.parts ?? []) : [];
const parts = result.kind === 'task' ? (result.status?.message?.parts ?? []) : [];
References
  1. In TypeScript, when handling partial objects that may not fully implement an interface, defensively default their properties (e.g., using nullish coalescing or logical OR) to prevent runtime TypeErrors.

Comment on lines +53 to +55
if not isinstance(part.root, DataPart):
continue
action = part.root.data.get("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

Defensively verify that part.root.data is a dictionary before calling .get(). If the client sends a non-dictionary data part (or null), accessing .get() directly on part.root.data will raise an AttributeError.

Suggested change
if not isinstance(part.root, DataPart):
continue
action = part.root.data.get("action")
if not isinstance(part.root, DataPart) or not isinstance(part.root.data, dict):
continue
action = part.root.data.get("action")
References
  1. When validation checks or function arguments can be represented as either a dictionary (named) or a list (positional), always verify the type (e.g., using isinstance(args, dict)) before performing key-based lookups or assignments to prevent TypeError crashes.

regions if the region is unknown.
"""
logger.info(f"--- TOOL CALLED: get_sales_metrics (region: {region}) ---")
key = region.strip().lower()

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

Defensively check if region is a string before calling .strip(). Since LLMs can sometimes pass unexpected types or None to tools, calling .strip() directly on a non-string value will raise an AttributeError and crash the tool execution.

    if not isinstance(region, str):
        return json.dumps({
            "error": f"Invalid region type: {type(region).__name__}",
            "knownRegions": [m["region"] for m in _SALES_METRICS.values()],
        })
    key = region.strip().lower()

Comment on lines +120 to +121
const data = part.data as A2uiMessage | A2uiMessage[];
messages.push(...(Array.isArray(data) ? data : [data]));

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 variable name data shadows the outer parameter data of the handleData function. This can lead to developer confusion and is often flagged by linters. Renaming the inner variable to something more descriptive, like payload or uiMessages, improves maintainability.

Suggested change
const data = part.data as A2uiMessage | A2uiMessage[];
messages.push(...(Array.isArray(data) ? data : [data]));
const payload = part.data as A2uiMessage | A2uiMessage[];
messages.push(...(Array.isArray(payload) ? payload : [payload]));

@nan-yu
nan-yu marked this pull request as draft October 16, 2026 21:42
@nan-yu nan-yu changed the title feat(samples): multi-catalog agent and Angular client feat(samples): multi-catalog v1.0 agent and Angular client Oct 16, 2026
@nan-yu
nan-yu force-pushed the feat/samples-multi-catalog branch from f3643dc to 4edf47b Compare October 16, 2026 22:57
@nan-yu
nan-yu force-pushed the refactor/samples-core-per-request-processors branch from 101677d to 03b3802 Compare October 16, 2026 22:57
The agent is built with the Python agent SDK and answers with two
surfaces: one on a custom dashboard catalog and one on the basic
catalog. It negotiates a processor per request from the client's
keyed capabilities, and validates the response with open_stream when
streaming and parse_response otherwise.

The Angular client registers both catalogs, sends its capabilities
and a useStreaming flag, and uses message/stream or message/send
depending on ENABLE_STREAMING. check_e2e.py checks a running agent
over A2A in both modes.
…s; log each message

The app iterated the surface group's map, which is not a signal, so
with zoneless change detection new surfaces only rendered when
isLoading changed at the end of the stream. The client now keeps the
surfaces in a signal fed by onSurfaceCreated and onSurfaceDeleted.

The agent, the client's server and the browser console now log each
streamed A2UI message with its time.
The agent, the client's server and the browser console now log each
A2UI message as JSON, in both streaming and non-streaming mode, so
the catalog of each surface can be checked in the logs.
…I v1.0

v0.9 binds each surface to one catalog. v1.0 lets a component name its
own catalogId, so the agent now speaks v1.0 and answers with one
dashboard surface: the basic catalog is its default catalog, for the
layout, the text and the Refresh button, and each StatCard and
ProgressBar names the dashboard catalog.

- The dashboard catalog moves to catalogs/1.0 in the v1.0 catalog
  format, without MetricGrid; basic Rows lay out its components.
- The examples move to examples/1.0, with @path bindings and UAX #31
  component ids.
- The executor reads a2uiRendererCapabilities, the v1.0 metadata key.
- The instruction is passed to ADK as a callable, since the v1.0
  schemas contain {XID_Start}, which ADK read as a state variable.
- A model error ends the task with a message instead of dropping the
  connection.
- check_e2e.py checks that one surface resolves components to both
  catalogs.
- The Angular client uses provideA2UI and the v1.0 BasicCatalog from
  @a2ui/angular, sends a2uiRendererCapabilities with the v1.0
  extension, and its dashboard components use ShadowDom encapsulation,
  since the renderer mounts them inside the basic catalog's shadow
  roots.
@nan-yu
nan-yu force-pushed the refactor/samples-core-per-request-processors branch from 03b3802 to f50fe7e Compare October 16, 2026 23:07
@nan-yu
nan-yu force-pushed the feat/samples-multi-catalog branch from 4edf47b to 2a74ee1 Compare October 16, 2026 23:07
…/samples-multi-catalog

Signed-off-by: Nan Yu <nanyu@google.com>
…/samples-multi-catalog

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