Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions specification/v1_0/docs/a2ui_protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -1428,16 +1428,19 @@ This message is sent by the renderer to execute a function remotely on the agent

When the renderer evaluates a `FunctionCall` (from an action handler, validation check, or dynamic value), it determines the execution target using implicit fallback routing:

1. **Local Lookup:** The renderer checks if a local renderer-side function with that name is registered in its local catalog/registry. If found, the renderer executes the function locally.
2. **Fallback to Agent RPC:** If the function is not registered in the local renderer catalog, the renderer assumes it is an agent-side function and dispatches a `callAgentFunction` message over the protocol.
3. **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).
1. **Catalog Resolution:** The renderer resolves the catalog the call runs in, from its explicit `catalogId` or the surface default `catalogId` (see [Mixable catalogs and component resolution logic](#mixable-catalogs-and-component-resolution-logic)). A call whose catalog cannot be resolved is a catalog error, and is not routed to the agent.
2. **Local Lookup:** The renderer checks whether it has a local implementation of the function in that catalog. A function counts as registered locally only if the renderer can execute it. If it can, the renderer executes the function locally.
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.
Comment on lines +1432 to +1433

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.

Steps 2 and 3 dispatch callAgentFunction for any unimplemented function, omitting allowedCallers: "agentOnly", passive requiresUserActivation: true, and @ system functions. Implementers following Steps 1 to 3 verbatim will forward agentOnly functions, passive requiresUserActivation calls, or unknown @ functions to the agent.

Suggested change
2. **Local Lookup:** The renderer checks whether it has a local implementation of the function in that catalog. A function counts as registered locally only if the renderer can execute it. If it can, the renderer executes the function locally.
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.
2. **Local Lookup:** The renderer checks whether the call targets a system function (`@`-prefixed, such as `@index`) or a local implementation in that catalog. System functions are always evaluated locally and MUST NOT fall back to the agent. A catalog function counts as registered locally only if the renderer can execute it; if so, the renderer enforces its `allowedCallers` and `requiresUserActivation` boundaries and executes it locally.
3. **Fallback to Agent RPC:** If the renderer has no local implementation, the renderer checks any schema-only catalog declaration before dispatching `callAgentFunction`. If the declared function sets `allowedCallers: "agentOnly"`, or sets `requiresUserActivation: true` during passive evaluation, the renderer MUST block the call locally with `EXPRESSION_ERROR` instead of dispatching it. Otherwise, the renderer validates declared arguments against the schema and dispatches `callAgentFunction` for both undeclared and schema-only functions.

🤖 Agent-generated

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


**Properties:**

- `callAgentFunction` (object, required):
- `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


**Example:**

Expand All @@ -1449,6 +1452,7 @@ When the renderer evaluates a `FunctionCall` (from an action handler, validation
"functionCallId": "verify_provider_99",
"callFunction": {
"@call": "verifyProvider",
"catalogId": "https://example.com/catalogs/healthcare.json",
"args": {
"providerId": "PRV-102"
}
Expand Down
1 change: 1 addition & 0 deletions typescript/core/web_core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

- `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`.
- **BREAKING CHANGE**: A catalog's `invoker` refuses a function whose `allowedCallers` is `'agentOnly'` with an `A2uiExpressionError`, before running it, so a property binding, check or function call action that calls one reports an `EXPRESSION_ERROR` instead of running it, as the v1.0 specification requires. `RpcHandler` still runs such a function for an agent's `callRendererFunction`. Recommended migration: set `allowedCallers` to `'rendererOrAgent'` on a function both the renderer and the agent call ([#3483](https://github.com/a2ui-project/a2ui/issues/3483), [#3557](https://github.com/a2ui-project/a2ui/pull/3557)).
- **BREAKING CHANGE**: Unify the v1.0 protocol version wire value on `'1.0'` (without a `'v'` prefix) across message envelopes, `MessageProcessor.getRendererCapabilities()`, `RpcHandler`, and catalog documents (`ProtocolVersion` uses `'1.0'`), while accepting `'v1.0'` in `VersionAdapterFactory.getAdapter` and `Catalog.fromJson`. [#3097](https://github.com/a2ui-project/a2ui/issues/3097), [#3726](https://github.com/a2ui-project/a2ui/pull/3726)
Expand Down
66 changes: 58 additions & 8 deletions typescript/core/web_core/src/catalog/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {Signal} from '../reactivity/signals.js';
import {A2uiCatalogError, A2uiExpressionError} from '../errors.js';
import {A2uiMetadata} from '../common/metadata.js';
import {isAtLeastVersion} from '../common/semver.js';
import {isThenable, settleDeep} from '../common/settle_deep.js';
import {findFailedArgument, isThenable, settleDeep} from '../common/settle_deep.js';
import {SpecVersion} from '../spec_versions.js';
import {parseCatalogSchema} from './schema_loader.js';
import type {JsonSchemaObject} from './spec_annotations.js';
Expand Down Expand Up @@ -915,18 +915,66 @@ function createCatalogInvoker<F extends FunctionApi>(
protocolVersion: ProtocolVersion | string,
functions: ReadonlyMap<string, F>,
): FunctionInvoker {
const atLeastV10 = isAtLeastVersion(protocolVersion, SpecVersion.V1_0);
return (name, rawArgs, ctx, abortSignal) => {
const fn =
functions.get(name) ??
(isAtLeastVersion(protocolVersion, SpecVersion.V1_0) && name === '@index'
? (IndexImplementation as unknown as F)
: undefined);
(atLeastV10 && name === '@index' ? (IndexImplementation as unknown as F) : undefined);
const execute = (fn as Partial<FunctionImplementation> | undefined)?.execute;

if (typeof execute !== 'function') {
// From v1.0, a function the renderer does not implement (undeclared, or
// declared schema-only) runs on the agent: the fallback routing of
// `callAgentFunction`. The call always names this catalog, so the agent
// never has to know the surface's default catalog.
const surface = atLeastV10 && !name.startsWith('@') ? ctx?.surface : undefined;
if (surface?.agentFunctionCaller) {
if (fn) {
assertRendererCaller(fn);
assertUserActivation(fn, ctx);
}
const bypassCache = Boolean(ctx?.isActionExecution);
const sendToAgent = (resolvedArgs: unknown): unknown => {
const failed = findFailedArgument(resolvedArgs);
if (failed) {
return failed;
}
let args = (resolvedArgs ?? {}) as Record<string, unknown>;
if (fn) {
// A declared function's arguments are checked against its schema
// before the call leaves the renderer.
args = coerceValidationResults(fn.schema, args) as Record<string, unknown>;
const parsed = fn.schema.safeParse(args);
if (!parsed.success) {
throw new A2uiExpressionError(
`Validation failed for function '${name}': ${parsed.error.message}`,
name,
parsed.error.errors,
);
}
// Send what a local implementation would receive: the parsed
// arguments, with the schema's defaults applied.
args = parsed.data as Record<string, unknown>;
}
return surface.callAgentFunction({'@call': name, catalogId, args}, abortSignal, {
bypassCache,
});
};
const settledArgs = settleDeep(rawArgs);
if (!isThenable(settledArgs)) {
return sendToAgent(settledArgs);
}
return Promise.resolve(settledArgs).then(resolvedArgs =>
abortSignal?.aborted ? undefined : sendToAgent(resolvedArgs),
);
}
}

if (!fn) {
throw new A2uiExpressionError(`Function not found in catalog '${catalogId}': ${name}`, name);
}
assertRendererCaller(fn);
assertUserActivation(fn, ctx);
const execute = (fn as Partial<FunctionImplementation>).execute;
if (typeof execute !== 'function') {
throw new A2uiExpressionError(
`Function '${name}' in catalog '${catalogId}' is schema-only and has no implementation.`,
Expand All @@ -935,13 +983,15 @@ function createCatalogInvoker<F extends FunctionApi>(
}

const parseAndExecute = (resolvedArgs: unknown): unknown => {
const failed = findFailedArgument(resolvedArgs);
if (failed) {
return failed;
}
try {
// From v1.0 a `DynamicBoolean` argument may resolve to the
// `ValidationResult` a validation function returns; it is read by its
// validity, as the specification defines the type.
const args = isAtLeastVersion(protocolVersion, SpecVersion.V1_0)
? coerceValidationResults(fn.schema, resolvedArgs)
: resolvedArgs;
const args = atLeastV10 ? coerceValidationResults(fn.schema, resolvedArgs) : resolvedArgs;
const safeArgs = fn.schema.parse(args);
return execute.call(fn, safeArgs, ctx, abortSignal);
} catch (e: any) {
Expand Down
63 changes: 61 additions & 2 deletions typescript/core/web_core/src/common/settle_deep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,56 @@
* when nothing in it is pending, so synchronous expressions stay synchronous.
*/

/**
* Marker for a function argument whose evaluation has already failed and been
* reported on the surface. Enclosing function calls must not run or report a
* duplicate surface error when any argument resolves to a `FailedArgument`.
*/
export class FailedArgument {
constructor(readonly error: unknown) {}
}

/**
* Finds the first `FailedArgument` nested inside `value`, walking arrays and
* plain objects recursively.
*
* @param value The settled value or argument map to inspect.
* @returns The first `FailedArgument` encountered, or `undefined` if none is present.
*/
export function findFailedArgument(value: unknown): FailedArgument | undefined {
if (value instanceof FailedArgument) {
return value;
}
if (Array.isArray(value)) {
for (const item of value) {
const found = findFailedArgument(item);
if (found) return found;
}
return undefined;
}
if (isPlainObject(value)) {
for (const item of Object.values(value)) {
const found = findFailedArgument(item);
if (found) return found;
}
return undefined;
}
return undefined;
}

const settledPromises = new WeakMap<PromiseLike<unknown>, {value: unknown}>();

/**
* Records the settled value of a tracked Promise so subsequent `settleDeep`
* passes can read it synchronously when a sibling reactive dependency changes.
*
* @param promise The Promise that settled.
* @param value The settled value.
*/
export function recordSettledValue(promise: PromiseLike<unknown>, value: unknown): void {
settledPromises.set(promise, {value});
}

/**
* Checks whether a value is a Promise-like object.
*
Expand Down Expand Up @@ -68,16 +118,25 @@ export function isPlainObject(value: unknown): value is Record<string, unknown>
*/
export function settleDeep<T>(value: T): T | Promise<unknown> {
if (isThenable(value)) {
const recorded = settledPromises.get(value);
if (recorded !== undefined) {
return settleDeep(recorded.value) as T | Promise<unknown>;
}
return Promise.resolve(value).then(settleDeep);
}
if (Array.isArray(value)) {
const items = value.map(settleDeep);
return items.some(isThenable) ? Promise.all(items) : value;
if (!items.some(isThenable)) {
return (items.every((item, i) => item === value[i]) ? value : items) as T;
}
return Promise.all(items);
}
if (isPlainObject(value)) {
const entries = Object.entries(value).map(([key, item]) => [key, settleDeep(item)] as const);
if (!entries.some(([, item]) => isThenable(item))) {
return value;
return (
entries.every(([key, item]) => item === value[key]) ? value : Object.fromEntries(entries)
) as T;
}
return Promise.all(entries.map(([, item]) => item)).then(settled =>
Object.fromEntries(entries.map(([key], i) => [key, settled[i]])),
Expand Down
Loading
Loading