Skip to content

feat(python-agent): answer agent functions and call renderer functions - #3793

Open
nan-yu wants to merge 5 commits into
feat/python-core-agent-functionsfrom
feat/python-agent-function-rpc
Open

nan-yu wants to merge 5 commits into
feat/python-core-agent-functionsfrom
feat/python-agent-function-rpc

Conversation

@nan-yu

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

Copy link
Copy Markdown
Collaborator

Stacked on #3792. Review only the top commit (feat(python-agent): answer agent functions and call renderer functions).
Part of #3536. Part of #3535.

Description

#3792 lets an agent declare agent functions on its catalog, and the renderer stack (#3735, #3739–#3741) sends calls to them with callAgentFunction. The agent SDK still had nothing that answers those calls, and nothing for the other direction, callRendererFunction (#3535).

This PR adds a2ui.rpc to a2ui_agent. It has no framework imports (tests/test_framework_agnostic.py checks this); the A2A and ADK wiring comes in a later PR of this stack.

  1. AgentFunctionRegistry (agent_function_registry.py) indexes the catalogs' agent_functions by (catalog_id, name). await registry.handle(message) takes a callAgentFunction message (model or dict) and returns the AgentFunctionResponseMessage to send:
    1. It copies functionCallId verbatim.
    2. An unknown name or catalogId gets UNKNOWN_FUNCTION. A call without catalogId runs the function if exactly one catalog has it.
    3. Arguments are validated with PayloadValidator(catalog, config=STRICT_VALIDATION).validate_function, as a2ui_core does, so defaults are applied before the handler runs. Failures get INVALID_FUNCTION_CALL with the validation message. A call to an agentOnly function gets INVALID_FUNCTION_CALL too.
    4. A handler that raises gets EXECUTION_ERROR with the exception message; the traceback is only logged. A handler that raises A2uiRpcError(message, code=...) picks the code, so it can send the custom codes the spec shows (such as PROVIDER_NOT_FOUND).
    5. Async handlers are awaited; sync handlers run through asyncio.to_thread. max_concurrency (a semaphore) and timeout (per registry or per handle call; cancels the handler and replies EXECUTION_ERROR) are optional. dispose() cancels running handlers; those calls raise A2uiRpcError (DISPOSED) instead of returning a response. asyncio.CancelledError is never turned into a response.
  2. RendererFunctionCalls (renderer_function_calls.py), one per conversation. call(catalog_id=..., name=..., args=..., surface_id=None, timeout=None) returns (CallRendererFunctionMessage, asyncio.Future) without waiting. It generates the functionCallId, always sets catalogId, and builds the message through the schema model. resolve(message) settles the matching future with the value, or with A2uiRpcError carrying the renderer's code. It follows the renderer-side RpcHandler:
    1. The instance is bound to the loop it is first used on; resolve may be called from any thread and goes through loop.call_soon_threadsafe.
    2. Timeouts use loop.call_later (30 s by default, as in RpcHandler). Settling or cancelling a call removes its entry and timer.
    3. Unknown, late and duplicate responses are logged at debug level and ignored; malformed ones are logged as warnings. Nothing from the network raises.
    4. dispose(reason) fails pending calls (CANCELLED) and makes later call()s raise (DISPOSED). async with disposes on exit. max_pending makes call() raise with code TOO_MANY_PENDING.
  3. RendererMessageRouter (router.py). await router.dispatch(message) returns [agentFunctionResponse] for callAgentFunction, and [] after resolving a rendererFunctionResponse. Other messages (action, error, ...) belong to the agent's turn, so dispatch raises A2uiValidationError for them, and router.handles(message) tells the caller beforehand. The ADK callback in a later PR can use handles to decide whether to skip the LLM turn.
  4. Docs. A new section in agent_development.md covers declaring agent functions, answering calls, calling renderer functions, the concurrency and lifecycle rules, and what renderers need: nothing with default validation; allowUnknownElements: true or schema-only declarations with strict validation. The README and CHANGELOG are updated.

Responses must be serialized with model_dump(by_alias=True, exclude_unset=True): exclude_none=True would drop a value of None, which the response must carry. The docs and docstrings say so.

Tests

  1. New tests under python/a2ui_agent/tests/rpc/ (74 tests, real asyncio with short timeouts and events, no sleeps that the result depends on):
    1. test_agent_function_registry.py: every response rule above, defaults and context, sync handlers on a worker thread and async ones on the loop, concurrency limits, timeouts, caller cancellation and dispose.
    2. test_renderer_function_calls.py: message shape, matching, error responses, unknown/late/duplicate/malformed responses, resolve and dispose from another thread, timeouts, cancellation, async with, max_pending, and loop binding.
    3. test_router.py: both message types as dicts and models, and the messages it refuses.
    4. test_agent_function_validation_end_to_end.py: the agent's copy of the basic v1.0 catalog declares fetchFxRate; Express and Direct JSON output that binds a Text to it validates on the agent, and an undeclared name is still rejected. The same messages then go into an a2ui_core MessageProcessor whose catalog lacks the function: with the default config they are accepted and the call reaches the agent's router, whose response reaches the bound value; with STRICT_VALIDATION they are rejected; with strict validation and a schema-only declaration they are accepted and routed, with validated args.
  2. uv run pytest python/a2ui_agent/tests python/a2ui_core/tests: 3411 passed, 7 skipped. uv run mypy ., pyink and scripts/fix_licenses.py --check are clean.

Add a2ui.rpc to the agent SDK for v1.0 function calls in both directions:

- AgentFunctionRegistry answers callAgentFunction with the handlers of the
  catalogs' agent functions. It copies functionCallId, validates the args
  against the function's schema (applying defaults), and replies
  UNKNOWN_FUNCTION, INVALID_FUNCTION_CALL or EXECUTION_ERROR. Sync handlers
  run through asyncio.to_thread; max_concurrency, timeout and dispose()
  bound the work, and cancellation is never turned into a response.
- RendererFunctionCalls builds callRendererFunction messages for one
  conversation and settles their futures from rendererFunctionResponse,
  with loop-bound state, thread-safe resolve, timeouts (30 s by default),
  max_pending and dispose().
- RendererMessageRouter passes each renderer RPC message to the helper
  that handles it.

An end-to-end test validates a payload that uses an agent function on the
agent (Express and Direct JSON) and feeds it to an a2ui_core
MessageProcessor without the function: accepted and routed by default,
rejected under strict validation, and accepted and routed under strict
validation with a schema-only declaration.

Part of #3536. Part of #3535.

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

@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 the a2ui.rpc package to support v1.0 function calls between the agent and the renderer. It implements AgentFunctionRegistry to handle renderer-initiated calls, RendererFunctionCalls to manage agent-initiated calls, and RendererMessageRouter to route incoming RPC messages. Additionally, comprehensive documentation and end-to-end tests have been added. Feedback on the changes suggests wrapping loop.call_soon_threadsafe in a try...except RuntimeError block within RendererFunctionCalls.dispose to safely handle scenarios where the event loop is closed or closing during teardown.

…surface

A callRendererFunction without a surface set surfaceId to None, so
model_dump(exclude_unset=True) wrote "surfaceId": null, which the v1.0
schema rejects. Leave it unset, so exclude_unset=True serializes every
A2UI RPC message correctly.

Signed-off-by: Nan Yu <nanyu@google.com>
@nan-yu
nan-yu added this pull request to stack #3751 October 30, 2026 22:00
…oop closes

dispose() handed failing the pending calls to the bound loop with
call_soon_threadsafe, which raises RuntimeError if the loop closes after
the is_closed check. Fall back to failing them directly, and let
_fail_all drop a call whose future can't be failed because its loop is
closed.

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

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