Skip to content
Merged
1 change: 1 addition & 0 deletions typescript/core/web_core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
- Add `MessageProcessor.catalogs`, the catalogs the processor was created with, and `ExecutionContext.validationConfig`, which `processMessages`, `processMessagesAsync` and `processOperation` apply to that call in place of the processor's own `validationConfig`. [#3597](https://github.com/a2ui-project/a2ui/issues/3597)
- Universal elements rendered from a `ComponentNode` read their props from the node instead of allocating a second `GenericBinder`, and `A2uiLitElement.renderNode` accepts a resolved `ComponentNode` as well as an `A2uiChildRef`. [#3705](https://github.com/a2ui-project/a2ui/pull/3705)
- (v1_0) `Catalog.invoker` reads a `ValidationResult` object that a function argument resolves to by its `valid` boolean wherever the function's schema types that argument as `DynamicBoolean`, as the specification defines the type. This applies to every `DynamicBoolean` argument, in built-in and custom catalogs, so `formatNumber`'s `grouping` and a custom function's boolean parameters now read a failing validation as `false` rather than as a truthy object; `and`, `or` and `not` already did. Catalogs below v1.0, whose validation functions return booleans, are unchanged. [#3653](https://github.com/a2ui-project/a2ui/pull/3653)
- Add `mapErrorToRendererMessage`, `extractEnvelopeErrorContext`, and `normalizeJsonPointer` (along with `RendererErrorMessage`, `A2uiRendererError`, `ValidationFailedErrorBody`, `GenericErrorBody`, and `ErrorEnvelopeContext`) to `@a2ui/web_core`, `@a2ui/web_core/v0_9`, `@a2ui/web_core/v1_0`, and `@a2ui/web_core/processing`, add `SurfaceGroupModel.onError`, `MessageProcessor.processMessagesWithErrors`, `MessageProcessorOptions.errorHandler`, and `MessageProcessorOptions.reportErrorsOnly` for structured outbound renderer error envelopes, add `A2uiErrorDetail`, `A2uiErrorContext`, and `A2uiError` context properties `path`/`surfaceId`/`functionCallId`, and emit RFC 6901 JSON Pointer `path` values on `UNALLOWED_PARENT` and `UNALLOWED_CHILD` composition constraint errors. [#3579](https://github.com/a2ui-project/a2ui/pull/3579)
- v1.0 function RPC payloads name the function with `'@call'`. `RpcHandler.callAgentFunction` rewrites a v0.9-style `call` key to `'@call'` in the outbound `callAgentFunction` message, and `MessageProcessor` passes `'@call'` in the `callRendererFunction` message it hands to the `RpcHandler` ([#3486](https://github.com/a2ui-project/a2ui/issues/3486)).
- **BREAKING CHANGE**: Remove cross-catalog `$ref` resolution across peer catalogs, added in 0.14.0. `Catalog.peerCatalogs`, `Catalog.withPeerCatalogs`, the optional `peerCatalogs` and `withPeerCatalogs` members of `CatalogInterface`, `linkCatalogs` and `inlinePeerAndLocalRefs` are removed, and `MessageProcessor` uses the catalogs passed to it as is. A catalog's `$ref`s resolve only against its own document and `common_types.json`, and `Catalog.fromJson` of a v1.0 catalog throws `A2uiCatalogError` (`Unsupported external $ref ...`) for a `$ref` to any other document, such as another catalog, without fetching it; v0.9 catalogs load as before. Recommended migration: copy the referenced definitions into each catalog that uses them. [#3232](https://github.com/a2ui-project/a2ui/issues/3232), [#3575](https://github.com/a2ui-project/a2ui/pull/3575)
- Function calls now await pending Promises in their arguments before validating and running, while synchronous calls remain synchronous. [#3578](https://github.com/a2ui-project/a2ui/pull/3578)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,9 @@ describe('v1.0 BASIC_FUNCTIONS', () => {
assert.deepStrictEqual(invoke('email', {value: 'test@example.com'}), {valid: true});
assert.deepStrictEqual(invoke('email', {value: 'invalid'}), {
valid: false,
message: 'Must be a valid email address.',
code: 'INVALID_EMAIL',
severity: 'error',
message: 'Please enter a valid email address (e.g. user@example.com).',
});
});

Expand Down
12 changes: 11 additions & 1 deletion typescript/core/web_core/src/common/basic_functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ export interface ValidationResult {
valid: boolean;
/** Explanatory message when validation failed. */
message?: string;
/** Optional machine-readable validation code. */
code?: string;
/** Optional severity level. */
severity?: 'error' | 'warning' | 'info';
}

/**
Expand Down Expand Up @@ -181,7 +185,13 @@ export function validateEmail(val: unknown): ValidationResult {
const isValid = typeof val === 'string' && EMAIL_REGEX.test(val);
return {
valid: isValid,
...(isValid ? {} : {message: 'Must be a valid email address.'}),
...(isValid
? {}
: {
code: 'INVALID_EMAIL',
severity: 'error',
message: 'Please enter a valid email address (e.g. user@example.com).',
}),
};
}

Expand Down
5 changes: 4 additions & 1 deletion typescript/core/web_core/src/common/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,10 @@ export class EventEmitter<T> implements EventSource<T> {
async emit(data: T): Promise<void> {
for (const listener of this.listeners) {
try {
await listener(data);
const res = listener(data);
if (res && typeof (res as Promise<void>).then === 'function') {
await res;
}
} catch (e) {
console.error('EventEmitter error:', e);
}
Expand Down
88 changes: 71 additions & 17 deletions typescript/core/web_core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,32 @@ interface V8ErrorConstructor extends ErrorConstructor {
captureStackTrace(targetObject: object, constructorOpt?: Function): void;
}

/**
* Structured validation or processing error detail for a specific field path.
*/
export interface A2uiErrorDetail {
/** RFC 6901 JSON Pointer path to the offending field. */
path: string;
/** Machine-readable error code. */
code: string;
/** Human-readable error message. */
message: string;
/** Optional target surface identifier. */
surfaceId?: string;
}

/**
* Optional context metadata for A2UI errors.
*/
export interface A2uiErrorContext {
/** RFC 6901 JSON Pointer path to the offending field. */
path?: string;
/** Target surface identifier. */
surfaceId?: string;
/** Correlated RPC function call identifier. */
functionCallId?: string;
}

/**
* Base class for all A2UI specific errors.
*
Expand All @@ -34,17 +60,29 @@ interface V8ErrorConstructor extends ErrorConstructor {
export class A2uiError extends Error {
/** Machine-readable string identifying the error category. */
public readonly code: string;
/** Optional RFC 6901 JSON Pointer path to the offending field. */
public path?: string;
/** Optional target surface identifier. */
public surfaceId?: string;
/** Optional correlated RPC function call identifier. */
public functionCallId?: string;

/**
* Initializes a new `A2uiError` instance.
*
* @param message Human-readable error description.
* @param code Machine-readable error category code.
* @param context Optional error context metadata (path, surfaceId, functionCallId).
*/
constructor(message: string, code: string = 'UNKNOWN_ERROR') {
constructor(message: string, code: string = 'UNKNOWN_ERROR', context?: A2uiErrorContext) {
super(message);
this.name = this.constructor.name;
this.code = code;
if (context?.path !== undefined) this.path = context.path;
if (context?.surfaceId !== undefined) this.surfaceId = context.surfaceId;
if (context?.functionCallId !== undefined) {
this.functionCallId = context.functionCallId;
}

// Maintains proper stack trace for where our error was thrown (only available on V8)
if ((Error as V8ErrorConstructor).captureStackTrace) {
Expand All @@ -63,13 +101,15 @@ export class A2uiValidationError extends A2uiError {
* @param message Error description detailing the validation failure.
* @param details Additional error context or Zod validation issues.
* @param code Optional error category code.
* @param context Optional error context metadata.
*/
constructor(
message: string,
public readonly details?: any,
code: string = 'VALIDATION_ERROR',
context?: A2uiErrorContext,
) {
super(message, code);
super(message, code, context);
}
}

Expand All @@ -82,12 +122,10 @@ export class A2uiDataError extends A2uiError {
*
* @param message Error description.
* @param path Target data model path where the mutation failed.
* @param surfaceId Optional target surface identifier.
*/
constructor(
message: string,
public readonly path?: string,
) {
super(message, 'DATA_ERROR');
constructor(message: string, path?: string, surfaceId?: string) {
super(message, 'DATA_ERROR', {path, surfaceId});
}
}

Expand All @@ -101,13 +139,15 @@ export class A2uiExpressionError extends A2uiError {
* @param message Error description.
* @param expression Evaluated expression string.
* @param details Additional error details.
* @param context Optional error context metadata.
*/
constructor(
message: string,
public readonly expression?: string,
public readonly details?: any,
context?: A2uiErrorContext,
) {
super(message, 'EXPRESSION_ERROR');
super(message, 'EXPRESSION_ERROR', context);
}
}

Expand All @@ -119,9 +159,10 @@ export class A2uiStateError extends A2uiError {
* Initializes a new `A2uiStateError` instance.
*
* @param message Error description.
* @param context Optional error context metadata.
*/
constructor(message: string) {
super(message, 'STATE_ERROR');
constructor(message: string, context?: A2uiErrorContext) {
super(message, 'STATE_ERROR', context);
}
}

Expand All @@ -137,9 +178,10 @@ export class A2uiCatalogError extends A2uiError {
*
* @param message Human-readable error description.
* @param code Machine-readable error category code. Defaults to `'CATALOG_ERROR'`.
* @param context Optional error context metadata.
*/
constructor(message: string, code: string = 'CATALOG_ERROR') {
super(message, code);
constructor(message: string, code: string = 'CATALOG_ERROR', context?: A2uiErrorContext) {
super(message, code, context);
}
}

Expand All @@ -153,9 +195,15 @@ export class A2uiIntegrityError extends A2uiValidationError {
* @param message Error description.
* @param details Additional error details.
* @param code Error category code. Defaults to 'INTEGRITY_ERROR'.
* @param context Optional error context metadata.
*/
constructor(message: string, details?: any, code: string = 'INTEGRITY_ERROR') {
super(message, details, code);
constructor(
message: string,
details?: any,
code: string = 'INTEGRITY_ERROR',
context?: A2uiErrorContext,
) {
super(message, details, code, context);
this.name = 'A2uiIntegrityError';
}
}
Expand All @@ -170,9 +218,15 @@ export class A2uiRecursionError extends A2uiValidationError {
* @param message Error description.
* @param details Additional error details.
* @param code Error category code. Defaults to 'RECURSION_ERROR'.
* @param context Optional error context metadata.
*/
constructor(message: string, details?: any, code: string = 'RECURSION_ERROR') {
super(message, details, code);
constructor(
message: string,
details?: any,
code: string = 'RECURSION_ERROR',
context?: A2uiErrorContext,
) {
super(message, details, code, context);
this.name = 'A2uiRecursionError';
}
}
Expand Down Expand Up @@ -225,7 +279,7 @@ export class A2uiRpcError extends A2uiError {
message: string,
code: RpcErrorCode | string = RpcErrorCode.UNKNOWN_ERROR,
/** Identifier of the failed function call, if available. */
public readonly functionCallId?: string,
public override readonly functionCallId?: string,
/** Structured error details or original error cause, if available. */
public readonly details?: unknown,
) {
Expand Down
24 changes: 21 additions & 3 deletions typescript/core/web_core/src/processing/adapters/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {z} from 'zod';
import {InternalOperation} from '../operations.js';
import {A2uiValidationError} from '../../errors.js';
import {formatZodIssue} from '../format-zod-issue.js';
import {extractEnvelopeErrorContext, mapErrorToRendererMessage} from '../message-error-mapper.js';
import {SemVer, normalizeVersionString, toSemVer, toCanonicalVersion} from '../../common/semver.js';

/**
Expand Down Expand Up @@ -167,7 +168,13 @@ function validateActionSurfaceIds(
actionObj.surfaceId !== undefined &&
typeof actionObj.surfaceId !== 'string'
) {
throw new A2uiValidationError('surfaceId must be a string');
const msg = 'surfaceId must be a string';
throw new A2uiValidationError(
msg,
[{path: `/${key}/surfaceId`, code: 'type_mismatch', message: msg}],
'VALIDATION_ERROR',
{path: '/surfaceId'},
);
}
}
}
Expand Down Expand Up @@ -232,8 +239,12 @@ export abstract class BaseVersionAdapter implements VersionAdapter {
);
const sortedAllowed = nativeActionKeys.slice().sort().join(', ');
if (otherAction) {
const msg = `Invalid ${this.version} message: action '${otherAction}' is not supported in protocol version ${this.version}. Allowed actions: ${sortedAllowed}.`;
throw new A2uiValidationError(
`Invalid ${this.version} message: action '${otherAction}' is not supported in protocol version ${this.version}. Allowed actions: ${sortedAllowed}.`,
msg,
[{path: '/version', code: 'invalid_value', message: msg}],
'VALIDATION_ERROR',
{path: '/version'},
);
}
throw new A2uiValidationError(
Expand All @@ -245,10 +256,17 @@ export abstract class BaseVersionAdapter implements VersionAdapter {
const parseResult = this.schema.safeParse(preparedPayload);
if (!parseResult.success) {
const formattedErrors = parseResult.error.errors.map(formatZodIssue).join('; ');
throw new A2uiValidationError(
const envCtx = extractEnvelopeErrorContext(msgObj, this.version);
const preliminaryErr = new A2uiValidationError(
`Invalid ${this.version} message: ${formattedErrors}`,
parseResult.error,
'VALIDATION_ERROR',
);
const mapped = mapErrorToRendererMessage(preliminaryErr, envCtx);
preliminaryErr.path = mapped.error.path;
preliminaryErr.surfaceId = mapped.error.surfaceId || undefined;
preliminaryErr.functionCallId = mapped.error.functionCallId;
throw preliminaryErr;
}

return this.extractOperationsFromObject(msgObj);
Expand Down
61 changes: 47 additions & 14 deletions typescript/core/web_core/src/processing/adapters/v1_0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,49 @@ import {
InternalOperation,
INTERNAL_OPERATION_TYPES,
} from '../operations.js';
import {A2uiValidationError} from '../../errors.js';
import {AgentToRendererMessageSchema} from '../../v1_0/schema/agent-to-renderer.js';

function assertObjectArgs(
cf: Record<string, unknown>,
version: ProtocolVersion,
functionCallId: string,
): void {
if (
Object.hasOwn(cf, 'args') &&
cf.args !== undefined &&
(typeof cf.args !== 'object' || cf.args === null || Array.isArray(cf.args))
) {
Comment thread
gspencergoog marked this conversation as resolved.
throw new A2uiValidationError(
`Invalid ${version} message: callRendererFunction.callFunction.args: Expected object, received ${Array.isArray(cf.args) ? 'array' : typeof cf.args}`,
undefined,
'VALIDATION_ERROR',
{functionCallId: functionCallId || undefined},
);
}
}

function extractCallRendererFunctionOp(
crf: Record<string, unknown> | undefined,
version: ProtocolVersion,
): InternalOperation {
const cf = (crf?.callFunction || {}) as Record<string, unknown>;
const functionCallId = String(crf?.functionCallId || '');
assertObjectArgs(cf, version, functionCallId);
return {
type: 'callRendererFunction',
surfaceId: typeof crf?.surfaceId === 'string' ? crf.surfaceId : undefined,
functionCallId,
call: String(cf?.['@call'] || cf?.call || ''),
version,
catalogId: typeof cf?.catalogId === 'string' ? cf?.catalogId : undefined,
args:
cf?.args && typeof cf.args === 'object' && !Array.isArray(cf.args)
? (cf.args as Record<string, unknown>)
: {},
};
}

/**
* Protocol version adapter for specification v1.0.
*/
Expand Down Expand Up @@ -84,20 +125,12 @@ export class V1Point0Adapter extends BaseVersionAdapter {
});
}
if ('callRendererFunction' in msgObj) {
const crf = msgObj.callRendererFunction as Record<string, unknown>;
const cf = (crf?.callFunction || {}) as Record<string, unknown>;
ops.push({
type: 'callRendererFunction',
surfaceId: typeof crf?.surfaceId === 'string' ? crf.surfaceId : undefined,
functionCallId: String(crf?.functionCallId || ''),
call: String(cf?.['@call'] || cf?.call || ''),
version: this.version,
catalogId: typeof cf?.catalogId === 'string' ? cf.catalogId : undefined,
args:
cf?.args && typeof cf.args === 'object' && !Array.isArray(cf.args)
? (cf.args as Record<string, unknown>)
: {},
});
ops.push(
extractCallRendererFunctionOp(
msgObj.callRendererFunction as Record<string, unknown>,
this.version,
),
);
}
if ('agentFunctionResponse' in msgObj) {
const afr = msgObj.agentFunctionResponse as Record<string, unknown>;
Expand Down
10 changes: 10 additions & 0 deletions typescript/core/web_core/src/processing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@
*/

export * from './message-processor.js';
export {
mapErrorToRendererMessage,
extractEnvelopeErrorContext,
normalizeJsonPointer,
type RendererErrorMessage,
type A2uiRendererError,
type ValidationFailedErrorBody,
type GenericErrorBody,
type ErrorEnvelopeContext,
} from './message-error-mapper.js';
export * from './adapters/factory.js';
export * from './adapters/base.js';
export * from '../errors.js';
Loading
Loading