experimental-a2 0.18.0 → 0.19.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 (152) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/{actor-Bjce9_5U.d.ts → actor-C7b-1arn.d.ts} +3 -3
  3. package/dist/{actor-Bjce9_5U.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
  4. package/dist/actor-client.d.ts +1 -1
  5. package/dist/actor-react.d.ts +1 -1
  6. package/dist/actor-react.js +1 -1
  7. package/dist/actor.d.ts +1 -1
  8. package/dist/actor.js +2 -2
  9. package/dist/{ai-DrUaMyRt.d.ts → ai-Cz3hqVhk.d.ts} +34 -17
  10. package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
  11. package/dist/ai-client.d.ts +1 -1
  12. package/dist/ai-client.js +2 -2
  13. package/dist/ai-control-CvLbW10B.js +46 -0
  14. package/dist/ai-control-CvLbW10B.js.map +1 -0
  15. package/dist/ai-fx.d.ts +1 -1
  16. package/dist/ai-fx.d.ts.map +1 -1
  17. package/dist/ai-fx.js +122 -44
  18. package/dist/ai-fx.js.map +1 -1
  19. package/dist/{ai-harness-BuLh_EYi.js → ai-harness-BuuC-gJ3.js} +79 -15
  20. package/dist/ai-harness-BuuC-gJ3.js.map +1 -0
  21. package/dist/{ai-harness-DvsPeJeB.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
  22. package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
  23. package/dist/{ai-BpOGm-zB.js → ai-mMiWul1T.js} +117 -78
  24. package/dist/ai-mMiWul1T.js.map +1 -0
  25. package/dist/ai-pi.d.ts +1 -1
  26. package/dist/ai-pi.d.ts.map +1 -1
  27. package/dist/ai-pi.js +114 -23
  28. package/dist/ai-pi.js.map +1 -1
  29. package/dist/ai-react.d.ts +1 -1
  30. package/dist/ai-react.js +2 -2
  31. package/dist/ai-server.d.ts +3 -3
  32. package/dist/ai-server.d.ts.map +1 -1
  33. package/dist/ai-server.js +127 -55
  34. package/dist/ai-server.js.map +1 -1
  35. package/dist/ai-tool-input-DmSqMUQi.js +40 -0
  36. package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
  37. package/dist/ai.d.ts +2 -2
  38. package/dist/ai.js +1 -1
  39. package/dist/{client-BReKqwZC.js → client-BhHLoNKF.js} +3 -3
  40. package/dist/{client-BReKqwZC.js.map → client-BhHLoNKF.js.map} +1 -1
  41. package/dist/client.js +1 -1
  42. package/dist/index.d.ts +2 -2
  43. package/dist/{internal-B2T3U4Rq.js → internal-CscSY7K_.js} +4 -2
  44. package/dist/{internal-B2T3U4Rq.js.map → internal-CscSY7K_.js.map} +1 -1
  45. package/dist/react.js +1 -1
  46. package/dist/scheduler-qstash.d.ts +2 -2
  47. package/dist/scheduler-qstash.js +3 -3
  48. package/dist/scheduler-qstash.js.map +1 -1
  49. package/dist/scheduler-vercel.d.ts +7 -7
  50. package/dist/scheduler-vercel.d.ts.map +1 -1
  51. package/dist/scheduler-vercel.js +10 -14
  52. package/dist/scheduler-vercel.js.map +1 -1
  53. package/dist/{server-Dw_k_7kO.js → server-D-Y4pqwu.js} +37 -11
  54. package/dist/server-D-Y4pqwu.js.map +1 -0
  55. package/dist/{server-En7cbi3v.d.ts → server-DYAATFuT.d.ts} +11 -5
  56. package/dist/server-DYAATFuT.d.ts.map +1 -0
  57. package/dist/server.d.ts +2 -2
  58. package/dist/server.js +1 -1
  59. package/dist/{session-clear-Bo-XTFEy.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
  60. package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
  61. package/dist/{store-Djdv0bnZ.d.ts → store-CyLUy86v.d.ts} +23 -4
  62. package/dist/store-CyLUy86v.d.ts.map +1 -0
  63. package/dist/store-N8PXxDAS.js.map +1 -1
  64. package/dist/store-memory.d.ts +1 -1
  65. package/dist/store-memory.js +25 -6
  66. package/dist/store-memory.js.map +1 -1
  67. package/dist/{store-polling-B5FcwJfo.js → store-polling-7WJV0YWP.js} +2 -2
  68. package/dist/{store-polling-B5FcwJfo.js.map → store-polling-7WJV0YWP.js.map} +1 -1
  69. package/dist/store-postgres.d.ts +1 -1
  70. package/dist/store-postgres.d.ts.map +1 -1
  71. package/dist/store-postgres.js +55 -25
  72. package/dist/store-postgres.js.map +1 -1
  73. package/dist/{store-presence-polling-BAwS8RWn.js → store-presence-polling-B1ixeqIS.js} +3 -3
  74. package/dist/{store-presence-polling-BAwS8RWn.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
  75. package/dist/store-redis-http.d.ts +1 -1
  76. package/dist/store-redis-http.js +3 -3
  77. package/dist/{store-redis-notify-BRITti_o.js → store-redis-notify-CM0AfLY0.js} +88 -37
  78. package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
  79. package/dist/store-redis.d.ts +1 -1
  80. package/dist/store-redis.js +1 -1
  81. package/dist/store-sqlite.d.ts +1 -1
  82. package/dist/store-sqlite.js +35 -20
  83. package/dist/store-sqlite.js.map +1 -1
  84. package/dist/{wire-rSUglYo8.js → wire-BKxH1vbP.js} +2 -2
  85. package/dist/{wire-rSUglYo8.js.map → wire-BKxH1vbP.js.map} +1 -1
  86. package/docs/agents/03-conversations.mdx +27 -15
  87. package/docs/agents/04-harnesses.mdx +51 -5
  88. package/docs/contract/03-sessions.mdx +1 -1
  89. package/docs/index.mdx +3 -3
  90. package/docs/reference/02-server.mdx +12 -7
  91. package/docs/reference/04-ai.mdx +41 -23
  92. package/docs/reference/05-adapters.mdx +6 -6
  93. package/docs/server/01-handlers.mdx +1 -1
  94. package/docs/server/02-timers.mdx +1 -1
  95. package/docs/server/03-cancellation.mdx +3 -2
  96. package/docs/server/05-production.mdx +4 -3
  97. package/docs/server/06-guarantees.mdx +20 -11
  98. package/examples/playground/DEPLOY.md +11 -4
  99. package/examples/playground/app/a0/[appId]/page.tsx +2 -8
  100. package/examples/playground/app/a0/a0.css +244 -5
  101. package/examples/playground/app/a0/app-view.tsx +2 -1
  102. package/examples/playground/app/a0/attachments.ts +133 -0
  103. package/examples/playground/app/a0/chat.test.tsx +504 -0
  104. package/examples/playground/app/a0/chat.tsx +623 -63
  105. package/examples/playground/app/a0/context-menu.tsx +38 -11
  106. package/examples/playground/app/a0/devtools.tsx +2 -8
  107. package/examples/playground/app/a0/model.test.ts +28 -0
  108. package/examples/playground/app/a0/model.ts +30 -0
  109. package/examples/playground/app/a0/server.ts +13 -19
  110. package/examples/playground/app/a0/uploads/route.ts +29 -0
  111. package/examples/playground/app/agent/[agentId]/agent-client.tsx +2 -1
  112. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +1 -0
  113. package/examples/playground/app/agent/model.test.ts +12 -3
  114. package/examples/playground/app/chat/[chatId]/agent-state.test.ts +1 -5
  115. package/examples/playground/next.config.ts +7 -4
  116. package/examples/playground/package.json +3 -1
  117. package/package.json +1 -1
  118. package/src/ai-agent.ts +14 -3
  119. package/src/ai-client-state.ts +21 -9
  120. package/src/ai-context-schema.ts +28 -4
  121. package/src/ai-context.ts +83 -3
  122. package/src/ai-control-server.ts +70 -43
  123. package/src/ai-control-state.ts +3 -4
  124. package/src/ai-control.ts +59 -10
  125. package/src/ai-coordinator.ts +7 -6
  126. package/src/ai-fx.ts +184 -67
  127. package/src/ai-harness.ts +153 -29
  128. package/src/ai-message-shape.ts +41 -0
  129. package/src/ai-pi.ts +161 -46
  130. package/src/ai-stored-state.ts +1 -3
  131. package/src/ai-tool-input.ts +45 -0
  132. package/src/ai.ts +116 -77
  133. package/src/internal.ts +6 -0
  134. package/src/libfx.d.ts +8 -1
  135. package/src/scheduler-qstash.ts +5 -5
  136. package/src/scheduler-vercel.ts +15 -27
  137. package/src/server.ts +58 -12
  138. package/src/store-memory.ts +50 -9
  139. package/src/store-postgres.ts +79 -28
  140. package/src/store-redis-core.ts +112 -31
  141. package/src/store-sqlite.ts +61 -28
  142. package/src/store.ts +22 -3
  143. package/dist/ai-BpOGm-zB.js.map +0 -1
  144. package/dist/ai-DrUaMyRt.d.ts.map +0 -1
  145. package/dist/ai-harness-BuLh_EYi.js.map +0 -1
  146. package/dist/ai-harness-DvsPeJeB.d.ts.map +0 -1
  147. package/dist/server-Dw_k_7kO.js.map +0 -1
  148. package/dist/server-En7cbi3v.d.ts.map +0 -1
  149. package/dist/session-clear-Bo-XTFEy.d.ts.map +0 -1
  150. package/dist/store-Djdv0bnZ.d.ts.map +0 -1
  151. package/dist/store-redis-notify-BRITti_o.js.map +0 -1
  152. 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,
@@ -22,6 +22,8 @@ import {
22
22
  type GatewayCredential,
23
23
  type Harness,
24
24
  type HarnessGeneration,
25
+ harnessImages,
26
+ type HarnessInput,
25
27
  type HarnessRead,
26
28
  type HarnessStep,
27
29
  type HarnessTurn,
@@ -30,6 +32,7 @@ import {
30
32
  saveHarness,
31
33
  } from './ai-harness.ts'
32
34
  import type { CompactionPartData, GenerationCompletedPayload } from './ai.ts'
35
+ import { schemaInput } from './ai-tool-input.ts'
33
36
  import { boundedCache } from './bounded-cache.ts'
34
37
 
35
38
  export type { Harness } from './ai-harness.ts'
@@ -47,8 +50,11 @@ export function fx(): Harness<'fx', never> {
47
50
 
48
51
  const SNAPSHOT_VERSION = 1
49
52
  const CATALOG_TTL_MS = 60 * 60 * 1000
53
+ /** The Gateway model catalog libfx reads for each new kernel. */
54
+ const CATALOG_PATH = '/coding-agent/v1/models'
50
55
 
51
56
  type StreamPart = { type: string } & Record<string, unknown>
57
+ /** A recorded tool call, with the input the kernel reads from its text. */
52
58
  type ToolCall = { toolCallId: string; toolName: string; input: unknown }
53
59
 
54
60
  /** A model response A2 recorded, in the Gateway's stream format. */
@@ -60,7 +66,7 @@ type RecordedCall = {
60
66
 
61
67
  /** One FX turn to replay: its prompt, recorded calls, and how it ended. */
62
68
  type TurnPlan = {
63
- prompt: string
69
+ prompt: string | FxPromptBlock[]
64
70
  calls: RecordedCall[]
65
71
  /** Steering to queue while serving the call at that index. */
66
72
  steers: Map<number, string[]>
@@ -108,6 +114,10 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
108
114
 
109
115
  let completion: Completion = {}
110
116
  let stopped = false
117
+ // Live responses belong to the step: the kernel aborts its fetch once it
118
+ // has read a response's finish, while A2 may still be reading its copy.
119
+ const released = new AbortController()
120
+ const network = AbortSignal.any([step.signal, released.signal])
111
121
  let agent: FxAgent | undefined
112
122
  let turn: TurnState | undefined
113
123
 
@@ -182,7 +192,7 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
182
192
  try {
183
193
  checkpoint = await agent!.checkpoint()
184
194
  } catch {
185
- // Over libfx's bound: later steps replay from the previous snapshot.
195
+ // Later steps replay from the previous snapshot.
186
196
  return
187
197
  }
188
198
  await saveHarness({
@@ -200,8 +210,10 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
200
210
  init: RequestInit = {},
201
211
  ): Promise<Response> => {
202
212
  const url = input instanceof Request ? input.url : String(input)
203
- if ((init.method ?? 'GET').toUpperCase() === 'GET')
213
+ const method = (init.method ?? 'GET').toUpperCase()
214
+ if (method === 'GET' && new URL(url).pathname === CATALOG_PATH)
204
215
  return catalog(url, credential, withAuth(init.headers, credential))
216
+ if (method === 'GET') return fetch(input, init)
205
217
  const state = turn!
206
218
  if (state.failure) throw state.failure
207
219
  const body = decodeBody(init.body)
@@ -245,9 +257,18 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
245
257
  const response = await fetch(url, {
246
258
  ...init,
247
259
  headers: withAuth(init.headers, credential),
260
+ signal: network,
248
261
  })
262
+ // Abandoned before its headers came: the kernel has moved on without it.
263
+ if (init.signal?.aborted) {
264
+ void response.body?.cancel()
265
+ throw init.signal.reason
266
+ }
249
267
  if (!response.ok || response.body === null) return response
250
- const [forKernel, forLog] = response.body.tee()
268
+ const [fromNetwork, forLog] = response.body.tee()
269
+ const forKernel = init.signal
270
+ ? fromNetwork.pipeThrough(new TransformStream(), { signal: init.signal })
271
+ : fromNetwork
251
272
  // Live responses reach the log in request order: a compaction's part
252
273
  // precedes the step that follows it.
253
274
  const previous = state.parsed
@@ -268,8 +289,13 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
268
289
  state.parsed = (async () => {
269
290
  await previous
270
291
  emit({ type: 'start-step' })
271
- await streamStep(forLog, emit, (finished) => {
272
- completion = finished
292
+ await streamStep({
293
+ body: forLog,
294
+ tools: step.tools,
295
+ emit,
296
+ finished: (finished) => {
297
+ completion = finished
298
+ },
273
299
  })
274
300
  })()
275
301
  }
@@ -308,7 +334,8 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
308
334
  return fail(state, diverged(`no recorded result for ${toolName}`))
309
335
  })
310
336
 
311
- let turns = read.turns.map(turnPlan)
337
+ const plans = (turns: HarnessTurn[]): Promise<TurnPlan[]> =>
338
+ Promise.all(turns.map((recorded) => turnPlan(recorded, step.signal)))
312
339
  let results = read.results
313
340
  let controller: ReadableStreamDefaultController<UIMessageChunk> | undefined
314
341
  let closed = false
@@ -344,11 +371,10 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
344
371
  if (read.saved === undefined) throw error
345
372
  // An unusable checkpoint: replay the whole log instead.
346
373
  read = await replayAll()
347
- turns = read.turns.map(turnPlan)
348
374
  results = read.results
349
375
  agent = await create(undefined)
350
376
  }
351
- await run(streamController, turns)
377
+ await run(streamController, await plans(read.turns))
352
378
  closed = true
353
379
  streamController.close()
354
380
  } catch (error) {
@@ -357,6 +383,7 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
357
383
  step.signal.aborted ? step.signal.reason : error,
358
384
  )
359
385
  } finally {
386
+ released.abort()
360
387
  step.signal.removeEventListener('abort', abort)
361
388
  await agent?.close().catch(() => {})
362
389
  }
@@ -365,6 +392,7 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
365
392
  cancel() {
366
393
  closed = true
367
394
  stopped = true
395
+ released.abort()
368
396
  turn?.fxTurn.cancel()
369
397
  },
370
398
  })
@@ -374,11 +402,14 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
374
402
 
375
403
  // ------------------------------------------------------------ the plan
376
404
 
377
- function turnPlan(turn: HarnessTurn): TurnPlan {
405
+ async function turnPlan(
406
+ turn: HarnessTurn,
407
+ signal: AbortSignal,
408
+ ): Promise<TurnPlan> {
378
409
  const calls: RecordedCall[] = []
379
410
  const steers = new Map<number, string[]>()
380
- const steer = (at: number, texts: string[] | undefined) => {
381
- if (texts !== undefined) steers.set(Math.max(0, at), texts)
411
+ const steer = (at: number, inputs: HarnessInput[] | undefined) => {
412
+ if (inputs !== undefined) steers.set(Math.max(0, at), inputs.map(steerText))
382
413
  }
383
414
  steer(0, turn.generations.length === 0 ? turn.steers.get(0) : undefined)
384
415
  turn.generations.forEach((generation, index) => {
@@ -386,7 +417,38 @@ function turnPlan(turn: HarnessTurn): TurnPlan {
386
417
  // A generation's step is its last call.
387
418
  steer(calls.length - 1, turn.steers.get(index))
388
419
  })
389
- return { prompt: turn.prompt, calls, steers, end: turn.end }
420
+ const images = await harnessImages({
421
+ harness: 'fx()',
422
+ input: turn.prompt,
423
+ signal,
424
+ })
425
+ return {
426
+ prompt:
427
+ images.length === 0
428
+ ? turn.prompt.text
429
+ : [
430
+ ...images.map((image): FxPromptBlock => ({
431
+ type: 'image',
432
+ data: image.data,
433
+ mimeType: image.mediaType,
434
+ })),
435
+ ...(turn.prompt.text === ''
436
+ ? []
437
+ : [{ type: 'text' as const, text: turn.prompt.text }]),
438
+ ],
439
+ calls,
440
+ steers,
441
+ end: turn.end,
442
+ }
443
+ }
444
+
445
+ /** libfx steers with text alone, so a steer's files fail the step. */
446
+ function steerText(input: HarnessInput): string {
447
+ if (input.files.length > 0)
448
+ throw new Error(
449
+ 'fx() steers with text only: send files in a message of their own',
450
+ )
451
+ return input.text
390
452
  }
391
453
 
392
454
  /** A completed generation's model calls: its compactions, then its step. */
@@ -448,36 +510,35 @@ function recordedCalls(
448
510
  })
449
511
  break
450
512
  case 'tool-input-error':
451
- // A call whose input did not parse: FX answered it itself.
513
+ case 'tool-input-available': {
514
+ // A call recorded without its text: input that failed to parse is
515
+ // that text.
516
+ const text =
517
+ inputText.get(chunk.toolCallId) ||
518
+ (chunk.type === 'tool-input-error' && typeof chunk.input === 'string'
519
+ ? chunk.input
520
+ : JSON.stringify(chunk.input))
452
521
  if (inputText.has(chunk.toolCallId))
453
522
  parts.push({ type: 'tool-input-end', id: chunk.toolCallId })
523
+ // The kernel calls back with this text's JSON, not the input the
524
+ // tool's schema made of it. A call it answers itself, such as one
525
+ // whose text is not JSON, is never asked for.
526
+ const input = parseJson(text)
527
+ if (input !== undefined)
528
+ toolCalls.push({
529
+ toolCallId: chunk.toolCallId,
530
+ toolName: chunk.toolName,
531
+ input,
532
+ })
454
533
  parts.push({
455
534
  type: 'tool-call',
456
535
  toolCallId: chunk.toolCallId,
457
536
  toolName: chunk.toolName,
458
- input:
459
- inputText.get(chunk.toolCallId) ||
460
- (typeof chunk.input === 'string'
461
- ? chunk.input
462
- : JSON.stringify(chunk.input)),
463
- })
464
- break
465
- case 'tool-input-available':
466
- if (inputText.has(chunk.toolCallId))
467
- parts.push({ type: 'tool-input-end', id: chunk.toolCallId })
468
- toolCalls.push({
469
- toolCallId: chunk.toolCallId,
470
- toolName: chunk.toolName,
471
- input: chunk.input,
472
- })
473
- parts.push({
474
- type: 'tool-call',
475
- toolCallId: chunk.toolCallId,
476
- toolName: chunk.toolName,
477
- input: inputText.get(chunk.toolCallId) || JSON.stringify(chunk.input),
537
+ input: text,
478
538
  ...metadata,
479
539
  })
480
540
  break
541
+ }
481
542
  }
482
543
  }
483
544
  parts.push({
@@ -505,11 +566,19 @@ function compactionSse(data: CompactionPartData): string {
505
566
  // ------------------------------------------------------- live responses
506
567
 
507
568
  /** Streams one live step response as UI chunks and reports its finish. */
508
- async function streamStep(
509
- body: ReadableStream<Uint8Array>,
510
- emit: (chunk: UIMessageChunk) => void,
511
- finished: (completion: Completion) => void,
512
- ): Promise<void> {
569
+ async function streamStep({
570
+ body,
571
+ tools,
572
+ emit,
573
+ finished,
574
+ }: {
575
+ body: ReadableStream<Uint8Array>
576
+ tools: ToolSet
577
+ emit: (chunk: UIMessageChunk) => void
578
+ finished: (completion: Completion) => void
579
+ }): Promise<void> {
580
+ // The tool calls whose input started streaming, and whether text arrived.
581
+ const streamed = new Map<string, boolean>()
513
582
  for await (const part of sseParts(body)) {
514
583
  const metadata =
515
584
  part['providerMetadata'] === undefined
@@ -532,6 +601,7 @@ async function streamStep(
532
601
  })
533
602
  break
534
603
  case 'tool-input-start':
604
+ streamed.set(part['id'] as string, false)
535
605
  emit({
536
606
  type: 'tool-input-start',
537
607
  toolCallId: part['id'] as string,
@@ -540,6 +610,7 @@ async function streamStep(
540
610
  })
541
611
  break
542
612
  case 'tool-input-delta':
613
+ if (part['delta'] !== '') streamed.set(part['id'] as string, true)
543
614
  emit({
544
615
  type: 'tool-input-delta',
545
616
  toolCallId: part['id'] as string,
@@ -547,27 +618,39 @@ async function streamStep(
547
618
  })
548
619
  break
549
620
  case 'tool-call': {
621
+ const toolCallId = part['toolCallId'] as string
622
+ const toolName = part['toolName'] as string
550
623
  const text = part['input'] as string
551
- let input: unknown
552
- try {
553
- input = JSON.parse(text)
554
- } catch (error) {
555
- emit({
556
- type: 'tool-input-error',
557
- toolCallId: part['toolCallId'] as string,
558
- toolName: part['toolName'] as string,
559
- input: text,
560
- errorText: error instanceof Error ? error.message : String(error),
561
- })
562
- break
624
+ // Replay hands the kernel the text the log holds, so the log keeps
625
+ // the provider's text when it streamed none.
626
+ if (streamed.get(toolCallId) !== true && text !== '') {
627
+ if (!streamed.has(toolCallId))
628
+ emit({ type: 'tool-input-start', toolCallId, toolName })
629
+ emit({ type: 'tool-input-delta', toolCallId, inputTextDelta: text })
563
630
  }
564
- emit({
565
- type: 'tool-input-available',
566
- toolCallId: part['toolCallId'] as string,
567
- toolName: part['toolName'] as string,
568
- input,
569
- ...metadata,
631
+ const input = await parseToolInput({
632
+ tools,
633
+ toolName,
634
+ text,
570
635
  })
636
+ emit(
637
+ input.success
638
+ ? {
639
+ type: 'tool-input-available',
640
+ toolCallId,
641
+ toolName,
642
+ input: input.value,
643
+ ...metadata,
644
+ }
645
+ : {
646
+ type: 'tool-input-error',
647
+ toolCallId,
648
+ toolName,
649
+ input: input.input,
650
+ errorText: input.errorText,
651
+ ...metadata,
652
+ },
653
+ )
571
654
  break
572
655
  }
573
656
  case 'finish': {
@@ -587,6 +670,34 @@ async function streamStep(
587
670
  }
588
671
  }
589
672
 
673
+ type ToolInput =
674
+ | { success: true; value: unknown }
675
+ | { success: false; input: unknown; errorText: string }
676
+
677
+ /**
678
+ * A live call's input as the AI SDK step reads it: JSON, then the tool's
679
+ * schema with its defaults. Input that fails either is an input error, which
680
+ * A2 answers without running the tool.
681
+ */
682
+ async function parseToolInput({
683
+ tools,
684
+ toolName,
685
+ text,
686
+ }: {
687
+ tools: ToolSet
688
+ toolName: string
689
+ text: string
690
+ }): Promise<ToolInput> {
691
+ let value: unknown
692
+ try {
693
+ value = JSON.parse(text)
694
+ } catch (error) {
695
+ return { success: false, input: text, errorText: errorText(error) }
696
+ }
697
+ const input = await schemaInput({ tools, toolName, value })
698
+ return input.success ? input : { ...input, input: value }
699
+ }
700
+
590
701
  async function readParts(
591
702
  body: ReadableStream<Uint8Array>,
592
703
  ): Promise<StreamPart[]> {
@@ -693,15 +804,13 @@ async function kernelTools(
693
804
  execute: (toolName: string, input: unknown) => unknown,
694
805
  ): Promise<FxHostTool[]> {
695
806
  return Promise.all(
696
- Object.entries(tools)
697
- .filter(([, tool]) => tool.type !== 'provider')
698
- .map(async ([toolName, tool]) => ({
699
- name: toolName,
700
- description:
701
- typeof tool.description === 'string' ? tool.description : toolName,
702
- inputSchema: (await asSchema(tool.inputSchema).jsonSchema) as object,
703
- execute: (input: unknown) => execute(toolName, input),
704
- })),
807
+ Object.entries(tools).map(async ([toolName, tool]) => ({
808
+ name: toolName,
809
+ description:
810
+ typeof tool.description === 'string' ? tool.description : toolName,
811
+ inputSchema: (await asSchema(tool.inputSchema).jsonSchema) as object,
812
+ execute: (input: unknown) => execute(toolName, input),
813
+ })),
705
814
  )
706
815
  }
707
816
 
@@ -760,6 +869,14 @@ function decodeBody(body: BodyInit | null | undefined): string {
760
869
  throw new TypeError('FX sent an unexpected request body')
761
870
  }
762
871
 
872
+ function parseJson(text: string): unknown {
873
+ try {
874
+ return JSON.parse(text)
875
+ } catch {
876
+ return undefined
877
+ }
878
+ }
879
+
763
880
  function sameJson(a: unknown, b: unknown): boolean {
764
881
  return JSON.stringify(a) === JSON.stringify(b)
765
882
  }
package/src/ai-harness.ts CHANGED
@@ -1,16 +1,20 @@
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 { STEERED_REASON } from './ai-control.ts'
14
18
  import { boundedCache, type BoundedCache } from './bounded-cache.ts'
15
19
  import type { ContractEvent, EventDefs } from './contract.ts'
16
20
 
@@ -307,21 +311,21 @@ function savedEnvelope(value: unknown): SavedEnvelope | undefined {
307
311
 
308
312
  // ------------------------------------------------- the log as harness turns
309
313
 
310
- // A2's reason on `ai.message.interrupted` when a steer ends a response.
311
- const STEERED = 'steered after step'
312
-
313
314
  /** A completed generation as the log recorded it. */
314
315
  export type RecordedGeneration = {
315
316
  chunks: UIMessageChunk[]
316
317
  completed: GenerationCompletedPayload
317
318
  }
318
319
 
320
+ /** What user messages hand a harness: their text, and the files they attach. */
321
+ export type HarnessInput = { text: string; files: FileUIPart[] }
322
+
319
323
  /** One harness turn to rebuild: its prompt, completed generations, and end. */
320
324
  export type HarnessTurn = {
321
- prompt: string
325
+ prompt: HarnessInput
322
326
  generations: RecordedGeneration[]
323
327
  /** Steering that entered after the generation at that index. */
324
- steers: Map<number, string[]>
328
+ steers: Map<number, HarnessInput[]>
325
329
  end: 'stop' | 'interrupted' | 'live'
326
330
  }
327
331
 
@@ -342,15 +346,18 @@ export function harnessTurns(
342
346
  }
343
347
  type Response = {
344
348
  id: string
345
- prompt: string[]
349
+ prompt: HarnessInput[]
346
350
  generations: Generation[]
347
351
  end?: 'stop' | 'steered' | 'interrupted'
348
352
  }
349
- // Queued messages are admitted together, possibly before the snapshot.
350
- const userText = new Map<string, string>(
353
+ // Prompts take their content from the transcript: a harness agent never
354
+ // runs A2's compaction, so the step's messages hold every input admitted by
355
+ // its read, before the snapshot or after it. A session another loop
356
+ // compacted can't move to a harness.
357
+ const userInput = new Map<string, HarnessInput>(
351
358
  step.messages
352
359
  .filter((message) => message.role === 'user')
353
- .map((message) => [message.id, textOf(message)]),
360
+ .map((message) => [message.id, inputOf(message)]),
354
361
  )
355
362
  let passive: string[] = []
356
363
  const responses = new Map<string, Response>()
@@ -362,10 +369,9 @@ export function harnessTurns(
362
369
  const payload = event.payload as Record<string, unknown>
363
370
  switch (event.type) {
364
371
  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)
372
+ const admitted = event.payload as MessageCreatedPayload
373
+ if (admitted.role === 'user' && admitted.generate === false)
374
+ passive.push(admitted.messageId)
369
375
  break
370
376
  }
371
377
  case 'ai.generation.requested': {
@@ -376,7 +382,7 @@ export function harnessTurns(
376
382
  passive = []
377
383
  responses.set(id, {
378
384
  id,
379
- prompt: ids.flatMap((p) => userText.get(p) ?? []),
385
+ prompt: ids.flatMap((p) => userInput.get(p) ?? []),
380
386
  generations: [],
381
387
  })
382
388
  break
@@ -420,7 +426,7 @@ export function harnessTurns(
420
426
  const response = responses.get(payload['messageId'] as string)
421
427
  if (response)
422
428
  response.end =
423
- payload['reason'] === STEERED ? 'steered' : 'interrupted'
429
+ payload['reason'] === STEERED_REASON ? 'steered' : 'interrupted'
424
430
  break
425
431
  }
426
432
  }
@@ -429,13 +435,16 @@ export function harnessTurns(
429
435
  const turns: HarnessTurn[] = []
430
436
  let steered = false
431
437
  for (const response of responses.values()) {
432
- const text = response.prompt.join('\n\n')
438
+ const input: HarnessInput = {
439
+ text: response.prompt.map((prompt) => prompt.text).join('\n\n'),
440
+ files: response.prompt.flatMap((prompt) => prompt.files),
441
+ }
433
442
  let turn = turns.at(-1)
434
443
  if (steered && turn !== undefined) {
435
444
  const at = Math.max(0, turn.generations.length - 1)
436
- turn.steers.set(at, [...(turn.steers.get(at) ?? []), text])
445
+ turn.steers.set(at, [...(turn.steers.get(at) ?? []), input])
437
446
  } else {
438
- turn = { prompt: text, generations: [], steers: new Map(), end: 'live' }
447
+ turn = { prompt: input, generations: [], steers: new Map(), end: 'live' }
439
448
  turns.push(turn)
440
449
  }
441
450
  for (const generation of response.generations)
@@ -455,10 +464,125 @@ export function harnessTurns(
455
464
  return { turns, results }
456
465
  }
457
466
 
458
- function textOf(message: UIMessage): string {
459
- return message.parts
460
- .flatMap((part) => (part.type === 'text' ? [part.text] : []))
461
- .join('\n')
467
+ function inputOf(message: UIMessage): HarnessInput {
468
+ return {
469
+ text: message.parts
470
+ .flatMap((part) => (part.type === 'text' ? [part.text] : []))
471
+ .join('\n'),
472
+ files: message.parts.filter(
473
+ (part): part is FileUIPart => part.type === 'file',
474
+ ),
475
+ }
476
+ }
477
+
478
+ // ------------------------------------------------------------- attachments
479
+
480
+ /** An attached image as an engine takes it: its bytes in base64. */
481
+ export type HarnessImage = { mediaType: string; data: string }
482
+
483
+ // What one message's files hold in a step's memory, at most.
484
+ const MAX_ATTACHMENT_BYTES = 32 * 1024 * 1024
485
+
486
+ type Attachment = { data: string; bytes: number }
487
+
488
+ // Every step of a response replays its turn, and a URL's file doesn't change,
489
+ // so the process keeps what it fetched.
490
+ const attachments = boundedCache<string, Attachment>({
491
+ maxEntries: 256,
492
+ maxBytes: 64 * 1024 * 1024,
493
+ })
494
+
495
+ /**
496
+ * The images an input attaches, as its engine takes them: engines take
497
+ * bytes, not URLs. A file it can't attach fails the step, naming the file,
498
+ * and is never left out: the failure may pass, and the step is retried like
499
+ * any other.
500
+ */
501
+ export async function harnessImages({
502
+ harness,
503
+ input,
504
+ signal,
505
+ }: {
506
+ /** As the error names it: `fx()` or `pi()`. */
507
+ harness: string
508
+ input: HarnessInput
509
+ signal: AbortSignal
510
+ }): Promise<HarnessImage[]> {
511
+ const images: HarnessImage[] = []
512
+ let budget = MAX_ATTACHMENT_BYTES
513
+ // One at a time, so the budget bounds what the step holds.
514
+ for (const file of input.files) {
515
+ try {
516
+ if (!file.mediaType.startsWith('image/'))
517
+ throw new Error(`it takes images only, not ${file.mediaType}`)
518
+ const attached = await attachment({ url: file.url, budget, signal })
519
+ budget -= attached.bytes
520
+ images.push({
521
+ mediaType: file.mediaType,
522
+ data: attached.data,
523
+ })
524
+ } catch (error) {
525
+ if (signal.aborted) throw error
526
+ throw new Error(
527
+ `${harness} couldn't attach ${file.filename ?? 'a file'}: ${
528
+ error instanceof Error ? error.message : String(error)
529
+ }`,
530
+ { cause: error },
531
+ )
532
+ }
533
+ }
534
+ return images
535
+ }
536
+
537
+ async function attachment({
538
+ url,
539
+ budget,
540
+ signal,
541
+ }: {
542
+ url: string
543
+ budget: number
544
+ signal: AbortSignal
545
+ }): Promise<Attachment> {
546
+ const tooLarge = () =>
547
+ new Error(
548
+ `a message's files may hold ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MiB in a step`,
549
+ )
550
+ const cached = attachments.get(url)
551
+ if (cached !== undefined) {
552
+ if (cached.bytes > budget) throw tooLarge()
553
+ return cached
554
+ }
555
+ const { protocol } = new URL(url)
556
+ if (protocol !== 'https:' && protocol !== 'data:')
557
+ throw new Error(`attachments must be https: or data: URLs, not ${protocol}`)
558
+ // AI SDK's download checks an https: URL's address and its redirects,
559
+ // which a message's sender chooses; a data: URL holds its bytes.
560
+ const bytes =
561
+ protocol === 'data:'
562
+ ? dataBytes(url)
563
+ : (
564
+ await createDownload({ maxBytes: budget })({
565
+ url: new URL(url),
566
+ abortSignal: signal,
567
+ })
568
+ ).data
569
+ if (bytes.byteLength > budget) throw tooLarge()
570
+ const attached: Attachment = {
571
+ data: Buffer.from(bytes).toString('base64'),
572
+ bytes: bytes.byteLength,
573
+ }
574
+ if (protocol === 'https:')
575
+ attachments.set(url, attached, attached.data.length)
576
+ return attached
577
+ }
578
+
579
+ function dataBytes(url: string): Uint8Array {
580
+ const comma = url.indexOf(',')
581
+ if (comma === -1) throw new Error('its data: URL has no data')
582
+ const body = url.slice(comma + 1)
583
+ return url.slice(0, comma).endsWith(';base64')
584
+ ? Buffer.from(body, 'base64')
585
+ : Buffer.from(decodeURIComponent(body))
462
586
  }
463
587
 
464
588
  // ------------------------------------------------------------- Gateway