experimental-a2 0.12.0 → 0.14.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 (106) hide show
  1. package/CHANGELOG.md +47 -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 +16 -8
  17. package/dist/ai-server.d.ts.map +1 -1
  18. package/dist/ai-server.js +1322 -475
  19. package/dist/ai-server.js.map +1 -1
  20. package/dist/ai.d.ts +2 -334
  21. package/dist/ai.js +838 -85
  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 +361 -76
  64. package/docs/reference/01-api.mdx +159 -27
  65. package/examples/playground/app/agent/[agentId]/agent-client.tsx +145 -33
  66. package/examples/playground/app/agent/[agentId]/agent-queue.tsx +289 -0
  67. package/examples/playground/app/agent/[agentId]/compaction/route.ts +14 -0
  68. package/examples/playground/app/agent/[agentId]/compaction-event.tsx +38 -0
  69. package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +294 -0
  70. package/examples/playground/app/agent/compaction-settings.test.ts +144 -0
  71. package/examples/playground/app/agent/compaction-settings.ts +49 -0
  72. package/examples/playground/app/agent/compaction-timeline.test.ts +337 -0
  73. package/examples/playground/app/agent/compaction-timeline.ts +198 -0
  74. package/examples/playground/app/agent/model.ts +56 -1
  75. package/examples/playground/app/agent/server.ts +9 -2
  76. package/examples/playground/app/chat/[chatId]/chat-client.tsx +3 -13
  77. package/examples/playground/app/chat/model.ts +2 -2
  78. package/examples/playground/app/chat/server.ts +24 -17
  79. package/examples/playground/app/globals.css +333 -0
  80. package/examples/playground/package.json +1 -1
  81. package/package.json +1 -1
  82. package/src/ai-client-state.ts +185 -0
  83. package/src/ai-control-server.ts +829 -0
  84. package/src/ai-control-state.ts +152 -0
  85. package/src/ai-control.ts +139 -0
  86. package/src/ai-coordinator.ts +99 -32
  87. package/src/ai-model-metadata.ts +108 -0
  88. package/src/ai-progress-batches.ts +68 -0
  89. package/src/ai-projector.ts +76 -15
  90. package/src/ai-sdk-step.ts +5 -2
  91. package/src/ai-server.ts +920 -638
  92. package/src/ai.ts +650 -110
  93. package/src/client.ts +31 -9
  94. package/src/licenses/Apache-2.0.txt +55 -0
  95. package/src/parse-partial-json.ts +441 -0
  96. package/src/reducer.ts +6 -0
  97. package/src/server.ts +8 -4
  98. package/src/store-postgres.ts +27 -0
  99. package/src/store-redis-core.ts +53 -1
  100. package/src/store-redis-notify.ts +1 -0
  101. package/src/store.ts +6 -0
  102. package/dist/ai.d.ts.map +0 -1
  103. package/dist/client-Bf6uSEAk.js.map +0 -1
  104. package/dist/contract-48bUMgcL.js.map +0 -1
  105. package/dist/server-BeNADlCI.js.map +0 -1
  106. package/dist/store-redis-notify-BUCyXOn0.js.map +0 -1
package/src/ai-server.ts CHANGED
@@ -8,8 +8,10 @@
8
8
  // oxlint-disable no-await-in-loop -- stream chunks and durable appends are
9
9
  // ordered; parallel consumption would corrupt progress sequence and state.
10
10
 
11
+ import { createControlRuntime, ControlCancelled } from './ai-control-server.ts'
12
+ import type { ControlCommit } from './ai-control.ts'
11
13
  import { AsyncLocalStorage } from 'node:async_hooks'
12
- import { convertToModelMessages } from 'ai'
14
+ import { asSchema, convertToModelMessages } from 'ai'
13
15
  import type {
14
16
  FinishReason,
15
17
  Instructions,
@@ -22,6 +24,7 @@ import type {
22
24
  UIMessageChunk,
23
25
  } from 'ai'
24
26
  import { generateAISDKStep, type AISDKStepSettings } from './ai-sdk-step.ts'
27
+ import { gatewayModelId, readModelLimits } from './ai-model-metadata.ts'
25
28
  import type {
26
29
  AIEventDefs,
27
30
  AIState,
@@ -33,17 +36,13 @@ import type {
33
36
  GenerationCompletedPayload,
34
37
  GenerationFailedPayload,
35
38
  GenerationProgressPayload,
36
- GenerationRequestedPayload,
37
39
  GenerationStartedPayload,
38
40
  MessageCompletedPayload,
39
- MessageCreatedPayload,
40
- MessageInterruptedPayload,
41
41
  ToolCalledPayload,
42
42
  ToolResultPayload,
43
43
  } from './ai.ts'
44
44
  import {
45
45
  aiCoordinatorReducer,
46
- continuationReady,
47
46
  type AICoordinatorState,
48
47
  } from './ai-coordinator.ts'
49
48
  import type { AppendInput, ContractEvent, EventDefs } from './contract.ts'
@@ -66,24 +65,23 @@ import {
66
65
 
67
66
  function validateAgentIngress(context: ServerIngressContext): void {
68
67
  const rejected = context.events.find((event) => {
69
- if (event.type !== 'ai.message.created') {
70
- return !(
71
- event.type === 'ai.approval.responded' ||
72
- event.type === 'ai.input.responded' ||
73
- event.type === 'ai.message.interrupted' ||
74
- event.type === 'ai.retry.requested'
68
+ if (event.type !== 'ai.control.requested') return true
69
+ const command = event.payload as Record<string, unknown>
70
+ if (
71
+ command['action'] === 'request-input' ||
72
+ command['action'] === 'tool-result'
73
+ )
74
+ return true
75
+ if (
76
+ command['action'] === 'send' ||
77
+ command['action'] === 'edit' ||
78
+ command['action'] === 'steer'
79
+ ) {
80
+ return (
81
+ (command['message'] as { role?: unknown } | undefined)?.role !== 'user'
75
82
  )
76
83
  }
77
- const payload = event.payload
78
- return (
79
- typeof payload !== 'object' ||
80
- payload === null ||
81
- !('message' in payload) ||
82
- typeof payload.message !== 'object' ||
83
- payload.message === null ||
84
- !('role' in payload.message) ||
85
- payload.message.role !== 'user'
86
- )
84
+ return false
87
85
  })
88
86
  if (rejected !== undefined) {
89
87
  throw new A2Error(
@@ -116,8 +114,9 @@ export type AgentGenerateContext<
116
114
  generationId: string
117
115
  responseMessageId: string
118
116
  messages: M[]
117
+ modelMessages: ModelMessage[]
119
118
  state: AIState<M>
120
- history: ContractEvent<D>[]
119
+ session: Pick<HandlerContext<D>['session'], 'state'>
121
120
  signal: AbortSignal
122
121
  model: LanguageModel
123
122
  tools: T
@@ -150,7 +149,7 @@ export type AgentResolverContext<
150
149
  > = {
151
150
  event: ContractEvent<D, 'ai.generation.requested'>
152
151
  state: AIState<M>
153
- history: ContractEvent<D>[]
152
+ session: Pick<HandlerContext<D>['session'], 'state'>
154
153
  signal: AbortSignal
155
154
  }
156
155
 
@@ -161,13 +160,29 @@ export type CompactionPolicy<
161
160
  D extends AIEventDefs<M> & EventDefs,
162
161
  > = {
163
162
  shouldCompact(
164
- context: AgentResolverContext<M, D> & { messages: M[] },
163
+ context: AgentResolverContext<M, D> & {
164
+ messages: M[]
165
+ modelMessages: ModelMessage[]
166
+ },
165
167
  ): boolean | Promise<boolean>
166
168
  compact(
167
- context: AgentResolverContext<M, D> & { messages: M[] },
169
+ context: AgentResolverContext<M, D> & {
170
+ messages: M[]
171
+ modelMessages: ModelMessage[]
172
+ },
168
173
  ): M[] | Promise<M[]>
169
174
  }
170
175
 
176
+ export type AutomaticCompaction = {
177
+ thresholdTokens?: number
178
+ instructions?: string
179
+ }
180
+
181
+ export type CompactionOptions<
182
+ M extends UIMessage,
183
+ D extends AIEventDefs<M> & EventDefs,
184
+ > = false | AutomaticCompaction | CompactionPolicy<M, D>
185
+
171
186
  export type AgentGenerationSettings<T extends ToolSet> = AISDKStepSettings<T>
172
187
 
173
188
  export type CreateHandlersOptions<
@@ -196,7 +211,7 @@ export type CreateHandlersOptions<
196
211
  * The custom stream owns its metadata chunks.
197
212
  */
198
213
  generate?: AgentGenerate<M, D, T>
199
- compaction?: CompactionPolicy<M, D>
214
+ compaction?: Resolvable<CompactionOptions<M, D>, AgentResolverContext<M, D>>
200
215
  /** Progress is durably flushed at either limit, whichever is reached first. */
201
216
  progress?: { maxChunks?: number; maxDelayMs?: number }
202
217
  }
@@ -210,13 +225,35 @@ export type CreateAgentServerOptions<
210
225
  handlers?: ServerOptions<D>['handlers']
211
226
  }
212
227
 
213
- const resolve = async <T, C>(
228
+ const checkAbort = (signal: AbortSignal): boolean => {
229
+ if (!signal.aborted) return false
230
+ const reason: unknown = signal.reason
231
+ if (
232
+ reason instanceof A2Error &&
233
+ (reason.code === 'CLAIM_EXPIRED' || reason.code === 'SUPERSEDED_ATTEMPT')
234
+ ) {
235
+ throw reason
236
+ }
237
+ return true
238
+ }
239
+
240
+ const resolve = async <T, C extends { signal: AbortSignal }>(
214
241
  value: Resolvable<T, C>,
215
242
  context: C,
216
- ): Promise<T> =>
217
- typeof value === 'function'
218
- ? await (value as (context: C) => T | Promise<T>)(context)
219
- : value
243
+ ): Promise<T> => {
244
+ let result: T
245
+ try {
246
+ result =
247
+ typeof value === 'function'
248
+ ? await (value as (context: C) => T | Promise<T>)(context)
249
+ : value
250
+ } catch (error) {
251
+ checkAbort(context.signal)
252
+ throw error
253
+ }
254
+ checkAbort(context.signal)
255
+ return result
256
+ }
220
257
 
221
258
  const modelName = (model: LanguageModel): string => {
222
259
  if (typeof model === 'string') return model
@@ -265,10 +302,12 @@ const isBoundaryChunk = (chunk: UIMessageChunk | undefined): boolean =>
265
302
  chunk.type === 'abort' ||
266
303
  chunk.type === 'error')
267
304
 
305
+ type StreamRead<T> = Awaited<ReturnType<ReadableStreamDefaultReader<T>['read']>>
306
+
268
307
  const nextOrTimer = async <T>(
269
- next: Promise<IteratorResult<T>>,
308
+ next: Promise<StreamRead<T>>,
270
309
  delayMs: number,
271
- ): Promise<{ type: 'next'; result: IteratorResult<T> } | { type: 'timer' }> => {
310
+ ): Promise<{ type: 'next'; result: StreamRead<T> } | { type: 'timer' }> => {
272
311
  let timer: ReturnType<typeof setTimeout> | undefined
273
312
  try {
274
313
  return await Promise.race([
@@ -305,6 +344,7 @@ async function generateWithAISDK<
305
344
  model: context.model,
306
345
  tools: context.tools,
307
346
  messages: context.messages,
347
+ modelMessages: context.modelMessages,
308
348
  responseMessageId: context.responseMessageId,
309
349
  abortSignal: context.signal,
310
350
  ...(context.instructions === undefined
@@ -333,11 +373,11 @@ async function* consumeGeneration(options: {
333
373
  const maxChunks = options.progress?.maxChunks ?? 16
334
374
  const maxDelayMs = options.progress?.maxDelayMs ?? 30
335
375
  let lastFlush = Date.now()
336
- const chunks = options.source.stream[Symbol.asyncIterator]()
337
- let pendingChunk = chunks.next()
376
+ const reader = options.source.stream.getReader()
377
+ let pendingChunk = reader.read()
338
378
  try {
339
379
  for (;;) {
340
- let result: IteratorResult<UIMessageChunk>
380
+ let result: StreamRead<UIMessageChunk>
341
381
  if (pending.length > 0) {
342
382
  const remaining = Math.max(0, maxDelayMs - (Date.now() - lastFlush))
343
383
  const outcome = await nextOrTimer(pendingChunk, remaining)
@@ -355,7 +395,7 @@ async function* consumeGeneration(options: {
355
395
  pending.push(chunk)
356
396
  if (chunk.type === 'finish') streamedFinishReason = chunk.finishReason
357
397
  if (chunk.type === 'error') streamedError = chunk.errorText
358
- pendingChunk = chunks.next()
398
+ pendingChunk = reader.read()
359
399
 
360
400
  if (pending.length >= maxChunks || isBoundaryChunk(pending.at(-1))) {
361
401
  yield { type: 'progress', chunks: pending.splice(0) }
@@ -366,6 +406,15 @@ async function* consumeGeneration(options: {
366
406
  if (pending.length > 0)
367
407
  yield { type: 'progress', chunks: pending.splice(0) }
368
408
  throw error
409
+ } finally {
410
+ void pendingChunk.catch(() => {})
411
+ try {
412
+ await reader.cancel()
413
+ } catch {
414
+ // Cleanup must not replace the error that ended generation.
415
+ } finally {
416
+ reader.releaseLock()
417
+ }
369
418
  }
370
419
 
371
420
  let completion: GenerationCompletion | undefined
@@ -389,46 +438,249 @@ async function* consumeGeneration(options: {
389
438
  }
390
439
  }
391
440
 
392
- const replay = <M extends UIMessage, D extends AIEventDefs<M> & EventDefs>(
393
- definition: AgentDefinition<M, D>,
394
- history: ContractEvent<D>[],
395
- ): AIState<M> => {
396
- let state = definition.reducer.initialState
397
- for (const event of history) state = definition.reducer.fold(state, event)
441
+ const foldAIEvents = <
442
+ M extends UIMessage,
443
+ D extends AIEventDefs<M> & EventDefs,
444
+ >(options: {
445
+ agent: AgentDefinition<M, D>
446
+ state: AIState<M>
447
+ events: ContractEvent<D>[]
448
+ }): AIState<M> => {
449
+ let state = options.state
450
+ for (const event of options.events)
451
+ state = options.agent.reducer.fold(state, event)
398
452
  return state
399
453
  }
400
454
 
401
- const contextMessages = <M extends UIMessage>(state: AIState<M>): M[] => {
455
+ const summarize = async <
456
+ M extends UIMessage,
457
+ D extends AIEventDefs<M> & EventDefs,
458
+ T extends ToolSet,
459
+ >(options: {
460
+ context: AgentGenerateContext<M, D, T>
461
+ instructions: string | undefined
462
+ }): Promise<{ summary: string; usage?: LanguageModelUsage }> => {
463
+ const instruction = [
464
+ 'Summarize this conversation so you can continue the task after the earlier context is replaced by your summary.',
465
+ 'Preserve the objective, constraints, decisions, exact identifiers, completed actions and their results, unresolved questions, and next steps. Distinguish observations from assumptions.',
466
+ 'Respond only with the summary as text. Do not call any tools or continue working on the task.',
467
+ options.instructions,
468
+ ]
469
+ .filter(Boolean)
470
+ .join('\n')
471
+ const generation = { ...options.context.generation }
472
+ for (const key of Object.keys(generation)) {
473
+ if (
474
+ key.startsWith('on') ||
475
+ key.startsWith('experimental_on') ||
476
+ [
477
+ 'repairToolCall',
478
+ 'experimental_repairToolCall',
479
+ 'experimental_refineToolInput',
480
+ 'experimental_transform',
481
+ 'toolApproval',
482
+ ].includes(key)
483
+ ) {
484
+ Reflect.deleteProperty(generation, key)
485
+ }
486
+ }
487
+ const tools = Object.fromEntries(
488
+ Object.entries(options.context.tools).map(([name, tool]) => {
489
+ const definition = { ...tool, needsApproval: false }
490
+ for (const key of [
491
+ 'execute',
492
+ 'onInputStart',
493
+ 'onInputDelta',
494
+ 'onInputAvailable',
495
+ ]) {
496
+ Reflect.deleteProperty(definition, key)
497
+ }
498
+ return [name, definition]
499
+ }),
500
+ ) as T
501
+ const source = await generateWithAISDK(
502
+ {
503
+ ...options.context,
504
+ generation,
505
+ tools,
506
+ modelMessages: [
507
+ ...options.context.modelMessages,
508
+ { role: 'user', content: instruction },
509
+ ],
510
+ responseMessageId: `${options.context.generationId}:summary`,
511
+ },
512
+ undefined,
513
+ )
514
+ let summary = ''
515
+ let calledTool = false
516
+ let finish: AgentGenerationFinish | undefined
517
+ for await (const update of consumeGeneration({ source })) {
518
+ options.context.signal.throwIfAborted()
519
+ if (update.type === 'finish') {
520
+ finish = update
521
+ continue
522
+ }
523
+ for (const chunk of update.chunks) {
524
+ if (chunk.type === 'text-delta') summary += chunk.delta
525
+ if (chunk.type.startsWith('tool-')) calledTool = true
526
+ }
527
+ }
528
+ if (
529
+ calledTool ||
530
+ summary.trim().length === 0 ||
531
+ finish?.finishReason !== 'stop'
532
+ ) {
533
+ throw new Error(
534
+ 'compaction must produce a complete text summary without calling tools',
535
+ )
536
+ }
537
+ return {
538
+ summary,
539
+ ...(finish.usage === undefined ? {} : { usage: finish.usage }),
540
+ }
541
+ }
542
+
543
+ const activeCompaction = <M extends UIMessage>(
544
+ state: AIState<M>,
545
+ ): AIState<M>['compaction'] => {
402
546
  const compaction = state.compaction
403
- if (compaction?.status !== 'completed' || !compaction.messages) {
404
- return state.messages
547
+ return compaction?.status === 'completed' &&
548
+ compaction.messages !== undefined &&
549
+ state.messages.some((message) => message.id === compaction.throughMessageId)
550
+ ? compaction
551
+ : null
552
+ }
553
+
554
+ const contextMessages = async <
555
+ M extends UIMessage,
556
+ D extends AIEventDefs<M> & EventDefs,
557
+ >(options: {
558
+ agent: AgentDefinition<M, D>
559
+ session: HandlerContext<D>['session']
560
+ snapshot: { state: AIState<M>; index: number }
561
+ coordinator: AICoordinatorState
562
+ appended?: ContractEvent<D>[]
563
+ excluded?: ReadonlySet<string>
564
+ }): Promise<M[]> => {
565
+ const { agent, session, snapshot } = options
566
+ const appended = options.appended ?? []
567
+ const state = foldAIEvents({ agent, events: appended, state: snapshot.state })
568
+ const compaction = activeCompaction(state)
569
+ const queued = new Set(
570
+ options.coordinator.queued.map((item) => item.messageId),
571
+ )
572
+ const visible = (messages: M[]): M[] =>
573
+ messages.filter((message) => !queued.has(message.id))
574
+ if (compaction === null) return visible(state.messages)
575
+ const retained = new Set(compaction.retainedMessageIds ?? [])
576
+ const throughIndex = compaction.throughIndex
577
+ if (throughIndex !== undefined) {
578
+ const [prefix, priorCoordinator, tail] = await Promise.all([
579
+ throughIndex === snapshot.index
580
+ ? Promise.resolve(snapshot)
581
+ : session.state(agent.reducer, { through: throughIndex }),
582
+ session.state(aiCoordinatorReducer(agent.contract), {
583
+ through: throughIndex,
584
+ }),
585
+ throughIndex >= snapshot.index
586
+ ? Promise.resolve([])
587
+ : session.history({ gte: throughIndex + 1, lte: snapshot.index }),
588
+ ])
589
+ for (const item of priorCoordinator.state.queued)
590
+ retained.add(item.messageId)
591
+ const events =
592
+ options.excluded === undefined
593
+ ? tail
594
+ : withoutGenerationLifecycle(tail, options.excluded)
595
+ const context = foldAIEvents({
596
+ agent,
597
+ events: [...events, ...appended],
598
+ state: {
599
+ ...prefix.state,
600
+ messages: [
601
+ ...compaction.messages!,
602
+ ...prefix.state.messages.filter((message) =>
603
+ retained.has(message.id),
604
+ ),
605
+ ],
606
+ activeProjection: null,
607
+ },
608
+ })
609
+ const positions = new Map<string, number>()
610
+ for (const message of [...compaction.messages!, ...state.messages]) {
611
+ if (!positions.has(message.id)) positions.set(message.id, positions.size)
612
+ }
613
+ return visible(
614
+ context.messages.toSorted(
615
+ (left, right) =>
616
+ (positions.get(left.id) ?? positions.size) -
617
+ (positions.get(right.id) ?? positions.size),
618
+ ),
619
+ )
405
620
  }
406
621
  const boundary = state.messages.findIndex(
407
622
  (message) => message.id === compaction.throughMessageId,
408
623
  )
409
- const retained = new Set(compaction.retainedMessageIds ?? [])
410
- return boundary === -1
411
- ? state.messages
412
- : [
413
- ...compaction.messages,
414
- ...state.messages
415
- .slice(0, boundary + 1)
416
- .filter((message) => retained.has(message.id)),
417
- ...state.messages.slice(boundary + 1),
418
- ]
624
+ return visible([
625
+ ...compaction.messages!,
626
+ ...state.messages
627
+ .slice(0, boundary + 1)
628
+ .filter((message) => retained.has(message.id)),
629
+ ...state.messages.slice(boundary + 1),
630
+ ])
419
631
  }
420
632
 
421
- const activeContextMessages = <M extends UIMessage>(
422
- state: AIState<M>,
423
- coordinator: AICoordinatorState,
424
- ): M[] => {
425
- const queued = new Set(coordinator.queued.map((item) => item.messageId))
426
- return contextMessages(state).filter((message) => !queued.has(message.id))
633
+ const modelContext = async <M extends UIMessage>(options: {
634
+ messages: M[]
635
+ state: AIState<M>
636
+ tools: ToolSet
637
+ }): Promise<ModelMessage[]> => {
638
+ const messages = await convertToModelMessages(options.messages, {
639
+ tools: options.tools,
640
+ })
641
+ const summary = activeCompaction(options.state)?.summary
642
+ return summary === undefined
643
+ ? messages
644
+ : [{ role: 'user', content: summary }, ...messages]
645
+ }
646
+
647
+ const estimateInputTokens = async (options: {
648
+ messages: ModelMessage[]
649
+ instructions: Instructions | undefined
650
+ tools: ToolSet
651
+ }): Promise<number> => {
652
+ const tools = await Promise.all(
653
+ Object.entries(options.tools).map(async ([name, tool]) => ({
654
+ name,
655
+ description: tool.description,
656
+ inputSchema: await asSchema(tool.inputSchema).jsonSchema,
657
+ })),
658
+ )
659
+ return Math.ceil(
660
+ new TextEncoder().encode(
661
+ JSON.stringify({
662
+ messages: options.messages,
663
+ instructions: options.instructions,
664
+ tools,
665
+ }),
666
+ ).byteLength / 4,
667
+ )
427
668
  }
428
669
 
429
- const generationRequestId = (generationId: string): string | undefined => {
430
- const markerIndex = generationId.lastIndexOf(':generation:')
431
- return markerIndex === -1 ? undefined : generationId.slice(0, markerIndex)
670
+ const measuredInputTokens = (options: {
671
+ estimate: number
672
+ model: string
673
+ calibration: AICoordinatorState['calibration']
674
+ }): number => {
675
+ const previous = options.calibration
676
+ return previous !== undefined &&
677
+ previous.model === options.model &&
678
+ options.estimate >= previous.estimate
679
+ ? Math.max(
680
+ options.estimate,
681
+ Math.ceil(previous.inputTokens + options.estimate - previous.estimate),
682
+ )
683
+ : options.estimate
432
684
  }
433
685
 
434
686
  type ToolCallClassification = 'automatic' | 'approval' | 'unknown'
@@ -675,36 +927,6 @@ const lifecycleEvents = <D extends EventDefs, T extends ToolSet>(options: {
675
927
  return { events: result, pending }
676
928
  }
677
929
 
678
- function queuedMessagesAt<D extends EventDefs>(
679
- history: ContractEvent<D>[],
680
- ): AICoordinatorState['queued'] {
681
- const queued: AICoordinatorState['queued'] = []
682
- for (const event of history) {
683
- if (event.type === 'ai.message.created') {
684
- const payload = event.payload as MessageCreatedPayload<UIMessage>
685
- if (payload.message.role !== 'user') continue
686
- const duplicate = queued.findIndex(
687
- (item) => item.messageId === payload.message.id,
688
- )
689
- if (duplicate !== -1) queued.splice(duplicate, 1)
690
- queued.push({
691
- index: event.index,
692
- messageId: payload.message.id,
693
- generate: payload.generate !== false,
694
- })
695
- continue
696
- }
697
- if (event.type !== 'ai.generation.requested') continue
698
- const request = event.payload as GenerationRequestedPayload
699
- if (request.reason !== 'message') continue
700
- const requested = queued.findIndex(
701
- (item) => item.messageId === request.messageId,
702
- )
703
- if (requested !== -1) queued.splice(0, requested + 1)
704
- }
705
- return queued
706
- }
707
-
708
930
  function withoutGenerationLifecycle<D extends EventDefs>(
709
931
  history: ContractEvent<D>[],
710
932
  generationIds: ReadonlySet<string>,
@@ -772,6 +994,62 @@ function withoutGenerationLifecycle<D extends EventDefs>(
772
994
  })
773
995
  }
774
996
 
997
+ const validateCompaction = <
998
+ M extends UIMessage,
999
+ D extends AIEventDefs<M> & EventDefs,
1000
+ T extends ToolSet,
1001
+ >(options: {
1002
+ compaction: CompactionOptions<M, D>
1003
+ explicit: boolean
1004
+ generation: AgentGenerationSettings<T> | undefined
1005
+ tools: T | undefined
1006
+ }): CompactionOptions<M, D> => {
1007
+ let compaction = options.compaction
1008
+ if (
1009
+ compaction !== false &&
1010
+ (typeof compaction !== 'object' || compaction === null)
1011
+ ) {
1012
+ throw new TypeError('compaction must resolve to false or a policy object')
1013
+ }
1014
+ if (compaction !== false && 'then' in compaction) {
1015
+ void Promise.resolve(compaction).catch(() => {})
1016
+ throw new TypeError('compaction options must be a policy or a resolver')
1017
+ }
1018
+ if (compaction !== false && !('shouldCompact' in compaction)) {
1019
+ if (
1020
+ compaction.thresholdTokens !== undefined &&
1021
+ (!Number.isSafeInteger(compaction.thresholdTokens) ||
1022
+ compaction.thresholdTokens < 1)
1023
+ ) {
1024
+ throw new TypeError(
1025
+ 'compaction.thresholdTokens must be a positive safe integer',
1026
+ )
1027
+ }
1028
+ const choice = options.generation?.toolChoice
1029
+ const unsupported =
1030
+ options.generation?.output !== undefined
1031
+ ? 'structured output'
1032
+ : choice !== undefined && choice !== 'auto' && choice !== 'none'
1033
+ ? 'forced tool choice'
1034
+ : Object.values(options.tools ?? {}).some(
1035
+ (tool) =>
1036
+ tool.type === 'provider' && tool.isProviderExecuted === true,
1037
+ )
1038
+ ? 'provider-executed tools'
1039
+ : undefined
1040
+ if (unsupported !== undefined) {
1041
+ if (options.explicit) {
1042
+ throw new TypeError(
1043
+ `automatic compaction does not support ${unsupported}; use a custom policy or compaction: false`,
1044
+ )
1045
+ }
1046
+ compaction = false
1047
+ }
1048
+ }
1049
+
1050
+ return compaction
1051
+ }
1052
+
775
1053
  /**
776
1054
  * Build the ordinary A2 handler table for the built-in agent protocol.
777
1055
  * Application handlers can be spread beside this table.
@@ -808,92 +1086,25 @@ export function createHandlers<
808
1086
  throw new TypeError('maxSteps must be a positive integer or Infinity')
809
1087
  }
810
1088
 
1089
+ const configuredCompaction =
1090
+ options.compaction ?? (options.generate === undefined ? {} : false)
1091
+ const staticCompaction =
1092
+ typeof configuredCompaction === 'function'
1093
+ ? undefined
1094
+ : validateCompaction({
1095
+ compaction: configuredCompaction,
1096
+ explicit: options.compaction !== undefined,
1097
+ generation: options.generation,
1098
+ tools: options.tools,
1099
+ })
1100
+
811
1101
  const tools = options.tools ?? ({} as T)
812
- const generation = options.generation ?? {}
1102
+ const generation: AgentGenerationSettings<T> = options.generation ?? {}
813
1103
  const maxSteps = options.maxSteps ?? Number.POSITIVE_INFINITY
814
- const coordinator = aiCoordinatorReducer(options.agent.contract)
1104
+ const control = createControlRuntime({ agent: options.agent })
1105
+ const coordinator = control.coordinator
815
1106
  const promptCache = new Map<string, Promise<ModelMessage[]>>()
816
1107
 
817
- const coordinatorStateAt = (
818
- history: ContractEvent<D>[],
819
- frontier = Number.POSITIVE_INFINITY,
820
- ): AICoordinatorState => {
821
- let state = coordinator.initialState
822
- for (const event of history) {
823
- if (event.index >= frontier) break
824
- state = coordinator.fold(state, event)
825
- }
826
- return state
827
- }
828
-
829
- const requestForNextMessage = (
830
- state: AICoordinatorState,
831
- ): AppendInput<D> | undefined => {
832
- if (state.closed || state.response !== undefined) return undefined
833
- const next = state.queued.find((item) => item.generate !== false)
834
- if (next === undefined) return undefined
835
- return {
836
- type: 'ai.generation.requested',
837
- id: `ai.generate:message:${next.messageId}`,
838
- payload: { messageId: next.messageId, reason: 'message' },
839
- } as AppendInput<D>
840
- }
841
-
842
- const scheduleNext = async (
843
- ctx: Pick<HandlerContext<D>, 'session'>,
844
- ): Promise<AppendInput<D> | void> =>
845
- requestForNextMessage(
846
- (await ctx.session.state(coordinator, { through: 'latest' })).state,
847
- )
848
-
849
- const continueIfReady = async (
850
- ctx: Pick<HandlerContext<D>, 'session'>,
851
- generationId: string,
852
- ): Promise<void> => {
853
- const state = (await ctx.session.state(coordinator, { through: 'latest' }))
854
- .state
855
- const response = state.response
856
- if (response?.generation?.generationId !== generationId) {
857
- return
858
- }
859
- const input = response.inputResponse
860
- const inputReady =
861
- response.completion?.generationId === generationId &&
862
- response.failure === undefined &&
863
- input !== undefined &&
864
- response.inputs.length === 0 &&
865
- response.calls.every(
866
- (call) =>
867
- call.terminal ||
868
- (call.call.providerExecuted === true &&
869
- call.call.supportsDeferredResults !== true &&
870
- call.approval !== undefined &&
871
- call.response !== undefined),
872
- )
873
- if (inputReady) {
874
- await ctx.session.append('continue-after-input', {
875
- type: 'ai.generation.requested',
876
- id: `ai.generate:input:${encodeURIComponent(response.responseMessageId)}:${encodeURIComponent(input.generationId)}:${encodeURIComponent(input.inputId)}`,
877
- payload: {
878
- messageId: response.responseMessageId,
879
- responseMessageId: response.responseMessageId,
880
- reason: 'input',
881
- },
882
- } as AppendInput<D>)
883
- return
884
- }
885
- if (!continuationReady(state)) return
886
- await ctx.session.append('continue-after-tools', {
887
- type: 'ai.generation.requested',
888
- id: `ai.generate:tools:${generationId}`,
889
- payload: {
890
- messageId: response.responseMessageId,
891
- responseMessageId: response.responseMessageId,
892
- reason: 'tool',
893
- },
894
- } as AppendInput<D>)
895
- }
896
-
897
1108
  type RuntimeTool = {
898
1109
  execute?: (
899
1110
  input: unknown,
@@ -905,9 +1116,7 @@ export function createHandlers<
905
1116
  ) => unknown
906
1117
  }
907
1118
 
908
- type ToolHandlerContext =
909
- | HandlerContext<D, 'ai.tool.called'>
910
- | HandlerContext<D, 'ai.approval.responded'>
1119
+ type ToolHandlerContext = HandlerContext<D, 'ai.tool.execution.requested'>
911
1120
 
912
1121
  const resultEvent = (
913
1122
  call: ToolCalledPayload,
@@ -946,49 +1155,48 @@ export function createHandlers<
946
1155
  },
947
1156
  }) as AppendInput<D>
948
1157
 
949
- const promptMessages = (
950
- sessionId: string,
951
- call: ToolCalledPayload,
952
- readHistory: () => Promise<ContractEvent<D>[]>,
953
- ): Promise<ModelMessage[]> => {
954
- const key = promptCacheKey(sessionId, call.generationId)
1158
+ const promptMessages = (input: {
1159
+ ctx: ToolHandlerContext
1160
+ call: ToolCalledPayload
1161
+ generation: GenerationStartedPayload
1162
+ }): Promise<ModelMessage[]> => {
1163
+ const { ctx, call, generation: owner } = input
1164
+ const key = promptCacheKey(ctx.event.sessionId, call.generationId)
955
1165
  const cached = promptCache.get(key)
956
1166
  if (cached) return cached
957
1167
  const computation = (async () => {
958
- const history = await readHistory()
959
- const coordinatorState = coordinatorStateAt(history)
960
- const compaction = history.find(
1168
+ const frontier = owner.promptThroughIndex
1169
+ if (frontier === undefined)
1170
+ throw new TypeError('AI work requires a prompt checkpoint')
1171
+ const [snapshot, atPrompt, tail] = await Promise.all([
1172
+ ctx.session.state(options.agent.reducer, { through: frontier }),
1173
+ ctx.session.state(coordinator, { through: frontier }),
1174
+ ctx.session.history({ gte: frontier + 1, lte: ctx.event.index }),
1175
+ ])
1176
+ const appended = tail.filter(
961
1177
  (event) =>
962
- event.type === 'ai.compaction.completed' &&
963
- (event.payload as CompactionCompletedPayload<M>).generationId ===
1178
+ (event.type === 'ai.generation.started' ||
1179
+ event.type === 'ai.compaction.requested' ||
1180
+ event.type === 'ai.compaction.completed') &&
1181
+ (event.payload as { generationId: string }).generationId ===
964
1182
  call.generationId,
965
1183
  )
966
- if (compaction !== undefined) {
967
- const messages = (
968
- compaction.payload as CompactionCompletedPayload<M>
969
- ).messages.filter(
970
- (message) =>
971
- !coordinatorState.queued.some(
972
- (queued) => queued.messageId === message.id,
973
- ),
974
- )
975
- return convertToModelMessages(messages, { tools })
976
- }
977
- const started = history.find(
978
- (event) =>
979
- event.type === 'ai.generation.started' &&
980
- (event.payload as GenerationStartedPayload).generationId ===
981
- call.generationId,
982
- )
983
- const frontier = started?.index ?? Number.POSITIVE_INFINITY
984
- const state = replay(
985
- options.agent,
986
- history.filter((event) => event.index < frontier),
987
- )
988
- return convertToModelMessages(
989
- activeContextMessages(state, coordinatorState),
990
- { tools },
991
- )
1184
+ const state = foldAIEvents({
1185
+ agent: options.agent,
1186
+ events: appended,
1187
+ state: snapshot.state,
1188
+ })
1189
+ return modelContext({
1190
+ messages: await contextMessages({
1191
+ agent: options.agent,
1192
+ session: ctx.session,
1193
+ snapshot,
1194
+ coordinator: atPrompt.state,
1195
+ appended,
1196
+ }),
1197
+ state,
1198
+ tools,
1199
+ })
992
1200
  })()
993
1201
  promptCache.set(key, computation)
994
1202
  void computation.catch(() => {
@@ -1003,9 +1211,9 @@ export function createHandlers<
1003
1211
  error: unknown,
1004
1212
  ): AppendInput<D> | void => {
1005
1213
  const schedulerFailure = consumeSchedulerSendFailure(error)
1006
- if (ctx.signal.aborted) return
1214
+ if (checkAbort(ctx.signal)) return
1007
1215
  if (schedulerFailure === 'retryable') throw error
1008
- return resultEvent(call, 'execution:error', {
1216
+ return resultEvent(call, `${ctx.event.id}:execution:error`, {
1009
1217
  error: errorMessage(error),
1010
1218
  })
1011
1219
  }
@@ -1033,69 +1241,73 @@ export function createHandlers<
1033
1241
  return toolExecutionFailure(ctx, call, error)
1034
1242
  }
1035
1243
  if (iterator === undefined) {
1036
- return resultEvent(call, 'execution:0', { output })
1244
+ return resultEvent(call, `${ctx.event.id}:execution:0`, { output })
1037
1245
  }
1038
1246
  let last: unknown
1039
1247
  let sequence = 0
1040
- for (;;) {
1041
- let result: IteratorResult<unknown>
1042
- try {
1043
- result = await iterator.next()
1044
- } catch (error) {
1045
- return toolExecutionFailure(ctx, call, error)
1046
- }
1047
- if (result.done) break
1048
- if (ctx.signal.aborted) {
1248
+ let done = false
1249
+ try {
1250
+ for (;;) {
1251
+ let result: IteratorResult<unknown>
1049
1252
  try {
1050
- await iterator.return?.()
1051
- } catch {
1052
- // The aborted handler cannot record an iterator cleanup failure.
1253
+ result = await iterator.next()
1254
+ } catch (error) {
1255
+ return toolExecutionFailure(ctx, call, error)
1053
1256
  }
1054
- return
1257
+ if (result.done) {
1258
+ if (ctx.signal.reason instanceof A2Error) throw ctx.signal.reason
1259
+ done = true
1260
+ break
1261
+ }
1262
+ if (checkAbort(ctx.signal)) return
1263
+ last = result.value
1264
+ await control.append({
1265
+ ctx,
1266
+ name: `tool:${call.toolCallId}:preliminary:${sequence}`,
1267
+ events: [
1268
+ resultEvent(
1269
+ call,
1270
+ `${ctx.event.id}:execution:${ctx.attempt}:${sequence}:preliminary`,
1271
+ {
1272
+ output: result.value,
1273
+ preliminary: true,
1274
+ },
1275
+ ),
1276
+ ],
1277
+ })
1278
+ sequence += 1
1055
1279
  }
1056
- last = result.value
1057
- try {
1058
- await ctx.session.append(
1059
- `tool:${call.toolCallId}:preliminary:${sequence}`,
1060
- resultEvent(call, `execution:${sequence}:preliminary`, {
1061
- output: result.value,
1062
- preliminary: true,
1063
- }),
1064
- )
1065
- } catch (error) {
1280
+ } finally {
1281
+ if (!done) {
1066
1282
  try {
1067
1283
  await iterator.return?.()
1068
1284
  } catch {
1069
- // Preserve the append failure that makes the durable handler retry.
1285
+ // Preserve the failure or cancellation that ended execution.
1070
1286
  }
1071
- throw error
1072
1287
  }
1073
- sequence += 1
1074
1288
  }
1075
1289
  return resultEvent(
1076
1290
  call,
1077
- `execution:${sequence}:final`,
1291
+ `${ctx.event.id}:execution:${sequence}:final`,
1078
1292
  sequence === 0 ? {} : { output: last },
1079
1293
  )
1080
1294
  }
1081
1295
 
1082
- const executeTool = async (
1083
- ctx: ToolHandlerContext,
1084
- call: ToolCalledPayload,
1085
- ): Promise<AppendInput<D> | void> => {
1296
+ const executeTool = async (input: {
1297
+ ctx: ToolHandlerContext
1298
+ call: ToolCalledPayload
1299
+ generation: GenerationStartedPayload
1300
+ }): Promise<AppendInput<D> | void> => {
1301
+ const { ctx, call } = input
1086
1302
  const tool = tools[call.toolName] as RuntimeTool | undefined
1087
1303
  const execute = tool?.execute
1088
1304
  if (execute === undefined) {
1089
- return resultEvent(call, 'execution:error', {
1305
+ return resultEvent(call, `${ctx.event.id}:execution:error`, {
1090
1306
  error: `Tool '${call.toolName}' has no server executor`,
1091
1307
  })
1092
1308
  }
1093
- const messages = await promptMessages(
1094
- ctx.event.sessionId,
1095
- call,
1096
- ctx.session.history,
1097
- )
1098
- if (ctx.signal.aborted) return
1309
+ const messages = await promptMessages(input)
1310
+ if (checkAbort(ctx.signal)) return
1099
1311
  const scope: AmbientToolScope = {
1100
1312
  contract: options.agent.contract,
1101
1313
  context: ctx,
@@ -1107,100 +1319,77 @@ export function createHandlers<
1107
1319
  )
1108
1320
  }
1109
1321
 
1110
- const handleToolCall = async (
1111
- ctx: HandlerContext<D, 'ai.tool.called'>,
1112
- ): Promise<AppendInput<D> | void> => {
1113
- const state = (await ctx.session.state(coordinator, { through: 'latest' }))
1114
- .state
1115
- const response = state.response
1116
- const current = response?.calls.find(
1117
- (candidate) => candidate.index === ctx.event.index,
1118
- )
1119
- if (
1120
- current === undefined ||
1121
- response?.generation?.generationId !== ctx.event.payload.generationId ||
1122
- response.failure !== undefined ||
1123
- current.terminal ||
1124
- current.approval !== undefined
1125
- ) {
1126
- return
1127
- }
1128
- if (current.call.providerExecuted === true) {
1129
- await continueIfReady(ctx, current.call.generationId)
1130
- return
1131
- }
1132
- return executeTool(ctx, current.call)
1133
- }
1134
-
1135
- const handleApproval = async (
1136
- ctx: HandlerContext<D, 'ai.approval.responded'>,
1137
- ): Promise<AppendInput<D> | void> => {
1138
- const state = (await ctx.session.state(coordinator, { through: 'latest' }))
1139
- .state
1140
- const response = state.response
1141
- const current = response?.calls.find(
1142
- (candidate) =>
1143
- candidate.approval?.approvalId === ctx.event.payload.approvalId &&
1144
- candidate.approval.messageId === ctx.event.payload.messageId &&
1145
- candidate.approval.generationId === ctx.event.payload.generationId &&
1146
- candidate.responseIndex === ctx.event.index,
1147
- )
1148
- if (
1149
- current === undefined ||
1150
- response?.generation?.generationId !== current.call.generationId ||
1151
- response.failure !== undefined ||
1152
- current.terminal
1153
- ) {
1154
- return
1155
- }
1156
- if (current.call.providerExecuted === true) {
1157
- await continueIfReady(ctx, current.call.generationId)
1158
- return
1159
- }
1160
- if (!ctx.event.payload.approved) {
1161
- return resultEvent(current.call, 'execution:denied', { denied: true })
1322
+ const handleToolExecution = async (
1323
+ ctx: ToolHandlerContext,
1324
+ ): Promise<AppendInput<D>> => {
1325
+ try {
1326
+ const state = (
1327
+ await ctx.session.state(control.reducer, { through: 'latest' })
1328
+ ).state
1329
+ const request = ctx.event.payload
1330
+ const call = state.coordinator.response?.calls.find(
1331
+ (candidate) => candidate.work?.id === ctx.event.id,
1332
+ )
1333
+ if (
1334
+ state.active?.turnId !== request.turnId ||
1335
+ state.active.suspended ||
1336
+ state.active.version !== request.version ||
1337
+ state.coordinator.response?.failure !== undefined ||
1338
+ !call ||
1339
+ call.terminal ||
1340
+ call.work?.settled
1341
+ )
1342
+ return control.settled({ ctx, events: undefined })
1343
+ return control.settled({
1344
+ ctx,
1345
+ events: await executeTool({
1346
+ ctx,
1347
+ call: request.call,
1348
+ generation: request.generation,
1349
+ }),
1350
+ })
1351
+ } catch (error) {
1352
+ if (control.cancelled({ error, signal: ctx.signal }))
1353
+ return control.settled({ ctx, events: undefined })
1354
+ throw error
1162
1355
  }
1163
- return executeTool(ctx, current.call)
1164
1356
  }
1165
1357
 
1166
1358
  const generationHandler = async (
1167
1359
  ctx: HandlerContext<D, 'ai.generation.requested'>,
1168
1360
  ): Promise<void | AppendInput<D> | readonly AppendInput<D>[]> => {
1169
- const history = await ctx.session.history()
1361
+ if (checkAbort(ctx.signal)) return
1170
1362
  const requestId = ctx.event.id
1171
1363
  const request = ctx.event.payload
1364
+ const snapshot = await ctx.session.state(options.agent.reducer, {
1365
+ through: 'latest',
1366
+ })
1367
+ if (
1368
+ snapshot.state.active?.phase === 'paused' ||
1369
+ snapshot.state.active?.phase === 'pausing'
1370
+ )
1371
+ return
1172
1372
  const coordinatorState = (
1173
- await ctx.session.state(coordinator, { through: 'latest' })
1373
+ await ctx.session.state(coordinator, { through: snapshot.index })
1174
1374
  ).state
1175
1375
  if (
1176
1376
  coordinatorState.closed ||
1177
1377
  coordinatorState.response?.activeRequestId !== requestId
1178
- ) {
1179
- return
1180
- }
1181
- const terminal = history.some((event) => {
1182
- if (event.type === 'ai.generation.completed') {
1183
- return (
1184
- (event.payload as GenerationCompletedPayload).requestId === requestId
1185
- )
1186
- }
1187
- if (event.type !== 'ai.generation.failed') return false
1188
- const payload = event.payload as GenerationFailedPayload
1189
- return payload.requestId === requestId && payload.superseded !== true
1190
- })
1191
- if (terminal) return
1192
-
1193
- const previousStarts = history.filter(
1194
- (event) =>
1195
- event.type === 'ai.generation.started' &&
1196
- (event.payload as GenerationStartedPayload).requestId === requestId,
1197
1378
  )
1198
- const priorAttempts = previousStarts.map(
1199
- (event) => (event.payload as GenerationStartedPayload).attempt,
1379
+ return
1380
+ const response = coordinatorState.response
1381
+ const current =
1382
+ response.generation?.requestId === requestId
1383
+ ? response.generation
1384
+ : undefined
1385
+ if (
1386
+ current !== undefined &&
1387
+ (response.completion !== undefined ||
1388
+ (response.failure !== undefined &&
1389
+ response.failure.superseded !== true))
1200
1390
  )
1201
- if (priorAttempts.some((priorAttempt) => priorAttempt >= ctx.attempt)) {
1202
1391
  return
1203
- }
1392
+ if (current !== undefined && current.attempt >= ctx.attempt) return
1204
1393
  const attempt = ctx.attempt
1205
1394
  const generationId = `${requestId}:generation:${attempt}`
1206
1395
  const responseMessageId =
@@ -1208,202 +1397,363 @@ export function createHandlers<
1208
1397
  (request.reason === 'message'
1209
1398
  ? `${request.messageId}:assistant`
1210
1399
  : request.messageId)
1211
-
1212
- const responseStepCount = history.filter(
1213
- (event) =>
1214
- event.type === 'ai.generation.completed' &&
1215
- (event.payload as GenerationCompletedPayload).responseMessageId ===
1216
- responseMessageId,
1217
- ).length
1218
-
1219
- const sourceCoordinatorState = coordinatorStateAt(history, ctx.event.index)
1220
- if (request.reason === 'tool') {
1221
- const prefix = 'ai.generate:tools:'
1222
- if (!requestId.startsWith(prefix)) return
1223
- const sourceGenerationId = requestId.slice(prefix.length)
1224
- if (
1225
- !continuationReady(sourceCoordinatorState) ||
1226
- sourceCoordinatorState.response?.generation?.generationId !==
1227
- sourceGenerationId ||
1228
- sourceCoordinatorState.response.responseMessageId !==
1229
- request.messageId ||
1230
- sourceCoordinatorState.response.responseMessageId !==
1231
- request.responseMessageId
1232
- ) {
1233
- return
1234
- }
1235
- }
1236
-
1237
- const previous = previousStarts
1238
- .filter(
1239
- (event) =>
1240
- (event.payload as GenerationStartedPayload).attempt < attempt,
1241
- )
1242
- .toSorted(
1243
- (left, right) =>
1244
- (right.payload as GenerationStartedPayload).attempt -
1245
- (left.payload as GenerationStartedPayload).attempt,
1246
- )[0]
1247
-
1248
- const incompleteId = previous
1249
- ? (previous.payload as GenerationStartedPayload).generationId
1250
- : undefined
1251
- const replacedGenerationIds = new Set<string>()
1252
- if (incompleteId !== undefined) replacedGenerationIds.add(incompleteId)
1400
+ const responseStepCount = response.stepCount
1253
1401
  if (
1254
- request.reason === 'retry' &&
1255
- sourceCoordinatorState.response?.failure?.generationId !== undefined
1256
- ) {
1257
- replacedGenerationIds.add(
1258
- sourceCoordinatorState.response.failure.generationId,
1259
- )
1402
+ request.reason === 'tool' &&
1403
+ (requestId !==
1404
+ `ai.generate:tools:${response.source?.generation.generationId}` ||
1405
+ response.responseMessageId !== request.messageId ||
1406
+ response.responseMessageId !== request.responseMessageId)
1407
+ )
1408
+ return
1409
+ const previous = current
1410
+ const replaced =
1411
+ previous === undefined
1412
+ ? []
1413
+ : [{ generation: previous, frontier: response.promptThroughIndex! }]
1414
+ if (request.reason === 'retry' && response.source?.failed) {
1415
+ replaced.push({
1416
+ generation: response.source.generation,
1417
+ frontier: response.source.promptThroughIndex,
1418
+ })
1260
1419
  }
1261
- const promptHistory = withoutGenerationLifecycle(
1262
- history,
1263
- replacedGenerationIds,
1420
+ const replacedGenerationIds = new Set(
1421
+ replaced.map(({ generation: owner }) => owner.generationId),
1264
1422
  )
1265
- let state = replay(options.agent, promptHistory)
1423
+ let state = snapshot.state
1424
+ if (replaced.length > 0) {
1425
+ const through = Math.min(...replaced.map(({ frontier }) => frontier))
1426
+ const baseline = await ctx.session.state(options.agent.reducer, {
1427
+ through,
1428
+ })
1429
+ const tail = await ctx.session.history({
1430
+ gte: through + 1,
1431
+ lte: snapshot.index,
1432
+ })
1433
+ state = foldAIEvents({
1434
+ agent: options.agent,
1435
+ events: withoutGenerationLifecycle(tail, replacedGenerationIds),
1436
+ state: baseline.state,
1437
+ })
1438
+ }
1266
1439
  const resolverContext: AgentResolverContext<M, D> = {
1267
1440
  event: ctx.event,
1268
1441
  state,
1269
- history,
1442
+ session: {
1443
+ state: (reducer, readOptions) =>
1444
+ ctx.session.state(reducer, {
1445
+ ...readOptions,
1446
+ through: readOptions?.through ?? snapshot.index,
1447
+ }),
1448
+ },
1270
1449
  signal: ctx.signal,
1271
1450
  }
1272
1451
 
1273
- const resolvedModel = await resolve(options.model, resolverContext)
1274
- if (resolvedModel === undefined) {
1275
- throw new TypeError('the model resolver returned undefined')
1276
- }
1277
- if (ctx.signal.aborted) return
1278
-
1279
- if (request.reason === 'tool' && responseStepCount >= maxSteps) {
1280
- const source = sourceCoordinatorState.response?.generation
1281
- if (source === undefined) return
1282
- return {
1283
- type: 'ai.generation.failed',
1284
- id: `${requestId}:step-limit`,
1285
- payload: {
1286
- requestId: source.requestId,
1287
- messageId: source.messageId,
1288
- generationId: source.generationId,
1289
- responseMessageId,
1290
- error: `agent exceeded the ${maxSteps}-step limit`,
1291
- stepLimit: true,
1292
- },
1293
- } as AppendInput<D>
1294
- }
1452
+ let generationStarted = false
1453
+ try {
1454
+ const resolvedModel = await resolve(options.model, resolverContext)
1455
+ if (resolvedModel === undefined) {
1456
+ throw new TypeError('the model resolver returned undefined')
1457
+ }
1458
+ if (checkAbort(ctx.signal)) return
1459
+ const resolvedInstructions =
1460
+ options.instructions === undefined
1461
+ ? undefined
1462
+ : await resolve(options.instructions, resolverContext)
1463
+ if (checkAbort(ctx.signal)) return
1464
+ const compaction =
1465
+ typeof configuredCompaction === 'function'
1466
+ ? validateCompaction({
1467
+ compaction: await resolve(configuredCompaction, resolverContext),
1468
+ explicit: true,
1469
+ generation: options.generation,
1470
+ tools: options.tools,
1471
+ })
1472
+ : staticCompaction!
1473
+ if (checkAbort(ctx.signal)) return
1474
+
1475
+ if (request.reason === 'tool' && responseStepCount >= maxSteps) {
1476
+ const source = response.source?.generation
1477
+ if (source === undefined) return
1478
+ return {
1479
+ type: 'ai.generation.failed',
1480
+ id: `${requestId}:step-limit`,
1481
+ payload: {
1482
+ requestId: source.requestId,
1483
+ messageId: source.messageId,
1484
+ generationId: source.generationId,
1485
+ responseMessageId,
1486
+ error: `agent exceeded the ${maxSteps}-step limit`,
1487
+ stepLimit: true,
1488
+ },
1489
+ } as AppendInput<D>
1490
+ }
1295
1491
 
1296
- const started: GenerationStartedPayload = {
1297
- requestId,
1298
- messageId: request.messageId,
1299
- generationId,
1300
- responseMessageId,
1301
- attempt,
1302
- model: modelName(resolvedModel),
1303
- }
1304
- const startEvents: AppendInput<D>[] = []
1305
- if (previous) {
1306
- const payload = previous.payload as GenerationStartedPayload
1307
- const superseded: GenerationFailedPayload = {
1492
+ const started: GenerationStartedPayload = {
1308
1493
  requestId,
1309
- messageId: payload.messageId,
1310
- generationId: payload.generationId,
1311
- responseMessageId: payload.responseMessageId,
1312
- error: 'generation attempt was superseded after an incomplete run',
1313
- superseded: true,
1494
+ messageId: request.messageId,
1495
+ generationId,
1496
+ responseMessageId,
1497
+ attempt,
1498
+ model: modelName(resolvedModel),
1499
+ promptThroughIndex: snapshot.index,
1500
+ }
1501
+ const catalogModelId = gatewayModelId(resolvedModel)
1502
+ const gatewayOptions = options.generation?.providerOptions?.['gateway']
1503
+ const usesFallbackModels =
1504
+ Array.isArray(gatewayOptions?.['models']) &&
1505
+ gatewayOptions['models'].length > 0
1506
+ const discoversMetadata =
1507
+ compaction !== false &&
1508
+ !('shouldCompact' in compaction) &&
1509
+ compaction.thresholdTokens === undefined &&
1510
+ !usesFallbackModels &&
1511
+ catalogModelId !== undefined
1512
+ const startEvents: AppendInput<D>[] = []
1513
+ if (
1514
+ discoversMetadata &&
1515
+ !Object.hasOwn(state.modelMetadata, catalogModelId)
1516
+ ) {
1517
+ startEvents.push({
1518
+ type: 'ai.model.metadata.requested',
1519
+ id: `ai.model.metadata:${encodeURIComponent(options.agent.contract.name)}:${encodeURIComponent(ctx.event.sessionId)}:${encodeURIComponent(catalogModelId)}`,
1520
+ payload: { modelId: catalogModelId },
1521
+ } as AppendInput<D>)
1522
+ }
1523
+ if (previous) {
1524
+ const payload = previous
1525
+ const superseded: GenerationFailedPayload = {
1526
+ requestId,
1527
+ messageId: payload.messageId,
1528
+ generationId: payload.generationId,
1529
+ responseMessageId: payload.responseMessageId,
1530
+ error: 'generation attempt was superseded after an incomplete run',
1531
+ superseded: true,
1532
+ }
1533
+ startEvents.push({
1534
+ type: 'ai.generation.failed',
1535
+ id: `${payload.generationId}:superseded`,
1536
+ payload: superseded,
1537
+ } as AppendInput<D>)
1314
1538
  }
1315
1539
  startEvents.push({
1316
- type: 'ai.generation.failed',
1317
- id: `${payload.generationId}:superseded`,
1318
- payload: superseded,
1540
+ type: 'ai.generation.started',
1541
+ id: generationId,
1542
+ payload: started,
1319
1543
  } as AppendInput<D>)
1320
- }
1321
- startEvents.push({
1322
- type: 'ai.generation.started',
1323
- id: generationId,
1324
- payload: started,
1325
- } as AppendInput<D>)
1326
- await ctx.session.append('generation-start', ...startEvents)
1327
-
1328
- const startedCoordinatorState = (
1329
- await ctx.session.state(coordinator, { through: 'latest' })
1330
- ).state
1331
- if (
1332
- startedCoordinatorState.response?.activeRequestId !== requestId ||
1333
- startedCoordinatorState.response.generation?.generationId !== generationId
1334
- ) {
1335
- return
1336
- }
1544
+ const generationEvents = await control.append({
1545
+ ctx,
1546
+ name: 'generation-start',
1547
+ events: startEvents,
1548
+ })
1549
+ generationStarted = true
1337
1550
 
1338
- const promptCoordinatorState = {
1339
- ...coordinatorStateAt(history),
1340
- queued: queuedMessagesAt(history),
1341
- }
1342
- let messages = activeContextMessages(state, promptCoordinatorState)
1343
- if (options.compaction) {
1344
- const compactionContext = { ...resolverContext, messages }
1345
- if (await options.compaction.shouldCompact(compactionContext)) {
1346
- const throughMessageId = messages.at(-1)?.id ?? request.messageId
1347
- await ctx.session.append('compaction-requested', {
1348
- type: 'ai.compaction.requested',
1349
- id: `${generationId}:compaction:requested`,
1350
- payload: { generationId, throughMessageId },
1351
- })
1352
- messages = await options.compaction.compact(compactionContext)
1353
- const completed: CompactionCompletedPayload<M> = {
1354
- generationId,
1355
- throughMessageId,
1551
+ if (checkAbort(ctx.signal)) return
1552
+
1553
+ const promptCoordinatorState = coordinatorState
1554
+ const messages = await contextMessages({
1555
+ agent: options.agent,
1556
+ session: ctx.session,
1557
+ snapshot: { state, index: snapshot.index },
1558
+ coordinator: promptCoordinatorState,
1559
+ excluded: replacedGenerationIds,
1560
+ })
1561
+ let generationMessages = messages
1562
+ let modelMessages = await modelContext({ messages, state, tools })
1563
+ const baseContext: AgentGenerateContext<M, D, T> = {
1564
+ request: ctx.event,
1565
+ requestId,
1566
+ generationId,
1567
+ responseMessageId,
1568
+ messages,
1569
+ modelMessages,
1570
+ state,
1571
+ session: resolverContext.session,
1572
+ signal: ctx.signal,
1573
+ model: resolvedModel,
1574
+ tools,
1575
+ ...(resolvedInstructions === undefined
1576
+ ? {}
1577
+ : { instructions: resolvedInstructions }),
1578
+ generation,
1579
+ }
1580
+ const policy = compaction
1581
+ const tracksInput =
1582
+ policy !== false &&
1583
+ !('shouldCompact' in policy) &&
1584
+ (policy.thresholdTokens !== undefined || discoversMetadata)
1585
+ const metadata =
1586
+ catalogModelId === undefined
1587
+ ? undefined
1588
+ : state.modelMetadata[catalogModelId]
1589
+ const limits =
1590
+ metadata?.status === 'resolved' && !usesFallbackModels
1591
+ ? metadata.limits
1592
+ : undefined
1593
+ let inputTokenEstimate: number | undefined
1594
+ let compacted = false
1595
+ const canCompact = response.source?.canCompact ?? true
1596
+ if (policy && canCompact) {
1597
+ const compactionContext = {
1598
+ ...resolverContext,
1356
1599
  messages,
1357
- ...(promptCoordinatorState.queued.length === 0
1358
- ? {}
1359
- : {
1360
- retainedMessageIds: promptCoordinatorState.queued.map(
1361
- (item) => item.messageId,
1362
- ),
1363
- }),
1600
+ modelMessages,
1364
1601
  }
1365
- await ctx.session.append('compaction-completed', {
1366
- type: 'ai.compaction.completed',
1367
- id: `${generationId}:compaction:completed`,
1368
- payload: completed,
1369
- })
1370
- state = {
1371
- ...state,
1372
- compaction: { status: 'completed', ...completed },
1602
+ let shouldCompact: boolean
1603
+ if ('shouldCompact' in policy) {
1604
+ shouldCompact = await policy.shouldCompact(compactionContext)
1605
+ } else if (tracksInput) {
1606
+ inputTokenEstimate = await estimateInputTokens({
1607
+ messages: modelMessages,
1608
+ instructions: resolvedInstructions,
1609
+ tools,
1610
+ })
1611
+ const inputTokens = measuredInputTokens({
1612
+ estimate: inputTokenEstimate,
1613
+ model: modelName(resolvedModel),
1614
+ calibration: coordinatorState.calibration,
1615
+ })
1616
+ const threshold =
1617
+ policy.thresholdTokens ??
1618
+ (limits === undefined
1619
+ ? undefined
1620
+ : Math.floor(
1621
+ Math.min(
1622
+ limits.contextWindow * 0.75,
1623
+ limits.contextWindow - (generation.maxOutputTokens ?? 0),
1624
+ ),
1625
+ ))
1626
+ if (
1627
+ limits !== undefined &&
1628
+ threshold !== undefined &&
1629
+ threshold <= 0
1630
+ ) {
1631
+ throw new Error(
1632
+ `output allowance exhausts the ${limits.contextWindow}-token context window`,
1633
+ )
1634
+ }
1635
+ shouldCompact = threshold !== undefined && inputTokens >= threshold
1636
+ } else {
1637
+ shouldCompact = false
1638
+ }
1639
+ if (checkAbort(ctx.signal)) return
1640
+ if (shouldCompact) {
1641
+ const throughMessageId =
1642
+ state.messages.findLast(
1643
+ (message) =>
1644
+ !promptCoordinatorState.queued.some(
1645
+ (queued) => queued.messageId === message.id,
1646
+ ),
1647
+ )?.id ?? request.messageId
1648
+ const throughIndex = snapshot.index
1649
+ generationEvents.push(
1650
+ ...(await control.append({
1651
+ ctx,
1652
+ name: 'compaction-requested',
1653
+ events: [
1654
+ {
1655
+ type: 'ai.compaction.requested',
1656
+ id: `${generationId}:compaction:requested`,
1657
+ payload: { generationId, throughMessageId, throughIndex },
1658
+ } as AppendInput<D>,
1659
+ ],
1660
+ })),
1661
+ )
1662
+ const result = !('shouldCompact' in policy)
1663
+ ? {
1664
+ messages: [] as M[],
1665
+ ...(await summarize({
1666
+ context: baseContext,
1667
+ instructions: policy.instructions,
1668
+ })),
1669
+ }
1670
+ : { messages: await policy.compact(compactionContext) }
1671
+ if (checkAbort(ctx.signal)) return
1672
+ const completed: CompactionCompletedPayload<M> = {
1673
+ generationId,
1674
+ throughMessageId,
1675
+ throughIndex,
1676
+ ...result,
1677
+ ...(promptCoordinatorState.queued.length === 0
1678
+ ? {}
1679
+ : {
1680
+ retainedMessageIds: promptCoordinatorState.queued.map(
1681
+ (item) => item.messageId,
1682
+ ),
1683
+ }),
1684
+ }
1685
+ generationEvents.push(
1686
+ ...(await control.append({
1687
+ ctx,
1688
+ name: 'compaction-completed',
1689
+ events: [
1690
+ {
1691
+ type: 'ai.compaction.completed',
1692
+ id: `${generationId}:compaction:completed`,
1693
+ payload: completed,
1694
+ } as AppendInput<D>,
1695
+ ],
1696
+ })),
1697
+ )
1698
+ const queued = new Set(
1699
+ promptCoordinatorState.queued.map((item) => item.messageId),
1700
+ )
1701
+ generationMessages = result.messages.filter(
1702
+ (message) => !queued.has(message.id),
1703
+ )
1704
+ compacted = true
1373
1705
  }
1374
1706
  }
1375
- }
1376
1707
 
1377
- const currentState = (
1378
- await ctx.session.state(options.agent.reducer, { through: 'latest' })
1379
- ).state
1380
- const currentCoordinatorState = (
1381
- await ctx.session.state(coordinator, { through: 'latest' })
1382
- ).state
1383
- const generationMessages = activeContextMessages(
1384
- currentState,
1385
- currentCoordinatorState,
1386
- )
1387
- promptCache.set(
1388
- promptCacheKey(ctx.event.sessionId, generationId),
1389
- convertToModelMessages(generationMessages, { tools }),
1390
- )
1708
+ const currentState = foldAIEvents({
1709
+ agent: options.agent,
1710
+ events: generationEvents,
1711
+ state: snapshot.state,
1712
+ })
1713
+ if (request.reason === 'retry' || previous !== undefined) {
1714
+ generationMessages = await contextMessages({
1715
+ agent: options.agent,
1716
+ session: ctx.session,
1717
+ snapshot,
1718
+ coordinator: promptCoordinatorState,
1719
+ appended: generationEvents,
1720
+ })
1721
+ }
1722
+ if (
1723
+ (policy && 'shouldCompact' in policy) ||
1724
+ generationMessages !== messages ||
1725
+ activeCompaction(currentState)?.summary !==
1726
+ activeCompaction(state)?.summary
1727
+ ) {
1728
+ modelMessages = await modelContext({
1729
+ messages: generationMessages,
1730
+ state: currentState,
1731
+ tools,
1732
+ })
1733
+ }
1734
+ promptCache.set(
1735
+ promptCacheKey(ctx.event.sessionId, generationId),
1736
+ Promise.resolve(modelMessages),
1737
+ )
1738
+ if (tracksInput && (inputTokenEstimate === undefined || compacted)) {
1739
+ inputTokenEstimate = await estimateInputTokens({
1740
+ messages: modelMessages,
1741
+ instructions: resolvedInstructions,
1742
+ tools,
1743
+ })
1744
+ }
1391
1745
 
1392
- let pendingToolCalls: PendingToolCall[] = []
1393
- try {
1394
- const resolvedInstructions =
1395
- options.instructions === undefined
1396
- ? undefined
1397
- : await resolve(options.instructions, resolverContext)
1398
- if (ctx.signal.aborted) return
1746
+ let pendingToolCalls: PendingToolCall[] = []
1747
+ if (checkAbort(ctx.signal)) return
1399
1748
  const generateContext: AgentGenerateContext<M, D, T> = {
1400
1749
  request: ctx.event,
1401
1750
  requestId,
1402
1751
  generationId,
1403
1752
  responseMessageId,
1404
1753
  messages: generationMessages,
1754
+ modelMessages,
1405
1755
  state: currentState,
1406
- history,
1756
+ session: resolverContext.session,
1407
1757
  signal: ctx.signal,
1408
1758
  model: resolvedModel,
1409
1759
  tools,
@@ -1452,15 +1802,18 @@ export function createHandlers<
1452
1802
  custom,
1453
1803
  })
1454
1804
  pendingToolCalls = lifecycle.pending
1455
- await ctx.session.append(
1456
- `generation-progress:${sequence}`,
1457
- {
1458
- type: 'ai.generation.progress',
1459
- id: `${generationId}:progress:${sequence}`,
1460
- payload: progress,
1461
- },
1462
- ...lifecycle.events,
1463
- )
1805
+ await control.append({
1806
+ ctx,
1807
+ name: `generation-progress:${sequence}`,
1808
+ events: [
1809
+ {
1810
+ type: 'ai.generation.progress',
1811
+ id: `${generationId}:progress:${sequence}`,
1812
+ payload: progress,
1813
+ } as AppendInput<D>,
1814
+ ...lifecycle.events,
1815
+ ],
1816
+ })
1464
1817
  sequence += 1
1465
1818
  }
1466
1819
 
@@ -1470,6 +1823,7 @@ export function createHandlers<
1470
1823
  messageId: request.messageId,
1471
1824
  generationId,
1472
1825
  responseMessageId,
1826
+ ...(inputTokenEstimate === undefined ? {} : { inputTokenEstimate }),
1473
1827
  ...(finish.finishReason === undefined
1474
1828
  ? {}
1475
1829
  : { finishReason: finish.finishReason }),
@@ -1513,19 +1867,13 @@ export function createHandlers<
1513
1867
  } as AppendInput<D>,
1514
1868
  ]
1515
1869
  } catch (error) {
1516
- if (error instanceof A2Error) throw error
1517
- if (ctx.signal.aborted) {
1518
- const interrupted: MessageInterruptedPayload = {
1519
- messageId: responseMessageId,
1520
- generationId,
1521
- reason: 'aborted',
1522
- }
1523
- return {
1524
- type: 'ai.message.interrupted',
1525
- id: `${generationId}:interrupted`,
1526
- payload: interrupted,
1527
- } as AppendInput<D>
1870
+ if (error instanceof ControlCancelled) throw error
1871
+ if (error instanceof A2Error) {
1872
+ checkAbort(ctx.signal)
1873
+ throw error
1528
1874
  }
1875
+ if (checkAbort(ctx.signal)) return
1876
+ if (!generationStarted) throw error
1529
1877
  const failed: GenerationFailedPayload = {
1530
1878
  requestId,
1531
1879
  messageId: request.messageId,
@@ -1541,59 +1889,6 @@ export function createHandlers<
1541
1889
  }
1542
1890
  }
1543
1891
 
1544
- const handleMessageCreated = async (
1545
- ctx: HandlerContext<D, 'ai.message.created'>,
1546
- ): Promise<AppendInput<D> | void> => {
1547
- if (
1548
- ctx.event.payload.message.role !== 'user' ||
1549
- ctx.event.payload.generate === false
1550
- ) {
1551
- return
1552
- }
1553
- return scheduleNext(ctx)
1554
- }
1555
-
1556
- const handleRetry = async (
1557
- ctx: HandlerContext<D, 'ai.retry.requested'>,
1558
- ): Promise<AppendInput<D> | void> => {
1559
- const state = (await ctx.session.state(coordinator, { through: 'latest' }))
1560
- .state
1561
- const response = state.response
1562
- if (
1563
- response?.status !== 'failed' ||
1564
- response.rootMessageId !== ctx.event.payload.messageId ||
1565
- response.responseMessageId !== ctx.event.payload.responseMessageId
1566
- ) {
1567
- return
1568
- }
1569
- return {
1570
- type: 'ai.generation.requested',
1571
- id: `ai.generate:retry:${ctx.event.payload.retryId}`,
1572
- payload: {
1573
- messageId: response.rootMessageId,
1574
- responseMessageId: response.responseMessageId,
1575
- reason: 'retry',
1576
- },
1577
- } as AppendInput<D>
1578
- }
1579
-
1580
- const handleInputResponse = async (
1581
- ctx: HandlerContext<D, 'ai.input.responded'>,
1582
- ): Promise<void> => {
1583
- const state = (await ctx.session.state(coordinator, { through: 'latest' }))
1584
- .state
1585
- const response = state.response
1586
- if (
1587
- response?.responseMessageId !== ctx.event.payload.messageId ||
1588
- response.inputResponse?.index !== ctx.event.index ||
1589
- response.inputResponse.generationId !== ctx.event.payload.generationId ||
1590
- response.inputResponse.inputId !== ctx.event.payload.inputId
1591
- ) {
1592
- return
1593
- }
1594
- await continueIfReady(ctx, ctx.event.payload.generationId)
1595
- }
1596
-
1597
1892
  const clearPromptCache = (sessionId: string): void => {
1598
1893
  const prefix = `${sessionId}\u001f`
1599
1894
  for (const key of promptCache.keys()) {
@@ -1601,32 +1896,38 @@ export function createHandlers<
1601
1896
  }
1602
1897
  }
1603
1898
 
1604
- const handleResponseEnded = async (
1605
- ctx: HandlerContext<D, 'ai.message.completed' | 'ai.message.interrupted'>,
1606
- ): Promise<AppendInput<D> | void> => {
1607
- clearPromptCache(ctx.event.sessionId)
1608
- return scheduleNext(ctx)
1609
- }
1610
-
1611
1899
  return {
1612
- 'ai.message.created': { lane: 'a2.ai.turn', handler: handleMessageCreated },
1613
- 'ai.retry.requested': { lane: 'a2.ai.turn', handler: handleRetry },
1614
- 'ai.input.responded': {
1615
- lane: 'a2.ai.turn',
1616
- handler: handleInputResponse,
1900
+ 'ai.model.metadata.requested': {
1901
+ handler: async (ctx) => {
1902
+ const limits = await readModelLimits({
1903
+ modelId: ctx.event.payload.modelId,
1904
+ signal: ctx.signal,
1905
+ })
1906
+ ctx.signal.throwIfAborted()
1907
+ return {
1908
+ type: 'ai.model.metadata.resolved',
1909
+ id: `${ctx.event.id}:resolved`,
1910
+ payload: { modelId: ctx.event.payload.modelId, limits },
1911
+ } as AppendInput<D>
1912
+ },
1617
1913
  },
1914
+ 'ai.control.requested': { lane: 'a2.ai.control', handler: control.handler },
1915
+ 'ai.work.reported': { lane: 'a2.ai.control', handler: control.handler },
1618
1916
  'ai.message.completed': {
1619
- lane: 'a2.ai.turn',
1620
- handler: handleResponseEnded,
1917
+ handler: async (ctx) => {
1918
+ clearPromptCache(ctx.event.sessionId)
1919
+ },
1621
1920
  },
1622
1921
  'ai.message.interrupted': {
1623
- lane: 'a2.ai.turn',
1624
- handler: handleResponseEnded,
1922
+ handler: async (ctx) => {
1923
+ clearPromptCache(ctx.event.sessionId)
1924
+ },
1625
1925
  },
1626
1926
  'ai.session.closed': {
1627
- handler: (ctx) => {
1927
+ lane: 'a2.ai.control',
1928
+ handler: async (ctx) => {
1628
1929
  clearPromptCache(ctx.event.sessionId)
1629
- return Promise.resolve()
1930
+ return control.handler(ctx)
1630
1931
  },
1631
1932
  },
1632
1933
  'ai.generation.failed': {
@@ -1638,60 +1939,41 @@ export function createHandlers<
1638
1939
  },
1639
1940
  },
1640
1941
  'ai.generation.requested': {
1641
- lane: 'a2.ai.turn',
1942
+ lane: 'a2.ai.model',
1642
1943
  abortOn: {
1643
- 'ai.message.interrupted': (event, trigger, context) => {
1644
- const responseMessageId =
1645
- trigger.payload.responseMessageId ??
1646
- (trigger.payload.reason === 'message'
1647
- ? `${trigger.payload.messageId}:assistant`
1648
- : trigger.payload.messageId)
1944
+ 'ai.control.committed': (event, trigger) => {
1945
+ const active = (event.payload as ControlCommit).view
1649
1946
  return (
1650
- event.payload.messageId === responseMessageId &&
1651
- (event.payload.requestId === trigger.id ||
1652
- event.payload.generationId ===
1653
- `${trigger.id}:generation:${context.attempt}`)
1947
+ active?.turnId !== trigger.payload.control?.turnId ||
1948
+ active?.version !== trigger.payload.control?.version
1654
1949
  )
1655
1950
  },
1656
1951
  'ai.session.closed': true,
1657
1952
  },
1658
- handler: generationHandler,
1659
- },
1660
- 'ai.generation.completed': {
1661
- handler: async (ctx) =>
1662
- continueIfReady(ctx, ctx.event.payload.generationId),
1663
- },
1664
- 'ai.tool.called': {
1665
- abortOn: {
1666
- 'ai.generation.failed': (event, trigger) =>
1667
- event.payload.generationId === trigger.payload.generationId,
1668
- 'ai.message.interrupted': (event, trigger) =>
1669
- event.payload.messageId === trigger.payload.messageId &&
1670
- (event.payload.requestId === trigger.payload.requestId ||
1671
- event.payload.generationId === trigger.payload.generationId),
1672
- 'ai.session.closed': true,
1953
+ handler: async (ctx) => {
1954
+ try {
1955
+ return control.settled({ ctx, events: await generationHandler(ctx) })
1956
+ } catch (error) {
1957
+ if (control.cancelled({ error, signal: ctx.signal }))
1958
+ return control.settled({ ctx, events: undefined })
1959
+ throw error
1960
+ }
1673
1961
  },
1674
- handler: handleToolCall,
1675
1962
  },
1676
- 'ai.approval.responded': {
1963
+ 'ai.tool.execution.requested': {
1677
1964
  abortOn: {
1678
1965
  'ai.generation.failed': (event, trigger) =>
1679
- event.payload.responseMessageId === trigger.payload.messageId &&
1680
- event.payload.generationId === trigger.payload.generationId,
1681
- 'ai.message.interrupted': (event, trigger) =>
1682
- event.payload.messageId === trigger.payload.messageId &&
1683
- (event.payload.requestId ===
1684
- generationRequestId(trigger.payload.generationId) ||
1685
- event.payload.generationId === trigger.payload.generationId),
1966
+ event.payload.generationId === trigger.payload.call.generationId,
1967
+ 'ai.control.committed': (event, trigger) => {
1968
+ const active = (event.payload as ControlCommit).view
1969
+ return (
1970
+ active?.turnId !== trigger.payload.turnId ||
1971
+ active?.version !== trigger.payload.version
1972
+ )
1973
+ },
1686
1974
  'ai.session.closed': true,
1687
1975
  },
1688
- handler: handleApproval,
1689
- },
1690
- 'ai.tool.result': {
1691
- handler: async (ctx) => {
1692
- if (ctx.event.payload.preliminary === true) return
1693
- await continueIfReady(ctx, ctx.event.payload.generationId)
1694
- },
1976
+ handler: handleToolExecution,
1695
1977
  },
1696
1978
  } as NonNullable<ServerOptions<D>['handlers']>
1697
1979
  }