@tangle-network/agent-gateway 0.7.0 → 0.8.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 (65) hide show
  1. package/README.md +108 -6
  2. package/dist/chunk-GITV7CPT.js +84 -0
  3. package/dist/chunk-GITV7CPT.js.map +1 -0
  4. package/dist/chunk-J5SDVHOL.js +104 -0
  5. package/dist/chunk-J5SDVHOL.js.map +1 -0
  6. package/dist/chunk-MP6IIAIA.js +5651 -0
  7. package/dist/chunk-MP6IIAIA.js.map +1 -0
  8. package/dist/index.d.ts +76 -12
  9. package/dist/index.js +307 -21
  10. package/dist/index.js.map +1 -1
  11. package/dist/middleware.d.ts +7 -2
  12. package/dist/middleware.js +3 -2
  13. package/dist/nonce-store.d.ts +47 -11
  14. package/dist/nonce-store.js +9 -3
  15. package/dist/observer-types-A0RtA8uL.d.ts +95 -0
  16. package/dist/observer.d.ts +79 -0
  17. package/dist/observer.js +11 -0
  18. package/dist/observer.js.map +1 -0
  19. package/dist/{types-CX2V06cN.d.ts → types-BHISsm7D.d.ts} +423 -166
  20. package/dist/types.d.ts +2 -1
  21. package/package.json +1 -1
  22. package/src/a2a/agent-card.ts +4 -3
  23. package/src/a2a/execution-fence.ts +162 -0
  24. package/src/a2a/handler.ts +507 -562
  25. package/src/a2a/message-send-execution.ts +241 -0
  26. package/src/a2a/message-stream-execution.ts +392 -0
  27. package/src/a2a/payment-recovery.ts +431 -0
  28. package/src/a2a/push-config-methods.ts +158 -0
  29. package/src/a2a/push-notifications.ts +172 -22
  30. package/src/a2a/task-cancellation.ts +50 -0
  31. package/src/a2a/task-finalization.ts +451 -0
  32. package/src/a2a/task-lifecycle.ts +54 -0
  33. package/src/a2a/task-methods.ts +163 -0
  34. package/src/a2a/task-push-delivery.ts +119 -0
  35. package/src/a2a/task-recovery.ts +11 -0
  36. package/src/a2a/task-state.ts +99 -0
  37. package/src/a2a/task-store-sql.ts +222 -24
  38. package/src/a2a/task-store.ts +58 -1
  39. package/src/a2a/task-submission-recovery.ts +178 -0
  40. package/src/a2a/types.ts +1 -0
  41. package/src/dispatch-authorization.ts +437 -0
  42. package/src/dispatch-payment-recovery.ts +248 -0
  43. package/src/dispatch-payment.ts +425 -0
  44. package/src/dispatch-pricing.ts +108 -0
  45. package/src/dispatch-sandbox.ts +422 -0
  46. package/src/dispatch-settlement.ts +139 -0
  47. package/src/dispatch-types.ts +81 -0
  48. package/src/dispatch.ts +35 -462
  49. package/src/index.ts +64 -2
  50. package/src/middleware.ts +313 -32
  51. package/src/mpp-payment.ts +117 -0
  52. package/src/nonce-store.ts +122 -20
  53. package/src/observer-types.ts +63 -0
  54. package/src/observer.ts +3 -63
  55. package/src/payment-operations.ts +485 -0
  56. package/src/payment-recovery-sql.ts +108 -0
  57. package/src/payment-recovery-worker.ts +488 -0
  58. package/src/payment-recovery.ts +331 -0
  59. package/src/payment-types.ts +48 -0
  60. package/src/types.ts +153 -42
  61. package/src/verify.ts +265 -36
  62. package/dist/chunk-3IKQWFKX.js +0 -1703
  63. package/dist/chunk-3IKQWFKX.js.map +0 -1
  64. package/dist/chunk-M7ZJAK4K.js +0 -53
  65. package/dist/chunk-M7ZJAK4K.js.map +0 -1
@@ -13,36 +13,87 @@
13
13
  import type { Context } from 'hono'
14
14
 
15
15
  import {
16
- type A2ADispatchEvent,
17
16
  type AuthorizedRequest,
18
17
  type GatewayState,
19
18
  authenticateAndGuard,
20
- dispatchSandboxStreamRich,
21
- estimateTokens,
22
- settleAndRecord,
19
+ claimPayment,
23
20
  } from '../dispatch'
24
- import type { GatewayConfig } from '../types'
21
+ import type {
22
+ ChatMessage,
23
+ GatewayConfig,
24
+ } from '../types'
25
25
  import { buildAgentCard } from './agent-card'
26
+ import {
27
+ clearTaskExecution,
28
+ hasExpiredTaskExecution,
29
+ hasMalformedTaskExecution,
30
+ inspectTaskExecution,
31
+ } from './execution-fence'
32
+ import { executeMessageSend } from './message-send-execution'
33
+ import { executeMessageStream } from './message-stream-execution'
26
34
  import { fail, ok, parseEnvelope } from './jsonrpc'
27
35
  import {
28
- deliverPushNotifications,
29
36
  type PushNotificationStore,
30
- type TaskPushNotificationConfig,
31
37
  } from './push-notifications'
32
- import type { TaskStore } from './task-store'
33
- import { extractTextFromMessage, responseTextToArtifact } from './translate'
38
+ import { type TaskStore } from './task-store'
39
+ import { extractTextFromMessage } from './translate'
34
40
  import {
35
41
  A2A_ERROR_CODES,
36
42
  type JSONRPCRequest,
37
43
  type Message,
38
44
  type MessageSendParams,
39
- type StreamingEvent,
40
45
  type Task,
41
- type TaskArtifactUpdateEvent,
42
- type TaskIdParams,
43
- type TaskPushNotificationConfigGetParams,
44
- type TaskStatusUpdateEvent,
45
46
  } from './types'
47
+ import {
48
+ attachPaymentRecoveryMarker,
49
+ hasPaymentReleaseRecovery,
50
+ preservePaymentRecoveryMarker,
51
+ releaseTaskPayment,
52
+ recoverPaymentMarkerIfNeeded,
53
+ recoverPaymentReleaseIfNeeded,
54
+ retainPaymentRecoveryMarker,
55
+ } from './payment-recovery'
56
+ import {
57
+ isTaskFinalizing,
58
+ recoverFinalizationIfNeeded,
59
+ } from './task-finalization'
60
+ import {
61
+ clearTaskSubmission,
62
+ readTaskOrigin,
63
+ recoverSubmissionIfNeeded,
64
+ withTaskOrigin,
65
+ withTaskSubmission,
66
+ } from './task-submission-recovery'
67
+ import { deliverTaskPush, type PushDeliveryDependencies } from './task-push-delivery'
68
+ import {
69
+ bindRequestAbort,
70
+ TaskCancellationRegistry,
71
+ } from './task-cancellation'
72
+ import {
73
+ createTaskLifecycle as buildTaskLifecycle,
74
+ type TaskLifecycle,
75
+ } from './task-lifecycle'
76
+ import {
77
+ handleTasksCancel,
78
+ handleTasksGet,
79
+ handleTasksResubscribe,
80
+ type TaskMethodDependencies,
81
+ } from './task-methods'
82
+ import {
83
+ handlePushDelete,
84
+ handlePushGet,
85
+ handlePushList,
86
+ handlePushSet,
87
+ type PushConfigMethodDependencies,
88
+ } from './push-config-methods'
89
+ import {
90
+ compareAndSetTask,
91
+ cryptoRandomId,
92
+ isTerminal,
93
+ shouldPreserveTask,
94
+ nowIso,
95
+ withStatus,
96
+ } from './task-state'
46
97
 
47
98
  export interface A2AHandlerDeps {
48
99
  config: GatewayConfig
@@ -51,51 +102,60 @@ export interface A2AHandlerDeps {
51
102
  pushStore?: PushNotificationStore
52
103
  }
53
104
 
54
- /** Terminal task states fire-once push delivery occurs on these transitions. */
55
- const TERMINAL_STATES: ReadonlySet<Task['status']['state']> = new Set([
56
- 'completed',
57
- 'canceled',
58
- 'failed',
59
- 'rejected',
60
- ])
105
+ const MAX_A2A_BODY_BYTES = 64 * 1024
61
106
 
62
- /**
63
- * Per-gateway in-process registry of cancellable runs. Keyed by task id;
64
- * absent = task already terminal or never streamed. Cleared by the streaming
65
- * handler on completion. Cancel is best-effort: a cancel arriving after the
66
- * stream finished is reported as `TASK_NOT_CANCELABLE`.
67
- */
68
- class CancelRegistry {
69
- private readonly controllers = new Map<string, AbortController>()
107
+ class RequestBodyTooLargeError extends Error {}
70
108
 
71
- register(taskId: string): AbortController {
72
- const c = new AbortController()
73
- this.controllers.set(taskId, c)
74
- return c
75
- }
109
+ function createTaskLifecycle(deps: A2AHandlerDeps): TaskLifecycle {
110
+ return buildTaskLifecycle({
111
+ taskStore: deps.taskStore,
112
+ config: deps.config,
113
+ state: deps.state,
114
+ deliverPush: (task) => maybeDeliverPush(task, deps),
115
+ })
116
+ }
76
117
 
77
- clear(taskId: string): void {
78
- this.controllers.delete(taskId)
118
+ function buildTaskMethodDependencies(
119
+ deps: A2AHandlerDeps,
120
+ cancels: TaskCancellationRegistry,
121
+ ): TaskMethodDependencies {
122
+ const lifecycle = createTaskLifecycle(deps)
123
+ return {
124
+ taskStore: deps.taskStore,
125
+ payment: lifecycle.payment,
126
+ cancels,
127
+ authorizeTaskAccess: (c, req, task) => authorizeTaskAccess(c, req, task, deps),
128
+ recoverTask: (task, requestedAgentSlug) =>
129
+ recoverTaskIfNeeded(task, deps, requestedAgentSlug),
130
+ deliverPush: (task) => maybeDeliverPush(task, deps),
79
131
  }
132
+ }
80
133
 
81
- cancel(taskId: string): boolean {
82
- const c = this.controllers.get(taskId)
83
- if (!c) return false
84
- c.abort()
85
- this.controllers.delete(taskId)
86
- return true
134
+ function buildPushConfigMethodDependencies(
135
+ deps: A2AHandlerDeps,
136
+ ): PushConfigMethodDependencies {
137
+ return {
138
+ taskStore: deps.taskStore,
139
+ pushStore: deps.pushStore,
140
+ demoMode: deps.config.x402.demoMode === true,
141
+ urlValidator: deps.config.a2a?.pushUrlValidator,
142
+ authorizeTaskAccess: (c, req, task) => authorizeTaskAccess(c, req, task, deps),
87
143
  }
88
144
  }
89
145
 
90
146
  export function createA2AHandlers(deps: A2AHandlerDeps) {
91
- const cancels = new CancelRegistry()
147
+ const runtimeDeps: A2AHandlerDeps = {
148
+ ...deps,
149
+ taskStore: normalizeTaskStore(deps.taskStore, deps.config.x402.demoMode === true),
150
+ }
151
+ const cancels = new TaskCancellationRegistry()
92
152
 
93
153
  // GET /:slug/.well-known/agent.json
94
154
  const handleAgentCard = async (c: Context): Promise<Response> => {
95
155
  const slug = c.req.param('slug')
96
156
  if (!slug) return c.json({ error: 'slug required' }, 400)
97
157
  const agent = await deps.config.resolveAgent(slug)
98
- if (!agent) {
158
+ if (!agent || !agent.enabled) {
99
159
  return c.json({ error: 'Agent not found or not published' }, 404)
100
160
  }
101
161
  const url = new URL(c.req.url)
@@ -112,14 +172,20 @@ export function createA2AHandlers(deps: A2AHandlerDeps) {
112
172
 
113
173
  // Body size limit (DoS prevention) — mirrors the OpenAI-compat handler.
114
174
  const contentLength = Number.parseInt(c.req.header('Content-Length') ?? '0', 10)
115
- if (contentLength > 65536) {
175
+ if (contentLength > MAX_A2A_BODY_BYTES) {
116
176
  return c.json(fail(null, A2A_ERROR_CODES.INVALID_REQUEST, 'request body too large (max 64KB)'), 413)
117
177
  }
118
178
 
119
179
  let raw: unknown
120
180
  try {
121
- raw = await c.req.json()
122
- } catch {
181
+ raw = await readJsonBody(c.req.raw)
182
+ } catch (error) {
183
+ if (error instanceof RequestBodyTooLargeError) {
184
+ return c.json(
185
+ fail(null, A2A_ERROR_CODES.INVALID_REQUEST, 'request body too large (max 64KB)'),
186
+ 413,
187
+ )
188
+ }
123
189
  return c.json(fail(null, A2A_ERROR_CODES.PARSE_ERROR, 'invalid JSON'), 400)
124
190
  }
125
191
  const parsed = parseEnvelope(raw)
@@ -129,23 +195,23 @@ export function createA2AHandlers(deps: A2AHandlerDeps) {
129
195
 
130
196
  switch (parsed.method) {
131
197
  case 'message/send':
132
- return handleMessageSend(c, slug, parsed, deps)
198
+ return handleMessageSend(c, slug, parsed, runtimeDeps, cancels)
133
199
  case 'message/stream':
134
- return handleMessageStream(c, slug, parsed, deps, cancels)
200
+ return handleMessageStream(c, slug, parsed, runtimeDeps, cancels)
135
201
  case 'tasks/get':
136
- return handleTasksGet(c, parsed, deps)
202
+ return handleTasksGet(c, parsed, buildTaskMethodDependencies(runtimeDeps, cancels))
137
203
  case 'tasks/cancel':
138
- return handleTasksCancel(c, parsed, deps, cancels)
204
+ return handleTasksCancel(c, parsed, buildTaskMethodDependencies(runtimeDeps, cancels))
139
205
  case 'tasks/resubscribe':
140
- return handleTasksResubscribe(c, parsed, deps)
206
+ return handleTasksResubscribe(c, parsed, buildTaskMethodDependencies(runtimeDeps, cancels))
141
207
  case 'tasks/pushNotificationConfig/set':
142
- return handlePushSet(c, parsed, deps)
208
+ return handlePushSet(c, parsed, buildPushConfigMethodDependencies(runtimeDeps))
143
209
  case 'tasks/pushNotificationConfig/get':
144
- return handlePushGet(c, parsed, deps)
210
+ return handlePushGet(c, parsed, buildPushConfigMethodDependencies(runtimeDeps))
145
211
  case 'tasks/pushNotificationConfig/list':
146
- return handlePushList(c, parsed, deps)
212
+ return handlePushList(c, parsed, buildPushConfigMethodDependencies(runtimeDeps))
147
213
  case 'tasks/pushNotificationConfig/delete':
148
- return handlePushDelete(c, parsed, deps)
214
+ return handlePushDelete(c, parsed, buildPushConfigMethodDependencies(runtimeDeps))
149
215
  default:
150
216
  return c.json(
151
217
  fail(parsed.id, A2A_ERROR_CODES.METHOD_NOT_FOUND, `unknown method '${parsed.method}'`),
@@ -163,77 +229,28 @@ async function handleMessageSend(
163
229
  slug: string,
164
230
  req: JSONRPCRequest,
165
231
  deps: A2AHandlerDeps,
232
+ cancels: TaskCancellationRegistry,
166
233
  ): Promise<Response> {
167
234
  const guard = await guardMessageRequest(c, slug, req, deps)
168
235
  if (guard instanceof Response) return guard
169
236
  const { authz, task } = guard
170
-
171
- let responseText = ''
172
- let outputTokens = 0
173
- let inputRequiredPrompt: string | undefined
174
- let inputRequiredSeen = false
237
+ setPaymentResponseHeaders(c, authz)
238
+ const controller = cancels.register(task.id)
239
+ const detachRequestAbort = bindRequestAbort(c.req.raw.signal, controller)
240
+ const lifecycle = createTaskLifecycle(deps)
175
241
  try {
176
- for await (const event of dispatchSandboxStreamRich(
177
- authz.agent,
178
- authz.userMessage,
179
- authz.consumerId,
180
- deps.config,
181
- undefined,
182
- task.id,
183
- )) {
184
- if (event.kind === 'text') {
185
- responseText += event.delta
186
- outputTokens += estimateTokens(event.delta)
187
- } else {
188
- inputRequiredSeen = true
189
- inputRequiredPrompt = event.prompt
190
- }
191
- }
192
- } catch (err) {
193
- const failed = withStatus(task, 'failed')
194
- await deps.taskStore.put(failed)
195
- await maybeDeliverPush(failed, deps)
196
- return c.json(
197
- fail(
198
- req.id,
199
- A2A_ERROR_CODES.INTERNAL_ERROR,
200
- err instanceof Error ? err.message : String(err),
201
- ),
202
- )
203
- }
204
-
205
- // Settle for the work done so far before short-circuiting on input-required.
206
- // The user has been charged for the partial response, which is the right
207
- // commercial behavior — the sandbox produced tokens.
208
- await settleAndRecord(
209
- authz.agent,
210
- authz,
211
- estimateTokens(authz.userMessage),
212
- outputTokens,
213
- deps.config,
214
- deps.state.obs,
215
- )
216
-
217
- if (inputRequiredSeen) {
218
- const paused = withStatus(
242
+ return await executeMessageSend(
243
+ c,
244
+ req,
245
+ { taskStore: deps.taskStore, config: deps.config, ...lifecycle },
246
+ authz,
219
247
  task,
220
- 'input-required',
221
- inputRequiredPrompt ? agentMessage(task, inputRequiredPrompt) : undefined,
222
- responseText
223
- ? [responseTextToArtifact(responseText, `${task.id}-artifact-0`)]
224
- : task.artifacts,
248
+ controller.signal,
225
249
  )
226
- await deps.taskStore.put(paused)
227
- // input-required is non-terminal — do NOT deliver push notifications.
228
- return c.json(ok(req.id, paused))
250
+ } finally {
251
+ detachRequestAbort()
252
+ cancels.clear(task.id)
229
253
  }
230
-
231
- const completed = withStatus(task, 'completed', undefined, [
232
- responseTextToArtifact(responseText, `${task.id}-artifact-0`),
233
- ])
234
- await deps.taskStore.put(completed)
235
- await maybeDeliverPush(completed, deps)
236
- return c.json(ok(req.id, completed))
237
254
  }
238
255
 
239
256
  // ── message/stream (SSE) ──────────────────────────────────────────────────
@@ -243,386 +260,38 @@ async function handleMessageStream(
243
260
  slug: string,
244
261
  req: JSONRPCRequest,
245
262
  deps: A2AHandlerDeps,
246
- cancels: CancelRegistry,
263
+ cancels: TaskCancellationRegistry,
247
264
  ): Promise<Response> {
248
265
  const guard = await guardMessageRequest(c, slug, req, deps)
249
266
  if (guard instanceof Response) return guard
250
267
  const { authz, task } = guard
251
-
252
- const controller = cancels.register(task.id)
253
- const inputTokens = estimateTokens(authz.userMessage)
254
- let outputTokens = 0
255
- let responseText = ''
256
-
257
- const stream = new ReadableStream({
258
- async start(ctrl) {
259
- const encoder = new TextEncoder()
260
- const send = (event: StreamingEvent) => {
261
- ctrl.enqueue(encoder.encode(`data: ${JSON.stringify(ok(req.id, event))}\n\n`))
262
- }
263
-
264
- // Status: working
265
- const workingStatus: TaskStatusUpdateEvent = {
266
- kind: 'status-update',
267
- taskId: task.id,
268
- contextId: task.contextId,
269
- status: { state: 'working', timestamp: nowIso() },
270
- final: false,
271
- }
272
- await deps.taskStore.put({ ...task, status: workingStatus.status })
273
- send(workingStatus)
274
-
275
- let inputRequiredPrompt: string | undefined
276
- let inputRequiredSeen = false
277
- try {
278
- for await (const event of dispatchSandboxStreamRich(
279
- authz.agent,
280
- authz.userMessage,
281
- authz.consumerId,
282
- deps.config,
283
- controller.signal,
284
- task.id,
285
- )) {
286
- if (event.kind === 'text') {
287
- responseText += event.delta
288
- outputTokens += estimateTokens(event.delta)
289
- const artifactEvent: TaskArtifactUpdateEvent = {
290
- kind: 'artifact-update',
291
- taskId: task.id,
292
- contextId: task.contextId,
293
- artifact: {
294
- artifactId: `${task.id}-artifact-0`,
295
- name: 'response',
296
- parts: [{ kind: 'text', text: event.delta }],
297
- },
298
- append: true,
299
- }
300
- send(artifactEvent)
301
- } else {
302
- inputRequiredSeen = true
303
- inputRequiredPrompt = event.prompt
304
- }
305
- }
306
-
307
- // Caller aborted via tasks/cancel — emit canceled, do not settle.
308
- if (controller.signal.aborted) {
309
- const canceled = withStatus(task, 'canceled', undefined, [
310
- responseTextToArtifact(responseText, `${task.id}-artifact-0`),
311
- ])
312
- await deps.taskStore.put(canceled)
313
- send({
314
- kind: 'status-update',
315
- taskId: task.id,
316
- contextId: task.contextId,
317
- status: canceled.status,
318
- final: true,
319
- })
320
- await maybeDeliverPush(canceled, deps)
321
- return
322
- }
323
-
324
- // Settle once for whatever the sandbox produced (full or partial).
325
- await settleAndRecord(
326
- authz.agent,
327
- authz,
328
- inputTokens,
329
- outputTokens,
330
- deps.config,
331
- deps.state.obs,
332
- )
333
-
334
- if (inputRequiredSeen) {
335
- const paused = withStatus(
336
- task,
337
- 'input-required',
338
- inputRequiredPrompt ? agentMessage(task, inputRequiredPrompt) : undefined,
339
- responseText
340
- ? [responseTextToArtifact(responseText, `${task.id}-artifact-0`)]
341
- : task.artifacts,
342
- )
343
- await deps.taskStore.put(paused)
344
- send({
345
- kind: 'status-update',
346
- taskId: task.id,
347
- contextId: task.contextId,
348
- status: paused.status,
349
- final: true,
350
- })
351
- // input-required is non-terminal — do NOT deliver push notifications.
352
- return
353
- }
354
-
355
- // Final: artifact lastChunk + completed status.
356
- send({
357
- kind: 'artifact-update',
358
- taskId: task.id,
359
- contextId: task.contextId,
360
- artifact: {
361
- artifactId: `${task.id}-artifact-0`,
362
- name: 'response',
363
- parts: [{ kind: 'text', text: '' }],
364
- },
365
- append: true,
366
- lastChunk: true,
367
- })
368
- const completed = withStatus(task, 'completed', undefined, [
369
- responseTextToArtifact(responseText, `${task.id}-artifact-0`),
370
- ])
371
- await deps.taskStore.put(completed)
372
- send({
373
- kind: 'status-update',
374
- taskId: task.id,
375
- contextId: task.contextId,
376
- status: completed.status,
377
- final: true,
378
- })
379
- await maybeDeliverPush(completed, deps)
380
- } catch (err) {
381
- const failed = withStatus(task, 'failed')
382
- await deps.taskStore.put(failed)
383
- send({
384
- kind: 'status-update',
385
- taskId: task.id,
386
- contextId: task.contextId,
387
- status: failed.status,
388
- final: true,
389
- })
390
- await maybeDeliverPush(failed, deps)
268
+ setPaymentResponseHeaders(c, authz)
269
+ return executeMessageStream(
270
+ c,
271
+ req,
272
+ {
273
+ taskStore: deps.taskStore,
274
+ config: deps.config,
275
+ lifecycle: createTaskLifecycle(deps),
276
+ cancels,
277
+ deliverPush: (streamTask) => maybeDeliverPush(streamTask, deps),
278
+ reportStreamError: async (streamAuthz, error) => {
391
279
  await deps.state.obs?.onStreamError?.(
392
280
  {
393
- requestId: authz.requestId,
394
- agentSlug: authz.agent.slug,
395
- startMs: authz.startMs,
281
+ requestId: streamAuthz.requestId,
282
+ agentSlug: streamAuthz.agent.slug,
283
+ startMs: streamAuthz.startMs,
396
284
  },
397
285
  {
398
- consumerId: authz.consumerId,
399
- errorMessage: err instanceof Error ? err.message : String(err),
286
+ consumerId: streamAuthz.consumerId,
287
+ errorMessage: error instanceof Error ? error.message : String(error),
400
288
  },
401
289
  )
402
- } finally {
403
- cancels.clear(task.id)
404
- ctrl.close()
405
- }
406
- },
407
- })
408
-
409
- return new Response(stream, {
410
- headers: {
411
- 'Content-Type': 'text/event-stream',
412
- 'Cache-Control': 'no-cache',
413
- 'X-Request-Id': authz.requestId,
414
- 'X-Agent-Slug': authz.agent.slug,
415
- 'X-Task-Id': task.id,
416
- },
417
- })
418
- }
419
-
420
- // ── tasks/get + tasks/cancel ──────────────────────────────────────────────
421
-
422
- async function handleTasksGet(
423
- c: Context,
424
- req: JSONRPCRequest,
425
- deps: A2AHandlerDeps,
426
- ): Promise<Response> {
427
- const params = req.params as TaskIdParams | undefined
428
- if (!params || typeof params.id !== 'string') {
429
- return c.json(fail(req.id, A2A_ERROR_CODES.INVALID_PARAMS, 'params.id required'))
430
- }
431
- const task = await deps.taskStore.get(params.id)
432
- if (!task) {
433
- return c.json(
434
- fail(req.id, A2A_ERROR_CODES.TASK_NOT_FOUND, `task '${params.id}' not found`),
435
- )
436
- }
437
- return c.json(ok(req.id, task))
438
- }
439
-
440
- async function handleTasksCancel(
441
- c: Context,
442
- req: JSONRPCRequest,
443
- deps: A2AHandlerDeps,
444
- cancels: CancelRegistry,
445
- ): Promise<Response> {
446
- const params = req.params as TaskIdParams | undefined
447
- if (!params || typeof params.id !== 'string') {
448
- return c.json(fail(req.id, A2A_ERROR_CODES.INVALID_PARAMS, 'params.id required'))
449
- }
450
- const task = await deps.taskStore.get(params.id)
451
- if (!task) {
452
- return c.json(
453
- fail(req.id, A2A_ERROR_CODES.TASK_NOT_FOUND, `task '${params.id}' not found`),
454
- )
455
- }
456
- if (isTerminal(task.status.state)) {
457
- return c.json(
458
- fail(
459
- req.id,
460
- A2A_ERROR_CODES.TASK_NOT_CANCELABLE,
461
- `task '${params.id}' is in terminal state '${task.status.state}'`,
462
- ),
463
- )
464
- }
465
-
466
- const stillActive = cancels.cancel(task.id)
467
- const canceled: Task = {
468
- ...task,
469
- status: { state: 'canceled', timestamp: nowIso() },
470
- }
471
- await deps.taskStore.put(canceled)
472
-
473
- // If a stream was active, it'll observe the abort and emit its own final
474
- // status-update AND fire its own push delivery; the dispatcher only fires
475
- // push when the cancel races to terminal state with no active streamer.
476
- if (!stillActive) {
477
- await maybeDeliverPush(canceled, deps)
478
- }
479
- return c.json(ok(req.id, canceled))
480
- }
481
-
482
- // ── tasks/resubscribe ─────────────────────────────────────────────────────
483
-
484
- /**
485
- * Re-attach to a known task via SSE. The minimum-viable shape (and the one
486
- * the spec actually requires): emit the task's current status as one
487
- * status-update event with the right `final` flag, then close. Callers that
488
- * lost their original stream connection can re-subscribe to find out where
489
- * the task ended up; in-flight tasks return their last-known state and the
490
- * client polls (or re-subscribes) for further updates.
491
- *
492
- * Out of scope: live-rebroadcasting deltas from an in-flight stream to a new
493
- * subscriber. That requires per-task pub/sub which we haven't needed yet —
494
- * the typical recovery path is "task already finished, fetch the result."
495
- */
496
- async function handleTasksResubscribe(
497
- c: Context,
498
- req: JSONRPCRequest,
499
- deps: A2AHandlerDeps,
500
- ): Promise<Response> {
501
- const params = req.params as TaskIdParams | undefined
502
- if (!params || typeof params.id !== 'string') {
503
- return c.json(fail(req.id, A2A_ERROR_CODES.INVALID_PARAMS, 'params.id required'))
504
- }
505
- const task = await deps.taskStore.get(params.id)
506
- if (!task) {
507
- return c.json(
508
- fail(req.id, A2A_ERROR_CODES.TASK_NOT_FOUND, `task '${params.id}' not found`),
509
- )
510
- }
511
- const final = isTerminal(task.status.state) || task.status.state === 'input-required'
512
- const event: TaskStatusUpdateEvent = {
513
- kind: 'status-update',
514
- taskId: task.id,
515
- contextId: task.contextId,
516
- status: task.status,
517
- final,
518
- }
519
- const encoder = new TextEncoder()
520
- const stream = new ReadableStream({
521
- start(ctrl) {
522
- ctrl.enqueue(encoder.encode(`data: ${JSON.stringify(ok(req.id, event))}\n\n`))
523
- ctrl.close()
524
- },
525
- })
526
- return new Response(stream, {
527
- headers: {
528
- 'Content-Type': 'text/event-stream',
529
- 'Cache-Control': 'no-cache',
530
- 'X-Task-Id': task.id,
290
+ },
531
291
  },
532
- })
533
- }
534
-
535
- // ── tasks/pushNotificationConfig/* ────────────────────────────────────────
536
-
537
- async function handlePushSet(
538
- c: Context,
539
- req: JSONRPCRequest,
540
- deps: A2AHandlerDeps,
541
- ): Promise<Response> {
542
- if (!deps.pushStore) {
543
- return c.json(fail(req.id, A2A_ERROR_CODES.PUSH_NOT_SUPPORTED, 'push notifications not configured'))
544
- }
545
- const params = req.params as TaskPushNotificationConfig | undefined
546
- if (!params || typeof params.taskId !== 'string' || !params.pushNotificationConfig?.id) {
547
- return c.json(
548
- fail(
549
- req.id,
550
- A2A_ERROR_CODES.INVALID_PARAMS,
551
- 'params.taskId and params.pushNotificationConfig.id required',
552
- ),
553
- )
554
- }
555
- if (typeof params.pushNotificationConfig.url !== 'string') {
556
- return c.json(fail(req.id, A2A_ERROR_CODES.INVALID_PARAMS, 'pushNotificationConfig.url required'))
557
- }
558
- const task = await deps.taskStore.get(params.taskId)
559
- if (!task) {
560
- return c.json(fail(req.id, A2A_ERROR_CODES.TASK_NOT_FOUND, `task '${params.taskId}' not found`))
561
- }
562
- await deps.pushStore.set(params.taskId, params.pushNotificationConfig)
563
- const stored = await deps.pushStore.get(params.taskId, params.pushNotificationConfig.id)
564
- return c.json(ok(req.id, { taskId: params.taskId, pushNotificationConfig: stored }))
565
- }
566
-
567
- async function handlePushGet(
568
- c: Context,
569
- req: JSONRPCRequest,
570
- deps: A2AHandlerDeps,
571
- ): Promise<Response> {
572
- if (!deps.pushStore) {
573
- return c.json(fail(req.id, A2A_ERROR_CODES.PUSH_NOT_SUPPORTED, 'push notifications not configured'))
574
- }
575
- const params = req.params as TaskPushNotificationConfigGetParams | undefined
576
- if (!params || typeof params.id !== 'string' || typeof params.pushNotificationConfigId !== 'string') {
577
- return c.json(
578
- fail(req.id, A2A_ERROR_CODES.INVALID_PARAMS, 'params.id and params.pushNotificationConfigId required'),
579
- )
580
- }
581
- const cfg = await deps.pushStore.get(params.id, params.pushNotificationConfigId)
582
- if (!cfg) {
583
- return c.json(
584
- fail(
585
- req.id,
586
- A2A_ERROR_CODES.TASK_NOT_FOUND,
587
- `push config '${params.pushNotificationConfigId}' not found for task '${params.id}'`,
588
- ),
589
- )
590
- }
591
- return c.json(ok(req.id, { taskId: params.id, pushNotificationConfig: cfg }))
592
- }
593
-
594
- async function handlePushList(
595
- c: Context,
596
- req: JSONRPCRequest,
597
- deps: A2AHandlerDeps,
598
- ): Promise<Response> {
599
- if (!deps.pushStore) {
600
- return c.json(fail(req.id, A2A_ERROR_CODES.PUSH_NOT_SUPPORTED, 'push notifications not configured'))
601
- }
602
- const params = req.params as TaskIdParams | undefined
603
- if (!params || typeof params.id !== 'string') {
604
- return c.json(fail(req.id, A2A_ERROR_CODES.INVALID_PARAMS, 'params.id required'))
605
- }
606
- const configs = await deps.pushStore.list(params.id)
607
- return c.json(ok(req.id, configs.map((cfg) => ({ taskId: params.id, pushNotificationConfig: cfg }))))
608
- }
609
-
610
- async function handlePushDelete(
611
- c: Context,
612
- req: JSONRPCRequest,
613
- deps: A2AHandlerDeps,
614
- ): Promise<Response> {
615
- if (!deps.pushStore) {
616
- return c.json(fail(req.id, A2A_ERROR_CODES.PUSH_NOT_SUPPORTED, 'push notifications not configured'))
617
- }
618
- const params = req.params as TaskPushNotificationConfigGetParams | undefined
619
- if (!params || typeof params.id !== 'string' || typeof params.pushNotificationConfigId !== 'string') {
620
- return c.json(
621
- fail(req.id, A2A_ERROR_CODES.INVALID_PARAMS, 'params.id and params.pushNotificationConfigId required'),
622
- )
623
- }
624
- await deps.pushStore.delete(params.id, params.pushNotificationConfigId)
625
- return c.json(ok(req.id, null))
292
+ authz,
293
+ task,
294
+ )
626
295
  }
627
296
 
628
297
  // ── Shared message-send setup (auth + task allocation) ────────────────────
@@ -647,23 +316,47 @@ async function guardMessageRequest(
647
316
  return c.json(fail(req.id, extracted.error.code, extracted.error.message))
648
317
  }
649
318
 
319
+ let billingMessages: ChatMessage[] = [{ role: 'user', content: extracted.text }]
320
+ if (typeof params.message.taskId === 'string') {
321
+ const storedForQuote = await deps.taskStore.get(params.message.taskId)
322
+ if (storedForQuote) {
323
+ const accessError = await authorizeTaskAccess(c, req, storedForQuote, deps)
324
+ if (accessError) return accessError
325
+ const quotedTask = await recoverTaskIfNeeded(storedForQuote, deps, slug)
326
+ if (quotedTask.status.state === 'input-required') {
327
+ billingMessages = [
328
+ ...taskHistoryAsChatMessages(quotedTask),
329
+ { role: 'user', content: extracted.text },
330
+ ]
331
+ }
332
+ }
333
+ }
334
+
650
335
  const guard = await authenticateAndGuard(
651
336
  c,
652
337
  slug,
653
- [{ role: 'user', content: extracted.text }],
338
+ billingMessages,
654
339
  deps.config,
655
340
  deps.state,
656
341
  )
657
342
  if (guard instanceof Response) return guard
658
343
  const authz = guard
344
+ // The provider session receives only the new turn. The quote above covers
345
+ // every retained message and the configured hidden provider context.
346
+ authz.userMessage = extracted.text
659
347
 
660
348
  // Multi-turn continuation: if the caller addressed an existing task that is
661
- // currently in `input-required`, append the new message and transition to
662
- // `working`. Any other taskId (unknown OR pointing at a terminal/working
349
+ // currently in `input-required`, append the new message and reserve it as
350
+ // `submitted`. The handler transitions it to `working` only after payment
351
+ // succeeds, so cancellation during payment cannot start sandbox work.
352
+ // Any other taskId (unknown OR pointing at a terminal/working
663
353
  // task) means the caller is starting a fresh task and we mint a new id.
664
354
  if (typeof params.message.taskId === 'string') {
665
- const existing = await deps.taskStore.get(params.message.taskId)
666
- if (existing) {
355
+ const storedExisting = await deps.taskStore.get(params.message.taskId)
356
+ if (storedExisting) {
357
+ const accessError = await authorizeTaskAccess(c, req, storedExisting, deps)
358
+ if (accessError) return accessError
359
+ const existing = await recoverTaskIfNeeded(storedExisting, deps, slug)
667
360
  if (existing.status.state !== 'input-required') {
668
361
  return c.json(
669
362
  fail(
@@ -680,11 +373,18 @@ async function guardMessageRequest(
680
373
  }
681
374
  const continued: Task = {
682
375
  ...existing,
683
- status: { state: 'working', timestamp: nowIso() },
376
+ status: { state: 'submitted', timestamp: nowIso() },
684
377
  history: [...(existing.history ?? []), appendedMessage],
378
+ metadata: withTaskSubmission(existing.metadata, authz),
685
379
  }
686
- await deps.taskStore.put(continued)
687
- return { authz, task: continued }
380
+ if (!await compareAndSetTask(deps.taskStore, existing, continued)) {
381
+ return c.json(
382
+ fail(req.id, A2A_ERROR_CODES.INVALID_PARAMS, `task '${existing.id}' changed before continuation`),
383
+ )
384
+ }
385
+ const claimedTask = await claimTaskPayment(c, req, continued, authz, deps, existing)
386
+ if (claimedTask instanceof Response) return claimedTask
387
+ return { authz, task: claimedTask }
688
388
  }
689
389
  // Unknown taskId in params: fall through and mint a fresh task with that
690
390
  // exact id so callers that pre-allocate ids (idempotency) get them.
@@ -703,62 +403,316 @@ async function guardMessageRequest(
703
403
  contextId,
704
404
  status: { state: 'submitted', timestamp: nowIso() },
705
405
  history: [initialMessage],
406
+ metadata: withTaskSubmission(withTaskOrigin(undefined, authz.agent), authz),
407
+ }
408
+ if (!await createTask(deps.taskStore, task)) {
409
+ return c.json(
410
+ fail(req.id, A2A_ERROR_CODES.INVALID_PARAMS, `task '${task.id}' already exists`),
411
+ )
412
+ }
413
+ const claimedTask = await claimTaskPayment(c, req, task, authz, deps)
414
+ if (claimedTask instanceof Response) return claimedTask
415
+ return { authz, task: claimedTask }
416
+ }
417
+
418
+ async function claimTaskPayment(
419
+ c: Context,
420
+ req: JSONRPCRequest,
421
+ task: Task,
422
+ authz: AuthorizedRequest,
423
+ deps: A2AHandlerDeps,
424
+ paymentFailureTask?: Task,
425
+ ): Promise<Response | Task> {
426
+ const lifecycle = createTaskLifecycle(deps)
427
+ let paymentTask = task
428
+ try {
429
+ await claimPayment(authz, deps.config, deps.state, {
430
+ onRecoveryPrepared: async (recoveryId) => {
431
+ paymentTask = await attachPaymentRecoveryMarker(
432
+ deps.taskStore,
433
+ paymentTask,
434
+ recoveryId,
435
+ )
436
+ },
437
+ })
438
+ } catch {
439
+ let recoveryTask = paymentTask
440
+ try {
441
+ recoveryTask = await retainPaymentRecoveryMarker(
442
+ deps.taskStore,
443
+ paymentTask,
444
+ authz.paymentRecoveryId,
445
+ )
446
+ } catch (error) {
447
+ console.error(
448
+ `[a2a] failed to attach payment recovery to ${task.id}:`,
449
+ error instanceof Error ? error.message : String(error),
450
+ )
451
+ }
452
+ const releasedTask = await releaseTaskPayment(
453
+ authz,
454
+ recoveryTask,
455
+ lifecycle.payment,
456
+ 'payment authorization failed',
457
+ false,
458
+ )
459
+ const cleanedReleasedTask = clearTaskSubmission(releasedTask)
460
+ const hasReleaseRecord = hasPaymentReleaseRecovery(cleanedReleasedTask)
461
+ const failed = hasReleaseRecord
462
+ ? isTerminal(cleanedReleasedTask.status.state)
463
+ ? cleanedReleasedTask
464
+ : withStatus(cleanedReleasedTask, 'failed')
465
+ : paymentFailureTask
466
+ ? preservePaymentRecoveryMarker(paymentFailureTask, cleanedReleasedTask)
467
+ : isTerminal(cleanedReleasedTask.status.state)
468
+ ? cleanedReleasedTask
469
+ : withStatus(cleanedReleasedTask, 'failed')
470
+ try {
471
+ if (await compareAndSetTask(deps.taskStore, releasedTask, failed) && isTerminal(failed.status.state)) {
472
+ await maybeDeliverPush(failed, deps)
473
+ }
474
+ } catch (taskError) {
475
+ console.error(
476
+ `[a2a] failed to persist payment-failed task ${task.id}:`,
477
+ taskError instanceof Error ? taskError.message : String(taskError),
478
+ )
479
+ }
480
+ return c.json(fail(req.id, A2A_ERROR_CODES.INTERNAL_ERROR, 'Payment authorization failed'))
481
+ }
482
+
483
+ const current = await deps.taskStore.get(task.id)
484
+ if (current && JSON.stringify(current) === JSON.stringify(paymentTask)) {
485
+ return paymentTask
486
+ }
487
+ {
488
+ const changedTask = current ?? paymentTask
489
+ let recoveryTask = changedTask
490
+ try {
491
+ recoveryTask = await retainPaymentRecoveryMarker(
492
+ deps.taskStore,
493
+ changedTask,
494
+ authz.paymentRecoveryId,
495
+ )
496
+ } catch (error) {
497
+ console.error(
498
+ `[a2a] failed to retain payment recovery after task race for ${task.id}:`,
499
+ error instanceof Error ? error.message : String(error),
500
+ )
501
+ }
502
+ const released = await releaseTaskPayment(
503
+ authz,
504
+ recoveryTask,
505
+ lifecycle.payment,
506
+ 'A2A task changed during payment confirmation',
507
+ false,
508
+ )
509
+ if (released.status.state === 'canceled') return c.json(ok(req.id, released))
510
+ return c.json(
511
+ fail(req.id, A2A_ERROR_CODES.INVALID_PARAMS, `task '${task.id}' changed during payment confirmation`),
512
+ )
706
513
  }
707
- await deps.taskStore.put(task)
708
- return { authz, task }
709
514
  }
710
515
 
711
516
  // ── Helpers ───────────────────────────────────────────────────────────────
712
517
 
713
- function isTerminal(state: Task['status']['state']): boolean {
714
- return (
715
- state === 'completed' ||
716
- state === 'canceled' ||
717
- state === 'failed' ||
718
- state === 'rejected'
719
- )
518
+ const EXECUTION_RECOVERY_METADATA_KEY = 'gatewayExecutionRecovery'
519
+
520
+ function setPaymentResponseHeaders(c: Context, authz: AuthorizedRequest): void {
521
+ if (authz.mppChargeOperation) {
522
+ c.header('Payment-Receipt', authz.mppChargeOperation.receipt)
523
+ }
524
+ if (authz.paymentRecoveryId) {
525
+ c.header('X-Payment-Operation-Id', authz.paymentRecoveryId)
526
+ }
720
527
  }
721
528
 
722
- function nowIso(): string {
723
- return new Date().toISOString()
529
+ async function authorizeTaskAccess(
530
+ c: Context,
531
+ req: JSONRPCRequest,
532
+ task: Task,
533
+ deps: A2AHandlerDeps,
534
+ ): Promise<Response | undefined> {
535
+ const requestedAgentSlug = c.req.param('slug') ?? ''
536
+ const origin = readTaskOrigin(task)
537
+ if (origin) {
538
+ if (origin.agentSlug !== requestedAgentSlug) {
539
+ return c.json(fail(req.id, A2A_ERROR_CODES.TASK_ACCESS_DENIED, 'task belongs to a different agent'), 403)
540
+ }
541
+ const requestedAgent = await deps.config.resolveAgent(requestedAgentSlug)
542
+ if (!requestedAgent || requestedAgent.id !== origin.agentId) {
543
+ return c.json(fail(req.id, A2A_ERROR_CODES.TASK_ACCESS_DENIED, 'task belongs to a different agent'), 403)
544
+ }
545
+ } else if (!deps.config.x402.demoMode) {
546
+ return c.json(fail(req.id, A2A_ERROR_CODES.TASK_ACCESS_DENIED, 'task origin is not recorded'), 403)
547
+ }
548
+ const authorize = deps.config.a2a?.authorizeTaskAccess
549
+ if (!authorize && deps.config.x402.demoMode) return undefined
550
+ if (!authorize) {
551
+ return c.json(
552
+ fail(req.id, A2A_ERROR_CODES.TASK_ACCESS_DENIED, 'task access authorization is not configured'),
553
+ 403,
554
+ )
555
+ }
556
+ let allowed = false
557
+ try {
558
+ allowed = await authorize(task, {
559
+ method: req.method,
560
+ agentSlug: requestedAgentSlug,
561
+ authorization: c.req.header('Authorization') ?? '',
562
+ paymentSignature: c.req.header('X-Payment-Signature') ?? '',
563
+ })
564
+ } catch (error) {
565
+ console.error(
566
+ `[a2a] task access authorization failed for ${task.id}:`,
567
+ error instanceof Error ? error.message : String(error),
568
+ )
569
+ }
570
+ if (allowed) return undefined
571
+ return c.json(fail(req.id, A2A_ERROR_CODES.TASK_ACCESS_DENIED, 'task access denied'), 403)
724
572
  }
725
573
 
726
- function cryptoRandomId(): string {
727
- return crypto.randomUUID().replace(/-/g, '')
574
+ function taskHistoryAsChatMessages(task: Task): ChatMessage[] {
575
+ return (task.history ?? []).flatMap((message) => {
576
+ const extracted = extractTextFromMessage(message)
577
+ if ('error' in extracted) return []
578
+ return [{
579
+ role: message.role === 'agent' ? 'assistant' : 'user',
580
+ content: extracted.text,
581
+ }]
582
+ })
728
583
  }
729
584
 
730
- /**
731
- * Build a new Task with an updated status (and optional artifacts). Centralises
732
- * the timestamp + status structure so terminal transitions are written
733
- * identically across every code path.
734
- */
735
- function withStatus(
736
- task: Task,
737
- state: Task['status']['state'],
738
- message?: Message,
739
- artifacts?: Task['artifacts'],
740
- ): Task {
585
+ async function createTask(taskStore: TaskStore, task: Task): Promise<boolean> {
586
+ if (!taskStore.createIfAbsent) {
587
+ throw new Error('A2A task store does not provide createIfAbsent')
588
+ }
589
+ return taskStore.createIfAbsent(task)
590
+ }
591
+ function normalizeTaskStore(taskStore: TaskStore, allowUnsafeFallback: boolean): TaskStore {
592
+ const hasCreateIfAbsent = typeof taskStore.createIfAbsent === 'function'
593
+ const hasCompareAndSet = typeof taskStore.compareAndSet === 'function'
594
+ const hasCompareAndSetExecution = typeof taskStore.compareAndSetExecution === 'function'
595
+ if (hasCreateIfAbsent && hasCompareAndSet && hasCompareAndSetExecution) {
596
+ return taskStore
597
+ }
598
+ if (!allowUnsafeFallback) {
599
+ throw new Error(
600
+ 'A2A production task store must implement createIfAbsent, compareAndSet, and compareAndSetExecution',
601
+ )
602
+ }
741
603
  return {
742
- ...task,
743
- status: { state, timestamp: nowIso(), ...(message ? { message } : {}) },
744
- ...(artifacts !== undefined ? { artifacts } : {}),
604
+ get: (id) => taskStore.get(id),
605
+ put: (task) => taskStore.put(task),
606
+ delete: (id) => taskStore.delete(id),
607
+ async createIfAbsent(task) {
608
+ if (hasCreateIfAbsent) return taskStore.createIfAbsent!(task)
609
+ if (await taskStore.get(task.id)) return false
610
+ await taskStore.put(task)
611
+ return true
612
+ },
613
+ async compareAndSet(expected, next) {
614
+ if (hasCompareAndSet) return taskStore.compareAndSet!(expected, next)
615
+ const current = await taskStore.get(expected.id)
616
+ if (!current || JSON.stringify(current) !== JSON.stringify(expected)) return false
617
+ await taskStore.put(next)
618
+ return true
619
+ },
620
+ async compareAndSetExecution(expected, next, requestId, now) {
621
+ if (hasCompareAndSetExecution) {
622
+ return taskStore.compareAndSetExecution!(expected, next, requestId, now)
623
+ }
624
+ const current = await taskStore.get(expected.id)
625
+ const expectedMarker = inspectTaskExecution(current ?? expected)
626
+ const nextMarker = inspectTaskExecution(next)
627
+ if (
628
+ !current ||
629
+ JSON.stringify(current) !== JSON.stringify(expected) ||
630
+ expectedMarker.state !== 'valid' ||
631
+ nextMarker.state !== 'valid' ||
632
+ expectedMarker.marker.requestId !== requestId ||
633
+ nextMarker.marker.requestId !== requestId ||
634
+ expectedMarker.marker.lease.expiresAt <= now
635
+ ) return false
636
+ await taskStore.put(next)
637
+ return true
638
+ },
745
639
  }
746
640
  }
747
641
 
748
- /**
749
- * Synthesize an agent-role message attached to a status (e.g. the
750
- * input-required prompt text). messageId is deterministic-by-task so callers
751
- * can dedupe on retry.
752
- */
753
- function agentMessage(task: Task, text: string): Message {
754
- return {
755
- kind: 'message',
756
- role: 'agent',
757
- parts: [{ kind: 'text', text }],
758
- messageId: `${task.id}-status-${task.status.state}-${nowIso()}`,
759
- taskId: task.id,
760
- contextId: task.contextId,
642
+ async function recoverTaskIfNeeded(
643
+ task: Task,
644
+ deps: A2AHandlerDeps,
645
+ requestedAgentSlug: string,
646
+ ): Promise<Task> {
647
+ const lifecycle = createTaskLifecycle(deps)
648
+ const paymentReleased = await recoverPaymentReleaseIfNeeded(task, lifecycle.payment)
649
+ const finalized = await recoverFinalizationIfNeeded(
650
+ paymentReleased,
651
+ lifecycle.finalization,
652
+ requestedAgentSlug,
653
+ )
654
+ const paymentRecovered = await recoverPaymentMarkerIfNeeded(finalized, lifecycle.payment)
655
+ const submissionRecovered = await recoverSubmissionIfNeeded(paymentRecovered, {
656
+ taskStore: deps.taskStore,
657
+ deliverPush: lifecycle.finalization.deliverPush,
658
+ })
659
+ return recoverExpiredExecutionIfNeeded(submissionRecovered, deps)
660
+ }
661
+
662
+ async function recoverExpiredExecutionIfNeeded(task: Task, deps: A2AHandlerDeps): Promise<Task> {
663
+ const malformed = hasMalformedTaskExecution(task)
664
+ if (
665
+ task.status.state !== 'working' ||
666
+ (isTaskFinalizing(task) && !malformed) ||
667
+ (!malformed && !hasExpiredTaskExecution(task))
668
+ ) return task
669
+ const inspection = inspectTaskExecution(task)
670
+ const failed: Task = {
671
+ ...withStatus(task, 'failed'),
672
+ metadata: {
673
+ ...(clearTaskExecution(task).metadata ?? {}),
674
+ [EXECUTION_RECOVERY_METADATA_KEY]: {
675
+ error: inspection.state === 'malformed'
676
+ ? `A2A execution marker was malformed: ${inspection.reason}`
677
+ : 'A2A execution lease expired before a task result was stored',
678
+ },
679
+ },
761
680
  }
681
+ if (await compareAndSetTask(deps.taskStore, task, failed)) {
682
+ await maybeDeliverPush(failed, deps)
683
+ return failed
684
+ }
685
+ return await deps.taskStore.get(task.id) ?? task
686
+ }
687
+
688
+ async function readJsonBody(request: Request): Promise<unknown> {
689
+ if (!request.body) return await request.json()
690
+ const reader = request.body.getReader()
691
+ const chunks: Uint8Array[] = []
692
+ let total = 0
693
+ try {
694
+ while (true) {
695
+ const { done, value } = await reader.read()
696
+ if (done) break
697
+ if (value) {
698
+ total += value.byteLength
699
+ if (total > MAX_A2A_BODY_BYTES) {
700
+ await reader.cancel().catch(() => undefined)
701
+ throw new RequestBodyTooLargeError('request body too large')
702
+ }
703
+ chunks.push(value)
704
+ }
705
+ }
706
+ } finally {
707
+ reader.releaseLock()
708
+ }
709
+ const body = new Uint8Array(total)
710
+ let offset = 0
711
+ for (const chunk of chunks) {
712
+ body.set(chunk, offset)
713
+ offset += chunk.byteLength
714
+ }
715
+ return JSON.parse(new TextDecoder().decode(body)) as unknown
762
716
  }
763
717
 
764
718
  /**
@@ -768,31 +722,22 @@ function agentMessage(task: Task, text: string): Message {
768
722
  * via `tasks/get` to confirm state.
769
723
  */
770
724
  async function maybeDeliverPush(task: Task, deps: A2AHandlerDeps): Promise<void> {
771
- if (!deps.pushStore || !TERMINAL_STATES.has(task.status.state)) return
772
- try {
773
- await deliverPushNotifications({
774
- task,
775
- store: deps.pushStore,
776
- webhookSecret: deps.config.a2a?.webhookSecret,
777
- fetcher: deps.config.a2a?.pushFetcher,
778
- onDelivery: (result) => {
779
- if (!result.ok) {
780
- deps.state.obs?.onStreamError?.(
781
- { requestId: result.taskId, agentSlug: task.id, startMs: Date.now() },
782
- {
783
- consumerId: result.configId,
784
- errorMessage: `push delivery failed (${result.status ?? 'no-status'}): ${result.error ?? 'non-2xx'}`,
785
- },
786
- )
787
- }
788
- },
789
- })
790
- } catch (err) {
791
- // Catastrophic failure of the push pipeline itself (e.g. the store threw).
792
- // Logged but never escalated — a busted webhook MUST NOT fail the agent.
793
- console.error(
794
- `[agent-gateway] push delivery threw for task ${task.id}: ${err instanceof Error ? err.message : String(err)}`,
795
- )
725
+ const pushDeps: PushDeliveryDependencies = {
726
+ taskStore: deps.taskStore,
727
+ pushStore: deps.pushStore,
728
+ demoMode: deps.config.x402.demoMode === true,
729
+ webhookSecret: deps.config.a2a?.webhookSecret,
730
+ fetcher: deps.config.a2a?.pushFetcher,
731
+ urlValidator: deps.config.a2a?.pushUrlValidator,
732
+ onDeliveryFailure: (failedTask, result) => {
733
+ void deps.state.obs?.onStreamError?.(
734
+ { requestId: result.taskId, agentSlug: failedTask.id, startMs: Date.now() },
735
+ {
736
+ consumerId: result.configId,
737
+ errorMessage: `push delivery failed (${result.status ?? 'no-status'}): ${result.error ?? 'non-2xx'}`,
738
+ },
739
+ )
740
+ },
796
741
  }
742
+ await deliverTaskPush(task, pushDeps)
797
743
  }
798
-