experimental-a2 0.14.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) 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-CrLYwNEx.js +2431 -0
  9. package/dist/ai-CrLYwNEx.js.map +1 -0
  10. package/dist/{ai-Cai-lCbj.d.ts → ai-HJ9fHfYI.d.ts} +72 -13
  11. package/dist/ai-HJ9fHfYI.d.ts.map +1 -0
  12. package/dist/ai-server.d.ts +4 -5
  13. package/dist/ai-server.d.ts.map +1 -1
  14. package/dist/ai-server.js +755 -290
  15. package/dist/ai-server.js.map +1 -1
  16. package/dist/ai.d.ts +2 -2
  17. package/dist/ai.js +1 -1999
  18. package/dist/client-BYzHjkwU.js.map +1 -1
  19. package/dist/{client-BAEABRZB.d.ts → client-CzyacQpJ.d.ts} +9 -9
  20. package/dist/client-CzyacQpJ.d.ts.map +1 -0
  21. package/dist/client.d.ts +1 -1
  22. package/dist/idempotent-id-BRJVeylj.js +13 -0
  23. package/dist/idempotent-id-BRJVeylj.js.map +1 -0
  24. package/dist/index.d.ts +8 -4
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +2 -1
  27. package/dist/otel.d.ts +1 -1
  28. package/dist/react.d.ts +13 -13
  29. package/dist/react.d.ts.map +1 -1
  30. package/dist/react.js.map +1 -1
  31. package/dist/reducer-DEMjEY_O.js.map +1 -1
  32. package/dist/{reducer-BcS9VDKC.d.ts → reducer-otzHjuJj.d.ts} +3 -3
  33. package/dist/{reducer-BcS9VDKC.d.ts.map → reducer-otzHjuJj.d.ts.map} +1 -1
  34. package/dist/scheduler-qstash.d.ts +2 -2
  35. package/dist/scheduler-qstash.js +1 -1
  36. package/dist/scheduler-vercel.d.ts +2 -2
  37. package/dist/scheduler-vercel.js +1 -1
  38. package/dist/{server-CjJSGcF7.js → server-BD5ckxZb.js} +39 -26
  39. package/dist/server-BD5ckxZb.js.map +1 -0
  40. package/dist/{server-Bp5Nd1pF.d.ts → server-DbD7IJVK.d.ts} +5 -5
  41. package/dist/{server-Bp5Nd1pF.d.ts.map → server-DbD7IJVK.d.ts.map} +1 -1
  42. package/dist/server.d.ts +3 -3
  43. package/dist/server.js +1 -1
  44. package/dist/{store-D_yhNdPz.d.ts → store-DyZM6fS5.d.ts} +3 -2
  45. package/dist/{store-D_yhNdPz.d.ts.map → store-DyZM6fS5.d.ts.map} +1 -1
  46. package/dist/store-N8PXxDAS.js.map +1 -1
  47. package/dist/store-memory.d.ts +1 -1
  48. package/dist/store-postgres.d.ts +1 -1
  49. package/dist/store-redis-http.d.ts +1 -1
  50. package/dist/store-redis-http.js +1 -1
  51. package/dist/{store-redis-notify-D2EI6gwX.js → store-redis-notify-BVLUfI3j.js} +14 -13
  52. package/dist/store-redis-notify-BVLUfI3j.js.map +1 -0
  53. package/dist/store-redis.d.ts +1 -1
  54. package/dist/store-redis.js +1 -1
  55. package/dist/store-sqlite.d.ts +1 -1
  56. package/dist/{telemetry-CpeclqB2.d.ts → telemetry-B5jzpy6y.d.ts} +2 -2
  57. package/dist/telemetry-B5jzpy6y.d.ts.map +1 -0
  58. package/docs/concepts/04-state.mdx +9 -4
  59. package/docs/guides/06-ai-agents.mdx +134 -43
  60. package/docs/reference/01-api.mdx +129 -27
  61. package/examples/playground/app/agent/[agentId]/agent-client.tsx +20 -28
  62. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +123 -0
  63. package/examples/playground/app/agent/[agentId]/agent-queue.tsx +37 -43
  64. package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +44 -24
  65. package/examples/playground/app/agent/compaction-timeline.test.ts +47 -7
  66. package/examples/playground/app/agent/compaction-timeline.ts +13 -3
  67. package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -5
  68. package/examples/playground/app/chat/[chatId]/session.ts +17 -2
  69. package/examples/playground/app/chat/model.test.ts +4 -4
  70. package/examples/playground/app/chat/model.ts +18 -6
  71. package/examples/playground/app/chat/server.ts +6 -3
  72. package/examples/playground/package.json +3 -1
  73. package/package.json +1 -1
  74. package/src/ai-client-state.ts +0 -2
  75. package/src/ai-context-schema.ts +296 -0
  76. package/src/ai-context.ts +680 -0
  77. package/src/ai-control-server.ts +160 -131
  78. package/src/ai-control-state.ts +6 -0
  79. package/src/ai-control.ts +6 -7
  80. package/src/ai-coordinator.ts +92 -38
  81. package/src/ai-id.ts +6 -0
  82. package/src/ai-input-tokens.ts +75 -0
  83. package/src/ai-message-projection.ts +348 -0
  84. package/src/ai-server.ts +232 -334
  85. package/src/ai-stored-state.ts +484 -0
  86. package/src/ai.ts +252 -418
  87. package/src/client.ts +21 -15
  88. package/src/idempotent-id.ts +11 -0
  89. package/src/index.ts +1 -0
  90. package/src/react.ts +25 -19
  91. package/src/reducer.ts +2 -1
  92. package/src/server.ts +73 -56
  93. package/src/store-redis-core.ts +15 -17
  94. package/src/store.ts +1 -0
  95. package/src/telemetry.ts +2 -1
  96. package/dist/ai-Cai-lCbj.d.ts.map +0 -1
  97. package/dist/ai-control-CcD4hh3y.js +0 -119
  98. package/dist/ai-control-CcD4hh3y.js.map +0 -1
  99. package/dist/ai.js.map +0 -1
  100. package/dist/client-BAEABRZB.d.ts.map +0 -1
  101. package/dist/server-CjJSGcF7.js.map +0 -1
  102. package/dist/store-redis-notify-D2EI6gwX.js.map +0 -1
  103. package/dist/telemetry-CpeclqB2.d.ts.map +0 -1
@@ -20,9 +20,9 @@ AI_GATEWAY_API_KEY=your_api_key
20
20
 
21
21
  ## Define the agent
22
22
 
23
- `agent()` defines an isomorphic event contract and its standard `AIState`
24
- reducer. The same value types the server, server-rendered state, and optimistic
25
- client updates.
23
+ `agent()` defines an isomorphic event contract, normalized `AIStoredState`,
24
+ and its `AIState` view. The same reducer types server snapshots, hydration,
25
+ and optimistic client updates.
26
26
 
27
27
  ```ts assistant.ts
28
28
  import { agent } from 'experimental-a2/ai'
@@ -399,7 +399,7 @@ stores every AI SDK chunk once. The reducer and the exported
399
399
  `UIMessage`; cumulative message snapshots are not duplicated in the log.
400
400
 
401
401
  `AIState` exposes `messages`, `status`, `activeGeneration`, `activeRequestId`,
402
- `activeResponseMessageId`, `activeProjection`, `responseGenerationIds`,
402
+ `activeResponseMessageId`, `activeProjection`, `responseOwners`,
403
403
  `terminalRequestIds`, `terminalGenerations`, `pendingApprovals`,
404
404
  `pendingInputs`, `tools`, `compaction`, per-generation `usage`, and the last
405
405
  generation `error`.
@@ -417,11 +417,23 @@ current response before its generation starts.
417
417
  `activeResponseMessageId` identifies that request's response before
418
418
  `activeGeneration` exists. It is `null` after the generation starts.
419
419
 
420
- `responseGenerationIds` keeps the latest generation owner for each response
420
+ `responseOwners` keeps the latest generation ID, request ID, and attempt for each response
421
421
  message. Late lifecycle events from a superseded generation stay in raw
422
422
  history but cannot alter the projected message, even after its replacement
423
423
  finishes.
424
424
 
425
+ A2 generates fixed-length deterministic IDs in its reserved `a2.ai:` namespace
426
+ for requests, generations, responses, and worker lifecycle events. Treat those
427
+ IDs as opaque. An assistant response uses its first model-request event's ID. Generation requests carry
428
+ `responseMessageId` explicitly; tool continuations also carry `sourceGenerationId`.
429
+ Ownership comes from these fields and the generation's `requestId` and `attempt`,
430
+ so ID strings never embed the preceding tool loop.
431
+
432
+ Approval and application-input events carry both `requestId` and `generationId`.
433
+ Copy both from the pending approval, pending input, or active generation when
434
+ sending a response. `activeRequestReason` and `activeRequestSourceGenerationId`
435
+ describe the current request independently from the message projection.
436
+
425
437
  Generation requests share one durable lane, so model calls do not overlap.
426
438
  The queued-turn policy is the stronger ordering rule: a later user message is
427
439
  not scheduled until the current response's complete tool loop finishes. Lane
@@ -442,7 +454,7 @@ continuation that has not been appended yet.
442
454
  | `inputs.retry(options)` | requests a fresh attempt for a failed response |
443
455
  | `inputs.stop({ turnId })` | ends the observed turn and advances |
444
456
  | `inputs.steer({ turnId, message })` | prioritizes input after the observed turn's current step finishes |
445
- | `inputs.queue.edit({ message, expectedRevision })` | edits a pending input |
457
+ | `inputs.queue.edit({ message })` | edits a pending input |
446
458
  | `inputs.queue.move({ inputId, beforeId })` | reorders pending input; `null` means the end |
447
459
  | `inputs.queue.remove({ inputId })` | removes pending input from the inbox |
448
460
  | `inputs.queue.sendNow({ inputId, turnId })` | atomically prioritizes queued input and interrupts the observed turn |
@@ -610,6 +622,7 @@ export function ApprovalControls() {
610
622
  onClick={() =>
611
623
  void push(
612
624
  ...inputs.approval({
625
+ requestId: approval.requestId,
613
626
  messageId: approval.messageId,
614
627
  generationId: approval.generationId,
615
628
  approvalId: approval.approvalId,
@@ -624,6 +637,7 @@ export function ApprovalControls() {
624
637
  onClick={() =>
625
638
  void push(
626
639
  ...inputs.approval({
640
+ requestId: approval.requestId,
627
641
  messageId: approval.messageId,
628
642
  generationId: approval.generationId,
629
643
  approvalId: approval.approvalId,
@@ -738,12 +752,19 @@ copy the owner from a pending approval, pending input, or running tool while
738
752
  the response is still waiting.
739
753
 
740
754
  After a failed model call, `inputs.retry({ messageId, responseMessageId,
741
- retryId })` records a retry request. The server schedules its fresh attempt.
755
+ retryId })` records a retry request. The server reruns that failed model call.
756
+ Earlier completed model steps and their tool results remain, including steps
757
+ completed after the latest compaction. The committed summary also remains. For
758
+ example, if a tool succeeds after compaction and the next model call fails, retry
759
+ uses the summary and that tool's result.
742
760
  `retryId` identifies the user's action, so resending one retry is safe while a
743
- later retry remains distinct. Failed partial progress remains in raw history
744
- but is excluded from the new prompt. Later user messages remain queued while
761
+ later retry remains distinct. The failed model call's uncommitted step remains in
762
+ raw history but is excluded from the new prompt and response. Later user messages remain queued while
745
763
  the failed response awaits an explicit retry or interruption.
746
764
 
765
+ Applications own conversation forks and edit-and-resend flows for previously
766
+ admitted messages. A2's retry operation continues the current response.
767
+
747
768
  ## Append from the server
748
769
 
749
770
  Browser interactions should use `push()` for instant state. Server-originated
@@ -802,17 +823,17 @@ failure or use an explicit threshold in that case.
802
823
  The default threshold is 75% of the context window. A larger configured
803
824
  `generation.maxOutputTokens` lowers the threshold further to reserve that output
804
825
  allowance. This is an input-context policy, not a new output limit. Limits and
805
- metadata status are available in `state.modelMetadata`.
826
+ metadata status are available through an explicit transcript reducer read.
806
827
 
807
828
  `compaction: { thresholdTokens: 80_000 }` overrides discovery with an explicit
808
829
  positive safe integer. `compaction: { instructions: 'Preserve exact IDs.' }`
809
830
  adds guidance to the built-in summary prompt while keeping the derived threshold.
810
831
  `compaction: false` disables both compaction and metadata lookup. The `compaction`
811
832
  option can also be a resolver receiving the same
812
- `{ event, state, session, signal }` context as `model` and `instructions`. It
833
+ `{ event, messages, session, signal }` context as `model` and `instructions`. It
813
834
  returns `false`, automatic options, or a custom policy once per generation and
814
835
  can await `session.state(settingsReducer)` to read application settings.
815
- Use this to select a threshold from the checkpointed AI state.
836
+ Use this to select a threshold from application settings at the prompt boundary.
816
837
  Changes after resolution apply to a later model step. Disabling compaction
817
838
  preserves summaries that were already recorded. Custom provider
818
839
  models and custom global SDK providers need an explicit threshold. A custom
@@ -849,7 +870,13 @@ the summary, its usage, and the exact event frontier it covers. Later model
849
870
  requests receive the summary plus everything produced after that frontier,
850
871
  including later tool steps in the same assistant message. Queued user messages
851
872
  remain queued. Compaction waits until earlier tool calls have terminal results.
852
- The raw log and `state.messages` remain available in full.
873
+ The raw log and transcript from `agent.reducer` remain available in full.
874
+ Server execution uses a separate model-context reducer and checkpoint. Successful
875
+ compaction permanently replaces covered context. Callbacks receive their prompt
876
+ messages and request information, not reducer state or execution internals.
877
+ Application reducers remain explicit reads through `session.state(reducer)`.
878
+ To read the full transcript, load `session.state(agent.reducer)` and pass its
879
+ `state` to `agent.reducer.view()`. Client hooks continue to expose `AIState`.
853
880
 
854
881
  Custom `shouldCompact(context)` and `compact(context)` callbacks remain
855
882
  available for deterministic replacement messages or application-specific
@@ -873,16 +900,25 @@ export function CompactionStatus() {
873
900
  }
874
901
  ```
875
902
 
876
- A failed or interrupted generation restores the prior compaction state. The
877
- synthetic summary request is not a user turn or an assistant reply in
903
+ Successful compaction is a permanent boundary. Failure, interruption, retry, and
904
+ worker recovery never restore the messages it replaced. Retry reruns the failed
905
+ model step from the latest committed context and preserves earlier completed
906
+ steps. A failed summary attempt leaves the previous committed context intact.
907
+ The synthetic summary request is not a user turn or an assistant reply in
878
908
  `state.messages`.
879
909
 
910
+ Compaction runs only when retained tool exchanges have final results and all
911
+ A2-owned tool work has settled, including work from stopped or superseded turns.
912
+ A2 checks a fresh coordinator checkpoint before starting a candidate compaction.
913
+ This adds one state read on that path. Unresolved dead-lettered tool work keeps
914
+ compaction deferred until repair and settlement or session closure.
915
+
880
916
  ## Extend the assembly
881
917
 
882
918
  The convenience API is ordinary A2 parts:
883
919
 
884
920
  - `events` and `createEvents()` export the built-in schemas.
885
- - `createReducer({ contract })` builds the standard `AIState` projection for a
921
+ - `createReducer({ contract })` builds the normalized state and `AIState` view for a
886
922
  compatible contract.
887
923
  - `agent()` combines the built-in protocol, application events, and reducer.
888
924
  - `tool()` binds an AI SDK function-tool definition to an agent's typed A2
@@ -979,20 +1015,56 @@ emit typed metadata chunks itself.
979
1015
 
980
1016
  ## Checkpoints and recovery reads
981
1017
 
982
- Model steps read the AI reducer and coordinator at a fixed log boundary. Model,
983
- instruction, compaction, and custom-generation callbacks receive derived state.
1018
+ The AI reducer stores normalized `AIStoredState`. `session.state(agent.reducer)`
1019
+ returns that state and its log index, ready to pass to `SessionProvider` or client
1020
+ hydration. Client and React snapshots derive the familiar `AIState` view
1021
+ immediately, including streamed message parts and tool activity.
1022
+
1023
+ For server-side inspection, use the reducer's view:
1024
+
1025
+ ```ts server/read-agent.ts
1026
+ import type { UIMessage } from 'ai'
1027
+ import { assistantServer } from './assistant'
1028
+ import { assistant } from '../assistant'
1029
+
1030
+ export async function readMessages(sessionId: string): Promise<UIMessage[]> {
1031
+ const snapshot = await assistantServer.session(sessionId).state(assistant.reducer)
1032
+ return assistant.reducer.view(snapshot.state).messages
1033
+ }
1034
+ ```
1035
+
1036
+ Each snapshot stores repeated payloads once and keeps only the changes needed
1037
+ to restore the current generation's prompt and projection. The live view is cached while
1038
+ events fold; normalization does not run for every token. Reading or serializing
1039
+ the stored fields materializes the records. The log and displayed conversation
1040
+ retain their full content.
1041
+
1042
+ Model steps read the model-context reducer and coordinator at a fixed log
1043
+ boundary. Model, instruction, compaction, and custom-generation callbacks
1044
+ receive prompt messages and request information without reducer state. Custom transcript folds and
1045
+ schemas apply only to the transcript; read application state explicitly with
1046
+ `session.state(yourReducer)` when a callback needs it.
984
1047
  They do not receive a raw history array. Their `session.state(reducer)` reads
985
1048
  application reducers at the captured prompt boundary by default; explicit
986
1049
  `through` options follow the ordinary state-read contract. Compaction selection
987
1050
  can be asynchronous when it needs a settings-state read.
988
1051
 
989
- Ordinary generation uses checkpointed state. A recovered attempt reads from its
990
- saved prompt boundary to the captured current boundary, excluding abandoned
991
- attempts. Tools recovering in a fresh process reconstruct their original prompt
992
- from that checkpoint and the owning generation's start and compaction events.
993
- Compacted context uses its checkpoint and subsequent events. These AI history
994
- reads always have explicit lower and upper bounds. A2 core can rebuild a missing
995
- or invalid reducer snapshot from the log.
1052
+ Ordinary generation uses the model-context checkpoint without reading the
1053
+ transcript or replaying history to apply an existing compaction. The context
1054
+ checkpoint has its own identity and fold. Recovery and explicit retry discard
1055
+ only the unfinished model step. Completed compactions remain committed, so these
1056
+ paths never reconstruct pre-compaction prompts or retain rollback copies.
1057
+
1058
+ Tools recovering in a fresh process read context at their tool-admission index.
1059
+ The saved generation projection restores their original prompt, preserving the
1060
+ committed summary and excluding later arrivals. No separate history read is
1061
+ needed. A2 core can rebuild a missing or invalid reducer snapshot from the log.
1062
+
1063
+ Compaction immediately releases covered message and tool content from model
1064
+ context. Execution retains the active turn, queued inputs, unsettled tool work
1065
+ IDs, and token calibration. Model metadata is cached per distinct model.
1066
+ Historical input uniqueness comes from core event deduplication. The full
1067
+ transcript remains available independently.
996
1068
 
997
1069
  Streaming progress updates the current message incrementally. Snapshots preserve
998
1070
  open text and reasoning parts and partial tool inputs. Indexed progress remains
@@ -1037,9 +1109,23 @@ agent session when adopting it. Existing event logs remain readable; finish
1037
1109
  pending work with the runtime that created it before upgrading.
1038
1110
 
1039
1111
  `inputs.message(message)` places a message in the durable inbox. The controller
1040
- admits one input at a time, freezes its revision, and adds it to `state.messages`.
1112
+ admits one input at a time, freezes its content, and adds it to `state.messages`.
1041
1113
  Pending inputs appear in `state.inbox.items`; they do not enter the model prompt.
1042
- Each item has an `id`, `revision`, `message`, and `generate` flag.
1114
+ Each item has an `id`, `message`, and `generate` flag.
1115
+
1116
+ New-input commands from `inputs.message()`, `inputs.seed()`, and `inputs.steer()`
1117
+ use the message ID as their event ID. Those IDs must match on hand-written
1118
+ commands too. Caller IDs cannot start with the reserved `a2.ai:` prefix. IDs must
1119
+ be unique within the store; include contract/session scope when deriving them
1120
+ with `idempotentId()`.
1121
+
1122
+ An identical submission replays its original event without creating more work,
1123
+ even after removal or compaction. Reusing the ID with different content, a
1124
+ different command action, or another session rejects at append with
1125
+ `PARTIAL_DUPLICATE_BATCH`. Use `queue.edit()` to change a pending input and a fresh
1126
+ message ID to create another input. Edit, move, remove, and other commands have
1127
+ their own event IDs. The controller stores no historical input-ID registry.
1128
+
1043
1129
 
1044
1130
  `state.active` identifies the logical turn, its frozen `input`, and its `phase`.
1045
1131
  The turn ID stays the same across model steps, tools, and a pause. Stop ends that
@@ -1049,8 +1135,8 @@ turn and admits the next queued input when admission is running.
1049
1135
  import { inputs, type AIState } from 'experimental-a2/ai'
1050
1136
  import type { UIMessage } from 'ai'
1051
1137
 
1052
- export function editPending(message: UIMessage, expectedRevision: number) {
1053
- return inputs.queue.edit({ message, expectedRevision })
1138
+ export function editPending(message: UIMessage) {
1139
+ return inputs.queue.edit({ message })
1054
1140
  }
1055
1141
 
1056
1142
  export function movePending(inputId: string, beforeId: string | null) {
@@ -1083,13 +1169,15 @@ export const resume = () => inputs.resume()
1083
1169
  Pass these arrays to `push(...events)` from `useSession()`, or to a server
1084
1170
  session's `append(...events)`. `beforeId: null` moves an item to the end.
1085
1171
  Removal only applies to pending input. It preserves the immutable log and keeps
1086
- the input identity reserved, so replay cannot recreate a removed prompt.
1087
- Removing an admitted input returns `already-active`; removing an absent or
1088
- already removed input returns `not-found`.
1172
+ its original submission event, so replay cannot recreate a removed prompt.
1173
+ Removing an input that is no longer pending returns `not-pending`, whether it was
1174
+ admitted, removed, or never queued.
1089
1175
 
1090
- An edit that loses to admission returns `already-active`. Concurrent edits use
1091
- `expectedRevision`; an outdated revision returns `revision-conflict`. A Stop
1092
- aimed at a completed turn returns `stale-turn`.
1176
+ Edits to pending input use last-write-wins in session log order. Clients can send
1177
+ another edit before an earlier one is confirmed. Editing preserves the message's
1178
+ identity and role; changing its role returns `role-mismatch`. An edit that loses
1179
+ to admission returns `not-pending`. A Stop aimed at a completed turn returns
1180
+ `stale-turn`.
1093
1181
 
1094
1182
  Steer validates the new input and places it ahead of the inbox in one decision.
1095
1183
  If the observed turn is still active, it finishes its current model response and
@@ -1109,7 +1197,7 @@ Steering preserves the admission gate: steering while paused does not resume
1109
1197
  execution. Duplicate input identities and closed sessions still reject the command.
1110
1198
 
1111
1199
  Send now uses the selected queued message's existing identity and latest
1112
- accepted revision. It validates the selected input and the observed `turnId`,
1200
+ accepted content. It validates the selected input and the observed `turnId`,
1113
1201
  then moves the input first for the same handoff after the current step. Use
1114
1202
  `turnId: null` when no turn is active. If that expectation is stale, the command
1115
1203
  returns `stale-turn` without changing the queue.
@@ -1117,8 +1205,8 @@ returns `stale-turn` without changing the queue.
1117
1205
  Send now preserves pause just like steering. A paused queue keeps the selected
1118
1206
  input first until Resume. It accepts user-role inputs, including passive user
1119
1207
  input, which it marks for generation. Non-user context returns `not-user-input`;
1120
- a conflicting response identity returns `duplicate-input`. Unsaved editor text
1121
- is separate from the accepted queue revision; save it before sending.
1208
+ reserved internal IDs are rejected before append. Unsaved editor text
1209
+ is separate from the accepted queue content; save it before sending.
1122
1210
 
1123
1211
  A queued item's optional `afterStepOf` records the observed turn for steering.
1124
1212
  A matching active turn hands off after its current step.
@@ -1175,12 +1263,15 @@ Server `session.state(reducer)`, reducer folds, checkpoints, and model prompts
1175
1263
  contain confirmed messages and inbox entries, with `starting: false`. The client
1176
1264
  applies pending intent and displays accepted steering only when returning its
1177
1265
  state, after folding events.
1178
- A custom reducer that calls `reduceAIState()` or nests `agent.reducer.fold()`
1179
- uses that confirmed fold; it does not inherit the built-in reducer's client display.
1266
+ A custom reducer can keep normalized AI state by nesting `agent.reducer.fold()`
1267
+ and derive its view with `agent.reducer.view()`. It does not inherit the built-in
1268
+ optimistic client display. `reduceAIState()` remains a pure transition over the
1269
+ confirmed `AIState` view for applications that project raw event histories.
1180
1270
  `state.pendingQueueCommands` tracks unresolved queue commands incrementally, so
1181
- the client does not scan raw history. Revisions remain server-confirmed; keep
1182
- repeat editing and Send now disabled while an edit for that item awaits its
1183
- receipt. Moving and removing an item do not require a revision.
1271
+ the client does not scan raw history. Pending edits apply in order, so the latest
1272
+ local edit stays visible while earlier commands are confirmed. Editing and Send
1273
+ now can remain available while an edit awaits its receipt. Close an editor as
1274
+ soon as its save is pushed; retain the draft if the push fails.
1184
1275
 
1185
1276
  ### Persistence and application are separate
1186
1277
 
@@ -63,6 +63,31 @@ The shared base for A2 failures, discriminated by `code`. The session
63
63
  client exposes its `A2ClientError` subclass. See
64
64
  [Errors](/reference/errors).
65
65
 
66
+ ### `idempotentId(...parts)`
67
+
68
+ `idempotentId(...parts: string[]): Promise<string>` derives a stable UUIDv8
69
+ from an ordered tuple of strings. It runs on the server and in the browser.
70
+
71
+ ```ts shared/request-id.ts
72
+ import { idempotentId } from 'experimental-a2'
73
+
74
+ export const id = await idempotentId('orders', 'shop-7', 'request-42')
75
+ ```
76
+
77
+ Pass the result as an event's `id` when retries should converge on the same
78
+ event. The same tuple produces the same ID across runtimes and A2 versions.
79
+ String boundaries, order, and case matter; strings are not normalized.
80
+ Empty strings and an empty tuple are valid inputs.
81
+
82
+ Every argument has the same role. A prefix such as `'orders'` is a namespace
83
+ by convention. Include enough context to distinguish operations across
84
+ contracts and sessions, since event IDs are globally unique within a store.
85
+
86
+ The helper performs no I/O. A2 preserves explicit IDs and keeps its automatic
87
+ ID generation unchanged. Changing an existing application's ID recipe changes
88
+ retry identity, so retries of previously committed requests need to retain
89
+ their original IDs.
90
+
66
91
  ## `experimental-a2/server`
67
92
 
68
93
  ### `handler(entry)`
@@ -736,6 +761,13 @@ eligible retained checkpoint. A root historical read never creates retention
736
761
  or writes a stale checkpoint behind the latest snapshot; it folds from the log
737
762
  when no eligible checkpoint exists.
738
763
 
764
+ Operational snapshot-read failures preserve existing A2 errors; other errors
765
+ become `STORE_UNAVAILABLE` with the original error as the cause. They do not retry through individual
766
+ reads or raw history. Cache misses and `stateSchema` rejection still rebuild
767
+ from the log. Snapshot write-back remains background work; failures are reported
768
+ through `a2.snapshot` telemetry or the default console sink, without failing the
769
+ completed read or attaching snapshot contents.
770
+
739
771
  ### `session.stream(options?)`
740
772
 
741
773
  ```ts
@@ -1073,20 +1105,24 @@ Pure typed inputs for `append()` and `push()`:
1073
1105
  | `inputs.retry(options)` | retries a failed response |
1074
1106
  | `inputs.stop({ turnId, lastSeenIndex? })` | ends the observed turn and advances when admission is open |
1075
1107
  | `inputs.steer({ turnId, message, lastSeenIndex? })` | puts the new input first for a handoff after the observed turn's current step |
1076
- | `inputs.queue.edit({ message, expectedRevision })` | changes a pending input; its ID stays fixed |
1108
+ | `inputs.queue.edit({ message })` | changes a pending input; its ID stays fixed |
1077
1109
  | `inputs.queue.move({ inputId, beforeId })` | moves pending input before an ID; `null` means the end |
1078
- | `inputs.queue.remove({ inputId })` | removes pending input; its identity stays reserved |
1110
+ | `inputs.queue.remove({ inputId })` | removes pending input; its submission stays in the log |
1079
1111
  | `inputs.queue.sendNow({ inputId, turnId, lastSeenIndex? })` | promotes an existing queued input for a handoff after the current step; preserves pause |
1080
1112
  | `inputs.pause({ when: 'now' \| 'after-turn' })` | pauses execution now or inbox admission after the turn |
1081
1113
  | `inputs.resume()` | resumes retained work, then inbox admission |
1082
1114
  | `inputs.toolResult(result)` | records a deferred provider-executed tool result from trusted server code |
1083
1115
  | `inputs.interrupt(options)` | interrupts a specific generation/request owner |
1084
1116
 
1117
+ Edits are last-write-wins in session log order and preserve the pending message's
1118
+ identity and role. They require no revision or confirmation before another edit.
1119
+ Admission freezes the message; subsequent edits return `not-pending`.
1120
+
1085
1121
  All builders emit `ai.control.requested`. Persistence and application are
1086
1122
  separate: `ai.control.decided` returns `{ commandId, outcome, reason? }` through
1087
1123
  the existing stream. Outcomes are `applied` or `rejected`. Rejection reasons
1088
- include `already-active`, `revision-conflict`, `stale-turn`, `not-found`,
1089
- `duplicate-input`, `not-user-input`, and `closed`. `state.receipt` holds the latest user receipt.
1124
+ include `not-pending`, `role-mismatch`, `stale-turn`, `not-found`,
1125
+ `not-user-input`, and `closed`. `state.receipt` holds the latest user receipt.
1090
1126
 
1091
1127
  Steering validates and prioritizes new input even if the observed turn has
1092
1128
  finished. A matching active turn finishes its current model response and tool
@@ -1097,15 +1133,14 @@ or waits first behind a different active turn. The priority change is atomic;
1097
1133
  the later handoff and new work are committed atomically at the step boundary.
1098
1134
  Steer and Send now do not use `lastSeenIndex` to cut output.
1099
1135
 
1100
- Queue removal leaves the log intact and retains the removed identity's reservation.
1101
- Send now preserves the queued input ID and latest accepted revision. It requires
1136
+ Queue removal leaves the original submission in the log, so replay does not recreate the input.
1137
+ Send now preserves the queued input ID and latest accepted content. It requires
1102
1138
  an observed `turnId` (or null for idle), preserves pause, and only accepts
1103
- user-role input. It promotes passive user input to generation after reserving its
1104
- response ID. Missing targets, stale turns, and identity collisions reject before
1105
- any queue change.
1139
+ user-role input. It promotes passive user input to generation. Missing targets
1140
+ and stale turns reject before any queue change.
1106
1141
 
1107
1142
  `state.inbox` contains `paused` and ordered `items`. Each item contains
1108
- `{ id, revision, message, generate, afterStepOf? }`. The optional turn ID
1143
+ `{ id, message, generate, afterStepOf? }`. The optional turn ID
1109
1144
  `afterStepOf` records the observed steering turn while that item remains queued;
1110
1145
  a matching active turn hands off after its current step.
1111
1146
  Removal cancels it and admission consumes it. `state.active` is null or contains
@@ -1117,7 +1152,12 @@ execution version. Tools without terminal outcomes can retry; their stable
1117
1152
 
1118
1153
  `inputs` deliberately has no session lifecycle methods. Append explicit
1119
1154
  `ai.session.created` and `ai.session.closed` events from trusted server code
1120
- when an application uses them. Message, approval, input, and retry builders use stable interaction IDs.
1155
+ when an application uses them. Message, seed, and steering builders set the event ID to the input message ID.
1156
+ The two IDs must match for all new-input commands. Caller command/input IDs must
1157
+ be nonempty and cannot use the reserved `a2.ai:` prefix. Identical events replay;
1158
+ conflicting ID reuse rejects before append under core deduplication rules. IDs
1159
+ are unique across the store, including different contracts and sessions.
1160
+ Approval, input-response, and retry builders use stable interaction IDs.
1121
1161
  Other commands use IDs assigned by `push()` or `append()`; transport retries
1122
1162
  preserve those IDs. Browser
1123
1163
  ingress accepts user messages, approval and input responses, interruptions,
@@ -1138,12 +1178,25 @@ field. Model output is generation progress rather than another
1138
1178
  new turn.
1139
1179
 
1140
1180
  Approval and input request/response payloads require the active
1141
- `generationId`. Clients copy it from the pending request, which prevents a
1181
+ `requestId` and `generationId`. Clients copy both from the pending request, which prevents a
1142
1182
  delayed response from satisfying a newer model step.
1143
1183
 
1144
1184
  `ai.retry.requested` carries `{ messageId, responseMessageId, retryId }`.
1145
1185
  `retryId` identifies one user action. The server converts the fact into an
1146
1186
  `ai.generation.requested` whose reason is `retry`.
1187
+ Retry reruns the failed model call while retaining earlier completed model steps,
1188
+ their tool results, and committed compactions, including steps completed after
1189
+ the latest compaction.
1190
+ The failed model call's uncommitted step is removed from the active prompt and
1191
+ response. Conversation forks and edit-and-resend flows for admitted messages are
1192
+ application-owned.
1193
+
1194
+ A2-generated requests, generations, assistant responses, worker jobs, reports,
1195
+ and lifecycle events have fixed-length deterministic IDs in the reserved
1196
+ `a2.ai:` namespace. An assistant message's ID equals its initial model-request
1197
+ event ID. Control state keeps no lifetime input/response-ID registry. IDs are opaque;
1198
+ ownership is carried by event fields. `ai.generation.requested` requires
1199
+ `responseMessageId`; a tool continuation also requires `sourceGenerationId`.
1147
1200
 
1148
1201
  `ai.generation.failed` sets `stepLimit: true` when `maxSteps` rejects a
1149
1202
  continuation before another model step starts.
@@ -1192,11 +1245,12 @@ inbox. This display survives receipt and hydration, including while paused.
1192
1245
  They remain pending server inputs until admission and do not enter the model
1193
1246
  prompt early. Multiple steering inputs follow inbox admission order.
1194
1247
 
1195
- `state.active`, model execution, and revision numbers stay confirmed.
1248
+ `state.active` and model execution stay confirmed.
1196
1249
  Server reads and reducer folds retain confirmed messages and inbox entries;
1197
1250
  `state.starting` is false there. Custom reducers that call `reduceAIState()` or
1198
- nest the AI fold do not inherit the client display behavior. Disable repeat editing and Send now while an
1199
- edit for that input is present in `state.pendingQueueCommands`.
1251
+ nest the AI fold do not inherit the client display behavior. Repeat editing and
1252
+ Send now remain available while earlier edits are pending. Later local edits
1253
+ stay visible through earlier commands' acknowledgments and receipts.
1200
1254
 
1201
1255
  The pending-command ledger survives request echo and retires on the matching
1202
1256
  `ai.control.decided` receipt or session close. Failed pushes roll back through
@@ -1206,15 +1260,32 @@ read raw history.
1206
1260
  ### `createReducer(options)`
1207
1261
 
1208
1262
  ```ts
1209
- createReducer({ contract, name? }): Reducer<AIState>
1263
+ createReducer({ contract, name?, fold?, stateSchema? }): AIStoredReducer
1210
1264
  ```
1211
1265
 
1212
- Builds the standard AI projection for a compatible contract. `AIState`
1266
+ Builds a normalized AI reducer for a compatible contract. Its `initialState`,
1267
+ `fold`, and server `session.state(reducer)` use `AIStoredState`. Client and
1268
+ React snapshots expose the inferred `AIState` view automatically.
1269
+
1270
+ `reducer.view(storedState)` derives confirmed `AIState` for server code.
1271
+ `reducer.fromView(state)` converts a confirmed view into stored state, including
1272
+ when migrating an application-owned hydration value. Keep the stored state and
1273
+ its log index together when hydrating; do not fold already-seen history twice.
1274
+ `fold` can customize the confirmed `AIState` transition, and `stateSchema`
1275
+ validates that view after restoration. Both apply only to the transcript.
1276
+ Model context has its own built-in fold and schema. Changed custom fold semantics
1277
+ still require a new `name`.
1278
+
1279
+ Stored state is versioned, serializable data. Shared payload records and
1280
+ inverse changes replace repeated tool values and complete recovery copies.
1281
+ The reducer caches its live view and builds the records when the stored fields
1282
+ are read or serialized. Treat state and views as immutable. No event or tool
1283
+ payload is truncated. `AIState`
1213
1284
  contains `inbox`, `active`, `starting`, the latest command `receipt`, session lifecycle,
1214
1285
  messages, generation status, pending approvals
1215
1286
  and input, tool activity, compaction, usage, the last error, and
1216
- `activeRequestId`, `activeResponseMessageId`,
1217
- `responseGenerationIds: Record<string, string>`, and terminal request and
1287
+ `activeRequestId`, `activeRequestReason`, `activeRequestSourceGenerationId`, `activeResponseMessageId`,
1288
+ `responseOwners: Record<string, { generationId: string; requestId: string; attempt: number }>`, and terminal request and
1218
1289
  generation ownership fences.
1219
1290
  `activeRequestId` is the server-authorized generation request and fences
1220
1291
  delayed requests before their generation starts. `activeResponseMessageId`
@@ -1223,7 +1294,7 @@ identifies the requested response until `activeGeneration` exists.
1223
1294
  active, after a generation step completes while its response waits on tool,
1224
1295
  approval, or input barriers, while paused, and after a generation fails while it awaits retry
1225
1296
  or interruption. Response completion, interruption, supersession, retry, or a
1226
- later generation clears or replaces it. `responseGenerationIds` keeps the
1297
+ later generation clears or replaces it. `responseOwners` keeps the
1227
1298
  latest generation owner for each response message, so late events from a
1228
1299
  superseded owner cannot alter the projection. `terminalRequestIds` and
1229
1300
  `terminalGenerations` preserve accepted completion, failure, interruption,
@@ -1242,7 +1313,7 @@ Checkpoint size and serialization cost still grow with retained progress.
1242
1313
  Tool-input deltas update `input-streaming` parts with best-effort partial JSON
1243
1314
  for display. The state stays `input-streaming` until a completed input arrives;
1244
1315
  partial parsing does not authorize execution or change interruption handling.
1245
- Extension events are ignored. The default reducer name is `a2.ai.state.v17`.
1316
+ Extension events are ignored. The default reducer name is `a2.ai.state.v21:records-v2`.
1246
1317
 
1247
1318
  ### `deriveUIMessages(history)` and `reduceAIState(state, event)`
1248
1319
 
@@ -1345,6 +1416,16 @@ The resolver runs once per generation and returns a valid policy or `false`.
1345
1416
  It can await application reducers through `context.session.state(reducer)`, whose
1346
1417
  default boundary is the captured prompt frontier. Model, instruction, compaction,
1347
1418
  metadata, and custom-generation contexts expose state access and omit raw history.
1419
+ Callbacks receive prompt messages and request identity, without `state` or
1420
+ `execution` arguments. Model and instruction resolvers receive
1421
+ `{ event, messages, session, signal }`. Compaction and generation callbacks also
1422
+ receive `modelMessages`, including committed summaries. Custom transcript folds
1423
+ and schemas apply only to the transcript. `agent.reducer` and `createReducer()`
1424
+ continue to expose the full
1425
+ transcript view. To read it explicitly, load
1426
+ `context.session.state(agent.reducer)` and pass its `state` to
1427
+ `agent.reducer.view()`. The stored state also remains suitable for client
1428
+ hydration.
1348
1429
  Static options are validated at construction; resolved options are validated
1349
1430
  before that generation starts. Each session uses its own resolved value. `thresholdTokens` is an
1350
1431
  optional positive safe integer. `instructions` adds to A2's internal summary
@@ -1365,10 +1446,20 @@ compaction configuration. Provider-executed tools, structured output, and forced
1365
1446
  tool choices skip implicit compaction and reject explicit automatic policies.
1366
1447
  Custom policies remain available for these configurations.
1367
1448
 
1368
- The input estimate includes messages, tools, and instructions, and uses prior
1369
- reported input usage to anchor later growth where applicable. It is not a provider
1370
- tokenizer. Completed generations record `inputTokenEstimate` beside usage. The
1371
- built-in summary adds one model request and preserves the request prefix for
1449
+ The raw input estimate uses serialized bytes of messages, tools, and instructions,
1450
+ excluding provider options on messages and content parts. These options still reach
1451
+ the model unchanged. It is not a provider tokenizer. A preceding same-model
1452
+ completion anchors later growth to reported input usage, correcting both overestimates
1453
+ and underestimates. A smaller prompt, retry, compaction, or model change stops
1454
+ that calibration.
1455
+
1456
+ Completed generations record the raw `inputTokenEstimate` beside usage.
1457
+ Automatic `ai.compaction.requested` events also record the pre-compaction
1458
+ `inputTokenEstimate`, the calibrated `inputTokens` used for the decision, and
1459
+ `thresholdTokens`. Custom-policy requests omit these diagnostics. They add no
1460
+ model calls or database round trips.
1461
+
1462
+ The built-in summary adds one model request and preserves the request prefix for
1372
1463
  caching. It does not invoke application generation callbacks, transforms, or tool
1373
1464
  hooks. Both custom policy callbacks and `generate` receive `modelMessages`, the
1374
1465
  model-ready prompt including summaries, alongside typed UI `messages`.
@@ -1376,7 +1467,14 @@ model-ready prompt including summaries, alongside typed UI `messages`.
1376
1467
  Built-in summaries remain separate from application-typed messages. Render
1377
1468
  `state.compaction?.status === 'running'` as current activity, and the durable
1378
1469
  `ai.compaction.*` events as historical timeline markers. The conversation in
1379
- `state.messages` remains intact. `progress` controls durable batching with
1470
+ `state.messages` from the transcript reducer remains intact. Model execution
1471
+ uses a separate context checkpoint; existing compactions do not require a
1472
+ transcript read. Successful compaction is permanent, including across failure,
1473
+ interruption, retry, and recovery. Retry reruns the failed step from the latest
1474
+ committed context. Compaction defers while tool exchanges or A2-owned tool work
1475
+ remain unsettled, checked with one fresh coordinator state read on each candidate.
1476
+ Unresolved dead-lettered tool work also defers compaction until repair and
1477
+ settlement or session closure. `progress` controls durable batching with
1380
1478
  `maxChunks` and `maxDelayMs`.
1381
1479
 
1382
1480
  This entry point is server-only and resolves to a throwing browser stub.
@@ -1519,7 +1617,7 @@ each handler → the events those handlers append.
1519
1617
 
1520
1618
  ### The span catalogue
1521
1619
 
1522
- Four spans, all carrying `a2.contract` and `a2.session_id`:
1620
+ Five spans, all carrying `a2.contract` and `a2.session_id`:
1523
1621
 
1524
1622
  | Span | Wraps |
1525
1623
  | ----------- | ------------------------------------------------------ |
@@ -1527,6 +1625,7 @@ Four spans, all carrying `a2.contract` and `a2.session_id`:
1527
1625
  | `a2.drain` | one drain pass over a session's backlog |
1528
1626
  | `a2.event` | one claimed dispatch of one event |
1529
1627
  | `a2.state` | one `state()` read: snapshot-plus-tail load + fold |
1628
+ | `a2.snapshot` | one background batch of checkpoint writes |
1530
1629
 
1531
1630
  Attributes arrive in two waves: **at start** (passed to `span()`) and
1532
1631
  **mid-span** (via `handle.setAttribute`; outcomes and counts aren't
@@ -1554,6 +1653,9 @@ alert on are all mid-span.
1554
1653
  | `a2.state.snapshot` | `a2.state` | mid | `hit` \| `miss` \| `rejected` (schema guard discarded it) |
1555
1654
  | `a2.state.folded` | `a2.state` | mid | events folded past the snapshot |
1556
1655
  | `a2.state.index` | `a2.state` | mid | the frontier the returned state reflects |
1656
+ | `a2.snapshot.reducer` | `a2.snapshot` | start | the reducer's name |
1657
+ | `a2.snapshot.index` | `a2.snapshot` | start | greatest checkpoint index in the write batch |
1658
+ | `a2.snapshot.count` | `a2.snapshot` | start | number of checkpoints in the write batch |
1557
1659
 
1558
1660
  Drain outcomes: `settled` means nothing actionable is left; `busy` means live
1559
1661
  per-event claims remain; `stalled` means a caught failure needs a later retry.