experimental-a2 0.13.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 (99) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/{actor-DJi3RsNu.d.ts → actor-BfQSE0KC.d.ts} +4 -4
  3. package/dist/{actor-DJi3RsNu.d.ts.map → actor-BfQSE0KC.d.ts.map} +1 -1
  4. package/dist/actor-client.d.ts +1 -1
  5. package/dist/actor-client.js +1 -1
  6. package/dist/actor-react.d.ts +3 -3
  7. package/dist/actor-react.js +2 -2
  8. package/dist/{actor-shared-DI7J5upy.js → actor-shared-B5tJfzt-.js} +2 -2
  9. package/dist/{actor-shared-DI7J5upy.js.map → actor-shared-B5tJfzt-.js.map} +1 -1
  10. package/dist/actor.d.ts +1 -1
  11. package/dist/actor.js +3 -3
  12. package/dist/ai-Cai-lCbj.d.ts +580 -0
  13. package/dist/ai-Cai-lCbj.d.ts.map +1 -0
  14. package/dist/ai-control-CcD4hh3y.js +119 -0
  15. package/dist/ai-control-CcD4hh3y.js.map +1 -0
  16. package/dist/ai-server.d.ts +6 -6
  17. package/dist/ai-server.d.ts.map +1 -1
  18. package/dist/ai-server.js +1014 -501
  19. package/dist/ai-server.js.map +1 -1
  20. package/dist/ai.d.ts +2 -365
  21. package/dist/ai.js +801 -80
  22. package/dist/ai.js.map +1 -1
  23. package/dist/{client-P_NNNRM-.d.ts → client-BAEABRZB.d.ts} +2 -2
  24. package/dist/{client-P_NNNRM-.d.ts.map → client-BAEABRZB.d.ts.map} +1 -1
  25. package/dist/{client-Bf6uSEAk.js → client-BYzHjkwU.js} +21 -6
  26. package/dist/client-BYzHjkwU.js.map +1 -0
  27. package/dist/client.d.ts +1 -1
  28. package/dist/client.js +1 -1
  29. package/dist/{contract-48bUMgcL.js → contract-CKRg_E4q.js} +3 -26
  30. package/dist/contract-CKRg_E4q.js.map +1 -0
  31. package/dist/index.d.ts +2 -2
  32. package/dist/index.js +1 -1
  33. package/dist/react.d.ts +2 -2
  34. package/dist/react.js +1 -1
  35. package/dist/{reducer-DJKWm3cp.d.ts → reducer-BcS9VDKC.d.ts} +4 -1
  36. package/dist/{reducer-DJKWm3cp.d.ts.map → reducer-BcS9VDKC.d.ts.map} +1 -1
  37. package/dist/reducer-DEMjEY_O.js +29 -0
  38. package/dist/reducer-DEMjEY_O.js.map +1 -0
  39. package/dist/scheduler-qstash.d.ts +2 -2
  40. package/dist/scheduler-qstash.js +1 -1
  41. package/dist/scheduler-vercel.d.ts +2 -2
  42. package/dist/scheduler-vercel.js +1 -1
  43. package/dist/{server-DjZZa1wr.d.ts → server-Bp5Nd1pF.d.ts} +3 -3
  44. package/dist/{server-DjZZa1wr.d.ts.map → server-Bp5Nd1pF.d.ts.map} +1 -1
  45. package/dist/{server-BeNADlCI.js → server-CjJSGcF7.js} +4 -3
  46. package/dist/server-CjJSGcF7.js.map +1 -0
  47. package/dist/server.d.ts +3 -3
  48. package/dist/server.js +1 -1
  49. package/dist/{store-DtDOWLSn.d.ts → store-D_yhNdPz.d.ts} +7 -2
  50. package/dist/{store-DtDOWLSn.d.ts.map → store-D_yhNdPz.d.ts.map} +1 -1
  51. package/dist/store-N8PXxDAS.js.map +1 -1
  52. package/dist/store-memory.d.ts +1 -1
  53. package/dist/store-postgres.d.ts +1 -1
  54. package/dist/store-postgres.js +19 -0
  55. package/dist/store-postgres.js.map +1 -1
  56. package/dist/store-redis-http.d.ts +1 -1
  57. package/dist/store-redis-http.js +1 -1
  58. package/dist/{store-redis-notify-BUCyXOn0.js → store-redis-notify-D2EI6gwX.js} +27 -2
  59. package/dist/store-redis-notify-D2EI6gwX.js.map +1 -0
  60. package/dist/store-redis.d.ts +1 -1
  61. package/dist/store-redis.js +1 -1
  62. package/dist/store-sqlite.d.ts +1 -1
  63. package/docs/guides/06-ai-agents.mdx +265 -62
  64. package/docs/reference/01-api.mdx +122 -27
  65. package/examples/playground/app/agent/[agentId]/agent-client.tsx +118 -21
  66. package/examples/playground/app/agent/[agentId]/agent-queue.tsx +289 -0
  67. package/examples/playground/app/agent/compaction-settings.test.ts +22 -6
  68. package/examples/playground/app/agent/model.ts +11 -2
  69. package/examples/playground/app/agent/server.ts +8 -2
  70. package/examples/playground/app/chat/[chatId]/chat-client.tsx +3 -13
  71. package/examples/playground/app/chat/model.ts +2 -2
  72. package/examples/playground/app/chat/server.ts +24 -17
  73. package/examples/playground/app/globals.css +179 -0
  74. package/examples/playground/package.json +1 -1
  75. package/package.json +1 -1
  76. package/src/ai-client-state.ts +185 -0
  77. package/src/ai-control-server.ts +829 -0
  78. package/src/ai-control-state.ts +152 -0
  79. package/src/ai-control.ts +139 -0
  80. package/src/ai-coordinator.ts +99 -32
  81. package/src/ai-progress-batches.ts +68 -0
  82. package/src/ai-projector.ts +76 -15
  83. package/src/ai-sdk-step.ts +0 -1
  84. package/src/ai-server.ts +381 -608
  85. package/src/ai.ts +553 -108
  86. package/src/client.ts +31 -9
  87. package/src/licenses/Apache-2.0.txt +55 -0
  88. package/src/parse-partial-json.ts +441 -0
  89. package/src/reducer.ts +6 -0
  90. package/src/server.ts +8 -4
  91. package/src/store-postgres.ts +27 -0
  92. package/src/store-redis-core.ts +53 -1
  93. package/src/store-redis-notify.ts +1 -0
  94. package/src/store.ts +6 -0
  95. package/dist/ai.d.ts.map +0 -1
  96. package/dist/client-Bf6uSEAk.js.map +0 -1
  97. package/dist/contract-48bUMgcL.js.map +0 -1
  98. package/dist/server-BeNADlCI.js.map +0 -1
  99. package/dist/store-redis-notify-BUCyXOn0.js.map +0 -1
@@ -135,10 +135,10 @@ export const GET = assistantServer.fetch
135
135
  export const POST = assistantServer.fetch
136
136
  ```
137
137
 
138
- The route never calls the model directly. The browser appends user facts such
139
- as `ai.message.created`. Built-in server handlers schedule
140
- `ai.generation.requested`, run the AI SDK, and append progress back to the same
141
- log.
138
+ The route never calls the model directly. The browser appends
139
+ `ai.control.requested` commands. The controller admits inputs and schedules
140
+ `ai.generation.requested`; workers run the AI SDK and append progress to the
141
+ same log.
142
142
 
143
143
  ## Bind the reducer to React
144
144
 
@@ -309,11 +309,9 @@ export function AgentClient() {
309
309
  }
310
310
  ```
311
311
 
312
- There is no separate message state to reconcile. `push()` folds the
313
- `ai.message.created` fact locally before starting the request, so the user
314
- message appears immediately. If the request fails, A2 removes that optimistic
315
- event and the component restores the draft. The server owns the corresponding
316
- generation request.
312
+ `push()` persists the command. Its receipt confirms admission to the inbox.
313
+ `state.inbox.items` contains pending inputs; `state.messages` contains the
314
+ admitted conversation. If persistence fails, the component restores the draft.
317
315
 
318
316
  The form gives Enter its normal submit behavior. The thinking row is also
319
317
  derived from `AIState`: it appears with the Agent label when the server's
@@ -323,7 +321,7 @@ it. Empty stream-start messages never create a blank conversation row.
323
321
  Users can send another message while a response is active. The message enters
324
322
  the durable log immediately, but its turn waits until the active assistant
325
323
  response, including every tool step and approval, reaches a terminal event.
326
- Queued messages keep log order.
324
+ Inbox order defaults to arrival order and can be edited explicitly.
327
325
 
328
326
  Pass `{ generate: false }` when a user message should update the conversation
329
327
  without starting a model turn:
@@ -343,7 +341,7 @@ export async function recordPassiveMessage(
343
341
  }
344
342
  ```
345
343
 
346
- The passive user message still appears in `AIState.messages`. A later user
344
+ The passive user message appears in `AIState.messages` when admitted. A later user
347
345
  message with the default generation behavior includes passive user messages
348
346
  before it in model context. Passive user messages after that trigger remain
349
347
  outside its prompt and wait for the next generating message. This ordering
@@ -355,7 +353,10 @@ already queued turn.
355
353
  One generating user interaction becomes a durable sequence:
356
354
 
357
355
  ```text
358
- browser ai.message.created optimistic, then durable
356
+ browser ai.control.requested durable input command
357
+ server ai.message.created input admitted
358
+ server ai.control.committed accepted state changes
359
+ server ai.control.decided command receipt
359
360
  server ai.generation.requested scheduled from durable facts
360
361
  server ai.generation.started
361
362
  server ai.generation.progress batched UIMessageChunk[]
@@ -414,15 +415,26 @@ continuation that has not been appended yet.
414
415
 
415
416
  | Input | Events |
416
417
  | --- | --- |
417
- | `inputs.message(message, { generate? })` | records a message fact; `false` keeps context without scheduling |
418
+ | `inputs.message(message, { generate? })` | queues input; `false` queues passive context |
418
419
  | `inputs.seed(message)` | records `generate: false`; non-user roles require a trusted append |
419
- | `inputs.approval(response)` | records an approval decision fact |
420
+ | `inputs.approval(response)` | requests a decision on a pending approval |
420
421
  | `inputs.input(response)` | records an application input response fact |
421
422
  | `inputs.requestInput(request)` | records a trusted server request for application input |
422
- | `inputs.retry(options)` | records `ai.retry.requested` for a failed response |
423
- | `inputs.interrupt(options)` | interrupts an active response |
424
-
425
- The builders hide stable event ids, so the same interaction is safe to resend.
423
+ | `inputs.retry(options)` | requests a fresh attempt for a failed response |
424
+ | `inputs.stop({ turnId })` | ends the observed turn and advances |
425
+ | `inputs.steer({ turnId, message })` | prioritizes input after the observed turn's current step finishes |
426
+ | `inputs.queue.edit({ message, expectedRevision })` | edits a pending input |
427
+ | `inputs.queue.move({ inputId, beforeId })` | reorders pending input; `null` means the end |
428
+ | `inputs.queue.remove({ inputId })` | removes pending input from the inbox |
429
+ | `inputs.queue.sendNow({ inputId, turnId })` | atomically prioritizes queued input and interrupts the observed turn |
430
+ | `inputs.pause({ when })` | pauses now or after the active turn |
431
+ | `inputs.resume()` | resumes retained work and inbox admission |
432
+ | `inputs.toolResult(result)` | records a trusted deferred provider-tool result |
433
+ | `inputs.interrupt(options)` | addresses a specific generation or request |
434
+
435
+ Builders return `ai.control.requested` commands. Message, approval, input, and
436
+ retry builders identify a stable interaction. Other controls use the event ID
437
+ assigned by `push()` or `append()`; transport retries preserve that ID.
426
438
  Generation scheduling considers only user-role `ai.message.created` facts.
427
439
  Assistant and system message facts remain context regardless of the `generate`
428
440
  field, and model output is recorded as generation progress rather than a new
@@ -482,10 +494,9 @@ The AI SDK infers the tool input and output from its schemas and
482
494
  scope carrying the current durable handler attempt. Read it with
483
495
  `handlerContext(agent)` from `experimental-a2/ai`: it returns the same
484
496
  typed context bag an event handler receives, with `event`, `attempt`,
485
- `session`, and `signal`. Automatic tools see an `ai.tool.called` event.
486
- Approved tools see the `ai.approval.responded` event that authorized
487
- them. Narrow `ctx.event.type` if you need fields specific to either
488
- event. The agent argument carries the types; A2 verifies it against the
497
+ `session`, and `signal`. Tools execute an `ai.tool.execution.requested`
498
+ event after authorization. Its payload contains the admitted `call`, its
499
+ `generation`, and its turn and execution version. The agent argument carries the types; A2 verifies it against the
489
500
  server executing the tool and throws when a tool written for one agent
490
501
  runs under another, or when `handlerContext()` is called outside a tool
491
502
  execution. The scope survives awaited helpers and async iteration, so
@@ -547,7 +558,7 @@ and plan.
547
558
  The schedule name uses `toolCallId`, and the message id uses the durable
548
559
  triggering event id. A retry therefore converges on the same timer and message,
549
560
  even if a provider reuses tool-call ids in a later generation. When the timer
550
- arrives, `inputs.message()` records an ordinary `ai.message.created` fact. The
561
+ arrives, `inputs.message()` queues a durable input command. The
551
562
  queued-turn policy starts a fresh response after the active response completes
552
563
  or is interrupted. A failed response must be retried or interrupted first.
553
564
  If the scheduler send fails ambiguously or transiently, A2 retries the same
@@ -664,40 +675,15 @@ import { useSession } from '../session'
664
675
 
665
676
  export function StopButton() {
666
677
  const { state, push, index } = useSession()
667
- const active = state.activeGeneration
668
- const requested =
669
- state.activeRequestId && state.activeResponseMessageId
670
- ? {
671
- messageId: state.activeResponseMessageId,
672
- requestId: state.activeRequestId,
673
- }
674
- : null
675
- const waiting =
676
- state.pendingApprovals[0] ??
677
- state.pendingInputs[0] ??
678
- state.tools.find((tool) => tool.status === 'running')
679
- const target = active
680
- ? {
681
- messageId: active.responseMessageId,
682
- generationId: active.generationId,
683
- }
684
- : requested ??
685
- (waiting
686
- ? {
687
- messageId: waiting.messageId,
688
- generationId: waiting.generationId,
689
- }
690
- : null)
691
-
692
- if (!target) return null
678
+ const active = state.active
679
+ if (!active) return null
693
680
 
694
681
  return (
695
682
  <button
696
683
  onClick={() =>
697
684
  void push(
698
- ...inputs.interrupt({
699
- ...target,
700
- reason: 'Stopped by the user',
685
+ ...inputs.stop({
686
+ turnId: active.turnId,
701
687
  lastSeenIndex: index,
702
688
  }),
703
689
  )
@@ -709,17 +695,20 @@ export function StopButton() {
709
695
  }
710
696
  ```
711
697
 
712
- The optimistic event updates the UI immediately and reaches A2's cancellation
713
- channel. `lastSeenIndex` is the exact confirmed log frontier visible when the
698
+ The accepted command ends the turn and reaches A2's cancellation channel. `lastSeenIndex` is the exact confirmed log frontier visible when the
714
699
  user clicked. The reducer rewinds the indexed generation projection to that
715
- frontier, keeps the partial response the user actually saw, and turns
716
- incomplete visible tools into `output-error`. An accepted interruption
700
+ frontier and keeps the partial text the user actually saw. Tool parts whose
701
+ arguments are still streaming are drafts: interruption removes them from the
702
+ conversation and subsequent model prompts, even if their current JSON parses.
703
+ Their progress remains in the raw log. Accepted tools still awaiting completion
704
+ become `output-error`. An accepted interruption
717
705
  terminally fences that request or generation. Later generation, tool, approval,
718
706
  input, and compaction events remain in raw history but cannot reactivate it or
719
707
  alter the projection.
720
708
  Completed tool results at or before the visible frontier remain completed.
721
709
 
722
- Between `ai.generation.requested` and `ai.generation.started`, copy
710
+ For the lower-level `inputs.interrupt()` builder, between
711
+ `ai.generation.requested` and `ai.generation.started`, copy
723
712
  `activeRequestId` as `requestId` and `activeResponseMessageId` as `messageId`.
724
713
  Once `activeGeneration` exists, send its `generationId` and
725
714
  `responseMessageId` instead. A request-owned interruption remains valid if
@@ -800,10 +789,11 @@ metadata status are available in `state.modelMetadata`.
800
789
  positive safe integer. `compaction: { instructions: 'Preserve exact IDs.' }`
801
790
  adds guidance to the built-in summary prompt while keeping the derived threshold.
802
791
  `compaction: false` disables both compaction and metadata lookup. The `compaction`
803
- option can also be a synchronous resolver receiving the same
804
- `{ event, state, history, signal }` context as `model` and `instructions`. It
805
- returns `false`, automatic options, or a custom policy once per generation.
806
- Use this to select a session-specific threshold from already-recorded settings.
792
+ option can also be a resolver receiving the same
793
+ `{ event, state, session, signal }` context as `model` and `instructions`. It
794
+ returns `false`, automatic options, or a custom policy once per generation and
795
+ can await `session.state(settingsReducer)` to read application settings.
796
+ Use this to select a threshold from the checkpointed AI state.
807
797
  Changes after resolution apply to a later model step. Disabling compaction
808
798
  preserves summaries that were already recorded. Custom provider
809
799
  models and custom global SDK providers need an explicit threshold. A custom
@@ -959,7 +949,7 @@ export const customGenerationServer = createAgentServer({
959
949
  ```
960
950
 
961
951
  `generate` receives compacted messages, resolved model and instructions, tools,
962
- generation settings, durable request identity, current state and history, and
952
+ generation settings, durable request identity, current state, and
963
953
  the abort signal. It returns exactly one model step as a
964
954
  `ReadableStream<UIMessageChunk>`. A tool-aware replacement sends definitions to
965
955
  the model without running local `execute` functions. A2 still owns durable
@@ -968,6 +958,33 @@ progress, tool execution, approval, continuation, interruption, and failure.
968
958
  function owns its metadata and can pass a callback to `toUIMessageStream()` or
969
959
  emit typed metadata chunks itself.
970
960
 
961
+ ## Checkpoints and recovery reads
962
+
963
+ Model steps read the AI reducer and coordinator at a fixed log boundary. Model,
964
+ instruction, compaction, and custom-generation callbacks receive derived state.
965
+ They do not receive a raw history array. Their `session.state(reducer)` reads
966
+ application reducers at the captured prompt boundary by default; explicit
967
+ `through` options follow the ordinary state-read contract. Compaction selection
968
+ can be asynchronous when it needs a settings-state read.
969
+
970
+ Ordinary generation uses checkpointed state. A recovered attempt reads from its
971
+ saved prompt boundary to the captured current boundary, excluding abandoned
972
+ attempts. Tools recovering in a fresh process reconstruct their original prompt
973
+ from that checkpoint and the owning generation's start and compaction events.
974
+ Compacted context uses its checkpoint and subsequent events. These AI history
975
+ reads always have explicit lower and upper bounds. A2 core can rebuild a missing
976
+ or invalid reducer snapshot from the log.
977
+
978
+ Streaming progress updates the current message incrementally. Snapshots preserve
979
+ open text and reasoning parts and partial tool inputs. Indexed progress remains
980
+ available for exact interruption cutoffs; reordered events rebuild the projection.
981
+
982
+ While tool arguments arrive, `input-streaming` parts expose a best-effort partial
983
+ JSON value in `input`. A command string can appear and grow before its closing
984
+ quote arrives. This is a display draft, not validated execution input.
985
+ `tool-input-available` supplies the completed input. Interruption still discards
986
+ streaming drafts even when their partial JSON is readable.
987
+
971
988
  ## Delivery semantics
972
989
 
973
990
  Model steps and tools run inside at-least-once A2 handlers. The returned-event
@@ -993,3 +1010,189 @@ continuation, but it cannot roll back that external effect.
993
1010
  Configure a [production scheduler](/guides/production) exactly as for any other A2
994
1011
  server. It recovers an incomplete generation attempt after the original
995
1012
  serverless invocation disappears.
1013
+
1014
+ ## Inbox and turn controls
1015
+
1016
+ This input protocol requires matching client and server versions. Start a new
1017
+ agent session when adopting it. Existing event logs remain readable; finish
1018
+ pending work with the runtime that created it before upgrading.
1019
+
1020
+ `inputs.message(message)` places a message in the durable inbox. The controller
1021
+ admits one input at a time, freezes its revision, and adds it to `state.messages`.
1022
+ Pending inputs appear in `state.inbox.items`; they do not enter the model prompt.
1023
+ Each item has an `id`, `revision`, `message`, and `generate` flag.
1024
+
1025
+ `state.active` identifies the logical turn, its frozen `input`, and its `phase`.
1026
+ The turn ID stays the same across model steps, tools, and a pause. Stop ends that
1027
+ turn and admits the next queued input when admission is running.
1028
+
1029
+ ```ts client/turn-controls.ts
1030
+ import { inputs, type AIState } from 'experimental-a2/ai'
1031
+ import type { UIMessage } from 'ai'
1032
+
1033
+ export function editPending(message: UIMessage, expectedRevision: number) {
1034
+ return inputs.queue.edit({ message, expectedRevision })
1035
+ }
1036
+
1037
+ export function movePending(inputId: string, beforeId: string | null) {
1038
+ return inputs.queue.move({ inputId, beforeId })
1039
+ }
1040
+
1041
+ export function removePending(inputId: string) {
1042
+ return inputs.queue.remove({ inputId })
1043
+ }
1044
+
1045
+ export function sendPendingNow(state: AIState, inputId: string) {
1046
+ return inputs.queue.sendNow({ inputId, turnId: state.active?.turnId ?? null })
1047
+ }
1048
+
1049
+ export function stopCurrent(state: AIState) {
1050
+ return state.active ? inputs.stop({ turnId: state.active.turnId }) : []
1051
+ }
1052
+
1053
+ export function steerCurrent(state: AIState, message: UIMessage) {
1054
+ return state.active
1055
+ ? inputs.steer({ turnId: state.active.turnId, message })
1056
+ : inputs.message(message)
1057
+ }
1058
+
1059
+ export const pauseAfterTurn = () => inputs.pause({ when: 'after-turn' })
1060
+ export const pauseNow = () => inputs.pause({ when: 'now' })
1061
+ export const resume = () => inputs.resume()
1062
+ ```
1063
+
1064
+ Pass these arrays to `push(...events)` from `useSession()`, or to a server
1065
+ session's `append(...events)`. `beforeId: null` moves an item to the end.
1066
+ Removal only applies to pending input. It preserves the immutable log and keeps
1067
+ the input identity reserved, so replay cannot recreate a removed prompt.
1068
+ Removing an admitted input returns `already-active`; removing an absent or
1069
+ already removed input returns `not-found`.
1070
+
1071
+ An edit that loses to admission returns `already-active`. Concurrent edits use
1072
+ `expectedRevision`; an outdated revision returns `revision-conflict`. A Stop
1073
+ aimed at a completed turn returns `stale-turn`.
1074
+
1075
+ Steer validates the new input and places it ahead of the inbox in one decision.
1076
+ If the observed turn is still active, it finishes its current model response and
1077
+ the tools produced by that response before handing off. Parallel tools all
1078
+ settle, and steering runs before the old turn starts another model step.
1079
+ Text and tool results produced after the click remain in the conversation.
1080
+ `lastSeenIndex` is not used to truncate output for steering or Send now.
1081
+
1082
+ When running work settles, unanswered approval or application-input requests
1083
+ are closed so the steering message can take over. The unapproved tool never
1084
+ executes. Provider-executed deferred results still count as running work when
1085
+ execution is authorized; a denied approval cannot block handoff.
1086
+ If the targeted turn has already finished, the input starts when idle or waits
1087
+ first behind a different active turn. Stop remains an immediate interruption.
1088
+
1089
+ Steering preserves the admission gate: steering while paused does not resume
1090
+ execution. Duplicate input identities and closed sessions still reject the command.
1091
+
1092
+ Send now uses the selected queued message's existing identity and latest
1093
+ accepted revision. It validates the selected input and the observed `turnId`,
1094
+ then moves the input first for the same handoff after the current step. Use
1095
+ `turnId: null` when no turn is active. If that expectation is stale, the command
1096
+ returns `stale-turn` without changing the queue.
1097
+
1098
+ Send now preserves pause just like steering. A paused queue keeps the selected
1099
+ input first until Resume. It accepts user-role inputs, including passive user
1100
+ input, which it marks for generation. Non-user context returns `not-user-input`;
1101
+ a conflicting response identity returns `duplicate-input`. Unsaved editor text
1102
+ is separate from the accepted queue revision; save it before sending.
1103
+
1104
+ A queued item's optional `afterStepOf` records the observed turn for steering.
1105
+ A matching active turn hands off after its current step.
1106
+ Editing preserves this intent; removing the item cancels it. Reordering the
1107
+ queue controls which input is admitted at that boundary.
1108
+
1109
+ ### Optimistic conversation display
1110
+
1111
+ With `agent().reducer` or `createReducer()`, the client state is already optimistic.
1112
+ An idle, unpaused send appears in `state.messages` immediately, with
1113
+ `state.starting: true`. Another send waits in `state.inbox.items` while that
1114
+ optimistic turn or a confirmed turn is active. Paused sessions keep new sends in
1115
+ the inbox. Render the conversation and queue from `state`; `state.active` and
1116
+ execution controls remain server-confirmed.
1117
+ Steering appears immediately in `state.messages`, after the current response,
1118
+ and stays there while that step finishes. Send now has the same display when
1119
+ an active turn is targeted. These inputs are omitted from the client inbox,
1120
+ including while paused, but remain pending on the server until admission.
1121
+ Accepted steering survives receipt and hydration in this display. Multiple
1122
+ steering inputs appear in admission order, with the most recent first.
1123
+ No additional request, subscription, or component-owned queue is needed.
1124
+
1125
+ ```tsx app/agent/[sessionId]/conversation.tsx
1126
+ 'use client'
1127
+ import { useSession } from '../session'
1128
+
1129
+ export function Conversation() {
1130
+ const { state } = useSession()
1131
+ const { messages, inbox, starting } = state
1132
+ const text = (message: (typeof messages)[number]) =>
1133
+ message.parts.filter((part) => part.type === 'text').map((part) => part.text).join(' ')
1134
+ return (
1135
+ <section>
1136
+ <div aria-label="Conversation">
1137
+ {messages.map((message) => <p key={message.id}>{text(message)}</p>)}
1138
+ {starting ? <p role="status">Starting…</p> : null}
1139
+ </div>
1140
+ <ol aria-label="Queued messages">
1141
+ {inbox.items.map((item) => <li key={item.id}>{text(item.message)}</li>)}
1142
+ </ol>
1143
+ </section>
1144
+ )
1145
+ }
1146
+ ```
1147
+
1148
+ The request echo keeps the optimistic display until `ai.control.decided` arrives. Accepted
1149
+ commits replace the confirmed base; rejected receipts remove the pending intent.
1150
+ A failed POST rolls back through the ordinary client optimistic overlay.
1151
+ Another client's accepted turn can move a speculative ordinary send back into
1152
+ the queue. Steering stays visible as a message while waiting behind that turn. `state.starting` describes an optimistic turn awaiting acceptance; it does
1153
+ not indicate a running model.
1154
+
1155
+ Server `session.state(reducer)`, reducer folds, checkpoints, and model prompts
1156
+ contain confirmed messages and inbox entries, with `starting: false`. The client
1157
+ applies pending intent and displays accepted steering only when returning its
1158
+ state, after folding events.
1159
+ A custom reducer that calls `reduceAIState()` or nests `agent.reducer.fold()`
1160
+ uses that confirmed fold; it does not inherit the built-in reducer's client display.
1161
+ `state.pendingQueueCommands` tracks unresolved queue commands incrementally, so
1162
+ the client does not scan raw history. Revisions remain server-confirmed; keep
1163
+ repeat editing and Send now disabled while an edit for that item awaits its
1164
+ receipt. Moving and removing an item do not require a revision.
1165
+
1166
+ ### Persistence and application are separate
1167
+
1168
+ `push()` confirms that the command is persisted. The `ai.control.decided` event
1169
+ confirms its application, using the submitted event's ID as `commandId`. Its
1170
+ `outcome` is `applied` or `rejected`; a rejection includes a `reason`.
1171
+ `state.receipt` exposes the most recent user command receipt. Applications that
1172
+ have several commands in flight correlate receipts through the existing event
1173
+ stream. A rejection is a normal result and does not spend a handler retry.
1174
+
1175
+ ### Pause and resume
1176
+
1177
+ Pause after the turn closes inbox admission and lets the active turn finish.
1178
+ Pause now also cancels current model and tool execution. `state.active.phase`
1179
+ becomes `pausing` while old work settles, then `paused`. Resume reopens admission
1180
+ and resumes retained work before admitting another input.
1181
+
1182
+ Resume starts a fresh model request from durable state. It does not resume the
1183
+ provider's old stream or undo completed tool effects. The controller retains
1184
+ accepted tool calls and records their outcomes while paused. A cancelled tool
1185
+ without a terminal result can execute again after resume; use the stable SDK
1186
+ `toolCallId` for external idempotency. No model or tool claim is held merely to
1187
+ keep a turn paused.
1188
+
1189
+ The controller runs on a short lane. Each decision commits accepted facts,
1190
+ compact state changes, work requests, and its receipt atomically. Workers use
1191
+ separate claims, report lifecycle batches, and wait for acceptance at model-start
1192
+ and compaction boundaries. Token progress appends directly. Explicit AI history
1193
+ reads remain bounded to a relevant generation suffix; core checkpoint rebuilding
1194
+ can replay older events when a snapshot is missing.
1195
+
1196
+ For a deferred provider-executed tool, trusted server code submits
1197
+ `inputs.toolResult(result)`. The controller checks its admitted call and records
1198
+ the result before continuing. Browser ingress rejects this server-only input.
@@ -697,6 +697,13 @@ Raw events from the session, oldest first. `gte` and `lte` are inclusive event
697
697
  indexes; omit a bound to leave that end of the log open.
698
698
  Bounds are non-negative safe integers. `lte: 0` returns `[]`; `gte > lte` throws.
699
699
 
700
+ Redis reads a fixed committed range in pages of at most 256 events, with up to
701
+ sixteen page reads in flight. Public history omits dispatch metadata. PostgreSQL
702
+ uses one indexed range query selecting only public event columns. Custom store
703
+ adapters can implement `readHistory(sessionId, options)` with the same exclusive
704
+ `afterIndex` and inclusive `throughIndex` bounds as `read()`. A2 falls back to
705
+ `read()` when that capability is absent.
706
+
700
707
  ### `session.state(reducer, options?)`
701
708
 
702
709
  ```ts
@@ -1033,14 +1040,12 @@ result and is verified against the executing server's contract: a
1033
1040
  mismatched agent, or a call outside any tool execution, throws a
1034
1041
  `TypeError`.
1035
1042
 
1036
- The returned `AgentToolContext<D>` is a union of the current A2
1037
- handler's `event`, `attempt`, `session`, and `signal`. Automatic
1038
- execution has an `ai.tool.called` event; execution after approval has an
1039
- `ai.approval.responded` event. Narrow `ctx.event.type` when
1040
- event-specific payload fields matter. Both variants have a session typed
1041
- from the supplied agent's complete event vocabulary. The agent does not
1042
- hold a scheduler. Calls such as `ctx.session.schedule()` use the
1043
- scheduler of the server executing the tool.
1043
+ The returned `AgentToolContext<D>` supplies the current A2 handler's `event`,
1044
+ `attempt`, `session`, and `signal`. Local tools execute the explicit
1045
+ `ai.tool.execution.requested` work event. Its payload contains the admitted
1046
+ `call`, `generation`, `turnId`, and execution `version`. The session has the
1047
+ supplied agent's complete event vocabulary. Calls such as
1048
+ `ctx.session.schedule()` use the scheduler of the server executing the tool.
1044
1049
 
1045
1050
  The context is runtime capability, not event data. A2 creates it for the
1046
1051
  current attempt, never persists it, and never sends it to the model or a
@@ -1060,29 +1065,72 @@ Pure typed inputs for `append()` and `push()`:
1060
1065
 
1061
1066
  | Input | Events |
1062
1067
  | --- | --- |
1063
- | `inputs.message(message, { generate? })` | `ai.message.created`; pass `generate: false` to record context without scheduling |
1064
- | `inputs.seed(message)` | `ai.message.created` with `generate: false`; non-user roles require a trusted append |
1065
- | `inputs.approval(response)` | `ai.approval.responded` only |
1066
- | `inputs.input(response)` | `ai.input.responded` only |
1067
- | `inputs.requestInput(request)` | `ai.input.requested` for a trusted server append |
1068
- | `inputs.retry(options)` | `ai.retry.requested` |
1069
- | `inputs.interrupt(options)` | `ai.message.interrupted` |
1068
+ | `inputs.message(message, { generate? })` | queues input; `generate: false` records passive context at admission |
1069
+ | `inputs.seed(message)` | queues passive context; non-user roles require a trusted append |
1070
+ | `inputs.approval(response)` | answers a pending tool approval |
1071
+ | `inputs.input(response)` | answers a pending application input |
1072
+ | `inputs.requestInput(request)` | requests application input from trusted server code |
1073
+ | `inputs.retry(options)` | retries a failed response |
1074
+ | `inputs.stop({ turnId, lastSeenIndex? })` | ends the observed turn and advances when admission is open |
1075
+ | `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 |
1077
+ | `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 |
1079
+ | `inputs.queue.sendNow({ inputId, turnId, lastSeenIndex? })` | promotes an existing queued input for a handoff after the current step; preserves pause |
1080
+ | `inputs.pause({ when: 'now' \| 'after-turn' })` | pauses execution now or inbox admission after the turn |
1081
+ | `inputs.resume()` | resumes retained work, then inbox admission |
1082
+ | `inputs.toolResult(result)` | records a deferred provider-executed tool result from trusted server code |
1083
+ | `inputs.interrupt(options)` | interrupts a specific generation/request owner |
1084
+
1085
+ All builders emit `ai.control.requested`. Persistence and application are
1086
+ separate: `ai.control.decided` returns `{ commandId, outcome, reason? }` through
1087
+ 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.
1090
+
1091
+ Steering validates and prioritizes new input even if the observed turn has
1092
+ finished. A matching active turn finishes its current model response and tool
1093
+ batch, then yields before another model step. Running local workers and deferred
1094
+ provider results settle first; unanswered human approval/input waits can be
1095
+ closed at that point. Pause stays closed. Otherwise the input starts when idle
1096
+ or waits first behind a different active turn. The priority change is atomic;
1097
+ the later handoff and new work are committed atomically at the step boundary.
1098
+ Steer and Send now do not use `lastSeenIndex` to cut output.
1099
+
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
1102
+ 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.
1106
+
1107
+ `state.inbox` contains `paused` and ordered `items`. Each item contains
1108
+ `{ id, revision, message, generate, afterStepOf? }`. The optional turn ID
1109
+ `afterStepOf` records the observed steering turn while that item remains queued;
1110
+ a matching active turn hands off after its current step.
1111
+ Removal cancels it and admission consumes it. `state.active` is null or contains
1112
+ `{ turnId, input, phase }`; admission freezes `input`. On the server, pending
1113
+ inputs are absent from `messages` and model context. The `paused` status and the `pausing`/`paused`
1114
+ active phases represent retained work. Resume uses a fresh model request and
1115
+ execution version. Tools without terminal outcomes can retry; their stable
1116
+ `toolCallId` supports external idempotency.
1070
1117
 
1071
1118
  `inputs` deliberately has no session lifecycle methods. Append explicit
1072
1119
  `ai.session.created` and `ai.session.closed` events from trusted server code
1073
- when an application uses them. Input event ids are stable for the interaction
1074
- they describe, so a lost append acknowledgment can be resent safely. Browser
1120
+ when an application uses them. Message, approval, input, and retry builders use stable interaction IDs.
1121
+ Other commands use IDs assigned by `push()` or `append()`; transport retries
1122
+ preserve those IDs. Browser
1075
1123
  ingress accepts user messages, approval and input responses, interruptions,
1076
1124
  and explicit retries. Only built-in server handlers append generation requests
1077
- and AI lifecycle events. `inputs.requestInput()` and non-user messages built
1125
+ and AI lifecycle events. `inputs.requestInput()`, `inputs.toolResult()`, and non-user messages built
1078
1126
  with `inputs.seed()` are for trusted server appends.
1079
1127
 
1080
- `inputs.message(message, { generate: false })` is valid browser input. It keeps
1081
- the user message in `AIState.messages` without starting a model turn. The next
1128
+ `inputs.message(message, { generate: false })` is valid browser input. It admits
1129
+ the user message to `AIState.messages` without starting a model turn. The next
1082
1130
  user message that allows generation includes passive user messages before it
1083
1131
  in model context. Passive user messages after that trigger wait for a later
1084
1132
  generating message. Omitting the option preserves the default scheduling
1085
- behavior and the existing `{ message }` event payload. `inputs.seed()` writes
1133
+ behavior. `inputs.seed()` writes
1086
1134
  the same passive flag. Trusted assistant and system seeds are context, not user
1087
1135
  queue cutpoints. The server never schedules them, regardless of the `generate`
1088
1136
  field. Model output is generation progress rather than another
@@ -1117,12 +1165,44 @@ Completed tool results at or before `lastSeenIndex` remain completed. A final
1117
1165
  tool result that races an ordinary generation failure remains authoritative in
1118
1166
  either commit order, while the generation stays failed.
1119
1167
 
1168
+ Interruption and failure projections discard tool parts still in
1169
+ `input-streaming`. These argument drafts are not completed tool calls and do not
1170
+ enter later model prompts. Raw progress remains in the log. Available inputs,
1171
+ approval-waiting calls, and completed results retain their existing terminal
1172
+ handling.
1173
+
1120
1174
  ### `events` and `createEvents(options?)`
1121
1175
 
1122
1176
  The built-in Standard Schema definitions. Use `events` for the default AI SDK
1123
1177
  `UIMessage`; use `createEvents({ messageSchema })` to validate a more specific
1124
1178
  message type. Both are isomorphic.
1125
1179
 
1180
+ ### Optimistic AI client state
1181
+
1182
+ Clients using `agent().reducer` or `createReducer()` expose `state.messages`,
1183
+ `state.inbox`, and `state.starting` with pending send, edit, move, remove,
1184
+ Send now, steer, pause, and resume commands applied for display. Idle, unpaused
1185
+ sends appear in the conversation immediately. A generating input occupies the
1186
+ optimistic turn, so subsequent sends queue. Busy or paused sessions queue new
1187
+ sends. `starting` indicates a speculative turn awaiting acceptance; it does not
1188
+ indicate model execution. Confirmed turns take precedence during reconciliation.
1189
+ Steering inputs, and Send now inputs targeting an active turn, appear in
1190
+ `state.messages` after the current response and are omitted from the client
1191
+ inbox. This display survives receipt and hydration, including while paused.
1192
+ They remain pending server inputs until admission and do not enter the model
1193
+ prompt early. Multiple steering inputs follow inbox admission order.
1194
+
1195
+ `state.active`, model execution, and revision numbers stay confirmed.
1196
+ Server reads and reducer folds retain confirmed messages and inbox entries;
1197
+ `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`.
1200
+
1201
+ The pending-command ledger survives request echo and retires on the matching
1202
+ `ai.control.decided` receipt or session close. Failed pushes roll back through
1203
+ the client's existing optimistic overlay. This display adds no I/O and does not
1204
+ read raw history.
1205
+
1126
1206
  ### `createReducer(options)`
1127
1207
 
1128
1208
  ```ts
@@ -1130,7 +1210,8 @@ createReducer({ contract, name? }): Reducer<AIState>
1130
1210
  ```
1131
1211
 
1132
1212
  Builds the standard AI projection for a compatible contract. `AIState`
1133
- contains session lifecycle, messages, generation status, pending approvals
1213
+ contains `inbox`, `active`, `starting`, the latest command `receipt`, session lifecycle,
1214
+ messages, generation status, pending approvals
1134
1215
  and input, tool activity, compaction, usage, the last error, and
1135
1216
  `activeRequestId`, `activeResponseMessageId`,
1136
1217
  `responseGenerationIds: Record<string, string>`, and terminal request and
@@ -1140,7 +1221,7 @@ delayed requests before their generation starts. `activeResponseMessageId`
1140
1221
  identifies the requested response until `activeGeneration` exists.
1141
1222
  `activeProjection` holds the indexed generation frontier while a generation is
1142
1223
  active, after a generation step completes while its response waits on tool,
1143
- approval, or input barriers, and after a generation fails while it awaits retry
1224
+ approval, or input barriers, while paused, and after a generation fails while it awaits retry
1144
1225
  or interruption. Response completion, interruption, supersession, retry, or a
1145
1226
  later generation clears or replaces it. `responseGenerationIds` keeps the
1146
1227
  latest generation owner for each response message, so late events from a
@@ -1149,7 +1230,19 @@ superseded owner cannot alter the projection. `terminalRequestIds` and
1149
1230
  and supersession fences across snapshots and recovery. They are optional
1150
1231
  snapshot-compatible fields with the shapes `Record<string, true>` and
1151
1232
  `Record<string, 'completed' | 'failed' | 'interrupted' | 'superseded'>`.
1152
- Extension events are ignored. The default reducer name is `a2.ai.state.v10`.
1233
+ The active projection also stores its prompt boundary and a serializable stream
1234
+ cursor for open text, reasoning, and tool-input parts. Ordered progress applies
1235
+ only its new chunks. `activeProjection.batches` stores indexed batches as
1236
+ `{ length, tail, blocks }`: a bounded tail and shared completed blocks. Appending
1237
+ ordered progress does not copy the full retained batch list. The blocks are
1238
+ ordinary JSON with logarithmic nesting depth, so checkpoints can be serialized
1239
+ and cloned. Retained batches support exact interruption cutoffs and out-of-order
1240
+ reconstruction. The cursor and blocks share the active projection's lifetime.
1241
+ Checkpoint size and serialization cost still grow with retained progress.
1242
+ Tool-input deltas update `input-streaming` parts with best-effort partial JSON
1243
+ for display. The state stays `input-streaming` until a completed input arrives;
1244
+ partial parsing does not authorize execution or change interruption handling.
1245
+ Extension events are ignored. The default reducer name is `a2.ai.state.v17`.
1153
1246
 
1154
1247
  ### `deriveUIMessages(history)` and `reduceAIState(state, event)`
1155
1248
 
@@ -1247,9 +1340,11 @@ the trusted server session API; the browser push allowlist rejects it.
1247
1340
  `false` to disable compaction and discovery, `{ thresholdTokens?, instructions? }`
1248
1341
  to override automatic behavior, or the existing custom
1249
1342
  `{ shouldCompact(context), compact(context) }` policy. It also accepts a
1250
- resolver `(context: AgentResolverContext) => CompactionOptions`.
1251
- The resolver runs synchronously once per generation and must return a valid policy
1252
- or `false`. Custom `shouldCompact` and `compact` callbacks can still be asynchronous.
1343
+ resolver `(context: AgentResolverContext) => CompactionOptions | Promise<CompactionOptions>`.
1344
+ The resolver runs once per generation and returns a valid policy or `false`.
1345
+ It can await application reducers through `context.session.state(reducer)`, whose
1346
+ default boundary is the captured prompt frontier. Model, instruction, compaction,
1347
+ metadata, and custom-generation contexts expose state access and omit raw history.
1253
1348
  Static options are validated at construction; resolved options are validated
1254
1349
  before that generation starts. Each session uses its own resolved value. `thresholdTokens` is an
1255
1350
  optional positive safe integer. `instructions` adds to A2's internal summary