@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/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
+ }