mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
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:
committed by
Alisa Novikova
parent
bdda2f4d59
commit
72e0cd5112
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user