@tangle-network/agent-gateway 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +108 -6
  2. package/dist/chunk-GITV7CPT.js +84 -0
  3. package/dist/chunk-GITV7CPT.js.map +1 -0
  4. package/dist/chunk-J5SDVHOL.js +104 -0
  5. package/dist/chunk-J5SDVHOL.js.map +1 -0
  6. package/dist/chunk-MP6IIAIA.js +5651 -0
  7. package/dist/chunk-MP6IIAIA.js.map +1 -0
  8. package/dist/index.d.ts +76 -12
  9. package/dist/index.js +307 -21
  10. package/dist/index.js.map +1 -1
  11. package/dist/middleware.d.ts +7 -2
  12. package/dist/middleware.js +3 -2
  13. package/dist/nonce-store.d.ts +47 -11
  14. package/dist/nonce-store.js +9 -3
  15. package/dist/observer-types-A0RtA8uL.d.ts +95 -0
  16. package/dist/observer.d.ts +79 -0
  17. package/dist/observer.js +11 -0
  18. package/dist/observer.js.map +1 -0
  19. package/dist/{types-CX2V06cN.d.ts → types-BHISsm7D.d.ts} +423 -166
  20. package/dist/types.d.ts +2 -1
  21. package/package.json +1 -1
  22. package/src/a2a/agent-card.ts +4 -3
  23. package/src/a2a/execution-fence.ts +162 -0
  24. package/src/a2a/handler.ts +507 -562
  25. package/src/a2a/message-send-execution.ts +241 -0
  26. package/src/a2a/message-stream-execution.ts +392 -0
  27. package/src/a2a/payment-recovery.ts +431 -0
  28. package/src/a2a/push-config-methods.ts +158 -0
  29. package/src/a2a/push-notifications.ts +172 -22
  30. package/src/a2a/task-cancellation.ts +50 -0
  31. package/src/a2a/task-finalization.ts +451 -0
  32. package/src/a2a/task-lifecycle.ts +54 -0
  33. package/src/a2a/task-methods.ts +163 -0
  34. package/src/a2a/task-push-delivery.ts +119 -0
  35. package/src/a2a/task-recovery.ts +11 -0
  36. package/src/a2a/task-state.ts +99 -0
  37. package/src/a2a/task-store-sql.ts +222 -24
  38. package/src/a2a/task-store.ts +58 -1
  39. package/src/a2a/task-submission-recovery.ts +178 -0
  40. package/src/a2a/types.ts +1 -0
  41. package/src/dispatch-authorization.ts +437 -0
  42. package/src/dispatch-payment-recovery.ts +248 -0
  43. package/src/dispatch-payment.ts +425 -0
  44. package/src/dispatch-pricing.ts +108 -0
  45. package/src/dispatch-sandbox.ts +422 -0
  46. package/src/dispatch-settlement.ts +139 -0
  47. package/src/dispatch-types.ts +81 -0
  48. package/src/dispatch.ts +35 -462
  49. package/src/index.ts +64 -2
  50. package/src/middleware.ts +313 -32
  51. package/src/mpp-payment.ts +117 -0
  52. package/src/nonce-store.ts +122 -20
  53. package/src/observer-types.ts +63 -0
  54. package/src/observer.ts +3 -63
  55. package/src/payment-operations.ts +485 -0
  56. package/src/payment-recovery-sql.ts +108 -0
  57. package/src/payment-recovery-worker.ts +488 -0
  58. package/src/payment-recovery.ts +331 -0
  59. package/src/payment-types.ts +48 -0
  60. package/src/types.ts +153 -42
  61. package/src/verify.ts +265 -36
  62. package/dist/chunk-3IKQWFKX.js +0 -1703
  63. package/dist/chunk-3IKQWFKX.js.map +0 -1
  64. package/dist/chunk-M7ZJAK4K.js +0 -53
  65. package/dist/chunk-M7ZJAK4K.js.map +0 -1
@@ -0,0 +1,488 @@
1
+ import {
2
+ type AuthorizedRequest,
3
+ releasePayment,
4
+ settleAndRecord,
5
+ } from './dispatch'
6
+ import { assertMppChargeOperation } from './mpp-payment'
7
+ import {
8
+ deserializePaymentOperation,
9
+ PaymentRecoveryFenceError,
10
+ recoveryTiming,
11
+ updateOwnedPaymentRecovery,
12
+ type PaymentRecoveryRecord,
13
+ } from './payment-recovery'
14
+ import type { AgentMeta, GatewayConfig, SandboxUsageReceipt } from './types'
15
+
16
+ interface RecoveryWorkerOptions {
17
+ now?: number
18
+ /** Fresh wall-clock source for each row's lease and retry timestamps. */
19
+ clock?: () => number
20
+ workerId?: string
21
+ }
22
+
23
+ export interface RecoverPaymentOptions extends RecoveryWorkerOptions {
24
+ /** Process one requested row even when its normal retry time is later. */
25
+ force?: boolean
26
+ /** Exact receipt recovered from a durable protocol record, such as an A2A task. */
27
+ usage?: SandboxUsageReceipt
28
+ }
29
+
30
+ /** Batch recovery never accepts receipt data because each receipt belongs to one row. */
31
+ export interface RecoverPaymentsOptions extends RecoveryWorkerOptions {
32
+ limit?: number
33
+ }
34
+
35
+ export interface PaymentRecoveryRun {
36
+ scanned: number
37
+ reconciled: number
38
+ deferred: number
39
+ failed: number
40
+ }
41
+
42
+ /** Scan and reconcile due payment rows. Safe to run concurrently on many workers. */
43
+ export async function recoverPayments(
44
+ config: GatewayConfig,
45
+ options: RecoverPaymentsOptions = {},
46
+ ): Promise<PaymentRecoveryRun> {
47
+ const recovery = config.paymentRecovery
48
+ if (!recovery) throw new Error('durable payment recovery is not configured')
49
+ const scanNow = options.now ?? recoveryNow(options)
50
+ const limit = options.limit ?? 100
51
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
52
+ throw new Error('payment recovery limit must be a positive safe integer')
53
+ }
54
+ const due = await recovery.store.listDue(scanNow, limit)
55
+ const run: PaymentRecoveryRun = { scanned: due.length, reconciled: 0, deferred: 0, failed: 0 }
56
+ for (const candidate of due) {
57
+ try {
58
+ const result = await recoverPayment(candidate.id, config, {
59
+ force: false,
60
+ ...(options.clock ? { clock: options.clock } : {}),
61
+ ...(options.workerId ? { workerId: options.workerId } : {}),
62
+ })
63
+ if (result?.state === 'reconciled') run.reconciled += 1
64
+ else run.deferred += 1
65
+ } catch {
66
+ run.failed += 1
67
+ }
68
+ }
69
+ return run
70
+ }
71
+
72
+ /** Reconcile one payment identity. Hosts can expose this through a private worker API. */
73
+ export async function recoverPayment(
74
+ recoveryId: string,
75
+ config: GatewayConfig,
76
+ options: RecoverPaymentOptions = {},
77
+ ): Promise<PaymentRecoveryRecord | undefined> {
78
+ const recovery = config.paymentRecovery
79
+ if (!recovery) throw new Error('durable payment recovery is not configured')
80
+ const scanNow = options.now ?? recoveryNow(options)
81
+ const current = await recovery.store.get(recoveryId)
82
+ if (!current || current.state === 'reconciled') return current
83
+ if (!options.force && current.nextAttemptAt > scanNow) return current
84
+ const now = options.now ?? recoveryNow(options)
85
+
86
+ const leased = await acquireLease(
87
+ current.id,
88
+ config,
89
+ options.workerId ? `${options.workerId}:${randomId()}` : randomId(),
90
+ now,
91
+ )
92
+ if (!leased) return recovery.store.get(recoveryId)
93
+ const fenceId = requireFence(leased)
94
+
95
+ try {
96
+ const ready = options.usage
97
+ ? await persistRecoveredUsage(leased, options.usage, config, now)
98
+ : leased
99
+ await reconcileLeased(ready, config, now)
100
+ } catch (error) {
101
+ const message = error instanceof Error ? error.message : String(error)
102
+ const failedAt = options.now ?? recoveryNow(options)
103
+ try {
104
+ await updateOwnedPaymentRecovery(recovery.store, recoveryId, fenceId, (record) => ({
105
+ ...record,
106
+ attempts: record.attempts + 1,
107
+ lastError: message,
108
+ lease: undefined,
109
+ nextAttemptAt: failedAt + recoveryTiming(recovery).retryDelayMs,
110
+ }), failedAt)
111
+ } catch (updateError) {
112
+ if (!(updateError instanceof PaymentRecoveryFenceError)) throw updateError
113
+ }
114
+ throw error
115
+ }
116
+ return recovery.store.get(recoveryId)
117
+ }
118
+
119
+ function recoveryNow(options: RecoveryWorkerOptions): number {
120
+ return options.clock?.() ?? Date.now()
121
+ }
122
+
123
+ async function reconcileLeased(
124
+ record: PaymentRecoveryRecord,
125
+ config: GatewayConfig,
126
+ now: number,
127
+ ): Promise<void> {
128
+ if (record.payment.kind === 'mpp-charge' && !record.payment.operation) {
129
+ record = await recoverUnknownMppCharge(record, config, now)
130
+ }
131
+ if (record.state === 'reconciled') return
132
+
133
+ if (record.state === 'claiming') {
134
+ if (record.payment.kind === 'x402') {
135
+ if (!config.x402.paymentOperations) {
136
+ throw new Error('x402 payment recovery is not configured')
137
+ }
138
+ const recovered = await config.x402.paymentOperations.reclaimPayment(record.payment.operationId)
139
+ if (recovered.state === 'not-found') {
140
+ if (recovered.operationId !== record.payment.operationId) {
141
+ throw new Error('x402 recovery operation id mismatch')
142
+ }
143
+ await completeRecord(record, config, now)
144
+ return
145
+ }
146
+ if (recovered.state !== 'released' && recovered.state !== 'reclaimed') {
147
+ throw new Error(`ambiguous x402 claim recovered in state ${recovered.state}`)
148
+ }
149
+ await completeRecord(record, config, now)
150
+ return
151
+ }
152
+ throw new Error('MPP charge confirmation remains unresolved')
153
+ }
154
+
155
+ if (record.state === 'claimed' && !record.workStarted) {
156
+ await releaseRecoveredPayment(record, config, 'request ended before sandbox execution', now)
157
+ return
158
+ }
159
+
160
+ if (record.state === 'releasing') {
161
+ await releaseRecoveredPayment(record, config, record.reason ?? 'payment recovery release', now)
162
+ return
163
+ }
164
+
165
+ if (
166
+ record.state === 'executing' ||
167
+ record.state === 'retained' ||
168
+ record.state === 'settling' ||
169
+ (record.state === 'claimed' && record.workStarted)
170
+ ) {
171
+ if (!record.usage && record.fallbackAt !== undefined && record.fallbackAt > now) {
172
+ await updateOwnedPaymentRecovery(
173
+ config.paymentRecovery!.store,
174
+ record.id,
175
+ requireFence(record),
176
+ (current) => ({
177
+ ...current,
178
+ lease: undefined,
179
+ nextAttemptAt: record.fallbackAt!,
180
+ }),
181
+ now,
182
+ )
183
+ return
184
+ }
185
+ await settleRecoveredPayment(record, config)
186
+ return
187
+ }
188
+
189
+ throw new Error(`payment recovery cannot reconcile state ${record.state}`)
190
+ }
191
+
192
+ async function recoverUnknownMppCharge(
193
+ record: PaymentRecoveryRecord,
194
+ config: GatewayConfig,
195
+ now: number,
196
+ ): Promise<PaymentRecoveryRecord> {
197
+ if (record.payment.kind !== 'mpp-charge') return record
198
+ const method = record.payment.method
199
+ const operationId = record.payment.operationId
200
+ const lifecycle = config.mpp?.charge
201
+ if (!lifecycle || lifecycle.protocolVersion !== 1) {
202
+ throw new Error('MPP charge recovery is not configured')
203
+ }
204
+ const result = await lifecycle.recoverPayment(operationId)
205
+ if (!('protocolVersion' in result)) {
206
+ if (result.operationId !== operationId) {
207
+ throw new Error('MPP recovery operation id mismatch')
208
+ }
209
+ if (result.state === 'not-found') {
210
+ await completeRecord(record, config, now)
211
+ return requireRecord(record.id, config)
212
+ }
213
+ throw new Error('MPP charge confirmation is still pending')
214
+ }
215
+ assertMppChargeOperation(
216
+ result,
217
+ {
218
+ operationId,
219
+ requestId: record.attribution.requestId,
220
+ method,
221
+ },
222
+ ['confirmed', 'releasing', 'released'],
223
+ false,
224
+ )
225
+ return updateOwnedPaymentRecovery(
226
+ config.paymentRecovery!.store,
227
+ record.id,
228
+ requireFence(record),
229
+ (current) => ({
230
+ ...current,
231
+ state: result.state === 'released'
232
+ ? 'reconciled'
233
+ : result.state === 'releasing'
234
+ ? 'releasing'
235
+ : current.workStarted
236
+ ? 'executing'
237
+ : 'claimed',
238
+ payment: { kind: 'mpp-charge', method, operationId, operation: result },
239
+ ...(result.state === 'released'
240
+ ? { reconciledAt: now, nextAttemptAt: Number.MAX_SAFE_INTEGER, lease: undefined }
241
+ : {}),
242
+ }),
243
+ now,
244
+ )
245
+ }
246
+
247
+ async function releaseRecoveredPayment(
248
+ record: PaymentRecoveryRecord,
249
+ config: GatewayConfig,
250
+ reason: string,
251
+ now: number,
252
+ ): Promise<void> {
253
+ const authz = authorizedRequest(record)
254
+ if (record.payment.kind === 'x402') {
255
+ if (!record.payment.operation) {
256
+ if (!config.x402.paymentOperations) throw new Error('x402 payment recovery is not configured')
257
+ const recovered = await config.x402.paymentOperations.reclaimPayment(record.payment.operationId)
258
+ if (recovered.state === 'not-found') {
259
+ if (recovered.operationId !== record.payment.operationId) {
260
+ throw new Error('x402 recovery operation id mismatch')
261
+ }
262
+ await completeRecord(record, config, now)
263
+ return
264
+ }
265
+ if (recovered.state !== 'released' && recovered.state !== 'reclaimed') {
266
+ throw new Error(`x402 release recovered in state ${recovered.state}`)
267
+ }
268
+ await completeRecord(record, config, now)
269
+ return
270
+ }
271
+ authz.paymentOperation = deserializePaymentOperation(record.payment.operation)
272
+ authz.paymentOperationAcquired = true
273
+ } else {
274
+ if (!record.payment.operation) throw new Error('MPP release operation is missing')
275
+ authz.mppChargeOperation = record.payment.operation
276
+ }
277
+ await releasePayment(authz, config, reason)
278
+ }
279
+
280
+ async function settleRecoveredPayment(
281
+ record: PaymentRecoveryRecord,
282
+ config: GatewayConfig,
283
+ ): Promise<void> {
284
+ const authz = authorizedRequest(record)
285
+ if (record.payment.kind === 'x402') {
286
+ if (!record.payment.operation) throw new Error('x402 settlement operation is missing')
287
+ authz.paymentOperation = deserializePaymentOperation(record.payment.operation)
288
+ authz.paymentOperationAcquired = true
289
+ } else {
290
+ if (!record.payment.operation) throw new Error('MPP settlement operation is missing')
291
+ authz.mppChargeOperation = record.payment.operation
292
+ }
293
+
294
+ const fallback = record.settlementBasis === 'quoted-ceiling' || !record.usage
295
+ const usage = record.usage ?? quotedCeilingUsage(record)
296
+ const settlementBasis = fallback
297
+ ? 'quoted-ceiling'
298
+ : record.settlementBasis ?? 'usage-receipt'
299
+ await settleAndRecord(
300
+ recoveryAgent(record),
301
+ authz,
302
+ usage,
303
+ config,
304
+ config.observer,
305
+ {
306
+ usageAlreadyRecorded: record.usageRecorded,
307
+ settlementBasis,
308
+ ...(settlementBasis === 'quoted-ceiling' && record.payment.kind === 'x402'
309
+ ? { paymentAmount: BigInt(record.attribution.requiredAmount) }
310
+ : {}),
311
+ },
312
+ )
313
+ }
314
+
315
+ function quotedCeilingUsage(record: PaymentRecoveryRecord): SandboxUsageReceipt {
316
+ const amount = BigInt(record.attribution.requiredAmount)
317
+ const providerCostUsd = baseUnitsToNumber(amount, record.attribution.currencyDecimals)
318
+ if (!Number.isFinite(providerCostUsd) || providerCostUsd < 0) {
319
+ throw new Error('quoted payment ceiling cannot be represented as USD')
320
+ }
321
+ return {
322
+ inputTokens: 0,
323
+ outputTokens: 0,
324
+ reasoningTokens: 0,
325
+ toolTokens: 0,
326
+ toolCallCount: 0,
327
+ providerCostUsd,
328
+ budgetEnforced: true,
329
+ }
330
+ }
331
+
332
+ function baseUnitsToNumber(amount: bigint, decimals: number): number {
333
+ const digits = amount.toString().padStart(decimals + 1, '0')
334
+ if (decimals === 0) return Number(digits)
335
+ const split = digits.length - decimals
336
+ return Number(`${digits.slice(0, split)}.${digits.slice(split)}`)
337
+ }
338
+
339
+ function authorizedRequest(record: PaymentRecoveryRecord): AuthorizedRequest {
340
+ const attribution = record.attribution
341
+ return {
342
+ agent: recoveryAgent(record),
343
+ consumerId: attribution.consumerId,
344
+ paymentMethod: attribution.paymentMethod,
345
+ keyInfo: null,
346
+ userMessage: '[payment recovery]',
347
+ rateLimitRemaining: undefined,
348
+ requestId: attribution.requestId,
349
+ startMs: attribution.startMs,
350
+ maxOutputTokens: attribution.maxOutputTokens,
351
+ executionBudget: attribution.executionBudget,
352
+ requiredPaymentAmount: BigInt(attribution.requiredAmount),
353
+ paymentPayload: null,
354
+ paymentRecoveryId: record.id,
355
+ paymentRecoveryFence: requireFence(record),
356
+ }
357
+ }
358
+
359
+ function recoveryAgent(record: PaymentRecoveryRecord): AgentMeta {
360
+ const attribution = record.attribution
361
+ return {
362
+ id: attribution.agentId,
363
+ ownerId: '[payment recovery]',
364
+ slug: attribution.agentSlug,
365
+ pricePerTokenUsd: attribution.pricePerTokenUsd,
366
+ platformFeePercent: attribution.platformFeePercent,
367
+ sandboxEndpoint: null,
368
+ remoteSandboxId: null,
369
+ remoteBearerToken: null,
370
+ enabled: true,
371
+ }
372
+ }
373
+
374
+ async function acquireLease(
375
+ id: string,
376
+ config: GatewayConfig,
377
+ workerId: string,
378
+ now: number,
379
+ ): Promise<PaymentRecoveryRecord | undefined> {
380
+ const recovery = config.paymentRecovery!
381
+ for (let attempt = 0; attempt < 16; attempt += 1) {
382
+ const current = await recovery.store.get(id)
383
+ if (!current || current.state === 'reconciled') return current
384
+ if (current.lease && current.lease.expiresAt > now) return undefined
385
+ const next: PaymentRecoveryRecord = {
386
+ ...current,
387
+ revision: current.revision + 1,
388
+ lease: { id: workerId, expiresAt: now + recoveryTiming(recovery).leaseMs },
389
+ updatedAt: now,
390
+ }
391
+ if (await recovery.store.compareAndSet(current, next)) return next
392
+ }
393
+ throw new Error(`payment recovery record ${id} changed too many times`)
394
+ }
395
+
396
+ async function completeRecord(
397
+ record: PaymentRecoveryRecord,
398
+ config: GatewayConfig,
399
+ now: number,
400
+ ): Promise<void> {
401
+ await updateOwnedPaymentRecovery(
402
+ config.paymentRecovery!.store,
403
+ record.id,
404
+ requireFence(record),
405
+ (current) => ({
406
+ ...current,
407
+ state: 'reconciled',
408
+ lease: undefined,
409
+ lastError: undefined,
410
+ nextAttemptAt: Number.MAX_SAFE_INTEGER,
411
+ reconciledAt: now,
412
+ }),
413
+ now,
414
+ )
415
+ }
416
+
417
+ async function requireRecord(id: string, config: GatewayConfig): Promise<PaymentRecoveryRecord> {
418
+ const record = await config.paymentRecovery!.store.get(id)
419
+ if (!record) throw new Error(`payment recovery record ${id} was not found`)
420
+ return record
421
+ }
422
+
423
+ function randomId(): string {
424
+ return globalThis.crypto.randomUUID()
425
+ }
426
+
427
+ function requireFence(record: PaymentRecoveryRecord): string {
428
+ if (!record.lease?.id) throw new Error(`payment recovery record ${record.id} has no owner fence`)
429
+ return record.lease.id
430
+ }
431
+
432
+ async function persistRecoveredUsage(
433
+ record: PaymentRecoveryRecord,
434
+ usage: SandboxUsageReceipt,
435
+ config: GatewayConfig,
436
+ now: number,
437
+ ): Promise<PaymentRecoveryRecord> {
438
+ assertRecoveryUsage(usage)
439
+ return updateOwnedPaymentRecovery(
440
+ config.paymentRecovery!.store,
441
+ record.id,
442
+ requireFence(record),
443
+ (current) => {
444
+ if (current.usage && !sameUsage(current.usage, usage)) {
445
+ throw new Error('payment recovery receipt does not match persisted usage')
446
+ }
447
+ return {
448
+ ...current,
449
+ state: 'settling',
450
+ workStarted: true,
451
+ usage,
452
+ settlementBasis: 'usage-receipt',
453
+ nextAttemptAt: now,
454
+ }
455
+ },
456
+ now,
457
+ )
458
+ }
459
+
460
+ function assertRecoveryUsage(usage: SandboxUsageReceipt): void {
461
+ for (const [name, value] of [
462
+ ['inputTokens', usage.inputTokens],
463
+ ['outputTokens', usage.outputTokens],
464
+ ['reasoningTokens', usage.reasoningTokens],
465
+ ['toolTokens', usage.toolTokens],
466
+ ['toolCallCount', usage.toolCallCount],
467
+ ] as const) {
468
+ if (!Number.isSafeInteger(value) || value < 0) {
469
+ throw new Error(`payment recovery ${name} must be a non-negative safe integer`)
470
+ }
471
+ }
472
+ if (!Number.isFinite(usage.providerCostUsd) || usage.providerCostUsd < 0) {
473
+ throw new Error('payment recovery providerCostUsd must be finite and non-negative')
474
+ }
475
+ if (usage.budgetEnforced !== true) {
476
+ throw new Error('payment recovery receipt must confirm budget enforcement')
477
+ }
478
+ }
479
+
480
+ function sameUsage(left: SandboxUsageReceipt, right: SandboxUsageReceipt): boolean {
481
+ return left.inputTokens === right.inputTokens &&
482
+ left.outputTokens === right.outputTokens &&
483
+ left.reasoningTokens === right.reasoningTokens &&
484
+ left.toolTokens === right.toolTokens &&
485
+ left.toolCallCount === right.toolCallCount &&
486
+ left.providerCostUsd === right.providerCostUsd &&
487
+ left.budgetEnforced === right.budgetEnforced
488
+ }