experimental-a2 0.17.0 → 0.18.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 (211) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-DBbe5xeE.d.ts → actor-Bjce9_5U.d.ts} +5 -5
  4. package/dist/{actor-DBbe5xeE.d.ts.map → actor-Bjce9_5U.d.ts.map} +1 -1
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-react.d.ts +3 -3
  7. package/dist/actor-react.js +1 -1
  8. package/dist/actor.d.ts +1 -1
  9. package/dist/actor.js +2 -2
  10. package/dist/{ai-UrWfKeuv.js → ai-BpOGm-zB.js} +2 -2
  11. package/dist/{ai-UrWfKeuv.js.map → ai-BpOGm-zB.js.map} +1 -1
  12. package/dist/{ai-vpGtcaCB.d.ts → ai-DrUaMyRt.d.ts} +3 -3
  13. package/dist/{ai-vpGtcaCB.d.ts.map → ai-DrUaMyRt.d.ts.map} +1 -1
  14. package/dist/ai-client.d.ts +2 -2
  15. package/dist/ai-client.js +2 -2
  16. package/dist/ai-fx.browser.d.ts +1 -0
  17. package/dist/ai-fx.browser.js +6 -0
  18. package/dist/ai-fx.browser.js.map +1 -0
  19. package/dist/ai-fx.d.ts +10 -0
  20. package/dist/ai-fx.d.ts.map +1 -0
  21. package/dist/ai-fx.js +634 -0
  22. package/dist/ai-fx.js.map +1 -0
  23. package/dist/ai-harness-BuLh_EYi.js +273 -0
  24. package/dist/ai-harness-BuLh_EYi.js.map +1 -0
  25. package/dist/ai-harness-DvsPeJeB.d.ts +26 -0
  26. package/dist/ai-harness-DvsPeJeB.d.ts.map +1 -0
  27. package/dist/ai-pi.browser.d.ts +1 -0
  28. package/dist/ai-pi.browser.js +6 -0
  29. package/dist/ai-pi.browser.js.map +1 -0
  30. package/dist/ai-pi.d.ts +17 -0
  31. package/dist/ai-pi.d.ts.map +1 -0
  32. package/dist/ai-pi.js +904 -0
  33. package/dist/ai-pi.js.map +1 -0
  34. package/dist/ai-react.d.ts +5 -5
  35. package/dist/ai-react.js +2 -2
  36. package/dist/ai-server.d.ts +47 -25
  37. package/dist/ai-server.d.ts.map +1 -1
  38. package/dist/ai-server.js +106 -37
  39. package/dist/ai-server.js.map +1 -1
  40. package/dist/ai.d.ts +1 -1
  41. package/dist/ai.js +1 -1
  42. package/dist/bounded-cache-CPHWF-8h.js +41 -0
  43. package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
  44. package/dist/{client-COaiaDub.js → client-BReKqwZC.js} +3 -3
  45. package/dist/{client-COaiaDub.js.map → client-BReKqwZC.js.map} +1 -1
  46. package/dist/{client-qRnRUzBS.d.ts → client-h_DKFAjj.d.ts} +3 -3
  47. package/dist/{client-qRnRUzBS.d.ts.map → client-h_DKFAjj.d.ts.map} +1 -1
  48. package/dist/client.d.ts +3 -3
  49. package/dist/client.js +1 -1
  50. package/dist/index.d.ts +5 -5
  51. package/dist/{initial-snapshot-BrLso5x_.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +2 -2
  52. package/dist/{initial-snapshot-BrLso5x_.d.ts.map → initial-snapshot-Bx-c0pDP.d.ts.map} +1 -1
  53. package/dist/{internal-CbVvEwad.js → internal-B2T3U4Rq.js} +8 -2
  54. package/dist/internal-B2T3U4Rq.js.map +1 -0
  55. package/dist/otel.d.ts +1 -1
  56. package/dist/{react-BbJ-g6Uu.d.ts → react-BBXgHWFc.d.ts} +4 -4
  57. package/dist/{react-BbJ-g6Uu.d.ts.map → react-BBXgHWFc.d.ts.map} +1 -1
  58. package/dist/react.d.ts +1 -1
  59. package/dist/react.js +1 -1
  60. package/dist/reducer-BAXc8JyF.js.map +1 -1
  61. package/dist/{reducer-BYu6DT7h.d.ts → reducer-CqA59Q6U.d.ts} +2 -2
  62. package/dist/{reducer-BYu6DT7h.d.ts.map → reducer-CqA59Q6U.d.ts.map} +1 -1
  63. package/dist/scheduler-qstash.d.ts +2 -2
  64. package/dist/scheduler-qstash.js +2 -2
  65. package/dist/scheduler-vercel.d.ts +2 -2
  66. package/dist/scheduler-vercel.js +2 -2
  67. package/dist/{server-Rj64fNvX.js → server-Dw_k_7kO.js} +93 -60
  68. package/dist/server-Dw_k_7kO.js.map +1 -0
  69. package/dist/{server-CGM8WzRG.d.ts → server-En7cbi3v.d.ts} +36 -7
  70. package/dist/server-En7cbi3v.d.ts.map +1 -0
  71. package/dist/server.d.ts +4 -4
  72. package/dist/server.js +1 -1
  73. package/dist/{session-clear-Dshpuhff.d.ts → session-clear-Bo-XTFEy.d.ts} +2 -2
  74. package/dist/session-clear-Bo-XTFEy.d.ts.map +1 -0
  75. package/dist/{store-Gpkq7WBo.d.ts → store-Djdv0bnZ.d.ts} +31 -2
  76. package/dist/store-Djdv0bnZ.d.ts.map +1 -0
  77. package/dist/store-N8PXxDAS.js.map +1 -1
  78. package/dist/store-memory.d.ts +1 -1
  79. package/dist/store-memory.js +32 -6
  80. package/dist/store-memory.js.map +1 -1
  81. package/dist/{store-polling-4_y8lETm.js → store-polling-B5FcwJfo.js} +2 -2
  82. package/dist/{store-polling-4_y8lETm.js.map → store-polling-B5FcwJfo.js.map} +1 -1
  83. package/dist/store-postgres.d.ts +1 -1
  84. package/dist/store-postgres.js +54 -3
  85. package/dist/store-postgres.js.map +1 -1
  86. package/dist/{store-presence-polling-Q1C-k2dd.js → store-presence-polling-BAwS8RWn.js} +3 -3
  87. package/dist/{store-presence-polling-Q1C-k2dd.js.map → store-presence-polling-BAwS8RWn.js.map} +1 -1
  88. package/dist/store-redis-http.d.ts +1 -1
  89. package/dist/store-redis-http.js +3 -3
  90. package/dist/{store-redis-notify-tqWNnMRu.js → store-redis-notify-BRITti_o.js} +187 -68
  91. package/dist/store-redis-notify-BRITti_o.js.map +1 -0
  92. package/dist/store-redis.d.ts +1 -1
  93. package/dist/store-redis.js +1 -1
  94. package/dist/store-sqlite.d.ts +1 -1
  95. package/dist/store-sqlite.js +32 -2
  96. package/dist/store-sqlite.js.map +1 -1
  97. package/dist/{telemetry-cZH-cibj.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
  98. package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
  99. package/dist/{wire-DjfHOvdW.js → wire-rSUglYo8.js} +2 -2
  100. package/dist/{wire-DjfHOvdW.js.map → wire-rSUglYo8.js.map} +1 -1
  101. package/docs/01-quickstart.mdx +49 -83
  102. package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
  103. package/docs/actors/01-introduction.mdx +48 -86
  104. package/docs/actors/02-concurrency.mdx +43 -70
  105. package/docs/actors/03-timers.mdx +28 -42
  106. package/docs/actors/04-routes.mdx +110 -150
  107. package/docs/actors/meta.ts +1 -1
  108. package/docs/agents/01-introduction.mdx +257 -0
  109. package/docs/agents/02-tools.mdx +247 -0
  110. package/docs/agents/03-conversations.mdx +538 -0
  111. package/docs/agents/04-harnesses.mdx +136 -0
  112. package/docs/agents/meta.ts +1 -0
  113. package/docs/client/01-react.mdx +294 -0
  114. package/docs/client/02-transports.mdx +86 -0
  115. package/docs/client/03-presence.mdx +224 -0
  116. package/docs/client/meta.ts +1 -0
  117. package/docs/contract/01-events.mdx +57 -0
  118. package/docs/contract/02-state.mdx +142 -0
  119. package/docs/contract/03-sessions.mdx +175 -0
  120. package/docs/index.mdx +108 -144
  121. package/docs/reference/01-core.mdx +139 -0
  122. package/docs/reference/02-server.mdx +855 -0
  123. package/docs/reference/03-client.mdx +301 -0
  124. package/docs/reference/04-ai.mdx +722 -0
  125. package/docs/reference/05-adapters.mdx +351 -0
  126. package/docs/reference/06-errors.mdx +138 -0
  127. package/docs/reference/meta.ts +1 -1
  128. package/docs/server/01-handlers.mdx +200 -0
  129. package/docs/server/02-timers.mdx +130 -0
  130. package/docs/server/03-cancellation.mdx +141 -0
  131. package/docs/server/04-routes.mdx +243 -0
  132. package/docs/server/05-production.mdx +322 -0
  133. package/docs/server/06-guarantees.mdx +123 -0
  134. package/examples/playground/DEPLOY.md +1 -1
  135. package/examples/playground/app/a0/[appId]/page.tsx +11 -6
  136. package/examples/playground/app/a0/app-list.tsx +9 -2
  137. package/examples/playground/app/a0/app-view.tsx +9 -1
  138. package/examples/playground/app/a0/apps/[appId]/route.ts +10 -5
  139. package/examples/playground/app/a0/apps.test.ts +12 -0
  140. package/examples/playground/app/a0/apps.ts +22 -7
  141. package/examples/playground/app/a0/chat.tsx +47 -1
  142. package/examples/playground/app/a0/fx/events/route.ts +9 -0
  143. package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
  144. package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
  145. package/examples/playground/app/a0/model.test.ts +18 -1
  146. package/examples/playground/app/a0/model.ts +47 -10
  147. package/examples/playground/app/a0/no-photon.cjs +4 -0
  148. package/examples/playground/app/a0/no-quickjs.cjs +3 -0
  149. package/examples/playground/app/a0/pi/events/route.ts +9 -0
  150. package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
  151. package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
  152. package/examples/playground/app/a0/sandbox-log.ts +10 -4
  153. package/examples/playground/app/a0/sandbox-session.ts +152 -0
  154. package/examples/playground/app/a0/scheduler/route.ts +2 -2
  155. package/examples/playground/app/a0/server.ts +100 -45
  156. package/examples/playground/app/a0/session.ts +17 -5
  157. package/examples/playground/app/a0/shell.tsx +11 -3
  158. package/examples/playground/app/agent/ingress.test.ts +6 -3
  159. package/examples/playground/app/agent/server.ts +9 -6
  160. package/examples/playground/app/agent/telemetry.test.ts +71 -50
  161. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +1 -1
  162. package/examples/playground/app/chat/server.test.ts +30 -26
  163. package/examples/playground/app/chat/server.ts +46 -46
  164. package/examples/playground/next.config.ts +17 -5
  165. package/examples/playground/package.json +4 -1
  166. package/examples/playground/vercel.json +18 -0
  167. package/package.json +27 -3
  168. package/src/ai-agent.ts +261 -87
  169. package/src/ai-fx.browser.ts +5 -0
  170. package/src/ai-fx.ts +833 -0
  171. package/src/ai-harness.ts +518 -0
  172. package/src/ai-model-metadata.ts +11 -6
  173. package/src/ai-pi.browser.ts +5 -0
  174. package/src/ai-pi.ts +1263 -0
  175. package/src/ai-sdk-step.ts +2 -2
  176. package/src/ai-server.ts +3 -1
  177. package/src/internal.ts +7 -0
  178. package/src/libfx.d.ts +34 -0
  179. package/src/reducer.ts +1 -1
  180. package/src/server.ts +177 -19
  181. package/src/store-memory.ts +42 -7
  182. package/src/store-postgres.ts +80 -0
  183. package/src/store-redis-core.ts +210 -79
  184. package/src/store-redis-notify.ts +2 -0
  185. package/src/store-sqlite.ts +60 -0
  186. package/src/store.ts +33 -0
  187. package/src/telemetry.ts +1 -0
  188. package/dist/internal-CbVvEwad.js.map +0 -1
  189. package/dist/server-CGM8WzRG.d.ts.map +0 -1
  190. package/dist/server-Rj64fNvX.js.map +0 -1
  191. package/dist/session-clear-Dshpuhff.d.ts.map +0 -1
  192. package/dist/store-Gpkq7WBo.d.ts.map +0 -1
  193. package/dist/store-redis-notify-tqWNnMRu.js.map +0 -1
  194. package/dist/telemetry-cZH-cibj.d.ts.map +0 -1
  195. package/docs/concepts/01-contracts.mdx +0 -237
  196. package/docs/concepts/02-handlers.mdx +0 -284
  197. package/docs/concepts/03-durability.mdx +0 -314
  198. package/docs/concepts/04-state.mdx +0 -209
  199. package/docs/guides/01-timers.mdx +0 -193
  200. package/docs/guides/02-cancellation.mdx +0 -161
  201. package/docs/guides/03-react.mdx +0 -324
  202. package/docs/guides/04-local-first.mdx +0 -47
  203. package/docs/guides/05-production.mdx +0 -534
  204. package/docs/guides/06-ai-agents.mdx +0 -1408
  205. package/docs/guides/08-application-data.mdx +0 -113
  206. package/docs/guides/09-presence.mdx +0 -262
  207. package/docs/guides/10-transports.mdx +0 -221
  208. package/docs/reference/01-api.mdx +0 -2358
  209. package/docs/reference/02-errors.mdx +0 -150
  210. /package/docs/{concepts → contract}/meta.ts +0 -0
  211. /package/docs/{guides → server}/meta.ts +0 -0
package/src/ai-fx.ts ADDED
@@ -0,0 +1,833 @@
1
+ /**
2
+ * experimental-a2/ai/fx — `fx()`: the FX harness for `agent({ harness })`.
3
+ * Server-only; FX's kernel comes from the optional `libfx` peer dependency.
4
+ */
5
+
6
+ // oxlint-disable no-await-in-loop -- turns replay in order, each on the kernel the last one left.
7
+
8
+ import {
9
+ asSchema,
10
+ type FinishReason,
11
+ type LanguageModelUsage,
12
+ type ToolSet,
13
+ type UIMessageChunk,
14
+ } from 'ai'
15
+ import type { FxAgent, FxHostTool, FxTurn } from 'libfx'
16
+ import {
17
+ type Deferred,
18
+ defineHarness,
19
+ deferred,
20
+ Divergence,
21
+ gatewayCredential,
22
+ type GatewayCredential,
23
+ type Harness,
24
+ type HarnessGeneration,
25
+ type HarnessRead,
26
+ type HarnessStep,
27
+ type HarnessTurn,
28
+ pending,
29
+ readHarness,
30
+ saveHarness,
31
+ } from './ai-harness.ts'
32
+ import type { CompactionPartData, GenerationCompletedPayload } from './ai.ts'
33
+ import { boundedCache } from './bounded-cache.ts'
34
+
35
+ export type { Harness } from './ai-harness.ts'
36
+
37
+ /**
38
+ * The FX harness. Every model call FX makes is one durable step; A2 rebuilds
39
+ * the kernel for each step from its latest checkpoint and the log since.
40
+ */
41
+ export function fx(): Harness<'fx', never> {
42
+ return defineHarness('fx', {
43
+ tools: {},
44
+ step: runStep,
45
+ })
46
+ }
47
+
48
+ const SNAPSHOT_VERSION = 1
49
+ const CATALOG_TTL_MS = 60 * 60 * 1000
50
+
51
+ type StreamPart = { type: string } & Record<string, unknown>
52
+ type ToolCall = { toolCallId: string; toolName: string; input: unknown }
53
+
54
+ /** A model response A2 recorded, in the Gateway's stream format. */
55
+ type RecordedCall = {
56
+ kind: 'step' | 'compaction'
57
+ sse: string
58
+ toolCalls: ToolCall[]
59
+ }
60
+
61
+ /** One FX turn to replay: its prompt, recorded calls, and how it ended. */
62
+ type TurnPlan = {
63
+ prompt: string
64
+ calls: RecordedCall[]
65
+ /** Steering to queue while serving the call at that index. */
66
+ steers: Map<number, string[]>
67
+ end: 'stop' | 'interrupted' | 'live'
68
+ }
69
+
70
+ type Completion = { finishReason?: FinishReason; usage?: LanguageModelUsage }
71
+
72
+ /** The turn being replayed or run live, as the fetch and tool hooks see it. */
73
+ type TurnState = {
74
+ plan: TurnPlan
75
+ fxTurn: FxTurn
76
+ next: number
77
+ /** Tool calls of the last served step and the ones already answered. */
78
+ served: ToolCall[]
79
+ answered: Set<string>
80
+ live: boolean
81
+ liveStep: boolean
82
+ failure?: Error
83
+ /** Resolves when the live step is over: FX asked for its tools. */
84
+ toolsRequested: Deferred
85
+ parsed?: Promise<void>
86
+ }
87
+
88
+ function fail(state: TurnState, error: Error): never {
89
+ state.failure ??= error
90
+ state.fxTurn.cancel()
91
+ throw state.failure
92
+ }
93
+
94
+ const diverged = (message: string): Divergence => new Divergence('FX', message)
95
+
96
+ // ---------------------------------------------------------------- step
97
+
98
+ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
99
+ const { createFxAgent } = await loadLibfx()
100
+ const credential = gatewayCredential('fx')
101
+ const name = `${step.agentName}.a2.harness.fx`
102
+ const { replayAll, ...first } = await readHarness({
103
+ step,
104
+ name,
105
+ parse: savedCheckpoint,
106
+ })
107
+ let read: HarnessRead<Uint8Array> = first
108
+
109
+ let completion: Completion = {}
110
+ let stopped = false
111
+ let agent: FxAgent | undefined
112
+ let turn: TurnState | undefined
113
+
114
+ const run = async (
115
+ controller: ReadableStreamDefaultController<UIMessageChunk>,
116
+ turns: TurnPlan[],
117
+ ): Promise<void> => {
118
+ controller.enqueue({ type: 'start', messageId: step.responseMessageId })
119
+ for (const plan of turns) {
120
+ if (stopped) return
121
+ const fxTurn = agent!.prompt(plan.prompt, { signal: step.signal })
122
+ const state: TurnState = {
123
+ plan,
124
+ fxTurn,
125
+ next: 0,
126
+ served: [],
127
+ answered: new Set(),
128
+ live: false,
129
+ liveStep: false,
130
+ toolsRequested: deferred(),
131
+ }
132
+ turn = state
133
+ const drained = (async () => {
134
+ for await (const _ of fxTurn) {
135
+ // Live output reaches A2 from the Gateway response itself.
136
+ }
137
+ })()
138
+ // A failed turn surfaces through its result.
139
+ drained.catch(() => {})
140
+ if (plan.end !== 'live') {
141
+ const result = await fxTurn.result.catch((error: unknown) => {
142
+ throw state.failure ?? error
143
+ })
144
+ await drained
145
+ if (state.failure) throw state.failure
146
+ const expected = plan.end === 'stop' ? 'end_turn' : 'cancelled'
147
+ if (result.stopReason !== expected)
148
+ throw diverged(
149
+ `a recorded turn ended with ${result.stopReason}, not ${expected}`,
150
+ )
151
+ continue
152
+ }
153
+ const outcome = await Promise.race([
154
+ fxTurn.result.then((result) => ({ ended: true, result })),
155
+ state.toolsRequested.promise.then(() => ({ ended: false as const })),
156
+ ]).catch((error: unknown) => {
157
+ throw state.failure ?? error
158
+ })
159
+ if (state.failure) throw state.failure
160
+ await state.parsed
161
+ if (outcome.ended) {
162
+ await drained
163
+ // Only a turn this attempt finished describes the log.
164
+ if (outcome.result.stopReason === 'end_turn' && !step.signal.aborted)
165
+ await saveCheckpoint()
166
+ } else {
167
+ // FX now waits on tools that A2's tool handlers run.
168
+ fxTurn.cancel()
169
+ }
170
+ controller.enqueue({ type: 'finish-step' })
171
+ controller.enqueue({
172
+ type: 'finish',
173
+ ...(completion.finishReason === undefined
174
+ ? {}
175
+ : { finishReason: completion.finishReason }),
176
+ })
177
+ }
178
+ }
179
+
180
+ const saveCheckpoint = async (): Promise<void> => {
181
+ let checkpoint: Uint8Array
182
+ try {
183
+ checkpoint = await agent!.checkpoint()
184
+ } catch {
185
+ // Over libfx's bound: later steps replay from the previous snapshot.
186
+ return
187
+ }
188
+ await saveHarness({
189
+ step,
190
+ name,
191
+ state: {
192
+ version: SNAPSHOT_VERSION,
193
+ checkpoint: Buffer.from(checkpoint).toString('base64'),
194
+ },
195
+ })
196
+ }
197
+
198
+ const hostFetch = async (
199
+ input: string | URL | Request,
200
+ init: RequestInit = {},
201
+ ): Promise<Response> => {
202
+ const url = input instanceof Request ? input.url : String(input)
203
+ if ((init.method ?? 'GET').toUpperCase() === 'GET')
204
+ return catalog(url, credential, withAuth(init.headers, credential))
205
+ const state = turn!
206
+ if (state.failure) throw state.failure
207
+ const body = decodeBody(init.body)
208
+ const compaction = isCompaction(body)
209
+ const recorded = state.plan.calls[state.next]
210
+ if (recorded !== undefined) {
211
+ if ((recorded.kind === 'compaction') !== compaction)
212
+ fail(state, diverged(`request ${state.next} is not a ${recorded.kind}`))
213
+ const index = state.next++
214
+ if (recorded.kind === 'step') {
215
+ state.served = recorded.toolCalls
216
+ state.answered = new Set()
217
+ }
218
+ const steers = state.plan.steers.get(index) ?? []
219
+ return relay(recorded.sse, async () => {
220
+ for (const text of steers) await state.fxTurn.steer(text)
221
+ })
222
+ }
223
+ if (state.plan.end === 'stop')
224
+ fail(state, diverged('FX continued a turn the log ended'))
225
+ if (state.plan.end === 'interrupted') {
226
+ state.fxTurn.cancel()
227
+ throw new DOMException('The recorded turn was interrupted', 'AbortError')
228
+ }
229
+ if (state.liveStep && !compaction) {
230
+ // One generation is one step; the next step is A2's next generation.
231
+ state.toolsRequested.resolve()
232
+ return pending(init.signal)
233
+ }
234
+ state.live = true
235
+ return live(state, url, init, compaction)
236
+ }
237
+
238
+ let compactions = 0
239
+ const live = async (
240
+ state: TurnState,
241
+ url: string,
242
+ init: RequestInit,
243
+ compaction: boolean,
244
+ ): Promise<Response> => {
245
+ const response = await fetch(url, {
246
+ ...init,
247
+ headers: withAuth(init.headers, credential),
248
+ })
249
+ if (!response.ok || response.body === null) return response
250
+ const [forKernel, forLog] = response.body.tee()
251
+ // Live responses reach the log in request order: a compaction's part
252
+ // precedes the step that follows it.
253
+ const previous = state.parsed
254
+ if (compaction) {
255
+ // Unique across the response's generations: the transcript keys data parts by ID.
256
+ const id = `fx-compaction:${step.requestIndex}:${compactions++}`
257
+ state.parsed = (async () => {
258
+ const all = await readParts(forLog)
259
+ await previous
260
+ emit({
261
+ type: 'data-a2-compaction',
262
+ id,
263
+ data: compactionData(all),
264
+ })
265
+ })()
266
+ } else {
267
+ state.liveStep = true
268
+ state.parsed = (async () => {
269
+ await previous
270
+ emit({ type: 'start-step' })
271
+ await streamStep(forLog, emit, (finished) => {
272
+ completion = finished
273
+ })
274
+ })()
275
+ }
276
+ // A stopped step abandons its parse; its failure surfaces through the turn.
277
+ state.parsed.catch(() => {})
278
+ return new Response(forKernel, {
279
+ status: response.status,
280
+ headers: response.headers,
281
+ })
282
+ }
283
+
284
+ const hostTools = await kernelTools(step.tools, (toolName, toolInput) => {
285
+ const state = turn!
286
+ if (state.failure) throw state.failure
287
+ const call = state.served.find(
288
+ (candidate) =>
289
+ candidate.toolName === toolName &&
290
+ !state.answered.has(candidate.toolCallId) &&
291
+ sameJson(candidate.input, toolInput),
292
+ )
293
+ const result = call === undefined ? undefined : results.get(call.toolCallId)
294
+ if (call !== undefined && result !== undefined) {
295
+ state.answered.add(call.toolCallId)
296
+ if (result.error !== undefined) throw new Error(result.error)
297
+ if (result.denied === true) throw new Error('The tool call was denied.')
298
+ return result.output ?? null
299
+ }
300
+ if (state.live) {
301
+ state.toolsRequested.resolve()
302
+ return pending(undefined)
303
+ }
304
+ if (state.plan.end === 'interrupted') {
305
+ state.fxTurn.cancel()
306
+ return pending(undefined)
307
+ }
308
+ return fail(state, diverged(`no recorded result for ${toolName}`))
309
+ })
310
+
311
+ let turns = read.turns.map(turnPlan)
312
+ let results = read.results
313
+ let controller: ReadableStreamDefaultController<UIMessageChunk> | undefined
314
+ let closed = false
315
+ const emit = (chunk: UIMessageChunk): void => {
316
+ if (!closed) controller!.enqueue(chunk)
317
+ }
318
+
319
+ const create = (checkpoint: Uint8Array | undefined) =>
320
+ createFxAgent({
321
+ apiKey: credential.token,
322
+ model: kernelModel(step),
323
+ ...(step.instructions.length === 0
324
+ ? {}
325
+ : { instructions: step.instructions }),
326
+ tools: hostTools,
327
+ ...(checkpoint === undefined ? {} : { checkpoint }),
328
+ fetch: hostFetch,
329
+ })
330
+
331
+ const stream = new ReadableStream<UIMessageChunk>({
332
+ start(streamController) {
333
+ controller = streamController
334
+ const abort = () => {
335
+ stopped = true
336
+ turn?.fxTurn.cancel()
337
+ }
338
+ step.signal.addEventListener('abort', abort, { once: true })
339
+ void (async () => {
340
+ try {
341
+ try {
342
+ agent = await create(read.saved)
343
+ } catch (error) {
344
+ if (read.saved === undefined) throw error
345
+ // An unusable checkpoint: replay the whole log instead.
346
+ read = await replayAll()
347
+ turns = read.turns.map(turnPlan)
348
+ results = read.results
349
+ agent = await create(undefined)
350
+ }
351
+ await run(streamController, turns)
352
+ closed = true
353
+ streamController.close()
354
+ } catch (error) {
355
+ closed = true
356
+ streamController.error(
357
+ step.signal.aborted ? step.signal.reason : error,
358
+ )
359
+ } finally {
360
+ step.signal.removeEventListener('abort', abort)
361
+ await agent?.close().catch(() => {})
362
+ }
363
+ })()
364
+ },
365
+ cancel() {
366
+ closed = true
367
+ stopped = true
368
+ turn?.fxTurn.cancel()
369
+ },
370
+ })
371
+
372
+ return { stream, completion: () => completion }
373
+ }
374
+
375
+ // ------------------------------------------------------------ the plan
376
+
377
+ function turnPlan(turn: HarnessTurn): TurnPlan {
378
+ const calls: RecordedCall[] = []
379
+ 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)
382
+ }
383
+ steer(0, turn.generations.length === 0 ? turn.steers.get(0) : undefined)
384
+ turn.generations.forEach((generation, index) => {
385
+ calls.push(...recordedCalls(generation.chunks, generation.completed))
386
+ // A generation's step is its last call.
387
+ steer(calls.length - 1, turn.steers.get(index))
388
+ })
389
+ return { prompt: turn.prompt, calls, steers, end: turn.end }
390
+ }
391
+
392
+ /** A completed generation's model calls: its compactions, then its step. */
393
+ function recordedCalls(
394
+ chunks: readonly UIMessageChunk[],
395
+ completed: GenerationCompletedPayload,
396
+ ): RecordedCall[] {
397
+ const calls: RecordedCall[] = []
398
+ const parts: StreamPart[] = []
399
+ const toolCalls: ToolCall[] = []
400
+ // A streamed call's input is the provider's exact text; the kernel copies it.
401
+ const inputText = new Map<string, string>()
402
+ for (const chunk of chunks) {
403
+ const metadata =
404
+ 'providerMetadata' in chunk && chunk.providerMetadata !== undefined
405
+ ? { providerMetadata: chunk.providerMetadata }
406
+ : {}
407
+ switch (chunk.type) {
408
+ case 'data-a2-compaction':
409
+ calls.push({
410
+ kind: 'compaction',
411
+ sse: compactionSse(chunk.data as CompactionPartData),
412
+ toolCalls: [],
413
+ })
414
+ break
415
+ case 'text-start':
416
+ case 'text-end':
417
+ case 'reasoning-start':
418
+ case 'reasoning-end':
419
+ parts.push({ type: chunk.type, id: chunk.id, ...metadata })
420
+ break
421
+ case 'text-delta':
422
+ case 'reasoning-delta':
423
+ parts.push({
424
+ type: chunk.type,
425
+ id: chunk.id,
426
+ delta: chunk.delta,
427
+ ...metadata,
428
+ })
429
+ break
430
+ case 'tool-input-start':
431
+ inputText.set(chunk.toolCallId, '')
432
+ parts.push({
433
+ type: 'tool-input-start',
434
+ id: chunk.toolCallId,
435
+ toolName: chunk.toolName,
436
+ ...metadata,
437
+ })
438
+ break
439
+ case 'tool-input-delta':
440
+ inputText.set(
441
+ chunk.toolCallId,
442
+ (inputText.get(chunk.toolCallId) ?? '') + chunk.inputTextDelta,
443
+ )
444
+ parts.push({
445
+ type: 'tool-input-delta',
446
+ id: chunk.toolCallId,
447
+ delta: chunk.inputTextDelta,
448
+ })
449
+ break
450
+ case 'tool-input-error':
451
+ // A call whose input did not parse: FX answered it itself.
452
+ if (inputText.has(chunk.toolCallId))
453
+ parts.push({ type: 'tool-input-end', id: chunk.toolCallId })
454
+ parts.push({
455
+ type: 'tool-call',
456
+ toolCallId: chunk.toolCallId,
457
+ 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),
478
+ ...metadata,
479
+ })
480
+ break
481
+ }
482
+ }
483
+ parts.push({
484
+ type: 'finish',
485
+ finishReason: { unified: completed.finishReason ?? 'other' },
486
+ usage: streamUsage(completed.usage),
487
+ })
488
+ calls.push({ kind: 'step', sse: sse(parts), toolCalls })
489
+ return calls
490
+ }
491
+
492
+ function compactionSse(data: CompactionPartData): string {
493
+ return sse([
494
+ { type: 'text-start', id: '0' },
495
+ { type: 'text-delta', id: '0', delta: data.summary ?? '' },
496
+ { type: 'text-end', id: '0' },
497
+ {
498
+ type: 'finish',
499
+ finishReason: { unified: 'stop' },
500
+ usage: streamUsage(data.usage),
501
+ },
502
+ ])
503
+ }
504
+
505
+ // ------------------------------------------------------- live responses
506
+
507
+ /** 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> {
513
+ for await (const part of sseParts(body)) {
514
+ const metadata =
515
+ part['providerMetadata'] === undefined
516
+ ? {}
517
+ : { providerMetadata: part['providerMetadata'] as never }
518
+ switch (part.type) {
519
+ case 'text-start':
520
+ case 'text-end':
521
+ case 'reasoning-start':
522
+ case 'reasoning-end':
523
+ emit({ type: part.type, id: part['id'] as string, ...metadata })
524
+ break
525
+ case 'text-delta':
526
+ case 'reasoning-delta':
527
+ emit({
528
+ type: part.type,
529
+ id: part['id'] as string,
530
+ delta: part['delta'] as string,
531
+ ...metadata,
532
+ })
533
+ break
534
+ case 'tool-input-start':
535
+ emit({
536
+ type: 'tool-input-start',
537
+ toolCallId: part['id'] as string,
538
+ toolName: part['toolName'] as string,
539
+ ...metadata,
540
+ })
541
+ break
542
+ case 'tool-input-delta':
543
+ emit({
544
+ type: 'tool-input-delta',
545
+ toolCallId: part['id'] as string,
546
+ inputTextDelta: part['delta'] as string,
547
+ })
548
+ break
549
+ case 'tool-call': {
550
+ 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
563
+ }
564
+ emit({
565
+ type: 'tool-input-available',
566
+ toolCallId: part['toolCallId'] as string,
567
+ toolName: part['toolName'] as string,
568
+ input,
569
+ ...metadata,
570
+ })
571
+ break
572
+ }
573
+ case 'finish': {
574
+ const reason = part['finishReason'] as { unified?: FinishReason }
575
+ finished({
576
+ ...(reason?.unified === undefined
577
+ ? {}
578
+ : { finishReason: reason.unified }),
579
+ usage: modelUsage(part['usage']),
580
+ })
581
+ break
582
+ }
583
+ case 'error':
584
+ emit({ type: 'error', errorText: errorText(part['error']) })
585
+ break
586
+ }
587
+ }
588
+ }
589
+
590
+ async function readParts(
591
+ body: ReadableStream<Uint8Array>,
592
+ ): Promise<StreamPart[]> {
593
+ const all: StreamPart[] = []
594
+ for await (const part of sseParts(body)) all.push(part)
595
+ return all
596
+ }
597
+
598
+ function compactionData(all: readonly StreamPart[]): CompactionPartData {
599
+ const finish = all.find((part) => part.type === 'finish')
600
+ return {
601
+ status: 'completed',
602
+ summary: all
603
+ .filter((part) => part.type === 'text-delta')
604
+ .map((part) => part['delta'] as string)
605
+ .join(''),
606
+ ...(finish === undefined ? {} : { usage: modelUsage(finish['usage']) }),
607
+ }
608
+ }
609
+
610
+ async function* sseParts(
611
+ body: ReadableStream<Uint8Array>,
612
+ ): AsyncGenerator<StreamPart> {
613
+ const decoder = new TextDecoder()
614
+ let pendingText = ''
615
+ for await (const chunk of body) {
616
+ pendingText += decoder.decode(chunk, { stream: true })
617
+ let end: number
618
+ while ((end = pendingText.indexOf('\n\n')) !== -1) {
619
+ const frame = pendingText.slice(0, end)
620
+ pendingText = pendingText.slice(end + 2)
621
+ if (frame.startsWith('data: {')) yield JSON.parse(frame.slice(6))
622
+ }
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Hands a recorded response to the kernel frame by frame. Steering due after
628
+ * it is queued before its finish part, while the kernel still waits on it.
629
+ */
630
+ function relay(text: string, beforeFinish: () => Promise<void>): Response {
631
+ const encoder = new TextEncoder()
632
+ const frames = text.split(/(?<=\n\n)/)
633
+ let index = 0
634
+ let steered = false
635
+ return new Response(
636
+ new ReadableStream({
637
+ async pull(controller) {
638
+ const frame = frames[index++]
639
+ if (frame === undefined) {
640
+ controller.close()
641
+ return
642
+ }
643
+ if (!steered && frame.includes('"type":"finish"')) {
644
+ steered = true
645
+ await beforeFinish()
646
+ }
647
+ controller.enqueue(encoder.encode(frame))
648
+ },
649
+ }),
650
+ { headers: { 'content-type': 'text/event-stream' } },
651
+ )
652
+ }
653
+
654
+ // ----------------------------------------------------------- the kernel
655
+
656
+ async function loadLibfx(): Promise<typeof import('libfx')> {
657
+ try {
658
+ return await import('libfx')
659
+ } catch (error) {
660
+ throw new Error('fx() needs the libfx package: install libfx', {
661
+ cause: error,
662
+ })
663
+ }
664
+ }
665
+
666
+ function withAuth(
667
+ headers: HeadersInit | undefined,
668
+ credential: GatewayCredential,
669
+ ): Headers {
670
+ const result = new Headers(headers)
671
+ result.set('ai-gateway-auth-method', credential.method)
672
+ return result
673
+ }
674
+
675
+ function kernelModel(
676
+ step: HarnessStep,
677
+ ): string | { id: string; effort?: string; fast?: boolean } {
678
+ const effort =
679
+ step.reasoning === undefined || step.reasoning === 'provider-default'
680
+ ? undefined
681
+ : step.reasoning
682
+ const fast = step.speed === 'fast'
683
+ if (effort === undefined && !fast) return step.model
684
+ return {
685
+ id: step.model,
686
+ ...(effort === undefined ? {} : { effort }),
687
+ ...(fast ? { fast } : {}),
688
+ }
689
+ }
690
+
691
+ async function kernelTools(
692
+ tools: ToolSet,
693
+ execute: (toolName: string, input: unknown) => unknown,
694
+ ): Promise<FxHostTool[]> {
695
+ 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
+ })),
705
+ )
706
+ }
707
+
708
+ // Keyed by credential, and Vercel's OIDC tokens rotate, so it is bounded.
709
+ const catalogCache = boundedCache<
710
+ string,
711
+ { expires: number; body: Promise<string> }
712
+ >({ maxEntries: 16, maxBytes: 16 })
713
+
714
+ /**
715
+ * Model catalog reads, shared for an hour by every step in the process that
716
+ * uses the same credential; credentials can see different catalogs.
717
+ */
718
+ async function catalog(
719
+ url: string,
720
+ credential: GatewayCredential,
721
+ headers: Headers,
722
+ ): Promise<Response> {
723
+ const key = `${url}\n${credential.token}`
724
+ const now = Date.now()
725
+ let entry = catalogCache.get(key)
726
+ if (entry === undefined || entry.expires <= now) {
727
+ const body = fetch(url, { headers }).then(async (response) => {
728
+ if (!response.ok)
729
+ throw new Error(`catalog request failed: ${response.status}`)
730
+ return response.text()
731
+ })
732
+ entry = { expires: now + CATALOG_TTL_MS, body }
733
+ catalogCache.set(key, entry, 1)
734
+ body.catch(() => catalogCache.delete(key))
735
+ }
736
+ return new Response(await entry.body, {
737
+ headers: { 'content-type': 'application/json' },
738
+ })
739
+ }
740
+
741
+ // ------------------------------------------------------------- helpers
742
+
743
+ function savedCheckpoint(state: unknown): Uint8Array | undefined {
744
+ if (typeof state !== 'object' || state === null) return undefined
745
+ const { version, checkpoint } = state as Record<string, unknown>
746
+ if (version !== SNAPSHOT_VERSION || typeof checkpoint !== 'string')
747
+ return undefined
748
+ return new Uint8Array(Buffer.from(checkpoint, 'base64'))
749
+ }
750
+
751
+ function isCompaction(body: string): boolean {
752
+ const request = JSON.parse(body) as { toolChoice?: { type?: string } }
753
+ return request.toolChoice?.type === 'none'
754
+ }
755
+
756
+ function decodeBody(body: BodyInit | null | undefined): string {
757
+ if (typeof body === 'string') return body
758
+ if (body instanceof Uint8Array || body instanceof ArrayBuffer)
759
+ return new TextDecoder().decode(body)
760
+ throw new TypeError('FX sent an unexpected request body')
761
+ }
762
+
763
+ function sameJson(a: unknown, b: unknown): boolean {
764
+ return JSON.stringify(a) === JSON.stringify(b)
765
+ }
766
+
767
+ function sse(all: readonly object[]): string {
768
+ return (
769
+ all.map((part) => `data: ${JSON.stringify(part)}\n\n`).join('') +
770
+ 'data: [DONE]\n\n'
771
+ )
772
+ }
773
+
774
+ function errorText(error: unknown): string {
775
+ if (typeof error === 'string') return error
776
+ if (error instanceof Error) return error.message
777
+ return JSON.stringify(error)
778
+ }
779
+
780
+ type StreamUsage = {
781
+ inputTokens?: {
782
+ total?: number | undefined
783
+ noCache?: number | undefined
784
+ cacheRead?: number | undefined
785
+ cacheWrite?: number | undefined
786
+ }
787
+ outputTokens?: {
788
+ total?: number | undefined
789
+ text?: number | undefined
790
+ reasoning?: number | undefined
791
+ }
792
+ }
793
+
794
+ /** A Gateway stream usage, as AI SDK normalizes it. */
795
+ function modelUsage(value: unknown): LanguageModelUsage {
796
+ const usage = (value ?? {}) as StreamUsage
797
+ const input = usage.inputTokens ?? {}
798
+ const output = usage.outputTokens ?? {}
799
+ return {
800
+ inputTokens: input.total,
801
+ inputTokenDetails: {
802
+ noCacheTokens: input.noCache,
803
+ cacheReadTokens: input.cacheRead,
804
+ cacheWriteTokens: input.cacheWrite,
805
+ },
806
+ outputTokens: output.total,
807
+ outputTokenDetails: {
808
+ textTokens: output.text,
809
+ reasoningTokens: output.reasoning,
810
+ },
811
+ totalTokens:
812
+ input.total === undefined && output.total === undefined
813
+ ? undefined
814
+ : (input.total ?? 0) + (output.total ?? 0),
815
+ }
816
+ }
817
+
818
+ /** The inverse of `modelUsage`, for replay. */
819
+ function streamUsage(usage: LanguageModelUsage | undefined): StreamUsage {
820
+ return {
821
+ inputTokens: {
822
+ total: usage?.inputTokens,
823
+ noCache: usage?.inputTokenDetails?.noCacheTokens,
824
+ cacheRead: usage?.inputTokenDetails?.cacheReadTokens,
825
+ cacheWrite: usage?.inputTokenDetails?.cacheWriteTokens,
826
+ },
827
+ outputTokens: {
828
+ total: usage?.outputTokens,
829
+ text: usage?.outputTokenDetails?.textTokens,
830
+ reasoning: usage?.outputTokenDetails?.reasoningTokens,
831
+ },
832
+ }
833
+ }