From 861ccac5a2ffc6aad9797be2e0e4d0392058f036 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Wed, 11 Mar 2026 23:48:24 -0400 Subject: [PATCH] fix(a2a): fix card resolver bug, simplify normalizeAgentCard, use correct gRPC URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pass empty string to resolver.resolve() to prevent SDK from appending /.well-known/agent-card.json to direct card URLs - Simplify normalizeAgentCard to only handle proto field name aliases (supportedInterfaces → additionalInterfaces, protocolBinding → transport) - Use gRPC-specific URL from additionalInterfaces for credentials - Remove dead helper functions and unnecessary behaviors - Add shallow copy to prevent SDK object mutation --- .../core/src/agents/a2a-client-manager.ts | 23 ++-- packages/core/src/agents/a2aUtils.test.ts | 56 ++++------ packages/core/src/agents/a2aUtils.ts | 100 +++++------------- 3 files changed, 54 insertions(+), 125 deletions(-) diff --git a/packages/core/src/agents/a2a-client-manager.ts b/packages/core/src/agents/a2a-client-manager.ts index 8934a4e121..576f66825f 100644 --- a/packages/core/src/agents/a2a-client-manager.ts +++ b/packages/core/src/agents/a2a-client-manager.ts @@ -121,9 +121,16 @@ export class A2AClientManager { }; const resolver = new DefaultAgentCardResolver({ fetchImpl: cardFetch }); - const agentCard = await this.resolveAgentCard(name, agentCardUrl, resolver); + const rawCard = await resolver.resolve(agentCardUrl, ''); + // TODO: Remove normalizeAgentCard once @a2a-js/sdk handles + // proto field name aliases (supportedInterfaces → additionalInterfaces, + // protocolBinding → transport). + const agentCard = normalizeAgentCard(rawCard); + + const grpcUrl = + agentCard.additionalInterfaces?.find((i) => i.transport === 'GRPC') + ?.url ?? agentCard.url; - // Configure standard SDK client for tool registration and discovery const clientOptions = ClientFactoryOptions.createFrom( ClientFactoryOptions.default, { @@ -131,7 +138,7 @@ export class A2AClientManager { new RestTransportFactory({ fetchImpl: authFetch }), new JsonRpcTransportFactory({ fetchImpl: authFetch }), new GrpcTransportFactory({ - grpcChannelCredentials: getGrpcCredentials(agentCard.url), + grpcChannelCredentials: getGrpcCredentials(grpcUrl), }), ], cardResolver: resolver, @@ -263,14 +270,4 @@ export class A2AClientManager { throw new Error(`${prefix}: Unexpected error: ${String(error)}`); } } - - private async resolveAgentCard( - agentName: string, - url: string, - resolver: DefaultAgentCardResolver, - ): Promise { - const rawCard = await resolver.resolve(url); - const agentCard = normalizeAgentCard(rawCard); - return agentCard; - } } diff --git a/packages/core/src/agents/a2aUtils.test.ts b/packages/core/src/agents/a2aUtils.test.ts index d5ab74f037..2dd70d92b1 100644 --- a/packages/core/src/agents/a2aUtils.test.ts +++ b/packages/core/src/agents/a2aUtils.test.ts @@ -291,7 +291,7 @@ describe('a2aUtils', () => { expect(normalized.defaultInputModes).toBeUndefined(); }); - it('should normalize and synchronize interfaces while preserving other fields', () => { + it('should map supportedInterfaces to additionalInterfaces with protocolBinding → transport', () => { const raw = { name: 'test', supportedInterfaces: [ @@ -305,13 +305,7 @@ describe('a2aUtils', () => { const normalized = normalizeAgentCard(raw); - // Should exist in both fields expect(normalized.additionalInterfaces).toHaveLength(1); - expect( - (normalized as unknown as Record)[ - 'supportedInterfaces' - ], - ).toHaveLength(1); const intf = normalized.additionalInterfaces?.[0] as unknown as Record< string, @@ -320,43 +314,18 @@ describe('a2aUtils', () => { expect(intf['transport']).toBe('GRPC'); expect(intf['url']).toBe('grpc://test'); - - // Should fallback top-level url - expect(normalized.url).toBe('grpc://test'); }); - it('should preserve existing top-level url if present', () => { + it('should not overwrite additionalInterfaces if already present', () => { const raw = { name: 'test', - url: 'http://existing', + additionalInterfaces: [{ url: 'http://grpc', transport: 'GRPC' }], supportedInterfaces: [{ url: 'http://other', transport: 'REST' }], }; const normalized = normalizeAgentCard(raw); - expect(normalized.url).toBe('http://existing'); - }); - - it('should NOT prepend http:// scheme to raw IP:port strings for gRPC interfaces', () => { - const raw = { - name: 'raw-ip-grpc', - supportedInterfaces: [{ url: '127.0.0.1:9000', transport: 'GRPC' }], - }; - - const normalized = normalizeAgentCard(raw); - expect(normalized.additionalInterfaces?.[0].url).toBe('127.0.0.1:9000'); - expect(normalized.url).toBe('127.0.0.1:9000'); - }); - - it('should prepend http:// scheme to raw IP:port strings for REST interfaces', () => { - const raw = { - name: 'raw-ip-rest', - supportedInterfaces: [{ url: '127.0.0.1:8080', transport: 'REST' }], - }; - - const normalized = normalizeAgentCard(raw); - expect(normalized.additionalInterfaces?.[0].url).toBe( - 'http://127.0.0.1:8080', - ); + expect(normalized.additionalInterfaces).toHaveLength(1); + expect(normalized.additionalInterfaces?.[0].url).toBe('http://grpc'); }); it('should NOT override existing transport if protocolBinding is also present', () => { @@ -369,6 +338,21 @@ describe('a2aUtils', () => { const normalized = normalizeAgentCard(raw); expect(normalized.additionalInterfaces?.[0].transport).toBe('GRPC'); }); + + it('should not mutate the original card object', () => { + const raw = { + name: 'test', + supportedInterfaces: [{ url: 'grpc://test', protocolBinding: 'GRPC' }], + }; + + const normalized = normalizeAgentCard(raw); + expect(normalized).not.toBe(raw); + expect(normalized.additionalInterfaces).toBeDefined(); + // Original should not have additionalInterfaces added + expect( + (raw as Record)['additionalInterfaces'], + ).toBeUndefined(); + }); }); describe('A2AResultReassembler', () => { diff --git a/packages/core/src/agents/a2aUtils.ts b/packages/core/src/agents/a2aUtils.ts index 227908d07d..a30f8571dc 100644 --- a/packages/core/src/agents/a2aUtils.ts +++ b/packages/core/src/agents/a2aUtils.ts @@ -212,90 +212,45 @@ function extractPartText(part: Part): string { } /** - * Normalizes an agent card by ensuring it has the required properties - * and resolving any inconsistencies between protocol versions. + * Normalizes proto field name aliases that the SDK doesn't handle yet. + * The A2A proto spec uses `supported_interfaces` and `protocol_binding`, + * while the SDK expects `additionalInterfaces` and `transport`. + * TODO: Remove once @a2a-js/sdk handles these aliases natively. */ export function normalizeAgentCard(card: unknown): AgentCard { if (!isObject(card)) { throw new Error('Agent card is missing.'); } - // Narrowing to AgentCard interface. + // Shallow-copy to avoid mutating the SDK's cached object. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const result = card as unknown as AgentCard; + const result = { ...card } as unknown as AgentCard; - // 1. Normalize and Sync Interfaces - const interfaces = normalizeInterfaces(card); - result.additionalInterfaces = interfaces; - - // Sync supportedInterfaces for backward compatibility. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const legacyResult = result as unknown as Record; - legacyResult['supportedInterfaces'] = interfaces; - - // 2. Fallback preferredTransport: If not specified, default to GRPC if available. - if ( - !result.preferredTransport && - interfaces.some((i) => i.transport === 'GRPC') - ) { - result.preferredTransport = 'GRPC'; + // Map supportedInterfaces → additionalInterfaces if needed + if (!result.additionalInterfaces) { + const raw = card; + if (Array.isArray(raw['supportedInterfaces'])) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + result.additionalInterfaces = raw[ + 'supportedInterfaces' + ] as AgentInterface[]; + } } - // 3. Fallback: If top-level URL is missing, use the first interface's URL. - if ((!result.url || result.url === '') && interfaces.length > 0) { - result.url = interfaces[0].url; + // Map protocolBinding → transport on each interface + for (const intf of result.additionalInterfaces ?? []) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const raw = intf as unknown as Record; + const binding = raw['protocolBinding']; + + if (!intf.transport && typeof binding === 'string') { + intf.transport = binding; + } } return result; } -/** - * Extracts and normalizes interfaces from the card, handling protocol version fallbacks. - */ -function normalizeInterfaces(card: Record): AgentInterface[] { - const additional = card['additionalInterfaces']; - const supported = card['supportedInterfaces']; - - let rawInterfaces: unknown[] = []; - if (Array.isArray(additional)) { - rawInterfaces = additional; - } else if (Array.isArray(supported)) { - rawInterfaces = supported; - } - - return rawInterfaces.filter(isObject).map((i) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const intf = i as unknown as AgentInterface; - normalizeInterface(intf); - return intf; - }); -} - -/** - * Normalizes a single AgentInterface. - */ -function normalizeInterface(intf: AgentInterface): void { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const raw = intf as unknown as Record; - - // Normalize 'transport' from 'protocolBinding' if missing. - const protocolBinding = raw['protocolBinding']; - if (!intf.transport && isString(protocolBinding)) { - intf.transport = protocolBinding; - } - - // Robust URL: Ensure the URL has a scheme (except for gRPC). - if ( - intf.url && - !intf.url.includes('://') && - !intf.url.startsWith('/') && - intf.transport !== 'GRPC' - ) { - // Default to http:// for insecure REST/JSON-RPC if scheme is missing. - intf.url = `http://${intf.url}`; - } -} - /** * Returns gRPC channel credentials based on the URL scheme. */ @@ -376,13 +331,6 @@ export function isTerminalState(state: TaskState | undefined): boolean { ); } -/** - * Type guard to check if a value is a string. - */ -function isString(val: unknown): val is string { - return typeof val === 'string'; -} - /** * Type guard to check if a value is a non-array object. */