experimental-a2 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +81 -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-Cai-lCbj.d.ts → ai-CrEf6p_W.d.ts} +73 -14
  9. package/dist/ai-CrEf6p_W.d.ts.map +1 -0
  10. package/dist/ai-DgOBltJ_.js +2451 -0
  11. package/dist/ai-DgOBltJ_.js.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 +788 -296
  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 +149 -48
  59. package/docs/reference/01-api.mdx +110 -28
  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 +36 -4
  74. package/src/ai-context-schema.ts +296 -0
  75. package/src/ai-context.ts +680 -0
  76. package/src/ai-control-server.ts +183 -135
  77. package/src/ai-control-state.ts +6 -0
  78. package/src/ai-control.ts +8 -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 +474 -0
  83. package/src/ai-server.ts +247 -336
  84. package/src/ai-stored-state.ts +484 -0
  85. package/src/ai.ts +255 -538
  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,17 +73,36 @@ 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.v5',
76
77
  initialState: initialControlState<M>(),
77
78
  })
78
- .fold((state, event) =>
79
- event.type === 'ai.control.committed'
79
+ .fold((state, event) => {
80
+ if (event.type === 'ai.control.requested') {
81
+ const command = event.payload as ControlCommand<M>
82
+ if (command.action === 'stop')
83
+ return {
84
+ ...state,
85
+ pendingStops: {
86
+ ...state.pendingStops,
87
+ [event.id]: command.turnId,
88
+ },
89
+ }
90
+ }
91
+ if (event.type === 'ai.control.decided') {
92
+ const receipt = event.payload as ControlReceipt
93
+ if (Object.hasOwn(state.pendingStops, receipt.commandId)) {
94
+ const pendingStops = { ...state.pendingStops }
95
+ delete pendingStops[receipt.commandId]
96
+ return { ...state, pendingStops }
97
+ }
98
+ }
99
+ return event.type === 'ai.control.committed'
80
100
  ? applyControlChanges({
81
101
  state,
82
102
  changes: (event.payload as ControlCommit).changes,
83
103
  })
84
- : state,
85
- )
104
+ : state
105
+ })
86
106
  const coordinator = aiCoordinatorReducer(options.agent.contract)
87
107
 
88
108
  const handler = async (
@@ -97,16 +117,22 @@ export const createControlRuntime = <
97
117
  const state = structuredClone(snapshot.state)
98
118
  const facts: WorkFact[] = []
99
119
  const jobs: AppendInput<D>[] = []
100
- const emit = ({
120
+ const emit = async ({
101
121
  type,
102
122
  payload,
103
- id = `${ctx.event.id}:fact:${facts.length}`,
123
+ id,
104
124
  }: {
105
125
  type: string
106
126
  payload: unknown
107
127
  id?: string
108
- }): void => {
109
- const fact = { type, payload, id }
128
+ }): Promise<void> => {
129
+ const fact = {
130
+ type,
131
+ payload,
132
+ id:
133
+ id ??
134
+ (await aiEventId('a2.ai.fact', ctx.event.id, String(facts.length))),
135
+ }
110
136
  facts.push(fact)
111
137
  state.coordinator = foldCoordinator<M, D>(state.coordinator, {
112
138
  ...ctx.event,
@@ -114,20 +140,20 @@ export const createControlRuntime = <
114
140
  } as ContractEvent<D>)
115
141
  }
116
142
  let reason: ControlReceipt['reason']
117
- const end = ({
143
+ const end = async ({
118
144
  lastSeenIndex,
119
145
  reason: interruptionReason,
120
146
  }: {
121
147
  lastSeenIndex: number | undefined
122
148
  reason: string | undefined
123
- }): void => {
149
+ }): Promise<void> => {
124
150
  const response = state.coordinator.response
125
151
  if (response === undefined) return
126
152
  const owner =
127
153
  response.generation === undefined
128
154
  ? { requestId: response.activeRequestId! }
129
155
  : { generationId: response.generation.generationId }
130
- emit({
156
+ await emit({
131
157
  type: 'ai.message.interrupted',
132
158
  payload: {
133
159
  messageId: response.responseMessageId,
@@ -140,24 +166,6 @@ export const createControlRuntime = <
140
166
  })
141
167
  state.active = null
142
168
  }
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
169
  const insert = ({
162
170
  message,
163
171
  generate = true,
@@ -171,18 +179,10 @@ export const createControlRuntime = <
171
179
  }): void => {
172
180
  const candidate = {
173
181
  id: message.id,
174
- revision: 0,
175
182
  message,
176
183
  generate: generate && message.role === 'user',
177
184
  ...(afterStepOf === undefined ? {} : { afterStepOf }),
178
185
  }
179
- state.reservedIds = {
180
- ...state.reservedIds,
181
- [candidate.id]: true,
182
- ...(candidate.generate
183
- ? { [`${candidate.id}:assistant`]: true as const }
184
- : {}),
185
- }
186
186
  if (first) state.inbox.items.unshift(candidate)
187
187
  else state.inbox.items.push(candidate)
188
188
  }
@@ -230,61 +230,41 @@ export const createControlRuntime = <
230
230
  else
231
231
  switch (command.action) {
232
232
  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
- })
233
+ insert({
234
+ message: command.message,
235
+ generate: command.generate !== false,
236
+ })
245
237
  break
246
238
  case 'edit': {
247
239
  const item = state.inbox.items.find(
248
240
  (candidate) => candidate.id === command.message.id,
249
241
  )
250
- if (item === undefined)
251
- reason = missingInputReason(command.message.id)
252
- else if (item.revision !== command.expectedRevision)
253
- reason = 'revision-conflict'
242
+ if (item === undefined) reason = 'not-pending'
254
243
  else if (item.message.role !== command.message.role)
255
- reason = 'revision-conflict'
256
- else {
257
- item.message = command.message
258
- item.revision += 1
259
- }
244
+ reason = 'role-mismatch'
245
+ else item.message = command.message
260
246
  break
261
247
  }
262
248
  case 'remove': {
263
249
  const position = state.inbox.items.findIndex(
264
250
  (item) => item.id === command.inputId,
265
251
  )
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
- }
252
+ if (position < 0) reason = 'not-pending'
253
+ else state.inbox.items.splice(position, 1)
274
254
  break
275
255
  }
276
256
  case 'move': {
277
257
  const index = state.inbox.items.findIndex(
278
258
  (candidate) => candidate.id === command.inputId,
279
259
  )
280
- if (index < 0) reason = missingInputReason(command.inputId)
260
+ if (index < 0) reason = 'not-pending'
281
261
  else if (
282
262
  command.beforeId !== null &&
283
263
  !state.inbox.items.some(
284
264
  (candidate) => candidate.id === command.beforeId,
285
265
  )
286
266
  )
287
- reason = 'not-found'
267
+ reason = 'not-pending'
288
268
  else {
289
269
  const destination =
290
270
  command.beforeId === null
@@ -304,23 +284,12 @@ export const createControlRuntime = <
304
284
  (item) => item.id === command.inputId,
305
285
  )
306
286
  const selected = state.inbox.items[position]
307
- if (!selected) reason = missingInputReason(command.inputId)
287
+ if (!selected) reason = 'not-pending'
308
288
  else if ((state.active?.turnId ?? null) !== command.turnId)
309
289
  reason = 'stale-turn'
310
290
  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
291
  else {
317
- if (!selected.generate) {
318
- state.reservedIds = {
319
- ...state.reservedIds,
320
- [`${selected.id}:assistant`]: true,
321
- }
322
- selected.generate = true
323
- }
292
+ selected.generate = true
324
293
  if (state.active) selected.afterStepOf = state.active.turnId
325
294
  else delete selected.afterStepOf
326
295
  moveInput({ from: position, to: 0 })
@@ -330,19 +299,18 @@ export const createControlRuntime = <
330
299
  case 'stop':
331
300
  if (state.active?.turnId !== command.turnId) reason = 'stale-turn'
332
301
  else
333
- end({ lastSeenIndex: command.lastSeenIndex, reason: undefined })
302
+ await end({
303
+ lastSeenIndex: command.lastSeenIndex,
304
+ reason: undefined,
305
+ })
334
306
  break
335
307
  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
- }
308
+ insert({
309
+ message: command.message,
310
+ generate: true,
311
+ first: true,
312
+ afterStepOf: command.turnId,
313
+ })
346
314
  break
347
315
  case 'interrupt': {
348
316
  const response = state.coordinator.response
@@ -355,7 +323,7 @@ export const createControlRuntime = <
355
323
  )
356
324
  reason = 'stale-turn'
357
325
  else
358
- end({
326
+ await end({
359
327
  lastSeenIndex: command.request.lastSeenIndex,
360
328
  reason: command.request.reason,
361
329
  })
@@ -373,7 +341,7 @@ export const createControlRuntime = <
373
341
  active.version += 1
374
342
  const generation = state.coordinator.response?.generation
375
343
  if (generation)
376
- emit({
344
+ await emit({
377
345
  type: 'ai.generation.paused',
378
346
  payload: {
379
347
  generationId: generation.generationId,
@@ -389,6 +357,7 @@ export const createControlRuntime = <
389
357
  case 'tool-result': {
390
358
  const call = state.coordinator.response?.calls.find(
391
359
  (candidate) =>
360
+ candidate.call.requestId === command.result.requestId &&
392
361
  candidate.call.generationId === command.result.generationId &&
393
362
  candidate.call.messageId === command.result.messageId &&
394
363
  candidate.call.toolCallId === command.result.toolCallId &&
@@ -396,15 +365,15 @@ export const createControlRuntime = <
396
365
  !candidate.terminal,
397
366
  )
398
367
  if (!call) reason = 'not-found'
399
- else emit({ type: 'ai.tool.result', payload: command.result })
368
+ else await emit({ type: 'ai.tool.result', payload: command.result })
400
369
  break
401
370
  }
402
371
  case 'approval': {
403
372
  const response = state.coordinator.response
404
373
  const call = response?.calls.find(
405
374
  (candidate) =>
406
- candidate.approval?.approvalId ===
407
- command.response.approvalId &&
375
+ candidate.approval?.requestId === command.response.requestId &&
376
+ candidate.approval.approvalId === command.response.approvalId &&
408
377
  candidate.approval.generationId ===
409
378
  command.response.generationId &&
410
379
  candidate.approval.messageId === command.response.messageId,
@@ -413,7 +382,10 @@ export const createControlRuntime = <
413
382
  else if (!call || call.response || call.terminal)
414
383
  reason = 'not-found'
415
384
  else
416
- emit({ type: 'ai.approval.responded', payload: command.response })
385
+ await emit({
386
+ type: 'ai.approval.responded',
387
+ payload: command.response,
388
+ })
417
389
  break
418
390
  }
419
391
  case 'request-input':
@@ -423,7 +395,8 @@ export const createControlRuntime = <
423
395
  const response = state.coordinator.response
424
396
  if (
425
397
  response?.failure !== undefined ||
426
- response?.generation?.generationId !== payload.generationId ||
398
+ response?.generation?.requestId !== payload.requestId ||
399
+ response.generation.generationId !== payload.generationId ||
427
400
  response.responseMessageId !== payload.messageId ||
428
401
  (command.action === 'input' &&
429
402
  !response.inputs.some(
@@ -434,7 +407,7 @@ export const createControlRuntime = <
434
407
  )
435
408
  reason = 'not-found'
436
409
  else
437
- emit({
410
+ await emit({
438
411
  type:
439
412
  command.action === 'input'
440
413
  ? 'ai.input.responded'
@@ -453,14 +426,22 @@ export const createControlRuntime = <
453
426
  )
454
427
  reason = 'stale-turn'
455
428
  else if (!state.inbox.paused && !state.active.suspended) {
456
- emit({ type: 'ai.retry.requested', payload: command.request })
429
+ await emit({
430
+ type: 'ai.retry.requested',
431
+ payload: command.request,
432
+ })
457
433
  requestModel({
458
434
  request: {
459
435
  messageId: response.rootMessageId,
460
436
  responseMessageId: response.responseMessageId,
461
437
  reason: 'retry',
462
438
  },
463
- id: `ai.generate:retry:${command.request.retryId}`,
439
+ id: await aiEventId(
440
+ 'a2.ai.request.retry',
441
+ options.agent.contract.name,
442
+ ctx.event.sessionId,
443
+ command.request.retryId,
444
+ ),
464
445
  })
465
446
  } else reason = 'stale-turn'
466
447
  break
@@ -468,6 +449,15 @@ export const createControlRuntime = <
468
449
  }
469
450
  } else {
470
451
  const report = ctx.event.payload as WorkReport
452
+ if (
453
+ report.kind === 'tool' &&
454
+ report.settled &&
455
+ state.coordinator.unsettledTools
456
+ ) {
457
+ delete state.coordinator.unsettledTools[report.workId]
458
+ if (Object.keys(state.coordinator.unsettledTools).length === 0)
459
+ delete state.coordinator.unsettledTools
460
+ }
471
461
  const active = state.active
472
462
  const response = state.coordinator.response
473
463
  const call = response?.calls.find(
@@ -492,11 +482,18 @@ export const createControlRuntime = <
492
482
  authorized ||
493
483
  (report.kind === 'tool' && event.type === 'ai.tool.result')
494
484
  ) {
495
- emit({ type: event.type, payload: event.payload, id: event.id })
485
+ await emit({
486
+ type: event.type,
487
+ payload: event.payload,
488
+ id: event.id,
489
+ })
496
490
  work.facts = [...(work.facts ?? []), event.id]
497
491
  }
498
492
  }
499
- if (report.settled) work.settled = true
493
+ if (report.settled) {
494
+ work.settled = true
495
+ delete work.facts
496
+ }
500
497
  if (!authorized) reason = 'stale-work'
501
498
  }
502
499
  }
@@ -534,7 +531,7 @@ export const createControlRuntime = <
534
531
  )
535
532
  continue
536
533
  if (call.response?.approved === false) {
537
- emit({
534
+ await emit({
538
535
  type: 'ai.tool.result',
539
536
  payload: {
540
537
  ...call.call,
@@ -544,8 +541,17 @@ export const createControlRuntime = <
544
541
  })
545
542
  continue
546
543
  }
547
- const id = `${ctx.event.id}:tool:${call.call.toolCallId}`
544
+ const id = await aiEventId(
545
+ 'a2.ai.tool.execution',
546
+ ctx.event.id,
547
+ call.call.generationId,
548
+ call.call.toolCallId,
549
+ )
548
550
  call.work = { id, version: active.version, settled: false }
551
+ state.coordinator.unsettledTools = {
552
+ ...state.coordinator.unsettledTools,
553
+ [id]: true,
554
+ }
549
555
  jobs.push({
550
556
  type: 'ai.tool.execution.requested',
551
557
  id,
@@ -593,7 +599,7 @@ export const createControlRuntime = <
593
599
  response.completion !== undefined &&
594
600
  response.completion.finishReason !== 'tool-calls'
595
601
  if (handoffReady && !naturalCompletion) {
596
- end({ lastSeenIndex: undefined, reason: 'steered after step' })
602
+ await end({ lastSeenIndex: undefined, reason: 'steered after step' })
597
603
  } else if (readyForModel && (!handoff || handoffReady)) {
598
604
  const resume =
599
605
  active.model !== null && active.model.version !== active.version
@@ -605,20 +611,29 @@ export const createControlRuntime = <
605
611
  (response.completion && input))
606
612
  ) {
607
613
  const nextReason = resume ? 'resume' : input ? 'input' : 'tool'
614
+ const sourceGenerationId = (
615
+ response.generation ?? response.source?.generation
616
+ )?.generationId
608
617
  const id =
609
618
  nextReason === 'tool'
610
- ? `ai.generate:tools:${response.generation!.generationId}`
611
- : `${ctx.event.id}:generate:${nextReason}`
619
+ ? await aiEventId(
620
+ 'a2.ai.request.tool',
621
+ response.generation!.generationId,
622
+ )
623
+ : await aiEventId('a2.ai.request', ctx.event.id, nextReason)
612
624
  requestModel({
613
625
  request: {
614
626
  messageId: response.responseMessageId,
615
627
  responseMessageId: response.responseMessageId,
616
628
  reason: nextReason,
629
+ ...(sourceGenerationId === undefined
630
+ ? {}
631
+ : { sourceGenerationId }),
617
632
  },
618
633
  id,
619
634
  })
620
635
  } else if (naturalCompletion) {
621
- emit({
636
+ await emit({
622
637
  type: 'ai.message.completed',
623
638
  payload: {
624
639
  messageId: response.responseMessageId,
@@ -648,10 +663,15 @@ export const createControlRuntime = <
648
663
  while (state.inbox.items.length > 0) {
649
664
  const item = state.inbox.items.shift()!
650
665
  delete item.afterStepOf
651
- emit({
666
+ await emit({
652
667
  type: 'ai.message.created',
653
668
  payload: { message: item.message, generate: item.generate },
654
- id: `ai.admitted:${item.id}`,
669
+ id: await aiEventId(
670
+ 'a2.ai.admitted',
671
+ options.agent.contract.name,
672
+ ctx.event.sessionId,
673
+ item.id,
674
+ ),
655
675
  })
656
676
  if (!item.generate) continue
657
677
  state.active = {
@@ -662,9 +682,19 @@ export const createControlRuntime = <
662
682
  suspended: false,
663
683
  model: null,
664
684
  }
685
+ const requestId = await aiEventId(
686
+ 'a2.ai.request.message',
687
+ options.agent.contract.name,
688
+ ctx.event.sessionId,
689
+ item.id,
690
+ )
665
691
  requestModel({
666
- request: { messageId: item.id, reason: 'message' },
667
- id: `ai.generate:message:${item.id}`,
692
+ request: {
693
+ messageId: item.id,
694
+ responseMessageId: requestId,
695
+ reason: 'message',
696
+ },
697
+ id: requestId,
668
698
  })
669
699
  break
670
700
  }
@@ -679,14 +709,19 @@ export const createControlRuntime = <
679
709
  ...(facts as AppendInput<D>[]),
680
710
  {
681
711
  type: 'ai.control.committed',
712
+ id: await aiEventId('a2.ai.control.committed', ctx.event.id),
682
713
  payload: controlCommit({ before, after: state, moves }),
683
714
  } as AppendInput<D>,
684
715
  ...jobs,
685
- { type: 'ai.control.decided', payload: receipt } as AppendInput<D>,
716
+ {
717
+ type: 'ai.control.decided',
718
+ id: await aiEventId('a2.ai.control.decided', ctx.event.id),
719
+ payload: receipt,
720
+ } as AppendInput<D>,
686
721
  ]
687
722
  }
688
723
 
689
- const report = ({
724
+ const report = async ({
690
725
  ctx,
691
726
  events,
692
727
  settled,
@@ -696,7 +731,7 @@ export const createControlRuntime = <
696
731
  events: readonly AppendInput<D>[]
697
732
  settled: boolean
698
733
  name: string
699
- }): AppendInput<D> => {
734
+ }): Promise<AppendInput<D>> => {
700
735
  const payload = ctx.event.payload as
701
736
  GenerationRequestedPayload | { turnId: string; version: number }
702
737
  const { turnId, version } = 'turnId' in payload ? payload : payload.control!
@@ -716,7 +751,12 @@ export const createControlRuntime = <
716
751
  }
717
752
  return {
718
753
  type: 'ai.work.reported',
719
- id: `${ctx.event.id}:report:${ctx.attempt}:${name}`,
754
+ id: await aiEventId(
755
+ 'a2.ai.report',
756
+ ctx.event.id,
757
+ String(ctx.attempt),
758
+ name,
759
+ ),
720
760
  payload: {
721
761
  workId: ctx.event.id,
722
762
  attempt: ctx.attempt,
@@ -724,19 +764,27 @@ export const createControlRuntime = <
724
764
  version,
725
765
  kind: ctx.event.type === 'ai.generation.requested' ? 'model' : 'tool',
726
766
  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
- })),
767
+ events: await Promise.all(
768
+ events
769
+ .filter(
770
+ (event) =>
771
+ event.type !== 'ai.message.completed' &&
772
+ event.type !== 'ai.message.interrupted',
773
+ )
774
+ .map(async (event, index) => ({
775
+ type: event.type,
776
+ payload: event.payload,
777
+ id:
778
+ event.id ??
779
+ (await aiEventId(
780
+ 'a2.ai.work.fact',
781
+ ctx.event.id,
782
+ String(ctx.attempt),
783
+ name,
784
+ String(index),
785
+ )),
786
+ })),
787
+ ),
740
788
  },
741
789
  } as AppendInput<D>
742
790
  }
@@ -757,7 +805,7 @@ export const createControlRuntime = <
757
805
  (event) => event.type !== 'ai.generation.progress',
758
806
  )
759
807
  if (lifecycle.length === 0) return ctx.session.append(name, ...progress)
760
- const input = report({ ctx, events: lifecycle, settled: false, name })
808
+ const input = await report({ ctx, events: lifecycle, settled: false, name })
761
809
  const persisted = await ctx.session.append(name, ...progress, input)
762
810
  const index = persisted.at(-1)!.index
763
811
  const feed = ctx.session.stream({ startAfter: index })
@@ -803,7 +851,7 @@ export const createControlRuntime = <
803
851
  }: {
804
852
  ctx: HandlerContext<D>
805
853
  events: void | AppendInput<D> | readonly AppendInput<D>[]
806
- }): AppendInput<D> =>
854
+ }): Promise<AppendInput<D>> =>
807
855
  report({
808
856
  ctx,
809
857
  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
  }