experimental-a2 0.21.1 → 0.22.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 (139) hide show
  1. package/CHANGELOG.md +21 -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 +211 -61
  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-CsJw_hfD.js} +538 -397
  73. package/dist/store-redis-notify-CsJw_hfD.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/stream-buffer-DrTyiIuo.js +57 -0
  83. package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
  84. package/dist/{wire-CZGqNZRx.js → wire-CV-Z7wz-.js} +3 -3
  85. package/dist/{wire-CZGqNZRx.js.map → wire-CV-Z7wz-.js.map} +1 -1
  86. package/docs/actors/01-introduction.mdx +5 -2
  87. package/docs/agents/03-conversations.mdx +9 -8
  88. package/docs/agents/04-harnesses.mdx +7 -4
  89. package/docs/reference/01-core.mdx +3 -2
  90. package/docs/reference/02-server.mdx +5 -1
  91. package/docs/reference/04-ai.mdx +13 -4
  92. package/docs/reference/06-errors.mdx +1 -1
  93. package/docs/server/01-consumers.mdx +4 -1
  94. package/docs/server/06-guarantees.mdx +7 -4
  95. package/docs/stores/02-redis.mdx +16 -10
  96. package/docs/stores/03-redis-http.mdx +6 -4
  97. package/docs/stores/04-postgres.mdx +2 -2
  98. package/docs/upgrades/01-overview.mdx +1 -0
  99. package/docs/upgrades/78-0-22.mdx +115 -0
  100. package/examples/playground/app/a0/sandbox.ts +58 -13
  101. package/examples/playground/app/a2slack/db/a2.ts +7 -4
  102. package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
  103. package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
  104. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +8 -1
  105. package/examples/playground/app/chat/server.ts +2 -2
  106. package/examples/playground/lib/store.ts +1 -1
  107. package/examples/playground/package.json +1 -1
  108. package/package.json +1 -1
  109. package/sql/postgres/drizzle-schema.ts +7 -4
  110. package/sql/postgres/schema.sql +24 -3
  111. package/src/actor-shared.ts +10 -2
  112. package/src/ai-agent.ts +13 -0
  113. package/src/ai-fx.ts +321 -77
  114. package/src/ai-harness.ts +7 -0
  115. package/src/client.ts +19 -2
  116. package/src/deterministic-id.ts +1 -1
  117. package/src/internal.ts +42 -11
  118. package/src/presence-recovery.ts +18 -11
  119. package/src/presence-tombstones.ts +52 -0
  120. package/src/push-envelope.ts +2 -2
  121. package/src/server.ts +49 -15
  122. package/src/store-memory.ts +24 -32
  123. package/src/store-postgres.ts +43 -53
  124. package/src/store-presence-polling.ts +59 -34
  125. package/src/store-redis-core.ts +595 -439
  126. package/src/store-redis-notify.ts +74 -26
  127. package/src/store-redis.ts +2 -3
  128. package/src/store-sqlite.ts +34 -40
  129. package/src/store.ts +14 -5
  130. package/dist/actor-HnGmrOZZ.d.ts.map +0 -1
  131. package/dist/client-9IeTsiAs.js.map +0 -1
  132. package/dist/server-DvIB8kj5.js.map +0 -1
  133. package/dist/session-clear-Bk_29eEw.d.ts.map +0 -1
  134. package/dist/store-Z2qjw50n.d.ts.map +0 -1
  135. package/dist/store-presence-polling-CzQ0wdtM.js +0 -94
  136. package/dist/store-presence-polling-CzQ0wdtM.js.map +0 -1
  137. package/dist/store-redis-notify-IiVdlYHc.js.map +0 -1
  138. package/dist/stream-buffer-Cx7AAPGq.js +0 -23
  139. 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
 
@@ -72,6 +89,8 @@ type TurnPlan = {
72
89
  /** Steering to queue while serving the call at that index. */
73
90
  steers: Map<number, string[]>
74
91
  end: 'stop' | 'interrupted' | 'live'
92
+ /** The attempt that made its last committed model call. */
93
+ generationId?: string
75
94
  }
76
95
 
77
96
  type Completion = { finishReason?: FinishReason; usage?: LanguageModelUsage }
@@ -92,6 +111,12 @@ type TurnState = {
92
111
  parsed?: Promise<void>
93
112
  }
94
113
 
114
+ /** A model request as the kernel sends it to the Gateway. */
115
+ type GatewayRequest = {
116
+ toolChoice?: { type?: string }
117
+ providerOptions?: Record<string, Record<string, unknown> | undefined>
118
+ }
119
+
95
120
  function fail(state: TurnState, error: Error): never {
96
121
  state.failure ??= error
97
122
  state.fxTurn.cancel()
@@ -100,12 +125,102 @@ function fail(state: TurnState, error: Error): never {
100
125
 
101
126
  const diverged = (message: string): Divergence => new Divergence('FX', message)
102
127
 
128
+ // ------------------------------------------------------------- kernels
129
+
130
+ /** A tool call the kernel waits on while A2's tool consumer runs it. */
131
+ type Waiting = {
132
+ toolName: string
133
+ input: unknown
134
+ resolve: (output: unknown) => void
135
+ reject: (error: unknown) => void
136
+ }
137
+
138
+ /** What a kernel's fetch and tools call: the step driving it now. */
139
+ type KernelHooks = {
140
+ fetch: (
141
+ input: string | URL | Request,
142
+ init?: RequestInit,
143
+ ) => Promise<Response>
144
+ tool: (toolName: string, input: unknown) => unknown
145
+ }
146
+
147
+ type Kernel = { agent: FxAgent; hooks: KernelHooks; waiting: Waiting[] }
148
+
149
+ /**
150
+ * A kernel a step left waiting on its tool calls. The session's next step in
151
+ * this process, in the same response or one that steered it, continues it
152
+ * with their results instead of rebuilding one from the log.
153
+ */
154
+ type Parked = {
155
+ kernel: Kernel
156
+ fxTurn: FxTurn
157
+ drained: Promise<void>
158
+ /** The attempt whose model call the kernel made. */
159
+ generationId: string
160
+ settings: string
161
+ }
162
+
163
+ type ParkingLot = {
164
+ /** Removes the session's parked kernel; the caller then owns it. */
165
+ take(sessionId: string): Parked | undefined
166
+ park(sessionId: string, entry: Parked): void
167
+ }
168
+
169
+ function closeParked(entry: Parked): void {
170
+ entry.fxTurn.cancel()
171
+ void entry.kernel.agent.close().catch(() => {})
172
+ }
173
+
174
+ /**
175
+ * One agent's parked kernels: one per session, at most `maxKernels`. Each is
176
+ * closed when its session has nothing left to run in this process (the
177
+ * agent's release), when its turn ends, or when another takes its place.
178
+ */
179
+ function parkingLot(): ParkingLot {
180
+ const held = new Map<string, Parked>()
181
+ const take = (sessionId: string): Parked | undefined => {
182
+ const entry = held.get(sessionId)
183
+ held.delete(sessionId)
184
+ return entry
185
+ }
186
+ return {
187
+ take,
188
+ park(sessionId, entry) {
189
+ const replaced = take(sessionId)
190
+ if (replaced !== undefined) closeParked(replaced)
191
+ held.set(sessionId, entry)
192
+ // A turn that ends while it waits, as a cancelled one does, is done.
193
+ const ended = () => {
194
+ if (held.get(sessionId) !== entry) return
195
+ held.delete(sessionId)
196
+ closeParked(entry)
197
+ }
198
+ entry.fxTurn.result.then(ended, ended)
199
+ const oldest = held.keys().next().value
200
+ if (held.size > HARNESS_PARKING.maxKernels && oldest !== undefined) {
201
+ const evicted = take(oldest)
202
+ if (evicted !== undefined) closeParked(evicted)
203
+ }
204
+ },
205
+ }
206
+ }
207
+
103
208
  // ---------------------------------------------------------------- step
104
209
 
105
- async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
210
+ async function runStep(
211
+ step: HarnessStep,
212
+ lot: ParkingLot,
213
+ ): Promise<HarnessGeneration> {
106
214
  const { createFxAgent } = await loadLibfx()
107
215
  const credential = gatewayCredential('fx')
108
216
  const name = `${step.agentName}.a2.harness.fx`
217
+ const definitions = await toolDefinitions(step.tools)
218
+ // What the kernel is made with: one made otherwise can't continue the step.
219
+ const settings = JSON.stringify([
220
+ kernelModel(step),
221
+ step.instructions,
222
+ definitions,
223
+ ])
109
224
  const { replayAll, ...first } = await readHarness({
110
225
  step,
111
226
  name,
@@ -119,17 +234,25 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
119
234
  // has read a response's finish, while A2 may still be reading its copy.
120
235
  const released = new AbortController()
121
236
  const network = AbortSignal.any([step.signal, released.signal])
122
- let agent: FxAgent | undefined
237
+ let kernel: Kernel | undefined
238
+ /** Whether this step left its kernel for the next one. */
239
+ let parked = false
123
240
  let turn: TurnState | undefined
124
241
 
125
242
  const run = async (
126
243
  controller: ReadableStreamDefaultController<UIMessageChunk>,
127
244
  turns: TurnPlan[],
245
+ adopted: Parked | undefined,
128
246
  ): Promise<void> => {
129
247
  controller.enqueue({ type: 'start', messageId: step.responseMessageId })
248
+ if (adopted !== undefined) {
249
+ await resume(controller, turns.at(-1)!, adopted)
250
+ return
251
+ }
130
252
  for (const plan of turns) {
131
- if (stopped) return
132
- const fxTurn = agent!.prompt(plan.prompt, { signal: step.signal })
253
+ if (stopped || step.signal.aborted) return
254
+ // The step's abort cancels the turn; the turn outlives it when parked.
255
+ const fxTurn = kernel!.agent.prompt(plan.prompt)
133
256
  const state: TurnState = {
134
257
  plan,
135
258
  fxTurn,
@@ -161,37 +284,123 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
161
284
  )
162
285
  continue
163
286
  }
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()
287
+ await finishLive(controller, state, drained)
288
+ }
289
+ }
290
+
291
+ /** Continues a parked kernel: the results it waits on are in the log. */
292
+ const resume = async (
293
+ controller: ReadableStreamDefaultController<UIMessageChunk>,
294
+ plan: TurnPlan,
295
+ adopted: Parked,
296
+ ): Promise<void> => {
297
+ const state: TurnState = {
298
+ plan,
299
+ fxTurn: adopted.fxTurn,
300
+ next: plan.calls.length,
301
+ served: plan.calls.at(-1)!.toolCalls,
302
+ answered: new Set(),
303
+ live: false,
304
+ liveStep: false,
305
+ toolsRequested: deferred(),
306
+ }
307
+ turn = state
308
+ // Steering goes in before any tool result, so FX's next model call can't
309
+ // start without it, as replay queues it before the recorded call ends.
310
+ for (const text of plan.steers.get(plan.calls.length - 1) ?? [])
311
+ await adopted.fxTurn.steer(text)
312
+ for (const waiting of kernel!.waiting.splice(0)) {
313
+ try {
314
+ const answered = answer(state, waiting.toolName, waiting.input)
315
+ if (answered === undefined)
316
+ fail(state, diverged(`no recorded result for ${waiting.toolName}`))
317
+ waiting.resolve(answered.output)
318
+ } catch (error) {
319
+ waiting.reject(error)
180
320
  }
181
- controller.enqueue({ type: 'finish-step' })
182
- controller.enqueue({
183
- type: 'finish',
184
- ...(completion.finishReason === undefined
185
- ? {}
186
- : { finishReason: completion.finishReason }),
321
+ }
322
+ await finishLive(controller, state, adopted.drained)
323
+ }
324
+
325
+ const finishLive = async (
326
+ controller: ReadableStreamDefaultController<UIMessageChunk>,
327
+ state: TurnState,
328
+ drained: Promise<void>,
329
+ ): Promise<void> => {
330
+ const outcome = await Promise.race([
331
+ state.fxTurn.result.then((result) => ({ ended: true, result })),
332
+ state.toolsRequested.promise.then(() => ({ ended: false as const })),
333
+ ]).catch((error: unknown) => {
334
+ throw state.failure ?? error
335
+ })
336
+ if (state.failure) throw state.failure
337
+ await state.parsed
338
+ if (outcome.ended) {
339
+ if (
340
+ outcome.result.stopReason === 'cancelled' &&
341
+ !stopped &&
342
+ !step.signal.aborted
343
+ )
344
+ throw diverged('the turn was cancelled while this step ran it')
345
+ await drained
346
+ // Only a turn this attempt finished describes the log.
347
+ if (outcome.result.stopReason === 'end_turn' && !step.signal.aborted)
348
+ await saveCheckpoint()
349
+ } else if (kernel!.waiting.length > 0 && !stopped && !closed) {
350
+ // FX now waits on tools that A2's tool consumer runs; the next step in
351
+ // this process can answer it with their results.
352
+ lot.park(step.sessionId, {
353
+ kernel: kernel!,
354
+ fxTurn: state.fxTurn,
355
+ drained,
356
+ generationId: step.generationId,
357
+ settings,
187
358
  })
359
+ parked = true
360
+ } else {
361
+ state.fxTurn.cancel()
188
362
  }
363
+ controller.enqueue({ type: 'finish-step' })
364
+ controller.enqueue({
365
+ type: 'finish',
366
+ ...(completion.finishReason === undefined
367
+ ? {}
368
+ : { finishReason: completion.finishReason }),
369
+ })
370
+ }
371
+
372
+ /**
373
+ * Whether a parked kernel stands where this step's log does: made the same
374
+ * way, its model call the last one the log's live turn committed, and a
375
+ * recorded result for each tool call it waits on. A steered response
376
+ * continues the same turn, so its steps qualify too.
377
+ */
378
+ const adoptable = (entry: Parked, turns: TurnPlan[]): boolean => {
379
+ const plan = turns.at(-1)
380
+ if (
381
+ entry.settings !== settings ||
382
+ plan?.end !== 'live' ||
383
+ plan.generationId !== entry.generationId
384
+ )
385
+ return false
386
+ const unanswered = [...(plan.calls.at(-1)?.toolCalls ?? [])]
387
+ return entry.kernel.waiting.every((waiting) => {
388
+ const at = unanswered.findIndex(
389
+ (call) =>
390
+ call.toolName === waiting.toolName &&
391
+ sameJson(call.input, waiting.input) &&
392
+ results.has(call.toolCallId),
393
+ )
394
+ if (at === -1) return false
395
+ unanswered.splice(at, 1)
396
+ return true
397
+ })
189
398
  }
190
399
 
191
400
  const saveCheckpoint = async (): Promise<void> => {
192
401
  let checkpoint: Uint8Array
193
402
  try {
194
- checkpoint = await agent!.checkpoint()
403
+ checkpoint = await kernel!.agent.checkpoint()
195
404
  } catch {
196
405
  // Later steps replay from the previous snapshot.
197
406
  return
@@ -218,7 +427,8 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
218
427
  const state = turn!
219
428
  if (state.failure) throw state.failure
220
429
  const body = decodeBody(init.body)
221
- const compaction = isCompaction(body)
430
+ const request = JSON.parse(body) as GatewayRequest
431
+ const compaction = request.toolChoice?.type === 'none'
222
432
  const recorded = state.plan.calls[state.next]
223
433
  if (recorded !== undefined) {
224
434
  if ((recorded.kind === 'compaction') !== compaction)
@@ -245,7 +455,12 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
245
455
  return pending(init.signal)
246
456
  }
247
457
  state.live = true
248
- return live(state, url, { ...init, body }, compaction)
458
+ return live(
459
+ state,
460
+ url,
461
+ { ...init, body: withGatewayCaching(body, request) },
462
+ compaction,
463
+ )
249
464
  }
250
465
 
251
466
  const affinity = gatewaySessionAffinity({
@@ -256,18 +471,13 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
256
471
  const live = async (
257
472
  state: TurnState,
258
473
  url: string,
259
- init: RequestInit & { body: string },
474
+ init: RequestInit,
260
475
  compaction: boolean,
261
476
  ): Promise<Response> => {
262
477
  const headers = withAuth(init.headers, credential)
263
478
  // The kernel's own affinity is new with every rebuild; A2's lasts the session.
264
479
  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
- })
480
+ const response = await fetch(url, { ...init, headers, signal: network })
271
481
  // Abandoned before its headers came: the kernel has moved on without it.
272
482
  if (init.signal?.aborted) {
273
483
  void response.body?.cancel()
@@ -316,9 +526,12 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
316
526
  })
317
527
  }
318
528
 
319
- const hostTools = await kernelTools(step.tools, (toolName, toolInput) => {
320
- const state = turn!
321
- if (state.failure) throw state.failure
529
+ /** The recorded result of a call from the step the kernel was served. */
530
+ const answer = (
531
+ state: TurnState,
532
+ toolName: string,
533
+ toolInput: unknown,
534
+ ): { output: unknown } | undefined => {
322
535
  const call = state.served.find(
323
536
  (candidate) =>
324
537
  candidate.toolName === toolName &&
@@ -326,24 +539,33 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
326
539
  sameJson(candidate.input, toolInput),
327
540
  )
328
541
  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
- }
542
+ if (call === undefined || result === undefined) return undefined
543
+ state.answered.add(call.toolCallId)
544
+ if (result.error !== undefined) throw new Error(result.error)
545
+ if (result.denied === true) throw new Error('The tool call was denied.')
546
+ return { output: result.output ?? null }
547
+ }
548
+
549
+ const hostTool = (toolName: string, toolInput: unknown): unknown => {
550
+ const state = turn!
551
+ if (state.failure) throw state.failure
552
+ const answered = answer(state, toolName, toolInput)
553
+ if (answered !== undefined) return answered.output
335
554
  if (state.live) {
336
555
  state.toolsRequested.resolve()
337
- return pending(undefined)
556
+ return new Promise((resolve, reject) => {
557
+ kernel!.waiting.push({ toolName, input: toolInput, resolve, reject })
558
+ })
338
559
  }
339
560
  if (state.plan.end === 'interrupted') {
340
561
  state.fxTurn.cancel()
341
562
  return pending(undefined)
342
563
  }
343
564
  return fail(state, diverged(`no recorded result for ${toolName}`))
344
- })
565
+ }
345
566
 
346
- const plans = (turns: HarnessTurn[]): Promise<TurnPlan[]> =>
567
+ const hooks: KernelHooks = { fetch: hostFetch, tool: hostTool }
568
+ const planTurns = (turns: HarnessTurn[]): Promise<TurnPlan[]> =>
347
569
  Promise.all(turns.map((recorded) => turnPlan(recorded, step.signal)))
348
570
  let results = read.results
349
571
  let controller: ReadableStreamDefaultController<UIMessageChunk> | undefined
@@ -352,17 +574,26 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
352
574
  if (!closed) controller!.enqueue(chunk)
353
575
  }
354
576
 
355
- const create = (checkpoint: Uint8Array | undefined) =>
356
- createFxAgent({
577
+ const create = async (
578
+ checkpoint: Uint8Array | undefined,
579
+ ): Promise<Kernel> => {
580
+ const created = { hooks, waiting: [] as Waiting[] }
581
+ const agent = await createFxAgent({
357
582
  apiKey: credential.token,
358
583
  model: kernelModel(step),
359
584
  ...(step.instructions.length === 0
360
585
  ? {}
361
586
  : { instructions: step.instructions }),
362
- tools: hostTools,
587
+ tools: definitions.map((definition): FxHostTool => ({
588
+ ...definition,
589
+ execute: (toolInput: unknown) =>
590
+ created.hooks.tool(definition.name, toolInput),
591
+ })),
363
592
  ...(checkpoint === undefined ? {} : { checkpoint }),
364
- fetch: hostFetch,
593
+ fetch: (input, init) => created.hooks.fetch(input, init),
365
594
  })
595
+ return Object.assign(created, { agent })
596
+ }
366
597
 
367
598
  const stream = new ReadableStream<UIMessageChunk>({
368
599
  start(streamController) {
@@ -374,16 +605,33 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
374
605
  step.signal.addEventListener('abort', abort, { once: true })
375
606
  void (async () => {
376
607
  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)
608
+ let turns = await planTurns(read.turns)
609
+ // An aborted step leaves a parked kernel for a later attempt.
610
+ const candidate = step.signal.aborted
611
+ ? undefined
612
+ : lot.take(step.sessionId)
613
+ const adopted =
614
+ candidate !== undefined && adoptable(candidate, turns)
615
+ ? candidate
616
+ : undefined
617
+ if (candidate !== undefined && adopted === undefined)
618
+ closeParked(candidate)
619
+ if (adopted !== undefined) {
620
+ kernel = adopted.kernel
621
+ kernel.hooks = hooks
622
+ } else {
623
+ try {
624
+ kernel = await create(read.saved)
625
+ } catch (error) {
626
+ if (read.saved === undefined) throw error
627
+ // An unusable checkpoint: replay the whole log instead.
628
+ read = await replayAll()
629
+ results = read.results
630
+ kernel = await create(undefined)
631
+ turns = await planTurns(read.turns)
632
+ }
385
633
  }
386
- await run(streamController, await plans(read.turns))
634
+ await run(streamController, turns, adopted)
387
635
  closed = true
388
636
  streamController.close()
389
637
  } catch (error) {
@@ -394,7 +642,7 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
394
642
  } finally {
395
643
  released.abort()
396
644
  step.signal.removeEventListener('abort', abort)
397
- await agent?.close().catch(() => {})
645
+ if (!parked) await kernel?.agent.close().catch(() => {})
398
646
  }
399
647
  })()
400
648
  },
@@ -429,7 +677,9 @@ async function turnPlan(
429
677
  input: turn.prompt,
430
678
  signal,
431
679
  })
680
+ const generationId = turn.generations.at(-1)?.completed.generationId
432
681
  return {
682
+ ...(generationId === undefined ? {} : { generationId }),
433
683
  prompt:
434
684
  images.length === 0
435
685
  ? turn.prompt.text
@@ -786,10 +1036,7 @@ async function loadLibfx(): Promise<typeof import('libfx')> {
786
1036
  * asks for it; options the request already has take precedence. Every other
787
1037
  * byte stays as the kernel wrote it, a tool input's spacing included.
788
1038
  */
789
- function withGatewayCaching(body: string): string {
790
- const request = JSON.parse(body) as {
791
- providerOptions?: Record<string, Record<string, unknown> | undefined>
792
- }
1039
+ function withGatewayCaching(body: string, request: GatewayRequest): string {
793
1040
  const options = request.providerOptions
794
1041
  if (options === undefined)
795
1042
  return body.replace(
@@ -812,6 +1059,9 @@ function withAuth(
812
1059
  ): Headers {
813
1060
  const result = new Headers(headers)
814
1061
  result.set('ai-gateway-auth-method', credential.method)
1062
+ // A parked kernel keeps the credential it was made with; the step's is current.
1063
+ if (result.has('authorization'))
1064
+ result.set('authorization', `Bearer ${credential.token}`)
815
1065
  return result
816
1066
  }
817
1067
 
@@ -831,17 +1081,16 @@ function kernelModel(
831
1081
  }
832
1082
  }
833
1083
 
834
- async function kernelTools(
1084
+ /** The agent's tools as the kernel takes them, without their execution. */
1085
+ async function toolDefinitions(
835
1086
  tools: ToolSet,
836
- execute: (toolName: string, input: unknown) => unknown,
837
- ): Promise<FxHostTool[]> {
1087
+ ): Promise<{ name: string; description: string; inputSchema: object }[]> {
838
1088
  return Promise.all(
839
1089
  Object.entries(tools).map(async ([toolName, tool]) => ({
840
1090
  name: toolName,
841
1091
  description:
842
1092
  typeof tool.description === 'string' ? tool.description : toolName,
843
1093
  inputSchema: (await asSchema(tool.inputSchema).jsonSchema) as object,
844
- execute: (input: unknown) => execute(toolName, input),
845
1094
  })),
846
1095
  )
847
1096
  }
@@ -889,11 +1138,6 @@ function savedCheckpoint(state: unknown): Uint8Array | undefined {
889
1138
  return new Uint8Array(Buffer.from(checkpoint, 'base64'))
890
1139
  }
891
1140
 
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
1141
  function decodeBody(body: BodyInit | null | undefined): string {
898
1142
  if (typeof body === 'string') return body
899
1143
  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.
package/src/client.ts CHANGED
@@ -38,6 +38,7 @@ import type {
38
38
  PresencePatch,
39
39
  WithPresence,
40
40
  } from './contract.ts'
41
+ import { presenceTombstones } from './presence-tombstones.ts'
41
42
  import { foldEvent, type Reducer, type StateSnapshot } from './reducer.ts'
42
43
  import { isSessionCleared } from './session-clear.ts'
43
44
  import { validateSync } from './validate.ts'
@@ -1560,6 +1561,8 @@ export function createClient<
1560
1561
  // Participant and field keys arrive off the wire, so the map and
1561
1562
  // every entry are built null-prototype — see `nullProtoRecord`.
1562
1563
  let presenceState: PresenceMap = nullProtoRecord()
1564
+ /** Stamps of applied foreign nulls — kept out of `presenceState`. */
1565
+ const presenceDeletes = presenceTombstones()
1563
1566
  let presenceLastSentAt = 0
1564
1567
  /** The last LWW stamp issued — see `nextPresenceAt`. */
1565
1568
  let presenceLastAt = 0
@@ -1763,14 +1766,19 @@ export function createClient<
1763
1766
  for (const [field, value] of Object.entries(patch.values)) {
1764
1767
  if (value === undefined) continue
1765
1768
  const existing = entry[field]
1766
- // Field-wise LWW: an older stamp loses, a tie goes to the wire.
1769
+ const write = { participant: patch.participant, field, at: patch.at }
1770
+ // Field-wise LWW against the live value or its deletion: an older
1771
+ // stamp loses, a tie goes to the wire.
1767
1772
  if (existing && existing.at.getTime() > patch.at.getTime()) continue
1773
+ if (presenceDeletes.outranks(write)) continue
1768
1774
  if (value === null) {
1775
+ presenceDeletes.keep(write)
1769
1776
  if (existing) {
1770
1777
  delete entry[field]
1771
1778
  changed = true
1772
1779
  }
1773
1780
  } else {
1781
+ presenceDeletes.drop(write)
1774
1782
  entry[field] = { value, seen: patch.seen, at: patch.at }
1775
1783
  changed = true
1776
1784
  }
@@ -1787,7 +1795,16 @@ export function createClient<
1787
1795
  const next: PresenceMap = nullProtoRecord()
1788
1796
  for (const [id, fields] of Object.entries(incoming)) {
1789
1797
  if (id === participant) continue
1790
- next[id] = fields
1798
+ const entry: PresenceMap[string] = nullProtoRecord()
1799
+ for (const [field, stamp] of Object.entries(fields)) {
1800
+ if (stamp === undefined) continue
1801
+ const write = { participant: id, field, at: stamp.at }
1802
+ // A row older than the field's deletion must not resurrect it.
1803
+ if (presenceDeletes.outranks(write)) continue
1804
+ presenceDeletes.drop(write)
1805
+ entry[field] = stamp
1806
+ }
1807
+ if (Object.keys(entry).length > 0) next[id] = entry
1791
1808
  }
1792
1809
  // Foreign entries replaced wholesale; the own echo survives.
1793
1810
  const own =
@@ -3,7 +3,7 @@
3
3
  * re-run after a crash calls `session.append` again; giving
4
4
  * each call a deterministic id — derived from the triggering event's id
5
5
  * and the caller's stable name — makes the re-run a no-op via the store's
6
- * unique index on event ids.
6
+ * per-session unique index on event ids.
7
7
  *
8
8
  * The id is a UUIDv5-shaped SHA-1 over
9
9
  * `(namespace, triggerEventId, name, itemOrdinal)`, computed with