experimental-a2 0.17.0 → 0.18.1

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