Skip to content

fix(web_core): route unimplemented functions to the agent with callAgentFunction - #3735

Open
nan-yu wants to merge 11 commits into
a2ui-project:mainfrom
nan-yu:fix/agent-function-fallback-routing
Open

nan-yu wants to merge 11 commits into
a2ui-project:mainfrom
nan-yu:fix/agent-function-fallback-routing

Conversation

@nan-yu

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

Copy link
Copy Markdown
Collaborator

Fixes #3415
Part of #3536 (first step of the SDK-level fix plan)

Description

In v1.0, a renderer should send a function call to the agent with callAgentFunction when it has no local implementation of the function (the fallback routing in the callAgentFunction section of the protocol doc). web_core never did this. A binding, check rule or action.functionCall that named such a function failed with an EXPRESSION_ERROR ("Function not found" or "schema-only and has no implementation") and never reached the agent. Only app code that called MessageProcessor.callAgentFunction directly could use agent-side functions.

This PR adds the routing to web_core and clarifies the protocol doc. It does not change any schema.

Changes

  1. Routing in the catalog invoker (types.ts). In a v1.0 catalog, a call to a function without an execute implementation (undeclared, or declared schema-only) goes to the surface's agent function caller instead of throwing. Specifically:
    1. The outbound callFunction always carries the catalogId the call resolved to, even when the payload omitted it and relied on the surface default. This follows the discussion on [v1.0][spec] No way to declare agent-hosted functions #3536 (comment): with Resolve inconsistency around catalogId. #3650, callRendererFunction always names its catalog, and now callAgentFunction does too, so the agent never has to know a surface's default catalog.
    2. For a schema-only function, the arguments are validated against the declared schema (and requiresUserActivation is enforced) before the call is sent. An undeclared function's arguments are sent as resolved.
    3. Nested asynchronous arguments are settled first, as for local functions.
    4. Reserved @ system functions and pre-1.0 catalogs are never routed.
  2. SurfaceModel.agentFunctionCaller and SurfaceModel.callAgentFunction (surface-model.ts). The caller is the hook the invoker uses. callAgentFunction shares one request between identical calls (same catalog, function and arguments) that are still waiting for a response, so re-evaluating a binding does not send repeated RPCs.
  3. MessageProcessor wiring (message-processor.ts). On each v1.0 surface, when the processor has an outboundListener, it installs a caller that dispatches through its RpcHandler. Without an outboundListener nothing changes: such calls still fail locally, since there is no way to reach the agent.
  4. Protocol doc (a2ui_protocol.md):
    1. "Registered locally" now means the renderer can execute the function. Undeclared and schema-only functions are routed to the agent, and a call whose catalog can't be resolved is a catalog error, not an agent call.
    2. Fallback routing only applies to calls the renderer accepts. A renderer that rejects unknown functions must declare the agent-side functions it expects, without implementations.
    3. The renderer SHOULD set catalogId on callAgentFunction to the resolved catalog. The example now includes it.
  5. ValidationConfig.allowUnknownElements docstring (integrity-checker.ts). It already applies to functions as well as components; the docstring now says so and explains what renderers that use agent-side functions need to do. Behavior and defaults are unchanged.

Behavior notes for review

  1. While the agent call is pending, and after an agent error or a timeout, the value is undefined. This matches how web_core already handles asynchronous local functions (feat(web_core): settle Promise-valued function arguments before validation #3578). The protocol doc's failure rules mention null; I kept the existing web_core convention rather than special-casing agent calls.
  2. Errors and timeouts are reported as EXPRESSION_ERROR through the surface's onError, with the agent's error message.
  3. In a binding or check, a function whose argument is still pending (such as an agent call) waits for it to settle instead of running with undefined. Inside function arguments, DataContext keeps the pending Promise in its signal, so the enclosing invoker settles it, as it already did when a value is resolved once. A failed argument stops the enclosing call and is reported once.

Testing

  1. New agent-function-routing.test.ts, which runs through MessageProcessor end to end:
    1. an undeclared function is sent with the resolved catalogId, is pending, then takes the response value;
    2. an agent result feeds a local function (the formatNumber(fetchFxRate(...)) case from web_core (v1.0): unregistered function is not routed to the agent via callAgentFunction; binding silently resolves to undefined #3415);
    3. schema-only arguments are validated before sending;
    4. an implemented function still runs locally;
    5. an agent error and a timeout both raise EXPRESSION_ERROR;
    6. identical in-flight calls share one request, and different arguments do not;
    7. action.functionCall is routed;
    8. an unavailable catalogId raises CATALOG_ERROR without routing;
    9. no routing without an outboundListener or on a pre-1.0 surface.
  2. The full web_core suite passes (1148 tests, Node 22), as does yarn test:conformance. ESLint reports no new warnings in the changed source files, and Prettier is clean.

Follow-ups (from the fix plan on #3536)

  1. The same routing in the Python, Dart and Swift renderer cores.
  2. A server-function API in the agent SDKs (declare, prompt, validate, answer callAgentFunction). This overlaps with [v1.0] Agent SDK has no RPC helpers for function calls #3535.
  3. Conformance cases for fallback routing, then a sample.

…entFunction

A v1.0 function call whose function the renderer does not implement
(undeclared in the catalog the call resolves to, or declared schema-only)
now goes to the agent through callAgentFunction, as the fallback routing in
the specification defines. Before, bindings, check rules and
action.functionCall failed with an EXPRESSION_ERROR or resolved to
undefined without reaching the agent.

The outbound call always names the catalog the call resolved to.
Schema-only arguments are validated before sending, and identical
in-flight calls share one request. MessageProcessor enables this on v1.0
surfaces that have an outboundListener.

The v1.0 protocol doc now defines "registered locally" as having an
implementation, covers schema-only declarations, and asks renderers to
fill in the resolved catalogId on callAgentFunction.

Fixes a2ui-project#3415
Part of a2ui-project#3536

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 implements fallback routing of unimplemented functions (undeclared or schema-only) to the agent via callAgentFunction on v1.0 surfaces, including request deduplication for identical in-flight calls. The review feedback highlights three important improvements: using the parsed and coerced arguments (parsed.data) from schema validation to preserve default values, sorting argument keys in agentCallKey to ensure deterministic cache keys for request sharing, and defensively defaulting surface.protocolVersion to prevent potential runtime TypeErrors.

Send the arguments a local implementation would receive, with the
schema's defaults applied, and use the 2024 copyright year required by the
license check.

Signed-off-by: Nan Yu <nanyu@google.com>
Resolve conflicts in the web_core CHANGELOG and surface-model imports,
and use the bare '1.0' version from a2ui-project#3726 in the new routing tests.

Signed-off-by: Nan Yu <nanyu@google.com>
In a binding or check, a function whose argument is a pending
asynchronous result (such as an agent call) now waits for it to settle
instead of running with undefined. Inside function arguments the pending
Promise stays in its signal, so the enclosing invoker settles it as it
does when a value is resolved once.

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

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

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

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

# Conflicts:
#	typescript/core/web_core/CHANGELOG.md
Signed-off-by: Nan Yu <nanyu@google.com>

# Conflicts:
#	typescript/core/web_core/src/state/surface-model.ts

@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 pull request implements v1.0 fallback routing in @a2ui/web_core so functions without a local catalog implementation route to the agent through RpcHandler.callAgentFunction, backed by per-surface in-flight deduplication and a 256-entry LRU cache on SurfaceModel. It also awaits nested Promise arguments across local and remote calls via settleDeep, resolves pending values in action.event context and userMessage, cancels surface RPCs on deletion or disposal, and updates the v1.0 protocol specification and CHANGELOG.md.

Six high-severity issues require changes before merge. Synchronous finally blocks in DataContext reset _isActionExecution and _isUserActivated before returned Promise values settle (H1), failed multi-entry action.event evaluations leak uncached agent RPCs (H2), resolveAction drops userActivation options on event actions (H3), canonicalizeJson corrupts "__proto__" keys and collapses non-JSON cache keys (H4), SurfaceModel retains stale cache entries across agentFunctionCaller reassignment (H5), and the v1.0 fallback-routing prose omits agentOnly, passive requiresUserActivation, and @-prefixed system-function exclusions (H6).

🤖 Agent-generated

Comment on lines +658 to +660
if (!this.surface?.disposed) {
this.dispatchExpressionError(e, callName);
}

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.

executeActionFunctionCall and resolveFunctionCallValue reset _isActionExecution and _isUserActivated in synchronous finally blocks before a returned Promise settles (raised by both reviewers). If an action awaits an async argument and then throws, dispatchExpressionError sees _isActionExecution === false and emits EXPRESSION_ERROR instead of EXECUTION_ERROR.

Capture _isActionExecution, _isUserActivated, and _isPassiveEvaluation at call initiation and pass them explicitly into async continuations and dispatchExpressionError.

🤖 Agent-generated

Comment on lines +1004 to +1010
for (const [key, value] of Object.entries(ev.context as Record<string, unknown>)) {
const resolvedValue = this.resolveDynamicValue(value, 0, false, true);
if (findFailedArgument(resolvedValue)) {
return undefined;
}
resolvedContext[key] = resolvedValue;
}

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.

resolveActionEventObject creates an unretained AbortController per entry in resolveFunctionCallValue, so returning undefined on a later failure leaves earlier queued agent calls unaborted. A rejected or malformed action.event still sends imperative callAgentFunction RPCs to the agent on the next microtask, potentially triggering unintended remote side effects.

Pass a shared AbortController across resolveActionEventObject (and sibling argument loops in resolveFunctionCallValue) and call abort() whenever validation or a sibling argument fails.

🤖 Agent-generated

if (ev.context && typeof ev.context === 'object' && !Array.isArray(ev.context)) {
resolvedContext = {};
for (const [key, value] of Object.entries(ev.context as Record<string, unknown>)) {
const resolvedValue = this.resolveDynamicValue(value, 0, false, true);

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.

resolveAction ignores options.userActivation on event actions, and resolveActionEventObject hardcodes userActivated = false without setting _isPassiveEvaluation when userActivation is false. Functions with requiresUserActivation: true inside event.context or event.userMessage fail in browsers despite {userActivation: true} and bypass blocking in non-DOM runtimes under {userActivation: false}.

Thread options from resolveAction into resolveActionEventObject and apply the same userActivated and _isPassiveEvaluation scoping used in executeActionFunctionCall.

🤖 Agent-generated

Comment on lines +712 to +742
function canonicalizeJson(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(canonicalizeJson);
}
if (value !== null && typeof value === 'object') {
const proto = Object.getPrototypeOf(value);
if (proto === Object.prototype || proto === null) {
const sorted: Record<string, unknown> = {};
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
sorted[key] = canonicalizeJson((value as Record<string, unknown>)[key]);
}
return sorted;
}
}
return value;
}

/**
* Builds the key that identifies identical agent function calls.
*
* @param call The agent function call.
* @returns The key, or `undefined` if the arguments cannot be serialized, in
* which case the call is not shared.
*/
function agentCallKey(call: AgentFunctionCall): string | undefined {
try {
return JSON.stringify([call.catalogId, call['@call'], canonicalizeJson(call.args)]);
} catch {
return undefined;
}
}

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.

canonicalizeJson allocates const sorted: Record<string, unknown> = {}, so assigning sorted['__proto__'] mutates the prototype instead of defining an own enumerable property. Distinct "__proto__" payloads, undefined object properties, NaN, Infinity, and Date values collide in inFlightAgentCalls and settledAgentCalls, returning another call's cached response.

Allocate Object.create(null) (or Object.defineProperty) in canonicalizeJson, and return undefined from agentCallKey when args contains non-JSON values such as NaN, Infinity, undefined, or Date.

🤖 Agent-generated

return caller(call).then(
val => {
release();
if (key !== undefined && !this.isDisposed) {

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.

agentFunctionCaller is a plain field, so replacing or clearing it leaves prior entries in settledAgentCalls, and in-flight completions still populate the cache after caller swap. Reactive bindings continue reading cached responses from a disconnected or replaced agent transport instead of failing locally or querying the new caller.

Convert agentFunctionCaller into a getter/setter that clears inFlightAgentCalls and settledAgentCalls on change, and require this.agentFunctionCaller === caller before caching at line 467.

🤖 Agent-generated

Comment on lines +354 to +366
cancelSurfaceCalls(surfaceId: string): void {
for (const [id, pending] of Array.from(this.pendingAgentCalls.entries())) {
if (pending.surfaceId === surfaceId) {
pending.reject(
new A2uiRpcError(
`Surface '${surfaceId}' was deleted while call '${id}' was pending.`,
RpcErrorCode.CANCELLED,
id,
),
);
}
}
}

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.

Public cancelSurfaceCalls(surfaceId) hardcodes "Surface '...' was deleted..." in its CANCELLED error, and DataContext only suppresses errors when this.surface?.disposed is true. Calling rpc.cancelSurfaceCalls(surfaceId) to reset or reconnect a live surface dispatches EXPRESSION_ERROR events claiming the surface was deleted.

Use neutral cancellation wording in cancelSurfaceCalls (such as "Pending calls for surface '${surfaceId}' were cancelled.") and ignore A2uiRpcError rejections with code === RpcErrorCode.CANCELLED in DataContext.

🤖 Agent-generated

- `surfaceId` (string, required): The surface ID where the call originated.
- `functionCallId` (string, required): A unique identifier for this invocation instance. The agent MUST copy this ID verbatim into the return `agentFunctionResponse`.
- `callFunction` (object, required): The description of the function call (`@call`, `catalogId`, `args`).
- `callFunction` (object, required): The description of the function call (`@call`, `catalogId`, `args`). The renderer SHOULD set `catalogId` to the catalog the call resolved to, even when the originating `FunctionCall` omits it and relies on the surface default, so that the agent does not need to track each surface's default catalog. `args` carries the resolved argument values.

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 1443 uses RFC 2119 SHOULD for callFunction.catalogId yet names no valid exception and claims agents can skip tracking default catalogs. Renderers that omit catalogId under SHOULD break agents that rely on the stated guarantee to resolve the owning catalog.

Suggested change
- `callFunction` (object, required): The description of the function call (`@call`, `catalogId`, `args`). The renderer SHOULD set `catalogId` to the catalog the call resolved to, even when the originating `FunctionCall` omits it and relies on the surface default, so that the agent does not need to track each surface's default catalog. `args` carries the resolved argument values.
- `callFunction` (object, required): The description of the function call (`@call`, `catalogId`, `args`). The renderer MUST set `catalogId` to the catalog the call resolved to, even when the originating `FunctionCall` omits it and relies on the surface default, so that the agent does not need to track each surface's default catalog. `args` carries the resolved argument values.

🤖 Agent-generated

@@ -1,5 +1,6 @@
## Unreleased

- (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)

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.

The CHANGELOG.md entry buries the MessageProcessor.dispose() behavioral change inside a 245-word implementation narrative and omits the [#3735] pull request reference. Consumers upgrading @a2ui/web_core can miss that MessageProcessor.dispose() now disposes all surfaces in model, breaking callers that retain surface models after processor teardown.

Suggested change
- (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)
- **BREAKING CHANGE**: `MessageProcessor.dispose()` now disposes all surfaces in `model` in addition to `rpc`, and deleting a surface cancels its pending agent RPC calls. Callers that inspect surfaces after processor teardown must read them before calling `dispose()`. [#3735](https://github.com/a2ui-project/a2ui/pull/3735)
- (v1_0) Route unimplemented and schema-only catalog functions to the agent via `callAgentFunction` across bindings, check rules, and actions when `MessageProcessor` has an `outboundListener`, with per-surface in-flight deduplication and a 256-entry LRU result cache for reactive evaluations. Adds `SurfaceModel.agentFunctionCaller`, `SurfaceModel.callAgentFunction`, `SurfaceModel.disposed`, `RpcHandler.cancelSurfaceCalls`, `AgentFunctionCall`, and `AgentFunctionCaller`. [#3415](https://github.com/a2ui-project/a2ui/issues/3415), [#3536](https://github.com/a2ui-project/a2ui/issues/3536), [#3735](https://github.com/a2ui-project/a2ui/pull/3735)

🤖 Agent-generated

3. **Fallback to Agent RPC:** If the renderer has no local implementation, the renderer assumes it is an agent-side function and dispatches a `callAgentFunction` message over the protocol. This covers a function that the catalog does not declare, and a function that the catalog declares without the renderer implementing it (a schema-only declaration). For a declared function, the renderer validates the arguments against the declared schema before dispatching.
4. **Agent Error Handling:** If the agent does not recognize the function name (or if parameter validation fails on the server), the agent MUST return an `agentFunctionResponse` message containing an `error` payload (`code: "UNKNOWN_FUNCTION"` or `"INVALID_FUNCTION_CALL"`). The renderer then handles the error state locally (e.g., via component error boundaries or fallbacks).

Fallback routing only applies to function calls that the renderer accepts. A renderer that rejects payloads naming functions absent from its catalogs must declare the agent-side functions it expects, without implementing them, for those calls to reach the agent.

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 1436 uses lowercase must declare immediately after uppercase RFC 2119 MUST in Step 4. Readers and RFC 2119 requirement extractors cannot tell whether schema-only declaration for strict renderers is a normative MUST or descriptive prose.

Suggested change
Fallback routing only applies to function calls that the renderer accepts. A renderer that rejects payloads naming functions absent from its catalogs must declare the agent-side functions it expects, without implementing them, for those calls to reach the agent.
Fallback routing only applies to function calls that the renderer accepts. A renderer that rejects payloads naming functions absent from its catalogs MUST declare the agent-side functions it expects, without implementing them, for those calls to reach the agent.

🤖 Agent-generated

Comment on lines +425 to +430
const settled = this.settledAgentCalls.get(key);
if (settled !== undefined) {
// Refresh LRU order.
this.settledAgentCalls.delete(key);
this.settledAgentCalls.set(key, settled);
return Promise.resolve(settled.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.

Should imperative actions or a public SurfaceModel method invalidate settledAgentCalls after remote mutations? If an action mutates server-side state without modifying the bound argument paths in DataModel, reactive bindings keep displaying stale cached agent responses until surface disposal.

🤖 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.

web_core (v1.0): unregistered function is not routed to the agent via callAgentFunction; binding silently resolves to undefined

2 participants