experimental-a2 0.19.0 → 0.20.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 (95) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/actor.js +1 -1
  3. package/dist/ai-fx.d.ts.map +1 -1
  4. package/dist/ai-fx.js +34 -4
  5. package/dist/ai-fx.js.map +1 -1
  6. package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -1
  7. package/dist/{ai-harness-BuuC-gJ3.js → ai-harness-Clbg5ei7.js} +11 -2
  8. package/dist/ai-harness-Clbg5ei7.js.map +1 -0
  9. package/dist/ai-pi.js +2 -2
  10. package/dist/ai-server.d.ts.map +1 -1
  11. package/dist/ai-server.js +6 -4
  12. package/dist/ai-server.js.map +1 -1
  13. package/dist/internal-CscSY7K_.js.map +1 -1
  14. package/dist/scheduler-qstash.d.ts.map +1 -1
  15. package/dist/scheduler-qstash.js +5 -5
  16. package/dist/scheduler-qstash.js.map +1 -1
  17. package/dist/scheduler-vercel.d.ts.map +1 -1
  18. package/dist/scheduler-vercel.js +4 -3
  19. package/dist/scheduler-vercel.js.map +1 -1
  20. package/dist/server-DYAATFuT.d.ts.map +1 -1
  21. package/dist/{server-D-Y4pqwu.js → server-qyb5aJJG.js} +39 -25
  22. package/dist/server-qyb5aJJG.js.map +1 -0
  23. package/dist/server.js +1 -1
  24. package/dist/session-clear-DGI4jTQ5.js.map +1 -1
  25. package/dist/store-postgres.d.ts.map +1 -1
  26. package/dist/store-postgres.js +38 -136
  27. package/dist/store-postgres.js.map +1 -1
  28. package/docs/01-quickstart.mdx +7 -4
  29. package/docs/actors/01-introduction.mdx +1 -1
  30. package/docs/actors/meta.ts +1 -1
  31. package/docs/agents/02-tools.mdx +1 -1
  32. package/docs/agents/03-conversations.mdx +1 -1
  33. package/docs/agents/meta.ts +1 -1
  34. package/docs/client/meta.ts +1 -1
  35. package/docs/contract/03-sessions.mdx +1 -1
  36. package/docs/index.mdx +2 -2
  37. package/docs/reference/01-core.mdx +8 -8
  38. package/docs/reference/02-server.mdx +3 -3
  39. package/docs/reference/04-ai.mdx +9 -1
  40. package/docs/reference/05-telemetry.mdx +132 -0
  41. package/docs/reference/06-errors.mdx +1 -1
  42. package/docs/reference/meta.ts +1 -1
  43. package/docs/schedulers/01-overview.mdx +112 -0
  44. package/docs/schedulers/02-vercel-queues.mdx +87 -0
  45. package/docs/schedulers/03-qstash.mdx +123 -0
  46. package/docs/schedulers/meta.ts +1 -0
  47. package/docs/server/01-handlers.mdx +29 -2
  48. package/docs/server/02-timers.mdx +2 -2
  49. package/docs/server/06-guarantees.mdx +6 -3
  50. package/docs/stores/01-overview.mdx +37 -0
  51. package/docs/stores/02-redis.mdx +49 -0
  52. package/docs/stores/03-redis-http.mdx +56 -0
  53. package/docs/stores/04-postgres.mdx +121 -0
  54. package/docs/stores/05-sqlite.mdx +34 -0
  55. package/docs/stores/06-memory.mdx +22 -0
  56. package/docs/stores/meta.ts +1 -0
  57. package/examples/playground/DEPLOY.md +12 -7
  58. package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
  59. package/examples/playground/app/a0/chat.test.tsx +421 -330
  60. package/examples/playground/app/a0/shell.tsx +1 -1
  61. package/examples/playground/app/a0/uploads/route.test.ts +59 -0
  62. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -90
  63. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
  64. package/examples/playground/app/agent/ingress.test.ts +41 -17
  65. package/examples/playground/app/agent/model-input.test.tsx +92 -43
  66. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
  67. package/examples/playground/app/chat/server.test.ts +38 -65
  68. package/examples/playground/app/components/activity-history.test.tsx +60 -18
  69. package/examples/playground/app/components/fast-link.test.tsx +3 -5
  70. package/examples/playground/app/initial-state.test.tsx +86 -45
  71. package/examples/playground/package.json +2 -1
  72. package/examples/playground/test/agent.ts +35 -0
  73. package/examples/playground/test/model.ts +149 -0
  74. package/examples/playground/test/serve.ts +184 -0
  75. package/examples/playground/test/server-only.ts +1 -0
  76. package/examples/playground/vitest.config.ts +27 -0
  77. package/package.json +3 -1
  78. package/sql/postgres/notifications.sql +24 -0
  79. package/sql/postgres/schema.sql +137 -0
  80. package/src/ai-agent.ts +5 -4
  81. package/src/ai-fx.ts +37 -3
  82. package/src/ai-harness.ts +18 -0
  83. package/src/errors.ts +1 -1
  84. package/src/internal.ts +2 -1
  85. package/src/postgres-notifications.ts +0 -22
  86. package/src/postgres-resources.ts +6 -9
  87. package/src/postgres-schema.ts +80 -0
  88. package/src/scheduler-qstash.ts +12 -5
  89. package/src/scheduler-vercel.ts +10 -4
  90. package/src/server.ts +66 -32
  91. package/src/store-postgres.ts +5 -123
  92. package/dist/ai-harness-BuuC-gJ3.js.map +0 -1
  93. package/dist/server-D-Y4pqwu.js.map +0 -1
  94. package/docs/reference/05-adapters.mdx +0 -351
  95. package/docs/server/05-production.mdx +0 -323
@@ -60,7 +60,7 @@ export const ordersServer = createServer({
60
60
 
61
61
  Development needs no database setup: events go to SQLite at `.a2/dev.db` and
62
62
  survive restarts. Tests get an in-memory store. Production needs an explicit
63
- store; see [Production](/server/production).
63
+ store; see [Stores](/stores/overview).
64
64
 
65
65
  ## 4. Append the first event
66
66
 
@@ -169,11 +169,14 @@ You have a validated write path, a handler, a log you can read, and a view
169
169
  folded from it. The rest of these docs apply the same two moves, append an
170
170
  event and react to it, to timers, cancellation, live UIs, and production.
171
171
 
172
- <CardGroup cols={2}>
172
+ <CardGroup cols={3}>
173
173
  <Card title="Handlers" href="/server/handlers" icon="file">
174
174
  Context, chaining, and re-runs.
175
175
  </Card>
176
- <Card title="Production" href="/server/production" icon="rocket">
177
- A store, a scheduler, dead letters.
176
+ <Card title="Stores" href="/stores/overview" icon="rocket">
177
+ Where sessions live in production.
178
+ </Card>
179
+ <Card title="Schedulers" href="/schedulers/overview" icon="clock">
180
+ Retries and timers on a queue.
178
181
  </Card>
179
182
  </CardGroup>
@@ -57,7 +57,7 @@ handler's first lines and throw `NonRetriableError` on bad shapes.
57
57
 
58
58
  `actor` also accepts `createServer`'s `store`, `scheduler`, and
59
59
  `telemetry` options, with the same development default store; see
60
- [Production](/server/production). A protocol can also declare
60
+ [Stores](/stores/overview) and [Schedulers](/schedulers/overview). A protocol can also declare
61
61
  [`presence`](/actors/routes#presence).
62
62
 
63
63
  ## Call it
@@ -1 +1 @@
1
- export default { order: 6 }
1
+ export default { order: 8 }
@@ -81,7 +81,7 @@ for the event payload.
81
81
 
82
82
  This tool schedules a reminder as a new user message. `ctx.session` belongs to
83
83
  the executing agent, so `schedule()` uses that agent's scheduler: add
84
- `setReminder` to its `tools` and configure a [scheduler](/server/production).
84
+ `setReminder` to its `tools` and configure a [scheduler](/schedulers/overview).
85
85
  Naming the timer after `toolCallId` and the message after the triggering
86
86
  event's ID makes a retry converge on the same timer and message, even if a
87
87
  provider reuses tool-call IDs. The six-day maximum is Vercel Queues'
@@ -393,7 +393,7 @@ retries, and so does one where A2 itself fails while or just after the tool
393
393
  runs. Every other tool uses the default, `'rerun'`. See
394
394
  [`agent()`](/reference/ai#agentoptions) for the exact cases.
395
395
 
396
- Configure a [production scheduler](/server/production) as for any A2 server. It
396
+ Configure a [production scheduler](/schedulers/overview) as for any A2 server. It
397
397
  recovers an incomplete generation after the original serverless invocation
398
398
  disappears.
399
399
 
@@ -1 +1 @@
1
- export default { order: 5 }
1
+ export default { order: 7 }
@@ -1 +1 @@
1
- export default { order: 4 }
1
+ export default { order: 6 }
@@ -70,7 +70,7 @@ authorize your own route before calling it.
70
70
 
71
71
  ### Scheduling a clear
72
72
 
73
- With a [scheduler](/server/production) configured, a handler can schedule a
73
+ With a [scheduler](/schedulers/overview) configured, a handler can schedule a
74
74
  clear. This one clears an idle cart a day after its last activity:
75
75
 
76
76
  ```ts server/carts.ts
package/docs/index.mdx CHANGED
@@ -156,7 +156,7 @@ from the log.
156
156
  <Card title="Timers" href="/server/timers" icon="clock">
157
157
  Schedule an event for later.
158
158
  </Card>
159
- <Card title="Production" href="/server/production" icon="rocket">
159
+ <Card title="Production" href="/stores/overview" icon="rocket">
160
160
  A store and a scheduler.
161
161
  </Card>
162
162
  </CardGroup>
@@ -212,7 +212,7 @@ A durable store backend, such as Postgres or Redis. Add a queue-backed scheduler
212
212
  when the contract has handlers or calls `session.schedule`. A history-only
213
213
  contract with no timers needs no reaction infrastructure. Development needs
214
214
  neither: SQLite appears under `.a2/` and state survives restarts. See
215
- [Production](/server/production).
215
+ [Stores](/stores/overview) and [Schedulers](/schedulers/overview).
216
216
 
217
217
  </details>
218
218
 
@@ -17,15 +17,15 @@ description: "Every entry point, and the isomorphic `experimental-a2` core: cont
17
17
  | `experimental-a2/ai/pi` | `pi`: the Pi harness for `agent()` | `ai`, `@earendil-works/pi-coding-agent`, `@earendil-works/pi-ai` |
18
18
  | `experimental-a2/ai/client` | `createAgentClient`: contract-free agent client | `ai` |
19
19
  | `experimental-a2/ai/react` | `useAgent` | `ai`, `react` |
20
- | [`experimental-a2/store-postgres`](/reference/adapters#experimental-a2store-postgres) | `postgres`: Postgres store backend | `pg` (or inject a client) |
21
- | [`experimental-a2/store-redis`](/reference/adapters#experimental-a2store-redis) | `redis`: Redis Streams store backend, push-native streaming | `ioredis` (or inject a client) |
22
- | [`experimental-a2/store-redis-http`](/reference/adapters#experimental-a2store-redis-http) | `redisHttp`: the same Redis store over provider REST APIs (Upstash) | none |
23
- | `experimental-a2/store-sqlite` | SQLite store backend | none |
24
- | `experimental-a2/store-memory` | in-memory store backend | none |
25
- | `experimental-a2/scheduler-vercel` | `vercelQueues`: recovery and timers | `@vercel/queue` |
26
- | `experimental-a2/scheduler-qstash` | `qstash`: signed recovery and timers | `@upstash/qstash` |
20
+ | [`experimental-a2/store-postgres`](/stores/postgres) | `postgres`: Postgres store backend | `pg` (or inject a client) |
21
+ | [`experimental-a2/store-redis`](/stores/redis) | `redis`: Redis Streams store backend, push-native streaming | `ioredis` (or inject a client) |
22
+ | [`experimental-a2/store-redis-http`](/stores/redis-http) | `redisHttp`: the same Redis store over provider REST APIs (Upstash) | none |
23
+ | [`experimental-a2/store-sqlite`](/stores/sqlite) | SQLite store backend | none |
24
+ | [`experimental-a2/store-memory`](/stores/memory) | in-memory store backend | none |
25
+ | [`experimental-a2/scheduler-vercel`](/schedulers/vercel-queues) | `vercelQueues`: recovery and timers | `@vercel/queue` |
26
+ | [`experimental-a2/scheduler-qstash`](/schedulers/qstash) | `qstash`: signed recovery and timers | `@upstash/qstash` |
27
27
  | `experimental-a2/cache-indexeddb` | `indexedDb` browser cache | none |
28
- | `experimental-a2/otel` | `otel` telemetry adapter | `@opentelemetry/api` |
28
+ | [`experimental-a2/otel`](/reference/telemetry) | `otel` telemetry adapter | `@opentelemetry/api` |
29
29
 
30
30
  `experimental-a2` imports none of the backends, and a browser-bundle test in CI
31
31
  enforces it. Importing a backend's entry point, such as
@@ -438,7 +438,7 @@ Custom scheduler adapters implement two methods:
438
438
 
439
439
  Application routes call `scheduler.handler(...servers)` on the configured
440
440
  adapter. See
441
- [Production](/server/production#2-add-a-scheduler-for-handlers-and-timers).
441
+ [Schedulers](/schedulers/overview).
442
442
 
443
443
  `SchedulerTask` is `SchedulerDrainTask | SchedulerAppendTask |
444
444
  SchedulerClearTask`. These task types, plus `ScheduledEvent`, are exported
@@ -522,7 +522,7 @@ session.append.dispatch(
522
522
 
523
523
  Commits the same atomic batch, then sends pending work to the configured
524
524
  scheduler instead of starting an inline drain. See
525
- [Dispatch in a fresh invocation](/server/production#dispatch-in-a-fresh-invocation).
525
+ [Dispatch in a fresh invocation](/schedulers/overview#dispatch-in-a-fresh-invocation).
526
526
 
527
527
  - Awaits acceptance of an immediate scheduler message. A batch that leaves no
528
528
  pending handler work sends none.
@@ -681,7 +681,7 @@ type DeadLetter = Event & {
681
681
  ```
682
682
 
683
683
  The session's dead-lettered events, oldest first, each with why it failed. See
684
- [When an event dead-letters](/server/production#3-when-an-event-dead-letters).
684
+ [Resolve a dead letter](/server/handlers#resolve-a-dead-letter).
685
685
 
686
686
  - `failureCount` counts the attempts that spent its budget: caught failures,
687
687
  and attempts that ended before they finished. `attemptCount` counts every
@@ -362,7 +362,7 @@ stub.
362
362
  | `toolLane` | one lane for every local tool execution, one at a time in admission order; model steps keep their own lane |
363
363
  | `toolRecovery` | `'rerun'` (default) or `'report'` per tool, the harness's own tools included |
364
364
  | `handlers` | application event handlers; naming an event the agent handles throws a `TypeError` |
365
- | `store`, `scheduler`, `telemetry` | as for any A2 server; `telemetry` covers Core and AI in one trace ([span catalogue](/reference/adapters#the-span-catalogue)) |
365
+ | `store`, `scheduler`, `telemetry` | as for any A2 server; `telemetry` covers Core and AI in one trace ([span catalogue](/reference/telemetry#the-span-catalogue)) |
366
366
 
367
367
  **Contracts.** A contract not built by `agentContract()` throws a `TypeError`
368
368
  and fails to typecheck (serve custom contracts with `createServer()`). Messages
@@ -601,6 +601,14 @@ call. Replay grows with the response, roughly 0.1 to 0.5 seconds for one of 80
601
601
  to 200 tool calls. When libfx can't take a checkpoint, A2 replays from an
602
602
  earlier one.
603
603
 
604
+ FX's model calls carry the same [AI Gateway](#agentoptions) defaults as the
605
+ AI SDK harness: `caching: 'auto'`, under any Gateway options FX sets itself,
606
+ and the session's stable `x-session-affinity` header in place of FX's own,
607
+ which changes every time A2 rebuilds the kernel. A2 leaves every other byte of
608
+ the request as FX wrote it, so it adds caching only where FX's own options come
609
+ last in the request, as libfx writes them; otherwise the request goes
610
+ unchanged.
611
+
604
612
  Credentials come from `AI_GATEWAY_API_KEY`, or Vercel's OIDC token on Vercel.
605
613
  This entry point is server-only.
606
614
 
@@ -0,0 +1,132 @@
1
+ ---
2
+ title: Telemetry
3
+ description: "Trace A2 with OpenTelemetry, and the spans it reports."
4
+ ---
5
+
6
+ ```ts server/orders.ts
7
+ import { createServer } from 'experimental-a2/server'
8
+ import { otel } from 'experimental-a2/otel'
9
+ import { orders } from '@/contracts'
10
+
11
+ export const ordersServer = createServer({
12
+ contract: orders,
13
+ telemetry: otel(),
14
+ // ...store, scheduler, and handlers
15
+ })
16
+ ```
17
+
18
+ With any OpenTelemetry setup registered (on Vercel, `@vercel/otel`), one trace
19
+ shows the request, `a2.append`, the inline `a2.drain`, an `a2.event` span per
20
+ handler, and the appends those handlers make. Failures mark their `a2.event`
21
+ span, and `a2.event.outcome = dead_lettered` is the attribute to alert on.
22
+ Agent servers add `a2.ai.*` spans.
23
+
24
+ ## `experimental-a2/otel`
25
+
26
+ ```ts
27
+ otel(options?: { tracer?: Tracer; tracerName?: string }): A2Telemetry
28
+ ```
29
+
30
+ OpenTelemetry instrumentation for a server (`@opentelemetry/api` is a peer
31
+ dependency). Pass it as `telemetry`. Spans nest through the active context, so
32
+ the happy path lands in one trace. `A2Telemetry` is a one-method interface:
33
+ anything with `span(name, attributes, fn)` works, and `otel()` is the adapter
34
+ A2 ships.
35
+
36
+ ## The span catalogue
37
+
38
+ This catalogue is a contract: renames and additions are breaking for
39
+ dashboards and are called out in the package's `CHANGELOG.md`. All spans carry
40
+ `a2.contract` and `a2.session_id`. Core spans describe the durable runtime;
41
+ `a2.ai.*` spans describe the built-in AI handlers.
42
+
43
+ | Layer | Span | Wraps |
44
+ | ----- | ---- | ----- |
45
+ | Core | `a2.append` | validation + the store write (one batch) |
46
+ | Core | `a2.drain` | one drain pass, including concurrent handlers |
47
+ | Core | `a2.event` | one claimed dispatch, including its handler and completion |
48
+ | Core | `a2.claim` | one `claimAvailable` store operation |
49
+ | Core | `a2.commit` | one `completeAttempt` or `failAttempt` store operation |
50
+ | Core | `a2.state` | one `state()` read: snapshot-plus-tail load + reducer fold |
51
+ | Core | `a2.snapshot` | one background batch of checkpoint writes |
52
+ | Core | `a2.redrive` | one `session.redrive()`: the store operation and starting the session's wakeup |
53
+ | AI | `a2.ai.control` | applying an input/work report/session-close decision and preparing its returned events |
54
+ | AI | `a2.ai.generate` | one generation handler, including Core calls and model/SDK work |
55
+ | AI | `a2.ai.context` | constructing model messages, including tool prompt reconstruction when uncached |
56
+ | AI | `a2.ai.compaction` | automatic summarization or the custom compaction callback |
57
+ | AI | `a2.ai.tool` | one durable tool handler, including application execution |
58
+ | AI | `a2.ai.ack` | waiting for a persisted work report's canonical events and control receipt |
59
+
60
+ Durations include nested work: `a2.event` contains its AI handler and later
61
+ commit, AI handlers call Core, and `a2.state` includes reducer code.
62
+ Acknowledgements overlap control processing, claims can cover several events,
63
+ and snapshots run in the background, so summing spans, or subtracting summed
64
+ children, does not yield exclusive Core or AI overhead. Model and application
65
+ I/O is included; its child spans have the detail.
66
+
67
+ Attributes arrive in two waves: **at start** (passed to `span()`) and
68
+ **mid-span** (via `handle.setAttribute`, for outcomes and counts known only
69
+ after the work ran). A recorder must merge both; the ones you alert on are all
70
+ mid-span.
71
+
72
+ | Attribute | Span | When | Values |
73
+ | --------------------- | ----------- | ----- | ----------------------------------------------------- |
74
+ | `a2.append.source` | `a2.append` | start | `external` \| `handler` |
75
+ | `a2.append.mode` | `a2.append` | start | `inline` \| `dispatch` |
76
+ | `a2.append.types` | `a2.append` | start | comma-joined event types |
77
+ | `a2.append.count` | `a2.append` | start | batch size |
78
+ | `a2.append.armed` | `a2.append` | mid | `false` when the scheduler arm failed and this append degraded to append-driven healing |
79
+ | `a2.drain.outcome` | `a2.drain` | mid | `settled` \| `busy` \| `stalled` \| `failed` |
80
+ | `a2.drain.processed` | `a2.drain` | mid | events processed this pass |
81
+ | `a2.claim.outcome` | `a2.claim` | mid | `claimed` \| `busy` \| `settled` |
82
+ | `a2.claim.count` | `a2.claim` | mid | number of claimed events, or zero |
83
+ | `a2.commit.operation` | `a2.commit` | start | `complete` \| `fail` |
84
+ | `a2.commit.index` | `a2.commit` | start | event log position |
85
+ | `a2.commit.attempt` | `a2.commit` | start | durable 1-based attempt ordinal |
86
+ | `a2.commit.outcome` | `a2.commit` | mid | `completed` \| `failed` \| `dead_lettered` \| `superseded` |
87
+ | `a2.event.type` | `a2.event` | start | the event's type |
88
+ | `a2.event.index` | `a2.event` | start | log position |
89
+ | `a2.event.id` | `a2.event` | start | event id |
90
+ | `a2.event.attempt` | `a2.event` | start | same durable 1-based ordinal as `ctx.attempt` |
91
+ | `a2.event.lane` | `a2.event` | start | stored lane value; absent for concurrent unlaned work |
92
+ | `a2.event.handled` | `a2.event` | start | normally `true`; `false` when a custom-adapter row has no handler |
93
+ | `a2.event.outcome` | `a2.event` | mid | `processed` \| `failed` \| `dead_lettered` \| `superseded` \| `surrendered` |
94
+ | `a2.event.aborted` | `a2.event` | mid | `true` when `abortOn` fired during the run |
95
+ | `a2.state.reducer` | `a2.state` | start | the reducer's name |
96
+ | `a2.state.snapshot` | `a2.state` | mid | `hit` \| `miss` \| `rejected` (schema guard discarded it) \| `memory` (served from the server's in-memory state) |
97
+ | `a2.state.folded` | `a2.state` | mid | events folded past the snapshot |
98
+ | `a2.state.index` | `a2.state` | mid | the frontier the returned state reflects |
99
+ | `a2.snapshot.reducer` | `a2.snapshot` | start | the reducer's name |
100
+ | `a2.snapshot.index` | `a2.snapshot` | start | greatest checkpoint index in the write batch |
101
+ | `a2.snapshot.count` | `a2.snapshot` | start | number of checkpoints in the write batch |
102
+ | `a2.redrive.index` | `a2.redrive` | start | log position of the event being redriven |
103
+ | `a2.redrive.action` | `a2.redrive` | start | `retry` \| `skip` |
104
+ | `a2.redrive.redriven` | `a2.redrive` | mid | `true` when the event was dead-lettered and is now resolved |
105
+ | `a2.redrive.armed` | `a2.redrive` | mid | `false` when the scheduler arm failed and the wakeup degraded to append-driven healing |
106
+ | `a2.ai.control.type` | `a2.ai.control` | start | `ai.control.requested` \| `ai.work.reported` \| `ai.session.closed` |
107
+ | `a2.ai.control.id` | `a2.ai.control` | start | triggering event id |
108
+ | `a2.ai.control.attempt` | `a2.ai.control` | start | durable 1-based attempt ordinal |
109
+ | `a2.ai.generate.request_id` | `a2.ai.generate` | start | generation request event id |
110
+ | `a2.ai.generate.attempt` | `a2.ai.generate` | start | durable 1-based attempt ordinal |
111
+ | `a2.ai.context.request_id` | `a2.ai.context` | start | generation request event id |
112
+ | `a2.ai.context.phase` | `a2.ai.context` | start | `generation` \| `refresh` \| `tool` |
113
+ | `a2.ai.compaction.request_id` | `a2.ai.compaction` | start | generation request event id |
114
+ | `a2.ai.compaction.generation_id` | `a2.ai.compaction` | start | generation attempt id |
115
+ | `a2.ai.compaction.policy` | `a2.ai.compaction` | start | `automatic` \| `custom` |
116
+ | `a2.ai.tool.request_id` | `a2.ai.tool` | start | originating generation request event id |
117
+ | `a2.ai.tool.call_id` | `a2.ai.tool` | start | tool call id |
118
+ | `a2.ai.tool.name` | `a2.ai.tool` | start | tool name |
119
+ | `a2.ai.tool.attempt` | `a2.ai.tool` | start | durable 1-based attempt ordinal |
120
+ | `a2.ai.ack.request_id` | `a2.ai.ack` | start | reporting worker event id |
121
+ | `a2.ai.ack.report_id` | `a2.ai.ack` | start | work report event id, matching the control span's id |
122
+
123
+ - Drain outcomes: `settled` means nothing actionable is left; `busy` means live
124
+ per-event claims or a failed event's retry hold remain; `stalled` means a
125
+ caught failure in this drain needs a later retry. `failed` appears only on
126
+ scheduler deliveries: the drain threw after arming its successor watchdog,
127
+ the span records the error, and that successor retries the session.
128
+ - A failing handler marks its `a2.event` span with the exception and error
129
+ status. Alert on `a2.event.outcome = dead_lettered`. A dead-lettered event
130
+ blocks only later events in its lane, until `session.redrive()` resolves it;
131
+ other lanes stay eligible.
132
+ - `superseded` means a stale completion or failure did not change the log.
@@ -44,7 +44,7 @@ export async function POST(req: Request) {
44
44
  | `SESSION_CLEARED` | a state read reaches below the latest clear, a handler reads state past a clear of its event, or an actor call is cleared before it receives its answer | no |
45
45
  | `CLAIM_EXPIRED` | never thrown; only the `ctx.signal` abort reason when the attempt's lease lapsed without renewal (assumed loss, decided locally, not proof) | no |
46
46
  | `STORE_UNAVAILABLE` | the store backend failed; nothing was written. The original error is the `cause` | **yes** |
47
- | `STORE_NOT_CONFIGURED` | production boots with no `store` configured (during `next build` page collection, deferred to first use) | no |
47
+ | `STORE_NOT_CONFIGURED` | production boots with no `store` configured (during `next build` page collection, deferred to first use), a store's optional peer dependency is missing, or a Postgres database lacks A2's tables at the version the release needs (or the notification triggers `listenConnectionString` uses). The message names the fix | no |
48
48
 
49
49
  Retrying an *identical* batch is not `PARTIAL_DUPLICATE_BATCH`: it's an
50
50
  idempotent success that returns the previously appended events. A failing
@@ -1 +1 @@
1
- export default { order: 7 }
1
+ export default { order: 9 }
@@ -0,0 +1,112 @@
1
+ ---
2
+ title: Overview
3
+ description: A clock for handler retries and timers.
4
+ ---
5
+
6
+ ```ts server/orders.ts
7
+ import { createServer } from 'experimental-a2/server'
8
+ import { redis } from 'experimental-a2/store-redis'
9
+ import { vercelQueues } from 'experimental-a2/scheduler-vercel'
10
+ import { orders } from '@/contracts'
11
+
12
+ export const scheduler = vercelQueues()
13
+
14
+ export const ordersServer = createServer({
15
+ contract: orders,
16
+ store: redis({ url: process.env.REDIS_URL }),
17
+ scheduler,
18
+ handlers: {
19
+ /* ... */
20
+ },
21
+ })
22
+ ```
23
+
24
+ ```ts app/api/a2/scheduler/route.ts
25
+ import { billingServer, ordersServer, scheduler } from '@/server'
26
+
27
+ export const POST = scheduler.handler(ordersServer, billingServer)
28
+ ```
29
+
30
+ A scheduler wakes sessions on a clock. It retries a handler that failed or
31
+ whose process died, and it delivers [timers](/server/timers), whose delays must
32
+ fit the [provider's limit](/server/timers#provider-limits). Share one
33
+ scheduler across your servers and mount its route once, with every server it
34
+ delivers to. `scheduler.handler(...)` checks those servers when the module
35
+ loads, so a bad route fails at startup.
36
+
37
+ | Scheduler | Use it for |
38
+ | --- | --- |
39
+ | [Vercel Queues](/schedulers/vercel-queues) | Apps on Vercel (recommended) |
40
+ | [QStash](/schedulers/qstash) | A stable public URL, called from separate infrastructure |
41
+
42
+ ## Without a scheduler
43
+
44
+ If no event type has a handler and nothing calls `session.schedule`, skip it.
45
+ Events settle as they're appended, with no queue messages. That's a complete
46
+ production setup for history, reducers, and live sync.
47
+
48
+ With handlers but no scheduler, a session only wakes on a top-level append or
49
+ an explicit `server.drain()`. Reads never wake it, so a session nobody writes
50
+ to stays stuck. That's fine for internal tools that can heal on the next
51
+ write. For production, add a scheduler.
52
+
53
+ ## More than one scheduler
54
+
55
+ Servers with different scheduler instances need separate routes, even from the
56
+ same adapter. Give each QStash instance its own `url`, and each Vercel Queues
57
+ instance its own `topic` and trigger.
58
+
59
+ ## Dispatch in a fresh invocation
60
+
61
+ `append` starts handlers in the same request and uses the scheduler as a
62
+ watchdog. `append.dispatch` stores the event and hands its handlers to the
63
+ queue instead:
64
+
65
+ ```ts app/api/imports/route.ts
66
+ import { ordersServer } from '@/server'
67
+
68
+ export async function POST(request: Request) {
69
+ const { orderId, shopId, items } = await request.json()
70
+ await ordersServer.session(orderId).append.dispatch({
71
+ id: `created:${orderId}`,
72
+ type: 'created',
73
+ payload: { shopId, items },
74
+ })
75
+ return Response.json({ accepted: true }, { status: 202 })
76
+ }
77
+ ```
78
+
79
+ It needs a scheduler and waits for the queue to accept the message. If that
80
+ send fails, the event may already be stored; its explicit `id` makes a retry
81
+ safe.
82
+
83
+ ## Deploys and rollbacks
84
+
85
+ Queued work can reach a different deployment than the one that sent it. Keep
86
+ event schemas compatible across deployments. Each adapter's page explains
87
+ where its tasks go: [Vercel Queues](/schedulers/vercel-queues#deployments) and
88
+ [QStash](/schedulers/qstash#deployments).
89
+
90
+ :::warning
91
+ Don't roll back to an A2 version without timer delivery while scheduled tasks
92
+ are outstanding. An old route can acknowledge a scheduled append without
93
+ writing its events. Rolling back past scheduled clears fails too: an old
94
+ Vercel Queues route keeps failing the clear until retention ends, and an old
95
+ QStash route moves it to QStash's dead-letter queue. Roll forward, or wait for
96
+ those tasks to deliver first.
97
+ :::
98
+
99
+ ## Send errors
100
+
101
+ When a provider rejects a send, both adapters keep the provider's original
102
+ error.
103
+
104
+ - **Terminal:** a definite 4xx status or error, except 408, 409, 425, and 429.
105
+ The Vercel adapter also treats the SDK's `BadRequestError`,
106
+ `UnauthorizedError`, and `ForbiddenError` as terminal.
107
+ - **Retryable:** 408, 409, 425, 429, 5xx, network failures, and errors with no
108
+ recognized status.
109
+ - Vercel Queues' duplicate-message error counts as success.
110
+
111
+ A2's durable AI tools use this classification. Direct `session.schedule()`
112
+ calls still reject with the original error.
@@ -0,0 +1,87 @@
1
+ ---
2
+ title: Vercel Queues
3
+ description: The recommended scheduler on Vercel.
4
+ ---
5
+
6
+ ```ts server/orders.ts
7
+ import { createServer } from 'experimental-a2/server'
8
+ import { redis } from 'experimental-a2/store-redis'
9
+ import { vercelQueues } from 'experimental-a2/scheduler-vercel'
10
+ import { orders } from '@/contracts'
11
+
12
+ export const scheduler = vercelQueues()
13
+
14
+ export const ordersServer = createServer({
15
+ contract: orders,
16
+ store: redis({ url: process.env.REDIS_URL }),
17
+ scheduler,
18
+ handlers: {
19
+ /* ... */
20
+ },
21
+ })
22
+ ```
23
+
24
+ ```ts app/api/a2/scheduler/route.ts
25
+ import { ordersServer, scheduler } from '@/server'
26
+
27
+ export const POST = scheduler.handler(ordersServer)
28
+ ```
29
+
30
+ ```json vercel.json
31
+ {
32
+ "functions": {
33
+ "app/api/a2/scheduler/route.ts": {
34
+ "experimentalTriggers": [
35
+ {
36
+ "type": "queue/v2beta",
37
+ "topic": "a2"
38
+ }
39
+ ]
40
+ }
41
+ }
42
+ }
43
+ ```
44
+
45
+ ```package-install
46
+ npm i @vercel/queue
47
+ ```
48
+
49
+ The trigger is required. It connects the route to Vercel Queues and makes the
50
+ function private, so only the queue can call it. Don't also expose the route
51
+ publicly or wrap it in your own authentication.
52
+
53
+ Messages are kept for seven days (Vercel Queues' default is 24 hours, and
54
+ seven days is its maximum), so timers can be scheduled up to seven days out. A
55
+ longer delay goes out as asked, and Vercel Queues refuses it.
56
+
57
+ A drain delivery is acknowledged once its session settles or a later watchdog
58
+ for it is stored, including when the drain fails after storing that watchdog.
59
+ A drain that fails before then stays unacknowledged, so Vercel Queues
60
+ redelivers it.
61
+
62
+ ## Options
63
+
64
+ | Option | Default | |
65
+ | --- | --- | --- |
66
+ | `topic` | `'a2'` | must match the trigger's `topic`, and `[A-Za-z0-9_-]+` |
67
+ | `deploymentId` | `VERCEL_DEPLOYMENT_ID` | which deployment receives tasks; see below |
68
+ | `transport`, `clock` | the `@vercel/queue` client, the system clock | test seams for custom delivery |
69
+
70
+ An injected `transport` receives the topic, the task, and send options, and
71
+ owns its own provider configuration; `deploymentId` only configures the
72
+ built-in client.
73
+
74
+ ## Deployments
75
+
76
+ `deploymentId` applies to every task: retries, timers, and scheduled clears.
77
+ Leave it out to deliver to the deployment that sent the task, pass an ID to
78
+ target one deployment, or pass `null` to let Vercel pick. Every deployment that
79
+ sends or receives must use the same setting.
80
+
81
+ - **Tasks stay with their deployment.** Promoting a new deployment doesn't move
82
+ them, so older deployments keep receiving their own work until it's done or
83
+ expires. Upgrading doesn't fix an older deployment's route.
84
+ - **`null` doesn't mean latest.** Vercel picks any eligible deployment, so every
85
+ one of them must use `deploymentId: null` and handle pending tasks.
86
+ - **Changing the setting doesn't move queued tasks.** Keep the old deployments
87
+ running until those tasks finish.
@@ -0,0 +1,123 @@
1
+ ---
2
+ title: QStash
3
+ description: Signed HTTP delivery from Upstash, to a stable public URL.
4
+ ---
5
+
6
+ ```ts server/orders.ts
7
+ import { createServer } from 'experimental-a2/server'
8
+ import { qstash } from 'experimental-a2/scheduler-qstash'
9
+ import { redis } from 'experimental-a2/store-redis'
10
+ import { orders } from '@/contracts'
11
+
12
+ export const scheduler = qstash()
13
+
14
+ export const ordersServer = createServer({
15
+ contract: orders,
16
+ store: redis({ url: process.env.REDIS_URL }),
17
+ scheduler,
18
+ handlers: {
19
+ /* ... */
20
+ },
21
+ })
22
+ ```
23
+
24
+ ```ts app/api/a2/scheduler/route.ts
25
+ import { ordersServer, scheduler } from '@/server'
26
+
27
+ export const POST = scheduler.handler(ordersServer)
28
+ ```
29
+
30
+ ```package-install
31
+ npm i @upstash/qstash
32
+ ```
33
+
34
+ Set `QSTASH_TOKEN`, `QSTASH_CURRENT_SIGNING_KEY`, and
35
+ `QSTASH_NEXT_SIGNING_KEY`. In development you need none of them: A2 starts
36
+ QStash's local server on the first publish. It needs `@upstash/qstash` 2.11.0
37
+ or newer.
38
+
39
+ The route checks `Upstash-Signature` before it touches the store and answers
40
+ unsigned requests with 401. Delivery is at least once, so handler side effects
41
+ need idempotency keys.
42
+
43
+ ## Callback URL
44
+
45
+ QStash calls `/api/a2/scheduler`. An explicit `url` always wins; otherwise A2
46
+ infers it:
47
+
48
+ - **Vercel production:** `https://${VERCEL_PROJECT_PRODUCTION_URL}/api/a2/scheduler`.
49
+ - **Vercel preview:** `https://${VERCEL_BRANCH_URL}/api/a2/scheduler`.
50
+ - **Locally:** `http://localhost:${PORT ?? 3000}/api/a2/scheduler`, against
51
+ QStash's development server. `PORT` must be an integer from 1 through 65535.
52
+
53
+ Pass `url` for anything else: production outside Vercel, a custom path or
54
+ host, Docker networking, or a runtime without `process.env`. With an explicit
55
+ `url` outside production, A2 doesn't start the development server: set
56
+ `QSTASH_DEV`, or `QSTASH_URL` and credentials, yourself. An Edge runtime infers
57
+ the local URL but must also start the development server itself.
58
+
59
+ ## Options
60
+
61
+ | Option | Default | |
62
+ | --- | --- | --- |
63
+ | `url` | inferred, see above | the callback URL |
64
+ | `token` | `QSTASH_TOKEN` | |
65
+ | `currentSigningKey` | `QSTASH_CURRENT_SIGNING_KEY` | |
66
+ | `nextSigningKey` | `QSTASH_NEXT_SIGNING_KEY` | |
67
+ | `headers` | none | static headers on every callback |
68
+ | `client` | a lazy `@upstash/qstash` client | your own configured client |
69
+ | `retries` | QStash's default | retries per delivery; every attempt is billed |
70
+ | `maxBodyBytes` | `1_048_576` (1 MiB) | the largest task A2 sends or accepts |
71
+ | `transport`, `clock` | the `@upstash/qstash` client and receiver, the system clock | test seams for custom delivery |
72
+
73
+ - **`headers`** are checked and copied when `qstash()` runs. A2 asks QStash to
74
+ redact every value from its dashboard and API, but QStash still stores and
75
+ delivers them. Names are case-insensitive. `Upstash-*`, `Content-Type`, HTTP
76
+ framing headers, and A2's Vercel identity headers are reserved.
77
+ - **`client`** is the `publishJSON(...)` part of a configured `Client`, for its
78
+ endpoint, retry policy, or telemetry. It can't be combined with `token` or
79
+ `transport`. It receives callback credentials unredacted and must apply each
80
+ publish's redaction. Put callback credentials in `headers`, not on the
81
+ client.
82
+ - **`maxBodyBytes`**: A2 reserves room for a task's retry metadata before the
83
+ first publish, so an accepted task never outgrows the limit. Raise it for a
84
+ larger paid-plan limit, and keep one value on every deployment that receives
85
+ the URL.
86
+ - **`transport`** implements `publish(request)` and `verify(request)`. It must
87
+ keep the body, deliver every header, and apply every redaction.
88
+
89
+ ## How delivery works
90
+
91
+ - The route returns 200 once a task is done, or once its follow-up is queued,
92
+ so each retry gets a fresh message and retry budget. That includes a drain
93
+ that fails after queueing its follow-up.
94
+ - If the store is down (`STORE_UNAVAILABLE`) or not set up yet
95
+ (`STORE_NOT_CONFIGURED`, such as Postgres before its tables exist) before a
96
+ drain queues its follow-up, or during an append or clear task, the task moves
97
+ to a new message: 30 seconds later, four times longer each time, up to one
98
+ day.
99
+ - A signed but malformed task, or one over `maxBodyBytes`, returns 489 with
100
+ `Upstash-NonRetryable-Error: true`. Unknown contracts, missing
101
+ configuration, and other failures return an error, so QStash retries.
102
+ - If QStash can't reach the route at all, nothing queues a follow-up: watch
103
+ QStash's dead-letter queue and redrive from it.
104
+ - Delays are capped by your plan: 7 days on free plans and 1 year on
105
+ usage-based plans, with no fixed cap on fixed plans. Leave a second of
106
+ margin at the cap.
107
+
108
+ ## Protected previews
109
+
110
+ For an inferred preview URL, each publish forwards the invocation's Vercel
111
+ OIDC token as `x-vercel-trusted-oidc-idp-token`, redacted, so a Vercel Trusted
112
+ Sources rule can let the first delivery through. This is best effort: a
113
+ missing or expiring token falls back to a plain publish, and retries reuse the
114
+ stored token, which may have expired. When every protected delivery must get
115
+ through, pass an automation bypass in `headers` instead. Signature checks still
116
+ apply after Vercel's.
117
+
118
+ ## Deployments
119
+
120
+ Inferred production and preview URLs are moving aliases, so a pending task can
121
+ reach a later deployment. Keep tasks and event schemas compatible across
122
+ deployments, and keep the same `maxBodyBytes` on all of them: a lower value
123
+ rejects tasks that an earlier deployment accepted.
@@ -0,0 +1 @@
1
+ export default { order: 5 }