Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
38 changes: 25 additions & 13 deletions .github/workflows/build-tests.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
# Ensures no accidential Node.js API usage in browser and Edge compatible entrypoints.
# Ensures no accidential Node.js API usage in browser and Edge compatible
# entrypoints, and that gRPC-only helpers stay isolated from the pb-free
# entrypoints. The two `test-build:*` steps run in DIFFERENT environments:
# 1. workers-safe: no @grpc/grpc-js, no @bufbuild/protobuf installed.
# esbuild fails natively if any Workers-safe entry pulls them in.
# Mirrors what a real Workers / REST-only consumer gets.
# 2. grpc: pb + grpc-js reinstalled; the gRPC entrypoint must resolve.

name: Run Build Tests

on:
push:
branches: [ "main", "epic/**" ]
branches: ['main', 'epic/**']
pull_request:
branches: [ "main", "epic/**" ]
branches: ['main', 'epic/**']
paths-ignore:
- '**.md'
- 'LICENSE'
Expand All @@ -15,16 +21,22 @@ on:

jobs:
test:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
- run: npm ci
- run: npm run build
- run: npm run test-build
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
- run: npm ci
- run: npm run build
Comment thread
JakubWorek marked this conversation as resolved.
Outdated
# Strip the optional peer deps so esbuild sees the same module graph
# a non-gRPC consumer would. Any Workers-safe entrypoint that leaks
# a gRPC / pb import fails to resolve here.
- run: npm uninstall --no-save @grpc/grpc-js @bufbuild/protobuf
Comment thread
JakubWorek marked this conversation as resolved.
Outdated
- run: npm run test-build:workers-safe
# Reinstall the peer deps and verify the gRPC entrypoint still bundles.
- run: npm install --no-save @grpc/grpc-js @bufbuild/protobuf
- run: npm run test-build:grpc
69 changes: 51 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,47 @@ 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`, `GenericError`.

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 +338,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 +478,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) |
20 changes: 19 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,9 @@
"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 --bundle --platform=neutral --outdir=dist/tmp-checks --outbase=./dist",
Comment thread
JakubWorek marked this conversation as resolved.
Outdated
"test-build": "npm run test-build:workers-safe && npm run test-build:grpc",
"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
40 changes: 5 additions & 35 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 @@ -310,43 +309,14 @@ 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
* Maps a gRPC `ServiceError` to a semantic {@link import('../../../errors/index.js').GrpcA2AError}
* via `google.rpc.ErrorInfo` from `grpc-status-details-bin`. Falls
* back to `GrpcGenericError` preserving the gRPC status and details
Comment thread
JakubWorek marked this conversation as resolved.
Outdated
* 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,
});
return fromGrpcError(error, method ? String(method) : undefined);
Comment thread
JakubWorek marked this conversation as resolved.
Outdated
}
}

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
44 changes: 22 additions & 22 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 @@ -329,15 +329,27 @@ export class RestTransport implements Transport {
// Body wasn't JSON — fall through to a generic error.
}

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