Skip to content

Commit 9807a66

Browse files
authored
fix: normalize legacy cards in createFromAgentCard (#602)
# Description ## Summary `ClientFactory.createFromAgentCard()` selected transports directly from `supportedInterfaces`, while the URL path first normalized cards through the configured `AgentCardResolver`. As a result, a pure v0.3 card with a top-level `url` still failed with `No compatible transport found` even when both documented legacy compatibility options were enabled. ## Changes This change exposes the default resolver's existing agent-card normalizer as an optional resolver capability and applies it to direct-card creation before transport selection. The URL path continues to consume the already-resolved card without a second normalization pass. The normalized card is also passed to the transport factory and retained by the returned client. Compatibility remains opt-in: disabling resolver compatibility still rejects a pure v0.3 card, and custom resolvers that only implement `resolve()` keep their existing behavior. The compatibility guide now includes the custom-fetch plus `createFromAgentCard()` workflow. ## Verification ```bash npm run lint:ci npm run build npm run test-build npm test npm run test:edge npm run test:integration ``` - [x] Follow the [`CONTRIBUTING` Guide](https://github.com/google-a2a/a2a-js/blob/main/CONTRIBUTING.md). - [x] Make your Pull Request title in the <https://www.conventionalcommits.org/> specification. - [x] Ensure the tests and linter pass - [x] Appropriate docs were updated (if necessary) Fixes #601 Signed-off-by: King Star <mcxin.y@gmail.com>
1 parent 056322b commit 9807a66

4 files changed

Lines changed: 104 additions & 8 deletions

File tree

docs/compatibility-v0_3.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,19 @@ const factory = new ClientFactory(
153153
const client = await factory.createFromUrl(serverUrl);
154154
```
155155

156+
If the card is fetched through an application-specific path, the same factory
157+
configuration also normalizes it before direct client creation:
158+
159+
```ts
160+
const response = await fetch(customAgentCardUrl);
161+
const card = await response.json();
162+
const client = await factory.createFromAgentCard(card);
163+
```
164+
156165
With those flags set:
157166

158-
- `DefaultAgentCardResolver` inspects every fetched card. A pure v0.3 card
167+
- `DefaultAgentCardResolver` inspects fetched cards and cards passed directly to
168+
`ClientFactory.createFromAgentCard`. A pure v0.3 card
159169
(top-level `url`, no `supportedInterfaces[]`, or a `protocolVersion` in
160170
`[0.3, 1.0)`) is translated to a v1.0 representation with
161171
`protocolVersion: '0.3'` stamped on each synthesized `AgentInterface`. A

src/client/card-resolver.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ export interface AgentCardResolverOptions {
2424

2525
export interface AgentCardResolver {
2626
resolve(baseUrl: string, path?: string): Promise<AgentCard>;
27+
28+
/** Normalizes an already-fetched card before client transport selection. */
29+
normalizeAgentCard?(card: unknown): AgentCard;
2730
}
2831

2932
export class DefaultAgentCardResolver implements AgentCardResolver {
@@ -52,7 +55,7 @@ export class DefaultAgentCardResolver implements AgentCardResolver {
5255
return fetch(...args);
5356
}
5457

55-
/*
58+
/**
5659
* In v0.3 there was structural drift between the JSON Schema data
5760
* model and the Protobuf-based data model for AgentCards: JSON Schema
5861
* uses a `"type"` discriminator, while Protobuf JSON uses the `oneof`
@@ -62,7 +65,7 @@ export class DefaultAgentCardResolver implements AgentCardResolver {
6265
* When `legacyCompat: { enabled: true }`, this method also detects
6366
* v0.3-shaped cards and translates them via the compat module.
6467
*/
65-
private normalizeAgentCard(card: unknown): AgentCard {
68+
normalizeAgentCard(card: unknown): AgentCard {
6669
if (this.options?.legacyCompat?.enabled) {
6770
if (isLegacyAgentCard(card)) {
6871
return parseLegacyAgentCard(card);

src/client/factory.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,18 @@ export class ClientFactory {
9090
}
9191

9292
/**
93-
* Creates a new client from the provided agent card. When the selected
94-
* `AgentInterface` declares a non-empty `tenant`, the transport is
95-
* wrapped with a {@link TenantTransportDecorator} so the default tenant
96-
* is applied to every request.
93+
* Creates a new client from the provided agent card. The configured resolver
94+
* may normalize an already-fetched card before transport selection. When the
95+
* selected `AgentInterface` declares a non-empty `tenant`, the transport is
96+
* wrapped with a {@link TenantTransportDecorator} so the default tenant is
97+
* applied to every request.
9798
*/
9899
async createFromAgentCard(agentCard: AgentCard): Promise<Client> {
100+
const normalizedAgentCard = this.agentCardResolver.normalizeAgentCard?.(agentCard) ?? agentCard;
101+
return this.createFromNormalizedAgentCard(normalizedAgentCard);
102+
}
103+
104+
private async createFromNormalizedAgentCard(agentCard: AgentCard): Promise<Client> {
99105
const interfaces = agentCard.supportedInterfaces ?? [];
100106

101107
const bestInterfacePerProtocol = new CaseInsensitiveMap<(typeof interfaces)[number]>();
@@ -146,7 +152,7 @@ export class ClientFactory {
146152
*/
147153
async createFromUrl(baseUrl: string, path?: string): Promise<Client> {
148154
const agentCard = await this.agentCardResolver.resolve(baseUrl, path);
149-
return this.createFromAgentCard(agentCard);
155+
return this.createFromNormalizedAgentCard(agentCard);
150156
}
151157
}
152158

test/client/factory.spec.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
JsonRpcTransportFactory,
1111
} from '../../src/client/transports/json_rpc_transport.js';
1212
import { LegacyJsonRpcTransport } from '../../src/compat/v0_3/client/transports/json_rpc_transport.js';
13+
import { DefaultAgentCardResolver } from '../../src/client/card-resolver.js';
1314

1415
describe('ClientFactory', () => {
1516
let mockTransportFactory1: { protocolName: string; create: Mock };
@@ -270,6 +271,21 @@ describe('ClientFactory', () => {
270271
expect(jsonRpcFactory.create).toHaveBeenCalledTimes(1);
271272
});
272273

274+
it('preserves direct-card behavior for resolvers without a card normalizer', async () => {
275+
const cardResolver = {
276+
resolve: vi.fn().mockResolvedValue(agentCard),
277+
};
278+
const factory = new ClientFactory({
279+
transports: [mockTransportFactory1],
280+
cardResolver,
281+
});
282+
283+
const client = await factory.createFromAgentCard(agentCard);
284+
285+
expect(client).to.be.instanceOf(Client);
286+
expect(mockTransportFactory1.create).toHaveBeenCalledTimes(1);
287+
});
288+
273289
it('should use card resolver with default path', async () => {
274290
const cardResolver = {
275291
resolve: vi.fn().mockResolvedValue(agentCard),
@@ -399,6 +415,67 @@ describe('ClientFactory', () => {
399415
expect(client.transport).to.be.instanceOf(LegacyJsonRpcTransport);
400416
expect(client.protocolVersion).to.equal('0.3');
401417
});
418+
419+
it('normalizes a pure v0.3 card through the configured resolver', async () => {
420+
const factory = new ClientFactory(
421+
ClientFactoryOptions.createFrom(ClientFactoryOptions.default, {
422+
cardResolver: new DefaultAgentCardResolver({ legacyCompat: { enabled: true } }),
423+
transports: [new JsonRpcTransportFactory({ legacyCompat: { enabled: true } })],
424+
})
425+
);
426+
const legacyCard: Record<string, unknown> = {
427+
name: 'Legacy Agent',
428+
description: 'A v0.3 agent',
429+
protocolVersion: '0.3.0',
430+
version: '1.0.0',
431+
url: 'https://v03.example/rpc',
432+
skills: [],
433+
capabilities: {
434+
streaming: true,
435+
pushNotifications: true,
436+
stateTransitionHistory: false,
437+
},
438+
defaultInputModes: ['text'],
439+
defaultOutputModes: ['text'],
440+
};
441+
442+
const client = await factory.createFromAgentCard(legacyCard as unknown as AgentCard);
443+
444+
expect(client.transport).to.be.instanceOf(LegacyJsonRpcTransport);
445+
expect(client.protocolVersion).to.equal('0.3');
446+
expect((await client.getAgentCard()).supportedInterfaces).to.deep.equal([
447+
{
448+
url: 'https://v03.example/rpc',
449+
protocolBinding: 'JSONRPC',
450+
tenant: '',
451+
protocolVersion: '0.3.0',
452+
},
453+
]);
454+
});
455+
456+
it('does not normalize a pure v0.3 card when resolver compatibility is disabled', async () => {
457+
const factory = new ClientFactory(
458+
ClientFactoryOptions.createFrom(ClientFactoryOptions.default, {
459+
cardResolver: new DefaultAgentCardResolver(),
460+
transports: [new JsonRpcTransportFactory({ legacyCompat: { enabled: true } })],
461+
})
462+
);
463+
const legacyCard: Record<string, unknown> = {
464+
name: 'Legacy Agent',
465+
description: 'A v0.3 agent',
466+
protocolVersion: '0.3.0',
467+
version: '1.0.0',
468+
url: 'https://v03.example/rpc',
469+
skills: [],
470+
capabilities: {},
471+
defaultInputModes: ['text'],
472+
defaultOutputModes: ['text'],
473+
};
474+
475+
await expect(factory.createFromAgentCard(legacyCard as unknown as AgentCard)).rejects.toThrow(
476+
'No compatible transport found'
477+
);
478+
});
402479
});
403480

404481
describe('ClientFactoryOptions.createFrom', () => {

0 commit comments

Comments
 (0)