@nylorun/runtime 0.36.1-beta → 0.38.0-beta

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.
Files changed (71) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +10 -0
  3. package/dist/api/a2a/card.d.ts +10 -4
  4. package/dist/api/a2a/card.js +18 -6
  5. package/dist/api/a2a/endpoint.d.ts +5 -2
  6. package/dist/api/a2a/endpoint.js +31 -9
  7. package/dist/api/a2a/extensions.d.ts +41 -0
  8. package/dist/api/a2a/extensions.js +92 -0
  9. package/dist/api/a2a/list.d.ts +60 -0
  10. package/dist/api/a2a/list.js +169 -0
  11. package/dist/api/a2a/protocol.d.ts +105 -9
  12. package/dist/api/a2a/protocol.js +169 -18
  13. package/dist/api/a2a/push.d.ts +76 -0
  14. package/dist/api/a2a/push.js +305 -0
  15. package/dist/api/a2a/routes.d.ts +12 -6
  16. package/dist/api/a2a/routes.js +409 -112
  17. package/dist/api/a2a/tasks.d.ts +72 -4
  18. package/dist/api/a2a/tasks.js +305 -27
  19. package/dist/api/components.d.ts +538 -0
  20. package/dist/api/components.js +3 -1
  21. package/dist/api/http/app.js +2 -0
  22. package/dist/api/http/routes/artifacts.js +19 -0
  23. package/dist/api/http/routes/turns.d.ts +18 -0
  24. package/dist/api/http/routes/turns.js +67 -0
  25. package/dist/api/inbound-files.d.ts +12 -1
  26. package/dist/api/inbound-files.js +44 -0
  27. package/dist/api/openapi.js +7 -2
  28. package/dist/harness/image-engine.d.ts +14 -0
  29. package/dist/harness/image-engine.js +16 -0
  30. package/dist/harness/pod.d.ts +2 -0
  31. package/dist/harness/pod.js +5 -0
  32. package/dist/harness-api/ws-server.js +7 -1
  33. package/dist/host/config-for.d.ts +2 -0
  34. package/dist/host/config-for.js +1 -0
  35. package/dist/host/main.js +1 -0
  36. package/dist/host/stack-config.d.ts +16 -0
  37. package/dist/host/stack-config.js +30 -0
  38. package/dist/management-openapi.json +37 -2
  39. package/dist/openapi.json +1485 -738
  40. package/dist/reads/types.d.ts +19 -0
  41. package/dist/sandbox/join.d.ts +7 -1
  42. package/dist/sandbox/join.js +5 -1
  43. package/dist/sandbox/pods/builtin.d.ts +18 -0
  44. package/dist/sandbox/pods/builtin.js +33 -0
  45. package/dist/sandbox/pods/client.d.ts +5 -4
  46. package/dist/sandbox/pods/spec.js +14 -1
  47. package/dist/store/postgres/drizzle/0039_a2a_push.sql +32 -0
  48. package/dist/store/postgres/drizzle/0040_turn_index.sql +23 -0
  49. package/dist/store/postgres/drizzle/0041_turn_index_backfill.sql +88 -0
  50. package/dist/store/postgres/drizzle/0042_worker_engine_image.sql +1 -0
  51. package/dist/store/postgres/drizzle/meta/_journal.json +28 -0
  52. package/dist/store/postgres/reads.js +41 -1
  53. package/dist/store/postgres/schema.d.ts +1314 -533
  54. package/dist/store/postgres/schema.js +94 -0
  55. package/dist/store/postgres/store.js +135 -3
  56. package/dist/store/postgres/turns.d.ts +62 -0
  57. package/dist/store/postgres/turns.js +153 -0
  58. package/dist/store/types.d.ts +82 -3
  59. package/dist/tenant/runtime.js +7 -5
  60. package/dist/tenant/session-model.d.ts +11 -1
  61. package/dist/tenant/session-model.js +34 -5
  62. package/dist/tenant/sessions.d.ts +2 -1
  63. package/dist/tenant/sessions.js +8 -2
  64. package/dist/tenant/sweep.d.ts +3 -0
  65. package/dist/tenant/sweep.js +39 -11
  66. package/dist/tenant/types.d.ts +23 -0
  67. package/dist/tuning.d.ts +9 -0
  68. package/dist/tuning.js +11 -0
  69. package/dist/version.d.ts +1 -1
  70. package/dist/version.js +1 -1
  71. package/package.json +3 -3
@@ -0,0 +1,169 @@
1
+ import { encodeCursor } from "../../record/index.js";
2
+ import { historyCursor, readCursor } from "../../reads/cursor.js";
3
+ import { ENDED_TURN_STATUSES } from "../../store/postgres/turns.js";
4
+ import { loadSession } from "../../tenant/context.js";
5
+ import { HttpError } from "../../tenant/http.js";
6
+ import { readHistory } from "../../tenant/session-streams.js";
7
+ import { a2aFail, parseHistoryLength } from "./protocol.js";
8
+ import { contextSessionId } from "./tasks.js";
9
+ /** `ListTasks`' page size when the request names none, and the most it may ask. */
10
+ export const LIST_PAGE_DEFAULT = 50;
11
+ export const LIST_PAGE_MAX = 100;
12
+ /**
13
+ * The turn index states each A2A state is (profile v2 §1): a paused turn by what its first open
14
+ * interaction asks for. `SUBMITTED` (a queued message), `REJECTED` and anything else: none.
15
+ */
16
+ const TURN_STATES = {
17
+ TASK_STATE_WORKING: [{ status: "running" }],
18
+ TASK_STATE_INPUT_REQUIRED: [{ status: "paused", waitingFor: ["question"] }],
19
+ TASK_STATE_AUTH_REQUIRED: [{ status: "paused", waitingFor: ["approval", "connection"] }],
20
+ TASK_STATE_COMPLETED: [{ status: "completed" }],
21
+ TASK_STATE_FAILED: [{ status: "failed" }],
22
+ TASK_STATE_CANCELED: [{ status: "cancelled" }],
23
+ };
24
+ const TASK_STATES = [
25
+ "TASK_STATE_SUBMITTED",
26
+ "TASK_STATE_WORKING",
27
+ "TASK_STATE_INPUT_REQUIRED",
28
+ "TASK_STATE_AUTH_REQUIRED",
29
+ "TASK_STATE_COMPLETED",
30
+ "TASK_STATE_FAILED",
31
+ "TASK_STATE_CANCELED",
32
+ "TASK_STATE_REJECTED",
33
+ ];
34
+ const VISIBLE = /^[\x21-\x7e]{1,200}$/;
35
+ const present = (value) => value !== undefined && value !== null && value !== "";
36
+ /** `ListTasks` params (A2A 1.0 §3.1.4), checked in the order a client can fix them. */
37
+ export function parseListParams(params) {
38
+ const { contextId, status, statusTimestampAfter, pageSize, pageToken, includeArtifacts } = params;
39
+ if (present(contextId) && (typeof contextId !== "string" || !VISIBLE.test(contextId)))
40
+ a2aFail("invalidParams", "contextId must be 1 to 200 visible ASCII characters", {}, "contextId");
41
+ if (present(status) &&
42
+ status !== "TASK_STATE_UNSPECIFIED" &&
43
+ !TASK_STATES.includes(status))
44
+ a2aFail("invalidParams", "status must be a TaskState", {}, "status");
45
+ let since;
46
+ if (present(statusTimestampAfter)) {
47
+ const time = typeof statusTimestampAfter === "string" &&
48
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.test(statusTimestampAfter)
49
+ ? new Date(statusTimestampAfter)
50
+ : undefined;
51
+ if (!time || Number.isNaN(time.getTime()))
52
+ a2aFail("invalidParams", "statusTimestampAfter must be an ISO 8601 timestamp", {}, "statusTimestampAfter");
53
+ since = time.toISOString();
54
+ }
55
+ if (present(pageSize) &&
56
+ (typeof pageSize !== "number" ||
57
+ !Number.isInteger(pageSize) ||
58
+ pageSize < 1 ||
59
+ pageSize > LIST_PAGE_MAX))
60
+ a2aFail("invalidParams", `pageSize must be an integer from 1 to ${LIST_PAGE_MAX}`, {}, "pageSize");
61
+ if (present(pageToken) && typeof pageToken !== "string")
62
+ a2aFail("invalidParams", "pageToken must be a string", {}, "pageToken");
63
+ if (includeArtifacts !== undefined && includeArtifacts !== null && typeof includeArtifacts !== "boolean")
64
+ a2aFail("invalidParams", "includeArtifacts must be a boolean", {}, "includeArtifacts");
65
+ const historyLength = parseHistoryLength(params.historyLength, "historyLength");
66
+ return {
67
+ ...(present(contextId) ? { contextId: contextId } : {}),
68
+ ...(present(status) && status !== "TASK_STATE_UNSPECIFIED" ? { status: status } : {}),
69
+ ...(since !== undefined ? { statusTimestampAfter: since } : {}),
70
+ pageSize: present(pageSize) ? pageSize : LIST_PAGE_DEFAULT,
71
+ ...(present(pageToken) ? { pageToken: pageToken } : {}),
72
+ ...(historyLength !== undefined ? { historyLength } : {}),
73
+ includeArtifacts: includeArtifacts === true,
74
+ };
75
+ }
76
+ /**
77
+ * One turn's events, in stream order, read from its first lifecycle event: up to its last one
78
+ * when it has ended, else to the stream's tail. Never the session's whole history.
79
+ */
80
+ export async function indexedTurnEvents(ctx, row) {
81
+ const after = row.firstSeq > 0 ? encodeCursor(row.sessionId, row.firstSeq - 1) : undefined;
82
+ const { items } = ENDED_TURN_STATUSES.has(row.status)
83
+ ? await readHistory(ctx, row.sessionId, after === undefined
84
+ ? undefined
85
+ : historyCursor(ctx.config.tenantId, row.sessionId).encode(after), undefined, row.lastSeq - row.firstSeq + 1)
86
+ : await readHistory(ctx, row.sessionId, after, undefined);
87
+ return items.filter((event) => event.turnId === row.turnId);
88
+ }
89
+ /** The events of a session's turn, from the turn index; none when the session never had it. */
90
+ export async function turnEventsOf(ctx, sessionId, turnId) {
91
+ const row = await ctx.store.tx((t) => t.turn(sessionId, turnId));
92
+ return row ? indexedTurnEvents(ctx, row) : [];
93
+ }
94
+ /** `ListTasks`: the caller's tasks with this Agent, from the turn index. */
95
+ export async function listTasks(caller, params, build) {
96
+ const { ctx, access, agentId } = caller;
97
+ const owner = access.owner;
98
+ const token = readCursor(ctx.config.tenantId, "a2a-tasks", {
99
+ owner,
100
+ agentId,
101
+ contextId: params.contextId ?? null,
102
+ status: params.status ?? null,
103
+ statusTimestampAfter: params.statusTimestampAfter ?? null,
104
+ });
105
+ let key;
106
+ try {
107
+ key = token.decode(params.pageToken);
108
+ }
109
+ catch (error) {
110
+ if (!(error instanceof HttpError))
111
+ throw error;
112
+ return a2aFail("invalidParams", "pageToken is not one this request returned", {}, "pageToken");
113
+ }
114
+ if (key && (key.length !== 3 || key.some((part) => typeof part !== "string")))
115
+ a2aFail("invalidParams", "pageToken is not one this request returned", {}, "pageToken");
116
+ const states = params.status === undefined ? undefined : (TURN_STATES[params.status] ?? []);
117
+ const { turns, total } = await ctx.store.tx((t) => t.listTurns({
118
+ ownerUserId: owner,
119
+ agentId,
120
+ a2a: true,
121
+ ...(params.contextId === undefined
122
+ ? {}
123
+ : { sessionId: contextSessionId(owner, agentId, params.contextId) }),
124
+ ...(states === undefined ? {} : { states }),
125
+ ...(params.statusTimestampAfter === undefined ? {} : { changedSince: params.statusTimestampAfter }),
126
+ ...(key ? { after: { changedAt: key[0], sessionId: key[1], turnId: key[2] } } : {}),
127
+ limit: params.pageSize + 1,
128
+ }));
129
+ const page = turns.slice(0, params.pageSize);
130
+ const last = page.at(-1);
131
+ const tasks = [];
132
+ const sessions = new Map();
133
+ for (const row of page) {
134
+ // Only a context the A2A server opened for this caller: its session id is the context's.
135
+ if (contextSessionId(owner, agentId, row.a2aContextId) !== row.sessionId)
136
+ continue;
137
+ if (!sessions.has(row.sessionId))
138
+ sessions.set(row.sessionId, await sessionOf(caller, row.sessionId));
139
+ const session = sessions.get(row.sessionId);
140
+ const built = session ? await build(row, session, params.historyLength ?? 0) : undefined;
141
+ if (!built)
142
+ continue;
143
+ const { artifacts, ...task } = built;
144
+ tasks.push({
145
+ ...task,
146
+ status: { ...task.status, timestamp: row.changedAt },
147
+ ...(params.includeArtifacts && artifacts ? { artifacts } : {}),
148
+ });
149
+ }
150
+ return {
151
+ tasks,
152
+ nextPageToken: turns.length > params.pageSize && last
153
+ ? token.encode([last.changedAt, last.sessionId, last.turnId])
154
+ : "",
155
+ pageSize: params.pageSize,
156
+ totalSize: total,
157
+ };
158
+ }
159
+ /** The caller's session for a listed task, or undefined when it is gone. */
160
+ async function sessionOf(caller, sessionId) {
161
+ try {
162
+ return await loadSession(caller.ctx, sessionId, caller.access);
163
+ }
164
+ catch (error) {
165
+ if (error instanceof HttpError && error.status === 404)
166
+ return undefined;
167
+ throw error;
168
+ }
169
+ }
@@ -1,9 +1,3 @@
1
- /**
2
- * A2A 1.0 over JSON-RPC: the wire types the Runtime answers with, the envelope, the error
3
- * codes and the parameter checks (Host feature `a2a-endpoint`). Only what v1 serves is typed:
4
- * text, data and file parts, tasks, the card. Field names are the spec's ProtoJSON (camelCase,
5
- * enum values such as `TASK_STATE_WORKING`). No Tenant state is read here.
6
- */
7
1
  /** The A2A version this endpoint speaks (`A2A-Version`, Major.Minor). */
8
2
  export declare const A2A_VERSION = "1.0";
9
3
  export type TaskState = "TASK_STATE_SUBMITTED" | "TASK_STATE_WORKING" | "TASK_STATE_INPUT_REQUIRED" | "TASK_STATE_AUTH_REQUIRED" | "TASK_STATE_COMPLETED" | "TASK_STATE_FAILED" | "TASK_STATE_CANCELED" | "TASK_STATE_REJECTED";
@@ -34,11 +28,15 @@ export interface Message {
34
28
  contextId?: string;
35
29
  taskId?: string;
36
30
  metadata?: Record<string, unknown>;
31
+ /** The extensions whose data the message carries (A2A §4.6). */
32
+ extensions?: string[];
37
33
  }
38
34
  export interface Artifact {
39
35
  artifactId: string;
40
36
  name?: string;
41
37
  parts: Part[];
38
+ metadata?: Record<string, unknown>;
39
+ extensions?: string[];
42
40
  }
43
41
  export interface TaskStatus {
44
42
  state: TaskState;
@@ -51,6 +49,54 @@ export interface Task {
51
49
  status: TaskStatus;
52
50
  artifacts?: Artifact[];
53
51
  history?: Message[];
52
+ /** Profile v2 data about the whole task: `progress/v1`, `telemetry/v1`, `effects/v1`. */
53
+ metadata?: Record<string, unknown>;
54
+ }
55
+ /** A change of a task's status, as a push delivers it (`StreamResponse.statusUpdate`). */
56
+ export interface TaskStatusUpdateEvent {
57
+ taskId: string;
58
+ contextId: string;
59
+ status: TaskStatus;
60
+ metadata?: Record<string, unknown>;
61
+ }
62
+ /** An artifact of a task, as a push delivers it (`StreamResponse.artifactUpdate`). */
63
+ export interface TaskArtifactUpdateEvent {
64
+ taskId: string;
65
+ contextId: string;
66
+ artifact: Artifact;
67
+ append?: boolean;
68
+ lastChunk?: boolean;
69
+ metadata?: Record<string, unknown>;
70
+ }
71
+ /** One push's body: A2A's `StreamResponse`, one of its cases. */
72
+ export type StreamResponse = {
73
+ statusUpdate: TaskStatusUpdateEvent;
74
+ } | {
75
+ artifactUpdate: TaskArtifactUpdateEvent;
76
+ } | {
77
+ task: Task;
78
+ };
79
+ /** How a push authenticates to its receiver (A2A `AuthenticationInfo`): `Authorization: <scheme> <credentials>`. */
80
+ export interface PushAuthentication {
81
+ scheme: string;
82
+ credentials: string;
83
+ }
84
+ /** A push notification config as a client gives it (A2A `TaskPushNotificationConfig`). */
85
+ export interface PushConfigInput {
86
+ /** The config's id within its task; the Runtime makes one when absent. */
87
+ id?: string;
88
+ url: string;
89
+ /** Sent back as `X-A2A-Notification-Token` with every push. */
90
+ token?: string;
91
+ authentication?: PushAuthentication;
92
+ }
93
+ /** A push notification config as the Runtime answers it. */
94
+ export interface TaskPushNotificationConfig {
95
+ id: string;
96
+ taskId: string;
97
+ url: string;
98
+ token?: string;
99
+ authentication?: PushAuthentication;
54
100
  }
55
101
  /** The standard JSON-RPC codes and the A2A codes (spec §5.4, §9.5). */
56
102
  export declare const A2A_ERRORS: {
@@ -106,9 +152,13 @@ export declare class A2aError extends Error {
106
152
  readonly metadata: Readonly<Record<string, string>>;
107
153
  /** `google.rpc.BadRequest` field violations, for invalid parameters. */
108
154
  readonly field?: string | undefined;
155
+ /** `ErrorInfo.reason` when it is more precise than the kind's, as `CONTEXT_BUSY`. */
156
+ readonly reason?: string | undefined;
109
157
  constructor(kind: A2aErrorKind, message: string, metadata?: Readonly<Record<string, string>>,
110
158
  /** `google.rpc.BadRequest` field violations, for invalid parameters. */
111
- field?: string | undefined);
159
+ field?: string | undefined,
160
+ /** `ErrorInfo.reason` when it is more precise than the kind's, as `CONTEXT_BUSY`. */
161
+ reason?: string | undefined);
112
162
  }
113
163
  export declare const a2aFail: (kind: A2aErrorKind, message: string, metadata?: Record<string, string>, field?: string) => never;
114
164
  export type JsonRpcId = string | number | null;
@@ -136,9 +186,15 @@ export interface RawFile {
136
186
  readonly mediaType?: string;
137
187
  readonly filename?: string;
138
188
  }
189
+ /** A file sent by URL: fetched by the Runtime (`guardedFetch`), then stored as a `raw` one is. */
190
+ export interface UrlFile {
191
+ readonly url: string;
192
+ readonly mediaType?: string;
193
+ readonly filename?: string;
194
+ }
139
195
  /**
140
196
  * A client's message reduced to what the Runtime runs: text, one JSON value, or text and files
141
- * sent inline.
197
+ * sent inline or by URL.
142
198
  */
143
199
  export type MessageInput = {
144
200
  content: string;
@@ -147,7 +203,17 @@ export type MessageInput = {
147
203
  } | {
148
204
  files: readonly ({
149
205
  text: string;
150
- } | RawFile)[];
206
+ } | RawFile | UrlFile)[];
207
+ };
208
+ /** What a message to a busy context does (profile v2 §1): queued, or refused with `CONTEXT_BUSY`. */
209
+ export type Delivery = "queue" | "reject";
210
+ /** A caller's answer to an open interaction (`interactions/v1` on the reply message). */
211
+ export type InteractionAnswer = {
212
+ interactionId: string;
213
+ approved: boolean;
214
+ } | {
215
+ interactionId: string;
216
+ value: unknown;
151
217
  };
152
218
  export interface SendParams {
153
219
  messageId: string;
@@ -156,8 +222,20 @@ export interface SendParams {
156
222
  input: MessageInput;
157
223
  returnImmediately: boolean;
158
224
  historyLength?: number;
225
+ /** `configuration.taskPushNotificationConfig`: where this task's updates go. */
226
+ push?: PushConfigInput;
227
+ /** `metadata.delivery`; default `queue`. */
228
+ delivery: Delivery;
229
+ /** The answer the message carries under `interactions/v1`. */
230
+ answer?: InteractionAnswer;
159
231
  }
160
232
  export declare function parseHistoryLength(value: unknown, field: string): number | undefined;
233
+ /**
234
+ * A push notification config (A2A `TaskPushNotificationConfig`) at `field`: an http(s) `url`, an
235
+ * optional `id`, `token` and `authentication` (`scheme`, `credentials`). Its `taskId`, when
236
+ * present, is the caller's to check.
237
+ */
238
+ export declare function parsePushConfig(value: unknown, field: string): PushConfigInput;
161
239
  /** `SendMessage` params (spec §3.2.1), checked in the order a client can fix them. */
162
240
  export declare function parseSendParams(params: Record<string, unknown>): SendParams;
163
241
  /** `GetTask` and `CancelTask` params: the task id (and `historyLength` for `GetTask`). */
@@ -165,3 +243,21 @@ export declare function parseTaskParams(params: Record<string, unknown>): {
165
243
  id: string;
166
244
  historyLength?: number;
167
245
  };
246
+ /** `CreateTaskPushNotificationConfig` params: a `TaskPushNotificationConfig` naming its task. */
247
+ export declare function parseCreatePushParams(params: Record<string, unknown>): {
248
+ taskId: string;
249
+ config: PushConfigInput;
250
+ };
251
+ /** `GetTaskPushNotificationConfig` and `DeleteTaskPushNotificationConfig` params. */
252
+ export declare function parsePushConfigRef(params: Record<string, unknown>): {
253
+ taskId: string;
254
+ id: string;
255
+ };
256
+ /** The most configs one page of `ListTaskPushNotificationConfigs` holds. */
257
+ export declare const PUSH_CONFIG_PAGE = 100;
258
+ /** `ListTaskPushNotificationConfigs` params: the task, and paging. */
259
+ export declare function parseListPushParams(params: Record<string, unknown>): {
260
+ taskId: string;
261
+ pageSize: number;
262
+ pageToken?: string;
263
+ };
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * A2A 1.0 over JSON-RPC: the wire types the Runtime answers with, the envelope, the error
3
- * codes and the parameter checks (Host feature `a2a-endpoint`). Only what v1 serves is typed:
4
- * text, data and file parts, tasks, the card. Field names are the spec's ProtoJSON (camelCase,
5
- * enum values such as `TASK_STATE_WORKING`). No Tenant state is read here.
3
+ * codes and the parameter checks (Host features `a2a-endpoint` and `a2a-profile-v2`). Only what
4
+ * the Runtime serves is typed: text, data, `raw` and `url` parts, tasks, their status and
5
+ * artifact updates (push), push configs, the card. Field names are the spec's ProtoJSON
6
+ * (camelCase, enum values such as `TASK_STATE_WORKING`). No Tenant state is read here.
6
7
  */
8
+ import { A2A_EXTENSIONS } from "./extensions.js";
7
9
  /** The A2A version this endpoint speaks (`A2A-Version`, Major.Minor). */
8
10
  export const A2A_VERSION = "1.0";
9
11
  export const TERMINAL_STATES = new Set([
@@ -31,13 +33,17 @@ export class A2aError extends Error {
31
33
  kind;
32
34
  metadata;
33
35
  field;
36
+ reason;
34
37
  constructor(kind, message, metadata = {},
35
38
  /** `google.rpc.BadRequest` field violations, for invalid parameters. */
36
- field) {
39
+ field,
40
+ /** `ErrorInfo.reason` when it is more precise than the kind's, as `CONTEXT_BUSY`. */
41
+ reason) {
37
42
  super(message);
38
43
  this.kind = kind;
39
44
  this.metadata = metadata;
40
45
  this.field = field;
46
+ this.reason = reason;
41
47
  this.name = "A2aError";
42
48
  }
43
49
  }
@@ -52,7 +58,7 @@ export function jsonRpcError(id, error) {
52
58
  const data = [
53
59
  {
54
60
  "@type": "type.googleapis.com/google.rpc.ErrorInfo",
55
- reason,
61
+ reason: error.reason ?? reason,
56
62
  domain: "a2a-protocol.org",
57
63
  metadata: { ...error.metadata },
58
64
  },
@@ -98,10 +104,30 @@ export function parseEnvelope(text) {
98
104
  request: {
99
105
  id: safeId,
100
106
  method: body.method,
101
- params: body.params ?? {},
107
+ params: protoJsonKeys(body.params ?? {}),
102
108
  },
103
109
  };
104
110
  }
111
+ /**
112
+ * Params with ProtoJSON's original field names (`task_id`, `return_immediately`) read as their
113
+ * lowerCamel JSON names, as ProtoJSON parsers must accept both. A part's `data` and any
114
+ * `metadata` are the caller's own values and keep their keys.
115
+ */
116
+ function protoJsonKeys(value) {
117
+ if (Array.isArray(value))
118
+ return value.map(protoJsonKeys);
119
+ if (!isObject(value))
120
+ return value;
121
+ const out = {};
122
+ for (const [key, child] of Object.entries(value)) {
123
+ const name = key.includes("_") ? key.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase()) : key;
124
+ // The lowerCamel name wins when a client sends both.
125
+ if (name !== key && Object.hasOwn(value, name))
126
+ continue;
127
+ out[name] = name === "data" || name === "metadata" ? child : protoJsonKeys(child);
128
+ }
129
+ return out;
130
+ }
105
131
  /** `A2A-Version` as Major.Minor; empty means 0.3 (spec §3.6.2). */
106
132
  export function checkVersion(value) {
107
133
  const trimmed = (value ?? "").trim();
@@ -142,10 +168,89 @@ function partKind(part, field) {
142
168
  return a2aFail("invalidParams", `${field}.text must be a string`, {}, `${field}.text`);
143
169
  if (kinds[0] === "raw" && typeof part.raw !== "string")
144
170
  return a2aFail("invalidParams", `${field}.raw must be base64 bytes`, {}, `${field}.raw`);
171
+ if (kinds[0] === "url" && !isHttpUrl(part.url))
172
+ return a2aFail("invalidParams", `${field}.url must be an http or https URL`, {}, `${field}.url`);
145
173
  for (const key of ["mediaType", "filename"])
146
174
  if (part[key] !== undefined && typeof part[key] !== "string")
147
175
  return a2aFail("invalidParams", `${field}.${key} must be a string`, {}, `${field}.${key}`);
148
- return kinds[0] === "raw" ? "raw" : kinds[0] === "url" ? "url" : kinds[0];
176
+ return kinds[0];
177
+ }
178
+ function isHttpUrl(value) {
179
+ if (typeof value !== "string" || value.length > 2048)
180
+ return false;
181
+ try {
182
+ const url = new URL(value);
183
+ return url.protocol === "http:" || url.protocol === "https:";
184
+ }
185
+ catch {
186
+ return false;
187
+ }
188
+ }
189
+ /** The longest push token and credentials kept: a header value, not a document. */
190
+ const MAX_SECRET = 1024;
191
+ /**
192
+ * A push notification config (A2A `TaskPushNotificationConfig`) at `field`: an http(s) `url`, an
193
+ * optional `id`, `token` and `authentication` (`scheme`, `credentials`). Its `taskId`, when
194
+ * present, is the caller's to check.
195
+ */
196
+ export function parsePushConfig(value, field) {
197
+ if (!isObject(value))
198
+ return a2aFail("invalidParams", `${field} must be an object`, {}, field);
199
+ if (!isHttpUrl(value.url))
200
+ a2aFail("invalidParams", `${field}.url must be an http or https URL`, {}, `${field}.url`);
201
+ const id = value.id === undefined || value.id === "" ? undefined : checkId(value.id, `${field}.id`, true);
202
+ if (value.token !== undefined && (typeof value.token !== "string" || value.token.length > MAX_SECRET))
203
+ a2aFail("invalidParams", `${field}.token must be a string of at most ${MAX_SECRET} characters`, {}, `${field}.token`);
204
+ let authentication;
205
+ if (value.authentication !== undefined && value.authentication !== null) {
206
+ const auth = value.authentication;
207
+ if (!isObject(auth) ||
208
+ typeof auth.scheme !== "string" ||
209
+ !/^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/.test(auth.scheme) ||
210
+ typeof auth.credentials !== "string" ||
211
+ auth.credentials.length > MAX_SECRET ||
212
+ /[\r\n]/.test(auth.credentials))
213
+ a2aFail("invalidParams", `${field}.authentication needs an HTTP scheme and credentials`, {}, `${field}.authentication`);
214
+ authentication = {
215
+ scheme: auth.scheme,
216
+ credentials: auth.credentials,
217
+ };
218
+ }
219
+ return {
220
+ url: value.url,
221
+ ...(id !== undefined ? { id } : {}),
222
+ ...(typeof value.token === "string" && value.token !== "" ? { token: value.token } : {}),
223
+ ...(authentication ? { authentication } : {}),
224
+ };
225
+ }
226
+ /** `metadata.delivery`: `queue` (the default) or `reject`. */
227
+ function parseDelivery(metadata) {
228
+ const delivery = metadata.delivery;
229
+ if (delivery === undefined || delivery === null)
230
+ return "queue";
231
+ if (delivery !== "queue" && delivery !== "reject")
232
+ return a2aFail("invalidParams", 'message.metadata.delivery must be "queue" or "reject"', {}, "message.metadata.delivery");
233
+ return delivery;
234
+ }
235
+ /** The answer under `interactions/v1`, when the message carries one. */
236
+ function parseAnswer(metadata) {
237
+ const field = `message.metadata["${A2A_EXTENSIONS.interactions}"]`;
238
+ const raw = metadata[A2A_EXTENSIONS.interactions];
239
+ if (raw === undefined || raw === null)
240
+ return undefined;
241
+ if (!isObject(raw))
242
+ return a2aFail("invalidParams", `${field} must be an object`, {}, field);
243
+ const interactionId = checkId(raw.interactionId, `${field}.interactionId`, true);
244
+ if (raw.approved !== undefined) {
245
+ if (typeof raw.approved !== "boolean")
246
+ a2aFail("invalidParams", `${field}.approved must be a boolean`, {}, `${field}.approved`);
247
+ if (raw.value !== undefined)
248
+ a2aFail("invalidParams", `${field} takes approved or value, not both`, {}, field);
249
+ return { interactionId, approved: raw.approved };
250
+ }
251
+ if (!("value" in raw))
252
+ return a2aFail("invalidParams", `${field} needs approved or value`, {}, field);
253
+ return { interactionId, value: raw.value };
149
254
  }
150
255
  /** `SendMessage` params (spec §3.2.1), checked in the order a client can fix them. */
151
256
  export function parseSendParams(params) {
@@ -161,12 +266,19 @@ export function parseSendParams(params) {
161
266
  a2aFail("invalidParams", "At least one part is required", {}, "message.parts");
162
267
  const parts = message.parts;
163
268
  const kinds = parts.map((part, index) => partKind(part, `message.parts[${index}]`));
269
+ if (message.metadata !== undefined && message.metadata !== null && !isObject(message.metadata))
270
+ a2aFail("invalidParams", "message.metadata must be an object", {}, "message.metadata");
271
+ const metadata = (message.metadata ?? {});
272
+ // On the message (the profile's place), or on the request's own metadata.
273
+ const delivery = parseDelivery(metadata.delivery === undefined && isObject(params.metadata) ? params.metadata : metadata);
274
+ const answer = parseAnswer(metadata);
164
275
  const configuration = params.configuration;
165
- if (configuration !== undefined && !isObject(configuration))
276
+ if (configuration !== undefined && configuration !== null && !isObject(configuration))
166
277
  a2aFail("invalidParams", "configuration must be an object", {}, "configuration");
167
278
  const config = (configuration ?? {});
168
- if (config.taskPushNotificationConfig !== undefined && config.taskPushNotificationConfig !== null)
169
- a2aFail("pushNotSupported", "Push notifications are not supported");
279
+ const push = config.taskPushNotificationConfig === undefined || config.taskPushNotificationConfig === null
280
+ ? undefined
281
+ : parsePushConfig(config.taskPushNotificationConfig, "configuration.taskPushNotificationConfig");
170
282
  if (config.returnImmediately !== undefined && typeof config.returnImmediately !== "boolean")
171
283
  a2aFail("invalidParams", "configuration.returnImmediately must be a boolean", {}, "configuration.returnImmediately");
172
284
  const historyLength = parseHistoryLength(config.historyLength, "configuration.historyLength");
@@ -175,21 +287,24 @@ export function parseSendParams(params) {
175
287
  input = { content: parts.map((part) => part.text).join("\n") };
176
288
  else if (kinds.length === 1 && kinds[0] === "data")
177
289
  input = { data: parts[0].data };
178
- else if (kinds.includes("url"))
179
- return a2aFail("contentTypeNotSupported", "File URLs are not fetched yet; send the bytes inline as raw", { accepted: "raw" });
180
290
  else if (kinds.includes("data"))
181
291
  return a2aFail("contentTypeNotSupported", "This agent accepts text and file parts, or a single data part", { accepted: "text/plain,application/json" });
182
292
  else
183
293
  input = {
184
- files: parts.map((part, index) => kinds[index] === "text"
185
- ? { text: part.text }
186
- : {
187
- raw: part.raw,
294
+ files: parts.map((part, index) => {
295
+ const named = {
188
296
  ...(part.mediaType !== undefined ? { mediaType: part.mediaType } : {}),
189
297
  ...(part.filename !== undefined ? { filename: part.filename } : {}),
190
- }),
298
+ };
299
+ return kinds[index] === "text"
300
+ ? { text: part.text }
301
+ : kinds[index] === "url"
302
+ ? { url: part.url, ...named }
303
+ : { raw: part.raw, ...named };
304
+ }),
191
305
  };
192
- if ("content" in input && input.content.trim() === "")
306
+ // An answer may carry its value only in metadata: its parts are then what a person reads.
307
+ if ("content" in input && input.content.trim() === "" && answer === undefined)
193
308
  a2aFail("invalidParams", "The message has no text", {}, "message.parts");
194
309
  return {
195
310
  messageId,
@@ -198,6 +313,9 @@ export function parseSendParams(params) {
198
313
  input,
199
314
  returnImmediately: config.returnImmediately === true,
200
315
  ...(historyLength !== undefined ? { historyLength } : {}),
316
+ ...(push ? { push } : {}),
317
+ delivery,
318
+ ...(answer ? { answer } : {}),
201
319
  };
202
320
  }
203
321
  /** `GetTask` and `CancelTask` params: the task id (and `historyLength` for `GetTask`). */
@@ -207,3 +325,36 @@ export function parseTaskParams(params) {
207
325
  const historyLength = parseHistoryLength(params.historyLength, "historyLength");
208
326
  return { id: params.id, ...(historyLength !== undefined ? { historyLength } : {}) };
209
327
  }
328
+ /** `taskId` of a push config method's params. */
329
+ function pushTaskId(params) {
330
+ if (typeof params.taskId !== "string" || params.taskId === "")
331
+ return a2aFail("invalidParams", "taskId is required", {}, "taskId");
332
+ return params.taskId;
333
+ }
334
+ /** `CreateTaskPushNotificationConfig` params: a `TaskPushNotificationConfig` naming its task. */
335
+ export function parseCreatePushParams(params) {
336
+ const taskId = pushTaskId(params);
337
+ // A2A 1.0 sends the config itself; an earlier draft nested it under `config`.
338
+ const config = isObject(params.config) ? { ...params.config, ...(params.id ? { id: params.id } : {}) } : params;
339
+ return { taskId, config: parsePushConfig(config, isObject(params.config) ? "config" : "params") };
340
+ }
341
+ /** `GetTaskPushNotificationConfig` and `DeleteTaskPushNotificationConfig` params. */
342
+ export function parsePushConfigRef(params) {
343
+ const taskId = pushTaskId(params);
344
+ const id = checkId(params.id, "id", true);
345
+ return { taskId, id };
346
+ }
347
+ /** The most configs one page of `ListTaskPushNotificationConfigs` holds. */
348
+ export const PUSH_CONFIG_PAGE = 100;
349
+ /** `ListTaskPushNotificationConfigs` params: the task, and paging. */
350
+ export function parseListPushParams(params) {
351
+ const taskId = pushTaskId(params);
352
+ const size = params.pageSize;
353
+ if (size !== undefined && size !== null && size !== 0 && (typeof size !== "number" || !Number.isInteger(size) || size < 1))
354
+ a2aFail("invalidParams", "pageSize must be a positive integer", {}, "pageSize");
355
+ const pageSize = Math.min(typeof size === "number" && size > 0 ? size : PUSH_CONFIG_PAGE, PUSH_CONFIG_PAGE);
356
+ const token = params.pageToken;
357
+ if (token !== undefined && token !== null && token !== "" && typeof token !== "string")
358
+ a2aFail("invalidParams", "pageToken must be a string", {}, "pageToken");
359
+ return { taskId, pageSize, ...(typeof token === "string" && token !== "" ? { pageToken: token } : {}) };
360
+ }