Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions src/client/card-resolver.ts
Original file line number Diff line number Diff line change
@@ -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<AgentCard>;
}

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<AgentCard> {
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<typeof fetch>): ReturnType<typeof fetch> {
if (this.options?.fetchImpl) {
return this.options.fetchImpl(...args);
}
return fetch(...args);
}
}

export const AgentCardResolver = {
Default: new DefaultAgentCardResolver(),
};
20 changes: 20 additions & 0 deletions src/client/factory.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 = {
Expand All @@ -31,6 +37,7 @@ export const ClientFactoryOptions = {

export class ClientFactory {
private readonly transportsByName = new Map<string, TransportFactory>();
private readonly agentCardResolver: AgentCardResolver;

constructor(public readonly options: ClientFactoryOptions = ClientFactoryOptions.Default) {
if (!options.transports || options.transports.length === 0) {
Expand All @@ -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<Client> {
const agentCardPreferred = agentCard.preferredTransport ?? JsonRpcTransportFactory.name;
const additionalInterfaces = agentCard.additionalInterfaces ?? [];
Expand Down Expand Up @@ -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<Client> {
const agentCard = await this.agentCardResolver.resolve(baseUrl, path);
return await this.createFromAgentCard(agentCard);
}
}
5 changes: 5 additions & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
136 changes: 136 additions & 0 deletions test/client/card-resolver.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Response>((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');
}
});
});
44 changes: 37 additions & 7 deletions test/client/factory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ describe('ClientFactory', () => {
});

describe('createClient', () => {
let factory: ClientFactory;
let agentCard: AgentCard;

beforeEach(() => {
Expand All @@ -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);

Expand All @@ -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'],
});
Expand All @@ -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 {
Expand All @@ -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
});
Expand All @@ -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);

Expand All @@ -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,
});
Expand All @@ -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;
});
});
});