experimental-a2 0.0.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 +128 -0
- package/dist/ai-server.browser.d.ts +1 -0
- package/dist/ai-server.browser.js +4 -0
- package/dist/ai-server.d.ts +65 -0
- package/dist/ai-server.js +494 -0
- package/dist/ai.d.ts +282 -0
- package/dist/ai.js +922 -0
- package/dist/cache-indexeddb.d.ts +1 -0
- package/dist/cache-indexeddb.js +0 -0
- package/dist/client.d.ts +90 -0
- package/dist/client.js +410 -0
- package/dist/contract-B0kAXoaL.js +60 -0
- package/dist/contract-DL8btVd9.d.ts +161 -0
- package/dist/devtools-server.browser.d.ts +1 -0
- package/dist/devtools-server.browser.js +4 -0
- package/dist/devtools-server.d.ts +22 -0
- package/dist/devtools-server.js +1087 -0
- package/dist/errors-BJRMd-h6.js +23 -0
- package/dist/errors-xL_JTXsY.d.ts +20 -0
- package/dist/http.d.ts +44 -0
- package/dist/http.js +119 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/inspection-E7qbD0Xj.js +10 -0
- package/dist/internal-Dm8Ejnud.js +36 -0
- package/dist/log-Dg1I8NRr.d.ts +245 -0
- package/dist/log-memory.d.ts +11 -0
- package/dist/log-memory.js +345 -0
- package/dist/log-polling-RO7kclzR.js +83 -0
- package/dist/log-postgres.d.ts +40 -0
- package/dist/log-postgres.js +628 -0
- package/dist/log-redis.d.ts +31 -0
- package/dist/log-redis.js +711 -0
- package/dist/log-sqlite.d.ts +17 -0
- package/dist/log-sqlite.js +450 -0
- package/dist/log-yJbXUf72.js +5 -0
- package/dist/otel.d.ts +12 -0
- package/dist/otel.js +41 -0
- package/dist/react.d.ts +54 -0
- package/dist/react.js +85 -0
- package/dist/recovery-vercel.d.ts +60 -0
- package/dist/recovery-vercel.js +120 -0
- package/dist/retryable-lazy-DZWmHpii.js +19 -0
- package/dist/server-DYsnKTTy.js +780 -0
- package/dist/server.browser.d.ts +1 -0
- package/dist/server.browser.js +11 -0
- package/dist/server.d.ts +136 -0
- package/dist/server.js +2 -0
- package/dist/telemetry-C78al20p.d.ts +32 -0
- package/dist/validate-XKT4FSNn.js +28 -0
- package/dist/wire-2QpU1EtJ.js +62 -0
- package/docs/01-quickstart.mdx +214 -0
- package/docs/concepts/01-contracts.mdx +138 -0
- package/docs/concepts/02-handlers.mdx +146 -0
- package/docs/concepts/03-durability.mdx +230 -0
- package/docs/concepts/04-state.mdx +133 -0
- package/docs/guides/01-timers.mdx +85 -0
- package/docs/guides/02-cancellation.mdx +107 -0
- package/docs/guides/03-react.mdx +234 -0
- package/docs/guides/04-local-first.mdx +88 -0
- package/docs/guides/05-production.mdx +179 -0
- package/docs/guides/06-ai-agents.mdx +659 -0
- package/docs/guides/07-devtools.mdx +101 -0
- package/docs/guides/08-application-data.mdx +114 -0
- package/docs/index.mdx +282 -0
- package/docs/reference/01-api.mdx +637 -0
- package/docs/reference/02-errors.mdx +77 -0
- package/package.json +111 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Durable AI agents
|
|
3
|
+
description: Build an AI SDK agent whose messages, streaming progress, tools, approvals, and recovery live in an A2 event log.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
Install A2, the AI SDK, and Zod for the examples below:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pnpm add experimental-a2 ai zod
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The examples use the AI SDK's default global provider, AI Gateway. Add its key
|
|
15
|
+
to your environment:
|
|
16
|
+
|
|
17
|
+
```bash .env.local
|
|
18
|
+
AI_GATEWAY_API_KEY=your_api_key
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Define the agent
|
|
22
|
+
|
|
23
|
+
`agent()` defines an isomorphic event contract and its standard `AIState`
|
|
24
|
+
reducer. The same value types the server, server-rendered state, and optimistic
|
|
25
|
+
client updates.
|
|
26
|
+
|
|
27
|
+
```ts assistant.ts
|
|
28
|
+
import { agent } from 'experimental-a2/ai'
|
|
29
|
+
|
|
30
|
+
export const assistant = agent({
|
|
31
|
+
name: 'support-agent',
|
|
32
|
+
})
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Keep this module isomorphic. Model providers, secrets, tools, and storage belong
|
|
36
|
+
in the server module.
|
|
37
|
+
|
|
38
|
+
## Connect the model
|
|
39
|
+
|
|
40
|
+
`createAgentServer()` adds the AI SDK runner and A2's ordinary storage,
|
|
41
|
+
recovery, telemetry, and handler machinery:
|
|
42
|
+
|
|
43
|
+
```ts server/assistant.ts
|
|
44
|
+
import { createAgentServer } from 'experimental-a2/ai/server'
|
|
45
|
+
import { assistant } from '../assistant'
|
|
46
|
+
|
|
47
|
+
export const assistantServer = createAgentServer({
|
|
48
|
+
agent: assistant,
|
|
49
|
+
model: 'openai/gpt-5.6-terra',
|
|
50
|
+
instructions: 'Help the customer. Be concise.',
|
|
51
|
+
generation: {
|
|
52
|
+
temperature: 0.2,
|
|
53
|
+
maxOutputTokens: 2_000,
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A string model id uses the AI SDK's global provider, which is AI Gateway by
|
|
59
|
+
default. `model` also accepts any AI SDK `LanguageModel`, such as a provider
|
|
60
|
+
model returned by `openai('gpt-5.6-terra')`, or an async resolver that chooses a
|
|
61
|
+
model for each generation.
|
|
62
|
+
|
|
63
|
+
`generation` accepts the remaining AI SDK `ToolLoopAgent` settings: sampling,
|
|
64
|
+
token limits, stop conditions, provider options, step callbacks, and tool
|
|
65
|
+
approval policy. Individual providers decide which settings they support.
|
|
66
|
+
|
|
67
|
+
`experimental-a2/ai/server` is server-only. The isomorphic `experimental-a2/ai` entry point contains the
|
|
68
|
+
contract, reducer, schemas, and pure inputs; it never imports a model provider
|
|
69
|
+
or backend.
|
|
70
|
+
|
|
71
|
+
## Expose the event stream
|
|
72
|
+
|
|
73
|
+
One HTTP route gives the browser a read and write path. `GET` streams events;
|
|
74
|
+
`POST` accepts optimistic pushes:
|
|
75
|
+
|
|
76
|
+
```ts app/api/agent-events/route.ts
|
|
77
|
+
import { A2Error } from 'experimental-a2'
|
|
78
|
+
import { errorResponse, parsePushBody, sseResponse } from 'experimental-a2/http'
|
|
79
|
+
import { assistantServer } from '@/server/assistant'
|
|
80
|
+
|
|
81
|
+
export async function GET(req: Request): Promise<Response> {
|
|
82
|
+
const { searchParams } = new URL(req.url)
|
|
83
|
+
const sessionId = searchParams.get('sessionId')
|
|
84
|
+
const startAt = Number(searchParams.get('index')) || 0
|
|
85
|
+
|
|
86
|
+
if (!sessionId) {
|
|
87
|
+
return errorResponse(new A2Error('INVALID_PAYLOAD', 'missing sessionId'))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// here's where you'd do auth, or any other checks
|
|
91
|
+
|
|
92
|
+
return sseResponse(assistantServer.session(sessionId).stream({ startAt }))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function POST(req: Request): Promise<Response> {
|
|
96
|
+
try {
|
|
97
|
+
const { sessionId, events } = await parsePushBody(req)
|
|
98
|
+
|
|
99
|
+
// here's where you'd do auth, or any other checks
|
|
100
|
+
|
|
101
|
+
const appended = await assistantServer.session(sessionId).append(...events)
|
|
102
|
+
return Response.json(appended)
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return errorResponse(error)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The route never calls the model directly. Appending
|
|
110
|
+
`ai.generation.requested` wakes the built-in handler, which runs the AI SDK on
|
|
111
|
+
the server and appends progress back to the same log.
|
|
112
|
+
|
|
113
|
+
## Bind the reducer to React
|
|
114
|
+
|
|
115
|
+
Create the typed provider and hook in a client module:
|
|
116
|
+
|
|
117
|
+
```tsx app/agent/session.ts
|
|
118
|
+
'use client'
|
|
119
|
+
import { createClient } from 'experimental-a2/client'
|
|
120
|
+
import { createReact } from 'experimental-a2/react'
|
|
121
|
+
import { assistant } from '@/assistant'
|
|
122
|
+
|
|
123
|
+
export const assistantClient = createClient({
|
|
124
|
+
reducer: assistant.reducer,
|
|
125
|
+
api: '/api/agent-events',
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
export const { SessionProvider, useSession } = createReact({
|
|
129
|
+
client: assistantClient,
|
|
130
|
+
})
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Fold the initial state on the server, then let the provider resume the event
|
|
134
|
+
stream from that exact index:
|
|
135
|
+
|
|
136
|
+
```tsx app/agent/[sessionId]/page.tsx
|
|
137
|
+
import { assistant } from '@/assistant'
|
|
138
|
+
import { assistantServer } from '@/server/assistant'
|
|
139
|
+
import { AgentClient } from './agent-client'
|
|
140
|
+
import { SessionProvider } from '../session'
|
|
141
|
+
|
|
142
|
+
export default async function AgentPage({
|
|
143
|
+
params,
|
|
144
|
+
}: {
|
|
145
|
+
params: Promise<{ sessionId: string }>
|
|
146
|
+
}) {
|
|
147
|
+
const { sessionId } = await params
|
|
148
|
+
const session = assistantServer.session(sessionId)
|
|
149
|
+
const { state, index } = await session.state(assistant.reducer)
|
|
150
|
+
const initialEvents = (await session.history()).filter(
|
|
151
|
+
(event) => event.index <= index,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<SessionProvider
|
|
156
|
+
sessionId={sessionId}
|
|
157
|
+
initialState={state}
|
|
158
|
+
initialIndex={index}
|
|
159
|
+
initialEvents={initialEvents}
|
|
160
|
+
>
|
|
161
|
+
<AgentClient />
|
|
162
|
+
</SessionProvider>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
An empty session folds to the initial `AIState`. Its log is created lazily when
|
|
168
|
+
the first message is pushed.
|
|
169
|
+
|
|
170
|
+
Start the session from the previous route, but wait for its durable append
|
|
171
|
+
before navigating. The destination server render then sees the message and its
|
|
172
|
+
events in one complete response instead of racing the `POST`:
|
|
173
|
+
|
|
174
|
+
```tsx app/agent/new-agent-session.tsx
|
|
175
|
+
'use client'
|
|
176
|
+
import type { UIMessage } from 'ai'
|
|
177
|
+
import { inputs } from 'experimental-a2/ai'
|
|
178
|
+
import { assistantClient } from './session'
|
|
179
|
+
|
|
180
|
+
export async function openAgent(
|
|
181
|
+
sessionId: string,
|
|
182
|
+
message: UIMessage,
|
|
183
|
+
router: { push(href: string): void },
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
await assistantClient.session(sessionId).push(
|
|
186
|
+
{
|
|
187
|
+
type: 'ai.session.created',
|
|
188
|
+
payload: { metadata: {} },
|
|
189
|
+
},
|
|
190
|
+
...inputs.message(message),
|
|
191
|
+
)
|
|
192
|
+
router.push(`/agent/${sessionId}`)
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The draft clears immediately while the append is in flight and can be restored
|
|
197
|
+
if it fails. Once navigation begins, SSR already has a durable frontier and
|
|
198
|
+
event history. When the destination provider mounts, it resolves the same live
|
|
199
|
+
session and reconciles that server render without replacing the object. Idle
|
|
200
|
+
session objects expire after five minutes by default; configure `gcTime` on
|
|
201
|
+
`createClient` when a different lifetime fits the app.
|
|
202
|
+
|
|
203
|
+
## Send messages from the client
|
|
204
|
+
|
|
205
|
+
Build the message inline and pass the resulting events straight to `push()`:
|
|
206
|
+
|
|
207
|
+
```tsx app/agent/[sessionId]/agent-client.tsx
|
|
208
|
+
'use client'
|
|
209
|
+
import type { FormEvent } from 'react'
|
|
210
|
+
import { useState } from 'react'
|
|
211
|
+
import { inputs } from 'experimental-a2/ai'
|
|
212
|
+
import { useSession } from '../session'
|
|
213
|
+
|
|
214
|
+
export function AgentClient() {
|
|
215
|
+
const { state, push } = useSession()
|
|
216
|
+
const [draft, setDraft] = useState('')
|
|
217
|
+
const [error, setError] = useState<string | null>(null)
|
|
218
|
+
|
|
219
|
+
const canSend = state.status === 'idle' || state.status === 'failed'
|
|
220
|
+
const lastMessage = state.messages.at(-1)
|
|
221
|
+
const lastText =
|
|
222
|
+
lastMessage?.parts
|
|
223
|
+
.flatMap((part) => (part.type === 'text' ? [part.text] : []))
|
|
224
|
+
.join('\n') ?? ''
|
|
225
|
+
const showThinking =
|
|
226
|
+
state.status === 'generating' &&
|
|
227
|
+
(lastMessage?.role !== 'assistant' || lastText.length === 0)
|
|
228
|
+
|
|
229
|
+
async function send(event: FormEvent<HTMLFormElement>): Promise<void> {
|
|
230
|
+
event.preventDefault()
|
|
231
|
+
const previousDraft = draft
|
|
232
|
+
const text = previousDraft.trim()
|
|
233
|
+
if (!text || !canSend) return
|
|
234
|
+
|
|
235
|
+
setDraft('')
|
|
236
|
+
setError(null)
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
await push(
|
|
240
|
+
...inputs.message({
|
|
241
|
+
id: crypto.randomUUID(),
|
|
242
|
+
role: 'user',
|
|
243
|
+
parts: [{ type: 'text', text }],
|
|
244
|
+
}),
|
|
245
|
+
)
|
|
246
|
+
} catch (cause) {
|
|
247
|
+
setDraft(previousDraft)
|
|
248
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<main>
|
|
254
|
+
<section aria-label="Conversation">
|
|
255
|
+
{state.messages.map((message) => {
|
|
256
|
+
const text = message.parts
|
|
257
|
+
.flatMap((part) => (part.type === 'text' ? [part.text] : []))
|
|
258
|
+
.join('\n')
|
|
259
|
+
if (!text) return null
|
|
260
|
+
|
|
261
|
+
return (
|
|
262
|
+
<article key={message.id}>
|
|
263
|
+
<strong>{message.role === 'user' ? 'You' : 'Agent'}</strong>
|
|
264
|
+
<p>{text}</p>
|
|
265
|
+
</article>
|
|
266
|
+
)
|
|
267
|
+
})}
|
|
268
|
+
|
|
269
|
+
{showThinking ? (
|
|
270
|
+
<article aria-live="polite">
|
|
271
|
+
<strong>Agent</strong>
|
|
272
|
+
<p>Thinking…</p>
|
|
273
|
+
</article>
|
|
274
|
+
) : null}
|
|
275
|
+
</section>
|
|
276
|
+
|
|
277
|
+
<form onSubmit={send}>
|
|
278
|
+
<input
|
|
279
|
+
aria-label="Message"
|
|
280
|
+
value={draft}
|
|
281
|
+
onChange={(event) => setDraft(event.currentTarget.value)}
|
|
282
|
+
/>
|
|
283
|
+
<button disabled={!draft.trim() || !canSend}>Send</button>
|
|
284
|
+
</form>
|
|
285
|
+
|
|
286
|
+
{error ? <p role="alert">{error}</p> : null}
|
|
287
|
+
</main>
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
There is no separate message state to reconcile. `push()` folds
|
|
293
|
+
`ai.message.created` and `ai.generation.requested` locally before starting the
|
|
294
|
+
request. The user message and generating state appear immediately. If the
|
|
295
|
+
request fails, A2 removes both optimistic events; the component only restores
|
|
296
|
+
the draft.
|
|
297
|
+
|
|
298
|
+
The form gives Enter its normal submit behavior. The thinking row is also
|
|
299
|
+
derived from `AIState`: it appears with the Agent label as soon as the
|
|
300
|
+
optimistic generation request folds, then the first visible assistant content
|
|
301
|
+
replaces it. Empty stream-start messages never create a blank conversation row.
|
|
302
|
+
|
|
303
|
+
## What happens after `push()`
|
|
304
|
+
|
|
305
|
+
One user interaction becomes a durable sequence:
|
|
306
|
+
|
|
307
|
+
```text
|
|
308
|
+
browser ai.message.created optimistic, then durable
|
|
309
|
+
browser ai.generation.requested optimistic, then durable
|
|
310
|
+
server ai.generation.started
|
|
311
|
+
server ai.generation.progress batched UIMessageChunk[]
|
|
312
|
+
server ai.tool.called when present
|
|
313
|
+
server ai.tool.result when present
|
|
314
|
+
server ai.approval.requested when present
|
|
315
|
+
server ai.generation.completed
|
|
316
|
+
server ai.message.completed
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
The SSE connection delivers the server events to the same reducer. Progress
|
|
320
|
+
stores every AI SDK chunk once. The reducer and the exported
|
|
321
|
+
`deriveUIMessages()` apply those chunks synchronously to produce the current
|
|
322
|
+
`UIMessage`; cumulative message snapshots are not duplicated in the log.
|
|
323
|
+
|
|
324
|
+
`AIState` exposes `messages`, `status`, `activeGeneration`, `activeProjection`,
|
|
325
|
+
`pendingApprovals`, `pendingInputs`, `tools`, `compaction`, per-generation
|
|
326
|
+
`usage`, and the last generation `error`. `activeProjection` is the temporary
|
|
327
|
+
indexed chunk/tool frontier used for exact interruption and becomes `null` at
|
|
328
|
+
a terminal event.
|
|
329
|
+
|
|
330
|
+
## The built-in inputs
|
|
331
|
+
|
|
332
|
+
`inputs` contains pure, isomorphic builders for protocol interactions:
|
|
333
|
+
|
|
334
|
+
| Input | Events |
|
|
335
|
+
| --- | --- |
|
|
336
|
+
| `inputs.message(message)` | records a message and requests generation when its role is `user` |
|
|
337
|
+
| `inputs.seed(message)` | records a message without requesting generation |
|
|
338
|
+
| `inputs.approval(response)` | records an approval decision and requests continuation |
|
|
339
|
+
| `inputs.input(response)` | records application input and requests continuation |
|
|
340
|
+
| `inputs.requestInput(request)` | records an application-defined input request |
|
|
341
|
+
| `inputs.retry(options)` | requests a fresh attempt after a failed partial response |
|
|
342
|
+
| `inputs.interrupt(options)` | interrupts an active response |
|
|
343
|
+
|
|
344
|
+
The builders hide stable event ids, so the same interaction is safe to resend.
|
|
345
|
+
They return plain values accepted by both browser `push()` and server
|
|
346
|
+
`append()`.
|
|
347
|
+
|
|
348
|
+
`inputs` deliberately has no session lifecycle methods. The A2 log begins with
|
|
349
|
+
the first append. Apps that use explicit `ai.session.created` or
|
|
350
|
+
`ai.session.closed` lifecycle events push those ordinary contract events
|
|
351
|
+
directly.
|
|
352
|
+
|
|
353
|
+
## Add tools and approval
|
|
354
|
+
|
|
355
|
+
Pass ordinary AI SDK tools to the server. Approval policy belongs in
|
|
356
|
+
`generation`, beside the other `ToolLoopAgent` settings:
|
|
357
|
+
|
|
358
|
+
```ts server/with-tools.ts
|
|
359
|
+
import { tool } from 'ai'
|
|
360
|
+
import { z } from 'zod'
|
|
361
|
+
import { createAgentServer } from 'experimental-a2/ai/server'
|
|
362
|
+
import { assistant } from '../assistant'
|
|
363
|
+
|
|
364
|
+
const tools = {
|
|
365
|
+
closeTicket: tool({
|
|
366
|
+
description: 'Close a resolved support ticket',
|
|
367
|
+
inputSchema: z.object({ ticketId: z.string() }),
|
|
368
|
+
execute: async ({ ticketId }, { toolCallId }) => {
|
|
369
|
+
// your side effect; toolCallId makes a stable idempotency key:
|
|
370
|
+
// await closeTicket(ticketId, { idempotencyKey: toolCallId })
|
|
371
|
+
return { ticketId, closed: true }
|
|
372
|
+
},
|
|
373
|
+
}),
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export const assistantServerWithTools = createAgentServer({
|
|
377
|
+
agent: assistant,
|
|
378
|
+
model: 'openai/gpt-5.6-terra',
|
|
379
|
+
tools,
|
|
380
|
+
generation: {
|
|
381
|
+
toolApproval: {
|
|
382
|
+
closeTicket: 'user-approval',
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
})
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
The AI SDK emits an approval request instead of executing the tool. A2 records
|
|
389
|
+
it as `ai.approval.requested`, and the reducer adds it to
|
|
390
|
+
`state.pendingApprovals`. Respond from the same optimistic client path:
|
|
391
|
+
|
|
392
|
+
```tsx app/agent/[sessionId]/approval-controls.tsx
|
|
393
|
+
'use client'
|
|
394
|
+
import { inputs } from 'experimental-a2/ai'
|
|
395
|
+
import { useSession } from '../session'
|
|
396
|
+
|
|
397
|
+
export function ApprovalControls() {
|
|
398
|
+
const { state, push } = useSession()
|
|
399
|
+
|
|
400
|
+
return state.pendingApprovals.map((approval) => (
|
|
401
|
+
<div key={approval.approvalId}>
|
|
402
|
+
<span>Allow tool call {approval.toolCallId}?</span>
|
|
403
|
+
<button
|
|
404
|
+
onClick={() =>
|
|
405
|
+
void push(
|
|
406
|
+
...inputs.approval({
|
|
407
|
+
messageId: approval.messageId,
|
|
408
|
+
approvalId: approval.approvalId,
|
|
409
|
+
approved: true,
|
|
410
|
+
}),
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
>
|
|
414
|
+
Allow
|
|
415
|
+
</button>
|
|
416
|
+
<button
|
|
417
|
+
onClick={() =>
|
|
418
|
+
void push(
|
|
419
|
+
...inputs.approval({
|
|
420
|
+
messageId: approval.messageId,
|
|
421
|
+
approvalId: approval.approvalId,
|
|
422
|
+
approved: false,
|
|
423
|
+
reason: 'Denied by the user',
|
|
424
|
+
}),
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
>
|
|
428
|
+
Deny
|
|
429
|
+
</button>
|
|
430
|
+
</div>
|
|
431
|
+
))
|
|
432
|
+
}
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
`inputs.approval()` updates the approval part in the projected `UIMessage` and
|
|
436
|
+
requests another model turn. The server then executes the approved tool or
|
|
437
|
+
returns the denial to the model.
|
|
438
|
+
|
|
439
|
+
Tool activity follows the full AI SDK chunk lifecycle. Input validation errors,
|
|
440
|
+
execution errors, denials, and preliminary and final outputs become durable
|
|
441
|
+
`ai.tool.result` events. Preliminary outputs keep the tool `running`; a final
|
|
442
|
+
output completes it. Each result has its own event id, so a preliminary result
|
|
443
|
+
cannot deduplicate the final one. Dynamic-tool, provider-executed, provider
|
|
444
|
+
metadata, and tool metadata fields are preserved in the projection.
|
|
445
|
+
|
|
446
|
+
`inputs.input()` provides the same durable request/response shape for
|
|
447
|
+
application-defined input. Its value stays in raw history and
|
|
448
|
+
`state.pendingInputs`; resolve instructions dynamically or replace `generate`
|
|
449
|
+
when the value should become model context.
|
|
450
|
+
|
|
451
|
+
## Interrupt and retry
|
|
452
|
+
|
|
453
|
+
Interrupt the active response from the client:
|
|
454
|
+
|
|
455
|
+
```tsx app/agent/[sessionId]/stop-button.tsx
|
|
456
|
+
'use client'
|
|
457
|
+
import { inputs } from 'experimental-a2/ai'
|
|
458
|
+
import { useSession } from '../session'
|
|
459
|
+
|
|
460
|
+
export function StopButton() {
|
|
461
|
+
const { state, push, index } = useSession()
|
|
462
|
+
const active = state.activeGeneration
|
|
463
|
+
|
|
464
|
+
if (!active) return null
|
|
465
|
+
|
|
466
|
+
return (
|
|
467
|
+
<button
|
|
468
|
+
onClick={() =>
|
|
469
|
+
void push(
|
|
470
|
+
...inputs.interrupt({
|
|
471
|
+
messageId: active.responseMessageId,
|
|
472
|
+
generationId: active.generationId,
|
|
473
|
+
reason: 'Stopped by the user',
|
|
474
|
+
lastSeenIndex: index,
|
|
475
|
+
}),
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
>
|
|
479
|
+
Stop
|
|
480
|
+
</button>
|
|
481
|
+
)
|
|
482
|
+
}
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
The optimistic event updates the UI immediately and reaches A2's cancellation
|
|
486
|
+
channel. `lastSeenIndex` is the exact confirmed log frontier visible when the
|
|
487
|
+
user clicked. The reducer rewinds chunk and tool projections to that frontier,
|
|
488
|
+
keeps the partial response the user actually saw, and turns incomplete visible
|
|
489
|
+
tools into `output-error`. Progress, completion, or failure from that generation
|
|
490
|
+
cannot reactivate it after the interruption.
|
|
491
|
+
|
|
492
|
+
After a failed model call, `inputs.retry({ messageId, responseMessageId,
|
|
493
|
+
retryId })` starts a fresh attempt. `retryId` identifies the user's action, so
|
|
494
|
+
resending one retry is safe while a later retry remains distinct. Failed
|
|
495
|
+
partial progress remains in raw history but is excluded from the new prompt.
|
|
496
|
+
|
|
497
|
+
## Append from the server
|
|
498
|
+
|
|
499
|
+
Browser interactions should use `push()` for instant state. Server-originated
|
|
500
|
+
turns use the same inputs with `append()`:
|
|
501
|
+
|
|
502
|
+
```ts server/append-message.ts
|
|
503
|
+
import { inputs } from 'experimental-a2/ai'
|
|
504
|
+
import { assistantServer } from './assistant'
|
|
505
|
+
|
|
506
|
+
export async function appendServerMessage(sessionId: string, text: string) {
|
|
507
|
+
await assistantServer.session(sessionId).append(
|
|
508
|
+
...inputs.message({
|
|
509
|
+
id: crypto.randomUUID(),
|
|
510
|
+
role: 'user',
|
|
511
|
+
parts: [{ type: 'text', text }],
|
|
512
|
+
}),
|
|
513
|
+
)
|
|
514
|
+
}
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
This path fits webhooks, jobs, and other backend sources. A connected browser
|
|
518
|
+
still receives the events over SSE, but it has no optimistic overlay for an
|
|
519
|
+
interaction that began elsewhere.
|
|
520
|
+
|
|
521
|
+
## Compact long conversations
|
|
522
|
+
|
|
523
|
+
Compaction is optional application policy. A2 records both the decision and
|
|
524
|
+
replacement messages, so later prompts remain explainable from history:
|
|
525
|
+
|
|
526
|
+
```ts server/compacted.ts
|
|
527
|
+
import type { UIMessage } from 'ai'
|
|
528
|
+
import { createAgentServer } from 'experimental-a2/ai/server'
|
|
529
|
+
import { assistant } from '../assistant'
|
|
530
|
+
|
|
531
|
+
export const compactedAssistantServer = createAgentServer({
|
|
532
|
+
agent: assistant,
|
|
533
|
+
model: 'openai/gpt-5.6-terra',
|
|
534
|
+
compaction: {
|
|
535
|
+
shouldCompact: ({ messages }) => messages.length > 40,
|
|
536
|
+
compact: async ({ messages }) => {
|
|
537
|
+
const summary = {
|
|
538
|
+
id: crypto.randomUUID(),
|
|
539
|
+
role: 'user',
|
|
540
|
+
parts: [{ type: 'text', text: 'Summary of the earlier conversation.' }],
|
|
541
|
+
} satisfies UIMessage
|
|
542
|
+
|
|
543
|
+
return [summary, ...messages.slice(-10)]
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
})
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
The callback can call another model, create a deterministic summary, or retain
|
|
550
|
+
selected messages. A2 owns only when the result enters the log and how later
|
|
551
|
+
generations consume it.
|
|
552
|
+
|
|
553
|
+
## Extend the assembly
|
|
554
|
+
|
|
555
|
+
The convenience API is ordinary A2 parts:
|
|
556
|
+
|
|
557
|
+
- `events` and `createEvents()` export the built-in schemas.
|
|
558
|
+
- `createReducer({ contract })` builds the standard `AIState` projection for a
|
|
559
|
+
compatible contract.
|
|
560
|
+
- `agent()` combines the built-in protocol, application events, and reducer.
|
|
561
|
+
- `createHandlers({ agent, ... })` returns the AI handler table.
|
|
562
|
+
- `createAgentServer()` combines those handlers with `createServer()`.
|
|
563
|
+
|
|
564
|
+
Use the lower-level pieces when an application needs more handlers or a
|
|
565
|
+
different server assembly:
|
|
566
|
+
|
|
567
|
+
```ts server/custom.ts
|
|
568
|
+
import { z } from 'zod'
|
|
569
|
+
import { agent } from 'experimental-a2/ai'
|
|
570
|
+
import { createHandlers } from 'experimental-a2/ai/server'
|
|
571
|
+
import { createServer } from 'experimental-a2/server'
|
|
572
|
+
|
|
573
|
+
const supportAgent = agent({
|
|
574
|
+
name: 'extended-support-agent',
|
|
575
|
+
events: {
|
|
576
|
+
'ticket.linked': z.object({ ticketId: z.string() }),
|
|
577
|
+
},
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
const aiHandlers = createHandlers({
|
|
581
|
+
agent: supportAgent,
|
|
582
|
+
model: 'openai/gpt-5.6-terra',
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
export const customAssistantServer = createServer({
|
|
586
|
+
contract: supportAgent.contract,
|
|
587
|
+
handlers: {
|
|
588
|
+
...aiHandlers,
|
|
589
|
+
'ticket.linked': async ({ event }) => {
|
|
590
|
+
// your side effect; event.id makes a stable idempotency key:
|
|
591
|
+
// await indexTicket(event.payload.ticketId, { idempotencyKey: event.id })
|
|
592
|
+
},
|
|
593
|
+
},
|
|
594
|
+
})
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
Application events stay fully typed. The standard AI reducer ignores unknown
|
|
598
|
+
events, so another reducer can project application state without forking the AI
|
|
599
|
+
protocol.
|
|
600
|
+
|
|
601
|
+
### Replace generation, not durability
|
|
602
|
+
|
|
603
|
+
The default generator uses the AI SDK `ToolLoopAgent`. Replace only that
|
|
604
|
+
model-facing step for fixtures, provider routing, or a different AI SDK
|
|
605
|
+
assembly:
|
|
606
|
+
|
|
607
|
+
```ts server/custom-generation.ts
|
|
608
|
+
import { ToolLoopAgent, createAgentUIStream } from 'ai'
|
|
609
|
+
import { createAgentServer } from 'experimental-a2/ai/server'
|
|
610
|
+
import { assistant } from '../assistant'
|
|
611
|
+
|
|
612
|
+
export const customGenerationServer = createAgentServer({
|
|
613
|
+
agent: assistant,
|
|
614
|
+
model: 'openai/gpt-5.6-terra',
|
|
615
|
+
generate: async ({
|
|
616
|
+
messages,
|
|
617
|
+
model,
|
|
618
|
+
tools,
|
|
619
|
+
instructions,
|
|
620
|
+
generation,
|
|
621
|
+
generationId,
|
|
622
|
+
responseMessageId,
|
|
623
|
+
signal,
|
|
624
|
+
}) => {
|
|
625
|
+
const sdkAgent = new ToolLoopAgent<never, typeof tools>({
|
|
626
|
+
...generation,
|
|
627
|
+
id: generationId,
|
|
628
|
+
model,
|
|
629
|
+
tools,
|
|
630
|
+
...(instructions === undefined ? {} : { instructions }),
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
return createAgentUIStream({
|
|
634
|
+
agent: sdkAgent,
|
|
635
|
+
uiMessages: messages,
|
|
636
|
+
generateMessageId: () => responseMessageId,
|
|
637
|
+
abortSignal: signal,
|
|
638
|
+
})
|
|
639
|
+
},
|
|
640
|
+
})
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
`generate` receives compacted messages, resolved model and instructions, tools,
|
|
644
|
+
generation settings, durable request identity, current state and history, and
|
|
645
|
+
the abort signal. It returns one `ReadableStream<UIMessageChunk>`. A2 still
|
|
646
|
+
records progress, tool and approval events, completion, interruption, and
|
|
647
|
+
failure.
|
|
648
|
+
|
|
649
|
+
## Delivery semantics
|
|
650
|
+
|
|
651
|
+
Model calls and tools run inside an at-least-once A2 handler. Durable event ids
|
|
652
|
+
make lifecycle appends effectively once, and A2 marks an incomplete attempt
|
|
653
|
+
`superseded` before starting its replacement. Model providers and external tool
|
|
654
|
+
side effects remain outside the log. Give side-effecting tools their own
|
|
655
|
+
idempotency strategy, normally keyed by the AI SDK tool call id.
|
|
656
|
+
|
|
657
|
+
Configure [production recovery](/guides/production) exactly as for any other A2
|
|
658
|
+
server. Recovery wakes an interrupted generation after the original serverless
|
|
659
|
+
invocation disappears.
|