Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
f0df108
WIP
ishymko Nov 6, 2025
df4890c
WIP: builds
ishymko Nov 6, 2025
bba1cb6
WIP
ishymko Nov 6, 2025
dbef872
Merge remote-tracking branch 'origin/main' into ishymko/client-transport
ishymko Nov 6, 2025
7eac830
WIP: error handling
ishymko Nov 7, 2025
2691614
Backport fix from main
ishymko Nov 7, 2025
a3a33bc
WIP: tests pass
ishymko Nov 7, 2025
df49442
WIP
ishymko Nov 7, 2025
08c81ae
Backport error handling fix
ishymko Nov 7, 2025
d31e244
Cosmetics, declare client errors in client
ishymko Nov 7, 2025
39b4a47
Revert error extraction
ishymko Nov 7, 2025
a1a3ff2
Share errors between client and server
ishymko Nov 7, 2025
ede8673
Make types more accurate
ishymko Nov 7, 2025
88d8dcf
Merge branch 'main' into ishymko/client-transport
ishymko Nov 7, 2025
4317636
Review updates
ishymko Nov 12, 2025
c18739d
Add some comments on compatibility related changes
ishymko Nov 12, 2025
696ccdb
Improve comment around JsonRpcTransport type used in A2AClient
ishymko Nov 13, 2025
501cae5
Minor comments
ishymko Nov 14, 2025
fa8fd24
Merge remote-tracking branch 'origin/main' into ishymko/client-transport
ishymko Nov 14, 2025
92154f3
Merge branch 'main' into ishymko/client-transport
ishymko Nov 17, 2025
0297b37
Merge remote-tracking branch 'origin/main' into ishymko/client-transport
ishymko Nov 18, 2025
547faea
Rename client.ts to legacy.ts
ishymko Nov 18, 2025
9d23991
WIP
ishymko Nov 18, 2025
57398dc
Merge remote-tracking branch 'origin/main' into ishymko/client-multi-…
ishymko Nov 19, 2025
9b66539
npm run lint:fix
ishymko Nov 19, 2025
7e71dab
Add abort signal everywhere
ishymko Nov 19, 2025
ef1dfaa
WIP
ishymko Nov 19, 2025
3b0867d
WIP
ishymko Nov 20, 2025
225dd75
WIP
ishymko Nov 20, 2025
caacb80
Merge remote-tracking branch 'origin/main' into ishymko/client-multi-…
ishymko Nov 20, 2025
e6ce316
Fixes after merge
ishymko Nov 20, 2025
81aaa80
Rename tests
ishymko Nov 20, 2025
8ad7df9
WIP: prototype
ishymko Nov 21, 2025
66626bf
WIP
ishymko Nov 24, 2025
a66b123
Renamings
ishymko Nov 24, 2025
967f421
Minor
ishymko Nov 24, 2025
d95745d
Revert client_auth.spec.ts
ishymko Nov 24, 2025
d3b185b
Minors
ishymko Nov 24, 2025
2949081
WIP
ishymko Nov 25, 2025
f68cb44
Minor
ishymko Nov 25, 2025
72916c3
Add close method
ishymko Nov 25, 2025
8507f0c
Revert "Add close method"
ishymko Nov 25, 2025
2db00af
Merge branch 'main' into ishymko/client-multi-transport
ishymko Nov 25, 2025
2f2eaa3
createClient -> createFromAgentCard
ishymko Nov 26, 2025
5ece211
factory.ts: minors
ishymko Nov 26, 2025
a242822
Do not override send message config from request
ishymko Nov 26, 2025
732321c
Review fix
ishymko Nov 26, 2025
7c2894c
Merge remote-tracking branch 'origin/main' into ishymko/client-multi-…
ishymko Nov 27, 2025
c871ad6
Review updates
ishymko Nov 27, 2025
893d78c
Merge remote-tracking branch 'origin/main' into ishymko/client-multi-…
ishymko Nov 27, 2025
c970547
Fix tests after merge
ishymko Nov 27, 2025
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
"clean": "gts clean",
"build": "tsup",
"pretest": "npm run build",
"test": "mocha test/**/*.spec.ts",
"test": "mocha \"test/**/*.spec.ts\"",
"lint": "npx eslint .",
"format:readme": "prettier --write ./README.md",
"lint:fix": "npx eslint . --fix",
Expand Down
17 changes: 10 additions & 7 deletions src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from '../types.js'; // Assuming schema.ts is in the same directory or appropriately pathed
import { AGENT_CARD_PATH } from '../constants.js';
import { JsonRpcTransport } from './transports/json_rpc_transport.js';
import { RequestOptions } from './transports/transport.js';

export type A2AStreamEventData = Message | Task | TaskStatusUpdateEvent | TaskArtifactUpdateEvent;

Expand All @@ -38,6 +39,8 @@ export interface A2AClientOptions {
* Only JSON-RPC transport is supported.
*/
export class A2AClient {
private static emptyOptions?: RequestOptions = undefined;

private readonly agentCardPromise: Promise<AgentCard>;
private readonly customFetchImpl?: typeof fetch;
private serviceEndpointUrl?: string; // To be populated from AgentCard after fetchin
Expand Down Expand Up @@ -150,7 +153,7 @@ export class A2AClient {
*/
public async sendMessage(params: MessageSendParams): Promise<SendMessageResponse> {
return await this.invokeJsonRpc<MessageSendParams, SendMessageResponse>(
(t, p, id) => t.sendMessage(p, id),
(t, p, id) => t.sendMessage(p, A2AClient.emptyOptions, id),
params
);
}
Expand Down Expand Up @@ -196,7 +199,7 @@ export class A2AClient {
return await this.invokeJsonRpc<
TaskPushNotificationConfig,
SetTaskPushNotificationConfigResponse
>((t, p, id) => t.setTaskPushNotificationConfig(p, id), params);
>((t, p, id) => t.setTaskPushNotificationConfig(p, A2AClient.emptyOptions, id), params);
}

/**
Expand All @@ -208,7 +211,7 @@ export class A2AClient {
params: TaskIdParams
): Promise<GetTaskPushNotificationConfigResponse> {
return await this.invokeJsonRpc<TaskIdParams, GetTaskPushNotificationConfigResponse>(
(t, p, id) => t.getTaskPushNotificationConfig(p, id),
(t, p, id) => t.getTaskPushNotificationConfig(p, A2AClient.emptyOptions, id),
params
);
}
Expand All @@ -224,7 +227,7 @@ export class A2AClient {
return await this.invokeJsonRpc<
ListTaskPushNotificationConfigParams,
ListTaskPushNotificationConfigResponse
>((t, p, id) => t.listTaskPushNotificationConfig(p, id), params);
>((t, p, id) => t.listTaskPushNotificationConfig(p, A2AClient.emptyOptions, id), params);
}

/**
Expand All @@ -238,7 +241,7 @@ export class A2AClient {
return await this.invokeJsonRpc<
DeleteTaskPushNotificationConfigParams,
DeleteTaskPushNotificationConfigResponse
>((t, p, id) => t.deleteTaskPushNotificationConfig(p, id), params);
>((t, p, id) => t.deleteTaskPushNotificationConfig(p, A2AClient.emptyOptions, id), params);
}

/**
Expand All @@ -248,7 +251,7 @@ export class A2AClient {
*/
public async getTask(params: TaskQueryParams): Promise<GetTaskResponse> {
return await this.invokeJsonRpc<TaskQueryParams, GetTaskResponse>(
(t, p, id) => t.getTask(p, id),
(t, p, id) => t.getTask(p, A2AClient.emptyOptions, id),
params
);
}
Expand All @@ -260,7 +263,7 @@ export class A2AClient {
*/
public async cancelTask(params: TaskIdParams): Promise<CancelTaskResponse> {
return await this.invokeJsonRpc<TaskIdParams, CancelTaskResponse>(
(t, p, id) => t.cancelTask(p, id),
(t, p, id) => t.cancelTask(p, A2AClient.emptyOptions, id),
params
);
}
Expand Down
86 changes: 86 additions & 0 deletions src/client/factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { TransportProtocolName } from '../core.js';
import { AgentCard } from '../types.js';
import { Client, ClientConfig } from './multitransport-client.js';
import { JsonRpcTransportFactory } from './transports/json_rpc_transport.js';
import { TransportFactory } from './transports/transport.js';

export interface ClientFactoryOptions {
/**
* Transport factories to use.
* Effectively defines transports supported by this client factory.
*/
transports: ReadonlyArray<TransportFactory>;

/**
* Client config to be used for clients created by this factory.
*/
clientConfig?: ClientConfig;

/**
* Transport preferences to override ones defined by the agent card.
* If no matches are found among preferred transports, agent card values are used next.
*/
preferredTransports?: TransportProtocolName[];
}

export const ClientFactoryOptions = {
Default: {
transports: [new JsonRpcTransportFactory()],
},
};

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

constructor(public readonly options: ClientFactoryOptions = ClientFactoryOptions.Default) {
if (!options.transports || options.transports.length === 0) {
throw new Error('No transports provided');
}
for (const transport of options.transports) {
if (this.transportsByName.has(transport.protocolName)) {
throw new Error(`Duplicate transport name: ${transport.protocolName}`);
}
this.transportsByName.set(transport.protocolName, transport);
}
for (const transport of options.preferredTransports ?? []) {
const factory = this.options.transports.find((t) => t.protocolName === transport);
if (!factory) {
throw new Error(
`Unknown preferred transport: ${transport}, available transports: ${[...this.transportsByName.keys()].join()}`
);
}
}
}

async createFromAgentCard(agentCard: AgentCard): Promise<Client> {
const agentCardPreferred = agentCard.preferredTransport ?? JsonRpcTransportFactory.name;
const additionalInterfaces = agentCard.additionalInterfaces ?? [];
const urlsPerAgentTransports = new Map<string, string>([
[agentCardPreferred, agentCard.url],
...additionalInterfaces.map<[string, string]>((i) => [i.transport, i.url]),
]);
const transportsByPreference = [
...(this.options.preferredTransports ?? []),
agentCardPreferred,
...additionalInterfaces.map((i) => i.transport),
];
for (const transport of transportsByPreference) {
if (!urlsPerAgentTransports.has(transport)) {
continue;
}
const factory = this.transportsByName.get(transport);
if (!factory) {
continue;
}
return new Client(
await factory.create(urlsPerAgentTransports.get(transport), agentCard),
agentCard,
this.options.clientConfig
);
}
throw new Error(
'No compatible transport found, available transports: ' +
[...this.transportsByName.keys()].join()
);
}
}
155 changes: 155 additions & 0 deletions src/client/multitransport-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { PushNotificationNotSupportedError } from '../errors.js';
import {
MessageSendParams,
TaskPushNotificationConfig,
DeleteTaskPushNotificationConfigParams,
ListTaskPushNotificationConfigParams,
Task,
TaskIdParams,
TaskQueryParams,
PushNotificationConfig,
AgentCard,
} from '../types.js';
import { A2AStreamEventData, SendMessageResult } from './client.js';
import { Transport } from './transports/transport.js';

export interface ClientConfig {
/**
* Whether client prefers to poll for task updates instead of blocking until a terminal state is reached.
* If set to true, non-streaming send message result might be a Message or a Task in any (including non-terminal) state.
* Callers are responsible for running the polling loop. This configuration does not apply to streaming requests.
*/
polling: boolean;

/**
* Specifies the default list of accepted media types to apply for all "send message" calls.
*/
acceptedOutputModes?: string[];

/**
* Specifies the default push notification configuration to apply for every Task.
*/
pushNotificationConfig?: PushNotificationConfig;
}

export class Client {
constructor(
public readonly transport: Transport,
public readonly agentCard: AgentCard,
public readonly config?: ClientConfig
) {}

/**
* Sends a message to an agent to initiate a new interaction or to continue an existing one.
* Uses blocking mode by default.
*/
sendMessage(params: MessageSendParams): Promise<SendMessageResult> {
params = this.applyClientConfig({
params,
blocking: !(this.config?.polling ?? false),
});
return this.transport.sendMessage(params);
}

/**
* Sends a message to an agent to initiate/continue a task AND subscribes the client to real-time updates for that task.
* Performs fallback to non-streaming if not supported by the agent.
*/
async *sendMessageStream(
params: MessageSendParams
): AsyncGenerator<A2AStreamEventData, void, undefined> {
params = this.applyClientConfig({ params, blocking: true });
if (!this.agentCard.capabilities.streaming) {
yield this.transport.sendMessage(params);
return;
}
yield* this.transport.sendMessageStream(params);
}

/**
* Sets or updates the push notification configuration for a specified task.
* Requires the server to have AgentCard.capabilities.pushNotifications: true.
*/
setTaskPushNotificationConfig(
params: TaskPushNotificationConfig
): Promise<TaskPushNotificationConfig> {
if (!this.agentCard.capabilities.pushNotifications) {
throw new PushNotificationNotSupportedError();
}
return this.transport.setTaskPushNotificationConfig(params);
}

/**
* Retrieves the current push notification configuration for a specified task.
* Requires the server to have AgentCard.capabilities.pushNotifications: true.
*/
getTaskPushNotificationConfig(params: TaskIdParams): Promise<TaskPushNotificationConfig> {
if (!this.agentCard.capabilities.pushNotifications) {
throw new PushNotificationNotSupportedError();
}
return this.transport.getTaskPushNotificationConfig(params);
}

/**
* Retrieves the associated push notification configurations for a specified task.
* Requires the server to have AgentCard.capabilities.pushNotifications: true.
*/
listTaskPushNotificationConfig(
params: ListTaskPushNotificationConfigParams
): Promise<TaskPushNotificationConfig[]> {
if (!this.agentCard.capabilities.pushNotifications) {
throw new PushNotificationNotSupportedError();
}
return this.transport.listTaskPushNotificationConfig(params);
}

/**
* Deletes an associated push notification configuration for a task.
*/
deleteTaskPushNotificationConfig(params: DeleteTaskPushNotificationConfigParams): Promise<void> {
return this.transport.deleteTaskPushNotificationConfig(params);
}

/**
* Retrieves the current state (including status, artifacts, and optionally history) of a previously initiated task.
*/
getTask(params: TaskQueryParams): Promise<Task> {
return this.transport.getTask(params);
}

/**
* Requests the cancellation of an ongoing task. The server will attempt to cancel the task,
* but success is not guaranteed (e.g., the task might have already completed or failed, or cancellation might not be supported at its current stage).
*/
cancelTask(params: TaskIdParams): Promise<Task> {
return this.transport.cancelTask(params);
}

/**
* Allows a client to reconnect to an updates stream for an ongoing task after a previous connection was interrupted.
*/
async *resubscribeTask(
params: TaskIdParams
): AsyncGenerator<A2AStreamEventData, void, undefined> {
yield* this.transport.resubscribeTask(params);
}

private applyClientConfig({
params,
blocking,
}: {
params: MessageSendParams;
blocking: boolean;
}): MessageSendParams {
const result = { ...params, configuration: params.configuration ?? {} };

if (!result.configuration.acceptedOutputModes && this.config?.acceptedOutputModes) {
result.configuration.acceptedOutputModes = this.config.acceptedOutputModes;
}
if (!result.configuration.pushNotificationConfig && this.config?.pushNotificationConfig) {
result.configuration.pushNotificationConfig = this.config.pushNotificationConfig;
}
result.configuration.blocking ??= blocking;
return result;
}
}
Loading