experimental-a2 0.13.0 → 0.14.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 (99) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/{actor-DJi3RsNu.d.ts → actor-BfQSE0KC.d.ts} +4 -4
  3. package/dist/{actor-DJi3RsNu.d.ts.map → actor-BfQSE0KC.d.ts.map} +1 -1
  4. package/dist/actor-client.d.ts +1 -1
  5. package/dist/actor-client.js +1 -1
  6. package/dist/actor-react.d.ts +3 -3
  7. package/dist/actor-react.js +2 -2
  8. package/dist/{actor-shared-DI7J5upy.js → actor-shared-B5tJfzt-.js} +2 -2
  9. package/dist/{actor-shared-DI7J5upy.js.map → actor-shared-B5tJfzt-.js.map} +1 -1
  10. package/dist/actor.d.ts +1 -1
  11. package/dist/actor.js +3 -3
  12. package/dist/ai-Cai-lCbj.d.ts +580 -0
  13. package/dist/ai-Cai-lCbj.d.ts.map +1 -0
  14. package/dist/ai-control-CcD4hh3y.js +119 -0
  15. package/dist/ai-control-CcD4hh3y.js.map +1 -0
  16. package/dist/ai-server.d.ts +6 -6
  17. package/dist/ai-server.d.ts.map +1 -1
  18. package/dist/ai-server.js +1053 -513
  19. package/dist/ai-server.js.map +1 -1
  20. package/dist/ai.d.ts +2 -365
  21. package/dist/ai.js +801 -80
  22. package/dist/ai.js.map +1 -1
  23. package/dist/{client-P_NNNRM-.d.ts → client-BAEABRZB.d.ts} +2 -2
  24. package/dist/{client-P_NNNRM-.d.ts.map → client-BAEABRZB.d.ts.map} +1 -1
  25. package/dist/{client-Bf6uSEAk.js → client-BYzHjkwU.js} +21 -6
  26. package/dist/client-BYzHjkwU.js.map +1 -0
  27. package/dist/client.d.ts +1 -1
  28. package/dist/client.js +1 -1
  29. package/dist/{contract-48bUMgcL.js → contract-CKRg_E4q.js} +3 -26
  30. package/dist/contract-CKRg_E4q.js.map +1 -0
  31. package/dist/index.d.ts +2 -2
  32. package/dist/index.js +1 -1
  33. package/dist/react.d.ts +2 -2
  34. package/dist/react.js +1 -1
  35. package/dist/{reducer-DJKWm3cp.d.ts → reducer-BcS9VDKC.d.ts} +4 -1
  36. package/dist/{reducer-DJKWm3cp.d.ts.map → reducer-BcS9VDKC.d.ts.map} +1 -1
  37. package/dist/reducer-DEMjEY_O.js +29 -0
  38. package/dist/reducer-DEMjEY_O.js.map +1 -0
  39. package/dist/scheduler-qstash.d.ts +2 -2
  40. package/dist/scheduler-qstash.js +1 -1
  41. package/dist/scheduler-vercel.d.ts +2 -2
  42. package/dist/scheduler-vercel.js +1 -1
  43. package/dist/{server-DjZZa1wr.d.ts → server-Bp5Nd1pF.d.ts} +3 -3
  44. package/dist/{server-DjZZa1wr.d.ts.map → server-Bp5Nd1pF.d.ts.map} +1 -1
  45. package/dist/{server-BeNADlCI.js → server-CjJSGcF7.js} +4 -3
  46. package/dist/server-CjJSGcF7.js.map +1 -0
  47. package/dist/server.d.ts +3 -3
  48. package/dist/server.js +1 -1
  49. package/dist/{store-DtDOWLSn.d.ts → store-D_yhNdPz.d.ts} +7 -2
  50. package/dist/{store-DtDOWLSn.d.ts.map → store-D_yhNdPz.d.ts.map} +1 -1
  51. package/dist/store-N8PXxDAS.js.map +1 -1
  52. package/dist/store-memory.d.ts +1 -1
  53. package/dist/store-postgres.d.ts +1 -1
  54. package/dist/store-postgres.js +19 -0
  55. package/dist/store-postgres.js.map +1 -1
  56. package/dist/store-redis-http.d.ts +1 -1
  57. package/dist/store-redis-http.js +1 -1
  58. package/dist/{store-redis-notify-BUCyXOn0.js → store-redis-notify-D2EI6gwX.js} +27 -2
  59. package/dist/store-redis-notify-D2EI6gwX.js.map +1 -0
  60. package/dist/store-redis.d.ts +1 -1
  61. package/dist/store-redis.js +1 -1
  62. package/dist/store-sqlite.d.ts +1 -1
  63. package/docs/guides/06-ai-agents.mdx +287 -65
  64. package/docs/reference/01-api.mdx +122 -27
  65. package/examples/playground/app/agent/[agentId]/agent-client.tsx +118 -21
  66. package/examples/playground/app/agent/[agentId]/agent-queue.tsx +289 -0
  67. package/examples/playground/app/agent/compaction-settings.test.ts +22 -6
  68. package/examples/playground/app/agent/model.ts +11 -2
  69. package/examples/playground/app/agent/server.ts +8 -2
  70. package/examples/playground/app/chat/[chatId]/chat-client.tsx +3 -13
  71. package/examples/playground/app/chat/model.ts +2 -2
  72. package/examples/playground/app/chat/server.ts +24 -17
  73. package/examples/playground/app/globals.css +179 -0
  74. package/examples/playground/package.json +1 -1
  75. package/package.json +1 -1
  76. package/src/ai-client-state.ts +185 -0
  77. package/src/ai-control-server.ts +829 -0
  78. package/src/ai-control-state.ts +152 -0
  79. package/src/ai-control.ts +139 -0
  80. package/src/ai-coordinator.ts +99 -32
  81. package/src/ai-progress-batches.ts +68 -0
  82. package/src/ai-projector.ts +76 -15
  83. package/src/ai-sdk-step.ts +0 -1
  84. package/src/ai-server.ts +429 -619
  85. package/src/ai.ts +553 -108
  86. package/src/client.ts +31 -9
  87. package/src/licenses/Apache-2.0.txt +55 -0
  88. package/src/parse-partial-json.ts +441 -0
  89. package/src/reducer.ts +6 -0
  90. package/src/server.ts +8 -4
  91. package/src/store-postgres.ts +27 -0
  92. package/src/store-redis-core.ts +53 -1
  93. package/src/store-redis-notify.ts +1 -0
  94. package/src/store.ts +6 -0
  95. package/dist/ai.d.ts.map +0 -1
  96. package/dist/client-Bf6uSEAk.js.map +0 -1
  97. package/dist/contract-48bUMgcL.js.map +0 -1
  98. package/dist/server-BeNADlCI.js.map +0 -1
  99. package/dist/store-redis-notify-BUCyXOn0.js.map +0 -1
@@ -0,0 +1,829 @@
1
+ // oxlint-disable no-await-in-loop -- work acknowledgements follow the durable stream.
2
+ import { applyControlChanges, controlCommit } from './ai-control-state.ts'
3
+ import type { ControlChange, ControlCommit } from './ai-control.ts'
4
+ import type { UIMessage } from 'ai'
5
+ import type {
6
+ AIEventDefs,
7
+ AgentDefinition,
8
+ GenerationRequestedPayload,
9
+ MessageInterruptedPayload,
10
+ } from './ai.ts'
11
+ import {
12
+ aiCoordinatorReducer,
13
+ continuationReady,
14
+ foldCoordinator,
15
+ } from './ai-coordinator.ts'
16
+ import {
17
+ initialControlState,
18
+ type AIControlState,
19
+ type ControlCommand,
20
+ type ControlReceipt,
21
+ type WorkFact,
22
+ type WorkReport,
23
+ } from './ai-control.ts'
24
+ import type { AppendInput, ContractEvent, EventDefs } from './contract.ts'
25
+ import type { HandlerContext } from './server.ts'
26
+ import type { Reducer } from './reducer.ts'
27
+ import type { AICoordinatorState, CoordinatorCall } from './ai-coordinator.ts'
28
+ import { validateSync } from './validate.ts'
29
+ import type { StandardSchemaV1 } from './standard-schema.ts'
30
+ import { A2Error } from './errors.ts'
31
+
32
+ export class ControlCancelled extends Error {}
33
+
34
+ const callReadyForModel = (call: CoordinatorCall): boolean =>
35
+ call.terminal ||
36
+ (call.call.providerExecuted === true &&
37
+ !call.call.supportsDeferredResults &&
38
+ call.response !== undefined)
39
+
40
+ const approvalBlocksExecution = (call: CoordinatorCall): boolean =>
41
+ call.approval !== undefined && call.response?.approved !== true
42
+
43
+ export type AIControlRuntime<
44
+ M extends UIMessage,
45
+ D extends AIEventDefs<M> & EventDefs,
46
+ > = {
47
+ handler(
48
+ ctx: HandlerContext<
49
+ D,
50
+ 'ai.control.requested' | 'ai.work.reported' | 'ai.session.closed'
51
+ >,
52
+ ): Promise<AppendInput<D>[]>
53
+ append(options: {
54
+ ctx: HandlerContext<D>
55
+ name: string
56
+ events: AppendInput<D>[]
57
+ }): Promise<ContractEvent<D>[]>
58
+ settled(options: {
59
+ ctx: HandlerContext<D>
60
+ events: void | AppendInput<D> | readonly AppendInput<D>[]
61
+ }): AppendInput<D>
62
+ cancelled(options: { error: unknown; signal: AbortSignal }): boolean
63
+ reducer: Reducer<D, AIControlState<M>>
64
+ coordinator: Reducer<D, AICoordinatorState>
65
+ }
66
+
67
+ export const createControlRuntime = <
68
+ M extends UIMessage,
69
+ D extends AIEventDefs<M> & EventDefs,
70
+ >(options: {
71
+ agent: AgentDefinition<M, D>
72
+ }): AIControlRuntime<M, D> => {
73
+ const reducer = options.agent.contract
74
+ .reducer({
75
+ name: 'a2.ai.control.v1',
76
+ initialState: initialControlState<M>(),
77
+ })
78
+ .fold((state, event) =>
79
+ event.type === 'ai.control.committed'
80
+ ? applyControlChanges({
81
+ state,
82
+ changes: (event.payload as ControlCommit).changes,
83
+ })
84
+ : state,
85
+ )
86
+ const coordinator = aiCoordinatorReducer(options.agent.contract)
87
+
88
+ const handler = async (
89
+ ctx: HandlerContext<
90
+ D,
91
+ 'ai.control.requested' | 'ai.work.reported' | 'ai.session.closed'
92
+ >,
93
+ ): Promise<AppendInput<D>[]> => {
94
+ const snapshot = await ctx.session.state(reducer, { through: 'latest' })
95
+ const before = structuredClone(snapshot.state)
96
+ const moves: ControlChange[] = []
97
+ const state = structuredClone(snapshot.state)
98
+ const facts: WorkFact[] = []
99
+ const jobs: AppendInput<D>[] = []
100
+ const emit = ({
101
+ type,
102
+ payload,
103
+ id = `${ctx.event.id}:fact:${facts.length}`,
104
+ }: {
105
+ type: string
106
+ payload: unknown
107
+ id?: string
108
+ }): void => {
109
+ const fact = { type, payload, id }
110
+ facts.push(fact)
111
+ state.coordinator = foldCoordinator<M, D>(state.coordinator, {
112
+ ...ctx.event,
113
+ ...fact,
114
+ } as ContractEvent<D>)
115
+ }
116
+ let reason: ControlReceipt['reason']
117
+ const end = ({
118
+ lastSeenIndex,
119
+ reason: interruptionReason,
120
+ }: {
121
+ lastSeenIndex: number | undefined
122
+ reason: string | undefined
123
+ }): void => {
124
+ const response = state.coordinator.response
125
+ if (response === undefined) return
126
+ const owner =
127
+ response.generation === undefined
128
+ ? { requestId: response.activeRequestId! }
129
+ : { generationId: response.generation.generationId }
130
+ emit({
131
+ type: 'ai.message.interrupted',
132
+ payload: {
133
+ messageId: response.responseMessageId,
134
+ ...owner,
135
+ ...(interruptionReason === undefined
136
+ ? {}
137
+ : { reason: interruptionReason }),
138
+ ...(lastSeenIndex === undefined ? {} : { lastSeenIndex }),
139
+ } satisfies MessageInterruptedPayload,
140
+ })
141
+ state.active = null
142
+ }
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
+ const insert = ({
162
+ message,
163
+ generate = true,
164
+ first = false,
165
+ afterStepOf,
166
+ }: {
167
+ message: M
168
+ generate?: boolean
169
+ first?: boolean
170
+ afterStepOf?: string
171
+ }): void => {
172
+ const candidate = {
173
+ id: message.id,
174
+ revision: 0,
175
+ message,
176
+ generate: generate && message.role === 'user',
177
+ ...(afterStepOf === undefined ? {} : { afterStepOf }),
178
+ }
179
+ state.reservedIds = {
180
+ ...state.reservedIds,
181
+ [candidate.id]: true,
182
+ ...(candidate.generate
183
+ ? { [`${candidate.id}:assistant`]: true as const }
184
+ : {}),
185
+ }
186
+ if (first) state.inbox.items.unshift(candidate)
187
+ else state.inbox.items.push(candidate)
188
+ }
189
+ const moveInput = ({ from, to }: { from: number; to: number }): void => {
190
+ if (from === to) return
191
+ const [moved] = state.inbox.items.splice(from, 1)
192
+ state.inbox.items.splice(to, 0, moved!)
193
+ const [previous] = before.inbox.items.splice(from, 1)
194
+ before.inbox.items.splice(to, 0, previous!)
195
+ moves.push({ path: ['inbox', 'items'], move: { from, to } })
196
+ }
197
+ const requestModel = ({
198
+ request,
199
+ id,
200
+ }: {
201
+ request: GenerationRequestedPayload
202
+ id: string
203
+ }): void => {
204
+ const active = state.active!
205
+ const payload = {
206
+ ...request,
207
+ control: { turnId: active.turnId, version: active.version },
208
+ }
209
+ state.coordinator = foldCoordinator<M, D>(state.coordinator, {
210
+ ...ctx.event,
211
+ type: 'ai.generation.requested',
212
+ id,
213
+ payload,
214
+ } as ContractEvent<D>)
215
+ active.model = { id, version: active.version, settled: false }
216
+ active.phase = 'starting'
217
+ jobs.push({
218
+ type: 'ai.generation.requested',
219
+ id,
220
+ payload,
221
+ } as AppendInput<D>)
222
+ }
223
+ if (ctx.event.type === 'ai.session.closed') {
224
+ state.coordinator = { closed: true, queued: [] }
225
+ state.active = null
226
+ state.inbox.items = []
227
+ } else if (ctx.event.type === 'ai.control.requested') {
228
+ const command = ctx.event.payload as ControlCommand<M>
229
+ if (state.coordinator.closed) reason = 'closed'
230
+ else
231
+ switch (command.action) {
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
+ })
245
+ break
246
+ case 'edit': {
247
+ const item = state.inbox.items.find(
248
+ (candidate) => candidate.id === command.message.id,
249
+ )
250
+ if (item === undefined)
251
+ reason = missingInputReason(command.message.id)
252
+ else if (item.revision !== command.expectedRevision)
253
+ reason = 'revision-conflict'
254
+ else if (item.message.role !== command.message.role)
255
+ reason = 'revision-conflict'
256
+ else {
257
+ item.message = command.message
258
+ item.revision += 1
259
+ }
260
+ break
261
+ }
262
+ case 'remove': {
263
+ const position = state.inbox.items.findIndex(
264
+ (item) => item.id === command.inputId,
265
+ )
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
+ }
274
+ break
275
+ }
276
+ case 'move': {
277
+ const index = state.inbox.items.findIndex(
278
+ (candidate) => candidate.id === command.inputId,
279
+ )
280
+ if (index < 0) reason = missingInputReason(command.inputId)
281
+ else if (
282
+ command.beforeId !== null &&
283
+ !state.inbox.items.some(
284
+ (candidate) => candidate.id === command.beforeId,
285
+ )
286
+ )
287
+ reason = 'not-found'
288
+ else {
289
+ const destination =
290
+ command.beforeId === null
291
+ ? state.inbox.items.length
292
+ : state.inbox.items.findIndex(
293
+ (item) => item.id === command.beforeId,
294
+ )
295
+ moveInput({
296
+ from: index,
297
+ to: destination > index ? destination - 1 : destination,
298
+ })
299
+ }
300
+ break
301
+ }
302
+ case 'send-now': {
303
+ const position = state.inbox.items.findIndex(
304
+ (item) => item.id === command.inputId,
305
+ )
306
+ const selected = state.inbox.items[position]
307
+ if (!selected) reason = missingInputReason(command.inputId)
308
+ else if ((state.active?.turnId ?? null) !== command.turnId)
309
+ reason = 'stale-turn'
310
+ 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
+ else {
317
+ if (!selected.generate) {
318
+ state.reservedIds = {
319
+ ...state.reservedIds,
320
+ [`${selected.id}:assistant`]: true,
321
+ }
322
+ selected.generate = true
323
+ }
324
+ if (state.active) selected.afterStepOf = state.active.turnId
325
+ else delete selected.afterStepOf
326
+ moveInput({ from: position, to: 0 })
327
+ }
328
+ break
329
+ }
330
+ case 'stop':
331
+ if (state.active?.turnId !== command.turnId) reason = 'stale-turn'
332
+ else
333
+ end({ lastSeenIndex: command.lastSeenIndex, reason: undefined })
334
+ break
335
+ 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
+ }
346
+ break
347
+ case 'interrupt': {
348
+ const response = state.coordinator.response
349
+ if (
350
+ !response ||
351
+ response.responseMessageId !== command.request.messageId ||
352
+ (command.request.generationId !==
353
+ response.generation?.generationId &&
354
+ command.request.requestId !== response.activeRequestId)
355
+ )
356
+ reason = 'stale-turn'
357
+ else
358
+ end({
359
+ lastSeenIndex: command.request.lastSeenIndex,
360
+ reason: command.request.reason,
361
+ })
362
+ break
363
+ }
364
+ case 'pause': {
365
+ state.inbox.paused = true
366
+ const active = state.active
367
+ if (
368
+ command.when === 'now' &&
369
+ active !== null &&
370
+ !active.suspended
371
+ ) {
372
+ active.suspended = true
373
+ active.version += 1
374
+ const generation = state.coordinator.response?.generation
375
+ if (generation)
376
+ emit({
377
+ type: 'ai.generation.paused',
378
+ payload: {
379
+ generationId: generation.generationId,
380
+ messageId: generation.responseMessageId,
381
+ },
382
+ })
383
+ }
384
+ break
385
+ }
386
+ case 'resume':
387
+ state.inbox.paused = false
388
+ break
389
+ case 'tool-result': {
390
+ const call = state.coordinator.response?.calls.find(
391
+ (candidate) =>
392
+ candidate.call.generationId === command.result.generationId &&
393
+ candidate.call.messageId === command.result.messageId &&
394
+ candidate.call.toolCallId === command.result.toolCallId &&
395
+ candidate.call.providerExecuted === true &&
396
+ !candidate.terminal,
397
+ )
398
+ if (!call) reason = 'not-found'
399
+ else emit({ type: 'ai.tool.result', payload: command.result })
400
+ break
401
+ }
402
+ case 'approval': {
403
+ const response = state.coordinator.response
404
+ const call = response?.calls.find(
405
+ (candidate) =>
406
+ candidate.approval?.approvalId ===
407
+ command.response.approvalId &&
408
+ candidate.approval.generationId ===
409
+ command.response.generationId &&
410
+ candidate.approval.messageId === command.response.messageId,
411
+ )
412
+ if (response?.failure) reason = 'stale-turn'
413
+ else if (!call || call.response || call.terminal)
414
+ reason = 'not-found'
415
+ else
416
+ emit({ type: 'ai.approval.responded', payload: command.response })
417
+ break
418
+ }
419
+ case 'request-input':
420
+ case 'input': {
421
+ const payload =
422
+ command.action === 'input' ? command.response : command.request
423
+ const response = state.coordinator.response
424
+ if (
425
+ response?.failure !== undefined ||
426
+ response?.generation?.generationId !== payload.generationId ||
427
+ response.responseMessageId !== payload.messageId ||
428
+ (command.action === 'input' &&
429
+ !response.inputs.some(
430
+ (input) =>
431
+ input.inputId === payload.inputId &&
432
+ input.name === payload.name,
433
+ ))
434
+ )
435
+ reason = 'not-found'
436
+ else
437
+ emit({
438
+ type:
439
+ command.action === 'input'
440
+ ? 'ai.input.responded'
441
+ : 'ai.input.requested',
442
+ payload,
443
+ })
444
+ break
445
+ }
446
+ case 'retry': {
447
+ const response = state.coordinator.response
448
+ if (
449
+ !state.active ||
450
+ response?.status !== 'failed' ||
451
+ response.rootMessageId !== command.request.messageId ||
452
+ response.responseMessageId !== command.request.responseMessageId
453
+ )
454
+ reason = 'stale-turn'
455
+ else if (!state.inbox.paused && !state.active.suspended) {
456
+ emit({ type: 'ai.retry.requested', payload: command.request })
457
+ requestModel({
458
+ request: {
459
+ messageId: response.rootMessageId,
460
+ responseMessageId: response.responseMessageId,
461
+ reason: 'retry',
462
+ },
463
+ id: `ai.generate:retry:${command.request.retryId}`,
464
+ })
465
+ } else reason = 'stale-turn'
466
+ break
467
+ }
468
+ }
469
+ } else {
470
+ const report = ctx.event.payload as WorkReport
471
+ const active = state.active
472
+ const response = state.coordinator.response
473
+ const call = response?.calls.find(
474
+ (candidate) => candidate.work?.id === report.workId,
475
+ )
476
+ const work = report.kind === 'model' ? active?.model : call?.work
477
+ const current =
478
+ active?.turnId === report.turnId &&
479
+ work?.id === report.workId &&
480
+ work.version === report.version &&
481
+ !work.settled &&
482
+ report.attempt >= (work.attempt ?? 0)
483
+ if (!current) {
484
+ reason = 'stale-work'
485
+ } else {
486
+ work.attempt = report.attempt
487
+ const authorized =
488
+ !active.suspended && active.version === report.version
489
+ for (const event of report.events) {
490
+ if (work.facts?.includes(event.id)) continue
491
+ if (
492
+ authorized ||
493
+ (report.kind === 'tool' && event.type === 'ai.tool.result')
494
+ ) {
495
+ emit({ type: event.type, payload: event.payload, id: event.id })
496
+ work.facts = [...(work.facts ?? []), event.id]
497
+ }
498
+ }
499
+ if (report.settled) work.settled = true
500
+ if (!authorized) reason = 'stale-work'
501
+ }
502
+ }
503
+
504
+ let active = state.active
505
+ let response = state.coordinator.response
506
+ if (active && response === undefined) {
507
+ state.active = null
508
+ active = null
509
+ }
510
+ if (active && response) {
511
+ const handoff = state.inbox.items.some(
512
+ (item) => item.afterStepOf === active.turnId,
513
+ )
514
+ const outstanding =
515
+ (active.model !== null && !active.model.settled) ||
516
+ response.calls.some(
517
+ (candidate) =>
518
+ candidate.work !== undefined && !candidate.work.settled,
519
+ )
520
+ if (active.suspended) {
521
+ active.phase = outstanding ? 'pausing' : 'paused'
522
+ if (!state.inbox.paused && !outstanding && !handoff)
523
+ active.suspended = false
524
+ }
525
+ if (!active.suspended && response.failure) active.phase = 'failed'
526
+ if (!active.suspended && response.failure === undefined) {
527
+ for (const call of response.calls) {
528
+ if (
529
+ call.terminal ||
530
+ (call.work !== undefined &&
531
+ (!call.work.settled || call.work.version === active.version)) ||
532
+ call.call.providerExecuted === true ||
533
+ (call.approval && !call.response)
534
+ )
535
+ continue
536
+ if (call.response?.approved === false) {
537
+ emit({
538
+ type: 'ai.tool.result',
539
+ payload: {
540
+ ...call.call,
541
+ denied: true,
542
+ phase: 'execution',
543
+ },
544
+ })
545
+ continue
546
+ }
547
+ const id = `${ctx.event.id}:tool:${call.call.toolCallId}`
548
+ call.work = { id, version: active.version, settled: false }
549
+ jobs.push({
550
+ type: 'ai.tool.execution.requested',
551
+ id,
552
+ payload: {
553
+ turnId: active.turnId,
554
+ version: active.version,
555
+ call: call.call,
556
+ generation: response.generation!,
557
+ },
558
+ } as AppendInput<D>)
559
+ }
560
+ }
561
+ response = state.coordinator.response!
562
+ const callsDone = response.calls.every(callReadyForModel)
563
+ const modelDone = active.model === null || active.model.settled
564
+ const workSettled =
565
+ modelDone &&
566
+ response.calls.every(
567
+ (call) => call.work === undefined || call.work.settled,
568
+ )
569
+ const externalPending = response.calls.some(
570
+ (call) =>
571
+ call.call.providerExecuted &&
572
+ call.call.supportsDeferredResults &&
573
+ !call.terminal &&
574
+ !approvalBlocksExecution(call),
575
+ )
576
+ const handoffReady =
577
+ handoff &&
578
+ workSettled &&
579
+ !externalPending &&
580
+ (active.suspended ||
581
+ response.failure !== undefined ||
582
+ response.calls.every(
583
+ (call) => callReadyForModel(call) || approvalBlocksExecution(call),
584
+ ))
585
+ const readyForModel =
586
+ !active.suspended &&
587
+ response.failure === undefined &&
588
+ modelDone &&
589
+ callsDone &&
590
+ response.inputs.length === 0
591
+ const naturalCompletion =
592
+ readyForModel &&
593
+ response.completion !== undefined &&
594
+ response.completion.finishReason !== 'tool-calls'
595
+ if (handoffReady && !naturalCompletion) {
596
+ end({ lastSeenIndex: undefined, reason: 'steered after step' })
597
+ } else if (readyForModel && (!handoff || handoffReady)) {
598
+ const resume =
599
+ active.model !== null && active.model.version !== active.version
600
+ const input = response.inputResponse
601
+ if (
602
+ !handoff &&
603
+ (resume ||
604
+ continuationReady(state.coordinator) ||
605
+ (response.completion && input))
606
+ ) {
607
+ const nextReason = resume ? 'resume' : input ? 'input' : 'tool'
608
+ const id =
609
+ nextReason === 'tool'
610
+ ? `ai.generate:tools:${response.generation!.generationId}`
611
+ : `${ctx.event.id}:generate:${nextReason}`
612
+ requestModel({
613
+ request: {
614
+ messageId: response.responseMessageId,
615
+ responseMessageId: response.responseMessageId,
616
+ reason: nextReason,
617
+ },
618
+ id,
619
+ })
620
+ } else if (naturalCompletion) {
621
+ emit({
622
+ type: 'ai.message.completed',
623
+ payload: {
624
+ messageId: response.responseMessageId,
625
+ },
626
+ })
627
+ state.active = null
628
+ }
629
+ }
630
+ if (
631
+ !active.suspended &&
632
+ state.active &&
633
+ jobs.every((job) => job.type !== 'ai.generation.requested')
634
+ )
635
+ state.active.phase = response.failure
636
+ ? 'failed'
637
+ : !modelDone
638
+ ? 'generating'
639
+ : !callsDone
640
+ ? 'tools'
641
+ : 'waiting'
642
+ }
643
+ if (
644
+ state.active === null &&
645
+ !state.inbox.paused &&
646
+ !state.coordinator.closed
647
+ ) {
648
+ while (state.inbox.items.length > 0) {
649
+ const item = state.inbox.items.shift()!
650
+ delete item.afterStepOf
651
+ emit({
652
+ type: 'ai.message.created',
653
+ payload: { message: item.message, generate: item.generate },
654
+ id: `ai.admitted:${item.id}`,
655
+ })
656
+ if (!item.generate) continue
657
+ state.active = {
658
+ turnId: item.id,
659
+ input: item,
660
+ phase: 'starting',
661
+ version: 0,
662
+ suspended: false,
663
+ model: null,
664
+ }
665
+ requestModel({
666
+ request: { messageId: item.id, reason: 'message' },
667
+ id: `ai.generate:message:${item.id}`,
668
+ })
669
+ break
670
+ }
671
+ }
672
+ const receipt: ControlReceipt = {
673
+ commandId: ctx.event.id,
674
+ ...(ctx.event.type === 'ai.work.reported' ? { work: true as const } : {}),
675
+ outcome: reason === undefined ? 'applied' : 'rejected',
676
+ ...(reason === undefined ? {} : { reason }),
677
+ }
678
+ return [
679
+ ...(facts as AppendInput<D>[]),
680
+ {
681
+ type: 'ai.control.committed',
682
+ payload: controlCommit({ before, after: state, moves }),
683
+ } as AppendInput<D>,
684
+ ...jobs,
685
+ { type: 'ai.control.decided', payload: receipt } as AppendInput<D>,
686
+ ]
687
+ }
688
+
689
+ const report = ({
690
+ ctx,
691
+ events,
692
+ settled,
693
+ name,
694
+ }: {
695
+ ctx: HandlerContext<D>
696
+ events: readonly AppendInput<D>[]
697
+ settled: boolean
698
+ name: string
699
+ }): AppendInput<D> => {
700
+ const payload = ctx.event.payload as
701
+ GenerationRequestedPayload | { turnId: string; version: number }
702
+ const { turnId, version } = 'turnId' in payload ? payload : payload.control!
703
+ for (const event of events) {
704
+ const schema = options.agent.contract.events[
705
+ event.type
706
+ ] as StandardSchemaV1<unknown>
707
+ const result = validateSync(
708
+ schema,
709
+ event.payload,
710
+ `AI worker ${event.type}`,
711
+ )
712
+ if (result.issues)
713
+ throw new TypeError(
714
+ result.issues.map((issue) => issue.message).join('; '),
715
+ )
716
+ }
717
+ return {
718
+ type: 'ai.work.reported',
719
+ id: `${ctx.event.id}:report:${ctx.attempt}:${name}`,
720
+ payload: {
721
+ workId: ctx.event.id,
722
+ attempt: ctx.attempt,
723
+ turnId,
724
+ version,
725
+ kind: ctx.event.type === 'ai.generation.requested' ? 'model' : 'tool',
726
+ 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
+ })),
740
+ },
741
+ } as AppendInput<D>
742
+ }
743
+
744
+ const append = async ({
745
+ ctx,
746
+ name,
747
+ events,
748
+ }: {
749
+ ctx: HandlerContext<D>
750
+ name: string
751
+ events: AppendInput<D>[]
752
+ }): Promise<ContractEvent<D>[]> => {
753
+ const progress = events.filter(
754
+ (event) => event.type === 'ai.generation.progress',
755
+ )
756
+ const lifecycle = events.filter(
757
+ (event) => event.type !== 'ai.generation.progress',
758
+ )
759
+ if (lifecycle.length === 0) return ctx.session.append(name, ...progress)
760
+ const input = report({ ctx, events: lifecycle, settled: false, name })
761
+ const persisted = await ctx.session.append(name, ...progress, input)
762
+ const index = persisted.at(-1)!.index
763
+ const feed = ctx.session.stream({ startAfter: index })
764
+ const stream = feed[Symbol.asyncIterator]()
765
+ const canonical: ContractEvent<D>[] = []
766
+ const wanted = new Set(lifecycle.map((event) => event.id))
767
+ let rejectAbort!: (error: unknown) => void
768
+ const aborted = new Promise<never>((_, reject) => {
769
+ rejectAbort = reject
770
+ })
771
+ const onAbort = (): void => {
772
+ rejectAbort(ctx.signal.reason)
773
+ }
774
+ ctx.signal.addEventListener('abort', onAbort, { once: true })
775
+ try {
776
+ ctx.signal.throwIfAborted()
777
+ for (;;) {
778
+ const item = await Promise.race([stream.next(), aborted])
779
+ if (item.done)
780
+ throw new Error('AI control acknowledgement stream ended')
781
+ if (wanted.has(item.value.id)) canonical.push(item.value)
782
+ if (
783
+ item.value.type === 'ai.control.decided' &&
784
+ (item.value.payload as ControlReceipt).commandId === input.id
785
+ ) {
786
+ if ((item.value.payload as ControlReceipt).outcome === 'rejected')
787
+ throw new ControlCancelled('stale AI work')
788
+ return [
789
+ ...persisted.filter((event) => event.type !== 'ai.work.reported'),
790
+ ...canonical,
791
+ ]
792
+ }
793
+ }
794
+ } finally {
795
+ ctx.signal.removeEventListener('abort', onAbort)
796
+ await stream.return?.()
797
+ }
798
+ }
799
+
800
+ const settled = ({
801
+ ctx,
802
+ events,
803
+ }: {
804
+ ctx: HandlerContext<D>
805
+ events: void | AppendInput<D> | readonly AppendInput<D>[]
806
+ }): AppendInput<D> =>
807
+ report({
808
+ ctx,
809
+ events:
810
+ events === undefined
811
+ ? []
812
+ : Array.isArray(events)
813
+ ? events
814
+ : [events as AppendInput<D>],
815
+ settled: true,
816
+ name: 'settled',
817
+ })
818
+ const cancelled = ({
819
+ error,
820
+ signal,
821
+ }: {
822
+ error: unknown
823
+ signal: AbortSignal
824
+ }): boolean => {
825
+ if (signal.reason instanceof A2Error) throw signal.reason
826
+ return error instanceof ControlCancelled || signal.aborted
827
+ }
828
+ return { handler, append, settled, cancelled, reducer, coordinator }
829
+ }