@tangle-network/agent-gateway 0.5.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.
- package/README.md +4 -0
- package/dist/chunk-3IKQWFKX.js +1703 -0
- package/dist/chunk-3IKQWFKX.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +108 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts +2 -2
- package/dist/middleware.js +1 -1
- package/dist/types-CX2V06cN.d.ts +862 -0
- package/dist/types.d.ts +1 -1
- package/package.json +14 -10
- package/src/a2a/agent-card.ts +54 -0
- package/src/a2a/handler.ts +798 -0
- package/src/a2a/jsonrpc.ts +65 -0
- package/src/a2a/push-notifications.ts +299 -0
- package/src/a2a/task-store-sql.ts +189 -0
- package/src/a2a/task-store.ts +53 -0
- package/src/a2a/translate.ts +77 -0
- package/src/a2a/types.ts +217 -0
- package/src/dispatch.ts +465 -0
- package/src/index.ts +51 -0
- package/src/middleware.ts +134 -289
- package/src/types.ts +95 -0
- package/dist/chunk-373QHRKV.js +0 -635
- package/dist/chunk-373QHRKV.js.map +0 -1
- package/dist/types-BbTSfNhx.d.ts +0 -328
|
@@ -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
|
+
|