@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,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-RPC 2.0 envelope parsing + response builders. Keeps the wire
|
|
3
|
+
* format isolated from the method dispatcher so a fuzz harness can throw
|
|
4
|
+
* malformed bodies at `parseEnvelope` directly.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
A2A_ERROR_CODES,
|
|
9
|
+
type JSONRPCErrorResponse,
|
|
10
|
+
type JSONRPCRequest,
|
|
11
|
+
type JSONRPCSuccessResponse,
|
|
12
|
+
} from './types'
|
|
13
|
+
|
|
14
|
+
export interface EnvelopeError {
|
|
15
|
+
/** id is whatever the caller sent (or null when we can't recover it). */
|
|
16
|
+
id: string | number | null
|
|
17
|
+
code: number
|
|
18
|
+
message: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse an inbound body. Returns the request envelope on success OR an
|
|
23
|
+
* EnvelopeError that the caller renders as a JSONRPCErrorResponse. We never
|
|
24
|
+
* throw — JSON-RPC says any malformed body becomes a -32700/-32600 response.
|
|
25
|
+
*/
|
|
26
|
+
export function parseEnvelope(raw: unknown): JSONRPCRequest | EnvelopeError {
|
|
27
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
28
|
+
return {
|
|
29
|
+
id: null,
|
|
30
|
+
code: A2A_ERROR_CODES.INVALID_REQUEST,
|
|
31
|
+
message: 'request must be a JSON object',
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const req = raw as Record<string, unknown>
|
|
35
|
+
const id = req.id === undefined ? null : (req.id as string | number | null)
|
|
36
|
+
if (req.jsonrpc !== '2.0') {
|
|
37
|
+
return { id, code: A2A_ERROR_CODES.INVALID_REQUEST, message: 'jsonrpc field must be "2.0"' }
|
|
38
|
+
}
|
|
39
|
+
if (typeof req.method !== 'string' || req.method.length === 0) {
|
|
40
|
+
return { id, code: A2A_ERROR_CODES.INVALID_REQUEST, message: 'method field required' }
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
jsonrpc: '2.0',
|
|
44
|
+
id,
|
|
45
|
+
method: req.method,
|
|
46
|
+
params: req.params,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function ok<T>(id: string | number | null, result: T): JSONRPCSuccessResponse<T> {
|
|
51
|
+
return { jsonrpc: '2.0', id, result }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function fail(
|
|
55
|
+
id: string | number | null,
|
|
56
|
+
code: number,
|
|
57
|
+
message: string,
|
|
58
|
+
data?: unknown,
|
|
59
|
+
): JSONRPCErrorResponse {
|
|
60
|
+
return {
|
|
61
|
+
jsonrpc: '2.0',
|
|
62
|
+
id,
|
|
63
|
+
error: { code, message, ...(data !== undefined ? { data } : {}) },
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @stable
|
|
3
|
+
*
|
|
4
|
+
* A2A push notifications — a webhook delivery channel that fires when a task
|
|
5
|
+
* reaches a terminal state (`completed`, `canceled`, `failed`, `rejected`).
|
|
6
|
+
* The protocol specifies four JSON-RPC methods (`tasks/pushNotificationConfig/`
|
|
7
|
+
* {set, get, list, delete}) for registering / inspecting / removing configs,
|
|
8
|
+
* plus the delivery contract: an HTTP POST to the registered URL with the
|
|
9
|
+
* task envelope as the body and an HMAC-SHA256 signature for verification.
|
|
10
|
+
*
|
|
11
|
+
* This is the minimum shape long-horizon agents need. A consumer that finishes
|
|
12
|
+
* a task in 30 minutes can't keep an SSE stream open against a Worker (CPU
|
|
13
|
+
* limits) or an unauthenticated browser tab (network drops) — they need a
|
|
14
|
+
* fire-and-forget endpoint the gateway calls when the task is done.
|
|
15
|
+
*
|
|
16
|
+
* Out of scope for the first pass: retries, queue durability, partial-state
|
|
17
|
+
* notifications. If the webhook returns non-2xx or the request fails, the
|
|
18
|
+
* gateway logs and moves on — the consumer's endpoint should idempotently
|
|
19
|
+
* pull state via `tasks/get` rather than rely on at-least-once delivery.
|
|
20
|
+
*
|
|
21
|
+
* @example registering a webhook
|
|
22
|
+
* {
|
|
23
|
+
* "jsonrpc": "2.0", "id": 1, "method": "tasks/pushNotificationConfig/set",
|
|
24
|
+
* "params": {
|
|
25
|
+
* "taskId": "task_abc",
|
|
26
|
+
* "pushNotificationConfig": {
|
|
27
|
+
* "id": "cfg_1",
|
|
28
|
+
* "url": "https://my-consumer.example.com/agent/done",
|
|
29
|
+
* "token": "my-shared-secret-not-the-hmac-secret"
|
|
30
|
+
* }
|
|
31
|
+
* }
|
|
32
|
+
* }
|
|
33
|
+
*
|
|
34
|
+
* @example webhook delivery
|
|
35
|
+
* POST https://my-consumer.example.com/agent/done
|
|
36
|
+
* X-A2A-Notification-Token: my-shared-secret-not-the-hmac-secret
|
|
37
|
+
* X-A2A-Signature: sha256=<hex(HMAC-SHA256(webhookSecret, body))>
|
|
38
|
+
* Content-Type: application/json
|
|
39
|
+
* { "taskId": "task_abc", "state": "completed", "task": { ...full Task... } }
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import type { SqlAdapter } from './task-store-sql'
|
|
43
|
+
import type { Task } from './types'
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Authentication metadata for the webhook itself. The A2A spec leaves this
|
|
47
|
+
* to consumers — the most common shape is a bearer token the gateway sends as
|
|
48
|
+
* `Authorization: <scheme> <credential>`. We pass it through verbatim.
|
|
49
|
+
*/
|
|
50
|
+
export interface PushNotificationAuthentication {
|
|
51
|
+
schemes: string[]
|
|
52
|
+
credentials?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Per-task push notification configuration. A task can have multiple configs
|
|
57
|
+
* (e.g. one for the consumer's own webhook + one for an audit log endpoint).
|
|
58
|
+
*/
|
|
59
|
+
export interface PushNotificationConfig {
|
|
60
|
+
/** Stable id within the task's config set. Required for get/delete addressing. */
|
|
61
|
+
id: string
|
|
62
|
+
/** HTTPS URL the gateway will POST to. */
|
|
63
|
+
url: string
|
|
64
|
+
/**
|
|
65
|
+
* Opaque token the gateway sends back as `X-A2A-Notification-Token` so the
|
|
66
|
+
* webhook can verify the call originated from a registration the consumer
|
|
67
|
+
* authorized. Distinct from the HMAC signature (which proves the body
|
|
68
|
+
* wasn't tampered with) — this proves the registration is recognised.
|
|
69
|
+
*/
|
|
70
|
+
token?: string
|
|
71
|
+
/** Optional webhook-side auth metadata. */
|
|
72
|
+
authentication?: PushNotificationAuthentication
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface TaskPushNotificationConfig {
|
|
76
|
+
taskId: string
|
|
77
|
+
pushNotificationConfig: PushNotificationConfig
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Storage for push configs. The default in-memory store is fine for single
|
|
82
|
+
* Worker instances + tests; production multi-instance deployments need
|
|
83
|
+
* `SqlPushNotificationStore` (or any other shared-state adapter) so a config
|
|
84
|
+
* registered on instance A is visible to a delivery firing from instance B.
|
|
85
|
+
*/
|
|
86
|
+
export interface PushNotificationStore {
|
|
87
|
+
set(taskId: string, config: PushNotificationConfig): Promise<void>
|
|
88
|
+
get(taskId: string, configId: string): Promise<PushNotificationConfig | undefined>
|
|
89
|
+
list(taskId: string): Promise<PushNotificationConfig[]>
|
|
90
|
+
delete(taskId: string, configId: string): Promise<void>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class InMemoryPushNotificationStore implements PushNotificationStore {
|
|
94
|
+
private readonly byTask = new Map<string, Map<string, PushNotificationConfig>>()
|
|
95
|
+
|
|
96
|
+
async set(taskId: string, config: PushNotificationConfig): Promise<void> {
|
|
97
|
+
let configs = this.byTask.get(taskId)
|
|
98
|
+
if (!configs) {
|
|
99
|
+
configs = new Map()
|
|
100
|
+
this.byTask.set(taskId, configs)
|
|
101
|
+
}
|
|
102
|
+
configs.set(config.id, { ...config })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async get(taskId: string, configId: string): Promise<PushNotificationConfig | undefined> {
|
|
106
|
+
const cfg = this.byTask.get(taskId)?.get(configId)
|
|
107
|
+
return cfg ? { ...cfg } : undefined
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async list(taskId: string): Promise<PushNotificationConfig[]> {
|
|
111
|
+
const configs = this.byTask.get(taskId)
|
|
112
|
+
if (!configs) return []
|
|
113
|
+
return [...configs.values()].map((c) => ({ ...c }))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async delete(taskId: string, configId: string): Promise<void> {
|
|
117
|
+
const configs = this.byTask.get(taskId)
|
|
118
|
+
if (!configs) return
|
|
119
|
+
configs.delete(configId)
|
|
120
|
+
if (configs.size === 0) this.byTask.delete(taskId)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const PUSH_TABLE_DDL = (table: string) => `
|
|
125
|
+
CREATE TABLE IF NOT EXISTS ${table} (
|
|
126
|
+
task_id TEXT NOT NULL,
|
|
127
|
+
config_id TEXT NOT NULL,
|
|
128
|
+
url TEXT NOT NULL,
|
|
129
|
+
token TEXT,
|
|
130
|
+
authentication TEXT,
|
|
131
|
+
PRIMARY KEY (task_id, config_id)
|
|
132
|
+
)
|
|
133
|
+
`
|
|
134
|
+
|
|
135
|
+
/** SQL-backed push config store. Schema: one row per (taskId, configId). */
|
|
136
|
+
export class SqlPushNotificationStore implements PushNotificationStore {
|
|
137
|
+
constructor(
|
|
138
|
+
private readonly db: SqlAdapter,
|
|
139
|
+
private readonly table: string = 'a2a_push_configs',
|
|
140
|
+
) {}
|
|
141
|
+
|
|
142
|
+
async migrate(): Promise<void> {
|
|
143
|
+
await this.db.exec(PUSH_TABLE_DDL(this.table))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async set(taskId: string, config: PushNotificationConfig): Promise<void> {
|
|
147
|
+
const auth = config.authentication ? JSON.stringify(config.authentication) : null
|
|
148
|
+
const updated = await this.db.exec(
|
|
149
|
+
`UPDATE ${this.table} SET url = ?, token = ?, authentication = ? WHERE task_id = ? AND config_id = ?`,
|
|
150
|
+
[config.url, config.token ?? null, auth, taskId, config.id],
|
|
151
|
+
)
|
|
152
|
+
if (updated.rowsAffected === 0) {
|
|
153
|
+
await this.db.exec(
|
|
154
|
+
`INSERT INTO ${this.table} (task_id, config_id, url, token, authentication) VALUES (?, ?, ?, ?, ?)`,
|
|
155
|
+
[taskId, config.id, config.url, config.token ?? null, auth],
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async get(taskId: string, configId: string): Promise<PushNotificationConfig | undefined> {
|
|
161
|
+
const rows = await this.db.query<{
|
|
162
|
+
config_id: string
|
|
163
|
+
url: string
|
|
164
|
+
token: string | null
|
|
165
|
+
authentication: string | null
|
|
166
|
+
}>(
|
|
167
|
+
`SELECT config_id, url, token, authentication FROM ${this.table} WHERE task_id = ? AND config_id = ?`,
|
|
168
|
+
[taskId, configId],
|
|
169
|
+
)
|
|
170
|
+
const row = rows[0]
|
|
171
|
+
if (!row) return undefined
|
|
172
|
+
return {
|
|
173
|
+
id: row.config_id,
|
|
174
|
+
url: row.url,
|
|
175
|
+
token: row.token ?? undefined,
|
|
176
|
+
authentication: row.authentication
|
|
177
|
+
? (JSON.parse(row.authentication) as PushNotificationAuthentication)
|
|
178
|
+
: undefined,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async list(taskId: string): Promise<PushNotificationConfig[]> {
|
|
183
|
+
const rows = await this.db.query<{
|
|
184
|
+
config_id: string
|
|
185
|
+
url: string
|
|
186
|
+
token: string | null
|
|
187
|
+
authentication: string | null
|
|
188
|
+
}>(
|
|
189
|
+
`SELECT config_id, url, token, authentication FROM ${this.table} WHERE task_id = ?`,
|
|
190
|
+
[taskId],
|
|
191
|
+
)
|
|
192
|
+
return rows.map((row) => ({
|
|
193
|
+
id: row.config_id,
|
|
194
|
+
url: row.url,
|
|
195
|
+
token: row.token ?? undefined,
|
|
196
|
+
authentication: row.authentication
|
|
197
|
+
? (JSON.parse(row.authentication) as PushNotificationAuthentication)
|
|
198
|
+
: undefined,
|
|
199
|
+
}))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async delete(taskId: string, configId: string): Promise<void> {
|
|
203
|
+
await this.db.exec(
|
|
204
|
+
`DELETE FROM ${this.table} WHERE task_id = ? AND config_id = ?`,
|
|
205
|
+
[taskId, configId],
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Send the webhook for each registered config on a task. Signs the body with
|
|
212
|
+
* HMAC-SHA256 against `webhookSecret` so the consumer can verify authenticity.
|
|
213
|
+
* Fire-and-forget per the design note above — the function awaits delivery
|
|
214
|
+
* (so observability hooks see the result) but does not retry on failure.
|
|
215
|
+
*
|
|
216
|
+
* The caller decides *when* to deliver — typically on terminal-state
|
|
217
|
+
* transitions emitted from `message/send` and `message/stream`.
|
|
218
|
+
*/
|
|
219
|
+
export async function deliverPushNotifications(args: {
|
|
220
|
+
task: Task
|
|
221
|
+
store: PushNotificationStore
|
|
222
|
+
webhookSecret: string | undefined
|
|
223
|
+
/** Inject for tests. Defaults to global `fetch`. */
|
|
224
|
+
fetcher?: typeof fetch
|
|
225
|
+
/** Optional callback so the gateway's observer can log delivery outcomes. */
|
|
226
|
+
onDelivery?: (result: PushDeliveryResult) => void
|
|
227
|
+
}): Promise<PushDeliveryResult[]> {
|
|
228
|
+
const fetcher = args.fetcher ?? fetch
|
|
229
|
+
const configs = await args.store.list(args.task.id)
|
|
230
|
+
const body = JSON.stringify({
|
|
231
|
+
taskId: args.task.id,
|
|
232
|
+
state: args.task.status.state,
|
|
233
|
+
task: args.task,
|
|
234
|
+
})
|
|
235
|
+
const signature = args.webhookSecret
|
|
236
|
+
? `sha256=${await hmacSha256Hex(args.webhookSecret, body)}`
|
|
237
|
+
: undefined
|
|
238
|
+
|
|
239
|
+
const results: PushDeliveryResult[] = []
|
|
240
|
+
for (const config of configs) {
|
|
241
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
242
|
+
if (config.token) headers['X-A2A-Notification-Token'] = config.token
|
|
243
|
+
if (signature) headers['X-A2A-Signature'] = signature
|
|
244
|
+
if (config.authentication?.credentials) {
|
|
245
|
+
const scheme = config.authentication.schemes[0] ?? 'Bearer'
|
|
246
|
+
headers.Authorization = `${scheme} ${config.authentication.credentials}`
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let result: PushDeliveryResult
|
|
250
|
+
try {
|
|
251
|
+
const res = await fetcher(config.url, { method: 'POST', headers, body })
|
|
252
|
+
result = {
|
|
253
|
+
taskId: args.task.id,
|
|
254
|
+
configId: config.id,
|
|
255
|
+
url: config.url,
|
|
256
|
+
ok: res.ok,
|
|
257
|
+
status: res.status,
|
|
258
|
+
}
|
|
259
|
+
} catch (err) {
|
|
260
|
+
result = {
|
|
261
|
+
taskId: args.task.id,
|
|
262
|
+
configId: config.id,
|
|
263
|
+
url: config.url,
|
|
264
|
+
ok: false,
|
|
265
|
+
error: err instanceof Error ? err.message : String(err),
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
args.onDelivery?.(result)
|
|
269
|
+
results.push(result)
|
|
270
|
+
}
|
|
271
|
+
return results
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export interface PushDeliveryResult {
|
|
275
|
+
taskId: string
|
|
276
|
+
configId: string
|
|
277
|
+
url: string
|
|
278
|
+
ok: boolean
|
|
279
|
+
status?: number
|
|
280
|
+
error?: string
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* HMAC-SHA256 via WebCrypto. Works on Workers, Node 19+, Bun, Deno. The
|
|
285
|
+
* gateway requires WebCrypto for x402 verification already, so this adds no
|
|
286
|
+
* new platform constraint.
|
|
287
|
+
*/
|
|
288
|
+
async function hmacSha256Hex(secret: string, body: string): Promise<string> {
|
|
289
|
+
const enc = new TextEncoder()
|
|
290
|
+
const key = await crypto.subtle.importKey(
|
|
291
|
+
'raw',
|
|
292
|
+
enc.encode(secret),
|
|
293
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
294
|
+
false,
|
|
295
|
+
['sign'],
|
|
296
|
+
)
|
|
297
|
+
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(body))
|
|
298
|
+
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
299
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @stable
|
|
3
|
+
*
|
|
4
|
+
* Durable `TaskStore` against any SQL store. Adapter-agnostic: callers wire a
|
|
5
|
+
* `SqlAdapter` against their driver (D1, postgres, sqlite, libSQL, Turso) and
|
|
6
|
+
* the same store survives gateway restarts so an in-flight task (and its
|
|
7
|
+
* artifacts) is recoverable after a Worker recycle.
|
|
8
|
+
*
|
|
9
|
+
* Schema is one table: tasks keyed by id with the full JSON payload, plus a
|
|
10
|
+
* secondary index on `context_id` so `tasks/resubscribe` and conversational
|
|
11
|
+
* lookups by context are O(log n). TTL is enforced at read time the same way
|
|
12
|
+
* `InMemoryTaskStore` does — the gateway is single-writer per task id so a
|
|
13
|
+
* stale row is invisible to callers regardless of when the row is physically
|
|
14
|
+
* deleted.
|
|
15
|
+
*
|
|
16
|
+
* Why not bake in a specific driver? Hono workers run on Cloudflare (D1),
|
|
17
|
+
* Node (pg / sqlite), Bun, Deno. Burning a hard dependency on one client
|
|
18
|
+
* limits the gateway's reach. The adapter indirection costs ~5 lines per
|
|
19
|
+
* driver in the consumer's code and keeps the package free of native deps.
|
|
20
|
+
*
|
|
21
|
+
* @example D1
|
|
22
|
+
* import { SqlTaskStore, d1ToSqlAdapter } from '@tangle-network/agent-gateway'
|
|
23
|
+
* const store = new SqlTaskStore(d1ToSqlAdapter(env.DB))
|
|
24
|
+
* await store.migrate()
|
|
25
|
+
* const gw = createAgentGateway({ ..., a2a: { taskStore: store } })
|
|
26
|
+
*
|
|
27
|
+
* @example libSQL / Turso
|
|
28
|
+
* import { createClient } from '@libsql/client'
|
|
29
|
+
* const client = createClient({ url: process.env.TURSO_URL!, authToken: process.env.TURSO_TOKEN! })
|
|
30
|
+
* const libsql: SqlAdapter = {
|
|
31
|
+
* exec: async (sql, params = []) => {
|
|
32
|
+
* const r = await client.execute({ sql, args: params as never[] })
|
|
33
|
+
* return { rowsAffected: Number(r.rowsAffected ?? 0) }
|
|
34
|
+
* },
|
|
35
|
+
* query: async (sql, params = []) => {
|
|
36
|
+
* const r = await client.execute({ sql, args: params as never[] })
|
|
37
|
+
* return r.rows as unknown as Record<string, unknown>[]
|
|
38
|
+
* },
|
|
39
|
+
* }
|
|
40
|
+
* const store = new SqlTaskStore(libsql)
|
|
41
|
+
* await store.migrate()
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import type { TaskStore } from './task-store'
|
|
45
|
+
import type { Task } from './types'
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Minimal SQL driver shape — identical to agent-runtime's `SqlAdapter` so the
|
|
49
|
+
* same wrapper code works for both packages. Parameter placeholders MUST be
|
|
50
|
+
* `?` (positional); driver wrappers that use `$1`, `$2`, … should rewrite at
|
|
51
|
+
* the adapter boundary (see node-postgres example in the durability docs).
|
|
52
|
+
*/
|
|
53
|
+
export interface SqlAdapter {
|
|
54
|
+
exec(sql: string, params?: readonly unknown[]): Promise<{ rowsAffected: number }>
|
|
55
|
+
query<TRow = Record<string, unknown>>(
|
|
56
|
+
sql: string,
|
|
57
|
+
params?: readonly unknown[],
|
|
58
|
+
): Promise<TRow[]>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Adapt a Cloudflare D1 binding to `SqlAdapter`. The package never imports
|
|
63
|
+
* `@cloudflare/workers-types`; the binding's structural shape lines up via
|
|
64
|
+
* TypeScript structural compatibility.
|
|
65
|
+
*/
|
|
66
|
+
export function d1ToSqlAdapter(db: D1DatabaseLike): SqlAdapter {
|
|
67
|
+
return {
|
|
68
|
+
async exec(sql, params = []) {
|
|
69
|
+
const stmt = db.prepare(sql)
|
|
70
|
+
const bound = params.length > 0 ? stmt.bind(...params) : stmt
|
|
71
|
+
const result = await bound.run()
|
|
72
|
+
const meta = (result as { meta?: { rows_written?: number; changes?: number } }).meta
|
|
73
|
+
return { rowsAffected: meta?.rows_written ?? meta?.changes ?? 0 }
|
|
74
|
+
},
|
|
75
|
+
async query<TRow>(sql: string, params: readonly unknown[] = []): Promise<TRow[]> {
|
|
76
|
+
const stmt = db.prepare(sql)
|
|
77
|
+
const bound = params.length > 0 ? stmt.bind(...params) : stmt
|
|
78
|
+
const result = await bound.all<TRow>()
|
|
79
|
+
return result.results ?? []
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface D1DatabaseLike {
|
|
85
|
+
prepare(sql: string): D1StmtLike
|
|
86
|
+
}
|
|
87
|
+
export interface D1StmtLike {
|
|
88
|
+
bind(...params: unknown[]): D1StmtLike
|
|
89
|
+
run(): Promise<unknown>
|
|
90
|
+
all<TRow = unknown>(): Promise<{ results?: TRow[] }>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const DEFAULT_TTL_MS = 60 * 60 * 1000
|
|
94
|
+
|
|
95
|
+
const TASKS_TABLE_DDL = (table: string) => `
|
|
96
|
+
CREATE TABLE IF NOT EXISTS ${table} (
|
|
97
|
+
id TEXT PRIMARY KEY,
|
|
98
|
+
context_id TEXT NOT NULL,
|
|
99
|
+
state TEXT NOT NULL,
|
|
100
|
+
payload TEXT NOT NULL,
|
|
101
|
+
updated_at INTEGER NOT NULL
|
|
102
|
+
)
|
|
103
|
+
`
|
|
104
|
+
const CTX_INDEX_DDL = (table: string) => `
|
|
105
|
+
CREATE INDEX IF NOT EXISTS idx_${table}_context ON ${table} (context_id, updated_at)
|
|
106
|
+
`
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* SQL-backed TaskStore. Stores the full Task JSON; reads return a deep clone
|
|
110
|
+
* so callers never observe shared references. TTL is enforced at read time:
|
|
111
|
+
* expired rows are filtered out and (best-effort) deleted, matching the
|
|
112
|
+
* in-memory store's semantics so behavior is portable across both adapters.
|
|
113
|
+
*/
|
|
114
|
+
export class SqlTaskStore implements TaskStore {
|
|
115
|
+
constructor(
|
|
116
|
+
private readonly db: SqlAdapter,
|
|
117
|
+
private readonly opts: { ttlMs?: number; table?: string } = {},
|
|
118
|
+
) {}
|
|
119
|
+
|
|
120
|
+
private get ttlMs(): number {
|
|
121
|
+
return this.opts.ttlMs ?? DEFAULT_TTL_MS
|
|
122
|
+
}
|
|
123
|
+
private get table(): string {
|
|
124
|
+
return this.opts.table ?? 'a2a_tasks'
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Idempotent. Call once at deploy. */
|
|
128
|
+
async migrate(): Promise<void> {
|
|
129
|
+
await this.db.exec(TASKS_TABLE_DDL(this.table))
|
|
130
|
+
await this.db.exec(CTX_INDEX_DDL(this.table))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async get(id: string): Promise<Task | undefined> {
|
|
134
|
+
const rows = await this.db.query<{ payload: string; updated_at: number }>(
|
|
135
|
+
`SELECT payload, updated_at FROM ${this.table} WHERE id = ?`,
|
|
136
|
+
[id],
|
|
137
|
+
)
|
|
138
|
+
const row = rows[0]
|
|
139
|
+
if (!row) return undefined
|
|
140
|
+
if (Date.now() - row.updated_at > this.ttlMs) {
|
|
141
|
+
// Lazy GC. If the delete loses a race with another reader, that reader
|
|
142
|
+
// observes either the stale-then-deleted task (returning undefined here)
|
|
143
|
+
// or, after this delete commits, observes undefined directly — either
|
|
144
|
+
// way callers see consistent "expired" semantics.
|
|
145
|
+
void this.db.exec(`DELETE FROM ${this.table} WHERE id = ?`, [id])
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
return JSON.parse(row.payload) as Task
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async put(task: Task): Promise<void> {
|
|
152
|
+
const payload = JSON.stringify(task)
|
|
153
|
+
const updatedAt = Date.now()
|
|
154
|
+
// Adapter-agnostic upsert: try update, fall back to insert if no row
|
|
155
|
+
// existed. Avoids needing ON CONFLICT (postgres) vs INSERT OR REPLACE
|
|
156
|
+
// (sqlite/libSQL) divergence at the SQL layer.
|
|
157
|
+
const updated = await this.db.exec(
|
|
158
|
+
`UPDATE ${this.table} SET context_id = ?, state = ?, payload = ?, updated_at = ? WHERE id = ?`,
|
|
159
|
+
[task.contextId, task.status.state, payload, updatedAt, task.id],
|
|
160
|
+
)
|
|
161
|
+
if (updated.rowsAffected === 0) {
|
|
162
|
+
await this.db.exec(
|
|
163
|
+
`INSERT INTO ${this.table} (id, context_id, state, payload, updated_at) VALUES (?, ?, ?, ?, ?)`,
|
|
164
|
+
[task.id, task.contextId, task.status.state, payload, updatedAt],
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async delete(id: string): Promise<void> {
|
|
170
|
+
await this.db.exec(`DELETE FROM ${this.table} WHERE id = ?`, [id])
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Lookup tasks by contextId — used by `tasks/resubscribe` and the multi-turn
|
|
175
|
+
* dispatcher. Returns most-recent-first. Not part of the base TaskStore
|
|
176
|
+
* interface since the in-memory store doesn't expose it; consumers that
|
|
177
|
+
* specifically wire SqlTaskStore can use it for richer queries.
|
|
178
|
+
*/
|
|
179
|
+
async listByContext(contextId: string): Promise<Task[]> {
|
|
180
|
+
const rows = await this.db.query<{ payload: string; updated_at: number }>(
|
|
181
|
+
`SELECT payload, updated_at FROM ${this.table} WHERE context_id = ? ORDER BY updated_at DESC`,
|
|
182
|
+
[contextId],
|
|
183
|
+
)
|
|
184
|
+
const now = Date.now()
|
|
185
|
+
return rows
|
|
186
|
+
.filter((r) => now - r.updated_at <= this.ttlMs)
|
|
187
|
+
.map((r) => JSON.parse(r.payload) as Task)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task persistence behind the JSON-RPC dispatcher. Default adapter is in
|
|
3
|
+
* memory with a 1-hour TTL — adequate for tests, scratch, and Workers with
|
|
4
|
+
* a short-lived process. Production deployments wire their own
|
|
5
|
+
* `TaskStore` (D1, postgres, Durable Object) via `GatewayConfig.a2a`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Task } from './types'
|
|
9
|
+
|
|
10
|
+
export interface TaskStore {
|
|
11
|
+
get(id: string): Promise<Task | undefined>
|
|
12
|
+
put(task: Task): Promise<void>
|
|
13
|
+
delete(id: string): Promise<void>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TTL_MS = 60 * 60 * 1000
|
|
17
|
+
|
|
18
|
+
export class InMemoryTaskStore implements TaskStore {
|
|
19
|
+
private readonly entries = new Map<string, { task: Task; expiresAt: number }>()
|
|
20
|
+
|
|
21
|
+
constructor(private readonly ttlMs: number = DEFAULT_TTL_MS) {}
|
|
22
|
+
|
|
23
|
+
async get(id: string): Promise<Task | undefined> {
|
|
24
|
+
this.gc()
|
|
25
|
+
const entry = this.entries.get(id)
|
|
26
|
+
if (!entry) return undefined
|
|
27
|
+
return clone(entry.task)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async put(task: Task): Promise<void> {
|
|
31
|
+
this.gc()
|
|
32
|
+
this.entries.set(task.id, { task: clone(task), expiresAt: Date.now() + this.ttlMs })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async delete(id: string): Promise<void> {
|
|
36
|
+
this.entries.delete(id)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Sweep expired tasks. Called inline on every read/write — cheap for the
|
|
41
|
+
* Map sizes this is designed for (10s–1000s of concurrent tasks).
|
|
42
|
+
*/
|
|
43
|
+
private gc(): void {
|
|
44
|
+
const now = Date.now()
|
|
45
|
+
for (const [id, entry] of this.entries) {
|
|
46
|
+
if (entry.expiresAt <= now) this.entries.delete(id)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function clone<T>(value: T): T {
|
|
52
|
+
return JSON.parse(JSON.stringify(value)) as T
|
|
53
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A2A Message ↔ inner-agent (text-only) translation. Both directions are
|
|
3
|
+
* intentionally narrow: callers send text parts, the inner sandbox produces
|
|
4
|
+
* text. Data + file parts are rejected with `CONTENT_TYPE_NOT_SUPPORTED`
|
|
5
|
+
* rather than silently dropped — the protocol's whole point is letting the
|
|
6
|
+
* caller know exactly what an agent does and doesn't accept.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { A2A_ERROR_CODES, type Artifact, type Message } from './types'
|
|
10
|
+
|
|
11
|
+
export interface ExtractedText {
|
|
12
|
+
text: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ExtractError {
|
|
16
|
+
error: { code: number; message: string }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Pull `text` out of a Message's parts. Concatenates multi-text-part messages
|
|
21
|
+
* with two newlines, mirroring the OpenAI-compat path's join of `user`
|
|
22
|
+
* messages. Returns an A2A error code on any non-text part — text-only is the
|
|
23
|
+
* declared capability of every agent this gateway fronts.
|
|
24
|
+
*/
|
|
25
|
+
export function extractTextFromMessage(message: Message): ExtractedText | ExtractError {
|
|
26
|
+
if (!message || message.kind !== 'message') {
|
|
27
|
+
return {
|
|
28
|
+
error: {
|
|
29
|
+
code: A2A_ERROR_CODES.INVALID_PARAMS,
|
|
30
|
+
message: 'params.message must be an A2A Message ({ kind: "message", role, parts, messageId })',
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!Array.isArray(message.parts) || message.parts.length === 0) {
|
|
35
|
+
return {
|
|
36
|
+
error: {
|
|
37
|
+
code: A2A_ERROR_CODES.INVALID_PARAMS,
|
|
38
|
+
message: 'message.parts must be a non-empty array',
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const texts: string[] = []
|
|
43
|
+
for (const part of message.parts) {
|
|
44
|
+
if (part.kind === 'text') {
|
|
45
|
+
if (typeof part.text !== 'string') {
|
|
46
|
+
return {
|
|
47
|
+
error: {
|
|
48
|
+
code: A2A_ERROR_CODES.INVALID_PARAMS,
|
|
49
|
+
message: 'text part .text must be a string',
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
texts.push(part.text)
|
|
54
|
+
} else {
|
|
55
|
+
return {
|
|
56
|
+
error: {
|
|
57
|
+
code: A2A_ERROR_CODES.CONTENT_TYPE_NOT_SUPPORTED,
|
|
58
|
+
message: `part.kind '${(part as { kind: string }).kind}' not supported; this agent accepts text parts only`,
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { text: texts.join('\n\n') }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Wrap the agent's final response text as an A2A Artifact for the task's
|
|
68
|
+
* `artifacts` field. `name='response'` is the convention for the primary
|
|
69
|
+
* model output across A2A reference servers.
|
|
70
|
+
*/
|
|
71
|
+
export function responseTextToArtifact(text: string, artifactId: string): Artifact {
|
|
72
|
+
return {
|
|
73
|
+
artifactId,
|
|
74
|
+
name: 'response',
|
|
75
|
+
parts: [{ kind: 'text', text }],
|
|
76
|
+
}
|
|
77
|
+
}
|