experimental-a2 0.0.0 → 0.1.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 +36 -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-BWffWe5A.js +867 -0
  36. package/dist/server.browser.js +4 -4
  37. package/dist/server.d.ts +42 -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 +191 -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 +36 -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 +280 -70
  54. package/package.json +31 -31
  55. package/dist/server-DYsnKTTy.js +0 -780
@@ -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
227
+ }
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
135
236
  }
136
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,12 @@ 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
+
226
360
  ### `session.history()`
227
361
 
228
362
  ```ts
@@ -375,18 +509,38 @@ Pure typed inputs for `append()` and `push()`:
375
509
 
376
510
  | Input | Events |
377
511
  | --- | --- |
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 |
512
+ | `inputs.message(message)` | `ai.message.created`; the server schedules a user turn |
513
+ | `inputs.seed(message)` | `ai.message.created` for a trusted server append |
514
+ | `inputs.approval(response)` | `ai.approval.responded` only |
515
+ | `inputs.input(response)` | `ai.input.responded` only |
516
+ | `inputs.requestInput(request)` | `ai.input.requested` for a trusted server append |
517
+ | `inputs.retry(options)` | `ai.retry.requested` |
384
518
  | `inputs.interrupt(options)` | `ai.message.interrupted` |
385
519
 
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.
520
+ `inputs` deliberately has no session lifecycle methods. Append explicit
521
+ `ai.session.created` and `ai.session.closed` events from trusted server code
522
+ when an application uses them. Input event ids are stable for the interaction
523
+ they describe, so a lost append acknowledgment can be resent safely. Browser
524
+ ingress accepts user messages, approval and input responses, interruptions,
525
+ and explicit retries. Only built-in server handlers append generation requests
526
+ and AI lifecycle events. `inputs.seed()` and `inputs.requestInput()` are for
527
+ trusted server appends.
528
+
529
+ Approval and input request/response payloads require the active
530
+ `generationId`. Clients copy it from the pending request, which prevents a
531
+ delayed response from satisfying a newer model step.
532
+
533
+ `ai.retry.requested` carries `{ messageId, responseMessageId, retryId }`.
534
+ `retryId` identifies one user action. The server converts the fact into an
535
+ `ai.generation.requested` whose reason is `retry`.
536
+
537
+ `ai.generation.failed` sets `stepLimit: true` when `maxSteps` rejects a
538
+ continuation before another model step starts.
539
+
540
+ `ai.message.interrupted` carries `{ messageId, generationId?, reason?,
541
+ lastSeenIndex? }`. Omit `generationId` only while the matching response is in
542
+ the requested phase and `activeGeneration` does not exist yet. Once a
543
+ generation starts, include its id to fence delayed interruption actions.
390
544
 
391
545
  ### `events` and `createEvents(options?)`
392
546
 
@@ -402,9 +556,16 @@ createReducer({ contract, name? }): Reducer<AIState>
402
556
 
403
557
  Builds the standard AI projection for a compatible contract. `AIState`
404
558
  contains session lifecycle, messages, generation status, pending approvals
405
- and input, tool activity, compaction, usage, and the last error.
559
+ and input, tool activity, compaction, usage, the last error, and
560
+ `activeRequestId` and `activeResponseMessageId`, plus
561
+ `responseGenerationIds: Record<string, string>`.
562
+ `activeRequestId` is the server-authorized generation request and fences
563
+ delayed requests before their generation starts. `activeResponseMessageId`
564
+ identifies the requested response until `activeGeneration` exists.
406
565
  `activeProjection` holds the indexed chunk/tool frontier only while a
407
- generation is active; terminal events clear it. Extension events are ignored.
566
+ generation is active; terminal events clear it. `responseGenerationIds` keeps
567
+ the latest generation owner for each response message, so late events from a
568
+ superseded owner cannot alter the projection. Extension events are ignored.
408
569
 
409
570
  ### `deriveUIMessages(history)` and `reduceAIState(state, event)`
410
571
 
@@ -423,6 +584,7 @@ createAgentServer({
423
584
  tools?,
424
585
  instructions?,
425
586
  generation?,
587
+ maxSteps?,
426
588
  generate?,
427
589
  compaction?,
428
590
  progress?,
@@ -430,21 +592,58 @@ createAgentServer({
430
592
  }): A2Server
431
593
  ```
432
594
 
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.
595
+ Creates the standard server for an agent. A2 runs one AI SDK `streamText()`
596
+ model step per durable generation, persists each `UIMessageChunk` once in a
597
+ durable progress batch, projects messages synchronously, extracts tool and
598
+ approval lifecycle events, executes local tools, and records completion,
599
+ usage, interruption, and failure. `model` accepts an AI SDK model string or
600
+ provider model. `model` and `instructions` can be values or per-generation
601
+ async resolvers.
602
+
603
+ `generation` contains per-step settings such as `temperature`,
604
+ `maxOutputTokens`, `topP`, provider options, and tool approval policy. A2 owns
605
+ the one-step stop condition, local tool execution, and continuation. `maxSteps`
606
+ limits one complete assistant response and defaults to 20. Support for
607
+ individual model settings depends on the selected model and provider.
608
+ `generation` excludes `stopWhen`, tool execution callbacks, tool callers, tool
609
+ context, sandbox execution, and the tool approval secret. Model-step timeouts
610
+ remain available; tool-execution timeouts do not.
442
611
 
443
612
  `generate(context)` optionally replaces the default AI SDK generation. It
444
613
  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.
614
+ settings, request state, and the abort signal. It returns exactly one model
615
+ step as a `ReadableStream<UIMessageChunk>`. A2 continues to own tool execution,
616
+ continuation, and the durable lifecycle around that stream.
617
+
618
+ Generation requests use one durable lane, so model calls for a session do not
619
+ overlap. Queued-turn policy also waits to schedule a later user message until
620
+ the active assistant response, including its complete tool loop, reaches a
621
+ terminal event. The policy matters in addition to lane FIFO because a tool
622
+ continuation may be appended after the later user message. A generation
623
+ failure keeps later messages queued until the failed response is explicitly
624
+ retried or interrupted.
625
+
626
+ Independent authorized tool handlers run concurrently without a fixed
627
+ concurrency limit. A private coordinator reducer tracks generation closure,
628
+ cancellation, calls, approvals, and terminal results for the active response.
629
+ Completed responses do not accumulate in its state. Its `ctx.session.state()` reads the
630
+ durable snapshot plus log tail. Process memory is not authoritative. Concurrent
631
+ join checks return the same deterministic continuation event, so they use
632
+ `ctx.session.append()` and storage deduplicates the race.
633
+
634
+ Tool results and generation completion have no fixed relative order. The
635
+ continuation predicate needs both the closed model step and every required
636
+ terminal result.
637
+
638
+ Single-owner terminal events use returned-event causality. They commit
639
+ atomically with parent completion and receive the parent's durable cause.
640
+ Many-owner joins and preliminary streaming outputs use immediate idempotent
641
+ appends. Provider calls and external tool side effects are at least once and
642
+ require their own idempotency. Approved provider-executed calls may satisfy the
643
+ join from the approval response. Provider tools with declared deferred-result
644
+ support wait for their provider result instead of a local executor. An
645
+ authenticated provider callback appends the terminal `ai.tool.result` through
646
+ the trusted server session API; the browser push allowlist rejects it.
448
647
 
449
648
  `compaction` has `shouldCompact(context)` and `compact(context)` callbacks.
450
649
  When selected, both the request and the replacement messages enter the log.
@@ -456,8 +655,20 @@ This entry point is server-only and resolves to a throwing browser stub.
456
655
 
457
656
  Returns the built-in A2 handler table without constructing a server. Spread
458
657
  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.
658
+ a custom assembly. The table handles input facts, generation requests, model
659
+ step completion, tool calls, approval responses, and terminal tool results.
660
+ Application handlers spread later can deliberately replace a built-in
661
+ handler. Custom assemblies pass `validateAgentPush` as
662
+ `createServer({ validatePush })` to preserve the browser boundary.
663
+
664
+ ### `validateAgentPush(context)`
665
+
666
+ `validateAgentPush({ sessionId, events }): void`
667
+
668
+ Accepts the browser interaction allowlist: user messages, approval and input
669
+ responses, interruptions, and explicit retries. It rejects server-authored
670
+ scheduling and lifecycle events, trusted seed messages, and input requests.
671
+ `createAgentServer()` installs it automatically.
461
672
 
462
673
  See [Durable AI agents](/guides/ai-agents) for the protocol and complete
463
674
  examples.
@@ -548,26 +759,25 @@ alert on are all mid-span.
548
759
  | `a2.append.types` | `a2.append` | start | comma-joined event types |
549
760
  | `a2.append.count` | `a2.append` | start | batch size |
550
761
  | `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` |
762
+ | `a2.drain.outcome` | `a2.drain` | mid | `settled` \| `busy` \| `stalled` |
553
763
  | `a2.drain.processed` | `a2.drain` | mid | events processed this pass |
554
764
  | `a2.event.type` | `a2.event` | start | the event's type |
555
765
  | `a2.event.index` | `a2.event` | start | log position |
556
766
  | `a2.event.id` | `a2.event` | start | event id |
557
767
  | `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 |
768
+ | `a2.event.lane` | `a2.event` | start | stored lane value; absent for concurrent unlaned work |
769
+ | `a2.event.handled` | `a2.event` | start | normally `true`; `false` when a custom-adapter row has no handler |
559
770
  | `a2.event.outcome` | `a2.event` | mid | `processed` \| `failed` \| `dead_lettered` \| `superseded` |
560
771
  | `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
772
  | `a2.state.reducer` | `a2.state` | start | the reducer's name |
563
773
  | `a2.state.snapshot` | `a2.state` | mid | `hit` \| `miss` \| `rejected` (schema guard discarded it) |
564
774
  | `a2.state.folded` | `a2.state` | mid | events folded past the snapshot |
565
775
  | `a2.state.index` | `a2.state` | mid | the frontier the returned state reflects |
566
776
 
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.
777
+ Drain outcomes: `settled` means nothing actionable is left; `busy` means live
778
+ per-event claims remain; `stalled` means a caught failure needs a later retry.
779
+ A dead-lettered event blocks only later events in its lane. Other lanes remain
780
+ eligible.
571
781
 
572
782
  A failing handler marks its `a2.event` span with the exception and
573
783
  error status; `a2.event.outcome = dead_lettered` is the attribute to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-a2",
3
- "version": "0.0.0",
3
+ "version": "0.1.0",
4
4
  "description": "Durable sync and reactions for things with a lifecycle: one event log, derived state, and live client per session.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,13 +49,6 @@
49
49
  },
50
50
  "./package.json": "./package.json"
51
51
  },
52
- "scripts": {
53
- "build": "tsdown",
54
- "dev": "tsdown --watch",
55
- "typecheck": "tsc --noEmit",
56
- "test": "vitest run",
57
- "test:watch": "vitest"
58
- },
59
52
  "peerDependencies": {
60
53
  "@opentelemetry/api": "^1.9.0",
61
54
  "@vercel/queue": "*",
@@ -85,27 +78,34 @@
85
78
  }
86
79
  },
87
80
  "devDependencies": {
88
- "@electric-sql/pglite": "catalog:",
89
- "ai": "catalog:",
90
- "ioredis": "catalog:",
91
- "@opentelemetry/api": "catalog:",
92
- "@types/pg": "catalog:",
93
- "@vercel/queue": "catalog:",
94
- "pg": "catalog:",
95
- "@testing-library/react": "catalog:",
96
- "@types/react": "catalog:",
97
- "esbuild": "catalog:",
98
- "@types/react-dom": "catalog:",
99
- "jsdom": "catalog:",
100
- "react": "catalog:",
101
- "react-dom": "catalog:",
102
- "@opentelemetry/context-async-hooks": "catalog:",
103
- "@opentelemetry/sdk-trace-base": "catalog:",
104
- "@types/node": "catalog:",
105
- "publint": "catalog:",
106
- "tsdown": "catalog:",
107
- "typescript": "catalog:",
108
- "vitest": "catalog:",
109
- "zod": "catalog:"
81
+ "@electric-sql/pglite": "^0.3.14",
82
+ "ai": "^7.0.58",
83
+ "ioredis": "^5.9.0",
84
+ "@opentelemetry/api": "^1.9.0",
85
+ "@types/pg": "^8.15.0",
86
+ "@vercel/queue": "^0.2.0",
87
+ "pg": "^8.16.0",
88
+ "@testing-library/react": "^16.1.0",
89
+ "@types/react": "^19.0.0",
90
+ "esbuild": "^0.25.0",
91
+ "@types/react-dom": "^19.0.0",
92
+ "jsdom": "^26.0.0",
93
+ "react": "^19.0.0",
94
+ "react-dom": "^19.0.0",
95
+ "@opentelemetry/context-async-hooks": "^2.0.0",
96
+ "@opentelemetry/sdk-trace-base": "^2.0.0",
97
+ "@types/node": "^24.0.0",
98
+ "publint": "^0.3.23",
99
+ "tsdown": "^0.22.14",
100
+ "typescript": "^7.0.2",
101
+ "vitest": "^4.1.10",
102
+ "zod": "^4.4.3"
103
+ },
104
+ "scripts": {
105
+ "build": "tsdown",
106
+ "dev": "tsdown --watch",
107
+ "typecheck": "tsc --noEmit",
108
+ "test": "vitest run",
109
+ "test:watch": "vitest"
110
110
  }
111
- }
111
+ }