@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/src/index.ts CHANGED
@@ -57,3 +57,54 @@ export type {
57
57
  ChatCompletionRequest,
58
58
  ChatCompletionChunk,
59
59
  } from './types'
60
+
61
+ // --- A2A protocol surface (Google Agent-to-Agent) ---
62
+ // Types + task-store adapter. Handlers are wired automatically by
63
+ // createAgentGateway when `GatewayConfig.a2a` (or its default) is honored;
64
+ // consumers only import these to BYO a durable TaskStore (D1, postgres, DO)
65
+ // or to declare richer AgentMeta.skills for the Agent Card.
66
+ export { InMemoryTaskStore, type TaskStore } from './a2a/task-store'
67
+ export {
68
+ type D1DatabaseLike,
69
+ type D1StmtLike,
70
+ d1ToSqlAdapter,
71
+ type SqlAdapter,
72
+ SqlTaskStore,
73
+ } from './a2a/task-store-sql'
74
+ export {
75
+ deliverPushNotifications,
76
+ InMemoryPushNotificationStore,
77
+ type PushDeliveryResult,
78
+ type PushNotificationAuthentication,
79
+ type PushNotificationConfig,
80
+ type PushNotificationStore,
81
+ SqlPushNotificationStore,
82
+ type TaskPushNotificationConfig,
83
+ } from './a2a/push-notifications'
84
+ export type {
85
+ AgentCard,
86
+ AgentCapabilities,
87
+ AgentCardAuthentication,
88
+ AgentProvider,
89
+ AgentSkill,
90
+ Artifact,
91
+ DataPart,
92
+ FilePart,
93
+ JSONRPCErrorResponse,
94
+ JSONRPCRequest,
95
+ JSONRPCResponse,
96
+ JSONRPCSuccessResponse,
97
+ Message,
98
+ MessageSendParams,
99
+ Part,
100
+ StreamingEvent,
101
+ Task,
102
+ TaskArtifactUpdateEvent,
103
+ TaskIdParams,
104
+ TaskPushNotificationConfigGetParams,
105
+ TaskState,
106
+ TaskStatus,
107
+ TaskStatusUpdateEvent,
108
+ TextPart,
109
+ } from './a2a/types'
110
+ export { A2A_ERROR_CODES } from './a2a/types'
package/src/middleware.ts CHANGED
@@ -1,16 +1,19 @@
1
1
  import { Hono } from 'hono'
2
- import type {
3
- GatewayConfig,
4
- ChatCompletionRequest,
5
- ChatCompletionChunk,
6
- PaymentMethod,
7
- ApiKeyInfo,
8
- } from './types'
9
- import { verifyX402, verifyMpp, defaultVerifyApiKey } from './verify'
10
- import { filterConsumerMessagesStrict, redactSystemPromptFromOutput } from './filter'
11
- import { checkRateLimit, MemoryRateLimitStore, type RateLimitStore } from './rate-limit'
2
+
3
+ import { createA2AHandlers } from './a2a/handler'
4
+ import { InMemoryTaskStore } from './a2a/task-store'
5
+ import {
6
+ type AuthorizedRequest,
7
+ type GatewayState,
8
+ authenticateAndGuard,
9
+ dispatchSandboxStream,
10
+ estimateTokens,
11
+ settleAndRecord,
12
+ } from './dispatch'
12
13
  import { MemoryNonceStore } from './nonce-store'
13
- import { generateRequestId, type GatewayObserver, type RequestContext } from './observer'
14
+ import { type GatewayObserver, type RequestContext, generateRequestId } from './observer'
15
+ import { MemoryRateLimitStore, type RateLimitStore } from './rate-limit'
16
+ import type { ChatCompletionChunk, ChatCompletionRequest, GatewayConfig } from './types'
14
17
 
15
18
  /**
16
19
  * Create a Hono router that serves the agent gateway.
@@ -19,7 +22,7 @@ import { generateRequestId, type GatewayObserver, type RequestContext } from './
19
22
  * app.route('/v1/agents', createAgentGateway(config))
20
23
  *
21
24
  * Exposes:
22
- * GET /:slug/chat/completions — agent discovery metadata
25
+ * GET /:slug/chat/completions — agent discovery metadata (Tangle-native shape)
23
26
  * POST /:slug/chat/completions — OpenAI-compatible chat endpoint (paid)
24
27
  */
25
28
  export function createAgentGateway(config: GatewayConfig) {
@@ -32,12 +35,16 @@ export function createAgentGateway(config: GatewayConfig) {
32
35
  )
33
36
  }
34
37
  const gw = new Hono()
35
- const maxLen = config.maxMessageLength ?? 8000
36
38
  const rateLimitStore: RateLimitStore = config.rateLimitStore ?? new MemoryRateLimitStore()
37
- const globalRateLimit = config.rateLimit ?? { limit: 60, windowSeconds: 60 }
38
- const nonceStore = config.nonceStore ?? new MemoryNonceStore()
39
- const requiredScope = config.requiredScope ?? 'chat'
40
- const obs: GatewayObserver | undefined = config.observer
39
+ const state: GatewayState = {
40
+ rateLimitStore,
41
+ nonceStore: config.nonceStore ?? new MemoryNonceStore(),
42
+ globalRateLimit: config.rateLimit ?? { limit: 60, windowSeconds: 60 },
43
+ requiredScope: config.requiredScope ?? 'chat',
44
+ maxLen: config.maxMessageLength ?? 8000,
45
+ obs: config.observer,
46
+ }
47
+ const obs: GatewayObserver | undefined = state.obs
41
48
 
42
49
  // --- Discovery endpoint (no auth) ---
43
50
 
@@ -84,24 +91,22 @@ export function createAgentGateway(config: GatewayConfig) {
84
91
 
85
92
  gw.post('/:slug/chat/completions', async (c) => {
86
93
  const slug = c.req.param('slug')
87
- const startMs = Date.now()
88
- const requestId = generateRequestId()
89
- const ctx: RequestContext = { requestId, agentSlug: slug, startMs }
90
-
91
- await obs?.onRequestStart?.(ctx)
92
94
 
93
- // 1. Resolve agent
94
- const agent = await config.resolveAgent(slug)
95
- if (!agent) {
96
- return c.json({ error: { message: 'Agent not found', type: 'not_found' } }, 404)
97
- }
98
-
99
- // 2. Body size limit (before parsing — DoS prevention)
100
- const contentLength = parseInt(c.req.header('Content-Length') ?? '0', 10)
95
+ // Body size limit (before parsing — DoS prevention).
96
+ const contentLength = Number.parseInt(c.req.header('Content-Length') ?? '0', 10)
101
97
  if (contentLength > 65536) {
102
- await obs?.onBodyTooLarge?.(ctx, contentLength)
98
+ const requestId = generateRequestId()
99
+ await obs?.onBodyTooLarge?.(
100
+ { requestId, agentSlug: slug, startMs: Date.now() },
101
+ contentLength,
102
+ )
103
103
  return c.json(
104
- { error: { message: 'Request body too large (max 64KB)', type: 'invalid_request' } },
104
+ {
105
+ error: {
106
+ message: 'Request body too large (max 64KB)',
107
+ type: 'invalid_request',
108
+ },
109
+ },
105
110
  413,
106
111
  )
107
112
  }
@@ -113,277 +118,117 @@ export function createAgentGateway(config: GatewayConfig) {
113
118
  return c.json({ error: { message: 'Invalid JSON', type: 'invalid_request' } }, 400)
114
119
  }
115
120
  if (!body.messages?.length) {
116
- return c.json({ error: { message: 'messages array required', type: 'invalid_request' } }, 400)
117
- }
118
-
119
- // 3. Authenticate — x402 SpendAuth, MPP, or API key
120
- const spendAuthHeader = c.req.header('X-Payment-Signature')
121
- const authHeader = c.req.header('Authorization') ?? ''
122
- let consumerId: string | null = null
123
- let paymentMethod: PaymentMethod = 'none'
124
- let keyInfo: ApiKeyInfo | null = null
125
-
126
- if (spendAuthHeader) {
127
- const signer = await verifyX402(spendAuthHeader, config.x402, nonceStore)
128
- if (!signer) {
129
- await obs?.onAuthFailure?.(ctx, { method: 'x402', code: 'invalid_spend_auth', httpStatus: 402 })
130
- return c.json(
131
- { error: { message: 'Invalid X-Payment-Signature', type: 'authentication_error', code: 'invalid_spend_auth' } },
132
- { status: 402, headers: { 'X-Payment-Required': 'spendauth', 'X-Request-Id': requestId } },
133
- )
134
- }
135
- consumerId = signer
136
- paymentMethod = 'x402'
137
- } else if (config.mpp && authHeader.toLowerCase().startsWith('payment ')) {
138
- const signer = await verifyMpp(authHeader, config.mpp, config.x402)
139
- if (!signer) {
140
- const realm = config.mpp.realm
141
- const method = config.mpp.method ?? 'blueprintevm'
142
- await obs?.onAuthFailure?.(ctx, { method: 'mpp', code: 'invalid_mpp_credential', httpStatus: 401 })
143
- return c.json(
144
- { error: { message: 'Invalid Payment credential', type: 'authentication_error', code: 'invalid_mpp_credential' } },
145
- { status: 401, headers: { 'WWW-Authenticate': `Payment realm="${realm}", method="${method}"`, 'X-Request-Id': requestId } },
146
- )
147
- }
148
- consumerId = signer
149
- paymentMethod = 'mpp'
150
- } else if (authHeader.startsWith('Bearer ')) {
151
- const verify = config.verifyApiKey ?? defaultVerifyApiKey
152
- const key = await verify(authHeader)
153
- if (!key) {
154
- await obs?.onAuthFailure?.(ctx, { method: 'apikey', code: 'invalid_api_key', httpStatus: 401 })
155
- return c.json(
156
- { error: { message: 'Invalid API key', type: 'authentication_error' } },
157
- { status: 401, headers: { 'X-Request-Id': requestId } },
158
- )
159
- }
160
-
161
- // Scope enforcement — API key must include the required scope
162
- if (key.scopes && key.scopes.length > 0 && !key.scopes.includes(requiredScope)) {
163
- await obs?.onAuthFailure?.(ctx, { method: 'apikey', code: 'insufficient_scope', httpStatus: 403 })
164
- return c.json(
165
- { error: { message: `API key missing required scope: ${requiredScope}`, type: 'forbidden', code: 'insufficient_scope' } },
166
- { status: 403, headers: { 'X-Request-Id': requestId } },
167
- )
168
- }
169
-
170
- consumerId = key.consumerId
171
- paymentMethod = 'apikey'
172
- keyInfo = key
173
- } else {
174
- // No payment — return 402 with instructions
175
- await obs?.onAuthFailure?.(ctx, { method: 'none', code: 'payment_required', httpStatus: 402 })
176
- const methods: string[] = ['x402']
177
- if (config.mpp) methods.push('mpp')
178
- methods.push('api_key')
179
-
180
- const headers: Record<string, string> = {
181
- 'X-Payment-Required': methods.join(', '),
182
- 'X-Request-Id': requestId,
183
- }
184
- if (config.mpp) {
185
- headers['WWW-Authenticate'] = `Payment realm="${config.mpp.realm}", method="${config.mpp.method ?? 'blueprintevm'}"`
186
- }
187
-
188
- return c.json({
189
- error: {
190
- message: 'Payment required',
191
- type: 'payment_required',
192
- payment_methods: methods,
193
- x402: {
194
- operator: config.x402.operatorAddress,
195
- chain_id: config.x402.chainId,
196
- credits_address: config.x402.creditsAddress,
197
- estimated_amount_per_request: '20000',
198
- },
199
- ...(config.mpp ? {
200
- mpp: { realm: config.mpp.realm, method: config.mpp.method ?? 'blueprintevm' },
201
- } : {}),
202
- api_key: {
203
- purchase_url: config.baseUrl ? `${config.baseUrl}/agents/${slug}/api-keys` : undefined,
204
- },
205
- },
206
- }, { status: 402, headers })
207
- }
208
-
209
- await obs?.onPaymentVerified?.(ctx, { method: paymentMethod, consumerId: consumerId!, keyId: keyInfo?.keyId })
210
-
211
- // 4. Rate limit — per-key override or global
212
- const effectiveRateLimit = keyInfo?.rateLimitPerMinute
213
- ? { limit: keyInfo.rateLimitPerMinute, windowSeconds: 60 }
214
- : globalRateLimit
215
-
216
- const rl = await checkRateLimit(consumerId!, effectiveRateLimit, rateLimitStore)
217
- if (!rl.allowed) {
218
- await obs?.onRateLimited?.(ctx, { consumerId: consumerId!, retryAfterSeconds: rl.retryAfterSeconds ?? 60 })
219
121
  return c.json(
220
- { error: { message: 'Rate limit exceeded', type: 'rate_limit_error', retry_after: rl.retryAfterSeconds } },
221
- { status: 429, headers: { 'Retry-After': String(rl.retryAfterSeconds ?? 60), 'X-Request-Id': requestId } },
122
+ { error: { message: 'messages array required', type: 'invalid_request' } },
123
+ 400,
222
124
  )
223
125
  }
224
126
 
225
- // 5. Filter messages injection detection + sanitization
226
- const { messages: filtered, injectionWarnings } = filterConsumerMessagesStrict(body.messages, maxLen)
127
+ const guard = await authenticateAndGuard(c, slug, body.messages, config, state)
128
+ if (guard instanceof Response) return guard
129
+ const authz = guard
227
130
 
228
- if (injectionWarnings.length > 0) {
229
- await obs?.onInjectionDetected?.(ctx, {
230
- consumerId: consumerId!,
231
- patterns: injectionWarnings,
232
- blocked: !!config.blockInjection,
233
- })
131
+ return streamChatCompletions(c, authz, config, obs)
132
+ })
234
133
 
235
- if (config.blockInjection) {
236
- return c.json(
237
- { error: { message: 'Request rejected: potential prompt injection detected', type: 'content_policy_violation' } },
238
- { status: 400, headers: { 'X-Request-Id': requestId } },
239
- )
240
- }
241
- // In non-blocking mode, continue but the warning is logged for auditing
242
- }
134
+ // --- A2A protocol surface (Google Agent-to-Agent, JSON-RPC 2.0 + AgentCard) ---
135
+ // Mounted alongside the OpenAI-compat routes so a single agent speaks both.
136
+ // Both surfaces share authenticateAndGuard + dispatchSandboxStream +
137
+ // settleAndRecord, so every security and billing guarantee applies uniformly
138
+ // regardless of which protocol the caller used.
139
+ const taskStore = config.a2a?.taskStore ?? new InMemoryTaskStore()
140
+ const pushStore = config.a2a?.pushStore
141
+ const a2a = createA2AHandlers({ config, state, taskStore, pushStore })
142
+ gw.get('/:slug/.well-known/agent.json', a2a.handleAgentCard)
143
+ gw.post('/:slug', a2a.handleJsonRpc)
243
144
 
244
- const userMessage = filtered
245
- .filter((m) => m.role === 'user')
246
- .map((m) => m.content)
247
- .join('\n\n')
145
+ return gw
146
+ }
248
147
 
249
- if (!userMessage) {
250
- return c.json({ error: { message: 'No user message provided', type: 'invalid_request' } }, 400)
251
- }
148
+ /**
149
+ * Drain the sandbox stream into an OpenAI-shaped SSE response, settle the
150
+ * payment, fire observer hooks. Identical pre-refactor behavior, just lifted
151
+ * out of the handler so the A2A wrapper can reach the same dispatch path
152
+ * without duplicating it.
153
+ */
154
+ function streamChatCompletions(
155
+ c: import('hono').Context,
156
+ authz: AuthorizedRequest,
157
+ config: GatewayConfig,
158
+ obs: GatewayObserver | undefined,
159
+ ): Response {
160
+ const { agent, consumerId, paymentMethod, requestId, userMessage, rateLimitRemaining } = authz
161
+ const inputTokens = estimateTokens(userMessage)
162
+ let outputTokens = 0
163
+ const ctx: RequestContext = {
164
+ requestId,
165
+ agentSlug: agent.slug,
166
+ startMs: authz.startMs,
167
+ }
252
168
 
253
- // 5b. Optional host authorization. Runs after payment/rate-limit
254
- // checks and before sandbox allocation.
255
- if (config.authorizeConsumer) {
256
- const authz = await config.authorizeConsumer(agent, {
257
- method: paymentMethod,
258
- consumerId: consumerId!,
259
- keyId: keyInfo?.keyId,
260
- requestId,
261
- })
262
- if (!authz.allow) {
263
- return c.json(
264
- {
265
- error: {
266
- message: authz.reason,
267
- type: 'authorization_denied',
268
- code: authz.code,
269
- },
270
- },
271
- { status: 403, headers: { 'X-Request-Id': requestId } },
272
- )
169
+ const stream = new ReadableStream({
170
+ async start(controller) {
171
+ const encoder = new TextEncoder()
172
+ const sendChunk = (delta: string) => {
173
+ outputTokens += estimateTokens(delta)
174
+ const chunk: ChatCompletionChunk = {
175
+ id: `chatcmpl-${Date.now()}`,
176
+ object: 'chat.completion.chunk',
177
+ created: Math.floor(Date.now() / 1000),
178
+ model: agent.slug,
179
+ choices: [{ index: 0, delta: { content: delta }, finish_reason: null }],
180
+ }
181
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`))
273
182
  }
274
- }
275
-
276
- // 6. Get sandbox and stream response with output filtering
277
- const inputTokens = Math.ceil(userMessage.length / 4)
278
- let outputTokens = 0
279
183
 
280
- const stream = new ReadableStream({
281
- async start(controller) {
282
- const encoder = new TextEncoder()
283
- const sendChunk = (rawDelta: string) => {
284
- // Redact system prompt leakage from output
285
- const delta = redactSystemPromptFromOutput(rawDelta, agent.systemPrompt)
286
- outputTokens += Math.ceil(delta.length / 4)
287
- const chunk: ChatCompletionChunk = {
288
- id: `chatcmpl-${Date.now()}`,
289
- object: 'chat.completion.chunk',
290
- created: Math.floor(Date.now() / 1000),
291
- model: agent.slug,
292
- choices: [{ index: 0, delta: { content: delta }, finish_reason: null }],
293
- }
294
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`))
184
+ try {
185
+ for await (const delta of dispatchSandboxStream(agent, userMessage, consumerId, config)) {
186
+ sendChunk(delta)
295
187
  }
296
188
 
297
- try {
298
- const box = await config.getSandbox(agent)
299
- const promptStream = box.streamPrompt(userMessage, {
300
- sessionId: `consumer:${consumerId}`,
301
- systemPrompt: agent.systemPrompt,
302
- })
303
-
304
- for await (const event of promptStream) {
305
- if (
306
- event.type === 'message.part.updated' &&
307
- event.data?.part?.type === 'text' &&
308
- event.data.delta
309
- ) {
310
- sendChunk(event.data.delta)
311
- }
312
- }
313
-
314
- // Final chunk
315
- const done: ChatCompletionChunk = {
316
- id: `chatcmpl-${Date.now()}`,
317
- object: 'chat.completion.chunk',
318
- created: Math.floor(Date.now() / 1000),
319
- model: agent.slug,
320
- choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
321
- }
322
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(done)}\n\n`))
323
- controller.enqueue(encoder.encode('data: [DONE]\n\n'))
324
-
325
- // 7. Record usage + settle payment
326
- const totalCost = (inputTokens + outputTokens) * agent.pricePerTokenUsd
327
- const ownerEarned = totalCost * (1 - agent.platformFeePercent)
328
- const platformFee = totalCost * agent.platformFeePercent
329
-
330
- const usageEvent = {
331
- requestId: ctx.requestId,
332
- agentId: agent.id,
333
- agentSlug: agent.slug,
334
- consumerId: consumerId!,
335
- paymentMethod,
336
- inputTokens,
337
- outputTokens,
338
- totalCostUsd: totalCost,
339
- ownerEarnedUsd: ownerEarned,
340
- platformFeeUsd: platformFee,
341
- durationMs: Date.now() - startMs,
342
- }
343
-
344
- await config.recordUsage(usageEvent)
345
- await obs?.onRequestComplete?.(ctx, usageEvent)
346
-
347
- if (config.settlePayment) {
348
- await config.settlePayment(
349
- { method: paymentMethod, consumerId: consumerId!, requestId: ctx.requestId },
350
- totalCost,
351
- ).catch(async err => {
352
- const msg = err instanceof Error ? err.message : String(err)
353
- console.error(`[agent-gateway] settlement failed for ${consumerId}: ${msg}`)
354
- await obs?.onSettlementError?.(ctx, { consumerId: consumerId!, method: paymentMethod, errorMessage: msg })
355
- })
356
- }
357
- } catch (err) {
358
- // Sanitize error — never expose stack traces or internal paths
359
- const rawMessage = err instanceof Error ? err.message : String(err)
360
- const safeMessage =
361
- rawMessage.includes('/') || rawMessage.includes('\\')
362
- ? 'Internal agent error'
363
- : rawMessage
364
- await obs?.onStreamError?.(ctx, { consumerId: consumerId!, errorMessage: rawMessage })
365
- controller.enqueue(
366
- encoder.encode(`data: ${JSON.stringify({ error: { message: safeMessage, type: 'server_error' } })}\n\n`),
367
- )
368
- } finally {
369
- controller.close()
189
+ const done: ChatCompletionChunk = {
190
+ id: `chatcmpl-${Date.now()}`,
191
+ object: 'chat.completion.chunk',
192
+ created: Math.floor(Date.now() / 1000),
193
+ model: agent.slug,
194
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
370
195
  }
371
- },
372
- })
373
-
374
- return new Response(stream, {
375
- headers: {
376
- 'Content-Type': 'text/event-stream',
377
- 'Cache-Control': 'no-cache',
378
- 'X-Request-Id': requestId,
379
- 'X-Agent-Slug': agent.slug,
380
- 'X-Agent-Hosting': agent.sandboxEndpoint ? 'sovereign' : 'centralized',
381
- 'X-Payment-Method': paymentMethod,
382
- 'X-Payment-Settled': paymentMethod === 'x402' ? 'pending' : 'true',
383
- ...(rl.remaining !== undefined ? { 'X-RateLimit-Remaining': String(rl.remaining) } : {}),
384
- },
385
- })
196
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(done)}\n\n`))
197
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'))
198
+
199
+ await settleAndRecord(agent, authz, inputTokens, outputTokens, config, obs)
200
+ } catch (err) {
201
+ const rawMessage = err instanceof Error ? err.message : String(err)
202
+ // Never expose stack traces / absolute paths from sandbox internals.
203
+ const safeMessage =
204
+ rawMessage.includes('/') || rawMessage.includes('\\')
205
+ ? 'Internal agent error'
206
+ : rawMessage
207
+ await obs?.onStreamError?.(ctx, { consumerId, errorMessage: rawMessage })
208
+ controller.enqueue(
209
+ encoder.encode(
210
+ `data: ${JSON.stringify({ error: { message: safeMessage, type: 'server_error' } })}\n\n`,
211
+ ),
212
+ )
213
+ } finally {
214
+ controller.close()
215
+ }
216
+ },
386
217
  })
387
218
 
388
- return gw
219
+ return new Response(stream, {
220
+ headers: {
221
+ 'Content-Type': 'text/event-stream',
222
+ 'Cache-Control': 'no-cache',
223
+ 'X-Request-Id': requestId,
224
+ 'X-Agent-Slug': agent.slug,
225
+ 'X-Agent-Hosting': agent.sandboxEndpoint ? 'sovereign' : 'centralized',
226
+ 'X-Payment-Method': paymentMethod,
227
+ 'X-Payment-Settled': paymentMethod === 'x402' ? 'pending' : 'true',
228
+ ...(rateLimitRemaining !== undefined
229
+ ? { 'X-RateLimit-Remaining': String(rateLimitRemaining) }
230
+ : {}),
231
+ },
232
+ })
389
233
  }
234
+
package/src/types.ts CHANGED
@@ -21,6 +21,52 @@ export interface AgentMeta {
21
21
  remoteBearerToken: string | null
22
22
  /** Whether agent is published and accepting requests */
23
23
  enabled: boolean
24
+ /**
25
+ * CLI harness backend that runs this agent inside the sandbox sidecar.
26
+ *
27
+ * When set, the host's `getSandbox()` SHOULD return a `SandboxBox`
28
+ * whose `streamPrompt` POSTs to the sidecar's
29
+ * `POST /agent/invoke/chat/completions` endpoint with
30
+ * `model: "<harness>/<harnessModel>"` — that endpoint runs the
31
+ * harness against the sandbox workspace and streams OpenAI-shape
32
+ * `chat.completion.chunk` frames back.
33
+ *
34
+ * When unset (legacy / template mode), the host's `streamPrompt`
35
+ * falls back to the template's own `/api/chat/completions` (proxied
36
+ * via the sidecar's `/agent/invoke`).
37
+ *
38
+ * Known harnesses (registered in agent-dev-container's
39
+ * cli-agent-bindings.ts): opencode, claude-code, codex, kimi-code,
40
+ * amp, factory-droids, pi, hermes, openclaw, forge, acp, cursor.
41
+ * Aliases the sidecar canonicalizes: claude → claude-code,
42
+ * kimi → kimi-code, factory → factory-droids.
43
+ */
44
+ harness?: string
45
+ /**
46
+ * Model identifier to pass after the harness in the
47
+ * `<harness>/<model>` slash form. Format is harness-specific:
48
+ * claude-code: "sonnet", "opus", or a versioned id like
49
+ * "claude-sonnet-4-20250514"
50
+ * opencode: "anthropic/claude-sonnet-4-5", "openai/gpt-4o", …
51
+ * (opencode embeds provider before model)
52
+ * codex: "gpt-5-codex"
53
+ * kimi-code: "kimi-for-coding"
54
+ *
55
+ * Only meaningful when `harness` is set; ignored otherwise.
56
+ */
57
+ harnessModel?: string
58
+ /**
59
+ * Optional human description surfaced in the A2A Agent Card. Defaults to
60
+ * `"{slug} agent"` when absent.
61
+ */
62
+ description?: string
63
+ /**
64
+ * Optional A2A skill descriptors. Each entry advertises what the agent
65
+ * can do so non-Tangle A2A clients can select agents by capability. When
66
+ * absent, the gateway synthesizes a single default `chat` skill from
67
+ * `slug` + `description`.
68
+ */
69
+ skills?: import('./a2a/types').AgentSkill[]
24
70
  }
25
71
 
26
72
  // --- Payment ---
@@ -107,6 +153,15 @@ export interface SandboxStreamEvent {
107
153
  part?: { type?: string; text?: string }
108
154
  delta?: string
109
155
  finalText?: string
156
+ /**
157
+ * Optional sandbox-side signal that the agent has paused and is waiting
158
+ * for additional input from the caller. The A2A gateway translates this
159
+ * into an `input-required` task status; the caller can then submit a
160
+ * follow-up `message/send` with the same `taskId` to continue. Ignored
161
+ * by the OpenAI-compat path. Carry an optional `prompt` to surface to
162
+ * the caller (rendered as the input-required message body).
163
+ */
164
+ inputRequired?: { prompt?: string }
110
165
  }
111
166
  }
112
167
 
@@ -184,6 +239,46 @@ export interface GatewayConfig {
184
239
  * ConsoleObserver / CompositeObserver implementations.
185
240
  */
186
241
  observer?: import('./observer').GatewayObserver
242
+
243
+ /**
244
+ * A2A protocol configuration. When set, the gateway exposes the A2A
245
+ * surface alongside its OpenAI-compatible endpoints:
246
+ * GET /:slug/.well-known/agent.json — AgentCard discovery
247
+ * POST /:slug — JSON-RPC 2.0 endpoint
248
+ * methods: message/send, message/stream, tasks/get, tasks/cancel
249
+ * Auth + rate-limit + injection-filter + authorization all share the
250
+ * same pipeline as the OpenAI-compat path. `taskStore` defaults to
251
+ * `InMemoryTaskStore`; swap in D1/postgres/DO for durable deployments.
252
+ */
253
+ a2a?: {
254
+ /**
255
+ * Where tasks live. Defaults to `InMemoryTaskStore`; swap in
256
+ * `SqlTaskStore` (D1, postgres, sqlite, libSQL) for durability across
257
+ * gateway restarts.
258
+ */
259
+ taskStore?: import('./a2a/task-store').TaskStore
260
+ /**
261
+ * Where push notification configs live. When set, the gateway advertises
262
+ * `capabilities.pushNotifications: true` and exposes the four
263
+ * `tasks/pushNotificationConfig/*` JSON-RPC methods. Defaults to
264
+ * undefined (push support disabled), so the agent card honestly reflects
265
+ * what the gateway will actually do.
266
+ */
267
+ pushStore?: import('./a2a/push-notifications').PushNotificationStore
268
+ /**
269
+ * Shared HMAC secret used to sign webhook deliveries (`X-A2A-Signature:
270
+ * sha256=<hex>`). The consumer's webhook verifies the body against this
271
+ * secret to confirm the call originated from this gateway. Required when
272
+ * `pushStore` is set; without it, deliveries fire unsigned and a
273
+ * malicious party that knows the webhook URL can forge deliveries.
274
+ */
275
+ webhookSecret?: string
276
+ /**
277
+ * Optional fetcher override for webhook delivery. Defaults to global
278
+ * `fetch`. Override for tests or to wire a queue-backed sender.
279
+ */
280
+ pushFetcher?: typeof fetch
281
+ }
187
282
  }
188
283
 
189
284
  // --- Chat completion types (OpenAI-compatible) ---