experimental-a2 0.18.0 → 0.19.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 (152) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/{actor-Bjce9_5U.d.ts → actor-C7b-1arn.d.ts} +3 -3
  3. package/dist/{actor-Bjce9_5U.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
  4. package/dist/actor-client.d.ts +1 -1
  5. package/dist/actor-react.d.ts +1 -1
  6. package/dist/actor-react.js +1 -1
  7. package/dist/actor.d.ts +1 -1
  8. package/dist/actor.js +2 -2
  9. package/dist/{ai-DrUaMyRt.d.ts → ai-Cz3hqVhk.d.ts} +34 -17
  10. package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
  11. package/dist/ai-client.d.ts +1 -1
  12. package/dist/ai-client.js +2 -2
  13. package/dist/ai-control-CvLbW10B.js +46 -0
  14. package/dist/ai-control-CvLbW10B.js.map +1 -0
  15. package/dist/ai-fx.d.ts +1 -1
  16. package/dist/ai-fx.d.ts.map +1 -1
  17. package/dist/ai-fx.js +122 -44
  18. package/dist/ai-fx.js.map +1 -1
  19. package/dist/{ai-harness-BuLh_EYi.js → ai-harness-BuuC-gJ3.js} +79 -15
  20. package/dist/ai-harness-BuuC-gJ3.js.map +1 -0
  21. package/dist/{ai-harness-DvsPeJeB.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
  22. package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
  23. package/dist/{ai-BpOGm-zB.js → ai-mMiWul1T.js} +117 -78
  24. package/dist/ai-mMiWul1T.js.map +1 -0
  25. package/dist/ai-pi.d.ts +1 -1
  26. package/dist/ai-pi.d.ts.map +1 -1
  27. package/dist/ai-pi.js +114 -23
  28. package/dist/ai-pi.js.map +1 -1
  29. package/dist/ai-react.d.ts +1 -1
  30. package/dist/ai-react.js +2 -2
  31. package/dist/ai-server.d.ts +3 -3
  32. package/dist/ai-server.d.ts.map +1 -1
  33. package/dist/ai-server.js +127 -55
  34. package/dist/ai-server.js.map +1 -1
  35. package/dist/ai-tool-input-DmSqMUQi.js +40 -0
  36. package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
  37. package/dist/ai.d.ts +2 -2
  38. package/dist/ai.js +1 -1
  39. package/dist/{client-BReKqwZC.js → client-BhHLoNKF.js} +3 -3
  40. package/dist/{client-BReKqwZC.js.map → client-BhHLoNKF.js.map} +1 -1
  41. package/dist/client.js +1 -1
  42. package/dist/index.d.ts +2 -2
  43. package/dist/{internal-B2T3U4Rq.js → internal-CscSY7K_.js} +4 -2
  44. package/dist/{internal-B2T3U4Rq.js.map → internal-CscSY7K_.js.map} +1 -1
  45. package/dist/react.js +1 -1
  46. package/dist/scheduler-qstash.d.ts +2 -2
  47. package/dist/scheduler-qstash.js +3 -3
  48. package/dist/scheduler-qstash.js.map +1 -1
  49. package/dist/scheduler-vercel.d.ts +7 -7
  50. package/dist/scheduler-vercel.d.ts.map +1 -1
  51. package/dist/scheduler-vercel.js +10 -14
  52. package/dist/scheduler-vercel.js.map +1 -1
  53. package/dist/{server-Dw_k_7kO.js → server-D-Y4pqwu.js} +37 -11
  54. package/dist/server-D-Y4pqwu.js.map +1 -0
  55. package/dist/{server-En7cbi3v.d.ts → server-DYAATFuT.d.ts} +11 -5
  56. package/dist/server-DYAATFuT.d.ts.map +1 -0
  57. package/dist/server.d.ts +2 -2
  58. package/dist/server.js +1 -1
  59. package/dist/{session-clear-Bo-XTFEy.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
  60. package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
  61. package/dist/{store-Djdv0bnZ.d.ts → store-CyLUy86v.d.ts} +23 -4
  62. package/dist/store-CyLUy86v.d.ts.map +1 -0
  63. package/dist/store-N8PXxDAS.js.map +1 -1
  64. package/dist/store-memory.d.ts +1 -1
  65. package/dist/store-memory.js +25 -6
  66. package/dist/store-memory.js.map +1 -1
  67. package/dist/{store-polling-B5FcwJfo.js → store-polling-7WJV0YWP.js} +2 -2
  68. package/dist/{store-polling-B5FcwJfo.js.map → store-polling-7WJV0YWP.js.map} +1 -1
  69. package/dist/store-postgres.d.ts +1 -1
  70. package/dist/store-postgres.d.ts.map +1 -1
  71. package/dist/store-postgres.js +55 -25
  72. package/dist/store-postgres.js.map +1 -1
  73. package/dist/{store-presence-polling-BAwS8RWn.js → store-presence-polling-B1ixeqIS.js} +3 -3
  74. package/dist/{store-presence-polling-BAwS8RWn.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
  75. package/dist/store-redis-http.d.ts +1 -1
  76. package/dist/store-redis-http.js +3 -3
  77. package/dist/{store-redis-notify-BRITti_o.js → store-redis-notify-CM0AfLY0.js} +88 -37
  78. package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
  79. package/dist/store-redis.d.ts +1 -1
  80. package/dist/store-redis.js +1 -1
  81. package/dist/store-sqlite.d.ts +1 -1
  82. package/dist/store-sqlite.js +35 -20
  83. package/dist/store-sqlite.js.map +1 -1
  84. package/dist/{wire-rSUglYo8.js → wire-BKxH1vbP.js} +2 -2
  85. package/dist/{wire-rSUglYo8.js.map → wire-BKxH1vbP.js.map} +1 -1
  86. package/docs/agents/03-conversations.mdx +27 -15
  87. package/docs/agents/04-harnesses.mdx +51 -5
  88. package/docs/contract/03-sessions.mdx +1 -1
  89. package/docs/index.mdx +3 -3
  90. package/docs/reference/02-server.mdx +12 -7
  91. package/docs/reference/04-ai.mdx +41 -23
  92. package/docs/reference/05-adapters.mdx +6 -6
  93. package/docs/server/01-handlers.mdx +1 -1
  94. package/docs/server/02-timers.mdx +1 -1
  95. package/docs/server/03-cancellation.mdx +3 -2
  96. package/docs/server/05-production.mdx +4 -3
  97. package/docs/server/06-guarantees.mdx +20 -11
  98. package/examples/playground/DEPLOY.md +11 -4
  99. package/examples/playground/app/a0/[appId]/page.tsx +2 -8
  100. package/examples/playground/app/a0/a0.css +244 -5
  101. package/examples/playground/app/a0/app-view.tsx +2 -1
  102. package/examples/playground/app/a0/attachments.ts +133 -0
  103. package/examples/playground/app/a0/chat.test.tsx +504 -0
  104. package/examples/playground/app/a0/chat.tsx +623 -63
  105. package/examples/playground/app/a0/context-menu.tsx +38 -11
  106. package/examples/playground/app/a0/devtools.tsx +2 -8
  107. package/examples/playground/app/a0/model.test.ts +28 -0
  108. package/examples/playground/app/a0/model.ts +30 -0
  109. package/examples/playground/app/a0/server.ts +13 -19
  110. package/examples/playground/app/a0/uploads/route.ts +29 -0
  111. package/examples/playground/app/agent/[agentId]/agent-client.tsx +2 -1
  112. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +1 -0
  113. package/examples/playground/app/agent/model.test.ts +12 -3
  114. package/examples/playground/app/chat/[chatId]/agent-state.test.ts +1 -5
  115. package/examples/playground/next.config.ts +7 -4
  116. package/examples/playground/package.json +3 -1
  117. package/package.json +1 -1
  118. package/src/ai-agent.ts +14 -3
  119. package/src/ai-client-state.ts +21 -9
  120. package/src/ai-context-schema.ts +28 -4
  121. package/src/ai-context.ts +83 -3
  122. package/src/ai-control-server.ts +70 -43
  123. package/src/ai-control-state.ts +3 -4
  124. package/src/ai-control.ts +59 -10
  125. package/src/ai-coordinator.ts +7 -6
  126. package/src/ai-fx.ts +184 -67
  127. package/src/ai-harness.ts +153 -29
  128. package/src/ai-message-shape.ts +41 -0
  129. package/src/ai-pi.ts +161 -46
  130. package/src/ai-stored-state.ts +1 -3
  131. package/src/ai-tool-input.ts +45 -0
  132. package/src/ai.ts +116 -77
  133. package/src/internal.ts +6 -0
  134. package/src/libfx.d.ts +8 -1
  135. package/src/scheduler-qstash.ts +5 -5
  136. package/src/scheduler-vercel.ts +15 -27
  137. package/src/server.ts +58 -12
  138. package/src/store-memory.ts +50 -9
  139. package/src/store-postgres.ts +79 -28
  140. package/src/store-redis-core.ts +112 -31
  141. package/src/store-sqlite.ts +61 -28
  142. package/src/store.ts +22 -3
  143. package/dist/ai-BpOGm-zB.js.map +0 -1
  144. package/dist/ai-DrUaMyRt.d.ts.map +0 -1
  145. package/dist/ai-harness-BuLh_EYi.js.map +0 -1
  146. package/dist/ai-harness-DvsPeJeB.d.ts.map +0 -1
  147. package/dist/server-Dw_k_7kO.js.map +0 -1
  148. package/dist/server-En7cbi3v.d.ts.map +0 -1
  149. package/dist/session-clear-Bo-XTFEy.d.ts.map +0 -1
  150. package/dist/store-Djdv0bnZ.d.ts.map +0 -1
  151. package/dist/store-redis-notify-BRITti_o.js.map +0 -1
  152. package/examples/playground/app/a0/no-photon.cjs +0 -4
@@ -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
 
@@ -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
@@ -197,9 +197,9 @@ derived view exactly. See [Cancellation](/server/cancellation).
197
197
  <details>
198
198
  <summary>What happens when a handler keeps failing?</summary>
199
199
 
200
- It retries with backoff, then dead-letters after ten caught failures. Later
201
- events in the same lane wait until you retry or skip it; unlaned events and
202
- other lanes continue.
200
+ It retries with backoff, then dead-letters after ten failed attempts, whether
201
+ the handler threw or its process ended. Later events in the same lane wait
202
+ until you retry or skip it; unlaned events and other lanes continue.
203
203
  [Guarantees](/server/guarantees) has the exact guarantees and the
204
204
  recovery story behind them.
205
205
 
@@ -252,7 +252,8 @@ server.drain(sessionId: string): Promise<{ settled: boolean }>
252
252
  ```
253
253
 
254
254
  Claims every eligible event and runs their handlers concurrently.
255
- `settled` means no actionable or live-claimed work remains. A dead-lettered
255
+ `settled` means no actionable or live-claimed work remains. Past the
256
+ invocation's deadline it claims nothing and returns `settled: false`. A dead-lettered
256
257
  event can block later work in its lane while other lanes continue, until
257
258
  [`session.redrive()`](#sessionredriveindex-options) resolves it. Scheduler
258
259
  callbacks use it; you'll rarely call it yourself. The public result stays this
@@ -343,6 +344,7 @@ interface A2Store {
343
344
  ttlMs: number
344
345
  expiresAtMs?: number
345
346
  excludeIndexes?: readonly number[]
347
+ maxFailures?: number
346
348
  }): Promise<StoreClaimAvailableResult>
347
349
 
348
350
  renewClaims(options: {
@@ -366,6 +368,7 @@ interface A2Store {
366
368
  attempt: number
367
369
  error: string
368
370
  maxFailures: number
371
+ retryAfterMs?: number
369
372
  }): Promise<{
370
373
  outcome: 'failed' | 'dead_lettered' | 'superseded'
371
374
  failureCount: number
@@ -376,10 +379,10 @@ interface A2Store {
376
379
  | Method | Atomic effect |
377
380
  | --- | --- |
378
381
  | `append` | Write a consecutive batch and report session-wide pending handler work as `hasPending`. Events with the internal `settled` flag get `processedAt` in the same transaction, with no dispatch attempt. An event with a `cause` commits only while `cause.attempt` is the parent's latest attempt and the parent isn't dead-lettered; otherwise throw `SUPERSEDED_ATTEMPT`. The check serializes against a concurrent claim, after the idempotent-replay path. |
379
- | `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`. |
382
+ | `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`. Taking over a lapsed claim, one its holder never completed or failed, adds one to `failureCount`; when that reaches `maxFailures`, dead-letter the event instead of claiming it: advance `attemptCount` to fence the lapsed attempt, leave `lastFailedAttempt` unchanged, and return it with `failedAt` set. A failure's retry hold has no holder, so it is no lapsed claim. |
380
383
  | `renewClaims` | Extend the listed live claims the holder still owns. `renewed` lists those; `superseded` lists claims whose event's `attemptCount` passed the listed attempt. An expired claim no successor took is in neither and may still complete; renewal never revives it. |
381
384
  | `completeAttempt` | In one transaction: fence on the current attempt, mark the parent processed, store its exact ordered `returnedEventIds`, and append the returned batch. A same-attempt retry returns the committed children; a stale attempt returns `superseded`. |
382
- | `failAttempt` | Record one current caught failure, clear its claim, and dead-letter at `maxFailures`. A repeated acknowledgment is idempotent; a stale attempt returns `superseded`. |
385
+ | `failAttempt` | Record one current caught failure, clear its claim, and dead-letter at `maxFailures`. Short of that, with `retryAfterMs` the event stays unclaimable for that long: set its claim expiry with no holder, so `claimAvailable` reports it `busy` until then. A repeated acknowledgment is idempotent; a stale attempt returns `superseded`. |
383
386
  | `clear` | Serialized with `append`. Check `ifLastIndex` against the head and the latest marker, then append a settled clear marker at the next index, touching no existing rows. A `cause` is fenced like a handler append. |
384
387
  | `reclaim` | Delete one bounded batch of rows below the latest clear marker, with their bookkeeping, event-id records, lane pointers, and cached state. Report how many rows went, and `done` when none are left. Never wait on a lock other methods hold. |
385
388
  | `deadLetters` | Return the unprocessed events with `failedAt` set above the latest clear marker, oldest first. |
@@ -412,7 +415,8 @@ stores support both.
412
415
  Stored fields:
413
416
 
414
417
  - `attemptCount` counts claims, including abandoned ones. `failureCount`
415
- counts caught failures and dead-letters at ten. See
418
+ counts failed attempts: caught failures, and lapsed claims a later claim
419
+ took over. Ten dead-letter the event. See
416
420
  [Guarantees](/server/guarantees#one-event-many-attempts).
417
421
  - `cause` names the event and handler attempt whose `ctx.session.append` or
418
422
  return value first persisted the child; `null` means a top-level event.
@@ -679,9 +683,10 @@ type DeadLetter = Event & {
679
683
  The session's dead-lettered events, oldest first, each with why it failed. See
680
684
  [When an event dead-letters](/server/production#3-when-an-event-dead-letters).
681
685
 
682
- - `failureCount` counts the caught failures that spent its budget;
683
- `attemptCount` also counts claims abandoned by hard kills. `lastError` is the
684
- last failure, stringified.
686
+ - `failureCount` counts the attempts that spent its budget: caught failures,
687
+ and attempts that ended before they finished. `attemptCount` counts every
688
+ claim. `lastError` is the last error a handler threw, stringified, or null
689
+ when every failed attempt ended before it finished.
685
690
  - Events at or below the latest clear marker are never listed.
686
691
  - Only on `server.session(id)`. `DeadLetter` is exported from
687
692
  `experimental-a2/server`.
@@ -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
 
@@ -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
@@ -577,7 +586,10 @@ fx(): Harness<'fx'>
577
586
  The [FX](https://fx.sh) harness for `agent({ harness })`, running FX's kernel
578
587
  from `libfx` (an optional peer dependency) in the server process. FX adds no
579
588
  system prompt and brings no tools: `instructions` is the whole system prompt
580
- and the agent's `tools` are all FX can call. `reasoning` becomes FX's effort and
589
+ and the agent's `tools` are all FX can call. A call's input goes through its
590
+ tool's schema, as under `{ type: 'ai-sdk' }`: `execute` receives the schema's
591
+ output, defaults included, and input the schema rejects goes back to the model
592
+ as an error, without running the tool. `reasoning` becomes FX's effort and
581
593
  `speed: 'fast'` its fast tier. FX compacts its own context; a compaction
582
594
  appears in the transcript as a `data-a2-compaction` part with its summary.
583
595
 
@@ -586,8 +598,8 @@ continue a response, A2 restores FX's latest checkpoint and replays the
586
598
  response so far from the log: recorded model responses and tool results answer
587
599
  the kernel without network calls or tool runs, then FX makes its next model
588
600
  call. Replay grows with the response, roughly 0.1 to 0.5 seconds for one of 80
589
- to 200 tool calls. FX checkpoints hold at most 4 MiB of conversation; past that,
590
- 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.
591
603
 
592
604
  Credentials come from `AI_GATEWAY_API_KEY`, or Vercel's OIDC token on Vercel.
593
605
  This entry point is server-only.
@@ -607,12 +619,18 @@ agent session from `@earendil-works/pi-coding-agent` (with
607
619
  `@earendil-works/pi-ai`, both optional peer dependencies). Pi brings its system
608
620
  prompt and its `read`, `write`, `edit`, and `bash` tools, which run in the
609
621
  session's sandbox. `instructions` is added to Pi's prompt. The agent's `tools`
610
- are added to Pi's; a tool with one of their names replaces Pi's. `reasoning`
611
- becomes Pi's thinking level, and `model` is one of the Gateway models Pi's
612
- catalog lists. Pi works in the sandbox's working directory; its `bash` runs
613
- with the sandbox's environment, never the server's. Pi compacts its own
614
- context; a compaction appears in the transcript as a `data-a2-compaction` part
615
- with its summary.
622
+ are added to Pi's; a tool with one of their names replaces Pi's. Every call
623
+ first passes Pi's own checks: Pi coerces its arguments to the tool's JSON Schema
624
+ (the string `"5"` becomes `5` for a number) and rejects what that schema
625
+ forbids, such as a property it does not list. A call to one of the agent's tools
626
+ then goes through the tool's schema, as under `{ type: 'ai-sdk' }`: `execute`
627
+ receives the schema's output, defaults included. Input either check rejects goes
628
+ back to the model as an error, without running the tool. `reasoning` becomes
629
+ Pi's thinking level and `speed: 'fast'` asks AI Gateway for the model's fast
630
+ 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
631
+ sandbox's environment, never the server's. Pi compacts its own context; a
632
+ compaction appears in the transcript as a `data-a2-compaction` part with its
633
+ summary.
616
634
 
617
635
  - `sandbox` (`HarnessSandbox`, given a `HarnessSandboxContext`) resolves the
618
636
  session's AI SDK sandbox session (`Experimental_SandboxSession` from `ai`).
@@ -104,8 +104,8 @@ mid-span.
104
104
  | `a2.ai.ack.report_id` | `a2.ai.ack` | start | work report event id, matching the control span's id |
105
105
 
106
106
  - Drain outcomes: `settled` means nothing actionable is left; `busy` means live
107
- per-event claims remain; `stalled` means a caught failure needs a later
108
- retry.
107
+ per-event claims or a failed event's retry hold remain; `stalled` means a
108
+ caught failure in this drain needs a later retry.
109
109
  - A failing handler marks its `a2.event` span with the exception and error
110
110
  status. Alert on `a2.event.outcome = dead_lettered`. A dead-lettered event
111
111
  blocks only later events in its lane, until `session.redrive()` resolves it;
@@ -189,8 +189,8 @@ export const scheduler = vercelQueues()
189
189
  Deploymentless delivery lets Vercel choose an eligible consumer, not the
190
190
  latest production deployment; every eligible consumer must use
191
191
  `deploymentId: null` and stay compatible with pending tasks and session work.
192
- - Tasks are retained for seven days. Delays are accepted through six days,
193
- leaving the final day for delivery and retries.
192
+ - Tasks are retained for seven days, Vercel Queues' maximum. A delay goes out
193
+ as asked; Vercel Queues refuses one longer than the retention.
194
194
  - `transport` and `clock` are advanced injection seams for tests and custom
195
195
  delivery. An injected transport receives the topic, an exact
196
196
  `SchedulerTask`, and send options, and owns its provider configuration;
@@ -294,8 +294,8 @@ export const POST = scheduler.handler(ordersServer, billingServer)
294
294
  signatures return 401.
295
295
  - A signed malformed envelope, or a declared or streamed body over
296
296
  `maxBodyBytes`, returns 489 with `Upstash-NonRetryable-Error: true`.
297
- - Returns 200 when settled; when busy, after a durable successor watchdog
298
- exists; when stalled, after publishing a fresh successor, which resets the
297
+ - Returns 200 when settled; when busy or stalled, after a durable successor
298
+ watchdog exists, so each caught failure gets a fresh message and a fresh
299
299
  per-message retry budget. Append tasks return 200 after their fixed-id append
300
300
  succeeds and any recovery watchdog for pending handler work is durable; clear
301
301
  tasks after their conditional clear and its deletion finish.
@@ -192,7 +192,7 @@ records completion runs the handler again.
192
192
  ## When handlers throw
193
193
 
194
194
  Throwing means "retry me." A2 records the failure and retries with backoff.
195
- The failed event blocks only its lane. After ten caught failures it
195
+ The failed event blocks only its lane. After ten failed attempts it
196
196
  dead-letters and keeps blocking its lane until
197
197
  [you retry or skip it](/server/production#3-when-an-event-dead-letters).
198
198
 
@@ -119,7 +119,7 @@ handler schedule the next one.
119
119
 
120
120
  | Adapter | Maximum one-shot delay |
121
121
  | --- | --- |
122
- | Vercel Queues | 6 days |
122
+ | Vercel Queues | 7 days |
123
123
  | QStash free | 7 days |
124
124
  | QStash usage-based | 1 year |
125
125
  | QStash fixed | no fixed maximum |
@@ -70,8 +70,9 @@ what stops it racing its successor outside A2.
70
70
  Pass `ctx.signal` to your I/O and let the abort propagate. That handles every
71
71
  case:
72
72
 
73
- - **A propagated lease abort** surrenders the attempt: no completion, no
74
- failure budget spent, normal recovery.
73
+ - **A propagated lease abort** surrenders the attempt: no completion, and
74
+ normal recovery. Like a crash, it costs the event one attempt of its
75
+ failure budget.
75
76
  - **A user cancellation** (`abortOn`) is the one to catch and return normally
76
77
  from. The stop is the outcome, so the completion counts.
77
78
  - **Return normally after a lease abort** only when the work is done. A lapsed
@@ -143,8 +143,8 @@ The trigger is required. It connects the route to Vercel Queues and makes the
143
143
  function private, so only queue infrastructure can invoke it. Don't expose the
144
144
  callback as a public route or wrap it in a second authentication scheme.
145
145
 
146
- The adapter keeps messages for 7 days (Vercel Queues' default is 24 hours) and
147
- accepts scheduled appends and clears up to 6 days out.
146
+ The adapter keeps messages for 7 days (Vercel Queues' default is 24 hours), so
147
+ scheduled appends and clears can be up to 7 days out.
148
148
 
149
149
  ### QStash
150
150
 
@@ -241,7 +241,8 @@ import { ordersServer } from '@/server'
241
241
  const order = ordersServer.session(orderId)
242
242
  const [dead] = await order.deadLetters()
243
243
  if (dead) {
244
- // dead.lastError says why; deploy the handler fix first, then:
244
+ // dead.lastError says why (null if every attempt timed out or crashed);
245
+ // deploy the handler fix first, then:
245
246
  await order.redrive(dead.index, { action: 'retry' })
246
247
  }
247
248
  ```
@@ -22,8 +22,9 @@ and the log decides what remains. Reads never dispatch handlers.
22
22
  Each claim expires. If its holder dies, the claim lapses and the next wakeup
23
23
  claims the event again with a larger `ctx.attempt`. The retry gets the same
24
24
  event id, index, type, and payload. A kill after the claim can use up an
25
- attempt before your code runs, but it doesn't count as a caught failure. An
26
- older attempt can never overwrite a newer one's completion or failure.
25
+ attempt before your code runs, and that attempt counts toward the event's
26
+ [failure budget](#when-a-handler-keeps-failing). An older attempt can never
27
+ overwrite a newer one's completion or failure.
27
28
 
28
29
  :::note[Recovery is not an event]
29
30
  A2 does not append `recovered` or `continued`. Recovery retries the same event.
@@ -86,20 +87,28 @@ keeps its original cause.
86
87
  ## A chain that crosses the function timeout
87
88
 
88
89
  A function killed mid-handler restarts that handler from its entry point in a
89
- fresh invocation: same event, new attempt, no caught failure. Events already
90
- processed stay processed, so a chain of handlers can outlast one invocation,
91
- but each handler must fit in a fresh one. Split longer work into smaller
92
- events or increase the function duration. On Vercel, A2 caps the claim window
90
+ fresh invocation: same event, new attempt. Events already processed stay
91
+ processed, so a chain of handlers can outlast one invocation, but each handler
92
+ must fit in a fresh one: an attempt that runs out of time is a failed attempt,
93
+ and a handler that never fits dead-letters after ten. Split longer work into
94
+ smaller events or increase the function duration. On Vercel, A2 caps the claim window
93
95
  at the function deadline when the platform exposes it, so a retry starts
94
- promptly after a timeout.
96
+ promptly after a timeout. Once the deadline passes, A2 claims nothing more in
97
+ that invocation, so a timeout costs the event one attempt.
95
98
 
96
99
  ## When a handler keeps failing
97
100
 
98
101
  A caught failure counts against that event, releases its claim, and leaves
99
- other eligible events running. A2 retries it with backoff. Ten caught failures
100
- dead-letter the event: later events in its lane stay blocked, while unlaned
101
- events and other lanes continue. Hard kills don't consume this budget.
102
- The event waits until you
102
+ other eligible events running. A2 holds it back for a delay that starts at
103
+ one second and doubles with each failed attempt up to 30 seconds, then retries
104
+ it on the session's next wake. With a scheduler, that's a few seconds at first,
105
+ then the delay itself. An attempt that ends before it finishes, because its
106
+ process timed out, crashed, or lost its claim, counts too, once the next
107
+ attempt takes its claim over. Ten failed attempts dead-letter the event: later
108
+ events in its lane stay blocked, while unlaned events and other lanes continue.
109
+ A crash costs every event it interrupts one attempt, so a session whose work
110
+ keeps outrunning its function dead-letters: bound its fan-out, or put work that
111
+ waits on the same resource in one lane. The event waits until you
103
112
  [retry or skip it](/server/production#3-when-an-event-dead-letters) with
104
113
  `session.redrive()`.
105
114
 
@@ -116,12 +116,19 @@ of a public deployment. A0's sandboxes are named `a2-a0-*`, which is where to
116
116
  look when cleaning up. The sidebar lists the apps this browser opened, from a
117
117
  cookie.
118
118
 
119
+ Files attached in A0's chat upload from the browser straight to a public
120
+ Vercel Blob store, through a token from `/a0/uploads`. Connect a Blob store to
121
+ the project, which sets `BLOB_READ_WRITE_TOKEN`; `vercel env pull` brings it
122
+ to local runs. Images go to the model with the message; other files go into
123
+ its text as links, which the agent downloads in its sandbox. Each lives at an
124
+ unguessable public URL under `a0/<app id>/`, which anyone with the URL can
125
+ read. Nothing deletes them: the conversation that stays in the log still links
126
+ them.
127
+
119
128
  A0's dev tools have a Crash button, which ends the function instance running
120
129
  an app's agent, mid-turn, so the scheduler resumes the turn in a fresh one.
121
- Crashing ends everything else that instance is serving, so only preview
122
- deployments allow it, behind Deployment Protection. Set `A0_CRASHES=on` to
123
- allow it anywhere else. Under `next dev` that takes the dev server down with
124
- it, so it's for trying recovery, not for everyday local runs.
130
+ Crashing ends everything else that instance is serving, and under `next dev`
131
+ it takes the dev server down with it.
125
132
 
126
133
  ## 6. WebSockets for the canvas
127
134
 
@@ -4,7 +4,7 @@ import { AppView } from '../app-view'
4
4
  import { isAppId } from '../apps'
5
5
  import { DevTools } from '../devtools'
6
6
  import { harnessFrom, logPage } from '../model'
7
- import { agentServers, crashesAllowed } from '../server'
7
+ import { agentServers } from '../server'
8
8
  import { SessionProvider } from '../session'
9
9
 
10
10
  /** An app, from its current state; the provider follows its log from there. */
@@ -40,13 +40,7 @@ export default async function A0App({
40
40
  <AppView
41
41
  appId={appId}
42
42
  harness={harness}
43
- devtools={
44
- <DevTools
45
- appId={appId}
46
- deployed={!!process.env.VERCEL}
47
- crashesAllowed={crashesAllowed}
48
- />
49
- }
43
+ devtools={<DevTools appId={appId} deployed={!!process.env.VERCEL} />}
50
44
  />
51
45
  </SessionProvider>
52
46
  )