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
package/docs/index.mdx CHANGED
@@ -10,7 +10,7 @@ pnpm add experimental-a2
10
10
  A2 is durable sync and reactions for things with a lifecycle.
11
11
 
12
12
  An order, agent run, approval, or import gets a session. You append events.
13
- Handlers react to them, and usually append the next one. The browser follows
13
+ Handlers react to them, and usually return the next one. The browser follows
14
14
  the same log and folds the same state as the server. That's the whole model;
15
15
  the rest of this page is it happening.
16
16
 
@@ -41,7 +41,7 @@ export const orders = a2.contract({
41
41
 
42
42
  Handlers are plain async functions: no determinism rules, no replay, no
43
43
  wrappers around side effects. Each one reacts to a fact and usually
44
- appends the next one.
44
+ returns the next one.
45
45
 
46
46
  ```ts server/orders.ts
47
47
  import { createServer } from 'experimental-a2/server'
@@ -50,10 +50,10 @@ import { orders } from '@/contracts'
50
50
  export const ordersServer = createServer({
51
51
  contract: orders,
52
52
  handlers: {
53
- created: async ({ event, append }) => {
53
+ created: async ({ event }) => {
54
54
  // your side effect; event.id makes a stable idempotency key:
55
55
  // await notifyShop(event.payload, { idempotencyKey: event.id })
56
- await append({ type: 'shop.notified', payload: {} })
56
+ return { type: 'shop.notified', payload: {} }
57
57
  },
58
58
  },
59
59
  })
@@ -61,8 +61,8 @@ export const ordersServer = createServer({
61
61
 
62
62
  ## Open a session
63
63
 
64
- A session is one instance of the contract, with its own log and its own
65
- ordering. It is the thing A2 keeps durable and live. The id is yours:
64
+ A session is one instance of the contract, with its own ordered log. It is the
65
+ thing A2 keeps durable and live. The id is yours:
66
66
 
67
67
  - `session('order-8194')`: an order moving through fulfillment
68
68
  - `session('chat-a8c1')`: an agent chat streaming its reply
@@ -70,7 +70,8 @@ ordering. It is the thing A2 keeps durable and live. The id is yours:
70
70
  - `session('doc-roadmap')`: a document with three people typing in it
71
71
 
72
72
  Anything whose story spans more than one request. Put events in the same
73
- session when they must be ordered or viewed together. Any server code can open
73
+ session when they form one history or view. Handlers are concurrent unless
74
+ they share a lane. Any server code can open
74
75
  one and append: a route handler, a webhook, a cron job.
75
76
 
76
77
  ```ts app/api/orders/route.ts
@@ -165,11 +166,13 @@ twenty lines; [Live UI](/guides/react) wires it end to end.
165
166
 
166
167
  ## The properties
167
168
 
168
- - **Handlers are plain async functions.** If it runs on your laptop, it
169
- runs in production.
169
+ - **Handlers are plain async functions.** They run concurrently by default.
170
+ Use a lane for selective FIFO execution.
171
+ - **Handlers are optional per event type.** A log-only event settles during
172
+ append and creates no reaction or recovery work.
170
173
  - **History is real.** [`history()`](/concepts/state) returns what
171
174
  actually happened, in order. Debugging is reading, not reconstructing.
172
- - **Nothing inside a session is sacred except its log.** Snapshots, leases, queue
175
+ - **Nothing inside a session is sacred except its log.** Snapshots, claims, queue
173
176
  messages: all disposable caches. Delete them and A2 rebuilds
174
177
  everything from the log.
175
178
 
@@ -201,6 +204,18 @@ framework. A contract, a log, handlers, reducers.
201
204
 
202
205
  </details>
203
206
 
207
+ <details>
208
+ <summary>Can I use A2 without handlers?</summary>
209
+
210
+ Yes. Omit `handlers` and use `append`, `history`, reducers, and live streams as
211
+ a durable event log. Events settle in their append transaction, with no drain,
212
+ claim, or queue message. A contract can also mix handled and log-only event
213
+ types. A2 decides per event type.
214
+
215
+ See [Events without handlers](/concepts/handlers#events-without-handlers).
216
+
217
+ </details>
218
+
204
219
  <details>
205
220
  <summary>How do I wait five days?</summary>
206
221
 
@@ -223,8 +238,8 @@ derived view exactly. See [Cancellation](/guides/cancellation).
223
238
  <details>
224
239
  <summary>What happens when a handler keeps failing?</summary>
225
240
 
226
- It retries with backoff, then dead-letters after ten caught failures, and the
227
- session stalls at that event, visibly, instead of silently skipping it.
241
+ It retries with backoff, then dead-letters after ten caught failures. Later
242
+ events in the same lane wait; unlaned events and other lanes continue.
228
243
  [Durability](/concepts/durability) has the exact guarantees and the
229
244
  recovery story behind them.
230
245
 
@@ -233,8 +248,9 @@ recovery story behind them.
233
248
  <details>
234
249
  <summary>What does production need?</summary>
235
250
 
236
- Two decisions: a log backend (Postgres or Redis) and queue-backed
237
- recovery. Development needs neither. SQLite appears under `.a2/` and
251
+ A durable log backend, such as Postgres or Redis. Add queue-backed recovery
252
+ when the contract has handlers. A log-only contract needs no reaction
253
+ infrastructure. Development needs neither: SQLite appears under `.a2/` and
238
254
  state survives restarts. See [Going to production](/guides/production).
239
255
 
240
256
  </details>
@@ -25,6 +25,12 @@ ArkType, anything that implements it. Validators must be synchronous
25
25
  (async ones are rejected here, at definition time), and the validated
26
26
  output is what's stored.
27
27
 
28
+ ### `contract.batch(...events)`
29
+
30
+ Preserves the literal types in a heterogeneous handler-returned event array.
31
+ It returns the same events as a readonly tuple; A2 validates them when the
32
+ handler completes.
33
+
28
34
  ### `contract.reducer(options)`
29
35
 
30
36
  ```ts
@@ -60,12 +66,21 @@ createServer(options: {
60
66
  log?: A2Log // default: sqlite in dev, memory in tests, required in prod
61
67
  recovery?: A2Recovery
62
68
  telemetry?: A2Telemetry // optional instrumentation; see experimental-a2/otel
69
+ validatePush?: (context: PushValidationContext) => void | PromiseLike<void>
63
70
  handlers?: {
64
71
  [type]:
65
- | (ctx: Context) => Promise<void>
66
- | { abortOn: AbortSpec; handler: (ctx: Context) => Promise<void> }
72
+ | Handler
73
+ | {
74
+ abortOn?: AbortSpec
75
+ lane?: string | ((ctx: LaneContext) => string)
76
+ handler: Handler
77
+ }
67
78
  }
68
79
  }): A2Server
80
+
81
+ type Handler = (
82
+ ctx: Context,
83
+ ) => Promise<void | AppendInput | readonly AppendInput[]>
69
84
  ```
70
85
 
71
86
  Implements a contract: binds the vocabulary to storage and reactions.
@@ -74,43 +89,118 @@ be silently missing because the module that registered it wasn't
74
89
  imported. Compose across files by spreading objects into `handlers`
75
90
  (note: a duplicate key under spread silently last-wins).
76
91
 
92
+ Handlers are concurrent by default. A2 starts every eligible event after its
93
+ append commits. Add `lane` when events share a resource and must not overlap.
94
+ Within one session, events with the same lane value run one at a time in log
95
+ order. Different lanes and events without a lane run concurrently. A lane
96
+ resolver receives `{ sessionId, event }`; A2 resolves and stores the value when
97
+ the event is appended, so a later deployment cannot reinterpret pending work.
98
+ For example, `lane: ({ event }) => event.payload.warehouseId` serializes work
99
+ per warehouse while different warehouses continue concurrently.
100
+
101
+ Handlers can return one event or an array. A2 marks the triggering event
102
+ processed and appends the returned batch in one atomic log operation. Returned
103
+ events do not exist when the handler throws. `ctx.session.append(name, ...events)`
104
+ is different: it commits immediately, so its events may run while the current
105
+ handler is still active unless a lane orders them.
106
+
107
+ Handlers are optional per event type. An event type without one settles in
108
+ the append transaction with no dispatch attempt. If the session has no older
109
+ pending handler work, A2 starts no drain or recovery arm. A server with no
110
+ handlers is a durable event log with no reaction infrastructure. See
111
+ [Events without handlers](/concepts/handlers#events-without-handlers).
112
+
77
113
  Server-only by construction: `experimental-a2/server` is the only entry point that
78
114
  can reach a log backend, and its exports map resolves to a loud error
79
115
  under the browser condition.
80
116
 
117
+ `validatePush({ sessionId, events })` runs only when `events` came from
118
+ `parsePushBody()`. It runs before contract schema validation and before the log
119
+ append, so throwing rejects the complete push without writing anything. Direct
120
+ trusted server appends and handler appends bypass it. `parsePushBody()` creates
121
+ the runtime provenance brand after reading the envelope; a caller-supplied
122
+ field with the same name is ignored, and the brand is not stored in the log.
123
+
81
124
  `abortOn` names the events that fire `ctx.signal` while a handler runs.
82
125
  an array matches by type; an object takes per-type predicates for
83
126
  targeted cancellation:
84
127
 
85
128
  ```ts
86
129
  generate: {
87
- abortOn: { cancelled: (event, trigger) => event.payload.of === trigger.id },
130
+ abortOn: {
131
+ cancelled: (event, trigger, { attempt }) =>
132
+ event.payload.of === `${trigger.id}:${attempt}`,
133
+ },
88
134
  handler: async (ctx) => { /* ... */ },
89
135
  }
90
136
  ```
91
137
 
138
+ The predicate's third argument contains the triggering event's durable
139
+ `attempt`, so a cancellation can fence a specific recovered run.
140
+
92
141
  The context every handler receives:
93
142
 
94
- | Property | Type |
95
- | ------------------ | ------------------------------------------ |
96
- | `ctx.event` | `Event`: the triggering event |
97
- | `ctx.attempt` | durable 1-based dispatch claim ordinal |
98
- | `ctx.append(...e)` | append to this session, typed, atomic |
99
- | `ctx.history()` | `Promise<Event[]>`: raw log, oldest first |
100
- | `ctx.signal` | `AbortSignal`: active only with `abortOn` |
143
+ | Property | Type |
144
+ | ------------- | --------------------------------------------------------- |
145
+ | `ctx.event` | `Event`: the triggering event |
146
+ | `ctx.attempt` | durable 1-based dispatch claim ordinal |
147
+ | `ctx.session` | this session's `id`, `append`, `history`, `state`, `stream` |
148
+ | `ctx.signal` | `AbortSignal`: active only with `abortOn` |
101
149
 
102
150
  `ctx.attempt` starts at `1` and increments on every durable claim. It may skip
103
151
  when a process dies before handler entry.
104
152
 
153
+ `ctx.session.id` equals `ctx.event.sessionId`. Its `history`, `state`, and
154
+ `stream` methods are the same session operations returned by
155
+ `server.session(id)`. `ctx.session.state(reducer)` reads the cached snapshot
156
+ plus immutable log tail and returns `{ state, index }`. The index is a
157
+ consistent committed frontier captured when the call runs. It includes the
158
+ triggering event and may include events committed later while another handler
159
+ is active.
160
+
161
+ The handler-local append is specialized:
162
+
163
+ ```ts
164
+ ctx.session.append(name: string, ...events: AppendInput[]): Promise<Event[]>
165
+ ```
166
+
167
+ The name is an idempotency key scoped to the triggering event for events whose
168
+ `id` is omitted. A2 derives each omitted id from the trigger id, name, and event
169
+ position. The same name and generated-id batch returns the existing rows
170
+ across retries. Changing that batch conflicts with those rows. An explicit
171
+ event `id` wins over the generated id, which lets different triggering events
172
+ converge on one fact. A root `server.session(id).append(...events)` takes no
173
+ name.
174
+
175
+ A state read and following append are not atomic. Concurrent appends and
176
+ retries may move the frontier between them. A generic join should use a
177
+ monotone readiness predicate and a stable explicit output event `id`, so every
178
+ eligible attempt converges on the same append.
179
+
105
180
  ### `server.session(id)`
106
181
 
107
182
  ```ts
108
183
  server.session(id: string): Session
109
184
  ```
110
185
 
111
- A handle on one instance of the contract. The session is the unit of ordering,
112
- recovery, state, and live sync. Creating the handle does no I/O; nothing loads
113
- until you append, read, or stream.
186
+ A handle on one instance of the contract. The session is the unit of log
187
+ ordering, lane keys, recovery, state, and live sync. Handler execution is
188
+ concurrent unless events share a lane. Creating the handle does no I/O;
189
+ nothing loads until you append, read, or stream.
190
+
191
+ ```ts
192
+ const session = server.session('order-42')
193
+
194
+ session.id
195
+ session.append(...events)
196
+ session.history()
197
+ session.state(reducer)
198
+ session.stream({ startAt })
199
+ ```
200
+
201
+ `session.id` is the id passed to `server.session(id)`. Root `append` takes only
202
+ events. The handler-local form at `ctx.session.append` adds its required name
203
+ before the events.
114
204
 
115
205
  ### `server.drain(sessionId)`
116
206
 
@@ -118,10 +208,11 @@ until you append, read, or stream.
118
208
  server.drain(sessionId: string): Promise<{ settled: boolean }>
119
209
  ```
120
210
 
121
- Processes the session's pending events in order. `settled` means every
122
- event is processed or dead-lettered. You'll rarely call this yourself;
123
- it's the primitive recovery callbacks use. The public result stays this
124
- simple boolean. Recovery retries the first event without a processed marker.
211
+ Claims every currently eligible event and runs their handlers concurrently.
212
+ `settled` means no actionable or live-claimed work remains. A dead-lettered
213
+ event can leave later work in its lane blocked while other lanes continue.
214
+ You'll rarely call this yourself; it is the primitive recovery callbacks use.
215
+ The public result stays this simple boolean.
125
216
 
126
217
  ### `A2Log`
127
218
 
@@ -132,15 +223,31 @@ Custom adapters implement these atomic drain methods:
132
223
  type EventCause = {
133
224
  index: number
134
225
  attempt: number
226
+ batchSize?: number // present on named handler appends
135
227
  }
136
228
 
229
+ type AppendEvent = {
230
+ type: string
231
+ payload: unknown
232
+ id?: string
233
+ cause?: EventCause
234
+ lane?: string
235
+ settled?: true // internal: no handler is registered for this event type
236
+ }
237
+
238
+ type ReturnedEvent = AppendEvent & { id: string }
239
+
137
240
  type StoredEvent = Event & {
138
241
  cause: EventCause | null
242
+ lane: string | null
139
243
  processedAt: Date | null
140
244
  processedByAttempt: number | null
245
+ returnedEventIds: string[] | null
141
246
  firstClaimedAt: Date | null
142
247
  lastClaimedAt: Date | null
143
248
  attemptCount: number
249
+ claimHolder: string | null
250
+ claimExpiresAt: Date | null
144
251
  failureCount: number
145
252
  lastFailedAt: Date | null
146
253
  lastFailedAttempt: number | null
@@ -148,31 +255,48 @@ type StoredEvent = Event & {
148
255
  failedAt: Date | null
149
256
  }
150
257
 
151
- type LogClaimResult =
152
- | { outcome: 'claimed'; event: StoredEvent }
153
- | { outcome: 'busy' }
258
+ type LogAppendResult = {
259
+ events: StoredEvent[]
260
+ hasPending: boolean
261
+ }
262
+
263
+ type LogClaimAvailableResult =
264
+ | { outcome: 'claimed'; events: StoredEvent[] }
265
+ | { outcome: 'busy'; retryAt: Date }
154
266
  | { outcome: 'settled' }
155
267
 
156
- type LogHandoffResult =
157
- | LogClaimResult
268
+ type CompleteAttemptResult =
269
+ | { outcome: 'completed'; events: StoredEvent[] }
158
270
  | { outcome: 'superseded' }
159
271
 
160
272
  interface A2Log {
161
- claimNext(options: {
273
+ append(
274
+ sessionId: string,
275
+ events: AppendEvent[],
276
+ ): Promise<LogAppendResult>
277
+
278
+ claimAvailable(options: {
162
279
  sessionId: string
163
280
  holder: string
164
281
  ttlMs: number
165
282
  expiresAtMs?: number
166
- maxIndex?: number
167
- }): Promise<LogClaimResult>
283
+ excludeIndexes?: readonly number[]
284
+ }): Promise<LogClaimAvailableResult>
168
285
 
169
- completeAndClaimNext(options: {
286
+ renewClaims(options: {
170
287
  sessionId: string
171
288
  holder: string
172
- completedIndex: number
289
+ indexes: number[]
290
+ ttlMs: number
291
+ expiresAtMs?: number
292
+ }): Promise<number[]>
293
+
294
+ completeAttempt(options: {
295
+ sessionId: string
296
+ index: number
173
297
  attempt: number
174
- maxIndex?: number
175
- }): Promise<LogHandoffResult>
298
+ events: ReturnedEvent[]
299
+ }): Promise<CompleteAttemptResult>
176
300
 
177
301
  failAttempt(options: {
178
302
  sessionId: string
@@ -189,17 +313,21 @@ interface A2Log {
189
313
 
190
314
  | Method | Atomic effect |
191
315
  | --- | --- |
192
- | `claimNext` | Select the ordered head, respect optional `maxIndex`, acquire the lease, increment `attemptCount`, and update claim timestamps. |
193
- | `completeAndClaimNext` | Complete the current attempt once; record `processedByAttempt`; claim and timestamp the next only while the same holder owns a live lease. Stale attempts return `superseded`. |
194
- | `failAttempt` | Record a current caught failure, `lastFailedAt`, and `lastFailedAttempt`; dead-letter at `maxFailures`. Stale attempts return `superseded`. |
316
+ | `append` | Write a consecutive batch. Events carrying core's internal `settled` flag receive `processedAt` in the same transaction, with no dispatch attempt. |
317
+ | `claimAvailable` | Claim every eligible event. All unlaned events are independent; only the lowest-index unfinished event in each lane is eligible. A claim records its holder, expiry, timestamps, and next `attemptCount`. |
318
+ | `renewClaims` | Extend the listed live claims still owned by the holder. Expired, completed, failed, and superseded claims are omitted and cannot be revived. |
319
+ | `completeAttempt` | Fence on the current attempt, mark the parent processed, store its exact ordered `returnedEventIds`, and append the returned batch in the same transaction. A same-attempt retry returns the committed children; a stale attempt returns `superseded`. |
320
+ | `failAttempt` | Record one current caught failure, clear its claim, and dead-letter at `maxFailures`. A repeated failure acknowledgment is idempotent; stale attempts return `superseded`. |
195
321
 
196
322
  `attemptCount` counts claims, including abandoned ones. `failureCount` counts
197
323
  caught failures and dead-letters at ten. See
198
324
  [Durability](/concepts/durability#one-event-many-attempts) for the recovery
199
- model. `cause` identifies the event and handler attempt whose `ctx.append`
200
- first persisted the child. A null cause means a root or a legacy event whose
201
- origin is unknown. Migrated rows can have `firstClaimedAt === null` with a
202
- nonzero `attemptCount`; the historical first claim is unknowable. Lifecycle
325
+ model. `cause` identifies the event and handler attempt whose
326
+ `ctx.session.append` or return value first persisted the child. A null cause
327
+ means a top-level event.
328
+ `lane` is the session-scoped serialized group resolved before append.
329
+ `returnedEventIds` makes a lost completion
330
+ acknowledgment recoverable without accepting a partial child batch. Lifecycle
203
331
  timestamps are adapter clock values for their atomic log operations, not exact
204
332
  database commit times.
205
333
  Built-in adapters persist these fields inside their existing atomic operations,
@@ -223,6 +351,36 @@ identical batch returns the original rows. (Events parsed by
223
351
  `parsePushBody` are accepted directly, the push-route path.) See
224
352
  [Durability](/concepts/durability).
225
353
 
354
+ After the write, A2 dispatches only event types with registered handlers.
355
+ Other event types are already settled by the append itself. An unhandled
356
+ append still starts session healing when older handled work is pending. The
357
+ log reports that session-wide pending state as part of the atomic append, so
358
+ this decision needs no follow-up read.
359
+
360
+ ### `session.append.dispatch(...events)`
361
+
362
+ ```ts
363
+ session.append.dispatch(
364
+ ...events: Array<{ type: string; payload: unknown; id?: string }>
365
+ ): Promise<Event[]>
366
+ ```
367
+
368
+ Commits the same atomic batch, but sends pending work directly to configured
369
+ recovery instead of starting an inline drain. The call awaits acceptance of an
370
+ immediate recovery message. It throws before writing if the server has no
371
+ recovery adapter.
372
+
373
+ If the queue send fails, the events are already durable. Give them explicit
374
+ IDs and retry the same dispatch safely. A batch that leaves no pending handler
375
+ work does not send a recovery message. Dispatch chooses how this append wakes
376
+ the session; it does not reserve events for one worker. A drain that is already
377
+ active may still claim newly eligible work first.
378
+
379
+ `dispatch` exists only on a server session's top-level append. Handler-scoped
380
+ `ctx.session.append(name, ...events)` already runs inside an active drain and
381
+ does not expose `dispatch`. Its immediate children and atomically returned
382
+ children become eligible in that drain.
383
+
226
384
  ### `session.history()`
227
385
 
228
386
  ```ts
@@ -375,18 +533,38 @@ Pure typed inputs for `append()` and `push()`:
375
533
 
376
534
  | Input | Events |
377
535
  | --- | --- |
378
- | `inputs.message(message)` | `ai.message.created`, plus `ai.generation.requested` for a user message |
379
- | `inputs.seed(message)` | `ai.message.created` only |
380
- | `inputs.approval(response)` | `ai.approval.responded` + continuation request |
381
- | `inputs.input(response)` | `ai.input.responded` + continuation request |
382
- | `inputs.requestInput(request)` | `ai.input.requested` |
383
- | `inputs.retry(options)` | a fresh `ai.generation.requested` for a failed response |
536
+ | `inputs.message(message)` | `ai.message.created`; the server schedules a user turn |
537
+ | `inputs.seed(message)` | `ai.message.created` for a trusted server append |
538
+ | `inputs.approval(response)` | `ai.approval.responded` only |
539
+ | `inputs.input(response)` | `ai.input.responded` only |
540
+ | `inputs.requestInput(request)` | `ai.input.requested` for a trusted server append |
541
+ | `inputs.retry(options)` | `ai.retry.requested` |
384
542
  | `inputs.interrupt(options)` | `ai.message.interrupted` |
385
543
 
386
- `inputs` deliberately has no session lifecycle methods. Push explicit
387
- `ai.session.created` and `ai.session.closed` events directly when an
388
- application uses them. Input event ids are stable for the interaction they
389
- describe, so a lost append acknowledgment can be resent safely.
544
+ `inputs` deliberately has no session lifecycle methods. Append explicit
545
+ `ai.session.created` and `ai.session.closed` events from trusted server code
546
+ when an application uses them. Input event ids are stable for the interaction
547
+ they describe, so a lost append acknowledgment can be resent safely. Browser
548
+ ingress accepts user messages, approval and input responses, interruptions,
549
+ and explicit retries. Only built-in server handlers append generation requests
550
+ and AI lifecycle events. `inputs.seed()` and `inputs.requestInput()` are for
551
+ trusted server appends.
552
+
553
+ Approval and input request/response payloads require the active
554
+ `generationId`. Clients copy it from the pending request, which prevents a
555
+ delayed response from satisfying a newer model step.
556
+
557
+ `ai.retry.requested` carries `{ messageId, responseMessageId, retryId }`.
558
+ `retryId` identifies one user action. The server converts the fact into an
559
+ `ai.generation.requested` whose reason is `retry`.
560
+
561
+ `ai.generation.failed` sets `stepLimit: true` when `maxSteps` rejects a
562
+ continuation before another model step starts.
563
+
564
+ `ai.message.interrupted` carries `{ messageId, generationId?, reason?,
565
+ lastSeenIndex? }`. Omit `generationId` only while the matching response is in
566
+ the requested phase and `activeGeneration` does not exist yet. Once a
567
+ generation starts, include its id to fence delayed interruption actions.
390
568
 
391
569
  ### `events` and `createEvents(options?)`
392
570
 
@@ -402,9 +580,16 @@ createReducer({ contract, name? }): Reducer<AIState>
402
580
 
403
581
  Builds the standard AI projection for a compatible contract. `AIState`
404
582
  contains session lifecycle, messages, generation status, pending approvals
405
- and input, tool activity, compaction, usage, and the last error.
583
+ and input, tool activity, compaction, usage, the last error, and
584
+ `activeRequestId` and `activeResponseMessageId`, plus
585
+ `responseGenerationIds: Record<string, string>`.
586
+ `activeRequestId` is the server-authorized generation request and fences
587
+ delayed requests before their generation starts. `activeResponseMessageId`
588
+ identifies the requested response until `activeGeneration` exists.
406
589
  `activeProjection` holds the indexed chunk/tool frontier only while a
407
- generation is active; terminal events clear it. Extension events are ignored.
590
+ generation is active; terminal events clear it. `responseGenerationIds` keeps
591
+ the latest generation owner for each response message, so late events from a
592
+ superseded owner cannot alter the projection. Extension events are ignored.
408
593
 
409
594
  ### `deriveUIMessages(history)` and `reduceAIState(state, event)`
410
595
 
@@ -423,6 +608,7 @@ createAgentServer({
423
608
  tools?,
424
609
  instructions?,
425
610
  generation?,
611
+ maxSteps?,
426
612
  generate?,
427
613
  compaction?,
428
614
  progress?,
@@ -430,21 +616,58 @@ createAgentServer({
430
616
  }): A2Server
431
617
  ```
432
618
 
433
- Creates the standard server for an agent. A2 runs an AI SDK `ToolLoopAgent`,
434
- persists each `UIMessageChunk` once in a durable progress batch, projects
435
- messages synchronously, extracts tool and approval lifecycle events, and
436
- records completion, usage, interruption, and failure. `model` accepts an AI
437
- SDK model string or provider model. `model` and `instructions` can be values or per-generation async
438
- resolvers. `generation` accepts the remaining `ToolLoopAgent` settings, such
439
- as `temperature`, `maxOutputTokens`, `topP`, stop conditions, and provider
440
- options. Support for individual settings depends on the selected model and
441
- provider.
619
+ Creates the standard server for an agent. A2 runs one AI SDK `streamText()`
620
+ model step per durable generation, persists each `UIMessageChunk` once in a
621
+ durable progress batch, projects messages synchronously, extracts tool and
622
+ approval lifecycle events, executes local tools, and records completion,
623
+ usage, interruption, and failure. `model` accepts an AI SDK model string or
624
+ provider model. `model` and `instructions` can be values or per-generation
625
+ async resolvers.
626
+
627
+ `generation` contains per-step settings such as `temperature`,
628
+ `maxOutputTokens`, `topP`, provider options, and tool approval policy. A2 owns
629
+ the one-step stop condition, local tool execution, and continuation. `maxSteps`
630
+ limits one complete assistant response and defaults to 20. Support for
631
+ individual model settings depends on the selected model and provider.
632
+ `generation` excludes `stopWhen`, tool execution callbacks, tool callers, tool
633
+ context, sandbox execution, and the tool approval secret. Model-step timeouts
634
+ remain available; tool-execution timeouts do not.
442
635
 
443
636
  `generate(context)` optionally replaces the default AI SDK generation. It
444
637
  receives messages, the resolved model and instructions, tools, generation
445
- settings, request state, and the abort signal. It returns a
446
- `ReadableStream<UIMessageChunk>`. A2 continues to own the durable lifecycle
447
- around that stream.
638
+ settings, request state, and the abort signal. It returns exactly one model
639
+ step as a `ReadableStream<UIMessageChunk>`. A2 continues to own tool execution,
640
+ continuation, and the durable lifecycle around that stream.
641
+
642
+ Generation requests use one durable lane, so model calls for a session do not
643
+ overlap. Queued-turn policy also waits to schedule a later user message until
644
+ the active assistant response, including its complete tool loop, reaches a
645
+ terminal event. The policy matters in addition to lane FIFO because a tool
646
+ continuation may be appended after the later user message. A generation
647
+ failure keeps later messages queued until the failed response is explicitly
648
+ retried or interrupted.
649
+
650
+ Independent authorized tool handlers run concurrently without a fixed
651
+ concurrency limit. A private coordinator reducer tracks generation closure,
652
+ cancellation, calls, approvals, and terminal results for the active response.
653
+ Completed responses do not accumulate in its state. Its `ctx.session.state()` reads the
654
+ durable snapshot plus log tail. Process memory is not authoritative. Concurrent
655
+ join checks return the same deterministic continuation event, so they use
656
+ `ctx.session.append()` and storage deduplicates the race.
657
+
658
+ Tool results and generation completion have no fixed relative order. The
659
+ continuation predicate needs both the closed model step and every required
660
+ terminal result.
661
+
662
+ Single-owner terminal events use returned-event causality. They commit
663
+ atomically with parent completion and receive the parent's durable cause.
664
+ Many-owner joins and preliminary streaming outputs use immediate idempotent
665
+ appends. Provider calls and external tool side effects are at least once and
666
+ require their own idempotency. Approved provider-executed calls may satisfy the
667
+ join from the approval response. Provider tools with declared deferred-result
668
+ support wait for their provider result instead of a local executor. An
669
+ authenticated provider callback appends the terminal `ai.tool.result` through
670
+ the trusted server session API; the browser push allowlist rejects it.
448
671
 
449
672
  `compaction` has `shouldCompact(context)` and `compact(context)` callbacks.
450
673
  When selected, both the request and the replacement messages enter the log.
@@ -456,8 +679,20 @@ This entry point is server-only and resolves to a throwing browser stub.
456
679
 
457
680
  Returns the built-in A2 handler table without constructing a server. Spread
458
681
  it into `createServer({ handlers })` beside application handlers when you need
459
- a custom assembly. Application handlers spread later can deliberately replace
460
- a built-in handler.
682
+ a custom assembly. The table handles input facts, generation requests, model
683
+ step completion, tool calls, approval responses, and terminal tool results.
684
+ Application handlers spread later can deliberately replace a built-in
685
+ handler. Custom assemblies pass `validateAgentPush` as
686
+ `createServer({ validatePush })` to preserve the browser boundary.
687
+
688
+ ### `validateAgentPush(context)`
689
+
690
+ `validateAgentPush({ sessionId, events }): void`
691
+
692
+ Accepts the browser interaction allowlist: user messages, approval and input
693
+ responses, interruptions, and explicit retries. It rejects server-authored
694
+ scheduling and lifecycle events, trusted seed messages, and input requests.
695
+ `createAgentServer()` installs it automatically.
461
696
 
462
697
  See [Durable AI agents](/guides/ai-agents) for the protocol and complete
463
698
  examples.
@@ -545,29 +780,29 @@ alert on are all mid-span.
545
780
  | Attribute | Span | When | Values |
546
781
  | --------------------- | ----------- | ----- | ----------------------------------------------------- |
547
782
  | `a2.append.source` | `a2.append` | start | `external` \| `handler` |
783
+ | `a2.append.mode` | `a2.append` | start | `inline` \| `dispatch` |
548
784
  | `a2.append.types` | `a2.append` | start | comma-joined event types |
549
785
  | `a2.append.count` | `a2.append` | start | batch size |
550
786
  | `a2.append.armed` | `a2.append` | mid | `false` when the recovery arm failed and this append degraded to append-driven healing |
551
- | `a2.drain.scope` | `a2.drain` | start | `full` \| `tree` |
552
- | `a2.drain.outcome` | `a2.drain` | mid | `settled` \| `busy` \| `stalled` \| `handed_off` |
787
+ | `a2.drain.outcome` | `a2.drain` | mid | `settled` \| `busy` \| `stalled` |
553
788
  | `a2.drain.processed` | `a2.drain` | mid | events processed this pass |
554
789
  | `a2.event.type` | `a2.event` | start | the event's type |
555
790
  | `a2.event.index` | `a2.event` | start | log position |
556
791
  | `a2.event.id` | `a2.event` | start | event id |
557
792
  | `a2.event.attempt` | `a2.event` | start | same durable 1-based ordinal as `ctx.attempt` |
558
- | `a2.event.handled` | `a2.event` | start | `false` when no handler is registered |
793
+ | `a2.event.lane` | `a2.event` | start | stored lane value; absent for concurrent unlaned work |
794
+ | `a2.event.handled` | `a2.event` | start | normally `true`; `false` when a custom-adapter row has no handler |
559
795
  | `a2.event.outcome` | `a2.event` | mid | `processed` \| `failed` \| `dead_lettered` \| `superseded` |
560
796
  | `a2.event.aborted` | `a2.event` | mid | `true` when `abortOn` fired during the run |
561
- | `a2.event.lease_lost` | `a2.event` | mid | `true` when the lease was lost mid-handler |
562
797
  | `a2.state.reducer` | `a2.state` | start | the reducer's name |
563
798
  | `a2.state.snapshot` | `a2.state` | mid | `hit` \| `miss` \| `rejected` (schema guard discarded it) |
564
799
  | `a2.state.folded` | `a2.state` | mid | events folded past the snapshot |
565
800
  | `a2.state.index` | `a2.state` | mid | the frontier the returned state reflects |
566
801
 
567
- Drain outcomes: `settled` means nothing actionable is left; `busy` means
568
- another drainer holds the session (in-process slot or cross-process lease);
569
- `stalled` means blocked at a dead-lettered event; `handed_off` means leftovers
570
- went to a scheduled in-process drain.
802
+ Drain outcomes: `settled` means nothing actionable is left; `busy` means live
803
+ per-event claims remain; `stalled` means a caught failure needs a later retry.
804
+ A dead-lettered event blocks only later events in its lane. Other lanes remain
805
+ eligible.
571
806
 
572
807
  A failing handler marks its `a2.event` span with the exception and
573
808
  error status; `a2.event.outcome = dead_lettered` is the attribute to