Skip to content
Merged
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
9 changes: 4 additions & 5 deletions blueprints/modules/a2ui_core.blueprint.md
Original file line number Diff line number Diff line change
Expand Up @@ -1000,12 +1000,11 @@ export class RpcHandler {
#### RPC Execution Lifecycle & Safeguards

1. **Inbound Call Execution (`handleCallRendererFunction`)**:
- **Catalog Resolution Order**: An explicit `catalogId` and its absence are two separate paths, not steps in one fallback chain.
1. If the call carries a `catalogId`, resolve it against the surface's `availableCatalogs`, then against the processor's global catalog list. If neither has it, return `RpcErrorCode.INVALID_FUNCTION_CALL`. Do not fall through to the surface default: the caller named a catalog, and answering with a different one is worse than failing.
2. If the call carries no `catalogId`, use the surface's `defaultCatalog`.
3. If neither path yields a catalog, return `RpcErrorCode.INVALID_FUNCTION_CALL`.
- **Catalog Resolution Order**: Resolve the call's `catalogId` against the surface's `availableCatalogs`, then against the processor's global catalog list. If neither has it, return `RpcErrorCode.INVALID_FUNCTION_CALL`. Do not fall through to the surface default.

Falling back to the first entry of the global catalog list is wrong for the same reason. It resolves against a catalog the surface may never have negotiated, so a typo in `catalogId` executes a same-named function from an unrelated catalog instead of failing. Once resolved, verify the catalog's `protocolVersion` is compatible with the surface before executing.
Falling back to the first entry of the global catalog list is also wrong. It resolves against a catalog the surface may never have negotiated, so a typo in `catalogId` executes a same-named function from an unrelated catalog instead of failing. Once resolved, verify the catalog's `protocolVersion` is compatible with the surface before executing.

A call with no `catalogId` should not reach this step. The schema requires `catalogId` on `callRendererFunction`, so inbound parsing should reject the message with `A2uiValidationError` and no `rendererFunctionResponse` will be sent. Agents should check this field against the schema before sending the message.

- **Function Resolution & Argument Coercion**: Look up the function in the resolved catalog. If it is absent, or argument validation fails, return `RpcErrorCode.INVALID_FUNCTION_CALL`. Execute with the coerced arguments returned by `validateFunction`, never the raw payload.
- **Permission & Activation Boundaries**: Verifies execution permissions. If a function requires user activation (e.g., audio playback or clipboard write), execution checks that `isUserActivated === true`.
Expand Down
22 changes: 22 additions & 0 deletions conformance/core/rpc_functions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,28 @@
error:
message: "callFunction"

- name: test_rpc_call_renderer_function_invalid_payload_missing_catalog_id
description: Verifies a callRendererFunction whose callFunction has no catalogId fails schema validation, even when a held catalog defines the function.
catalog:
protocolVersion: "v1.0"
action: handle_rpc
args:
message:
version: "v1.0"
callRendererFunction:
functionCallId: "call-110"
callFunction:
"@call": "playMedia"
args:
mediaId: "video-789"
functionMetadata:
playMedia:
allowedCallers: "rendererOrAgent"
requiresUserActivation: false
expect:
error:
message: "catalogId"

- name: test_rpc_agent_function_response_inbound
description: Verifies inbound agentFunctionResponse message processing by MessageProcessor.
catalog:
Expand Down
2 changes: 1 addition & 1 deletion specification/v1_0/docs/a2ui_protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ This message is sent by the agent to execute a function registered on the render
- `functionCallId` (string, required): A unique identifier for this invocation instance. The renderer MUST copy this ID verbatim into the subsequent `rendererFunctionResponse` message.
- `callFunction` (object, required): The description of the function call.
- `@call` (string, required): The registered name of the function to execute.
- `catalogId` (string, required): The catalog ID defining the function to execute. `catalogId` MUST be specified on `callRendererFunction`; if omitted or unregistered, the renderer rejects the call with `code: "INVALID_FUNCTION_CALL"`.
- `catalogId` (string, required): The catalog ID defining the function to execute. `catalogId` MUST be specified on `callRendererFunction`. A call without it violates the schema, so the message is malformed, and should be rejected. Agents should validate outgoing messages against the schema so that a missing `catalogId` never reaches the renderer. If `catalogId` names a catalog the renderer has not registered, the renderer rejects the call with `code: "INVALID_FUNCTION_CALL"`.
- `args` (object, optional): Arguments passed to the function, as defined by its schema in the catalog.

**Evaluation Scope and Ordering:**
Expand Down
63 changes: 34 additions & 29 deletions swift/core/Tests/A2UIConformanceTests/RPCConformanceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,40 +133,45 @@ struct RPCConformanceTests {

// Inbound call
if let messageJSON = testCase.args?["message"] {
let data = try JSONEncoder().encode(messageJSON)
let expectedErr = testCase.expect?["error"]?.objectValue
let message: AgentToRendererMessage?
do {
let message = try JSONDecoder().decode(AgentToRendererMessage.self, from: data)
if case .callRendererFunction(let callMsg) = message {
let response = await rpcHandler.handleIncomingCall(
callMsg,
catalogs: catalogs,
userActivationPresent: userActivation
)

if let expectedResp = testCase.expect?["response"]?["rendererFunctionResponse"] {
let expectedCallID = expectedResp["functionCallId"]?.stringValue
#expect(response.functionCallID == expectedCallID)

if let expectedValue = expectedResp["value"] {
#expect(response.value == expectedValue)
}

if let expectedErr = expectedResp["error"] {
let expectedCode = expectedErr["code"]?.stringValue
let expectedMsg = expectedErr["message"]?.stringValue
#expect(response.error?.code == expectedCode)
if let expectedMsg {
#expect(response.error?.message.contains(expectedMsg) == true)
}
}
}
}
// parseAll runs the version adapter's schema checks, which a bare
// JSONDecoder skips (e.g. a missing callFunction.catalogId).
message = try AgentToRendererMessage.parseAll(messageJSON).first
#expect(expectedErr == nil, "\(testCase.name): expected a validation error")
} catch {
// If schema validation rejected invalid payload (e.g. missing callFunction)
if let expectedErr = testCase.expect?["error"]?.objectValue {
let msgSub = expectedErr["message"]?.stringValue ?? ""
#expect(expectedErr != nil, "\(testCase.name): unexpected error \(error)")
if let msgSub = expectedErr?["message"]?.stringValue {
#expect("\(error)".contains(msgSub))
}
message = nil
}
if case .callRendererFunction(let callMsg)? = message {
Comment thread
polina-c marked this conversation as resolved.
Resolved
let response = await rpcHandler.handleIncomingCall(
callMsg,
catalogs: catalogs,
userActivationPresent: userActivation
)

if let expectedResp = testCase.expect?["response"]?["rendererFunctionResponse"] {
let expectedCallID = expectedResp["functionCallId"]?.stringValue
#expect(response.functionCallID == expectedCallID)

if let expectedValue = expectedResp["value"] {
#expect(response.value == expectedValue)
}

if let expectedErr = expectedResp["error"] {
let expectedCode = expectedErr["code"]?.stringValue
let expectedMsg = expectedErr["message"]?.stringValue
#expect(response.error?.code == expectedCode)
if let expectedMsg {
#expect(response.error?.message.contains(expectedMsg) == true)
}
}
}
}
}

Expand Down
Loading