experimental-a2 0.2.0 → 0.4.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 (92) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/dist/ai-B4YhEnfw.d.ts +333 -0
  3. package/dist/ai-server.d.ts +48 -9
  4. package/dist/ai-server.js +121 -49
  5. package/dist/ai.d.ts +2 -303
  6. package/dist/ai.js +231 -86
  7. package/dist/cli-B3VuxoDe.js +597 -0
  8. package/dist/cli-bin.d.ts +1 -0
  9. package/dist/cli-bin.js +5 -0
  10. package/dist/cli.d.ts +19 -0
  11. package/dist/cli.js +2 -0
  12. package/dist/client-BrfDXQ8A.d.ts +155 -0
  13. package/dist/client-Bt4tAKi9.js +798 -0
  14. package/dist/client.d.ts +2 -90
  15. package/dist/client.js +1 -409
  16. package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +10 -2
  17. package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +62 -8
  18. package/dist/devtools-J_jZ2vQf.d.ts +151 -0
  19. package/dist/devtools-kJJaORn-.js +338 -0
  20. package/dist/devtools-server.browser.js +1 -1
  21. package/dist/devtools-server.d.ts +2 -2
  22. package/dist/devtools-server.js +224 -43
  23. package/dist/devtools.d.ts +2 -0
  24. package/dist/devtools.js +2 -0
  25. package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +4 -4
  26. package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +1 -1
  27. package/dist/http.d.ts +71 -13
  28. package/dist/http.js +302 -41
  29. package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +2 -2
  30. package/dist/index.d.ts +5 -5
  31. package/dist/index.js +2 -2
  32. package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +2 -1
  33. package/dist/internal-aEotMzu_.js +209 -0
  34. package/dist/otel.d.ts +1 -1
  35. package/dist/platform-B4TnJtWu.js +32 -0
  36. package/dist/react.d.ts +37 -14
  37. package/dist/react.js +26 -15
  38. package/dist/scheduler-qstash.d.ts +78 -0
  39. package/dist/scheduler-qstash.js +499 -0
  40. package/dist/scheduler-task-BpzhPnRS.js +54 -0
  41. package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +17 -24
  42. package/dist/scheduler-vercel.js +226 -0
  43. package/dist/server-CcNnFnoW.js +1405 -0
  44. package/dist/server-YtPq7hjw.d.ts +260 -0
  45. package/dist/server.d.ts +4 -155
  46. package/dist/server.js +2 -2
  47. package/dist/{log-ldf5g8Cx.d.ts → store-C3sNAaBT.d.ts} +111 -35
  48. package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +1 -1
  49. package/dist/store-codec-DTG0Ftek.js +8 -0
  50. package/dist/store-memory.d.ts +11 -0
  51. package/dist/{log-memory.js → store-memory.js} +127 -24
  52. package/dist/{log-polling-6COoN60V.js → store-polling-DgrrAE3d.js} +7 -6
  53. package/dist/{log-postgres.d.ts → store-postgres.d.ts} +6 -6
  54. package/dist/{log-postgres.js → store-postgres.js} +158 -24
  55. package/dist/{log-redis.js → store-redis-core-DWqx3F47.js} +294 -156
  56. package/dist/store-redis-http.d.ts +21 -0
  57. package/dist/store-redis-http.js +70 -0
  58. package/dist/store-redis.d.ts +37 -0
  59. package/dist/store-redis.js +298 -0
  60. package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +6 -6
  61. package/dist/{log-sqlite.js → store-sqlite.js} +116 -22
  62. package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +1 -1
  63. package/dist/testing.browser.d.ts +1 -0
  64. package/dist/testing.browser.js +4 -0
  65. package/dist/testing.d.ts +31 -0
  66. package/dist/testing.js +101 -0
  67. package/dist/wire-DCUZBUlT.js +222 -0
  68. package/docs/01-quickstart.mdx +4 -5
  69. package/docs/concepts/01-contracts.mdx +21 -17
  70. package/docs/concepts/02-handlers.mdx +7 -7
  71. package/docs/concepts/03-durability.mdx +26 -29
  72. package/docs/concepts/04-state.mdx +18 -21
  73. package/docs/guides/01-timers.mdx +154 -54
  74. package/docs/guides/02-cancellation.mdx +30 -4
  75. package/docs/guides/03-react.mdx +20 -21
  76. package/docs/guides/04-local-first.mdx +1 -1
  77. package/docs/guides/05-production.mdx +321 -60
  78. package/docs/guides/06-ai-agents.mdx +249 -49
  79. package/docs/guides/07-devtools.mdx +137 -12
  80. package/docs/guides/08-application-data.mdx +1 -1
  81. package/docs/guides/09-presence.mdx +284 -0
  82. package/docs/guides/10-transports.mdx +131 -0
  83. package/docs/index.mdx +22 -46
  84. package/docs/reference/01-api.mdx +751 -102
  85. package/docs/reference/02-errors.mdx +10 -5
  86. package/package.json +23 -6
  87. package/dist/internal-D6wNxTck.js +0 -36
  88. package/dist/log-memory.d.ts +0 -11
  89. package/dist/log-redis.d.ts +0 -31
  90. package/dist/recovery-vercel.js +0 -119
  91. package/dist/server-DJgD2YWP.js +0 -877
  92. 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,15 +63,65 @@ 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'
83
+
84
+ export type MetadataAssistantMessage = UIMessage<{
85
+ status: 'writing' | 'complete'
86
+ usage?: LanguageModelUsage
87
+ }>
88
+
89
+ export const metadataAssistant = agent<MetadataAssistantMessage>({
90
+ name: 'metadata-assistant',
91
+ })
92
+ ```
93
+
94
+ Map the AI SDK's standard stream parts to that metadata type:
95
+
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
+ ```
71
112
 
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.
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.
120
+
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.
75
125
 
76
126
  ## Expose the event stream
77
127
 
@@ -86,7 +136,7 @@ import { assistantServer } from '@/server/assistant'
86
136
  export async function GET(req: Request): Promise<Response> {
87
137
  const { searchParams } = new URL(req.url)
88
138
  const sessionId = searchParams.get('sessionId')
89
- const startAt = Number(searchParams.get('index')) || 0
139
+ const startAfter = Number(searchParams.get('index')) || 0
90
140
 
91
141
  if (!sessionId) {
92
142
  return errorResponse(new A2Error('INVALID_PAYLOAD', 'missing sessionId'))
@@ -94,7 +144,7 @@ export async function GET(req: Request): Promise<Response> {
94
144
 
95
145
  // here's where you'd do auth, or any other checks
96
146
 
97
- return sseResponse(assistantServer.session(sessionId).stream({ startAt }))
147
+ return sseResponse(assistantServer.session(sessionId).stream({ startAfter }))
98
148
  }
99
149
 
100
150
  export async function POST(req: Request): Promise<Response> {
@@ -153,16 +203,12 @@ export default async function AgentPage({
153
203
  const { sessionId } = await params
154
204
  const session = assistantServer.session(sessionId)
155
205
  const { state, index } = await session.state(assistant.reducer)
156
- const initialEvents = (await session.history()).filter(
157
- (event) => event.index <= index,
158
- )
159
206
 
160
207
  return (
161
208
  <SessionProvider
162
209
  sessionId={sessionId}
163
210
  initialState={state}
164
211
  initialIndex={index}
165
- initialEvents={initialEvents}
166
212
  >
167
213
  <AgentClient />
168
214
  </SessionProvider>
@@ -174,8 +220,8 @@ An empty session folds to the initial `AIState`. Its log is created lazily when
174
220
  the first message is pushed.
175
221
 
176
222
  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`:
223
+ before navigating. The destination server render then sees the message in its
224
+ folded state instead of racing the `POST`:
179
225
 
180
226
  ```tsx app/agent/new-agent-session.tsx
181
227
  'use client'
@@ -194,8 +240,8 @@ export async function openAgent(
194
240
  ```
195
241
 
196
242
  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
243
+ if it fails. Once navigation begins, SSR already has folded state at a durable
244
+ frontier. When the destination provider mounts, it resolves the same live
199
245
  session and reconciles that server render without replacing the object. Idle
200
246
  session objects expire after five minutes by default; configure `gcTime` on
201
247
  `createClient` when a different lifetime fits the app.
@@ -305,9 +351,34 @@ the durable log immediately, but its turn waits until the active assistant
305
351
  response, including every tool step and approval, reaches a terminal event.
306
352
  Queued messages keep log order.
307
353
 
354
+ Pass `{ generate: false }` when a user message should update the conversation
355
+ without starting a model turn:
356
+
357
+ ```ts app/agent/passive-message.ts
358
+ import type { UIMessage } from 'ai'
359
+ import { inputs } from 'experimental-a2/ai'
360
+ import { assistantClient } from './session'
361
+
362
+ export async function recordPassiveMessage(
363
+ sessionId: string,
364
+ message: UIMessage,
365
+ ): Promise<void> {
366
+ await assistantClient
367
+ .session(sessionId)
368
+ .push(...inputs.message(message, { generate: false }))
369
+ }
370
+ ```
371
+
372
+ The passive user message still appears in `AIState.messages`. A later user
373
+ message with the default generation behavior includes passive user messages
374
+ before it in model context. Passive user messages after that trigger remain
375
+ outside its prompt and wait for the next generating message. This ordering
376
+ supports ambient agents without letting new background context leak into an
377
+ already queued turn.
378
+
308
379
  ## What happens after `push()`
309
380
 
310
- One user interaction becomes a durable sequence:
381
+ One generating user interaction becomes a durable sequence:
311
382
 
312
383
  ```text
313
384
  browser ai.message.created optimistic, then durable
@@ -335,10 +406,15 @@ stores every AI SDK chunk once. The reducer and the exported
335
406
 
336
407
  `AIState` exposes `messages`, `status`, `activeGeneration`, `activeRequestId`,
337
408
  `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.
409
+ `terminalRequestIds`, `terminalGenerations`, `pendingApprovals`,
410
+ `pendingInputs`, `tools`, `compaction`, per-generation `usage`, and the last
411
+ generation `error`.
412
+ `activeProjection` is the temporary indexed generation frontier used for exact
413
+ interruption. It remains available after a generation step completes while the
414
+ response waits on tool, approval, or input barriers, and after a generation
415
+ fails while it awaits retry or interruption. It clears when the response
416
+ completes, is interrupted or superseded, a retry begins, or a later generation
417
+ replaces it.
342
418
 
343
419
  `activeRequestId` identifies the server-authorized generation request. It
344
420
  prevents a delayed request or recovered attempt from taking ownership from the
@@ -364,8 +440,8 @@ continuation that has not been appended yet.
364
440
 
365
441
  | Input | Events |
366
442
  | --- | --- |
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 |
443
+ | `inputs.message(message, { generate? })` | records a message fact; `false` keeps context without scheduling |
444
+ | `inputs.seed(message)` | records `generate: false`; non-user roles require a trusted append |
369
445
  | `inputs.approval(response)` | records an approval decision fact |
370
446
  | `inputs.input(response)` | records an application input response fact |
371
447
  | `inputs.requestInput(request)` | records a trusted server request for application input |
@@ -373,10 +449,15 @@ continuation that has not been appended yet.
373
449
  | `inputs.interrupt(options)` | interrupts an active response |
374
450
 
375
451
  The builders hide stable event ids, so the same interaction is safe to resend.
452
+ Generation scheduling considers only user-role `ai.message.created` facts.
453
+ Assistant and system message facts remain context regardless of the `generate`
454
+ field, and model output is recorded as generation progress rather than a new
455
+ message fact, so an assistant reply cannot recursively schedule another turn.
376
456
  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.
457
+ interruptions, and explicit retries. `inputs.requestInput()` and non-user
458
+ messages built with `inputs.seed()` are for trusted server appends. Browser
459
+ inputs never append server scheduling, seeded non-user messages, or lifecycle
460
+ events.
380
461
  Approval and input request/response payloads carry the active `generationId`;
381
462
  clients copy it from the pending request so a stale interaction cannot satisfy
382
463
  a later model step.
@@ -388,8 +469,9 @@ directly.
388
469
 
389
470
  ## Add tools and approval
390
471
 
391
- Pass ordinary AI SDK tools to the server. Approval policy belongs in
392
- `generation`, beside the other per-step settings:
472
+ Tools are ordinary AI SDK `tool()` definitions. A2 passes them through to
473
+ the AI SDK without interpreting or restricting them. Approval policy
474
+ belongs in `generation`, beside the other per-step settings:
393
475
 
394
476
  ```ts server/with-tools.ts
395
477
  import { tool } from 'ai'
@@ -421,6 +503,90 @@ export const assistantServerWithTools = createAgentServer({
421
503
  })
422
504
  ```
423
505
 
506
+ The AI SDK infers the tool input and output from its schemas and
507
+ `execute`. A2 additionally runs each local `execute` inside an ambient
508
+ scope carrying the current durable handler attempt. Read it with
509
+ `handlerContext(agent)` from `experimental-a2/ai`: it returns the same
510
+ typed context bag an event handler receives, with `event`, `attempt`,
511
+ `session`, and `signal`. Automatic tools see an `ai.tool.called` event.
512
+ Approved tools see the `ai.approval.responded` event that authorized
513
+ them. Narrow `ctx.event.type` if you need fields specific to either
514
+ event. The agent argument carries the types; A2 verifies it against the
515
+ server executing the tool and throws when a tool written for one agent
516
+ runs under another, or when `handlerContext()` is called outside a tool
517
+ execution. The scope survives awaited helpers and async iteration, so
518
+ any code below `execute` can read it.
519
+
520
+ `generation.toolApproval` is evaluated during the model step, before A2 has a
521
+ durable tool handler. Static policies such as the example above are simplest.
522
+ An input-dependent policy may be a function that returns `user-approval` or
523
+ `approved` from the validated tool input. Approval predicates are
524
+ re-evaluated on recovery for the same durable tool call, so they must be
525
+ deterministic over the tool input; an impure predicate can fork one
526
+ durable call's flow. Model-phase options such as `toolsContext` and
527
+ per-tool `contextSchema` are yours and pass through to the AI SDK
528
+ untouched. Any model-phase hook that receives tool definitions sees
529
+ them without local `execute` functions. The durable A2 context is
530
+ ambient only inside `execute`.
531
+
532
+ The agent argument supplies types, not infrastructure. The context's
533
+ `session` belongs to the server executing the tool, so its `schedule()`
534
+ method uses that server's scheduler. For example, this tool schedules a
535
+ reminder as a new user message:
536
+
537
+ ```ts server/set-reminder.ts
538
+ import { tool } from 'ai'
539
+ import { handlerContext, inputs } from 'experimental-a2/ai'
540
+ import { z } from 'zod'
541
+ import { assistant } from '../assistant'
542
+
543
+ export const setReminder = tool({
544
+ description: 'Set a reminder for yourself',
545
+ inputSchema: z.object({
546
+ delaySeconds: z.number().int().min(1).max(6 * 24 * 60 * 60),
547
+ reminder: z.string(),
548
+ }),
549
+ execute: async ({ delaySeconds, reminder }, { toolCallId }) => {
550
+ const ctx = handlerContext(assistant)
551
+ await ctx.session.schedule(
552
+ `reminder:${toolCallId}`,
553
+ { delay: `${delaySeconds}s` },
554
+ ...inputs.message({
555
+ id: `reminder:${ctx.event.id}`,
556
+ role: 'user',
557
+ parts: [{ type: 'text', text: `Reminder: ${reminder}` }],
558
+ }),
559
+ )
560
+
561
+ return { scheduled: true, delaySeconds }
562
+ },
563
+ })
564
+ ```
565
+
566
+ Add `setReminder` to the server's `tools` and configure a
567
+ [scheduler](/guides/production).
568
+ The numeric schema lets the model choose any whole-second delay in the
569
+ provider's supported range instead of selecting from a fixed application enum.
570
+ The example uses A2's six-day Vercel Queues limit. The seventh retention day is
571
+ reserved for delivery and retries. Adjust the maximum to match your scheduler
572
+ and plan.
573
+ The schedule name uses `toolCallId`, and the message id uses the durable
574
+ triggering event id. A retry therefore converges on the same timer and message,
575
+ even if a provider reuses tool-call ids in a later generation. When the timer
576
+ arrives, `inputs.message()` records an ordinary `ai.message.created` fact. The
577
+ queued-turn policy starts a fresh response after the active response completes
578
+ or is interrupted. A failed response must be retried or interrupted first.
579
+ If the scheduler send fails ambiguously or transiently, A2 retries the same
580
+ durable tool handler so that identity and target time stay fixed. Deterministic
581
+ adapter rejections are terminal tool errors. A missing scheduler is also a
582
+ normal tool error, so configure one before exposing the tool.
583
+
584
+ A tool that never reads `handlerContext()` is just an AI SDK tool;
585
+ nothing else changes. To test a context-reading tool directly, wrap the
586
+ call with `runWithHandlerContext(ctx, fn)` from
587
+ `experimental-a2/ai/server`: inside `fn`, `handlerContext()` returns
588
+ `ctx` for any agent.
589
+
424
590
  The AI SDK emits an approval request instead of executing the tool. A2 records
425
591
  it as `ai.approval.requested`, and the reducer adds it to
426
592
  `state.pendingApprovals`. Respond from the same optimistic client path:
@@ -524,16 +690,38 @@ import { useSession } from '../session'
524
690
  export function StopButton() {
525
691
  const { state, push, index } = useSession()
526
692
  const active = state.activeGeneration
693
+ const requested =
694
+ state.activeRequestId && state.activeResponseMessageId
695
+ ? {
696
+ messageId: state.activeResponseMessageId,
697
+ requestId: state.activeRequestId,
698
+ }
699
+ : null
700
+ const waiting =
701
+ state.pendingApprovals[0] ??
702
+ state.pendingInputs[0] ??
703
+ state.tools.find((tool) => tool.status === 'running')
704
+ const target = active
705
+ ? {
706
+ messageId: active.responseMessageId,
707
+ generationId: active.generationId,
708
+ }
709
+ : requested ??
710
+ (waiting
711
+ ? {
712
+ messageId: waiting.messageId,
713
+ generationId: waiting.generationId,
714
+ }
715
+ : null)
527
716
 
528
- if (!active) return null
717
+ if (!target) return null
529
718
 
530
719
  return (
531
720
  <button
532
721
  onClick={() =>
533
722
  void push(
534
723
  ...inputs.interrupt({
535
- messageId: active.responseMessageId,
536
- generationId: active.generationId,
724
+ ...target,
537
725
  reason: 'Stopped by the user',
538
726
  lastSeenIndex: index,
539
727
  }),
@@ -548,16 +736,23 @@ export function StopButton() {
548
736
 
549
737
  The optimistic event updates the UI immediately and reaches A2's cancellation
550
738
  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.
739
+ user clicked. The reducer rewinds the indexed generation projection to that
740
+ frontier, keeps the partial response the user actually saw, and turns
741
+ incomplete visible tools into `output-error`. An accepted interruption
742
+ terminally fences that request or generation. Later generation, tool, approval,
743
+ input, and compaction events remain in raw history but cannot reactivate it or
744
+ alter the projection.
745
+ Completed tool results at or before the visible frontier remain completed.
746
+
747
+ Between `ai.generation.requested` and `ai.generation.started`, copy
748
+ `activeRequestId` as `requestId` and `activeResponseMessageId` as `messageId`.
749
+ Once `activeGeneration` exists, send its `generationId` and
750
+ `responseMessageId` instead. A request-owned interruption remains valid if
751
+ that same request starts before the append commits. Every interruption has
752
+ exactly one of `requestId` or `generationId`, so a delayed action cannot stop
753
+ later work that reuses the response message. After a generation completes,
754
+ copy the owner from a pending approval, pending input, or running tool while
755
+ the response is still waiting.
561
756
 
562
757
  After a failed model call, `inputs.retry({ messageId, responseMessageId,
563
758
  retryId })` records a retry request. The server schedules its fresh attempt.
@@ -630,6 +825,8 @@ The convenience API is ordinary A2 parts:
630
825
  - `createReducer({ contract })` builds the standard `AIState` projection for a
631
826
  compatible contract.
632
827
  - `agent()` combines the built-in protocol, application events, and reducer.
828
+ - `tool()` binds an AI SDK function-tool definition to an agent's typed A2
829
+ handler context.
633
830
  - `createHandlers({ agent, ... })` returns the AI handler table.
634
831
  - `createAgentServer()` combines those handlers with `createServer()`.
635
832
 
@@ -674,7 +871,7 @@ Application events stay fully typed. The standard AI reducer ignores unknown
674
871
  events, so another reducer can project application state without forking the AI
675
872
  protocol. `validateAgentPush` preserves the same browser boundary as
676
873
  `createAgentServer()`: user facts may enter through a parsed push, while model
677
- scheduling, tool lifecycle, and trusted seed messages stay server-authored.
874
+ scheduling, tool lifecycle, and seeded non-user messages stay server-authored.
678
875
 
679
876
  ### Replace generation, not durability
680
877
 
@@ -718,6 +915,9 @@ the abort signal. It returns exactly one model step as a
718
915
  `ReadableStream<UIMessageChunk>`. A tool-aware replacement sends definitions to
719
916
  the model without running local `execute` functions. A2 still owns durable
720
917
  progress, tool execution, approval, continuation, interruption, and failure.
918
+ `messageMetadata` applies only to the default generator. A custom `generate`
919
+ function owns its metadata and can pass a callback to `toUIMessageStream()` or
920
+ emit typed metadata chunks itself.
721
921
 
722
922
  ## Delivery semantics
723
923
 
@@ -735,6 +935,6 @@ id. This is the same at-least-once boundary as every other A2 handler. A fast
735
935
  tool may also start before its model step later fails. The failure prevents a
736
936
  continuation, but it cannot roll back that external effect.
737
937
 
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.
938
+ Configure a [production scheduler](/guides/production) exactly as for any other A2
939
+ server. It recovers an incomplete generation attempt after the original
940
+ 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