Skip to content

test(conformance): cover agent-function fallback routing across SDKs - #3742

Open
nan-yu wants to merge 15 commits into
fix/swift-agent-function-routingfrom
test/conformance-agent-function-routing
Open

nan-yu wants to merge 15 commits into
fix/swift-agent-function-routingfrom
test/conformance-agent-function-routing

Conversation

@nan-yu

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

Copy link
Copy Markdown
Collaborator

Stacked on #3741. Review only the top commit (test(conformance): ...).
Part of #3536.

Description

#3735, #3739, #3740 and #3741 route v1.0 function calls the renderer can't run to the agent, in web_core, Dart, Python and Swift. No shared conformance case covered this: test_rpc_call_agent_function_dispatch calls the RPC handler directly and never goes through a binding. This PR adds a shared suite that every core runs, and fixes the two places where it found the SDKs differing.

Changes

  1. New suite conformance/core/agent_function_routing.yaml (20 cases, new action route_agent_function). A case declares catalogs (localFunctions taken from the SDK's basic catalog, agentFunctions declared schema-only), a v1.0 surface with or without an outbound listener, and steps that either evaluate a FunctionCall as a binding, check or action, or answer the n-th outbound call. After each step it checks the outbound callFunction payloads, the reported errors, and the current value of every binding and check. It uses no timers. The cases cover:
    1. an undeclared function sent with the resolved catalogId (filled in when the call omits it), pending until answered;
    2. an explicit catalogId carried; an implemented function run locally; an agent result fed into a local function;
    3. an agent error reported as EXPRESSION_ERROR with a null value, for bindings and actions;
    4. an unknown catalogId and a missing default catalog reported as CATALOG_ERROR, nothing sent;
    5. no outbound listener, a v0.9 surface, and a @ system function: not routed;
    6. identical in-flight calls sharing one request, including a call that names the default catalog and one that omits it; different arguments sending separate requests;
    7. check rules passing and failing on an agent result; action.functionCall routed;
    8. schema-only functions: parsed arguments sent with defaults, invalid arguments rejected before sending, requiresUserActivation blocking a binding but not an action.
  2. Schema and docs: RouteAgentFunctionTest, RoutingCatalog and RoutingStep in conformance_schema.json, and the suite plus a "Writing cases for route_agent_function" section in conformance/README.md.
  3. Runners: the new action in typescript/core/web_core/tests/conformance/conformance_test.mjs, python/a2ui_core/tests/test_conformance.py, a new Dart agent_function_routing_conformance_test.dart and a new Swift AgentFunctionRoutingConformanceTests.swift.
  4. SDK fixes found by the suite:
    1. Dart reported a failed or timed-out agent call as EXECUTION_ERROR (or the RPC code). It now reports EXPRESSION_ERROR, like the other cores, keeping the functionCallId and the agent's message. A local function whose Future fails is still EXECUTION_ERROR. This is marked breaking in the Dart CHANGELOG.
    2. web_core ignored JSON-schema defaults in the arguments of a function loaded with Catalog.fromJson, so a schema-only function sent its arguments without them. convertFunctionArgsJsonSchemaToZod now applies them.

Known divergences

  1. Dart has no schema-only functions: its catalogs only hold implementations, so the three cases that use agentFunctions are skipped for Dart.
  2. Not asserted on purpose, because the cores differ and the spec doesn't decide it: whether an answered call is sent again when re-evaluated, a check's value while pending, and the wording of the no-listener and system-function errors (only the code is checked).

Testing

  1. web_core: yarn test passes; the new suite 20/20.
  2. Python: uv run pytest python/a2ui_core conformance/tests 1677 passed, 15 skipped; the new suite 20/20. pyink and mypy clean.
  3. Dart: dart test for a2ui_core 1914 passed, 11 skipped; the new suite 17 passed and 3 skips. dart analyze and dart format clean.
  4. Swift (Docker swift:6.1, Combine mapped to OpenCombine in a scratch copy): the new suite 20/20. The other failures in the full run fail the same way on the base commit.

Add conformance/core/agent_function_routing.yaml, whose
route_agent_function cases evaluate function calls (as bindings, check
rules and action function calls) on a v1.0 surface created through a
message processor, answer the callAgentFunction messages it sends, and
check the outbound payloads, values and reported errors. The action is
described in conformance_schema.json and the README, and implemented in
the TypeScript, Python, Dart and Swift conformance runners.

- dart-core: report an agent error or timeout as EXPRESSION_ERROR
  instead of EXECUTION_ERROR, as the other SDKs do.
- web_core: apply the default of an optional argument of a function
  loaded with Catalog.fromJson, so schema-only functions are sent with
  their defaults.

Known divergences: Dart cannot declare schema-only functions, and the
TypeScript and Dart cores run a local function before an agent call in
its arguments has settled.

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 a new conformance test suite (route_agent_function) to verify the fallback routing of function calls to the agent with callAgentFunction. It implements the corresponding test runners across the Dart, Python, Swift, and TypeScript SDKs. Additionally, it updates the Dart SDK to report agent errors and timeouts as EXPRESSION_ERROR instead of EXECUTION_ERROR for consistency with other SDKs, and updates the TypeScript SDK to correctly apply default values for missing optional function arguments. A review comment identified an unused variable atLeastV10 in the Swift conformance test implementation that should be removed.

@nan-yu
nan-yu added this pull request to stack #3751 October 29, 2026 16:53
…yright year

Remove the unused atLeastV10 in the Swift routing conformance runner, and
use the copyright year required by the license check in the new files.

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

Bring in the web_core and Dart fixes that make a binding wait for a
pending agent argument, and remove the known-divergence entries for
test_agent_routing_agent_result_feeds_local_function from the web_core
and Dart conformance runners and the README.

Signed-off-by: Nan Yu <nanyu@google.com>
…routing' into test/conformance-agent-function-routing

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

# Conflicts:
#	swift/core/Sources/A2UICore/Resolution/NodeResolver.swift
#	swift/core/Sources/A2UICore/State/DataContext.swift
…lls, and check results

Signed-off-by: Nan Yu <nanyu@google.com>
…' into HEAD

Signed-off-by: Nan Yu <nanyu@google.com>
…d pin failure eviction

Signed-off-by: Nan Yu <nanyu@google.com>
…' into test/conformance-agent-function-routing
…' into test/conformance-agent-function-routing

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

@gspencergoog gspencergoog left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This change adds the shared core/agent_function_routing.yaml conformance suite (26 cases across v0.9 and v1.0), extends conformance/conformance_schema.json and conformance/README.md with action: route_agent_function, implements runners in Dart, Python, Swift, and TypeScript, and updates TypeScript Catalog.fromJson to apply optional argument defaults. Four high-severity issues need attention: Catalog.fromJson still drops defaults on nested object and array arguments (H1), the Dart runner crashes on catalogless check or action cases (H2), RoutingCatalog.agentFunctions omits returnType and allowedCallers (H3), and conformance_harness_implementation_guide.md is stale (H4).

🤖 Agent-generated

Comment on lines +923 to +927
for (const [name, field] of Object.entries(shape)) {
if (field instanceof z.ZodOptional && field.unwrap() instanceof z.ZodDefault) {
shape[name] = field.unwrap();
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

convertFunctionArgsJsonSchemaToZod unwraps ZodOptional around ZodDefault only on top-level shape entries. Nested object and array schemas keep ZodOptional outside ZodDefault, returning undefined on missing keys. Omitted nested properties with JSON Schema default values resolve to undefined instead of materializing their defaults in outbound callAgentFunction payloads.

Suggested change
for (const [name, field] of Object.entries(shape)) {
if (field instanceof z.ZodOptional && field.unwrap() instanceof z.ZodDefault) {
shape[name] = field.unwrap();
}
}
const unwrapOptionalDefaults = (schema: z.ZodTypeAny): z.ZodTypeAny => {
if (schema instanceof z.ZodOptional) {
const inner = unwrapOptionalDefaults(schema.unwrap());
return inner instanceof z.ZodDefault
? inner
: new z.ZodOptional({...schema._def, innerType: inner});
}
if (schema instanceof z.ZodDefault) {
return new z.ZodDefault({
...schema._def,
innerType: unwrapOptionalDefaults(schema._def.innerType),
});
}
if (schema instanceof z.ZodObject) {
const nextShape = Object.fromEntries(
Object.entries(schema.shape).map(([k, v]) => [
k,
unwrapOptionalDefaults(v as z.ZodTypeAny),
]),
);
return new z.ZodObject({...schema._def, shape: () => nextShape});
}
if (schema instanceof z.ZodArray) {
return new z.ZodArray({...schema._def, type: unwrapOptionalDefaults(schema.element)});
}
return schema;
};
for (const [name, field] of Object.entries(shape)) {
shape[name] = unwrapOptionalDefaults(field);
}

🤖 Agent-generated

Comment on lines +165 to +172
final component = ComponentModel(id, 'Probe', {
if (kind == 'check')
'checks': [
{'condition': call, 'message': 'Check failed'},
]
else
'action': {'functionCall': call},
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

When surface.catalogId is omitted, ComponentModel(id, 'Probe', ...) leaves catalog null, causing GenericBinder to throw A2uiCatalogResolutionError before evaluating call. Swift avoids this by setting the first catalog ID on synthetic Probe components. Any check or action step on a catalogless v1.0 surface crashes the Dart runner instead of routing calls or reporting CATALOG_ERROR.

Suggested change
final component = ComponentModel(id, 'Probe', {
if (kind == 'check')
'checks': [
{'condition': call, 'message': 'Check failed'},
]
else
'action': {'functionCall': call},
});
final component = ComponentModel(
id,
'Probe',
{
if (kind == 'check')
'checks': [
{'condition': call, 'message': 'Check failed'},
]
else
'action': {'functionCall': call},
},
catalog: surface.defaultCatalog == null
? processor.catalogs.first.id
: null,
);

🤖 Agent-generated

Comment on lines +919 to +929
"properties": {
"args": {
"type": "object",
"description": "JSON Schema of the function's args."
},
"requiresUserActivation": {
"type": "boolean"
}
},
"required": ["args"],
"additionalProperties": false

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

RoutingCatalog.agentFunctions sets additionalProperties: false while omitting returnType and allowedCallers from the v1.0 FunctionDefinition schema (raised by both reviewers). Cases therefore cannot declare schema-only functions with allowedCallers: "agentOnly" or explicit return types. The suite misses Python's live bug where DataContext._call_agent_function sends agentOnly calls to the agent instead of reporting EXPRESSION_ERROR.

Suggested change
"properties": {
"args": {
"type": "object",
"description": "JSON Schema of the function's args."
},
"requiresUserActivation": {
"type": "boolean"
}
},
"required": ["args"],
"additionalProperties": false
"properties": {
"args": {
"type": "object",
"description": "JSON Schema of the function's args."
},
"returnType": {
"type": "string",
"enum": [
"string",
"number",
"boolean",
"array",
"object",
"validationResult",
"any",
"void"
],
"default": "any"
},
"allowedCallers": {
"type": "string",
"enum": ["rendererOnly", "agentOnly", "rendererOrAgent"]
},
"requiresUserActivation": {
"type": "boolean"
}
},
"required": ["args"],
"additionalProperties": false

🤖 Agent-generated

Comment thread conformance/README.md
- `core/functions.yaml`: Contains test cases for the basic catalog functions, for the catalog a function call runs in on a surface, and for a call on a surface to a function whose `allowedCallers` is `agentOnly`, which the renderer refuses. See [Writing cases for `evaluate_function`](#writing-cases-for-evaluate_function).
- `core/functions.yaml`: Contains test cases for the basic catalog functions, and for the catalog a function call runs in on a surface. See [Writing cases for `evaluate_function`](#writing-cases-for-evaluate_function).
- `core/message_processor_scaling.yaml`: Timing cases for `processMessages`, with a validation config and without one, which check that a surface arriving in many small messages takes time in proportion to its size. See [Timing cases](#timing-cases).
- `core/agent_function_routing.yaml`: Covers the v1.0 fallback routing of function calls to the agent with `callAgentFunction` ("Function Location Resolution & Fallback Routing" in the v1.0 protocol): which calls are sent and which run or fail locally, the `catalogId` an outbound call names, the value while a call is pending and once the agent answers, how an agent error is reported, surface-level caching and request sharing for reactive bindings (including canonical argument key ordering), and fresh execution without sharing or caching for imperative action calls. See [Writing cases for `route_agent_function`](#writing-cases-for-route_agent_function).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

README.md adds core/agent_function_routing.yaml and route_agent_function while linking implementers to conformance_harness_implementation_guide.md. That guide still lists only the five older C4 suites and 14 actions. Engineers building a new SDK test runner from conformance_harness_implementation_guide.md will omit route_agent_function and skip C4 fallback routing cases.

Update C4 and Action reference by suite in conformance/conformance_harness_implementation_guide.md.

🤖 Agent-generated

},
"expectValues": {
"type": "array",
"description": "The current value of every binding and check evaluated so far, in order, with null for a pending value. A check's value is whether its rule passes."

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Line 1009 specifies null for all pending values, contradicting README.md:139. In practice, Dart (_checkOutcome) maps pending checks to null while Python, TypeScript, and Swift return false. Future cases asserting expectValues while an earlier check remains pending will pass in Dart and fail in the other three SDKs.

Suggested change
"description": "The current value of every binding and check evaluated so far, in order, with null for a pending value. A check's value is whether its rule passes."
"description": "The current value of every binding and check evaluated so far, in order, with null for a pending binding. A check's value is whether its rule passes; cases read a check only after its response arrives."

🤖 Agent-generated

Comment on lines +27 to +29
- Reactive bindings cache settled agent function answers per surface (bounded
to 256 entries, evicted on failure or surface disposal), while imperative
action calls always execute fresh without sharing or caching.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This PR modifies only the Dart conformance test file and zero files under dart/a2ui_core/lib/. Under update-changelog Rule 3, test-only package changes do not add runtime changelog bullets. Recording library behavior in a test-only PR leaves CHANGELOG.md inaccurate if this PR or its predecessor lands separately.

Move this sub-bullet to the pull request that modifies dart/a2ui_core/lib/.

🤖 Agent-generated


- (v1_0) A function call whose function the renderer does not implement (undeclared in the catalog the call resolves to, or declared schema-only) is routed to the agent with `callAgentFunction`, as the specification's fallback routing defines, instead of failing with an `EXPRESSION_ERROR`. This applies to bindings, check rules, `action.functionCall`, and `action.event` `context`/`userMessage`. The value is pending (`undefined`) until the `agentFunctionResponse` arrives; an agent error or a timeout dispatches an `EXPRESSION_ERROR`. The outbound call always names the catalog the call resolved to, even when the payload relied on the surface default. Arguments of a schema-only function are validated against its schema before the call is sent. Reactive bindings share in-flight calls and cache settled answers per surface (bounded to 256 entries, keyed by canonically sorted argument keys, evicted on failure or surface deletion), while imperative action calls always execute fresh without sharing or caching. In a binding, check, or action, a function whose argument is still pending (such as an agent call) waits for it to settle instead of running with `undefined`, and stops without running or re-reporting if a nested argument fails. `MessageProcessor.dispose()` now disposes all surfaces in `model` in addition to `rpc`, and deleting a surface cancels its pending RPC calls. `MessageProcessor` enables fallback routing on v1.0 surfaces when it has an `outboundListener`; without one, such calls still fail locally. Adds `SurfaceModel.agentFunctionCaller`, `SurfaceModel.callAgentFunction`, `SurfaceModel.disposed`, `RpcHandler.cancelSurfaceCalls`, and the `AgentFunctionCall` and `AgentFunctionCaller` types. [#3415](https://github.com/a2ui-project/a2ui/issues/3415), [#3536](https://github.com/a2ui-project/a2ui/issues/3536)
- `DataContext.resolveSignal` seeds the result of a function call that returns a `Signal` with the signal's current value before it creates the forwarding `effect`. Under a reactivity backend whose effects run on the next flush rather than on creation (the Angular adapter), an enclosing call such as `not(areComponentsValid(...))` previously read `undefined` for that argument on its first evaluation and dispatched a spurious `EXPRESSION_ERROR`.
- The argument schema of a function loaded with `Catalog.fromJson` applies the `default` of an optional argument that a call leaves out, so a schema-only function routed to the agent is sent with its defaults, as one defined with Zod is.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Line 4 places a non-breaking Catalog.fromJson fix above the **BREAKING CHANGE**: entries on lines 5-7 and omits a PR link. update-changelog Rules 4 and 6 require **BREAKING CHANGE**: items at the top of ## Unreleased and PR links on every entry. Readers scanning the top of ## Unreleased for breaking migrations can miss breaking entries below non-breaking items.

Move line 4 below line 7 and append ([#3742](https://github.com/a2ui-project/a2ui/pull/3742)).

🤖 Agent-generated

await waitUntil {
recorder.agentCalls.count >= outboundSeen + expectedOutbound.count
&& genericErrors(recorder).count >= errorsSeen + expectedErrors.count
&& (expectedValues == nil || currentValues() == expectedValues!.map(Self.canonical))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Line 186 (expectedValues!) and line 350 (object[$0]!) use ! force unwraps in test code, violating swift/CODING_STANDARDS.md Section 3. Use optional map here and compactMap in canonical. Force unwraps in Swift test helpers crash the entire test runner process on unexpected nil instead of recording a test failure.

Suggested change
&& (expectedValues == nil || currentValues() == expectedValues!.map(Self.canonical))
&& (expectedValues.map { currentValues() == $0.map(Self.canonical) } ?? true)

🤖 Agent-generated

from a2ui.core.state import DataModel, SurfaceModel
from a2ui.core.resolution import DataContext
from a2ui.core.state import ComponentModel, DataModel, SurfaceModel
from a2ui.core.resolution import ComponentContext, DataContext, GenericBinder

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Line 49 places from a2ui.core.resolution after from a2ui.core.schema and from a2ui.core.state rather than in alphabetical module order per AGENTS.md Section 8. Unsorted imports in modified blocks create avoidable merge conflicts and violate the import ordering rules in AGENTS.md.

Move the a2ui.core.resolution import above line 45 in alphabetical module order.

🤖 Agent-generated

Comment on lines +135 to +139
if (testCase['dataModel'] case final Map<String, Object?> data) {
for (final MapEntry<String, Object?> entry in data.entries) {
surface.dataModel.set('/${entry.key}', entry.value);
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Lines 135-139 (and AgentFunctionRoutingConformanceTests.swift:92-100) write each dataModel key to '/${key}' without RFC 6901 escaping, unlike Python and TypeScript's root set('/', ...). Any future case with / or ~ in a top-level dataModel key will diverge across runners.

Suggested change
if (testCase['dataModel'] case final Map<String, Object?> data) {
for (final MapEntry<String, Object?> entry in data.entries) {
surface.dataModel.set('/${entry.key}', entry.value);
}
}
if (testCase['dataModel'] case final Map<String, Object?> data) {
surface.dataModel.set('/', data);
}

🤖 Agent-generated

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.

2 participants