dsh-a2a-server 0.1.1 → 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.
Files changed (86) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/CHANGELOG.zh-CN.md +28 -0
  3. package/README.md +151 -54
  4. package/README.zh-CN.md +113 -53
  5. package/cordis.patch.yml +10 -6
  6. package/lib/briefing.d.ts +39 -0
  7. package/lib/briefing.d.ts.map +1 -0
  8. package/lib/briefing.js +108 -0
  9. package/lib/briefing.js.map +1 -0
  10. package/lib/config.d.ts +69 -22
  11. package/lib/config.d.ts.map +1 -1
  12. package/lib/config.js +76 -6
  13. package/lib/config.js.map +1 -1
  14. package/lib/contexts.d.ts +9 -4
  15. package/lib/contexts.d.ts.map +1 -1
  16. package/lib/contexts.js +7 -13
  17. package/lib/contexts.js.map +1 -1
  18. package/lib/index.d.ts +1 -26
  19. package/lib/index.d.ts.map +1 -1
  20. package/lib/index.js +420 -408
  21. package/lib/index.js.map +1 -1
  22. package/lib/management.d.ts +5 -0
  23. package/lib/management.d.ts.map +1 -0
  24. package/lib/management.js +74 -0
  25. package/lib/management.js.map +1 -0
  26. package/lib/projection.d.ts +67 -22
  27. package/lib/projection.d.ts.map +1 -1
  28. package/lib/projection.js +30 -11
  29. package/lib/projection.js.map +1 -1
  30. package/lib/protocol/card.d.ts +25 -7
  31. package/lib/protocol/card.d.ts.map +1 -1
  32. package/lib/protocol/card.js +35 -14
  33. package/lib/protocol/card.js.map +1 -1
  34. package/lib/protocol/index.d.ts +10 -10
  35. package/lib/protocol/index.d.ts.map +1 -1
  36. package/lib/protocol/index.js +8 -8
  37. package/lib/protocol/index.js.map +1 -1
  38. package/lib/protocol/jsonrpc.d.ts +54 -6
  39. package/lib/protocol/jsonrpc.d.ts.map +1 -1
  40. package/lib/protocol/jsonrpc.js +70 -8
  41. package/lib/protocol/jsonrpc.js.map +1 -1
  42. package/lib/protocol/parse.d.ts +79 -0
  43. package/lib/protocol/parse.d.ts.map +1 -0
  44. package/lib/protocol/parse.js +249 -0
  45. package/lib/protocol/parse.js.map +1 -0
  46. package/lib/protocol/sse.d.ts +6 -3
  47. package/lib/protocol/sse.d.ts.map +1 -1
  48. package/lib/protocol/sse.js +4 -2
  49. package/lib/protocol/sse.js.map +1 -1
  50. package/lib/protocol/wire.d.ts +165 -54
  51. package/lib/protocol/wire.d.ts.map +1 -1
  52. package/lib/protocol/wire.js +54 -9
  53. package/lib/protocol/wire.js.map +1 -1
  54. package/lib/router.d.ts +55 -1
  55. package/lib/router.d.ts.map +1 -1
  56. package/lib/router.js +419 -137
  57. package/lib/router.js.map +1 -1
  58. package/lib/security.d.ts +37 -3
  59. package/lib/security.d.ts.map +1 -1
  60. package/lib/security.js +48 -4
  61. package/lib/security.js.map +1 -1
  62. package/lib/state.d.ts +37 -0
  63. package/lib/state.d.ts.map +1 -0
  64. package/lib/state.js +146 -0
  65. package/lib/state.js.map +1 -0
  66. package/lib/tasks.d.ts +4 -0
  67. package/lib/tasks.d.ts.map +1 -1
  68. package/lib/tasks.js +23 -5
  69. package/lib/tasks.js.map +1 -1
  70. package/lib/types.d.ts +12 -3
  71. package/lib/types.d.ts.map +1 -1
  72. package/lib/types.js +1 -1
  73. package/lib/types.js.map +1 -1
  74. package/lib/version.d.ts +20 -0
  75. package/lib/version.d.ts.map +1 -0
  76. package/lib/version.js +20 -0
  77. package/lib/version.js.map +1 -0
  78. package/lib/workspace-read.d.ts +9 -0
  79. package/lib/workspace-read.d.ts.map +1 -0
  80. package/lib/workspace-read.js +152 -0
  81. package/lib/workspace-read.js.map +1 -0
  82. package/package.json +39 -30
  83. package/lib/protocol/normalize.d.ts +0 -72
  84. package/lib/protocol/normalize.d.ts.map +0 -1
  85. package/lib/protocol/normalize.js +0 -278
  86. package/lib/protocol/normalize.js.map +0 -1
package/lib/router.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * HTTP + JSON-RPC dispatch.
2
+ * HTTP + JSON-RPC dispatch for the A2A v1.0 binding.
3
3
  *
4
4
  * Deliberately free of Cordis: everything it needs arrives through
5
5
  * {@link RouterDeps}, so the RPC semantics can be unit-tested without booting a
@@ -8,10 +8,12 @@
8
8
  *
9
9
  * @module dsh-a2a/router
10
10
  */
11
- import { A2AContextId, ERR_INVALID_PARAMS, ERR_INVALID_REQUEST, ERR_METHOD_NOT_FOUND, ERR_PARSE, ERR_PUSH_NOT_SUPPORTED, ERR_TASK_NOT_CANCELABLE, ERR_UNSUPPORTED_OPERATION, A2ARpcError, jsonRpcError, jsonRpcResult, nowIso, parseSendParams, partsToText, renderStatusUpdate, renderTask, resolveMethod, sseFrame, taskNotFound, } from "./protocol/index.js";
12
- import { filterInbound, redactOutbound } from "./security.js";
11
+ import { A2AContextId, A2A_PROTOCOL_VERSION, ERR_EXTENDED_CARD_NOT_CONFIGURED, ERR_INVALID_PARAMS, ERR_INVALID_REQUEST, ERR_METHOD_NOT_FOUND, ERR_PARSE, ERR_PUSH_NOT_SUPPORTED, ERR_TASK_NOT_CANCELABLE, A2ARpcError, jsonRpcError, jsonRpcResult, legacyMethodReplacement, nowIso, parseListTasksParams, parseSendMessageRequest, parseTenant, partsToText, resolveMethod, sseFrame, streamArtifactUpdate, streamStatusUpdate, streamTask, taskNotFound, unsupportedOperation, versionNotSupported, } from "./protocol/index.js";
12
+ import { constantTimeEqual, filterInbound, redactOutbound } from "./security.js";
13
13
  /** Largest request body accepted, before parsing. */
14
14
  const MAX_BODY_BYTES = 1_000_000;
15
+ /** `ListTasks` page size when a peer names none, per the spec's default. */
16
+ const DEFAULT_PAGE_SIZE = 50;
15
17
  /**
16
18
  * Build the router.
17
19
  * @param deps - the injected plugin capabilities.
@@ -28,6 +30,10 @@ export function createRouter(deps) {
28
30
  });
29
31
  res.end(text);
30
32
  };
33
+ /** Answer a JSON-RPC failure, carrying its A2A reason when it has one. */
34
+ const sendRpcError = (res, id, error) => {
35
+ sendJson(res, error.httpStatus, jsonRpcError(id, error.code, error.message, error.data));
36
+ };
31
37
  /**
32
38
  * Answer an authentication-class failure.
33
39
  *
@@ -46,8 +52,9 @@ export function createRouter(deps) {
46
52
  for await (const chunk of req) {
47
53
  const buffer = chunk;
48
54
  size += buffer.length;
49
- if (size > MAX_BODY_BYTES)
50
- throw new A2ARpcError(ERR_PARSE, 'payload too large', 413);
55
+ if (size > MAX_BODY_BYTES) {
56
+ throw new A2ARpcError(ERR_PARSE, 'payload too large', { httpStatus: 413 });
57
+ }
51
58
  chunks.push(buffer);
52
59
  }
53
60
  return Buffer.concat(chunks).toString('utf8');
@@ -61,7 +68,7 @@ export function createRouter(deps) {
61
68
  }
62
69
  sendJson(res, 200, deps.cardFor(req.headers.host));
63
70
  };
64
- const serveRpc = async (req, res) => {
71
+ const serveRpc = async (req, res, ownerChannel = false) => {
65
72
  if (req.method !== 'POST') {
66
73
  sendJson(res, 405, jsonRpcError(null, ERR_INVALID_REQUEST, 'method not allowed'));
67
74
  return;
@@ -69,12 +76,22 @@ export function createRouter(deps) {
69
76
  let peer;
70
77
  try {
71
78
  deps.assertOpen();
72
- const secrets = await deps.resolvePeerSecrets();
73
- const header = req.headers.authorization;
74
- peer = deps.identify(typeof header === 'string' ? /^Bearer\s+(.+)$/i.exec(header.trim())?.[1] : undefined, secrets);
79
+ const presented = typeof req.headers.authorization === 'string'
80
+ ? /^Bearer\s+(.+)$/i.exec(req.headers.authorization.trim())?.[1]
81
+ : undefined;
82
+ // The owner's token wins before the visitor table, so a visitor named
83
+ // "owner" can never shadow the console's identity.
84
+ const ownerSecret = await deps.resolveOwnerSecret?.();
85
+ if (ownerChannel && presented !== undefined && ownerSecret !== undefined && constantTimeEqual(presented, ownerSecret)) {
86
+ peer = 'owner';
87
+ }
88
+ else if (!ownerChannel && !(presented !== undefined && ownerSecret !== undefined && constantTimeEqual(presented, ownerSecret))) {
89
+ peer = deps.identify(presented, await deps.resolvePeerSecrets());
90
+ }
75
91
  }
76
92
  catch (error) {
77
- sendJson(res, 503, jsonRpcError(null, ERR_INVALID_REQUEST, String(error)));
93
+ deps.logger.warn(`a2a: credential resolution failed: ${String(error)}`);
94
+ sendJson(res, 503, jsonRpcError(null, ERR_INVALID_REQUEST, 'authentication unavailable'));
78
95
  return;
79
96
  }
80
97
  if (peer === undefined) {
@@ -85,11 +102,9 @@ export function createRouter(deps) {
85
102
  sendAuthFailure(res, 429, 'rate limit exceeded');
86
103
  return;
87
104
  }
88
- // An EMPTY allow list means "no allow list", matching the documented
89
- // "omit to allow every authenticated peer". Schemastery materializes an
90
- // omitted array as `[]`, so testing only for `undefined` would silently
91
- // lock out every peer on a config that never mentioned trustedPeers.
92
- if (config.trustedPeers !== undefined && config.trustedPeers.length > 0
105
+ // The owner bypasses the visitor allow list: the console IS the trust.
106
+ if (peer !== 'owner'
107
+ && config.trustedPeers !== undefined
93
108
  && !config.trustedPeers.includes(peer)) {
94
109
  sendAuthFailure(res, 403, `peer "${peer}" is not trusted`);
95
110
  return;
@@ -123,23 +138,40 @@ export function createRouter(deps) {
123
138
  sendJson(res, 200, jsonRpcError(id, ERR_INVALID_PARAMS, 'params must be an object'));
124
139
  return;
125
140
  }
141
+ // Version negotiation. The spec says an EMPTY header means 0.3 — but this
142
+ // interface declares only 1.0 on its card, and a v0.3 peer could not read
143
+ // the reply anyway. So an absent header is taken as 1.0 (the interface a
144
+ // client just discovered), and an explicit version this interface does not
145
+ // serve gets the spec's own VersionNotSupportedError rather than a reply in
146
+ // a spelling the caller cannot parse.
126
147
  const version = req.headers['a2a-version'];
127
- if (typeof version === 'string' && version.length > 0
128
- && !['0.3', '0.3.0', '1.0', '1.0.0'].includes(version)) {
129
- sendJson(res, 200, jsonRpcError(id, ERR_INVALID_PARAMS, `unsupported A2A-Version: ${version}`));
148
+ if (typeof version === 'string' && version.trim().length > 0
149
+ && !['1.0', '1.0.0'].includes(version.trim())) {
150
+ sendRpcError(res, id, versionNotSupported(version.trim(), A2A_PROTOCOL_VERSION));
151
+ return;
152
+ }
153
+ const operation = resolveMethod(method);
154
+ if (operation === undefined) {
155
+ const replacement = legacyMethodReplacement(method);
156
+ sendJson(res, 200, jsonRpcError(id, ERR_METHOD_NOT_FOUND, replacement === undefined
157
+ ? `method not found: ${method}`
158
+ : `method not found: ${method}; this agent speaks A2A `
159
+ + `${A2A_PROTOCOL_VERSION}, which renamed it to ${replacement}`));
130
160
  return;
131
161
  }
132
- const entry = resolveMethod(method);
133
- if (entry === undefined) {
134
- sendJson(res, 200, jsonRpcError(id, ERR_METHOD_NOT_FOUND, `method not found: ${method}`));
162
+ // This card's interface declares no tenant, so a peer that names one has
163
+ // been routed somewhere it did not intend.
164
+ const tenant = parseTenant(params);
165
+ if (tenant !== undefined) {
166
+ sendJson(res, 200, jsonRpcError(id, ERR_INVALID_PARAMS, `this interface declares no tenant; remove tenant: ${tenant}`));
135
167
  return;
136
168
  }
137
169
  try {
138
- await dispatch(entry.operation, entry.dialect, id, params, peer, res);
170
+ await dispatch(operation, id, params, peer, req, res);
139
171
  }
140
172
  catch (error) {
141
173
  if (error instanceof A2ARpcError) {
142
- sendJson(res, error.httpStatus, jsonRpcError(id, error.code, error.message));
174
+ sendRpcError(res, id, error);
143
175
  return;
144
176
  }
145
177
  deps.logger.warn(`a2a: ${method} failed: ${String(error)}`);
@@ -157,6 +189,11 @@ export function createRouter(deps) {
157
189
  if (rawContextId === undefined)
158
190
  return deps.createActivation(peer);
159
191
  const found = deps.contexts.lookup(A2AContextId(rawContextId), peer);
192
+ if (found === 'unknown') {
193
+ const restored = await deps.restoreActivation(rawContextId, peer);
194
+ if (restored !== undefined)
195
+ return restored;
196
+ }
160
197
  if (found === 'unknown' || found === 'forbidden') {
161
198
  throw new A2ARpcError(ERR_INVALID_PARAMS, `unknown contextId: ${rawContextId}`);
162
199
  }
@@ -168,7 +205,7 @@ export function createRouter(deps) {
168
205
  * A settled task has no slot — `settleSlot` removes it — so this is only the
169
206
  * first half of a lookup. Operations that merely READ a task fall through to
170
207
  * the projection ({@link readTask}); operations that act on a running task
171
- * (cancel, resubscribe) need the live slot and stop here.
208
+ * (cancel, subscribe) need the live slot and stop here.
172
209
  */
173
210
  const requireSlot = (rawTaskId, peer) => {
174
211
  const found = findSlot(rawTaskId, peer);
@@ -188,17 +225,36 @@ export function createRouter(deps) {
188
225
  }
189
226
  return undefined;
190
227
  };
191
- /** Validate the `taskId` parameter shape. */
228
+ /**
229
+ * Validate the `id` parameter shape.
230
+ *
231
+ * v1.0 spells every task reference `id`. The v0.3 `taskId` is NOT accepted:
232
+ * a peer still sending it would also be sending a v0.3 message body and
233
+ * expecting a v0.3 reply, and a half-understood request is worse than a
234
+ * refused one.
235
+ */
192
236
  const requireTaskId = (rawTaskId) => {
193
237
  if (typeof rawTaskId !== 'string' || rawTaskId.length === 0) {
194
- throw new A2ARpcError(ERR_INVALID_PARAMS, 'taskId is required');
238
+ throw new A2ARpcError(ERR_INVALID_PARAMS, 'id is required');
195
239
  }
196
240
  return rawTaskId;
197
241
  };
242
+ /** The current wire shape of an in-flight task. */
243
+ const liveTask = (activation, slot) => {
244
+ const state = slot.forcedState ?? (slot.turn === undefined
245
+ ? 'TASK_STATE_SUBMITTED'
246
+ : 'TASK_STATE_WORKING');
247
+ return deps.taskSnapshot(activation, slot, state, redactArtifacts(slot.texts.length > 0
248
+ ? [{
249
+ artifactId: `${slot.taskId}-result`,
250
+ parts: [{ text: slot.texts.join('\n'), mediaType: 'text/plain' }],
251
+ }]
252
+ : []));
253
+ };
198
254
  /**
199
255
  * Read a task's current state, live slot or not.
200
256
  *
201
- * Falls back to the durable projection, which is what makes `tasks/get`
257
+ * Falls back to the durable projection, which is what makes `GetTask`
202
258
  * answerable after settlement — and, once persistence is composed, after a
203
259
  * restart. A task belonging to another peer reports exactly like an absent
204
260
  * one, so ownership cannot be probed by comparing responses.
@@ -209,15 +265,8 @@ export function createRouter(deps) {
209
265
  if (activation.peer !== peer)
210
266
  continue;
211
267
  const slot = activation.slots.get(taskId);
212
- if (slot !== undefined) {
213
- const state = slot.turn === undefined ? 'submitted' : 'working';
214
- return deps.taskSnapshot(activation, slot, state, redactArtifacts(slot.texts.length > 0
215
- ? [{
216
- artifactId: `${taskId}-result`,
217
- parts: [{ kind: 'text', text: slot.texts.join('\n') }],
218
- }]
219
- : []));
220
- }
268
+ if (slot !== undefined)
269
+ return liveTask(activation, slot);
221
270
  const projected = deps.readProjectedTask?.(activation, taskId);
222
271
  // Text read back from the log leaves the process just like live text
223
272
  // does, so it goes through the same scrub.
@@ -225,167 +274,373 @@ export function createRouter(deps) {
225
274
  return { ...projected, artifacts: redactArtifacts(projected.artifacts ?? []) };
226
275
  }
227
276
  }
277
+ const retained = deps.retainedTasks(peer).find(task => task.id === taskId);
278
+ if (retained !== undefined)
279
+ return { ...retained, artifacts: redactArtifacts(retained.artifacts ?? []) };
228
280
  throw taskNotFound(taskId);
229
281
  };
230
- const dispatch = async (operation, dialect, id, params, peer, res) => {
282
+ const dispatch = async (operation, id, params, peer, req, res) => {
231
283
  switch (operation) {
232
284
  case 'send':
233
285
  case 'stream': {
234
- const parsed = parseSendParams(params);
286
+ const parsed = parseSendMessageRequest(params);
235
287
  if (parsed === undefined)
236
288
  throw new A2ARpcError(ERR_INVALID_PARAMS, 'message is required');
237
289
  const raw = partsToText(parsed.message.parts);
238
290
  if (raw.trim().length === 0) {
239
291
  throw new A2ARpcError(ERR_INVALID_PARAMS, 'message carries no readable content');
240
292
  }
241
- const activation = await resolveContext(parsed.message.contextId, peer);
242
- const turn = deps.turns.track(activation.contextId);
243
- if (turn > config.maxContextTurns) {
244
- const rejected = deps.taskSnapshot(activation, { taskId: 'rejected' }, 'rejected', [{
245
- artifactId: 'rejected',
246
- parts: [{
247
- kind: 'text',
248
- text: `context ${activation.contextId} exceeded ${config.maxContextTurns} turns; `
249
- + 'start a new context or raise maxContextTurns',
250
- }],
251
- }]);
252
- sendJson(res, 200, jsonRpcResult(id, renderTask(rejected, dialect)));
253
- return;
293
+ if (raw.length > config.maxInputChars)
294
+ throw new A2ARpcError(ERR_INVALID_PARAMS, 'message exceeds input limit', { httpStatus: 413 });
295
+ if (operation === 'stream' && streams.size >= config.maxConcurrentTasks * config.maxStreamsPerTask) {
296
+ throw new A2ARpcError(ERR_INVALID_REQUEST, 'stream capacity reached', { httpStatus: 429 });
254
297
  }
255
- const slot = await deps.submit(activation, filterInbound(raw), peer);
256
- if (operation === 'stream') {
257
- openStream(res, id, dialect, slot, activation);
298
+ const release = deps.reserveTask(peer);
299
+ let handedOff = false;
300
+ let selected;
301
+ try {
302
+ // A message may address a context directly, or name a task and let the
303
+ // server infer the context from it. A task that already settled cannot
304
+ // take further messages, which the spec spells UnsupportedOperation.
305
+ const activation = parsed.message.taskId === undefined
306
+ ? await resolveContext(parsed.message.contextId, peer)
307
+ : continueTask(parsed.message.taskId, peer);
308
+ selected = activation;
309
+ if (activation.slots.size > 0)
310
+ throw new A2ARpcError(ERR_INVALID_REQUEST, 'context already has an active task', { httpStatus: 409 });
311
+ const turn = deps.turns.track(activation.contextId);
312
+ // The owner's caretaker context is long-lived by design (scheduled
313
+ // briefing upkeep); the visitor turn cap does not apply to it.
314
+ if (peer !== 'owner' && turn > config.maxContextTurns) {
315
+ const rejected = deps.taskSnapshot(activation, { taskId: 'rejected' }, 'TASK_STATE_REJECTED', [{
316
+ artifactId: 'rejected',
317
+ parts: [{
318
+ text: `context ${activation.contextId} exceeded ${config.maxContextTurns} turns; `
319
+ + 'start a new context or raise maxContextTurns',
320
+ mediaType: 'text/plain',
321
+ }],
322
+ }]);
323
+ sendJson(res, 200, jsonRpcResult(id, { task: rejected }));
324
+ return;
325
+ }
326
+ const slot = await deps.submit(activation, filterInbound(raw), peer);
327
+ slot.onSettled(release);
328
+ handedOff = true;
329
+ if (operation === 'stream') {
330
+ openStream(res, id, slot, activation);
331
+ return;
332
+ }
333
+ // v1.0 inverted the send-mode knob: operations BLOCK by default and a
334
+ // client opts out with `returnImmediately`. The deployment's `sendMode`
335
+ // is only the default for a client that expresses no preference.
336
+ //
337
+ // The spec still lets a server answer a blocking request with a
338
+ // non-terminal task, which is exactly what the timeout below does: the
339
+ // task keeps running and the peer polls.
340
+ const requested = parsed.configuration?.returnImmediately;
341
+ const shouldBlock = requested === undefined ? config.sendMode === 'block' : !requested;
342
+ const settlement = shouldBlock
343
+ ? await withTimeout(slot.settled, config.blockTimeoutMs)
344
+ : undefined;
345
+ const task = settlement === undefined
346
+ // Not an error: the peer polls GetTask or subscribes, and the task
347
+ // keeps running. Erroring here would discard real work.
348
+ ? deps.taskSnapshot(activation, slot, 'TASK_STATE_WORKING', [])
349
+ : deps.taskSnapshot(activation, slot, settlement.state, redactArtifacts(settlement.artifacts), settlement.stopReason);
350
+ // v1.0 wraps the result: SendMessage answers with a task OR a message,
351
+ // and the member name is what tells them apart.
352
+ sendJson(res, 200, jsonRpcResult(id, { task }));
258
353
  return;
259
354
  }
260
- // A2A is async-first: `message/send` MAY answer with a non-terminal
261
- // task. Whether it waits is negotiated, not fixed — the client asks
262
- // through `configuration.blocking`, and the deployment's `sendMode` is
263
- // only the default for a client that expresses no preference.
264
- //
265
- // The spec lets the server decline a blocking request ("The server may
266
- // reject this if the task is long-running"), which is exactly what the
267
- // timeout below does: the task keeps running and the peer polls.
268
- const requested = parsed.configuration?.blocking;
269
- const shouldBlock = requested ?? config.sendMode === 'block';
270
- const settlement = shouldBlock
271
- ? await withTimeout(slot.settled, config.blockTimeoutMs)
272
- : undefined;
273
- const task = settlement === undefined
274
- // Not an error: the peer polls tasks/get or resubscribes, and the task
275
- // keeps running. Erroring here would discard real work.
276
- ? deps.taskSnapshot(activation, slot, 'working', [])
277
- : deps.taskSnapshot(activation, slot, settlement.state, redactArtifacts(settlement.artifacts), settlement.stopReason);
278
- sendJson(res, 200, jsonRpcResult(id, renderTask(task, dialect)));
279
- return;
355
+ finally {
356
+ if (selected)
357
+ deps.releaseActivation(selected);
358
+ if (!handedOff)
359
+ release();
360
+ }
280
361
  }
281
362
  case 'get': {
282
- const task = readTask(params?.['taskId'], peer);
283
- sendJson(res, 200, jsonRpcResult(id, renderTask(task, dialect)));
363
+ const task = readTask(params?.['id'], peer);
364
+ sendJson(res, 200, jsonRpcResult(id, task));
365
+ return;
366
+ }
367
+ case 'list': {
368
+ sendJson(res, 200, jsonRpcResult(id, listTasks(parseListTasksParams(params), peer)));
284
369
  return;
285
370
  }
286
371
  case 'cancel': {
287
- const { activation, slot } = requireSlot(params?.['taskId'], peer);
288
- if (slot.done)
289
- throw new A2ARpcError(ERR_TASK_NOT_CANCELABLE, 'task already reached a terminal state');
372
+ const { activation, slot } = requireSlot(params?.['id'], peer);
373
+ if (slot.done) {
374
+ throw new A2ARpcError(ERR_TASK_NOT_CANCELABLE, 'task already reached a terminal state', { reason: 'TASK_NOT_CANCELABLE', metadata: { taskId: slot.taskId } });
375
+ }
290
376
  deps.cancel(activation, slot);
291
- const task = deps.taskSnapshot(activation, slot, 'canceled', []);
292
- sendJson(res, 200, jsonRpcResult(id, renderTask(task, dialect)));
377
+ const task = deps.taskSnapshot(activation, slot, 'TASK_STATE_CANCELED', []);
378
+ sendJson(res, 200, jsonRpcResult(id, task));
293
379
  return;
294
380
  }
295
- case 'resubscribe': {
296
- const rawTaskId = params?.['taskId'];
381
+ case 'subscribe': {
382
+ const rawTaskId = params?.['id'];
297
383
  const live = findSlot(rawTaskId, peer);
298
384
  if (live !== undefined) {
299
- openStream(res, id, dialect, live.slot, live.activation);
385
+ openStream(res, id, live.slot, live.activation);
300
386
  return;
301
387
  }
302
- // Already settled: a peer that reconnects after the fact still deserves
303
- // the outcome. Emit the terminal transition from the projection and
304
- // close, rather than claiming the task never existed.
388
+ // Settled, or never existed. readTask separates the two, and a settled
389
+ // task is refused rather than streamed: v1.0 states a subscription to a
390
+ // terminal task is an UnsupportedOperation, and the outcome is still
391
+ // one GetTask away.
305
392
  const task = readTask(rawTaskId, peer);
306
- openSettledStream(res, id, dialect, task);
307
- return;
393
+ throw unsupportedOperation(`task ${task.id} is in terminal state ${task.status.state}; read it with GetTask`);
308
394
  }
309
- case 'push_set':
395
+ case 'push_create':
310
396
  case 'push_get':
311
397
  case 'push_list':
312
398
  case 'push_delete': {
313
399
  // Advertised as unsupported on the card, so a compliant peer never
314
400
  // reaches this; answering with the spec's own code keeps one that does
315
401
  // from guessing.
316
- throw new A2ARpcError(config.push.enabled ? ERR_UNSUPPORTED_OPERATION : ERR_PUSH_NOT_SUPPORTED, 'push notifications are not enabled on this deployment');
402
+ if (config.push.enabled) {
403
+ throw unsupportedOperation('push notifications are not enabled on this deployment');
404
+ }
405
+ throw new A2ARpcError(ERR_PUSH_NOT_SUPPORTED, 'push notifications are not enabled on this deployment', { reason: 'PUSH_NOTIFICATION_NOT_SUPPORTED' });
406
+ }
407
+ case 'extended_card': {
408
+ const extended = deps.extendedCardFor(req.headers.host);
409
+ if (extended === undefined) {
410
+ throw new A2ARpcError(ERR_EXTENDED_CARD_NOT_CONFIGURED, 'this deployment declares no extended agent card', { reason: 'EXTENDED_AGENT_CARD_NOT_CONFIGURED' });
411
+ }
412
+ sendJson(res, 200, jsonRpcResult(id, extended));
413
+ return;
317
414
  }
318
415
  default:
319
- throw new A2ARpcError(ERR_METHOD_NOT_FOUND, `unsupported operation: ${operation}`);
416
+ throw new A2ARpcError(ERR_METHOD_NOT_FOUND, `unsupported operation: ${String(operation)}`);
320
417
  }
321
418
  };
322
- /** Open an SSE stream and push this task's terminal transition when it settles. */
323
- const openStream = (res, id, dialect, slot, activation) => {
419
+ /**
420
+ * Resolve the context that owns a task a message continues.
421
+ *
422
+ * A task here spans one submitted message, so "continuing" one means adding a
423
+ * message to the SAME context, not reopening the same task id. A settled task
424
+ * is refused outright, which is the rule v1.0 states for messages addressed to
425
+ * a terminal task.
426
+ * @param rawTaskId - the `taskId` the message carried.
427
+ * @param peer - the authenticated identity.
428
+ * @returns the owning context.
429
+ */
430
+ const continueTask = (rawTaskId, peer) => {
431
+ const live = findSlot(rawTaskId, peer);
432
+ if (live !== undefined)
433
+ return live.activation;
434
+ const settled = readTask(rawTaskId, peer);
435
+ throw unsupportedOperation(`task ${settled.id} is in terminal state ${settled.status.state} `
436
+ + 'and cannot accept further messages; send to its contextId instead');
437
+ };
438
+ /**
439
+ * List the tasks this peer may see, newest first.
440
+ *
441
+ * Scope includes live slots and retained checkpoints, always peer-owned.
442
+ * @param filters - the parsed `ListTasks` parameters.
443
+ * @param peer - the authenticated identity.
444
+ * @returns one page of results plus its cursor.
445
+ */
446
+ const listTasks = (filters, peer) => {
447
+ const collected = [];
448
+ for (const activation of deps.contexts.values()) {
449
+ if (activation.peer !== peer)
450
+ continue;
451
+ if (filters.contextId !== undefined && activation.contextId !== filters.contextId)
452
+ continue;
453
+ for (const slot of activation.slots.values())
454
+ collected.push(liveTask(activation, slot));
455
+ for (const projected of deps.readProjectedTasks?.(activation) ?? []) {
456
+ // A live slot is authoritative over its own projected row.
457
+ if (activation.slots.has(projected.id))
458
+ continue;
459
+ collected.push({ ...projected, artifacts: redactArtifacts(projected.artifacts ?? []) });
460
+ }
461
+ }
462
+ const existing = new Set(collected.map(task => task.id));
463
+ for (const task of deps.retainedTasks(peer)) {
464
+ if (!existing.has(task.id) && (filters.contextId === undefined || task.contextId === filters.contextId)) {
465
+ collected.push({ ...task, artifacts: redactArtifacts(task.artifacts ?? []) });
466
+ }
467
+ }
468
+ const matching = collected
469
+ .filter(task => filters.status === undefined || task.status.state === filters.status)
470
+ .filter(task => filters.statusTimestampAfter === undefined
471
+ || task.status.timestamp >= filters.statusTimestampAfter)
472
+ // Newest first, with the id as a tiebreak so the cursor below is total.
473
+ .sort((a, b) => a.status.timestamp === b.status.timestamp
474
+ ? (a.id < b.id ? 1 : a.id > b.id ? -1 : 0)
475
+ : (a.status.timestamp < b.status.timestamp ? 1 : -1));
476
+ const after = decodeCursor(filters.pageToken);
477
+ const remaining = after === undefined
478
+ ? matching
479
+ : matching.filter(task => task.status.timestamp < after.timestamp
480
+ || (task.status.timestamp === after.timestamp && task.id < after.taskId));
481
+ const pageSize = filters.pageSize ?? DEFAULT_PAGE_SIZE;
482
+ const page = remaining.slice(0, pageSize);
483
+ const last = page[page.length - 1];
484
+ const more = remaining.length > page.length;
485
+ return {
486
+ // `includeArtifacts` defaults to false, and the spec is explicit that the
487
+ // member must then be ABSENT rather than an empty array.
488
+ tasks: page.map((task) => {
489
+ if (filters.includeArtifacts === true)
490
+ return task;
491
+ const { artifacts: _artifacts, ...rest } = task;
492
+ return rest;
493
+ }),
494
+ // Always present; empty string is how "no more pages" is spelled.
495
+ nextPageToken: more && last !== undefined ? encodeCursor(last) : '',
496
+ pageSize,
497
+ totalSize: matching.length,
498
+ };
499
+ };
500
+ /** Open an SSE stream and push this task's transitions until it settles. */
501
+ const openStream = (res, id, slot, activation) => {
502
+ if ([...streams].filter(channel => channel.taskId === slot.taskId).length >= config.maxStreamsPerTask) {
503
+ throw new A2ARpcError(ERR_INVALID_REQUEST, 'task subscription limit reached', { httpStatus: 429 });
504
+ }
324
505
  res.writeHead(200, {
325
506
  'content-type': 'text/event-stream',
326
507
  'cache-control': 'no-cache',
327
508
  connection: 'keep-alive',
509
+ 'x-accel-buffering': 'no',
328
510
  });
329
511
  const channel = {
330
- res, id, dialect, taskId: slot.taskId, contextId: activation.contextId,
512
+ res, id, taskId: slot.taskId, contextId: activation.contextId, cleanup: () => { },
331
513
  };
332
514
  streams.add(channel);
333
- res.write(sseFrame(id, renderStatusUpdate({
334
- kind: 'status-update',
335
- taskId: slot.taskId,
336
- contextId: activation.contextId,
337
- status: { state: slot.turn === undefined ? 'submitted' : 'working', timestamp: nowIso() },
338
- final: false,
339
- }, dialect)));
340
- void slot.settled.then((settlement) => {
515
+ let unsubscribe = () => { };
516
+ const cleanup = () => {
517
+ streams.delete(channel);
518
+ clearInterval(heartbeat);
519
+ unsubscribe();
520
+ res.off('close', cleanup);
521
+ res.off('error', cleanup);
522
+ };
523
+ const write = (frame) => {
524
+ if (!streams.has(channel) || res.destroyed) {
525
+ cleanup();
526
+ return false;
527
+ }
528
+ if (res.writableLength + Buffer.byteLength(frame) > config.maxStreamBufferBytes) {
529
+ cleanup();
530
+ res.destroy();
531
+ return false;
532
+ }
533
+ try {
534
+ res.write(frame);
535
+ }
536
+ catch {
537
+ cleanup();
538
+ res.destroy();
539
+ return false;
540
+ }
541
+ return true;
542
+ };
543
+ const heartbeat = setInterval(() => {
544
+ if (res.writableLength === 0)
545
+ write(': heartbeat\n\n');
546
+ }, config.streamHeartbeatMs);
547
+ heartbeat.unref();
548
+ channel.cleanup = cleanup;
549
+ res.once('close', cleanup);
550
+ res.once('error', cleanup);
551
+ // v1.0 requires the Task object itself as the first frame, so a subscriber
552
+ // never has to call GetTask to learn where the task stood when it joined.
553
+ if (!write(sseFrame(id, streamTask(liveTask(activation, slot)))))
554
+ return;
555
+ unsubscribe = slot.onSettled((settlement) => {
341
556
  if (!streams.has(channel))
342
557
  return;
343
- for (const artifact of redactArtifacts(settlement.artifacts)) {
344
- res.write(sseFrame(id, { kind: 'artifact-update', taskId: slot.taskId, contextId: activation.contextId, artifact, lastChunk: true }));
345
- }
346
- res.write(sseFrame(id, renderStatusUpdate({
347
- kind: 'status-update',
558
+ redactArtifacts(settlement.artifacts).forEach((artifact, index) => {
559
+ write(sseFrame(id, streamArtifactUpdate({
560
+ taskId: slot.taskId,
561
+ contextId: activation.contextId,
562
+ artifact,
563
+ index,
564
+ lastChunk: true,
565
+ })));
566
+ });
567
+ write(sseFrame(id, streamStatusUpdate({
348
568
  taskId: slot.taskId,
349
569
  contextId: activation.contextId,
350
570
  status: { state: settlement.state, timestamp: nowIso() },
351
- final: true,
352
- }, dialect)));
353
- streams.delete(channel);
571
+ })));
572
+ cleanup();
573
+ // v1.0 dropped the `final` flag: closing the stream IS the terminal
574
+ // signal, so the close must follow the terminal status immediately.
354
575
  res.end();
355
576
  });
356
577
  };
357
578
  /**
358
- * Answer a resubscribe for an already-settled task: one terminal frame, then close.
359
- * @param res - the HTTP response to stream over.
360
- * @param id - the JSON-RPC id to correlate frames with.
361
- * @param dialect - the dialect the peer spoke.
362
- * @param task - the settled task, read from the projection.
579
+ * Serve the monitoring surface: GET `{basePath}/admin/contexts` and
580
+ * `{basePath}/admin/contexts/<contextId>`.
581
+ *
582
+ * The carrier must be local IPC and the request must carry the admin token.
583
+ * The body of a failure is
584
+ * plain JSON, not a JSON-RPC envelope — this surface is not A2A.
363
585
  */
364
- const openSettledStream = (res, id, dialect, task) => {
365
- res.writeHead(200, {
366
- 'content-type': 'text/event-stream',
367
- 'cache-control': 'no-cache',
368
- connection: 'keep-alive',
369
- });
370
- res.write(sseFrame(id, renderStatusUpdate({
371
- kind: 'status-update',
372
- taskId: task.id,
373
- contextId: task.contextId,
374
- status: task.status,
375
- final: true,
376
- }, dialect)));
377
- res.end();
586
+ const serveAdmin = async (req, res) => {
587
+ const fail = (status, code, message) => {
588
+ res.writeHead(status, { 'content-type': 'application/json' });
589
+ res.end(JSON.stringify({ error: { code, message } }));
590
+ };
591
+ try {
592
+ if (deps.resolveOwnerSecret === undefined || deps.adminSnapshot === undefined) {
593
+ fail(404, 'not_mounted', 'the admin surface is not configured on this deployment');
594
+ return;
595
+ }
596
+ if (req.method !== 'GET') {
597
+ res.setHeader('allow', 'GET');
598
+ fail(405, 'method_not_allowed', 'the admin surface is read-only');
599
+ return;
600
+ }
601
+ // This handler is registered exclusively on the local IPC listener.
602
+ const expected = await deps.resolveOwnerSecret?.();
603
+ if (expected === undefined) {
604
+ fail(401, 'admin_token_unconfigured', 'admin.tokenEnv resolves to no credential');
605
+ return;
606
+ }
607
+ const presented = /^Bearer (.+)$/.exec(req.headers.authorization ?? '')?.[1];
608
+ if (presented === undefined || !constantTimeEqual(presented, expected)) {
609
+ fail(401, 'unauthorized', 'a valid admin token is required');
610
+ return;
611
+ }
612
+ const snapshot = deps.adminSnapshot();
613
+ const wanted = decodeURIComponent(new URL(req.url ?? '/', 'http://localhost').pathname
614
+ .replace(/\/+$/, '')
615
+ .split('/').filter(Boolean).slice(-1)[0] ?? '');
616
+ // `contexts` itself lists everything; a contextId narrows to one context.
617
+ const view = wanted === '' || wanted === 'contexts'
618
+ ? snapshot
619
+ : {
620
+ generatedAt: snapshot.generatedAt,
621
+ contexts: snapshot.contexts.filter(context => context.contextId === wanted),
622
+ };
623
+ if (view.contexts.length === 0 && wanted !== '' && wanted !== 'contexts') {
624
+ fail(404, 'not_found', `no resident context "${wanted}"`);
625
+ return;
626
+ }
627
+ res.writeHead(200, { 'content-type': 'application/json' });
628
+ res.end(JSON.stringify(view));
629
+ }
630
+ catch (error) {
631
+ deps.logger.error(`a2a: admin request failed: ${String(error)}`);
632
+ fail(500, 'internal', 'internal error');
633
+ }
378
634
  };
379
635
  const closeStreams = () => {
380
636
  for (const channel of streams) {
637
+ channel.cleanup();
381
638
  try {
382
- channel.res.write(sseFrame(channel.id, renderStatusUpdate({
383
- kind: 'status-update',
639
+ channel.res.write(sseFrame(channel.id, streamStatusUpdate({
384
640
  taskId: channel.taskId,
385
641
  contextId: A2AContextId(channel.contextId),
386
- status: { state: 'canceled', timestamp: nowIso() },
387
- final: true,
388
- }, channel.dialect)));
642
+ status: { state: 'TASK_STATE_CANCELED', timestamp: nowIso() },
643
+ })));
389
644
  channel.res.end();
390
645
  }
391
646
  catch {
@@ -394,7 +649,34 @@ export function createRouter(deps) {
394
649
  }
395
650
  streams.clear();
396
651
  };
397
- return { serveCard, serveRpc, closeStreams };
652
+ return { serveCard, serveRpc, serveOwnerRpc: (req, res) => serveRpc(req, res, true), serveAdmin, closeStreams };
653
+ }
654
+ /**
655
+ * Encode a page cursor.
656
+ *
657
+ * The token is opaque by contract, so it is base64url of the sort key that
658
+ * produced it — a real cursor rather than an offset, which is what keeps a page
659
+ * boundary stable while tasks are being added ahead of it.
660
+ * @param task - the last task on the page just returned.
661
+ * @returns the token a peer sends back as `pageToken`.
662
+ */
663
+ function encodeCursor(task) {
664
+ return Buffer.from(`${task.status.timestamp}|${task.id}`, 'utf8').toString('base64url');
665
+ }
666
+ /**
667
+ * Decode a page cursor.
668
+ * @param token - the `pageToken` a peer sent, if any.
669
+ * @returns the sort key to resume after, or undefined for the first page.
670
+ * @throws {A2ARpcError} when the token is not one this server issued.
671
+ */
672
+ function decodeCursor(token) {
673
+ if (token === undefined)
674
+ return undefined;
675
+ const decoded = Buffer.from(token, 'base64url').toString('utf8');
676
+ const split = decoded.lastIndexOf('|');
677
+ if (split <= 0)
678
+ throw new A2ARpcError(ERR_INVALID_PARAMS, 'pageToken is not a cursor this server issued');
679
+ return { timestamp: decoded.slice(0, split), taskId: decoded.slice(split + 1) };
398
680
  }
399
681
  /**
400
682
  * Scrub credential-shaped text from artifacts before they leave the process.
@@ -404,7 +686,7 @@ export function createRouter(deps) {
404
686
  function redactArtifacts(artifacts) {
405
687
  return artifacts.map(artifact => ({
406
688
  ...artifact,
407
- parts: artifact.parts.map(part => part.kind === 'text' ? { kind: 'text', text: redactOutbound(part.text) } : part),
689
+ parts: artifact.parts.map(part => 'text' in part ? { ...part, text: redactOutbound(part.text) } : part),
408
690
  }));
409
691
  }
410
692
  /**