fix(a2a): fix card resolver bug, simplify normalizeAgentCard, use correct gRPC URL

- 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
This commit is contained in:
Adam Weidman
2026-03-11 23:48:24 -04:00
committed by Alisa Novikova
parent bdda2f4d59
commit 72e0cd5112
3 changed files with 54 additions and 125 deletions
+10 -13
View File
@@ -121,9 +121,16 @@ export class A2AClientManager {
}; };
const resolver = new DefaultAgentCardResolver({ fetchImpl: cardFetch }); 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( const clientOptions = ClientFactoryOptions.createFrom(
ClientFactoryOptions.default, ClientFactoryOptions.default,
{ {
@@ -131,7 +138,7 @@ export class A2AClientManager {
new RestTransportFactory({ fetchImpl: authFetch }), new RestTransportFactory({ fetchImpl: authFetch }),
new JsonRpcTransportFactory({ fetchImpl: authFetch }), new JsonRpcTransportFactory({ fetchImpl: authFetch }),
new GrpcTransportFactory({ new GrpcTransportFactory({
grpcChannelCredentials: getGrpcCredentials(agentCard.url), grpcChannelCredentials: getGrpcCredentials(grpcUrl),
}), }),
], ],
cardResolver: resolver, cardResolver: resolver,
@@ -263,14 +270,4 @@ export class A2AClientManager {
throw new Error(`${prefix}: Unexpected error: ${String(error)}`); throw new Error(`${prefix}: Unexpected error: ${String(error)}`);
} }
} }
private async resolveAgentCard(
agentName: string,
url: string,
resolver: DefaultAgentCardResolver,
): Promise<AgentCard> {
const rawCard = await resolver.resolve(url);
const agentCard = normalizeAgentCard(rawCard);
return agentCard;
}
} }
+20 -36
View File
@@ -291,7 +291,7 @@ describe('a2aUtils', () => {
expect(normalized.defaultInputModes).toBeUndefined(); 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 = { const raw = {
name: 'test', name: 'test',
supportedInterfaces: [ supportedInterfaces: [
@@ -305,13 +305,7 @@ describe('a2aUtils', () => {
const normalized = normalizeAgentCard(raw); const normalized = normalizeAgentCard(raw);
// Should exist in both fields
expect(normalized.additionalInterfaces).toHaveLength(1); expect(normalized.additionalInterfaces).toHaveLength(1);
expect(
(normalized as unknown as Record<string, unknown>)[
'supportedInterfaces'
],
).toHaveLength(1);
const intf = normalized.additionalInterfaces?.[0] as unknown as Record< const intf = normalized.additionalInterfaces?.[0] as unknown as Record<
string, string,
@@ -320,43 +314,18 @@ describe('a2aUtils', () => {
expect(intf['transport']).toBe('GRPC'); expect(intf['transport']).toBe('GRPC');
expect(intf['url']).toBe('grpc://test'); 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 = { const raw = {
name: 'test', name: 'test',
url: 'http://existing', additionalInterfaces: [{ url: 'http://grpc', transport: 'GRPC' }],
supportedInterfaces: [{ url: 'http://other', transport: 'REST' }], supportedInterfaces: [{ url: 'http://other', transport: 'REST' }],
}; };
const normalized = normalizeAgentCard(raw); const normalized = normalizeAgentCard(raw);
expect(normalized.url).toBe('http://existing'); expect(normalized.additionalInterfaces).toHaveLength(1);
}); expect(normalized.additionalInterfaces?.[0].url).toBe('http://grpc');
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',
);
}); });
it('should NOT override existing transport if protocolBinding is also present', () => { it('should NOT override existing transport if protocolBinding is also present', () => {
@@ -369,6 +338,21 @@ describe('a2aUtils', () => {
const normalized = normalizeAgentCard(raw); const normalized = normalizeAgentCard(raw);
expect(normalized.additionalInterfaces?.[0].transport).toBe('GRPC'); 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<string, unknown>)['additionalInterfaces'],
).toBeUndefined();
});
}); });
describe('A2AResultReassembler', () => { describe('A2AResultReassembler', () => {
+24 -76
View File
@@ -212,90 +212,45 @@ function extractPartText(part: Part): string {
} }
/** /**
* Normalizes an agent card by ensuring it has the required properties * Normalizes proto field name aliases that the SDK doesn't handle yet.
* and resolving any inconsistencies between protocol versions. * 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 { export function normalizeAgentCard(card: unknown): AgentCard {
if (!isObject(card)) { if (!isObject(card)) {
throw new Error('Agent card is missing.'); 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 // 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 // Map supportedInterfaces → additionalInterfaces if needed
const interfaces = normalizeInterfaces(card); if (!result.additionalInterfaces) {
result.additionalInterfaces = interfaces; const raw = card;
if (Array.isArray(raw['supportedInterfaces'])) {
// Sync supportedInterfaces for backward compatibility. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion result.additionalInterfaces = raw[
const legacyResult = result as unknown as Record<string, AgentInterface[]>; 'supportedInterfaces'
legacyResult['supportedInterfaces'] = interfaces; ] as AgentInterface[];
}
// 2. Fallback preferredTransport: If not specified, default to GRPC if available.
if (
!result.preferredTransport &&
interfaces.some((i) => i.transport === 'GRPC')
) {
result.preferredTransport = 'GRPC';
} }
// 3. Fallback: If top-level URL is missing, use the first interface's URL. // Map protocolBinding → transport on each interface
if ((!result.url || result.url === '') && interfaces.length > 0) { for (const intf of result.additionalInterfaces ?? []) {
result.url = interfaces[0].url; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const raw = intf as unknown as Record<string, unknown>;
const binding = raw['protocolBinding'];
if (!intf.transport && typeof binding === 'string') {
intf.transport = binding;
}
} }
return result; return result;
} }
/**
* Extracts and normalizes interfaces from the card, handling protocol version fallbacks.
*/
function normalizeInterfaces(card: Record<string, unknown>): 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<string, unknown>;
// 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. * 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. * Type guard to check if a value is a non-array object.
*/ */