experimental-a2 0.0.0 → 0.2.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.
- package/CHANGELOG.md +43 -0
- package/dist/ai-server.browser.js +2 -2
- package/dist/ai-server.d.ts +19 -7
- package/dist/ai-server.js +730 -96
- package/dist/ai.d.ts +32 -11
- package/dist/ai.js +253 -75
- package/dist/client.d.ts +1 -1
- package/dist/client.js +4 -4
- package/dist/{contract-B0kAXoaL.js → contract-CG_adnu_.js} +2 -1
- package/dist/{contract-DL8btVd9.d.ts → contract-C_3dIIEU.d.ts} +4 -1
- package/dist/devtools-server.browser.js +2 -2
- package/dist/devtools-server.js +1 -1
- package/dist/http.d.ts +1 -1
- package/dist/http.js +4 -3
- package/dist/idempotent-replay-BMyHrP0L.js +19 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1 -1
- package/dist/{internal-Dm8Ejnud.js → internal-D6wNxTck.js} +3 -3
- package/dist/{log-Dg1I8NRr.d.ts → log-ldf5g8Cx.d.ts} +74 -56
- package/dist/log-memory.d.ts +1 -1
- package/dist/log-memory.js +173 -96
- package/dist/{log-polling-RO7kclzR.js → log-polling-6COoN60V.js} +1 -1
- package/dist/log-postgres.d.ts +1 -1
- package/dist/log-postgres.js +235 -192
- package/dist/log-redis.d.ts +1 -1
- package/dist/log-redis.js +453 -263
- package/dist/log-sqlite.d.ts +1 -1
- package/dist/log-sqlite.js +216 -127
- package/dist/otel.d.ts +1 -1
- package/dist/otel.js +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/recovery-vercel.d.ts +2 -2
- package/dist/recovery-vercel.js +9 -10
- package/dist/server-DJgD2YWP.js +877 -0
- package/dist/server.browser.js +4 -4
- package/dist/server.d.ts +46 -27
- package/dist/server.js +1 -1
- package/dist/{telemetry-C78al20p.d.ts → telemetry-Cso0qyHQ.d.ts} +1 -1
- package/dist/{wire-2QpU1EtJ.js → wire-BVsgR8o9.js} +1 -1
- package/docs/01-quickstart.mdx +7 -7
- package/docs/concepts/01-contracts.mdx +22 -22
- package/docs/concepts/02-handlers.mdx +223 -89
- package/docs/concepts/03-durability.mdx +199 -112
- package/docs/concepts/04-state.mdx +27 -1
- package/docs/guides/01-timers.mdx +4 -4
- package/docs/guides/02-cancellation.mdx +32 -4
- package/docs/guides/05-production.mdx +61 -27
- package/docs/guides/06-ai-agents.mdx +151 -70
- package/docs/guides/07-devtools.mdx +6 -3
- package/docs/guides/08-application-data.mdx +5 -6
- package/docs/index.mdx +30 -14
- package/docs/reference/01-api.mdx +305 -70
- package/package.json +31 -31
- package/dist/server-DYsnKTTy.js +0 -780
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Going to production
|
|
3
|
-
description: Point the log at
|
|
3
|
+
description: Point the log at durable storage, add recovery for handler work, and know what to do when an event dead-letters.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
## Two pieces
|
|
7
7
|
|
|
8
8
|
Development needs zero setup: SQLite appears under `.a2/`, and tests run
|
|
9
|
-
in memory.
|
|
10
|
-
|
|
9
|
+
in memory. Every production server needs a durable log. Servers with handlers
|
|
10
|
+
also need recovery. Neither choice changes your event contract.
|
|
11
11
|
|
|
12
12
|
## 1. Choose a log
|
|
13
13
|
|
|
@@ -30,7 +30,8 @@ export const ordersServer = createServer({
|
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
The Postgres backend uses real transactions; appends serialize per
|
|
33
|
-
session on an advisory lock,
|
|
33
|
+
session on an advisory lock, while handler claims remain concurrent. The live
|
|
34
|
+
stream polls the log with an
|
|
34
35
|
activity-adaptive cadence: 25ms while a session is producing events
|
|
35
36
|
(a token stream reads smoothly, not in clumps), backing off to 250ms
|
|
36
37
|
when it goes quiet (a LISTEN/NOTIFY upgrade could still land without
|
|
@@ -43,7 +44,7 @@ This configures storage for A2's session logs. It does not connect A2 to your
|
|
|
43
44
|
application tables or make them part of the append transaction. See
|
|
44
45
|
[A2 and your database](/guides/application-data) for that boundary.
|
|
45
46
|
|
|
46
|
-
## 2. Add recovery
|
|
47
|
+
## 2. Add recovery for handlers
|
|
47
48
|
|
|
48
49
|
Recovery is what puts a clock on healing. `experimental-a2/recovery-vercel` rides
|
|
49
50
|
Vercel Queues (`@vercel/queue` is a peer
|
|
@@ -94,33 +95,60 @@ export const POST = recovery.handler(ordersServer, billingServer)
|
|
|
94
95
|
The trigger makes the route private. Only queue infrastructure can invoke
|
|
95
96
|
it, so it needs no auth of its own.
|
|
96
97
|
|
|
97
|
-
One route, one job.
|
|
98
|
-
"drain this session" arm alongside its inline
|
|
99
|
-
wait for the queue, while `append` joins the
|
|
100
|
-
before it returns. An unresponsive queue
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
98
|
+
One route, one job. A top-level append that leaves or finds pending handler
|
|
99
|
+
work starts a delayed, coalesced "drain this session" arm alongside its inline
|
|
100
|
+
handler. The handler does not wait for the queue, while `append` joins the
|
|
101
|
+
initial arm for up to two seconds before it returns. An unresponsive queue
|
|
102
|
+
therefore cannot hold the append open indefinitely. Claim renewals arm another
|
|
103
|
+
watchdog for just after the current window. Live handlers keep moving their
|
|
104
|
+
per-event claims and the watchdog forward. A killed holder stops heartbeating,
|
|
105
|
+
its claims expire, and the next watchdog retries those pending events. When
|
|
106
|
+
Vercel exposes the
|
|
107
|
+
function deadline, A2 caps the final claim window there so timeout recovery
|
|
108
|
+
starts promptly. A failing handler keeps the current message and redelivers
|
|
109
|
+
with backoff. Work outside its lane continues.
|
|
110
|
+
|
|
111
|
+
Due times are rounded to one-second slots. Top-level arms, claim renewals, and
|
|
110
112
|
racing deliveries targeting the same slot deduplicate into one queue message.
|
|
111
113
|
Busy deliveries continue the current message's heartbeat-aligned slot series,
|
|
112
114
|
so they do not create an independent stream of watchdog callbacks.
|
|
113
|
-
Events appended by handlers ride their current execution window
|
|
114
|
-
recovery operation of their own.
|
|
115
|
+
Events appended or returned by handlers ride their current execution window
|
|
116
|
+
and add no recovery operation of their own.
|
|
115
117
|
|
|
116
118
|
No cron, no sweep, no notification bookkeeping. The queue message is
|
|
117
119
|
the recovery state, and the log is the only thing it consults.
|
|
118
120
|
|
|
121
|
+
### Dispatch in a fresh invocation
|
|
122
|
+
|
|
123
|
+
Ordinary `append` starts handlers inline and uses recovery as the watchdog.
|
|
124
|
+
Use `append.dispatch` when the current request should only persist the input
|
|
125
|
+
and hand pending work directly to the queue:
|
|
126
|
+
|
|
127
|
+
```ts app/api/imports/route.ts
|
|
128
|
+
import { ordersServer } from '@/server'
|
|
129
|
+
|
|
130
|
+
export async function POST(request: Request) {
|
|
131
|
+
const { orderId, shopId, items } = await request.json()
|
|
132
|
+
await ordersServer.session(orderId).append.dispatch({
|
|
133
|
+
id: `created:${orderId}`,
|
|
134
|
+
type: 'created',
|
|
135
|
+
payload: { shopId, items },
|
|
136
|
+
})
|
|
137
|
+
return Response.json({ accepted: true }, { status: 202 })
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The call awaits the immediate queue send and does not start an inline drain.
|
|
142
|
+
If the send fails, the event may already be durable. Its explicit ID makes the
|
|
143
|
+
retry idempotent. An existing drain for the same session can still claim the
|
|
144
|
+
event first; dispatch controls the wakeup path, not worker affinity.
|
|
145
|
+
|
|
119
146
|
## 3. When an event dead-letters
|
|
120
147
|
|
|
121
|
-
After ten caught handler failures, A2 stops retrying an event
|
|
122
|
-
|
|
123
|
-
explains
|
|
148
|
+
After ten caught handler failures, A2 stops retrying an event. It blocks later
|
|
149
|
+
events in the same lane; unlaned events and other lanes continue.
|
|
150
|
+
[Durability](/concepts/durability#when-a-handler-keeps-failing) explains this
|
|
151
|
+
boundary. Resolution is manual, and has
|
|
124
152
|
exactly two shapes:
|
|
125
153
|
|
|
126
154
|
- **Fix and retry.** Deploy the handler fix, clear the event's failure
|
|
@@ -155,14 +183,20 @@ path lands in a single trace: the request → `a2.append` → the inline
|
|
|
155
183
|
make: the whole causal chain, visually. Handler failures mark their `a2.event`
|
|
156
184
|
span with the exception, each dispatch reports `ctx.attempt` as
|
|
157
185
|
`a2.event.attempt`, and `a2.event.outcome = dead_lettered` is the attribute to
|
|
158
|
-
alert on when
|
|
186
|
+
alert on when an event [needs manual
|
|
159
187
|
resolution](#3-when-an-event-dead-letters). See the
|
|
160
188
|
[API reference](/reference/api#a2otel) for the span catalogue.
|
|
161
189
|
|
|
162
190
|
## Running without a queue
|
|
163
191
|
|
|
164
|
-
|
|
165
|
-
|
|
192
|
+
If none of a contract's event types have handlers, skip `recovery`. Those
|
|
193
|
+
events settle in their append transaction, so they create no drains, claims,
|
|
194
|
+
queue messages, or recovery callbacks. The server is a durable event log for
|
|
195
|
+
history, reducers, and live sync. That is a complete production configuration,
|
|
196
|
+
not degraded recovery, because there is no reaction to recover.
|
|
197
|
+
|
|
198
|
+
With handlers, skipping `recovery` means the only wakeups are a top-level
|
|
199
|
+
append or explicit `server.drain()`. Reads never wake the session.
|
|
166
200
|
|
|
167
201
|
That's a real configuration, not a broken one. Fine for internal tools
|
|
168
202
|
and low-stakes apps where "heals on the next write" is acceptable. But
|
|
@@ -174,6 +208,6 @@ For production, configure recovery.
|
|
|
174
208
|
| Piece | Done when |
|
|
175
209
|
| ------------------- | -------------------------------------------------------------------- |
|
|
176
210
|
| Log | `log: postgres(...)` on every server |
|
|
177
|
-
| Recovery | one shared `vercelQueues()`, route
|
|
211
|
+
| Recovery | for servers with handlers: one shared `vercelQueues()`, route, and trigger |
|
|
178
212
|
| Idempotent handlers | external side effects take `event.id` as an idempotency key |
|
|
179
213
|
| Client split | contracts/reducers isomorphic; only `experimental-a2/server` touches backends |
|
|
@@ -60,9 +60,14 @@ default. `model` also accepts any AI SDK `LanguageModel`, such as a provider
|
|
|
60
60
|
model returned by `openai('gpt-5.6-terra')`, or an async resolver that chooses a
|
|
61
61
|
model for each generation.
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
approval policy.
|
|
63
|
+
Each durable generation runs one AI SDK `streamText()` step. `generation`
|
|
64
|
+
contains per-step settings such as sampling, token limits, provider options,
|
|
65
|
+
and tool approval policy. A2 owns the stop condition, local tool execution,
|
|
66
|
+
and continuation between steps. `maxSteps` limits one complete assistant
|
|
67
|
+
response and defaults to 20. Individual providers decide which model settings
|
|
68
|
+
they support. `generation` therefore excludes `stopWhen`, tool execution
|
|
69
|
+
callbacks, tool callers, tool context, sandbox execution, and the tool approval
|
|
70
|
+
secret. Model-step timeouts are supported; tool-execution timeouts are not.
|
|
66
71
|
|
|
67
72
|
`experimental-a2/ai/server` is server-only. The isomorphic `experimental-a2/ai` entry point contains the
|
|
68
73
|
contract, reducer, schemas, and pure inputs; it never imports a model provider
|
|
@@ -106,9 +111,10 @@ export async function POST(req: Request): Promise<Response> {
|
|
|
106
111
|
}
|
|
107
112
|
```
|
|
108
113
|
|
|
109
|
-
The route never calls the model directly.
|
|
110
|
-
`ai.
|
|
111
|
-
the
|
|
114
|
+
The route never calls the model directly. The browser appends user facts such
|
|
115
|
+
as `ai.message.created`. Built-in server handlers schedule
|
|
116
|
+
`ai.generation.requested`, run the AI SDK, and append progress back to the same
|
|
117
|
+
log.
|
|
112
118
|
|
|
113
119
|
## Bind the reducer to React
|
|
114
120
|
|
|
@@ -182,13 +188,7 @@ export async function openAgent(
|
|
|
182
188
|
message: UIMessage,
|
|
183
189
|
router: { push(href: string): void },
|
|
184
190
|
): Promise<void> {
|
|
185
|
-
await assistantClient.session(sessionId).push(
|
|
186
|
-
{
|
|
187
|
-
type: 'ai.session.created',
|
|
188
|
-
payload: { metadata: {} },
|
|
189
|
-
},
|
|
190
|
-
...inputs.message(message),
|
|
191
|
-
)
|
|
191
|
+
await assistantClient.session(sessionId).push(...inputs.message(message))
|
|
192
192
|
router.push(`/agent/${sessionId}`)
|
|
193
193
|
}
|
|
194
194
|
```
|
|
@@ -216,7 +216,7 @@ export function AgentClient() {
|
|
|
216
216
|
const [draft, setDraft] = useState('')
|
|
217
217
|
const [error, setError] = useState<string | null>(null)
|
|
218
218
|
|
|
219
|
-
const canSend = state.status
|
|
219
|
+
const canSend = state.status !== 'closed'
|
|
220
220
|
const lastMessage = state.messages.at(-1)
|
|
221
221
|
const lastText =
|
|
222
222
|
lastMessage?.parts
|
|
@@ -289,16 +289,21 @@ export function AgentClient() {
|
|
|
289
289
|
}
|
|
290
290
|
```
|
|
291
291
|
|
|
292
|
-
There is no separate message state to reconcile. `push()` folds
|
|
293
|
-
`ai.message.created`
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
292
|
+
There is no separate message state to reconcile. `push()` folds the
|
|
293
|
+
`ai.message.created` fact locally before starting the request, so the user
|
|
294
|
+
message appears immediately. If the request fails, A2 removes that optimistic
|
|
295
|
+
event and the component restores the draft. The server owns the corresponding
|
|
296
|
+
generation request.
|
|
297
297
|
|
|
298
298
|
The form gives Enter its normal submit behavior. The thinking row is also
|
|
299
|
-
derived from `AIState`: it appears with the Agent label
|
|
300
|
-
|
|
301
|
-
|
|
299
|
+
derived from `AIState`: it appears with the Agent label when the server's
|
|
300
|
+
generation request folds, then the first visible assistant content replaces
|
|
301
|
+
it. Empty stream-start messages never create a blank conversation row.
|
|
302
|
+
|
|
303
|
+
Users can send another message while a response is active. The message enters
|
|
304
|
+
the durable log immediately, but its turn waits until the active assistant
|
|
305
|
+
response, including every tool step and approval, reaches a terminal event.
|
|
306
|
+
Queued messages keep log order.
|
|
302
307
|
|
|
303
308
|
## What happens after `push()`
|
|
304
309
|
|
|
@@ -306,26 +311,52 @@ One user interaction becomes a durable sequence:
|
|
|
306
311
|
|
|
307
312
|
```text
|
|
308
313
|
browser ai.message.created optimistic, then durable
|
|
309
|
-
|
|
314
|
+
server ai.generation.requested scheduled from durable facts
|
|
310
315
|
server ai.generation.started
|
|
311
316
|
server ai.generation.progress batched UIMessageChunk[]
|
|
312
317
|
server ai.tool.called when present
|
|
313
|
-
server ai.tool.result when present
|
|
314
318
|
server ai.approval.requested when present
|
|
315
|
-
server ai.
|
|
319
|
+
server ai.tool.result after concurrent execution or denial
|
|
320
|
+
server ai.generation.completed closes this model step
|
|
321
|
+
server ai.generation.requested after all sibling tools are terminal
|
|
322
|
+
... another one-step generation
|
|
316
323
|
server ai.message.completed
|
|
324
|
+
server ai.generation.requested next queued user turn, when present
|
|
317
325
|
```
|
|
318
326
|
|
|
327
|
+
Tool results and generation completion may arrive in either order. A fast
|
|
328
|
+
automatic tool can finish while the model stream is still open. The join waits
|
|
329
|
+
for both step closure and every required terminal result.
|
|
330
|
+
|
|
319
331
|
The SSE connection delivers the server events to the same reducer. Progress
|
|
320
332
|
stores every AI SDK chunk once. The reducer and the exported
|
|
321
333
|
`deriveUIMessages()` apply those chunks synchronously to produce the current
|
|
322
334
|
`UIMessage`; cumulative message snapshots are not duplicated in the log.
|
|
323
335
|
|
|
324
|
-
`AIState` exposes `messages`, `status`, `activeGeneration`, `
|
|
336
|
+
`AIState` exposes `messages`, `status`, `activeGeneration`, `activeRequestId`,
|
|
337
|
+
`activeResponseMessageId`, `activeProjection`, `responseGenerationIds`,
|
|
325
338
|
`pendingApprovals`, `pendingInputs`, `tools`, `compaction`, per-generation
|
|
326
|
-
`usage`, and the last generation `error`.
|
|
327
|
-
indexed chunk/tool frontier used for exact
|
|
328
|
-
a terminal event.
|
|
339
|
+
`usage`, and the last generation `error`.
|
|
340
|
+
`activeProjection` is the temporary indexed chunk/tool frontier used for exact
|
|
341
|
+
interruption and becomes `null` at a terminal event.
|
|
342
|
+
|
|
343
|
+
`activeRequestId` identifies the server-authorized generation request. It
|
|
344
|
+
prevents a delayed request or recovered attempt from taking ownership from the
|
|
345
|
+
current response before its generation starts.
|
|
346
|
+
|
|
347
|
+
`activeResponseMessageId` identifies that request's response before
|
|
348
|
+
`activeGeneration` exists. It is `null` after the generation starts.
|
|
349
|
+
|
|
350
|
+
`responseGenerationIds` keeps the latest generation owner for each response
|
|
351
|
+
message. Late lifecycle events from a superseded generation stay in raw
|
|
352
|
+
history but cannot alter the projected message, even after its replacement
|
|
353
|
+
finishes.
|
|
354
|
+
|
|
355
|
+
Generation requests share one durable lane, so model calls do not overlap.
|
|
356
|
+
The queued-turn policy is the stronger ordering rule: a later user message is
|
|
357
|
+
not scheduled until the current response's complete tool loop finishes. Lane
|
|
358
|
+
FIFO alone would not prevent a newly appended user turn from overtaking a tool
|
|
359
|
+
continuation that has not been appended yet.
|
|
329
360
|
|
|
330
361
|
## The built-in inputs
|
|
331
362
|
|
|
@@ -333,27 +364,32 @@ a terminal event.
|
|
|
333
364
|
|
|
334
365
|
| Input | Events |
|
|
335
366
|
| --- | --- |
|
|
336
|
-
| `inputs.message(message)` | records a message
|
|
337
|
-
| `inputs.seed(message)` | records a message without
|
|
338
|
-
| `inputs.approval(response)` | records an approval decision
|
|
339
|
-
| `inputs.input(response)` | records application input
|
|
340
|
-
| `inputs.requestInput(request)` | records
|
|
341
|
-
| `inputs.retry(options)` |
|
|
367
|
+
| `inputs.message(message)` | records a message fact; the server schedules user turns |
|
|
368
|
+
| `inputs.seed(message)` | records a trusted server message without scheduling a turn |
|
|
369
|
+
| `inputs.approval(response)` | records an approval decision fact |
|
|
370
|
+
| `inputs.input(response)` | records an application input response fact |
|
|
371
|
+
| `inputs.requestInput(request)` | records a trusted server request for application input |
|
|
372
|
+
| `inputs.retry(options)` | records `ai.retry.requested` for a failed response |
|
|
342
373
|
| `inputs.interrupt(options)` | interrupts an active response |
|
|
343
374
|
|
|
344
375
|
The builders hide stable event ids, so the same interaction is safe to resend.
|
|
345
|
-
|
|
346
|
-
`
|
|
376
|
+
Browser `push()` accepts user messages, approval and input responses,
|
|
377
|
+
interruptions, and explicit retries. `inputs.seed()` and
|
|
378
|
+
`inputs.requestInput()` are for trusted server appends. Browser inputs never
|
|
379
|
+
append server scheduling, seeded non-user messages, or lifecycle events.
|
|
380
|
+
Approval and input request/response payloads carry the active `generationId`;
|
|
381
|
+
clients copy it from the pending request so a stale interaction cannot satisfy
|
|
382
|
+
a later model step.
|
|
347
383
|
|
|
348
384
|
`inputs` deliberately has no session lifecycle methods. The A2 log begins with
|
|
349
385
|
the first append. Apps that use explicit `ai.session.created` or
|
|
350
|
-
`ai.session.closed` lifecycle events
|
|
386
|
+
`ai.session.closed` lifecycle events append those trusted server facts
|
|
351
387
|
directly.
|
|
352
388
|
|
|
353
389
|
## Add tools and approval
|
|
354
390
|
|
|
355
391
|
Pass ordinary AI SDK tools to the server. Approval policy belongs in
|
|
356
|
-
`generation`, beside the other
|
|
392
|
+
`generation`, beside the other per-step settings:
|
|
357
393
|
|
|
358
394
|
```ts server/with-tools.ts
|
|
359
395
|
import { tool } from 'ai'
|
|
@@ -405,6 +441,7 @@ export function ApprovalControls() {
|
|
|
405
441
|
void push(
|
|
406
442
|
...inputs.approval({
|
|
407
443
|
messageId: approval.messageId,
|
|
444
|
+
generationId: approval.generationId,
|
|
408
445
|
approvalId: approval.approvalId,
|
|
409
446
|
approved: true,
|
|
410
447
|
}),
|
|
@@ -418,6 +455,7 @@ export function ApprovalControls() {
|
|
|
418
455
|
void push(
|
|
419
456
|
...inputs.approval({
|
|
420
457
|
messageId: approval.messageId,
|
|
458
|
+
generationId: approval.generationId,
|
|
421
459
|
approvalId: approval.approvalId,
|
|
422
460
|
approved: false,
|
|
423
461
|
reason: 'Denied by the user',
|
|
@@ -432,9 +470,10 @@ export function ApprovalControls() {
|
|
|
432
470
|
}
|
|
433
471
|
```
|
|
434
472
|
|
|
435
|
-
`inputs.approval()`
|
|
436
|
-
|
|
437
|
-
|
|
473
|
+
`inputs.approval()` records only the decision and updates the approval part in
|
|
474
|
+
the projected `UIMessage`. A built-in handler executes the approved tool or
|
|
475
|
+
records a denied result. It never bypasses the same sibling join used by
|
|
476
|
+
automatic tools.
|
|
438
477
|
|
|
439
478
|
Tool activity follows the full AI SDK chunk lifecycle. Input validation errors,
|
|
440
479
|
execution errors, denials, and preliminary and final outputs become durable
|
|
@@ -443,11 +482,36 @@ output completes it. Each result has its own event id, so a preliminary result
|
|
|
443
482
|
cannot deduplicate the final one. Dynamic-tool, provider-executed, provider
|
|
444
483
|
metadata, and tool metadata fields are preserved in the projection.
|
|
445
484
|
|
|
485
|
+
Independent tool handlers run concurrently as soon as their durable inputs and
|
|
486
|
+
authorization are ready. There is no fixed tool concurrency limit. A private
|
|
487
|
+
coordinator reducer tracks generation closure, cancellation, calls, approvals,
|
|
488
|
+
and terminal results for the active response. Its retained state is bounded;
|
|
489
|
+
completed responses do not accumulate in the coordinator. Each join reads a
|
|
490
|
+
durable reducer snapshot plus the log tail through `ctx.session.state()`. The snapshot
|
|
491
|
+
is only a cache. Recovery can rebuild the same coordinator state from the event
|
|
492
|
+
log after process death.
|
|
493
|
+
|
|
494
|
+
A continuation needs the generation to close and every sibling tool to have a
|
|
495
|
+
terminal success, error, or denied result. Racing join handlers call
|
|
496
|
+
`ctx.session.append()` with the same deterministic continuation ID, so append
|
|
497
|
+
idempotency resolves the race. A handler's single-owner terminal result is
|
|
498
|
+
returned and committed atomically with that handler's completion. Preliminary
|
|
499
|
+
streaming tool outputs use immediate appends. An approved provider-executed
|
|
500
|
+
call can satisfy the join through its response. A provider tool that declares
|
|
501
|
+
deferred-result support can wait for its provider result instead of a local
|
|
502
|
+
executor. The authenticated provider callback appends that `ai.tool.result`
|
|
503
|
+
through the trusted server session API. Browser `push()` rejects lifecycle
|
|
504
|
+
events, including provider results.
|
|
505
|
+
|
|
446
506
|
`inputs.input()` provides the same durable request/response shape for
|
|
447
507
|
application-defined input. Its value stays in raw history and
|
|
448
508
|
`state.pendingInputs`; resolve instructions dynamically or replace `generate`
|
|
449
509
|
when the value should become model context.
|
|
450
510
|
|
|
511
|
+
Approval and application-input request/response facts carry the active
|
|
512
|
+
`generationId`. Echo that value from the pending request so a delayed UI action
|
|
513
|
+
cannot authorize or resume a later model step.
|
|
514
|
+
|
|
451
515
|
## Interrupt and retry
|
|
452
516
|
|
|
453
517
|
Interrupt the active response from the client:
|
|
@@ -489,10 +553,18 @@ keeps the partial response the user actually saw, and turns incomplete visible
|
|
|
489
553
|
tools into `output-error`. Progress, completion, or failure from that generation
|
|
490
554
|
cannot reactivate it after the interruption.
|
|
491
555
|
|
|
556
|
+
`generationId` is optional only between `ai.generation.requested` and
|
|
557
|
+
`ai.generation.started`, when no generation id exists yet. In that phase, use
|
|
558
|
+
`state.activeResponseMessageId` as `messageId` and omit `generationId`. Once
|
|
559
|
+
`activeGeneration` exists, copy both ids from it so a delayed interruption
|
|
560
|
+
cannot stop a later generation.
|
|
561
|
+
|
|
492
562
|
After a failed model call, `inputs.retry({ messageId, responseMessageId,
|
|
493
|
-
retryId })`
|
|
494
|
-
resending one retry is safe while a
|
|
495
|
-
partial progress remains in raw history
|
|
563
|
+
retryId })` records a retry request. The server schedules its fresh attempt.
|
|
564
|
+
`retryId` identifies the user's action, so resending one retry is safe while a
|
|
565
|
+
later retry remains distinct. Failed partial progress remains in raw history
|
|
566
|
+
but is excluded from the new prompt. Later user messages remain queued while
|
|
567
|
+
the failed response awaits an explicit retry or interruption.
|
|
496
568
|
|
|
497
569
|
## Append from the server
|
|
498
570
|
|
|
@@ -567,7 +639,10 @@ different server assembly:
|
|
|
567
639
|
```ts server/custom.ts
|
|
568
640
|
import { z } from 'zod'
|
|
569
641
|
import { agent } from 'experimental-a2/ai'
|
|
570
|
-
import {
|
|
642
|
+
import {
|
|
643
|
+
createHandlers,
|
|
644
|
+
validateAgentPush,
|
|
645
|
+
} from 'experimental-a2/ai/server'
|
|
571
646
|
import { createServer } from 'experimental-a2/server'
|
|
572
647
|
|
|
573
648
|
const supportAgent = agent({
|
|
@@ -584,6 +659,7 @@ const aiHandlers = createHandlers({
|
|
|
584
659
|
|
|
585
660
|
export const customAssistantServer = createServer({
|
|
586
661
|
contract: supportAgent.contract,
|
|
662
|
+
validatePush: validateAgentPush,
|
|
587
663
|
handlers: {
|
|
588
664
|
...aiHandlers,
|
|
589
665
|
'ticket.linked': async ({ event }) => {
|
|
@@ -596,16 +672,18 @@ export const customAssistantServer = createServer({
|
|
|
596
672
|
|
|
597
673
|
Application events stay fully typed. The standard AI reducer ignores unknown
|
|
598
674
|
events, so another reducer can project application state without forking the AI
|
|
599
|
-
protocol.
|
|
675
|
+
protocol. `validateAgentPush` preserves the same browser boundary as
|
|
676
|
+
`createAgentServer()`: user facts may enter through a parsed push, while model
|
|
677
|
+
scheduling, tool lifecycle, and trusted seed messages stay server-authored.
|
|
600
678
|
|
|
601
679
|
### Replace generation, not durability
|
|
602
680
|
|
|
603
|
-
The default generator uses
|
|
604
|
-
model-facing step for fixtures, provider routing, or a different AI
|
|
605
|
-
assembly:
|
|
681
|
+
The default generator uses AI SDK `streamText()` for one model step. Replace
|
|
682
|
+
only that model-facing step for fixtures, provider routing, or a different AI
|
|
683
|
+
SDK assembly:
|
|
606
684
|
|
|
607
685
|
```ts server/custom-generation.ts
|
|
608
|
-
import {
|
|
686
|
+
import { convertToModelMessages, streamText } from 'ai'
|
|
609
687
|
import { createAgentServer } from 'experimental-a2/ai/server'
|
|
610
688
|
import { assistant } from '../assistant'
|
|
611
689
|
|
|
@@ -615,26 +693,20 @@ export const customGenerationServer = createAgentServer({
|
|
|
615
693
|
generate: async ({
|
|
616
694
|
messages,
|
|
617
695
|
model,
|
|
618
|
-
tools,
|
|
619
696
|
instructions,
|
|
620
|
-
generation,
|
|
621
|
-
generationId,
|
|
622
697
|
responseMessageId,
|
|
623
698
|
signal,
|
|
624
699
|
}) => {
|
|
625
|
-
const
|
|
626
|
-
...generation,
|
|
627
|
-
id: generationId,
|
|
700
|
+
const result = streamText({
|
|
628
701
|
model,
|
|
629
|
-
|
|
702
|
+
messages: await convertToModelMessages(messages),
|
|
703
|
+
abortSignal: signal,
|
|
630
704
|
...(instructions === undefined ? {} : { instructions }),
|
|
631
705
|
})
|
|
632
706
|
|
|
633
|
-
return
|
|
634
|
-
|
|
635
|
-
uiMessages: messages,
|
|
707
|
+
return result.toUIMessageStream({
|
|
708
|
+
originalMessages: messages,
|
|
636
709
|
generateMessageId: () => responseMessageId,
|
|
637
|
-
abortSignal: signal,
|
|
638
710
|
})
|
|
639
711
|
},
|
|
640
712
|
})
|
|
@@ -642,17 +714,26 @@ export const customGenerationServer = createAgentServer({
|
|
|
642
714
|
|
|
643
715
|
`generate` receives compacted messages, resolved model and instructions, tools,
|
|
644
716
|
generation settings, durable request identity, current state and history, and
|
|
645
|
-
the abort signal. It returns one
|
|
646
|
-
|
|
647
|
-
|
|
717
|
+
the abort signal. It returns exactly one model step as a
|
|
718
|
+
`ReadableStream<UIMessageChunk>`. A tool-aware replacement sends definitions to
|
|
719
|
+
the model without running local `execute` functions. A2 still owns durable
|
|
720
|
+
progress, tool execution, approval, continuation, interruption, and failure.
|
|
648
721
|
|
|
649
722
|
## Delivery semantics
|
|
650
723
|
|
|
651
|
-
Model
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
724
|
+
Model steps and tools run inside at-least-once A2 handlers. The returned-event
|
|
725
|
+
transaction makes each successful handler completion and its terminal events
|
|
726
|
+
one atomic durable operation. Deterministic ids make scheduling and lifecycle
|
|
727
|
+
appends effectively once, and A2 marks an incomplete model attempt
|
|
728
|
+
`superseded` before starting its replacement.
|
|
729
|
+
|
|
730
|
+
Provider calls and external tool side effects remain outside that transaction.
|
|
731
|
+
A process can die after an external effect succeeds and before its handler
|
|
732
|
+
completion commits, so recovery may run the call again. Give side-effecting
|
|
733
|
+
tools their own idempotency strategy, normally keyed by the AI SDK tool call
|
|
734
|
+
id. This is the same at-least-once boundary as every other A2 handler. A fast
|
|
735
|
+
tool may also start before its model step later fails. The failure prevents a
|
|
736
|
+
continuation, but it cannot roll back that external effect.
|
|
656
737
|
|
|
657
738
|
Configure [production recovery](/guides/production) exactly as for any other A2
|
|
658
739
|
server. Recovery wakes an interrupted generation after the original serverless
|
|
@@ -44,7 +44,7 @@ returning a `Response` supports a redirect or authentication challenge.
|
|
|
44
44
|
## Causal forest
|
|
45
45
|
|
|
46
46
|
Every stored `cause` connects a child to the event index and handler attempt
|
|
47
|
-
whose `ctx.append` first persisted it. The dashboard builds that forest
|
|
47
|
+
whose `ctx.session.append` first persisted it. The dashboard builds that forest
|
|
48
48
|
directly from the session log. It does not need a trace table or another write
|
|
49
49
|
on the append path.
|
|
50
50
|
|
|
@@ -75,10 +75,13 @@ dashboard shows the first dispatch time as unknown instead of moving it to
|
|
|
75
75
|
`createdAt` or `lastClaimedAt`.
|
|
76
76
|
|
|
77
77
|
The bar is event lifetime, not handler execution time. A2 stores lifecycle
|
|
78
|
-
summaries, not one span row for every attempt. Time spent waiting
|
|
79
|
-
handler,
|
|
78
|
+
summaries, not one span row for every attempt. Time spent waiting for a lane,
|
|
79
|
+
running a handler, holding a claim, or waiting for recovery remains one honest
|
|
80
80
|
interval.
|
|
81
81
|
|
|
82
|
+
An event type without a handler is complete at append. Its bar has no waiting
|
|
83
|
+
time or dispatch marker, and its attempt count stays at zero.
|
|
84
|
+
|
|
82
85
|
Snapshots appear as their reducer name, event frontier, and last update time.
|
|
83
86
|
The dashboard does not send cached snapshot state to the browser.
|
|
84
87
|
|
|
@@ -34,9 +34,9 @@ orders / order-43
|
|
|
34
34
|
agents / run-abc
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
The session is the boundary A2
|
|
38
|
-
in the same session when they
|
|
39
|
-
|
|
37
|
+
The session is the boundary A2 logs, recovers, folds, and streams. Put events
|
|
38
|
+
in the same session when they form one history or view. Handler execution is
|
|
39
|
+
concurrent unless events share a lane.
|
|
40
40
|
|
|
41
41
|
A contract is not a table. A session is not a table either. One contract
|
|
42
42
|
usually serves many sessions, and one session may describe changes that touch
|
|
@@ -77,8 +77,7 @@ a cost: boards and search span many sessions and need another query model. If
|
|
|
77
77
|
those collection views are the center of the product, let the database lead.
|
|
78
78
|
|
|
79
79
|
Do not put an entire workspace in one session just to make cross-issue queries
|
|
80
|
-
possible. Every event
|
|
81
|
-
that session follows the same growing log.
|
|
80
|
+
possible. Every event joins the same growing log and every client follows it.
|
|
82
81
|
|
|
83
82
|
## Database writes from handlers
|
|
84
83
|
|
|
@@ -99,7 +98,7 @@ integration:
|
|
|
99
98
|
|
|
100
99
|
- it is eventually consistent with the session logs;
|
|
101
100
|
- writes must be idempotent;
|
|
102
|
-
- A2
|
|
101
|
+
- A2 assigns log order within one session, not across sessions;
|
|
103
102
|
- A2 does not expose one API that scans and replays every session to rebuild
|
|
104
103
|
the index.
|
|
105
104
|
|