@tangle-network/agent-gateway 0.6.0 → 0.7.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.
@@ -0,0 +1,798 @@
1
+ /**
2
+ * A2A JSON-RPC method dispatcher. Both `message/send` and `message/stream`
3
+ * route through the shared `authenticateAndGuard` + `dispatchSandboxStream`
4
+ * + `settleAndRecord` pipeline so every protocol surface gets the same
5
+ * payment, rate-limit, injection, and authorization guarantees.
6
+ *
7
+ * State the dispatcher owns:
8
+ * - the task store (durable record of every task we accepted)
9
+ * - an in-process map of active AbortControllers so `tasks/cancel` can
10
+ * interrupt a still-running `dispatchSandboxStream`
11
+ */
12
+
13
+ import type { Context } from 'hono'
14
+
15
+ import {
16
+ type A2ADispatchEvent,
17
+ type AuthorizedRequest,
18
+ type GatewayState,
19
+ authenticateAndGuard,
20
+ dispatchSandboxStreamRich,
21
+ estimateTokens,
22
+ settleAndRecord,
23
+ } from '../dispatch'
24
+ import type { GatewayConfig } from '../types'
25
+ import { buildAgentCard } from './agent-card'
26
+ import { fail, ok, parseEnvelope } from './jsonrpc'
27
+ import {
28
+ deliverPushNotifications,
29
+ type PushNotificationStore,
30
+ type TaskPushNotificationConfig,
31
+ } from './push-notifications'
32
+ import type { TaskStore } from './task-store'
33
+ import { extractTextFromMessage, responseTextToArtifact } from './translate'
34
+ import {
35
+ A2A_ERROR_CODES,
36
+ type JSONRPCRequest,
37
+ type Message,
38
+ type MessageSendParams,
39
+ type StreamingEvent,
40
+ type Task,
41
+ type TaskArtifactUpdateEvent,
42
+ type TaskIdParams,
43
+ type TaskPushNotificationConfigGetParams,
44
+ type TaskStatusUpdateEvent,
45
+ } from './types'
46
+
47
+ export interface A2AHandlerDeps {
48
+ config: GatewayConfig
49
+ state: GatewayState
50
+ taskStore: TaskStore
51
+ pushStore?: PushNotificationStore
52
+ }
53
+
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
+ ])
61
+
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>()
70
+
71
+ register(taskId: string): AbortController {
72
+ const c = new AbortController()
73
+ this.controllers.set(taskId, c)
74
+ return c
75
+ }
76
+
77
+ clear(taskId: string): void {
78
+ this.controllers.delete(taskId)
79
+ }
80
+
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
87
+ }
88
+ }
89
+
90
+ export function createA2AHandlers(deps: A2AHandlerDeps) {
91
+ const cancels = new CancelRegistry()
92
+
93
+ // GET /:slug/.well-known/agent.json
94
+ const handleAgentCard = async (c: Context): Promise<Response> => {
95
+ const slug = c.req.param('slug')
96
+ if (!slug) return c.json({ error: 'slug required' }, 400)
97
+ const agent = await deps.config.resolveAgent(slug)
98
+ if (!agent) {
99
+ return c.json({ error: 'Agent not found or not published' }, 404)
100
+ }
101
+ const url = new URL(c.req.url)
102
+ const agentUrl = `${url.origin}${url.pathname.replace(/\/\.well-known\/agent\.json$/, '')}`
103
+ return c.json(buildAgentCard(agent, deps.config, agentUrl))
104
+ }
105
+
106
+ // POST /:slug — JSON-RPC dispatcher
107
+ const handleJsonRpc = async (c: Context): Promise<Response> => {
108
+ const slug = c.req.param('slug')
109
+ if (!slug) {
110
+ return c.json(fail(null, A2A_ERROR_CODES.INVALID_REQUEST, 'slug required'), 400)
111
+ }
112
+
113
+ // Body size limit (DoS prevention) — mirrors the OpenAI-compat handler.
114
+ const contentLength = Number.parseInt(c.req.header('Content-Length') ?? '0', 10)
115
+ if (contentLength > 65536) {
116
+ return c.json(fail(null, A2A_ERROR_CODES.INVALID_REQUEST, 'request body too large (max 64KB)'), 413)
117
+ }
118
+
119
+ let raw: unknown
120
+ try {
121
+ raw = await c.req.json()
122
+ } catch {
123
+ return c.json(fail(null, A2A_ERROR_CODES.PARSE_ERROR, 'invalid JSON'), 400)
124
+ }
125
+ const parsed = parseEnvelope(raw)
126
+ if ('code' in parsed) {
127
+ return c.json(fail(parsed.id, parsed.code, parsed.message), 400)
128
+ }
129
+
130
+ switch (parsed.method) {
131
+ case 'message/send':
132
+ return handleMessageSend(c, slug, parsed, deps)
133
+ case 'message/stream':
134
+ return handleMessageStream(c, slug, parsed, deps, cancels)
135
+ case 'tasks/get':
136
+ return handleTasksGet(c, parsed, deps)
137
+ case 'tasks/cancel':
138
+ return handleTasksCancel(c, parsed, deps, cancels)
139
+ case 'tasks/resubscribe':
140
+ return handleTasksResubscribe(c, parsed, deps)
141
+ case 'tasks/pushNotificationConfig/set':
142
+ return handlePushSet(c, parsed, deps)
143
+ case 'tasks/pushNotificationConfig/get':
144
+ return handlePushGet(c, parsed, deps)
145
+ case 'tasks/pushNotificationConfig/list':
146
+ return handlePushList(c, parsed, deps)
147
+ case 'tasks/pushNotificationConfig/delete':
148
+ return handlePushDelete(c, parsed, deps)
149
+ default:
150
+ return c.json(
151
+ fail(parsed.id, A2A_ERROR_CODES.METHOD_NOT_FOUND, `unknown method '${parsed.method}'`),
152
+ )
153
+ }
154
+ }
155
+
156
+ return { handleAgentCard, handleJsonRpc }
157
+ }
158
+
159
+ // ── message/send (synchronous) ────────────────────────────────────────────
160
+
161
+ async function handleMessageSend(
162
+ c: Context,
163
+ slug: string,
164
+ req: JSONRPCRequest,
165
+ deps: A2AHandlerDeps,
166
+ ): Promise<Response> {
167
+ const guard = await guardMessageRequest(c, slug, req, deps)
168
+ if (guard instanceof Response) return guard
169
+ const { authz, task } = guard
170
+
171
+ let responseText = ''
172
+ let outputTokens = 0
173
+ let inputRequiredPrompt: string | undefined
174
+ let inputRequiredSeen = false
175
+ 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(
219
+ task,
220
+ 'input-required',
221
+ inputRequiredPrompt ? agentMessage(task, inputRequiredPrompt) : undefined,
222
+ responseText
223
+ ? [responseTextToArtifact(responseText, `${task.id}-artifact-0`)]
224
+ : task.artifacts,
225
+ )
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))
229
+ }
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
+ }
238
+
239
+ // ── message/stream (SSE) ──────────────────────────────────────────────────
240
+
241
+ async function handleMessageStream(
242
+ c: Context,
243
+ slug: string,
244
+ req: JSONRPCRequest,
245
+ deps: A2AHandlerDeps,
246
+ cancels: CancelRegistry,
247
+ ): Promise<Response> {
248
+ const guard = await guardMessageRequest(c, slug, req, deps)
249
+ if (guard instanceof Response) return guard
250
+ 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)
391
+ await deps.state.obs?.onStreamError?.(
392
+ {
393
+ requestId: authz.requestId,
394
+ agentSlug: authz.agent.slug,
395
+ startMs: authz.startMs,
396
+ },
397
+ {
398
+ consumerId: authz.consumerId,
399
+ errorMessage: err instanceof Error ? err.message : String(err),
400
+ },
401
+ )
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,
531
+ },
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))
626
+ }
627
+
628
+ // ── Shared message-send setup (auth + task allocation) ────────────────────
629
+
630
+ interface GuardSuccess {
631
+ authz: AuthorizedRequest
632
+ task: Task
633
+ }
634
+
635
+ async function guardMessageRequest(
636
+ c: Context,
637
+ slug: string,
638
+ req: JSONRPCRequest,
639
+ deps: A2AHandlerDeps,
640
+ ): Promise<GuardSuccess | Response> {
641
+ const params = req.params as MessageSendParams | undefined
642
+ if (!params || !params.message) {
643
+ return c.json(fail(req.id, A2A_ERROR_CODES.INVALID_PARAMS, 'params.message required'))
644
+ }
645
+ const extracted = extractTextFromMessage(params.message)
646
+ if ('error' in extracted) {
647
+ return c.json(fail(req.id, extracted.error.code, extracted.error.message))
648
+ }
649
+
650
+ const guard = await authenticateAndGuard(
651
+ c,
652
+ slug,
653
+ [{ role: 'user', content: extracted.text }],
654
+ deps.config,
655
+ deps.state,
656
+ )
657
+ if (guard instanceof Response) return guard
658
+ const authz = guard
659
+
660
+ // 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
663
+ // task) means the caller is starting a fresh task and we mint a new id.
664
+ if (typeof params.message.taskId === 'string') {
665
+ const existing = await deps.taskStore.get(params.message.taskId)
666
+ if (existing) {
667
+ if (existing.status.state !== 'input-required') {
668
+ return c.json(
669
+ fail(
670
+ req.id,
671
+ A2A_ERROR_CODES.INVALID_PARAMS,
672
+ `task '${existing.id}' is in state '${existing.status.state}'; only 'input-required' tasks accept follow-up messages`,
673
+ ),
674
+ )
675
+ }
676
+ const appendedMessage: Message = {
677
+ ...params.message,
678
+ taskId: existing.id,
679
+ contextId: existing.contextId,
680
+ }
681
+ const continued: Task = {
682
+ ...existing,
683
+ status: { state: 'working', timestamp: nowIso() },
684
+ history: [...(existing.history ?? []), appendedMessage],
685
+ }
686
+ await deps.taskStore.put(continued)
687
+ return { authz, task: continued }
688
+ }
689
+ // Unknown taskId in params: fall through and mint a fresh task with that
690
+ // exact id so callers that pre-allocate ids (idempotency) get them.
691
+ }
692
+
693
+ const taskId = params.message.taskId ?? `task_${cryptoRandomId()}`
694
+ const contextId = params.message.contextId ?? `ctx_${cryptoRandomId()}`
695
+ const initialMessage = {
696
+ ...params.message,
697
+ taskId,
698
+ contextId,
699
+ }
700
+ const task: Task = {
701
+ kind: 'task',
702
+ id: taskId,
703
+ contextId,
704
+ status: { state: 'submitted', timestamp: nowIso() },
705
+ history: [initialMessage],
706
+ }
707
+ await deps.taskStore.put(task)
708
+ return { authz, task }
709
+ }
710
+
711
+ // ── Helpers ───────────────────────────────────────────────────────────────
712
+
713
+ function isTerminal(state: Task['status']['state']): boolean {
714
+ return (
715
+ state === 'completed' ||
716
+ state === 'canceled' ||
717
+ state === 'failed' ||
718
+ state === 'rejected'
719
+ )
720
+ }
721
+
722
+ function nowIso(): string {
723
+ return new Date().toISOString()
724
+ }
725
+
726
+ function cryptoRandomId(): string {
727
+ return crypto.randomUUID().replace(/-/g, '')
728
+ }
729
+
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 {
741
+ return {
742
+ ...task,
743
+ status: { state, timestamp: nowIso(), ...(message ? { message } : {}) },
744
+ ...(artifacts !== undefined ? { artifacts } : {}),
745
+ }
746
+ }
747
+
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,
761
+ }
762
+ }
763
+
764
+ /**
765
+ * Fire-and-forget push delivery. Idempotent w.r.t. push: if the task hasn't
766
+ * reached a terminal state, this is a no-op. The dispatch logs failures via
767
+ * the observer rather than failing the request — webhook receivers re-fetch
768
+ * via `tasks/get` to confirm state.
769
+ */
770
+ 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
+ )
796
+ }
797
+ }
798
+