@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.
- package/README.md +108 -6
- package/dist/chunk-GITV7CPT.js +84 -0
- package/dist/chunk-GITV7CPT.js.map +1 -0
- package/dist/chunk-J5SDVHOL.js +104 -0
- package/dist/chunk-J5SDVHOL.js.map +1 -0
- package/dist/chunk-MP6IIAIA.js +5651 -0
- package/dist/chunk-MP6IIAIA.js.map +1 -0
- package/dist/index.d.ts +76 -12
- package/dist/index.js +307 -21
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts +7 -2
- package/dist/middleware.js +3 -2
- package/dist/nonce-store.d.ts +47 -11
- package/dist/nonce-store.js +9 -3
- package/dist/observer-types-A0RtA8uL.d.ts +95 -0
- package/dist/observer.d.ts +79 -0
- package/dist/observer.js +11 -0
- package/dist/observer.js.map +1 -0
- package/dist/{types-CX2V06cN.d.ts → types-BHISsm7D.d.ts} +423 -166
- package/dist/types.d.ts +2 -1
- package/package.json +1 -1
- package/src/a2a/agent-card.ts +4 -3
- package/src/a2a/execution-fence.ts +162 -0
- package/src/a2a/handler.ts +507 -562
- package/src/a2a/message-send-execution.ts +241 -0
- package/src/a2a/message-stream-execution.ts +392 -0
- package/src/a2a/payment-recovery.ts +431 -0
- package/src/a2a/push-config-methods.ts +158 -0
- package/src/a2a/push-notifications.ts +172 -22
- package/src/a2a/task-cancellation.ts +50 -0
- package/src/a2a/task-finalization.ts +451 -0
- package/src/a2a/task-lifecycle.ts +54 -0
- package/src/a2a/task-methods.ts +163 -0
- package/src/a2a/task-push-delivery.ts +119 -0
- package/src/a2a/task-recovery.ts +11 -0
- package/src/a2a/task-state.ts +99 -0
- package/src/a2a/task-store-sql.ts +222 -24
- package/src/a2a/task-store.ts +58 -1
- package/src/a2a/task-submission-recovery.ts +178 -0
- package/src/a2a/types.ts +1 -0
- package/src/dispatch-authorization.ts +437 -0
- package/src/dispatch-payment-recovery.ts +248 -0
- package/src/dispatch-payment.ts +425 -0
- package/src/dispatch-pricing.ts +108 -0
- package/src/dispatch-sandbox.ts +422 -0
- package/src/dispatch-settlement.ts +139 -0
- package/src/dispatch-types.ts +81 -0
- package/src/dispatch.ts +35 -462
- package/src/index.ts +64 -2
- package/src/middleware.ts +313 -32
- package/src/mpp-payment.ts +117 -0
- package/src/nonce-store.ts +122 -20
- package/src/observer-types.ts +63 -0
- package/src/observer.ts +3 -63
- package/src/payment-operations.ts +485 -0
- package/src/payment-recovery-sql.ts +108 -0
- package/src/payment-recovery-worker.ts +488 -0
- package/src/payment-recovery.ts +331 -0
- package/src/payment-types.ts +48 -0
- package/src/types.ts +153 -42
- package/src/verify.ts +265 -36
- package/dist/chunk-3IKQWFKX.js +0 -1703
- package/dist/chunk-3IKQWFKX.js.map +0 -1
- package/dist/chunk-M7ZJAK4K.js +0 -53
- package/dist/chunk-M7ZJAK4K.js.map +0 -1
package/src/a2a/handler.ts
CHANGED
|
@@ -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
|
-
|
|
21
|
-
estimateTokens,
|
|
22
|
-
settleAndRecord,
|
|
19
|
+
claimPayment,
|
|
23
20
|
} from '../dispatch'
|
|
24
|
-
import type {
|
|
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
|
|
33
|
-
import { extractTextFromMessage
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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 >
|
|
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.
|
|
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,
|
|
198
|
+
return handleMessageSend(c, slug, parsed, runtimeDeps, cancels)
|
|
133
199
|
case 'message/stream':
|
|
134
|
-
return handleMessageStream(c, slug, parsed,
|
|
200
|
+
return handleMessageStream(c, slug, parsed, runtimeDeps, cancels)
|
|
135
201
|
case 'tasks/get':
|
|
136
|
-
return handleTasksGet(c, parsed,
|
|
202
|
+
return handleTasksGet(c, parsed, buildTaskMethodDependencies(runtimeDeps, cancels))
|
|
137
203
|
case 'tasks/cancel':
|
|
138
|
-
return handleTasksCancel(c, parsed,
|
|
204
|
+
return handleTasksCancel(c, parsed, buildTaskMethodDependencies(runtimeDeps, cancels))
|
|
139
205
|
case 'tasks/resubscribe':
|
|
140
|
-
return handleTasksResubscribe(c, parsed,
|
|
206
|
+
return handleTasksResubscribe(c, parsed, buildTaskMethodDependencies(runtimeDeps, cancels))
|
|
141
207
|
case 'tasks/pushNotificationConfig/set':
|
|
142
|
-
return handlePushSet(c, parsed,
|
|
208
|
+
return handlePushSet(c, parsed, buildPushConfigMethodDependencies(runtimeDeps))
|
|
143
209
|
case 'tasks/pushNotificationConfig/get':
|
|
144
|
-
return handlePushGet(c, parsed,
|
|
210
|
+
return handlePushGet(c, parsed, buildPushConfigMethodDependencies(runtimeDeps))
|
|
145
211
|
case 'tasks/pushNotificationConfig/list':
|
|
146
|
-
return handlePushList(c, parsed,
|
|
212
|
+
return handlePushList(c, parsed, buildPushConfigMethodDependencies(runtimeDeps))
|
|
147
213
|
case 'tasks/pushNotificationConfig/delete':
|
|
148
|
-
return handlePushDelete(c, parsed,
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
221
|
-
inputRequiredPrompt ? agentMessage(task, inputRequiredPrompt) : undefined,
|
|
222
|
-
responseText
|
|
223
|
-
? [responseTextToArtifact(responseText, `${task.id}-artifact-0`)]
|
|
224
|
-
: task.artifacts,
|
|
248
|
+
controller.signal,
|
|
225
249
|
)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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:
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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:
|
|
394
|
-
agentSlug:
|
|
395
|
-
startMs:
|
|
281
|
+
requestId: streamAuthz.requestId,
|
|
282
|
+
agentSlug: streamAuthz.agent.slug,
|
|
283
|
+
startMs: streamAuthz.startMs,
|
|
396
284
|
},
|
|
397
285
|
{
|
|
398
|
-
consumerId:
|
|
399
|
-
errorMessage:
|
|
286
|
+
consumerId: streamAuthz.consumerId,
|
|
287
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
400
288
|
},
|
|
401
289
|
)
|
|
402
|
-
}
|
|
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
|
-
|
|
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
|
|
662
|
-
// `
|
|
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
|
|
666
|
-
if (
|
|
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: '
|
|
376
|
+
status: { state: 'submitted', timestamp: nowIso() },
|
|
684
377
|
history: [...(existing.history ?? []), appendedMessage],
|
|
378
|
+
metadata: withTaskSubmission(existing.metadata, authz),
|
|
685
379
|
}
|
|
686
|
-
await deps.taskStore
|
|
687
|
-
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
|
723
|
-
|
|
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
|
|
727
|
-
return
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
)
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|