experimental-a2 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/dist/actor-client.d.ts +1 -1
  3. package/dist/{actor-BfQSE0KC.d.ts → actor-ohPC-81x.d.ts} +5 -5
  4. package/dist/{actor-BfQSE0KC.d.ts.map → actor-ohPC-81x.d.ts.map} +1 -1
  5. package/dist/actor-react.d.ts +3 -3
  6. package/dist/actor.d.ts +1 -1
  7. package/dist/actor.js +1 -1
  8. package/dist/{ai-Cai-lCbj.d.ts → ai-CrEf6p_W.d.ts} +73 -14
  9. package/dist/ai-CrEf6p_W.d.ts.map +1 -0
  10. package/dist/ai-DgOBltJ_.js +2451 -0
  11. package/dist/ai-DgOBltJ_.js.map +1 -0
  12. package/dist/ai-server.d.ts +4 -5
  13. package/dist/ai-server.d.ts.map +1 -1
  14. package/dist/ai-server.js +788 -296
  15. package/dist/ai-server.js.map +1 -1
  16. package/dist/ai.d.ts +2 -2
  17. package/dist/ai.js +1 -1999
  18. package/dist/client-BYzHjkwU.js.map +1 -1
  19. package/dist/{client-BAEABRZB.d.ts → client-CzyacQpJ.d.ts} +9 -9
  20. package/dist/client-CzyacQpJ.d.ts.map +1 -0
  21. package/dist/client.d.ts +1 -1
  22. package/dist/idempotent-id-BRJVeylj.js +13 -0
  23. package/dist/idempotent-id-BRJVeylj.js.map +1 -0
  24. package/dist/index.d.ts +3 -3
  25. package/dist/index.js +1 -12
  26. package/dist/otel.d.ts +1 -1
  27. package/dist/react.d.ts +13 -13
  28. package/dist/react.d.ts.map +1 -1
  29. package/dist/react.js.map +1 -1
  30. package/dist/reducer-DEMjEY_O.js.map +1 -1
  31. package/dist/{reducer-BcS9VDKC.d.ts → reducer-otzHjuJj.d.ts} +3 -3
  32. package/dist/{reducer-BcS9VDKC.d.ts.map → reducer-otzHjuJj.d.ts.map} +1 -1
  33. package/dist/scheduler-qstash.d.ts +2 -2
  34. package/dist/scheduler-qstash.js +1 -1
  35. package/dist/scheduler-vercel.d.ts +2 -2
  36. package/dist/scheduler-vercel.js +1 -1
  37. package/dist/{server-CjJSGcF7.js → server-BD5ckxZb.js} +39 -26
  38. package/dist/server-BD5ckxZb.js.map +1 -0
  39. package/dist/{server-Bp5Nd1pF.d.ts → server-DbD7IJVK.d.ts} +5 -5
  40. package/dist/{server-Bp5Nd1pF.d.ts.map → server-DbD7IJVK.d.ts.map} +1 -1
  41. package/dist/server.d.ts +3 -3
  42. package/dist/server.js +1 -1
  43. package/dist/{store-D_yhNdPz.d.ts → store-DyZM6fS5.d.ts} +3 -2
  44. package/dist/{store-D_yhNdPz.d.ts.map → store-DyZM6fS5.d.ts.map} +1 -1
  45. package/dist/store-N8PXxDAS.js.map +1 -1
  46. package/dist/store-memory.d.ts +1 -1
  47. package/dist/store-postgres.d.ts +1 -1
  48. package/dist/store-redis-http.d.ts +1 -1
  49. package/dist/store-redis-http.js +1 -1
  50. package/dist/{store-redis-notify-D2EI6gwX.js → store-redis-notify-BVLUfI3j.js} +14 -13
  51. package/dist/store-redis-notify-BVLUfI3j.js.map +1 -0
  52. package/dist/store-redis.d.ts +1 -1
  53. package/dist/store-redis.js +1 -1
  54. package/dist/store-sqlite.d.ts +1 -1
  55. package/dist/{telemetry-CpeclqB2.d.ts → telemetry-B5jzpy6y.d.ts} +2 -2
  56. package/dist/telemetry-B5jzpy6y.d.ts.map +1 -0
  57. package/docs/concepts/04-state.mdx +9 -4
  58. package/docs/guides/06-ai-agents.mdx +149 -48
  59. package/docs/reference/01-api.mdx +110 -28
  60. package/examples/playground/app/agent/[agentId]/agent-client.tsx +20 -28
  61. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +123 -0
  62. package/examples/playground/app/agent/[agentId]/agent-queue.tsx +37 -43
  63. package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +44 -24
  64. package/examples/playground/app/agent/compaction-timeline.test.ts +47 -7
  65. package/examples/playground/app/agent/compaction-timeline.ts +13 -3
  66. package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -5
  67. package/examples/playground/app/chat/[chatId]/session.ts +17 -2
  68. package/examples/playground/app/chat/model.test.ts +4 -4
  69. package/examples/playground/app/chat/model.ts +18 -6
  70. package/examples/playground/app/chat/server.ts +6 -3
  71. package/examples/playground/package.json +3 -1
  72. package/package.json +1 -1
  73. package/src/ai-client-state.ts +36 -4
  74. package/src/ai-context-schema.ts +296 -0
  75. package/src/ai-context.ts +680 -0
  76. package/src/ai-control-server.ts +183 -135
  77. package/src/ai-control-state.ts +6 -0
  78. package/src/ai-control.ts +8 -7
  79. package/src/ai-coordinator.ts +92 -38
  80. package/src/ai-id.ts +6 -0
  81. package/src/ai-input-tokens.ts +75 -0
  82. package/src/ai-message-projection.ts +474 -0
  83. package/src/ai-server.ts +247 -336
  84. package/src/ai-stored-state.ts +484 -0
  85. package/src/ai.ts +255 -538
  86. package/src/client.ts +21 -15
  87. package/src/react.ts +25 -19
  88. package/src/reducer.ts +2 -1
  89. package/src/server.ts +73 -56
  90. package/src/store-redis-core.ts +15 -17
  91. package/src/store.ts +1 -0
  92. package/src/telemetry.ts +2 -1
  93. package/dist/ai-Cai-lCbj.d.ts.map +0 -1
  94. package/dist/ai-control-CcD4hh3y.js +0 -119
  95. package/dist/ai-control-CcD4hh3y.js.map +0 -1
  96. package/dist/ai.js.map +0 -1
  97. package/dist/client-BAEABRZB.d.ts.map +0 -1
  98. package/dist/index.js.map +0 -1
  99. package/dist/server-CjJSGcF7.js.map +0 -1
  100. package/dist/store-redis-notify-D2EI6gwX.js.map +0 -1
  101. package/dist/telemetry-CpeclqB2.d.ts.map +0 -1
package/src/ai-server.ts CHANGED
@@ -9,10 +9,20 @@
9
9
  // ordered; parallel consumption would corrupt progress sequence and state.
10
10
 
11
11
  import { createControlRuntime, ControlCancelled } from './ai-control-server.ts'
12
- import type { ControlCommit } from './ai-control.ts'
12
+ import {
13
+ createContextReducer,
14
+ contextForAttempt,
15
+ generationBase,
16
+ type AIContextState,
17
+ type AIModelContext,
18
+ } from './ai-context.ts'
19
+ import type { Reducer } from './reducer.ts'
20
+ import { aiStateReducer } from './ai-stored-state.ts'
21
+ import { aiEventId, AI_EVENT_ID_PREFIX } from './ai-id.ts'
22
+ import type { ControlCommit, ControlCommand } from './ai-control.ts'
13
23
  import { AsyncLocalStorage } from 'node:async_hooks'
14
24
  import { createHash } from 'node:crypto'
15
- import { asSchema, convertToModelMessages } from 'ai'
25
+ import { convertToModelMessages, isToolUIPart } from 'ai'
16
26
  import type {
17
27
  FinishReason,
18
28
  Instructions,
@@ -26,14 +36,15 @@ import type {
26
36
  } from 'ai'
27
37
  import { generateAISDKStep, type AISDKStepSettings } from './ai-sdk-step.ts'
28
38
  import { gatewayModelId, readModelLimits } from './ai-model-metadata.ts'
39
+ import { estimateInputTokens, measuredInputTokens } from './ai-input-tokens.ts'
29
40
  import type {
30
41
  AIEventDefs,
31
- AIState,
32
42
  AgentDefinition,
33
43
  AgentToolContext,
34
44
  AgentToolEventDefs,
35
45
  ApprovalRequestedPayload,
36
46
  CompactionCompletedPayload,
47
+ CompactionRequestedPayload,
37
48
  GenerationCompletedPayload,
38
49
  GenerationFailedPayload,
39
50
  GenerationProgressPayload,
@@ -42,10 +53,7 @@ import type {
42
53
  ToolCalledPayload,
43
54
  ToolResultPayload,
44
55
  } from './ai.ts'
45
- import {
46
- aiCoordinatorReducer,
47
- type AICoordinatorState,
48
- } from './ai-coordinator.ts'
56
+ import type { AICoordinatorState } from './ai-coordinator.ts'
49
57
  import type { AppendInput, ContractEvent, EventDefs } from './contract.ts'
50
58
  import { A2Error } from './errors.ts'
51
59
  import {
@@ -116,7 +124,6 @@ export type AgentGenerateContext<
116
124
  responseMessageId: string
117
125
  messages: M[]
118
126
  modelMessages: ModelMessage[]
119
- state: AIState<M>
120
127
  session: Pick<HandlerContext<D>['session'], 'state'>
121
128
  signal: AbortSignal
122
129
  model: LanguageModel
@@ -149,7 +156,7 @@ export type AgentResolverContext<
149
156
  D extends AIEventDefs<M> & EventDefs,
150
157
  > = {
151
158
  event: ContractEvent<D, 'ai.generation.requested'>
152
- state: AIState<M>
159
+ messages: M[]
153
160
  session: Pick<HandlerContext<D>['session'], 'state'>
154
161
  signal: AbortSignal
155
162
  }
@@ -472,13 +479,12 @@ const foldAIEvents = <
472
479
  M extends UIMessage,
473
480
  D extends AIEventDefs<M> & EventDefs,
474
481
  >(options: {
475
- agent: AgentDefinition<M, D>
476
- state: AIState<M>
482
+ reducer: Reducer<D, AIContextState<M>>
483
+ state: AIContextState<M>
477
484
  events: ContractEvent<D>[]
478
- }): AIState<M> => {
485
+ }): AIContextState<M> => {
479
486
  let state = options.state
480
- for (const event of options.events)
481
- state = options.agent.reducer.fold(state, event)
487
+ for (const event of options.events) state = options.reducer.fold(state, event)
482
488
  return state
483
489
  }
484
490
 
@@ -539,7 +545,10 @@ const summarize = async <
539
545
  ...options.context.modelMessages,
540
546
  { role: 'user', content: instruction },
541
547
  ],
542
- responseMessageId: `${options.context.generationId}:summary`,
548
+ responseMessageId: await aiEventId(
549
+ 'a2.ai.summary',
550
+ options.context.generationId,
551
+ ),
543
552
  },
544
553
  messageMetadata: undefined,
545
554
  })
@@ -573,98 +582,26 @@ const summarize = async <
573
582
  }
574
583
 
575
584
  const activeCompaction = <M extends UIMessage>(
576
- state: AIState<M>,
577
- ): AIState<M>['compaction'] => {
585
+ state: AIModelContext<M>,
586
+ ): AIModelContext<M>['compaction'] => {
578
587
  const compaction = state.compaction
579
- return compaction?.status === 'completed' &&
580
- compaction.messages !== undefined &&
581
- state.messages.some((message) => message.id === compaction.throughMessageId)
582
- ? compaction
583
- : null
588
+ return compaction?.status === 'completed' ? compaction : null
584
589
  }
585
590
 
586
- const contextMessages = async <
587
- M extends UIMessage,
588
- D extends AIEventDefs<M> & EventDefs,
589
- >(options: {
590
- agent: AgentDefinition<M, D>
591
- session: HandlerContext<D>['session']
592
- snapshot: { state: AIState<M>; index: number }
591
+ const contextMessages = <M extends UIMessage>({
592
+ state,
593
+ coordinator,
594
+ }: {
595
+ state: AIModelContext<M>
593
596
  coordinator: AICoordinatorState
594
- appended?: ContractEvent<D>[]
595
- excluded?: ReadonlySet<string>
596
- }): Promise<M[]> => {
597
- const { agent, session, snapshot } = options
598
- const appended = options.appended ?? []
599
- const state = foldAIEvents({ agent, events: appended, state: snapshot.state })
600
- const compaction = activeCompaction(state)
601
- const queued = new Set(
602
- options.coordinator.queued.map((item) => item.messageId),
603
- )
604
- const visible = (messages: M[]): M[] =>
605
- messages.filter((message) => !queued.has(message.id))
606
- if (compaction === null) return visible(state.messages)
607
- const retained = new Set(compaction.retainedMessageIds ?? [])
608
- const throughIndex = compaction.throughIndex
609
- if (throughIndex !== undefined) {
610
- const [prefix, priorCoordinator, tail] = await Promise.all([
611
- throughIndex === snapshot.index
612
- ? Promise.resolve(snapshot)
613
- : session.state(agent.reducer, { through: throughIndex }),
614
- session.state(aiCoordinatorReducer(agent.contract), {
615
- through: throughIndex,
616
- }),
617
- throughIndex >= snapshot.index
618
- ? Promise.resolve([])
619
- : session.history({ gte: throughIndex + 1, lte: snapshot.index }),
620
- ])
621
- for (const item of priorCoordinator.state.queued)
622
- retained.add(item.messageId)
623
- const events =
624
- options.excluded === undefined
625
- ? tail
626
- : withoutGenerationLifecycle(tail, options.excluded)
627
- const context = foldAIEvents({
628
- agent,
629
- events: [...events, ...appended],
630
- state: {
631
- ...prefix.state,
632
- messages: [
633
- ...compaction.messages!,
634
- ...prefix.state.messages.filter((message) =>
635
- retained.has(message.id),
636
- ),
637
- ],
638
- activeProjection: null,
639
- },
640
- })
641
- const positions = new Map<string, number>()
642
- for (const message of [...compaction.messages!, ...state.messages]) {
643
- if (!positions.has(message.id)) positions.set(message.id, positions.size)
644
- }
645
- return visible(
646
- context.messages.toSorted(
647
- (left, right) =>
648
- (positions.get(left.id) ?? positions.size) -
649
- (positions.get(right.id) ?? positions.size),
650
- ),
651
- )
652
- }
653
- const boundary = state.messages.findIndex(
654
- (message) => message.id === compaction.throughMessageId,
655
- )
656
- return visible([
657
- ...compaction.messages!,
658
- ...state.messages
659
- .slice(0, boundary + 1)
660
- .filter((message) => retained.has(message.id)),
661
- ...state.messages.slice(boundary + 1),
662
- ])
597
+ }): M[] => {
598
+ const queued = new Set(coordinator.queued.map((item) => item.messageId))
599
+ return state.messages.filter((message) => !queued.has(message.id))
663
600
  }
664
601
 
665
602
  const modelContext = async <M extends UIMessage>(options: {
666
603
  messages: M[]
667
- state: AIState<M>
604
+ state: AIModelContext<M>
668
605
  tools: ToolSet
669
606
  }): Promise<ModelMessage[]> => {
670
607
  const messages = await convertToModelMessages(options.messages, {
@@ -676,45 +613,6 @@ const modelContext = async <M extends UIMessage>(options: {
676
613
  : [{ role: 'user', content: summary }, ...messages]
677
614
  }
678
615
 
679
- const estimateInputTokens = async (options: {
680
- messages: ModelMessage[]
681
- instructions: Instructions | undefined
682
- tools: ToolSet
683
- }): Promise<number> => {
684
- const tools = await Promise.all(
685
- Object.entries(options.tools).map(async ([name, tool]) => ({
686
- name,
687
- description: tool.description,
688
- inputSchema: await asSchema(tool.inputSchema).jsonSchema,
689
- })),
690
- )
691
- return Math.ceil(
692
- new TextEncoder().encode(
693
- JSON.stringify({
694
- messages: options.messages,
695
- instructions: options.instructions,
696
- tools,
697
- }),
698
- ).byteLength / 4,
699
- )
700
- }
701
-
702
- const measuredInputTokens = (options: {
703
- estimate: number
704
- model: string
705
- calibration: AICoordinatorState['calibration']
706
- }): number => {
707
- const previous = options.calibration
708
- return previous !== undefined &&
709
- previous.model === options.model &&
710
- options.estimate >= previous.estimate
711
- ? Math.max(
712
- options.estimate,
713
- Math.ceil(previous.inputTokens + options.estimate - previous.estimate),
714
- )
715
- : options.estimate
716
- }
717
-
718
616
  type ToolCallClassification = 'automatic' | 'approval' | 'unknown'
719
617
 
720
618
  type PendingToolCall = {
@@ -722,12 +620,16 @@ type PendingToolCall = {
722
620
  classification: ToolCallClassification
723
621
  }
724
622
 
725
- const toolCalledEvent = <D extends EventDefs>(
623
+ const toolCalledEvent = async <D extends EventDefs>(
726
624
  payload: ToolCalledPayload,
727
- ): AppendInput<D> =>
625
+ ): Promise<AppendInput<D>> =>
728
626
  ({
729
627
  type: 'ai.tool.called',
730
- id: `${payload.generationId}:tool:${payload.toolCallId}:called`,
628
+ id: await aiEventId(
629
+ 'a2.ai.tool.called',
630
+ payload.generationId,
631
+ payload.toolCallId,
632
+ ),
731
633
  payload,
732
634
  }) as AppendInput<D>
733
635
 
@@ -757,7 +659,10 @@ const approvalClassification = <T extends ToolSet>(
757
659
  return tool?.needsApproval === true ? 'approval' : 'automatic'
758
660
  }
759
661
 
760
- const lifecycleEvents = <D extends EventDefs, T extends ToolSet>(options: {
662
+ const lifecycleEvents = async <
663
+ D extends EventDefs,
664
+ T extends ToolSet,
665
+ >(options: {
761
666
  requestId: string
762
667
  messageId: string
763
668
  generationId: string
@@ -767,7 +672,7 @@ const lifecycleEvents = <D extends EventDefs, T extends ToolSet>(options: {
767
672
  tools: T
768
673
  generation: AgentGenerationSettings<T>
769
674
  custom: boolean
770
- }): { events: AppendInput<D>[]; pending: PendingToolCall[] } => {
675
+ }): Promise<{ events: AppendInput<D>[]; pending: PendingToolCall[] }> => {
771
676
  const result: AppendInput<D>[] = []
772
677
  const pending = [...options.pending]
773
678
  const take = (toolCallId: string): PendingToolCall | undefined => {
@@ -777,12 +682,18 @@ const lifecycleEvents = <D extends EventDefs, T extends ToolSet>(options: {
777
682
  if (index === -1) return undefined
778
683
  return pending.splice(index, 1)[0]
779
684
  }
780
- const flush = (toolCallId: string): void => {
685
+ const flush = async (toolCallId: string): Promise<void> => {
781
686
  const pendingCall = take(toolCallId)
782
- if (pendingCall) result.push(toolCalledEvent<D>(pendingCall.call))
687
+ if (pendingCall) result.push(await toolCalledEvent<D>(pendingCall.call))
783
688
  }
784
689
  for (const [chunkIndex, chunk] of options.chunks.entries()) {
785
- const resultId = `${options.generationId}:tool-result:${options.sequence}:${chunkIndex}`
690
+ const resultId = (): Promise<string> =>
691
+ aiEventId(
692
+ 'a2.ai.tool.stream-result',
693
+ options.generationId,
694
+ String(options.sequence),
695
+ String(chunkIndex),
696
+ )
786
697
  if (chunk.type === 'tool-input-available') {
787
698
  const payload: ToolCalledPayload = {
788
699
  requestId: options.requestId,
@@ -816,7 +727,7 @@ const lifecycleEvents = <D extends EventDefs, T extends ToolSet>(options: {
816
727
  chunk.toolName,
817
728
  )
818
729
  if (classification === 'automatic') {
819
- result.push(toolCalledEvent<D>(payload))
730
+ result.push(await toolCalledEvent<D>(payload))
820
731
  } else {
821
732
  pending.push({ call: payload, classification })
822
733
  }
@@ -850,13 +761,13 @@ const lifecycleEvents = <D extends EventDefs, T extends ToolSet>(options: {
850
761
  input: chunk.input,
851
762
  }
852
763
  take(chunk.toolCallId)
853
- result.push(toolCalledEvent<D>(called), {
764
+ result.push(await toolCalledEvent<D>(called), {
854
765
  type: 'ai.tool.result',
855
- id: resultId,
766
+ id: await resultId(),
856
767
  payload,
857
768
  } as AppendInput<D>)
858
769
  } else if (chunk.type === 'tool-output-available') {
859
- flush(chunk.toolCallId)
770
+ await flush(chunk.toolCallId)
860
771
  const payload: ToolResultPayload = {
861
772
  requestId: options.requestId,
862
773
  messageId: options.messageId,
@@ -880,11 +791,11 @@ const lifecycleEvents = <D extends EventDefs, T extends ToolSet>(options: {
880
791
  }
881
792
  result.push({
882
793
  type: 'ai.tool.result',
883
- id: resultId,
794
+ id: await resultId(),
884
795
  payload,
885
796
  } as AppendInput<D>)
886
797
  } else if (chunk.type === 'tool-output-error') {
887
- flush(chunk.toolCallId)
798
+ await flush(chunk.toolCallId)
888
799
  const payload: ToolResultPayload = {
889
800
  requestId: options.requestId,
890
801
  messageId: options.messageId,
@@ -905,11 +816,11 @@ const lifecycleEvents = <D extends EventDefs, T extends ToolSet>(options: {
905
816
  }
906
817
  result.push({
907
818
  type: 'ai.tool.result',
908
- id: resultId,
819
+ id: await resultId(),
909
820
  payload,
910
821
  } as AppendInput<D>)
911
822
  } else if (chunk.type === 'tool-output-denied') {
912
- flush(chunk.toolCallId)
823
+ await flush(chunk.toolCallId)
913
824
  const payload: ToolResultPayload = {
914
825
  requestId: options.requestId,
915
826
  messageId: options.messageId,
@@ -920,12 +831,13 @@ const lifecycleEvents = <D extends EventDefs, T extends ToolSet>(options: {
920
831
  }
921
832
  result.push({
922
833
  type: 'ai.tool.result',
923
- id: resultId,
834
+ id: await resultId(),
924
835
  payload,
925
836
  } as AppendInput<D>)
926
837
  } else if (chunk.type === 'tool-approval-request') {
927
- flush(chunk.toolCallId)
838
+ await flush(chunk.toolCallId)
928
839
  const payload: ApprovalRequestedPayload = {
840
+ requestId: options.requestId,
929
841
  messageId: options.messageId,
930
842
  generationId: options.generationId,
931
843
  approvalId: chunk.approvalId,
@@ -939,14 +851,23 @@ const lifecycleEvents = <D extends EventDefs, T extends ToolSet>(options: {
939
851
  }
940
852
  result.push({
941
853
  type: 'ai.approval.requested',
942
- id: `${options.generationId}:approval:${chunk.approvalId}`,
854
+ id: await aiEventId(
855
+ 'a2.ai.approval.requested',
856
+ options.generationId,
857
+ chunk.approvalId,
858
+ ),
943
859
  payload,
944
860
  } as AppendInput<D>)
945
861
  } else if (chunk.type === 'tool-approval-response') {
946
862
  result.push({
947
863
  type: 'ai.approval.responded',
948
- id: `${options.generationId}:approval:${chunk.approvalId}:automatic-response`,
864
+ id: await aiEventId(
865
+ 'a2.ai.approval.automatic-response',
866
+ options.generationId,
867
+ chunk.approvalId,
868
+ ),
949
869
  payload: {
870
+ requestId: options.requestId,
950
871
  messageId: options.messageId,
951
872
  generationId: options.generationId,
952
873
  approvalId: chunk.approvalId,
@@ -959,73 +880,6 @@ const lifecycleEvents = <D extends EventDefs, T extends ToolSet>(options: {
959
880
  return { events: result, pending }
960
881
  }
961
882
 
962
- function withoutGenerationLifecycle<D extends EventDefs>(
963
- history: ContractEvent<D>[],
964
- generationIds: ReadonlySet<string>,
965
- ): ContractEvent<D>[] {
966
- if (generationIds.size === 0) return history
967
- const approvalKeys = new Set<string>()
968
- const inputKeys = new Set<string>()
969
- for (const event of history) {
970
- const payload = event.payload as {
971
- generationId?: unknown
972
- messageId?: unknown
973
- approvalId?: unknown
974
- inputId?: unknown
975
- }
976
- if (
977
- typeof payload.generationId !== 'string' ||
978
- !generationIds.has(payload.generationId) ||
979
- typeof payload.messageId !== 'string'
980
- ) {
981
- continue
982
- }
983
- if (
984
- event.type === 'ai.approval.requested' &&
985
- typeof payload.approvalId === 'string'
986
- ) {
987
- approvalKeys.add(`${payload.messageId}\u001f${payload.approvalId}`)
988
- }
989
- if (
990
- event.type === 'ai.input.requested' &&
991
- typeof payload.inputId === 'string'
992
- ) {
993
- inputKeys.add(`${payload.messageId}\u001f${payload.inputId}`)
994
- }
995
- }
996
- return history.filter((event) => {
997
- const payload = event.payload as {
998
- generationId?: unknown
999
- messageId?: unknown
1000
- approvalId?: unknown
1001
- inputId?: unknown
1002
- }
1003
- if (
1004
- typeof payload.generationId === 'string' &&
1005
- generationIds.has(payload.generationId)
1006
- ) {
1007
- return false
1008
- }
1009
- if (
1010
- event.type === 'ai.approval.responded' &&
1011
- typeof payload.messageId === 'string' &&
1012
- typeof payload.approvalId === 'string'
1013
- ) {
1014
- return !approvalKeys.has(
1015
- `${payload.messageId}\u001f${payload.approvalId}`,
1016
- )
1017
- }
1018
- if (
1019
- event.type === 'ai.input.responded' &&
1020
- typeof payload.messageId === 'string' &&
1021
- typeof payload.inputId === 'string'
1022
- ) {
1023
- return !inputKeys.has(`${payload.messageId}\u001f${payload.inputId}`)
1024
- }
1025
- return true
1026
- })
1027
- }
1028
-
1029
883
  const validateCompaction = <
1030
884
  M extends UIMessage,
1031
885
  D extends AIEventDefs<M> & EventDefs,
@@ -1135,6 +989,14 @@ export function createHandlers<
1135
989
  const maxSteps = options.maxSteps ?? Number.POSITIVE_INFINITY
1136
990
  const control = createControlRuntime({ agent: options.agent })
1137
991
  const coordinator = control.coordinator
992
+ const contextReducer = createContextReducer({ agent: options.agent })
993
+ const readContext = async (
994
+ session: HandlerContext<D>['session'],
995
+ through: number | 'latest',
996
+ ) => {
997
+ const snapshot = await session.state(contextReducer, { through })
998
+ return { state: contextReducer.view(snapshot.state), index: snapshot.index }
999
+ }
1138
1000
  const promptCache = new Map<string, Promise<ModelMessage[]>>()
1139
1001
 
1140
1002
  type RuntimeTool = {
@@ -1150,7 +1012,7 @@ export function createHandlers<
1150
1012
 
1151
1013
  type ToolHandlerContext = HandlerContext<D, 'ai.tool.execution.requested'>
1152
1014
 
1153
- const resultEvent = (
1015
+ const resultEvent = async (
1154
1016
  call: ToolCalledPayload,
1155
1017
  suffix: string,
1156
1018
  result: {
@@ -1159,10 +1021,15 @@ export function createHandlers<
1159
1021
  denied?: boolean
1160
1022
  preliminary?: boolean
1161
1023
  },
1162
- ): AppendInput<D> =>
1024
+ ): Promise<AppendInput<D>> =>
1163
1025
  ({
1164
1026
  type: 'ai.tool.result',
1165
- id: `${call.generationId}:tool:${call.toolCallId}:${suffix}`,
1027
+ id: await aiEventId(
1028
+ 'a2.ai.tool.result',
1029
+ call.generationId,
1030
+ call.toolCallId,
1031
+ suffix,
1032
+ ),
1166
1033
  payload: {
1167
1034
  requestId: call.requestId,
1168
1035
  messageId: call.messageId,
@@ -1200,32 +1067,20 @@ export function createHandlers<
1200
1067
  const frontier = owner.promptThroughIndex
1201
1068
  if (frontier === undefined)
1202
1069
  throw new TypeError('AI work requires a prompt checkpoint')
1203
- const [snapshot, atPrompt, tail] = await Promise.all([
1204
- ctx.session.state(options.agent.reducer, { through: frontier }),
1205
- ctx.session.state(coordinator, { through: frontier }),
1206
- ctx.session.history({ gte: frontier + 1, lte: ctx.event.index }),
1207
- ])
1208
- const appended = tail.filter(
1209
- (event) =>
1210
- (event.type === 'ai.generation.started' ||
1211
- event.type === 'ai.compaction.requested' ||
1212
- event.type === 'ai.compaction.completed') &&
1213
- (event.payload as { generationId: string }).generationId ===
1214
- call.generationId,
1070
+ const snapshot = await readContext(ctx.session, ctx.event.index)
1071
+ if (
1072
+ snapshot.state.generation?.owner.generationId !== owner.generationId ||
1073
+ snapshot.state.generation.owner.requestId !== owner.requestId ||
1074
+ snapshot.state.activeProjection?.generationId !== owner.generationId
1215
1075
  )
1216
- const state = foldAIEvents({
1217
- agent: options.agent,
1218
- events: appended,
1219
- state: snapshot.state,
1220
- })
1076
+ throw new TypeError(
1077
+ 'Tool prompt checkpoint does not own its generation',
1078
+ )
1079
+ const state = generationBase(snapshot.state)
1221
1080
  return modelContext({
1222
- messages: await contextMessages({
1223
- agent: options.agent,
1224
- session: ctx.session,
1225
- snapshot,
1226
- coordinator: atPrompt.state,
1227
- appended,
1228
- }),
1081
+ messages: state.messages.filter(
1082
+ (message) => state.messageIndexes[message.id]! <= frontier,
1083
+ ),
1229
1084
  state,
1230
1085
  tools,
1231
1086
  })
@@ -1237,11 +1092,11 @@ export function createHandlers<
1237
1092
  return computation
1238
1093
  }
1239
1094
 
1240
- const toolExecutionFailure = (
1095
+ const toolExecutionFailure = async (
1241
1096
  ctx: ToolHandlerContext,
1242
1097
  call: ToolCalledPayload,
1243
1098
  error: unknown,
1244
- ): AppendInput<D> | void => {
1099
+ ): Promise<AppendInput<D> | void> => {
1245
1100
  const schedulerFailure = consumeSchedulerSendFailure(error)
1246
1101
  if (checkAbort(ctx.signal)) return
1247
1102
  if (schedulerFailure === 'retryable') throw error
@@ -1297,7 +1152,7 @@ export function createHandlers<
1297
1152
  ctx,
1298
1153
  name: `tool:${call.toolCallId}:preliminary:${sequence}`,
1299
1154
  events: [
1300
- resultEvent(
1155
+ await resultEvent(
1301
1156
  call,
1302
1157
  `${ctx.event.id}:execution:${ctx.attempt}:${sequence}:preliminary`,
1303
1158
  {
@@ -1364,6 +1219,7 @@ export function createHandlers<
1364
1219
  )
1365
1220
  if (
1366
1221
  state.active?.turnId !== request.turnId ||
1222
+ Object.values(state.pendingStops).includes(request.turnId) ||
1367
1223
  state.active.suspended ||
1368
1224
  state.active.version !== request.version ||
1369
1225
  state.coordinator.response?.failure !== undefined ||
@@ -1393,17 +1249,23 @@ export function createHandlers<
1393
1249
  if (checkAbort(ctx.signal)) return
1394
1250
  const requestId = ctx.event.id
1395
1251
  const request = ctx.event.payload
1396
- const snapshot = await ctx.session.state(options.agent.reducer, {
1397
- through: 'latest',
1398
- })
1252
+ const snapshot = await readContext(ctx.session, 'latest')
1399
1253
  if (
1400
- snapshot.state.active?.phase === 'paused' ||
1401
- snapshot.state.active?.phase === 'pausing'
1254
+ snapshot.state.closed ||
1255
+ snapshot.state.authority?.requestId !== requestId ||
1256
+ snapshot.state.authority?.phase === 'paused' ||
1257
+ snapshot.state.authority?.phase === 'pausing'
1402
1258
  )
1403
1259
  return
1404
- const coordinatorState = (
1405
- await ctx.session.state(coordinator, { through: snapshot.index })
1260
+ const controlState = (
1261
+ await ctx.session.state(control.reducer, { through: snapshot.index })
1406
1262
  ).state
1263
+ if (
1264
+ request.control !== undefined &&
1265
+ Object.values(controlState.pendingStops).includes(request.control.turnId)
1266
+ )
1267
+ return
1268
+ const coordinatorState = controlState.coordinator
1407
1269
  if (
1408
1270
  coordinatorState.closed ||
1409
1271
  coordinatorState.response?.activeRequestId !== requestId
@@ -1423,54 +1285,29 @@ export function createHandlers<
1423
1285
  return
1424
1286
  if (current !== undefined && current.attempt >= ctx.attempt) return
1425
1287
  const attempt = ctx.attempt
1426
- const generationId = `${requestId}:generation:${attempt}`
1427
- const responseMessageId =
1428
- request.responseMessageId ??
1429
- (request.reason === 'message'
1430
- ? `${request.messageId}:assistant`
1431
- : request.messageId)
1288
+ const generationId = await aiEventId(
1289
+ 'a2.ai.generation',
1290
+ requestId,
1291
+ String(attempt),
1292
+ )
1293
+ const responseMessageId = request.responseMessageId
1432
1294
  const responseStepCount = response.stepCount
1433
1295
  if (
1434
1296
  request.reason === 'tool' &&
1435
- (requestId !==
1436
- `ai.generate:tools:${response.source?.generation.generationId}` ||
1297
+ (request.sourceGenerationId !==
1298
+ response.source?.generation.generationId ||
1437
1299
  response.responseMessageId !== request.messageId ||
1438
1300
  response.responseMessageId !== request.responseMessageId)
1439
1301
  )
1440
1302
  return
1441
1303
  const previous = current
1442
- const replaced =
1304
+ const state =
1443
1305
  previous === undefined
1444
- ? []
1445
- : [{ generation: previous, frontier: response.promptThroughIndex! }]
1446
- if (request.reason === 'retry' && response.source?.failed) {
1447
- replaced.push({
1448
- generation: response.source.generation,
1449
- frontier: response.source.promptThroughIndex,
1450
- })
1451
- }
1452
- const replacedGenerationIds = new Set(
1453
- replaced.map(({ generation: owner }) => owner.generationId),
1454
- )
1455
- let state = snapshot.state
1456
- if (replaced.length > 0) {
1457
- const through = Math.min(...replaced.map(({ frontier }) => frontier))
1458
- const baseline = await ctx.session.state(options.agent.reducer, {
1459
- through,
1460
- })
1461
- const tail = await ctx.session.history({
1462
- gte: through + 1,
1463
- lte: snapshot.index,
1464
- })
1465
- state = foldAIEvents({
1466
- agent: options.agent,
1467
- events: withoutGenerationLifecycle(tail, replacedGenerationIds),
1468
- state: baseline.state,
1469
- })
1470
- }
1306
+ ? snapshot.state
1307
+ : contextForAttempt(snapshot.state)
1471
1308
  const resolverContext: AgentResolverContext<M, D> = {
1472
1309
  event: ctx.event,
1473
- state,
1310
+ messages: contextMessages({ state, coordinator: coordinatorState }),
1474
1311
  session: {
1475
1312
  state: (reducer, readOptions) =>
1476
1313
  ctx.session.state(reducer, {
@@ -1509,7 +1346,7 @@ export function createHandlers<
1509
1346
  if (source === undefined) return
1510
1347
  return {
1511
1348
  type: 'ai.generation.failed',
1512
- id: `${requestId}:step-limit`,
1349
+ id: await aiEventId('a2.ai.step-limit', requestId),
1513
1350
  payload: {
1514
1351
  requestId: source.requestId,
1515
1352
  messageId: source.messageId,
@@ -1548,7 +1385,12 @@ export function createHandlers<
1548
1385
  ) {
1549
1386
  startEvents.push({
1550
1387
  type: 'ai.model.metadata.requested',
1551
- id: `ai.model.metadata:${encodeURIComponent(options.agent.contract.name)}:${encodeURIComponent(ctx.event.sessionId)}:${encodeURIComponent(catalogModelId)}`,
1388
+ id: await aiEventId(
1389
+ 'a2.ai.model.metadata',
1390
+ options.agent.contract.name,
1391
+ ctx.event.sessionId,
1392
+ catalogModelId,
1393
+ ),
1552
1394
  payload: { modelId: catalogModelId },
1553
1395
  } as AppendInput<D>)
1554
1396
  }
@@ -1564,7 +1406,7 @@ export function createHandlers<
1564
1406
  }
1565
1407
  startEvents.push({
1566
1408
  type: 'ai.generation.failed',
1567
- id: `${payload.generationId}:superseded`,
1409
+ id: await aiEventId('a2.ai.superseded', payload.generationId),
1568
1410
  payload: superseded,
1569
1411
  } as AppendInput<D>)
1570
1412
  }
@@ -1583,12 +1425,9 @@ export function createHandlers<
1583
1425
  if (checkAbort(ctx.signal)) return
1584
1426
 
1585
1427
  const promptCoordinatorState = coordinatorState
1586
- const messages = await contextMessages({
1587
- agent: options.agent,
1588
- session: ctx.session,
1589
- snapshot: { state, index: snapshot.index },
1428
+ const messages = contextMessages({
1429
+ state,
1590
1430
  coordinator: promptCoordinatorState,
1591
- excluded: replacedGenerationIds,
1592
1431
  })
1593
1432
  let generationMessages = messages
1594
1433
  let modelMessages = await modelContext({ messages, state, tools })
@@ -1599,7 +1438,6 @@ export function createHandlers<
1599
1438
  responseMessageId,
1600
1439
  messages,
1601
1440
  modelMessages,
1602
- state,
1603
1441
  session: resolverContext.session,
1604
1442
  signal: ctx.signal,
1605
1443
  model: resolvedModel,
@@ -1624,8 +1462,24 @@ export function createHandlers<
1624
1462
  : undefined
1625
1463
  let inputTokenEstimate: number | undefined
1626
1464
  let compacted = false
1627
- const canCompact = response.source?.canCompact ?? true
1628
- if (policy && canCompact) {
1465
+ let compactionEstimate:
1466
+ | Pick<
1467
+ CompactionRequestedPayload,
1468
+ 'inputTokenEstimate' | 'inputTokens' | 'thresholdTokens'
1469
+ >
1470
+ | undefined
1471
+ const hasPendingTools = messages.some((message) =>
1472
+ message.parts.some(
1473
+ (part) =>
1474
+ isToolUIPart(part) &&
1475
+ !(
1476
+ part.state === 'output-error' ||
1477
+ part.state === 'output-denied' ||
1478
+ (part.state === 'output-available' && part.preliminary !== true)
1479
+ ),
1480
+ ),
1481
+ )
1482
+ if (policy && !hasPendingTools) {
1629
1483
  const compactionContext = {
1630
1484
  ...resolverContext,
1631
1485
  messages,
@@ -1665,10 +1519,24 @@ export function createHandlers<
1665
1519
  )
1666
1520
  }
1667
1521
  shouldCompact = threshold !== undefined && inputTokens >= threshold
1522
+ if (threshold !== undefined && shouldCompact) {
1523
+ compactionEstimate = {
1524
+ inputTokenEstimate,
1525
+ inputTokens,
1526
+ thresholdTokens: threshold,
1527
+ }
1528
+ }
1668
1529
  } else {
1669
1530
  shouldCompact = false
1670
1531
  }
1671
1532
  if (checkAbort(ctx.signal)) return
1533
+ if (shouldCompact) {
1534
+ const latest = (
1535
+ await ctx.session.state(coordinator, { through: 'latest' })
1536
+ ).state
1537
+ shouldCompact = Object.keys(latest.unsettledTools ?? {}).length === 0
1538
+ }
1539
+ if (checkAbort(ctx.signal)) return
1672
1540
  if (shouldCompact) {
1673
1541
  const throughMessageId =
1674
1542
  state.messages.findLast(
@@ -1685,8 +1553,16 @@ export function createHandlers<
1685
1553
  events: [
1686
1554
  {
1687
1555
  type: 'ai.compaction.requested',
1688
- id: `${generationId}:compaction:requested`,
1689
- payload: { generationId, throughMessageId, throughIndex },
1556
+ id: await aiEventId(
1557
+ 'a2.ai.compaction.requested',
1558
+ generationId,
1559
+ ),
1560
+ payload: {
1561
+ generationId,
1562
+ throughMessageId,
1563
+ throughIndex,
1564
+ ...compactionEstimate,
1565
+ },
1690
1566
  } as AppendInput<D>,
1691
1567
  ],
1692
1568
  })),
@@ -1722,7 +1598,10 @@ export function createHandlers<
1722
1598
  events: [
1723
1599
  {
1724
1600
  type: 'ai.compaction.completed',
1725
- id: `${generationId}:compaction:completed`,
1601
+ id: await aiEventId(
1602
+ 'a2.ai.compaction.completed',
1603
+ generationId,
1604
+ ),
1726
1605
  payload: completed,
1727
1606
  } as AppendInput<D>,
1728
1607
  ],
@@ -1739,17 +1618,14 @@ export function createHandlers<
1739
1618
  }
1740
1619
 
1741
1620
  const currentState = foldAIEvents({
1742
- agent: options.agent,
1621
+ reducer: contextReducer[aiStateReducer],
1743
1622
  events: generationEvents,
1744
- state: snapshot.state,
1623
+ state,
1745
1624
  })
1746
1625
  if (request.reason === 'retry' || previous !== undefined) {
1747
- generationMessages = await contextMessages({
1748
- agent: options.agent,
1749
- session: ctx.session,
1750
- snapshot,
1626
+ generationMessages = contextMessages({
1627
+ state: currentState,
1751
1628
  coordinator: promptCoordinatorState,
1752
- appended: generationEvents,
1753
1629
  })
1754
1630
  }
1755
1631
  if (
@@ -1785,7 +1661,6 @@ export function createHandlers<
1785
1661
  responseMessageId,
1786
1662
  messages: generationMessages,
1787
1663
  modelMessages,
1788
- state: currentState,
1789
1664
  session: resolverContext.session,
1790
1665
  signal: ctx.signal,
1791
1666
  model: resolvedModel,
@@ -1827,7 +1702,7 @@ export function createHandlers<
1827
1702
  sequence,
1828
1703
  chunks: update.chunks,
1829
1704
  }
1830
- const lifecycle = lifecycleEvents<D, T>({
1705
+ const lifecycle = await lifecycleEvents<D, T>({
1831
1706
  requestId,
1832
1707
  messageId: responseMessageId,
1833
1708
  generationId,
@@ -1845,7 +1720,11 @@ export function createHandlers<
1845
1720
  events: [
1846
1721
  {
1847
1722
  type: 'ai.generation.progress',
1848
- id: `${generationId}:progress:${sequence}`,
1723
+ id: await aiEventId(
1724
+ 'a2.ai.progress',
1725
+ generationId,
1726
+ String(sequence),
1727
+ ),
1849
1728
  payload: progress,
1850
1729
  } as AppendInput<D>,
1851
1730
  ...lifecycle.events,
@@ -1868,7 +1747,7 @@ export function createHandlers<
1868
1747
  }
1869
1748
  const completionEvent = {
1870
1749
  type: 'ai.generation.completed',
1871
- id: `${generationId}:completed`,
1750
+ id: await aiEventId('a2.ai.completed', generationId),
1872
1751
  payload: completed,
1873
1752
  } as AppendInput<D>
1874
1753
  const unresolved = options.generate
@@ -1881,14 +1760,16 @@ export function createHandlers<
1881
1760
  `generation ended with unresolved tool authorization for '${unresolved[0]!.call.toolName}'`,
1882
1761
  )
1883
1762
  }
1884
- const pendingEvents = pendingToolCalls
1885
- .filter(
1886
- (pending) =>
1887
- pending.classification === 'automatic' ||
1888
- (options.generate === undefined &&
1889
- pending.classification === 'unknown'),
1890
- )
1891
- .map((pending) => toolCalledEvent<D>(pending.call))
1763
+ const pendingEvents = await Promise.all(
1764
+ pendingToolCalls
1765
+ .filter(
1766
+ (pending) =>
1767
+ pending.classification === 'automatic' ||
1768
+ (options.generate === undefined &&
1769
+ pending.classification === 'unknown'),
1770
+ )
1771
+ .map((pending) => toolCalledEvent<D>(pending.call)),
1772
+ )
1892
1773
  if (finish.finishReason === 'tool-calls') {
1893
1774
  return [...pendingEvents, completionEvent]
1894
1775
  }
@@ -1897,7 +1778,7 @@ export function createHandlers<
1897
1778
  completionEvent,
1898
1779
  {
1899
1780
  type: 'ai.message.completed',
1900
- id: `${generationId}:message:completed`,
1781
+ id: await aiEventId('a2.ai.message.completed', generationId),
1901
1782
  payload: {
1902
1783
  messageId: responseMessageId,
1903
1784
  } satisfies MessageCompletedPayload,
@@ -1920,7 +1801,7 @@ export function createHandlers<
1920
1801
  }
1921
1802
  return {
1922
1803
  type: 'ai.generation.failed',
1923
- id: `${generationId}:failed`,
1804
+ id: await aiEventId('a2.ai.failed', generationId),
1924
1805
  payload: failed,
1925
1806
  } as AppendInput<D>
1926
1807
  }
@@ -1943,12 +1824,36 @@ export function createHandlers<
1943
1824
  ctx.signal.throwIfAborted()
1944
1825
  return {
1945
1826
  type: 'ai.model.metadata.resolved',
1946
- id: `${ctx.event.id}:resolved`,
1827
+ id: await aiEventId('a2.ai.model.metadata.resolved', ctx.event.id),
1947
1828
  payload: { modelId: ctx.event.payload.modelId, limits },
1948
1829
  } as AppendInput<D>
1949
1830
  },
1950
1831
  },
1951
- 'ai.control.requested': { lane: 'a2.ai.control', handler: control.handler },
1832
+ 'ai.control.requested': {
1833
+ lane: ({ event }) => {
1834
+ const command = event.payload as ControlCommand<M>
1835
+ if (event.id === '')
1836
+ throw new A2Error(
1837
+ 'INVALID_PAYLOAD',
1838
+ 'AI command IDs must be nonempty',
1839
+ )
1840
+ if (event.id?.startsWith(AI_EVENT_ID_PREFIX))
1841
+ throw new A2Error(
1842
+ 'INVALID_PAYLOAD',
1843
+ `AI command IDs cannot use the reserved '${AI_EVENT_ID_PREFIX}' prefix`,
1844
+ )
1845
+ if (
1846
+ (command.action === 'send' || command.action === 'steer') &&
1847
+ event.id !== command.message.id
1848
+ )
1849
+ throw new A2Error(
1850
+ 'INVALID_PAYLOAD',
1851
+ 'AI input event ID must equal message.id',
1852
+ )
1853
+ return 'a2.ai.control'
1854
+ },
1855
+ handler: control.handler,
1856
+ },
1952
1857
  'ai.work.reported': { lane: 'a2.ai.control', handler: control.handler },
1953
1858
  'ai.message.completed': {
1954
1859
  handler: async (ctx) => {
@@ -1978,6 +1883,9 @@ export function createHandlers<
1978
1883
  'ai.generation.requested': {
1979
1884
  lane: 'a2.ai.model',
1980
1885
  abortOn: {
1886
+ 'ai.control.requested': (event, trigger) =>
1887
+ event.payload.action === 'stop' &&
1888
+ event.payload.turnId === trigger.payload.control?.turnId,
1981
1889
  'ai.control.committed': (event, trigger) => {
1982
1890
  const active = (event.payload as ControlCommit).view
1983
1891
  return (
@@ -1999,6 +1907,9 @@ export function createHandlers<
1999
1907
  },
2000
1908
  'ai.tool.execution.requested': {
2001
1909
  abortOn: {
1910
+ 'ai.control.requested': (event, trigger) =>
1911
+ event.payload.action === 'stop' &&
1912
+ event.payload.turnId === trigger.payload.turnId,
2002
1913
  'ai.generation.failed': (event, trigger) =>
2003
1914
  event.payload.generationId === trigger.payload.call.generationId,
2004
1915
  'ai.control.committed': (event, trigger) => {