experimental-a2 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/{actor-Bjce9_5U.d.ts → actor-C7b-1arn.d.ts} +3 -3
  3. package/dist/{actor-Bjce9_5U.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
  4. package/dist/actor-client.d.ts +1 -1
  5. package/dist/actor-react.d.ts +1 -1
  6. package/dist/actor-react.js +1 -1
  7. package/dist/actor.d.ts +1 -1
  8. package/dist/actor.js +2 -2
  9. package/dist/{ai-DrUaMyRt.d.ts → ai-Cz3hqVhk.d.ts} +34 -17
  10. package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
  11. package/dist/ai-client.d.ts +1 -1
  12. package/dist/ai-client.js +2 -2
  13. package/dist/ai-control-CvLbW10B.js +46 -0
  14. package/dist/ai-control-CvLbW10B.js.map +1 -0
  15. package/dist/ai-fx.d.ts +1 -1
  16. package/dist/ai-fx.d.ts.map +1 -1
  17. package/dist/ai-fx.js +122 -44
  18. package/dist/ai-fx.js.map +1 -1
  19. package/dist/{ai-harness-BuLh_EYi.js → ai-harness-BuuC-gJ3.js} +79 -15
  20. package/dist/ai-harness-BuuC-gJ3.js.map +1 -0
  21. package/dist/{ai-harness-DvsPeJeB.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
  22. package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
  23. package/dist/{ai-BpOGm-zB.js → ai-mMiWul1T.js} +117 -78
  24. package/dist/ai-mMiWul1T.js.map +1 -0
  25. package/dist/ai-pi.d.ts +1 -1
  26. package/dist/ai-pi.d.ts.map +1 -1
  27. package/dist/ai-pi.js +114 -23
  28. package/dist/ai-pi.js.map +1 -1
  29. package/dist/ai-react.d.ts +1 -1
  30. package/dist/ai-react.js +2 -2
  31. package/dist/ai-server.d.ts +3 -3
  32. package/dist/ai-server.d.ts.map +1 -1
  33. package/dist/ai-server.js +127 -55
  34. package/dist/ai-server.js.map +1 -1
  35. package/dist/ai-tool-input-DmSqMUQi.js +40 -0
  36. package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
  37. package/dist/ai.d.ts +2 -2
  38. package/dist/ai.js +1 -1
  39. package/dist/{client-BReKqwZC.js → client-BhHLoNKF.js} +3 -3
  40. package/dist/{client-BReKqwZC.js.map → client-BhHLoNKF.js.map} +1 -1
  41. package/dist/client.js +1 -1
  42. package/dist/index.d.ts +2 -2
  43. package/dist/{internal-B2T3U4Rq.js → internal-CscSY7K_.js} +4 -2
  44. package/dist/{internal-B2T3U4Rq.js.map → internal-CscSY7K_.js.map} +1 -1
  45. package/dist/react.js +1 -1
  46. package/dist/scheduler-qstash.d.ts +2 -2
  47. package/dist/scheduler-qstash.js +3 -3
  48. package/dist/scheduler-qstash.js.map +1 -1
  49. package/dist/scheduler-vercel.d.ts +7 -7
  50. package/dist/scheduler-vercel.d.ts.map +1 -1
  51. package/dist/scheduler-vercel.js +10 -14
  52. package/dist/scheduler-vercel.js.map +1 -1
  53. package/dist/{server-Dw_k_7kO.js → server-D-Y4pqwu.js} +37 -11
  54. package/dist/server-D-Y4pqwu.js.map +1 -0
  55. package/dist/{server-En7cbi3v.d.ts → server-DYAATFuT.d.ts} +11 -5
  56. package/dist/server-DYAATFuT.d.ts.map +1 -0
  57. package/dist/server.d.ts +2 -2
  58. package/dist/server.js +1 -1
  59. package/dist/{session-clear-Bo-XTFEy.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
  60. package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
  61. package/dist/{store-Djdv0bnZ.d.ts → store-CyLUy86v.d.ts} +23 -4
  62. package/dist/store-CyLUy86v.d.ts.map +1 -0
  63. package/dist/store-N8PXxDAS.js.map +1 -1
  64. package/dist/store-memory.d.ts +1 -1
  65. package/dist/store-memory.js +25 -6
  66. package/dist/store-memory.js.map +1 -1
  67. package/dist/{store-polling-B5FcwJfo.js → store-polling-7WJV0YWP.js} +2 -2
  68. package/dist/{store-polling-B5FcwJfo.js.map → store-polling-7WJV0YWP.js.map} +1 -1
  69. package/dist/store-postgres.d.ts +1 -1
  70. package/dist/store-postgres.d.ts.map +1 -1
  71. package/dist/store-postgres.js +55 -25
  72. package/dist/store-postgres.js.map +1 -1
  73. package/dist/{store-presence-polling-BAwS8RWn.js → store-presence-polling-B1ixeqIS.js} +3 -3
  74. package/dist/{store-presence-polling-BAwS8RWn.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
  75. package/dist/store-redis-http.d.ts +1 -1
  76. package/dist/store-redis-http.js +3 -3
  77. package/dist/{store-redis-notify-BRITti_o.js → store-redis-notify-CM0AfLY0.js} +88 -37
  78. package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
  79. package/dist/store-redis.d.ts +1 -1
  80. package/dist/store-redis.js +1 -1
  81. package/dist/store-sqlite.d.ts +1 -1
  82. package/dist/store-sqlite.js +35 -20
  83. package/dist/store-sqlite.js.map +1 -1
  84. package/dist/{wire-rSUglYo8.js → wire-BKxH1vbP.js} +2 -2
  85. package/dist/{wire-rSUglYo8.js.map → wire-BKxH1vbP.js.map} +1 -1
  86. package/docs/agents/03-conversations.mdx +27 -15
  87. package/docs/agents/04-harnesses.mdx +51 -5
  88. package/docs/contract/03-sessions.mdx +1 -1
  89. package/docs/index.mdx +3 -3
  90. package/docs/reference/02-server.mdx +12 -7
  91. package/docs/reference/04-ai.mdx +41 -23
  92. package/docs/reference/05-adapters.mdx +6 -6
  93. package/docs/server/01-handlers.mdx +1 -1
  94. package/docs/server/02-timers.mdx +1 -1
  95. package/docs/server/03-cancellation.mdx +3 -2
  96. package/docs/server/05-production.mdx +4 -3
  97. package/docs/server/06-guarantees.mdx +20 -11
  98. package/examples/playground/DEPLOY.md +11 -4
  99. package/examples/playground/app/a0/[appId]/page.tsx +2 -8
  100. package/examples/playground/app/a0/a0.css +244 -5
  101. package/examples/playground/app/a0/app-view.tsx +2 -1
  102. package/examples/playground/app/a0/attachments.ts +133 -0
  103. package/examples/playground/app/a0/chat.test.tsx +504 -0
  104. package/examples/playground/app/a0/chat.tsx +623 -63
  105. package/examples/playground/app/a0/context-menu.tsx +38 -11
  106. package/examples/playground/app/a0/devtools.tsx +2 -8
  107. package/examples/playground/app/a0/model.test.ts +28 -0
  108. package/examples/playground/app/a0/model.ts +30 -0
  109. package/examples/playground/app/a0/server.ts +13 -19
  110. package/examples/playground/app/a0/uploads/route.ts +29 -0
  111. package/examples/playground/app/agent/[agentId]/agent-client.tsx +2 -1
  112. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +1 -0
  113. package/examples/playground/app/agent/model.test.ts +12 -3
  114. package/examples/playground/app/chat/[chatId]/agent-state.test.ts +1 -5
  115. package/examples/playground/next.config.ts +7 -4
  116. package/examples/playground/package.json +3 -1
  117. package/package.json +1 -1
  118. package/src/ai-agent.ts +14 -3
  119. package/src/ai-client-state.ts +21 -9
  120. package/src/ai-context-schema.ts +28 -4
  121. package/src/ai-context.ts +83 -3
  122. package/src/ai-control-server.ts +70 -43
  123. package/src/ai-control-state.ts +3 -4
  124. package/src/ai-control.ts +59 -10
  125. package/src/ai-coordinator.ts +7 -6
  126. package/src/ai-fx.ts +184 -67
  127. package/src/ai-harness.ts +153 -29
  128. package/src/ai-message-shape.ts +41 -0
  129. package/src/ai-pi.ts +161 -46
  130. package/src/ai-stored-state.ts +1 -3
  131. package/src/ai-tool-input.ts +45 -0
  132. package/src/ai.ts +116 -77
  133. package/src/internal.ts +6 -0
  134. package/src/libfx.d.ts +8 -1
  135. package/src/scheduler-qstash.ts +5 -5
  136. package/src/scheduler-vercel.ts +15 -27
  137. package/src/server.ts +58 -12
  138. package/src/store-memory.ts +50 -9
  139. package/src/store-postgres.ts +79 -28
  140. package/src/store-redis-core.ts +112 -31
  141. package/src/store-sqlite.ts +61 -28
  142. package/src/store.ts +22 -3
  143. package/dist/ai-BpOGm-zB.js.map +0 -1
  144. package/dist/ai-DrUaMyRt.d.ts.map +0 -1
  145. package/dist/ai-harness-BuLh_EYi.js.map +0 -1
  146. package/dist/ai-harness-DvsPeJeB.d.ts.map +0 -1
  147. package/dist/server-Dw_k_7kO.js.map +0 -1
  148. package/dist/server-En7cbi3v.d.ts.map +0 -1
  149. package/dist/session-clear-Bo-XTFEy.d.ts.map +0 -1
  150. package/dist/store-Djdv0bnZ.d.ts.map +0 -1
  151. package/dist/store-redis-notify-BRITti_o.js.map +0 -1
  152. package/examples/playground/app/a0/no-photon.cjs +0 -4
package/src/ai-context.ts CHANGED
@@ -19,7 +19,12 @@ import type {
19
19
  ToolCalledPayload,
20
20
  ToolResultPayload,
21
21
  } from './ai.ts'
22
- import type { ControlCommit } from './ai-control.ts'
22
+ import {
23
+ type ControlCommand,
24
+ type ControlCommit,
25
+ type ControlReceipt,
26
+ unexplainedAdmission,
27
+ } from './ai-control.ts'
23
28
  import { contextSchema } from './ai-context-schema.ts'
24
29
  import type { Contract, ContractEvent, EventDefs } from './contract.ts'
25
30
  import {
@@ -49,8 +54,22 @@ export type AIModelContext<M extends UIMessage = UIMessage> = {
49
54
  modelMetadata: Record<string, ModelMetadataState>
50
55
  }
51
56
 
57
+ /**
58
+ * Inputs' content between their command and their admission: each input's
59
+ * content is stored only in the command that brought it in. Lists, not
60
+ * records: clients choose the ids, and a record would read an id such as
61
+ * `constructor` or `__proto__` from its prototype.
62
+ */
63
+ export type AIContextInputs<M extends UIMessage> = {
64
+ /** Undecided input commands; a remove holds no message. */
65
+ commands: { commandId: string; inputId: string; message?: M }[]
66
+ /** Queued inputs' current content. */
67
+ waiting: { inputId: string; message: M }[]
68
+ }
69
+
52
70
  export type AIContextState<M extends UIMessage> = AIModelContext<M> & {
53
71
  closed: boolean
72
+ inputs: AIContextInputs<M>
54
73
  authority: Pick<
55
74
  ControlCommit['view'],
56
75
  | 'requestId'
@@ -211,6 +230,7 @@ export const createContextReducer = <M extends UIMessage, D extends EventDefs>({
211
230
  compaction: null,
212
231
  modelMetadata: {},
213
232
  closed: false,
233
+ inputs: { commands: [], waiting: [] },
214
234
  authority: null,
215
235
  generation: null,
216
236
  activeProjection: null,
@@ -222,7 +242,7 @@ export const createContextReducer = <M extends UIMessage, D extends EventDefs>({
222
242
  // The application state rides in this checkpoint so every generation
223
243
  // reads it without another state read.
224
244
  name:
225
- app === undefined ? 'a2.ai.context.v6' : `${app.name}+a2.ai.context.v6`,
245
+ app === undefined ? 'a2.ai.context.v7' : `${app.name}+a2.ai.context.v7`,
226
246
  initialState,
227
247
  stateSchema: contextSchema<M>(app),
228
248
  }).fold((state, event) => {
@@ -265,19 +285,79 @@ const reduceContext = <M extends UIMessage, D extends EventDefs>(
265
285
  }
266
286
  return view.closed ? endGeneration(next) : next
267
287
  }
288
+ case 'ai.control.requested': {
289
+ const command = event.payload as ControlCommand<M>
290
+ const held =
291
+ command.action === 'send' ||
292
+ command.action === 'steer' ||
293
+ command.action === 'edit'
294
+ ? {
295
+ commandId: event.id,
296
+ inputId: command.message.id,
297
+ message: command.message,
298
+ }
299
+ : command.action === 'remove'
300
+ ? { commandId: event.id, inputId: command.inputId }
301
+ : undefined
302
+ if (held === undefined) return state
303
+ return {
304
+ ...state,
305
+ inputs: {
306
+ ...state.inputs,
307
+ commands: [...state.inputs.commands, held],
308
+ },
309
+ }
310
+ }
311
+ case 'ai.control.decided': {
312
+ const receipt = event.payload as ControlReceipt
313
+ const held = receipt.work
314
+ ? undefined
315
+ : state.inputs.commands.find(
316
+ (command) => command.commandId === receipt.commandId,
317
+ )
318
+ if (held === undefined) return state
319
+ const commands = state.inputs.commands.filter(
320
+ (command) => command !== held,
321
+ )
322
+ if (receipt.outcome !== 'applied')
323
+ return { ...state, inputs: { ...state.inputs, commands } }
324
+ const waiting = state.inputs.waiting.filter(
325
+ (input) => input.inputId !== held.inputId,
326
+ )
327
+ if (held.message !== undefined)
328
+ waiting.push({ inputId: held.inputId, message: held.message })
329
+ return { ...state, inputs: { commands, waiting } }
330
+ }
268
331
  case 'ai.session.closed':
269
332
  return endGeneration({
270
333
  ...state,
271
334
  closed: true,
335
+ inputs: { commands: [], waiting: [] },
272
336
  compaction:
273
337
  state.compaction?.status === 'running'
274
338
  ? (state.activeProjection?.baseCompaction ?? null)
275
339
  : state.compaction,
276
340
  })
277
341
  case 'ai.message.created': {
278
- const { message } = event.payload as MessageCreatedPayload<M>
342
+ const { messageId } = event.payload as MessageCreatedPayload
343
+ // A send or steer admitted with its own receipt is still undecided
344
+ // here; its command id is the input's.
345
+ const message =
346
+ state.inputs.waiting.find((input) => input.inputId === messageId)
347
+ ?.message ??
348
+ state.inputs.commands.find((command) => command.commandId === messageId)
349
+ ?.message
350
+ if (message === undefined) throw unexplainedAdmission(messageId)
279
351
  return {
280
352
  ...state,
353
+ inputs: {
354
+ commands: state.inputs.commands.filter(
355
+ (command) => command.commandId !== messageId,
356
+ ),
357
+ waiting: state.inputs.waiting.filter(
358
+ (input) => input.inputId !== messageId,
359
+ ),
360
+ },
281
361
  messages: upsertMessage(state.messages, message),
282
362
  activeProjection:
283
363
  state.activeProjection?.responseMessageId === message.id
@@ -6,6 +6,7 @@ import type { UIMessage } from 'ai'
6
6
  import type {
7
7
  AIEventDefs,
8
8
  GenerationRequestedPayload,
9
+ MessageCreatedPayload,
9
10
  MessageInterruptedPayload,
10
11
  } from './ai.ts'
11
12
  import {
@@ -15,6 +16,9 @@ import {
15
16
  } from './ai-coordinator.ts'
16
17
  import {
17
18
  initialControlState,
19
+ STEERED_REASON,
20
+ steeringEnd,
21
+ steersOn,
18
22
  type AIControlState,
19
23
  type ControlCommand,
20
24
  type ControlReceipt,
@@ -67,7 +71,7 @@ export type AIControlRuntime<
67
71
  events: void | AppendInput<D> | readonly AppendInput<D>[]
68
72
  }): Promise<AppendInput<D>>
69
73
  cancelled(options: { error: unknown; signal: AbortSignal }): boolean
70
- reducer: Reducer<D, AIControlState<M>>
74
+ reducer: Reducer<D, AIControlState>
71
75
  coordinator: Reducer<D, AICoordinatorState>
72
76
  }
73
77
 
@@ -81,8 +85,8 @@ export const createControlRuntime = <
81
85
  const telemetry = options.telemetry ?? CONSOLE_TELEMETRY
82
86
  const reducer = makeReducer({
83
87
  contract: options.contract,
84
- name: 'a2.ai.control.v5',
85
- initialState: initialControlState<M>(),
88
+ name: 'a2.ai.control.v6',
89
+ initialState: initialControlState(),
86
90
  }).fold((state, event) => {
87
91
  if (event.type === 'ai.control.requested') {
88
92
  const command = event.payload as ControlCommand<M>
@@ -141,7 +145,7 @@ export const createControlRuntime = <
141
145
  (await aiEventId('a2.ai.fact', ctx.event.id, String(facts.length))),
142
146
  }
143
147
  facts.push(fact)
144
- state.coordinator = foldCoordinator<M, D>(state.coordinator, {
148
+ state.coordinator = foldCoordinator<D>(state.coordinator, {
145
149
  ...ctx.event,
146
150
  ...fact,
147
151
  } as ContractEvent<D>)
@@ -173,25 +177,52 @@ export const createControlRuntime = <
173
177
  })
174
178
  state.active = null
175
179
  }
180
+ const decide = async (): Promise<AppendInput<D>[]> => {
181
+ const receipt: ControlReceipt = {
182
+ commandId: ctx.event.id,
183
+ ...(ctx.event.type === 'ai.work.reported'
184
+ ? { work: true as const }
185
+ : {}),
186
+ outcome: reason === undefined ? 'applied' : 'rejected',
187
+ ...(reason === undefined ? {} : { reason }),
188
+ }
189
+ return [
190
+ ...(facts as AppendInput<D>[]),
191
+ {
192
+ type: 'ai.control.committed',
193
+ id: await aiEventId('a2.ai.control.committed', ctx.event.id),
194
+ payload: controlCommit({ before, after: state, moves }),
195
+ } as AppendInput<D>,
196
+ ...jobs,
197
+ {
198
+ type: 'ai.control.decided',
199
+ id: await aiEventId('a2.ai.control.decided', ctx.event.id),
200
+ payload: receipt,
201
+ } as AppendInput<D>,
202
+ ]
203
+ }
176
204
  const insert = ({
177
205
  message,
178
206
  generate = true,
179
- first = false,
180
207
  afterStepOf,
181
208
  }: {
182
209
  message: M
183
210
  generate?: boolean
184
- first?: boolean
185
211
  afterStepOf?: string
186
212
  }): void => {
187
213
  const candidate = {
188
214
  id: message.id,
189
- message,
215
+ role: message.role,
190
216
  generate: generate && message.role === 'user',
191
217
  ...(afterStepOf === undefined ? {} : { afterStepOf }),
192
218
  }
193
- if (first) state.inbox.items.unshift(candidate)
194
- else state.inbox.items.push(candidate)
219
+ if (afterStepOf === undefined) state.inbox.items.push(candidate)
220
+ else
221
+ state.inbox.items.splice(
222
+ steeringEnd(state.inbox.items, afterStepOf),
223
+ 0,
224
+ candidate,
225
+ )
195
226
  }
196
227
  const moveInput = ({ from, to }: { from: number; to: number }): void => {
197
228
  if (from === to) return
@@ -213,7 +244,7 @@ export const createControlRuntime = <
213
244
  ...request,
214
245
  control: { turnId: active.turnId, version: active.version },
215
246
  }
216
- state.coordinator = foldCoordinator<M, D>(state.coordinator, {
247
+ state.coordinator = foldCoordinator<D>(state.coordinator, {
217
248
  ...ctx.event,
218
249
  type: 'ai.generation.requested',
219
250
  id,
@@ -247,10 +278,11 @@ export const createControlRuntime = <
247
278
  (candidate) => candidate.id === command.message.id,
248
279
  )
249
280
  if (item === undefined) reason = 'not-pending'
250
- else if (item.message.role !== command.message.role)
281
+ else if (item.role !== command.message.role)
251
282
  reason = 'role-mismatch'
252
- else item.message = command.message
253
- break
283
+ // Its content takes effect at its receipt. An edit never runs
284
+ // admission, so no input's admission shares a batch with it.
285
+ return decide()
254
286
  }
255
287
  case 'remove': {
256
288
  const position = state.inbox.items.findIndex(
@@ -294,12 +326,18 @@ export const createControlRuntime = <
294
326
  if (!selected) reason = 'not-pending'
295
327
  else if ((state.active?.turnId ?? null) !== command.turnId)
296
328
  reason = 'stale-turn'
297
- else if (selected.message.role !== 'user') reason = 'not-user-input'
329
+ else if (selected.role !== 'user') reason = 'not-user-input'
298
330
  else {
299
331
  selected.generate = true
300
332
  if (state.active) selected.afterStepOf = state.active.turnId
301
333
  else delete selected.afterStepOf
302
- moveInput({ from: position, to: 0 })
334
+ const others = state.inbox.items.filter(
335
+ (item) => item !== selected,
336
+ )
337
+ moveInput({
338
+ from: position,
339
+ to: state.active ? steeringEnd(others, state.active.turnId) : 0,
340
+ })
303
341
  }
304
342
  break
305
343
  }
@@ -315,7 +353,6 @@ export const createControlRuntime = <
315
353
  insert({
316
354
  message: command.message,
317
355
  generate: true,
318
- first: true,
319
356
  afterStepOf: command.turnId,
320
357
  })
321
358
  break
@@ -586,15 +623,19 @@ export const createControlRuntime = <
586
623
  !call.terminal &&
587
624
  !approvalBlocksExecution(call),
588
625
  )
626
+ // A paused turn keeps its steering until Resume, so every steer sent
627
+ // while it's paused targets it and they enter together.
589
628
  const handoffReady =
590
629
  handoff &&
591
630
  workSettled &&
592
631
  !externalPending &&
593
- (active.suspended ||
594
- response.failure !== undefined ||
595
- response.calls.every(
596
- (call) => callReadyForModel(call) || approvalBlocksExecution(call),
597
- ))
632
+ (active.suspended
633
+ ? !state.inbox.paused
634
+ : response.failure !== undefined ||
635
+ response.calls.every(
636
+ (call) =>
637
+ callReadyForModel(call) || approvalBlocksExecution(call),
638
+ ))
598
639
  const readyForModel =
599
640
  !active.suspended &&
600
641
  response.failure === undefined &&
@@ -606,7 +647,7 @@ export const createControlRuntime = <
606
647
  response.completion !== undefined &&
607
648
  response.completion.finishReason !== 'tool-calls'
608
649
  if (handoffReady && !naturalCompletion) {
609
- await end({ lastSeenIndex: undefined, reason: 'steered after step' })
650
+ await end({ lastSeenIndex: undefined, reason: STEERED_REASON })
610
651
  } else if (readyForModel && (!handoff || handoffReady)) {
611
652
  const resume =
612
653
  active.model !== null && active.model.version !== active.version
@@ -669,10 +710,15 @@ export const createControlRuntime = <
669
710
  ) {
670
711
  while (state.inbox.items.length > 0) {
671
712
  const item = state.inbox.items.shift()!
713
+ if (steersOn(item, state.inbox.items)) item.generate = false
672
714
  delete item.afterStepOf
673
715
  await emit({
674
716
  type: 'ai.message.created',
675
- payload: { message: item.message, generate: item.generate },
717
+ payload: {
718
+ messageId: item.id,
719
+ role: item.role,
720
+ ...(item.generate ? {} : { generate: false as const }),
721
+ } satisfies MessageCreatedPayload,
676
722
  id: await aiEventId(
677
723
  'a2.ai.admitted',
678
724
  options.contract.name,
@@ -706,26 +752,7 @@ export const createControlRuntime = <
706
752
  break
707
753
  }
708
754
  }
709
- const receipt: ControlReceipt = {
710
- commandId: ctx.event.id,
711
- ...(ctx.event.type === 'ai.work.reported' ? { work: true as const } : {}),
712
- outcome: reason === undefined ? 'applied' : 'rejected',
713
- ...(reason === undefined ? {} : { reason }),
714
- }
715
- return [
716
- ...(facts as AppendInput<D>[]),
717
- {
718
- type: 'ai.control.committed',
719
- id: await aiEventId('a2.ai.control.committed', ctx.event.id),
720
- payload: controlCommit({ before, after: state, moves }),
721
- } as AppendInput<D>,
722
- ...jobs,
723
- {
724
- type: 'ai.control.decided',
725
- id: await aiEventId('a2.ai.control.decided', ctx.event.id),
726
- payload: receipt,
727
- } as AppendInput<D>,
728
- ]
755
+ return decide()
729
756
  }
730
757
 
731
758
  const report = async ({
@@ -3,7 +3,6 @@ import type {
3
3
  ControlCommit,
4
4
  ControlChange,
5
5
  } from './ai-control.ts'
6
- import type { UIMessage } from 'ai'
7
6
 
8
7
  type RecordValue = Record<string, unknown> | unknown[]
9
8
  const container = (value: unknown): value is RecordValue =>
@@ -131,9 +130,9 @@ export const applyControlChanges = <T>({
131
130
  return next
132
131
  }
133
132
 
134
- export const controlCommit = <M extends UIMessage>(options: {
135
- before: AIControlState<M>
136
- after: AIControlState<M>
133
+ export const controlCommit = (options: {
134
+ before: AIControlState
135
+ after: AIControlState
137
136
  moves: ControlChange[]
138
137
  }): ControlCommit => {
139
138
  const active = options.after.active
package/src/ai-control.ts CHANGED
@@ -12,13 +12,22 @@ import type {
12
12
  ToolResultPayload,
13
13
  } from './ai.ts'
14
14
 
15
- export type InboxItem<M extends UIMessage = UIMessage> = {
15
+ /**
16
+ * An input in the queue or the active turn. Its content stays in the
17
+ * `ai.control.requested` command that brought it, and in no other event.
18
+ */
19
+ export type InputRef = {
16
20
  id: string
17
- message: M
21
+ role: UIMessage['role']
18
22
  generate: boolean
19
23
  afterStepOf?: string
20
24
  }
21
25
 
26
+ /** A queued input with its content, as `AIState` shows it. */
27
+ export type InboxItem<M extends UIMessage = UIMessage> = InputRef & {
28
+ message: M
29
+ }
30
+
22
31
  export type ControlCommand<M extends UIMessage = UIMessage> =
23
32
  | { action: 'send'; message: M; generate?: boolean }
24
33
  | { action: 'edit'; message: M }
@@ -55,9 +64,9 @@ export type ControlReceipt = {
55
64
  | 'not-user-input'
56
65
  }
57
66
 
58
- export type ActiveTurn<M extends UIMessage = UIMessage> = {
67
+ export type ActiveTurn = {
59
68
  turnId: string
60
- input: InboxItem<M>
69
+ input: InputRef
61
70
  phase:
62
71
  | 'starting'
63
72
  | 'generating'
@@ -76,11 +85,11 @@ export type ControlWork = {
76
85
  facts?: string[]
77
86
  }
78
87
 
79
- export type AIControlState<M extends UIMessage = UIMessage> = {
88
+ export type AIControlState = {
80
89
  pendingStops: Record<string, string>
81
- inbox: { paused: boolean; items: InboxItem<M>[] }
90
+ inbox: { paused: boolean; items: InputRef[] }
82
91
  active:
83
- | (ActiveTurn<M> & {
92
+ | (ActiveTurn & {
84
93
  version: number
85
94
  suspended: boolean
86
95
  model: ControlWork | null
@@ -107,9 +116,49 @@ export type ToolExecutionRequest = {
107
116
  generation: GenerationStartedPayload
108
117
  }
109
118
 
110
- export const initialControlState = <
111
- M extends UIMessage,
112
- >(): AIControlState<M> => ({
119
+ /**
120
+ * The `ai.message.interrupted` reason A2 writes when steering ends a response
121
+ * after a step; a harness's replay continues the turn on it. Reserved, so a
122
+ * client's interruption can't carry it.
123
+ */
124
+ export const STEERED_REASON = 'steered after step'
125
+
126
+ /**
127
+ * A fold met an admission no command explains. Only A2's control lane writes
128
+ * `ai.message.created`, after the command that brought its input in.
129
+ */
130
+ export const unexplainedAdmission = (messageId: string): TypeError =>
131
+ new TypeError(
132
+ `ai.message.created for ${messageId} follows no command that brought it in; only A2's control lane writes it`,
133
+ )
134
+
135
+ /**
136
+ * Where new steering for a turn goes in the inbox: right after the turn's
137
+ * earlier steering, or first, so it hands off in the order it was sent.
138
+ */
139
+ export const steeringEnd = (
140
+ items: readonly InputRef[],
141
+ turnId: string,
142
+ ): number => items.findLastIndex((item) => item.afterStepOf === turnId) + 1
143
+
144
+ /**
145
+ * Whether an input steering a turn enters as context for later steering: the
146
+ * run of inputs steering one turn is admitted together, and only its last
147
+ * generating input starts the next turn.
148
+ */
149
+ export const steersOn = (
150
+ item: InputRef,
151
+ rest: readonly InputRef[],
152
+ ): boolean => {
153
+ if (item.afterStepOf === undefined) return false
154
+ for (const next of rest) {
155
+ if (next.afterStepOf !== item.afterStepOf) return false
156
+ if (next.generate) return true
157
+ }
158
+ return false
159
+ }
160
+
161
+ export const initialControlState = (): AIControlState => ({
113
162
  pendingStops: {},
114
163
  inbox: { paused: false, items: [] },
115
164
  active: null,
@@ -107,7 +107,7 @@ const updateResponse = (
107
107
  ? state
108
108
  : { ...state, response: update(state.response) }
109
109
 
110
- export const foldCoordinator = <M extends UIMessage, D extends EventDefs>(
110
+ export const foldCoordinator = <D extends EventDefs>(
111
111
  state: AICoordinatorState,
112
112
  event: ContractEvent<D>,
113
113
  ): AICoordinatorState => {
@@ -120,15 +120,16 @@ export const foldCoordinator = <M extends UIMessage, D extends EventDefs>(
120
120
  return rest
121
121
  }
122
122
  case 'ai.message.created': {
123
- const payload = event.payload as MessageCreatedPayload<M>
124
- const message = payload.message
125
- if (message.role !== 'user') return state
123
+ const payload = event.payload as MessageCreatedPayload
124
+ if (payload.role !== 'user') return state
126
125
  return {
127
126
  ...state,
128
127
  queued: [
129
- ...state.queued.filter((item) => item.messageId !== message.id),
128
+ ...state.queued.filter(
129
+ (item) => item.messageId !== payload.messageId,
130
+ ),
130
131
  {
131
- messageId: message.id,
132
+ messageId: payload.messageId,
132
133
  generate: payload.generate !== false,
133
134
  },
134
135
  ],