Skip to content

test(conformance): agent function RPC - #3794

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

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

Conversation

@nan-yu

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

Copy link
Copy Markdown
Collaborator

Stacked on #3793. Review only the top commit (test(conformance): agent function RPC).
Part of #3536. Part of #3535.

Description

#3792 and #3793 added agent functions and the agent's RPC helpers to the Python SDKs, tested only by Python unit tests. This PR writes their behaviour down as a shared conformance suite, so the TypeScript and Dart agent SDKs can implement against the same cases later.

  1. New suite agent/agent_functions.yaml (38 cases). A case declares each agent function with the JSON Schema of its args and a scripted behavior in place of a handler: returns: <value>, returns_args: true, returns_context: true, or raises: {message, code?}. That way every SDK runs the same function. The suite has four actions:
    1. declare_agent_functions: an agent function is listed among the catalog's functions and in its toJson() document. Taking the name of a function the catalog already declares is a CatalogError.
    2. handle_agent_function: answers one callAgentFunction message. The cases cover the value (including null) with functionCallId copied verbatim; schema defaults applied before the handler runs; the handler's context; INVALID_FUNCTION_CALL for bad, missing or undeclared args and for agentOnly functions; UNKNOWN_FUNCTION for an unknown name or catalog, or a renderer function of the catalog; EXECUTION_ERROR, or the handler's own code, when the handler fails; picking the catalog the call names; the rules for a call without catalogId; and malformed messages with and without a functionCallId.
    3. validate_payload: model output that calls a declared agent function validates. A call to an undeclared function, or with args the schema rejects, doesn't.
    4. call_renderer_function: steps (call, respond, advance_ms, dispose) run on a fake clock that only advance_ms moves, so no case waits on real time. The cases cover the message a call builds; a response settling the matching call with a value or the renderer's error code; unknown, duplicate and late responses being ignored; the 30 s default timeout, per-call and per-instance timeouts, and no timeout after a call settles; dispose failing pending calls with CANCELLED and refusing later calls with DISPOSED; and max_pending (TOO_MANY_PENDING).
  2. Schema and docs. conformance_schema.json gets closed definitions for the four case shapes. README.md lists the suite and adds "Writing cases for the agent function suite". The harness guide gets a stage row, a short section on the fake clock, and the Python runner in its reference list.
  3. Runners.
    1. Python: test_agent_function_conformance.py runs every case. Behaviours become async handlers, and renderer calls run on an asyncio loop whose time() only advance_ms moves.
    2. TypeScript agent SDK: the four actions are in UNIMPLEMENTED_ACTIONS in loader.ts, and agent-functions.test.ts registers each case as skipped with that reason.
    3. Dart agent SDK: agent_functions_conformance_test.dart does the same.
    4. web_core's runner reads every suite under agent/, so the four actions join its UNIMPLEMENTED_ACTIONS as agent-side only.
  4. Renderer-side case in message_processor_v1_0.yaml: under strictMode: true, a call without catalogId to a function that the surface default catalog declares schema-only is accepted. The existing cases covered an undeclared function (accepted when not strict, rejected when strict) and a schema-only function reached through an explicit catalogId, but not this one.

Tests

  1. Python: uv run pytest python/a2ui_agent/tests python/a2ui_core/tests: 3451 passed, 7 skipped (the new suite: 39 passed). uv run mypy ., pyink and scripts/fix_licenses.py --check are clean. uv run pytest conformance/tests validates every suite against the schema.
  2. TypeScript: yarn test in typescript/agents/a2ui_agent (the suite: 1 passed, 38 skipped) and yarn test:conformance in typescript/core/web_core (616/616, the new core case included).
  3. Dart: dart test in dart/a2ui_agent (the suite: 1 passed, 38 skipped) and dart test test/conformance in dart/a2ui_core, which runs the new core case.
  4. Swift: the new core case passes in MessageProcessorConformanceTests (run on Linux in the swift:6.1 image). That run also fails test_v10_call_to_undeclared_function_without_strict_mode_accepted, which fails the same way on feat(python-agent): answer agent functions and call renderer functions #3793 without this PR.

Adds conformance/agent/agent_functions.yaml for agent functions and the
agent's side of function RPC: declaring agent functions on a catalog,
answering callAgentFunction, validating model output that calls agent
functions, and calling renderer functions on a fake clock. The Python
agent SDK runs it; the TypeScript and Dart agent SDKs and the web_core
runner skip its actions with a reason.

Also adds a message_processor_v1_0.yaml case: under strict validation, a
call to a function the surface catalog declares schema-only is accepted.

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 conformance test suite for agent functions and renderer function calls (agent_functions.yaml), along with its documentation, schema definitions, and a Python test runner implementation. Placeholder/skip tests are added for Dart and TypeScript since they do not yet support agent functions. Feedback on the Python test runner recommends initializing self._now_ms before calling super().init() in _FakeClockLoop to prevent an AttributeError, checking if a future is done before calling future.exception() to avoid asyncio.InvalidStateError, and explicitly setting the custom event loop using asyncio.set_event_loop to prevent potential runtime errors.

Comment on lines +211 to +213
def __init__(self) -> None:
super().__init__(selectors.DefaultSelector())
self._now_ms = 0

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

When subclassing asyncio.SelectorEventLoop, the parent constructor (super().__init__) can invoke overridden methods like time(). If self._now_ms is initialized after super().__init__(), it can lead to an AttributeError during initialization. Please initialize self._now_ms before calling super().__init__().

Suggested change
def __init__(self) -> None:
super().__init__(selectors.DefaultSelector())
self._now_ms = 0
def __init__(self) -> None:
self._now_ms = 0
super().__init__(selectors.DefaultSelector())

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 38bb93d: _now_ms is set before super().__init__().

🤖 Agent-generated

Comment on lines +322 to +324
for future in futures.values():
if not future.cancelled():
future.exception()

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 future.exception() on a future that is not yet done will raise an asyncio.InvalidStateError. To prevent potential runtime errors, ensure the future is done before checking its exception.

Suggested change
for future in futures.values():
if not future.cancelled():
future.exception()
for future in futures.values():
if future.done() and not future.cancelled():
future.exception()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 38bb93d. dispose() settles every pending call before this loop, so it couldn't raise today, but the future.done() guard costs nothing and keeps it safe if that changes. I also added a comment on why the loop reads the exceptions at all (so asyncio doesn't log "exception was never retrieved").

🤖 Agent-generated

Comment on lines +327 to +332
def _run_calls(case: Mapping[str, Any]) -> None:
loop = _FakeClockLoop()
try:
loop.run_until_complete(_run_calls_on_loop(case, loop))
finally:
loop.close()

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

When running a custom event loop manually via loop.run_until_complete, it is a best practice to set it as the current thread's event loop using asyncio.set_event_loop(loop). This prevents potential RuntimeErrors or "attached to a different loop" errors if any downstream code or library calls asyncio.get_event_loop().

Suggested change
def _run_calls(case: Mapping[str, Any]) -> None:
loop = _FakeClockLoop()
try:
loop.run_until_complete(_run_calls_on_loop(case, loop))
finally:
loop.close()
def _run_calls(case: Mapping[str, Any]) -> None:
loop = _FakeClockLoop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(_run_calls_on_loop(case, loop))
finally:
loop.close()
asyncio.set_event_loop(None)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 38bb93d: the loop is installed with asyncio.set_event_loop while the case runs and cleared afterwards.

🤖 Agent-generated

Set the fake clock before the base event loop constructor runs, install
the loop as the current event loop while a case runs, and only read
failures of futures that are done.

Signed-off-by: Nan Yu <nanyu@google.com>
@nan-yu
nan-yu added this pull request to stack #3751 October 30, 2026 22:00
…into test/conformance-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