Skip to content

Commit ad7f772

Browse files
authored
feat: default to 0.3 if version not provided (#511)
# Description This pull request enhances the v0.3 compatibility ("legacyCompat") layer, making it easier for operators to support legacy clients without duplicating interface declarations. The main improvement is that, when `legacyCompat` is enabled, servers can serve v0.3 clients using only v1.0 interface declarations—both for request validation and for the discoverable agent card endpoint. The changes also clarify and document this behavior, update validation logic, and add/refine tests. Closes #474 🦕
1 parent 74a51ee commit ad7f772

16 files changed

Lines changed: 796 additions & 44 deletions

File tree

src/client/card-resolver.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AGENT_CARD_PATH } from '../constants.js';
1+
import { A2A_PROTOCOL_VERSION, A2A_VERSION_HEADER, AGENT_CARD_PATH } from '../constants.js';
22
import { AgentCard } from '../index.js';
33
import { isLegacyAgentCard, parseLegacyAgentCard } from '../compat/v0_3/client/card-resolver.js';
44

@@ -11,14 +11,21 @@ export interface AgentCardResolverOptions {
1111
* When enabled, the resolver inspects each fetched agent-card
1212
* payload; if its shape matches v0.3 (top-level `url` without
1313
* `supportedInterfaces`, `preferredTransport`,
14-
* `additionalInterfaces`, `supportsAuthenticatedExtendedCard`, or
15-
* a `protocolVersion` in `[0.3, 1.0)`), it is translated to the
16-
* v1.0 proto shape via `toCoreAgentCard`. Each synthesized
17-
* `AgentInterface` is stamped with `protocolVersion: '0.3'` so
18-
* that a {@link JsonRpcTransportFactory} configured with
14+
* `additionalInterfaces`, `supportsAuthenticatedExtendedCard`, or a
15+
* `protocolVersion` in `[0.3, 1.0)`), it is translated to the v1.0
16+
* proto shape via `toCoreAgentCard`. Each synthesized
17+
* `AgentInterface` is stamped with `protocolVersion: '0.3'` so that
18+
* a `JsonRpcTransportFactory` configured with
1919
* `legacyCompat: { enabled: true }` selects the compat transport
2020
* automatically.
2121
*
22+
* The discovery request itself always announces the SDK's native
23+
* v1.0 in the `A2A-Version` header — detection of v0.3 servers is
24+
* based on the response shape (see {@link resolve}), not on the
25+
* request value. This avoids a downgrade dance when both client
26+
* and server speak v1.0 natively but both have legacyCompat
27+
* enabled.
28+
*
2229
* Default: omitted (treated as disabled). When disabled, the v0.3
2330
* compat module is never loaded.
2431
*/
@@ -44,7 +51,9 @@ export class DefaultAgentCardResolver implements AgentCardResolver {
4451
*/
4552
async resolve(baseUrl: string, path?: string): Promise<AgentCard> {
4653
const agentCardUrl = new URL(path ?? this.options?.path ?? AGENT_CARD_PATH, baseUrl);
47-
const response = await this.fetchImpl(agentCardUrl);
54+
const response = await this.fetchImpl(agentCardUrl, {
55+
headers: { [A2A_VERSION_HEADER]: A2A_PROTOCOL_VERSION },
56+
});
4857
if (!response.ok) {
4958
throw new Error(`Failed to fetch Agent Card from ${agentCardUrl}: ${response.status}`);
5059
}

src/compat/v0_3/README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,18 @@ Notable policy decisions:
4444
- The v1.0 `OAuthFlows.deviceCode` flow is silently dropped going v1.0 → v0.3 (v0.3 has no equivalent).
4545
- `TaskStatusUpdateEvent.final` is computed from the status state going v1.0 → v0.3 (`true` for `completed`, `canceled`, `failed`, `rejected`).
4646
- `SendMessageConfiguration.returnImmediately``MessageSendConfiguration.blocking` with inverted polarity.
47-
- `toCompatAgentCard` filters `supportedInterfaces` to those whose `protocolVersion` is empty or in `[0.3, 1.0)` and throws `VersionNotSupportedError` if none qualify.
47+
- `toCompatAgentCard(card)` (strict mode, default) filters `supportedInterfaces` to those whose `protocolVersion` is empty or in `[0.3, 1.0)` and throws `VersionNotSupportedError` if none qualify.
48+
- `toCompatAgentCard(card, { synthesize: true })` (synthesis mode) accepts every interface in `supportedInterfaces` regardless of `protocolVersion` and emits `protocolVersion: '0.3'` on the result. Used by `legacyAgentCardRouter` to serve a discoverable v0.3 card when the operator has opted into `legacyCompat` but only declared v1.0 interfaces; the same v1.0 interface URLs are presented under the v0.3 protocol version.
49+
50+
## Version negotiation under `legacyCompat`
51+
52+
Per A2A spec §3.6.2, clients that omit the `A2A-Version` header are treated as v0.3 requests. Without an opt-in, the SDK's strict validator rejects header-less requests against a v1.0-only agent card with `VersionNotSupportedError`. When `legacyCompat: { enabled: true }` is passed to a handler, two pieces work together to honor §3.6.2 without requiring operators to duplicate every v1.0 `supportedInterfaces` entry with a v0.3 stub:
53+
54+
1. **Implicit v0.3 in `validateVersion`.** When called with `{ legacyCompat: true }`, the validator adds the legacy `'0.3'` version to the supported set for any binding the agent card already exposes at least one interface for. A header-less or `A2A-Version: 0.3` request therefore routes through the legacy handler chain (`LegacyJsonRpcTransportHandler`, `LegacyRestTransportHandler`, `legacyGrpcService`) even when the card declares only v1.0 interfaces. Requests for bindings the card doesn't expose at all are still rejected.
55+
56+
2. **Synthesized v0.3 card.** The `legacyAgentCardRouter` calls `toCompatAgentCard(card, { synthesize: true })` so the well-known endpoint returns a discoverable v0.3-shaped card whose `(url, preferredTransport, additionalInterfaces)` reflect the v1.0 `supportedInterfaces` entries but whose `protocolVersion` is stamped as `'0.3'`. v0.3 clients can therefore both discover and use a v1.0-only server when the operator has opted into the compat layer.
57+
58+
The v1.0 gRPC service factory (`src/server/grpc/grpc_service.ts`) intentionally does **not** carry a `legacyCompat` option; v0.3 gRPC clients are served by registering `legacyGrpcService` alongside the v1.0 `grpcService` on the same `Server`.
4859

4960
## Push Notifications
5061

src/compat/v0_3/server/express/agent_card_handler.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,14 @@ export function legacyAgentCardRouter(options: LegacyAgentCardHandlerOptions): R
110110
const coreCard = await provider();
111111
let compatCard: legacy.AgentCard;
112112
try {
113-
compatCard = toCompatAgentCard(coreCard);
113+
// `synthesize: true` lets the legacy endpoint serve a
114+
// discoverable v0.3 card even when the operator only declared
115+
// v1.0 interfaces in `supportedInterfaces` — symmetric with
116+
// the request handlers' implicit-v0.3 acceptance under
117+
// `legacyCompat`. Strict filtering would force operators to
118+
// duplicate every v1.0 entry with a v0.3 stub just to get a
119+
// discoverable v0.3 surface; this avoids that.
120+
compatCard = toCompatAgentCard(coreCard, { synthesize: true });
114121
} catch (error) {
115122
if (error instanceof VersionNotSupportedError) {
116123
res.append('Vary', A2A_VERSION_HEADER);

src/compat/v0_3/server/express/rest_handler.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,15 @@ export function legacyRestRouter(options: LegacyRestHandlerOptions): RequestHand
176176
tenant: (req.params.tenant as string) || undefined,
177177
});
178178
const agentCard = await transportHandler.getAgentCard();
179-
validateVersion(context.requestedVersion, agentCard, 'HTTP+JSON');
179+
// This router is only mounted when the operator opted into
180+
// `legacyCompat`, so the validator implicitly accepts '0.3' for
181+
// any binding the card already exposes (per §3.6.2). This lets a
182+
// v1.0-only card serve legacy clients without forcing operators
183+
// to duplicate every v1.0 `supportedInterfaces` entry with a v0.3
184+
// stub.
185+
validateVersion(context.requestedVersion, agentCard, 'HTTP+JSON', {
186+
legacyCompat: { enabled: true },
187+
});
180188
return context;
181189
};
182190

src/compat/v0_3/server/grpc/grpc_service.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,15 @@ const _buildContext = async (
573573
});
574574

575575
const agentCard = await requestHandler.getAgentCard();
576-
validateVersion(context.requestedVersion, agentCard, 'GRPC');
576+
// `legacyGrpcService` is only registered alongside the v1.0
577+
// `grpcService` when the operator opts into the v0.3 compat layer,
578+
// so the validator implicitly accepts '0.3' for any binding the
579+
// card already exposes (per §3.6.2). A v1.0-only card therefore
580+
// serves legacy gRPC clients without forcing operators to declare a
581+
// duplicate v0.3 `supportedInterfaces` entry.
582+
validateVersion(context.requestedVersion, agentCard, 'GRPC', {
583+
legacyCompat: { enabled: true },
584+
});
577585

578586
return context;
579587
};

src/compat/v0_3/translate/agent_card.ts

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -235,22 +235,63 @@ export function toCoreAgentCard(compat: legacy.AgentCard): V1AgentCard {
235235
return result;
236236
}
237237

238+
/**
239+
* Options for {@link toCompatAgentCard}.
240+
*/
241+
export interface ToCompatAgentCardOptions {
242+
/**
243+
* When `true`, accept every entry in `supportedInterfaces` regardless
244+
* of its `protocolVersion` (skipping the `[0.3, 1.0)` filter) and
245+
* stamp the emitted card's `protocolVersion` as
246+
* {@link PROTOCOL_VERSION_0_3} regardless of the source value.
247+
*
248+
* Designed for the SDK's opt-in compat layer: when a server is
249+
* configured with `legacyCompat: { enabled: true }` but only declares
250+
* v1.0 interfaces on its agent card, the well-known endpoint can
251+
* still serve a discoverable v0.3 card to legacy clients. The
252+
* resulting card advertises the same interface URLs as the v1.0
253+
* card but presents them under the v0.3 protocol version, mirroring
254+
* the validator's implicit-v0.3 acceptance for request handling.
255+
*
256+
* Default: `false` (strict behavior: filter to legacy-range
257+
* interfaces and throw `VersionNotSupportedError` if none qualify).
258+
*/
259+
synthesize?: boolean;
260+
}
261+
238262
/**
239263
* Converts a v1.0 proto `AgentCard` into a v0.3 JSON `AgentCard`.
240264
*
241-
* Filters `supportedInterfaces` to those whose `protocolVersion` is
242-
* empty or in `[0.3, 1.0)`; the first surviving entry becomes the v0.3
243-
* primary `(url, preferredTransport)`, and the rest become
244-
* `additionalInterfaces`. Throws `VersionNotSupportedError` if no
245-
* interface qualifies.
265+
* In the default (strict) mode, filters `supportedInterfaces` to those
266+
* whose `protocolVersion` is empty or in `[0.3, 1.0)`; the first
267+
* surviving entry becomes the v0.3 primary `(url, preferredTransport)`,
268+
* and the rest become `additionalInterfaces`. Throws
269+
* `VersionNotSupportedError` if no interface qualifies.
270+
*
271+
* When called with `{ synthesize: true }`, accepts every interface
272+
* unconditionally and emits `protocolVersion: '0.3'` on the result —
273+
* used by the well-known agent-card endpoint when the operator has
274+
* opted into the v0.3 compat layer but declared only v1.0 interfaces.
246275
*
247276
* `capabilities.extendedAgentCard` is pulled back out to the card-level
248277
* `supportsAuthenticatedExtendedCard` field.
249278
*/
250-
export function toCompatAgentCard(core: V1AgentCard): legacy.AgentCard {
251-
const compatInterfaces = core.supportedInterfaces.filter(
279+
export function toCompatAgentCard(
280+
core: V1AgentCard,
281+
options?: ToCompatAgentCardOptions
282+
): legacy.AgentCard {
283+
const allInterfaces = core.supportedInterfaces ?? [];
284+
const legacyInterfaces = allInterfaces.filter(
252285
(intf) => !intf.protocolVersion || isLegacyVersion(intf.protocolVersion)
253286
);
287+
// Under synthesis mode, fall back to *every* interface when none in
288+
// the legacy range exist — this is the discoverable-card path for
289+
// v1.0-only deployments that opted into `legacyCompat`. When legacy
290+
// interfaces ARE declared, prefer them so existing dual-version
291+
// deployments keep emitting the same v0.3 primary URL they did
292+
// before the synthesize option was introduced.
293+
const compatInterfaces =
294+
options?.synthesize && legacyInterfaces.length === 0 ? allInterfaces : legacyInterfaces;
254295
if (compatInterfaces.length === 0) {
255296
throw new VersionNotSupportedError(
256297
'AgentCard must have at least one interface with a protocol version in [0.3, 1.0).'
@@ -278,13 +319,24 @@ export function toCompatAgentCard(core: V1AgentCard): legacy.AgentCard {
278319
)
279320
: undefined;
280321

322+
// Under synthesize-fallback (no legacy-range interfaces, so we
323+
// accepted non-legacy entries) the primary interface may be v1.0
324+
// (or any other non-legacy version); the emitted v0.3 card always
325+
// presents itself as v0.3 regardless of the underlying interface's
326+
// declared version. Under strict / dual-version modes, fall back to
327+
// `PROTOCOL_VERSION_0_3` only when the source field is empty.
328+
const synthesizedFallback = options?.synthesize && legacyInterfaces.length === 0;
329+
const emittedProtocolVersion = synthesizedFallback
330+
? PROTOCOL_VERSION_0_3
331+
: primary.protocolVersion || PROTOCOL_VERSION_0_3;
332+
281333
const result: legacy.AgentCard = {
282334
name: core.name,
283335
description: core.description,
284336
version: core.version,
285337
url: primary.url,
286338
preferredTransport: primary.protocolBinding,
287-
protocolVersion: primary.protocolVersion || PROTOCOL_VERSION_0_3,
339+
protocolVersion: emittedProtocolVersion,
288340
capabilities,
289341
defaultInputModes: [...core.defaultInputModes],
290342
defaultOutputModes: [...core.defaultOutputModes],

src/server/express/json_rpc_handler.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,15 @@ export function jsonRpcHandler(options: JsonRpcHandlerOptions): RequestHandler {
106106
const agentCard = await options.requestHandler.getAgentCard();
107107
// The agent card is the single source of truth for which protocol
108108
// versions this transport accepts. `requestedVersion` defaults to
109-
// '0.3' when the A2A-Version header is absent (§3.6.2), so a
110-
// header-less legacy client will only succeed if the card declares
111-
// a v0.3 JSONRPC interface.
112-
validateVersion(context.requestedVersion, agentCard, 'JSONRPC');
109+
// '0.3' when the A2A-Version header is absent (§3.6.2). When
110+
// `legacyCompat` is enabled, the validator implicitly accepts
111+
// '0.3' for any binding the card already exposes, so v0.3
112+
// clients (and header-less clients) succeed without requiring
113+
// operators to duplicate every v1.0 `supportedInterfaces` entry
114+
// with a v0.3 stub.
115+
validateVersion(context.requestedVersion, agentCard, 'JSONRPC', {
116+
legacyCompat: options.legacyCompat,
117+
});
113118
const transportHandler = useLegacy ? legacyJsonRpcTransportHandler : jsonRpcTransportHandler;
114119
const rpcResponseOrStream = await transportHandler.handle(req.body, context);
115120

src/server/express/rest_handler.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,14 @@ export function restHandler(options: RestHandlerOptions): RequestHandler {
175175
tenant,
176176
});
177177
const agentCard = await restTransportHandler.getAgentCard();
178-
validateVersion(context.requestedVersion, agentCard, 'HTTP+JSON');
178+
// When `legacyCompat` is enabled, the validator implicitly accepts
179+
// '0.3' for any binding the card already exposes (per §3.6.2),
180+
// so v0.3 clients (and header-less clients) succeed without
181+
// requiring operators to duplicate every v1.0 `supportedInterfaces`
182+
// entry with a v0.3 stub.
183+
validateVersion(context.requestedVersion, agentCard, 'HTTP+JSON', {
184+
legacyCompat: options.legacyCompat,
185+
});
179186
return context;
180187
};
181188

src/server/version.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { A2A_LEGACY_PROTOCOL_VERSION } from '../constants.js';
12
import { TransportProtocolName } from '../core.js';
23
import { VersionNotSupportedError } from '../errors.js';
34
import { AgentCard } from '../index.js';
@@ -30,24 +31,80 @@ export function getSupportedVersions(
3031
return versions;
3132
}
3233

34+
/**
35+
* Options for {@link validateVersion}.
36+
*/
37+
export interface ValidateVersionOptions {
38+
/**
39+
* Opt-in to v0.3 compatibility. When `{ enabled: true }`, the legacy
40+
* v0.3 protocol version ({@link A2A_LEGACY_PROTOCOL_VERSION}) is
41+
* treated as implicitly supported for any binding that the agent
42+
* card exposes at least one interface for — even if the card itself
43+
* doesn't declare a v0.3 `protocolVersion` entry.
44+
*
45+
* This honors the §3.6.2 default-to-`'0.3'` rule (clients that omit
46+
* the `A2A-Version` header MUST be treated as v0.3 requests) under
47+
* the SDK's opt-in compat layer. Operators that don't opt in
48+
* (`legacyCompat: { enabled: false }` or omitted) keep the strict
49+
* behavior — only explicitly declared versions are accepted.
50+
*
51+
* The `{ enabled: boolean }` shape mirrors the option used by every
52+
* public handler / transport-factory option type that exposes
53+
* legacy compat (`AgentCardHandlerOptions`, `JsonRpcHandlerOptions`,
54+
* `RestHandlerOptions`, `AgentCardResolverOptions`,
55+
* `JsonRpcTransportFactoryOptions`, `RestTransportFactoryOptions`,
56+
* `GrpcTransportFactoryOptions`).
57+
*
58+
* Default: omitted (strict). Has no effect unless the agent card
59+
* already advertises at least one interface for the requested
60+
* binding; otherwise the implicit v0.3 entry would route requests
61+
* to a binding the agent doesn't actually serve.
62+
*/
63+
legacyCompat?: { enabled: boolean };
64+
}
65+
3366
/**
3467
* Validates that the requested A2A protocol version is supported by the agent.
3568
*
3669
* Per §3.6.2: "Agents MUST process requests using the semantics of the
3770
* requested A2A-Version (matching Major.Minor). If the version is not
3871
* supported by the interface, agents MUST return a VersionNotSupportedError."
3972
*
73+
* When `options.legacyCompat` is `true` AND the agent card exposes at
74+
* least one interface for the requested binding, the legacy v0.3
75+
* version is implicitly added to the supported set. This lets a v1.0
76+
* server opted into the compat layer honor the §3.6.2 default-to-`'0.3'`
77+
* rule (and explicit `A2A-Version: 0.3` requests) without forcing
78+
* operators to duplicate every v1.0 `supportedInterfaces` entry with a
79+
* matching v0.3 stub.
80+
*
4081
* @param requestedVersion - The version requested by the client (from A2A-Version header).
4182
* @param agentCard - The agent card declaring supported interfaces/versions.
4283
* @param protocolBinding - The protocol binding to filter versions by.
84+
* @param options - Validation options (see {@link ValidateVersionOptions}).
4385
* @throws {VersionNotSupportedError} If the requested version is not supported.
4486
*/
4587
export function validateVersion(
4688
requestedVersion: string,
4789
agentCard: AgentCard,
48-
protocolBinding?: TransportProtocolName
90+
protocolBinding?: TransportProtocolName,
91+
options?: ValidateVersionOptions
4992
): void {
5093
const supported = getSupportedVersions(agentCard, protocolBinding);
94+
95+
if (options?.legacyCompat?.enabled) {
96+
// Implicit v0.3 acceptance: only if the agent card actually
97+
// exposes the requested binding (otherwise we'd accept v0.3 for a
98+
// transport the agent doesn't serve, which would just defer the
99+
// failure to the dispatcher with a less useful error).
100+
const hasBindingInterface = (agentCard.supportedInterfaces ?? []).some(
101+
(intf) => !protocolBinding || intf.protocolBinding === protocolBinding
102+
);
103+
if (hasBindingInterface) {
104+
supported.add(A2A_LEGACY_PROTOCOL_VERSION);
105+
}
106+
}
107+
51108
if (!supported.has(requestedVersion)) {
52109
throw new VersionNotSupportedError(
53110
`The requested A2A protocol version '${requestedVersion}' is not supported. ` +

0 commit comments

Comments
 (0)