Repository navigation
Conversation
…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>
There was a problem hiding this comment.
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>
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
left a comment
There was a problem hiding this comment.
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
| if (!this.surface?.disposed) { | ||
| this.dispatchExpressionError(e, callName); | ||
| } |
There was a problem hiding this comment.
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
| 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; | ||
| } |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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
| 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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
| 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, | ||
| ), | ||
| ); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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.
| - `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) | |||
There was a problem hiding this comment.
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.
| - (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. |
There was a problem hiding this comment.
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.
| 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
| 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); |
There was a problem hiding this comment.
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
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
callAgentFunctionwhen it has no local implementation of the function (the fallback routing in thecallAgentFunctionsection of the protocol doc). web_core never did this. A binding, check rule oraction.functionCallthat named such a function failed with anEXPRESSION_ERROR("Function not found" or "schema-only and has no implementation") and never reached the agent. Only app code that calledMessageProcessor.callAgentFunctiondirectly 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
types.ts). In a v1.0 catalog, a call to a function without anexecuteimplementation (undeclared, or declared schema-only) goes to the surface's agent function caller instead of throwing. Specifically:callFunctionalways carries thecatalogIdthe 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,callRendererFunctionalways names its catalog, and nowcallAgentFunctiondoes too, so the agent never has to know a surface's default catalog.requiresUserActivationis enforced) before the call is sent. An undeclared function's arguments are sent as resolved.@system functions and pre-1.0 catalogs are never routed.SurfaceModel.agentFunctionCallerandSurfaceModel.callAgentFunction(surface-model.ts). The caller is the hook the invoker uses.callAgentFunctionshares 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.MessageProcessorwiring (message-processor.ts). On each v1.0 surface, when the processor has anoutboundListener, it installs a caller that dispatches through itsRpcHandler. Without anoutboundListenernothing changes: such calls still fail locally, since there is no way to reach the agent.a2ui_protocol.md):catalogIdoncallAgentFunctionto the resolved catalog. The example now includes it.ValidationConfig.allowUnknownElementsdocstring (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
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 mentionnull; I kept the existing web_core convention rather than special-casing agent calls.EXPRESSION_ERRORthrough the surface'sonError, with the agent's error message.undefined. Inside function arguments,DataContextkeeps 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
agent-function-routing.test.ts, which runs throughMessageProcessorend to end:catalogId, is pending, then takes the response value;formatNumber(fetchFxRate(...))case from web_core (v1.0): unregistered function is not routed to the agent via callAgentFunction; binding silently resolves to undefined #3415);EXPRESSION_ERROR;action.functionCallis routed;catalogIdraisesCATALOG_ERRORwithout routing;outboundListeneror on a pre-1.0 surface.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)
callAgentFunction). This overlaps with [v1.0] Agent SDK has no RPC helpers for function calls #3535.