experimental-a2 0.18.1 → 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 (143) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/actor.js +1 -1
  3. package/dist/{ai-BMLlI2Ff.d.ts → ai-Cz3hqVhk.d.ts} +33 -16
  4. package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
  5. package/dist/ai-client.d.ts +1 -1
  6. package/dist/ai-client.js +1 -1
  7. package/dist/ai-control-CvLbW10B.js +46 -0
  8. package/dist/ai-control-CvLbW10B.js.map +1 -0
  9. package/dist/ai-fx.d.ts +1 -1
  10. package/dist/ai-fx.d.ts.map +1 -1
  11. package/dist/ai-fx.js +63 -13
  12. package/dist/ai-fx.js.map +1 -1
  13. package/dist/{ai-harness-B2suP2Qh.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
  14. package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
  15. package/dist/{ai-harness-BuLh_EYi.js → ai-harness-Clbg5ei7.js} +88 -15
  16. package/dist/ai-harness-Clbg5ei7.js.map +1 -0
  17. package/dist/{ai-D6mGlO8j.js → ai-mMiWul1T.js} +116 -77
  18. package/dist/ai-mMiWul1T.js.map +1 -0
  19. package/dist/ai-pi.d.ts +1 -1
  20. package/dist/ai-pi.d.ts.map +1 -1
  21. package/dist/ai-pi.js +56 -11
  22. package/dist/ai-pi.js.map +1 -1
  23. package/dist/ai-react.d.ts +1 -1
  24. package/dist/ai-react.js +1 -1
  25. package/dist/ai-server.d.ts +2 -2
  26. package/dist/ai-server.d.ts.map +1 -1
  27. package/dist/ai-server.js +123 -53
  28. package/dist/ai-server.js.map +1 -1
  29. package/dist/ai.d.ts +2 -2
  30. package/dist/ai.js +1 -1
  31. package/dist/internal-CscSY7K_.js.map +1 -1
  32. package/dist/scheduler-qstash.d.ts.map +1 -1
  33. package/dist/scheduler-qstash.js +5 -5
  34. package/dist/scheduler-qstash.js.map +1 -1
  35. package/dist/scheduler-vercel.d.ts.map +1 -1
  36. package/dist/scheduler-vercel.js +5 -7
  37. package/dist/scheduler-vercel.js.map +1 -1
  38. package/dist/server-DYAATFuT.d.ts.map +1 -1
  39. package/dist/{server-D-Y4pqwu.js → server-qyb5aJJG.js} +39 -25
  40. package/dist/server-qyb5aJJG.js.map +1 -0
  41. package/dist/server.js +1 -1
  42. package/dist/session-clear-DGI4jTQ5.js.map +1 -1
  43. package/dist/store-postgres.d.ts.map +1 -1
  44. package/dist/store-postgres.js +38 -136
  45. package/dist/store-postgres.js.map +1 -1
  46. package/docs/01-quickstart.mdx +7 -4
  47. package/docs/actors/01-introduction.mdx +1 -1
  48. package/docs/actors/meta.ts +1 -1
  49. package/docs/agents/02-tools.mdx +1 -1
  50. package/docs/agents/03-conversations.mdx +26 -14
  51. package/docs/agents/04-harnesses.mdx +51 -5
  52. package/docs/agents/meta.ts +1 -1
  53. package/docs/client/meta.ts +1 -1
  54. package/docs/contract/03-sessions.mdx +2 -2
  55. package/docs/index.mdx +2 -2
  56. package/docs/reference/01-core.mdx +8 -8
  57. package/docs/reference/02-server.mdx +3 -3
  58. package/docs/reference/04-ai.mdx +36 -19
  59. package/docs/reference/05-telemetry.mdx +132 -0
  60. package/docs/reference/06-errors.mdx +1 -1
  61. package/docs/reference/meta.ts +1 -1
  62. package/docs/schedulers/01-overview.mdx +112 -0
  63. package/docs/schedulers/02-vercel-queues.mdx +87 -0
  64. package/docs/schedulers/03-qstash.mdx +123 -0
  65. package/docs/schedulers/meta.ts +1 -0
  66. package/docs/server/01-handlers.mdx +29 -2
  67. package/docs/server/02-timers.mdx +3 -3
  68. package/docs/server/06-guarantees.mdx +6 -3
  69. package/docs/stores/01-overview.mdx +37 -0
  70. package/docs/stores/02-redis.mdx +49 -0
  71. package/docs/stores/03-redis-http.mdx +56 -0
  72. package/docs/stores/04-postgres.mdx +121 -0
  73. package/docs/stores/05-sqlite.mdx +34 -0
  74. package/docs/stores/06-memory.mdx +22 -0
  75. package/docs/stores/meta.ts +1 -0
  76. package/examples/playground/DEPLOY.md +19 -5
  77. package/examples/playground/app/a0/a0.css +244 -5
  78. package/examples/playground/app/a0/app-view.tsx +2 -1
  79. package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
  80. package/examples/playground/app/a0/attachments.ts +133 -0
  81. package/examples/playground/app/a0/chat.test.tsx +595 -0
  82. package/examples/playground/app/a0/chat.tsx +623 -63
  83. package/examples/playground/app/a0/context-menu.tsx +38 -11
  84. package/examples/playground/app/a0/model.test.ts +28 -0
  85. package/examples/playground/app/a0/model.ts +30 -0
  86. package/examples/playground/app/a0/server.ts +5 -0
  87. package/examples/playground/app/a0/shell.tsx +1 -1
  88. package/examples/playground/app/a0/uploads/route.test.ts +59 -0
  89. package/examples/playground/app/a0/uploads/route.ts +29 -0
  90. package/examples/playground/app/agent/[agentId]/agent-client.tsx +2 -1
  91. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -89
  92. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
  93. package/examples/playground/app/agent/ingress.test.ts +41 -17
  94. package/examples/playground/app/agent/model-input.test.tsx +92 -43
  95. package/examples/playground/app/agent/model.test.ts +12 -3
  96. package/examples/playground/app/chat/[chatId]/agent-state.test.ts +1 -5
  97. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
  98. package/examples/playground/app/chat/server.test.ts +38 -65
  99. package/examples/playground/app/components/activity-history.test.tsx +60 -18
  100. package/examples/playground/app/components/fast-link.test.tsx +3 -5
  101. package/examples/playground/app/initial-state.test.tsx +86 -45
  102. package/examples/playground/next.config.ts +7 -4
  103. package/examples/playground/package.json +4 -1
  104. package/examples/playground/test/agent.ts +35 -0
  105. package/examples/playground/test/model.ts +149 -0
  106. package/examples/playground/test/serve.ts +184 -0
  107. package/examples/playground/test/server-only.ts +1 -0
  108. package/examples/playground/vitest.config.ts +27 -0
  109. package/package.json +3 -1
  110. package/sql/postgres/notifications.sql +24 -0
  111. package/sql/postgres/schema.sql +137 -0
  112. package/src/ai-agent.ts +9 -4
  113. package/src/ai-client-state.ts +21 -9
  114. package/src/ai-context-schema.ts +28 -4
  115. package/src/ai-context.ts +83 -3
  116. package/src/ai-control-server.ts +70 -43
  117. package/src/ai-control-state.ts +3 -4
  118. package/src/ai-control.ts +59 -10
  119. package/src/ai-coordinator.ts +7 -6
  120. package/src/ai-fx.ts +95 -23
  121. package/src/ai-harness.ts +171 -29
  122. package/src/ai-message-shape.ts +41 -0
  123. package/src/ai-pi.ts +88 -32
  124. package/src/ai-stored-state.ts +1 -3
  125. package/src/ai.ts +116 -77
  126. package/src/errors.ts +1 -1
  127. package/src/internal.ts +2 -1
  128. package/src/libfx.d.ts +8 -1
  129. package/src/postgres-notifications.ts +0 -22
  130. package/src/postgres-resources.ts +6 -9
  131. package/src/postgres-schema.ts +80 -0
  132. package/src/scheduler-qstash.ts +12 -5
  133. package/src/scheduler-vercel.ts +11 -16
  134. package/src/server.ts +66 -32
  135. package/src/store-postgres.ts +5 -123
  136. package/dist/ai-BMLlI2Ff.d.ts.map +0 -1
  137. package/dist/ai-D6mGlO8j.js.map +0 -1
  138. package/dist/ai-harness-B2suP2Qh.d.ts.map +0 -1
  139. package/dist/ai-harness-BuLh_EYi.js.map +0 -1
  140. package/dist/server-D-Y4pqwu.js.map +0 -1
  141. package/docs/reference/05-adapters.mdx +0 -351
  142. package/docs/server/05-production.mdx +0 -323
  143. package/examples/playground/app/a0/no-photon.cjs +0 -4
@@ -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
 
@@ -446,9 +446,11 @@ export function Queue({ sessionId }: { sessionId: string }) {
446
446
  ```
447
447
 
448
448
  `sendMessage()` puts a message in the durable inbox, `state.inbox.items`. The
449
- controller admits one input at a time, freezes its content, and adds it to
450
- `state.messages`; pending inputs never enter the model prompt. `state.active`
451
- is the logical turn: its ID, frozen `input`, and `phase`. The turn ID holds
449
+ controller admits one input at a time and adds it to `state.messages`; pending
450
+ inputs never enter the model prompt, and an edit after admission is rejected.
451
+ The log stores a message once, in the command that sent it or the edit that
452
+ last changed it. `state.active` is
453
+ the logical turn: its ID, its `input`, and `phase`. The turn ID holds
452
454
  across model steps, tools, and a pause. Stop ends the turn and admits the next
453
455
  input when admission is running.
454
456
 
@@ -466,15 +468,20 @@ replay cannot recreate the prompt. Removing an input that is no longer pending
466
468
  (admitted, removed, or never queued) returns `not-pending`, as does an edit that
467
469
  loses to admission; a Stop aimed at a finished turn returns `stale-turn`.
468
470
 
469
- `steer()` puts new input first. If the observed turn is still active, it
470
- finishes its current model response and that response's tools, including
471
- authorized deferred provider results, then hands off before another model
472
- step. Output produced after the click stays, and unanswered approval and input
473
- requests close, so the unapproved tool never runs; a denied approval never
474
- blocks the handoff. With no active turn, `steer()` sends like `sendMessage()`.
475
-
476
- `queue.sendNow()` makes the same handoff with a queued message, keeping its ID
477
- and latest accepted content and promoting passive user input to generation.
471
+ `steer()` puts new input right after the turn's earlier steering, or first
472
+ when there is none, so steering hands off in the order it was sent. If the observed turn
473
+ is still active, it finishes its current model response and that response's
474
+ tools, including authorized deferred provider results, then hands off before
475
+ another model step. Everything steering that turn enters together as one new
476
+ turn: the earlier messages as context and the last one generating, so the model
477
+ answers them once. Output produced after the click stays, and unanswered
478
+ approval and input requests close, so the unapproved tool never runs; a denied
479
+ approval never blocks the handoff. With no active turn, `steer()` sends like
480
+ `sendMessage()`.
481
+
482
+ `queue.sendNow()` makes the same handoff with a queued message, after the
483
+ steering sent before it, keeping its ID and latest accepted content and
484
+ promoting passive user input to generation.
478
485
  Non-user context returns `not-user-input`, and a stale observed turn returns
479
486
  `stale-turn` without changing the queue. Send now uses the accepted content, so
480
487
  save editor text first. `steer()` and `queue.sendNow()` both preserve pause: a
@@ -483,7 +490,8 @@ immediate interruption.
483
490
 
484
491
  A queued item's optional `afterStepOf` records the observed turn for steering.
485
492
  Editing keeps it, removing the item cancels it, and queue order decides which
486
- input is admitted at the handoff. See [`inputs`](/reference/ai#inputs) for the
493
+ inputs are admitted at the handoff: the run at the front that steers the same
494
+ turn. See [`inputs`](/reference/ai#inputs) for the
487
495
  full handoff rules.
488
496
 
489
497
  ### Input identity
@@ -531,6 +539,10 @@ finish. `pause({ when: 'now' })` also cancels current model and tool work;
531
539
  `state.active.phase` is `pausing` while old work settles, then `paused`.
532
540
  `resume()` resumes retained work before admitting another input.
533
541
 
542
+ A turn paused with Pause now keeps its steering until Resume. Every
543
+ `steer()` or `queue.sendNow()` sent while it's paused targets it, and on Resume
544
+ they enter together as one turn; the paused turn's own work does not resume.
545
+
534
546
  Resume starts a fresh model request from durable state; it does not resume the
535
547
  provider's old stream or undo completed tool effects. While paused, accepted
536
548
  tool calls are retained with their outcomes recorded, and the turn holds no
@@ -110,17 +110,63 @@ Both harnesses call models through Vercel AI Gateway. `model` is a Gateway
110
110
  model ID, or [`ModelSettings`](/reference/ai#modelsettings) with one, resolved
111
111
  for each step like any agent's model; `pi()` takes the Gateway models Pi's own
112
112
  catalog lists. `reasoning` becomes FX's effort or Pi's thinking level, and
113
- `speed: 'fast'` asks FX for the faster tier. Credentials come from
114
- `AI_GATEWAY_API_KEY`, or from Vercel's OIDC token on Vercel. The harness reads
115
- the text of each user message; files and seeded assistant messages do not
116
- reach it.
113
+ `speed: 'fast'` asks AI Gateway for the model's faster tier under either
114
+ harness. Credentials come from `AI_GATEWAY_API_KEY`, or from Vercel's OIDC
115
+ token on Vercel. Seeded assistant messages do not reach the harness.
116
+
117
+ ## Images and files
118
+
119
+ The harness reads each user message's text and its image files. Neither FX nor
120
+ Pi takes URLs, so A2 fetches each image from its `https:` or `data:` URL and
121
+ hands the harness its bytes. The fetch goes through the
122
+ AI SDK's download, which refuses private addresses, checks each redirect, and
123
+ stops past 32 MiB of one message's files. Each step of a response replays its
124
+ turn, so a process keeps the images it fetched instead of fetching them again.
125
+
126
+ A file that isn't an image, or that A2 can't fetch, fails the turn with an
127
+ error that names it. So does a steer with files under FX, which steers with
128
+ text only. The harness applies its own rules to the images it gets: FX fails a
129
+ turn whose model doesn't take images, and Pi resizes them. Every step sends the
130
+ conversation so far, files and all, so a file that fails one turn fails the
131
+ turns after it too.
132
+
133
+ A2 fetches whatever URL a user message names, so check file URLs on the way
134
+ in. An agent's `messageSchema` can require them to point at your storage:
135
+
136
+ ```ts server/reviewer.ts
137
+ import type { UIMessage } from 'ai'
138
+ import { agent } from 'experimental-a2/ai/server'
139
+ import { fx } from 'experimental-a2/ai/fx'
140
+ import { z } from 'zod'
141
+
142
+ // your storage's public URL:
143
+ const uploads = 'https://uploads.example.com/'
144
+
145
+ export const reviewer = agent({
146
+ name: 'reviewer',
147
+ harness: fx(),
148
+ model: 'anthropic/claude-sonnet-5.5',
149
+ instructions: 'Review the screenshots the user sends. Keep replies short.',
150
+ messageSchema: z
151
+ .custom<UIMessage>()
152
+ .refine(
153
+ (message) =>
154
+ message.role !== 'user' ||
155
+ message.parts.every(
156
+ (part) => part.type !== 'file' || part.url.startsWith(uploads),
157
+ ),
158
+ "A user's files must be uploads",
159
+ ),
160
+ })
161
+ ```
117
162
 
118
163
  ## What the harness owns
119
164
 
120
165
  The harness owns its loop, its prompt, and its compaction, so `generation`,
121
166
  `generate`, `compaction`, and `messageMetadata` exist only in
122
167
  `{ type: 'ai-sdk' }`. A harness runs its tools without approval, so a tool that
123
- sets `needsApproval` is rejected. `maxSteps` limits the model calls in one
168
+ sets `needsApproval` is rejected. It hands the engine only tools A2 runs, so a
169
+ provider-defined tool, such as a provider's web search, is rejected too. `maxSteps` limits the model calls in one
124
170
  response under every harness. Steering enters at the next step, as with any
125
171
  agent: the harness sees the steered message before its next model call.
126
172
 
@@ -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
@@ -109,7 +109,7 @@ append returned.
109
109
  Delivery runs the same conditional clear and finishes the deletion before it
110
110
  acknowledges, so provider retries complete an interrupted delete. No event is
111
111
  appended. The clear happens at delivery, not at an exact deadline, within
112
- provider limits: Vercel Queues accepts delays up to six days.
112
+ provider limits: Vercel Queues accepts delays up to seven days.
113
113
 
114
114
  ## Sessions and your database
115
115
 
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
@@ -39,8 +39,8 @@ state: serve it with `agent({ contract })` and use it with
39
39
  through.
40
40
  - An `initialState` key that `AIState` also has throws a `TypeError` and fails
41
41
  to typecheck.
42
- - The reducer name is `AI_STATE_REDUCER` (`a2.ai.state.v26`) without
43
- `reducer`, else `${reducer.name}+a2.ai.state.v26`, so checkpoints advance when
42
+ - The reducer name is `AI_STATE_REDUCER` (`a2.ai.state.v27`) without
43
+ `reducer`, else `${reducer.name}+a2.ai.state.v27`, so checkpoints advance when
44
44
  either fold changes. `AgentReducer` types that name (so
45
45
  [`createAgentClient()`](#createagentclientoptions) accepts a plain
46
46
  `agentContract()`) and carries `X` to `agent()`'s resolvers.
@@ -61,9 +61,9 @@ even when `M` declares its own data parts.
61
61
 
62
62
  ### `AIMessageOf<Events>`
63
63
 
64
- The `UIMessage` type a contract's `ai.message.created` schema carries, such as
65
- `AIMessageOf<typeof contract.events>`; `UIMessage` otherwise. `agent()` types
66
- messages with it.
64
+ The `UIMessage` type a contract's input commands (`ai.control.requested`)
65
+ carry, such as `AIMessageOf<typeof contract.events>`; `UIMessage` otherwise.
66
+ `agent()` types messages with it.
67
67
 
68
68
  ### `handlerContext(contract?)`
69
69
 
@@ -105,13 +105,13 @@ Pure typed builders of `ai.control.requested` commands, for `append()` and
105
105
  | `inputs.requestInput(request)` | requests application input from trusted server code |
106
106
  | `inputs.retry(options)` | retries a failed response |
107
107
  | `inputs.stop({ turnId, lastSeenIndex? })` | ends the observed turn and advances when admission is open |
108
- | `inputs.steer({ turnId, message, lastSeenIndex? })` | puts the new input first for a handoff after the observed turn's current step |
108
+ | `inputs.steer({ turnId, message, lastSeenIndex? })` | queues the new input right after the observed turn's earlier steering, or first, for a handoff after its current step; the turn's steering enters together, its last generating input starting the turn |
109
109
  | `inputs.queue.edit({ message })` | changes a pending input; its ID stays fixed |
110
110
  | `inputs.queue.move({ inputId, beforeId })` | moves pending input before an ID; `null` means the end |
111
111
  | `inputs.queue.remove({ inputId })` | removes pending input; its submission stays in the log |
112
- | `inputs.queue.sendNow({ inputId, turnId, lastSeenIndex? })` | promotes an existing queued input for a handoff after the current step; preserves pause |
112
+ | `inputs.queue.sendNow({ inputId, turnId, lastSeenIndex? })` | promotes an existing queued input, after the observed turn's earlier steering, for a handoff after the current step; preserves pause |
113
113
  | `inputs.pause({ when: 'now' \| 'after-turn' })` | pauses execution now or inbox admission after the turn |
114
- | `inputs.resume()` | resumes retained work, then inbox admission |
114
+ | `inputs.resume()` | resumes retained work, then inbox admission; a turn paused now with steering hands it off instead |
115
115
  | `inputs.toolResult(result)` | records a deferred provider-executed tool result from trusted server code |
116
116
  | `inputs.interrupt(options)` | interrupts a specific generation/request owner |
117
117
 
@@ -174,7 +174,9 @@ Pure typed builders of `ai.control.requested` commands, for `append()` and
174
174
  - **Interruption.** `ai.message.interrupted` is `{ messageId, requestId,
175
175
  reason?, lastSeenIndex? }` or `{ messageId, generationId, reason?,
176
176
  lastSeenIndex? }`: copy `activeRequestId` before the generation starts,
177
- `activeGeneration.generationId` after. A request-owned interruption survives
177
+ `activeGeneration.generationId` after. A2 writes the reason
178
+ `'steered after step'` itself when steering ends a response, so an
179
+ interruption you send can't use it. A request-owned interruption survives
178
180
  that request starting first. An older or mismatched message/owner pair is a
179
181
  no-op. An accepted one fences its owner for good: later events stay in raw
180
182
  history but cannot alter `AIState`, satisfy pending work, or reactivate the
@@ -249,7 +251,7 @@ extra I/O or raw-history reads.
249
251
  win.
250
252
  - Steer, and Send now against an active turn, show in `state.messages` after
251
253
  the current response, not the inbox, until admission; they stay out of the
252
- prompt. Several steering inputs show in admission order, most recent first,
254
+ prompt. Several steering inputs show in the order they were sent,
253
255
  and the display survives receipt and hydration, including while paused.
254
256
  - A pending Stop clears the displayed `state.active`, generation, and pending
255
257
  approvals and inputs, previews an idle status (paused when the inbox is), and
@@ -274,8 +276,8 @@ events. Encoding is internal; no event or tool payload is truncated.
274
276
  | Field | What it holds |
275
277
  | --- | --- |
276
278
  | `messages` | [`TranscriptMessage<M>`](#transcriptmessagem) values; server-side, without pending inputs |
277
- | `inbox` | `{ paused, items }`; items are `{ id, message, generate, afterStepOf? }`, where `afterStepOf` is an observed steering turn that hands off after its current step (removal cancels it, admission consumes it) |
278
- | `active` | `null` or `{ turnId, input, phase }`; admission freezes `input` |
279
+ | `inbox` | `{ paused, items }`; items are `{ id, role, message, generate, afterStepOf? }`, where `afterStepOf` is an observed steering turn that hands off after its current step (removal cancels it, admission consumes it) |
280
+ | `active` | `null` or `{ turnId, input, phase }`, where `input` is the admitted input's `{ id, role, generate }` |
279
281
  | `status` | generation status |
280
282
  | `starting` | a speculative optimistic turn |
281
283
  | `receipt` | the latest user command receipt |
@@ -311,7 +313,10 @@ See [What happens after a send](/agents/introduction#what-happens-after-a-send).
311
313
 
312
314
  `deriveUIMessages()` folds a raw event history into `TranscriptMessage<M>[]`.
313
315
  `reduceAIState()` is the event-at-a-time form the standard reducer uses. Both
314
- project the same chunk-only protocol synchronously.
316
+ project the same chunk-only protocol synchronously. A message's content is in
317
+ the command that brought it in, so a fold needs each `ai.message.created`'s
318
+ command before it; one without throws. Only A2's control lane writes
319
+ `ai.message.created`.
315
320
 
316
321
  ## `experimental-a2/ai/server`
317
322
 
@@ -357,7 +362,7 @@ stub.
357
362
  | `toolLane` | one lane for every local tool execution, one at a time in admission order; model steps keep their own lane |
358
363
  | `toolRecovery` | `'rerun'` (default) or `'report'` per tool, the harness's own tools included |
359
364
  | `handlers` | application event handlers; naming an event the agent handles throws a `TypeError` |
360
- | `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)) |
361
366
 
362
367
  **Contracts.** A contract not built by `agentContract()` throws a `TypeError`
363
368
  and fails to typecheck (serve custom contracts with `createServer()`). Messages
@@ -525,7 +530,11 @@ name the harness's own tools, such as Pi's `bash`. `model` is resolved per step
525
530
  as for any agent. `instructions` and `tools` reach the harness; how they
526
531
  combine with its own prompt and tools depends on the harness. Tool approval
527
532
  policy lives in `harness.generation`, so a harness agent's tools run without
528
- approval, and a tool that sets `needsApproval` is rejected.
533
+ approval, and a tool that sets `needsApproval` is rejected. So is a
534
+ provider-defined tool (`type: 'provider'`), which the engine can't run. A user
535
+ message reaches the harness as its text and its images, which A2 fetches from
536
+ their `https:` or `data:` URLs; any other file fails the turn. See
537
+ [Images and files](/agents/harnesses#images-and-files).
529
538
 
530
539
  A harness resumes after a crash at the interrupted step. Finished model calls
531
540
  are not made again and finished tool calls do not run again. A2 rebuilds the
@@ -589,8 +598,16 @@ continue a response, A2 restores FX's latest checkpoint and replays the
589
598
  response so far from the log: recorded model responses and tool results answer
590
599
  the kernel without network calls or tool runs, then FX makes its next model
591
600
  call. Replay grows with the response, roughly 0.1 to 0.5 seconds for one of 80
592
- to 200 tool calls. FX checkpoints hold at most 4 MiB of conversation; past that,
593
- A2 replays from an earlier checkpoint.
601
+ to 200 tool calls. When libfx can't take a checkpoint, A2 replays from an
602
+ earlier one.
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.
594
611
 
595
612
  Credentials come from `AI_GATEWAY_API_KEY`, or Vercel's OIDC token on Vercel.
596
613
  This entry point is server-only.
@@ -617,8 +634,8 @@ forbids, such as a property it does not list. A call to one of the agent's tools
617
634
  then goes through the tool's schema, as under `{ type: 'ai-sdk' }`: `execute`
618
635
  receives the schema's output, defaults included. Input either check rejects goes
619
636
  back to the model as an error, without running the tool. `reasoning` becomes
620
- Pi's thinking level, and `model` is one of the Gateway models Pi's catalog
621
- lists. Pi works in the sandbox's working directory; its `bash` runs with the
637
+ Pi's thinking level and `speed: 'fast'` asks AI Gateway for the model's fast
638
+ tier; `model` is one of the Gateway models Pi's catalog lists. Pi works in the sandbox's working directory; its `bash` runs with the
622
639
  sandbox's environment, never the server's. Pi compacts its own context; a
623
640
  compaction appears in the transcript as a `data-a2-compaction` part with its
624
641
  summary.
@@ -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 }