experimental-a2 0.15.0 → 0.16.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 (101) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/dist/actor-client.d.ts +1 -1
  3. package/dist/{actor-BfQSE0KC.d.ts → actor-ohPC-81x.d.ts} +5 -5
  4. package/dist/{actor-BfQSE0KC.d.ts.map → actor-ohPC-81x.d.ts.map} +1 -1
  5. package/dist/actor-react.d.ts +3 -3
  6. package/dist/actor.d.ts +1 -1
  7. package/dist/actor.js +1 -1
  8. package/dist/ai-CrLYwNEx.js +2431 -0
  9. package/dist/ai-CrLYwNEx.js.map +1 -0
  10. package/dist/{ai-Cai-lCbj.d.ts → ai-HJ9fHfYI.d.ts} +72 -13
  11. package/dist/ai-HJ9fHfYI.d.ts.map +1 -0
  12. package/dist/ai-server.d.ts +4 -5
  13. package/dist/ai-server.d.ts.map +1 -1
  14. package/dist/ai-server.js +755 -290
  15. package/dist/ai-server.js.map +1 -1
  16. package/dist/ai.d.ts +2 -2
  17. package/dist/ai.js +1 -1999
  18. package/dist/client-BYzHjkwU.js.map +1 -1
  19. package/dist/{client-BAEABRZB.d.ts → client-CzyacQpJ.d.ts} +9 -9
  20. package/dist/client-CzyacQpJ.d.ts.map +1 -0
  21. package/dist/client.d.ts +1 -1
  22. package/dist/idempotent-id-BRJVeylj.js +13 -0
  23. package/dist/idempotent-id-BRJVeylj.js.map +1 -0
  24. package/dist/index.d.ts +3 -3
  25. package/dist/index.js +1 -12
  26. package/dist/otel.d.ts +1 -1
  27. package/dist/react.d.ts +13 -13
  28. package/dist/react.d.ts.map +1 -1
  29. package/dist/react.js.map +1 -1
  30. package/dist/reducer-DEMjEY_O.js.map +1 -1
  31. package/dist/{reducer-BcS9VDKC.d.ts → reducer-otzHjuJj.d.ts} +3 -3
  32. package/dist/{reducer-BcS9VDKC.d.ts.map → reducer-otzHjuJj.d.ts.map} +1 -1
  33. package/dist/scheduler-qstash.d.ts +2 -2
  34. package/dist/scheduler-qstash.js +1 -1
  35. package/dist/scheduler-vercel.d.ts +2 -2
  36. package/dist/scheduler-vercel.js +1 -1
  37. package/dist/{server-CjJSGcF7.js → server-BD5ckxZb.js} +39 -26
  38. package/dist/server-BD5ckxZb.js.map +1 -0
  39. package/dist/{server-Bp5Nd1pF.d.ts → server-DbD7IJVK.d.ts} +5 -5
  40. package/dist/{server-Bp5Nd1pF.d.ts.map → server-DbD7IJVK.d.ts.map} +1 -1
  41. package/dist/server.d.ts +3 -3
  42. package/dist/server.js +1 -1
  43. package/dist/{store-D_yhNdPz.d.ts → store-DyZM6fS5.d.ts} +3 -2
  44. package/dist/{store-D_yhNdPz.d.ts.map → store-DyZM6fS5.d.ts.map} +1 -1
  45. package/dist/store-N8PXxDAS.js.map +1 -1
  46. package/dist/store-memory.d.ts +1 -1
  47. package/dist/store-postgres.d.ts +1 -1
  48. package/dist/store-redis-http.d.ts +1 -1
  49. package/dist/store-redis-http.js +1 -1
  50. package/dist/{store-redis-notify-D2EI6gwX.js → store-redis-notify-BVLUfI3j.js} +14 -13
  51. package/dist/store-redis-notify-BVLUfI3j.js.map +1 -0
  52. package/dist/store-redis.d.ts +1 -1
  53. package/dist/store-redis.js +1 -1
  54. package/dist/store-sqlite.d.ts +1 -1
  55. package/dist/{telemetry-CpeclqB2.d.ts → telemetry-B5jzpy6y.d.ts} +2 -2
  56. package/dist/telemetry-B5jzpy6y.d.ts.map +1 -0
  57. package/docs/concepts/04-state.mdx +9 -4
  58. package/docs/guides/06-ai-agents.mdx +134 -43
  59. package/docs/reference/01-api.mdx +104 -27
  60. package/examples/playground/app/agent/[agentId]/agent-client.tsx +20 -28
  61. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +123 -0
  62. package/examples/playground/app/agent/[agentId]/agent-queue.tsx +37 -43
  63. package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +44 -24
  64. package/examples/playground/app/agent/compaction-timeline.test.ts +47 -7
  65. package/examples/playground/app/agent/compaction-timeline.ts +13 -3
  66. package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -5
  67. package/examples/playground/app/chat/[chatId]/session.ts +17 -2
  68. package/examples/playground/app/chat/model.test.ts +4 -4
  69. package/examples/playground/app/chat/model.ts +18 -6
  70. package/examples/playground/app/chat/server.ts +6 -3
  71. package/examples/playground/package.json +3 -1
  72. package/package.json +1 -1
  73. package/src/ai-client-state.ts +0 -2
  74. package/src/ai-context-schema.ts +296 -0
  75. package/src/ai-context.ts +680 -0
  76. package/src/ai-control-server.ts +160 -131
  77. package/src/ai-control-state.ts +6 -0
  78. package/src/ai-control.ts +6 -7
  79. package/src/ai-coordinator.ts +92 -38
  80. package/src/ai-id.ts +6 -0
  81. package/src/ai-input-tokens.ts +75 -0
  82. package/src/ai-message-projection.ts +348 -0
  83. package/src/ai-server.ts +232 -334
  84. package/src/ai-stored-state.ts +484 -0
  85. package/src/ai.ts +252 -418
  86. package/src/client.ts +21 -15
  87. package/src/react.ts +25 -19
  88. package/src/reducer.ts +2 -1
  89. package/src/server.ts +73 -56
  90. package/src/store-redis-core.ts +15 -17
  91. package/src/store.ts +1 -0
  92. package/src/telemetry.ts +2 -1
  93. package/dist/ai-Cai-lCbj.d.ts.map +0 -1
  94. package/dist/ai-control-CcD4hh3y.js +0 -119
  95. package/dist/ai-control-CcD4hh3y.js.map +0 -1
  96. package/dist/ai.js.map +0 -1
  97. package/dist/client-BAEABRZB.d.ts.map +0 -1
  98. package/dist/index.js.map +0 -1
  99. package/dist/server-CjJSGcF7.js.map +0 -1
  100. package/dist/store-redis-notify-D2EI6gwX.js.map +0 -1
  101. package/dist/telemetry-CpeclqB2.d.ts.map +0 -1
@@ -28,6 +28,7 @@ import type { AICoordinatorState, CoordinatorCall } from './ai-coordinator.ts'
28
28
  import { validateSync } from './validate.ts'
29
29
  import type { StandardSchemaV1 } from './standard-schema.ts'
30
30
  import { A2Error } from './errors.ts'
31
+ import { aiEventId } from './ai-id.ts'
31
32
 
32
33
  export class ControlCancelled extends Error {}
33
34
 
@@ -58,7 +59,7 @@ export type AIControlRuntime<
58
59
  settled(options: {
59
60
  ctx: HandlerContext<D>
60
61
  events: void | AppendInput<D> | readonly AppendInput<D>[]
61
- }): AppendInput<D>
62
+ }): Promise<AppendInput<D>>
62
63
  cancelled(options: { error: unknown; signal: AbortSignal }): boolean
63
64
  reducer: Reducer<D, AIControlState<M>>
64
65
  coordinator: Reducer<D, AICoordinatorState>
@@ -72,7 +73,7 @@ export const createControlRuntime = <
72
73
  }): AIControlRuntime<M, D> => {
73
74
  const reducer = options.agent.contract
74
75
  .reducer({
75
- name: 'a2.ai.control.v1',
76
+ name: 'a2.ai.control.v4',
76
77
  initialState: initialControlState<M>(),
77
78
  })
78
79
  .fold((state, event) =>
@@ -97,16 +98,22 @@ export const createControlRuntime = <
97
98
  const state = structuredClone(snapshot.state)
98
99
  const facts: WorkFact[] = []
99
100
  const jobs: AppendInput<D>[] = []
100
- const emit = ({
101
+ const emit = async ({
101
102
  type,
102
103
  payload,
103
- id = `${ctx.event.id}:fact:${facts.length}`,
104
+ id,
104
105
  }: {
105
106
  type: string
106
107
  payload: unknown
107
108
  id?: string
108
- }): void => {
109
- const fact = { type, payload, id }
109
+ }): Promise<void> => {
110
+ const fact = {
111
+ type,
112
+ payload,
113
+ id:
114
+ id ??
115
+ (await aiEventId('a2.ai.fact', ctx.event.id, String(facts.length))),
116
+ }
110
117
  facts.push(fact)
111
118
  state.coordinator = foldCoordinator<M, D>(state.coordinator, {
112
119
  ...ctx.event,
@@ -114,20 +121,20 @@ export const createControlRuntime = <
114
121
  } as ContractEvent<D>)
115
122
  }
116
123
  let reason: ControlReceipt['reason']
117
- const end = ({
124
+ const end = async ({
118
125
  lastSeenIndex,
119
126
  reason: interruptionReason,
120
127
  }: {
121
128
  lastSeenIndex: number | undefined
122
129
  reason: string | undefined
123
- }): void => {
130
+ }): Promise<void> => {
124
131
  const response = state.coordinator.response
125
132
  if (response === undefined) return
126
133
  const owner =
127
134
  response.generation === undefined
128
135
  ? { requestId: response.activeRequestId! }
129
136
  : { generationId: response.generation.generationId }
130
- emit({
137
+ await emit({
131
138
  type: 'ai.message.interrupted',
132
139
  payload: {
133
140
  messageId: response.responseMessageId,
@@ -140,24 +147,6 @@ export const createControlRuntime = <
140
147
  })
141
148
  state.active = null
142
149
  }
143
- const knownInput = (id: string): boolean =>
144
- state.inbox.items.some((candidate) => candidate.id === id) ||
145
- Object.hasOwn(state.reservedIds, id)
146
- const missingInputReason = (id: string): ControlReceipt['reason'] =>
147
- state.reservedIds[id] === 'removed' || !knownInput(id)
148
- ? 'not-found'
149
- : 'already-active'
150
- const inputTaken = ({
151
- message,
152
- generate = true,
153
- }: {
154
- message: M
155
- generate?: boolean
156
- }): boolean =>
157
- knownInput(message.id) ||
158
- (generate &&
159
- message.role === 'user' &&
160
- knownInput(`${message.id}:assistant`))
161
150
  const insert = ({
162
151
  message,
163
152
  generate = true,
@@ -171,18 +160,10 @@ export const createControlRuntime = <
171
160
  }): void => {
172
161
  const candidate = {
173
162
  id: message.id,
174
- revision: 0,
175
163
  message,
176
164
  generate: generate && message.role === 'user',
177
165
  ...(afterStepOf === undefined ? {} : { afterStepOf }),
178
166
  }
179
- state.reservedIds = {
180
- ...state.reservedIds,
181
- [candidate.id]: true,
182
- ...(candidate.generate
183
- ? { [`${candidate.id}:assistant`]: true as const }
184
- : {}),
185
- }
186
167
  if (first) state.inbox.items.unshift(candidate)
187
168
  else state.inbox.items.push(candidate)
188
169
  }
@@ -230,61 +211,41 @@ export const createControlRuntime = <
230
211
  else
231
212
  switch (command.action) {
232
213
  case 'send':
233
- if (
234
- inputTaken({
235
- message: command.message,
236
- generate: command.generate !== false,
237
- })
238
- )
239
- reason = 'duplicate-input'
240
- else
241
- insert({
242
- message: command.message,
243
- generate: command.generate !== false,
244
- })
214
+ insert({
215
+ message: command.message,
216
+ generate: command.generate !== false,
217
+ })
245
218
  break
246
219
  case 'edit': {
247
220
  const item = state.inbox.items.find(
248
221
  (candidate) => candidate.id === command.message.id,
249
222
  )
250
- if (item === undefined)
251
- reason = missingInputReason(command.message.id)
252
- else if (item.revision !== command.expectedRevision)
253
- reason = 'revision-conflict'
223
+ if (item === undefined) reason = 'not-pending'
254
224
  else if (item.message.role !== command.message.role)
255
- reason = 'revision-conflict'
256
- else {
257
- item.message = command.message
258
- item.revision += 1
259
- }
225
+ reason = 'role-mismatch'
226
+ else item.message = command.message
260
227
  break
261
228
  }
262
229
  case 'remove': {
263
230
  const position = state.inbox.items.findIndex(
264
231
  (item) => item.id === command.inputId,
265
232
  )
266
- if (position < 0) reason = missingInputReason(command.inputId)
267
- else {
268
- state.inbox.items.splice(position, 1)
269
- state.reservedIds = {
270
- ...state.reservedIds,
271
- [command.inputId]: 'removed',
272
- }
273
- }
233
+ if (position < 0) reason = 'not-pending'
234
+ else state.inbox.items.splice(position, 1)
274
235
  break
275
236
  }
276
237
  case 'move': {
277
238
  const index = state.inbox.items.findIndex(
278
239
  (candidate) => candidate.id === command.inputId,
279
240
  )
280
- if (index < 0) reason = missingInputReason(command.inputId)
241
+ if (index < 0) reason = 'not-pending'
281
242
  else if (
282
243
  command.beforeId !== null &&
283
244
  !state.inbox.items.some(
284
245
  (candidate) => candidate.id === command.beforeId,
285
246
  )
286
247
  )
287
- reason = 'not-found'
248
+ reason = 'not-pending'
288
249
  else {
289
250
  const destination =
290
251
  command.beforeId === null
@@ -304,23 +265,12 @@ export const createControlRuntime = <
304
265
  (item) => item.id === command.inputId,
305
266
  )
306
267
  const selected = state.inbox.items[position]
307
- if (!selected) reason = missingInputReason(command.inputId)
268
+ if (!selected) reason = 'not-pending'
308
269
  else if ((state.active?.turnId ?? null) !== command.turnId)
309
270
  reason = 'stale-turn'
310
271
  else if (selected.message.role !== 'user') reason = 'not-user-input'
311
- else if (
312
- !selected.generate &&
313
- knownInput(`${selected.id}:assistant`)
314
- )
315
- reason = 'duplicate-input'
316
272
  else {
317
- if (!selected.generate) {
318
- state.reservedIds = {
319
- ...state.reservedIds,
320
- [`${selected.id}:assistant`]: true,
321
- }
322
- selected.generate = true
323
- }
273
+ selected.generate = true
324
274
  if (state.active) selected.afterStepOf = state.active.turnId
325
275
  else delete selected.afterStepOf
326
276
  moveInput({ from: position, to: 0 })
@@ -330,19 +280,18 @@ export const createControlRuntime = <
330
280
  case 'stop':
331
281
  if (state.active?.turnId !== command.turnId) reason = 'stale-turn'
332
282
  else
333
- end({ lastSeenIndex: command.lastSeenIndex, reason: undefined })
283
+ await end({
284
+ lastSeenIndex: command.lastSeenIndex,
285
+ reason: undefined,
286
+ })
334
287
  break
335
288
  case 'steer':
336
- if (inputTaken({ message: command.message }))
337
- reason = 'duplicate-input'
338
- else {
339
- insert({
340
- message: command.message,
341
- generate: true,
342
- first: true,
343
- afterStepOf: command.turnId,
344
- })
345
- }
289
+ insert({
290
+ message: command.message,
291
+ generate: true,
292
+ first: true,
293
+ afterStepOf: command.turnId,
294
+ })
346
295
  break
347
296
  case 'interrupt': {
348
297
  const response = state.coordinator.response
@@ -355,7 +304,7 @@ export const createControlRuntime = <
355
304
  )
356
305
  reason = 'stale-turn'
357
306
  else
358
- end({
307
+ await end({
359
308
  lastSeenIndex: command.request.lastSeenIndex,
360
309
  reason: command.request.reason,
361
310
  })
@@ -373,7 +322,7 @@ export const createControlRuntime = <
373
322
  active.version += 1
374
323
  const generation = state.coordinator.response?.generation
375
324
  if (generation)
376
- emit({
325
+ await emit({
377
326
  type: 'ai.generation.paused',
378
327
  payload: {
379
328
  generationId: generation.generationId,
@@ -389,6 +338,7 @@ export const createControlRuntime = <
389
338
  case 'tool-result': {
390
339
  const call = state.coordinator.response?.calls.find(
391
340
  (candidate) =>
341
+ candidate.call.requestId === command.result.requestId &&
392
342
  candidate.call.generationId === command.result.generationId &&
393
343
  candidate.call.messageId === command.result.messageId &&
394
344
  candidate.call.toolCallId === command.result.toolCallId &&
@@ -396,15 +346,15 @@ export const createControlRuntime = <
396
346
  !candidate.terminal,
397
347
  )
398
348
  if (!call) reason = 'not-found'
399
- else emit({ type: 'ai.tool.result', payload: command.result })
349
+ else await emit({ type: 'ai.tool.result', payload: command.result })
400
350
  break
401
351
  }
402
352
  case 'approval': {
403
353
  const response = state.coordinator.response
404
354
  const call = response?.calls.find(
405
355
  (candidate) =>
406
- candidate.approval?.approvalId ===
407
- command.response.approvalId &&
356
+ candidate.approval?.requestId === command.response.requestId &&
357
+ candidate.approval.approvalId === command.response.approvalId &&
408
358
  candidate.approval.generationId ===
409
359
  command.response.generationId &&
410
360
  candidate.approval.messageId === command.response.messageId,
@@ -413,7 +363,10 @@ export const createControlRuntime = <
413
363
  else if (!call || call.response || call.terminal)
414
364
  reason = 'not-found'
415
365
  else
416
- emit({ type: 'ai.approval.responded', payload: command.response })
366
+ await emit({
367
+ type: 'ai.approval.responded',
368
+ payload: command.response,
369
+ })
417
370
  break
418
371
  }
419
372
  case 'request-input':
@@ -423,7 +376,8 @@ export const createControlRuntime = <
423
376
  const response = state.coordinator.response
424
377
  if (
425
378
  response?.failure !== undefined ||
426
- response?.generation?.generationId !== payload.generationId ||
379
+ response?.generation?.requestId !== payload.requestId ||
380
+ response.generation.generationId !== payload.generationId ||
427
381
  response.responseMessageId !== payload.messageId ||
428
382
  (command.action === 'input' &&
429
383
  !response.inputs.some(
@@ -434,7 +388,7 @@ export const createControlRuntime = <
434
388
  )
435
389
  reason = 'not-found'
436
390
  else
437
- emit({
391
+ await emit({
438
392
  type:
439
393
  command.action === 'input'
440
394
  ? 'ai.input.responded'
@@ -453,14 +407,22 @@ export const createControlRuntime = <
453
407
  )
454
408
  reason = 'stale-turn'
455
409
  else if (!state.inbox.paused && !state.active.suspended) {
456
- emit({ type: 'ai.retry.requested', payload: command.request })
410
+ await emit({
411
+ type: 'ai.retry.requested',
412
+ payload: command.request,
413
+ })
457
414
  requestModel({
458
415
  request: {
459
416
  messageId: response.rootMessageId,
460
417
  responseMessageId: response.responseMessageId,
461
418
  reason: 'retry',
462
419
  },
463
- id: `ai.generate:retry:${command.request.retryId}`,
420
+ id: await aiEventId(
421
+ 'a2.ai.request.retry',
422
+ options.agent.contract.name,
423
+ ctx.event.sessionId,
424
+ command.request.retryId,
425
+ ),
464
426
  })
465
427
  } else reason = 'stale-turn'
466
428
  break
@@ -468,6 +430,15 @@ export const createControlRuntime = <
468
430
  }
469
431
  } else {
470
432
  const report = ctx.event.payload as WorkReport
433
+ if (
434
+ report.kind === 'tool' &&
435
+ report.settled &&
436
+ state.coordinator.unsettledTools
437
+ ) {
438
+ delete state.coordinator.unsettledTools[report.workId]
439
+ if (Object.keys(state.coordinator.unsettledTools).length === 0)
440
+ delete state.coordinator.unsettledTools
441
+ }
471
442
  const active = state.active
472
443
  const response = state.coordinator.response
473
444
  const call = response?.calls.find(
@@ -492,11 +463,18 @@ export const createControlRuntime = <
492
463
  authorized ||
493
464
  (report.kind === 'tool' && event.type === 'ai.tool.result')
494
465
  ) {
495
- emit({ type: event.type, payload: event.payload, id: event.id })
466
+ await emit({
467
+ type: event.type,
468
+ payload: event.payload,
469
+ id: event.id,
470
+ })
496
471
  work.facts = [...(work.facts ?? []), event.id]
497
472
  }
498
473
  }
499
- if (report.settled) work.settled = true
474
+ if (report.settled) {
475
+ work.settled = true
476
+ delete work.facts
477
+ }
500
478
  if (!authorized) reason = 'stale-work'
501
479
  }
502
480
  }
@@ -534,7 +512,7 @@ export const createControlRuntime = <
534
512
  )
535
513
  continue
536
514
  if (call.response?.approved === false) {
537
- emit({
515
+ await emit({
538
516
  type: 'ai.tool.result',
539
517
  payload: {
540
518
  ...call.call,
@@ -544,8 +522,17 @@ export const createControlRuntime = <
544
522
  })
545
523
  continue
546
524
  }
547
- const id = `${ctx.event.id}:tool:${call.call.toolCallId}`
525
+ const id = await aiEventId(
526
+ 'a2.ai.tool.execution',
527
+ ctx.event.id,
528
+ call.call.generationId,
529
+ call.call.toolCallId,
530
+ )
548
531
  call.work = { id, version: active.version, settled: false }
532
+ state.coordinator.unsettledTools = {
533
+ ...state.coordinator.unsettledTools,
534
+ [id]: true,
535
+ }
549
536
  jobs.push({
550
537
  type: 'ai.tool.execution.requested',
551
538
  id,
@@ -593,7 +580,7 @@ export const createControlRuntime = <
593
580
  response.completion !== undefined &&
594
581
  response.completion.finishReason !== 'tool-calls'
595
582
  if (handoffReady && !naturalCompletion) {
596
- end({ lastSeenIndex: undefined, reason: 'steered after step' })
583
+ await end({ lastSeenIndex: undefined, reason: 'steered after step' })
597
584
  } else if (readyForModel && (!handoff || handoffReady)) {
598
585
  const resume =
599
586
  active.model !== null && active.model.version !== active.version
@@ -605,20 +592,29 @@ export const createControlRuntime = <
605
592
  (response.completion && input))
606
593
  ) {
607
594
  const nextReason = resume ? 'resume' : input ? 'input' : 'tool'
595
+ const sourceGenerationId = (
596
+ response.generation ?? response.source?.generation
597
+ )?.generationId
608
598
  const id =
609
599
  nextReason === 'tool'
610
- ? `ai.generate:tools:${response.generation!.generationId}`
611
- : `${ctx.event.id}:generate:${nextReason}`
600
+ ? await aiEventId(
601
+ 'a2.ai.request.tool',
602
+ response.generation!.generationId,
603
+ )
604
+ : await aiEventId('a2.ai.request', ctx.event.id, nextReason)
612
605
  requestModel({
613
606
  request: {
614
607
  messageId: response.responseMessageId,
615
608
  responseMessageId: response.responseMessageId,
616
609
  reason: nextReason,
610
+ ...(sourceGenerationId === undefined
611
+ ? {}
612
+ : { sourceGenerationId }),
617
613
  },
618
614
  id,
619
615
  })
620
616
  } else if (naturalCompletion) {
621
- emit({
617
+ await emit({
622
618
  type: 'ai.message.completed',
623
619
  payload: {
624
620
  messageId: response.responseMessageId,
@@ -648,10 +644,15 @@ export const createControlRuntime = <
648
644
  while (state.inbox.items.length > 0) {
649
645
  const item = state.inbox.items.shift()!
650
646
  delete item.afterStepOf
651
- emit({
647
+ await emit({
652
648
  type: 'ai.message.created',
653
649
  payload: { message: item.message, generate: item.generate },
654
- id: `ai.admitted:${item.id}`,
650
+ id: await aiEventId(
651
+ 'a2.ai.admitted',
652
+ options.agent.contract.name,
653
+ ctx.event.sessionId,
654
+ item.id,
655
+ ),
655
656
  })
656
657
  if (!item.generate) continue
657
658
  state.active = {
@@ -662,9 +663,19 @@ export const createControlRuntime = <
662
663
  suspended: false,
663
664
  model: null,
664
665
  }
666
+ const requestId = await aiEventId(
667
+ 'a2.ai.request.message',
668
+ options.agent.contract.name,
669
+ ctx.event.sessionId,
670
+ item.id,
671
+ )
665
672
  requestModel({
666
- request: { messageId: item.id, reason: 'message' },
667
- id: `ai.generate:message:${item.id}`,
673
+ request: {
674
+ messageId: item.id,
675
+ responseMessageId: requestId,
676
+ reason: 'message',
677
+ },
678
+ id: requestId,
668
679
  })
669
680
  break
670
681
  }
@@ -679,14 +690,19 @@ export const createControlRuntime = <
679
690
  ...(facts as AppendInput<D>[]),
680
691
  {
681
692
  type: 'ai.control.committed',
693
+ id: await aiEventId('a2.ai.control.committed', ctx.event.id),
682
694
  payload: controlCommit({ before, after: state, moves }),
683
695
  } as AppendInput<D>,
684
696
  ...jobs,
685
- { type: 'ai.control.decided', payload: receipt } as AppendInput<D>,
697
+ {
698
+ type: 'ai.control.decided',
699
+ id: await aiEventId('a2.ai.control.decided', ctx.event.id),
700
+ payload: receipt,
701
+ } as AppendInput<D>,
686
702
  ]
687
703
  }
688
704
 
689
- const report = ({
705
+ const report = async ({
690
706
  ctx,
691
707
  events,
692
708
  settled,
@@ -696,7 +712,7 @@ export const createControlRuntime = <
696
712
  events: readonly AppendInput<D>[]
697
713
  settled: boolean
698
714
  name: string
699
- }): AppendInput<D> => {
715
+ }): Promise<AppendInput<D>> => {
700
716
  const payload = ctx.event.payload as
701
717
  GenerationRequestedPayload | { turnId: string; version: number }
702
718
  const { turnId, version } = 'turnId' in payload ? payload : payload.control!
@@ -716,7 +732,12 @@ export const createControlRuntime = <
716
732
  }
717
733
  return {
718
734
  type: 'ai.work.reported',
719
- id: `${ctx.event.id}:report:${ctx.attempt}:${name}`,
735
+ id: await aiEventId(
736
+ 'a2.ai.report',
737
+ ctx.event.id,
738
+ String(ctx.attempt),
739
+ name,
740
+ ),
720
741
  payload: {
721
742
  workId: ctx.event.id,
722
743
  attempt: ctx.attempt,
@@ -724,19 +745,27 @@ export const createControlRuntime = <
724
745
  version,
725
746
  kind: ctx.event.type === 'ai.generation.requested' ? 'model' : 'tool',
726
747
  settled,
727
- events: events
728
- .filter(
729
- (event) =>
730
- event.type !== 'ai.message.completed' &&
731
- event.type !== 'ai.message.interrupted',
732
- )
733
- .map((event, index) => ({
734
- type: event.type,
735
- payload: event.payload,
736
- id:
737
- event.id ??
738
- `${ctx.event.id}:fact:${ctx.attempt}:${name}:${index}`,
739
- })),
748
+ events: await Promise.all(
749
+ events
750
+ .filter(
751
+ (event) =>
752
+ event.type !== 'ai.message.completed' &&
753
+ event.type !== 'ai.message.interrupted',
754
+ )
755
+ .map(async (event, index) => ({
756
+ type: event.type,
757
+ payload: event.payload,
758
+ id:
759
+ event.id ??
760
+ (await aiEventId(
761
+ 'a2.ai.work.fact',
762
+ ctx.event.id,
763
+ String(ctx.attempt),
764
+ name,
765
+ String(index),
766
+ )),
767
+ })),
768
+ ),
740
769
  },
741
770
  } as AppendInput<D>
742
771
  }
@@ -757,7 +786,7 @@ export const createControlRuntime = <
757
786
  (event) => event.type !== 'ai.generation.progress',
758
787
  )
759
788
  if (lifecycle.length === 0) return ctx.session.append(name, ...progress)
760
- const input = report({ ctx, events: lifecycle, settled: false, name })
789
+ const input = await report({ ctx, events: lifecycle, settled: false, name })
761
790
  const persisted = await ctx.session.append(name, ...progress, input)
762
791
  const index = persisted.at(-1)!.index
763
792
  const feed = ctx.session.stream({ startAfter: index })
@@ -803,7 +832,7 @@ export const createControlRuntime = <
803
832
  }: {
804
833
  ctx: HandlerContext<D>
805
834
  events: void | AppendInput<D> | readonly AppendInput<D>[]
806
- }): AppendInput<D> =>
835
+ }): Promise<AppendInput<D>> =>
807
836
  report({
808
837
  ctx,
809
838
  events:
@@ -58,6 +58,10 @@ export const controlChanges = ({
58
58
  !Array.isArray(left) &&
59
59
  !Array.isArray(right)
60
60
  ) {
61
+ if (Object.keys(right).length === 0) {
62
+ changes.push({ path, value: right })
63
+ return
64
+ }
61
65
  for (const key of Object.keys(left))
62
66
  if (!Object.hasOwn(right, key))
63
67
  changes.push({ path: [...path, key], remove: true })
@@ -146,6 +150,8 @@ export const controlCommit = <M extends UIMessage>(options: {
146
150
  paused: options.after.inbox.paused,
147
151
  closed: options.after.coordinator.closed,
148
152
  requestId: response?.activeRequestId ?? null,
153
+ requestReason: response?.requestReason ?? null,
154
+ sourceGenerationId: response?.sourceGenerationId ?? null,
149
155
  responseMessageId: response?.responseMessageId ?? null,
150
156
  },
151
157
  }
package/src/ai-control.ts CHANGED
@@ -3,6 +3,7 @@ import type { AICoordinatorState } from './ai-coordinator.ts'
3
3
  import type {
4
4
  ApprovalRespondedPayload,
5
5
  GenerationStartedPayload,
6
+ GenerationReason,
6
7
  InputRequestedPayload,
7
8
  InputRespondedPayload,
8
9
  MessageInterruptedPayload,
@@ -13,7 +14,6 @@ import type {
13
14
 
14
15
  export type InboxItem<M extends UIMessage = UIMessage> = {
15
16
  id: string
16
- revision: number
17
17
  message: M
18
18
  generate: boolean
19
19
  afterStepOf?: string
@@ -21,7 +21,7 @@ export type InboxItem<M extends UIMessage = UIMessage> = {
21
21
 
22
22
  export type ControlCommand<M extends UIMessage = UIMessage> =
23
23
  | { action: 'send'; message: M; generate?: boolean }
24
- | { action: 'edit'; message: M; expectedRevision: number }
24
+ | { action: 'edit'; message: M }
25
25
  | { action: 'move'; inputId: string; beforeId: string | null }
26
26
  | { action: 'remove'; inputId: string }
27
27
  | {
@@ -46,11 +46,10 @@ export type ControlReceipt = {
46
46
  work?: true
47
47
  outcome: 'applied' | 'rejected'
48
48
  reason?:
49
- | 'already-active'
50
- | 'revision-conflict'
49
+ | 'not-pending'
50
+ | 'role-mismatch'
51
51
  | 'stale-turn'
52
52
  | 'not-found'
53
- | 'duplicate-input'
54
53
  | 'closed'
55
54
  | 'stale-work'
56
55
  | 'not-user-input'
@@ -78,7 +77,6 @@ export type ControlWork = {
78
77
  }
79
78
 
80
79
  export type AIControlState<M extends UIMessage = UIMessage> = {
81
- reservedIds: Record<string, true | 'removed'>
82
80
  inbox: { paused: boolean; items: InboxItem<M>[] }
83
81
  active:
84
82
  | (ActiveTurn<M> & {
@@ -111,7 +109,6 @@ export type ToolExecutionRequest = {
111
109
  export const initialControlState = <
112
110
  M extends UIMessage,
113
111
  >(): AIControlState<M> => ({
114
- reservedIds: {},
115
112
  inbox: { paused: false, items: [] },
116
113
  active: null,
117
114
  coordinator: { closed: false, queued: [] },
@@ -134,6 +131,8 @@ export type ControlCommit = {
134
131
  paused: boolean
135
132
  closed: boolean
136
133
  requestId: string | null
134
+ requestReason: GenerationReason | null
135
+ sourceGenerationId: string | null
137
136
  responseMessageId: string | null
138
137
  }
139
138
  }