Skip to content

feat(python-a2a,adk): route A2UI RPC messages - #3795

Open
nan-yu wants to merge 8 commits into
test/conformance-agent-function-rpcfrom
feat/python-a2a-adk-agent-function-rpc
Open

nan-yu wants to merge 8 commits into
test/conformance-agent-function-rpcfrom
feat/python-a2a-adk-agent-function-rpc

Conversation

@nan-yu

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

Copy link
Copy Markdown
Collaborator

Stacked on #3794. Review only commits 6809d4f (feat(python-a2a,adk): route A2UI RPC messages) and de70693 (feat(python-adk): complete RPC-only tasks and keep RPC out of prompts); the other new commits merge the branches below them.
Fixes #3535. Part of #3536.

Description

#3793 added a2ui.rpc (AgentFunctionRegistry, RendererFunctionCalls, RendererMessageRouter) to the framework-agnostic agent SDK. This PR connects it to A2A and ADK, so an agent behind ADK's stock A2aAgentExecutor answers callAgentFunction and settles rendererFunctionResponse without a model turn.

  1. A2A part helpers (parts.py):

    1. get_a2ui_messages(message_or_parts) returns the A2UI messages in the A2UI DataParts of an incoming A2A message, skipping other parts. It uses the existing get_a2ui_datapart.
    2. create_a2ui_parts(messages, version=None) wraps messages, such as the replies of router.dispatch, as A2UI parts with create_a2ui_part. Models are serialized with model_dump(by_alias=True, exclude_unset=True), so an agentFunctionResponse keeps "value": null (and, since the last fix on feat(python-agent): answer agent functions and call renderer functions #3793, a callRendererFunction without a surface has no surfaceId).
  2. ADK callback (rpc_callback.py). a2ui_rpc_callback(agent_functions=..., renderer_calls=...) returns a before_agent_callback:

    1. It converts each part of the invocation's user content back to an A2A part with ADK's convert_genai_part_to_a2a_part. A2aAgentExecutor turns an A2UI DataPart into text/plain inline data holding the whole part, and this is the inverse, so the callback doesn't depend on ADK's tag format.
    2. If every part is an A2UI message that the router handles (callAgentFunction with a registry, rendererFunctionResponse with renderer calls), it dispatches them concurrently and returns the replies as a Content. Each reply goes through create_a2ui_parts and ADK's convert_a2a_part_to_genai_part, so A2uiEventConverter/A2uiPartConverter (or ADK's default converter) send it back as an A2UI DataPart. ADK then ends the invocation, so the agent doesn't run.
    3. For anything else (text, an action, a mix of RPC messages and other parts, or no registry for a call) it returns None and the agent runs.
    4. agent_functions is a registry or a sync/async provider called with the context, so a provider can build the registry from the session's catalogs. renderer_calls is a provider of the session's RendererFunctionCalls. Providers are only called when the content needs them.
    5. A callAgentFunction without a functionCallId, or one that reaches a disposed registry, is logged and dropped instead of failing the request.
    6. To keep an existing callback, pass a list (before_agent_callback=[a2ui_rpc_callback(...), mine]); ADK runs them in order until one returns content.

    It lives in a2ui.adk.a2a, because it needs the a2a extra, and is exported lazily from a2ui.adk like the converters.

  3. A2uiRendererCallsStore (renderer_calls_store.py) keeps one RendererFunctionCalls per session in memory, next to A2uiProcessorStore. get(ctx) creates the session's instance on first use (and replaces one disposed elsewhere), so tools and the callback share it; remove(session_id) disposes it, failing its pending calls, and dispose() disposes all. It only needs ADK.

  4. a2ui_rpc_interceptor() (rpc_interceptor.py) returns an ExecuteInterceptor for A2aAgentExecutorConfig.execute_interceptors. ADK's executor marks a task completed only when the final status message has parts, so a request holding only rendererFunctionResponse messages, which has nothing to send back, would end working. The interceptor's before_agent hook checks the request message itself (every part an A2UI callAgentFunction or rendererFunctionResponse) and notes the task ID; its after_agent hook changes that task's final status from working with no message parts to completed. Other final states (failed, input_required, completed with a result) and other tasks are untouched. It doesn't depend on the callback. A request that fails between the hooks never reaches after_agent, so the noted IDs are capped at 1024.

  5. RPC kept out of the model's prompt (rpc_prompt_filter.py). ADK stores RPC turns in the session's events, so later prompts would carry them. SendA2uiToClientToolset.process_llm_request now removes, from the contents sent to the model, every inline-data part that ADK's convert_genai_part_to_a2a_part turns into an A2UI RPC message in either direction (callAgentFunction, agentFunctionResponse, callRendererFunction, rendererFunctionResponse), and drops contents left empty. The session's events keep them, and other A2UI messages (action, createSurface, ...) and other parts are kept. callRendererFunction messages the agent sent are removed too, for consistency: the model asked for them through a tool, whose call and result already record the exchange. exclude_a2ui_rpc_from_prompt is the same filter as a before_model_callback for agents without the toolset. Both need the a2a extra, since they use ADK's A2A converter; the toolset skips the filter without it, because then no A2A parts reach the session.

  6. Docs. READMEs and CHANGELOGs of a2ui_a2a and a2ui_adk, and a short "With A2A and ADK" section in agent_development.md.

Two behaviours reviewers should know:

  1. A request holding only rendererFunctionResponse messages gets a response with no parts. With a2ui_rpc_interceptor() in the executor's interceptors, its task ends completed; without it, ADK ends it working, as for any turn whose agent sends nothing.
  2. The RPC messages and replies stay in the session's events, as ADK stores every turn, but SendA2uiToClientToolset (or exclude_a2ui_rpc_from_prompt) keeps them out of the model's prompt.

google-adk >= 1.28.1 stays: 1.28.1's convert_a2a_part_to_genai_part and convert_genai_part_to_a2a_part handle A2UI DataParts the same way as 2.3.0 (2.3.0 also copies the part metadata into part_metadata, which the round trip doesn't need), and the new tests pass against both.

No sample is updated: every Python ADK sample speaks v0.8 or v0.9, and agent functions need v1.0. A sample can use the callback once one moves to v1.0.

Tests

  1. test_rpc_parts.py: reading A2UI messages from a message or parts, skipping other parts, and wrapping models (keeping a null value) and dicts in order.
  2. test_rpc_callback.py, with ADK's real converters and no model (a stub agent answers with fixed text):
    1. A user Content built by convert_a2a_part_to_genai_part from a callAgentFunction part gets a Content whose part converts back, through ADK's converter and through A2uiPartConverter, to an A2UI DataPart holding the agentFunctionResponse. Several calls answer in order; async providers get the context.
    2. Empty, text, action, action plus call, text plus call, and a call with no registry return None. A rendererFunctionResponse without renderer calls goes to the agent.
    3. A rendererFunctionResponse settles the pending future in an A2uiRendererCallsStore.
    4. Malformed calls and a disposed registry are dropped.
    5. Through ADK's Runner: the agent doesn't run for RPC content and runs for text; in a callback list, the next callback runs only when this one returns None.
    6. Through A2aAgentExecutor with A2uiEventConverter: the reply leaves as an A2UI DataPart in the working status update and the artifact, with no agent text, and the task completes; a rendererFunctionResponse request settles the call.
  3. test_renderer_calls_store.py: one instance per session, settings passed on, invalid settings, remove and dispose failing pending calls, and replacing a disposed instance. test_lazy_a2a_exports.py covers the new lazy exports.
  4. Interceptor tests in test_rpc_callback.py, through the real A2aAgentExecutor with stub agents: an RPC-only request (rendererFunctionResponse) ends completed; a callAgentFunction request keeps its result and completed; a text request and a text-plus-RPC request whose agent sends nothing stay working; a normal turn with a reply completes; a failed RPC request stays failed.
  5. test_rpc_prompt_filter.py: the callback and the toolset remove all four RPC message kinds, drop emptied contents, and keep text, action and createSurface; requests without RPC are unchanged. End to end with Runner, an LlmAgent and a model stub that records its requests, for both the toolset and the callback: after an RPC turn and a text turn, the model's request holds no RPC parts while the session's events still hold the call and its reply (without the filter, the same test sees both in the prompt). test_lazy_a2a_exports.py checks the toolset's hook without the a2a extra.
  6. uv run pytest python/a2ui_a2a/tests python/a2ui_adk/tests python/a2ui_agent/tests samples/agent/adk/tests conformance/tests: 2120 passed, 2 xfailed. uv run mypy ., pyink and scripts/fix_licenses.py --check are clean. The new tests and test_lazy_a2a_exports.py also pass in a venv with google-adk[a2a]==1.28.1 (62 passed after the second commit).

Wire the a2ui.rpc helpers into the A2A and ADK integrations:

- a2ui.a2a: get_a2ui_messages reads the A2UI messages in an A2A message's
  A2UI DataParts, and create_a2ui_parts wraps reply messages as A2UI parts
  (models dumped with by_alias=True, exclude_unset=True).
- a2ui.adk: a2ui_rpc_callback returns a before_agent_callback that, when
  the user content holds only callAgentFunction/rendererFunctionResponse
  messages, dispatches them through a RendererMessageRouter and returns the
  replies as the agent's response, so the agent doesn't run. It reads and
  writes parts with ADK's own A2A part converters, so A2aAgentExecutor and
  A2uiEventConverter send the replies as A2UI DataParts. Any other content
  returns None. It composes with other callbacks through ADK's callback
  list. A2uiRendererCallsStore keeps a RendererFunctionCalls per session
  and disposes it on remove().

Tests run through ADK's real converters, Runner and A2aAgentExecutor with
a stub agent, and pass against google-adk 2.3.0 and the 1.28.1 floor.

Part of #3536. Part of #3535.

Signed-off-by: Nan Yu <nanyu@google.com>
…rpc' into feat/python-a2a-adk-agent-function-rpc

@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 A2UI RPC helpers to facilitate handling A2UI v1.0 RPC messages within the A2A and ADK frameworks. Key additions include get_a2ui_messages and create_a2ui_parts for message processing, a2ui_rpc_callback to enable agents to answer RPC messages without a full model turn, and A2uiRendererCallsStore to manage session-specific renderer function calls. Comprehensive tests were added to verify these new functionalities. No review comments were provided for this pull request.

…rpc' into feat/python-a2a-adk-agent-function-rpc
…rpc' into feat/python-a2a-adk-agent-function-rpc
@nan-yu
nan-yu added this pull request to stack #3751 October 30, 2026 22:00
…rpc' into feat/python-a2a-adk-agent-function-rpc
…rpc' into feat/python-a2a-adk-agent-function-rpc

Signed-off-by: Nan Yu <nanyu@google.com>
- a2ui_rpc_interceptor() returns an A2aAgentExecutor ExecuteInterceptor.
  Its before_agent hook notes tasks whose request message holds only A2UI
  callAgentFunction/rendererFunctionResponse parts, and its after_agent hook
  changes such a task's final status from working (ADK's state when the
  agent sent nothing) to completed. Other final states are untouched.
- SendA2uiToClientToolset.process_llm_request removes A2UI RPC messages in
  both directions (callAgentFunction, agentFunctionResponse,
  callRendererFunction, rendererFunctionResponse) from the contents sent to
  the model, dropping contents left empty. Session events keep them, and
  other A2UI messages are kept. exclude_a2ui_rpc_from_prompt is the same
  filter as a before_model_callback for agents without the toolset.

Both read parts through ADK's own A2A part converters and need the a2a
extra; the toolset skips the filter without it. Tests run the real
A2aAgentExecutor, Runner and LlmAgent with stub agents and a recording
model, against google-adk 2.3.0 and 1.28.1.

Part of #3535.

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