experimental-a2 0.18.1 → 0.20.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 (143) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/actor.js +1 -1
  3. package/dist/{ai-BMLlI2Ff.d.ts → ai-Cz3hqVhk.d.ts} +33 -16
  4. package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
  5. package/dist/ai-client.d.ts +1 -1
  6. package/dist/ai-client.js +1 -1
  7. package/dist/ai-control-CvLbW10B.js +46 -0
  8. package/dist/ai-control-CvLbW10B.js.map +1 -0
  9. package/dist/ai-fx.d.ts +1 -1
  10. package/dist/ai-fx.d.ts.map +1 -1
  11. package/dist/ai-fx.js +63 -13
  12. package/dist/ai-fx.js.map +1 -1
  13. package/dist/{ai-harness-B2suP2Qh.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
  14. package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
  15. package/dist/{ai-harness-BuLh_EYi.js → ai-harness-Clbg5ei7.js} +88 -15
  16. package/dist/ai-harness-Clbg5ei7.js.map +1 -0
  17. package/dist/{ai-D6mGlO8j.js → ai-mMiWul1T.js} +116 -77
  18. package/dist/ai-mMiWul1T.js.map +1 -0
  19. package/dist/ai-pi.d.ts +1 -1
  20. package/dist/ai-pi.d.ts.map +1 -1
  21. package/dist/ai-pi.js +56 -11
  22. package/dist/ai-pi.js.map +1 -1
  23. package/dist/ai-react.d.ts +1 -1
  24. package/dist/ai-react.js +1 -1
  25. package/dist/ai-server.d.ts +2 -2
  26. package/dist/ai-server.d.ts.map +1 -1
  27. package/dist/ai-server.js +123 -53
  28. package/dist/ai-server.js.map +1 -1
  29. package/dist/ai.d.ts +2 -2
  30. package/dist/ai.js +1 -1
  31. package/dist/internal-CscSY7K_.js.map +1 -1
  32. package/dist/scheduler-qstash.d.ts.map +1 -1
  33. package/dist/scheduler-qstash.js +5 -5
  34. package/dist/scheduler-qstash.js.map +1 -1
  35. package/dist/scheduler-vercel.d.ts.map +1 -1
  36. package/dist/scheduler-vercel.js +5 -7
  37. package/dist/scheduler-vercel.js.map +1 -1
  38. package/dist/server-DYAATFuT.d.ts.map +1 -1
  39. package/dist/{server-D-Y4pqwu.js → server-qyb5aJJG.js} +39 -25
  40. package/dist/server-qyb5aJJG.js.map +1 -0
  41. package/dist/server.js +1 -1
  42. package/dist/session-clear-DGI4jTQ5.js.map +1 -1
  43. package/dist/store-postgres.d.ts.map +1 -1
  44. package/dist/store-postgres.js +38 -136
  45. package/dist/store-postgres.js.map +1 -1
  46. package/docs/01-quickstart.mdx +7 -4
  47. package/docs/actors/01-introduction.mdx +1 -1
  48. package/docs/actors/meta.ts +1 -1
  49. package/docs/agents/02-tools.mdx +1 -1
  50. package/docs/agents/03-conversations.mdx +26 -14
  51. package/docs/agents/04-harnesses.mdx +51 -5
  52. package/docs/agents/meta.ts +1 -1
  53. package/docs/client/meta.ts +1 -1
  54. package/docs/contract/03-sessions.mdx +2 -2
  55. package/docs/index.mdx +2 -2
  56. package/docs/reference/01-core.mdx +8 -8
  57. package/docs/reference/02-server.mdx +3 -3
  58. package/docs/reference/04-ai.mdx +36 -19
  59. package/docs/reference/05-telemetry.mdx +132 -0
  60. package/docs/reference/06-errors.mdx +1 -1
  61. package/docs/reference/meta.ts +1 -1
  62. package/docs/schedulers/01-overview.mdx +112 -0
  63. package/docs/schedulers/02-vercel-queues.mdx +87 -0
  64. package/docs/schedulers/03-qstash.mdx +123 -0
  65. package/docs/schedulers/meta.ts +1 -0
  66. package/docs/server/01-handlers.mdx +29 -2
  67. package/docs/server/02-timers.mdx +3 -3
  68. package/docs/server/06-guarantees.mdx +6 -3
  69. package/docs/stores/01-overview.mdx +37 -0
  70. package/docs/stores/02-redis.mdx +49 -0
  71. package/docs/stores/03-redis-http.mdx +56 -0
  72. package/docs/stores/04-postgres.mdx +121 -0
  73. package/docs/stores/05-sqlite.mdx +34 -0
  74. package/docs/stores/06-memory.mdx +22 -0
  75. package/docs/stores/meta.ts +1 -0
  76. package/examples/playground/DEPLOY.md +19 -5
  77. package/examples/playground/app/a0/a0.css +244 -5
  78. package/examples/playground/app/a0/app-view.tsx +2 -1
  79. package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
  80. package/examples/playground/app/a0/attachments.ts +133 -0
  81. package/examples/playground/app/a0/chat.test.tsx +595 -0
  82. package/examples/playground/app/a0/chat.tsx +623 -63
  83. package/examples/playground/app/a0/context-menu.tsx +38 -11
  84. package/examples/playground/app/a0/model.test.ts +28 -0
  85. package/examples/playground/app/a0/model.ts +30 -0
  86. package/examples/playground/app/a0/server.ts +5 -0
  87. package/examples/playground/app/a0/shell.tsx +1 -1
  88. package/examples/playground/app/a0/uploads/route.test.ts +59 -0
  89. package/examples/playground/app/a0/uploads/route.ts +29 -0
  90. package/examples/playground/app/agent/[agentId]/agent-client.tsx +2 -1
  91. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -89
  92. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
  93. package/examples/playground/app/agent/ingress.test.ts +41 -17
  94. package/examples/playground/app/agent/model-input.test.tsx +92 -43
  95. package/examples/playground/app/agent/model.test.ts +12 -3
  96. package/examples/playground/app/chat/[chatId]/agent-state.test.ts +1 -5
  97. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
  98. package/examples/playground/app/chat/server.test.ts +38 -65
  99. package/examples/playground/app/components/activity-history.test.tsx +60 -18
  100. package/examples/playground/app/components/fast-link.test.tsx +3 -5
  101. package/examples/playground/app/initial-state.test.tsx +86 -45
  102. package/examples/playground/next.config.ts +7 -4
  103. package/examples/playground/package.json +4 -1
  104. package/examples/playground/test/agent.ts +35 -0
  105. package/examples/playground/test/model.ts +149 -0
  106. package/examples/playground/test/serve.ts +184 -0
  107. package/examples/playground/test/server-only.ts +1 -0
  108. package/examples/playground/vitest.config.ts +27 -0
  109. package/package.json +3 -1
  110. package/sql/postgres/notifications.sql +24 -0
  111. package/sql/postgres/schema.sql +137 -0
  112. package/src/ai-agent.ts +9 -4
  113. package/src/ai-client-state.ts +21 -9
  114. package/src/ai-context-schema.ts +28 -4
  115. package/src/ai-context.ts +83 -3
  116. package/src/ai-control-server.ts +70 -43
  117. package/src/ai-control-state.ts +3 -4
  118. package/src/ai-control.ts +59 -10
  119. package/src/ai-coordinator.ts +7 -6
  120. package/src/ai-fx.ts +95 -23
  121. package/src/ai-harness.ts +171 -29
  122. package/src/ai-message-shape.ts +41 -0
  123. package/src/ai-pi.ts +88 -32
  124. package/src/ai-stored-state.ts +1 -3
  125. package/src/ai.ts +116 -77
  126. package/src/errors.ts +1 -1
  127. package/src/internal.ts +2 -1
  128. package/src/libfx.d.ts +8 -1
  129. package/src/postgres-notifications.ts +0 -22
  130. package/src/postgres-resources.ts +6 -9
  131. package/src/postgres-schema.ts +80 -0
  132. package/src/scheduler-qstash.ts +12 -5
  133. package/src/scheduler-vercel.ts +11 -16
  134. package/src/server.ts +66 -32
  135. package/src/store-postgres.ts +5 -123
  136. package/dist/ai-BMLlI2Ff.d.ts.map +0 -1
  137. package/dist/ai-D6mGlO8j.js.map +0 -1
  138. package/dist/ai-harness-B2suP2Qh.d.ts.map +0 -1
  139. package/dist/ai-harness-BuLh_EYi.js.map +0 -1
  140. package/dist/server-D-Y4pqwu.js.map +0 -1
  141. package/docs/reference/05-adapters.mdx +0 -351
  142. package/docs/server/05-production.mdx +0 -323
  143. package/examples/playground/app/a0/no-photon.cjs +0 -4
package/src/ai-fx.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  type ToolSet,
13
13
  type UIMessageChunk,
14
14
  } from 'ai'
15
- import type { FxAgent, FxHostTool, FxTurn } from 'libfx'
15
+ import type { FxAgent, FxHostTool, FxPromptBlock, FxTurn } from 'libfx'
16
16
  import {
17
17
  type Deferred,
18
18
  defineHarness,
@@ -20,8 +20,11 @@ import {
20
20
  Divergence,
21
21
  gatewayCredential,
22
22
  type GatewayCredential,
23
+ gatewaySessionAffinity,
23
24
  type Harness,
24
25
  type HarnessGeneration,
26
+ harnessImages,
27
+ type HarnessInput,
25
28
  type HarnessRead,
26
29
  type HarnessStep,
27
30
  type HarnessTurn,
@@ -48,6 +51,8 @@ export function fx(): Harness<'fx', never> {
48
51
 
49
52
  const SNAPSHOT_VERSION = 1
50
53
  const CATALOG_TTL_MS = 60 * 60 * 1000
54
+ /** The Gateway model catalog libfx reads for each new kernel. */
55
+ const CATALOG_PATH = '/coding-agent/v1/models'
51
56
 
52
57
  type StreamPart = { type: string } & Record<string, unknown>
53
58
  /** A recorded tool call, with the input the kernel reads from its text. */
@@ -62,7 +67,7 @@ type RecordedCall = {
62
67
 
63
68
  /** One FX turn to replay: its prompt, recorded calls, and how it ended. */
64
69
  type TurnPlan = {
65
- prompt: string
70
+ prompt: string | FxPromptBlock[]
66
71
  calls: RecordedCall[]
67
72
  /** Steering to queue while serving the call at that index. */
68
73
  steers: Map<number, string[]>
@@ -188,7 +193,7 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
188
193
  try {
189
194
  checkpoint = await agent!.checkpoint()
190
195
  } catch {
191
- // Over libfx's bound: later steps replay from the previous snapshot.
196
+ // Later steps replay from the previous snapshot.
192
197
  return
193
198
  }
194
199
  await saveHarness({
@@ -206,8 +211,10 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
206
211
  init: RequestInit = {},
207
212
  ): Promise<Response> => {
208
213
  const url = input instanceof Request ? input.url : String(input)
209
- if ((init.method ?? 'GET').toUpperCase() === 'GET')
214
+ const method = (init.method ?? 'GET').toUpperCase()
215
+ if (method === 'GET' && new URL(url).pathname === CATALOG_PATH)
210
216
  return catalog(url, credential, withAuth(init.headers, credential))
217
+ if (method === 'GET') return fetch(input, init)
211
218
  const state = turn!
212
219
  if (state.failure) throw state.failure
213
220
  const body = decodeBody(init.body)
@@ -238,19 +245,27 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
238
245
  return pending(init.signal)
239
246
  }
240
247
  state.live = true
241
- return live(state, url, init, compaction)
248
+ return live(state, url, { ...init, body }, compaction)
242
249
  }
243
250
 
251
+ const affinity = gatewaySessionAffinity({
252
+ agentName: step.agentName,
253
+ sessionId: step.sessionId,
254
+ })
244
255
  let compactions = 0
245
256
  const live = async (
246
257
  state: TurnState,
247
258
  url: string,
248
- init: RequestInit,
259
+ init: RequestInit & { body: string },
249
260
  compaction: boolean,
250
261
  ): Promise<Response> => {
262
+ const headers = withAuth(init.headers, credential)
263
+ // The kernel's own affinity is new with every rebuild; A2's lasts the session.
264
+ headers.set('x-session-affinity', affinity)
251
265
  const response = await fetch(url, {
252
266
  ...init,
253
- headers: withAuth(init.headers, credential),
267
+ body: withGatewayCaching(init.body),
268
+ headers,
254
269
  signal: network,
255
270
  })
256
271
  // Abandoned before its headers came: the kernel has moved on without it.
@@ -328,7 +343,8 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
328
343
  return fail(state, diverged(`no recorded result for ${toolName}`))
329
344
  })
330
345
 
331
- let turns = read.turns.map(turnPlan)
346
+ const plans = (turns: HarnessTurn[]): Promise<TurnPlan[]> =>
347
+ Promise.all(turns.map((recorded) => turnPlan(recorded, step.signal)))
332
348
  let results = read.results
333
349
  let controller: ReadableStreamDefaultController<UIMessageChunk> | undefined
334
350
  let closed = false
@@ -364,11 +380,10 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
364
380
  if (read.saved === undefined) throw error
365
381
  // An unusable checkpoint: replay the whole log instead.
366
382
  read = await replayAll()
367
- turns = read.turns.map(turnPlan)
368
383
  results = read.results
369
384
  agent = await create(undefined)
370
385
  }
371
- await run(streamController, turns)
386
+ await run(streamController, await plans(read.turns))
372
387
  closed = true
373
388
  streamController.close()
374
389
  } catch (error) {
@@ -396,11 +411,14 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
396
411
 
397
412
  // ------------------------------------------------------------ the plan
398
413
 
399
- function turnPlan(turn: HarnessTurn): TurnPlan {
414
+ async function turnPlan(
415
+ turn: HarnessTurn,
416
+ signal: AbortSignal,
417
+ ): Promise<TurnPlan> {
400
418
  const calls: RecordedCall[] = []
401
419
  const steers = new Map<number, string[]>()
402
- const steer = (at: number, texts: string[] | undefined) => {
403
- if (texts !== undefined) steers.set(Math.max(0, at), texts)
420
+ const steer = (at: number, inputs: HarnessInput[] | undefined) => {
421
+ if (inputs !== undefined) steers.set(Math.max(0, at), inputs.map(steerText))
404
422
  }
405
423
  steer(0, turn.generations.length === 0 ? turn.steers.get(0) : undefined)
406
424
  turn.generations.forEach((generation, index) => {
@@ -408,7 +426,38 @@ function turnPlan(turn: HarnessTurn): TurnPlan {
408
426
  // A generation's step is its last call.
409
427
  steer(calls.length - 1, turn.steers.get(index))
410
428
  })
411
- return { prompt: turn.prompt, calls, steers, end: turn.end }
429
+ const images = await harnessImages({
430
+ harness: 'fx()',
431
+ input: turn.prompt,
432
+ signal,
433
+ })
434
+ return {
435
+ prompt:
436
+ images.length === 0
437
+ ? turn.prompt.text
438
+ : [
439
+ ...images.map((image): FxPromptBlock => ({
440
+ type: 'image',
441
+ data: image.data,
442
+ mimeType: image.mediaType,
443
+ })),
444
+ ...(turn.prompt.text === ''
445
+ ? []
446
+ : [{ type: 'text' as const, text: turn.prompt.text }]),
447
+ ],
448
+ calls,
449
+ steers,
450
+ end: turn.end,
451
+ }
452
+ }
453
+
454
+ /** libfx steers with text alone, so a steer's files fail the step. */
455
+ function steerText(input: HarnessInput): string {
456
+ if (input.files.length > 0)
457
+ throw new Error(
458
+ 'fx() steers with text only: send files in a message of their own',
459
+ )
460
+ return input.text
412
461
  }
413
462
 
414
463
  /** A completed generation's model calls: its compactions, then its step. */
@@ -734,6 +783,31 @@ async function loadLibfx(): Promise<typeof import('libfx')> {
734
783
  }
735
784
  }
736
785
 
786
+ /**
787
+ * A model request with Gateway's automatic caching, as the AI SDK harness
788
+ * asks for it; options the request already has take precedence. Every other
789
+ * byte stays as the kernel wrote it, a tool input's spacing included.
790
+ */
791
+ function withGatewayCaching(body: string): string {
792
+ const request = JSON.parse(body) as {
793
+ providerOptions?: Record<string, Record<string, unknown> | undefined>
794
+ }
795
+ const options = request.providerOptions
796
+ if (options === undefined)
797
+ return body.replace(
798
+ /^\{/,
799
+ '{"providerOptions":{"gateway":{"caching":"auto"}},',
800
+ )
801
+ // The kernel writes its own options last; any other shape goes unchanged.
802
+ const own = `"providerOptions":${JSON.stringify(options)}}`
803
+ if (!body.endsWith(own)) return body
804
+ const merged = {
805
+ ...options,
806
+ gateway: { caching: 'auto', ...options['gateway'] },
807
+ }
808
+ return `${body.slice(0, -own.length)}"providerOptions":${JSON.stringify(merged)}}`
809
+ }
810
+
737
811
  function withAuth(
738
812
  headers: HeadersInit | undefined,
739
813
  credential: GatewayCredential,
@@ -764,15 +838,13 @@ async function kernelTools(
764
838
  execute: (toolName: string, input: unknown) => unknown,
765
839
  ): Promise<FxHostTool[]> {
766
840
  return Promise.all(
767
- Object.entries(tools)
768
- .filter(([, tool]) => tool.type !== 'provider')
769
- .map(async ([toolName, tool]) => ({
770
- name: toolName,
771
- description:
772
- typeof tool.description === 'string' ? tool.description : toolName,
773
- inputSchema: (await asSchema(tool.inputSchema).jsonSchema) as object,
774
- execute: (input: unknown) => execute(toolName, input),
775
- })),
841
+ Object.entries(tools).map(async ([toolName, tool]) => ({
842
+ name: toolName,
843
+ description:
844
+ typeof tool.description === 'string' ? tool.description : toolName,
845
+ inputSchema: (await asSchema(tool.inputSchema).jsonSchema) as object,
846
+ execute: (input: unknown) => execute(toolName, input),
847
+ })),
776
848
  )
777
849
  }
778
850
 
package/src/ai-harness.ts CHANGED
@@ -1,16 +1,21 @@
1
- import type {
2
- Experimental_SandboxSession,
3
- FinishReason,
4
- LanguageModelUsage,
5
- ToolSet,
6
- UIMessage,
7
- UIMessageChunk,
1
+ import {
2
+ createDownload,
3
+ type Experimental_SandboxSession,
4
+ type FileUIPart,
5
+ type FinishReason,
6
+ type LanguageModelUsage,
7
+ type ToolSet,
8
+ type UIMessage,
9
+ type UIMessageChunk,
8
10
  } from 'ai'
9
11
  import type {
10
12
  GenerationCompletedPayload,
13
+ MessageCreatedPayload,
11
14
  ReasoningLevel,
12
15
  ToolResultPayload,
13
16
  } from './ai.ts'
17
+ import { createHash } from 'node:crypto'
18
+ import { STEERED_REASON } from './ai-control.ts'
14
19
  import { boundedCache, type BoundedCache } from './bounded-cache.ts'
15
20
  import type { ContractEvent, EventDefs } from './contract.ts'
16
21
 
@@ -307,21 +312,21 @@ function savedEnvelope(value: unknown): SavedEnvelope | undefined {
307
312
 
308
313
  // ------------------------------------------------- the log as harness turns
309
314
 
310
- // A2's reason on `ai.message.interrupted` when a steer ends a response.
311
- const STEERED = 'steered after step'
312
-
313
315
  /** A completed generation as the log recorded it. */
314
316
  export type RecordedGeneration = {
315
317
  chunks: UIMessageChunk[]
316
318
  completed: GenerationCompletedPayload
317
319
  }
318
320
 
321
+ /** What user messages hand a harness: their text, and the files they attach. */
322
+ export type HarnessInput = { text: string; files: FileUIPart[] }
323
+
319
324
  /** One harness turn to rebuild: its prompt, completed generations, and end. */
320
325
  export type HarnessTurn = {
321
- prompt: string
326
+ prompt: HarnessInput
322
327
  generations: RecordedGeneration[]
323
328
  /** Steering that entered after the generation at that index. */
324
- steers: Map<number, string[]>
329
+ steers: Map<number, HarnessInput[]>
325
330
  end: 'stop' | 'interrupted' | 'live'
326
331
  }
327
332
 
@@ -342,15 +347,18 @@ export function harnessTurns(
342
347
  }
343
348
  type Response = {
344
349
  id: string
345
- prompt: string[]
350
+ prompt: HarnessInput[]
346
351
  generations: Generation[]
347
352
  end?: 'stop' | 'steered' | 'interrupted'
348
353
  }
349
- // Queued messages are admitted together, possibly before the snapshot.
350
- const userText = new Map<string, string>(
354
+ // Prompts take their content from the transcript: a harness agent never
355
+ // runs A2's compaction, so the step's messages hold every input admitted by
356
+ // its read, before the snapshot or after it. A session another loop
357
+ // compacted can't move to a harness.
358
+ const userInput = new Map<string, HarnessInput>(
351
359
  step.messages
352
360
  .filter((message) => message.role === 'user')
353
- .map((message) => [message.id, textOf(message)]),
361
+ .map((message) => [message.id, inputOf(message)]),
354
362
  )
355
363
  let passive: string[] = []
356
364
  const responses = new Map<string, Response>()
@@ -362,10 +370,9 @@ export function harnessTurns(
362
370
  const payload = event.payload as Record<string, unknown>
363
371
  switch (event.type) {
364
372
  case 'ai.message.created': {
365
- const message = payload['message'] as UIMessage
366
- if (message.role !== 'user') break
367
- userText.set(message.id, textOf(message))
368
- if (payload['generate'] === false) passive.push(message.id)
373
+ const admitted = event.payload as MessageCreatedPayload
374
+ if (admitted.role === 'user' && admitted.generate === false)
375
+ passive.push(admitted.messageId)
369
376
  break
370
377
  }
371
378
  case 'ai.generation.requested': {
@@ -376,7 +383,7 @@ export function harnessTurns(
376
383
  passive = []
377
384
  responses.set(id, {
378
385
  id,
379
- prompt: ids.flatMap((p) => userText.get(p) ?? []),
386
+ prompt: ids.flatMap((p) => userInput.get(p) ?? []),
380
387
  generations: [],
381
388
  })
382
389
  break
@@ -420,7 +427,7 @@ export function harnessTurns(
420
427
  const response = responses.get(payload['messageId'] as string)
421
428
  if (response)
422
429
  response.end =
423
- payload['reason'] === STEERED ? 'steered' : 'interrupted'
430
+ payload['reason'] === STEERED_REASON ? 'steered' : 'interrupted'
424
431
  break
425
432
  }
426
433
  }
@@ -429,13 +436,16 @@ export function harnessTurns(
429
436
  const turns: HarnessTurn[] = []
430
437
  let steered = false
431
438
  for (const response of responses.values()) {
432
- const text = response.prompt.join('\n\n')
439
+ const input: HarnessInput = {
440
+ text: response.prompt.map((prompt) => prompt.text).join('\n\n'),
441
+ files: response.prompt.flatMap((prompt) => prompt.files),
442
+ }
433
443
  let turn = turns.at(-1)
434
444
  if (steered && turn !== undefined) {
435
445
  const at = Math.max(0, turn.generations.length - 1)
436
- turn.steers.set(at, [...(turn.steers.get(at) ?? []), text])
446
+ turn.steers.set(at, [...(turn.steers.get(at) ?? []), input])
437
447
  } else {
438
- turn = { prompt: text, generations: [], steers: new Map(), end: 'live' }
448
+ turn = { prompt: input, generations: [], steers: new Map(), end: 'live' }
439
449
  turns.push(turn)
440
450
  }
441
451
  for (const generation of response.generations)
@@ -455,10 +465,125 @@ export function harnessTurns(
455
465
  return { turns, results }
456
466
  }
457
467
 
458
- function textOf(message: UIMessage): string {
459
- return message.parts
460
- .flatMap((part) => (part.type === 'text' ? [part.text] : []))
461
- .join('\n')
468
+ function inputOf(message: UIMessage): HarnessInput {
469
+ return {
470
+ text: message.parts
471
+ .flatMap((part) => (part.type === 'text' ? [part.text] : []))
472
+ .join('\n'),
473
+ files: message.parts.filter(
474
+ (part): part is FileUIPart => part.type === 'file',
475
+ ),
476
+ }
477
+ }
478
+
479
+ // ------------------------------------------------------------- attachments
480
+
481
+ /** An attached image as an engine takes it: its bytes in base64. */
482
+ export type HarnessImage = { mediaType: string; data: string }
483
+
484
+ // What one message's files hold in a step's memory, at most.
485
+ const MAX_ATTACHMENT_BYTES = 32 * 1024 * 1024
486
+
487
+ type Attachment = { data: string; bytes: number }
488
+
489
+ // Every step of a response replays its turn, and a URL's file doesn't change,
490
+ // so the process keeps what it fetched.
491
+ const attachments = boundedCache<string, Attachment>({
492
+ maxEntries: 256,
493
+ maxBytes: 64 * 1024 * 1024,
494
+ })
495
+
496
+ /**
497
+ * The images an input attaches, as its engine takes them: engines take
498
+ * bytes, not URLs. A file it can't attach fails the step, naming the file,
499
+ * and is never left out: the failure may pass, and the step is retried like
500
+ * any other.
501
+ */
502
+ export async function harnessImages({
503
+ harness,
504
+ input,
505
+ signal,
506
+ }: {
507
+ /** As the error names it: `fx()` or `pi()`. */
508
+ harness: string
509
+ input: HarnessInput
510
+ signal: AbortSignal
511
+ }): Promise<HarnessImage[]> {
512
+ const images: HarnessImage[] = []
513
+ let budget = MAX_ATTACHMENT_BYTES
514
+ // One at a time, so the budget bounds what the step holds.
515
+ for (const file of input.files) {
516
+ try {
517
+ if (!file.mediaType.startsWith('image/'))
518
+ throw new Error(`it takes images only, not ${file.mediaType}`)
519
+ const attached = await attachment({ url: file.url, budget, signal })
520
+ budget -= attached.bytes
521
+ images.push({
522
+ mediaType: file.mediaType,
523
+ data: attached.data,
524
+ })
525
+ } catch (error) {
526
+ if (signal.aborted) throw error
527
+ throw new Error(
528
+ `${harness} couldn't attach ${file.filename ?? 'a file'}: ${
529
+ error instanceof Error ? error.message : String(error)
530
+ }`,
531
+ { cause: error },
532
+ )
533
+ }
534
+ }
535
+ return images
536
+ }
537
+
538
+ async function attachment({
539
+ url,
540
+ budget,
541
+ signal,
542
+ }: {
543
+ url: string
544
+ budget: number
545
+ signal: AbortSignal
546
+ }): Promise<Attachment> {
547
+ const tooLarge = () =>
548
+ new Error(
549
+ `a message's files may hold ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MiB in a step`,
550
+ )
551
+ const cached = attachments.get(url)
552
+ if (cached !== undefined) {
553
+ if (cached.bytes > budget) throw tooLarge()
554
+ return cached
555
+ }
556
+ const { protocol } = new URL(url)
557
+ if (protocol !== 'https:' && protocol !== 'data:')
558
+ throw new Error(`attachments must be https: or data: URLs, not ${protocol}`)
559
+ // AI SDK's download checks an https: URL's address and its redirects,
560
+ // which a message's sender chooses; a data: URL holds its bytes.
561
+ const bytes =
562
+ protocol === 'data:'
563
+ ? dataBytes(url)
564
+ : (
565
+ await createDownload({ maxBytes: budget })({
566
+ url: new URL(url),
567
+ abortSignal: signal,
568
+ })
569
+ ).data
570
+ if (bytes.byteLength > budget) throw tooLarge()
571
+ const attached: Attachment = {
572
+ data: Buffer.from(bytes).toString('base64'),
573
+ bytes: bytes.byteLength,
574
+ }
575
+ if (protocol === 'https:')
576
+ attachments.set(url, attached, attached.data.length)
577
+ return attached
578
+ }
579
+
580
+ function dataBytes(url: string): Uint8Array {
581
+ const comma = url.indexOf(',')
582
+ if (comma === -1) throw new Error('its data: URL has no data')
583
+ const body = url.slice(comma + 1)
584
+ return url.slice(0, comma).endsWith(';base64')
585
+ ? Buffer.from(body, 'base64')
586
+ : Buffer.from(decodeURIComponent(body))
462
587
  }
463
588
 
464
589
  // ------------------------------------------------------------- Gateway
@@ -474,6 +599,23 @@ export function gatewayCredential(harness: string): GatewayCredential {
474
599
  throw new Error(`${harness}() needs AI_GATEWAY_API_KEY or VERCEL_OIDC_TOKEN`)
475
600
  }
476
601
 
602
+ /**
603
+ * The `x-session-affinity` an agent session's Gateway calls carry: stable for
604
+ * the session, so supporting providers can reuse their cache, and free of its
605
+ * raw ID.
606
+ */
607
+ export function gatewaySessionAffinity({
608
+ agentName,
609
+ sessionId,
610
+ }: {
611
+ agentName: string
612
+ sessionId: string
613
+ }): string {
614
+ return createHash('sha256')
615
+ .update(JSON.stringify([agentName, sessionId]))
616
+ .digest('hex')
617
+ }
618
+
477
619
  // Where Vercel Functions keep the current request, as @vercel/oidc reads it.
478
620
  const REQUEST_CONTEXT = Symbol.for('@vercel/request-context')
479
621
 
@@ -0,0 +1,41 @@
1
+ import type { UIMessage } from 'ai'
2
+
3
+ export const isRecord = (value: unknown): value is Record<string, unknown> =>
4
+ typeof value === 'object' && value !== null && !Array.isArray(value)
5
+
6
+ export const isJSONCompatible = (
7
+ value: unknown,
8
+ seen: Set<object> = new Set(),
9
+ ): boolean => {
10
+ if (
11
+ value === null ||
12
+ typeof value === 'string' ||
13
+ typeof value === 'boolean'
14
+ ) {
15
+ return true
16
+ }
17
+ if (typeof value === 'number') return Number.isFinite(value)
18
+ if (typeof value === 'undefined') return true
19
+ if (typeof value !== 'object') return false
20
+ if (seen.has(value)) return false
21
+ seen.add(value)
22
+ const valid = Array.isArray(value)
23
+ ? value.every((item) => isJSONCompatible(item, seen))
24
+ : Object.getPrototypeOf(value) === Object.prototype &&
25
+ Object.values(value).every((item) => isJSONCompatible(item, seen))
26
+ seen.delete(value)
27
+ return valid
28
+ }
29
+
30
+ /** Whether a value has an AI SDK `UIMessage`'s shape. */
31
+ export const isUIMessage = (value: unknown): value is UIMessage =>
32
+ isRecord(value) &&
33
+ typeof value['id'] === 'string' &&
34
+ (value['role'] === 'user' ||
35
+ value['role'] === 'assistant' ||
36
+ value['role'] === 'system') &&
37
+ Array.isArray(value['parts']) &&
38
+ value['parts'].every(
39
+ (part) => isRecord(part) && typeof part['type'] === 'string',
40
+ ) &&
41
+ isJSONCompatible(value)