experimental-a2 0.3.0 → 0.5.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 (205) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/dist/ai-CFNeCrRl.d.ts +334 -0
  3. package/dist/ai-CFNeCrRl.d.ts.map +1 -0
  4. package/dist/ai-server.browser.js +2 -0
  5. package/dist/ai-server.browser.js.map +1 -0
  6. package/dist/ai-server.d.ts +49 -9
  7. package/dist/ai-server.d.ts.map +1 -0
  8. package/dist/ai-server.js +123 -49
  9. package/dist/ai-server.js.map +1 -0
  10. package/dist/ai.d.ts +2 -303
  11. package/dist/ai.js +233 -86
  12. package/dist/ai.js.map +1 -0
  13. package/dist/cli-B3VuxoDe.js +599 -0
  14. package/dist/cli-B3VuxoDe.js.map +1 -0
  15. package/dist/cli-bin.d.ts +1 -0
  16. package/dist/cli-bin.js +7 -0
  17. package/dist/cli-bin.js.map +1 -0
  18. package/dist/cli.d.ts +20 -0
  19. package/dist/cli.d.ts.map +1 -0
  20. package/dist/cli.js +2 -0
  21. package/dist/client-BKlyLiOU.js +1008 -0
  22. package/dist/client-BKlyLiOU.js.map +1 -0
  23. package/dist/client-D7mvIXrF.d.ts +191 -0
  24. package/dist/client-D7mvIXrF.d.ts.map +1 -0
  25. package/dist/client.d.ts +2 -90
  26. package/dist/client.js +1 -409
  27. package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +12 -2
  28. package/dist/contract-48bUMgcL.js.map +1 -0
  29. package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +63 -8
  30. package/dist/contract-jIfaR085.d.ts.map +1 -0
  31. package/dist/devtools-J_jZ2vQf.d.ts +152 -0
  32. package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
  33. package/dist/devtools-kJJaORn-.js +340 -0
  34. package/dist/devtools-kJJaORn-.js.map +1 -0
  35. package/dist/devtools-server.browser.js +3 -1
  36. package/dist/devtools-server.browser.js.map +1 -0
  37. package/dist/devtools-server.d.ts +4 -3
  38. package/dist/devtools-server.d.ts.map +1 -0
  39. package/dist/devtools-server.js +226 -43
  40. package/dist/devtools-server.js.map +1 -0
  41. package/dist/devtools.d.ts +2 -0
  42. package/dist/devtools.js +2 -0
  43. package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +6 -4
  44. package/dist/errors-BQuJpe82.js.map +1 -0
  45. package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +3 -2
  46. package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
  47. package/dist/http.d.ts +135 -28
  48. package/dist/http.d.ts.map +1 -0
  49. package/dist/http.js +647 -61
  50. package/dist/http.js.map +1 -0
  51. package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +4 -2
  52. package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
  53. package/dist/index.d.ts +5 -5
  54. package/dist/index.js +2 -2
  55. package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +4 -1
  56. package/dist/inspection-DaxB5jM2.js.map +1 -0
  57. package/dist/internal-DstsI6Re.js +211 -0
  58. package/dist/internal-DstsI6Re.js.map +1 -0
  59. package/dist/otel.d.ts +3 -2
  60. package/dist/otel.d.ts.map +1 -0
  61. package/dist/otel.js +2 -0
  62. package/dist/otel.js.map +1 -0
  63. package/dist/platform-B4TnJtWu.js +34 -0
  64. package/dist/platform-B4TnJtWu.js.map +1 -0
  65. package/dist/react.d.ts +47 -15
  66. package/dist/react.d.ts.map +1 -0
  67. package/dist/react.js +30 -15
  68. package/dist/react.js.map +1 -0
  69. package/dist/retryable-lazy-DZWmHpii.js +2 -0
  70. package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
  71. package/dist/scheduler-qstash.d.ts +79 -0
  72. package/dist/scheduler-qstash.d.ts.map +1 -0
  73. package/dist/scheduler-qstash.js +501 -0
  74. package/dist/scheduler-qstash.js.map +1 -0
  75. package/dist/scheduler-task-BpzhPnRS.js +56 -0
  76. package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
  77. package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +18 -24
  78. package/dist/scheduler-vercel.d.ts.map +1 -0
  79. package/dist/scheduler-vercel.js +228 -0
  80. package/dist/scheduler-vercel.js.map +1 -0
  81. package/dist/server-C72KOw51.js +1457 -0
  82. package/dist/server-C72KOw51.js.map +1 -0
  83. package/dist/server-DUF9pjsx.d.ts +261 -0
  84. package/dist/server-DUF9pjsx.d.ts.map +1 -0
  85. package/dist/server.browser.js +2 -0
  86. package/dist/server.browser.js.map +1 -0
  87. package/dist/server.d.ts +4 -155
  88. package/dist/server.js +2 -2
  89. package/dist/{log-ldf5g8Cx.d.ts → store-DysUkTH3.d.ts} +120 -35
  90. package/dist/store-DysUkTH3.d.ts.map +1 -0
  91. package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +3 -1
  92. package/dist/store-N8PXxDAS.js.map +1 -0
  93. package/dist/store-codec-DTG0Ftek.js +10 -0
  94. package/dist/store-codec-DTG0Ftek.js.map +1 -0
  95. package/dist/store-memory.d.ts +12 -0
  96. package/dist/store-memory.d.ts.map +1 -0
  97. package/dist/{log-memory.js → store-memory.js} +145 -34
  98. package/dist/store-memory.js.map +1 -0
  99. package/dist/{log-polling-DZ1MiKLg.js → store-polling-dSeLxzfb.js} +7 -5
  100. package/dist/store-polling-dSeLxzfb.js.map +1 -0
  101. package/dist/{log-postgres.d.ts → store-postgres.d.ts} +7 -6
  102. package/dist/store-postgres.d.ts.map +1 -0
  103. package/dist/{log-postgres.js → store-postgres.js} +214 -24
  104. package/dist/store-postgres.js.map +1 -0
  105. package/dist/{log-redis-core-CyJ5L8yR.js → store-redis-core-BFLwz0Wj.js} +271 -66
  106. package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
  107. package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +8 -7
  108. package/dist/store-redis-http.d.ts.map +1 -0
  109. package/dist/{log-redis-http.js → store-redis-http.js} +22 -12
  110. package/dist/store-redis-http.js.map +1 -0
  111. package/dist/{log-redis.d.ts → store-redis.d.ts} +7 -6
  112. package/dist/store-redis.d.ts.map +1 -0
  113. package/dist/{log-redis.js → store-redis.js} +101 -39
  114. package/dist/store-redis.js.map +1 -0
  115. package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +7 -6
  116. package/dist/store-sqlite.d.ts.map +1 -0
  117. package/dist/{log-sqlite.js → store-sqlite.js} +118 -22
  118. package/dist/store-sqlite.js.map +1 -0
  119. package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +3 -2
  120. package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
  121. package/dist/testing.browser.d.ts +1 -0
  122. package/dist/testing.browser.js +6 -0
  123. package/dist/testing.browser.js.map +1 -0
  124. package/dist/testing.d.ts +32 -0
  125. package/dist/testing.d.ts.map +1 -0
  126. package/dist/testing.js +103 -0
  127. package/dist/testing.js.map +1 -0
  128. package/dist/validate-XKT4FSNn.js +2 -0
  129. package/dist/validate-XKT4FSNn.js.map +1 -0
  130. package/dist/wire-BFQmSJ-9.js +284 -0
  131. package/dist/wire-BFQmSJ-9.js.map +1 -0
  132. package/docs/01-quickstart.mdx +3 -3
  133. package/docs/concepts/01-contracts.mdx +18 -13
  134. package/docs/concepts/02-handlers.mdx +7 -7
  135. package/docs/concepts/03-durability.mdx +21 -21
  136. package/docs/concepts/04-state.mdx +18 -21
  137. package/docs/guides/01-timers.mdx +154 -49
  138. package/docs/guides/02-cancellation.mdx +30 -4
  139. package/docs/guides/03-react.mdx +76 -57
  140. package/docs/guides/04-local-first.mdx +1 -1
  141. package/docs/guides/05-production.mdx +310 -61
  142. package/docs/guides/06-ai-agents.mdx +252 -74
  143. package/docs/guides/07-devtools.mdx +137 -12
  144. package/docs/guides/08-application-data.mdx +1 -1
  145. package/docs/guides/09-presence.mdx +263 -0
  146. package/docs/guides/10-transports.mdx +140 -0
  147. package/docs/index.mdx +18 -14
  148. package/docs/reference/01-api.mdx +839 -110
  149. package/docs/reference/02-errors.mdx +13 -6
  150. package/package.json +24 -7
  151. package/src/ai-coordinator.ts +358 -0
  152. package/src/ai-projector.ts +524 -0
  153. package/src/ai-sdk-step.ts +261 -0
  154. package/src/ai-server.browser.ts +5 -0
  155. package/src/ai-server.ts +1719 -0
  156. package/src/ai.ts +2155 -0
  157. package/src/cache-indexeddb.ts +10 -0
  158. package/src/cli-bin.ts +5 -0
  159. package/src/cli.ts +1046 -0
  160. package/src/client.ts +1826 -0
  161. package/src/contract.ts +206 -0
  162. package/src/deterministic-id.ts +72 -0
  163. package/src/devtools-app.ts +989 -0
  164. package/src/devtools-server.browser.ts +5 -0
  165. package/src/devtools-server.ts +604 -0
  166. package/src/devtools.ts +716 -0
  167. package/src/errors.ts +50 -0
  168. package/src/http.ts +394 -0
  169. package/src/idempotent-replay.ts +53 -0
  170. package/src/index.ts +37 -0
  171. package/src/inspection.ts +39 -0
  172. package/src/internal.ts +426 -0
  173. package/src/otel.ts +59 -0
  174. package/src/platform.ts +60 -0
  175. package/src/push-envelope.ts +137 -0
  176. package/src/react.ts +284 -0
  177. package/src/reducer.ts +108 -0
  178. package/src/retryable-lazy.ts +27 -0
  179. package/src/scheduler-qstash.ts +915 -0
  180. package/src/scheduler-task.ts +106 -0
  181. package/src/scheduler-vercel.ts +437 -0
  182. package/src/server.browser.ts +12 -0
  183. package/src/server.ts +2700 -0
  184. package/src/session-socket.ts +548 -0
  185. package/src/sse.ts +141 -0
  186. package/src/standard-schema.ts +77 -0
  187. package/src/store-codec.ts +10 -0
  188. package/src/store-memory.ts +788 -0
  189. package/src/store-polling.ts +102 -0
  190. package/src/store-postgres.ts +1212 -0
  191. package/src/store-redis-core.ts +1494 -0
  192. package/src/store-redis-http.ts +116 -0
  193. package/src/store-redis.ts +458 -0
  194. package/src/store-sqlite.ts +1108 -0
  195. package/src/store.ts +385 -0
  196. package/src/telemetry.ts +47 -0
  197. package/src/testing.browser.ts +5 -0
  198. package/src/testing.ts +185 -0
  199. package/src/validate.ts +39 -0
  200. package/src/wire.ts +454 -0
  201. package/dist/internal-gCd5qMry.js +0 -44
  202. package/dist/log-memory.d.ts +0 -11
  203. package/dist/recovery-vercel.js +0 -119
  204. package/dist/server-BcLa4RFL.js +0 -877
  205. package/dist/wire-BVsgR8o9.js +0 -62
@@ -37,8 +37,8 @@ in the server module.
37
37
 
38
38
  ## Connect the model
39
39
 
40
- `createAgentServer()` adds the AI SDK runner and A2's ordinary storage,
41
- recovery, telemetry, and handler machinery:
40
+ `createAgentServer()` adds the AI SDK runner and A2's ordinary store,
41
+ scheduler, telemetry, and handler machinery:
42
42
 
43
43
  ```ts server/assistant.ts
44
44
  import { createAgentServer } from 'experimental-a2/ai/server'
@@ -63,52 +63,80 @@ model for each generation.
63
63
  Each durable generation runs one AI SDK `streamText()` step. `generation`
64
64
  contains per-step settings such as sampling, token limits, provider options,
65
65
  and tool approval policy. A2 owns the stop condition, local tool execution,
66
- and continuation between steps. `maxSteps` limits one complete assistant
67
- response and defaults to 20. Individual providers decide which model settings
68
- they support. `generation` therefore excludes `stopWhen`, tool execution
69
- callbacks, tool callers, tool context, sandbox execution, and the tool approval
70
- secret. Model-step timeouts are supported; tool-execution timeouts are not.
66
+ and continuation between steps. Responses have no step limit by default. Set
67
+ `maxSteps` to a positive integer to limit one complete assistant response.
68
+ Individual providers decide which model settings they support. `generation`
69
+ therefore excludes `stopWhen`, tool execution callbacks, tool callers, sandbox
70
+ execution, and the tool approval secret. Everything else, including
71
+ `toolsContext`, passes through to the AI SDK untouched. A local tool reads its
72
+ durable execution context later, from the handler that runs it. Model-step
73
+ timeouts are supported; tool-execution timeouts are not.
74
+
75
+ ### Store assistant metadata
76
+
77
+ Extend the agent's `UIMessage` type to keep generation status and usage on the
78
+ assistant message:
79
+
80
+ ```ts metadata-assistant.ts
81
+ import type { LanguageModelUsage, UIMessage } from 'ai'
82
+ import { agent } from 'experimental-a2/ai'
71
83
 
72
- `experimental-a2/ai/server` is server-only. The isomorphic `experimental-a2/ai` entry point contains the
73
- contract, reducer, schemas, and pure inputs; it never imports a model provider
74
- or backend.
84
+ export type MetadataAssistantMessage = UIMessage<{
85
+ status: 'writing' | 'complete'
86
+ usage?: LanguageModelUsage
87
+ }>
75
88
 
76
- ## Expose the event stream
89
+ export const metadataAssistant = agent<MetadataAssistantMessage>({
90
+ name: 'metadata-assistant',
91
+ })
92
+ ```
77
93
 
78
- One HTTP route gives the browser a read and write path. `GET` streams events;
79
- `POST` accepts optimistic pushes:
94
+ Map the AI SDK's standard stream parts to that metadata type:
80
95
 
81
- ```ts app/api/agent-events/route.ts
82
- import { A2Error } from 'experimental-a2'
83
- import { errorResponse, parsePushBody, sseResponse } from 'experimental-a2/http'
84
- import { assistantServer } from '@/server/assistant'
96
+ ```ts server/metadata-assistant.ts
97
+ import { createAgentServer } from 'experimental-a2/ai/server'
98
+ import { metadataAssistant } from '../metadata-assistant'
99
+
100
+ export const metadataAssistantServer = createAgentServer({
101
+ agent: metadataAssistant,
102
+ model: 'openai/gpt-5.6-terra',
103
+ messageMetadata: ({ part }) => {
104
+ if (part.type === 'start') return { status: 'writing' as const }
105
+ if (part.type === 'finish') {
106
+ return { status: 'complete' as const, usage: part.totalUsage }
107
+ }
108
+ return undefined
109
+ },
110
+ })
111
+ ```
85
112
 
86
- export async function GET(req: Request): Promise<Response> {
87
- const { searchParams } = new URL(req.url)
88
- const sessionId = searchParams.get('sessionId')
89
- const startAt = Number(searchParams.get('index')) || 0
113
+ The default generator passes `messageMetadata` to the AI SDK's
114
+ `toUIMessageStream()`. The callback receives the generation context and its
115
+ current `TextStreamPart`. Its return type follows the metadata type in the
116
+ agent's `UIMessage`. Metadata on standard `start` and `finish` chunks enters
117
+ the event log with the rest of the stream. A2 shallow-merges those values onto
118
+ the projected assistant message, so the example exposes `writing` immediately
119
+ and keeps final token usage after completion. The callback is synchronous.
90
120
 
91
- if (!sessionId) {
92
- return errorResponse(new A2Error('INVALID_PAYLOAD', 'missing sessionId'))
93
- }
121
+ `experimental-a2/ai/server` is server-only. The isomorphic
122
+ `experimental-a2/ai` entry point contains the contract, reducer, schemas,
123
+ `handlerContext()`, and pure inputs; it never imports a model provider or
124
+ backend.
94
125
 
95
- // here's where you'd do auth, or any other checks
126
+ ## Expose the event stream
96
127
 
97
- return sseResponse(assistantServer.session(sessionId).stream({ startAt }))
98
- }
128
+ One HTTP route gives the browser a read and write path. `GET` streams events;
129
+ `POST` accepts optimistic pushes:
99
130
 
100
- export async function POST(req: Request): Promise<Response> {
101
- try {
102
- const { sessionId, events } = await parsePushBody(req)
131
+ ```ts app/api/agent-events/route.ts
132
+ import { handle } from 'experimental-a2/http'
133
+ import { assistantServer } from '@/server/assistant'
103
134
 
135
+ export const { GET, POST } = handle(assistantServer, {
136
+ before({ request, intent }) {
104
137
  // here's where you'd do auth, or any other checks
105
-
106
- const appended = await assistantServer.session(sessionId).append(...events)
107
- return Response.json(appended)
108
- } catch (error) {
109
- return errorResponse(error)
110
- }
111
- }
138
+ },
139
+ })
112
140
  ```
113
141
 
114
142
  The route never calls the model directly. The browser appends user facts such
@@ -153,16 +181,12 @@ export default async function AgentPage({
153
181
  const { sessionId } = await params
154
182
  const session = assistantServer.session(sessionId)
155
183
  const { state, index } = await session.state(assistant.reducer)
156
- const initialEvents = (await session.history()).filter(
157
- (event) => event.index <= index,
158
- )
159
184
 
160
185
  return (
161
186
  <SessionProvider
162
187
  sessionId={sessionId}
163
188
  initialState={state}
164
189
  initialIndex={index}
165
- initialEvents={initialEvents}
166
190
  >
167
191
  <AgentClient />
168
192
  </SessionProvider>
@@ -174,8 +198,8 @@ An empty session folds to the initial `AIState`. Its log is created lazily when
174
198
  the first message is pushed.
175
199
 
176
200
  Start the session from the previous route, but wait for its durable append
177
- before navigating. The destination server render then sees the message and its
178
- events in one complete response instead of racing the `POST`:
201
+ before navigating. The destination server render then sees the message in its
202
+ folded state instead of racing the `POST`:
179
203
 
180
204
  ```tsx app/agent/new-agent-session.tsx
181
205
  'use client'
@@ -194,8 +218,8 @@ export async function openAgent(
194
218
  ```
195
219
 
196
220
  The draft clears immediately while the append is in flight and can be restored
197
- if it fails. Once navigation begins, SSR already has a durable frontier and
198
- event history. When the destination provider mounts, it resolves the same live
221
+ if it fails. Once navigation begins, SSR already has folded state at a durable
222
+ frontier. When the destination provider mounts, it resolves the same live
199
223
  session and reconciles that server render without replacing the object. Idle
200
224
  session objects expire after five minutes by default; configure `gcTime` on
201
225
  `createClient` when a different lifetime fits the app.
@@ -305,9 +329,34 @@ the durable log immediately, but its turn waits until the active assistant
305
329
  response, including every tool step and approval, reaches a terminal event.
306
330
  Queued messages keep log order.
307
331
 
332
+ Pass `{ generate: false }` when a user message should update the conversation
333
+ without starting a model turn:
334
+
335
+ ```ts app/agent/passive-message.ts
336
+ import type { UIMessage } from 'ai'
337
+ import { inputs } from 'experimental-a2/ai'
338
+ import { assistantClient } from './session'
339
+
340
+ export async function recordPassiveMessage(
341
+ sessionId: string,
342
+ message: UIMessage,
343
+ ): Promise<void> {
344
+ await assistantClient
345
+ .session(sessionId)
346
+ .push(...inputs.message(message, { generate: false }))
347
+ }
348
+ ```
349
+
350
+ The passive user message still appears in `AIState.messages`. A later user
351
+ message with the default generation behavior includes passive user messages
352
+ before it in model context. Passive user messages after that trigger remain
353
+ outside its prompt and wait for the next generating message. This ordering
354
+ supports ambient agents without letting new background context leak into an
355
+ already queued turn.
356
+
308
357
  ## What happens after `push()`
309
358
 
310
- One user interaction becomes a durable sequence:
359
+ One generating user interaction becomes a durable sequence:
311
360
 
312
361
  ```text
313
362
  browser ai.message.created optimistic, then durable
@@ -335,10 +384,15 @@ stores every AI SDK chunk once. The reducer and the exported
335
384
 
336
385
  `AIState` exposes `messages`, `status`, `activeGeneration`, `activeRequestId`,
337
386
  `activeResponseMessageId`, `activeProjection`, `responseGenerationIds`,
338
- `pendingApprovals`, `pendingInputs`, `tools`, `compaction`, per-generation
339
- `usage`, and the last generation `error`.
340
- `activeProjection` is the temporary indexed chunk/tool frontier used for exact
341
- interruption and becomes `null` at a terminal event.
387
+ `terminalRequestIds`, `terminalGenerations`, `pendingApprovals`,
388
+ `pendingInputs`, `tools`, `compaction`, per-generation `usage`, and the last
389
+ generation `error`.
390
+ `activeProjection` is the temporary indexed generation frontier used for exact
391
+ interruption. It remains available after a generation step completes while the
392
+ response waits on tool, approval, or input barriers, and after a generation
393
+ fails while it awaits retry or interruption. It clears when the response
394
+ completes, is interrupted or superseded, a retry begins, or a later generation
395
+ replaces it.
342
396
 
343
397
  `activeRequestId` identifies the server-authorized generation request. It
344
398
  prevents a delayed request or recovered attempt from taking ownership from the
@@ -364,8 +418,8 @@ continuation that has not been appended yet.
364
418
 
365
419
  | Input | Events |
366
420
  | --- | --- |
367
- | `inputs.message(message)` | records a message fact; the server schedules user turns |
368
- | `inputs.seed(message)` | records a trusted server message without scheduling a turn |
421
+ | `inputs.message(message, { generate? })` | records a message fact; `false` keeps context without scheduling |
422
+ | `inputs.seed(message)` | records `generate: false`; non-user roles require a trusted append |
369
423
  | `inputs.approval(response)` | records an approval decision fact |
370
424
  | `inputs.input(response)` | records an application input response fact |
371
425
  | `inputs.requestInput(request)` | records a trusted server request for application input |
@@ -373,10 +427,15 @@ continuation that has not been appended yet.
373
427
  | `inputs.interrupt(options)` | interrupts an active response |
374
428
 
375
429
  The builders hide stable event ids, so the same interaction is safe to resend.
430
+ Generation scheduling considers only user-role `ai.message.created` facts.
431
+ Assistant and system message facts remain context regardless of the `generate`
432
+ field, and model output is recorded as generation progress rather than a new
433
+ message fact, so an assistant reply cannot recursively schedule another turn.
376
434
  Browser `push()` accepts user messages, approval and input responses,
377
- interruptions, and explicit retries. `inputs.seed()` and
378
- `inputs.requestInput()` are for trusted server appends. Browser inputs never
379
- append server scheduling, seeded non-user messages, or lifecycle events.
435
+ interruptions, and explicit retries. `inputs.requestInput()` and non-user
436
+ messages built with `inputs.seed()` are for trusted server appends. Browser
437
+ inputs never append server scheduling, seeded non-user messages, or lifecycle
438
+ events.
380
439
  Approval and input request/response payloads carry the active `generationId`;
381
440
  clients copy it from the pending request so a stale interaction cannot satisfy
382
441
  a later model step.
@@ -388,8 +447,9 @@ directly.
388
447
 
389
448
  ## Add tools and approval
390
449
 
391
- Pass ordinary AI SDK tools to the server. Approval policy belongs in
392
- `generation`, beside the other per-step settings:
450
+ Tools are ordinary AI SDK `tool()` definitions. A2 passes them through to
451
+ the AI SDK without interpreting or restricting them. Approval policy
452
+ belongs in `generation`, beside the other per-step settings:
393
453
 
394
454
  ```ts server/with-tools.ts
395
455
  import { tool } from 'ai'
@@ -421,6 +481,90 @@ export const assistantServerWithTools = createAgentServer({
421
481
  })
422
482
  ```
423
483
 
484
+ The AI SDK infers the tool input and output from its schemas and
485
+ `execute`. A2 additionally runs each local `execute` inside an ambient
486
+ scope carrying the current durable handler attempt. Read it with
487
+ `handlerContext(agent)` from `experimental-a2/ai`: it returns the same
488
+ typed context bag an event handler receives, with `event`, `attempt`,
489
+ `session`, and `signal`. Automatic tools see an `ai.tool.called` event.
490
+ Approved tools see the `ai.approval.responded` event that authorized
491
+ them. Narrow `ctx.event.type` if you need fields specific to either
492
+ event. The agent argument carries the types; A2 verifies it against the
493
+ server executing the tool and throws when a tool written for one agent
494
+ runs under another, or when `handlerContext()` is called outside a tool
495
+ execution. The scope survives awaited helpers and async iteration, so
496
+ any code below `execute` can read it.
497
+
498
+ `generation.toolApproval` is evaluated during the model step, before A2 has a
499
+ durable tool handler. Static policies such as the example above are simplest.
500
+ An input-dependent policy may be a function that returns `user-approval` or
501
+ `approved` from the validated tool input. Approval predicates are
502
+ re-evaluated on recovery for the same durable tool call, so they must be
503
+ deterministic over the tool input; an impure predicate can fork one
504
+ durable call's flow. Model-phase options such as `toolsContext` and
505
+ per-tool `contextSchema` are yours and pass through to the AI SDK
506
+ untouched. Any model-phase hook that receives tool definitions sees
507
+ them without local `execute` functions. The durable A2 context is
508
+ ambient only inside `execute`.
509
+
510
+ The agent argument supplies types, not infrastructure. The context's
511
+ `session` belongs to the server executing the tool, so its `schedule()`
512
+ method uses that server's scheduler. For example, this tool schedules a
513
+ reminder as a new user message:
514
+
515
+ ```ts server/set-reminder.ts
516
+ import { tool } from 'ai'
517
+ import { handlerContext, inputs } from 'experimental-a2/ai'
518
+ import { z } from 'zod'
519
+ import { assistant } from '../assistant'
520
+
521
+ export const setReminder = tool({
522
+ description: 'Set a reminder for yourself',
523
+ inputSchema: z.object({
524
+ delaySeconds: z.number().int().min(1).max(6 * 24 * 60 * 60),
525
+ reminder: z.string(),
526
+ }),
527
+ execute: async ({ delaySeconds, reminder }, { toolCallId }) => {
528
+ const ctx = handlerContext(assistant)
529
+ await ctx.session.schedule(
530
+ `reminder:${toolCallId}`,
531
+ { delay: `${delaySeconds}s` },
532
+ ...inputs.message({
533
+ id: `reminder:${ctx.event.id}`,
534
+ role: 'user',
535
+ parts: [{ type: 'text', text: `Reminder: ${reminder}` }],
536
+ }),
537
+ )
538
+
539
+ return { scheduled: true, delaySeconds }
540
+ },
541
+ })
542
+ ```
543
+
544
+ Add `setReminder` to the server's `tools` and configure a
545
+ [scheduler](/guides/production).
546
+ The numeric schema lets the model choose any whole-second delay in the
547
+ provider's supported range instead of selecting from a fixed application enum.
548
+ The example uses A2's six-day Vercel Queues limit. The seventh retention day is
549
+ reserved for delivery and retries. Adjust the maximum to match your scheduler
550
+ and plan.
551
+ The schedule name uses `toolCallId`, and the message id uses the durable
552
+ triggering event id. A retry therefore converges on the same timer and message,
553
+ even if a provider reuses tool-call ids in a later generation. When the timer
554
+ arrives, `inputs.message()` records an ordinary `ai.message.created` fact. The
555
+ queued-turn policy starts a fresh response after the active response completes
556
+ or is interrupted. A failed response must be retried or interrupted first.
557
+ If the scheduler send fails ambiguously or transiently, A2 retries the same
558
+ durable tool handler so that identity and target time stay fixed. Deterministic
559
+ adapter rejections are terminal tool errors. A missing scheduler is also a
560
+ normal tool error, so configure one before exposing the tool.
561
+
562
+ A tool that never reads `handlerContext()` is just an AI SDK tool;
563
+ nothing else changes. To test a context-reading tool directly, wrap the
564
+ call with `runWithHandlerContext(ctx, fn)` from
565
+ `experimental-a2/ai/server`: inside `fn`, `handlerContext()` returns
566
+ `ctx` for any agent.
567
+
424
568
  The AI SDK emits an approval request instead of executing the tool. A2 records
425
569
  it as `ai.approval.requested`, and the reducer adds it to
426
570
  `state.pendingApprovals`. Respond from the same optimistic client path:
@@ -524,16 +668,38 @@ import { useSession } from '../session'
524
668
  export function StopButton() {
525
669
  const { state, push, index } = useSession()
526
670
  const active = state.activeGeneration
671
+ const requested =
672
+ state.activeRequestId && state.activeResponseMessageId
673
+ ? {
674
+ messageId: state.activeResponseMessageId,
675
+ requestId: state.activeRequestId,
676
+ }
677
+ : null
678
+ const waiting =
679
+ state.pendingApprovals[0] ??
680
+ state.pendingInputs[0] ??
681
+ state.tools.find((tool) => tool.status === 'running')
682
+ const target = active
683
+ ? {
684
+ messageId: active.responseMessageId,
685
+ generationId: active.generationId,
686
+ }
687
+ : requested ??
688
+ (waiting
689
+ ? {
690
+ messageId: waiting.messageId,
691
+ generationId: waiting.generationId,
692
+ }
693
+ : null)
527
694
 
528
- if (!active) return null
695
+ if (!target) return null
529
696
 
530
697
  return (
531
698
  <button
532
699
  onClick={() =>
533
700
  void push(
534
701
  ...inputs.interrupt({
535
- messageId: active.responseMessageId,
536
- generationId: active.generationId,
702
+ ...target,
537
703
  reason: 'Stopped by the user',
538
704
  lastSeenIndex: index,
539
705
  }),
@@ -548,16 +714,23 @@ export function StopButton() {
548
714
 
549
715
  The optimistic event updates the UI immediately and reaches A2's cancellation
550
716
  channel. `lastSeenIndex` is the exact confirmed log frontier visible when the
551
- user clicked. The reducer rewinds chunk and tool projections to that frontier,
552
- keeps the partial response the user actually saw, and turns incomplete visible
553
- tools into `output-error`. Progress, completion, or failure from that generation
554
- cannot reactivate it after the interruption.
555
-
556
- `generationId` is optional only between `ai.generation.requested` and
557
- `ai.generation.started`, when no generation id exists yet. In that phase, use
558
- `state.activeResponseMessageId` as `messageId` and omit `generationId`. Once
559
- `activeGeneration` exists, copy both ids from it so a delayed interruption
560
- cannot stop a later generation.
717
+ user clicked. The reducer rewinds the indexed generation projection to that
718
+ frontier, keeps the partial response the user actually saw, and turns
719
+ incomplete visible tools into `output-error`. An accepted interruption
720
+ terminally fences that request or generation. Later generation, tool, approval,
721
+ input, and compaction events remain in raw history but cannot reactivate it or
722
+ alter the projection.
723
+ Completed tool results at or before the visible frontier remain completed.
724
+
725
+ Between `ai.generation.requested` and `ai.generation.started`, copy
726
+ `activeRequestId` as `requestId` and `activeResponseMessageId` as `messageId`.
727
+ Once `activeGeneration` exists, send its `generationId` and
728
+ `responseMessageId` instead. A request-owned interruption remains valid if
729
+ that same request starts before the append commits. Every interruption has
730
+ exactly one of `requestId` or `generationId`, so a delayed action cannot stop
731
+ later work that reuses the response message. After a generation completes,
732
+ copy the owner from a pending approval, pending input, or running tool while
733
+ the response is still waiting.
561
734
 
562
735
  After a failed model call, `inputs.retry({ messageId, responseMessageId,
563
736
  retryId })` records a retry request. The server schedules its fresh attempt.
@@ -630,6 +803,8 @@ The convenience API is ordinary A2 parts:
630
803
  - `createReducer({ contract })` builds the standard `AIState` projection for a
631
804
  compatible contract.
632
805
  - `agent()` combines the built-in protocol, application events, and reducer.
806
+ - `tool()` binds an AI SDK function-tool definition to an agent's typed A2
807
+ handler context.
633
808
  - `createHandlers({ agent, ... })` returns the AI handler table.
634
809
  - `createAgentServer()` combines those handlers with `createServer()`.
635
810
 
@@ -674,7 +849,7 @@ Application events stay fully typed. The standard AI reducer ignores unknown
674
849
  events, so another reducer can project application state without forking the AI
675
850
  protocol. `validateAgentPush` preserves the same browser boundary as
676
851
  `createAgentServer()`: user facts may enter through a parsed push, while model
677
- scheduling, tool lifecycle, and trusted seed messages stay server-authored.
852
+ scheduling, tool lifecycle, and seeded non-user messages stay server-authored.
678
853
 
679
854
  ### Replace generation, not durability
680
855
 
@@ -718,6 +893,9 @@ the abort signal. It returns exactly one model step as a
718
893
  `ReadableStream<UIMessageChunk>`. A tool-aware replacement sends definitions to
719
894
  the model without running local `execute` functions. A2 still owns durable
720
895
  progress, tool execution, approval, continuation, interruption, and failure.
896
+ `messageMetadata` applies only to the default generator. A custom `generate`
897
+ function owns its metadata and can pass a callback to `toUIMessageStream()` or
898
+ emit typed metadata chunks itself.
721
899
 
722
900
  ## Delivery semantics
723
901
 
@@ -735,6 +913,6 @@ id. This is the same at-least-once boundary as every other A2 handler. A fast
735
913
  tool may also start before its model step later fails. The failure prevents a
736
914
  continuation, but it cannot roll back that external effect.
737
915
 
738
- Configure [production recovery](/guides/production) exactly as for any other A2
739
- server. Recovery wakes an interrupted generation after the original serverless
740
- invocation disappears.
916
+ Configure a [production scheduler](/guides/production) exactly as for any other A2
917
+ server. It recovers an incomplete generation attempt after the original
918
+ serverless invocation disappears.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Devtools
3
- description: Mount one read-only handler to inspect sessions, event lifecycles, failures, and snapshots from the durable log.
3
+ description: Mount one authenticated, read-only handler to inspect and export durable sessions from the browser, CLI, or a test.
4
4
  ---
5
5
 
6
6
  ## Mount the dashboard
@@ -16,8 +16,9 @@ import { billingServer, ordersServer } from '@/server'
16
16
  const devtools = createDevtools({
17
17
  servers: [ordersServer, billingServer],
18
18
  authorize: async (_request) => {
19
- // Return true after your auth or any other checks.
20
- return process.env.NODE_ENV !== 'production'
19
+ // your authentication and authorization check:
20
+ // return await canInspectA2(_request)
21
+ return process.env.NODE_ENV === 'development'
21
22
  },
22
23
  })
23
24
 
@@ -36,10 +37,132 @@ then takes over navigation and live updates.
36
37
  The selected contract and session stay in the URL. Refreshing preserves the
37
38
  view, links open the same session, and browser back and forward navigation work.
38
39
 
39
- Omit `authorize` for the same development-only behavior shown above. An
40
- unguarded handler returns 404 in production. In a deployed dashboard, provide
41
- your application's access check. Returning `false` hides the route with a 404;
42
- returning a `Response` supports a redirect or authentication challenge.
40
+ The mounted route is the policy boundary for the complete Devtools surface.
41
+ `authorize` runs before the browser application, JSON inspection endpoints,
42
+ live stream, capture export, and CLI requests. A2 does not maintain a second
43
+ credential store. Use your application's authentication and authorization at
44
+ this route, and give Devtools the same protection as the durable data it reads.
45
+
46
+ Omit `authorize` only for local development. An unguarded handler is available
47
+ when `NODE_ENV` is exactly `development`. It returns 404 when the value is
48
+ anything else, including unset. In a deployed dashboard, provide your
49
+ application's access check. Only a literal `true` grants access. Returning
50
+ `false` hides the route with a 404. Returning a `Response` supports a redirect
51
+ or authentication challenge.
52
+
53
+ ## Inspect from the CLI
54
+
55
+ The `a2 devtools` CLI reads the same mounted route. Set its URL once, then list
56
+ contracts and sessions or inspect one session:
57
+
58
+ ```bash terminal
59
+ export A2_DEVTOOLS_URL='https://example.com/api/a2/devtools'
60
+ export A2_DEVTOOLS_TOKEN="$DEVTOOLS_BEARER_TOKEN"
61
+
62
+ a2 devtools contracts
63
+ a2 devtools sessions --contract orders --limit 50
64
+ a2 devtools show --contract orders --session order-42
65
+ a2 devtools show --contract orders --session order-42 --format json
66
+ ```
67
+
68
+ `A2_DEVTOOLS_TOKEN` becomes an `Authorization: Bearer` header. For a different
69
+ environment variable, pass `--bearer-env DEVTOOLS_TOKEN`. Custom headers use
70
+ `--header-env HEADER=ENV`, which is repeatable. The CLI accepts credential
71
+ values only through environment variables. It rejects literal token and header
72
+ flags, credentials embedded in `--url`, and URLs with query strings. Requests
73
+ do not follow redirects, so an authentication redirect cannot forward those
74
+ headers to another origin.
75
+
76
+ Human-readable output is the default. `--format json` emits one JSON value and
77
+ `--format ndjson` emits one record per line. Session listing also accepts
78
+ `--cursor` for the next page. `--url` overrides `A2_DEVTOOLS_URL`.
79
+
80
+ ## Export and check a capture
81
+
82
+ Export writes one exact `.a2log` capture. `check` verifies a live capture and
83
+ can assert operational conditions. `verify` checks a saved file without a
84
+ server URL or credentials:
85
+
86
+ ```bash terminal
87
+ a2 devtools export \
88
+ --contract orders \
89
+ --session order-42 \
90
+ --output artifacts/order-42.a2log
91
+
92
+ a2 devtools check \
93
+ --contract orders \
94
+ --session order-42 \
95
+ --settled \
96
+ --no-dead-letters \
97
+ --no-caught-failures \
98
+ --max-redispatches 0
99
+
100
+ a2 devtools verify artifacts/order-42.a2log
101
+ ```
102
+
103
+ An export contains a versioned manifest, every durable event in log order,
104
+ snapshot metadata, and a footer with counts and a SHA-256 digest. Event records
105
+ retain payloads, causal edges, lanes, dispatch attempts, failures, claims, and
106
+ completion metadata. Snapshot state is not included. The CLI checks the digest
107
+ and structure before writing the capture. It refuses to replace an existing
108
+ file unless you pass `--force`, and creates capture files with owner-only
109
+ permissions (`0600`).
110
+
111
+ The CLI and browser read large sessions in bounded pages. The first page
112
+ freezes a finite event frontier, so messages appended during the download do
113
+ not extend it. Export includes every event through that frontier. If a store or
114
+ provider returns a gap or ends early, the read fails instead of producing a
115
+ partial capture with a valid footer.
116
+
117
+ The capture is canonical UTF-8 NDJSON. If a durable payload contains a value
118
+ that JSON would coerce or omit, export fails instead of signing a changed
119
+ representation. Keep event payloads JSON-compatible when they need to be
120
+ portable.
121
+
122
+ Capture verification and failed `check` assertions exit with status 2. Usage,
123
+ network, and filesystem errors exit with status 1. The live commands support
124
+ the same human, JSON, and NDJSON output modes, which makes `check` useful in CI.
125
+
126
+ An exact capture is sensitive production data. It can contain user payloads,
127
+ AI prompts and tool results, provider metadata, usage, and failure messages
128
+ when those values exist in the event log. Route authorization controls who can
129
+ download a capture. It does not redact or encrypt the downloaded file. Store,
130
+ share, and expire `.a2log` files under the same policy as the source log.
131
+
132
+ ## Replay reducer state offline
133
+
134
+ `experimental-a2/testing` loads a capture in Node.js, verifies it against the
135
+ current contract, and folds its events through a reducer:
136
+
137
+ ```ts test/replay-order.ts
138
+ import { loadCapture, replayCapture } from 'experimental-a2/testing'
139
+ import { orders } from '@/contracts'
140
+ import { ordersReducer } from '@/reducer'
141
+
142
+ export async function replayOrder(path: string) {
143
+ const loaded = await loadCapture(path, orders)
144
+
145
+ const afterFirstEvent = replayCapture(loaded, ordersReducer, {
146
+ throughIndex: 1,
147
+ })
148
+ const current = replayCapture(loaded, ordersReducer)
149
+
150
+ return { afterFirstEvent, current }
151
+ }
152
+ ```
153
+
154
+ `loadCapture()` verifies the capture digest and identity, validates every event
155
+ type and payload through the current contract schemas, and revives the public
156
+ `createdAt` timestamp as `Date`. The loaded value also keeps `rawEvents` with
157
+ the exact wire payloads and operational metadata.
158
+
159
+ `throughIndex` is inclusive. Pass `0` to return the reducer's initial state at
160
+ index 0. Replay always folds immutable events. Captured snapshots contain
161
+ metadata only and are not used as reducer state.
162
+
163
+ This is pure reducer replay. It does not construct a server, run handlers,
164
+ invoke a model or tool, or repeat an external side effect. Test handler or model
165
+ behavior with a fresh log and explicit test doubles instead.
43
166
 
44
167
  ## Causal forest
45
168
 
@@ -76,7 +199,7 @@ dashboard shows the first dispatch time as unknown instead of moving it to
76
199
 
77
200
  The bar is event lifetime, not handler execution time. A2 stores lifecycle
78
201
  summaries, not one span row for every attempt. Time spent waiting for a lane,
79
- running a handler, holding a claim, or waiting for recovery remains one honest
202
+ running a handler, holding a claim, or waiting for the scheduler remains one honest
80
203
  interval.
81
204
 
82
205
  An event type without a handler is complete at append. Its bar has no waiting
@@ -96,9 +219,11 @@ While work is pending, the handler checks processing metadata quickly. It
96
219
  backs off after the session settles and emits heartbeats for intermediaries.
97
220
  Only sessions someone is viewing have a live inspection loop.
98
221
 
99
- ## Custom logs
222
+ ## Custom stores
100
223
 
101
224
  The memory, SQLite, Postgres, and Redis adapters provide the optional read-only
102
- inspection interface. A custom `A2Log` can implement `inspect.listSessions`
103
- and `inspect.listSnapshots` to appear in the dashboard. Logs without inspection
104
- continue to work normally; their devtools endpoint returns 501.
225
+ inspection interface. A custom `A2Store` can implement `inspect.listSessions`
226
+ and `inspect.listSnapshots` to appear in the dashboard. Add
227
+ `inspect.readEvents` for bounded storage-native event pages. Without it, A2
228
+ reads the complete session for each page and slices it in memory. Stores without
229
+ inspection continue to work normally; their devtools endpoint returns 501.
@@ -49,7 +49,7 @@ projects, issue lists, labels, permissions, search, joins, pagination, and
49
49
  reporting. Use its transactions for constraints that must hold across several
50
50
  rows before a request returns.
51
51
 
52
- The Postgres adapter in `experimental-a2/log-postgres` stores A2's own logs. It does not
52
+ The Postgres adapter in `experimental-a2/store-postgres` stores A2's own event logs. It does not
53
53
  watch your application tables, turn their changes into events, or combine an
54
54
  A2 append with your SQL transaction. The A2 log and your tables may share one
55
55
  Postgres database, but they remain separate data models and transaction