experimental-a2 0.17.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-DBbe5xeE.d.ts → actor-C7b-1arn.d.ts} +5 -5
  4. package/dist/{actor-DBbe5xeE.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-react.d.ts +3 -3
  7. package/dist/actor-react.js +1 -1
  8. package/dist/actor.d.ts +1 -1
  9. package/dist/actor.js +2 -2
  10. package/dist/{ai-vpGtcaCB.d.ts → ai-BMLlI2Ff.d.ts} +3 -3
  11. package/dist/{ai-vpGtcaCB.d.ts.map → ai-BMLlI2Ff.d.ts.map} +1 -1
  12. package/dist/{ai-UrWfKeuv.js → ai-D6mGlO8j.js} +2 -2
  13. package/dist/{ai-UrWfKeuv.js.map → ai-D6mGlO8j.js.map} +1 -1
  14. package/dist/ai-client.d.ts +2 -2
  15. package/dist/ai-client.js +2 -2
  16. package/dist/ai-fx.browser.d.ts +1 -0
  17. package/dist/ai-fx.browser.js +6 -0
  18. package/dist/ai-fx.browser.js.map +1 -0
  19. package/dist/ai-fx.d.ts +10 -0
  20. package/dist/ai-fx.d.ts.map +1 -0
  21. package/dist/ai-fx.js +692 -0
  22. package/dist/ai-fx.js.map +1 -0
  23. package/dist/ai-harness-B2suP2Qh.d.ts +26 -0
  24. package/dist/ai-harness-B2suP2Qh.d.ts.map +1 -0
  25. package/dist/ai-harness-BuLh_EYi.js +273 -0
  26. package/dist/ai-harness-BuLh_EYi.js.map +1 -0
  27. package/dist/ai-pi.browser.d.ts +1 -0
  28. package/dist/ai-pi.browser.js +6 -0
  29. package/dist/ai-pi.browser.js.map +1 -0
  30. package/dist/ai-pi.d.ts +17 -0
  31. package/dist/ai-pi.d.ts.map +1 -0
  32. package/dist/ai-pi.js +950 -0
  33. package/dist/ai-pi.js.map +1 -0
  34. package/dist/ai-react.d.ts +5 -5
  35. package/dist/ai-react.js +2 -2
  36. package/dist/ai-server.d.ts +47 -25
  37. package/dist/ai-server.d.ts.map +1 -1
  38. package/dist/ai-server.js +113 -40
  39. package/dist/ai-server.js.map +1 -1
  40. package/dist/ai-tool-input-DmSqMUQi.js +40 -0
  41. package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
  42. package/dist/ai.d.ts +1 -1
  43. package/dist/ai.js +1 -1
  44. package/dist/bounded-cache-CPHWF-8h.js +41 -0
  45. package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
  46. package/dist/{client-COaiaDub.js → client-BhHLoNKF.js} +3 -3
  47. package/dist/{client-COaiaDub.js.map → client-BhHLoNKF.js.map} +1 -1
  48. package/dist/{client-qRnRUzBS.d.ts → client-h_DKFAjj.d.ts} +3 -3
  49. package/dist/{client-qRnRUzBS.d.ts.map → client-h_DKFAjj.d.ts.map} +1 -1
  50. package/dist/client.d.ts +3 -3
  51. package/dist/client.js +1 -1
  52. package/dist/index.d.ts +5 -5
  53. package/dist/{initial-snapshot-BrLso5x_.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +2 -2
  54. package/dist/{initial-snapshot-BrLso5x_.d.ts.map → initial-snapshot-Bx-c0pDP.d.ts.map} +1 -1
  55. package/dist/{internal-CbVvEwad.js → internal-CscSY7K_.js} +11 -3
  56. package/dist/internal-CscSY7K_.js.map +1 -0
  57. package/dist/otel.d.ts +1 -1
  58. package/dist/{react-BbJ-g6Uu.d.ts → react-BBXgHWFc.d.ts} +4 -4
  59. package/dist/{react-BbJ-g6Uu.d.ts.map → react-BBXgHWFc.d.ts.map} +1 -1
  60. package/dist/react.d.ts +1 -1
  61. package/dist/react.js +1 -1
  62. package/dist/reducer-BAXc8JyF.js.map +1 -1
  63. package/dist/{reducer-BYu6DT7h.d.ts → reducer-CqA59Q6U.d.ts} +2 -2
  64. package/dist/{reducer-BYu6DT7h.d.ts.map → reducer-CqA59Q6U.d.ts.map} +1 -1
  65. package/dist/scheduler-qstash.d.ts +2 -2
  66. package/dist/scheduler-qstash.js +3 -3
  67. package/dist/scheduler-qstash.js.map +1 -1
  68. package/dist/scheduler-vercel.d.ts +7 -7
  69. package/dist/scheduler-vercel.d.ts.map +1 -1
  70. package/dist/scheduler-vercel.js +9 -10
  71. package/dist/scheduler-vercel.js.map +1 -1
  72. package/dist/{server-Rj64fNvX.js → server-D-Y4pqwu.js} +126 -67
  73. package/dist/server-D-Y4pqwu.js.map +1 -0
  74. package/dist/{server-CGM8WzRG.d.ts → server-DYAATFuT.d.ts} +42 -7
  75. package/dist/server-DYAATFuT.d.ts.map +1 -0
  76. package/dist/server.d.ts +4 -4
  77. package/dist/server.js +1 -1
  78. package/dist/{session-clear-Dshpuhff.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
  79. package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
  80. package/dist/{store-Gpkq7WBo.d.ts → store-CyLUy86v.d.ts} +53 -5
  81. package/dist/store-CyLUy86v.d.ts.map +1 -0
  82. package/dist/store-N8PXxDAS.js.map +1 -1
  83. package/dist/store-memory.d.ts +1 -1
  84. package/dist/store-memory.js +56 -11
  85. package/dist/store-memory.js.map +1 -1
  86. package/dist/{store-polling-4_y8lETm.js → store-polling-7WJV0YWP.js} +2 -2
  87. package/dist/{store-polling-4_y8lETm.js.map → store-polling-7WJV0YWP.js.map} +1 -1
  88. package/dist/store-postgres.d.ts +1 -1
  89. package/dist/store-postgres.d.ts.map +1 -1
  90. package/dist/store-postgres.js +106 -25
  91. package/dist/store-postgres.js.map +1 -1
  92. package/dist/{store-presence-polling-Q1C-k2dd.js → store-presence-polling-B1ixeqIS.js} +3 -3
  93. package/dist/{store-presence-polling-Q1C-k2dd.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
  94. package/dist/store-redis-http.d.ts +1 -1
  95. package/dist/store-redis-http.js +3 -3
  96. package/dist/{store-redis-notify-tqWNnMRu.js → store-redis-notify-CM0AfLY0.js} +271 -101
  97. package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
  98. package/dist/store-redis.d.ts +1 -1
  99. package/dist/store-redis.js +1 -1
  100. package/dist/store-sqlite.d.ts +1 -1
  101. package/dist/store-sqlite.js +66 -21
  102. package/dist/store-sqlite.js.map +1 -1
  103. package/dist/{telemetry-cZH-cibj.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
  104. package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
  105. package/dist/{wire-DjfHOvdW.js → wire-BKxH1vbP.js} +2 -2
  106. package/dist/{wire-DjfHOvdW.js.map → wire-BKxH1vbP.js.map} +1 -1
  107. package/docs/01-quickstart.mdx +49 -83
  108. package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
  109. package/docs/actors/01-introduction.mdx +48 -86
  110. package/docs/actors/02-concurrency.mdx +43 -70
  111. package/docs/actors/03-timers.mdx +28 -42
  112. package/docs/actors/04-routes.mdx +110 -150
  113. package/docs/actors/meta.ts +1 -1
  114. package/docs/agents/01-introduction.mdx +257 -0
  115. package/docs/agents/02-tools.mdx +247 -0
  116. package/docs/agents/03-conversations.mdx +538 -0
  117. package/docs/agents/04-harnesses.mdx +136 -0
  118. package/docs/agents/meta.ts +1 -0
  119. package/docs/client/01-react.mdx +294 -0
  120. package/docs/client/02-transports.mdx +86 -0
  121. package/docs/client/03-presence.mdx +224 -0
  122. package/docs/client/meta.ts +1 -0
  123. package/docs/contract/01-events.mdx +57 -0
  124. package/docs/contract/02-state.mdx +142 -0
  125. package/docs/contract/03-sessions.mdx +175 -0
  126. package/docs/index.mdx +109 -145
  127. package/docs/reference/01-core.mdx +139 -0
  128. package/docs/reference/02-server.mdx +860 -0
  129. package/docs/reference/03-client.mdx +301 -0
  130. package/docs/reference/04-ai.mdx +731 -0
  131. package/docs/reference/05-adapters.mdx +351 -0
  132. package/docs/reference/06-errors.mdx +138 -0
  133. package/docs/reference/meta.ts +1 -1
  134. package/docs/server/01-handlers.mdx +200 -0
  135. package/docs/server/02-timers.mdx +130 -0
  136. package/docs/server/03-cancellation.mdx +142 -0
  137. package/docs/server/04-routes.mdx +243 -0
  138. package/docs/server/05-production.mdx +323 -0
  139. package/docs/server/06-guarantees.mdx +132 -0
  140. package/examples/playground/DEPLOY.md +3 -5
  141. package/examples/playground/app/a0/[appId]/page.tsx +12 -13
  142. package/examples/playground/app/a0/app-list.tsx +9 -2
  143. package/examples/playground/app/a0/app-view.tsx +9 -1
  144. package/examples/playground/app/a0/apps/[appId]/route.ts +10 -5
  145. package/examples/playground/app/a0/apps.test.ts +12 -0
  146. package/examples/playground/app/a0/apps.ts +22 -7
  147. package/examples/playground/app/a0/chat.tsx +47 -1
  148. package/examples/playground/app/a0/devtools.tsx +2 -8
  149. package/examples/playground/app/a0/fx/events/route.ts +9 -0
  150. package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
  151. package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
  152. package/examples/playground/app/a0/model.test.ts +18 -1
  153. package/examples/playground/app/a0/model.ts +47 -10
  154. package/examples/playground/app/a0/no-photon.cjs +4 -0
  155. package/examples/playground/app/a0/no-quickjs.cjs +3 -0
  156. package/examples/playground/app/a0/pi/events/route.ts +9 -0
  157. package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
  158. package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
  159. package/examples/playground/app/a0/sandbox-log.ts +10 -4
  160. package/examples/playground/app/a0/sandbox-session.ts +152 -0
  161. package/examples/playground/app/a0/scheduler/route.ts +2 -2
  162. package/examples/playground/app/a0/server.ts +98 -54
  163. package/examples/playground/app/a0/session.ts +17 -5
  164. package/examples/playground/app/a0/shell.tsx +11 -3
  165. package/examples/playground/app/agent/ingress.test.ts +6 -3
  166. package/examples/playground/app/agent/server.ts +9 -6
  167. package/examples/playground/app/agent/telemetry.test.ts +71 -50
  168. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +1 -1
  169. package/examples/playground/app/chat/server.test.ts +30 -26
  170. package/examples/playground/app/chat/server.ts +46 -46
  171. package/examples/playground/next.config.ts +17 -5
  172. package/examples/playground/package.json +4 -1
  173. package/examples/playground/vercel.json +18 -0
  174. package/package.json +27 -3
  175. package/src/ai-agent.ts +271 -90
  176. package/src/ai-fx.browser.ts +5 -0
  177. package/src/ai-fx.ts +912 -0
  178. package/src/ai-harness.ts +518 -0
  179. package/src/ai-model-metadata.ts +11 -6
  180. package/src/ai-pi.browser.ts +5 -0
  181. package/src/ai-pi.ts +1322 -0
  182. package/src/ai-sdk-step.ts +2 -2
  183. package/src/ai-server.ts +3 -1
  184. package/src/ai-tool-input.ts +45 -0
  185. package/src/internal.ts +13 -0
  186. package/src/libfx.d.ts +34 -0
  187. package/src/reducer.ts +1 -1
  188. package/src/scheduler-qstash.ts +5 -5
  189. package/src/scheduler-vercel.ts +14 -15
  190. package/src/server.ts +233 -29
  191. package/src/store-memory.ts +92 -16
  192. package/src/store-postgres.ts +156 -25
  193. package/src/store-redis-core.ts +322 -110
  194. package/src/store-redis-notify.ts +2 -0
  195. package/src/store-sqlite.ts +121 -28
  196. package/src/store.ts +55 -3
  197. package/src/telemetry.ts +1 -0
  198. package/dist/internal-CbVvEwad.js.map +0 -1
  199. package/dist/server-CGM8WzRG.d.ts.map +0 -1
  200. package/dist/server-Rj64fNvX.js.map +0 -1
  201. package/dist/session-clear-Dshpuhff.d.ts.map +0 -1
  202. package/dist/store-Gpkq7WBo.d.ts.map +0 -1
  203. package/dist/store-redis-notify-tqWNnMRu.js.map +0 -1
  204. package/dist/telemetry-cZH-cibj.d.ts.map +0 -1
  205. package/docs/concepts/01-contracts.mdx +0 -237
  206. package/docs/concepts/02-handlers.mdx +0 -284
  207. package/docs/concepts/03-durability.mdx +0 -314
  208. package/docs/concepts/04-state.mdx +0 -209
  209. package/docs/guides/01-timers.mdx +0 -193
  210. package/docs/guides/02-cancellation.mdx +0 -161
  211. package/docs/guides/03-react.mdx +0 -324
  212. package/docs/guides/04-local-first.mdx +0 -47
  213. package/docs/guides/05-production.mdx +0 -534
  214. package/docs/guides/06-ai-agents.mdx +0 -1408
  215. package/docs/guides/08-application-data.mdx +0 -113
  216. package/docs/guides/09-presence.mdx +0 -262
  217. package/docs/guides/10-transports.mdx +0 -221
  218. package/docs/reference/01-api.mdx +0 -2358
  219. package/docs/reference/02-errors.mdx +0 -150
  220. /package/docs/{concepts → contract}/meta.ts +0 -0
  221. /package/docs/{guides → server}/meta.ts +0 -0
@@ -1,1408 +0,0 @@
1
- ---
2
- title: Durable AI agents
3
- description: Build an AI SDK agent whose messages, streaming progress, tools, approvals, and recovery live in an A2 event log.
4
- ---
5
-
6
- ## Install
7
-
8
- Install A2, the AI SDK, and Zod for the examples below:
9
-
10
- ```bash
11
- pnpm add experimental-a2 ai zod
12
- ```
13
-
14
- The examples use the AI SDK's default global provider, AI Gateway. Add its key
15
- to your environment:
16
-
17
- ```bash .env.local
18
- AI_GATEWAY_API_KEY=your_api_key
19
- ```
20
-
21
- ## Define the agent
22
-
23
- `agent()` is the whole server: the built-in AI protocol, the AI SDK runner, and
24
- A2's ordinary store, scheduler, telemetry, and handler machinery, assembled as
25
- one A2 server.
26
-
27
- ```ts server/assistant.ts
28
- import { agent } from 'experimental-a2/ai/server'
29
-
30
- export const assistant = agent({
31
- name: 'support-agent',
32
- model: 'openai/gpt-5.6-terra',
33
- instructions: 'Help the customer. Be concise.',
34
- generation: {
35
- temperature: 0.2,
36
- maxOutputTokens: 2_000,
37
- },
38
- })
39
- ```
40
-
41
- `name` is the agent's identity: it prefixes storage keys and queue messages.
42
- `experimental-a2/ai/server` is server-only, and a browser bundle that imports it
43
- fails loudly. Browser code reaches the agent through its route and a type-only
44
- import, shown below.
45
-
46
- A string model id uses the AI SDK's global provider, which is AI Gateway by
47
- default. `model` also accepts any AI SDK `LanguageModel`, such as a provider
48
- model returned by `openai('gpt-5.6-terra')`, or an async resolver that chooses a
49
- model for each generation.
50
-
51
- Each durable generation runs one AI SDK `streamText()` step. `generation`
52
- contains per-step settings such as sampling, token limits, provider options,
53
- and tool approval policy. A2 owns the stop condition, local tool execution,
54
- and continuation between steps. Responses have no step limit by default. Set
55
- `maxSteps` to a positive integer to limit one complete assistant response.
56
- Individual providers decide which model settings they support. `generation`
57
- therefore excludes `stopWhen`, tool execution callbacks, tool callers, sandbox
58
- execution, and the tool approval secret. A2 preserves all other supplied
59
- settings, including `toolsContext`, when calling the AI SDK. A local tool
60
- reads its durable execution context later, from the handler that runs it. Model-step
61
- timeouts are supported; tool-execution timeouts are not.
62
-
63
- `generation` also accepts a sync or async resolver with the same context as
64
- `model`, called once per attempt before compaction and model dispatch. Read
65
- application state with `session.state()`, which defaults to the captured
66
- generation snapshot. See the
67
- [API reference](/reference/api#agentoptions).
68
-
69
- For AI Gateway models, the default generator sets
70
- `generation.providerOptions.gateway.caching` to `'auto'`. A2 merges this
71
- default with your provider options, with caller-supplied values taking
72
- precedence. Other Gateway options and provider namespaces are preserved.
73
- The same default applies to automatic compaction. A custom `generate`
74
- function receives your original settings and owns its caching policy.
75
-
76
- Gateway calls also send an `x-session-affinity` header derived from a SHA-256
77
- hash of the agent name and session ID. The value stays stable across turns,
78
- retries, worker restarts, and automatic compaction. No raw session identifier
79
- is sent in this header. A caller-supplied `generation.headers` entry takes
80
- precedence, regardless of header casing; an explicit `undefined` suppresses
81
- A2's default header. Set overrides here, since A2's per-call default can
82
- override headers configured on the model itself.
83
- Affinity helps supporting providers reuse their cache; it does not select
84
- Gateway's provider or guarantee a cache hit.
85
- See [Gateway automatic caching](https://vercel.com/docs/ai-gateway/models-and-providers/automatic-caching)
86
- for provider support and cache-write costs.
87
-
88
- ## Expose the route
89
-
90
- One HTTP route gives the browser a read and write path. `GET` streams events;
91
- `POST` accepts optimistic pushes:
92
-
93
- ```ts app/api/agent/route.ts
94
- import { assistant } from '@/server/assistant'
95
-
96
- export const GET = assistant.fetch
97
- export const POST = assistant.fetch
98
- ```
99
-
100
- The route never calls the model directly. The browser pushes user messages and
101
- controls as `ai.control.requested` commands, and `fetch` rejects every
102
- server-authored event with `400`. The controller admits inputs and schedules
103
- `ai.generation.requested`; workers run the AI SDK and append progress to the
104
- same log.
105
-
106
- Authentication stays in your route. Here's where you'd do auth, or any other
107
- checks, then hand the request to `assistant.fetch(request, { authorize })` for
108
- per-operation policy.
109
-
110
- ## Render the conversation
111
-
112
- Fold the initial state on the server and hand it to a client component:
113
-
114
- ```tsx app/agent/[sessionId]/page.tsx
115
- import { assistant } from '@/server/assistant'
116
- import { Chat } from './chat'
117
-
118
- export default async function AgentPage({
119
- params,
120
- }: {
121
- params: Promise<{ sessionId: string }>
122
- }) {
123
- const { sessionId } = await params
124
- const initial = await assistant.session(sessionId).state()
125
- return <Chat sessionId={sessionId} initial={initial} />
126
- }
127
- ```
128
-
129
- An empty session folds to the initial `AIState`. Its log is created lazily when
130
- the first message is pushed.
131
-
132
- `useAgent()` keeps that state live and returns the actions a chat needs:
133
-
134
- ```tsx app/agent/[sessionId]/chat.tsx
135
- 'use client'
136
- import type { FormEvent } from 'react'
137
- import { useState } from 'react'
138
- import type { StateSnapshot } from 'experimental-a2'
139
- import type { AIState } from 'experimental-a2/ai'
140
- import { useAgent } from 'experimental-a2/ai/react'
141
- import type { assistant } from '@/server/assistant'
142
-
143
- const indicatorLabel = {
144
- starting: 'Starting…',
145
- thinking: 'Thinking…',
146
- compacting: 'Compacting…',
147
- }
148
-
149
- export function Chat({
150
- sessionId,
151
- initial,
152
- }: {
153
- sessionId: string
154
- initial: StateSnapshot<AIState>
155
- }) {
156
- const { messages, status, indicator, sendMessage } =
157
- useAgent<typeof assistant>({ api: '/api/agent', id: sessionId, initial })
158
- const [draft, setDraft] = useState('')
159
- const [error, setError] = useState<string | null>(null)
160
-
161
- async function send(event: FormEvent<HTMLFormElement>): Promise<void> {
162
- event.preventDefault()
163
- const text = draft.trim()
164
- if (!text) return
165
- setDraft('')
166
- setError(null)
167
- try {
168
- await sendMessage(text)
169
- } catch (cause) {
170
- setDraft(text)
171
- setError(cause instanceof Error ? cause.message : String(cause))
172
- }
173
- }
174
-
175
- return (
176
- <main>
177
- <section aria-label="Conversation">
178
- {messages.map((message) => (
179
- <article key={message.id}>
180
- <strong>{message.role === 'user' ? 'You' : 'Agent'}</strong>
181
- {message.parts.map((part, index) =>
182
- part.type === 'text' ? <p key={index}>{part.text}</p> : null,
183
- )}
184
- </article>
185
- ))}
186
- {indicator ? (
187
- <p role="status">{indicatorLabel[indicator]}</p>
188
- ) : null}
189
- </section>
190
-
191
- <form onSubmit={send}>
192
- <input
193
- aria-label="Message"
194
- value={draft}
195
- onChange={(event) => setDraft(event.currentTarget.value)}
196
- />
197
- <button disabled={!draft.trim() || status === 'closed'}>Send</button>
198
- </form>
199
-
200
- {error ? <p role="alert">{error}</p> : null}
201
- </main>
202
- )
203
- }
204
- ```
205
-
206
- The `import type` of the server module is erased at build time, so neither the
207
- server nor a contract enters the client bundle. The AI protocol and its reducer
208
- are library code; the type argument only carries the agent's message types. The hook keeps one client per `api` for the page's lifetime,
209
- and every component that calls `useAgent()` with the same `api` and `id` shares
210
- one live session. That client reads `globalThis.fetch` on each request; pass
211
- `client` instead of `api` for a custom `fetch`, headers, or `gcTime`. Actions are
212
- rebuilt on every render, so do not rely on their identity.
213
-
214
- The result is the ordinary session result (`state`, `index`, `connection`,
215
- `push`, `events`, and history) plus actions, `messages` (the same array as
216
- `state.messages`), `status`, and `indicator`. `indicator` names what the UI
217
- shows while nothing new is visible yet: `starting` for an optimistic turn
218
- awaiting acceptance, `compacting` while a summary runs, `thinking` while the
219
- current model step has produced no visible part, and `null` otherwise. Reasoning
220
- and step boundaries do not count as visible output, so the indicator returns
221
- between tool steps.
222
-
223
- `sendMessage()` turns a string into one text part with a fresh message ID, or
224
- takes a complete `UIMessage`. Every action returns the same result as `push()`:
225
- it resolves to the persisted command events once they are stored, and its
226
- `.confirmed` promise resolves when the stream delivers them back. The draft
227
- clears immediately and returns if the push fails. `state.inbox.items`
228
- contains pending inputs; `state.messages` contains the admitted conversation.
229
-
230
- Users can send another message while a response is active. The message enters
231
- the durable log immediately, but its turn waits until the active assistant
232
- response, including every tool step and approval, reaches a terminal event.
233
- Inbox order defaults to arrival order and can be edited explicitly.
234
-
235
- Pass `{ generate: false }` when a user message should update the conversation
236
- without starting a model turn, as in
237
- `sendMessage('Order #4411 shipped this morning.', { generate: false })`.
238
-
239
- The passive user message appears in `AIState.messages` when admitted. A later user
240
- message with the default generation behavior includes passive user messages
241
- before it in model context. Passive user messages after that trigger remain
242
- outside its prompt and wait for the next generating message. This ordering
243
- supports ambient agents without letting new background context leak into an
244
- already queued turn.
245
-
246
- ## Start a session from another page
247
-
248
- Wait for the durable append before navigating, so the destination's server
249
- render sees the message in its folded state instead of racing the `POST`.
250
- `createAgentClient()` is the same contract-free client outside React:
251
-
252
- ```ts app/agent/client.ts
253
- 'use client'
254
- import { createAgentClient } from 'experimental-a2/ai/client'
255
- import type { assistant } from '@/server/assistant'
256
-
257
- export const assistantClient = createAgentClient<typeof assistant>({
258
- api: '/api/agent',
259
- })
260
- ```
261
-
262
- ```ts app/agent/open-agent.ts
263
- 'use client'
264
- import { inputs } from 'experimental-a2/ai'
265
- import { assistantClient } from './client'
266
-
267
- export async function openAgent({
268
- sessionId,
269
- text,
270
- router,
271
- }: {
272
- sessionId: string
273
- text: string
274
- router: { push(href: string): void }
275
- }): Promise<void> {
276
- await assistantClient.session(sessionId).push(
277
- ...inputs.message({
278
- id: crypto.randomUUID(),
279
- role: 'user',
280
- parts: [{ type: 'text', text }],
281
- }),
282
- )
283
- router.push(`/agent/${sessionId}`)
284
- }
285
- ```
286
-
287
- Pass the same client to the destination's hook,
288
- `useAgent<typeof assistant>({ client: assistantClient, id: sessionId, initial })`,
289
- so it resolves the live session the push created and reconciles the server
290
- render without replacing it. Idle session objects expire after five minutes by
291
- default; configure `gcTime` on `createAgentClient` when a different lifetime
292
- fits the app.
293
-
294
- ## Choose the model
295
-
296
- `model` takes a model, or `ModelSettings` to choose its reasoning level and
297
- speed with it:
298
-
299
- ```ts server/fast-assistant.ts
300
- import { agent } from 'experimental-a2/ai/server'
301
-
302
- export const fastAssistant = agent({
303
- name: 'fast-support',
304
- model: { model: 'openai/gpt-5.6-terra', reasoning: 'low', speed: 'fast' },
305
- })
306
- ```
307
-
308
- A resolver returns the same shape for each generation, so the choice can come
309
- from anything it reads, such as the conversation so far:
310
-
311
- ```ts server/routed-assistant.ts
312
- import { agent } from 'experimental-a2/ai/server'
313
-
314
- export const routedAssistant = agent({
315
- name: 'routed-support',
316
- model: ({ messages }) =>
317
- messages.length > 20
318
- ? { model: 'openai/gpt-5.6-terra', reasoning: 'high' }
319
- : { model: 'openai/gpt-5.6-luna', speed: 'fast' },
320
- })
321
- ```
322
-
323
- `reasoning` becomes the AI SDK's `reasoning` call option. `speed: 'fast'` sets
324
- `providerOptions.gateway.speed` to prefer AI Gateway's faster tier when the
325
- model offers one; `'standard'` removes a configured speed. Both override the
326
- same settings in `generation`. `reasoningLevels` from `experimental-a2/ai`
327
- lists the levels, for building a picker. To choose from your app's state, such
328
- as the user's plan or a picker's selection, see
329
- [Add your own events and state](#add-your-own-events-and-state).
330
-
331
- ## What happens after a send
332
-
333
- One generating user interaction becomes a durable sequence:
334
-
335
- ```text
336
- browser ai.control.requested durable input command
337
- server ai.message.created input admitted
338
- server ai.control.committed accepted state changes
339
- server ai.control.decided command receipt
340
- server ai.generation.requested scheduled from durable facts
341
- server ai.generation.started
342
- server ai.generation.progress batched UIMessageChunk[]
343
- server ai.tool.called when present
344
- server ai.approval.requested when present
345
- server ai.tool.result after concurrent execution or denial
346
- server ai.generation.completed closes this model step
347
- server ai.generation.requested after all sibling tools are terminal
348
- ... another one-step generation
349
- server ai.message.completed
350
- server ai.generation.requested next queued user turn, when present
351
- ```
352
-
353
- Tool results and generation completion may arrive in either order. A fast
354
- automatic tool can finish while the model stream is still open. The join waits
355
- for both step closure and every required terminal result.
356
-
357
- The SSE connection delivers the server events to the same reducer. Progress
358
- stores streamed output once. Within a batch, adjacent text, reasoning, and
359
- tool-input deltas of one part are joined into a single chunk. The reducer and the exported
360
- `deriveUIMessages()` apply those chunks synchronously to produce the current
361
- `UIMessage`; cumulative message snapshots are not duplicated in the log.
362
-
363
- `AIState` exposes `messages`, `status`, `inbox`, `active`,
364
- `pendingApprovals`, `pendingInputs`, `tools`, `compaction`, per-generation
365
- `usage`, the latest command `receipt`, and the last generation `error`. It also
366
- carries the protocol's ownership bookkeeping: `activeGeneration`,
367
- `activeRequestId`, `activeResponseMessageId`, `activeProjection`,
368
- `responseOwners`, `terminalRequestIds`, and `terminalGenerations`. The hook's
369
- actions read these fields for you; lower-level `inputs` builders need them
370
- explicitly.
371
-
372
- `activeProjection` is the temporary indexed generation frontier used for exact
373
- interruption. It remains available after a generation step completes while the
374
- response waits on tool, approval, or input barriers, and after a generation
375
- fails while it awaits retry or interruption. It clears when the response
376
- completes, is interrupted or superseded, a retry begins, or a later generation
377
- replaces it.
378
-
379
- `activeRequestId` identifies the server-authorized generation request. It
380
- prevents a delayed request or recovered attempt from taking ownership from the
381
- current response before its generation starts. `activeResponseMessageId`
382
- identifies that request's response before `activeGeneration` exists. It is
383
- `null` after the generation starts.
384
-
385
- `responseOwners` keeps the latest generation ID, request ID, and attempt for each response
386
- message. Late lifecycle events from a superseded generation stay in raw
387
- history but cannot alter the projected message, even after its replacement
388
- finishes.
389
-
390
- A2 generates fixed-length deterministic IDs in its reserved `a2.ai:` namespace
391
- for requests, generations, responses, and worker lifecycle events. Treat those
392
- IDs as opaque. An assistant response uses its first model-request event's ID. Generation requests carry
393
- `responseMessageId` explicitly; tool continuations also carry `sourceGenerationId`.
394
- Ownership comes from these fields and the generation's `requestId` and `attempt`,
395
- so ID strings never embed the preceding tool loop.
396
-
397
- Generation requests share one durable lane, so model calls do not overlap.
398
- The queued-turn policy is the stronger ordering rule: a later user message is
399
- not scheduled until the current response's complete tool loop finishes. Lane
400
- FIFO alone would not prevent a newly appended user turn from overtaking a tool
401
- continuation that has not been appended yet.
402
-
403
- ## Add tools and approval
404
-
405
- Tools are ordinary AI SDK `tool()` definitions. A2 passes them through to
406
- the AI SDK without interpreting or restricting them. Approval policy
407
- belongs in `generation`, beside the other per-step settings:
408
-
409
- ```ts server/with-tools.ts
410
- import { tool } from 'ai'
411
- import { z } from 'zod'
412
- import { agent } from 'experimental-a2/ai/server'
413
-
414
- const tools = {
415
- closeTicket: tool({
416
- description: 'Close a resolved support ticket',
417
- inputSchema: z.object({ ticketId: z.string() }),
418
- execute: async ({ ticketId }, { toolCallId }) => {
419
- // your side effect; toolCallId makes a stable idempotency key:
420
- // await closeTicket(ticketId, { idempotencyKey: toolCallId })
421
- return { ticketId, closed: true }
422
- },
423
- }),
424
- }
425
-
426
- export const toolsAssistant = agent({
427
- name: 'support-tools',
428
- model: 'openai/gpt-5.6-terra',
429
- tools,
430
- generation: {
431
- toolApproval: {
432
- closeTicket: 'user-approval',
433
- },
434
- },
435
- })
436
- ```
437
-
438
- The AI SDK infers the tool input and output from its schemas and
439
- `execute`. A2 additionally runs each local `execute` inside an ambient
440
- scope carrying the current durable handler attempt. Read it with
441
- `handlerContext()` from `experimental-a2/ai`: it returns the same context an
442
- event handler receives, with `event`, `attempt`, `session`, and `signal`. Tools
443
- execute an `ai.tool.execution.requested` event after authorization. Its payload
444
- contains the admitted `call`, its `generation`, and its turn and execution
445
- version. `handlerContext()` throws when called outside a tool execution. The
446
- scope survives awaited helpers and async iteration, so any code below `execute`
447
- can read it. Because the call takes no argument, a tool can live inside the
448
- `agent()` call that serves it. Pass a shared `agentContract()` to type
449
- application events on `ctx.session`; A2 checks it against the executing agent.
450
-
451
- `generation.toolApproval` is evaluated during the model step, before A2 has a
452
- durable tool handler. Static policies such as the example above are simplest.
453
- An input-dependent policy may be a function that returns `user-approval` or
454
- `approved` from the validated tool input. Approval predicates are
455
- re-evaluated on recovery for the same durable tool call, so they must be
456
- deterministic over the tool input; an impure predicate can fork one
457
- durable call's flow. Model-phase options such as `toolsContext` and
458
- per-tool `contextSchema` are yours and pass through to the AI SDK
459
- untouched. Any model-phase hook that receives tool definitions sees
460
- them without local `execute` functions. The durable A2 context is
461
- ambient only inside `execute`.
462
-
463
- The context's `session` belongs to the agent executing the tool, so its
464
- `schedule()` method uses that agent's scheduler. For example, this tool
465
- schedules a reminder as a new user message:
466
-
467
- ```ts server/set-reminder.ts
468
- import { tool } from 'ai'
469
- import { handlerContext, inputs } from 'experimental-a2/ai'
470
- import { z } from 'zod'
471
-
472
- export const setReminder = tool({
473
- description: 'Set a reminder for yourself',
474
- inputSchema: z.object({
475
- delaySeconds: z.number().int().min(1).max(6 * 24 * 60 * 60),
476
- reminder: z.string(),
477
- }),
478
- execute: async ({ delaySeconds, reminder }, { toolCallId }) => {
479
- const ctx = handlerContext()
480
- await ctx.session.schedule(
481
- `reminder:${toolCallId}`,
482
- { delay: `${delaySeconds}s` },
483
- ...inputs.message({
484
- id: `reminder:${ctx.event.id}`,
485
- role: 'user',
486
- parts: [{ type: 'text', text: `Reminder: ${reminder}` }],
487
- }),
488
- )
489
-
490
- return { scheduled: true, delaySeconds }
491
- },
492
- })
493
- ```
494
-
495
- Add `setReminder` to the agent's `tools` and configure a
496
- [scheduler](/guides/production).
497
- The numeric schema lets the model choose any whole-second delay in the
498
- provider's supported range instead of selecting from a fixed application enum.
499
- The example uses A2's six-day Vercel Queues limit. The seventh retention day is
500
- reserved for delivery and retries. Adjust the maximum to match your scheduler
501
- and plan.
502
- The schedule name uses `toolCallId`, and the message id uses the durable
503
- triggering event id. A retry therefore converges on the same timer and message,
504
- even if a provider reuses tool-call ids in a later generation. When the timer
505
- arrives, `inputs.message()` queues a durable input command. The
506
- queued-turn policy starts a fresh response after the active response completes
507
- or is interrupted. A failed response must be retried or interrupted first.
508
- If the scheduler send fails ambiguously or transiently, A2 retries the same
509
- durable tool handler so that identity and target time stay fixed. Deterministic
510
- adapter rejections are terminal tool errors. A missing scheduler is also a
511
- normal tool error, so configure one before exposing the tool.
512
-
513
- A tool that never reads `handlerContext()` is just an AI SDK tool;
514
- nothing else changes. To test a context-reading tool directly, wrap the
515
- call with `runWithHandlerContext(ctx, fn)` from
516
- `experimental-a2/ai/server`: inside `fn`, `handlerContext()` returns
517
- `ctx`.
518
-
519
- The AI SDK emits an approval request instead of executing the tool. A2 records
520
- it as `ai.approval.requested`, and the reducer adds it to
521
- `state.pendingApprovals`. Answer it from the hook:
522
-
523
- ```tsx app/tools/[sessionId]/approval-controls.tsx
524
- 'use client'
525
- import { useAgent } from 'experimental-a2/ai/react'
526
- import type { toolsAssistant } from '@/server/with-tools'
527
-
528
- export function ApprovalControls({ sessionId }: { sessionId: string }) {
529
- const { state, approve, deny } = useAgent<typeof toolsAssistant>({
530
- api: '/api/tools',
531
- id: sessionId,
532
- })
533
-
534
- return state.pendingApprovals.map((approval) => (
535
- <div key={approval.approvalId}>
536
- <span>Allow tool call {approval.toolCallId}?</span>
537
- <button onClick={() => void approve(approval)}>Allow</button>
538
- <button
539
- onClick={() => void deny(approval, { reason: 'Denied by the user' })}
540
- >
541
- Deny
542
- </button>
543
- </div>
544
- ))
545
- }
546
- ```
547
-
548
- `approve()` and `deny()` copy the request, message, generation, and approval
549
- IDs from the pending approval, so a delayed click cannot authorize a later
550
- model step. The decision updates the approval part in the projected
551
- `UIMessage`. A built-in handler executes the approved tool or records a denied
552
- result. It never bypasses the same sibling join used by automatic tools.
553
-
554
- Tool activity follows the full AI SDK chunk lifecycle. Input validation errors,
555
- execution errors, denials, and preliminary and final outputs become durable
556
- `ai.tool.result` events. Preliminary outputs keep the tool `running`; a final
557
- output completes it. Each result has its own event id, so a preliminary result
558
- cannot deduplicate the final one. Dynamic-tool, provider-executed, provider
559
- metadata, and tool metadata fields are preserved in the projection.
560
-
561
- Independent tool handlers run concurrently as soon as their durable inputs and
562
- authorization are ready. There is no fixed tool concurrency limit. Set
563
- `toolLane: 'tools'` on the agent when local tools must run one at a time, for
564
- example because they share a sandbox; every local tool execution then queues on
565
- that lane. A private
566
- coordinator reducer tracks generation closure, cancellation, calls, approvals,
567
- and terminal results for the active response. Its retained state is bounded;
568
- completed responses do not accumulate in the coordinator. Each join reads a
569
- durable reducer snapshot plus the log tail through
570
- `ctx.session.state(coordinator, { through: 'latest' })`. The snapshot
571
- is only a cache. Recovery can rebuild the same coordinator state from the event
572
- log after process death.
573
-
574
- A continuation needs the generation to close and every sibling tool to have a
575
- terminal success, error, or denied result. Racing join handlers call
576
- `ctx.session.append()` with the same deterministic continuation ID, so append
577
- idempotency resolves the race. A handler's single-owner terminal result is
578
- returned and committed atomically with that handler's completion. Preliminary
579
- streaming tool outputs use immediate appends. An approved provider-executed
580
- call can satisfy the join through its response. A provider tool that declares
581
- deferred-result support can wait for its provider result instead of a local
582
- executor. The authenticated provider callback appends that `ai.tool.result`
583
- through the trusted server session API. Browser pushes reject lifecycle
584
- events, including provider results.
585
-
586
- `state.pendingInputs` provides the same durable request/response shape for
587
- application-defined input. Answer one with `respond(input, value)` from the
588
- hook. Its value stays in raw history and `state.pendingInputs`; resolve
589
- instructions dynamically or replace `generate` when the value should become
590
- model context.
591
-
592
- ## Interrupt and retry
593
-
594
- Stop the active turn, or retry a failed response, from the hook:
595
-
596
- ```tsx app/agent/[sessionId]/turn-controls.tsx
597
- 'use client'
598
- import { useAgent } from 'experimental-a2/ai/react'
599
- import type { assistant } from '@/server/assistant'
600
-
601
- export function TurnControls({ sessionId }: { sessionId: string }) {
602
- const { state, status, stop, retry } = useAgent<typeof assistant>({
603
- api: '/api/agent',
604
- id: sessionId,
605
- })
606
-
607
- if (status === 'failed')
608
- return <button onClick={() => void retry()}>Retry</button>
609
- return state.active ? (
610
- <button onClick={() => void stop()}>Stop</button>
611
- ) : null
612
- }
613
- ```
614
-
615
- `stop()` addresses the observed turn and sends the exact confirmed log frontier
616
- the rendered state showed, not whatever arrived before the click was handled.
617
- The client immediately previews Stop for that turn. While that generation's
618
- indexed projection is retained, the client freezes the response at that
619
- frontier through the request echo and controller decision. A rejected push or
620
- decision removes the preview.
621
- The reducer rewinds the indexed generation projection to that
622
- frontier and keeps the partial text the user actually saw. Tool parts whose
623
- arguments are still streaming are drafts: interruption removes them from the
624
- conversation and subsequent model prompts, even if their current JSON parses.
625
- Their progress remains in the raw log. Accepted tools still awaiting completion
626
- become `output-error`. An accepted interruption
627
- terminally fences that request or generation. Later generation, tool, approval,
628
- input, and compaction events remain in raw history but cannot reactivate it or
629
- alter the projection.
630
- Completed tool results at or before the visible frontier remain completed.
631
-
632
- Once Stop is persisted, A2's existing cancellation stream aborts running model
633
- and tool work for that turn without waiting for the control handler. Startup
634
- checks also skip matching jobs created while Stop is pending. Cancellation is
635
- cooperative: tools must honor their abort signal. The control handler remains
636
- responsible for recording the interruption and advancing the inbox; a Stop for
637
- an older turn cannot cancel its successor.
638
-
639
- `retry()` records `inputs.retry()` for the failed response, with a fresh
640
- `retryId` for each click. The server reruns that failed model call.
641
- Earlier completed model steps and their tool results remain, including steps
642
- completed after the latest compaction. The committed summary also remains. For
643
- example, if a tool succeeds after compaction and the next model call fails, retry
644
- uses the summary and that tool's result.
645
- Resending one retry is safe while a later retry remains distinct. The failed
646
- model call's uncommitted step remains in
647
- raw history but is excluded from the new prompt and response. Later user messages remain queued while
648
- the failed response awaits an explicit retry or interruption.
649
-
650
- The lower-level `inputs.interrupt()` builder addresses a specific owner. Between
651
- `ai.generation.requested` and `ai.generation.started`, copy
652
- `activeRequestId` as `requestId` and `activeResponseMessageId` as `messageId`.
653
- Once `activeGeneration` exists, send its `generationId` and
654
- `responseMessageId` instead. A request-owned interruption remains valid if
655
- that same request starts before the append commits. Every interruption has
656
- exactly one of `requestId` or `generationId`, so a delayed action cannot stop
657
- later work that reuses the response message. After a generation completes,
658
- copy the owner from a pending approval, pending input, or running tool while
659
- the response is still waiting.
660
-
661
- Applications own conversation forks and edit-and-resend flows for previously
662
- admitted messages. A2's retry operation continues the current response.
663
-
664
- ## Append from the server
665
-
666
- Browser interactions go through the hook for instant state. Server-originated
667
- turns use the same inputs with `append()`:
668
-
669
- ```ts server/append-message.ts
670
- import { inputs } from 'experimental-a2/ai'
671
- import { assistant } from './assistant'
672
-
673
- export async function appendServerMessage(sessionId: string, text: string) {
674
- await assistant.session(sessionId).append(
675
- ...inputs.message({
676
- id: crypto.randomUUID(),
677
- role: 'user',
678
- parts: [{ type: 'text', text }],
679
- }),
680
- )
681
- }
682
- ```
683
-
684
- This path fits webhooks, jobs, and other backend sources. A connected browser
685
- still receives the events over SSE, but it has no optimistic overlay for an
686
- interaction that began elsewhere.
687
-
688
- ## The built-in inputs
689
-
690
- The hook's actions build these commands. `inputs` exposes the same pure,
691
- isomorphic builders for raw `push()` and server `append()`:
692
-
693
- | Input | Events |
694
- | --- | --- |
695
- | `inputs.message(message, { generate? })` | queues input; `generate: false` queues passive context |
696
- | `inputs.seed(message)` | records `generate: false`; non-user roles require a trusted append |
697
- | `inputs.approval(response)` | requests a decision on a pending approval |
698
- | `inputs.input(response)` | records an application input response fact |
699
- | `inputs.requestInput(request)` | records a trusted server request for application input |
700
- | `inputs.retry(options)` | requests a fresh attempt for a failed response |
701
- | `inputs.stop({ turnId, lastSeenIndex? })` | ends the observed turn and advances |
702
- | `inputs.steer({ turnId, message })` | prioritizes input after the observed turn's current step finishes |
703
- | `inputs.queue.edit({ message })` | edits a pending input |
704
- | `inputs.queue.move({ inputId, beforeId })` | reorders pending input; `null` means the end |
705
- | `inputs.queue.remove({ inputId })` | removes pending input from the inbox |
706
- | `inputs.queue.sendNow({ inputId, turnId })` | atomically prioritizes queued input and interrupts the observed turn |
707
- | `inputs.pause({ when })` | pauses now or after the active turn |
708
- | `inputs.resume()` | resumes retained work and inbox admission |
709
- | `inputs.toolResult(result)` | records a trusted deferred provider-tool result |
710
- | `inputs.interrupt(options)` | addresses a specific generation or request |
711
-
712
- Builders return `ai.control.requested` commands. Message, approval, input, and
713
- retry builders identify a stable interaction. Other controls use the event ID
714
- assigned by `push()` or `append()`; transport retries preserve that ID.
715
- Generation scheduling considers only user-role `ai.message.created` facts.
716
- Assistant and system message facts remain context regardless of the `generate`
717
- field, and model output is recorded as generation progress rather than a new
718
- message fact, so an assistant reply cannot recursively schedule another turn.
719
- Browser pushes accept user messages, approval and input responses,
720
- interruptions, and explicit retries. `inputs.requestInput()` and non-user
721
- messages built with `inputs.seed()` are for trusted server appends.
722
- Browser inputs never append server scheduling, seeded non-user messages, or
723
- lifecycle events.
724
- Approval and input request/response payloads carry the active `generationId`;
725
- clients copy it from the pending request so a stale interaction cannot satisfy
726
- a later model step.
727
-
728
- `inputs` deliberately has no session lifecycle methods. The A2 log begins with
729
- the first append. Apps that use explicit `ai.session.created` or
730
- `ai.session.closed` lifecycle events append those trusted server facts
731
- directly.
732
-
733
- ## Compact long conversations
734
-
735
- Compaction is automatic for supported Gateway models. A2 uses the active model
736
- with the same instructions, tool definitions, and provider settings, and appends
737
- an internal user message asking for a summary. This preserves the request prefix
738
- for prompt caching when the provider has a matching cache entry.
739
-
740
- ```ts server/compacted.ts
741
- import { agent } from 'experimental-a2/ai/server'
742
-
743
- export const compactedAssistant = agent({
744
- name: 'compacted-agent',
745
- model: 'openai/gpt-5.6-terra',
746
- })
747
- ```
748
-
749
- On first use of each Gateway model in a session, A2 includes
750
- `ai.model.metadata.requested` in the existing generation-start append. A separate
751
- handler fetches the public Gateway model catalog and records the selected limits
752
- in `ai.model.metadata.resolved`. It runs outside the AI turn lane. The first model
753
- request proceeds immediately; metadata that arrives afterward is available to
754
- later steps. Returning to a previously used model reuses its durable metadata.
755
- The catalog is also cached in memory for one hour across agents, with one
756
- shared in-flight request and a five-second timeout. A failed lookup follows A2's
757
- handler retry policy without failing the model generation. A model absent from
758
- the catalog is recorded as unavailable. A pending entry means no result has been
759
- recorded, including when the metadata handler exhausts its retry budget. A2 does
760
- not start a fresh lookup on every turn after exhaustion. Inspect the handler
761
- failure or use an explicit threshold in that case.
762
-
763
- The default threshold is 75% of the context window. A larger configured
764
- `generation.maxOutputTokens` lowers the threshold further to reserve that output
765
- allowance. This is an input-context policy, not a new output limit. Limits and
766
- metadata status are available in `state.modelMetadata`.
767
-
768
- `compaction: { thresholdTokens: 80_000 }` overrides discovery with an explicit
769
- positive safe integer. `compaction: { instructions: 'Preserve exact IDs.' }`
770
- adds guidance to the built-in summary prompt while keeping the derived threshold.
771
- `compaction: false` disables both compaction and metadata lookup. The `compaction`
772
- option can also be a resolver receiving the same
773
- `{ event, messages, session, signal }` context as `model` and
774
- `instructions`. It returns `false`, automatic options, or a custom policy once
775
- per generation and can await `session.state(settingsReducer)` to read
776
- application settings. Use this to select a threshold from application settings
777
- at the prompt boundary.
778
- Changes after resolution apply to a later model step. Disabling compaction
779
- preserves summaries that were already recorded. Custom provider
780
- models and custom global SDK providers need an explicit threshold. A custom
781
- `generate` callback keeps compaction disabled unless explicitly configured. Gateway
782
- fallback model lists also need an explicit threshold appropriate for every model
783
- they may use.
784
-
785
- The input estimate starts from serialized UTF-8 bytes divided by four, including
786
- model messages, instructions, and tool schemas. When a preceding model call has
787
- reported token usage for the same model and context, A2 uses that measurement as
788
- an anchor and estimates subsequent growth. It is not a provider tokenizer and
789
- does not precisely measure new image or audio tokens. A2 performs no remote token
790
- counting.
791
-
792
- On a cold session without recorded metadata or an explicit threshold, automatic
793
- compaction waits for discovery. An oversized first prompt is sent normally and
794
- its provider error becomes a generation failure. A2 does not silently truncate
795
- it or attempt automatic recovery from a context-limit error. Use an explicit threshold for
796
- applications that import a large initial conversation. The estimate is a
797
- compaction trigger, not a definitive overflow check. Provider context-limit
798
- errors during generation or summarization remain generation failures.
799
-
800
- Crossing the threshold adds one summary model request. Application generation
801
- callbacks, stream transforms, and tool hooks do not run for the summary, and it
802
- never executes local tools. Provider-executed tools, structured output, and forced
803
- tool choices disable implicit automatic compaction; explicitly configuring
804
- automatic compaction for those requests throws. Use a custom policy for them.
805
- An empty summary or a summary tool call fails the generation instead of replacing
806
- its context.
807
-
808
- A2 records `ai.compaction.requested` before the call and
809
- `ai.compaction.completed` after a successful summary. The completed event stores
810
- the summary, its usage, and the exact event frontier it covers. Later model
811
- requests receive the summary plus everything produced after that frontier,
812
- including later tool steps in the same assistant message. Queued user messages
813
- remain queued. Compaction waits until earlier tool calls have terminal results.
814
- Server execution uses a separate model-context reducer and checkpoint.
815
- Successful compaction permanently replaces covered context in model requests;
816
- the raw log and the transcript in `AIState.messages` stay complete.
817
-
818
- ### Show compaction in the conversation
819
-
820
- The transcript marks where each compaction happened. When compaction is
821
- requested, the reducer adds a `data-a2-compaction` part to the assistant
822
- response, between the steps before it and the model step it compacted for. A
823
- compaction before the first step opens the response with that part. Render it
824
- in the same loop as the other parts:
825
-
826
- ```tsx app/agent/message-view.tsx
827
- 'use client'
828
- import type { UIMessage } from 'ai'
829
- import { isCompactionPart, type TranscriptMessage } from 'experimental-a2/ai'
830
-
831
- const compactionLabel = {
832
- running: 'Compacting…',
833
- completed: 'Context compacted',
834
- failed: 'Compaction did not complete',
835
- interrupted: 'Compaction stopped',
836
- }
837
-
838
- export function MessageView({
839
- message,
840
- }: {
841
- message: TranscriptMessage<UIMessage>
842
- }) {
843
- return (
844
- <article>
845
- {message.parts.map((part, index) => {
846
- if (isCompactionPart(part))
847
- return (
848
- <details key={index}>
849
- <summary>{compactionLabel[part.data.status]}</summary>
850
- {part.data.summary ? <p>{part.data.summary}</p> : null}
851
- </details>
852
- )
853
- return part.type === 'text' ? <p key={index}>{part.text}</p> : null
854
- })}
855
- </article>
856
- )
857
- }
858
- ```
859
-
860
- The part's `data` holds `status` plus, when known, `inputTokens`,
861
- `thresholdTokens`, the `summary`, and its `usage`. It starts `running` and
862
- settles in place: `completed` when the summary commits, `failed` when its
863
- generation fails first, and `interrupted` when Stop, Pause now, or session
864
- close ends it first. A pending Stop previews the part as `interrupted`
865
- immediately. A worker attempt that recovery supersedes removes its part; the
866
- replacement attempt compacts again if it still needs to. A completed part stays
867
- through Stop cutoffs, later failures, and retries of the step that compacted,
868
- because successful compaction is permanent. The part lives in the state, not the event
869
- feed, so it survives server rendering, reloads, and other tabs. AI SDK
870
- `convertToModelMessages()` skips data parts by default, and A2's model context
871
- never includes it. `state.messages` is typed as `TranscriptMessage<M>[]`: your
872
- message type with `CompactionPart` added to its parts, so `isCompactionPart()`
873
- narrows even when your message type declares its own data parts.
874
-
875
- `state.compaction` holds the current or latest compaction, and the hook's
876
- `indicator` reports `compacting` while one runs.
877
-
878
- Compaction runs only when retained tool exchanges have final results and all
879
- A2-owned tool work has settled, including work from stopped or superseded turns.
880
- A2 checks a fresh coordinator checkpoint before starting a candidate compaction.
881
- This adds one state read on that path. Unresolved dead-lettered tool work keeps
882
- compaction deferred until repair and settlement or session closure.
883
-
884
- Custom `shouldCompact(context)` and `compact(context)` callbacks remain
885
- available for deterministic replacement messages or application-specific
886
- retention. Both receive the typed UI messages and `modelMessages`, the model-ready
887
- context including any existing summary. Custom generation callbacks also receive
888
- `modelMessages`; use it when forwarding context to a provider. A2 stores built-in
889
- summaries separately from application-typed messages, so a summary does not
890
- need your message metadata or schema.
891
-
892
- Successful compaction is a permanent boundary. Failure, interruption, retry, and
893
- worker recovery never restore the messages it replaced. Retry reruns the failed
894
- model step from the latest committed context and preserves earlier completed
895
- steps. A failed summary attempt leaves the previous committed context intact.
896
- The synthetic summary request is not a user turn or an assistant reply in
897
- `state.messages`.
898
-
899
- ## Store assistant metadata
900
-
901
- Give the agent a narrower `UIMessage` type to keep generation status and usage
902
- on the assistant message. `agentContract()` carries the type:
903
-
904
- ```ts server/metadata-assistant.ts
905
- import type { LanguageModelUsage, UIMessage } from 'ai'
906
- import { agentContract } from 'experimental-a2/ai'
907
- import { agent } from 'experimental-a2/ai/server'
908
-
909
- export type MetadataAssistantMessage = UIMessage<{
910
- status: 'writing' | 'complete'
911
- usage?: LanguageModelUsage
912
- }>
913
-
914
- export const metadataAssistant = agent({
915
- contract: agentContract<MetadataAssistantMessage>({
916
- name: 'metadata-assistant',
917
- }),
918
- model: 'openai/gpt-5.6-terra',
919
- messageMetadata: ({ part }) => {
920
- if (part.type === 'start') return { status: 'writing' as const }
921
- if (part.type === 'finish') {
922
- return { status: 'complete' as const, usage: part.totalUsage }
923
- }
924
- return undefined
925
- },
926
- })
927
- ```
928
-
929
- The default generator passes `messageMetadata` to the AI SDK's
930
- `toUIMessageStream()`. The callback receives the same context as `generate`,
931
- plus its current `TextStreamPart` as `part`. Its return type follows the metadata type in the
932
- agent's `UIMessage`. Metadata on standard `start` and `finish` chunks enters
933
- the event log with the rest of the stream. A2 shallow-merges those values onto
934
- the projected assistant message, so the example exposes `writing` immediately
935
- and keeps final token usage after completion. The callback is synchronous.
936
- `useAgent<typeof metadataAssistant>()` types `messages` with the same metadata.
937
-
938
- ## Add your own events and state
939
-
940
- Apps always carry more than the conversation: a plan, a selected ticket, a
941
- chosen model. Put those events and the state they fold into in a shared
942
- `agentContract()`. It takes a core `contract()`'s `events`, and its `reducer`
943
- takes the same `name`, `initialState`, and `fold`:
944
-
945
- ```ts support-desk.ts
946
- import { z } from 'zod'
947
- import { agentContract } from 'experimental-a2/ai'
948
-
949
- const noFlags: string[] = []
950
-
951
- export const supportDesk = agentContract({
952
- name: 'support-desk',
953
- events: {
954
- 'plan.changed': z.object({ plan: z.enum(['free', 'pro']) }),
955
- 'ticket.flagged': z.object({ ticketId: z.string() }),
956
- },
957
- reducer: {
958
- name: 'support-desk.v1',
959
- initialState: { plan: 'free' as 'free' | 'pro', flagged: noFlags },
960
- fold: (state, event) =>
961
- event.type === 'plan.changed'
962
- ? { ...state, plan: event.payload.plan }
963
- : event.type === 'ticket.flagged'
964
- ? { ...state, flagged: [...state.flagged, event.payload.ticketId] }
965
- : state,
966
- },
967
- })
968
- ```
969
-
970
- Keep this module isomorphic. `fold` sees your state and every event, but not
971
- the transcript. The session's state is `AIState` merged with yours, so a key
972
- that shadows one of `AIState`'s is a type error. The reducer's `name` is its
973
- snapshot identity: rename it when `fold` changes.
974
-
975
- Serve it with `agent({ contract })`. Every resolver receives your state as
976
- `state`, so model, reasoning level, and speed follow the app:
977
-
978
- ```ts server/support-desk.ts
979
- import { agent } from 'experimental-a2/ai/server'
980
- import { supportDesk } from '../support-desk'
981
-
982
- export const supportDeskAgent = agent({
983
- contract: supportDesk,
984
- model: ({ state }) =>
985
- state.plan === 'pro'
986
- ? { model: 'openai/gpt-5.6-terra', reasoning: 'high' }
987
- : { model: 'openai/gpt-5.6-luna', speed: 'fast' },
988
- handlers: {
989
- 'ticket.flagged': async ({ event }) => {
990
- // your side effect; event.id makes a stable idempotency key:
991
- // await notifyOnCall(event.payload.ticketId, { idempotencyKey: event.id })
992
- },
993
- },
994
- })
995
- ```
996
-
997
- `state` is your state as of the snapshot the generation already captured. It
998
- rides in the checkpoint every model step reads, so it costs no extra read. Each
999
- model step resolves again, so a change during a turn applies from the next
1000
- step. A resolver that throws fails the turn with its message, like a provider
1001
- error; `retry()` runs it again.
1002
-
1003
- In the browser, pass the contract to `useAgent()`. This import is real, not
1004
- type-only, because the browser folds your state too:
1005
-
1006
- ```tsx app/support/desk.tsx
1007
- 'use client'
1008
- import { useAgent } from 'experimental-a2/ai/react'
1009
- import { supportDesk } from '@/support-desk'
1010
-
1011
- export function Desk({ id }: { id: string }) {
1012
- const { state, push, sendMessage } = useAgent({
1013
- api: '/api/support',
1014
- id,
1015
- contract: supportDesk,
1016
- })
1017
-
1018
- return (
1019
- <section>
1020
- <p>
1021
- {state.plan} plan, {state.flagged.length} flagged tickets
1022
- </p>
1023
- <button
1024
- onClick={() =>
1025
- void push({ type: 'ticket.flagged', payload: { ticketId: 'T-1' } })
1026
- }
1027
- >
1028
- Flag T-1
1029
- </button>
1030
- <button onClick={() => void sendMessage('Summarize the flagged tickets.')}>
1031
- Ask
1032
- </button>
1033
- </section>
1034
- )
1035
- }
1036
- ```
1037
-
1038
- Browsers push a shared contract's application events like any A2 client, and
1039
- the route's `authorize` decides who may push what. The agent refuses only its
1040
- own server-authored `ai.*` events. Here the plan comes from your billing code,
1041
- not the browser:
1042
-
1043
- ```ts app/api/support/route.ts
1044
- import { supportDeskAgent } from '@/server/support-desk'
1045
-
1046
- function handle(request: Request): Promise<Response> {
1047
- // here's where you'd do auth, or any other checks:
1048
- return supportDeskAgent.fetch(request, {
1049
- authorize: (operation) =>
1050
- operation.type !== 'push' ||
1051
- operation.events.every((event) => event.type !== 'plan.changed'),
1052
- })
1053
- }
1054
-
1055
- export const GET = handle
1056
- export const POST = handle
1057
- ```
1058
-
1059
- Server appends, such as
1060
- `supportDeskAgent.session(id).append({ type: 'plan.changed', payload })`, are
1061
- trusted and skip `authorize`. `push()` also accepts any `inputs` builder, and
1062
- `handlerContext(supportDesk)` types application events inside tools.
1063
-
1064
- `agent({ contract })` takes only an `agentContract()`, so every resolver gets
1065
- your `state`. When the agent belongs inside something larger, such as a chat
1066
- room several people share, keep them as two sessions. The room is an ordinary
1067
- core A2 session. Its handler appends each message the agent should see to the
1068
- agent's session with `inputs.message()`. Event ids are unique across a store's
1069
- sessions, so derive the message id from the room event's id with
1070
- `idempotentId()`; a retried handler then converges on the same message. A tool
1071
- the agent calls to speak appends to the room's session with an `idempotentId()`
1072
- of its `toolCallId`, so the room's log orders the agent's messages among
1073
- everyone else's. Neither fold ever reads the other's state.
1074
-
1075
- The contract-free client, `createAgentClient()` and `useAgent({ api | client })`
1076
- with a type-only import, folds the built-in reducer, so it accepts only agents
1077
- whose reducer carries the built-in name: `agent({ name })`, or
1078
- `agent({ contract })` with an `agentContract()` that has no `reducer`. The types
1079
- enforce it through `AI_STATE_REDUCER`.
1080
-
1081
- `reduceAIState()` remains a pure transition over confirmed `AIState` for
1082
- applications that project raw event histories.
1083
-
1084
- ### Replace generation, not durability
1085
-
1086
- The default generator uses AI SDK `streamText()` for one model step. Replace
1087
- only that model-facing step for fixtures, provider routing, or a different AI
1088
- SDK assembly:
1089
-
1090
- ```ts server/custom-generation.ts
1091
- import { convertToModelMessages, streamText } from 'ai'
1092
- import { agent } from 'experimental-a2/ai/server'
1093
-
1094
- export const customGeneration = agent({
1095
- name: 'custom-generation',
1096
- model: 'openai/gpt-5.6-terra',
1097
- generate: async ({ event, messages, model, instructions, signal }) => {
1098
- const result = streamText({
1099
- model,
1100
- messages: await convertToModelMessages(messages),
1101
- abortSignal: signal,
1102
- ...(instructions === undefined ? {} : { instructions }),
1103
- })
1104
-
1105
- return result.toUIMessageStream({
1106
- originalMessages: messages,
1107
- generateMessageId: () => event.payload.responseMessageId,
1108
- })
1109
- },
1110
- })
1111
- ```
1112
-
1113
- `generate` receives what every resolver does, `{ event, messages, state,
1114
- session, signal }`, with `messages` already compacted. It also gets the
1115
- resolved `model`, `instructions`, `tools`, and `generation` settings, the
1116
- model-ready `modelMessages` with any compaction summary, and the step's
1117
- `generationId`. Its stream writes the assistant message
1118
- `event.payload.responseMessageId`. It returns exactly one model step as a
1119
- `ReadableStream<UIMessageChunk>`. A tool-aware replacement sends definitions to
1120
- the model without running local `execute` functions. A2 still owns durable
1121
- progress, tool execution, approval, continuation, interruption, and failure.
1122
- `messageMetadata` applies only to the default generator. A custom `generate`
1123
- function owns its metadata and can pass a callback to `toUIMessageStream()` or
1124
- emit typed metadata chunks itself.
1125
-
1126
- ## Checkpoints and recovery reads
1127
-
1128
- `assistant.session(id).state()` returns `AIState` and its log index, ready for
1129
- server code or client hydration. No conversion step is needed. The reducer's
1130
- initial state and fold use that same type.
1131
-
1132
- ```ts server/read-agent.ts
1133
- import type { UIMessage } from 'ai'
1134
- import type { TranscriptMessage } from 'experimental-a2/ai'
1135
- import { assistant } from './assistant'
1136
-
1137
- export async function readMessages(
1138
- sessionId: string,
1139
- ): Promise<TranscriptMessage<UIMessage>[]> {
1140
- const snapshot = await assistant.session(sessionId).state()
1141
- return snapshot.state.messages
1142
- }
1143
- ```
1144
-
1145
- Tool payloads live in message parts. `state.tools` keeps only identities and
1146
- execution status; it does not repeat inputs, outputs, or provider metadata.
1147
- Checkpoint and subscription encoding remain internal. The active step retains
1148
- its events and the information needed to restore overwritten message fields
1149
- for interruption cutoffs and retry. It does not copy the whole prior message.
1150
- Recovery is released when the response completes; completed model
1151
- context is kept by the separate context reducer.
1152
-
1153
- Model steps read the model-context reducer and coordinator at a fixed log
1154
- boundary. Model, instruction, compaction, and custom-generation callbacks
1155
- receive prompt messages, request information, and the contract's application
1156
- `state`, which rides in the model-context checkpoint. Read any other reducer
1157
- with `session.state(yourReducer)`. They do not receive a raw history array. Their `session.state(reducer)` reads
1158
- application reducers at the captured prompt boundary by default; explicit
1159
- `through` options follow the ordinary state-read contract. Compaction selection
1160
- can be asynchronous when it needs a settings-state read.
1161
-
1162
- Ordinary generation uses the model-context checkpoint without reading the
1163
- transcript or replaying history to apply an existing compaction. The context
1164
- checkpoint has its own identity and fold. Recovery and explicit retry discard
1165
- only the unfinished model step. Completed compactions remain committed, so these
1166
- paths never reconstruct pre-compaction prompts or retain rollback copies.
1167
-
1168
- Tools recovering in a fresh process read context at their tool-admission index.
1169
- The saved generation projection restores their original prompt, preserving the
1170
- committed summary and excluding later arrivals. No separate history read is
1171
- needed. A2 core can rebuild a missing or invalid reducer snapshot from the log.
1172
-
1173
- Compaction immediately releases covered message and tool content from model
1174
- context. Execution retains the active turn, queued inputs, unsettled tool work
1175
- IDs, and token calibration. Model metadata is cached per distinct model.
1176
- Historical input uniqueness comes from core event deduplication. The full
1177
- transcript remains available independently.
1178
-
1179
- Streaming progress updates the current message incrementally. Snapshots preserve
1180
- open text and reasoning parts and partial tool inputs. Indexed progress remains
1181
- available for exact interruption cutoffs; reordered events rebuild the projection.
1182
-
1183
- While tool arguments arrive, `input-streaming` parts expose a best-effort partial
1184
- JSON value in `input`. A command string can appear and grow before its closing
1185
- quote arrives. This is a display draft, not validated execution input.
1186
- `tool-input-available` supplies the completed input. Interruption still discards
1187
- streaming drafts even when their partial JSON is readable.
1188
-
1189
- ## Delivery semantics
1190
-
1191
- Model steps and tools run inside at-least-once A2 handlers. The returned-event
1192
- transaction makes each successful handler completion and its terminal events
1193
- one atomic durable operation. Deterministic ids make scheduling and lifecycle
1194
- appends effectively once, and A2 marks an incomplete model attempt
1195
- `superseded` before starting its replacement.
1196
-
1197
- An expired claim or superseded attempt preserves its abort reason through model,
1198
- resolver, compaction-policy, and tool calls. A2 retries that attempt without
1199
- spending its failure budget. Explicit user interruption remains terminal.
1200
- Streaming tool progress belongs to an execution attempt, so a retry can produce
1201
- different preliminary output while preserving the tool-call id for idempotency.
1202
-
1203
- Provider calls and external tool side effects remain outside that transaction.
1204
- A process can die after an external effect succeeds and before its handler
1205
- completion commits, so recovery may run the call again. Give side-effecting
1206
- tools their own idempotency strategy, normally keyed by the AI SDK tool call
1207
- id. This is the same at-least-once boundary as every other A2 handler. A fast
1208
- tool may also start before its model step later fails. The failure prevents a
1209
- continuation, but it cannot roll back that external effect.
1210
-
1211
- Configure a [production scheduler](/guides/production) exactly as for any other A2
1212
- server. It recovers an incomplete generation attempt after the original
1213
- serverless invocation disappears.
1214
-
1215
- ## Inbox and turn controls
1216
-
1217
- This input protocol requires matching client and server versions. Start a new
1218
- agent session when adopting it. Existing event logs remain readable; finish
1219
- pending work with the runtime that created it before upgrading.
1220
-
1221
- `sendMessage()` places a message in the durable inbox. The controller
1222
- admits one input at a time, freezes its content, and adds it to
1223
- `state.messages`. Pending inputs appear in `state.inbox.items`; they do not enter
1224
- the model prompt. Each item has an `id`, `message`, and `generate` flag.
1225
-
1226
- New-input commands from `inputs.message()`, `inputs.seed()`, and `inputs.steer()`
1227
- use the message ID as their event ID. Those IDs must match on hand-written
1228
- commands too. Caller IDs cannot start with the reserved `a2.ai:` prefix. IDs must
1229
- be unique within the store; include contract/session scope when deriving them
1230
- with `idempotentId()`.
1231
-
1232
- An identical submission replays its original event without creating more work,
1233
- even after removal or compaction. Reusing the ID with different content, a
1234
- different command action, or another session rejects at append with
1235
- `PARTIAL_DUPLICATE_BATCH`. Use `queue.edit()` to change a pending input and a fresh
1236
- message ID to create another input. Edit, move, remove, and other commands have
1237
- their own event IDs. The controller stores no historical input-ID registry.
1238
-
1239
- `state.active` identifies the logical turn, its frozen `input`, and its `phase`.
1240
- The turn ID stays the same across model steps, tools, and a pause. Stop ends that
1241
- turn and admits the next queued input when admission is running.
1242
-
1243
- ```tsx app/agent/[sessionId]/queue.tsx
1244
- 'use client'
1245
- import { useAgent } from 'experimental-a2/ai/react'
1246
- import type { assistant } from '@/server/assistant'
1247
-
1248
- export function Queue({ sessionId }: { sessionId: string }) {
1249
- const { state, queue, steer, pause, resume } = useAgent<typeof assistant>({
1250
- api: '/api/agent',
1251
- id: sessionId,
1252
- })
1253
-
1254
- return (
1255
- <section>
1256
- <ol aria-label="Queued messages">
1257
- {state.inbox.items.map((item) => (
1258
- <li key={item.id}>
1259
- {item.message.parts.map((part, index) =>
1260
- part.type === 'text' ? <span key={index}>{part.text}</span> : null,
1261
- )}
1262
- <button onClick={() => void queue.sendNow(item.id)}>Send now</button>
1263
- <button
1264
- onClick={() =>
1265
- void queue.move({ inputId: item.id, beforeId: null })
1266
- }
1267
- >
1268
- Move to end
1269
- </button>
1270
- <button onClick={() => void queue.remove(item.id)}>Remove</button>
1271
- </li>
1272
- ))}
1273
- </ol>
1274
- <button onClick={() => void steer('Check the billing service first.')}>
1275
- Steer
1276
- </button>
1277
- {state.inbox.paused ? (
1278
- <button onClick={() => void resume()}>Resume</button>
1279
- ) : (
1280
- <button onClick={() => void pause({ when: 'after-turn' })}>
1281
- Pause after this turn
1282
- </button>
1283
- )}
1284
- </section>
1285
- )
1286
- }
1287
- ```
1288
-
1289
- `queue.edit(message)` replaces a pending input's content. `queue.move()` takes
1290
- `beforeId: null` to move an item to the end. Removal only applies to pending
1291
- input. It preserves the immutable log and keeps
1292
- its original submission event, so replay cannot recreate a removed prompt.
1293
- Removing an input that is no longer pending returns `not-pending`, whether it was
1294
- admitted, removed, or never queued.
1295
-
1296
- Edits to pending input use last-write-wins in session log order. Clients can send
1297
- another edit before an earlier one is confirmed. Editing preserves the message's
1298
- identity and role; changing its role returns `role-mismatch`. An edit
1299
- that loses to admission returns `not-pending`. A Stop aimed at a completed turn
1300
- returns `stale-turn`.
1301
-
1302
- `steer()` validates the new input and places it ahead of the inbox in one
1303
- decision. With no active turn, it sends like `sendMessage()`.
1304
- If the observed turn is still active, it finishes its current model response and
1305
- the tools produced by that response before handing off. Parallel tools all
1306
- settle, and steering runs before the old turn starts another model step.
1307
- Text and tool results produced after the click remain in the conversation.
1308
- `lastSeenIndex` is not used to truncate output for steering or Send now.
1309
-
1310
- When running work settles, unanswered approval or application-input requests
1311
- are closed so the steering message can take over. The unapproved tool never
1312
- executes. Provider-executed deferred results still count as running work when
1313
- execution is authorized; a denied approval cannot block handoff.
1314
- If the targeted turn has already finished, the input starts when idle or waits
1315
- first behind a different active turn. Stop remains an immediate interruption.
1316
-
1317
- Steering preserves the admission gate: steering while paused does not resume
1318
- execution. Duplicate input identities and closed sessions still reject the command.
1319
-
1320
- `queue.sendNow()` uses the selected queued message's existing identity and
1321
- latest accepted content. It validates the selected input and the observed turn,
1322
- then moves the input first for the same handoff after the current step. If that
1323
- expectation is stale, the command returns `stale-turn` without changing the queue.
1324
-
1325
- Send now preserves pause just like steering. A paused queue keeps the selected
1326
- input first until Resume. It accepts user-role inputs, including passive user
1327
- input, which it marks for generation. Non-user context returns `not-user-input`;
1328
- reserved internal IDs are rejected before append. Unsaved editor text
1329
- is separate from the accepted queue content; save it before sending.
1330
-
1331
- A queued item's optional `afterStepOf` records the observed turn for steering.
1332
- A matching active turn hands off after its current step.
1333
- Editing preserves this intent; removing the item cancels it. Reordering the
1334
- queue controls which input is admitted at that boundary.
1335
-
1336
- ### Optimistic conversation display
1337
-
1338
- The AI reducer is optimistic in every client, including one built from an
1339
- extended `agentContract()`. An idle, unpaused send appears in `state.messages`
1340
- immediately, with `state.starting: true`. Another send waits in
1341
- `state.inbox.items` while that optimistic turn or a confirmed turn is active.
1342
- Paused sessions keep new sends in the inbox. Render the conversation and queue
1343
- from `state`. A pending Stop clears the displayed `state.active` turn
1344
- immediately; rejection restores it. New active turn identities still come from
1345
- accepted control decisions.
1346
- Steering appears immediately in `state.messages`, after the current response,
1347
- and stays there while that step finishes. Send now has the same display when
1348
- an active turn is targeted. These inputs are omitted from the client inbox,
1349
- including while paused, but remain pending on the server until admission.
1350
- Accepted steering survives receipt and hydration in this display. Multiple
1351
- steering inputs appear in admission order, with the most recent first.
1352
- No additional request, subscription, or component-owned queue is needed.
1353
-
1354
- The request echo keeps the optimistic display until `ai.control.decided` arrives. Accepted
1355
- commits replace the confirmed base; rejected receipts remove the pending intent.
1356
- A failed POST rolls back through the ordinary client optimistic overlay.
1357
- Another client's accepted turn can move a speculative ordinary send back into
1358
- the queue. Steering stays visible as a message while waiting behind that turn.
1359
- `state.starting` describes an optimistic turn awaiting acceptance; it does
1360
- not indicate a running model.
1361
-
1362
- Server `session.state()`, reducer folds, checkpoints, and model prompts
1363
- contain confirmed messages and inbox entries, with `starting: false`. The client
1364
- applies pending intent and displays accepted steering only when returning its
1365
- state, after folding events.
1366
- `state.pendingQueueCommands` tracks unresolved queue commands and Stops incrementally, so
1367
- the client does not scan raw history. Pending edits apply in order, so the latest
1368
- local edit stays visible while earlier commands are confirmed. Editing and Send
1369
- now can remain available while an edit awaits its receipt. Close an editor as
1370
- soon as its save is pushed; retain the draft if the push fails.
1371
-
1372
- ### Persistence and application are separate
1373
-
1374
- A push confirms that the command is persisted. The `ai.control.decided` event
1375
- confirms its application, using the submitted event's ID as `commandId`. Its
1376
- `outcome` is `applied` or `rejected`; a rejection includes a `reason`, such as
1377
- `not-pending` or `stale-turn`.
1378
- `state.receipt` exposes the most recent user command receipt. Applications that
1379
- have several commands in flight correlate receipts through the existing event
1380
- stream: a hook action resolves to its persisted command events, and each
1381
- event's `id` is the `commandId` of its receipt. An action with nothing to
1382
- address, such as Stop with no active turn, resolves to an empty array. A
1383
- rejection is a normal result and does not spend a handler retry.
1384
-
1385
- ### Pause and resume
1386
-
1387
- Pause after the turn closes inbox admission and lets the active turn finish.
1388
- Pause now also cancels current model and tool execution. `state.active.phase`
1389
- becomes `pausing` while old work settles, then `paused`. Resume reopens admission
1390
- and resumes retained work before admitting another input.
1391
-
1392
- Resume starts a fresh model request from durable state. It does not resume the
1393
- provider's old stream or undo completed tool effects. The controller retains
1394
- accepted tool calls and records their outcomes while paused. A cancelled tool
1395
- without a terminal result can execute again after resume; use the stable SDK
1396
- `toolCallId` for external idempotency. No model or tool claim is held merely to
1397
- keep a turn paused.
1398
-
1399
- The controller runs on a short lane. Each decision commits accepted facts,
1400
- compact state changes, work requests, and its receipt atomically. Workers use
1401
- separate claims, report lifecycle batches, and wait for acceptance at model-start
1402
- and compaction boundaries. Token progress appends directly. Explicit AI history
1403
- reads remain bounded to a relevant generation suffix; core checkpoint rebuilding
1404
- can replay older events when a snapshot is missing.
1405
-
1406
- For a deferred provider-executed tool, trusted server code submits
1407
- `inputs.toolResult(result)`. The controller checks its admitted call and records
1408
- the result before continuing. Browser pushes reject this server-only input.