@tangle-network/agent-gateway 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }