@galdor/a2a 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +107 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +241 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +89 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +321 -0
- package/dist/server.js.map +1 -0
- package/dist/types.d.ts +236 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +148 -0
- package/dist/types.js.map +1 -0
- package/package.json +36 -0
- package/src/a2a.test.ts +267 -0
- package/src/client.ts +298 -0
- package/src/index.ts +54 -0
- package/src/server.ts +408 -0
- package/src/types.ts +297 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The A2A HTTP surface as a Bun.serve-compatible fetch handler.
|
|
3
|
+
*
|
|
4
|
+
* Two routes are served:
|
|
5
|
+
* - GET /.well-known/agent.json → the Agent Card
|
|
6
|
+
* - POST <any other path> → JSON-RPC (tasks/send, tasks/get)
|
|
7
|
+
*
|
|
8
|
+
* Tasks live in an in-memory store keyed by id. Sends targeting the same id are
|
|
9
|
+
* serialized through a per-entry promise chain, while the task handler itself
|
|
10
|
+
* runs without holding any data lock — so a concurrent `tasks/get` observes the
|
|
11
|
+
* "working" state mid-flight. The single-threaded event loop keeps each
|
|
12
|
+
* synchronous section atomic.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
AGENT_CARD_PATH,
|
|
17
|
+
agentText,
|
|
18
|
+
appendMessage,
|
|
19
|
+
ERR_INTERNAL_ERROR,
|
|
20
|
+
ERR_INVALID_PARAMS,
|
|
21
|
+
ERR_INVALID_REQUEST,
|
|
22
|
+
ERR_INVALID_TASK_STATE,
|
|
23
|
+
ERR_METHOD_NOT_FOUND,
|
|
24
|
+
ERR_PARSE_ERROR,
|
|
25
|
+
ERR_TASK_NOT_FOUND,
|
|
26
|
+
isTerminalState,
|
|
27
|
+
METHOD_TASKS_GET,
|
|
28
|
+
METHOD_TASKS_SEND,
|
|
29
|
+
} from "./types.ts";
|
|
30
|
+
import type {
|
|
31
|
+
AgentCard,
|
|
32
|
+
RPCErrorObject,
|
|
33
|
+
RPCId,
|
|
34
|
+
RPCRequest,
|
|
35
|
+
RPCResponse,
|
|
36
|
+
Task,
|
|
37
|
+
TaskMessage,
|
|
38
|
+
TasksGetParams,
|
|
39
|
+
TasksSendParams,
|
|
40
|
+
} from "./types.ts";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Processes a single task. The handler receives the task with the user's
|
|
44
|
+
* incoming message already appended and is expected to mutate it in place:
|
|
45
|
+
* append agent turns, set artifacts, and set `status.state` to a terminal value
|
|
46
|
+
* (`completed`/`failed`/`canceled`) — or to `input-required` when the agent
|
|
47
|
+
* needs more input from the user. Throwing transitions the task to `"failed"`,
|
|
48
|
+
* with the thrown message recorded in `status.message`.
|
|
49
|
+
*/
|
|
50
|
+
export interface Handler {
|
|
51
|
+
/**
|
|
52
|
+
* @param task - The task to advance; mutate it in place.
|
|
53
|
+
* @param signal - Aborts when the inbound request is cancelled.
|
|
54
|
+
*/
|
|
55
|
+
handle(task: Task, signal?: AbortSignal): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Plain-function form of a {@link Handler}. */
|
|
59
|
+
export type HandlerFn = (task: Task, signal?: AbortSignal) => Promise<void>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Adapts a plain function into a {@link Handler}.
|
|
63
|
+
*
|
|
64
|
+
* @param fn - The task-handling function.
|
|
65
|
+
* @returns A {@link Handler} that delegates to `fn`.
|
|
66
|
+
*/
|
|
67
|
+
export function handlerFunc(fn: HandlerFn): Handler {
|
|
68
|
+
return { handle: fn };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Inbound-request and store limits. The server accepts unauthenticated input,
|
|
72
|
+
// so every growth vector is bounded.
|
|
73
|
+
const MAX_REQUEST_BYTES = 4 << 20; // 4 MiB
|
|
74
|
+
const MAX_TASK_ID_LEN = 512;
|
|
75
|
+
const DEFAULT_MAX_TASKS = 4096;
|
|
76
|
+
|
|
77
|
+
interface TaskEntry {
|
|
78
|
+
task: Task;
|
|
79
|
+
updated: number;
|
|
80
|
+
/** Promise chain that serializes sends targeting this task id. */
|
|
81
|
+
lock: Promise<void>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Exposes an agent over the A2A HTTP surface.
|
|
86
|
+
*
|
|
87
|
+
* Prefer {@link newServer} for construction. Wire {@link Server.fetch} into
|
|
88
|
+
* Bun.serve to start listening.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* const server = newServer(card, async (task) => {
|
|
92
|
+
* appendMessage(task, agentText("hi"));
|
|
93
|
+
* task.status.state = "completed";
|
|
94
|
+
* });
|
|
95
|
+
* Bun.serve({ port: 8080, fetch: server.fetch });
|
|
96
|
+
*/
|
|
97
|
+
export class Server {
|
|
98
|
+
/** The Agent Card served verbatim at the well-known path. */
|
|
99
|
+
readonly card: AgentCard;
|
|
100
|
+
private readonly handler: Handler;
|
|
101
|
+
/** Maximum number of tasks retained in the in-memory store. */
|
|
102
|
+
maxTasks = DEFAULT_MAX_TASKS;
|
|
103
|
+
private readonly tasks = new Map<string, TaskEntry>();
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param card - The Agent Card to advertise.
|
|
107
|
+
* @param handler - The handler that processes each task.
|
|
108
|
+
*/
|
|
109
|
+
constructor(card: AgentCard, handler: Handler) {
|
|
110
|
+
this.card = card;
|
|
111
|
+
this.handler = handler;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Bun.serve-compatible request handler, pre-bound so it can be passed by
|
|
116
|
+
* reference. Routes GETs of the well-known path to the Agent Card, POSTs to
|
|
117
|
+
* the JSON-RPC dispatcher, and rejects other methods with HTTP 405.
|
|
118
|
+
*
|
|
119
|
+
* @param req - The incoming HTTP request.
|
|
120
|
+
* @returns The HTTP response.
|
|
121
|
+
*/
|
|
122
|
+
fetch = async (req: Request): Promise<Response> => {
|
|
123
|
+
const url = new URL(req.url);
|
|
124
|
+
if (req.method === "GET" && url.pathname === AGENT_CARD_PATH) {
|
|
125
|
+
return this.serveAgentCard();
|
|
126
|
+
}
|
|
127
|
+
if (req.method !== "POST") {
|
|
128
|
+
return new Response("method not allowed", { status: 405 });
|
|
129
|
+
}
|
|
130
|
+
return this.serveJSONRPC(req);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
private serveAgentCard(): Response {
|
|
134
|
+
return new Response(JSON.stringify(this.card), {
|
|
135
|
+
status: 200,
|
|
136
|
+
headers: {
|
|
137
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
138
|
+
"Cache-Control": "no-store",
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async serveJSONRPC(req: Request): Promise<Response> {
|
|
144
|
+
// Bound the body: an unauthenticated peer must not drive unbounded
|
|
145
|
+
// allocation with a giant POST. Reject early on a declared Content-Length
|
|
146
|
+
// over the cap, and otherwise count bytes as the body streams in so an
|
|
147
|
+
// unsized body is also rejected once it crosses the cap.
|
|
148
|
+
const declared = req.headers.get("content-length");
|
|
149
|
+
if (declared !== null) {
|
|
150
|
+
const n = Number(declared);
|
|
151
|
+
if (Number.isFinite(n) && n > MAX_REQUEST_BYTES) {
|
|
152
|
+
return jsonResponse(errorReply(null, ERR_PARSE_ERROR, "parse error", "request body too large"));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const body = await readRequestCapped(req, MAX_REQUEST_BYTES);
|
|
156
|
+
if (body === null) {
|
|
157
|
+
return jsonResponse(errorReply(null, ERR_PARSE_ERROR, "parse error", "request body too large"));
|
|
158
|
+
}
|
|
159
|
+
let msg: RPCRequest;
|
|
160
|
+
try {
|
|
161
|
+
msg = JSON.parse(body) as RPCRequest;
|
|
162
|
+
} catch (e) {
|
|
163
|
+
return jsonResponse(errorReply(null, ERR_PARSE_ERROR, "parse error", String(e)));
|
|
164
|
+
}
|
|
165
|
+
const id = msg.id ?? null;
|
|
166
|
+
if (msg.jsonrpc !== "2.0") {
|
|
167
|
+
return jsonResponse(
|
|
168
|
+
errorReply(id, ERR_INVALID_REQUEST, 'jsonrpc must be "2.0"', String(msg.jsonrpc)),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
let reply: RPCResponse;
|
|
172
|
+
switch (msg.method) {
|
|
173
|
+
case METHOD_TASKS_SEND:
|
|
174
|
+
reply = await this.handleTasksSend(id, msg.params, req.signal);
|
|
175
|
+
break;
|
|
176
|
+
case METHOD_TASKS_GET:
|
|
177
|
+
reply = this.handleTasksGet(id, msg.params);
|
|
178
|
+
break;
|
|
179
|
+
default:
|
|
180
|
+
reply = errorReply(id, ERR_METHOD_NOT_FOUND, "method not found", String(msg.method));
|
|
181
|
+
}
|
|
182
|
+
return jsonResponse(reply);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private async handleTasksSend(
|
|
186
|
+
id: RPCId,
|
|
187
|
+
rawParams: unknown,
|
|
188
|
+
signal: AbortSignal,
|
|
189
|
+
): Promise<RPCResponse> {
|
|
190
|
+
const p = rawParams as TasksSendParams | undefined;
|
|
191
|
+
if (!p || typeof p !== "object") {
|
|
192
|
+
return errorReply(id, ERR_INVALID_PARAMS, "decode params", "");
|
|
193
|
+
}
|
|
194
|
+
const message = p.message as TaskMessage | undefined;
|
|
195
|
+
if (!message || !Array.isArray(message.parts) || message.parts.length === 0) {
|
|
196
|
+
return errorReply(id, ERR_INVALID_PARAMS, "message.parts is empty", "");
|
|
197
|
+
}
|
|
198
|
+
const reqID = p.id ?? "";
|
|
199
|
+
if (reqID.length > MAX_TASK_ID_LEN) {
|
|
200
|
+
return errorReply(id, ERR_INVALID_PARAMS, "id too long", "");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Look up or create the entry. The synchronous section is atomic on the
|
|
204
|
+
// event loop. An empty id always allocates a fresh uuid-keyed task.
|
|
205
|
+
let entry = reqID !== "" ? this.tasks.get(reqID) : undefined;
|
|
206
|
+
if (entry === undefined) {
|
|
207
|
+
if (this.tasks.size >= this.maxTasks && !this.evictOne()) {
|
|
208
|
+
return errorReply(id, ERR_INTERNAL_ERROR, "task store is full", "");
|
|
209
|
+
}
|
|
210
|
+
const newID = reqID !== "" ? reqID : crypto.randomUUID();
|
|
211
|
+
const task: Task = {
|
|
212
|
+
id: newID,
|
|
213
|
+
status: { state: "submitted", timestamp: new Date().toISOString() },
|
|
214
|
+
history: [],
|
|
215
|
+
...(p.sessionId !== undefined && p.sessionId !== "" ? { sessionId: p.sessionId } : {}),
|
|
216
|
+
...(p.metadata !== undefined ? { metadata: { ...p.metadata } } : {}),
|
|
217
|
+
};
|
|
218
|
+
entry = { task, updated: Date.now(), lock: Promise.resolve() };
|
|
219
|
+
this.tasks.set(newID, entry);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Serialize same-id sends: chain onto the entry's lock.
|
|
223
|
+
const e = entry;
|
|
224
|
+
const prev = e.lock;
|
|
225
|
+
let release!: () => void;
|
|
226
|
+
e.lock = new Promise<void>((res) => {
|
|
227
|
+
release = res;
|
|
228
|
+
});
|
|
229
|
+
await prev;
|
|
230
|
+
try {
|
|
231
|
+
return await this.processSend(id, e, message, p, signal);
|
|
232
|
+
} finally {
|
|
233
|
+
release();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private async processSend(
|
|
238
|
+
id: RPCId,
|
|
239
|
+
e: TaskEntry,
|
|
240
|
+
message: TaskMessage,
|
|
241
|
+
p: TasksSendParams,
|
|
242
|
+
signal: AbortSignal,
|
|
243
|
+
): Promise<RPCResponse> {
|
|
244
|
+
// Phase 1: reject a terminal task, append the user message, flip to
|
|
245
|
+
// "running" and commit — so a concurrent tasks/get observes progress.
|
|
246
|
+
if (isTerminalState(e.task.status.state)) {
|
|
247
|
+
return errorReply(
|
|
248
|
+
id,
|
|
249
|
+
ERR_INVALID_TASK_STATE,
|
|
250
|
+
"task is in a terminal state and cannot be continued",
|
|
251
|
+
e.task.status.state,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
appendMessage(e.task, message);
|
|
255
|
+
if (p.sessionId !== undefined && p.sessionId !== "") {
|
|
256
|
+
e.task.sessionId = p.sessionId;
|
|
257
|
+
}
|
|
258
|
+
if (p.metadata !== undefined && Object.keys(p.metadata).length > 0) {
|
|
259
|
+
e.task.metadata = { ...(e.task.metadata ?? {}), ...p.metadata };
|
|
260
|
+
}
|
|
261
|
+
// Flip only state + timestamp; a prior status message (e.g. an
|
|
262
|
+
// input-required prompt) stays visible on the mid-handler snapshot.
|
|
263
|
+
e.task.status = {
|
|
264
|
+
...e.task.status,
|
|
265
|
+
state: "working",
|
|
266
|
+
timestamp: new Date().toISOString(),
|
|
267
|
+
};
|
|
268
|
+
e.updated = Date.now();
|
|
269
|
+
|
|
270
|
+
// Detach an independent copy for the handler so its mutations can't bleed
|
|
271
|
+
// into the stored task until committed.
|
|
272
|
+
const wc = structuredClone(e.task);
|
|
273
|
+
|
|
274
|
+
// Phase 2: run the handler. No data lock is held across the await, so a
|
|
275
|
+
// concurrent tasks/get returns the "working" snapshot promptly.
|
|
276
|
+
try {
|
|
277
|
+
await this.handler.handle(wc, signal);
|
|
278
|
+
} catch (err) {
|
|
279
|
+
wc.status = {
|
|
280
|
+
state: "failed",
|
|
281
|
+
message: agentText(err instanceof Error ? err.message : String(err)),
|
|
282
|
+
timestamp: new Date().toISOString(),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
if (!isTerminalState(wc.status.state) && wc.status.state !== "input-required") {
|
|
286
|
+
// Handler returned cleanly but forgot to set a terminal state. A handler
|
|
287
|
+
// that asked for more input (input-required) is left as-is so the task
|
|
288
|
+
// can be continued with a later tasks/send.
|
|
289
|
+
wc.status = { ...wc.status, state: "completed", timestamp: new Date().toISOString() };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Phase 3: commit the result and snapshot for the reply.
|
|
293
|
+
e.task = wc;
|
|
294
|
+
e.updated = Date.now();
|
|
295
|
+
return successReply(id, structuredClone(wc));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private evictOne(): boolean {
|
|
299
|
+
let oldestID = "";
|
|
300
|
+
let oldest = Number.POSITIVE_INFINITY;
|
|
301
|
+
for (const [tid, e] of this.tasks) {
|
|
302
|
+
if (!isTerminalState(e.task.status.state)) continue;
|
|
303
|
+
if (oldestID === "" || e.updated < oldest) {
|
|
304
|
+
oldestID = tid;
|
|
305
|
+
oldest = e.updated;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (oldestID === "") return false;
|
|
309
|
+
this.tasks.delete(oldestID);
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private handleTasksGet(id: RPCId, rawParams: unknown): RPCResponse {
|
|
314
|
+
const p = rawParams as TasksGetParams | undefined;
|
|
315
|
+
if (!p || typeof p !== "object") {
|
|
316
|
+
return errorReply(id, ERR_INVALID_PARAMS, "decode params", "");
|
|
317
|
+
}
|
|
318
|
+
if (!p.id) {
|
|
319
|
+
return errorReply(id, ERR_INVALID_PARAMS, "id is required", "");
|
|
320
|
+
}
|
|
321
|
+
const e = this.tasks.get(p.id);
|
|
322
|
+
if (e === undefined) {
|
|
323
|
+
return errorReply(id, ERR_TASK_NOT_FOUND, "task not found", p.id);
|
|
324
|
+
}
|
|
325
|
+
const snap = structuredClone(e.task);
|
|
326
|
+
if (
|
|
327
|
+
p.historyLength !== undefined &&
|
|
328
|
+
p.historyLength > 0 &&
|
|
329
|
+
snap.history !== undefined &&
|
|
330
|
+
snap.history.length > p.historyLength
|
|
331
|
+
) {
|
|
332
|
+
snap.history = snap.history.slice(snap.history.length - p.historyLength);
|
|
333
|
+
}
|
|
334
|
+
return successReply(id, snap);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Constructs a {@link Server}, accepting either a {@link Handler} object or a
|
|
340
|
+
* plain {@link HandlerFn}. The card is served verbatim at the well-known path.
|
|
341
|
+
*
|
|
342
|
+
* @param card - The Agent Card to advertise.
|
|
343
|
+
* @param handler - The task handler, as an object or a function.
|
|
344
|
+
* @returns A ready-to-serve {@link Server}.
|
|
345
|
+
*/
|
|
346
|
+
export function newServer(card: AgentCard, handler: Handler | HandlerFn): Server {
|
|
347
|
+
const h: Handler = typeof handler === "function" ? { handle: handler } : handler;
|
|
348
|
+
return new Server(card, h);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Reads a request body, counting bytes as they arrive and aborting once the
|
|
353
|
+
* cap is exceeded — so an oversized body is rejected without first being fully
|
|
354
|
+
* buffered.
|
|
355
|
+
*
|
|
356
|
+
* @param req - The incoming request.
|
|
357
|
+
* @param cap - Maximum number of body bytes to accept.
|
|
358
|
+
* @returns The decoded body text, or `null` if the body exceeds `cap`.
|
|
359
|
+
*/
|
|
360
|
+
async function readRequestCapped(req: Request, cap: number): Promise<string | null> {
|
|
361
|
+
const stream = req.body;
|
|
362
|
+
if (stream === null) {
|
|
363
|
+
return "";
|
|
364
|
+
}
|
|
365
|
+
const reader = stream.getReader();
|
|
366
|
+
const chunks: Uint8Array[] = [];
|
|
367
|
+
let total = 0;
|
|
368
|
+
for (;;) {
|
|
369
|
+
const { done, value } = await reader.read();
|
|
370
|
+
if (done) {
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
if (value) {
|
|
374
|
+
total += value.byteLength;
|
|
375
|
+
if (total > cap) {
|
|
376
|
+
await reader.cancel();
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
chunks.push(value);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const buf = new Uint8Array(total);
|
|
383
|
+
let offset = 0;
|
|
384
|
+
for (const chunk of chunks) {
|
|
385
|
+
buf.set(chunk, offset);
|
|
386
|
+
offset += chunk.byteLength;
|
|
387
|
+
}
|
|
388
|
+
return new TextDecoder().decode(buf);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function successReply(id: RPCId, result: unknown): RPCResponse {
|
|
392
|
+
return { jsonrpc: "2.0", id, result };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function errorReply(id: RPCId, code: number, message: string, detail: string): RPCResponse {
|
|
396
|
+
const error: RPCErrorObject =
|
|
397
|
+
detail !== "" ? { code, message, data: { detail } } : { code, message };
|
|
398
|
+
return { jsonrpc: "2.0", id: id ?? null, error };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function jsonResponse(msg: RPCResponse): Response {
|
|
402
|
+
// JSON-RPC over HTTP always returns 200 even for protocol-level errors —
|
|
403
|
+
// the error lives in the response envelope.
|
|
404
|
+
return new Response(JSON.stringify(msg), {
|
|
405
|
+
status: 200,
|
|
406
|
+
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
407
|
+
});
|
|
408
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types, constants, message helpers and errors for the Agent-to-Agent
|
|
3
|
+
* (A2A) protocol carried as JSON-RPC 2.0 over HTTP.
|
|
4
|
+
*
|
|
5
|
+
* Field names are camelCase and serialize directly to the on-wire JSON. Data
|
|
6
|
+
* shapes are plain interfaces; failures are represented as {@link Error}
|
|
7
|
+
* subclasses ({@link A2AError}, {@link RPCError}).
|
|
8
|
+
*
|
|
9
|
+
* Scope notes for this implementation:
|
|
10
|
+
* - {@link TaskState} is the A2A-spec six-value lifecycle:
|
|
11
|
+
* submitted | working | input-required | completed | failed | canceled.
|
|
12
|
+
* - {@link TaskStatus} carries the state plus an optional status `message`
|
|
13
|
+
* (e.g. an input-required prompt or a failure message) and a `timestamp`.
|
|
14
|
+
* - A task's full message log lives in {@link Task.history}; non-message
|
|
15
|
+
* outputs live in {@link Task.artifacts}.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Well-known location of an agent's card. The A2A spec mandates this exact
|
|
20
|
+
* suffix; clients discover an agent at `<baseURL>/.well-known/agent.json`.
|
|
21
|
+
*/
|
|
22
|
+
export const AGENT_CARD_PATH = "/.well-known/agent.json";
|
|
23
|
+
|
|
24
|
+
/** A2A protocol revision this implementation targets. */
|
|
25
|
+
export const PROTOCOL_VERSION = "0.1";
|
|
26
|
+
|
|
27
|
+
/** JSON-RPC method name for creating or continuing a task. */
|
|
28
|
+
export const METHOD_TASKS_SEND = "tasks/send";
|
|
29
|
+
/** JSON-RPC method name for fetching a task's current state. */
|
|
30
|
+
export const METHOD_TASKS_GET = "tasks/get";
|
|
31
|
+
|
|
32
|
+
// JSON-RPC 2.0 standard error codes.
|
|
33
|
+
/** Malformed JSON in the request body. */
|
|
34
|
+
export const ERR_PARSE_ERROR = -32700;
|
|
35
|
+
/** Request envelope is not a valid JSON-RPC request. */
|
|
36
|
+
export const ERR_INVALID_REQUEST = -32600;
|
|
37
|
+
/** Requested method is not implemented. */
|
|
38
|
+
export const ERR_METHOD_NOT_FOUND = -32601;
|
|
39
|
+
/** Method params are missing or invalid. */
|
|
40
|
+
export const ERR_INVALID_PARAMS = -32602;
|
|
41
|
+
/** Unexpected server-side failure. */
|
|
42
|
+
export const ERR_INTERNAL_ERROR = -32603;
|
|
43
|
+
// A2A-specific error codes defined by the protocol.
|
|
44
|
+
/** No task exists for the supplied id. */
|
|
45
|
+
export const ERR_TASK_NOT_FOUND = -32001;
|
|
46
|
+
/** Task cannot accept the request in its current state (e.g. terminal). */
|
|
47
|
+
export const ERR_INVALID_TASK_STATE = -32002;
|
|
48
|
+
|
|
49
|
+
/** AgentProvider identifies the organization running the agent. */
|
|
50
|
+
export interface AgentProvider {
|
|
51
|
+
organization: string;
|
|
52
|
+
url: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** AgentCapabilities advertises optional protocol features. */
|
|
56
|
+
export interface AgentCapabilities {
|
|
57
|
+
streaming?: boolean;
|
|
58
|
+
pushNotifications?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** AgentSkill is one discrete capability the agent advertises. */
|
|
62
|
+
export interface AgentSkill {
|
|
63
|
+
id: string;
|
|
64
|
+
name: string;
|
|
65
|
+
description?: string;
|
|
66
|
+
tags?: string[];
|
|
67
|
+
examples?: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** AgentCard is the metadata document served at /.well-known/agent.json. */
|
|
71
|
+
export interface AgentCard {
|
|
72
|
+
name: string;
|
|
73
|
+
description?: string;
|
|
74
|
+
url: string;
|
|
75
|
+
version?: string;
|
|
76
|
+
provider?: AgentProvider;
|
|
77
|
+
capabilities: AgentCapabilities;
|
|
78
|
+
skills: AgentSkill[];
|
|
79
|
+
defaultInputModes?: string[];
|
|
80
|
+
defaultOutputModes?: string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Part is one element of a message's content. The A2A spec defines "text",
|
|
85
|
+
* "file" and "data" parts; only "text" is implemented. `type` is the
|
|
86
|
+
* discriminator so future parts deserialize without breaking older readers.
|
|
87
|
+
*/
|
|
88
|
+
export interface Part {
|
|
89
|
+
type: string;
|
|
90
|
+
/** Text is set when type === "text". */
|
|
91
|
+
text?: string;
|
|
92
|
+
metadata?: Record<string, unknown>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Role identifies who sent a message. */
|
|
96
|
+
export type Role = "user" | "agent";
|
|
97
|
+
|
|
98
|
+
/** TaskMessage is one turn in a task's message log. */
|
|
99
|
+
export interface TaskMessage {
|
|
100
|
+
role: Role;
|
|
101
|
+
parts: Part[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* TaskState is the discrete lifecycle state of a task, per the A2A spec.
|
|
106
|
+
*
|
|
107
|
+
* `submitted` → `working` are non-terminal; `input-required` is non-terminal
|
|
108
|
+
* and signals the agent needs more input (the task can be continued with
|
|
109
|
+
* another tasks/send); `completed` / `failed` / `canceled` are terminal.
|
|
110
|
+
*/
|
|
111
|
+
export type TaskState =
|
|
112
|
+
| "submitted"
|
|
113
|
+
| "working"
|
|
114
|
+
| "input-required"
|
|
115
|
+
| "completed"
|
|
116
|
+
| "failed"
|
|
117
|
+
| "canceled";
|
|
118
|
+
|
|
119
|
+
/** TaskStatus is the status block of a Task. */
|
|
120
|
+
export interface TaskStatus {
|
|
121
|
+
state: TaskState;
|
|
122
|
+
/** Optional status message (e.g. an input-required prompt or a failure message). */
|
|
123
|
+
message?: TaskMessage;
|
|
124
|
+
/** RFC 3339 timestamp of the last status transition. Omitted when unset. */
|
|
125
|
+
timestamp?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Artifact is a non-message output of a task (a file, a JSON blob, …). */
|
|
129
|
+
export interface Artifact {
|
|
130
|
+
name?: string;
|
|
131
|
+
description?: string;
|
|
132
|
+
parts: Part[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Task is the unit of work the protocol revolves around. Clients create one
|
|
137
|
+
* with tasks/send; servers append messages and transition Status as they
|
|
138
|
+
* process it; clients poll via tasks/get until the state is terminal.
|
|
139
|
+
*/
|
|
140
|
+
export interface Task {
|
|
141
|
+
id: string;
|
|
142
|
+
sessionId?: string;
|
|
143
|
+
status: TaskStatus;
|
|
144
|
+
/** The message log: the initial user message followed by assistant/user turns. */
|
|
145
|
+
history?: TaskMessage[];
|
|
146
|
+
/** Intermediate outputs that don't belong in the message log. */
|
|
147
|
+
artifacts?: Artifact[];
|
|
148
|
+
metadata?: Record<string, unknown>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Builds a text {@link Part} from a string.
|
|
153
|
+
*
|
|
154
|
+
* @param s - The text content.
|
|
155
|
+
* @returns A part with `type` `"text"`.
|
|
156
|
+
*/
|
|
157
|
+
export function textPart(s: string): Part {
|
|
158
|
+
return { type: "text", text: s };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Builds a user-role {@link TaskMessage} carrying a single text part.
|
|
163
|
+
*
|
|
164
|
+
* @param s - The user's text.
|
|
165
|
+
* @returns A message with role `"user"`.
|
|
166
|
+
* @example
|
|
167
|
+
* client.sendTask(userText("What is the weather?"));
|
|
168
|
+
*/
|
|
169
|
+
export function userText(s: string): TaskMessage {
|
|
170
|
+
return { role: "user", parts: [textPart(s)] };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Builds an agent-role {@link TaskMessage} carrying a single text part; the
|
|
175
|
+
* agent-side counterpart of {@link userText}.
|
|
176
|
+
*
|
|
177
|
+
* @param s - The agent's text.
|
|
178
|
+
* @returns A message with role `"agent"`.
|
|
179
|
+
*/
|
|
180
|
+
export function agentText(s: string): TaskMessage {
|
|
181
|
+
return { role: "agent", parts: [textPart(s)] };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Concatenates the text of every text part in a message, joined by newlines.
|
|
186
|
+
* Non-text parts are skipped.
|
|
187
|
+
*
|
|
188
|
+
* @param m - The message to flatten.
|
|
189
|
+
* @returns The combined text, or an empty string if the message has no text
|
|
190
|
+
* parts.
|
|
191
|
+
*/
|
|
192
|
+
export function messageText(m: TaskMessage): string {
|
|
193
|
+
let out = "";
|
|
194
|
+
for (const p of m.parts) {
|
|
195
|
+
if (p.type === "text") {
|
|
196
|
+
if (out !== "") out += "\n";
|
|
197
|
+
out += p.text ?? "";
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Appends a message to a task's history, mutating the task in place. Stamps the
|
|
205
|
+
* status timestamp when it isn't set yet.
|
|
206
|
+
*
|
|
207
|
+
* @param task - The task whose history to extend.
|
|
208
|
+
* @param m - The message to append.
|
|
209
|
+
*/
|
|
210
|
+
export function appendMessage(task: Task, m: TaskMessage): void {
|
|
211
|
+
if (task.history === undefined) task.history = [];
|
|
212
|
+
task.history.push(m);
|
|
213
|
+
if (!task.status.timestamp) task.status.timestamp = new Date().toISOString();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Reports whether a task state is terminal (no further work will occur).
|
|
218
|
+
*
|
|
219
|
+
* @param state - The state to test.
|
|
220
|
+
* @returns `true` for `"completed"`, `"failed"`, or `"canceled"`.
|
|
221
|
+
*/
|
|
222
|
+
export function isTerminalState(state: TaskState): boolean {
|
|
223
|
+
return state === "completed" || state === "failed" || state === "canceled";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Base error for transport and protocol failures raised by this library. */
|
|
227
|
+
export class A2AError extends Error {
|
|
228
|
+
override name = "A2AError";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Error carrying a JSON-RPC error envelope returned by a remote agent.
|
|
233
|
+
*
|
|
234
|
+
* @example
|
|
235
|
+
* try {
|
|
236
|
+
* await client.getTask("missing");
|
|
237
|
+
* } catch (e) {
|
|
238
|
+
* if (e instanceof RPCError) console.error(e.code, e.message);
|
|
239
|
+
* }
|
|
240
|
+
*/
|
|
241
|
+
export class RPCError extends A2AError {
|
|
242
|
+
override name = "RPCError";
|
|
243
|
+
/** The JSON-RPC numeric error code. */
|
|
244
|
+
readonly code: number;
|
|
245
|
+
/** Optional implementation-defined error detail. */
|
|
246
|
+
readonly data?: unknown;
|
|
247
|
+
/**
|
|
248
|
+
* @param code - JSON-RPC error code.
|
|
249
|
+
* @param message - Human-readable error message.
|
|
250
|
+
* @param data - Optional structured error detail.
|
|
251
|
+
*/
|
|
252
|
+
constructor(code: number, message: string, data?: unknown) {
|
|
253
|
+
super(`a2a: JSON-RPC ${code}: ${message}`);
|
|
254
|
+
this.code = code;
|
|
255
|
+
if (data !== undefined) this.data = data;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** JSON-RPC id: a number, string, or null. */
|
|
260
|
+
export type RPCId = number | string | null;
|
|
261
|
+
|
|
262
|
+
/** RPCRequest is an inbound JSON-RPC request envelope. */
|
|
263
|
+
export interface RPCRequest {
|
|
264
|
+
jsonrpc: "2.0";
|
|
265
|
+
id?: RPCId;
|
|
266
|
+
method?: string;
|
|
267
|
+
params?: unknown;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** RPCErrorObject is the `error` member of a JSON-RPC response. */
|
|
271
|
+
export interface RPCErrorObject {
|
|
272
|
+
code: number;
|
|
273
|
+
message: string;
|
|
274
|
+
data?: unknown;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** RPCResponse is an outbound JSON-RPC response envelope. */
|
|
278
|
+
export interface RPCResponse {
|
|
279
|
+
jsonrpc: "2.0";
|
|
280
|
+
id: RPCId;
|
|
281
|
+
result?: unknown;
|
|
282
|
+
error?: RPCErrorObject;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** tasksSendParams is the params payload for `tasks/send`. */
|
|
286
|
+
export interface TasksSendParams {
|
|
287
|
+
id?: string;
|
|
288
|
+
sessionId?: string;
|
|
289
|
+
message: TaskMessage;
|
|
290
|
+
metadata?: Record<string, unknown>;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** tasksGetParams is the params payload for `tasks/get`. */
|
|
294
|
+
export interface TasksGetParams {
|
|
295
|
+
id: string;
|
|
296
|
+
historyLength?: number;
|
|
297
|
+
}
|