experimental-a2 0.21.1 → 0.23.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 (148) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/{actor-HnGmrOZZ.d.ts → actor-CqibLgjQ.d.ts} +13 -5
  3. package/dist/actor-CqibLgjQ.d.ts.map +1 -0
  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-shared-DFET5q16.js.map +1 -1
  8. package/dist/actor.d.ts +1 -1
  9. package/dist/actor.js +3 -3
  10. package/dist/{ai-CvEnqVfo.d.ts → ai-DAu77CDr.d.ts} +2 -2
  11. package/dist/{ai-CvEnqVfo.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
  12. package/dist/{ai-CzE672Q3.js → ai-EEfKyc-z.js} +2 -2
  13. package/dist/{ai-CzE672Q3.js.map → ai-EEfKyc-z.js.map} +1 -1
  14. package/dist/ai-client.d.ts +1 -1
  15. package/dist/ai-client.js +2 -2
  16. package/dist/ai-fx.d.ts +4 -2
  17. package/dist/ai-fx.d.ts.map +1 -1
  18. package/dist/ai-fx.js +234 -62
  19. package/dist/ai-fx.js.map +1 -1
  20. package/dist/{ai-harness-Bfk-Lycn.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
  21. package/dist/{ai-harness-Bfk-Lycn.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
  22. package/dist/ai-harness-dQeJaR1t.js.map +1 -1
  23. package/dist/ai-pi.d.ts +1 -1
  24. package/dist/ai-pi.js +1 -1
  25. package/dist/ai-react.d.ts +1 -1
  26. package/dist/ai-react.js +2 -2
  27. package/dist/ai-server.d.ts +3 -3
  28. package/dist/ai-server.d.ts.map +1 -1
  29. package/dist/ai-server.js +10 -4
  30. package/dist/ai-server.js.map +1 -1
  31. package/dist/ai.d.ts +1 -1
  32. package/dist/ai.js +1 -1
  33. package/dist/{client-9IeTsiAs.js → client-BQS4i_V9.js} +27 -4
  34. package/dist/client-BQS4i_V9.js.map +1 -0
  35. package/dist/client-CUMbS_38.d.ts.map +1 -1
  36. package/dist/client.js +1 -1
  37. package/dist/index.d.ts +2 -2
  38. package/dist/{internal-Bc4PvfHk.js → internal-CvsrprnU.js} +32 -15
  39. package/dist/{internal-Bc4PvfHk.js.map → internal-CvsrprnU.js.map} +1 -1
  40. package/dist/react.js +1 -1
  41. package/dist/{runtime-consumer-BWmht60z.js → runtime-consumer-CIeL33wj.js} +2 -2
  42. package/dist/{runtime-consumer-BWmht60z.js.map → runtime-consumer-CIeL33wj.js.map} +1 -1
  43. package/dist/scheduler-qstash.d.ts +2 -2
  44. package/dist/scheduler-qstash.js +2 -2
  45. package/dist/scheduler-vercel.d.ts +2 -2
  46. package/dist/scheduler-vercel.js +2 -2
  47. package/dist/{server-DvIB8kj5.js → server-DtFrWxi3.js} +30 -8
  48. package/dist/server-DtFrWxi3.js.map +1 -0
  49. package/dist/{server-BshFZ0eL.d.ts → server-qgez0Xny.d.ts} +3 -3
  50. package/dist/{server-BshFZ0eL.d.ts.map → server-qgez0Xny.d.ts.map} +1 -1
  51. package/dist/server.d.ts +2 -2
  52. package/dist/server.js +1 -1
  53. package/dist/{session-clear-Bk_29eEw.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
  54. package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
  55. package/dist/{store-Z2qjw50n.d.ts → store-D0MhBvg6.d.ts} +15 -6
  56. package/dist/store-D0MhBvg6.d.ts.map +1 -0
  57. package/dist/store-N8PXxDAS.js.map +1 -1
  58. package/dist/store-memory.d.ts +1 -1
  59. package/dist/store-memory.d.ts.map +1 -1
  60. package/dist/store-memory.js +15 -17
  61. package/dist/store-memory.js.map +1 -1
  62. package/dist/{store-polling-DYNyxCev.js → store-polling-BZt2Rf3D.js} +2 -2
  63. package/dist/{store-polling-DYNyxCev.js.map → store-polling-BZt2Rf3D.js.map} +1 -1
  64. package/dist/store-postgres.d.ts +1 -1
  65. package/dist/store-postgres.d.ts.map +1 -1
  66. package/dist/store-postgres.js +31 -40
  67. package/dist/store-postgres.js.map +1 -1
  68. package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
  69. package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
  70. package/dist/store-redis-http.d.ts +1 -1
  71. package/dist/store-redis-http.js +3 -3
  72. package/dist/{store-redis-notify-IiVdlYHc.js → store-redis-notify-CBDVJxA_.js} +545 -399
  73. package/dist/store-redis-notify-CBDVJxA_.js.map +1 -0
  74. package/dist/store-redis.d.ts +1 -1
  75. package/dist/store-redis.d.ts.map +1 -1
  76. package/dist/store-redis.js +3 -4
  77. package/dist/store-redis.js.map +1 -1
  78. package/dist/store-sqlite.d.ts +1 -1
  79. package/dist/store-sqlite.d.ts.map +1 -1
  80. package/dist/store-sqlite.js +16 -18
  81. package/dist/store-sqlite.js.map +1 -1
  82. package/dist/store-valkey.d.ts +33 -0
  83. package/dist/store-valkey.d.ts.map +1 -0
  84. package/dist/store-valkey.js +218 -0
  85. package/dist/store-valkey.js.map +1 -0
  86. package/dist/stream-buffer-DrTyiIuo.js +57 -0
  87. package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
  88. package/dist/{wire-CZGqNZRx.js → wire-CV-Z7wz-.js} +3 -3
  89. package/dist/{wire-CZGqNZRx.js.map → wire-CV-Z7wz-.js.map} +1 -1
  90. package/docs/actors/01-introduction.mdx +5 -2
  91. package/docs/agents/03-conversations.mdx +9 -8
  92. package/docs/agents/04-harnesses.mdx +7 -4
  93. package/docs/client/03-presence.mdx +1 -0
  94. package/docs/reference/01-core.mdx +4 -2
  95. package/docs/reference/02-server.mdx +5 -1
  96. package/docs/reference/04-ai.mdx +13 -4
  97. package/docs/reference/06-errors.mdx +1 -1
  98. package/docs/server/01-consumers.mdx +4 -1
  99. package/docs/server/06-guarantees.mdx +7 -4
  100. package/docs/stores/01-overview.mdx +1 -0
  101. package/docs/stores/02-redis.mdx +18 -10
  102. package/docs/stores/03-redis-http.mdx +6 -4
  103. package/docs/stores/04-postgres.mdx +2 -2
  104. package/docs/stores/07-valkey.mdx +97 -0
  105. package/docs/upgrades/01-overview.mdx +1 -0
  106. package/docs/upgrades/78-0-22.mdx +115 -0
  107. package/examples/playground/app/a0/sandbox.ts +58 -13
  108. package/examples/playground/app/a2slack/db/a2.ts +7 -4
  109. package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
  110. package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
  111. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +8 -1
  112. package/examples/playground/app/chat/server.ts +2 -2
  113. package/examples/playground/lib/store.ts +1 -1
  114. package/examples/playground/package.json +1 -1
  115. package/package.json +8 -2
  116. package/sql/postgres/drizzle-schema.ts +7 -4
  117. package/sql/postgres/schema.sql +24 -3
  118. package/src/actor-shared.ts +10 -2
  119. package/src/ai-agent.ts +13 -0
  120. package/src/ai-fx.ts +365 -78
  121. package/src/ai-harness.ts +7 -0
  122. package/src/client.ts +19 -2
  123. package/src/deterministic-id.ts +1 -1
  124. package/src/internal.ts +42 -11
  125. package/src/libfx.d.ts +2 -0
  126. package/src/presence-recovery.ts +18 -11
  127. package/src/presence-tombstones.ts +52 -0
  128. package/src/push-envelope.ts +2 -2
  129. package/src/server.ts +49 -15
  130. package/src/store-memory.ts +24 -32
  131. package/src/store-postgres.ts +43 -53
  132. package/src/store-presence-polling.ts +59 -34
  133. package/src/store-redis-core.ts +595 -439
  134. package/src/store-redis-notify.ts +82 -28
  135. package/src/store-redis.ts +2 -3
  136. package/src/store-sqlite.ts +34 -40
  137. package/src/store-valkey.ts +376 -0
  138. package/src/store.ts +14 -5
  139. package/dist/actor-HnGmrOZZ.d.ts.map +0 -1
  140. package/dist/client-9IeTsiAs.js.map +0 -1
  141. package/dist/server-DvIB8kj5.js.map +0 -1
  142. package/dist/session-clear-Bk_29eEw.d.ts.map +0 -1
  143. package/dist/store-Z2qjw50n.d.ts.map +0 -1
  144. package/dist/store-presence-polling-CzQ0wdtM.js +0 -94
  145. package/dist/store-presence-polling-CzQ0wdtM.js.map +0 -1
  146. package/dist/store-redis-notify-IiVdlYHc.js.map +0 -1
  147. package/dist/stream-buffer-Cx7AAPGq.js +0 -23
  148. package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
package/src/ai-fx.ts CHANGED
@@ -34,18 +34,35 @@ import {
34
34
  } from './ai-harness.ts'
35
35
  import type { CompactionPartData, GenerationCompletedPayload } from './ai.ts'
36
36
  import { schemaInput } from './ai-tool-input.ts'
37
+ import { HARNESS_PARKING } from './internal.ts'
37
38
  import { boundedCache } from './bounded-cache.ts'
38
39
 
39
40
  export type { Harness } from './ai-harness.ts'
40
41
 
41
42
  /**
42
43
  * The FX harness. Every model call FX makes is one durable step; A2 rebuilds
43
- * the kernel for each step from its latest checkpoint and the log since.
44
+ * the kernel for each step from its latest checkpoint and the log since,
45
+ * unless this process holds the kernel the step before left waiting on its
46
+ * tool calls.
44
47
  */
45
48
  export function fx(): Harness<'fx', never> {
49
+ // Per agent, as its saved states are: sessions of two agents never share one.
50
+ const lots = new WeakMap<object, ParkingLot>()
51
+ const lotOf = (scope: object): ParkingLot => {
52
+ let lot = lots.get(scope)
53
+ if (lot === undefined) {
54
+ lot = parkingLot()
55
+ lots.set(scope, lot)
56
+ }
57
+ return lot
58
+ }
46
59
  return defineHarness('fx', {
47
60
  tools: {},
48
- step: runStep,
61
+ step: (step) => runStep(step, lotOf(step.scope)),
62
+ release: ({ scope, sessionId }) => {
63
+ const entry = lots.get(scope)?.take(sessionId)
64
+ if (entry !== undefined) closeParked(entry)
65
+ },
49
66
  })
50
67
  }
51
68
 
@@ -53,6 +70,8 @@ const SNAPSHOT_VERSION = 1
53
70
  const CATALOG_TTL_MS = 60 * 60 * 1000
54
71
  /** The Gateway model catalog libfx reads for each new kernel. */
55
72
  const CATALOG_PATH = '/coding-agent/v1/models'
73
+ /** Times a step rebuilds its kernel for a stopped turn FX failed on replay. */
74
+ const REPLAY_REBUILDS = 3
56
75
 
57
76
  type StreamPart = { type: string } & Record<string, unknown>
58
77
  /** A recorded tool call, with the input the kernel reads from its text. */
@@ -72,6 +91,8 @@ type TurnPlan = {
72
91
  /** Steering to queue while serving the call at that index. */
73
92
  steers: Map<number, string[]>
74
93
  end: 'stop' | 'interrupted' | 'live'
94
+ /** The attempt that made its last committed model call. */
95
+ generationId?: string
75
96
  }
76
97
 
77
98
  type Completion = { finishReason?: FinishReason; usage?: LanguageModelUsage }
@@ -92,6 +113,12 @@ type TurnState = {
92
113
  parsed?: Promise<void>
93
114
  }
94
115
 
116
+ /** A model request as the kernel sends it to the Gateway. */
117
+ type GatewayRequest = {
118
+ toolChoice?: { type?: string }
119
+ providerOptions?: Record<string, Record<string, unknown> | undefined>
120
+ }
121
+
95
122
  function fail(state: TurnState, error: Error): never {
96
123
  state.failure ??= error
97
124
  state.fxTurn.cancel()
@@ -100,12 +127,108 @@ function fail(state: TurnState, error: Error): never {
100
127
 
101
128
  const diverged = (message: string): Divergence => new Divergence('FX', message)
102
129
 
130
+ // ------------------------------------------------------------- kernels
131
+
132
+ /** A tool call the kernel waits on while A2's tool consumer runs it. */
133
+ type Waiting = {
134
+ toolName: string
135
+ input: unknown
136
+ resolve: (output: unknown) => void
137
+ reject: (error: unknown) => void
138
+ }
139
+
140
+ /** What a kernel's fetch and tools call: the step driving it now. */
141
+ type KernelHooks = {
142
+ fetch: (
143
+ input: string | URL | Request,
144
+ init?: RequestInit,
145
+ ) => Promise<Response>
146
+ tool: (toolName: string, input: unknown) => unknown
147
+ }
148
+
149
+ type Kernel = {
150
+ agent: FxAgent
151
+ hooks: KernelHooks
152
+ waiting: Waiting[]
153
+ /** Whether FX answered the kernel's latest prompt with an error. */
154
+ failed: boolean
155
+ }
156
+
157
+ /**
158
+ * A kernel a step left waiting on its tool calls. The session's next step in
159
+ * this process, in the same response or one that steered it, continues it
160
+ * with their results instead of rebuilding one from the log.
161
+ */
162
+ type Parked = {
163
+ kernel: Kernel
164
+ fxTurn: FxTurn
165
+ drained: Promise<void>
166
+ /** The attempt whose model call the kernel made. */
167
+ generationId: string
168
+ settings: string
169
+ }
170
+
171
+ type ParkingLot = {
172
+ /** Removes the session's parked kernel; the caller then owns it. */
173
+ take(sessionId: string): Parked | undefined
174
+ park(sessionId: string, entry: Parked): void
175
+ }
176
+
177
+ function closeParked(entry: Parked): void {
178
+ entry.fxTurn.cancel()
179
+ void entry.kernel.agent.close().catch(() => {})
180
+ }
181
+
182
+ /**
183
+ * One agent's parked kernels: one per session, at most `maxKernels`. Each is
184
+ * closed when its session has nothing left to run in this process (the
185
+ * agent's release), when its turn ends, or when another takes its place.
186
+ */
187
+ function parkingLot(): ParkingLot {
188
+ const held = new Map<string, Parked>()
189
+ const take = (sessionId: string): Parked | undefined => {
190
+ const entry = held.get(sessionId)
191
+ held.delete(sessionId)
192
+ return entry
193
+ }
194
+ return {
195
+ take,
196
+ park(sessionId, entry) {
197
+ const replaced = take(sessionId)
198
+ if (replaced !== undefined) closeParked(replaced)
199
+ held.set(sessionId, entry)
200
+ // A turn that ends while it waits, as a cancelled one does, is done.
201
+ const ended = () => {
202
+ if (held.get(sessionId) !== entry) return
203
+ held.delete(sessionId)
204
+ closeParked(entry)
205
+ }
206
+ entry.fxTurn.result.then(ended, ended)
207
+ const oldest = held.keys().next().value
208
+ if (held.size > HARNESS_PARKING.maxKernels && oldest !== undefined) {
209
+ const evicted = take(oldest)
210
+ if (evicted !== undefined) closeParked(evicted)
211
+ }
212
+ },
213
+ }
214
+ }
215
+
103
216
  // ---------------------------------------------------------------- step
104
217
 
105
- async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
218
+ async function runStep(
219
+ step: HarnessStep,
220
+ lot: ParkingLot,
221
+ ): Promise<HarnessGeneration> {
106
222
  const { createFxAgent } = await loadLibfx()
107
223
  const credential = gatewayCredential('fx')
108
224
  const name = `${step.agentName}.a2.harness.fx`
225
+ const definitions = await toolDefinitions(step.tools)
226
+ // What the kernel is made with: one made otherwise can't continue the step.
227
+ const settings = JSON.stringify([
228
+ kernelModel(step),
229
+ step.instructions,
230
+ definitions,
231
+ ])
109
232
  const { replayAll, ...first } = await readHarness({
110
233
  step,
111
234
  name,
@@ -119,17 +242,29 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
119
242
  // has read a response's finish, while A2 may still be reading its copy.
120
243
  const released = new AbortController()
121
244
  const network = AbortSignal.any([step.signal, released.signal])
122
- let agent: FxAgent | undefined
245
+ let kernel: Kernel | undefined
246
+ /** Whether this step left its kernel for the next one. */
247
+ let parked = false
123
248
  let turn: TurnState | undefined
124
249
 
125
250
  const run = async (
126
251
  controller: ReadableStreamDefaultController<UIMessageChunk>,
127
252
  turns: TurnPlan[],
253
+ adopted: Parked | undefined,
128
254
  ): Promise<void> => {
129
255
  controller.enqueue({ type: 'start', messageId: step.responseMessageId })
130
- for (const plan of turns) {
131
- if (stopped) return
132
- const fxTurn = agent!.prompt(plan.prompt, { signal: step.signal })
256
+ if (adopted !== undefined) {
257
+ await resume(controller, turns.at(-1)!, adopted)
258
+ return
259
+ }
260
+ let rebuilds = 0
261
+ let next = 0
262
+ while (next < turns.length) {
263
+ if (stopped || step.signal.aborted) return
264
+ const plan = turns[next]!
265
+ next += 1
266
+ // The step's abort cancels the turn; the turn outlives it when parked.
267
+ const fxTurn = kernel!.agent.prompt(plan.prompt)
133
268
  const state: TurnState = {
134
269
  plan,
135
270
  fxTurn,
@@ -159,39 +294,137 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
159
294
  throw diverged(
160
295
  `a recorded turn ended with ${result.stopReason}, not ${expected}`,
161
296
  )
297
+ if (kernel!.failed) {
298
+ // libfx's cancel aborts the request FX waits on as it sends the
299
+ // stop, and FX fails the turn when it reads the abort first. That
300
+ // kernel holds no interruption where the log has one, so the turns
301
+ // replay on a new kernel.
302
+ if (rebuilds === REPLAY_REBUILDS)
303
+ throw diverged('FX failed a stopped turn instead of cancelling it')
304
+ rebuilds += 1
305
+ await kernel!.agent.close().catch(() => {})
306
+ kernel = await create(read.saved)
307
+ next = 0
308
+ }
162
309
  continue
163
310
  }
164
- const outcome = await Promise.race([
165
- fxTurn.result.then((result) => ({ ended: true, result })),
166
- state.toolsRequested.promise.then(() => ({ ended: false as const })),
167
- ]).catch((error: unknown) => {
168
- throw state.failure ?? error
169
- })
170
- if (state.failure) throw state.failure
171
- await state.parsed
172
- if (outcome.ended) {
173
- await drained
174
- // Only a turn this attempt finished describes the log.
175
- if (outcome.result.stopReason === 'end_turn' && !step.signal.aborted)
176
- await saveCheckpoint()
177
- } else {
178
- // FX now waits on tools that A2's tool consumer run.
179
- fxTurn.cancel()
311
+ await finishLive(controller, state, drained)
312
+ }
313
+ }
314
+
315
+ /** Continues a parked kernel: the results it waits on are in the log. */
316
+ const resume = async (
317
+ controller: ReadableStreamDefaultController<UIMessageChunk>,
318
+ plan: TurnPlan,
319
+ adopted: Parked,
320
+ ): Promise<void> => {
321
+ const state: TurnState = {
322
+ plan,
323
+ fxTurn: adopted.fxTurn,
324
+ next: plan.calls.length,
325
+ served: plan.calls.at(-1)!.toolCalls,
326
+ answered: new Set(),
327
+ live: false,
328
+ liveStep: false,
329
+ toolsRequested: deferred(),
330
+ }
331
+ turn = state
332
+ // Steering goes in before any tool result, so FX's next model call can't
333
+ // start without it, as replay queues it before the recorded call ends.
334
+ for (const text of plan.steers.get(plan.calls.length - 1) ?? [])
335
+ await adopted.fxTurn.steer(text)
336
+ for (const waiting of kernel!.waiting.splice(0)) {
337
+ try {
338
+ const answered = answer(state, waiting.toolName, waiting.input)
339
+ if (answered === undefined)
340
+ fail(state, diverged(`no recorded result for ${waiting.toolName}`))
341
+ waiting.resolve(answered.output)
342
+ } catch (error) {
343
+ waiting.reject(error)
180
344
  }
181
- controller.enqueue({ type: 'finish-step' })
182
- controller.enqueue({
183
- type: 'finish',
184
- ...(completion.finishReason === undefined
185
- ? {}
186
- : { finishReason: completion.finishReason }),
345
+ }
346
+ await finishLive(controller, state, adopted.drained)
347
+ }
348
+
349
+ const finishLive = async (
350
+ controller: ReadableStreamDefaultController<UIMessageChunk>,
351
+ state: TurnState,
352
+ drained: Promise<void>,
353
+ ): Promise<void> => {
354
+ const outcome = await Promise.race([
355
+ state.fxTurn.result.then((result) => ({ ended: true, result })),
356
+ state.toolsRequested.promise.then(() => ({ ended: false as const })),
357
+ ]).catch((error: unknown) => {
358
+ throw state.failure ?? error
359
+ })
360
+ if (state.failure) throw state.failure
361
+ await state.parsed
362
+ if (outcome.ended) {
363
+ if (
364
+ outcome.result.stopReason === 'cancelled' &&
365
+ !stopped &&
366
+ !step.signal.aborted
367
+ )
368
+ throw diverged('the turn was cancelled while this step ran it')
369
+ await drained
370
+ // Only a turn this attempt finished describes the log.
371
+ if (outcome.result.stopReason === 'end_turn' && !step.signal.aborted)
372
+ await saveCheckpoint()
373
+ } else if (kernel!.waiting.length > 0 && !stopped && !closed) {
374
+ // FX now waits on tools that A2's tool consumer runs; the next step in
375
+ // this process can answer it with their results.
376
+ lot.park(step.sessionId, {
377
+ kernel: kernel!,
378
+ fxTurn: state.fxTurn,
379
+ drained,
380
+ generationId: step.generationId,
381
+ settings,
187
382
  })
383
+ parked = true
384
+ } else {
385
+ state.fxTurn.cancel()
188
386
  }
387
+ controller.enqueue({ type: 'finish-step' })
388
+ controller.enqueue({
389
+ type: 'finish',
390
+ ...(completion.finishReason === undefined
391
+ ? {}
392
+ : { finishReason: completion.finishReason }),
393
+ })
394
+ }
395
+
396
+ /**
397
+ * Whether a parked kernel stands where this step's log does: made the same
398
+ * way, its model call the last one the log's live turn committed, and a
399
+ * recorded result for each tool call it waits on. A steered response
400
+ * continues the same turn, so its steps qualify too.
401
+ */
402
+ const adoptable = (entry: Parked, turns: TurnPlan[]): boolean => {
403
+ const plan = turns.at(-1)
404
+ if (
405
+ entry.settings !== settings ||
406
+ plan?.end !== 'live' ||
407
+ plan.generationId !== entry.generationId
408
+ )
409
+ return false
410
+ const unanswered = [...(plan.calls.at(-1)?.toolCalls ?? [])]
411
+ return entry.kernel.waiting.every((waiting) => {
412
+ const at = unanswered.findIndex(
413
+ (call) =>
414
+ call.toolName === waiting.toolName &&
415
+ sameJson(call.input, waiting.input) &&
416
+ results.has(call.toolCallId),
417
+ )
418
+ if (at === -1) return false
419
+ unanswered.splice(at, 1)
420
+ return true
421
+ })
189
422
  }
190
423
 
191
424
  const saveCheckpoint = async (): Promise<void> => {
192
425
  let checkpoint: Uint8Array
193
426
  try {
194
- checkpoint = await agent!.checkpoint()
427
+ checkpoint = await kernel!.agent.checkpoint()
195
428
  } catch {
196
429
  // Later steps replay from the previous snapshot.
197
430
  return
@@ -218,7 +451,8 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
218
451
  const state = turn!
219
452
  if (state.failure) throw state.failure
220
453
  const body = decodeBody(init.body)
221
- const compaction = isCompaction(body)
454
+ const request = JSON.parse(body) as GatewayRequest
455
+ const compaction = request.toolChoice?.type === 'none'
222
456
  const recorded = state.plan.calls[state.next]
223
457
  if (recorded !== undefined) {
224
458
  if ((recorded.kind === 'compaction') !== compaction)
@@ -245,7 +479,12 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
245
479
  return pending(init.signal)
246
480
  }
247
481
  state.live = true
248
- return live(state, url, { ...init, body }, compaction)
482
+ return live(
483
+ state,
484
+ url,
485
+ { ...init, body: withGatewayCaching(body, request) },
486
+ compaction,
487
+ )
249
488
  }
250
489
 
251
490
  const affinity = gatewaySessionAffinity({
@@ -256,18 +495,13 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
256
495
  const live = async (
257
496
  state: TurnState,
258
497
  url: string,
259
- init: RequestInit & { body: string },
498
+ init: RequestInit,
260
499
  compaction: boolean,
261
500
  ): Promise<Response> => {
262
501
  const headers = withAuth(init.headers, credential)
263
502
  // The kernel's own affinity is new with every rebuild; A2's lasts the session.
264
503
  headers.set('x-session-affinity', affinity)
265
- const response = await fetch(url, {
266
- ...init,
267
- body: withGatewayCaching(init.body),
268
- headers,
269
- signal: network,
270
- })
504
+ const response = await fetch(url, { ...init, headers, signal: network })
271
505
  // Abandoned before its headers came: the kernel has moved on without it.
272
506
  if (init.signal?.aborted) {
273
507
  void response.body?.cancel()
@@ -316,9 +550,12 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
316
550
  })
317
551
  }
318
552
 
319
- const hostTools = await kernelTools(step.tools, (toolName, toolInput) => {
320
- const state = turn!
321
- if (state.failure) throw state.failure
553
+ /** The recorded result of a call from the step the kernel was served. */
554
+ const answer = (
555
+ state: TurnState,
556
+ toolName: string,
557
+ toolInput: unknown,
558
+ ): { output: unknown } | undefined => {
322
559
  const call = state.served.find(
323
560
  (candidate) =>
324
561
  candidate.toolName === toolName &&
@@ -326,24 +563,33 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
326
563
  sameJson(candidate.input, toolInput),
327
564
  )
328
565
  const result = call === undefined ? undefined : results.get(call.toolCallId)
329
- if (call !== undefined && result !== undefined) {
330
- state.answered.add(call.toolCallId)
331
- if (result.error !== undefined) throw new Error(result.error)
332
- if (result.denied === true) throw new Error('The tool call was denied.')
333
- return result.output ?? null
334
- }
566
+ if (call === undefined || result === undefined) return undefined
567
+ state.answered.add(call.toolCallId)
568
+ if (result.error !== undefined) throw new Error(result.error)
569
+ if (result.denied === true) throw new Error('The tool call was denied.')
570
+ return { output: result.output ?? null }
571
+ }
572
+
573
+ const hostTool = (toolName: string, toolInput: unknown): unknown => {
574
+ const state = turn!
575
+ if (state.failure) throw state.failure
576
+ const answered = answer(state, toolName, toolInput)
577
+ if (answered !== undefined) return answered.output
335
578
  if (state.live) {
336
579
  state.toolsRequested.resolve()
337
- return pending(undefined)
580
+ return new Promise((resolve, reject) => {
581
+ kernel!.waiting.push({ toolName, input: toolInput, resolve, reject })
582
+ })
338
583
  }
339
584
  if (state.plan.end === 'interrupted') {
340
585
  state.fxTurn.cancel()
341
586
  return pending(undefined)
342
587
  }
343
588
  return fail(state, diverged(`no recorded result for ${toolName}`))
344
- })
589
+ }
345
590
 
346
- const plans = (turns: HarnessTurn[]): Promise<TurnPlan[]> =>
591
+ const hooks: KernelHooks = { fetch: hostFetch, tool: hostTool }
592
+ const planTurns = (turns: HarnessTurn[]): Promise<TurnPlan[]> =>
347
593
  Promise.all(turns.map((recorded) => turnPlan(recorded, step.signal)))
348
594
  let results = read.results
349
595
  let controller: ReadableStreamDefaultController<UIMessageChunk> | undefined
@@ -352,17 +598,45 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
352
598
  if (!closed) controller!.enqueue(chunk)
353
599
  }
354
600
 
355
- const create = (checkpoint: Uint8Array | undefined) =>
356
- createFxAgent({
601
+ const create = async (
602
+ checkpoint: Uint8Array | undefined,
603
+ ): Promise<Kernel> => {
604
+ const created = { hooks, waiting: [] as Waiting[], failed: false }
605
+ let prompt: unknown
606
+ const agent = await createFxAgent({
357
607
  apiKey: credential.token,
358
608
  model: kernelModel(step),
359
609
  ...(step.instructions.length === 0
360
610
  ? {}
361
611
  : { instructions: step.instructions }),
362
- tools: hostTools,
612
+ tools: definitions.map((definition): FxHostTool => ({
613
+ ...definition,
614
+ execute: (toolInput: unknown) =>
615
+ created.hooks.tool(definition.name, toolInput),
616
+ })),
363
617
  ...(checkpoint === undefined ? {} : { checkpoint }),
364
- fetch: hostFetch,
618
+ fetch: (input, init) => created.hooks.fetch(input, init),
619
+ // libfx reports a turn FX failed after its cancel as cancelled; only
620
+ // the ACP response to the turn's prompt tells the two apart.
621
+ onEvent: ({ type, message }) => {
622
+ const rpc = (message ?? {}) as {
623
+ id?: unknown
624
+ method?: unknown
625
+ error?: unknown
626
+ }
627
+ if (type === 'acp.send' && rpc.method === 'session/prompt') {
628
+ prompt = rpc.id
629
+ created.failed = false
630
+ } else if (
631
+ type === 'acp.receive' &&
632
+ rpc.method === undefined &&
633
+ rpc.id === prompt
634
+ )
635
+ created.failed = rpc.error !== undefined
636
+ },
365
637
  })
638
+ return Object.assign(created, { agent })
639
+ }
366
640
 
367
641
  const stream = new ReadableStream<UIMessageChunk>({
368
642
  start(streamController) {
@@ -374,16 +648,33 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
374
648
  step.signal.addEventListener('abort', abort, { once: true })
375
649
  void (async () => {
376
650
  try {
377
- try {
378
- agent = await create(read.saved)
379
- } catch (error) {
380
- if (read.saved === undefined) throw error
381
- // An unusable checkpoint: replay the whole log instead.
382
- read = await replayAll()
383
- results = read.results
384
- agent = await create(undefined)
651
+ let turns = await planTurns(read.turns)
652
+ // An aborted step leaves a parked kernel for a later attempt.
653
+ const candidate = step.signal.aborted
654
+ ? undefined
655
+ : lot.take(step.sessionId)
656
+ const adopted =
657
+ candidate !== undefined && adoptable(candidate, turns)
658
+ ? candidate
659
+ : undefined
660
+ if (candidate !== undefined && adopted === undefined)
661
+ closeParked(candidate)
662
+ if (adopted !== undefined) {
663
+ kernel = adopted.kernel
664
+ kernel.hooks = hooks
665
+ } else {
666
+ try {
667
+ kernel = await create(read.saved)
668
+ } catch (error) {
669
+ if (read.saved === undefined) throw error
670
+ // An unusable checkpoint: replay the whole log instead.
671
+ read = await replayAll()
672
+ results = read.results
673
+ kernel = await create(undefined)
674
+ turns = await planTurns(read.turns)
675
+ }
385
676
  }
386
- await run(streamController, await plans(read.turns))
677
+ await run(streamController, turns, adopted)
387
678
  closed = true
388
679
  streamController.close()
389
680
  } catch (error) {
@@ -394,7 +685,7 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
394
685
  } finally {
395
686
  released.abort()
396
687
  step.signal.removeEventListener('abort', abort)
397
- await agent?.close().catch(() => {})
688
+ if (!parked) await kernel?.agent.close().catch(() => {})
398
689
  }
399
690
  })()
400
691
  },
@@ -429,7 +720,9 @@ async function turnPlan(
429
720
  input: turn.prompt,
430
721
  signal,
431
722
  })
723
+ const generationId = turn.generations.at(-1)?.completed.generationId
432
724
  return {
725
+ ...(generationId === undefined ? {} : { generationId }),
433
726
  prompt:
434
727
  images.length === 0
435
728
  ? turn.prompt.text
@@ -786,10 +1079,7 @@ async function loadLibfx(): Promise<typeof import('libfx')> {
786
1079
  * asks for it; options the request already has take precedence. Every other
787
1080
  * byte stays as the kernel wrote it, a tool input's spacing included.
788
1081
  */
789
- function withGatewayCaching(body: string): string {
790
- const request = JSON.parse(body) as {
791
- providerOptions?: Record<string, Record<string, unknown> | undefined>
792
- }
1082
+ function withGatewayCaching(body: string, request: GatewayRequest): string {
793
1083
  const options = request.providerOptions
794
1084
  if (options === undefined)
795
1085
  return body.replace(
@@ -812,6 +1102,9 @@ function withAuth(
812
1102
  ): Headers {
813
1103
  const result = new Headers(headers)
814
1104
  result.set('ai-gateway-auth-method', credential.method)
1105
+ // A parked kernel keeps the credential it was made with; the step's is current.
1106
+ if (result.has('authorization'))
1107
+ result.set('authorization', `Bearer ${credential.token}`)
815
1108
  return result
816
1109
  }
817
1110
 
@@ -831,17 +1124,16 @@ function kernelModel(
831
1124
  }
832
1125
  }
833
1126
 
834
- async function kernelTools(
1127
+ /** The agent's tools as the kernel takes them, without their execution. */
1128
+ async function toolDefinitions(
835
1129
  tools: ToolSet,
836
- execute: (toolName: string, input: unknown) => unknown,
837
- ): Promise<FxHostTool[]> {
1130
+ ): Promise<{ name: string; description: string; inputSchema: object }[]> {
838
1131
  return Promise.all(
839
1132
  Object.entries(tools).map(async ([toolName, tool]) => ({
840
1133
  name: toolName,
841
1134
  description:
842
1135
  typeof tool.description === 'string' ? tool.description : toolName,
843
1136
  inputSchema: (await asSchema(tool.inputSchema).jsonSchema) as object,
844
- execute: (input: unknown) => execute(toolName, input),
845
1137
  })),
846
1138
  )
847
1139
  }
@@ -889,11 +1181,6 @@ function savedCheckpoint(state: unknown): Uint8Array | undefined {
889
1181
  return new Uint8Array(Buffer.from(checkpoint, 'base64'))
890
1182
  }
891
1183
 
892
- function isCompaction(body: string): boolean {
893
- const request = JSON.parse(body) as { toolChoice?: { type?: string } }
894
- return request.toolChoice?.type === 'none'
895
- }
896
-
897
1184
  function decodeBody(body: BodyInit | null | undefined): string {
898
1185
  if (typeof body === 'string') return body
899
1186
  if (body instanceof Uint8Array || body instanceof ArrayBuffer)
package/src/ai-harness.ts CHANGED
@@ -69,6 +69,8 @@ export type HarnessLog = {
69
69
 
70
70
  /** Everything one durable generation hands its harness. */
71
71
  export type HarnessStep = {
72
+ /** The agent's own identity: what a harness keeps in memory is per scope. */
73
+ scope: object
72
74
  agentName: string
73
75
  sessionId: string
74
76
  requestIndex: number
@@ -95,6 +97,11 @@ export type HarnessDriver = {
95
97
  /** Tools the harness brings; A2 executes them like the agent's own. */
96
98
  readonly tools: ToolSet
97
99
  step(step: HarnessStep): Promise<HarnessGeneration>
100
+ /**
101
+ * The session has nothing left to run in this process. Lets go of anything
102
+ * kept in memory for its next step; it holds nothing the log needs.
103
+ */
104
+ release?(session: { scope: object; sessionId: string }): void
98
105
  }
99
106
 
100
107
  // A global symbol, so harness values work across separately bundled copies.