diff --git a/src/client/card-resolver.ts b/src/client/card-resolver.ts new file mode 100644 index 00000000..37297300 --- /dev/null +++ b/src/client/card-resolver.ts @@ -0,0 +1,45 @@ +import { AGENT_CARD_PATH } from '../constants.js'; +import { AgentCard } from '../types.js'; + +export interface AgentCardResolverOptions { + path?: string; + fetchImpl?: typeof fetch; +} + +export interface AgentCardResolver { + /** + * Fetches the agent card based on provided base URL and path, + */ + resolve(baseUrl: string, path?: string): Promise; +} + +export class DefaultAgentCardResolver implements AgentCardResolver { + constructor(public readonly options?: AgentCardResolverOptions) {} + + /** + * Fetches the agent card based on provided base URL and path. + * Path is selected in the following order: + * 1) path parameter + * 2) path from options + * 3) .well-known/agent-card.json + */ + async resolve(baseUrl: string, path?: string): Promise { + const agentCardUrl = new URL(path ?? this.options?.path ?? AGENT_CARD_PATH, baseUrl); + const response = await this.fetchImpl(agentCardUrl); + if (!response.ok) { + throw new Error(`Failed to fetch Agent Card from ${agentCardUrl}: ${response.status}`); + } + return await response.json(); + } + + private fetchImpl(...args: Parameters): ReturnType { + if (this.options?.fetchImpl) { + return this.options.fetchImpl(...args); + } + return fetch(...args); + } +} + +export const AgentCardResolver = { + Default: new DefaultAgentCardResolver(), +}; diff --git a/src/client/factory.ts b/src/client/factory.ts index ee117885..42ca2ed6 100644 --- a/src/client/factory.ts +++ b/src/client/factory.ts @@ -1,5 +1,6 @@ import { TransportProtocolName } from '../core.js'; import { AgentCard } from '../types.js'; +import { AgentCardResolver } from './card-resolver.js'; import { Client, ClientConfig } from './multitransport-client.js'; import { JsonRpcTransportFactory } from './transports/json_rpc_transport.js'; import { TransportFactory } from './transports/transport.js'; @@ -21,6 +22,11 @@ export interface ClientFactoryOptions { * If no matches are found among preferred transports, agent card values are used next. */ preferredTransports?: TransportProtocolName[]; + + /** + * Used for createFromAgentCardUrl to download agent card. + */ + cardResolver?: AgentCardResolver; } export const ClientFactoryOptions = { @@ -31,6 +37,7 @@ export const ClientFactoryOptions = { export class ClientFactory { private readonly transportsByName = new Map(); + private readonly agentCardResolver: AgentCardResolver; constructor(public readonly options: ClientFactoryOptions = ClientFactoryOptions.Default) { if (!options.transports || options.transports.length === 0) { @@ -50,8 +57,12 @@ export class ClientFactory { ); } } + this.agentCardResolver = options.cardResolver ?? AgentCardResolver.Default; } + /** + * Creates a new client from the provided agent card. + */ async createFromAgentCard(agentCard: AgentCard): Promise { const agentCardPreferred = agentCard.preferredTransport ?? JsonRpcTransportFactory.name; const additionalInterfaces = agentCard.additionalInterfaces ?? []; @@ -83,4 +94,13 @@ export class ClientFactory { [...this.transportsByName.keys()].join() ); } + + /** + * Downloads agent card using AgentCardResolver from options + * and creates a new client from the downloaded card. + */ + async createFromAgentCardUrl(baseUrl: string, path?: string): Promise { + const agentCard = await this.agentCardResolver.resolve(baseUrl, path); + return await this.createFromAgentCard(agentCard); + } } diff --git a/src/client/index.ts b/src/client/index.ts index 99ce6b1a..aec09f85 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -5,6 +5,11 @@ export { A2AClient } from './client.js'; export type { A2AClientOptions } from './client.js'; export * from './auth-handler.js'; +export { + AgentCardResolver, + AgentCardResolverOptions, + DefaultAgentCardResolver, +} from './card-resolver.js'; export { Client, ClientConfig, RequestOptions } from './multitransport-client.js'; export { Transport, TransportFactory } from './transports/transport.js'; export { ClientFactory, ClientFactoryOptions } from './factory.js'; diff --git a/test/client/card-resolver.spec.ts b/test/client/card-resolver.spec.ts new file mode 100644 index 00000000..8d1792b8 --- /dev/null +++ b/test/client/card-resolver.spec.ts @@ -0,0 +1,136 @@ +import { DefaultAgentCardResolver } from '../../src/client/card-resolver.js'; +import sinon from 'sinon'; +import { AgentCard } from '../../src/types.js'; +import { expect } from 'chai'; + +describe('DefaultAgentCardResolver', () => { + let mockFetch: sinon.SinonStub; + + const testAgentCard: AgentCard = { + protocolVersion: '0.3.0', + name: 'Test Agent', + description: 'An agent for testing purposes', + url: 'http://localhost:8080', + preferredTransport: 'JSONRPC', + version: '1.0.0', + capabilities: { + streaming: true, + pushNotifications: true, + }, + defaultInputModes: ['text/plain'], + defaultOutputModes: ['text/plain'], + skills: [], + }; + + beforeEach(() => { + mockFetch = sinon.stub(); + }); + + it('should fetch the agent card', async () => { + const resolver = new DefaultAgentCardResolver({ fetchImpl: mockFetch }); + mockFetch.resolves( + new Response(JSON.stringify(testAgentCard), { + status: 200, + }) + ); + + const actual = await resolver.resolve('https://example.com'); + + expect(actual).to.deep.equal(testAgentCard); + expect( + mockFetch.calledOnceWith( + sinon.match.has('href', 'https://example.com/.well-known/agent-card.json') + ) + ).to.be.true; + }); + + const pathTests = [ + { + baseUrl: 'https://example.com', + path: 'a2a/catalog/my-agent-card.json', + expected: 'https://example.com/a2a/catalog/my-agent-card.json', + }, + { + baseUrl: 'https://example.com', + path: undefined, + expected: 'https://example.com/.well-known/agent-card.json', + }, + { + baseUrl: 'https://example.com/.well-known/agent-card.json', + path: '', + expected: 'https://example.com/.well-known/agent-card.json', + }, + ]; + + pathTests.forEach((test) => { + it(`should use custom path "${test.path}" from config`, async () => { + const resolver = new DefaultAgentCardResolver({ + fetchImpl: mockFetch, + path: test.path, + }); + mockFetch.resolves( + new Response(JSON.stringify(testAgentCard), { + status: 200, + }) + ); + + const actual = await resolver.resolve(test.baseUrl); + + expect(actual).to.deep.equal(testAgentCard); + expect(mockFetch.calledOnceWithExactly(sinon.match.has('href', test.expected))).to.be.true; + }); + + it(`should use custom path "${test.path}" from parameter`, async () => { + const resolver = new DefaultAgentCardResolver({ + fetchImpl: mockFetch, + }); + mockFetch.resolves( + new Response(JSON.stringify(testAgentCard), { + status: 200, + }) + ); + + const actual = await resolver.resolve(test.baseUrl, test.path); + + expect(actual).to.deep.equal(testAgentCard); + expect(mockFetch.calledOnceWith(sinon.match.has('href', test.expected))).to.be.true; + }); + }); + + it('should use custom fetch impl', async () => { + const myFetch = () => { + return new Promise((resolve) => { + resolve( + new Response(JSON.stringify(testAgentCard), { + status: 200, + }) + ); + }); + }; + const resolver = new DefaultAgentCardResolver({ + fetchImpl: myFetch, + path: 'a2a/catalog/my-agent-card.json', + }); + + const actual = await resolver.resolve('https://example.com'); + + expect(actual).to.deep.equal(testAgentCard); + expect(mockFetch.notCalled).to.be.true; + }); + + it('should throw on non-OK response', async () => { + const resolver = new DefaultAgentCardResolver({ fetchImpl: mockFetch }); + mockFetch.resolves( + new Response(JSON.stringify(testAgentCard), { + status: 404, + }) + ); + + try { + await resolver.resolve('https://example.com'); + expect.fail('Should have thrown error'); + } catch (e: any) { + expect(e.message).to.include('Failed to fetch Agent Card from https://example.com'); + } + }); +}); diff --git a/test/client/factory.spec.ts b/test/client/factory.spec.ts index c1d51d73..3b799b15 100644 --- a/test/client/factory.spec.ts +++ b/test/client/factory.spec.ts @@ -74,7 +74,6 @@ describe('ClientFactory', () => { }); describe('createClient', () => { - let factory: ClientFactory; let agentCard: AgentCard; beforeEach(() => { @@ -93,7 +92,7 @@ describe('ClientFactory', () => { }); it('should use agentCard.preferredTransport if available and supported', async () => { - factory = new ClientFactory({ transports: [mockTransportFactory1] }); + const factory = new ClientFactory({ transports: [mockTransportFactory1] }); const client = await factory.createFromAgentCard(agentCard); @@ -104,7 +103,7 @@ describe('ClientFactory', () => { it('should use factory preferred transport if available', async () => { agentCard.additionalInterfaces = [{ transport: 'Transport2', url: 'http://transport2.com' }]; - factory = new ClientFactory({ + const factory = new ClientFactory({ transports: [mockTransportFactory1, mockTransportFactory2], preferredTransports: ['Transport2'], }); @@ -115,7 +114,7 @@ describe('ClientFactory', () => { }); it('should throw error if no compatible transport found', async () => { - factory = new ClientFactory({ transports: [mockTransportFactory1] }); + const factory = new ClientFactory({ transports: [mockTransportFactory1] }); agentCard.preferredTransport = 'Transport2'; // Not supported try { @@ -127,7 +126,7 @@ describe('ClientFactory', () => { }); it('should fallback to default transport if preferred transport is missing but default supported', async () => { - factory = new ClientFactory({ + const factory = new ClientFactory({ transports: [mockTransportFactory1, mockTransportFactory2], preferredTransports: ['Transport2'], // Not supported }); @@ -143,7 +142,7 @@ describe('ClientFactory', () => { protocolName: JsonRpcTransportFactory.name, create: sinon.stub().resolves(mockTransport), }; - factory = new ClientFactory({ transports: [jsonRpcFactory] }); + const factory = new ClientFactory({ transports: [jsonRpcFactory] }); await factory.createFromAgentCard(agentCard); @@ -152,7 +151,7 @@ describe('ClientFactory', () => { it('should pass clientConfig to the created Client', async () => { const clientConfig = { polling: true }; - factory = new ClientFactory({ + const factory = new ClientFactory({ transports: [mockTransportFactory1], clientConfig, }); @@ -161,5 +160,36 @@ describe('ClientFactory', () => { expect(client.config).to.equal(clientConfig); }); + + it('should use card resolver with default path', async () => { + const cardResolver = { + resolve: sinon.stub().resolves(agentCard), + }; + const factory = new ClientFactory({ + transports: [mockTransportFactory1], + cardResolver, + }); + + await factory.createFromAgentCardUrl('http://transport1.com'); + + expect(mockTransportFactory1.create.calledOnce); + expect(cardResolver.resolve.calledOnceWith('http://transport1.com')).to.be.true; + }); + + it('should use card resolver with custom path', async () => { + const cardResolver = { + resolve: sinon.stub().resolves(agentCard), + }; + const factory = new ClientFactory({ + transports: [mockTransportFactory1], + cardResolver, + }); + + await factory.createFromAgentCardUrl('http://transport1.com', 'a2a/my-agent-card.json'); + + expect(mockTransportFactory1.create.calledOnce); + expect(cardResolver.resolve.calledOnceWith('http://transport1.com', 'a2a/my-agent-card.json')) + .to.be.true; + }); }); });