experimental-a2 0.17.0 → 0.18.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 +21 -0
- package/README.md +1 -1
- package/dist/{actor-DBbe5xeE.d.ts → actor-Bjce9_5U.d.ts} +5 -5
- package/dist/{actor-DBbe5xeE.d.ts.map → actor-Bjce9_5U.d.ts.map} +1 -1
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-react.d.ts +3 -3
- package/dist/actor-react.js +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +2 -2
- package/dist/{ai-UrWfKeuv.js → ai-BpOGm-zB.js} +2 -2
- package/dist/{ai-UrWfKeuv.js.map → ai-BpOGm-zB.js.map} +1 -1
- package/dist/{ai-vpGtcaCB.d.ts → ai-DrUaMyRt.d.ts} +3 -3
- package/dist/{ai-vpGtcaCB.d.ts.map → ai-DrUaMyRt.d.ts.map} +1 -1
- package/dist/ai-client.d.ts +2 -2
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.browser.d.ts +1 -0
- package/dist/ai-fx.browser.js +6 -0
- package/dist/ai-fx.browser.js.map +1 -0
- package/dist/ai-fx.d.ts +10 -0
- package/dist/ai-fx.d.ts.map +1 -0
- package/dist/ai-fx.js +634 -0
- package/dist/ai-fx.js.map +1 -0
- package/dist/ai-harness-BuLh_EYi.js +273 -0
- package/dist/ai-harness-BuLh_EYi.js.map +1 -0
- package/dist/ai-harness-DvsPeJeB.d.ts +26 -0
- package/dist/ai-harness-DvsPeJeB.d.ts.map +1 -0
- package/dist/ai-pi.browser.d.ts +1 -0
- package/dist/ai-pi.browser.js +6 -0
- package/dist/ai-pi.browser.js.map +1 -0
- package/dist/ai-pi.d.ts +17 -0
- package/dist/ai-pi.d.ts.map +1 -0
- package/dist/ai-pi.js +904 -0
- package/dist/ai-pi.js.map +1 -0
- package/dist/ai-react.d.ts +5 -5
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +47 -25
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +106 -37
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/bounded-cache-CPHWF-8h.js +41 -0
- package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
- package/dist/{client-COaiaDub.js → client-BReKqwZC.js} +3 -3
- package/dist/{client-COaiaDub.js.map → client-BReKqwZC.js.map} +1 -1
- package/dist/{client-qRnRUzBS.d.ts → client-h_DKFAjj.d.ts} +3 -3
- package/dist/{client-qRnRUzBS.d.ts.map → client-h_DKFAjj.d.ts.map} +1 -1
- package/dist/client.d.ts +3 -3
- package/dist/client.js +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/{initial-snapshot-BrLso5x_.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +2 -2
- package/dist/{initial-snapshot-BrLso5x_.d.ts.map → initial-snapshot-Bx-c0pDP.d.ts.map} +1 -1
- package/dist/{internal-CbVvEwad.js → internal-B2T3U4Rq.js} +8 -2
- package/dist/internal-B2T3U4Rq.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/{react-BbJ-g6Uu.d.ts → react-BBXgHWFc.d.ts} +4 -4
- package/dist/{react-BbJ-g6Uu.d.ts.map → react-BBXgHWFc.d.ts.map} +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/reducer-BAXc8JyF.js.map +1 -1
- package/dist/{reducer-BYu6DT7h.d.ts → reducer-CqA59Q6U.d.ts} +2 -2
- package/dist/{reducer-BYu6DT7h.d.ts.map → reducer-CqA59Q6U.d.ts.map} +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +2 -2
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +2 -2
- package/dist/{server-Rj64fNvX.js → server-Dw_k_7kO.js} +93 -60
- package/dist/server-Dw_k_7kO.js.map +1 -0
- package/dist/{server-CGM8WzRG.d.ts → server-En7cbi3v.d.ts} +36 -7
- package/dist/server-En7cbi3v.d.ts.map +1 -0
- package/dist/server.d.ts +4 -4
- package/dist/server.js +1 -1
- package/dist/{session-clear-Dshpuhff.d.ts → session-clear-Bo-XTFEy.d.ts} +2 -2
- package/dist/session-clear-Bo-XTFEy.d.ts.map +1 -0
- package/dist/{store-Gpkq7WBo.d.ts → store-Djdv0bnZ.d.ts} +31 -2
- package/dist/store-Djdv0bnZ.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +32 -6
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-4_y8lETm.js → store-polling-B5FcwJfo.js} +2 -2
- package/dist/{store-polling-4_y8lETm.js.map → store-polling-B5FcwJfo.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.js +54 -3
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-Q1C-k2dd.js → store-presence-polling-BAwS8RWn.js} +3 -3
- package/dist/{store-presence-polling-Q1C-k2dd.js.map → store-presence-polling-BAwS8RWn.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +3 -3
- package/dist/{store-redis-notify-tqWNnMRu.js → store-redis-notify-BRITti_o.js} +187 -68
- package/dist/store-redis-notify-BRITti_o.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +32 -2
- package/dist/store-sqlite.js.map +1 -1
- package/dist/{telemetry-cZH-cibj.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
- package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
- package/dist/{wire-DjfHOvdW.js → wire-rSUglYo8.js} +2 -2
- package/dist/{wire-DjfHOvdW.js.map → wire-rSUglYo8.js.map} +1 -1
- package/docs/01-quickstart.mdx +49 -83
- package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
- package/docs/actors/01-introduction.mdx +48 -86
- package/docs/actors/02-concurrency.mdx +43 -70
- package/docs/actors/03-timers.mdx +28 -42
- package/docs/actors/04-routes.mdx +110 -150
- package/docs/actors/meta.ts +1 -1
- package/docs/agents/01-introduction.mdx +257 -0
- package/docs/agents/02-tools.mdx +247 -0
- package/docs/agents/03-conversations.mdx +538 -0
- package/docs/agents/04-harnesses.mdx +136 -0
- package/docs/agents/meta.ts +1 -0
- package/docs/client/01-react.mdx +294 -0
- package/docs/client/02-transports.mdx +86 -0
- package/docs/client/03-presence.mdx +224 -0
- package/docs/client/meta.ts +1 -0
- package/docs/contract/01-events.mdx +57 -0
- package/docs/contract/02-state.mdx +142 -0
- package/docs/contract/03-sessions.mdx +175 -0
- package/docs/index.mdx +108 -144
- package/docs/reference/01-core.mdx +139 -0
- package/docs/reference/02-server.mdx +855 -0
- package/docs/reference/03-client.mdx +301 -0
- package/docs/reference/04-ai.mdx +722 -0
- package/docs/reference/05-adapters.mdx +351 -0
- package/docs/reference/06-errors.mdx +138 -0
- package/docs/reference/meta.ts +1 -1
- package/docs/server/01-handlers.mdx +200 -0
- package/docs/server/02-timers.mdx +130 -0
- package/docs/server/03-cancellation.mdx +141 -0
- package/docs/server/04-routes.mdx +243 -0
- package/docs/server/05-production.mdx +322 -0
- package/docs/server/06-guarantees.mdx +123 -0
- package/examples/playground/DEPLOY.md +1 -1
- package/examples/playground/app/a0/[appId]/page.tsx +11 -6
- package/examples/playground/app/a0/app-list.tsx +9 -2
- package/examples/playground/app/a0/app-view.tsx +9 -1
- package/examples/playground/app/a0/apps/[appId]/route.ts +10 -5
- package/examples/playground/app/a0/apps.test.ts +12 -0
- package/examples/playground/app/a0/apps.ts +22 -7
- package/examples/playground/app/a0/chat.tsx +47 -1
- package/examples/playground/app/a0/fx/events/route.ts +9 -0
- package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
- package/examples/playground/app/a0/model.test.ts +18 -1
- package/examples/playground/app/a0/model.ts +47 -10
- package/examples/playground/app/a0/no-photon.cjs +4 -0
- package/examples/playground/app/a0/no-quickjs.cjs +3 -0
- package/examples/playground/app/a0/pi/events/route.ts +9 -0
- package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
- package/examples/playground/app/a0/sandbox-log.ts +10 -4
- package/examples/playground/app/a0/sandbox-session.ts +152 -0
- package/examples/playground/app/a0/scheduler/route.ts +2 -2
- package/examples/playground/app/a0/server.ts +100 -45
- package/examples/playground/app/a0/session.ts +17 -5
- package/examples/playground/app/a0/shell.tsx +11 -3
- package/examples/playground/app/agent/ingress.test.ts +6 -3
- package/examples/playground/app/agent/server.ts +9 -6
- package/examples/playground/app/agent/telemetry.test.ts +71 -50
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +1 -1
- package/examples/playground/app/chat/server.test.ts +30 -26
- package/examples/playground/app/chat/server.ts +46 -46
- package/examples/playground/next.config.ts +17 -5
- package/examples/playground/package.json +4 -1
- package/examples/playground/vercel.json +18 -0
- package/package.json +27 -3
- package/src/ai-agent.ts +261 -87
- package/src/ai-fx.browser.ts +5 -0
- package/src/ai-fx.ts +833 -0
- package/src/ai-harness.ts +518 -0
- package/src/ai-model-metadata.ts +11 -6
- package/src/ai-pi.browser.ts +5 -0
- package/src/ai-pi.ts +1263 -0
- package/src/ai-sdk-step.ts +2 -2
- package/src/ai-server.ts +3 -1
- package/src/internal.ts +7 -0
- package/src/libfx.d.ts +34 -0
- package/src/reducer.ts +1 -1
- package/src/server.ts +177 -19
- package/src/store-memory.ts +42 -7
- package/src/store-postgres.ts +80 -0
- package/src/store-redis-core.ts +210 -79
- package/src/store-redis-notify.ts +2 -0
- package/src/store-sqlite.ts +60 -0
- package/src/store.ts +33 -0
- package/src/telemetry.ts +1 -0
- package/dist/internal-CbVvEwad.js.map +0 -1
- package/dist/server-CGM8WzRG.d.ts.map +0 -1
- package/dist/server-Rj64fNvX.js.map +0 -1
- package/dist/session-clear-Dshpuhff.d.ts.map +0 -1
- package/dist/store-Gpkq7WBo.d.ts.map +0 -1
- package/dist/store-redis-notify-tqWNnMRu.js.map +0 -1
- package/dist/telemetry-cZH-cibj.d.ts.map +0 -1
- package/docs/concepts/01-contracts.mdx +0 -237
- package/docs/concepts/02-handlers.mdx +0 -284
- package/docs/concepts/03-durability.mdx +0 -314
- package/docs/concepts/04-state.mdx +0 -209
- package/docs/guides/01-timers.mdx +0 -193
- package/docs/guides/02-cancellation.mdx +0 -161
- package/docs/guides/03-react.mdx +0 -324
- package/docs/guides/04-local-first.mdx +0 -47
- package/docs/guides/05-production.mdx +0 -534
- package/docs/guides/06-ai-agents.mdx +0 -1408
- package/docs/guides/08-application-data.mdx +0 -113
- package/docs/guides/09-presence.mdx +0 -262
- package/docs/guides/10-transports.mdx +0 -221
- package/docs/reference/01-api.mdx +0 -2358
- package/docs/reference/02-errors.mdx +0 -150
- /package/docs/{concepts → contract}/meta.ts +0 -0
- /package/docs/{guides → server}/meta.ts +0 -0
|
@@ -1,1408 +0,0 @@
|
|
|
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()` is the whole server: the built-in AI protocol, the AI SDK runner, and
|
|
24
|
-
A2's ordinary store, scheduler, telemetry, and handler machinery, assembled as
|
|
25
|
-
one A2 server.
|
|
26
|
-
|
|
27
|
-
```ts server/assistant.ts
|
|
28
|
-
import { agent } from 'experimental-a2/ai/server'
|
|
29
|
-
|
|
30
|
-
export const assistant = agent({
|
|
31
|
-
name: 'support-agent',
|
|
32
|
-
model: 'openai/gpt-5.6-terra',
|
|
33
|
-
instructions: 'Help the customer. Be concise.',
|
|
34
|
-
generation: {
|
|
35
|
-
temperature: 0.2,
|
|
36
|
-
maxOutputTokens: 2_000,
|
|
37
|
-
},
|
|
38
|
-
})
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
`name` is the agent's identity: it prefixes storage keys and queue messages.
|
|
42
|
-
`experimental-a2/ai/server` is server-only, and a browser bundle that imports it
|
|
43
|
-
fails loudly. Browser code reaches the agent through its route and a type-only
|
|
44
|
-
import, shown below.
|
|
45
|
-
|
|
46
|
-
A string model id uses the AI SDK's global provider, which is AI Gateway by
|
|
47
|
-
default. `model` also accepts any AI SDK `LanguageModel`, such as a provider
|
|
48
|
-
model returned by `openai('gpt-5.6-terra')`, or an async resolver that chooses a
|
|
49
|
-
model for each generation.
|
|
50
|
-
|
|
51
|
-
Each durable generation runs one AI SDK `streamText()` step. `generation`
|
|
52
|
-
contains per-step settings such as sampling, token limits, provider options,
|
|
53
|
-
and tool approval policy. A2 owns the stop condition, local tool execution,
|
|
54
|
-
and continuation between steps. Responses have no step limit by default. Set
|
|
55
|
-
`maxSteps` to a positive integer to limit one complete assistant response.
|
|
56
|
-
Individual providers decide which model settings they support. `generation`
|
|
57
|
-
therefore excludes `stopWhen`, tool execution callbacks, tool callers, sandbox
|
|
58
|
-
execution, and the tool approval secret. A2 preserves all other supplied
|
|
59
|
-
settings, including `toolsContext`, when calling the AI SDK. A local tool
|
|
60
|
-
reads its durable execution context later, from the handler that runs it. Model-step
|
|
61
|
-
timeouts are supported; tool-execution timeouts are not.
|
|
62
|
-
|
|
63
|
-
`generation` also accepts a sync or async resolver with the same context as
|
|
64
|
-
`model`, called once per attempt before compaction and model dispatch. Read
|
|
65
|
-
application state with `session.state()`, which defaults to the captured
|
|
66
|
-
generation snapshot. See the
|
|
67
|
-
[API reference](/reference/api#agentoptions).
|
|
68
|
-
|
|
69
|
-
For AI Gateway models, the default generator sets
|
|
70
|
-
`generation.providerOptions.gateway.caching` to `'auto'`. A2 merges this
|
|
71
|
-
default with your provider options, with caller-supplied values taking
|
|
72
|
-
precedence. Other Gateway options and provider namespaces are preserved.
|
|
73
|
-
The same default applies to automatic compaction. A custom `generate`
|
|
74
|
-
function receives your original settings and owns its caching policy.
|
|
75
|
-
|
|
76
|
-
Gateway calls also send an `x-session-affinity` header derived from a SHA-256
|
|
77
|
-
hash of the agent name and session ID. The value stays stable across turns,
|
|
78
|
-
retries, worker restarts, and automatic compaction. No raw session identifier
|
|
79
|
-
is sent in this header. A caller-supplied `generation.headers` entry takes
|
|
80
|
-
precedence, regardless of header casing; an explicit `undefined` suppresses
|
|
81
|
-
A2's default header. Set overrides here, since A2's per-call default can
|
|
82
|
-
override headers configured on the model itself.
|
|
83
|
-
Affinity helps supporting providers reuse their cache; it does not select
|
|
84
|
-
Gateway's provider or guarantee a cache hit.
|
|
85
|
-
See [Gateway automatic caching](https://vercel.com/docs/ai-gateway/models-and-providers/automatic-caching)
|
|
86
|
-
for provider support and cache-write costs.
|
|
87
|
-
|
|
88
|
-
## Expose the route
|
|
89
|
-
|
|
90
|
-
One HTTP route gives the browser a read and write path. `GET` streams events;
|
|
91
|
-
`POST` accepts optimistic pushes:
|
|
92
|
-
|
|
93
|
-
```ts app/api/agent/route.ts
|
|
94
|
-
import { assistant } from '@/server/assistant'
|
|
95
|
-
|
|
96
|
-
export const GET = assistant.fetch
|
|
97
|
-
export const POST = assistant.fetch
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
The route never calls the model directly. The browser pushes user messages and
|
|
101
|
-
controls as `ai.control.requested` commands, and `fetch` rejects every
|
|
102
|
-
server-authored event with `400`. The controller admits inputs and schedules
|
|
103
|
-
`ai.generation.requested`; workers run the AI SDK and append progress to the
|
|
104
|
-
same log.
|
|
105
|
-
|
|
106
|
-
Authentication stays in your route. Here's where you'd do auth, or any other
|
|
107
|
-
checks, then hand the request to `assistant.fetch(request, { authorize })` for
|
|
108
|
-
per-operation policy.
|
|
109
|
-
|
|
110
|
-
## Render the conversation
|
|
111
|
-
|
|
112
|
-
Fold the initial state on the server and hand it to a client component:
|
|
113
|
-
|
|
114
|
-
```tsx app/agent/[sessionId]/page.tsx
|
|
115
|
-
import { assistant } from '@/server/assistant'
|
|
116
|
-
import { Chat } from './chat'
|
|
117
|
-
|
|
118
|
-
export default async function AgentPage({
|
|
119
|
-
params,
|
|
120
|
-
}: {
|
|
121
|
-
params: Promise<{ sessionId: string }>
|
|
122
|
-
}) {
|
|
123
|
-
const { sessionId } = await params
|
|
124
|
-
const initial = await assistant.session(sessionId).state()
|
|
125
|
-
return <Chat sessionId={sessionId} initial={initial} />
|
|
126
|
-
}
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
An empty session folds to the initial `AIState`. Its log is created lazily when
|
|
130
|
-
the first message is pushed.
|
|
131
|
-
|
|
132
|
-
`useAgent()` keeps that state live and returns the actions a chat needs:
|
|
133
|
-
|
|
134
|
-
```tsx app/agent/[sessionId]/chat.tsx
|
|
135
|
-
'use client'
|
|
136
|
-
import type { FormEvent } from 'react'
|
|
137
|
-
import { useState } from 'react'
|
|
138
|
-
import type { StateSnapshot } from 'experimental-a2'
|
|
139
|
-
import type { AIState } from 'experimental-a2/ai'
|
|
140
|
-
import { useAgent } from 'experimental-a2/ai/react'
|
|
141
|
-
import type { assistant } from '@/server/assistant'
|
|
142
|
-
|
|
143
|
-
const indicatorLabel = {
|
|
144
|
-
starting: 'Starting…',
|
|
145
|
-
thinking: 'Thinking…',
|
|
146
|
-
compacting: 'Compacting…',
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export function Chat({
|
|
150
|
-
sessionId,
|
|
151
|
-
initial,
|
|
152
|
-
}: {
|
|
153
|
-
sessionId: string
|
|
154
|
-
initial: StateSnapshot<AIState>
|
|
155
|
-
}) {
|
|
156
|
-
const { messages, status, indicator, sendMessage } =
|
|
157
|
-
useAgent<typeof assistant>({ api: '/api/agent', id: sessionId, initial })
|
|
158
|
-
const [draft, setDraft] = useState('')
|
|
159
|
-
const [error, setError] = useState<string | null>(null)
|
|
160
|
-
|
|
161
|
-
async function send(event: FormEvent<HTMLFormElement>): Promise<void> {
|
|
162
|
-
event.preventDefault()
|
|
163
|
-
const text = draft.trim()
|
|
164
|
-
if (!text) return
|
|
165
|
-
setDraft('')
|
|
166
|
-
setError(null)
|
|
167
|
-
try {
|
|
168
|
-
await sendMessage(text)
|
|
169
|
-
} catch (cause) {
|
|
170
|
-
setDraft(text)
|
|
171
|
-
setError(cause instanceof Error ? cause.message : String(cause))
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return (
|
|
176
|
-
<main>
|
|
177
|
-
<section aria-label="Conversation">
|
|
178
|
-
{messages.map((message) => (
|
|
179
|
-
<article key={message.id}>
|
|
180
|
-
<strong>{message.role === 'user' ? 'You' : 'Agent'}</strong>
|
|
181
|
-
{message.parts.map((part, index) =>
|
|
182
|
-
part.type === 'text' ? <p key={index}>{part.text}</p> : null,
|
|
183
|
-
)}
|
|
184
|
-
</article>
|
|
185
|
-
))}
|
|
186
|
-
{indicator ? (
|
|
187
|
-
<p role="status">{indicatorLabel[indicator]}</p>
|
|
188
|
-
) : null}
|
|
189
|
-
</section>
|
|
190
|
-
|
|
191
|
-
<form onSubmit={send}>
|
|
192
|
-
<input
|
|
193
|
-
aria-label="Message"
|
|
194
|
-
value={draft}
|
|
195
|
-
onChange={(event) => setDraft(event.currentTarget.value)}
|
|
196
|
-
/>
|
|
197
|
-
<button disabled={!draft.trim() || status === 'closed'}>Send</button>
|
|
198
|
-
</form>
|
|
199
|
-
|
|
200
|
-
{error ? <p role="alert">{error}</p> : null}
|
|
201
|
-
</main>
|
|
202
|
-
)
|
|
203
|
-
}
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
The `import type` of the server module is erased at build time, so neither the
|
|
207
|
-
server nor a contract enters the client bundle. The AI protocol and its reducer
|
|
208
|
-
are library code; the type argument only carries the agent's message types. The hook keeps one client per `api` for the page's lifetime,
|
|
209
|
-
and every component that calls `useAgent()` with the same `api` and `id` shares
|
|
210
|
-
one live session. That client reads `globalThis.fetch` on each request; pass
|
|
211
|
-
`client` instead of `api` for a custom `fetch`, headers, or `gcTime`. Actions are
|
|
212
|
-
rebuilt on every render, so do not rely on their identity.
|
|
213
|
-
|
|
214
|
-
The result is the ordinary session result (`state`, `index`, `connection`,
|
|
215
|
-
`push`, `events`, and history) plus actions, `messages` (the same array as
|
|
216
|
-
`state.messages`), `status`, and `indicator`. `indicator` names what the UI
|
|
217
|
-
shows while nothing new is visible yet: `starting` for an optimistic turn
|
|
218
|
-
awaiting acceptance, `compacting` while a summary runs, `thinking` while the
|
|
219
|
-
current model step has produced no visible part, and `null` otherwise. Reasoning
|
|
220
|
-
and step boundaries do not count as visible output, so the indicator returns
|
|
221
|
-
between tool steps.
|
|
222
|
-
|
|
223
|
-
`sendMessage()` turns a string into one text part with a fresh message ID, or
|
|
224
|
-
takes a complete `UIMessage`. Every action returns the same result as `push()`:
|
|
225
|
-
it resolves to the persisted command events once they are stored, and its
|
|
226
|
-
`.confirmed` promise resolves when the stream delivers them back. The draft
|
|
227
|
-
clears immediately and returns if the push fails. `state.inbox.items`
|
|
228
|
-
contains pending inputs; `state.messages` contains the admitted conversation.
|
|
229
|
-
|
|
230
|
-
Users can send another message while a response is active. The message enters
|
|
231
|
-
the durable log immediately, but its turn waits until the active assistant
|
|
232
|
-
response, including every tool step and approval, reaches a terminal event.
|
|
233
|
-
Inbox order defaults to arrival order and can be edited explicitly.
|
|
234
|
-
|
|
235
|
-
Pass `{ generate: false }` when a user message should update the conversation
|
|
236
|
-
without starting a model turn, as in
|
|
237
|
-
`sendMessage('Order #4411 shipped this morning.', { generate: false })`.
|
|
238
|
-
|
|
239
|
-
The passive user message appears in `AIState.messages` when admitted. A later user
|
|
240
|
-
message with the default generation behavior includes passive user messages
|
|
241
|
-
before it in model context. Passive user messages after that trigger remain
|
|
242
|
-
outside its prompt and wait for the next generating message. This ordering
|
|
243
|
-
supports ambient agents without letting new background context leak into an
|
|
244
|
-
already queued turn.
|
|
245
|
-
|
|
246
|
-
## Start a session from another page
|
|
247
|
-
|
|
248
|
-
Wait for the durable append before navigating, so the destination's server
|
|
249
|
-
render sees the message in its folded state instead of racing the `POST`.
|
|
250
|
-
`createAgentClient()` is the same contract-free client outside React:
|
|
251
|
-
|
|
252
|
-
```ts app/agent/client.ts
|
|
253
|
-
'use client'
|
|
254
|
-
import { createAgentClient } from 'experimental-a2/ai/client'
|
|
255
|
-
import type { assistant } from '@/server/assistant'
|
|
256
|
-
|
|
257
|
-
export const assistantClient = createAgentClient<typeof assistant>({
|
|
258
|
-
api: '/api/agent',
|
|
259
|
-
})
|
|
260
|
-
```
|
|
261
|
-
|
|
262
|
-
```ts app/agent/open-agent.ts
|
|
263
|
-
'use client'
|
|
264
|
-
import { inputs } from 'experimental-a2/ai'
|
|
265
|
-
import { assistantClient } from './client'
|
|
266
|
-
|
|
267
|
-
export async function openAgent({
|
|
268
|
-
sessionId,
|
|
269
|
-
text,
|
|
270
|
-
router,
|
|
271
|
-
}: {
|
|
272
|
-
sessionId: string
|
|
273
|
-
text: string
|
|
274
|
-
router: { push(href: string): void }
|
|
275
|
-
}): Promise<void> {
|
|
276
|
-
await assistantClient.session(sessionId).push(
|
|
277
|
-
...inputs.message({
|
|
278
|
-
id: crypto.randomUUID(),
|
|
279
|
-
role: 'user',
|
|
280
|
-
parts: [{ type: 'text', text }],
|
|
281
|
-
}),
|
|
282
|
-
)
|
|
283
|
-
router.push(`/agent/${sessionId}`)
|
|
284
|
-
}
|
|
285
|
-
```
|
|
286
|
-
|
|
287
|
-
Pass the same client to the destination's hook,
|
|
288
|
-
`useAgent<typeof assistant>({ client: assistantClient, id: sessionId, initial })`,
|
|
289
|
-
so it resolves the live session the push created and reconciles the server
|
|
290
|
-
render without replacing it. Idle session objects expire after five minutes by
|
|
291
|
-
default; configure `gcTime` on `createAgentClient` when a different lifetime
|
|
292
|
-
fits the app.
|
|
293
|
-
|
|
294
|
-
## Choose the model
|
|
295
|
-
|
|
296
|
-
`model` takes a model, or `ModelSettings` to choose its reasoning level and
|
|
297
|
-
speed with it:
|
|
298
|
-
|
|
299
|
-
```ts server/fast-assistant.ts
|
|
300
|
-
import { agent } from 'experimental-a2/ai/server'
|
|
301
|
-
|
|
302
|
-
export const fastAssistant = agent({
|
|
303
|
-
name: 'fast-support',
|
|
304
|
-
model: { model: 'openai/gpt-5.6-terra', reasoning: 'low', speed: 'fast' },
|
|
305
|
-
})
|
|
306
|
-
```
|
|
307
|
-
|
|
308
|
-
A resolver returns the same shape for each generation, so the choice can come
|
|
309
|
-
from anything it reads, such as the conversation so far:
|
|
310
|
-
|
|
311
|
-
```ts server/routed-assistant.ts
|
|
312
|
-
import { agent } from 'experimental-a2/ai/server'
|
|
313
|
-
|
|
314
|
-
export const routedAssistant = agent({
|
|
315
|
-
name: 'routed-support',
|
|
316
|
-
model: ({ messages }) =>
|
|
317
|
-
messages.length > 20
|
|
318
|
-
? { model: 'openai/gpt-5.6-terra', reasoning: 'high' }
|
|
319
|
-
: { model: 'openai/gpt-5.6-luna', speed: 'fast' },
|
|
320
|
-
})
|
|
321
|
-
```
|
|
322
|
-
|
|
323
|
-
`reasoning` becomes the AI SDK's `reasoning` call option. `speed: 'fast'` sets
|
|
324
|
-
`providerOptions.gateway.speed` to prefer AI Gateway's faster tier when the
|
|
325
|
-
model offers one; `'standard'` removes a configured speed. Both override the
|
|
326
|
-
same settings in `generation`. `reasoningLevels` from `experimental-a2/ai`
|
|
327
|
-
lists the levels, for building a picker. To choose from your app's state, such
|
|
328
|
-
as the user's plan or a picker's selection, see
|
|
329
|
-
[Add your own events and state](#add-your-own-events-and-state).
|
|
330
|
-
|
|
331
|
-
## What happens after a send
|
|
332
|
-
|
|
333
|
-
One generating user interaction becomes a durable sequence:
|
|
334
|
-
|
|
335
|
-
```text
|
|
336
|
-
browser ai.control.requested durable input command
|
|
337
|
-
server ai.message.created input admitted
|
|
338
|
-
server ai.control.committed accepted state changes
|
|
339
|
-
server ai.control.decided command receipt
|
|
340
|
-
server ai.generation.requested scheduled from durable facts
|
|
341
|
-
server ai.generation.started
|
|
342
|
-
server ai.generation.progress batched UIMessageChunk[]
|
|
343
|
-
server ai.tool.called when present
|
|
344
|
-
server ai.approval.requested when present
|
|
345
|
-
server ai.tool.result after concurrent execution or denial
|
|
346
|
-
server ai.generation.completed closes this model step
|
|
347
|
-
server ai.generation.requested after all sibling tools are terminal
|
|
348
|
-
... another one-step generation
|
|
349
|
-
server ai.message.completed
|
|
350
|
-
server ai.generation.requested next queued user turn, when present
|
|
351
|
-
```
|
|
352
|
-
|
|
353
|
-
Tool results and generation completion may arrive in either order. A fast
|
|
354
|
-
automatic tool can finish while the model stream is still open. The join waits
|
|
355
|
-
for both step closure and every required terminal result.
|
|
356
|
-
|
|
357
|
-
The SSE connection delivers the server events to the same reducer. Progress
|
|
358
|
-
stores streamed output once. Within a batch, adjacent text, reasoning, and
|
|
359
|
-
tool-input deltas of one part are joined into a single chunk. The reducer and the exported
|
|
360
|
-
`deriveUIMessages()` apply those chunks synchronously to produce the current
|
|
361
|
-
`UIMessage`; cumulative message snapshots are not duplicated in the log.
|
|
362
|
-
|
|
363
|
-
`AIState` exposes `messages`, `status`, `inbox`, `active`,
|
|
364
|
-
`pendingApprovals`, `pendingInputs`, `tools`, `compaction`, per-generation
|
|
365
|
-
`usage`, the latest command `receipt`, and the last generation `error`. It also
|
|
366
|
-
carries the protocol's ownership bookkeeping: `activeGeneration`,
|
|
367
|
-
`activeRequestId`, `activeResponseMessageId`, `activeProjection`,
|
|
368
|
-
`responseOwners`, `terminalRequestIds`, and `terminalGenerations`. The hook's
|
|
369
|
-
actions read these fields for you; lower-level `inputs` builders need them
|
|
370
|
-
explicitly.
|
|
371
|
-
|
|
372
|
-
`activeProjection` is the temporary indexed generation frontier used for exact
|
|
373
|
-
interruption. It remains available after a generation step completes while the
|
|
374
|
-
response waits on tool, approval, or input barriers, and after a generation
|
|
375
|
-
fails while it awaits retry or interruption. It clears when the response
|
|
376
|
-
completes, is interrupted or superseded, a retry begins, or a later generation
|
|
377
|
-
replaces it.
|
|
378
|
-
|
|
379
|
-
`activeRequestId` identifies the server-authorized generation request. It
|
|
380
|
-
prevents a delayed request or recovered attempt from taking ownership from the
|
|
381
|
-
current response before its generation starts. `activeResponseMessageId`
|
|
382
|
-
identifies that request's response before `activeGeneration` exists. It is
|
|
383
|
-
`null` after the generation starts.
|
|
384
|
-
|
|
385
|
-
`responseOwners` keeps the latest generation ID, request ID, and attempt for each response
|
|
386
|
-
message. Late lifecycle events from a superseded generation stay in raw
|
|
387
|
-
history but cannot alter the projected message, even after its replacement
|
|
388
|
-
finishes.
|
|
389
|
-
|
|
390
|
-
A2 generates fixed-length deterministic IDs in its reserved `a2.ai:` namespace
|
|
391
|
-
for requests, generations, responses, and worker lifecycle events. Treat those
|
|
392
|
-
IDs as opaque. An assistant response uses its first model-request event's ID. Generation requests carry
|
|
393
|
-
`responseMessageId` explicitly; tool continuations also carry `sourceGenerationId`.
|
|
394
|
-
Ownership comes from these fields and the generation's `requestId` and `attempt`,
|
|
395
|
-
so ID strings never embed the preceding tool loop.
|
|
396
|
-
|
|
397
|
-
Generation requests share one durable lane, so model calls do not overlap.
|
|
398
|
-
The queued-turn policy is the stronger ordering rule: a later user message is
|
|
399
|
-
not scheduled until the current response's complete tool loop finishes. Lane
|
|
400
|
-
FIFO alone would not prevent a newly appended user turn from overtaking a tool
|
|
401
|
-
continuation that has not been appended yet.
|
|
402
|
-
|
|
403
|
-
## Add tools and approval
|
|
404
|
-
|
|
405
|
-
Tools are ordinary AI SDK `tool()` definitions. A2 passes them through to
|
|
406
|
-
the AI SDK without interpreting or restricting them. Approval policy
|
|
407
|
-
belongs in `generation`, beside the other per-step settings:
|
|
408
|
-
|
|
409
|
-
```ts server/with-tools.ts
|
|
410
|
-
import { tool } from 'ai'
|
|
411
|
-
import { z } from 'zod'
|
|
412
|
-
import { agent } from 'experimental-a2/ai/server'
|
|
413
|
-
|
|
414
|
-
const tools = {
|
|
415
|
-
closeTicket: tool({
|
|
416
|
-
description: 'Close a resolved support ticket',
|
|
417
|
-
inputSchema: z.object({ ticketId: z.string() }),
|
|
418
|
-
execute: async ({ ticketId }, { toolCallId }) => {
|
|
419
|
-
// your side effect; toolCallId makes a stable idempotency key:
|
|
420
|
-
// await closeTicket(ticketId, { idempotencyKey: toolCallId })
|
|
421
|
-
return { ticketId, closed: true }
|
|
422
|
-
},
|
|
423
|
-
}),
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
export const toolsAssistant = agent({
|
|
427
|
-
name: 'support-tools',
|
|
428
|
-
model: 'openai/gpt-5.6-terra',
|
|
429
|
-
tools,
|
|
430
|
-
generation: {
|
|
431
|
-
toolApproval: {
|
|
432
|
-
closeTicket: 'user-approval',
|
|
433
|
-
},
|
|
434
|
-
},
|
|
435
|
-
})
|
|
436
|
-
```
|
|
437
|
-
|
|
438
|
-
The AI SDK infers the tool input and output from its schemas and
|
|
439
|
-
`execute`. A2 additionally runs each local `execute` inside an ambient
|
|
440
|
-
scope carrying the current durable handler attempt. Read it with
|
|
441
|
-
`handlerContext()` from `experimental-a2/ai`: it returns the same context an
|
|
442
|
-
event handler receives, with `event`, `attempt`, `session`, and `signal`. Tools
|
|
443
|
-
execute an `ai.tool.execution.requested` event after authorization. Its payload
|
|
444
|
-
contains the admitted `call`, its `generation`, and its turn and execution
|
|
445
|
-
version. `handlerContext()` throws when called outside a tool execution. The
|
|
446
|
-
scope survives awaited helpers and async iteration, so any code below `execute`
|
|
447
|
-
can read it. Because the call takes no argument, a tool can live inside the
|
|
448
|
-
`agent()` call that serves it. Pass a shared `agentContract()` to type
|
|
449
|
-
application events on `ctx.session`; A2 checks it against the executing agent.
|
|
450
|
-
|
|
451
|
-
`generation.toolApproval` is evaluated during the model step, before A2 has a
|
|
452
|
-
durable tool handler. Static policies such as the example above are simplest.
|
|
453
|
-
An input-dependent policy may be a function that returns `user-approval` or
|
|
454
|
-
`approved` from the validated tool input. Approval predicates are
|
|
455
|
-
re-evaluated on recovery for the same durable tool call, so they must be
|
|
456
|
-
deterministic over the tool input; an impure predicate can fork one
|
|
457
|
-
durable call's flow. Model-phase options such as `toolsContext` and
|
|
458
|
-
per-tool `contextSchema` are yours and pass through to the AI SDK
|
|
459
|
-
untouched. Any model-phase hook that receives tool definitions sees
|
|
460
|
-
them without local `execute` functions. The durable A2 context is
|
|
461
|
-
ambient only inside `execute`.
|
|
462
|
-
|
|
463
|
-
The context's `session` belongs to the agent executing the tool, so its
|
|
464
|
-
`schedule()` method uses that agent's scheduler. For example, this tool
|
|
465
|
-
schedules a reminder as a new user message:
|
|
466
|
-
|
|
467
|
-
```ts server/set-reminder.ts
|
|
468
|
-
import { tool } from 'ai'
|
|
469
|
-
import { handlerContext, inputs } from 'experimental-a2/ai'
|
|
470
|
-
import { z } from 'zod'
|
|
471
|
-
|
|
472
|
-
export const setReminder = tool({
|
|
473
|
-
description: 'Set a reminder for yourself',
|
|
474
|
-
inputSchema: z.object({
|
|
475
|
-
delaySeconds: z.number().int().min(1).max(6 * 24 * 60 * 60),
|
|
476
|
-
reminder: z.string(),
|
|
477
|
-
}),
|
|
478
|
-
execute: async ({ delaySeconds, reminder }, { toolCallId }) => {
|
|
479
|
-
const ctx = handlerContext()
|
|
480
|
-
await ctx.session.schedule(
|
|
481
|
-
`reminder:${toolCallId}`,
|
|
482
|
-
{ delay: `${delaySeconds}s` },
|
|
483
|
-
...inputs.message({
|
|
484
|
-
id: `reminder:${ctx.event.id}`,
|
|
485
|
-
role: 'user',
|
|
486
|
-
parts: [{ type: 'text', text: `Reminder: ${reminder}` }],
|
|
487
|
-
}),
|
|
488
|
-
)
|
|
489
|
-
|
|
490
|
-
return { scheduled: true, delaySeconds }
|
|
491
|
-
},
|
|
492
|
-
})
|
|
493
|
-
```
|
|
494
|
-
|
|
495
|
-
Add `setReminder` to the agent's `tools` and configure a
|
|
496
|
-
[scheduler](/guides/production).
|
|
497
|
-
The numeric schema lets the model choose any whole-second delay in the
|
|
498
|
-
provider's supported range instead of selecting from a fixed application enum.
|
|
499
|
-
The example uses A2's six-day Vercel Queues limit. The seventh retention day is
|
|
500
|
-
reserved for delivery and retries. Adjust the maximum to match your scheduler
|
|
501
|
-
and plan.
|
|
502
|
-
The schedule name uses `toolCallId`, and the message id uses the durable
|
|
503
|
-
triggering event id. A retry therefore converges on the same timer and message,
|
|
504
|
-
even if a provider reuses tool-call ids in a later generation. When the timer
|
|
505
|
-
arrives, `inputs.message()` queues a durable input command. The
|
|
506
|
-
queued-turn policy starts a fresh response after the active response completes
|
|
507
|
-
or is interrupted. A failed response must be retried or interrupted first.
|
|
508
|
-
If the scheduler send fails ambiguously or transiently, A2 retries the same
|
|
509
|
-
durable tool handler so that identity and target time stay fixed. Deterministic
|
|
510
|
-
adapter rejections are terminal tool errors. A missing scheduler is also a
|
|
511
|
-
normal tool error, so configure one before exposing the tool.
|
|
512
|
-
|
|
513
|
-
A tool that never reads `handlerContext()` is just an AI SDK tool;
|
|
514
|
-
nothing else changes. To test a context-reading tool directly, wrap the
|
|
515
|
-
call with `runWithHandlerContext(ctx, fn)` from
|
|
516
|
-
`experimental-a2/ai/server`: inside `fn`, `handlerContext()` returns
|
|
517
|
-
`ctx`.
|
|
518
|
-
|
|
519
|
-
The AI SDK emits an approval request instead of executing the tool. A2 records
|
|
520
|
-
it as `ai.approval.requested`, and the reducer adds it to
|
|
521
|
-
`state.pendingApprovals`. Answer it from the hook:
|
|
522
|
-
|
|
523
|
-
```tsx app/tools/[sessionId]/approval-controls.tsx
|
|
524
|
-
'use client'
|
|
525
|
-
import { useAgent } from 'experimental-a2/ai/react'
|
|
526
|
-
import type { toolsAssistant } from '@/server/with-tools'
|
|
527
|
-
|
|
528
|
-
export function ApprovalControls({ sessionId }: { sessionId: string }) {
|
|
529
|
-
const { state, approve, deny } = useAgent<typeof toolsAssistant>({
|
|
530
|
-
api: '/api/tools',
|
|
531
|
-
id: sessionId,
|
|
532
|
-
})
|
|
533
|
-
|
|
534
|
-
return state.pendingApprovals.map((approval) => (
|
|
535
|
-
<div key={approval.approvalId}>
|
|
536
|
-
<span>Allow tool call {approval.toolCallId}?</span>
|
|
537
|
-
<button onClick={() => void approve(approval)}>Allow</button>
|
|
538
|
-
<button
|
|
539
|
-
onClick={() => void deny(approval, { reason: 'Denied by the user' })}
|
|
540
|
-
>
|
|
541
|
-
Deny
|
|
542
|
-
</button>
|
|
543
|
-
</div>
|
|
544
|
-
))
|
|
545
|
-
}
|
|
546
|
-
```
|
|
547
|
-
|
|
548
|
-
`approve()` and `deny()` copy the request, message, generation, and approval
|
|
549
|
-
IDs from the pending approval, so a delayed click cannot authorize a later
|
|
550
|
-
model step. The decision updates the approval part in the projected
|
|
551
|
-
`UIMessage`. A built-in handler executes the approved tool or records a denied
|
|
552
|
-
result. It never bypasses the same sibling join used by automatic tools.
|
|
553
|
-
|
|
554
|
-
Tool activity follows the full AI SDK chunk lifecycle. Input validation errors,
|
|
555
|
-
execution errors, denials, and preliminary and final outputs become durable
|
|
556
|
-
`ai.tool.result` events. Preliminary outputs keep the tool `running`; a final
|
|
557
|
-
output completes it. Each result has its own event id, so a preliminary result
|
|
558
|
-
cannot deduplicate the final one. Dynamic-tool, provider-executed, provider
|
|
559
|
-
metadata, and tool metadata fields are preserved in the projection.
|
|
560
|
-
|
|
561
|
-
Independent tool handlers run concurrently as soon as their durable inputs and
|
|
562
|
-
authorization are ready. There is no fixed tool concurrency limit. Set
|
|
563
|
-
`toolLane: 'tools'` on the agent when local tools must run one at a time, for
|
|
564
|
-
example because they share a sandbox; every local tool execution then queues on
|
|
565
|
-
that lane. A private
|
|
566
|
-
coordinator reducer tracks generation closure, cancellation, calls, approvals,
|
|
567
|
-
and terminal results for the active response. Its retained state is bounded;
|
|
568
|
-
completed responses do not accumulate in the coordinator. Each join reads a
|
|
569
|
-
durable reducer snapshot plus the log tail through
|
|
570
|
-
`ctx.session.state(coordinator, { through: 'latest' })`. The snapshot
|
|
571
|
-
is only a cache. Recovery can rebuild the same coordinator state from the event
|
|
572
|
-
log after process death.
|
|
573
|
-
|
|
574
|
-
A continuation needs the generation to close and every sibling tool to have a
|
|
575
|
-
terminal success, error, or denied result. Racing join handlers call
|
|
576
|
-
`ctx.session.append()` with the same deterministic continuation ID, so append
|
|
577
|
-
idempotency resolves the race. A handler's single-owner terminal result is
|
|
578
|
-
returned and committed atomically with that handler's completion. Preliminary
|
|
579
|
-
streaming tool outputs use immediate appends. An approved provider-executed
|
|
580
|
-
call can satisfy the join through its response. A provider tool that declares
|
|
581
|
-
deferred-result support can wait for its provider result instead of a local
|
|
582
|
-
executor. The authenticated provider callback appends that `ai.tool.result`
|
|
583
|
-
through the trusted server session API. Browser pushes reject lifecycle
|
|
584
|
-
events, including provider results.
|
|
585
|
-
|
|
586
|
-
`state.pendingInputs` provides the same durable request/response shape for
|
|
587
|
-
application-defined input. Answer one with `respond(input, value)` from the
|
|
588
|
-
hook. Its value stays in raw history and `state.pendingInputs`; resolve
|
|
589
|
-
instructions dynamically or replace `generate` when the value should become
|
|
590
|
-
model context.
|
|
591
|
-
|
|
592
|
-
## Interrupt and retry
|
|
593
|
-
|
|
594
|
-
Stop the active turn, or retry a failed response, from the hook:
|
|
595
|
-
|
|
596
|
-
```tsx app/agent/[sessionId]/turn-controls.tsx
|
|
597
|
-
'use client'
|
|
598
|
-
import { useAgent } from 'experimental-a2/ai/react'
|
|
599
|
-
import type { assistant } from '@/server/assistant'
|
|
600
|
-
|
|
601
|
-
export function TurnControls({ sessionId }: { sessionId: string }) {
|
|
602
|
-
const { state, status, stop, retry } = useAgent<typeof assistant>({
|
|
603
|
-
api: '/api/agent',
|
|
604
|
-
id: sessionId,
|
|
605
|
-
})
|
|
606
|
-
|
|
607
|
-
if (status === 'failed')
|
|
608
|
-
return <button onClick={() => void retry()}>Retry</button>
|
|
609
|
-
return state.active ? (
|
|
610
|
-
<button onClick={() => void stop()}>Stop</button>
|
|
611
|
-
) : null
|
|
612
|
-
}
|
|
613
|
-
```
|
|
614
|
-
|
|
615
|
-
`stop()` addresses the observed turn and sends the exact confirmed log frontier
|
|
616
|
-
the rendered state showed, not whatever arrived before the click was handled.
|
|
617
|
-
The client immediately previews Stop for that turn. While that generation's
|
|
618
|
-
indexed projection is retained, the client freezes the response at that
|
|
619
|
-
frontier through the request echo and controller decision. A rejected push or
|
|
620
|
-
decision removes the preview.
|
|
621
|
-
The reducer rewinds the indexed generation projection to that
|
|
622
|
-
frontier and keeps the partial text the user actually saw. Tool parts whose
|
|
623
|
-
arguments are still streaming are drafts: interruption removes them from the
|
|
624
|
-
conversation and subsequent model prompts, even if their current JSON parses.
|
|
625
|
-
Their progress remains in the raw log. Accepted tools still awaiting completion
|
|
626
|
-
become `output-error`. An accepted interruption
|
|
627
|
-
terminally fences that request or generation. Later generation, tool, approval,
|
|
628
|
-
input, and compaction events remain in raw history but cannot reactivate it or
|
|
629
|
-
alter the projection.
|
|
630
|
-
Completed tool results at or before the visible frontier remain completed.
|
|
631
|
-
|
|
632
|
-
Once Stop is persisted, A2's existing cancellation stream aborts running model
|
|
633
|
-
and tool work for that turn without waiting for the control handler. Startup
|
|
634
|
-
checks also skip matching jobs created while Stop is pending. Cancellation is
|
|
635
|
-
cooperative: tools must honor their abort signal. The control handler remains
|
|
636
|
-
responsible for recording the interruption and advancing the inbox; a Stop for
|
|
637
|
-
an older turn cannot cancel its successor.
|
|
638
|
-
|
|
639
|
-
`retry()` records `inputs.retry()` for the failed response, with a fresh
|
|
640
|
-
`retryId` for each click. The server reruns that failed model call.
|
|
641
|
-
Earlier completed model steps and their tool results remain, including steps
|
|
642
|
-
completed after the latest compaction. The committed summary also remains. For
|
|
643
|
-
example, if a tool succeeds after compaction and the next model call fails, retry
|
|
644
|
-
uses the summary and that tool's result.
|
|
645
|
-
Resending one retry is safe while a later retry remains distinct. The failed
|
|
646
|
-
model call's uncommitted step remains in
|
|
647
|
-
raw history but is excluded from the new prompt and response. Later user messages remain queued while
|
|
648
|
-
the failed response awaits an explicit retry or interruption.
|
|
649
|
-
|
|
650
|
-
The lower-level `inputs.interrupt()` builder addresses a specific owner. Between
|
|
651
|
-
`ai.generation.requested` and `ai.generation.started`, copy
|
|
652
|
-
`activeRequestId` as `requestId` and `activeResponseMessageId` as `messageId`.
|
|
653
|
-
Once `activeGeneration` exists, send its `generationId` and
|
|
654
|
-
`responseMessageId` instead. A request-owned interruption remains valid if
|
|
655
|
-
that same request starts before the append commits. Every interruption has
|
|
656
|
-
exactly one of `requestId` or `generationId`, so a delayed action cannot stop
|
|
657
|
-
later work that reuses the response message. After a generation completes,
|
|
658
|
-
copy the owner from a pending approval, pending input, or running tool while
|
|
659
|
-
the response is still waiting.
|
|
660
|
-
|
|
661
|
-
Applications own conversation forks and edit-and-resend flows for previously
|
|
662
|
-
admitted messages. A2's retry operation continues the current response.
|
|
663
|
-
|
|
664
|
-
## Append from the server
|
|
665
|
-
|
|
666
|
-
Browser interactions go through the hook for instant state. Server-originated
|
|
667
|
-
turns use the same inputs with `append()`:
|
|
668
|
-
|
|
669
|
-
```ts server/append-message.ts
|
|
670
|
-
import { inputs } from 'experimental-a2/ai'
|
|
671
|
-
import { assistant } from './assistant'
|
|
672
|
-
|
|
673
|
-
export async function appendServerMessage(sessionId: string, text: string) {
|
|
674
|
-
await assistant.session(sessionId).append(
|
|
675
|
-
...inputs.message({
|
|
676
|
-
id: crypto.randomUUID(),
|
|
677
|
-
role: 'user',
|
|
678
|
-
parts: [{ type: 'text', text }],
|
|
679
|
-
}),
|
|
680
|
-
)
|
|
681
|
-
}
|
|
682
|
-
```
|
|
683
|
-
|
|
684
|
-
This path fits webhooks, jobs, and other backend sources. A connected browser
|
|
685
|
-
still receives the events over SSE, but it has no optimistic overlay for an
|
|
686
|
-
interaction that began elsewhere.
|
|
687
|
-
|
|
688
|
-
## The built-in inputs
|
|
689
|
-
|
|
690
|
-
The hook's actions build these commands. `inputs` exposes the same pure,
|
|
691
|
-
isomorphic builders for raw `push()` and server `append()`:
|
|
692
|
-
|
|
693
|
-
| Input | Events |
|
|
694
|
-
| --- | --- |
|
|
695
|
-
| `inputs.message(message, { generate? })` | queues input; `generate: false` queues passive context |
|
|
696
|
-
| `inputs.seed(message)` | records `generate: false`; non-user roles require a trusted append |
|
|
697
|
-
| `inputs.approval(response)` | requests a decision on a pending approval |
|
|
698
|
-
| `inputs.input(response)` | records an application input response fact |
|
|
699
|
-
| `inputs.requestInput(request)` | records a trusted server request for application input |
|
|
700
|
-
| `inputs.retry(options)` | requests a fresh attempt for a failed response |
|
|
701
|
-
| `inputs.stop({ turnId, lastSeenIndex? })` | ends the observed turn and advances |
|
|
702
|
-
| `inputs.steer({ turnId, message })` | prioritizes input after the observed turn's current step finishes |
|
|
703
|
-
| `inputs.queue.edit({ message })` | edits a pending input |
|
|
704
|
-
| `inputs.queue.move({ inputId, beforeId })` | reorders pending input; `null` means the end |
|
|
705
|
-
| `inputs.queue.remove({ inputId })` | removes pending input from the inbox |
|
|
706
|
-
| `inputs.queue.sendNow({ inputId, turnId })` | atomically prioritizes queued input and interrupts the observed turn |
|
|
707
|
-
| `inputs.pause({ when })` | pauses now or after the active turn |
|
|
708
|
-
| `inputs.resume()` | resumes retained work and inbox admission |
|
|
709
|
-
| `inputs.toolResult(result)` | records a trusted deferred provider-tool result |
|
|
710
|
-
| `inputs.interrupt(options)` | addresses a specific generation or request |
|
|
711
|
-
|
|
712
|
-
Builders return `ai.control.requested` commands. Message, approval, input, and
|
|
713
|
-
retry builders identify a stable interaction. Other controls use the event ID
|
|
714
|
-
assigned by `push()` or `append()`; transport retries preserve that ID.
|
|
715
|
-
Generation scheduling considers only user-role `ai.message.created` facts.
|
|
716
|
-
Assistant and system message facts remain context regardless of the `generate`
|
|
717
|
-
field, and model output is recorded as generation progress rather than a new
|
|
718
|
-
message fact, so an assistant reply cannot recursively schedule another turn.
|
|
719
|
-
Browser pushes accept user messages, approval and input responses,
|
|
720
|
-
interruptions, and explicit retries. `inputs.requestInput()` and non-user
|
|
721
|
-
messages built with `inputs.seed()` are for trusted server appends.
|
|
722
|
-
Browser inputs never append server scheduling, seeded non-user messages, or
|
|
723
|
-
lifecycle events.
|
|
724
|
-
Approval and input request/response payloads carry the active `generationId`;
|
|
725
|
-
clients copy it from the pending request so a stale interaction cannot satisfy
|
|
726
|
-
a later model step.
|
|
727
|
-
|
|
728
|
-
`inputs` deliberately has no session lifecycle methods. The A2 log begins with
|
|
729
|
-
the first append. Apps that use explicit `ai.session.created` or
|
|
730
|
-
`ai.session.closed` lifecycle events append those trusted server facts
|
|
731
|
-
directly.
|
|
732
|
-
|
|
733
|
-
## Compact long conversations
|
|
734
|
-
|
|
735
|
-
Compaction is automatic for supported Gateway models. A2 uses the active model
|
|
736
|
-
with the same instructions, tool definitions, and provider settings, and appends
|
|
737
|
-
an internal user message asking for a summary. This preserves the request prefix
|
|
738
|
-
for prompt caching when the provider has a matching cache entry.
|
|
739
|
-
|
|
740
|
-
```ts server/compacted.ts
|
|
741
|
-
import { agent } from 'experimental-a2/ai/server'
|
|
742
|
-
|
|
743
|
-
export const compactedAssistant = agent({
|
|
744
|
-
name: 'compacted-agent',
|
|
745
|
-
model: 'openai/gpt-5.6-terra',
|
|
746
|
-
})
|
|
747
|
-
```
|
|
748
|
-
|
|
749
|
-
On first use of each Gateway model in a session, A2 includes
|
|
750
|
-
`ai.model.metadata.requested` in the existing generation-start append. A separate
|
|
751
|
-
handler fetches the public Gateway model catalog and records the selected limits
|
|
752
|
-
in `ai.model.metadata.resolved`. It runs outside the AI turn lane. The first model
|
|
753
|
-
request proceeds immediately; metadata that arrives afterward is available to
|
|
754
|
-
later steps. Returning to a previously used model reuses its durable metadata.
|
|
755
|
-
The catalog is also cached in memory for one hour across agents, with one
|
|
756
|
-
shared in-flight request and a five-second timeout. A failed lookup follows A2's
|
|
757
|
-
handler retry policy without failing the model generation. A model absent from
|
|
758
|
-
the catalog is recorded as unavailable. A pending entry means no result has been
|
|
759
|
-
recorded, including when the metadata handler exhausts its retry budget. A2 does
|
|
760
|
-
not start a fresh lookup on every turn after exhaustion. Inspect the handler
|
|
761
|
-
failure or use an explicit threshold in that case.
|
|
762
|
-
|
|
763
|
-
The default threshold is 75% of the context window. A larger configured
|
|
764
|
-
`generation.maxOutputTokens` lowers the threshold further to reserve that output
|
|
765
|
-
allowance. This is an input-context policy, not a new output limit. Limits and
|
|
766
|
-
metadata status are available in `state.modelMetadata`.
|
|
767
|
-
|
|
768
|
-
`compaction: { thresholdTokens: 80_000 }` overrides discovery with an explicit
|
|
769
|
-
positive safe integer. `compaction: { instructions: 'Preserve exact IDs.' }`
|
|
770
|
-
adds guidance to the built-in summary prompt while keeping the derived threshold.
|
|
771
|
-
`compaction: false` disables both compaction and metadata lookup. The `compaction`
|
|
772
|
-
option can also be a resolver receiving the same
|
|
773
|
-
`{ event, messages, session, signal }` context as `model` and
|
|
774
|
-
`instructions`. It returns `false`, automatic options, or a custom policy once
|
|
775
|
-
per generation and can await `session.state(settingsReducer)` to read
|
|
776
|
-
application settings. Use this to select a threshold from application settings
|
|
777
|
-
at the prompt boundary.
|
|
778
|
-
Changes after resolution apply to a later model step. Disabling compaction
|
|
779
|
-
preserves summaries that were already recorded. Custom provider
|
|
780
|
-
models and custom global SDK providers need an explicit threshold. A custom
|
|
781
|
-
`generate` callback keeps compaction disabled unless explicitly configured. Gateway
|
|
782
|
-
fallback model lists also need an explicit threshold appropriate for every model
|
|
783
|
-
they may use.
|
|
784
|
-
|
|
785
|
-
The input estimate starts from serialized UTF-8 bytes divided by four, including
|
|
786
|
-
model messages, instructions, and tool schemas. When a preceding model call has
|
|
787
|
-
reported token usage for the same model and context, A2 uses that measurement as
|
|
788
|
-
an anchor and estimates subsequent growth. It is not a provider tokenizer and
|
|
789
|
-
does not precisely measure new image or audio tokens. A2 performs no remote token
|
|
790
|
-
counting.
|
|
791
|
-
|
|
792
|
-
On a cold session without recorded metadata or an explicit threshold, automatic
|
|
793
|
-
compaction waits for discovery. An oversized first prompt is sent normally and
|
|
794
|
-
its provider error becomes a generation failure. A2 does not silently truncate
|
|
795
|
-
it or attempt automatic recovery from a context-limit error. Use an explicit threshold for
|
|
796
|
-
applications that import a large initial conversation. The estimate is a
|
|
797
|
-
compaction trigger, not a definitive overflow check. Provider context-limit
|
|
798
|
-
errors during generation or summarization remain generation failures.
|
|
799
|
-
|
|
800
|
-
Crossing the threshold adds one summary model request. Application generation
|
|
801
|
-
callbacks, stream transforms, and tool hooks do not run for the summary, and it
|
|
802
|
-
never executes local tools. Provider-executed tools, structured output, and forced
|
|
803
|
-
tool choices disable implicit automatic compaction; explicitly configuring
|
|
804
|
-
automatic compaction for those requests throws. Use a custom policy for them.
|
|
805
|
-
An empty summary or a summary tool call fails the generation instead of replacing
|
|
806
|
-
its context.
|
|
807
|
-
|
|
808
|
-
A2 records `ai.compaction.requested` before the call and
|
|
809
|
-
`ai.compaction.completed` after a successful summary. The completed event stores
|
|
810
|
-
the summary, its usage, and the exact event frontier it covers. Later model
|
|
811
|
-
requests receive the summary plus everything produced after that frontier,
|
|
812
|
-
including later tool steps in the same assistant message. Queued user messages
|
|
813
|
-
remain queued. Compaction waits until earlier tool calls have terminal results.
|
|
814
|
-
Server execution uses a separate model-context reducer and checkpoint.
|
|
815
|
-
Successful compaction permanently replaces covered context in model requests;
|
|
816
|
-
the raw log and the transcript in `AIState.messages` stay complete.
|
|
817
|
-
|
|
818
|
-
### Show compaction in the conversation
|
|
819
|
-
|
|
820
|
-
The transcript marks where each compaction happened. When compaction is
|
|
821
|
-
requested, the reducer adds a `data-a2-compaction` part to the assistant
|
|
822
|
-
response, between the steps before it and the model step it compacted for. A
|
|
823
|
-
compaction before the first step opens the response with that part. Render it
|
|
824
|
-
in the same loop as the other parts:
|
|
825
|
-
|
|
826
|
-
```tsx app/agent/message-view.tsx
|
|
827
|
-
'use client'
|
|
828
|
-
import type { UIMessage } from 'ai'
|
|
829
|
-
import { isCompactionPart, type TranscriptMessage } from 'experimental-a2/ai'
|
|
830
|
-
|
|
831
|
-
const compactionLabel = {
|
|
832
|
-
running: 'Compacting…',
|
|
833
|
-
completed: 'Context compacted',
|
|
834
|
-
failed: 'Compaction did not complete',
|
|
835
|
-
interrupted: 'Compaction stopped',
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
export function MessageView({
|
|
839
|
-
message,
|
|
840
|
-
}: {
|
|
841
|
-
message: TranscriptMessage<UIMessage>
|
|
842
|
-
}) {
|
|
843
|
-
return (
|
|
844
|
-
<article>
|
|
845
|
-
{message.parts.map((part, index) => {
|
|
846
|
-
if (isCompactionPart(part))
|
|
847
|
-
return (
|
|
848
|
-
<details key={index}>
|
|
849
|
-
<summary>{compactionLabel[part.data.status]}</summary>
|
|
850
|
-
{part.data.summary ? <p>{part.data.summary}</p> : null}
|
|
851
|
-
</details>
|
|
852
|
-
)
|
|
853
|
-
return part.type === 'text' ? <p key={index}>{part.text}</p> : null
|
|
854
|
-
})}
|
|
855
|
-
</article>
|
|
856
|
-
)
|
|
857
|
-
}
|
|
858
|
-
```
|
|
859
|
-
|
|
860
|
-
The part's `data` holds `status` plus, when known, `inputTokens`,
|
|
861
|
-
`thresholdTokens`, the `summary`, and its `usage`. It starts `running` and
|
|
862
|
-
settles in place: `completed` when the summary commits, `failed` when its
|
|
863
|
-
generation fails first, and `interrupted` when Stop, Pause now, or session
|
|
864
|
-
close ends it first. A pending Stop previews the part as `interrupted`
|
|
865
|
-
immediately. A worker attempt that recovery supersedes removes its part; the
|
|
866
|
-
replacement attempt compacts again if it still needs to. A completed part stays
|
|
867
|
-
through Stop cutoffs, later failures, and retries of the step that compacted,
|
|
868
|
-
because successful compaction is permanent. The part lives in the state, not the event
|
|
869
|
-
feed, so it survives server rendering, reloads, and other tabs. AI SDK
|
|
870
|
-
`convertToModelMessages()` skips data parts by default, and A2's model context
|
|
871
|
-
never includes it. `state.messages` is typed as `TranscriptMessage<M>[]`: your
|
|
872
|
-
message type with `CompactionPart` added to its parts, so `isCompactionPart()`
|
|
873
|
-
narrows even when your message type declares its own data parts.
|
|
874
|
-
|
|
875
|
-
`state.compaction` holds the current or latest compaction, and the hook's
|
|
876
|
-
`indicator` reports `compacting` while one runs.
|
|
877
|
-
|
|
878
|
-
Compaction runs only when retained tool exchanges have final results and all
|
|
879
|
-
A2-owned tool work has settled, including work from stopped or superseded turns.
|
|
880
|
-
A2 checks a fresh coordinator checkpoint before starting a candidate compaction.
|
|
881
|
-
This adds one state read on that path. Unresolved dead-lettered tool work keeps
|
|
882
|
-
compaction deferred until repair and settlement or session closure.
|
|
883
|
-
|
|
884
|
-
Custom `shouldCompact(context)` and `compact(context)` callbacks remain
|
|
885
|
-
available for deterministic replacement messages or application-specific
|
|
886
|
-
retention. Both receive the typed UI messages and `modelMessages`, the model-ready
|
|
887
|
-
context including any existing summary. Custom generation callbacks also receive
|
|
888
|
-
`modelMessages`; use it when forwarding context to a provider. A2 stores built-in
|
|
889
|
-
summaries separately from application-typed messages, so a summary does not
|
|
890
|
-
need your message metadata or schema.
|
|
891
|
-
|
|
892
|
-
Successful compaction is a permanent boundary. Failure, interruption, retry, and
|
|
893
|
-
worker recovery never restore the messages it replaced. Retry reruns the failed
|
|
894
|
-
model step from the latest committed context and preserves earlier completed
|
|
895
|
-
steps. A failed summary attempt leaves the previous committed context intact.
|
|
896
|
-
The synthetic summary request is not a user turn or an assistant reply in
|
|
897
|
-
`state.messages`.
|
|
898
|
-
|
|
899
|
-
## Store assistant metadata
|
|
900
|
-
|
|
901
|
-
Give the agent a narrower `UIMessage` type to keep generation status and usage
|
|
902
|
-
on the assistant message. `agentContract()` carries the type:
|
|
903
|
-
|
|
904
|
-
```ts server/metadata-assistant.ts
|
|
905
|
-
import type { LanguageModelUsage, UIMessage } from 'ai'
|
|
906
|
-
import { agentContract } from 'experimental-a2/ai'
|
|
907
|
-
import { agent } from 'experimental-a2/ai/server'
|
|
908
|
-
|
|
909
|
-
export type MetadataAssistantMessage = UIMessage<{
|
|
910
|
-
status: 'writing' | 'complete'
|
|
911
|
-
usage?: LanguageModelUsage
|
|
912
|
-
}>
|
|
913
|
-
|
|
914
|
-
export const metadataAssistant = agent({
|
|
915
|
-
contract: agentContract<MetadataAssistantMessage>({
|
|
916
|
-
name: 'metadata-assistant',
|
|
917
|
-
}),
|
|
918
|
-
model: 'openai/gpt-5.6-terra',
|
|
919
|
-
messageMetadata: ({ part }) => {
|
|
920
|
-
if (part.type === 'start') return { status: 'writing' as const }
|
|
921
|
-
if (part.type === 'finish') {
|
|
922
|
-
return { status: 'complete' as const, usage: part.totalUsage }
|
|
923
|
-
}
|
|
924
|
-
return undefined
|
|
925
|
-
},
|
|
926
|
-
})
|
|
927
|
-
```
|
|
928
|
-
|
|
929
|
-
The default generator passes `messageMetadata` to the AI SDK's
|
|
930
|
-
`toUIMessageStream()`. The callback receives the same context as `generate`,
|
|
931
|
-
plus its current `TextStreamPart` as `part`. Its return type follows the metadata type in the
|
|
932
|
-
agent's `UIMessage`. Metadata on standard `start` and `finish` chunks enters
|
|
933
|
-
the event log with the rest of the stream. A2 shallow-merges those values onto
|
|
934
|
-
the projected assistant message, so the example exposes `writing` immediately
|
|
935
|
-
and keeps final token usage after completion. The callback is synchronous.
|
|
936
|
-
`useAgent<typeof metadataAssistant>()` types `messages` with the same metadata.
|
|
937
|
-
|
|
938
|
-
## Add your own events and state
|
|
939
|
-
|
|
940
|
-
Apps always carry more than the conversation: a plan, a selected ticket, a
|
|
941
|
-
chosen model. Put those events and the state they fold into in a shared
|
|
942
|
-
`agentContract()`. It takes a core `contract()`'s `events`, and its `reducer`
|
|
943
|
-
takes the same `name`, `initialState`, and `fold`:
|
|
944
|
-
|
|
945
|
-
```ts support-desk.ts
|
|
946
|
-
import { z } from 'zod'
|
|
947
|
-
import { agentContract } from 'experimental-a2/ai'
|
|
948
|
-
|
|
949
|
-
const noFlags: string[] = []
|
|
950
|
-
|
|
951
|
-
export const supportDesk = agentContract({
|
|
952
|
-
name: 'support-desk',
|
|
953
|
-
events: {
|
|
954
|
-
'plan.changed': z.object({ plan: z.enum(['free', 'pro']) }),
|
|
955
|
-
'ticket.flagged': z.object({ ticketId: z.string() }),
|
|
956
|
-
},
|
|
957
|
-
reducer: {
|
|
958
|
-
name: 'support-desk.v1',
|
|
959
|
-
initialState: { plan: 'free' as 'free' | 'pro', flagged: noFlags },
|
|
960
|
-
fold: (state, event) =>
|
|
961
|
-
event.type === 'plan.changed'
|
|
962
|
-
? { ...state, plan: event.payload.plan }
|
|
963
|
-
: event.type === 'ticket.flagged'
|
|
964
|
-
? { ...state, flagged: [...state.flagged, event.payload.ticketId] }
|
|
965
|
-
: state,
|
|
966
|
-
},
|
|
967
|
-
})
|
|
968
|
-
```
|
|
969
|
-
|
|
970
|
-
Keep this module isomorphic. `fold` sees your state and every event, but not
|
|
971
|
-
the transcript. The session's state is `AIState` merged with yours, so a key
|
|
972
|
-
that shadows one of `AIState`'s is a type error. The reducer's `name` is its
|
|
973
|
-
snapshot identity: rename it when `fold` changes.
|
|
974
|
-
|
|
975
|
-
Serve it with `agent({ contract })`. Every resolver receives your state as
|
|
976
|
-
`state`, so model, reasoning level, and speed follow the app:
|
|
977
|
-
|
|
978
|
-
```ts server/support-desk.ts
|
|
979
|
-
import { agent } from 'experimental-a2/ai/server'
|
|
980
|
-
import { supportDesk } from '../support-desk'
|
|
981
|
-
|
|
982
|
-
export const supportDeskAgent = agent({
|
|
983
|
-
contract: supportDesk,
|
|
984
|
-
model: ({ state }) =>
|
|
985
|
-
state.plan === 'pro'
|
|
986
|
-
? { model: 'openai/gpt-5.6-terra', reasoning: 'high' }
|
|
987
|
-
: { model: 'openai/gpt-5.6-luna', speed: 'fast' },
|
|
988
|
-
handlers: {
|
|
989
|
-
'ticket.flagged': async ({ event }) => {
|
|
990
|
-
// your side effect; event.id makes a stable idempotency key:
|
|
991
|
-
// await notifyOnCall(event.payload.ticketId, { idempotencyKey: event.id })
|
|
992
|
-
},
|
|
993
|
-
},
|
|
994
|
-
})
|
|
995
|
-
```
|
|
996
|
-
|
|
997
|
-
`state` is your state as of the snapshot the generation already captured. It
|
|
998
|
-
rides in the checkpoint every model step reads, so it costs no extra read. Each
|
|
999
|
-
model step resolves again, so a change during a turn applies from the next
|
|
1000
|
-
step. A resolver that throws fails the turn with its message, like a provider
|
|
1001
|
-
error; `retry()` runs it again.
|
|
1002
|
-
|
|
1003
|
-
In the browser, pass the contract to `useAgent()`. This import is real, not
|
|
1004
|
-
type-only, because the browser folds your state too:
|
|
1005
|
-
|
|
1006
|
-
```tsx app/support/desk.tsx
|
|
1007
|
-
'use client'
|
|
1008
|
-
import { useAgent } from 'experimental-a2/ai/react'
|
|
1009
|
-
import { supportDesk } from '@/support-desk'
|
|
1010
|
-
|
|
1011
|
-
export function Desk({ id }: { id: string }) {
|
|
1012
|
-
const { state, push, sendMessage } = useAgent({
|
|
1013
|
-
api: '/api/support',
|
|
1014
|
-
id,
|
|
1015
|
-
contract: supportDesk,
|
|
1016
|
-
})
|
|
1017
|
-
|
|
1018
|
-
return (
|
|
1019
|
-
<section>
|
|
1020
|
-
<p>
|
|
1021
|
-
{state.plan} plan, {state.flagged.length} flagged tickets
|
|
1022
|
-
</p>
|
|
1023
|
-
<button
|
|
1024
|
-
onClick={() =>
|
|
1025
|
-
void push({ type: 'ticket.flagged', payload: { ticketId: 'T-1' } })
|
|
1026
|
-
}
|
|
1027
|
-
>
|
|
1028
|
-
Flag T-1
|
|
1029
|
-
</button>
|
|
1030
|
-
<button onClick={() => void sendMessage('Summarize the flagged tickets.')}>
|
|
1031
|
-
Ask
|
|
1032
|
-
</button>
|
|
1033
|
-
</section>
|
|
1034
|
-
)
|
|
1035
|
-
}
|
|
1036
|
-
```
|
|
1037
|
-
|
|
1038
|
-
Browsers push a shared contract's application events like any A2 client, and
|
|
1039
|
-
the route's `authorize` decides who may push what. The agent refuses only its
|
|
1040
|
-
own server-authored `ai.*` events. Here the plan comes from your billing code,
|
|
1041
|
-
not the browser:
|
|
1042
|
-
|
|
1043
|
-
```ts app/api/support/route.ts
|
|
1044
|
-
import { supportDeskAgent } from '@/server/support-desk'
|
|
1045
|
-
|
|
1046
|
-
function handle(request: Request): Promise<Response> {
|
|
1047
|
-
// here's where you'd do auth, or any other checks:
|
|
1048
|
-
return supportDeskAgent.fetch(request, {
|
|
1049
|
-
authorize: (operation) =>
|
|
1050
|
-
operation.type !== 'push' ||
|
|
1051
|
-
operation.events.every((event) => event.type !== 'plan.changed'),
|
|
1052
|
-
})
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
export const GET = handle
|
|
1056
|
-
export const POST = handle
|
|
1057
|
-
```
|
|
1058
|
-
|
|
1059
|
-
Server appends, such as
|
|
1060
|
-
`supportDeskAgent.session(id).append({ type: 'plan.changed', payload })`, are
|
|
1061
|
-
trusted and skip `authorize`. `push()` also accepts any `inputs` builder, and
|
|
1062
|
-
`handlerContext(supportDesk)` types application events inside tools.
|
|
1063
|
-
|
|
1064
|
-
`agent({ contract })` takes only an `agentContract()`, so every resolver gets
|
|
1065
|
-
your `state`. When the agent belongs inside something larger, such as a chat
|
|
1066
|
-
room several people share, keep them as two sessions. The room is an ordinary
|
|
1067
|
-
core A2 session. Its handler appends each message the agent should see to the
|
|
1068
|
-
agent's session with `inputs.message()`. Event ids are unique across a store's
|
|
1069
|
-
sessions, so derive the message id from the room event's id with
|
|
1070
|
-
`idempotentId()`; a retried handler then converges on the same message. A tool
|
|
1071
|
-
the agent calls to speak appends to the room's session with an `idempotentId()`
|
|
1072
|
-
of its `toolCallId`, so the room's log orders the agent's messages among
|
|
1073
|
-
everyone else's. Neither fold ever reads the other's state.
|
|
1074
|
-
|
|
1075
|
-
The contract-free client, `createAgentClient()` and `useAgent({ api | client })`
|
|
1076
|
-
with a type-only import, folds the built-in reducer, so it accepts only agents
|
|
1077
|
-
whose reducer carries the built-in name: `agent({ name })`, or
|
|
1078
|
-
`agent({ contract })` with an `agentContract()` that has no `reducer`. The types
|
|
1079
|
-
enforce it through `AI_STATE_REDUCER`.
|
|
1080
|
-
|
|
1081
|
-
`reduceAIState()` remains a pure transition over confirmed `AIState` for
|
|
1082
|
-
applications that project raw event histories.
|
|
1083
|
-
|
|
1084
|
-
### Replace generation, not durability
|
|
1085
|
-
|
|
1086
|
-
The default generator uses AI SDK `streamText()` for one model step. Replace
|
|
1087
|
-
only that model-facing step for fixtures, provider routing, or a different AI
|
|
1088
|
-
SDK assembly:
|
|
1089
|
-
|
|
1090
|
-
```ts server/custom-generation.ts
|
|
1091
|
-
import { convertToModelMessages, streamText } from 'ai'
|
|
1092
|
-
import { agent } from 'experimental-a2/ai/server'
|
|
1093
|
-
|
|
1094
|
-
export const customGeneration = agent({
|
|
1095
|
-
name: 'custom-generation',
|
|
1096
|
-
model: 'openai/gpt-5.6-terra',
|
|
1097
|
-
generate: async ({ event, messages, model, instructions, signal }) => {
|
|
1098
|
-
const result = streamText({
|
|
1099
|
-
model,
|
|
1100
|
-
messages: await convertToModelMessages(messages),
|
|
1101
|
-
abortSignal: signal,
|
|
1102
|
-
...(instructions === undefined ? {} : { instructions }),
|
|
1103
|
-
})
|
|
1104
|
-
|
|
1105
|
-
return result.toUIMessageStream({
|
|
1106
|
-
originalMessages: messages,
|
|
1107
|
-
generateMessageId: () => event.payload.responseMessageId,
|
|
1108
|
-
})
|
|
1109
|
-
},
|
|
1110
|
-
})
|
|
1111
|
-
```
|
|
1112
|
-
|
|
1113
|
-
`generate` receives what every resolver does, `{ event, messages, state,
|
|
1114
|
-
session, signal }`, with `messages` already compacted. It also gets the
|
|
1115
|
-
resolved `model`, `instructions`, `tools`, and `generation` settings, the
|
|
1116
|
-
model-ready `modelMessages` with any compaction summary, and the step's
|
|
1117
|
-
`generationId`. Its stream writes the assistant message
|
|
1118
|
-
`event.payload.responseMessageId`. It returns exactly one model step as a
|
|
1119
|
-
`ReadableStream<UIMessageChunk>`. A tool-aware replacement sends definitions to
|
|
1120
|
-
the model without running local `execute` functions. A2 still owns durable
|
|
1121
|
-
progress, tool execution, approval, continuation, interruption, and failure.
|
|
1122
|
-
`messageMetadata` applies only to the default generator. A custom `generate`
|
|
1123
|
-
function owns its metadata and can pass a callback to `toUIMessageStream()` or
|
|
1124
|
-
emit typed metadata chunks itself.
|
|
1125
|
-
|
|
1126
|
-
## Checkpoints and recovery reads
|
|
1127
|
-
|
|
1128
|
-
`assistant.session(id).state()` returns `AIState` and its log index, ready for
|
|
1129
|
-
server code or client hydration. No conversion step is needed. The reducer's
|
|
1130
|
-
initial state and fold use that same type.
|
|
1131
|
-
|
|
1132
|
-
```ts server/read-agent.ts
|
|
1133
|
-
import type { UIMessage } from 'ai'
|
|
1134
|
-
import type { TranscriptMessage } from 'experimental-a2/ai'
|
|
1135
|
-
import { assistant } from './assistant'
|
|
1136
|
-
|
|
1137
|
-
export async function readMessages(
|
|
1138
|
-
sessionId: string,
|
|
1139
|
-
): Promise<TranscriptMessage<UIMessage>[]> {
|
|
1140
|
-
const snapshot = await assistant.session(sessionId).state()
|
|
1141
|
-
return snapshot.state.messages
|
|
1142
|
-
}
|
|
1143
|
-
```
|
|
1144
|
-
|
|
1145
|
-
Tool payloads live in message parts. `state.tools` keeps only identities and
|
|
1146
|
-
execution status; it does not repeat inputs, outputs, or provider metadata.
|
|
1147
|
-
Checkpoint and subscription encoding remain internal. The active step retains
|
|
1148
|
-
its events and the information needed to restore overwritten message fields
|
|
1149
|
-
for interruption cutoffs and retry. It does not copy the whole prior message.
|
|
1150
|
-
Recovery is released when the response completes; completed model
|
|
1151
|
-
context is kept by the separate context reducer.
|
|
1152
|
-
|
|
1153
|
-
Model steps read the model-context reducer and coordinator at a fixed log
|
|
1154
|
-
boundary. Model, instruction, compaction, and custom-generation callbacks
|
|
1155
|
-
receive prompt messages, request information, and the contract's application
|
|
1156
|
-
`state`, which rides in the model-context checkpoint. Read any other reducer
|
|
1157
|
-
with `session.state(yourReducer)`. They do not receive a raw history array. Their `session.state(reducer)` reads
|
|
1158
|
-
application reducers at the captured prompt boundary by default; explicit
|
|
1159
|
-
`through` options follow the ordinary state-read contract. Compaction selection
|
|
1160
|
-
can be asynchronous when it needs a settings-state read.
|
|
1161
|
-
|
|
1162
|
-
Ordinary generation uses the model-context checkpoint without reading the
|
|
1163
|
-
transcript or replaying history to apply an existing compaction. The context
|
|
1164
|
-
checkpoint has its own identity and fold. Recovery and explicit retry discard
|
|
1165
|
-
only the unfinished model step. Completed compactions remain committed, so these
|
|
1166
|
-
paths never reconstruct pre-compaction prompts or retain rollback copies.
|
|
1167
|
-
|
|
1168
|
-
Tools recovering in a fresh process read context at their tool-admission index.
|
|
1169
|
-
The saved generation projection restores their original prompt, preserving the
|
|
1170
|
-
committed summary and excluding later arrivals. No separate history read is
|
|
1171
|
-
needed. A2 core can rebuild a missing or invalid reducer snapshot from the log.
|
|
1172
|
-
|
|
1173
|
-
Compaction immediately releases covered message and tool content from model
|
|
1174
|
-
context. Execution retains the active turn, queued inputs, unsettled tool work
|
|
1175
|
-
IDs, and token calibration. Model metadata is cached per distinct model.
|
|
1176
|
-
Historical input uniqueness comes from core event deduplication. The full
|
|
1177
|
-
transcript remains available independently.
|
|
1178
|
-
|
|
1179
|
-
Streaming progress updates the current message incrementally. Snapshots preserve
|
|
1180
|
-
open text and reasoning parts and partial tool inputs. Indexed progress remains
|
|
1181
|
-
available for exact interruption cutoffs; reordered events rebuild the projection.
|
|
1182
|
-
|
|
1183
|
-
While tool arguments arrive, `input-streaming` parts expose a best-effort partial
|
|
1184
|
-
JSON value in `input`. A command string can appear and grow before its closing
|
|
1185
|
-
quote arrives. This is a display draft, not validated execution input.
|
|
1186
|
-
`tool-input-available` supplies the completed input. Interruption still discards
|
|
1187
|
-
streaming drafts even when their partial JSON is readable.
|
|
1188
|
-
|
|
1189
|
-
## Delivery semantics
|
|
1190
|
-
|
|
1191
|
-
Model steps and tools run inside at-least-once A2 handlers. The returned-event
|
|
1192
|
-
transaction makes each successful handler completion and its terminal events
|
|
1193
|
-
one atomic durable operation. Deterministic ids make scheduling and lifecycle
|
|
1194
|
-
appends effectively once, and A2 marks an incomplete model attempt
|
|
1195
|
-
`superseded` before starting its replacement.
|
|
1196
|
-
|
|
1197
|
-
An expired claim or superseded attempt preserves its abort reason through model,
|
|
1198
|
-
resolver, compaction-policy, and tool calls. A2 retries that attempt without
|
|
1199
|
-
spending its failure budget. Explicit user interruption remains terminal.
|
|
1200
|
-
Streaming tool progress belongs to an execution attempt, so a retry can produce
|
|
1201
|
-
different preliminary output while preserving the tool-call id for idempotency.
|
|
1202
|
-
|
|
1203
|
-
Provider calls and external tool side effects remain outside that transaction.
|
|
1204
|
-
A process can die after an external effect succeeds and before its handler
|
|
1205
|
-
completion commits, so recovery may run the call again. Give side-effecting
|
|
1206
|
-
tools their own idempotency strategy, normally keyed by the AI SDK tool call
|
|
1207
|
-
id. This is the same at-least-once boundary as every other A2 handler. A fast
|
|
1208
|
-
tool may also start before its model step later fails. The failure prevents a
|
|
1209
|
-
continuation, but it cannot roll back that external effect.
|
|
1210
|
-
|
|
1211
|
-
Configure a [production scheduler](/guides/production) exactly as for any other A2
|
|
1212
|
-
server. It recovers an incomplete generation attempt after the original
|
|
1213
|
-
serverless invocation disappears.
|
|
1214
|
-
|
|
1215
|
-
## Inbox and turn controls
|
|
1216
|
-
|
|
1217
|
-
This input protocol requires matching client and server versions. Start a new
|
|
1218
|
-
agent session when adopting it. Existing event logs remain readable; finish
|
|
1219
|
-
pending work with the runtime that created it before upgrading.
|
|
1220
|
-
|
|
1221
|
-
`sendMessage()` places a message in the durable inbox. The controller
|
|
1222
|
-
admits one input at a time, freezes its content, and adds it to
|
|
1223
|
-
`state.messages`. Pending inputs appear in `state.inbox.items`; they do not enter
|
|
1224
|
-
the model prompt. Each item has an `id`, `message`, and `generate` flag.
|
|
1225
|
-
|
|
1226
|
-
New-input commands from `inputs.message()`, `inputs.seed()`, and `inputs.steer()`
|
|
1227
|
-
use the message ID as their event ID. Those IDs must match on hand-written
|
|
1228
|
-
commands too. Caller IDs cannot start with the reserved `a2.ai:` prefix. IDs must
|
|
1229
|
-
be unique within the store; include contract/session scope when deriving them
|
|
1230
|
-
with `idempotentId()`.
|
|
1231
|
-
|
|
1232
|
-
An identical submission replays its original event without creating more work,
|
|
1233
|
-
even after removal or compaction. Reusing the ID with different content, a
|
|
1234
|
-
different command action, or another session rejects at append with
|
|
1235
|
-
`PARTIAL_DUPLICATE_BATCH`. Use `queue.edit()` to change a pending input and a fresh
|
|
1236
|
-
message ID to create another input. Edit, move, remove, and other commands have
|
|
1237
|
-
their own event IDs. The controller stores no historical input-ID registry.
|
|
1238
|
-
|
|
1239
|
-
`state.active` identifies the logical turn, its frozen `input`, and its `phase`.
|
|
1240
|
-
The turn ID stays the same across model steps, tools, and a pause. Stop ends that
|
|
1241
|
-
turn and admits the next queued input when admission is running.
|
|
1242
|
-
|
|
1243
|
-
```tsx app/agent/[sessionId]/queue.tsx
|
|
1244
|
-
'use client'
|
|
1245
|
-
import { useAgent } from 'experimental-a2/ai/react'
|
|
1246
|
-
import type { assistant } from '@/server/assistant'
|
|
1247
|
-
|
|
1248
|
-
export function Queue({ sessionId }: { sessionId: string }) {
|
|
1249
|
-
const { state, queue, steer, pause, resume } = useAgent<typeof assistant>({
|
|
1250
|
-
api: '/api/agent',
|
|
1251
|
-
id: sessionId,
|
|
1252
|
-
})
|
|
1253
|
-
|
|
1254
|
-
return (
|
|
1255
|
-
<section>
|
|
1256
|
-
<ol aria-label="Queued messages">
|
|
1257
|
-
{state.inbox.items.map((item) => (
|
|
1258
|
-
<li key={item.id}>
|
|
1259
|
-
{item.message.parts.map((part, index) =>
|
|
1260
|
-
part.type === 'text' ? <span key={index}>{part.text}</span> : null,
|
|
1261
|
-
)}
|
|
1262
|
-
<button onClick={() => void queue.sendNow(item.id)}>Send now</button>
|
|
1263
|
-
<button
|
|
1264
|
-
onClick={() =>
|
|
1265
|
-
void queue.move({ inputId: item.id, beforeId: null })
|
|
1266
|
-
}
|
|
1267
|
-
>
|
|
1268
|
-
Move to end
|
|
1269
|
-
</button>
|
|
1270
|
-
<button onClick={() => void queue.remove(item.id)}>Remove</button>
|
|
1271
|
-
</li>
|
|
1272
|
-
))}
|
|
1273
|
-
</ol>
|
|
1274
|
-
<button onClick={() => void steer('Check the billing service first.')}>
|
|
1275
|
-
Steer
|
|
1276
|
-
</button>
|
|
1277
|
-
{state.inbox.paused ? (
|
|
1278
|
-
<button onClick={() => void resume()}>Resume</button>
|
|
1279
|
-
) : (
|
|
1280
|
-
<button onClick={() => void pause({ when: 'after-turn' })}>
|
|
1281
|
-
Pause after this turn
|
|
1282
|
-
</button>
|
|
1283
|
-
)}
|
|
1284
|
-
</section>
|
|
1285
|
-
)
|
|
1286
|
-
}
|
|
1287
|
-
```
|
|
1288
|
-
|
|
1289
|
-
`queue.edit(message)` replaces a pending input's content. `queue.move()` takes
|
|
1290
|
-
`beforeId: null` to move an item to the end. Removal only applies to pending
|
|
1291
|
-
input. It preserves the immutable log and keeps
|
|
1292
|
-
its original submission event, so replay cannot recreate a removed prompt.
|
|
1293
|
-
Removing an input that is no longer pending returns `not-pending`, whether it was
|
|
1294
|
-
admitted, removed, or never queued.
|
|
1295
|
-
|
|
1296
|
-
Edits to pending input use last-write-wins in session log order. Clients can send
|
|
1297
|
-
another edit before an earlier one is confirmed. Editing preserves the message's
|
|
1298
|
-
identity and role; changing its role returns `role-mismatch`. An edit
|
|
1299
|
-
that loses to admission returns `not-pending`. A Stop aimed at a completed turn
|
|
1300
|
-
returns `stale-turn`.
|
|
1301
|
-
|
|
1302
|
-
`steer()` validates the new input and places it ahead of the inbox in one
|
|
1303
|
-
decision. With no active turn, it sends like `sendMessage()`.
|
|
1304
|
-
If the observed turn is still active, it finishes its current model response and
|
|
1305
|
-
the tools produced by that response before handing off. Parallel tools all
|
|
1306
|
-
settle, and steering runs before the old turn starts another model step.
|
|
1307
|
-
Text and tool results produced after the click remain in the conversation.
|
|
1308
|
-
`lastSeenIndex` is not used to truncate output for steering or Send now.
|
|
1309
|
-
|
|
1310
|
-
When running work settles, unanswered approval or application-input requests
|
|
1311
|
-
are closed so the steering message can take over. The unapproved tool never
|
|
1312
|
-
executes. Provider-executed deferred results still count as running work when
|
|
1313
|
-
execution is authorized; a denied approval cannot block handoff.
|
|
1314
|
-
If the targeted turn has already finished, the input starts when idle or waits
|
|
1315
|
-
first behind a different active turn. Stop remains an immediate interruption.
|
|
1316
|
-
|
|
1317
|
-
Steering preserves the admission gate: steering while paused does not resume
|
|
1318
|
-
execution. Duplicate input identities and closed sessions still reject the command.
|
|
1319
|
-
|
|
1320
|
-
`queue.sendNow()` uses the selected queued message's existing identity and
|
|
1321
|
-
latest accepted content. It validates the selected input and the observed turn,
|
|
1322
|
-
then moves the input first for the same handoff after the current step. If that
|
|
1323
|
-
expectation is stale, the command returns `stale-turn` without changing the queue.
|
|
1324
|
-
|
|
1325
|
-
Send now preserves pause just like steering. A paused queue keeps the selected
|
|
1326
|
-
input first until Resume. It accepts user-role inputs, including passive user
|
|
1327
|
-
input, which it marks for generation. Non-user context returns `not-user-input`;
|
|
1328
|
-
reserved internal IDs are rejected before append. Unsaved editor text
|
|
1329
|
-
is separate from the accepted queue content; save it before sending.
|
|
1330
|
-
|
|
1331
|
-
A queued item's optional `afterStepOf` records the observed turn for steering.
|
|
1332
|
-
A matching active turn hands off after its current step.
|
|
1333
|
-
Editing preserves this intent; removing the item cancels it. Reordering the
|
|
1334
|
-
queue controls which input is admitted at that boundary.
|
|
1335
|
-
|
|
1336
|
-
### Optimistic conversation display
|
|
1337
|
-
|
|
1338
|
-
The AI reducer is optimistic in every client, including one built from an
|
|
1339
|
-
extended `agentContract()`. An idle, unpaused send appears in `state.messages`
|
|
1340
|
-
immediately, with `state.starting: true`. Another send waits in
|
|
1341
|
-
`state.inbox.items` while that optimistic turn or a confirmed turn is active.
|
|
1342
|
-
Paused sessions keep new sends in the inbox. Render the conversation and queue
|
|
1343
|
-
from `state`. A pending Stop clears the displayed `state.active` turn
|
|
1344
|
-
immediately; rejection restores it. New active turn identities still come from
|
|
1345
|
-
accepted control decisions.
|
|
1346
|
-
Steering appears immediately in `state.messages`, after the current response,
|
|
1347
|
-
and stays there while that step finishes. Send now has the same display when
|
|
1348
|
-
an active turn is targeted. These inputs are omitted from the client inbox,
|
|
1349
|
-
including while paused, but remain pending on the server until admission.
|
|
1350
|
-
Accepted steering survives receipt and hydration in this display. Multiple
|
|
1351
|
-
steering inputs appear in admission order, with the most recent first.
|
|
1352
|
-
No additional request, subscription, or component-owned queue is needed.
|
|
1353
|
-
|
|
1354
|
-
The request echo keeps the optimistic display until `ai.control.decided` arrives. Accepted
|
|
1355
|
-
commits replace the confirmed base; rejected receipts remove the pending intent.
|
|
1356
|
-
A failed POST rolls back through the ordinary client optimistic overlay.
|
|
1357
|
-
Another client's accepted turn can move a speculative ordinary send back into
|
|
1358
|
-
the queue. Steering stays visible as a message while waiting behind that turn.
|
|
1359
|
-
`state.starting` describes an optimistic turn awaiting acceptance; it does
|
|
1360
|
-
not indicate a running model.
|
|
1361
|
-
|
|
1362
|
-
Server `session.state()`, reducer folds, checkpoints, and model prompts
|
|
1363
|
-
contain confirmed messages and inbox entries, with `starting: false`. The client
|
|
1364
|
-
applies pending intent and displays accepted steering only when returning its
|
|
1365
|
-
state, after folding events.
|
|
1366
|
-
`state.pendingQueueCommands` tracks unresolved queue commands and Stops incrementally, so
|
|
1367
|
-
the client does not scan raw history. Pending edits apply in order, so the latest
|
|
1368
|
-
local edit stays visible while earlier commands are confirmed. Editing and Send
|
|
1369
|
-
now can remain available while an edit awaits its receipt. Close an editor as
|
|
1370
|
-
soon as its save is pushed; retain the draft if the push fails.
|
|
1371
|
-
|
|
1372
|
-
### Persistence and application are separate
|
|
1373
|
-
|
|
1374
|
-
A push confirms that the command is persisted. The `ai.control.decided` event
|
|
1375
|
-
confirms its application, using the submitted event's ID as `commandId`. Its
|
|
1376
|
-
`outcome` is `applied` or `rejected`; a rejection includes a `reason`, such as
|
|
1377
|
-
`not-pending` or `stale-turn`.
|
|
1378
|
-
`state.receipt` exposes the most recent user command receipt. Applications that
|
|
1379
|
-
have several commands in flight correlate receipts through the existing event
|
|
1380
|
-
stream: a hook action resolves to its persisted command events, and each
|
|
1381
|
-
event's `id` is the `commandId` of its receipt. An action with nothing to
|
|
1382
|
-
address, such as Stop with no active turn, resolves to an empty array. A
|
|
1383
|
-
rejection is a normal result and does not spend a handler retry.
|
|
1384
|
-
|
|
1385
|
-
### Pause and resume
|
|
1386
|
-
|
|
1387
|
-
Pause after the turn closes inbox admission and lets the active turn finish.
|
|
1388
|
-
Pause now also cancels current model and tool execution. `state.active.phase`
|
|
1389
|
-
becomes `pausing` while old work settles, then `paused`. Resume reopens admission
|
|
1390
|
-
and resumes retained work before admitting another input.
|
|
1391
|
-
|
|
1392
|
-
Resume starts a fresh model request from durable state. It does not resume the
|
|
1393
|
-
provider's old stream or undo completed tool effects. The controller retains
|
|
1394
|
-
accepted tool calls and records their outcomes while paused. A cancelled tool
|
|
1395
|
-
without a terminal result can execute again after resume; use the stable SDK
|
|
1396
|
-
`toolCallId` for external idempotency. No model or tool claim is held merely to
|
|
1397
|
-
keep a turn paused.
|
|
1398
|
-
|
|
1399
|
-
The controller runs on a short lane. Each decision commits accepted facts,
|
|
1400
|
-
compact state changes, work requests, and its receipt atomically. Workers use
|
|
1401
|
-
separate claims, report lifecycle batches, and wait for acceptance at model-start
|
|
1402
|
-
and compaction boundaries. Token progress appends directly. Explicit AI history
|
|
1403
|
-
reads remain bounded to a relevant generation suffix; core checkpoint rebuilding
|
|
1404
|
-
can replay older events when a snapshot is missing.
|
|
1405
|
-
|
|
1406
|
-
For a deferred provider-executed tool, trusted server code submits
|
|
1407
|
-
`inputs.toolResult(result)`. The controller checks its admitted call and records
|
|
1408
|
-
the result before continuing. Browser pushes reject this server-only input.
|