experimental-a2 0.0.0 → 0.2.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 (55) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/ai-server.browser.js +2 -2
  3. package/dist/ai-server.d.ts +19 -7
  4. package/dist/ai-server.js +730 -96
  5. package/dist/ai.d.ts +32 -11
  6. package/dist/ai.js +253 -75
  7. package/dist/client.d.ts +1 -1
  8. package/dist/client.js +4 -4
  9. package/dist/{contract-B0kAXoaL.js → contract-CG_adnu_.js} +2 -1
  10. package/dist/{contract-DL8btVd9.d.ts → contract-C_3dIIEU.d.ts} +4 -1
  11. package/dist/devtools-server.browser.js +2 -2
  12. package/dist/devtools-server.js +1 -1
  13. package/dist/http.d.ts +1 -1
  14. package/dist/http.js +4 -3
  15. package/dist/idempotent-replay-BMyHrP0L.js +19 -0
  16. package/dist/index.d.ts +4 -4
  17. package/dist/index.js +1 -1
  18. package/dist/{internal-Dm8Ejnud.js → internal-D6wNxTck.js} +3 -3
  19. package/dist/{log-Dg1I8NRr.d.ts → log-ldf5g8Cx.d.ts} +74 -56
  20. package/dist/log-memory.d.ts +1 -1
  21. package/dist/log-memory.js +173 -96
  22. package/dist/{log-polling-RO7kclzR.js → log-polling-6COoN60V.js} +1 -1
  23. package/dist/log-postgres.d.ts +1 -1
  24. package/dist/log-postgres.js +235 -192
  25. package/dist/log-redis.d.ts +1 -1
  26. package/dist/log-redis.js +453 -263
  27. package/dist/log-sqlite.d.ts +1 -1
  28. package/dist/log-sqlite.js +216 -127
  29. package/dist/otel.d.ts +1 -1
  30. package/dist/otel.js +1 -1
  31. package/dist/react.d.ts +1 -1
  32. package/dist/react.js +1 -1
  33. package/dist/recovery-vercel.d.ts +2 -2
  34. package/dist/recovery-vercel.js +9 -10
  35. package/dist/server-DJgD2YWP.js +877 -0
  36. package/dist/server.browser.js +4 -4
  37. package/dist/server.d.ts +46 -27
  38. package/dist/server.js +1 -1
  39. package/dist/{telemetry-C78al20p.d.ts → telemetry-Cso0qyHQ.d.ts} +1 -1
  40. package/dist/{wire-2QpU1EtJ.js → wire-BVsgR8o9.js} +1 -1
  41. package/docs/01-quickstart.mdx +7 -7
  42. package/docs/concepts/01-contracts.mdx +22 -22
  43. package/docs/concepts/02-handlers.mdx +223 -89
  44. package/docs/concepts/03-durability.mdx +199 -112
  45. package/docs/concepts/04-state.mdx +27 -1
  46. package/docs/guides/01-timers.mdx +4 -4
  47. package/docs/guides/02-cancellation.mdx +32 -4
  48. package/docs/guides/05-production.mdx +61 -27
  49. package/docs/guides/06-ai-agents.mdx +151 -70
  50. package/docs/guides/07-devtools.mdx +6 -3
  51. package/docs/guides/08-application-data.mdx +5 -6
  52. package/docs/index.mdx +30 -14
  53. package/docs/reference/01-api.mdx +305 -70
  54. package/package.json +31 -31
  55. package/dist/server-DYsnKTTy.js +0 -780
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  title: Going to production
3
- description: Point the log at Postgres, add queue-backed recovery, and know what to do when an event dead-letters.
3
+ description: Point the log at durable storage, add recovery for handler work, and know what to do when an event dead-letters.
4
4
  ---
5
5
 
6
6
  ## Two pieces
7
7
 
8
8
  Development needs zero setup: SQLite appears under `.a2/`, and tests run
9
- in memory. Production needs two deliberate pieces. Neither changes your
10
- handlers.
9
+ in memory. Every production server needs a durable log. Servers with handlers
10
+ also need recovery. Neither choice changes your event contract.
11
11
 
12
12
  ## 1. Choose a log
13
13
 
@@ -30,7 +30,8 @@ export const ordersServer = createServer({
30
30
  ```
31
31
 
32
32
  The Postgres backend uses real transactions; appends serialize per
33
- session on an advisory lock, and the live stream polls the log with an
33
+ session on an advisory lock, while handler claims remain concurrent. The live
34
+ stream polls the log with an
34
35
  activity-adaptive cadence: 25ms while a session is producing events
35
36
  (a token stream reads smoothly, not in clumps), backing off to 250ms
36
37
  when it goes quiet (a LISTEN/NOTIFY upgrade could still land without
@@ -43,7 +44,7 @@ This configures storage for A2's session logs. It does not connect A2 to your
43
44
  application tables or make them part of the append transaction. See
44
45
  [A2 and your database](/guides/application-data) for that boundary.
45
46
 
46
- ## 2. Add recovery
47
+ ## 2. Add recovery for handlers
47
48
 
48
49
  Recovery is what puts a clock on healing. `experimental-a2/recovery-vercel` rides
49
50
  Vercel Queues (`@vercel/queue` is a peer
@@ -94,33 +95,60 @@ export const POST = recovery.handler(ordersServer, billingServer)
94
95
  The trigger makes the route private. Only queue infrastructure can invoke
95
96
  it, so it needs no auth of its own.
96
97
 
97
- One route, one job. Every top-level append starts a delayed, coalesced
98
- "drain this session" arm alongside its inline handler. The handler does not
99
- wait for the queue, while `append` joins the initial arm for up to two seconds
100
- before it returns. An unresponsive queue therefore cannot hold the append open
101
- indefinitely. Every lease renewal arms another watchdog for just after that
102
- lease window. A live holder keeps moving the watchdog forward. A killed holder
103
- stops heartbeating, its lease expires, and the next watchdog retries the
104
- pending event. When Vercel exposes the function deadline, A2 caps the final
105
- lease window there so timeout recovery starts promptly. A failing handler
106
- keeps the current message and redelivers with backoff until the session
107
- settles.
108
-
109
- Due times are rounded to one-second slots. Top-level arms, lease renewals, and
98
+ One route, one job. A top-level append that leaves or finds pending handler
99
+ work starts a delayed, coalesced "drain this session" arm alongside its inline
100
+ handler. The handler does not wait for the queue, while `append` joins the
101
+ initial arm for up to two seconds before it returns. An unresponsive queue
102
+ therefore cannot hold the append open indefinitely. Claim renewals arm another
103
+ watchdog for just after the current window. Live handlers keep moving their
104
+ per-event claims and the watchdog forward. A killed holder stops heartbeating,
105
+ its claims expire, and the next watchdog retries those pending events. When
106
+ Vercel exposes the
107
+ function deadline, A2 caps the final claim window there so timeout recovery
108
+ starts promptly. A failing handler keeps the current message and redelivers
109
+ with backoff. Work outside its lane continues.
110
+
111
+ Due times are rounded to one-second slots. Top-level arms, claim renewals, and
110
112
  racing deliveries targeting the same slot deduplicate into one queue message.
111
113
  Busy deliveries continue the current message's heartbeat-aligned slot series,
112
114
  so they do not create an independent stream of watchdog callbacks.
113
- Events appended by handlers ride their current execution window and add no
114
- recovery operation of their own.
115
+ Events appended or returned by handlers ride their current execution window
116
+ and add no recovery operation of their own.
115
117
 
116
118
  No cron, no sweep, no notification bookkeeping. The queue message is
117
119
  the recovery state, and the log is the only thing it consults.
118
120
 
121
+ ### Dispatch in a fresh invocation
122
+
123
+ Ordinary `append` starts handlers inline and uses recovery as the watchdog.
124
+ Use `append.dispatch` when the current request should only persist the input
125
+ and hand pending work directly to the queue:
126
+
127
+ ```ts app/api/imports/route.ts
128
+ import { ordersServer } from '@/server'
129
+
130
+ export async function POST(request: Request) {
131
+ const { orderId, shopId, items } = await request.json()
132
+ await ordersServer.session(orderId).append.dispatch({
133
+ id: `created:${orderId}`,
134
+ type: 'created',
135
+ payload: { shopId, items },
136
+ })
137
+ return Response.json({ accepted: true }, { status: 202 })
138
+ }
139
+ ```
140
+
141
+ The call awaits the immediate queue send and does not start an inline drain.
142
+ If the send fails, the event may already be durable. Its explicit ID makes the
143
+ retry idempotent. An existing drain for the same session can still claim the
144
+ event first; dispatch controls the wakeup path, not worker affinity.
145
+
119
146
  ## 3. When an event dead-letters
120
147
 
121
- After ten caught handler failures, A2 stops retrying an event and the session
122
- stalls at it; [Durability](/concepts/durability#when-a-handler-keeps-failing)
123
- explains why stalling is the honest choice. Resolution is manual, and has
148
+ After ten caught handler failures, A2 stops retrying an event. It blocks later
149
+ events in the same lane; unlaned events and other lanes continue.
150
+ [Durability](/concepts/durability#when-a-handler-keeps-failing) explains this
151
+ boundary. Resolution is manual, and has
124
152
  exactly two shapes:
125
153
 
126
154
  - **Fix and retry.** Deploy the handler fix, clear the event's failure
@@ -155,14 +183,20 @@ path lands in a single trace: the request → `a2.append` → the inline
155
183
  make: the whole causal chain, visually. Handler failures mark their `a2.event`
156
184
  span with the exception, each dispatch reports `ctx.attempt` as
157
185
  `a2.event.attempt`, and `a2.event.outcome = dead_lettered` is the attribute to
158
- alert on when a session [stalls for manual
186
+ alert on when an event [needs manual
159
187
  resolution](#3-when-an-event-dead-letters). See the
160
188
  [API reference](/reference/api#a2otel) for the span catalogue.
161
189
 
162
190
  ## Running without a queue
163
191
 
164
- Skip `recovery`, and the only wakeups are a top-level append or explicit
165
- `server.drain()`. Reads never wake the session.
192
+ If none of a contract's event types have handlers, skip `recovery`. Those
193
+ events settle in their append transaction, so they create no drains, claims,
194
+ queue messages, or recovery callbacks. The server is a durable event log for
195
+ history, reducers, and live sync. That is a complete production configuration,
196
+ not degraded recovery, because there is no reaction to recover.
197
+
198
+ With handlers, skipping `recovery` means the only wakeups are a top-level
199
+ append or explicit `server.drain()`. Reads never wake the session.
166
200
 
167
201
  That's a real configuration, not a broken one. Fine for internal tools
168
202
  and low-stakes apps where "heals on the next write" is acceptable. But
@@ -174,6 +208,6 @@ For production, configure recovery.
174
208
  | Piece | Done when |
175
209
  | ------------------- | -------------------------------------------------------------------- |
176
210
  | Log | `log: postgres(...)` on every server |
177
- | Recovery | one shared `vercelQueues()`, route mounted, trigger in `vercel.json` |
211
+ | Recovery | for servers with handlers: one shared `vercelQueues()`, route, and trigger |
178
212
  | Idempotent handlers | external side effects take `event.id` as an idempotency key |
179
213
  | Client split | contracts/reducers isomorphic; only `experimental-a2/server` touches backends |
@@ -60,9 +60,14 @@ default. `model` also accepts any AI SDK `LanguageModel`, such as a provider
60
60
  model returned by `openai('gpt-5.6-terra')`, or an async resolver that chooses a
61
61
  model for each generation.
62
62
 
63
- `generation` accepts the remaining AI SDK `ToolLoopAgent` settings: sampling,
64
- token limits, stop conditions, provider options, step callbacks, and tool
65
- approval policy. Individual providers decide which settings they support.
63
+ Each durable generation runs one AI SDK `streamText()` step. `generation`
64
+ contains per-step settings such as sampling, token limits, provider options,
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
71
 
67
72
  `experimental-a2/ai/server` is server-only. The isomorphic `experimental-a2/ai` entry point contains the
68
73
  contract, reducer, schemas, and pure inputs; it never imports a model provider
@@ -106,9 +111,10 @@ export async function POST(req: Request): Promise<Response> {
106
111
  }
107
112
  ```
108
113
 
109
- The route never calls the model directly. Appending
110
- `ai.generation.requested` wakes the built-in handler, which runs the AI SDK on
111
- the server and appends progress back to the same log.
114
+ The route never calls the model directly. The browser appends user facts such
115
+ as `ai.message.created`. Built-in server handlers schedule
116
+ `ai.generation.requested`, run the AI SDK, and append progress back to the same
117
+ log.
112
118
 
113
119
  ## Bind the reducer to React
114
120
 
@@ -182,13 +188,7 @@ export async function openAgent(
182
188
  message: UIMessage,
183
189
  router: { push(href: string): void },
184
190
  ): Promise<void> {
185
- await assistantClient.session(sessionId).push(
186
- {
187
- type: 'ai.session.created',
188
- payload: { metadata: {} },
189
- },
190
- ...inputs.message(message),
191
- )
191
+ await assistantClient.session(sessionId).push(...inputs.message(message))
192
192
  router.push(`/agent/${sessionId}`)
193
193
  }
194
194
  ```
@@ -216,7 +216,7 @@ export function AgentClient() {
216
216
  const [draft, setDraft] = useState('')
217
217
  const [error, setError] = useState<string | null>(null)
218
218
 
219
- const canSend = state.status === 'idle' || state.status === 'failed'
219
+ const canSend = state.status !== 'closed'
220
220
  const lastMessage = state.messages.at(-1)
221
221
  const lastText =
222
222
  lastMessage?.parts
@@ -289,16 +289,21 @@ export function AgentClient() {
289
289
  }
290
290
  ```
291
291
 
292
- There is no separate message state to reconcile. `push()` folds
293
- `ai.message.created` and `ai.generation.requested` locally before starting the
294
- request. The user message and generating state appear immediately. If the
295
- request fails, A2 removes both optimistic events; the component only restores
296
- the draft.
292
+ There is no separate message state to reconcile. `push()` folds the
293
+ `ai.message.created` fact locally before starting the request, so the user
294
+ message appears immediately. If the request fails, A2 removes that optimistic
295
+ event and the component restores the draft. The server owns the corresponding
296
+ generation request.
297
297
 
298
298
  The form gives Enter its normal submit behavior. The thinking row is also
299
- derived from `AIState`: it appears with the Agent label as soon as the
300
- optimistic generation request folds, then the first visible assistant content
301
- replaces it. Empty stream-start messages never create a blank conversation row.
299
+ derived from `AIState`: it appears with the Agent label when the server's
300
+ generation request folds, then the first visible assistant content replaces
301
+ it. Empty stream-start messages never create a blank conversation row.
302
+
303
+ Users can send another message while a response is active. The message enters
304
+ the durable log immediately, but its turn waits until the active assistant
305
+ response, including every tool step and approval, reaches a terminal event.
306
+ Queued messages keep log order.
302
307
 
303
308
  ## What happens after `push()`
304
309
 
@@ -306,26 +311,52 @@ One user interaction becomes a durable sequence:
306
311
 
307
312
  ```text
308
313
  browser ai.message.created optimistic, then durable
309
- browser ai.generation.requested optimistic, then durable
314
+ server ai.generation.requested scheduled from durable facts
310
315
  server ai.generation.started
311
316
  server ai.generation.progress batched UIMessageChunk[]
312
317
  server ai.tool.called when present
313
- server ai.tool.result when present
314
318
  server ai.approval.requested when present
315
- server ai.generation.completed
319
+ server ai.tool.result after concurrent execution or denial
320
+ server ai.generation.completed closes this model step
321
+ server ai.generation.requested after all sibling tools are terminal
322
+ ... another one-step generation
316
323
  server ai.message.completed
324
+ server ai.generation.requested next queued user turn, when present
317
325
  ```
318
326
 
327
+ Tool results and generation completion may arrive in either order. A fast
328
+ automatic tool can finish while the model stream is still open. The join waits
329
+ for both step closure and every required terminal result.
330
+
319
331
  The SSE connection delivers the server events to the same reducer. Progress
320
332
  stores every AI SDK chunk once. The reducer and the exported
321
333
  `deriveUIMessages()` apply those chunks synchronously to produce the current
322
334
  `UIMessage`; cumulative message snapshots are not duplicated in the log.
323
335
 
324
- `AIState` exposes `messages`, `status`, `activeGeneration`, `activeProjection`,
336
+ `AIState` exposes `messages`, `status`, `activeGeneration`, `activeRequestId`,
337
+ `activeResponseMessageId`, `activeProjection`, `responseGenerationIds`,
325
338
  `pendingApprovals`, `pendingInputs`, `tools`, `compaction`, per-generation
326
- `usage`, and the last generation `error`. `activeProjection` is the temporary
327
- indexed chunk/tool frontier used for exact interruption and becomes `null` at
328
- a terminal event.
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.
342
+
343
+ `activeRequestId` identifies the server-authorized generation request. It
344
+ prevents a delayed request or recovered attempt from taking ownership from the
345
+ current response before its generation starts.
346
+
347
+ `activeResponseMessageId` identifies that request's response before
348
+ `activeGeneration` exists. It is `null` after the generation starts.
349
+
350
+ `responseGenerationIds` keeps the latest generation owner for each response
351
+ message. Late lifecycle events from a superseded generation stay in raw
352
+ history but cannot alter the projected message, even after its replacement
353
+ finishes.
354
+
355
+ Generation requests share one durable lane, so model calls do not overlap.
356
+ The queued-turn policy is the stronger ordering rule: a later user message is
357
+ not scheduled until the current response's complete tool loop finishes. Lane
358
+ FIFO alone would not prevent a newly appended user turn from overtaking a tool
359
+ continuation that has not been appended yet.
329
360
 
330
361
  ## The built-in inputs
331
362
 
@@ -333,27 +364,32 @@ a terminal event.
333
364
 
334
365
  | Input | Events |
335
366
  | --- | --- |
336
- | `inputs.message(message)` | records a message and requests generation when its role is `user` |
337
- | `inputs.seed(message)` | records a message without requesting generation |
338
- | `inputs.approval(response)` | records an approval decision and requests continuation |
339
- | `inputs.input(response)` | records application input and requests continuation |
340
- | `inputs.requestInput(request)` | records an application-defined input request |
341
- | `inputs.retry(options)` | requests a fresh attempt after a failed partial response |
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 |
369
+ | `inputs.approval(response)` | records an approval decision fact |
370
+ | `inputs.input(response)` | records an application input response fact |
371
+ | `inputs.requestInput(request)` | records a trusted server request for application input |
372
+ | `inputs.retry(options)` | records `ai.retry.requested` for a failed response |
342
373
  | `inputs.interrupt(options)` | interrupts an active response |
343
374
 
344
375
  The builders hide stable event ids, so the same interaction is safe to resend.
345
- They return plain values accepted by both browser `push()` and server
346
- `append()`.
376
+ 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.
380
+ Approval and input request/response payloads carry the active `generationId`;
381
+ clients copy it from the pending request so a stale interaction cannot satisfy
382
+ a later model step.
347
383
 
348
384
  `inputs` deliberately has no session lifecycle methods. The A2 log begins with
349
385
  the first append. Apps that use explicit `ai.session.created` or
350
- `ai.session.closed` lifecycle events push those ordinary contract events
386
+ `ai.session.closed` lifecycle events append those trusted server facts
351
387
  directly.
352
388
 
353
389
  ## Add tools and approval
354
390
 
355
391
  Pass ordinary AI SDK tools to the server. Approval policy belongs in
356
- `generation`, beside the other `ToolLoopAgent` settings:
392
+ `generation`, beside the other per-step settings:
357
393
 
358
394
  ```ts server/with-tools.ts
359
395
  import { tool } from 'ai'
@@ -405,6 +441,7 @@ export function ApprovalControls() {
405
441
  void push(
406
442
  ...inputs.approval({
407
443
  messageId: approval.messageId,
444
+ generationId: approval.generationId,
408
445
  approvalId: approval.approvalId,
409
446
  approved: true,
410
447
  }),
@@ -418,6 +455,7 @@ export function ApprovalControls() {
418
455
  void push(
419
456
  ...inputs.approval({
420
457
  messageId: approval.messageId,
458
+ generationId: approval.generationId,
421
459
  approvalId: approval.approvalId,
422
460
  approved: false,
423
461
  reason: 'Denied by the user',
@@ -432,9 +470,10 @@ export function ApprovalControls() {
432
470
  }
433
471
  ```
434
472
 
435
- `inputs.approval()` updates the approval part in the projected `UIMessage` and
436
- requests another model turn. The server then executes the approved tool or
437
- returns the denial to the model.
473
+ `inputs.approval()` records only the decision and updates the approval part in
474
+ the projected `UIMessage`. A built-in handler executes the approved tool or
475
+ records a denied result. It never bypasses the same sibling join used by
476
+ automatic tools.
438
477
 
439
478
  Tool activity follows the full AI SDK chunk lifecycle. Input validation errors,
440
479
  execution errors, denials, and preliminary and final outputs become durable
@@ -443,11 +482,36 @@ output completes it. Each result has its own event id, so a preliminary result
443
482
  cannot deduplicate the final one. Dynamic-tool, provider-executed, provider
444
483
  metadata, and tool metadata fields are preserved in the projection.
445
484
 
485
+ Independent tool handlers run concurrently as soon as their durable inputs and
486
+ authorization are ready. There is no fixed tool concurrency limit. A private
487
+ coordinator reducer tracks generation closure, cancellation, calls, approvals,
488
+ and terminal results for the active response. Its retained state is bounded;
489
+ completed responses do not accumulate in the coordinator. Each join reads a
490
+ durable reducer snapshot plus the log tail through `ctx.session.state()`. The snapshot
491
+ is only a cache. Recovery can rebuild the same coordinator state from the event
492
+ log after process death.
493
+
494
+ A continuation needs the generation to close and every sibling tool to have a
495
+ terminal success, error, or denied result. Racing join handlers call
496
+ `ctx.session.append()` with the same deterministic continuation ID, so append
497
+ idempotency resolves the race. A handler's single-owner terminal result is
498
+ returned and committed atomically with that handler's completion. Preliminary
499
+ streaming tool outputs use immediate appends. An approved provider-executed
500
+ call can satisfy the join through its response. A provider tool that declares
501
+ deferred-result support can wait for its provider result instead of a local
502
+ executor. The authenticated provider callback appends that `ai.tool.result`
503
+ through the trusted server session API. Browser `push()` rejects lifecycle
504
+ events, including provider results.
505
+
446
506
  `inputs.input()` provides the same durable request/response shape for
447
507
  application-defined input. Its value stays in raw history and
448
508
  `state.pendingInputs`; resolve instructions dynamically or replace `generate`
449
509
  when the value should become model context.
450
510
 
511
+ Approval and application-input request/response facts carry the active
512
+ `generationId`. Echo that value from the pending request so a delayed UI action
513
+ cannot authorize or resume a later model step.
514
+
451
515
  ## Interrupt and retry
452
516
 
453
517
  Interrupt the active response from the client:
@@ -489,10 +553,18 @@ keeps the partial response the user actually saw, and turns incomplete visible
489
553
  tools into `output-error`. Progress, completion, or failure from that generation
490
554
  cannot reactivate it after the interruption.
491
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.
561
+
492
562
  After a failed model call, `inputs.retry({ messageId, responseMessageId,
493
- retryId })` starts a fresh attempt. `retryId` identifies the user's action, so
494
- resending one retry is safe while a later retry remains distinct. Failed
495
- partial progress remains in raw history but is excluded from the new prompt.
563
+ retryId })` records a retry request. The server schedules its fresh attempt.
564
+ `retryId` identifies the user's action, so resending one retry is safe while a
565
+ later retry remains distinct. Failed partial progress remains in raw history
566
+ but is excluded from the new prompt. Later user messages remain queued while
567
+ the failed response awaits an explicit retry or interruption.
496
568
 
497
569
  ## Append from the server
498
570
 
@@ -567,7 +639,10 @@ different server assembly:
567
639
  ```ts server/custom.ts
568
640
  import { z } from 'zod'
569
641
  import { agent } from 'experimental-a2/ai'
570
- import { createHandlers } from 'experimental-a2/ai/server'
642
+ import {
643
+ createHandlers,
644
+ validateAgentPush,
645
+ } from 'experimental-a2/ai/server'
571
646
  import { createServer } from 'experimental-a2/server'
572
647
 
573
648
  const supportAgent = agent({
@@ -584,6 +659,7 @@ const aiHandlers = createHandlers({
584
659
 
585
660
  export const customAssistantServer = createServer({
586
661
  contract: supportAgent.contract,
662
+ validatePush: validateAgentPush,
587
663
  handlers: {
588
664
  ...aiHandlers,
589
665
  'ticket.linked': async ({ event }) => {
@@ -596,16 +672,18 @@ export const customAssistantServer = createServer({
596
672
 
597
673
  Application events stay fully typed. The standard AI reducer ignores unknown
598
674
  events, so another reducer can project application state without forking the AI
599
- protocol.
675
+ protocol. `validateAgentPush` preserves the same browser boundary as
676
+ `createAgentServer()`: user facts may enter through a parsed push, while model
677
+ scheduling, tool lifecycle, and trusted seed messages stay server-authored.
600
678
 
601
679
  ### Replace generation, not durability
602
680
 
603
- The default generator uses the AI SDK `ToolLoopAgent`. Replace only that
604
- model-facing step for fixtures, provider routing, or a different AI SDK
605
- assembly:
681
+ The default generator uses AI SDK `streamText()` for one model step. Replace
682
+ only that model-facing step for fixtures, provider routing, or a different AI
683
+ SDK assembly:
606
684
 
607
685
  ```ts server/custom-generation.ts
608
- import { ToolLoopAgent, createAgentUIStream } from 'ai'
686
+ import { convertToModelMessages, streamText } from 'ai'
609
687
  import { createAgentServer } from 'experimental-a2/ai/server'
610
688
  import { assistant } from '../assistant'
611
689
 
@@ -615,26 +693,20 @@ export const customGenerationServer = createAgentServer({
615
693
  generate: async ({
616
694
  messages,
617
695
  model,
618
- tools,
619
696
  instructions,
620
- generation,
621
- generationId,
622
697
  responseMessageId,
623
698
  signal,
624
699
  }) => {
625
- const sdkAgent = new ToolLoopAgent<never, typeof tools>({
626
- ...generation,
627
- id: generationId,
700
+ const result = streamText({
628
701
  model,
629
- tools,
702
+ messages: await convertToModelMessages(messages),
703
+ abortSignal: signal,
630
704
  ...(instructions === undefined ? {} : { instructions }),
631
705
  })
632
706
 
633
- return createAgentUIStream({
634
- agent: sdkAgent,
635
- uiMessages: messages,
707
+ return result.toUIMessageStream({
708
+ originalMessages: messages,
636
709
  generateMessageId: () => responseMessageId,
637
- abortSignal: signal,
638
710
  })
639
711
  },
640
712
  })
@@ -642,17 +714,26 @@ export const customGenerationServer = createAgentServer({
642
714
 
643
715
  `generate` receives compacted messages, resolved model and instructions, tools,
644
716
  generation settings, durable request identity, current state and history, and
645
- the abort signal. It returns one `ReadableStream<UIMessageChunk>`. A2 still
646
- records progress, tool and approval events, completion, interruption, and
647
- failure.
717
+ the abort signal. It returns exactly one model step as a
718
+ `ReadableStream<UIMessageChunk>`. A tool-aware replacement sends definitions to
719
+ the model without running local `execute` functions. A2 still owns durable
720
+ progress, tool execution, approval, continuation, interruption, and failure.
648
721
 
649
722
  ## Delivery semantics
650
723
 
651
- Model calls and tools run inside an at-least-once A2 handler. Durable event ids
652
- make lifecycle appends effectively once, and A2 marks an incomplete attempt
653
- `superseded` before starting its replacement. Model providers and external tool
654
- side effects remain outside the log. Give side-effecting tools their own
655
- idempotency strategy, normally keyed by the AI SDK tool call id.
724
+ Model steps and tools run inside at-least-once A2 handlers. The returned-event
725
+ transaction makes each successful handler completion and its terminal events
726
+ one atomic durable operation. Deterministic ids make scheduling and lifecycle
727
+ appends effectively once, and A2 marks an incomplete model attempt
728
+ `superseded` before starting its replacement.
729
+
730
+ Provider calls and external tool side effects remain outside that transaction.
731
+ A process can die after an external effect succeeds and before its handler
732
+ completion commits, so recovery may run the call again. Give side-effecting
733
+ tools their own idempotency strategy, normally keyed by the AI SDK tool call
734
+ id. This is the same at-least-once boundary as every other A2 handler. A fast
735
+ tool may also start before its model step later fails. The failure prevents a
736
+ continuation, but it cannot roll back that external effect.
656
737
 
657
738
  Configure [production recovery](/guides/production) exactly as for any other A2
658
739
  server. Recovery wakes an interrupted generation after the original serverless
@@ -44,7 +44,7 @@ returning a `Response` supports a redirect or authentication challenge.
44
44
  ## Causal forest
45
45
 
46
46
  Every stored `cause` connects a child to the event index and handler attempt
47
- whose `ctx.append` first persisted it. The dashboard builds that forest
47
+ whose `ctx.session.append` first persisted it. The dashboard builds that forest
48
48
  directly from the session log. It does not need a trace table or another write
49
49
  on the append path.
50
50
 
@@ -75,10 +75,13 @@ dashboard shows the first dispatch time as unknown instead of moving it to
75
75
  `createdAt` or `lastClaimedAt`.
76
76
 
77
77
  The bar is event lifetime, not handler execution time. A2 stores lifecycle
78
- summaries, not one span row for every attempt. Time spent waiting, running a
79
- handler, coordinating a lease, or waiting for recovery remains one honest
78
+ 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
80
80
  interval.
81
81
 
82
+ An event type without a handler is complete at append. Its bar has no waiting
83
+ time or dispatch marker, and its attempt count stays at zero.
84
+
82
85
  Snapshots appear as their reducer name, event frontier, and last update time.
83
86
  The dashboard does not send cached snapshot state to the browser.
84
87
 
@@ -34,9 +34,9 @@ orders / order-43
34
34
  agents / run-abc
35
35
  ```
36
36
 
37
- The session is the boundary A2 orders, recovers, folds, and streams. Put events
38
- in the same session when they must be ordered or viewed together. Split work
39
- that should progress independently.
37
+ The session is the boundary A2 logs, recovers, folds, and streams. Put events
38
+ in the same session when they form one history or view. Handler execution is
39
+ concurrent unless events share a lane.
40
40
 
41
41
  A contract is not a table. A session is not a table either. One contract
42
42
  usually serves many sessions, and one session may describe changes that touch
@@ -77,8 +77,7 @@ a cost: boards and search span many sessions and need another query model. If
77
77
  those collection views are the center of the product, let the database lead.
78
78
 
79
79
  Do not put an entire workspace in one session just to make cross-issue queries
80
- possible. Every event in one session is ordered together, and every client of
81
- that session follows the same growing log.
80
+ possible. Every event joins the same growing log and every client follows it.
82
81
 
83
82
  ## Database writes from handlers
84
83
 
@@ -99,7 +98,7 @@ integration:
99
98
 
100
99
  - it is eventually consistent with the session logs;
101
100
  - writes must be idempotent;
102
- - A2 orders events within one session, not across sessions;
101
+ - A2 assigns log order within one session, not across sessions;
103
102
  - A2 does not expose one API that scans and replays every session to rebuild
104
103
  the index.
105
104