Skip to content

Commit 830c2eb

Browse files
committed
feat(ui): add A2A error extension (work in progress)
Signed-off-by: Petr Bulánek <bulanek.petr@gmail.com>
1 parent f14e5b5 commit 830c2eb

File tree

15 files changed

+135
-23
lines changed

15 files changed

+135
-23
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import z from 'zod';
7+
8+
import type { A2AUiExtension } from '../types';
9+
10+
const URI = 'https://a2a-extensions.agentstack.beeai.dev/ui/error/v1';
11+
12+
const schema = z.object({
13+
message: z.string(),
14+
title: z.string().nullish(),
15+
context: z.record(z.string(), z.unknown()).nullish(),
16+
stacktrace: z.string().nullish(),
17+
});
18+
19+
export type ErrorMetadata = z.infer<typeof schema>;
20+
21+
export const errorExtension: A2AUiExtension<typeof URI, ErrorMetadata> = {
22+
getMessageMetadataSchema: () => z.object({ [URI]: schema }).partial(),
23+
getUri: () => URI,
24+
};

apps/agentstack-sdk-ts/src/client/api/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import { z } from 'zod';
6+
import z from 'zod';
77

88
export const contextSchema = z.object({
99
id: z.string(),

apps/agentstack-sdk-ts/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export * from './client/a2a/extensions/services/secrets';
2222
export * from './client/a2a/extensions/types';
2323
export * from './client/a2a/extensions/ui/agent-detail';
2424
export * from './client/a2a/extensions/ui/citation';
25+
export * from './client/a2a/extensions/ui/error';
2526
export * from './client/a2a/extensions/ui/form-request';
2627
export * from './client/a2a/extensions/ui/oauth';
2728
export * from './client/a2a/extensions/ui/settings';

apps/agentstack-ui/src/api/a2a/client.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { handleAgentCard, handleInputRequired, handleTaskStatusUpdate } from 'ag
99
import { defaultIfEmpty, filter, lastValueFrom, Subject } from 'rxjs';
1010
import { match } from 'ts-pattern';
1111

12-
import { UnauthenticatedError } from '#api/errors.ts';
12+
import { A2AExtensionError, UnauthenticatedError } from '#api/errors.ts';
1313
import type { UIMessagePart } from '#modules/messages/types.ts';
1414
import type { TaskId } from '#modules/tasks/api/types.ts';
1515
import { getBaseUrl } from '#utils/api/getBaseUrl.ts';
@@ -18,15 +18,20 @@ import { AGENT_ERROR_MESSAGE } from './constants';
1818
import { processMessageMetadata, processParts } from './part-processors';
1919
import type { ChatResult, TaskStatusUpdateResultWithTaskId } from './types';
2020
import { type ChatParams, type ChatRun, RunResultType } from './types';
21-
import { createUserMessage, extractTextFromMessage } from './utils';
21+
import { createUserMessage, extractErrorExtension, extractTextFromMessage } from './utils';
2222

2323
function handleStatusUpdate<UIGenericPart = never>(
2424
event: TaskStatusUpdateEvent,
2525
onStatusUpdate?: (event: TaskStatusUpdateEvent) => UIGenericPart[],
2626
): (UIMessagePart | UIGenericPart)[] {
2727
const { message, state } = event.status;
28+
const extensionError = extractErrorExtension(message?.metadata);
2829

2930
if (state === 'failed' || state === 'rejected') {
31+
if (extensionError) {
32+
throw new A2AExtensionError(extensionError);
33+
}
34+
3035
const errorMessage = extractTextFromMessage(message) ?? AGENT_ERROR_MESSAGE;
3136

3237
throw new Error(errorMessage);

apps/agentstack-ui/src/api/a2a/utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { FilePart, FileWithUri, Message, Part, TextPart } from '@a2a-js/sdk
77
import {
88
type Citation,
99
citationExtension,
10+
errorExtension,
1011
extractUiExtensionData,
1112
trajectoryExtension,
1213
type TrajectoryMetadata,
@@ -30,6 +31,7 @@ import { PLATFORM_FILE_CONTENT_URL_BASE } from './constants';
3031

3132
export const extractCitation = extractUiExtensionData(citationExtension);
3233
export const extractTrajectory = extractUiExtensionData(trajectoryExtension);
34+
export const extractErrorExtension = extractUiExtensionData(errorExtension);
3335

3436
export function extractTextFromMessage(message: Message | undefined) {
3537
const text = message?.parts

apps/agentstack-ui/src/api/errors.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import type { ApiErrorCode, ApiErrorResponse, ApiValidationErrorResponse, StreamErrorResponse } from './types';
6+
import type {
7+
A2AErrorMetadata,
8+
ApiErrorCode,
9+
ApiErrorResponse,
10+
ApiValidationErrorResponse,
11+
StreamErrorResponse,
12+
} from './types';
713

814
export class ErrorWithResponse extends Error {
915
name: string;
@@ -64,3 +70,17 @@ export class StreamError extends ErrorWithResponse {
6470
}
6571

6672
export class UnauthenticatedError extends ErrorWithResponse {}
73+
74+
export class A2AExtensionError extends Error {
75+
title: NonNullable<A2AErrorMetadata['title']>;
76+
context: A2AErrorMetadata['context'];
77+
stacktrace: A2AErrorMetadata['stacktrace'];
78+
79+
constructor({ message, title, context, stacktrace }: A2AErrorMetadata) {
80+
super(message);
81+
82+
this.title = title ?? 'A2AExtensionError';
83+
this.context = context;
84+
this.stacktrace = stacktrace;
85+
}
86+
}

apps/agentstack-ui/src/api/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
import type { components } from './schema';
77

8+
export type { ErrorMetadata as A2AErrorMetadata } from 'agentstack-sdk';
9+
810
export type ApiErrorResponse = {
911
code: string;
1012
message?: string;

apps/agentstack-ui/src/api/utils.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@ import type { ServerSentEventMessage } from 'fetch-event-stream';
88
import type { FetchResponse } from 'openapi-fetch';
99
import type { MediaType } from 'openapi-typescript-helpers';
1010

11+
import type { QueryMetadataError } from '#contexts/QueryProvider/types.ts';
12+
import type { Toast } from '#contexts/Toast/toast-context.ts';
1113
import type { Agent } from '#modules/agents/api/types.ts';
1214
import { NEXTAUTH_URL, TRUST_PROXY_HEADERS } from '#utils/constants.ts';
1315
import { isNotNull } from '#utils/helpers.ts';
16+
import { createSection, joinSections } from '#utils/markdown.ts';
1417

15-
import { ApiError, ApiValidationError, HttpError, UnauthenticatedError } from './errors';
16-
import type { ApiErrorCode, ApiErrorResponse, ApiValidationErrorResponse } from './types';
18+
import { A2AExtensionError, ApiError, ApiValidationError, HttpError, UnauthenticatedError } from './errors';
19+
import type { A2AErrorMetadata, ApiErrorCode, ApiErrorResponse, ApiValidationErrorResponse } from './types';
1720

1821
export function ensureData<T extends Record<string | number, unknown>, O, M extends MediaType>(
1922
fetchResponse: FetchResponse<T, O, M>,
@@ -111,3 +114,45 @@ export async function getProxyHeaders(headers: Headers, url?: URL) {
111114

112115
return { forwardedHost, forwardedProto, forwarded };
113116
}
117+
118+
export function buildErrorToast({ metadata = {}, error }: { metadata?: QueryMetadataError; error: unknown }): Toast {
119+
const { includeErrorMessage } = metadata;
120+
121+
const defaults: Partial<Toast> = {
122+
kind: 'error',
123+
renderMarkdown: true,
124+
};
125+
126+
if (error instanceof A2AExtensionError) {
127+
const message = includeErrorMessage ? createA2AErrorMessage(error) : undefined;
128+
129+
return {
130+
...defaults,
131+
title: error.title,
132+
message,
133+
};
134+
}
135+
136+
const { title = 'An error occurred' } = metadata;
137+
const message = joinSections([metadata.message, includeErrorMessage ? getErrorMessage(error) : undefined]);
138+
139+
return {
140+
...defaults,
141+
title,
142+
message,
143+
};
144+
}
145+
146+
function createA2AErrorMessage(error: A2AErrorMetadata) {
147+
const { context, stacktrace } = error;
148+
149+
const errorMessage = getErrorMessage(error);
150+
const contextMessage = context
151+
? createSection({ heading: 'Context', content: JSON.stringify(context, null, 2) })
152+
: undefined;
153+
const stacktraceMessage = stacktrace ? createSection({ heading: 'Stacktrace', content: stacktrace }) : undefined;
154+
155+
const message = joinSections([errorMessage, contextMessage, stacktraceMessage]);
156+
157+
return message;
158+
}

apps/agentstack-ui/src/components/ErrorMessage/ErrorMessage.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,35 @@
66
import { ActionableNotification, Button, InlineLoading } from '@carbon/react';
77
import type { ReactNode } from 'react';
88

9+
import { LineClampText } from '#components/LineClampText/LineClampText.tsx';
910
import { MarkdownContent } from '#components/MarkdownContent/MarkdownContent.tsx';
1011

1112
import classes from './ErrorMessage.module.scss';
1213

1314
interface Props {
1415
title?: string;
15-
subtitle?: string;
16+
message?: string;
1617
onRetry?: () => void;
1718
isRefetching?: boolean;
1819
children?: ReactNode;
1920
}
2021

21-
export function ErrorMessage({ title, subtitle, onRetry, isRefetching, children }: Props) {
22+
export function ErrorMessage({ title, message, onRetry, isRefetching }: Props) {
2223
return (
2324
<ActionableNotification title={title} kind="error" lowContrast hideCloseButton>
24-
{(subtitle || onRetry) && (
25+
{(message || onRetry) && (
2526
<div className={classes.body}>
26-
{subtitle && <MarkdownContent>{subtitle}</MarkdownContent>}
27+
{message && (
28+
<LineClampText lines={4} useBlockElement>
29+
<MarkdownContent>{message}</MarkdownContent>
30+
</LineClampText>
31+
)}
2732

2833
{onRetry && (
2934
<Button size="sm" onClick={() => onRetry()} disabled={isRefetching}>
3035
{!isRefetching ? 'Retry' : <InlineLoading description="Retrying&hellip;" />}
3136
</Button>
3237
)}
33-
{children}
3438
</div>
3539
)}
3640
</ActionableNotification>

apps/agentstack-ui/src/hooks/useHandleError.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@ import { useRouter } from 'next/navigation';
66
import { useCallback } from 'react';
77

88
import { UnauthenticatedError } from '#api/errors.ts';
9-
import { getErrorMessage } from '#api/utils.ts';
9+
import { buildErrorToast } from '#api/utils.ts';
1010
import type { QueryMetadata } from '#contexts/QueryProvider/types.ts';
1111
import { useToast } from '#contexts/Toast/index.ts';
12-
import { isNotNull } from '#utils/helpers.ts';
1312
import { routes } from '#utils/router.ts';
1413

1514
export function useHandleError() {
@@ -25,14 +24,9 @@ export function useHandleError() {
2524
router.replace(routes.signIn({ callbackUrl }));
2625
console.error(error);
2726
} else if (errorToast !== false) {
28-
const errorMessage = errorToast?.includeErrorMessage ? getErrorMessage(error) : undefined;
27+
const toast = buildErrorToast({ metadata: errorToast, error });
2928

30-
addToast({
31-
kind: 'error',
32-
title: errorToast?.title ?? 'An error occurred',
33-
message: [errorToast?.message, errorMessage].filter(isNotNull).join('\n\n'),
34-
renderMarkdown: true,
35-
});
29+
addToast(toast);
3630
} else {
3731
console.error(error);
3832
}

0 commit comments

Comments
 (0)