experimental-a2 0.17.0 → 0.18.1

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 (221) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-DBbe5xeE.d.ts → actor-C7b-1arn.d.ts} +5 -5
  4. package/dist/{actor-DBbe5xeE.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-react.d.ts +3 -3
  7. package/dist/actor-react.js +1 -1
  8. package/dist/actor.d.ts +1 -1
  9. package/dist/actor.js +2 -2
  10. package/dist/{ai-vpGtcaCB.d.ts → ai-BMLlI2Ff.d.ts} +3 -3
  11. package/dist/{ai-vpGtcaCB.d.ts.map → ai-BMLlI2Ff.d.ts.map} +1 -1
  12. package/dist/{ai-UrWfKeuv.js → ai-D6mGlO8j.js} +2 -2
  13. package/dist/{ai-UrWfKeuv.js.map → ai-D6mGlO8j.js.map} +1 -1
  14. package/dist/ai-client.d.ts +2 -2
  15. package/dist/ai-client.js +2 -2
  16. package/dist/ai-fx.browser.d.ts +1 -0
  17. package/dist/ai-fx.browser.js +6 -0
  18. package/dist/ai-fx.browser.js.map +1 -0
  19. package/dist/ai-fx.d.ts +10 -0
  20. package/dist/ai-fx.d.ts.map +1 -0
  21. package/dist/ai-fx.js +692 -0
  22. package/dist/ai-fx.js.map +1 -0
  23. package/dist/ai-harness-B2suP2Qh.d.ts +26 -0
  24. package/dist/ai-harness-B2suP2Qh.d.ts.map +1 -0
  25. package/dist/ai-harness-BuLh_EYi.js +273 -0
  26. package/dist/ai-harness-BuLh_EYi.js.map +1 -0
  27. package/dist/ai-pi.browser.d.ts +1 -0
  28. package/dist/ai-pi.browser.js +6 -0
  29. package/dist/ai-pi.browser.js.map +1 -0
  30. package/dist/ai-pi.d.ts +17 -0
  31. package/dist/ai-pi.d.ts.map +1 -0
  32. package/dist/ai-pi.js +950 -0
  33. package/dist/ai-pi.js.map +1 -0
  34. package/dist/ai-react.d.ts +5 -5
  35. package/dist/ai-react.js +2 -2
  36. package/dist/ai-server.d.ts +47 -25
  37. package/dist/ai-server.d.ts.map +1 -1
  38. package/dist/ai-server.js +113 -40
  39. package/dist/ai-server.js.map +1 -1
  40. package/dist/ai-tool-input-DmSqMUQi.js +40 -0
  41. package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
  42. package/dist/ai.d.ts +1 -1
  43. package/dist/ai.js +1 -1
  44. package/dist/bounded-cache-CPHWF-8h.js +41 -0
  45. package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
  46. package/dist/{client-COaiaDub.js → client-BhHLoNKF.js} +3 -3
  47. package/dist/{client-COaiaDub.js.map → client-BhHLoNKF.js.map} +1 -1
  48. package/dist/{client-qRnRUzBS.d.ts → client-h_DKFAjj.d.ts} +3 -3
  49. package/dist/{client-qRnRUzBS.d.ts.map → client-h_DKFAjj.d.ts.map} +1 -1
  50. package/dist/client.d.ts +3 -3
  51. package/dist/client.js +1 -1
  52. package/dist/index.d.ts +5 -5
  53. package/dist/{initial-snapshot-BrLso5x_.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +2 -2
  54. package/dist/{initial-snapshot-BrLso5x_.d.ts.map → initial-snapshot-Bx-c0pDP.d.ts.map} +1 -1
  55. package/dist/{internal-CbVvEwad.js → internal-CscSY7K_.js} +11 -3
  56. package/dist/internal-CscSY7K_.js.map +1 -0
  57. package/dist/otel.d.ts +1 -1
  58. package/dist/{react-BbJ-g6Uu.d.ts → react-BBXgHWFc.d.ts} +4 -4
  59. package/dist/{react-BbJ-g6Uu.d.ts.map → react-BBXgHWFc.d.ts.map} +1 -1
  60. package/dist/react.d.ts +1 -1
  61. package/dist/react.js +1 -1
  62. package/dist/reducer-BAXc8JyF.js.map +1 -1
  63. package/dist/{reducer-BYu6DT7h.d.ts → reducer-CqA59Q6U.d.ts} +2 -2
  64. package/dist/{reducer-BYu6DT7h.d.ts.map → reducer-CqA59Q6U.d.ts.map} +1 -1
  65. package/dist/scheduler-qstash.d.ts +2 -2
  66. package/dist/scheduler-qstash.js +3 -3
  67. package/dist/scheduler-qstash.js.map +1 -1
  68. package/dist/scheduler-vercel.d.ts +7 -7
  69. package/dist/scheduler-vercel.d.ts.map +1 -1
  70. package/dist/scheduler-vercel.js +9 -10
  71. package/dist/scheduler-vercel.js.map +1 -1
  72. package/dist/{server-Rj64fNvX.js → server-D-Y4pqwu.js} +126 -67
  73. package/dist/server-D-Y4pqwu.js.map +1 -0
  74. package/dist/{server-CGM8WzRG.d.ts → server-DYAATFuT.d.ts} +42 -7
  75. package/dist/server-DYAATFuT.d.ts.map +1 -0
  76. package/dist/server.d.ts +4 -4
  77. package/dist/server.js +1 -1
  78. package/dist/{session-clear-Dshpuhff.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
  79. package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
  80. package/dist/{store-Gpkq7WBo.d.ts → store-CyLUy86v.d.ts} +53 -5
  81. package/dist/store-CyLUy86v.d.ts.map +1 -0
  82. package/dist/store-N8PXxDAS.js.map +1 -1
  83. package/dist/store-memory.d.ts +1 -1
  84. package/dist/store-memory.js +56 -11
  85. package/dist/store-memory.js.map +1 -1
  86. package/dist/{store-polling-4_y8lETm.js → store-polling-7WJV0YWP.js} +2 -2
  87. package/dist/{store-polling-4_y8lETm.js.map → store-polling-7WJV0YWP.js.map} +1 -1
  88. package/dist/store-postgres.d.ts +1 -1
  89. package/dist/store-postgres.d.ts.map +1 -1
  90. package/dist/store-postgres.js +106 -25
  91. package/dist/store-postgres.js.map +1 -1
  92. package/dist/{store-presence-polling-Q1C-k2dd.js → store-presence-polling-B1ixeqIS.js} +3 -3
  93. package/dist/{store-presence-polling-Q1C-k2dd.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
  94. package/dist/store-redis-http.d.ts +1 -1
  95. package/dist/store-redis-http.js +3 -3
  96. package/dist/{store-redis-notify-tqWNnMRu.js → store-redis-notify-CM0AfLY0.js} +271 -101
  97. package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
  98. package/dist/store-redis.d.ts +1 -1
  99. package/dist/store-redis.js +1 -1
  100. package/dist/store-sqlite.d.ts +1 -1
  101. package/dist/store-sqlite.js +66 -21
  102. package/dist/store-sqlite.js.map +1 -1
  103. package/dist/{telemetry-cZH-cibj.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
  104. package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
  105. package/dist/{wire-DjfHOvdW.js → wire-BKxH1vbP.js} +2 -2
  106. package/dist/{wire-DjfHOvdW.js.map → wire-BKxH1vbP.js.map} +1 -1
  107. package/docs/01-quickstart.mdx +49 -83
  108. package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
  109. package/docs/actors/01-introduction.mdx +48 -86
  110. package/docs/actors/02-concurrency.mdx +43 -70
  111. package/docs/actors/03-timers.mdx +28 -42
  112. package/docs/actors/04-routes.mdx +110 -150
  113. package/docs/actors/meta.ts +1 -1
  114. package/docs/agents/01-introduction.mdx +257 -0
  115. package/docs/agents/02-tools.mdx +247 -0
  116. package/docs/agents/03-conversations.mdx +538 -0
  117. package/docs/agents/04-harnesses.mdx +136 -0
  118. package/docs/agents/meta.ts +1 -0
  119. package/docs/client/01-react.mdx +294 -0
  120. package/docs/client/02-transports.mdx +86 -0
  121. package/docs/client/03-presence.mdx +224 -0
  122. package/docs/client/meta.ts +1 -0
  123. package/docs/contract/01-events.mdx +57 -0
  124. package/docs/contract/02-state.mdx +142 -0
  125. package/docs/contract/03-sessions.mdx +175 -0
  126. package/docs/index.mdx +109 -145
  127. package/docs/reference/01-core.mdx +139 -0
  128. package/docs/reference/02-server.mdx +860 -0
  129. package/docs/reference/03-client.mdx +301 -0
  130. package/docs/reference/04-ai.mdx +731 -0
  131. package/docs/reference/05-adapters.mdx +351 -0
  132. package/docs/reference/06-errors.mdx +138 -0
  133. package/docs/reference/meta.ts +1 -1
  134. package/docs/server/01-handlers.mdx +200 -0
  135. package/docs/server/02-timers.mdx +130 -0
  136. package/docs/server/03-cancellation.mdx +142 -0
  137. package/docs/server/04-routes.mdx +243 -0
  138. package/docs/server/05-production.mdx +323 -0
  139. package/docs/server/06-guarantees.mdx +132 -0
  140. package/examples/playground/DEPLOY.md +3 -5
  141. package/examples/playground/app/a0/[appId]/page.tsx +12 -13
  142. package/examples/playground/app/a0/app-list.tsx +9 -2
  143. package/examples/playground/app/a0/app-view.tsx +9 -1
  144. package/examples/playground/app/a0/apps/[appId]/route.ts +10 -5
  145. package/examples/playground/app/a0/apps.test.ts +12 -0
  146. package/examples/playground/app/a0/apps.ts +22 -7
  147. package/examples/playground/app/a0/chat.tsx +47 -1
  148. package/examples/playground/app/a0/devtools.tsx +2 -8
  149. package/examples/playground/app/a0/fx/events/route.ts +9 -0
  150. package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
  151. package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
  152. package/examples/playground/app/a0/model.test.ts +18 -1
  153. package/examples/playground/app/a0/model.ts +47 -10
  154. package/examples/playground/app/a0/no-photon.cjs +4 -0
  155. package/examples/playground/app/a0/no-quickjs.cjs +3 -0
  156. package/examples/playground/app/a0/pi/events/route.ts +9 -0
  157. package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
  158. package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
  159. package/examples/playground/app/a0/sandbox-log.ts +10 -4
  160. package/examples/playground/app/a0/sandbox-session.ts +152 -0
  161. package/examples/playground/app/a0/scheduler/route.ts +2 -2
  162. package/examples/playground/app/a0/server.ts +98 -54
  163. package/examples/playground/app/a0/session.ts +17 -5
  164. package/examples/playground/app/a0/shell.tsx +11 -3
  165. package/examples/playground/app/agent/ingress.test.ts +6 -3
  166. package/examples/playground/app/agent/server.ts +9 -6
  167. package/examples/playground/app/agent/telemetry.test.ts +71 -50
  168. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +1 -1
  169. package/examples/playground/app/chat/server.test.ts +30 -26
  170. package/examples/playground/app/chat/server.ts +46 -46
  171. package/examples/playground/next.config.ts +17 -5
  172. package/examples/playground/package.json +4 -1
  173. package/examples/playground/vercel.json +18 -0
  174. package/package.json +27 -3
  175. package/src/ai-agent.ts +271 -90
  176. package/src/ai-fx.browser.ts +5 -0
  177. package/src/ai-fx.ts +912 -0
  178. package/src/ai-harness.ts +518 -0
  179. package/src/ai-model-metadata.ts +11 -6
  180. package/src/ai-pi.browser.ts +5 -0
  181. package/src/ai-pi.ts +1322 -0
  182. package/src/ai-sdk-step.ts +2 -2
  183. package/src/ai-server.ts +3 -1
  184. package/src/ai-tool-input.ts +45 -0
  185. package/src/internal.ts +13 -0
  186. package/src/libfx.d.ts +34 -0
  187. package/src/reducer.ts +1 -1
  188. package/src/scheduler-qstash.ts +5 -5
  189. package/src/scheduler-vercel.ts +14 -15
  190. package/src/server.ts +233 -29
  191. package/src/store-memory.ts +92 -16
  192. package/src/store-postgres.ts +156 -25
  193. package/src/store-redis-core.ts +322 -110
  194. package/src/store-redis-notify.ts +2 -0
  195. package/src/store-sqlite.ts +121 -28
  196. package/src/store.ts +55 -3
  197. package/src/telemetry.ts +1 -0
  198. package/dist/internal-CbVvEwad.js.map +0 -1
  199. package/dist/server-CGM8WzRG.d.ts.map +0 -1
  200. package/dist/server-Rj64fNvX.js.map +0 -1
  201. package/dist/session-clear-Dshpuhff.d.ts.map +0 -1
  202. package/dist/store-Gpkq7WBo.d.ts.map +0 -1
  203. package/dist/store-redis-notify-tqWNnMRu.js.map +0 -1
  204. package/dist/telemetry-cZH-cibj.d.ts.map +0 -1
  205. package/docs/concepts/01-contracts.mdx +0 -237
  206. package/docs/concepts/02-handlers.mdx +0 -284
  207. package/docs/concepts/03-durability.mdx +0 -314
  208. package/docs/concepts/04-state.mdx +0 -209
  209. package/docs/guides/01-timers.mdx +0 -193
  210. package/docs/guides/02-cancellation.mdx +0 -161
  211. package/docs/guides/03-react.mdx +0 -324
  212. package/docs/guides/04-local-first.mdx +0 -47
  213. package/docs/guides/05-production.mdx +0 -534
  214. package/docs/guides/06-ai-agents.mdx +0 -1408
  215. package/docs/guides/08-application-data.mdx +0 -113
  216. package/docs/guides/09-presence.mdx +0 -262
  217. package/docs/guides/10-transports.mdx +0 -221
  218. package/docs/reference/01-api.mdx +0 -2358
  219. package/docs/reference/02-errors.mdx +0 -150
  220. /package/docs/{concepts → contract}/meta.ts +0 -0
  221. /package/docs/{guides → server}/meta.ts +0 -0
@@ -0,0 +1,538 @@
1
+ ---
2
+ title: Conversations
3
+ description: "Compact long conversations, add your own events and state, and control delivery, the inbox, and turns."
4
+ ---
5
+
6
+ ## Compact long conversations
7
+
8
+ ```ts server/compacted.ts
9
+ import { agent } from 'experimental-a2/ai/server'
10
+
11
+ export const compactedAssistant = agent({
12
+ name: 'compacted-agent',
13
+ model: 'openai/gpt-5.6-terra',
14
+ harness: {
15
+ type: 'ai-sdk',
16
+ // optional: compaction is automatic for supported Gateway models
17
+ compaction: {
18
+ thresholdTokens: 80_000,
19
+ instructions: 'Preserve exact IDs.',
20
+ },
21
+ },
22
+ })
23
+ ```
24
+
25
+ Compaction belongs to the AI SDK harness, and is automatic for supported
26
+ Gateway models. When the estimated prompt crosses the threshold, A2 sends one
27
+ summary request to the active model with the same instructions, tools, and
28
+ provider settings, keeping the prefix cacheable.
29
+ Later requests get the summary plus everything after it; the raw log and
30
+ `state.messages` stay complete, and the summary request never appears there as
31
+ a turn. Successful compaction is permanent across failure, interruption, retry,
32
+ and recovery, and a failed summary leaves the previous context intact.
33
+
34
+ The default threshold is 75% of the context window from the Gateway model
35
+ catalog, lowered for a larger `generation.maxOutputTokens`; it limits input
36
+ context, not output. `thresholdTokens` overrides it, `instructions` adds
37
+ guidance to the summary prompt, and `false` turns off compaction and the catalog
38
+ lookup, keeping recorded summaries. A resolver can choose per generation, for
39
+ example from `session.state(settingsReducer)`. Custom `shouldCompact(context)`
40
+ and `compact(context)` callbacks receive the typed messages and the model-ready
41
+ `modelMessages`, summary included.
42
+
43
+ An AI SDK global provider that resolves model IDs to models outside AI
44
+ Gateway, and Gateway fallback model lists, need an explicit `thresholdTokens`
45
+ (for a fallback list, one that fits every model it may use). A custom `generate` keeps compaction off unless you
46
+ configure it. Set a threshold too when importing a large conversation: a
47
+ session does not compact before the catalog lookup lands, so an oversized first
48
+ prompt is sent as-is, never truncated, and fails with the provider's error.
49
+ Provider-executed tools, structured output, and forced tool choices skip
50
+ automatic compaction, and configuring it for them throws; use custom callbacks.
51
+
52
+ The first generation with each Gateway model in a session records
53
+ `ai.model.metadata.requested`; a separate handler fetches the catalog and
54
+ records `ai.model.metadata.resolved`, without delaying or failing the
55
+ generation. `state.modelMetadata` holds each model's limits and status:
56
+ pending, resolved, or unavailable (absent from the catalog). A lookup that
57
+ exhausted its retries stays pending without a new lookup each turn, so inspect
58
+ that handler's failure or set a threshold. The estimate is serialized UTF-8
59
+ bytes divided by four, calibrated by the previous same-model call's usage. It is
60
+ not a tokenizer, makes no remote calls, and does not precisely measure new image
61
+ or audio tokens.
62
+
63
+ Compaction waits until earlier tool calls have final results and all A2-owned
64
+ tool work has settled, including work from stopped or superseded turns, with one
65
+ extra state read before each candidate compaction. Dead-lettered tool work
66
+ defers it until repair or session close. Queued messages stay queued. The
67
+ summary request runs none of your generation callbacks, stream transforms, or
68
+ tool hooks and never executes local tools; an empty summary or a summary tool
69
+ call fails the generation, as do provider context-limit errors. See
70
+ [`agent()`](/reference/ai#agentoptions) for every option.
71
+
72
+ ### Show compaction in the conversation
73
+
74
+ ```tsx app/agent/message-view.tsx
75
+ 'use client'
76
+ import type { UIMessage } from 'ai'
77
+ import { isCompactionPart, type TranscriptMessage } from 'experimental-a2/ai'
78
+
79
+ const compactionLabel = {
80
+ running: 'Compacting…',
81
+ completed: 'Context compacted',
82
+ failed: 'Compaction did not complete',
83
+ interrupted: 'Compaction stopped',
84
+ }
85
+
86
+ export function MessageView({
87
+ message,
88
+ }: {
89
+ message: TranscriptMessage<UIMessage>
90
+ }) {
91
+ return (
92
+ <article>
93
+ {message.parts.map((part, index) => {
94
+ if (isCompactionPart(part))
95
+ return (
96
+ <details key={index}>
97
+ <summary>{compactionLabel[part.data.status]}</summary>
98
+ {part.data.summary ? <p>{part.data.summary}</p> : null}
99
+ </details>
100
+ )
101
+ return part.type === 'text' ? <p key={index}>{part.text}</p> : null
102
+ })}
103
+ </article>
104
+ )
105
+ }
106
+ ```
107
+
108
+ The reducer marks each compaction with a `data-a2-compaction` part in the
109
+ assistant response. Its `data` holds a `status` (`running`, then `completed`,
110
+ `failed`, or `interrupted`) and, when known, `inputTokens`, `thresholdTokens`,
111
+ `summary`, and `usage`. The part lives in state, so it survives server
112
+ rendering, reloads, and other tabs, and never enters model context. Because
113
+ `state.messages` is `TranscriptMessage<M>[]`, `isCompactionPart()` narrows even
114
+ when your message type declares its own data parts. `state.compaction` holds the
115
+ current or latest compaction, and `indicator` is `compacting` while one runs.
116
+ See [`isCompactionPart()`](/reference/ai#iscompactionpartpart) for how the part
117
+ settles.
118
+
119
+ ## Store assistant metadata
120
+
121
+ ```ts server/metadata-assistant.ts
122
+ import type { LanguageModelUsage, UIMessage } from 'ai'
123
+ import { agentContract } from 'experimental-a2/ai'
124
+ import { agent } from 'experimental-a2/ai/server'
125
+
126
+ export type MetadataAssistantMessage = UIMessage<{
127
+ status: 'writing' | 'complete'
128
+ usage?: LanguageModelUsage
129
+ }>
130
+
131
+ export const metadataAssistant = agent({
132
+ contract: agentContract<MetadataAssistantMessage>({
133
+ name: 'metadata-assistant',
134
+ }),
135
+ model: 'openai/gpt-5.6-terra',
136
+ harness: {
137
+ type: 'ai-sdk',
138
+ messageMetadata: ({ part }) => {
139
+ if (part.type === 'start') return { status: 'writing' as const }
140
+ if (part.type === 'finish') {
141
+ return { status: 'complete' as const, usage: part.totalUsage }
142
+ }
143
+ return undefined
144
+ },
145
+ },
146
+ })
147
+ ```
148
+
149
+ A narrower `UIMessage` type, carried by `agentContract()`, keeps generation
150
+ status and usage on the assistant message. The synchronous
151
+ `harness.messageMetadata` callback gets the `generate` context plus the current `TextStreamPart` as
152
+ `part`. What it returns for `start` and `finish` is logged with the stream and
153
+ shallow-merged onto the assistant message, so the example shows `writing` at
154
+ once and keeps final usage. `useAgent<typeof metadataAssistant>()` types
155
+ `messages` with it.
156
+
157
+ ## Add your own events and state
158
+
159
+ Apps carry more than the conversation: a plan, a selected ticket, a chosen
160
+ model. Put those events and their state in a shared `agentContract()`. It takes
161
+ a core `contract()`'s `events`, and its `reducer` takes the same `name`,
162
+ `initialState`, and `fold`:
163
+
164
+ ```ts support-desk.ts
165
+ import { z } from 'zod'
166
+ import { agentContract } from 'experimental-a2/ai'
167
+
168
+ const noFlags: string[] = []
169
+
170
+ export const supportDesk = agentContract({
171
+ name: 'support-desk',
172
+ events: {
173
+ 'plan.changed': z.object({ plan: z.enum(['free', 'pro']) }),
174
+ 'ticket.flagged': z.object({ ticketId: z.string() }),
175
+ },
176
+ reducer: {
177
+ name: 'support-desk.v1',
178
+ initialState: { plan: 'free' as 'free' | 'pro', flagged: noFlags },
179
+ fold: (state, event) =>
180
+ event.type === 'plan.changed'
181
+ ? { ...state, plan: event.payload.plan }
182
+ : event.type === 'ticket.flagged'
183
+ ? { ...state, flagged: [...state.flagged, event.payload.ticketId] }
184
+ : state,
185
+ },
186
+ })
187
+ ```
188
+
189
+ Keep this module isomorphic. `fold` sees your state and every event, never the
190
+ transcript. The session's state is `AIState` merged with yours, so a key that
191
+ shadows one of `AIState`'s is a type error. The reducer's `name` is its snapshot
192
+ identity: rename it when `fold` changes.
193
+
194
+ Serve it with `agent({ contract })`. Every resolver receives your state as
195
+ `state`:
196
+
197
+ ```ts server/support-desk.ts
198
+ import { agent } from 'experimental-a2/ai/server'
199
+ import { supportDesk } from '../support-desk'
200
+
201
+ export const supportDeskAgent = agent({
202
+ contract: supportDesk,
203
+ model: ({ state }) =>
204
+ state.plan === 'pro'
205
+ ? { model: 'openai/gpt-5.6-terra', reasoning: 'high' }
206
+ : { model: 'openai/gpt-5.6-luna', speed: 'fast' },
207
+ handlers: {
208
+ 'ticket.flagged': async ({ event }) => {
209
+ // your side effect; event.id makes a stable idempotency key:
210
+ // await notifyOnCall(event.payload.ticketId, { idempotencyKey: event.id })
211
+ },
212
+ },
213
+ })
214
+ ```
215
+
216
+ `state` comes from the snapshot the generation already captured, so it costs no
217
+ extra read. Each model step resolves again, so a change during a turn applies
218
+ from the next step. A resolver that throws fails the turn with its message, like
219
+ a provider error; `retry()` runs it again.
220
+
221
+ In the browser, pass the contract to `useAgent()`. This import is real, not
222
+ type-only, because the browser folds your state too:
223
+
224
+ ```tsx app/support/desk.tsx
225
+ 'use client'
226
+ import { useAgent } from 'experimental-a2/ai/react'
227
+ import { supportDesk } from '@/support-desk'
228
+
229
+ export function Desk({ id }: { id: string }) {
230
+ const { state, push, sendMessage } = useAgent({
231
+ api: '/api/support',
232
+ id,
233
+ contract: supportDesk,
234
+ })
235
+
236
+ return (
237
+ <section>
238
+ <p>
239
+ {state.plan} plan, {state.flagged.length} flagged tickets
240
+ </p>
241
+ <button
242
+ onClick={() =>
243
+ void push({ type: 'ticket.flagged', payload: { ticketId: 'T-1' } })
244
+ }
245
+ >
246
+ Flag T-1
247
+ </button>
248
+ <button onClick={() => void sendMessage('Summarize the flagged tickets.')}>
249
+ Ask
250
+ </button>
251
+ </section>
252
+ )
253
+ }
254
+ ```
255
+
256
+ Browsers push a shared contract's application events like any A2 client. The
257
+ agent refuses only its own server-authored `ai.*` events; the route's
258
+ `authorize` decides the rest. Here the plan comes from your billing code, not
259
+ the browser:
260
+
261
+ ```ts app/api/support/route.ts
262
+ import { supportDeskAgent } from '@/server/support-desk'
263
+
264
+ function handle(request: Request): Promise<Response> {
265
+ // here's where you'd do auth, or any other checks:
266
+ return supportDeskAgent.fetch(request, {
267
+ authorize: (operation) =>
268
+ operation.type !== 'push' ||
269
+ operation.events.every((event) => event.type !== 'plan.changed'),
270
+ })
271
+ }
272
+
273
+ export const GET = handle
274
+ export const POST = handle
275
+ ```
276
+
277
+ Server appends, such as
278
+ `supportDeskAgent.session(id).append({ type: 'plan.changed', payload })`, are
279
+ trusted and skip `authorize`. `push()` also accepts any `inputs` builder, and
280
+ `handlerContext(supportDesk)` types application events inside tools.
281
+
282
+ The contract-free client, `createAgentClient()` or `useAgent({ api | client })`
283
+ with a type-only import, folds only the built-in reducer. It accepts
284
+ `agent({ name })`, or `agent({ contract })` with an `agentContract()` that has
285
+ no `reducer`; the types enforce this through `AI_STATE_REDUCER`.
286
+
287
+ When the agent belongs inside something larger, such as a shared chat room,
288
+ keep two sessions. The room's handler appends each message the agent should see
289
+ to the agent's session with `inputs.message()`. Event IDs are unique across a
290
+ store's sessions, so derive the message ID from the room event's ID with
291
+ `idempotentId()`; a retried handler then converges on the same message. A tool
292
+ the agent calls to speak appends to the room with an `idempotentId()` of its
293
+ `toolCallId`. Neither fold reads the other's state.
294
+
295
+ ### Replace generation, not durability
296
+
297
+ ```ts server/custom-generation.ts
298
+ import { convertToModelMessages, streamText } from 'ai'
299
+ import { agent } from 'experimental-a2/ai/server'
300
+
301
+ export const customGeneration = agent({
302
+ name: 'custom-generation',
303
+ model: 'openai/gpt-5.6-terra',
304
+ harness: {
305
+ type: 'ai-sdk',
306
+ generate: async ({ event, messages, model, instructions, signal }) => {
307
+ const result = streamText({
308
+ model,
309
+ messages: await convertToModelMessages(messages),
310
+ abortSignal: signal,
311
+ ...(instructions === undefined ? {} : { instructions }),
312
+ })
313
+
314
+ return result.toUIMessageStream({
315
+ originalMessages: messages,
316
+ generateMessageId: () => event.payload.responseMessageId,
317
+ })
318
+ },
319
+ },
320
+ })
321
+ ```
322
+
323
+ `harness.generate` replaces only the model-facing step, for fixtures, provider routing,
324
+ or a different AI SDK assembly. It receives the resolver context (with
325
+ `messages` already compacted), the resolved settings, `modelMessages`, and the
326
+ step's `generationId`, and returns exactly one model step as a
327
+ `ReadableStream<UIMessageChunk>` that writes the assistant message
328
+ `event.payload.responseMessageId`. A tool-aware replacement sends tool
329
+ definitions without running local `execute` functions. A2 still owns durable
330
+ progress, tool execution, approval, continuation, interruption, and failure.
331
+ `messageMetadata` applies only to the default generator, so a custom `generate`
332
+ emits its own metadata.
333
+
334
+ ## Checkpoints and recovery reads
335
+
336
+ Samples from here on import `assistant`, the agent from
337
+ [Agents](/agents/introduction#define-the-agent):
338
+
339
+ ```ts server/read-agent.ts
340
+ import type { UIMessage } from 'ai'
341
+ import type { TranscriptMessage } from 'experimental-a2/ai'
342
+ import { assistant } from './assistant'
343
+
344
+ export async function readMessages(
345
+ sessionId: string,
346
+ ): Promise<TranscriptMessage<UIMessage>[]> {
347
+ const snapshot = await assistant.session(sessionId).state()
348
+ return snapshot.state.messages
349
+ }
350
+ ```
351
+
352
+ `session(id).state()` returns `AIState` and its log index, ready for server code
353
+ or client hydration. Tool inputs, outputs, and provider metadata live in message
354
+ parts; `state.tools` keeps only identities and execution status.
355
+ `reduceAIState()` is the reducer's pure transition over confirmed `AIState`, for
356
+ projecting raw event histories.
357
+
358
+ Model, instruction, compaction, and custom-generation callbacks receive prompt
359
+ messages, the triggering request, and your contract's `state`, never a raw
360
+ history. Read any other reducer with `session.state(yourReducer)`, at the
361
+ captured prompt boundary unless you pass `through`. Recovery reads at fixed
362
+ boundaries too: a tool recovered in a fresh process sees its original prompt,
363
+ and A2 rebuilds a missing or invalid reducer snapshot from the log.
364
+
365
+ While tool arguments stream, `input-streaming` parts carry a best-effort partial
366
+ JSON `input`, so a command string can grow before its closing quote. It is a
367
+ display draft, not validated input; `tool-input-available` supplies the
368
+ completed input.
369
+
370
+ ## Delivery semantics
371
+
372
+ Model steps and tools run inside at-least-once A2 handlers. A successful
373
+ completion commits atomically with its terminal events, and deterministic IDs
374
+ make scheduling and lifecycle appends effectively once. An incomplete model
375
+ attempt is marked `superseded` before its replacement starts. An expired claim
376
+ or superseded attempt keeps its abort reason through model, resolver,
377
+ compaction-policy, and tool calls, and retries at the cost of one failed
378
+ attempt; an explicit interruption stays terminal. A retried tool can stream
379
+ different preliminary output under the same tool-call ID.
380
+
381
+ Provider calls and external side effects sit outside that transaction, so
382
+ recovery may repeat a call whose effect landed before its handler committed.
383
+ Give side-effecting tools their own idempotency, normally keyed by the AI SDK
384
+ `toolCallId`. A fast tool can also start before its model step fails; the
385
+ failure blocks the continuation but cannot undo the effect.
386
+
387
+ For a tool whose effect must not repeat and has no idempotency key, such as a
388
+ shell command, set `toolRecovery: { yourTool: 'report' }`. When a crash, a
389
+ timeout, Stop, or Pause now may have cut a call short, A2 does not run it again,
390
+ even on resume; the model gets an error result saying the call was interrupted
391
+ and may have partly run. A call that failed before reaching the tool still
392
+ retries, and so does one where A2 itself fails while or just after the tool
393
+ runs. Every other tool uses the default, `'rerun'`. See
394
+ [`agent()`](/reference/ai#agentoptions) for the exact cases.
395
+
396
+ Configure a [production scheduler](/server/production) as for any A2 server. It
397
+ recovers an incomplete generation after the original serverless invocation
398
+ disappears.
399
+
400
+ ## Inbox and turn controls
401
+
402
+ ```tsx app/agent/[sessionId]/queue.tsx
403
+ 'use client'
404
+ import { useAgent } from 'experimental-a2/ai/react'
405
+ import type { assistant } from '@/server/assistant'
406
+
407
+ export function Queue({ sessionId }: { sessionId: string }) {
408
+ const { state, queue, steer, pause, resume } = useAgent<typeof assistant>({
409
+ api: '/api/agent',
410
+ id: sessionId,
411
+ })
412
+
413
+ return (
414
+ <section>
415
+ <ol aria-label="Queued messages">
416
+ {state.inbox.items.map((item) => (
417
+ <li key={item.id}>
418
+ {item.message.parts.map((part, index) =>
419
+ part.type === 'text' ? <span key={index}>{part.text}</span> : null,
420
+ )}
421
+ <button onClick={() => void queue.sendNow(item.id)}>Send now</button>
422
+ <button
423
+ onClick={() =>
424
+ void queue.move({ inputId: item.id, beforeId: null })
425
+ }
426
+ >
427
+ Move to end
428
+ </button>
429
+ <button onClick={() => void queue.remove(item.id)}>Remove</button>
430
+ </li>
431
+ ))}
432
+ </ol>
433
+ <button onClick={() => void steer('Check the billing service first.')}>
434
+ Steer
435
+ </button>
436
+ {state.inbox.paused ? (
437
+ <button onClick={() => void resume()}>Resume</button>
438
+ ) : (
439
+ <button onClick={() => void pause({ when: 'after-turn' })}>
440
+ Pause after this turn
441
+ </button>
442
+ )}
443
+ </section>
444
+ )
445
+ }
446
+ ```
447
+
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
452
+ across model steps, tools, and a pause. Stop ends the turn and admits the next
453
+ input when admission is running.
454
+
455
+ :::note
456
+ This input protocol requires matching client and server versions. Start a new
457
+ agent session when adopting it; existing logs stay readable, but finish pending
458
+ work with the runtime that created it before upgrading.
459
+ :::
460
+
461
+ `queue.edit(message)` replaces a pending input's content; edits are
462
+ last-write-wins in log order. An edit keeps the message's identity and role;
463
+ changing the role returns `role-mismatch`. `queue.move()` takes `beforeId: null`
464
+ for the end. `queue.remove()` keeps the original submission in the log, so
465
+ replay cannot recreate the prompt. Removing an input that is no longer pending
466
+ (admitted, removed, or never queued) returns `not-pending`, as does an edit that
467
+ loses to admission; a Stop aimed at a finished turn returns `stale-turn`.
468
+
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.
478
+ Non-user context returns `not-user-input`, and a stale observed turn returns
479
+ `stale-turn` without changing the queue. Send now uses the accepted content, so
480
+ save editor text first. `steer()` and `queue.sendNow()` both preserve pause: a
481
+ paused queue keeps the selected input first until Resume. Stop remains the
482
+ immediate interruption.
483
+
484
+ A queued item's optional `afterStepOf` records the observed turn for steering.
485
+ 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
487
+ full handoff rules.
488
+
489
+ ### Input identity
490
+
491
+ New-input commands from `inputs.message()`, `inputs.seed()`, and
492
+ `inputs.steer()` use the message ID as their event ID, and hand-written commands
493
+ must too. Caller IDs cannot start with the reserved `a2.ai:` prefix and must be
494
+ unique across the store; include the contract and session when deriving them
495
+ with `idempotentId()`. An identical submission replays without new work, even
496
+ after removal or compaction. Reusing an ID with different content, action, or
497
+ session rejects with `PARTIAL_DUPLICATE_BATCH`: use `queue.edit()` to change a
498
+ pending input and a fresh ID for a new one.
499
+
500
+ ### Optimistic conversation display
501
+
502
+ Every client folding the AI reducer, an extended `agentContract()` included,
503
+ applies pending commands to `state` with no extra I/O, so render the
504
+ conversation and queue from `state`. An idle, unpaused send appears in
505
+ `state.messages` at once with `state.starting: true`: a turn awaiting
506
+ acceptance, not a running model. Later sends, and every send while paused, wait
507
+ in `state.inbox.items`; steering, and Send now aimed at an active turn, appear
508
+ in `state.messages` after the current response instead. Another client's
509
+ accepted turn can move a speculative send back into the queue, while steering
510
+ stays visible as a message. A pending Stop clears `state.active` at once.
511
+ Close an editor as soon as its save is pushed, and keep the draft if the push
512
+ fails. See
513
+ [Optimistic AI client state](/reference/ai#optimistic-ai-client-state).
514
+
515
+ ### Persistence and application are separate
516
+
517
+ A push confirms that the command is persisted; `ai.control.decided` confirms its
518
+ application, with `commandId` (the submitted event's ID) and an `outcome` of
519
+ `applied` or `rejected`. A rejection carries a `reason` such as `not-pending` or
520
+ `stale-turn`.
521
+ `state.receipt` is the most recent user command receipt. To correlate several
522
+ in flight, each action resolves to its persisted command events, whose `id` is
523
+ the receipt's `commandId`, or to an empty array when there is nothing to
524
+ address, such as Stop with no active turn. A rejection is a normal result and
525
+ spends no handler retry.
526
+
527
+ ### Pause and resume
528
+
529
+ `pause({ when: 'after-turn' })` closes inbox admission and lets the active turn
530
+ finish. `pause({ when: 'now' })` also cancels current model and tool work;
531
+ `state.active.phase` is `pausing` while old work settles, then `paused`.
532
+ `resume()` resumes retained work before admitting another input.
533
+
534
+ Resume starts a fresh model request from durable state; it does not resume the
535
+ provider's old stream or undo completed tool effects. While paused, accepted
536
+ tool calls are retained with their outcomes recorded, and the turn holds no
537
+ model or tool claim. A cancelled tool without a terminal result can run again
538
+ after resume, so use the stable `toolCallId` for external idempotency.
@@ -0,0 +1,136 @@
1
+ ---
2
+ title: Harnesses
3
+ description: "Run FX's or Pi's own loop as the agent's, durably, behind the same conversation."
4
+ ---
5
+
6
+ ## Choose the loop
7
+
8
+ An agent's harness is its loop: its system prompt, its tools, and how it
9
+ decides what to do next. The default, `{ type: 'ai-sdk' }`, runs one AI SDK
10
+ `streamText()` step per generation with the prompt and tools you give it.
11
+ `fx()` runs [FX](https://fx.sh) and `pi()` runs [Pi](https://pi.dev) instead,
12
+ and A2 keeps them durable. Each model call the harness makes is one step of the response,
13
+ with streamed progress, tool calls, steering, interruption, and recovery like
14
+ any other step. The conversation is the same `AIState`, so `useAgent()` renders
15
+ every harness alike.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pnpm add libfx # fx()
21
+ pnpm add @earendil-works/pi-coding-agent @earendil-works/pi-ai # pi()
22
+ ```
23
+
24
+ ## Run FX
25
+
26
+ `fx()` runs FX's kernel in your server process. FX adds no system prompt of its
27
+ own and brings no tools, so `instructions` is the whole prompt and `tools` is
28
+ everything it can call:
29
+
30
+ ```ts server/coder.ts
31
+ import { tool } from 'ai'
32
+ import { Sandbox } from '@vercel/sandbox'
33
+ import { z } from 'zod'
34
+ import { handlerContext } from 'experimental-a2/ai'
35
+ import { agent } from 'experimental-a2/ai/server'
36
+ import { fx } from 'experimental-a2/ai/fx'
37
+
38
+ export const coder = agent({
39
+ name: 'coder',
40
+ harness: fx(),
41
+ model: 'anthropic/claude-sonnet-5.5',
42
+ instructions: 'Fix the bug the user describes. Keep replies short.',
43
+ tools: {
44
+ bash: tool({
45
+ description: 'Run a shell command in the repository.',
46
+ inputSchema: z.object({ command: z.string() }),
47
+ execute: async ({ command }) => {
48
+ const { session, signal } = handlerContext()
49
+ // One Vercel Sandbox per session, created on first use.
50
+ const sandbox = await Sandbox.getOrCreate({
51
+ name: session.id,
52
+ persistent: true,
53
+ signal,
54
+ })
55
+ const result = await sandbox.runCommand({
56
+ cmd: 'bash',
57
+ args: ['-c', command],
58
+ signal,
59
+ })
60
+ return { exitCode: result.exitCode, output: await result.stdout() }
61
+ },
62
+ }),
63
+ },
64
+ toolRecovery: { bash: 'report' },
65
+ })
66
+ ```
67
+
68
+ ## Run Pi
69
+
70
+ `pi()` runs Pi's own agent session: Pi's system prompt, and Pi's `read`,
71
+ `write`, `edit`, and `bash` tools. `instructions` adds to Pi's prompt and
72
+ `tools` adds to Pi's tools; a tool with the name of one of Pi's replaces it.
73
+ Pi's tools work in the session's sandbox, so `pi()` takes one:
74
+
75
+ ```ts server/pi-coder.ts
76
+ import type { Experimental_SandboxSession } from 'ai'
77
+ import { agent } from 'experimental-a2/ai/server'
78
+ import { pi } from 'experimental-a2/ai/pi'
79
+
80
+ // your sandbox: the same AI SDK sandbox session for a session every time,
81
+ // for example from @ai-sdk/sandbox-vercel
82
+ declare function openSandbox(
83
+ sessionId: string,
84
+ ): Promise<Experimental_SandboxSession>
85
+
86
+ export const piCoder = agent({
87
+ name: 'pi-coder',
88
+ harness: pi({ sandbox: ({ session }) => openSandbox(session.id) }),
89
+ model: { model: 'anthropic/claude-sonnet-5.5', reasoning: 'low' },
90
+ })
91
+ ```
92
+
93
+ `sandbox` resolves the session's sandbox, any AI SDK sandbox session, for each
94
+ tool execution, and for a step until Pi's session records the sandbox's
95
+ working directory, so return the same one for a session every time and create
96
+ it only once. Your own tools under `pi()`
97
+ receive it as `experimental_sandbox` too. A2 never stops it; stopping it is
98
+ yours. Pi works in the sandbox's working directory, and its `bash` runs with
99
+ the sandbox's own environment; none of your server's environment reaches it.
100
+
101
+ Pi's tools move at most 4 MiB from the sandbox to your server per file read
102
+ or command. A larger file fails `read` or `edit`, and a command whose output
103
+ passes 4 MiB is stopped and fails `bash`; both errors tell the model to read in
104
+ parts. Files arrive through the sandbox's `readFile`, so implement it as a
105
+ stream for the limit to bound the download too.
106
+
107
+ ## Models and credentials
108
+
109
+ Both harnesses call models through Vercel AI Gateway. `model` is a Gateway
110
+ model ID, or [`ModelSettings`](/reference/ai#modelsettings) with one, resolved
111
+ for each step like any agent's model; `pi()` takes the Gateway models Pi's own
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.
117
+
118
+ ## What the harness owns
119
+
120
+ The harness owns its loop, its prompt, and its compaction, so `generation`,
121
+ `generate`, `compaction`, and `messageMetadata` exist only in
122
+ `{ 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
124
+ response under every harness. Steering enters at the next step, as with any
125
+ agent: the harness sees the steered message before its next model call.
126
+
127
+ ## Recovery
128
+
129
+ A2 records each model call and each tool result as the harness works. When a
130
+ process dies in the middle of a response, the next attempt resumes at the
131
+ interrupted step: finished model calls are not made again and finished tools
132
+ do not run again. A2 stores nothing outside the log: a process that ran the
133
+ last response resumes the harness from its state at the end of that response,
134
+ FX's checkpoint or Pi's session, kept in memory, and any other process replays
135
+ the session from the log. A tool call that a crash cut short reruns unless its
136
+ `toolRecovery` is `'report'`; see [Delivery semantics](/agents/conversations#delivery-semantics).
@@ -0,0 +1 @@
1
+ export default { order: 5 }