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
25 changes: 23 additions & 2 deletions .github/workflows/build-tests.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
# Ensures no accidential Node.js API usage in browser and Edge compatible entrypoints.
# Ensures no accidental Node.js API usage in browser and Edge compatible
# entrypoints, and that transport-scoped entrypoints resolve only their
# declared peers. Each `test-build:*` step runs in a DIFFERENT environment:
# 1. workers-safe: NO optional peer deps installed (no @grpc/grpc-js,
# @bufbuild/protobuf, or express). esbuild fails natively if any
# Workers-safe entry pulls them in. Mirrors what a real Workers /
# REST-only consumer gets.
# 2. grpc: @grpc/grpc-js + @bufbuild/protobuf reinstalled (still no
# express); every gRPC entry must resolve.
# 3. express: express reinstalled on top; every express entry must
# resolve.

name: Run Build Tests

Expand Down Expand Up @@ -27,4 +37,15 @@ jobs:
cache: 'npm'
- run: npm ci
- run: npm run build
- run: npm run test-build
# Strip ALL three optional peer deps so esbuild sees the same
# module graph a non-gRPC, non-Express consumer would. Any
# Workers-safe entrypoint that leaks one of them fails to resolve.
- run: npm uninstall --no-save @grpc/grpc-js @bufbuild/protobuf express
- run: npm run test-build:workers-safe
# Reinstall only the gRPC peers; verify every gRPC entry resolves
# (and would fail here if any of them silently required express).
- run: npm install --no-save @grpc/grpc-js @bufbuild/protobuf
- run: npm run test-build:grpc
# Reinstall express; verify every express entry resolves.
- run: npm install --no-save express
- run: npm run test-build:express
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ npm install express

### For gRPC Usage

If you plan to use the GRPC transport (imports from `@a2a-js/sdk/server/grpc` or `@a2a-js/sdk/client/grpc`), you must install the required peer dependencies:
If you plan to use the GRPC transport (imports from `@a2a-js/sdk/server/grpc`, `@a2a-js/sdk/client/grpc`, or the gRPC-specific error helpers in `@a2a-js/sdk/errors/grpc`), you must install the required peer dependencies:

```bash
npm install @grpc/grpc-js @bufbuild/protobuf
Expand Down
71 changes: 53 additions & 18 deletions docs/migration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,12 @@ tenant-prefixed routes (`/:tenant/tasks/:taskId`, etc.) and validates the

### 3.2 Error Classes Replaced

The monolithic `A2AError` class with static factory methods is removed.
Use specific error classes:
The monolithic `A2AError` class with static factory methods is removed. Errors
now form a shared transport-agnostic hierarchy — one `A2AError` base with
semantic subclasses (`TaskNotFoundError`, `RequestMalformedError`, …). They
live at `@a2a-js/sdk/errors`; gRPC-specific error helpers live at
`@a2a-js/sdk/errors/grpc` so consumers who don't use gRPC don't pull in
`@bufbuild/protobuf`.

```typescript
// v0.3
Expand All @@ -277,18 +281,49 @@ throw A2AError.taskNotFound('task-1');
throw A2AError.invalidParams('bad input');

// v1.0
import { TaskNotFoundError, RequestMalformedError } from '@a2a-js/sdk/server';
throw new TaskNotFoundError('task-1');
throw new RequestMalformedError('bad input');
import { TaskNotFoundError, RequestMalformedError } from '@a2a-js/sdk/errors';
throw new TaskNotFoundError({ message: 'task-1' });
throw new RequestMalformedError({ message: 'bad input' });
```

Available error classes from `@a2a-js/sdk/server`: `TaskNotFoundError`,
`TaskNotCancelableError`, `RequestMalformedError`, `UnsupportedOperationError`,
Semantic classes: `TaskNotFoundError`, `TaskNotCancelableError`,
`RequestMalformedError`, `UnsupportedOperationError`,
`PushNotificationNotSupportedError`, `ContentTypeNotSupportedError`,
`ExtendedAgentCardNotConfiguredError`, `VersionNotSupportedError`.
`InvalidAgentResponseError`, `ExtendedAgentCardNotConfiguredError`,
`ExtensionSupportRequiredError`, `VersionNotSupportedError`. `A2AError`
itself is the concrete fallback — instantiate it directly
(`new A2AError('...')`) when no semantic class fits.

Per-transport variants (`RestTaskNotFoundError`, `GrpcTaskNotFoundError`,
`JsonRpcTaskNotFoundError`, …) carry transport-native context; narrow via the
`isRestError` / `isGrpcError` / `isJsonRpcError` type guards. All catch-side
API surfaces are on the base:

```typescript
import { isRestError, TaskNotFoundError } from '@a2a-js/sdk/errors';

try {
await client.getTask({ id });
} catch (e) {
if (e instanceof TaskNotFoundError) {
if (isRestError(e)) {
// e.statusCode, e.headers, e.cause are typed
if (e.statusCode === 429) backoff(e.headers?.['retry-after']);
}
}
}
```

For gRPC callers, the transport variant + guard live in a separate subpath:

```typescript
import { isGrpcError, TaskNotFoundError } from '@a2a-js/sdk/errors/grpc';
```

Error codes and gRPC/HTTP status mappings are defined in the
[spec](https://a2a-protocol.org/v1.0.0/specification/#54-error-code-mappings).
[spec](https://a2a-protocol.org/v1.0.0/specification/#54-error-code-mappings)
and live in a single registry (`A2A_ERROR_SPECS`) exported from
`@a2a-js/sdk/errors`.

### 3.3 `ServerCallContext` -- Now Mandatory

Expand All @@ -305,7 +340,7 @@ new ServerCallContext(requestedExtensions, user);
new ServerCallContext({ requestedExtensions, user, tenant: 'my-tenant', requestedVersion: '1.0' });
```

`RequestContext` now wraps the incoming `SendMessageRequest`,
`RequestContext` now wraps the incoming `SendMessageRequest`,
and `context` moved from last (optional) to 4th (mandatory).
The loose `userMessage` parameter is replaced by `request: SendMessageRequest`;
agent executors read the message via `ctx.userMessage` (convenience accessor
Expand Down Expand Up @@ -445,11 +480,11 @@ await verify(agentCard);

## 5. Import Path Changes

| v0.3 Import | v1.0 Import |
| ------------------------------------------------------------ | ------------------------------------------------------------- |
| `import { A2AClient } from '@a2a-js/sdk/client'` | Removed -- use `ClientFactory` + `Client` |
| `import { TextPart, FilePart, DataPart } from '@a2a-js/sdk'` | Removed -- use `Part` |
| `import { MessageSendParams } from '@a2a-js/sdk'` | `import { SendMessageRequest } from '@a2a-js/sdk'` |
| `import { TaskQueryParams } from '@a2a-js/sdk'` | `import { GetTaskRequest } from '@a2a-js/sdk'` |
| `import { TaskIdParams } from '@a2a-js/sdk'` | `import { CancelTaskRequest } from '@a2a-js/sdk'` |
| `import { A2AError } from '@a2a-js/sdk/server'` | `import { TaskNotFoundError, ... } from '@a2a-js/sdk/server'` |
| v0.3 Import | v1.0 Import |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| `import { A2AClient } from '@a2a-js/sdk/client'` | Removed -- use `ClientFactory` + `Client` |
| `import { TextPart, FilePart, DataPart } from '@a2a-js/sdk'` | Removed -- use `Part` |
| `import { MessageSendParams } from '@a2a-js/sdk'` | `import { SendMessageRequest } from '@a2a-js/sdk'` |
| `import { TaskQueryParams } from '@a2a-js/sdk'` | `import { GetTaskRequest } from '@a2a-js/sdk'` |
| `import { TaskIdParams } from '@a2a-js/sdk'` | `import { CancelTaskRequest } from '@a2a-js/sdk'` |
| `import { A2AError } from '@a2a-js/sdk/server'` | `import { TaskNotFoundError, ... } from '@a2a-js/sdk/errors'` (or `@a2a-js/sdk/errors/grpc` for gRPC helpers) |
21 changes: 20 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
"type": "module",
"typesVersions": {
"*": {
"errors": [
"./dist/errors/index.d.ts"
],
"errors/grpc": [
"./dist/errors/grpc/index.d.ts"
],
"server": [
"./dist/server/index.d.ts"
],
Expand Down Expand Up @@ -56,6 +62,16 @@
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./errors": {
"types": "./dist/errors/index.d.ts",
"import": "./dist/errors/index.js",
"require": "./dist/errors/index.cjs"
},
"./errors/grpc": {
"types": "./dist/errors/grpc/index.d.ts",
"import": "./dist/errors/grpc/index.js",
"require": "./dist/errors/grpc/index.cjs"
},
Comment thread
JakubWorek marked this conversation as resolved.
"./server": {
"types": "./dist/server/index.d.ts",
"import": "./dist/server/index.js",
Expand Down Expand Up @@ -154,7 +170,10 @@
"coverage": "vitest run --coverage",
"generate": "curl https://raw.githubusercontent.com/google-a2a/A2A/refs/heads/main/specification/json/a2a.json > spec.json && node scripts/generateTypes.js && rm spec.json",
"generate:compat": "curl https://raw.githubusercontent.com/a2aproject/A2A/v0.3.0/specification/json/a2a.json > compat_spec.json && node scripts/generateCompatTypes.js && rm compat_spec.json",
"test-build": "esbuild ./dist/client/index.js ./dist/server/index.js ./dist/index.js ./dist/compat/v0_3/index.js ./dist/compat/v0_3/client/index.js ./dist/compat/v0_3/server/index.js --bundle --platform=neutral --outdir=dist/tmp-checks --outbase=./dist",
"test-build:workers-safe": "esbuild ./dist/index.js ./dist/errors/index.js ./dist/client/index.js ./dist/server/index.js ./dist/compat/v0_3/index.js ./dist/compat/v0_3/client/index.js ./dist/compat/v0_3/server/index.js --bundle --platform=neutral --outdir=dist/tmp-checks --outbase=./dist",
"test-build:grpc": "esbuild ./dist/errors/grpc/index.js ./dist/server/grpc/index.js ./dist/client/transports/grpc/index.js ./dist/compat/v0_3/server/grpc/index.js ./dist/compat/v0_3/client/transports/grpc/index.js --bundle --platform=node --outdir=dist/tmp-checks --outbase=./dist",
"test-build:express": "esbuild ./dist/server/express/index.js ./dist/compat/v0_3/server/express/index.js --bundle --platform=node --outdir=dist/tmp-checks --outbase=./dist",
"test-build": "npm run test-build:workers-safe && npm run test-build:grpc && npm run test-build:express",
"itk-agent": "tsx itk/itk_agent.ts"
},
"dependencies": {
Expand Down
11 changes: 0 additions & 11 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,3 @@ export {
withA2AVersion,
} from './service-parameters.js';
export { ClientCallContext, type ContextUpdate, ClientCallContextKey } from './context.js';
export {
ExtendedAgentCardNotConfiguredError,
ContentTypeNotSupportedError,
InvalidAgentResponseError,
PushNotificationNotSupportedError,
TaskNotCancelableError,
TaskNotFoundError,
UnsupportedOperationError,
RequestMalformedError,
VersionNotSupportedError,
} from '../errors.js';
2 changes: 1 addition & 1 deletion src/client/multitransport-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { withA2AVersion } from './service-parameters.js';
import { AgentCardSignatureVerifier } from '../signature.js';
import { LEGACY_HTTP_EXTENSION_HEADER } from '../compat/v0_3/index.js';
import { HTTP_EXTENSION_HEADER } from '../constants.js';
import { PushNotificationNotSupportedError } from '../errors.js';
import { PushNotificationNotSupportedError } from '../errors/index.js';
import { isLegacyVersion } from '../version_utils.js';
import { TaskPushNotificationConfig, Task, AgentCard, SendMessageResult } from '../index.js';
import {
Expand Down
46 changes: 3 additions & 43 deletions src/client/transports/grpc/grpc_transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ import { RequestOptions } from '../../multitransport-client.js';
import { Transport, TransportFactory } from '../transport.js';
import { FromProto } from '../../../types/converters/from_proto.js';

import { A2A_REASON_TO_ERROR_CLASS, ERROR_INFO_TYPE } from '../../../errors.js';
import { decodeStatus, decodeErrorInfo } from '../../../server/grpc/error_details.js';
import { fromGrpcError } from '../../../errors/grpc/index.js';
import { LegacyGrpcTransport } from '../../../compat/v0_3/client/transports/grpc/index.js';
import { isLegacyVersion } from '../../../version_utils.js';
import { pickMatchingInterface } from '../pick_interface.js';
Expand Down Expand Up @@ -232,7 +231,7 @@ export class GrpcTransport implements Transport {
options.signal.removeEventListener('abort', onAbort);
}
if (error) {
return reject(GrpcTransport.mapToError(error, method));
return reject(fromGrpcError(error, method));
}
resolve(converter(response));
}
Expand Down Expand Up @@ -282,7 +281,7 @@ export class GrpcTransport implements Transport {
}
} catch (error) {
if (this.isServiceError(error)) {
throw GrpcTransport.mapToError(error, method);
throw fromGrpcError(error, method);
} else {
throw new Error(`GRPC error for ${String(method)}!`, {
cause: error,
Expand All @@ -309,45 +308,6 @@ export class GrpcTransport implements Transport {
}
return metadata;
}

private static mapFromErrorInfo(error: grpc.ServiceError): Error | undefined {
const bin = error.metadata?.get('grpc-status-details-bin');
if (!bin || bin.length === 0) return undefined;

const raw = bin[0];
const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw, 'binary');

const status = decodeStatus(buffer);

for (const detail of status.details) {
if (detail.typeUrl === ERROR_INFO_TYPE) {
const errorInfo = decodeErrorInfo(detail.value);

const ErrorClass = A2A_REASON_TO_ERROR_CLASS[errorInfo.reason];
if (!ErrorClass) return undefined;

return new ErrorClass(error.details);
}
}

return undefined;
}

/**
* Maps a gRPC `ServiceError` to an SDK error class via
* `google.rpc.ErrorInfo` from `grpc-status-details-bin` metadata.
* Falls back to a generic `Error` carrying the gRPC code and details
* when no ErrorInfo is present.
*/
private static mapToError(error: grpc.ServiceError, method?: keyof A2AServiceClient): Error {
const fromErrorInfo = GrpcTransport.mapFromErrorInfo(error);
if (fromErrorInfo) return fromErrorInfo;

const methodContext = method ? ' for ' + String(method) : '';
return new Error('gRPC error' + methodContext + ': ' + error.code + ' ' + error.details, {
cause: error,
});
}
}

export class GrpcTransportFactoryOptions {
Expand Down
2 changes: 1 addition & 1 deletion src/client/transports/json_rpc_transport.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { JSONRPCErrorResponse, TransportProtocolName } from '../../core.js';
import { mapJsonRpcErrorToSdkError } from '../../errors.js';
import { fromJsonRpcErrorResponse as mapJsonRpcErrorToSdkError } from '../../errors/index.js';
import {
Task,
AgentCard,
Expand Down
46 changes: 23 additions & 23 deletions src/client/transports/rest_transport.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { TransportProtocolName } from '../../core.js';
import { A2A_REASON_TO_ERROR_CLASS, ERROR_INFO_TYPE } from '../../errors.js';
import { fromRestErrorBody } from '../../errors/index.js';

import { SendMessageResult, A2A_PROTOCOL_VERSION, A2A_CONTENT_TYPE } from '../../index.js';
import { JSON_CONTENT_TYPE } from '../../constants.js';
Expand Down Expand Up @@ -326,18 +326,30 @@ export class RestTransport implements Transport {
}
}
} catch {
// Body wasn't JSON — fall through to a generic error.
// Body wasn't JSON — fall through to a REST-scoped A2AError.
}

const transportCtx = {
statusCode: response.status,
headers: RestTransport._collectHeaders(response),
};
if (errorStatus) {
throw RestTransport.mapToError(errorStatus);
throw fromRestErrorBody(errorStatus, transportCtx);
}

throw new Error(
`HTTP error for ${path}! Status: ${response.status} ${response.statusText}. Response: ${errorBodyText}`
throw fromRestErrorBody(
{ message: `HTTP error for ${path}: ${response.status} ${response.statusText}` },
transportCtx
);
}

private static _collectHeaders(response: Response): Record<string, string> {
const out: Record<string, string> = {};
response.headers.forEach((value, key) => {
out[key] = value;
});
return out;
}

private async *_sendStreamingRequest(
path: string,
body: unknown | undefined,
Expand Down Expand Up @@ -367,11 +379,15 @@ export class RestTransport implements Transport {
);
}

const sseTransportCtx = {
statusCode: response.status,
headers: RestTransport._collectHeaders(response),
};
for await (const event of parseSseStream(response)) {
if (event.type === 'error') {
const errorData = JSON.parse(event.data) as { error?: RestErrorStatus };
if (errorData.error && typeof errorData.error === 'object') {
throw RestTransport.mapToError(errorData.error);
throw fromRestErrorBody(errorData.error, sseTransportCtx);
}
throw new Error(`SSE error event: ${JSON.stringify(errorData)}`);
}
Expand All @@ -394,22 +410,6 @@ export class RestTransport implements Transport {
);
}
}

private static mapToError(error: RestErrorStatus): Error {
const message = error.message || 'Unknown error';

if (Array.isArray(error.details)) {
const errorInfo = error.details.find((d) => d['@type'] === ERROR_INFO_TYPE);
if (errorInfo && typeof errorInfo['reason'] === 'string') {
const ErrorClass = A2A_REASON_TO_ERROR_CLASS[errorInfo['reason'] as string];
if (ErrorClass) return new ErrorClass(message);
}
}

return new Error(
`REST error: ${error.status || 'UNKNOWN'} (${error.code || 'unknown code'}) - ${message}`
);
}
}

export class RestTransportFactoryOptions {
Expand Down
Loading
Loading