@nylorun/runtime 0.36.0-beta → 0.37.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 (59) hide show
  1. package/CHANGELOG.md +60 -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 +532 -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/host/config-for.d.ts +2 -0
  29. package/dist/host/config-for.js +1 -0
  30. package/dist/host/main.js +1 -0
  31. package/dist/host/stack-config.d.ts +16 -0
  32. package/dist/host/stack-config.js +30 -0
  33. package/dist/management-openapi.json +5 -2
  34. package/dist/openapi.json +1485 -738
  35. package/dist/reads/types.d.ts +19 -0
  36. package/dist/store/postgres/drizzle/0039_a2a_push.sql +32 -0
  37. package/dist/store/postgres/drizzle/0040_turn_index.sql +23 -0
  38. package/dist/store/postgres/drizzle/0041_turn_index_backfill.sql +88 -0
  39. package/dist/store/postgres/drizzle/meta/_journal.json +21 -0
  40. package/dist/store/postgres/reads.js +41 -1
  41. package/dist/store/postgres/schema.d.ts +1296 -534
  42. package/dist/store/postgres/schema.js +92 -0
  43. package/dist/store/postgres/store.js +133 -3
  44. package/dist/store/postgres/turns.d.ts +62 -0
  45. package/dist/store/postgres/turns.js +153 -0
  46. package/dist/store/types.d.ts +73 -3
  47. package/dist/tenant/runtime.js +7 -5
  48. package/dist/tenant/session-model.d.ts +11 -1
  49. package/dist/tenant/session-model.js +34 -5
  50. package/dist/tenant/sessions.d.ts +2 -1
  51. package/dist/tenant/sessions.js +8 -2
  52. package/dist/tenant/sweep.d.ts +3 -0
  53. package/dist/tenant/sweep.js +39 -11
  54. package/dist/tenant/types.d.ts +23 -0
  55. package/dist/tuning.d.ts +9 -0
  56. package/dist/tuning.js +11 -0
  57. package/dist/version.d.ts +1 -1
  58. package/dist/version.js +1 -1
  59. package/package.json +3 -3
@@ -1,28 +1,47 @@
1
1
  /**
2
- * The A2A endpoint (Host feature `a2a-endpoint`): `POST /v1/a2a/agents/:agent` answers A2A 1.0
3
- * JSON-RPC for one subject, and `GET /v1/a2a/agents/:agent/card` returns the agent's card
4
- * without interfaces. v1 serves `SendMessage` (blocking, or `returnImmediately`), `GetTask`
5
- * and `CancelTask`; every other operation answers with the error the specification defines
6
- * for it (docs: `implementation/a2a-implementation.md`).
2
+ * The A2A endpoint (Host features `a2a-endpoint` and `a2a-profile-v2`): `POST
3
+ * /v1/a2a/agents/:agent` answers A2A 1.0 JSON-RPC for one subject, and `GET
4
+ * /v1/a2a/agents/:agent/card` returns the agent's card. It serves `SendMessage` (blocking up to
5
+ * `a2aWaitMs`, or `returnImmediately`), `GetTask`, `ListTasks`, `CancelTask` and the four push
6
+ * notification config methods; every other operation answers with the error the specification
7
+ * defines for it (docs: `implementation/a2a-implementation.md`, profile v2:
8
+ * `design/runtime/a2a-profile.md`). Tasks are found through the turn index (Host feature
9
+ * `turns`, `list.ts`): a context's session carries `info.a2a`, and a task's events are read from
10
+ * its turn's first event, never the whole history.
7
11
  *
8
12
  * Every task operation goes through the session contract as the caller: `command` with the
9
13
  * caller's scope (limits and owner checks apply), sessions derived from the caller's own
10
14
  * subject, history from Durable Streams. A task id from the client only selects a context and
11
15
  * a turn within the caller's own sessions, so another subject's task is `TaskNotFoundError`.
12
16
  *
13
- * A message's `raw` file parts are stored as artifacts of the context's session once it is open,
14
- * and the message goes in as `parts` (`../inbound-files.ts`): a file the model does not read is
15
- * `ContentTypeNotSupportedError`, and the files of a refused message are deleted.
17
+ * Profile v2:
18
+ * - A message to a busy context is **queued** (`delivery: "queue"`, protocol 17) and answered
19
+ * with the task that will take it: `SUBMITTED`, its id the queued message's own until a turn
20
+ * takes it, then that turn's state. `metadata.delivery: "reject"` keeps v1's
21
+ * `UnsupportedOperationError` (reason `CONTEXT_BUSY`).
22
+ * - An open interaction shows on the status message under `interactions/v1`; the caller answers
23
+ * on the same task with `{ interactionId, approved }` (the Runtime's `approve`, recorded with
24
+ * the caller as `approvedBy`) or `{ interactionId, value }` (`respond`). Text never approves.
25
+ * - A push config (in `SendMessage`, or the config methods) sends the task's updates to the
26
+ * caller's URL (`push.ts`).
27
+ * - A message's `raw` and `url` file parts are stored as artifacts of the context's session once
28
+ * it is open (`../inbound-files.ts`; a `url` is fetched under the push address policy), and the
29
+ * message goes in as `parts`. The files the turn writes come back as artifacts whose `url`
30
+ * parts point at `/v1/a2a/files/…`, served to the same subject, with `files/v1`.
16
31
  */
17
- import { randomUUID } from "node:crypto";
32
+ import { createHash, randomUUID } from "node:crypto";
18
33
  import { SessionCommandSchema, } from "@nylorun/core/contracts";
19
34
  import { commandKey } from "../../core/flow-host.js";
20
35
  import { contentTypeFor } from "@nylorun/core/contracts";
21
- import { discardFiles, inlineFileName, messageParts, } from "../inbound-files.js";
36
+ import { discardFiles, fetchUrlFile, inlineFileName, messageParts, } from "../inbound-files.js";
22
37
  import { agentCard, inputModesFor } from "./card.js";
23
- import { A2aError, a2aFail, checkVersion, jsonRpcError, jsonRpcResult, parseEnvelope, parseSendParams, parseTaskParams, } from "./protocol.js";
24
- import { SETTLED_EVENTS, buildTask, contextSessionId, newContextId, parseTaskId, pendingInteraction, taskIdOf, } from "./tasks.js";
38
+ import { keepExtensions } from "./extensions.js";
39
+ import { indexedTurnEvents, listTasks, parseListParams } from "./list.js";
40
+ import { A2aError, a2aFail, checkVersion, jsonRpcError, jsonRpcResult, parseCreatePushParams, parseEnvelope, parseListPushParams, parsePushConfigRef, parseSendParams, parseTaskParams, } from "./protocol.js";
41
+ import { configView, kickPushes, pushEnabled, pushPolicy, savePushConfig, } from "./push.js";
42
+ import { ENDED, SETTLED_EVENTS, buildTask, callerTraceId, contextSessionId, newContextId, parseTaskId, pendingInteraction, pendingInteractions, submittedTask, taskIdOf, } from "./tasks.js";
25
43
  import { currentModelDescriptor } from "../../model/descriptor.js";
44
+ import { decodeCursor } from "../../record/cursor.js";
26
45
  import { accessOf } from "../../tenant/auth.js";
27
46
  import { command } from "../../tenant/commands.js";
28
47
  import { loadSession, reachesAgent, } from "../../tenant/context.js";
@@ -31,21 +50,32 @@ import { HttpError, fail } from "../../tenant/http.js";
31
50
  import { observeSession, readHistory } from "../../tenant/session-streams.js";
32
51
  import { putSession } from "../../tenant/sessions.js";
33
52
  import { readRuntimeLimits } from "../../tenant/runtime-limits.js";
53
+ import { traceKey } from "../../traces/key.js";
54
+ import { parseTraceparent, traceIdOf } from "../../traces/ids.js";
55
+ /** The A2A endpoint's route for a published agent, from the Runtime's base URL. */
56
+ export function agentEndpoint(baseUrl, agentId) {
57
+ return `${baseUrl}/v1/a2a/agents/${encodeURIComponent(agentId)}`;
58
+ }
34
59
  /**
35
60
  * `GET /v1/a2a/agents/:agent/card`: the agent's card, for a caller who may use the agent. Its
36
61
  * input modes name the files the Tenant's model reads natively: a hint, since any file is
37
- * accepted and the agent's harness decides how its model sees it (protocol 15).
62
+ * accepted and the agent's harness decides how its model sees it (protocol 15). Its interface is
63
+ * this Runtime's JSON-RPC endpoint at `baseUrl` (a gateway lists its own instead).
38
64
  */
39
- export async function a2aCard(ctx, scope, agentId) {
65
+ export async function a2aCard(ctx, scope, agentId, baseUrl) {
40
66
  const definition = await definitionFor(ctx, agentId, accessOf(scope));
41
67
  // The files the Tenant's model reads now.
42
- return agentCard(agentId, definition, inputModesFor((await currentModelDescriptor(ctx))?.inputs));
68
+ return agentCard(agentId, definition, inputModesFor((await currentModelDescriptor(ctx))?.inputs), {
69
+ url: agentEndpoint(baseUrl, agentId),
70
+ pushNotifications: pushEnabled(ctx),
71
+ });
43
72
  }
44
73
  /**
45
74
  * `POST /v1/a2a/agents/:agent`: one A2A JSON-RPC call, for a subject. JSON-RPC answers (errors
46
- * included) are the body to send with 200; HTTP-level refusals (unknown agent, no subject,
47
- * limits) are thrown as `HttpError` like any Tenant route. `body` is read once the agent is
48
- * known to be the caller's; closing `response` stops a blocking wait, never the task.
75
+ * included) are the body to send with 200, without the data of extensions the request did not
76
+ * activate; HTTP-level refusals (unknown agent, no subject, limits) are thrown as `HttpError`
77
+ * like any Tenant route. `body` is read once the agent is known to be the caller's; closing
78
+ * `response` stops a blocking wait, never the task.
49
79
  */
50
80
  export async function a2aCall(ctx, scope, agentId, input, response) {
51
81
  const access = accessOf(scope);
@@ -60,11 +90,20 @@ export async function a2aCall(ctx, scope, agentId, input, response) {
60
90
  // A blocking call stops waiting when the caller hangs up; the task keeps running.
61
91
  const hangup = new AbortController();
62
92
  response.on("close", () => hangup.abort());
63
- const caller = { ctx, scope, access, agentId, ...(input.trace ? { trace: input.trace } : {}) };
93
+ const caller = {
94
+ ctx,
95
+ scope,
96
+ access,
97
+ agentId,
98
+ ...(input.trace ? { trace: input.trace } : {}),
99
+ extensions: input.extensions,
100
+ baseUrl: input.baseUrl,
101
+ };
64
102
  const { id } = envelope.request;
65
103
  try {
66
104
  checkVersion(input.version);
67
- return jsonRpcResult(id, await operation(caller, envelope.request, hangup.signal));
105
+ const result = await operation(caller, envelope.request, hangup.signal);
106
+ return jsonRpcResult(id, keepExtensions(result, input.extensions));
68
107
  }
69
108
  catch (error) {
70
109
  if (error instanceof A2aError)
@@ -91,15 +130,18 @@ async function operation(caller, request, signal) {
91
130
  case "CancelTask":
92
131
  return cancelTask(caller, parseTaskParams(request.params).id);
93
132
  case "ListTasks":
94
- return a2aFail("unsupportedOperation", "ListTasks is not supported by this agent yet");
133
+ return listTasks(caller, parseListParams(request.params), (row, session, historyLength) => indexedTask(caller, row, session, { historyLength }).then((built) => built?.task));
95
134
  case "SendStreamingMessage":
96
135
  case "SubscribeToTask":
97
- return a2aFail("unsupportedOperation", "Streaming is not supported: the Agent Card declares capabilities.streaming false");
136
+ return a2aFail("unsupportedOperation", "Streaming is not supported: the Agent Card declares capabilities.streaming false; register a push notification config");
98
137
  case "CreateTaskPushNotificationConfig":
138
+ return createPushConfig(caller, request.params);
99
139
  case "GetTaskPushNotificationConfig":
140
+ return getPushConfig(caller, request.params);
100
141
  case "ListTaskPushNotificationConfigs":
142
+ return listPushConfigs(caller, request.params);
101
143
  case "DeleteTaskPushNotificationConfig":
102
- return a2aFail("pushNotSupported", "Push notifications are not supported");
144
+ return deletePushConfig(caller, request.params);
103
145
  case "GetExtendedAgentCard":
104
146
  return a2aFail("unsupportedOperation", "This agent has no extended Agent Card");
105
147
  default:
@@ -135,30 +177,96 @@ async function contextSession(caller, sessionId) {
135
177
  throw error;
136
178
  }
137
179
  }
138
- /** The events of one turn, in stream order. */
139
- async function turnEvents(ctx, sessionId, turnId) {
140
- const { items } = await readHistory(ctx, sessionId, undefined, undefined);
141
- return items.filter((event) => event.turnId === turnId);
180
+ /** Every event of a session, in stream order: only to find a queued message no turn took yet. */
181
+ async function sessionEvents(ctx, sessionId) {
182
+ return (await readHistory(ctx, sessionId, undefined, undefined)).items;
183
+ }
184
+ /** `telemetry/v1`'s trace id for a turn: the caller's `traceparent`'s, else the Runtime's own. */
185
+ async function turnTraceId(ctx, sessionId, turnId, events) {
186
+ return callerTraceId(events) ?? traceIdOf(await traceKey(ctx.store), ctx.store.tenantId, sessionId, turnId);
142
187
  }
188
+ const payloadOf = (event) => (event.payload ?? {});
189
+ /**
190
+ * The task of turn index row `row` as the caller sees it (`taskId`: the id the caller knows it
191
+ * by), built from that turn's own events (`indexedTurnEvents`): what `GetTask` and `ListTasks`
192
+ * answer.
193
+ */
194
+ async function indexedTask(caller, row, session, options = {}) {
195
+ const contextId = row.a2aContextId ?? parseTaskId(options.taskId ?? "")?.contextId;
196
+ if (contextId === undefined)
197
+ return undefined;
198
+ const events = await indexedTurnEvents(caller.ctx, row);
199
+ const ended = events.some((event) => ENDED.has(event.type));
200
+ const task = buildTask({
201
+ contextId,
202
+ turnId: row.turnId,
203
+ ...(options.taskId !== undefined ? { taskId: options.taskId } : {}),
204
+ session,
205
+ events,
206
+ baseUrl: caller.baseUrl,
207
+ ...(ended ? { traceId: await turnTraceId(caller.ctx, row.sessionId, row.turnId, events) } : {}),
208
+ ...(options.historyLength !== undefined ? { historyLength: options.historyLength } : {}),
209
+ });
210
+ return task ? { task, events } : undefined;
211
+ }
212
+ /**
213
+ * The caller's task `taskId`, found through the turn index. Its last part is a turn, or a queued
214
+ * message's id: while the message waits the task is `SUBMITTED`, and once a turn took it (the
215
+ * index keeps the queued messages each turn took) the task is that turn's, under the id the
216
+ * caller knows. Only a message still in the queue is looked for in the session's history.
217
+ */
143
218
  async function ownedTask(caller, taskId, historyLength) {
144
219
  const ids = parseTaskId(taskId) ?? taskNotFound(taskId);
145
220
  const sessionId = contextSessionId(caller.access.owner, caller.agentId, ids.contextId);
146
221
  const session = (await contextSession(caller, sessionId)) ?? taskNotFound(taskId);
147
- const task = buildTask({
148
- ...ids,
149
- session,
150
- events: await turnEvents(caller.ctx, sessionId, ids.turnId),
222
+ const found = await caller.ctx.store.tx(async (t) => {
223
+ const turn = await t.turn(sessionId, ids.turnId);
224
+ if (turn)
225
+ return { turn };
226
+ // Still queued, or taken: the queue is read first, so a message taken meanwhile is found.
227
+ if ((await t.queuedMessages(sessionId)).some((row) => row.id === ids.turnId))
228
+ return { queued: true };
229
+ const taken = await t.turnOfQueuedMessage(sessionId, ids.turnId);
230
+ return taken ? { turn: taken } : {};
231
+ });
232
+ if (found.queued) {
233
+ const queued = (await sessionEvents(caller.ctx, sessionId)).find((event) => event.type === "message.queued" && payloadOf(event).queuedMessageId === ids.turnId);
234
+ if (queued)
235
+ return {
236
+ task: submittedTask(ids.contextId, taskId, queued),
237
+ session,
238
+ sessionId,
239
+ contextId: ids.contextId,
240
+ turnId: ids.turnId,
241
+ events: [],
242
+ queued,
243
+ };
244
+ }
245
+ const row = found.turn ?? taskNotFound(taskId);
246
+ const built = (await indexedTask(caller, { ...row, a2aContextId: ids.contextId }, session, {
247
+ taskId,
151
248
  ...(historyLength !== undefined ? { historyLength } : {}),
152
- }) ?? taskNotFound(taskId);
153
- return { task, session, sessionId, ...ids };
249
+ })) ?? taskNotFound(taskId);
250
+ return {
251
+ task: built.task,
252
+ session,
253
+ sessionId,
254
+ contextId: ids.contextId,
255
+ turnId: row.turnId,
256
+ events: built.events,
257
+ };
154
258
  }
155
259
  /** Runs a session command as the caller, mapping the Runtime's refusals to A2A errors. */
156
260
  async function run(caller, sessionId, input,
157
261
  /** The error for a `409` conflict: the session is busy or no longer waiting. */
158
- conflict) {
262
+ conflict,
263
+ /** What a message to a busy session does: queued (profile v2), or `409`. */
264
+ delivery = "reject") {
159
265
  try {
160
- const accepted = (await command(caller.ctx, sessionId, SessionCommandSchema.parse(input), caller.scope, caller.trace ? { trace: caller.trace } : {}));
161
- return { turnId: accepted.turnId, cursor: accepted.cursor };
266
+ const accepted = (await command(caller.ctx, sessionId, SessionCommandSchema.parse(input), caller.scope, { ...(caller.trace ? { trace: caller.trace } : {}), delivery }));
267
+ return accepted.status === "queued"
268
+ ? { queuedMessageId: accepted.queuedMessageId, cursor: accepted.cursor }
269
+ : { turnId: accepted.turnId, cursor: accepted.cursor };
162
270
  }
163
271
  catch (error) {
164
272
  if (!(error instanceof HttpError))
@@ -170,59 +278,56 @@ conflict) {
170
278
  throw await conflict();
171
279
  }
172
280
  }
173
- /** Opens the caller's session for a context: created when missing, never changed after. */
174
- async function openContext(caller, sessionId) {
281
+ /**
282
+ * Opens the caller's session for a context: created when missing, with `info.a2a` naming the
283
+ * context and the subject, so the turn index can list its tasks (`ListTasks`); the session id
284
+ * stays the one-way `contextSessionId`. A context opened before `info.a2a` gets it now, for the
285
+ * turns it starts from here on. Nothing else changes after.
286
+ */
287
+ async function openContext(caller, sessionId, contextId) {
288
+ const a2a = { contextId, subject: caller.access.owner };
175
289
  const existing = await contextSession(caller, sessionId);
176
- if (existing)
177
- return existing;
290
+ if (existing) {
291
+ if (existing.info?.a2a?.contextId === contextId)
292
+ return existing;
293
+ return caller.ctx.store.tx(async (t) => {
294
+ const session = (await t.lockSession(sessionId));
295
+ if (session.info?.a2a?.contextId !== contextId) {
296
+ session.info = { ...(session.info ?? {}), a2a };
297
+ await t.put("sessions", sessionId, session);
298
+ }
299
+ return session;
300
+ });
301
+ }
178
302
  return putSession(caller.ctx, sessionId, {
179
303
  requestId: randomUUID(),
180
304
  agent: caller.agentId,
181
305
  ownerUserId: caller.access.owner,
306
+ info: { a2a },
182
307
  }, caller.access, agentGrantsOf(caller.scope) === undefined ? {} : { agentGrants: agentGrantsOf(caller.scope) });
183
308
  }
184
309
  async function sendMessage(caller, params, signal) {
185
310
  const send = parseSendParams(params);
311
+ if (send.push && !pushEnabled(caller.ctx))
312
+ a2aFail("pushNotSupported", "Push notifications are not configured on this Runtime");
186
313
  const idempotencyKey = `a2a:${send.messageId}`;
187
314
  let contextId;
188
315
  let sessionId;
189
316
  let accepted;
190
317
  if (send.taskId !== undefined) {
191
- // A reply on an existing task: only a question it is waiting on can take it.
192
318
  const ids = parseTaskId(send.taskId) ?? taskNotFound(send.taskId);
193
- const value = "content" in send.input
194
- ? send.input.content
195
- : "data" in send.input
196
- ? send.input.data
197
- : a2aFail("contentTypeNotSupported", "A reply takes text or one data part; send files in a new message", { accepted: "text/plain,application/json" });
198
319
  if (send.contextId !== undefined && send.contextId !== ids.contextId)
199
320
  a2aFail("invalidParams", "message.contextId does not match the task's context", {}, "message.contextId");
200
321
  contextId = ids.contextId;
201
- sessionId = contextSessionId(caller.access.owner, caller.agentId, contextId);
202
- const session = (await contextSession(caller, sessionId)) ?? taskNotFound(send.taskId);
203
- const prior = await caller.ctx.store.tx((t) => t.get("commands", commandKey(sessionId, idempotencyKey)));
204
- // A retried reply replays through `command`, which compares it with the first one.
205
- const interactionId = prior?.command.type === "respond"
206
- ? prior.command.interactionId
207
- : prior
208
- ? a2aFail("invalidParams", "message.messageId was already used for a different message", {}, "message.messageId")
209
- : await replyTarget(caller, session, sessionId, ids.turnId, send.taskId);
210
- accepted = await run(caller, sessionId, {
211
- type: "respond",
212
- requestId: randomUUID(),
213
- idempotencyKey,
214
- interactionId,
215
- value,
216
- }, async () => new A2aError("unsupportedOperation", "The task is no longer waiting for input", {
217
- taskId: send.taskId,
218
- }));
219
- accepted = { ...accepted, turnId: ids.turnId };
322
+ const owned = await ownedTask(caller, send.taskId, 0);
323
+ sessionId = owned.sessionId;
324
+ accepted = await reply(caller, send, owned, idempotencyKey);
220
325
  }
221
326
  else {
222
327
  contextId =
223
328
  send.contextId ?? newContextId(caller.access.owner, caller.agentId, send.messageId);
224
329
  sessionId = contextSessionId(caller.access.owner, caller.agentId, contextId);
225
- await openContext(caller, sessionId);
330
+ await openContext(caller, sessionId, contextId);
226
331
  const context = contextId;
227
332
  const { body, stored } = await messageBody(caller, sessionId, idempotencyKey, send.input);
228
333
  accepted = await run(caller, sessionId, {
@@ -234,40 +339,144 @@ async function sendMessage(caller, params, signal) {
234
339
  const active = (await contextSession(caller, sessionId))?.activeTurnId;
235
340
  const taskId = active ? taskIdOf(context, active) : undefined;
236
341
  return taskId
237
- ? new A2aError("unsupportedOperation", `The context has a task in progress (${taskId}); reply to it or cancel it`, { taskId })
238
- : new A2aError("unsupportedOperation", "The context has unresolved work; start a new context");
239
- }).catch(async (error) => {
342
+ ? new A2aError("unsupportedOperation", `The context has a task in progress (${taskId}); reply to it, cancel it, or send without delivery "reject" to queue the message`, { taskId }, undefined, "CONTEXT_BUSY")
343
+ : new A2aError("unsupportedOperation", "The context has unresolved work; start a new context", {}, undefined, "CONTEXT_BUSY");
344
+ }, send.delivery).catch(async (error) => {
240
345
  await discardFiles(caller.ctx, stored, caller.access);
241
346
  throw error;
242
347
  });
243
348
  }
349
+ const turnId = accepted.turnId ?? accepted.queuedMessageId;
350
+ const taskId = send.taskId ?? taskIdOf(contextId, turnId);
351
+ if (send.push)
352
+ await registerPush(caller, send.push, {
353
+ sessionId,
354
+ contextId,
355
+ taskId,
356
+ turnId: accepted.turnId ?? accepted.queuedMessageId,
357
+ nextSeq: decodeCursor(sessionId, accepted.cursor),
358
+ ...(accepted.turnId !== undefined ? { traceId: await sendTraceId(caller, sessionId, accepted.turnId) } : {}),
359
+ });
244
360
  if (!send.returnImmediately)
245
- await settled(caller.ctx, sessionId, accepted.turnId, accepted.cursor, signal);
246
- const { task } = await ownedTask(caller, taskIdOf(contextId, accepted.turnId), send.historyLength);
361
+ await settled(caller.ctx, sessionId, accepted, signal);
362
+ const { task } = await ownedTask(caller, taskId, send.historyLength);
247
363
  return { task };
248
364
  }
365
+ /** The trace id of a turn this request started: its `traceparent`'s, else the Runtime's own. */
366
+ async function sendTraceId(caller, sessionId, turnId) {
367
+ const parsed = caller.trace ? parseTraceparent(caller.trace.traceparent) : undefined;
368
+ return parsed?.traceId ?? traceIdOf(await traceKey(caller.ctx.store), caller.ctx.store.tenantId, sessionId, turnId);
369
+ }
249
370
  /**
250
- * A new message's body: its text, its JSON value, or parts with its `raw` files stored as
251
- * artifacts of the session (`stored`: the ones this call created). A file the Runtime cannot
252
- * store is `InvalidParamsError`; one past a limit stays an HTTP `413`.
371
+ * A message on an existing task: an answer to one of its open interactions (`interactions/v1`),
372
+ * or a text or data reply to the question it waits on. A retried answer replays through
373
+ * `command`, which compares it with the first one.
374
+ */
375
+ async function reply(caller, send, owned, idempotencyKey) {
376
+ const { session, sessionId, turnId } = owned;
377
+ const taskId = owned.task.id;
378
+ const prior = await caller.ctx.store.tx((t) => t.get("commands", commandKey(sessionId, idempotencyKey)));
379
+ const reused = () => a2aFail("invalidParams", "message.messageId was already used for a different message", {}, "message.messageId");
380
+ let answer;
381
+ if (send.answer) {
382
+ const kind = prior?.command.type === "approve" || prior?.command.type === "respond"
383
+ ? prior.command.type
384
+ : prior
385
+ ? reused()
386
+ : answerTarget(owned, send.answer).engineKind === "approval"
387
+ ? "approve"
388
+ : "respond";
389
+ answer = answerCommand(kind, send.answer, idempotencyKey);
390
+ }
391
+ else {
392
+ const value = "content" in send.input
393
+ ? send.input.content
394
+ : "data" in send.input
395
+ ? send.input.data
396
+ : a2aFail("contentTypeNotSupported", "A reply takes text or one data part; send files in a new message", { accepted: "text/plain,application/json" });
397
+ const interactionId = prior?.command.type === "respond"
398
+ ? prior.command.interactionId
399
+ : prior
400
+ ? reused()
401
+ : replyTarget(owned);
402
+ answer = { type: "respond", requestId: randomUUID(), idempotencyKey, interactionId, value };
403
+ }
404
+ const accepted = await run(caller, sessionId, answer, async () => new A2aError("unsupportedOperation", "The task is no longer waiting for input", { taskId }));
405
+ return { turnId, cursor: accepted.cursor };
406
+ }
407
+ /**
408
+ * The command an `interactions/v1` answer is: `approve` with `approved`, or `respond` with
409
+ * `value` (an `approved` answer to a question or connection is its value).
410
+ */
411
+ function answerCommand(kind, answer, idempotencyKey) {
412
+ const base = { requestId: randomUUID(), idempotencyKey, interactionId: answer.interactionId };
413
+ if (kind === "approve") {
414
+ const approved = "approved" in answer
415
+ ? answer.approved
416
+ : typeof answer.value === "boolean"
417
+ ? answer.value
418
+ : a2aFail("invalidParams", "An approval is answered with approved: true or false", {}, "message.metadata");
419
+ return { type: "approve", ...base, approved };
420
+ }
421
+ return {
422
+ type: "respond",
423
+ ...base,
424
+ value: "approved" in answer ? answer.approved : (answer.value ?? null),
425
+ };
426
+ }
427
+ /** A reply to a task that is not active: it is queued, or in a terminal state. */
428
+ function notActive(owned) {
429
+ const taskId = owned.task.id;
430
+ if (owned.queued)
431
+ return a2aFail("unsupportedOperation", "The task is queued and waits for nothing yet", { taskId });
432
+ return a2aFail("unsupportedOperation", "The task is in a terminal state and accepts no more messages; send a new message without taskId", { taskId });
433
+ }
434
+ /** The task must be active and waiting on the interaction the answer names. */
435
+ function answerTarget(owned, answer) {
436
+ const { session } = owned;
437
+ if (owned.queued || session.activeTurnId !== owned.turnId)
438
+ return notActive(owned);
439
+ const open = session.status === "paused" ? pendingInteractions(session) : [];
440
+ return (open.find((interaction) => interaction.id === answer.interactionId) ??
441
+ a2aFail("invalidParams", open.length === 0
442
+ ? "The task is not waiting for an answer"
443
+ : `The task does not wait on interaction ${answer.interactionId}`, { taskId: owned.task.id }, "message.metadata"));
444
+ }
445
+ /**
446
+ * A new message's body: its text, its JSON value, or parts with its `raw` and `url` files stored
447
+ * as artifacts of the session (`stored`: the ones this call created). A file the Runtime cannot
448
+ * fetch or store is `InvalidParamsError`; one past a limit stays an HTTP `413`.
253
449
  */
254
450
  async function messageBody(caller, sessionId, idempotencyKey, input) {
255
451
  if (!("files" in input))
256
452
  return { body: input, stored: [] };
257
- let files = 0;
258
- const inbound = input.files.map((part) => {
259
- if ("text" in part)
260
- return { type: "text", text: part.text };
261
- const index = files++;
262
- const mediaType = part.mediaType ?? (part.filename !== undefined ? contentTypeFor(part.filename) : "application/octet-stream");
263
- return {
264
- type: "inline",
265
- name: part.filename ?? inlineFileName(mediaType, index),
266
- mediaType,
267
- data: part.raw,
268
- };
269
- });
270
453
  try {
454
+ let files = 0;
455
+ const inbound = [];
456
+ for (const part of input.files) {
457
+ if ("text" in part) {
458
+ inbound.push({ type: "text", text: part.text });
459
+ continue;
460
+ }
461
+ const index = files++;
462
+ if ("url" in part) {
463
+ inbound.push({
464
+ type: "inline",
465
+ ...(await fetchUrlFile(caller.ctx, part.url, {
466
+ ...(part.mediaType !== undefined ? { mediaType: part.mediaType } : {}),
467
+ ...(part.filename !== undefined ? { filename: part.filename } : {}),
468
+ }, pushPolicy(caller.ctx, part.url))),
469
+ });
470
+ continue;
471
+ }
472
+ const mediaType = part.mediaType ?? (part.filename !== undefined ? contentTypeFor(part.filename) : "application/octet-stream");
473
+ inbound.push({
474
+ type: "inline",
475
+ name: part.filename ?? inlineFileName(mediaType, index),
476
+ mediaType,
477
+ data: part.raw,
478
+ });
479
+ }
271
480
  const { parts, stored } = await messageParts(caller.ctx, sessionId, idempotencyKey, inbound, caller.access);
272
481
  return { body: { parts }, stored };
273
482
  }
@@ -277,29 +486,29 @@ async function messageBody(caller, sessionId, idempotencyKey, input) {
277
486
  throw error;
278
487
  }
279
488
  }
280
- /** The interaction a reply answers: the task must be active and paused on a question. */
281
- async function replyTarget(caller, session, sessionId, turnId, taskId) {
282
- if (session.activeTurnId !== turnId) {
283
- // Ended, or never this caller's: a terminal task takes no more messages.
284
- const task = buildTask({
285
- contextId: parseTaskId(taskId).contextId,
286
- turnId,
287
- session,
288
- events: await turnEvents(caller.ctx, sessionId, turnId),
289
- });
290
- if (!task)
291
- return taskNotFound(taskId);
292
- return a2aFail("unsupportedOperation", "The task is in a terminal state and accepts no more messages; send a new message without taskId", { taskId });
293
- }
489
+ /**
490
+ * The question a text or data reply answers: the task must be active and paused on one. An
491
+ * approval takes only an `interactions/v1` answer: text never approves (profile v2 §1).
492
+ */
493
+ function replyTarget(owned) {
494
+ const { session } = owned;
495
+ const taskId = owned.task.id;
496
+ if (owned.queued || session.activeTurnId !== owned.turnId)
497
+ return notActive(owned);
294
498
  const pending = session.status === "paused" ? pendingInteraction(session) : undefined;
295
499
  if (!pending)
296
500
  return a2aFail("unsupportedOperation", "The task is working and not waiting for input; wait for it or cancel it", { taskId });
297
- if (pending.kind === "approval")
298
- return a2aFail("unsupportedOperation", "The task waits for an approval, which A2A callers cannot give yet; cancel the task instead", { taskId });
501
+ if (pending.engineKind === "approval")
502
+ return a2aFail("unsupportedOperation", "The task waits for an approval, which text never gives: answer it under the interactions/v1 extension ({ interactionId, approved }), or cancel the task", { taskId, interactionId: pending.id });
299
503
  return pending.id;
300
504
  }
301
505
  async function cancelTask(caller, taskId) {
302
506
  const owned = await ownedTask(caller, taskId);
507
+ if (owned.queued)
508
+ return a2aFail("taskNotCancelable", "The task is queued and starts when the context's running task ends; cancel it then", {
509
+ taskId,
510
+ state: owned.task.status.state,
511
+ });
303
512
  if (owned.session.activeTurnId !== owned.turnId) {
304
513
  if (owned.task.status.state === "TASK_STATE_CANCELED")
305
514
  return owned.task;
@@ -322,14 +531,95 @@ async function cancelTask(caller, taskId) {
322
531
  }
323
532
  return (await ownedTask(caller, taskId)).task;
324
533
  }
534
+ // ---------------------------------------------------------------------------
535
+ // Push notification configs
536
+ /** A config's id when the caller names none: one per task. */
537
+ const DEFAULT_PUSH_CONFIG = "default";
538
+ /** Stores a push config for a task this request started or answered, then pumps the session. */
539
+ async function registerPush(caller, config, start) {
540
+ const configId = config.id ?? DEFAULT_PUSH_CONFIG;
541
+ const saved = await caller.ctx.store.tx(async (t) => {
542
+ // A retried message keeps its config where it is, so updates are not sent again.
543
+ const existing = await t.pushConfig(start.sessionId, start.taskId, configId);
544
+ if (existing && sameConfig(existing, config))
545
+ return configView(existing);
546
+ return savePushConfig(caller.ctx, t, { ...start, baseUrl: caller.baseUrl, extensions: caller.extensions }, config, configId);
547
+ });
548
+ void kickPushes(caller.ctx, start.sessionId);
549
+ return saved;
550
+ }
551
+ function sameConfig(row, config) {
552
+ return (row.url === config.url &&
553
+ (row.token ?? undefined) === config.token &&
554
+ JSON.stringify(row.authentication ?? null) === JSON.stringify(config.authentication ?? null));
555
+ }
556
+ function requirePush(caller) {
557
+ if (!pushEnabled(caller.ctx))
558
+ a2aFail("pushNotSupported", "Push notifications are not configured on this Runtime");
559
+ }
325
560
  /**
326
- * Waits until the turn ends, pauses or has an uncertain effect, the caller hangs up, or the
327
- * Tenant's Runtime limit `a2aWaitMs` passes: after it, the task is returned `WORKING` and keeps
328
- * running, and the caller polls `GetTask`. Follows the session's shared stream from the command's
329
- * cursor, so the events the task is built from are there when it returns. A feed that ends
330
- * (reset, deleted, closing) ends the wait: the task says what the session says then.
561
+ * `CreateTaskPushNotificationConfig`: from the task's next event on. A config on a finished task
562
+ * is stored and sends nothing.
331
563
  */
332
- async function settled(ctx, sessionId, turnId, cursor, signal) {
564
+ async function createPushConfig(caller, params) {
565
+ requirePush(caller);
566
+ const { taskId, config } = parseCreatePushParams(params);
567
+ const owned = await ownedTask(caller, taskId);
568
+ const last = owned.queued ?? owned.events.at(-1);
569
+ const ended = owned.events.some((event) => ENDED.has(event.type));
570
+ const configId = config.id ?? createHash("sha256").update(`${config.url}\u0000${config.token ?? ""}`).digest("hex").slice(0, 16);
571
+ return registerPush(caller, { ...config, id: configId }, {
572
+ sessionId: owned.sessionId,
573
+ contextId: owned.contextId,
574
+ taskId: owned.task.id,
575
+ turnId: owned.turnId,
576
+ nextSeq: last ? last.seq + 1 : 0,
577
+ ...(owned.queued ? {} : { traceId: await turnTraceId(caller.ctx, owned.sessionId, owned.turnId, owned.events) }),
578
+ done: ended,
579
+ });
580
+ }
581
+ async function getPushConfig(caller, params) {
582
+ requirePush(caller);
583
+ const ref = parsePushConfigRef(params);
584
+ const owned = await ownedTask(caller, ref.taskId, 0);
585
+ const row = await caller.ctx.store.tx((t) => t.pushConfig(owned.sessionId, owned.task.id, ref.id));
586
+ return row ? configView(row) : a2aFail("taskNotFound", "Push notification config not found", { taskId: ref.taskId, id: ref.id });
587
+ }
588
+ async function listPushConfigs(caller, params) {
589
+ requirePush(caller);
590
+ const query = parseListPushParams(params);
591
+ const owned = await ownedTask(caller, query.taskId, 0);
592
+ let after;
593
+ if (query.pageToken !== undefined) {
594
+ after = Buffer.from(query.pageToken, "base64url").toString("utf8");
595
+ if (after === "")
596
+ a2aFail("invalidParams", "pageToken is not one this agent issued", {}, "pageToken");
597
+ }
598
+ const rows = await caller.ctx.store.tx((t) => t.pushConfigs(owned.sessionId, owned.task.id, { ...(after !== undefined ? { after } : {}), limit: query.pageSize + 1 }));
599
+ const page = rows.slice(0, query.pageSize);
600
+ return {
601
+ configs: page.map(configView),
602
+ nextPageToken: rows.length > query.pageSize ? Buffer.from(page.at(-1).configId, "utf8").toString("base64url") : "",
603
+ };
604
+ }
605
+ async function deletePushConfig(caller, params) {
606
+ requirePush(caller);
607
+ const ref = parsePushConfigRef(params);
608
+ const owned = await ownedTask(caller, ref.taskId, 0);
609
+ // Idempotent: a config already gone is deleted.
610
+ await caller.ctx.store.tx((t) => t.deletePushConfig(owned.sessionId, owned.task.id, ref.id));
611
+ return {};
612
+ }
613
+ /**
614
+ * Waits until the task's turn ends, pauses or has an uncertain effect, the caller hangs up, or
615
+ * the Tenant's Runtime limit `a2aWaitMs` passes: after it, the task is returned as it is
616
+ * (`WORKING`, or `SUBMITTED` while queued) and keeps running, and the caller polls `GetTask` or
617
+ * waits for pushes. A queued message's task is followed to the turn that takes it. Follows the
618
+ * session's shared stream from the command's cursor, so the events the task is built from are
619
+ * there when it returns. A feed that ends (reset, deleted, closing) ends the wait: the task says
620
+ * what the session says then.
621
+ */
622
+ async function settled(ctx, sessionId, accepted, signal) {
333
623
  const stop = new AbortController();
334
624
  const { a2aWaitMs } = await ctx.store.tx((t) => readRuntimeLimits(t));
335
625
  const timer = setTimeout(() => stop.abort(), a2aWaitMs);
@@ -338,12 +628,19 @@ async function settled(ctx, sessionId, turnId, cursor, signal) {
338
628
  signal.addEventListener("abort", hangup, { once: true });
339
629
  if (signal.aborted)
340
630
  stop.abort();
631
+ let turnId = accepted.turnId;
341
632
  try {
342
- for await (const event of observeSession(ctx, sessionId, cursor, {
633
+ for await (const event of observeSession(ctx, sessionId, accepted.cursor, {
343
634
  signal: stop.signal,
344
- }))
345
- if (event.turnId === turnId && SETTLED_EVENTS.has(event.type))
635
+ })) {
636
+ if (turnId === undefined &&
637
+ event.type === "command.message" &&
638
+ event.turnId &&
639
+ payloadOf(event).queuedMessageId === accepted.queuedMessageId)
640
+ turnId = event.turnId;
641
+ if (turnId !== undefined && event.turnId === turnId && SETTLED_EVENTS.has(event.type))
346
642
  return;
643
+ }
347
644
  }
348
645
  finally {
349
646
  clearTimeout(timer);