agents 0.16.2 → 0.17.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/README.md +11 -8
- package/dist/{agent-tool-types-CTw3UJUP.d.ts → agent-tool-types-Cd1TZPfB.d.ts} +587 -137
- package/dist/agent-tool-types.d.ts +34 -18
- package/dist/agent-tool-types.js +20 -1
- package/dist/agent-tool-types.js.map +1 -0
- package/dist/agent-tools-BXlsuX0d.js +304 -0
- package/dist/agent-tools-BXlsuX0d.js.map +1 -0
- package/dist/agent-tools-_E8wxUIK.d.ts +119 -0
- package/dist/agent-tools.d.ts +24 -18
- package/dist/agent-tools.js.map +1 -1
- package/dist/ai-chat-agent.d.ts +1 -1
- package/dist/ai-chat-agent.js +1 -2
- package/dist/ai-chat-agent.js.map +1 -1
- package/dist/ai-chat-v5-migration.d.ts +1 -1
- package/dist/ai-chat-v5-migration.js +1 -2
- package/dist/ai-chat-v5-migration.js.map +1 -1
- package/dist/ai-react.d.ts +1 -1
- package/dist/ai-react.js +1 -2
- package/dist/ai-react.js.map +1 -1
- package/dist/ai-types.d.ts +1 -7
- package/dist/ai-types.js +2 -8
- package/dist/ai-types.js.map +1 -1
- package/dist/browser/ai.js +1 -1
- package/dist/browser/index.js +1 -1
- package/dist/chat/index.d.ts +1918 -72
- package/dist/chat/index.js +1730 -245
- package/dist/chat/index.js.map +1 -1
- package/dist/chat/react.d.ts +602 -0
- package/dist/chat/react.js +1506 -0
- package/dist/chat/react.js.map +1 -0
- package/dist/chat-sdk/index.d.ts +4 -4
- package/dist/{classPrivateFieldGet2-CZ7QjTXN.js → classPrivateFieldGet2-DZBYAB34.js} +5 -5
- package/dist/{classPrivateMethodInitSpec-D-0__zd9.js → classPrivateMethodInitSpec-qMjJ6sHQ.js} +2 -2
- package/dist/{client-BXJ9n2f7.js → client-BZ-B3NhC.js} +2 -2
- package/dist/client-BZ-B3NhC.js.map +1 -0
- package/dist/client-tools-aIBO0Fk7.d.ts +53 -0
- package/dist/client.d.ts +76 -57
- package/dist/client.js +33 -5
- package/dist/client.js.map +1 -1
- package/dist/{connector-CrKhowfD.js → connector-CdldGF3h.js} +5 -5
- package/dist/{connector-CrKhowfD.js.map → connector-CdldGF3h.js.map} +1 -1
- package/dist/email.js +1 -1
- package/dist/email.js.map +1 -1
- package/dist/{index-B7IbEeze.d.ts → index-CcbnKkNh.d.ts} +188 -14
- package/dist/index.d.ts +91 -71
- package/dist/index.js +562 -24
- package/dist/index.js.map +1 -1
- package/dist/mcp/client.d.ts +18 -14
- package/dist/mcp/client.js +1 -1
- package/dist/mcp/index.d.ts +30 -30
- package/dist/mcp/index.js +7 -7
- package/dist/mcp/index.js.map +1 -1
- package/dist/{agent-tools-3zLG7MgA.js → message-builder-BymO4N_D.js} +45 -126
- package/dist/message-builder-BymO4N_D.js.map +1 -0
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +4 -2
- package/dist/observability/index.js.map +1 -1
- package/dist/react.d.ts +123 -110
- package/dist/react.js +44 -11
- package/dist/react.js.map +1 -1
- package/dist/serializable.d.ts +1 -1
- package/dist/skills/compile.js +1 -1
- package/dist/skills/compile.js.map +1 -1
- package/dist/skills/index.js +5 -5
- package/dist/skills/index.js.map +1 -1
- package/dist/sub-routing.d.ts +6 -6
- package/dist/utils.js +1 -1
- package/dist/utils.js.map +1 -1
- package/dist/vite.js +5 -5
- package/dist/vite.js.map +1 -1
- package/dist/wire-types-nflOzNuU.js +240 -0
- package/dist/wire-types-nflOzNuU.js.map +1 -0
- package/dist/{workflow-types-SrZK_o9p.d.ts → workflow-types-Baz_PO5v.d.ts} +42 -22
- package/dist/workflow-types.d.ts +25 -21
- package/dist/workflow-types.js.map +1 -1
- package/dist/workflows.d.ts +22 -21
- package/dist/workflows.js +31 -7
- package/dist/workflows.js.map +1 -1
- package/docs/adding-to-existing-project.md +450 -0
- package/docs/agent-class.md +503 -0
- package/docs/agent-tools.md +552 -0
- package/docs/browse-the-web.md +430 -0
- package/docs/callable-methods.md +627 -0
- package/docs/chat-agents.md +1687 -0
- package/docs/chat-sdk.md +181 -0
- package/docs/client-sdk.md +520 -0
- package/docs/client-tools-continuation.md +177 -0
- package/docs/codemode.md +440 -0
- package/docs/configuration.md +775 -0
- package/docs/cross-domain-authentication.md +171 -0
- package/docs/durable-execution.md +537 -0
- package/docs/email.md +663 -0
- package/docs/get-current-agent.md +204 -0
- package/docs/getting-started.md +305 -0
- package/docs/http-websockets.md +668 -0
- package/docs/human-in-the-loop.md +661 -0
- package/docs/index.md +151 -0
- package/docs/long-running-agents.md +730 -0
- package/docs/mcp-client.md +620 -0
- package/docs/mcp-servers.md +526 -0
- package/docs/mcp-transports.md +308 -0
- package/docs/migration-to-ai-sdk-v5.md +96 -0
- package/docs/migration-to-ai-sdk-v6.md +163 -0
- package/docs/observability.md +261 -0
- package/docs/push-notifications.md +367 -0
- package/docs/queue.md +329 -0
- package/docs/readonly-connections.md +278 -0
- package/docs/resumable-streaming.md +127 -0
- package/docs/retries.md +444 -0
- package/docs/routing.md +749 -0
- package/docs/scheduling.md +898 -0
- package/docs/securing-mcp-servers.md +359 -0
- package/docs/server-driven-messages.md +477 -0
- package/docs/sessions.md +1024 -0
- package/docs/state.md +512 -0
- package/docs/sub-agents.md +389 -0
- package/docs/webhooks.md +604 -0
- package/docs/workflows.md +877 -0
- package/package.json +41 -14
- package/dist/agent-tools-3zLG7MgA.js.map +0 -1
- package/dist/agent-tools-DZhI5F6Q.d.ts +0 -14
- package/dist/client-BXJ9n2f7.js.map +0 -1
|
@@ -0,0 +1,1687 @@
|
|
|
1
|
+
# Chat Agents
|
|
2
|
+
|
|
3
|
+
Build AI-powered chat interfaces with `AIChatAgent` and `useAgentChat`. Messages are automatically persisted to SQLite, streams resume on disconnect, and tool calls work across server and client.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`@cloudflare/ai-chat` provides two main exports:
|
|
8
|
+
|
|
9
|
+
| Export | Import | Purpose |
|
|
10
|
+
| -------------- | --------------------------- | -------------------------------------------------------------- |
|
|
11
|
+
| `AIChatAgent` | `@cloudflare/ai-chat` | Server-side agent class with message persistence and streaming |
|
|
12
|
+
| `useAgentChat` | `@cloudflare/ai-chat/react` | React hook for building chat UIs |
|
|
13
|
+
|
|
14
|
+
Built on the [AI SDK](https://ai-sdk.dev) and Cloudflare Durable Objects, you get:
|
|
15
|
+
|
|
16
|
+
- **Automatic message persistence** — conversations stored in SQLite, survive restarts
|
|
17
|
+
- **Resumable streaming** — disconnected clients resume mid-stream without data loss
|
|
18
|
+
- **Real-time sync** — messages broadcast to all connected clients via WebSocket
|
|
19
|
+
- **Tool support** — server-side, client-side, and human-in-the-loop tool patterns
|
|
20
|
+
- **Data parts** — attach typed JSON (citations, progress, usage) to messages alongside text
|
|
21
|
+
- **Row size protection** — automatic compaction when messages approach SQLite limits
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
|
|
25
|
+
### Install
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npm install @cloudflare/ai-chat agents ai workers-ai-provider
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Server
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { AIChatAgent } from "@cloudflare/ai-chat";
|
|
35
|
+
import { createWorkersAI } from "workers-ai-provider";
|
|
36
|
+
import { streamText, convertToModelMessages } from "ai";
|
|
37
|
+
|
|
38
|
+
export class ChatAgent extends AIChatAgent {
|
|
39
|
+
async onChatMessage() {
|
|
40
|
+
const workersai = createWorkersAI({ binding: this.env.AI });
|
|
41
|
+
|
|
42
|
+
const result = streamText({
|
|
43
|
+
model: workersai("@cf/moonshotai/kimi-k2.7-code"),
|
|
44
|
+
messages: await convertToModelMessages(this.messages)
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return result.toUIMessageStreamResponse();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Client
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
import { useAgent } from "agents/react";
|
|
56
|
+
import { useAgentChat } from "@cloudflare/ai-chat/react";
|
|
57
|
+
|
|
58
|
+
function Chat() {
|
|
59
|
+
const agent = useAgent({ agent: "ChatAgent" });
|
|
60
|
+
const { messages, sendMessage, status } = useAgentChat({ agent });
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div>
|
|
64
|
+
{messages.map((msg) => (
|
|
65
|
+
<div key={msg.id}>
|
|
66
|
+
<strong>{msg.role}:</strong>
|
|
67
|
+
{msg.parts.map((part, i) =>
|
|
68
|
+
part.type === "text" ? <span key={i}>{part.text}</span> : null
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
))}
|
|
72
|
+
|
|
73
|
+
<form
|
|
74
|
+
onSubmit={(e) => {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
const input = e.currentTarget.elements.namedItem(
|
|
77
|
+
"input"
|
|
78
|
+
) as HTMLInputElement;
|
|
79
|
+
sendMessage({ text: input.value });
|
|
80
|
+
input.value = "";
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
<input name="input" placeholder="Type a message..." />
|
|
84
|
+
<button type="submit" disabled={status === "streaming"}>
|
|
85
|
+
Send
|
|
86
|
+
</button>
|
|
87
|
+
</form>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Wrangler Config
|
|
94
|
+
|
|
95
|
+
```jsonc
|
|
96
|
+
// wrangler.jsonc
|
|
97
|
+
{
|
|
98
|
+
"ai": { "binding": "AI" },
|
|
99
|
+
"durable_objects": {
|
|
100
|
+
"bindings": [{ "name": "ChatAgent", "class_name": "ChatAgent" }]
|
|
101
|
+
},
|
|
102
|
+
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["ChatAgent"] }]
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The `new_sqlite_classes` migration is required — `AIChatAgent` uses SQLite for message persistence and stream chunk buffering.
|
|
107
|
+
|
|
108
|
+
## How It Works
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
┌──────────┐ WebSocket ┌──────────────┐
|
|
112
|
+
│ Client │ ◀──────────────────────────────────▶ │ AIChatAgent │
|
|
113
|
+
│ │ │ │
|
|
114
|
+
│ useAgent │ CF_AGENT_USE_CHAT_REQUEST ──────▶ │ onChatMessage│
|
|
115
|
+
│ Chat │ │ │
|
|
116
|
+
│ │ ◀────── CF_AGENT_USE_CHAT_RESPONSE │ streamText │
|
|
117
|
+
│ │ (UIMessageChunk stream) │ │
|
|
118
|
+
│ │ │ SQLite │
|
|
119
|
+
│ │ ◀────── CF_AGENT_CHAT_MESSAGES │ (messages, │
|
|
120
|
+
│ │ (broadcast to all clients) │ chunks) │
|
|
121
|
+
└──────────┘ └──────────────┘
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
1. The client sends a message via WebSocket
|
|
125
|
+
2. `AIChatAgent` persists messages to SQLite and calls your `onChatMessage` method
|
|
126
|
+
3. Your method returns a streaming `Response` (typically from `streamText`)
|
|
127
|
+
4. Chunks stream back over WebSocket in real-time
|
|
128
|
+
5. When the stream completes, the final message is persisted and broadcast to all connections
|
|
129
|
+
|
|
130
|
+
## Server API
|
|
131
|
+
|
|
132
|
+
### `AIChatAgent`
|
|
133
|
+
|
|
134
|
+
Extends `Agent` from the `agents` package. Manages conversation state, persistence, and streaming.
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
import { AIChatAgent } from "@cloudflare/ai-chat";
|
|
138
|
+
|
|
139
|
+
export class ChatAgent extends AIChatAgent {
|
|
140
|
+
// Access current messages
|
|
141
|
+
// this.messages: ChatMessage[]
|
|
142
|
+
|
|
143
|
+
// Limit stored messages (optional)
|
|
144
|
+
maxPersistedMessages = 200;
|
|
145
|
+
|
|
146
|
+
// Collapse overlapping user submits to the latest one (optional)
|
|
147
|
+
// messageConcurrency = "latest";
|
|
148
|
+
|
|
149
|
+
async onChatMessage(onFinish?, options?) {
|
|
150
|
+
// onFinish: optional callback for streamText (cleanup is automatic)
|
|
151
|
+
// options.abortSignal: cancel signal
|
|
152
|
+
// options.body: custom data from client
|
|
153
|
+
// Return a Response (streaming or plain text)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### `onChatMessage`
|
|
159
|
+
|
|
160
|
+
This is the main method you override. It receives the conversation context and should return a `Response`.
|
|
161
|
+
|
|
162
|
+
**Streaming response** (most common):
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
async onChatMessage() {
|
|
166
|
+
const workersai = createWorkersAI({ binding: this.env.AI });
|
|
167
|
+
|
|
168
|
+
const result = streamText({
|
|
169
|
+
model: workersai("@cf/moonshotai/kimi-k2.7-code"),
|
|
170
|
+
system: "You are a helpful assistant.",
|
|
171
|
+
messages: await convertToModelMessages(this.messages)
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return result.toUIMessageStreamResponse();
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**Plain text response**:
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
async onChatMessage() {
|
|
182
|
+
return new Response("Hello! I am a simple agent.", {
|
|
183
|
+
headers: { "Content-Type": "text/plain" }
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**Accessing custom body data and request ID**:
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
async onChatMessage(_onFinish, options) {
|
|
192
|
+
const { timezone, userId } = options?.body ?? {};
|
|
193
|
+
// Use these values in your LLM call or business logic
|
|
194
|
+
|
|
195
|
+
// options.requestId — unique identifier for this chat request,
|
|
196
|
+
// useful for logging and correlating events
|
|
197
|
+
console.log("Request ID:", options?.requestId);
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### `this.messages`
|
|
202
|
+
|
|
203
|
+
The current conversation history, loaded from SQLite. This is an array of `UIMessage` objects from the AI SDK. Messages are automatically persisted after each interaction.
|
|
204
|
+
|
|
205
|
+
### `maxPersistedMessages`
|
|
206
|
+
|
|
207
|
+
Cap the number of messages stored in SQLite. When the limit is exceeded, the oldest messages are deleted. This controls storage only — it does not affect what is sent to the LLM.
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
export class ChatAgent extends AIChatAgent {
|
|
211
|
+
maxPersistedMessages = 200;
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
To control what is sent to the model, use the AI SDK's `pruneMessages()`:
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
import { streamText, convertToModelMessages, pruneMessages } from "ai";
|
|
219
|
+
|
|
220
|
+
async onChatMessage() {
|
|
221
|
+
const workersai = createWorkersAI({ binding: this.env.AI });
|
|
222
|
+
|
|
223
|
+
const result = streamText({
|
|
224
|
+
model: workersai("@cf/moonshotai/kimi-k2.7-code"),
|
|
225
|
+
messages: pruneMessages({
|
|
226
|
+
messages: await convertToModelMessages(this.messages),
|
|
227
|
+
reasoning: "before-last-message",
|
|
228
|
+
toolCalls: "before-last-2-messages"
|
|
229
|
+
})
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return result.toUIMessageStreamResponse();
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### `messageConcurrency`
|
|
237
|
+
|
|
238
|
+
Controls what happens when a new `sendMessage()` submit arrives while another
|
|
239
|
+
chat turn is already active or queued.
|
|
240
|
+
|
|
241
|
+
| Value | Behavior |
|
|
242
|
+
| ------------------------------- | -------------------------------------------------------------------- |
|
|
243
|
+
| `"queue"` | Process every submit in order (default, existing behavior) |
|
|
244
|
+
| `"latest"` | Keep only the newest overlapping submit |
|
|
245
|
+
| `"merge"` | Collapse overlapping queued user messages into one follow-up submit |
|
|
246
|
+
| `"drop"` | Ignore overlapping submits entirely |
|
|
247
|
+
| `{ strategy: "debounce", ... }` | Wait for a quiet period, then run only the latest overlapping submit |
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
export class ChatAgent extends AIChatAgent {
|
|
251
|
+
messageConcurrency = "latest";
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Debounce uses the same trailing-edge semantics as chat apps that wait for the
|
|
256
|
+
user to finish sending a burst of short messages:
|
|
257
|
+
|
|
258
|
+
```typescript
|
|
259
|
+
export class ChatAgent extends AIChatAgent {
|
|
260
|
+
messageConcurrency = {
|
|
261
|
+
strategy: "debounce",
|
|
262
|
+
debounceMs: 1000
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
**Choosing a strategy:**
|
|
268
|
+
|
|
269
|
+
- Building a focused assistant where each turn matters? Use `"latest"` — the user can correct themselves mid-stream and only the final message gets a response.
|
|
270
|
+
- Building a messaging or chat app where every message should be processed? Use `"queue"` (default) or `"merge"` to collapse rapid-fire messages into one turn.
|
|
271
|
+
- Want to prevent accidental double-sends? Use `"drop"` — overlapping submits are rejected and the client rolls back.
|
|
272
|
+
- Users send bursts of short messages (like a messaging app)? Use `"debounce"` to wait for a quiet window before responding.
|
|
273
|
+
|
|
274
|
+
**What the user sees:**
|
|
275
|
+
|
|
276
|
+
- `"queue"` — every message gets its own assistant response, in order. Standard chat behavior.
|
|
277
|
+
- `"latest"` — all user messages appear in the transcript, but only the last overlapping message gets an assistant response. Earlier overlapping messages sit in the history with no reply.
|
|
278
|
+
- `"merge"` — overlapping user messages are collapsed into one combined message in the transcript, which gets a single assistant response.
|
|
279
|
+
- `"drop"` — the overlapping message briefly appears (optimistic), then disappears when the server sends back the rollback.
|
|
280
|
+
- `"debounce"` — same as `"latest"`, but the response waits for a quiet period before starting.
|
|
281
|
+
|
|
282
|
+
Notes:
|
|
283
|
+
|
|
284
|
+
- This setting only applies to overlapping `sendMessage()` submits (`trigger: "submit-message"`)
|
|
285
|
+
- `regenerate()`, tool continuations, approvals, clears, and programmatic `saveMessages()` calls keep the existing serialized behavior
|
|
286
|
+
- `"latest"` and `"debounce"` still persist the skipped user messages in `this.messages`; they only suppress extra model turns
|
|
287
|
+
- `"drop"` rejects the overlapping submit before it is persisted
|
|
288
|
+
|
|
289
|
+
### `waitForMcpConnections`
|
|
290
|
+
|
|
291
|
+
Controls whether `AIChatAgent` waits for MCP server connections to settle before calling `onChatMessage`. This ensures `this.mcp.getAITools()` returns the full set of tools, especially after Durable Object hibernation when connections are being restored in the background.
|
|
292
|
+
|
|
293
|
+
| Value | Behavior |
|
|
294
|
+
| --------------------- | --------------------------------------------- |
|
|
295
|
+
| `{ timeout: 10_000 }` | Wait up to 10 seconds (default) |
|
|
296
|
+
| `{ timeout: N }` | Wait up to `N` milliseconds |
|
|
297
|
+
| `true` | Wait indefinitely until all connections ready |
|
|
298
|
+
| `false` | Do not wait (old behavior before 0.2.0) |
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
export class ChatAgent extends AIChatAgent {
|
|
302
|
+
// Default — waits up to 10 seconds
|
|
303
|
+
// waitForMcpConnections = { timeout: 10_000 };
|
|
304
|
+
|
|
305
|
+
// Wait forever
|
|
306
|
+
waitForMcpConnections = true;
|
|
307
|
+
|
|
308
|
+
// Disable waiting
|
|
309
|
+
waitForMcpConnections = false;
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
For lower-level control, call `this.mcp.waitForConnections()` directly inside your `onChatMessage` instead.
|
|
314
|
+
|
|
315
|
+
### `persistMessages` and `saveMessages`
|
|
316
|
+
|
|
317
|
+
For advanced cases (schedule callbacks, webhook handlers, background tasks),
|
|
318
|
+
you can manually persist messages or queue programmatic turns:
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
// Persist messages without triggering a new response
|
|
322
|
+
await this.persistMessages(messages);
|
|
323
|
+
|
|
324
|
+
// Persist messages AND trigger onChatMessage
|
|
325
|
+
await this.saveMessages([...this.messages, newMessage]);
|
|
326
|
+
|
|
327
|
+
// Functional form: derive from latest transcript at execution time
|
|
328
|
+
// (e.g., from a schedule() callback or webhook handler)
|
|
329
|
+
await this.saveMessages((messages) => [...messages, syntheticMessage]);
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Use the functional form when background work needs to append or transform
|
|
333
|
+
messages against the latest persisted transcript when the turn actually starts.
|
|
334
|
+
This avoids stale baselines when multiple `saveMessages()` calls queue up
|
|
335
|
+
behind active work.
|
|
336
|
+
|
|
337
|
+
`saveMessages()` returns `{ requestId, status, error? }`. The `status` is
|
|
338
|
+
`"completed"` when the turn ran, `"error"` when the stream reported an error,
|
|
339
|
+
`"skipped"` when it was invalidated (chat cleared mid-flight), or `"aborted"`
|
|
340
|
+
when an external `AbortSignal` cancelled it before completion. When `status` is
|
|
341
|
+
`"error"`, `error` contains the stream error message when available.
|
|
342
|
+
|
|
343
|
+
Pass `options.signal` to cancel the turn from outside. Useful for forwarding
|
|
344
|
+
an upstream `AbortSignal` (e.g. an AI SDK tool `execute`'s `abortSignal` on a
|
|
345
|
+
parent agent) into a child DO's `saveMessages` call without knowing the
|
|
346
|
+
internally-generated request id:
|
|
347
|
+
|
|
348
|
+
```typescript
|
|
349
|
+
const result = await this.saveMessages([...this.messages, newMessage], {
|
|
350
|
+
signal: abortController.signal
|
|
351
|
+
});
|
|
352
|
+
if (result.status === "aborted") {
|
|
353
|
+
// Partial chunks already streamed are persisted; the inference
|
|
354
|
+
// loop terminated when the signal aborted.
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
The same `options.signal` is accepted by `continueLastTurn()`. See
|
|
359
|
+
[`cloudflare/agents#1406`](https://github.com/cloudflare/agents/issues/1406)
|
|
360
|
+
for the agent-tool orchestration pattern that motivated the API, and
|
|
361
|
+
[Agent Tools](./agent-tools.md) for using `AIChatAgent` and Think subclasses as
|
|
362
|
+
retained, streaming tools.
|
|
363
|
+
|
|
364
|
+
### `onChatResponse`
|
|
365
|
+
|
|
366
|
+
Called after a chat turn reaches a terminal state. Override this to react when the agent finishes responding — broadcast state, process queued work, track analytics, or trigger follow-up messages. Successful turns and errors that produced partial assistant chunks persist the assistant message before the hook runs; errors that occur before any assistant chunks still fire the hook with an empty assistant message and `status: "error"`.
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
import { AIChatAgent, type ChatResponseResult } from "@cloudflare/ai-chat";
|
|
370
|
+
|
|
371
|
+
export class ChatAgent extends AIChatAgent {
|
|
372
|
+
protected async onChatResponse(result: ChatResponseResult) {
|
|
373
|
+
if (result.status === "completed") {
|
|
374
|
+
this.broadcast(JSON.stringify({ streaming: false }));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
The turn lock is released before `onChatResponse` runs, so it is safe to call `saveMessages` from inside the hook. This enables sequential queue processing:
|
|
381
|
+
|
|
382
|
+
```typescript
|
|
383
|
+
protected async onChatResponse(result: ChatResponseResult) {
|
|
384
|
+
if (result.status === "completed" && this.workQueue.length > 0) {
|
|
385
|
+
const next = this.workQueue.shift()!;
|
|
386
|
+
await this.saveMessages([
|
|
387
|
+
...this.messages,
|
|
388
|
+
{ id: nanoid(), role: "user", parts: [{ type: "text", text: next }] }
|
|
389
|
+
]);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
When `saveMessages` is called from `onChatResponse`, the inner turn's response is automatically drained — `onChatResponse` fires again for the inner response, allowing the queue to progress naturally. This continues until the queue is empty.
|
|
395
|
+
|
|
396
|
+
Responses triggered from inside `onChatResponse` do not fire the hook concurrently. They are drained sequentially after the outer hook returns.
|
|
397
|
+
|
|
398
|
+
**`ChatResponseResult` fields:**
|
|
399
|
+
|
|
400
|
+
| Field | Type | Description |
|
|
401
|
+
| -------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
402
|
+
| `message` | `UIMessage` | The finalized assistant message from this turn. Error turns can provide an empty assistant message if no chunks were produced. |
|
|
403
|
+
| `requestId` | `string` | The request ID associated with this turn |
|
|
404
|
+
| `continuation` | `boolean` | Whether this turn was a continuation (auto-continue) |
|
|
405
|
+
| `status` | `"completed" \| "error" \| "aborted"` | How the turn ended |
|
|
406
|
+
| `error` | `string \| undefined` | Error message when `status` is `"error"` |
|
|
407
|
+
|
|
408
|
+
`onChatResponse` fires for all turn completion paths: WebSocket chat requests, `saveMessages`, and auto-continuation after tool results or approvals.
|
|
409
|
+
|
|
410
|
+
### Turn coordination helpers
|
|
411
|
+
|
|
412
|
+
`AIChatAgent` serializes chat turns — WebSocket requests, tool continuations,
|
|
413
|
+
`saveMessages()` calls all run one at a time. Most subclasses do not need to
|
|
414
|
+
think about this; the SDK handles the queuing automatically. If you configure
|
|
415
|
+
`messageConcurrency`, that policy decides which overlapping `sendMessage()` submits
|
|
416
|
+
make it into the queue.
|
|
417
|
+
|
|
418
|
+
Coordination becomes relevant when your subclass runs code **outside** the
|
|
419
|
+
normal `onChatMessage()` flow — for example, a `schedule()` callback that
|
|
420
|
+
injects a message, a workflow-switching method, or a custom clear handler that
|
|
421
|
+
scopes deletes to a particular workflow. In these situations, the subclass
|
|
422
|
+
needs to know whether the conversation is mid-stream or waiting on user input
|
|
423
|
+
before it can safely act on `this.messages`.
|
|
424
|
+
|
|
425
|
+
Three protected helpers cover these cases:
|
|
426
|
+
|
|
427
|
+
| Helper | Returns | When to use |
|
|
428
|
+
| ------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
429
|
+
| `waitUntilStable()` | `Promise<boolean>` | Before reading or writing messages — waits for any active stream, pending tool interactions, and queued continuations to finish |
|
|
430
|
+
| `resetTurnState()` | `void` | When discarding the current conversation state — aborts the active stream and invalidates queued continuations |
|
|
431
|
+
| `hasPendingInteraction()` | `boolean` | When you need a synchronous check for whether a tool is waiting on user input or approval |
|
|
432
|
+
|
|
433
|
+
#### How the turn lifecycle works
|
|
434
|
+
|
|
435
|
+
A chat turn starts when a WebSocket message or `saveMessages()` call enters the
|
|
436
|
+
queue and ends after the `_reply()` stream
|
|
437
|
+
finishes and the final assistant message is persisted. If a tool result or
|
|
438
|
+
approval arrives with
|
|
439
|
+
`autoContinue: true`, a continuation turn is queued automatically — the
|
|
440
|
+
conversation is not stable until that continuation finishes too.
|
|
441
|
+
|
|
442
|
+
A pending interaction is different from an active turn. The stream has finished,
|
|
443
|
+
but a tool part in the assistant message is in `input-available` or
|
|
444
|
+
`approval-requested` state — the SDK is waiting for the client to send a result
|
|
445
|
+
or approval. Until the user responds, `this.messages` reflects the pending
|
|
446
|
+
state.
|
|
447
|
+
|
|
448
|
+
`waitUntilStable()` handles both cases: it drains the turn queue, checks for
|
|
449
|
+
pending interactions, waits for any in-flight tool applies, and loops until
|
|
450
|
+
nothing is left. It only returns `true` once the conversation is genuinely
|
|
451
|
+
idle.
|
|
452
|
+
|
|
453
|
+
#### Waiting before injecting messages
|
|
454
|
+
|
|
455
|
+
The most common pattern is a `schedule()` callback or `onConnect()` method that
|
|
456
|
+
needs to inject a synthetic message into the conversation. Call
|
|
457
|
+
`waitUntilStable()` before reading `this.messages` or calling `saveMessages()`:
|
|
458
|
+
|
|
459
|
+
```typescript
|
|
460
|
+
async onTaskComplete(payload: { result: string }) {
|
|
461
|
+
const ready = await this.waitUntilStable({ timeout: 30_000 });
|
|
462
|
+
if (!ready) return; // timed out — a pending interaction was not resolved
|
|
463
|
+
|
|
464
|
+
const syntheticMessage = {
|
|
465
|
+
id: nanoid(),
|
|
466
|
+
role: "user" as const,
|
|
467
|
+
parts: [{ type: "text" as const, text: `Task result: ${payload.result}` }]
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
await this.saveMessages((messages) => [...messages, syntheticMessage]);
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
Always pass a `timeout`. Without one, `waitUntilStable()` waits indefinitely —
|
|
475
|
+
if a tool is pending user approval and the user closes their browser, the
|
|
476
|
+
promise never resolves. The timeout lets you fail gracefully and retry later.
|
|
477
|
+
|
|
478
|
+
When `waitUntilStable()` returns `true`, `this.messages` is safe to read and
|
|
479
|
+
`saveMessages()` will not overlap with another turn. When it returns `false`,
|
|
480
|
+
the conversation is still in flux and you should not assume message state is
|
|
481
|
+
settled.
|
|
482
|
+
|
|
483
|
+
#### Checking pending interactions synchronously
|
|
484
|
+
|
|
485
|
+
`hasPendingInteraction()` is a synchronous check — it scans `this.messages`
|
|
486
|
+
for any assistant message with a tool part in `input-available` or
|
|
487
|
+
`approval-requested` state. Use it when you need to branch without awaiting:
|
|
488
|
+
|
|
489
|
+
```typescript
|
|
490
|
+
async onConnect(connection, ctx) {
|
|
491
|
+
if (this.hasPendingInteraction()) {
|
|
492
|
+
connection.send(JSON.stringify({
|
|
493
|
+
type: "status",
|
|
494
|
+
message: "Waiting for your input on a pending tool action"
|
|
495
|
+
}));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
This does not tell you whether a stream is active — only whether a tool is
|
|
501
|
+
waiting on user input. For most coordination needs, prefer `waitUntilStable()`.
|
|
502
|
+
|
|
503
|
+
#### Resetting on workflow switch
|
|
504
|
+
|
|
505
|
+
Call `resetTurnState()` when the user switches context and the current stream
|
|
506
|
+
and any queued continuations are no longer relevant. It does three things:
|
|
507
|
+
increments the internal epoch (so queued continuations skip themselves), fires
|
|
508
|
+
the abort signal on the active stream, and clears any pending interaction
|
|
509
|
+
bookkeeping.
|
|
510
|
+
|
|
511
|
+
```typescript
|
|
512
|
+
async switchWorkflow(newWorkflowId: string) {
|
|
513
|
+
this.resetTurnState();
|
|
514
|
+
this.workflowId = newWorkflowId;
|
|
515
|
+
}
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
After `resetTurnState()`, the turn queue drains quickly — aborted turns finish
|
|
519
|
+
their cleanup and skipped continuations return immediately.
|
|
520
|
+
|
|
521
|
+
#### Overriding the clear handler
|
|
522
|
+
|
|
523
|
+
The SDK's built-in `CF_AGENT_CHAT_CLEAR` handler calls `resetTurnState()`
|
|
524
|
+
automatically. If your `onMessage` override intercepts `CF_AGENT_CHAT_CLEAR`
|
|
525
|
+
and returns before the SDK sees the message — for example, to scope the delete
|
|
526
|
+
to a specific workflow — the built-in handler never runs. The active stream
|
|
527
|
+
continues and queued continuations persist into the newly-cleared conversation.
|
|
528
|
+
|
|
529
|
+
Call `this.resetTurnState()` before performing your scoped delete:
|
|
530
|
+
|
|
531
|
+
```typescript
|
|
532
|
+
import { MessageType } from "@cloudflare/ai-chat/types";
|
|
533
|
+
|
|
534
|
+
const _onMessage = this.onMessage.bind(this);
|
|
535
|
+
this.onMessage = async (connection, message) => {
|
|
536
|
+
if (typeof message === "string") {
|
|
537
|
+
const data = JSON.parse(message);
|
|
538
|
+
if (data.type === MessageType.CF_AGENT_CHAT_CLEAR) {
|
|
539
|
+
this.resetTurnState();
|
|
540
|
+
this.sql`
|
|
541
|
+
DELETE FROM cf_ai_chat_agent_messages
|
|
542
|
+
WHERE workflow_id = ${this.workflowId}
|
|
543
|
+
`;
|
|
544
|
+
await this.saveMessages([]);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return _onMessage(connection, message);
|
|
549
|
+
};
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
### Lifecycle Hooks
|
|
553
|
+
|
|
554
|
+
Override `onConnect` and `onClose` to add custom logic. Stream resumption and message sync are handled for you automatically — you do not need to call `super`:
|
|
555
|
+
|
|
556
|
+
```typescript
|
|
557
|
+
export class ChatAgent extends AIChatAgent {
|
|
558
|
+
async onConnect(connection, ctx) {
|
|
559
|
+
// Your custom logic (e.g., logging, auth checks)
|
|
560
|
+
console.log("Client connected:", connection.id);
|
|
561
|
+
// Stream resumption and message sync are handled automatically
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async onClose(connection, code, reason, wasClean) {
|
|
565
|
+
console.log("Client disconnected:", connection.id);
|
|
566
|
+
// Connection cleanup is handled automatically
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
The `destroy()` method cancels any pending chat requests and cleans up stream state. It is called automatically when the Durable Object is evicted, but you can call it manually if needed.
|
|
572
|
+
|
|
573
|
+
### Request Cancellation
|
|
574
|
+
|
|
575
|
+
When a user clicks "stop" in the chat UI, the client sends a `CF_AGENT_CHAT_REQUEST_CANCEL` message. The server propagates this to the `abortSignal` in `options`:
|
|
576
|
+
|
|
577
|
+
```typescript
|
|
578
|
+
async onChatMessage(_onFinish, options) {
|
|
579
|
+
const result = streamText({
|
|
580
|
+
model: workersai("@cf/moonshotai/kimi-k2.7-code"),
|
|
581
|
+
messages: await convertToModelMessages(this.messages),
|
|
582
|
+
abortSignal: options?.abortSignal // Pass through for cancellation
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
return result.toUIMessageStreamResponse();
|
|
586
|
+
}
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
If you do not pass `abortSignal` to `streamText`, the LLM call will continue running in the background even after the user cancels. Always forward it when possible.
|
|
590
|
+
|
|
591
|
+
### Stream Recovery
|
|
592
|
+
|
|
593
|
+
When a Durable Object is evicted mid-stream (code update, inactivity timeout, resource limit), the LLM connection is severed permanently and the in-memory streaming state is lost. `chatRecovery` wraps each chat turn in a [`runFiber()`](./durable-execution.md), providing automatic `keepAlive` during streaming and a recovery hook on restart.
|
|
594
|
+
|
|
595
|
+
```typescript
|
|
596
|
+
export class ChatAgent extends AIChatAgent {
|
|
597
|
+
override chatRecovery = true;
|
|
598
|
+
}
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
When enabled, every `onChatMessage` call runs inside a fiber. If the agent is evicted mid-stream, the fiber row survives in SQLite. On the next activation, the framework detects the interrupted fiber, reconstructs the partial response from buffered stream chunks, and calls `onChatRecovery`.
|
|
602
|
+
|
|
603
|
+
`AIChatAgent` defaults `chatRecovery` to `false` so existing chat agents only get client reconnect/resumable-stream behavior. `Think` defaults it to `true`.
|
|
604
|
+
|
|
605
|
+
> **Assign `chatRecovery` as a class field or in the constructor — never in `onStart()`.** On every wake the SDK evaluates recovery budgets (and may seal an interrupted turn, firing `onExhausted`) _before_ your `onStart()` body runs. A config produced inside `onStart()` is therefore read as the built-in defaults at the moment recovery decides, so your `maxRecoveryWork` / `shouldKeepRecovering` / `onExhausted` silently never apply to the recovery that matters. The SDK logs a one-time warning if it detects `chatRecovery` being assigned during `onStart()`.
|
|
606
|
+
|
|
607
|
+
#### `onChatRecovery`
|
|
608
|
+
|
|
609
|
+
Override to implement provider-specific recovery. The default behavior persists the partial response and schedules a continuation via `continueLastTurn()`.
|
|
610
|
+
|
|
611
|
+
```typescript
|
|
612
|
+
export class ChatAgent extends AIChatAgent {
|
|
613
|
+
override chatRecovery = true;
|
|
614
|
+
|
|
615
|
+
override async onChatRecovery(
|
|
616
|
+
ctx: ChatRecoveryContext
|
|
617
|
+
): Promise<ChatRecoveryOptions> {
|
|
618
|
+
// Inspect what was generated before eviction
|
|
619
|
+
console.log(`Recovered ${ctx.partialText.length} chars of partial text`);
|
|
620
|
+
|
|
621
|
+
// Default: persist partial + schedule continuation
|
|
622
|
+
return {};
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
**`ChatRecoveryContext`:**
|
|
628
|
+
|
|
629
|
+
| Field | Type | Description |
|
|
630
|
+
| ----------------- | -------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
631
|
+
| `incidentId` | `string` | Stable ID for this recovery incident |
|
|
632
|
+
| `attempt` | `number` | Current attempt number for this incident, starting at 1 |
|
|
633
|
+
| `maxAttempts` | `number` | Configured attempt cap before terminal exhaustion |
|
|
634
|
+
| `recoveryKind` | `"retry" \| "continue"` | Whether recovery will retry an unanswered user turn or continue a partial assistant turn |
|
|
635
|
+
| `streamId` | `string` | ID of the interrupted stream |
|
|
636
|
+
| `requestId` | `string` | ID of the original chat request |
|
|
637
|
+
| `partialText` | `string` | Text generated before eviction |
|
|
638
|
+
| `partialParts` | `MessagePart[]` | Message parts (text, reasoning, tool calls) generated before eviction |
|
|
639
|
+
| `recoveryData` | `unknown \| null` | Data from `this.stash()` — entirely user-controlled |
|
|
640
|
+
| `messages` | `ChatMessage[]` | Full conversation history |
|
|
641
|
+
| `lastBody` | `Record<string, unknown> \| undefined` | The original request body |
|
|
642
|
+
| `lastClientTools` | `ClientToolSchema[] \| undefined` | Client tool schemas from the original request |
|
|
643
|
+
| `createdAt` | `number` | Epoch milliseconds when the underlying fiber started |
|
|
644
|
+
|
|
645
|
+
**`ChatRecoveryOptions`:**
|
|
646
|
+
|
|
647
|
+
| Field | Default | Description |
|
|
648
|
+
| ---------- | ------- | ------------------------------------------------- |
|
|
649
|
+
| `persist` | `true` | Save the partial response as an assistant message |
|
|
650
|
+
| `continue` | `true` | Schedule a continuation via `continueLastTurn()` |
|
|
651
|
+
|
|
652
|
+
Common return values:
|
|
653
|
+
|
|
654
|
+
- `{}` — persist partial + auto-continue (default, works with providers that support assistant prefill)
|
|
655
|
+
- `{ continue: false }` — persist partial but do not auto-continue (handle continuation yourself)
|
|
656
|
+
- `{ persist: false, continue: false }` — do not persist the unsettled remainder and handle everything yourself (e.g., retrieve a completed response from the provider)
|
|
657
|
+
|
|
658
|
+
Settled work is never dropped: `persist: false` only suppresses persistence of a partial that has nothing settled to lose. A partial that already carries settled tool results (completed, often non-idempotent work) is persisted regardless, so an app cannot accidentally discard completed tool calls — and never needs `{ persist: true }` just to stay safe.
|
|
659
|
+
|
|
660
|
+
When recovery happens before any stream chunks were written, there is no partial assistant message to continue. If the latest persisted message is still the unanswered user message from the interrupted turn, the framework retries that turn automatically unless `continue` is `false`.
|
|
661
|
+
|
|
662
|
+
`chatRecovery` can also be configured with budgets and terminal behavior:
|
|
663
|
+
|
|
664
|
+
```typescript
|
|
665
|
+
override chatRecovery = {
|
|
666
|
+
maxAttempts: 10,
|
|
667
|
+
stableTimeoutMs: 10_000,
|
|
668
|
+
terminalMessage: "The assistant was interrupted and could not recover.",
|
|
669
|
+
// Primary stuck-turn bound. Resets on every progress-bearing attempt, so a
|
|
670
|
+
// turn that keeps producing content survives unbounded interruption.
|
|
671
|
+
noProgressTimeoutMs: 5 * 60 * 1000,
|
|
672
|
+
// Runaway-loop guard. Defaults to Infinity (no cap). Set a finite value to
|
|
673
|
+
// seal a turn that keeps emitting content but never converges.
|
|
674
|
+
maxRecoveryWork: 200,
|
|
675
|
+
// Caller policy consulted from the second recovery attempt onward. Return
|
|
676
|
+
// false to stop recovery. This is where you enforce a token/cost budget.
|
|
677
|
+
// Note: this is called as `config.shouldKeepRecovering(ctx)`, so it is not
|
|
678
|
+
// bound to the agent instance — track spend in your own store keyed by the
|
|
679
|
+
// incident.
|
|
680
|
+
async shouldKeepRecovering(ctx) {
|
|
681
|
+
return (await getSpendForTurn(ctx.recoveryRootRequestId)) < MAX_SPEND;
|
|
682
|
+
},
|
|
683
|
+
async onExhausted(ctx) {
|
|
684
|
+
console.warn("Chat recovery exhausted", ctx.incidentId, ctx.reason);
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
**`chatRecovery` configuration:**
|
|
690
|
+
|
|
691
|
+
| Field | Default | Description |
|
|
692
|
+
| ---------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
693
|
+
| `maxAttempts` | `10` | Attempt cap before terminal exhaustion. Resets on forward progress, so it catches a tight no-progress alarm loop, not a healthy long turn. |
|
|
694
|
+
| `stableTimeoutMs` | `10_000` | How long a recovery attempt waits for the isolate to reach stable state before rescheduling. |
|
|
695
|
+
| `terminalMessage` | generic message | The message shown to the user when recovery is given up on. |
|
|
696
|
+
| `noProgressTimeoutMs` | `300_000` (5 min) | Primary stuck-turn bound: how long an incident may go without forward progress before it is sealed (`no_progress_timeout`). **Resets on every progress-bearing attempt**, so a turn that keeps producing content survives unbounded interruption. |
|
|
697
|
+
| `maxRecoveryWork` | `Infinity` | Runaway-loop guard. Maximum produced content/tool units since the incident began before a still-progressing turn is sealed. Defaults to no cap. |
|
|
698
|
+
| `shouldKeepRecovering` | — | Caller policy consulted from the second recovery attempt onward (never on the first detection, never once a hard bound has sealed the incident). Return `false` to stop recovery. The hook point for a token/cost budget — `ctx.work` is a coarse segment count, not tokens, so track real spend yourself. |
|
|
699
|
+
| `onExhausted` | — | Called once when recovery is given up on, before the terminal message is delivered. Inspect `ctx.reason` for why. |
|
|
700
|
+
|
|
701
|
+
**`ChatRecoveryProgressContext`** (the `ctx` passed to `shouldKeepRecovering`):
|
|
702
|
+
|
|
703
|
+
| Field | Type | Description |
|
|
704
|
+
| ----------------------- | ----------------------- | ------------------------------------------------------------------------------------------------- |
|
|
705
|
+
| `incidentId` | `string` | Stable ID for this recovery incident. |
|
|
706
|
+
| `requestId` | `string` | Request ID for the current continuation (changes per chained continuation). |
|
|
707
|
+
| `recoveryRootRequestId` | `string` | Stable ID for the whole continuation chain — the right key for per-incident budget tracking. |
|
|
708
|
+
| `attempt` | `number` | Attempt number for this incident (2 or greater when this hook runs). |
|
|
709
|
+
| `maxAttempts` | `number` | Configured attempt cap. |
|
|
710
|
+
| `recoveryKind` | `"retry" \| "continue"` | Whether recovery retries an unanswered user turn or continues a partial assistant turn. |
|
|
711
|
+
| `work` | `number` | Coarse, monotonic count of content/tool segments produced since the incident opened (not tokens). |
|
|
712
|
+
| `ageMs` | `number` | Wall-clock ms since the incident's first interruption. |
|
|
713
|
+
|
|
714
|
+
A progressing turn is never terminated by the framework on its own — it survives unbounded interruption (for example a dense deploy window) as long as it keeps making forward progress. Recovery is sealed only by one of these `ctx.reason` values:
|
|
715
|
+
|
|
716
|
+
- `no_progress_timeout` — no forward progress within the no-progress window (a stuck turn).
|
|
717
|
+
- `max_attempts_exceeded` — the attempt cap was spent on a tight no-progress alarm loop.
|
|
718
|
+
- `work_budget_exceeded` — the turn kept producing content but exceeded `maxRecoveryWork` (a runaway loop).
|
|
719
|
+
- `recovery_aborted` — your `shouldKeepRecovering` hook returned `false`.
|
|
720
|
+
- `stable_timeout` — recovery attempts kept timing out waiting for stable state until the budget drained (extreme churn).
|
|
721
|
+
|
|
722
|
+
> Setting a finite `maxRecoveryWork` reintroduces a false-positive risk for a legitimately long turn — pick a cap well above what a healthy turn produces, or prefer `shouldKeepRecovering` with real token/cost accounting for a precise budget.
|
|
723
|
+
|
|
724
|
+
#### Turns waiting on a human are not sealed
|
|
725
|
+
|
|
726
|
+
A turn parked on a pending **client** interaction — a tool part in `input-available` state for a client-side tool (one with no server `execute`, whose result the client replays), or a part in `approval-requested` state — is _waiting on the human_, not stuck. While such an interaction is pending, the turn is exempt from every recovery budget: the no-progress window, attempt cap, `maxRecoveryWork`, and `shouldKeepRecovering` are all suspended, so a slow human (for example, a user who takes minutes to answer a confirmation prompt that was interrupted by a deploy) never trips a seal. Instead of rescheduling or exhausting, recovery **parks** the incident (status `skipped`, reason `awaiting_client_interaction`) and clears the live "recovering…" indicator; the client's eventual reconnect-and-replay resumes the turn through the normal continuation path. A client that never returns is reclaimed by the incident TTL sweep and Durable Object idle eviction.
|
|
727
|
+
|
|
728
|
+
This exemption is intentionally **client-only**. A server tool whose `execute()` died with the evicted isolate is a genuine orphan — nothing will ever resolve it — so server-tool interruptions are not exempt and recover normally through the transcript-repair pass.
|
|
729
|
+
|
|
730
|
+
Monitor recovery through observability:
|
|
731
|
+
|
|
732
|
+
```ts
|
|
733
|
+
import { subscribe } from "agents/observability";
|
|
734
|
+
|
|
735
|
+
const unsubscribe = subscribe("chat", (event) => {
|
|
736
|
+
if (event.type === "chat:recovery:exhausted") {
|
|
737
|
+
console.error("Chat recovery exhausted", event.payload);
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
#### Recovering status on the client
|
|
743
|
+
|
|
744
|
+
While a turn is being recovered, the agent broadcasts a `cf_agent_chat_recovering` status frame so clients can show a "recovering…" indicator instead of looking frozen. It is set when a recovery continuation is scheduled and cleared on every terminal outcome (so the indicator never spins forever). `@cloudflare/think` also replays it on connect, so a client that joins mid-recovery still learns the turn is working. Consume it via `useAgentChat`'s `isRecovering` flag (see [Client API](#useagentchat)). The signal is purely advisory and backward-compatible — clients that do not understand it ignore it.
|
|
745
|
+
|
|
746
|
+
> `@cloudflare/ai-chat` broadcasts the live signal but does not yet replay it on connect (a client connecting mid-recovery will not be re-told until it reconnects to an active stream).
|
|
747
|
+
|
|
748
|
+
Transcript repairs — healing orphaned tool calls (preserving them as errored results rather than deleting them, so the record survives and the model does not silently re-run the tool) and normalizing malformed/stringified or missing tool inputs before a provider call — are emitted on the `transcript` channel.
|
|
749
|
+
|
|
750
|
+
#### Guarding against stale recoveries
|
|
751
|
+
|
|
752
|
+
After a long outage (an eviction combined with a slow reactivation, or a tool/container that became unreachable mid-stream), auto-continuing can replay a turn the user has already moved on from — or worse, kick off a continuation that keeps failing in the same way and occupies the turn queue while new messages pile up behind it.
|
|
753
|
+
|
|
754
|
+
Use `ctx.createdAt` to suppress continuation for turns that have been orphaned too long:
|
|
755
|
+
|
|
756
|
+
```typescript
|
|
757
|
+
override async onChatRecovery(
|
|
758
|
+
ctx: ChatRecoveryContext
|
|
759
|
+
): Promise<ChatRecoveryOptions> {
|
|
760
|
+
const ageMs = Date.now() - ctx.createdAt;
|
|
761
|
+
if (ageMs > 2 * 60 * 1000) {
|
|
762
|
+
// Persist whatever was already streamed so the user sees it, but do not
|
|
763
|
+
// spawn a fresh inference against a potentially broken tool state.
|
|
764
|
+
return { continue: false };
|
|
765
|
+
}
|
|
766
|
+
return {};
|
|
767
|
+
}
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
For loop protection (suppressing continuation after N recoveries of the same turn), pair this with a small per-assistant-message counter that `onChatResponse` clears on successful completion. The counter lives entirely in your agent — no framework support required:
|
|
771
|
+
|
|
772
|
+
```typescript
|
|
773
|
+
override async onChatRecovery(
|
|
774
|
+
ctx: ChatRecoveryContext
|
|
775
|
+
): Promise<ChatRecoveryOptions> {
|
|
776
|
+
const target = ctx.messages.filter((m) => m.role === "assistant").at(-1);
|
|
777
|
+
if (!target) return {};
|
|
778
|
+
|
|
779
|
+
const row = this.sql<{ count: number }>`
|
|
780
|
+
SELECT count FROM my_recoveries WHERE assistant_id = ${target.id}
|
|
781
|
+
`;
|
|
782
|
+
const count = (row[0]?.count ?? 0) + 1;
|
|
783
|
+
this.sql`
|
|
784
|
+
INSERT INTO my_recoveries (assistant_id, count, last_at)
|
|
785
|
+
VALUES (${target.id}, ${count}, ${Date.now()})
|
|
786
|
+
ON CONFLICT(assistant_id) DO UPDATE SET count = excluded.count, last_at = excluded.last_at
|
|
787
|
+
`;
|
|
788
|
+
if (count >= 2) return { continue: false };
|
|
789
|
+
return {};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
override async onChatResponse(result: ChatResponseResult): Promise<void> {
|
|
793
|
+
if (result.status === "completed") {
|
|
794
|
+
this.sql`DELETE FROM my_recoveries WHERE assistant_id = ${result.message.id}`;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
#### `continueLastTurn`
|
|
800
|
+
|
|
801
|
+
Appends to the last assistant message by re-calling `onChatMessage` with the saved request body. The response is streamed as a continuation — appended to the existing assistant message, not a new one. No synthetic user message is created.
|
|
802
|
+
|
|
803
|
+
```typescript
|
|
804
|
+
protected continueLastTurn(
|
|
805
|
+
body?: Record<string, unknown>,
|
|
806
|
+
options?: SaveMessagesOptions
|
|
807
|
+
): Promise<SaveMessagesResult>;
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
The optional `options.signal` accepts an external `AbortSignal` for cancellation, matching the `saveMessages` contract.
|
|
811
|
+
|
|
812
|
+
Called automatically by the default recovery path. Can also be called manually from scheduled callbacks or other entry points. The optional `body` parameter merges with the saved `_lastBody`.
|
|
813
|
+
|
|
814
|
+
#### Stashing recovery data
|
|
815
|
+
|
|
816
|
+
Use `this.stash()` inside `onChatMessage` to persist provider-specific data for recovery. The stash is stored in the fiber's SQLite row, separate from agent state, and available as `ctx.recoveryData` in `onChatRecovery`.
|
|
817
|
+
|
|
818
|
+
```typescript
|
|
819
|
+
async onChatMessage(_onFinish, options) {
|
|
820
|
+
const result = streamText({
|
|
821
|
+
model: openai("gpt-5.4"),
|
|
822
|
+
messages: await convertToModelMessages(this.messages),
|
|
823
|
+
providerOptions: { openai: { store: true } },
|
|
824
|
+
includeRawChunks: true,
|
|
825
|
+
onChunk: ({ chunk }) => {
|
|
826
|
+
if (chunk.type === "raw") {
|
|
827
|
+
const raw = chunk.rawValue as { type?: string; response?: { id?: string } };
|
|
828
|
+
if (raw?.type === "response.created" && raw.response?.id) {
|
|
829
|
+
this.stash({ responseId: raw.response.id });
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
return result.toUIMessageStreamResponse();
|
|
835
|
+
}
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
#### Recovery strategies by provider
|
|
839
|
+
|
|
840
|
+
The right strategy depends on whether the provider supports assistant prefill and whether the response continues server-side after disconnection:
|
|
841
|
+
|
|
842
|
+
| Provider | Strategy | Token cost |
|
|
843
|
+
| ---------------------- | ------------------------------------------------------------ | ---------- |
|
|
844
|
+
| Workers AI | `continueLastTurn()` — model continues via assistant prefill | Low |
|
|
845
|
+
| OpenAI (Responses API) | Retrieve completed response by ID — zero wasted tokens | Zero |
|
|
846
|
+
| Anthropic | Persist partial, send a synthetic user message to continue | Medium |
|
|
847
|
+
|
|
848
|
+
For a complete multi-provider implementation, see the [`forever-chat` example](https://github.com/cloudflare/agents/tree/main/experimental/forever-chat) and the [`forever.md` design doc](https://github.com/cloudflare/agents/tree/main/experimental/forever.md). For how chat recovery fits into the broader long-running agents story, see [Long-Running Agents: Recovering interrupted LLM streams](./long-running-agents.md#recovering-interrupted-llm-streams).
|
|
849
|
+
|
|
850
|
+
## Client API
|
|
851
|
+
|
|
852
|
+
### `useAgentChat`
|
|
853
|
+
|
|
854
|
+
React hook that connects to an `AIChatAgent` over WebSocket. Wraps the AI SDK's `useChat` with a native WebSocket transport.
|
|
855
|
+
|
|
856
|
+
```tsx
|
|
857
|
+
import { useAgent } from "agents/react";
|
|
858
|
+
import { useAgentChat } from "@cloudflare/ai-chat/react";
|
|
859
|
+
|
|
860
|
+
function Chat() {
|
|
861
|
+
const agent = useAgent({ agent: "ChatAgent" });
|
|
862
|
+
const {
|
|
863
|
+
messages,
|
|
864
|
+
sendMessage,
|
|
865
|
+
clearHistory,
|
|
866
|
+
addToolOutput,
|
|
867
|
+
addToolApprovalResponse,
|
|
868
|
+
setMessages,
|
|
869
|
+
status,
|
|
870
|
+
isServerStreaming,
|
|
871
|
+
isStreaming,
|
|
872
|
+
isRecovering
|
|
873
|
+
} = useAgentChat({ agent });
|
|
874
|
+
|
|
875
|
+
// ...
|
|
876
|
+
}
|
|
877
|
+
```
|
|
878
|
+
|
|
879
|
+
### Options
|
|
880
|
+
|
|
881
|
+
| Option | Type | Default | Description |
|
|
882
|
+
| ----------------------------- | ----------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
883
|
+
| `agent` | `ReturnType<typeof useAgent>` | Required | Agent connection from `useAgent` |
|
|
884
|
+
| `onToolCall` | `({ toolCall, addToolOutput }) => void` | — | Handle client-side tool execution |
|
|
885
|
+
| `autoContinueAfterToolResult` | `boolean` | `true` | Auto-continue conversation after client tool results and approvals |
|
|
886
|
+
| `resume` | `boolean` | `true` | Enable automatic stream resumption on reconnect |
|
|
887
|
+
| `cancelOnClientAbort` | `boolean` | `false` | Cancel the server turn when generic client stream abort/cleanup occurs. Explicit `stop()` always cancels the server turn |
|
|
888
|
+
| `body` | `object \| () => object` | — | Custom data sent with every request |
|
|
889
|
+
| `prepareSendMessagesRequest` | `(options) => { body?, headers? }` | — | Advanced per-request customization |
|
|
890
|
+
| `tools` | `Record<string, AITool>` | — | Dynamic client-defined tools for SDK/platform use cases. Schemas are sent to the server automatically |
|
|
891
|
+
| `getInitialMessages` | `(options) => Promise<ChatMessage[]>` or `null` | — | Custom initial message loader. Set to `null` to skip the HTTP fetch entirely (useful when providing `messages` directly) |
|
|
892
|
+
| `syncMessagesToServer` | `boolean` | `true` | When `true`, `setMessages` pushes the transcript to the server. Set to `false` for hosts with server-authoritative transcript storage so `setMessages` updates the local view only |
|
|
893
|
+
|
|
894
|
+
### Return Values
|
|
895
|
+
|
|
896
|
+
| Property | Type | Description |
|
|
897
|
+
| ------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
898
|
+
| `messages` | `ChatMessage[]` | Current conversation messages |
|
|
899
|
+
| `sendMessage` | `(message) => void` | Send a message |
|
|
900
|
+
| `clearHistory` | `() => void` | Clear conversation (client and server) |
|
|
901
|
+
| `addToolOutput` | `({ toolCallId, output }) => void` | Provide output for a client-side tool |
|
|
902
|
+
| `addToolApprovalResponse` | `({ id, approved }) => void` | Approve or reject a tool requiring approval |
|
|
903
|
+
| `setMessages` | `(messages \| updater) => void` | Set messages directly (syncs to server) |
|
|
904
|
+
| `status` | `string` | `"idle"`, `"submitted"`, `"streaming"`, or `"error"` |
|
|
905
|
+
| `isServerStreaming` | `boolean` | `true` when a server-initiated stream is active (e.g. from `saveMessages`) |
|
|
906
|
+
| `isStreaming` | `boolean` | `true` when any stream is active (client or server-initiated) |
|
|
907
|
+
| `isRecovering` | `boolean` | `true` while a durable turn is being recovered (interrupted and resuming). Distinct from `isStreaming` — a recovering turn is not producing tokens yet. Render a "recovering…" hint; most UIs treat `isStreaming \|\| isRecovering` as "busy" |
|
|
908
|
+
|
|
909
|
+
## Tools
|
|
910
|
+
|
|
911
|
+
`AIChatAgent` supports three tool patterns, all using the AI SDK's `tool()` function:
|
|
912
|
+
|
|
913
|
+
| Pattern | Where it runs | When to use |
|
|
914
|
+
| ----------- | ---------------------------- | --------------------------------------------- |
|
|
915
|
+
| Server-side | Server (automatic) | API calls, database queries, computations |
|
|
916
|
+
| Client-side | Browser (via `onToolCall`) | Geolocation, clipboard, camera, local storage |
|
|
917
|
+
| Approval | Server (after user approval) | Payments, deletions, external actions |
|
|
918
|
+
|
|
919
|
+
### Server-Side Tools
|
|
920
|
+
|
|
921
|
+
Tools with an `execute` function run automatically on the server:
|
|
922
|
+
|
|
923
|
+
```typescript
|
|
924
|
+
import { streamText, convertToModelMessages, tool, stepCountIs } from "ai";
|
|
925
|
+
import { z } from "zod";
|
|
926
|
+
|
|
927
|
+
async onChatMessage() {
|
|
928
|
+
const workersai = createWorkersAI({ binding: this.env.AI });
|
|
929
|
+
|
|
930
|
+
const result = streamText({
|
|
931
|
+
model: workersai("@cf/moonshotai/kimi-k2.7-code"),
|
|
932
|
+
messages: await convertToModelMessages(this.messages),
|
|
933
|
+
tools: {
|
|
934
|
+
getWeather: tool({
|
|
935
|
+
description: "Get weather for a city",
|
|
936
|
+
inputSchema: z.object({ city: z.string() }),
|
|
937
|
+
execute: async ({ city }) => {
|
|
938
|
+
const data = await fetchWeather(city);
|
|
939
|
+
return { temperature: data.temp, condition: data.condition };
|
|
940
|
+
}
|
|
941
|
+
})
|
|
942
|
+
},
|
|
943
|
+
stopWhen: stepCountIs(5)
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
return result.toUIMessageStreamResponse();
|
|
947
|
+
}
|
|
948
|
+
```
|
|
949
|
+
|
|
950
|
+
### Client-Side Tools
|
|
951
|
+
|
|
952
|
+
Define a tool on the server without `execute`, then handle it on the client with `onToolCall`. Use this for tools that need browser APIs:
|
|
953
|
+
|
|
954
|
+
**Server:**
|
|
955
|
+
|
|
956
|
+
```typescript
|
|
957
|
+
tools: {
|
|
958
|
+
getLocation: tool({
|
|
959
|
+
description: "Get the user's location from the browser",
|
|
960
|
+
inputSchema: z.object({})
|
|
961
|
+
// No execute — the client handles it
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
```
|
|
965
|
+
|
|
966
|
+
**Client:**
|
|
967
|
+
|
|
968
|
+
```tsx
|
|
969
|
+
const { messages, sendMessage } = useAgentChat({
|
|
970
|
+
agent,
|
|
971
|
+
onToolCall: async ({ toolCall, addToolOutput }) => {
|
|
972
|
+
if (toolCall.toolName === "getLocation") {
|
|
973
|
+
const pos = await new Promise((resolve, reject) =>
|
|
974
|
+
navigator.geolocation.getCurrentPosition(resolve, reject)
|
|
975
|
+
);
|
|
976
|
+
addToolOutput({
|
|
977
|
+
toolCallId: toolCall.toolCallId,
|
|
978
|
+
output: { lat: pos.coords.latitude, lng: pos.coords.longitude }
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
When the LLM invokes `getLocation`, the stream pauses. The `onToolCall` callback fires, your code provides the output, and the conversation continues.
|
|
986
|
+
|
|
987
|
+
### Dynamic Client Tools (SDK/Platform Pattern)
|
|
988
|
+
|
|
989
|
+
For SDKs and platforms where tools are defined dynamically by the embedding application at runtime, use the `tools` option on `useAgentChat` and `createToolsFromClientSchemas()` on the server:
|
|
990
|
+
|
|
991
|
+
**Server:**
|
|
992
|
+
|
|
993
|
+
```typescript
|
|
994
|
+
import { createToolsFromClientSchemas } from "@cloudflare/ai-chat";
|
|
995
|
+
|
|
996
|
+
async onChatMessage(_onFinish, options) {
|
|
997
|
+
const result = streamText({
|
|
998
|
+
model: workersai("@cf/moonshotai/kimi-k2.7-code"),
|
|
999
|
+
messages: await convertToModelMessages(this.messages),
|
|
1000
|
+
tools: createToolsFromClientSchemas(options?.clientTools)
|
|
1001
|
+
});
|
|
1002
|
+
return result.toUIMessageStreamResponse();
|
|
1003
|
+
}
|
|
1004
|
+
```
|
|
1005
|
+
|
|
1006
|
+
**Client:**
|
|
1007
|
+
|
|
1008
|
+
```tsx
|
|
1009
|
+
import { useAgentChat, type AITool } from "@cloudflare/ai-chat/react";
|
|
1010
|
+
|
|
1011
|
+
const tools: Record<string, AITool> = {
|
|
1012
|
+
getPageTitle: {
|
|
1013
|
+
description: "Get the current page title",
|
|
1014
|
+
parameters: { type: "object", properties: {} },
|
|
1015
|
+
execute: async () => ({ title: document.title })
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
|
|
1019
|
+
const { messages, sendMessage } = useAgentChat({
|
|
1020
|
+
agent,
|
|
1021
|
+
tools,
|
|
1022
|
+
onToolCall: async ({ toolCall, addToolOutput }) => {
|
|
1023
|
+
const tool = tools[toolCall.toolName];
|
|
1024
|
+
if (tool?.execute) {
|
|
1025
|
+
const output = await tool.execute(toolCall.input);
|
|
1026
|
+
addToolOutput({ toolCallId: toolCall.toolCallId, output });
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
```
|
|
1031
|
+
|
|
1032
|
+
For most apps, server-side tools with `tool()` and `onToolCall` are simpler and provide full Zod type safety. Use dynamic client tools when the server does not know the tool surface at deploy time.
|
|
1033
|
+
|
|
1034
|
+
### Tool Approval (Human-in-the-Loop)
|
|
1035
|
+
|
|
1036
|
+
Use `needsApproval` for tools that require user confirmation before executing:
|
|
1037
|
+
|
|
1038
|
+
**Server:**
|
|
1039
|
+
|
|
1040
|
+
```typescript
|
|
1041
|
+
tools: {
|
|
1042
|
+
processPayment: tool({
|
|
1043
|
+
description: "Process a payment",
|
|
1044
|
+
inputSchema: z.object({
|
|
1045
|
+
amount: z.number(),
|
|
1046
|
+
recipient: z.string()
|
|
1047
|
+
}),
|
|
1048
|
+
needsApproval: async ({ amount }) => amount > 100,
|
|
1049
|
+
execute: async ({ amount, recipient }) => charge(amount, recipient)
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
```
|
|
1053
|
+
|
|
1054
|
+
**Client:**
|
|
1055
|
+
|
|
1056
|
+
```tsx
|
|
1057
|
+
import { isToolUIPart, getToolName } from "ai";
|
|
1058
|
+
|
|
1059
|
+
const { messages, addToolApprovalResponse } = useAgentChat({ agent });
|
|
1060
|
+
|
|
1061
|
+
// Render pending approvals from message parts
|
|
1062
|
+
{
|
|
1063
|
+
messages.map((msg) =>
|
|
1064
|
+
msg.parts
|
|
1065
|
+
.filter(
|
|
1066
|
+
(part) =>
|
|
1067
|
+
isToolUIPart(part) &&
|
|
1068
|
+
"approval" in part &&
|
|
1069
|
+
part.state === "approval-requested"
|
|
1070
|
+
)
|
|
1071
|
+
.map((part) => (
|
|
1072
|
+
<div key={part.toolCallId}>
|
|
1073
|
+
<p>Approve {getToolName(part)}?</p>
|
|
1074
|
+
<button
|
|
1075
|
+
onClick={() =>
|
|
1076
|
+
addToolApprovalResponse({
|
|
1077
|
+
id: part.approval?.id,
|
|
1078
|
+
approved: true
|
|
1079
|
+
})
|
|
1080
|
+
}
|
|
1081
|
+
>
|
|
1082
|
+
Approve
|
|
1083
|
+
</button>
|
|
1084
|
+
<button
|
|
1085
|
+
onClick={() =>
|
|
1086
|
+
addToolApprovalResponse({
|
|
1087
|
+
id: part.approval?.id,
|
|
1088
|
+
approved: false
|
|
1089
|
+
})
|
|
1090
|
+
}
|
|
1091
|
+
>
|
|
1092
|
+
Reject
|
|
1093
|
+
</button>
|
|
1094
|
+
</div>
|
|
1095
|
+
))
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
```
|
|
1099
|
+
|
|
1100
|
+
When denied, the tool part transitions to `output-denied`. You can also use `addToolOutput` with `state: "output-error"` for custom denial messages — see [Human in the Loop](./human-in-the-loop.md) for details.
|
|
1101
|
+
|
|
1102
|
+
## Custom Request Data
|
|
1103
|
+
|
|
1104
|
+
Include custom data with every chat request using the `body` option:
|
|
1105
|
+
|
|
1106
|
+
```tsx
|
|
1107
|
+
const { messages, sendMessage } = useAgentChat({
|
|
1108
|
+
agent,
|
|
1109
|
+
body: {
|
|
1110
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
1111
|
+
userId: currentUser.id
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
For dynamic values, use a function:
|
|
1117
|
+
|
|
1118
|
+
```tsx
|
|
1119
|
+
body: () => ({
|
|
1120
|
+
token: getAuthToken(),
|
|
1121
|
+
timestamp: Date.now()
|
|
1122
|
+
});
|
|
1123
|
+
```
|
|
1124
|
+
|
|
1125
|
+
Access these fields on the server:
|
|
1126
|
+
|
|
1127
|
+
```typescript
|
|
1128
|
+
async onChatMessage(_onFinish, options) {
|
|
1129
|
+
const { timezone, userId } = options?.body ?? {};
|
|
1130
|
+
// ...
|
|
1131
|
+
}
|
|
1132
|
+
```
|
|
1133
|
+
|
|
1134
|
+
For advanced per-request customization (custom headers, different body per request), use `prepareSendMessagesRequest`:
|
|
1135
|
+
|
|
1136
|
+
```tsx
|
|
1137
|
+
const { messages, sendMessage } = useAgentChat({
|
|
1138
|
+
agent,
|
|
1139
|
+
prepareSendMessagesRequest: async ({ messages, trigger }) => ({
|
|
1140
|
+
headers: { Authorization: `Bearer ${await getToken()}` },
|
|
1141
|
+
body: { requestedAt: Date.now() }
|
|
1142
|
+
})
|
|
1143
|
+
});
|
|
1144
|
+
```
|
|
1145
|
+
|
|
1146
|
+
## Data Parts
|
|
1147
|
+
|
|
1148
|
+
Data parts let you attach typed JSON to messages alongside text — progress indicators, source citations, token usage, or any structured data your UI needs.
|
|
1149
|
+
|
|
1150
|
+
### Writing Data Parts (Server)
|
|
1151
|
+
|
|
1152
|
+
Use `createUIMessageStream` with `writer.write()` to send data parts from the server:
|
|
1153
|
+
|
|
1154
|
+
```ts
|
|
1155
|
+
import {
|
|
1156
|
+
streamText,
|
|
1157
|
+
convertToModelMessages,
|
|
1158
|
+
createUIMessageStream,
|
|
1159
|
+
createUIMessageStreamResponse
|
|
1160
|
+
} from "ai";
|
|
1161
|
+
|
|
1162
|
+
export class ChatAgent extends AIChatAgent {
|
|
1163
|
+
async onChatMessage() {
|
|
1164
|
+
const workersai = createWorkersAI({ binding: this.env.AI });
|
|
1165
|
+
|
|
1166
|
+
const stream = createUIMessageStream({
|
|
1167
|
+
execute: async ({ writer }) => {
|
|
1168
|
+
const result = streamText({
|
|
1169
|
+
model: workersai("@cf/moonshotai/kimi-k2.7-code"),
|
|
1170
|
+
messages: await convertToModelMessages(this.messages)
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
// Merge the LLM stream
|
|
1174
|
+
writer.merge(result.toUIMessageStream());
|
|
1175
|
+
|
|
1176
|
+
// Write a data part — persisted to message.parts
|
|
1177
|
+
writer.write({
|
|
1178
|
+
type: "data-sources",
|
|
1179
|
+
id: "src-1",
|
|
1180
|
+
data: { query: "agents", status: "searching", results: [] }
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
// Later: update the same part in-place (same type + id)
|
|
1184
|
+
writer.write({
|
|
1185
|
+
type: "data-sources",
|
|
1186
|
+
id: "src-1",
|
|
1187
|
+
data: {
|
|
1188
|
+
query: "agents",
|
|
1189
|
+
status: "found",
|
|
1190
|
+
results: ["Agents SDK docs", "Durable Objects guide"]
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
return createUIMessageStreamResponse({ stream });
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
```
|
|
1200
|
+
|
|
1201
|
+
### Three Patterns
|
|
1202
|
+
|
|
1203
|
+
| Pattern | How | Persisted? | Use case |
|
|
1204
|
+
| ------------------ | ------------------------------------------------ | ---------- | ------------------------------------- |
|
|
1205
|
+
| **Reconciliation** | Same `type` + `id` → updates in-place | Yes | Progressive state (searching → found) |
|
|
1206
|
+
| **Append** | No `id`, or different `id` → appends | Yes | Log entries, multiple citations |
|
|
1207
|
+
| **Transient** | `transient: true` → not added to `message.parts` | No | Ephemeral status (thinking indicator) |
|
|
1208
|
+
|
|
1209
|
+
Transient parts are broadcast to connected clients in real time but excluded from SQLite persistence and `message.parts`. Use the `onData` callback to consume them.
|
|
1210
|
+
|
|
1211
|
+
### Reading Data Parts (Client)
|
|
1212
|
+
|
|
1213
|
+
Non-transient data parts appear in `message.parts`. Use the `UIMessage` generic to type them:
|
|
1214
|
+
|
|
1215
|
+
```ts
|
|
1216
|
+
import { useAgentChat } from "@cloudflare/ai-chat/react";
|
|
1217
|
+
import type { UIMessage } from "ai";
|
|
1218
|
+
|
|
1219
|
+
type ChatMessage = UIMessage<
|
|
1220
|
+
unknown,
|
|
1221
|
+
{
|
|
1222
|
+
sources: { query: string; status: string; results: string[] };
|
|
1223
|
+
usage: { model: string; inputTokens: number; outputTokens: number };
|
|
1224
|
+
}
|
|
1225
|
+
>;
|
|
1226
|
+
|
|
1227
|
+
const { messages } = useAgentChat<unknown, ChatMessage>({ agent });
|
|
1228
|
+
|
|
1229
|
+
// Typed access — no casts needed
|
|
1230
|
+
for (const msg of messages) {
|
|
1231
|
+
for (const part of msg.parts) {
|
|
1232
|
+
if (part.type === "data-sources") {
|
|
1233
|
+
console.log(part.data.results); // string[]
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
```
|
|
1238
|
+
|
|
1239
|
+
### Transient Parts with `onData`
|
|
1240
|
+
|
|
1241
|
+
Transient data parts are not in `message.parts`. Use the `onData` callback instead:
|
|
1242
|
+
|
|
1243
|
+
```ts
|
|
1244
|
+
const [thinking, setThinking] = useState(false);
|
|
1245
|
+
|
|
1246
|
+
const { messages } = useAgentChat<unknown, ChatMessage>({
|
|
1247
|
+
agent,
|
|
1248
|
+
onData(part) {
|
|
1249
|
+
if (part.type === "data-thinking") {
|
|
1250
|
+
setThinking(true);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
});
|
|
1254
|
+
```
|
|
1255
|
+
|
|
1256
|
+
On the server, write transient parts with `transient: true`:
|
|
1257
|
+
|
|
1258
|
+
```ts
|
|
1259
|
+
writer.write({
|
|
1260
|
+
transient: true,
|
|
1261
|
+
type: "data-thinking",
|
|
1262
|
+
data: { model: "glm-4.7-flash", startedAt: new Date().toISOString() }
|
|
1263
|
+
});
|
|
1264
|
+
```
|
|
1265
|
+
|
|
1266
|
+
`onData` fires on all code paths — new messages, stream resumption, and cross-tab broadcasts.
|
|
1267
|
+
|
|
1268
|
+
## Resumable Streaming
|
|
1269
|
+
|
|
1270
|
+
Streams automatically resume when a client disconnects and reconnects. No configuration is needed — it works out of the box.
|
|
1271
|
+
|
|
1272
|
+
When streaming is active:
|
|
1273
|
+
|
|
1274
|
+
1. All chunks are buffered in SQLite as they are generated
|
|
1275
|
+
2. If the client disconnects, the server continues streaming and buffering
|
|
1276
|
+
3. When the client reconnects, it receives all buffered chunks and resumes live streaming
|
|
1277
|
+
|
|
1278
|
+
Generic client stream abort/cleanup stays local to the browser by default, so the server turn keeps running and can be resumed later. An explicit `stop()` still sends server cancellation:
|
|
1279
|
+
|
|
1280
|
+
```tsx
|
|
1281
|
+
const { messages, stop } = useAgentChat({ agent });
|
|
1282
|
+
```
|
|
1283
|
+
|
|
1284
|
+
If your app intentionally wants client lifecycle to own server lifecycle, set `cancelOnClientAbort: true`.
|
|
1285
|
+
This is useful for request-lifetime or token-saving flows; explicit `stop()` is
|
|
1286
|
+
always server-side cancellation regardless of this option.
|
|
1287
|
+
|
|
1288
|
+
Disable resume with `resume: false`:
|
|
1289
|
+
|
|
1290
|
+
```tsx
|
|
1291
|
+
const { messages } = useAgentChat({ agent, resume: false });
|
|
1292
|
+
```
|
|
1293
|
+
|
|
1294
|
+
For more details, see [Resumable Streaming](./resumable-streaming.md).
|
|
1295
|
+
|
|
1296
|
+
## Storage Management
|
|
1297
|
+
|
|
1298
|
+
### Row Size Protection
|
|
1299
|
+
|
|
1300
|
+
SQLite rows have a maximum size of 2 MB. When a message approaches this limit (for example, a tool returning a very large output), `AIChatAgent` automatically compacts the message:
|
|
1301
|
+
|
|
1302
|
+
1. **Tool output compaction** — Large tool outputs are replaced with an LLM-friendly summary that instructs the model to suggest re-running the tool
|
|
1303
|
+
2. **Text truncation** — If the message is still too large after tool compaction, text parts are truncated with a note
|
|
1304
|
+
|
|
1305
|
+
Compacted messages include `metadata.compactedToolOutputs` so clients can detect and display this gracefully.
|
|
1306
|
+
|
|
1307
|
+
### `sanitizeMessageForPersistence`
|
|
1308
|
+
|
|
1309
|
+
Override this method to transform messages before they are written to SQLite. It runs after the built-in sanitization (OpenAI metadata stripping, Anthropic provider-executed tool payload truncation, empty reasoning part removal), so built-in cleanup is never bypassed.
|
|
1310
|
+
|
|
1311
|
+
The default implementation returns the message unchanged.
|
|
1312
|
+
|
|
1313
|
+
```typescript
|
|
1314
|
+
import type { UIMessage } from "ai";
|
|
1315
|
+
|
|
1316
|
+
export class ChatAgent extends AIChatAgent {
|
|
1317
|
+
protected sanitizeMessageForPersistence(message: UIMessage): UIMessage {
|
|
1318
|
+
return {
|
|
1319
|
+
...message,
|
|
1320
|
+
parts: message.parts.map((part) => {
|
|
1321
|
+
// Strip large tool outputs you do not need to persist
|
|
1322
|
+
if (
|
|
1323
|
+
"output" in part &&
|
|
1324
|
+
typeof part.output === "string" &&
|
|
1325
|
+
part.output.length > 2000
|
|
1326
|
+
) {
|
|
1327
|
+
return { ...part, output: "[redacted — too large to store]" };
|
|
1328
|
+
}
|
|
1329
|
+
return part;
|
|
1330
|
+
})
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
```
|
|
1335
|
+
|
|
1336
|
+
Built-in sanitization already handles:
|
|
1337
|
+
|
|
1338
|
+
- **OpenAI** — strips ephemeral `itemId` and `reasoningEncryptedContent` from `providerMetadata`
|
|
1339
|
+
- **Anthropic** — truncates large strings in `input`/`output` of provider-executed tool parts (code execution, text editor)
|
|
1340
|
+
- **Reasoning** — removes empty reasoning parts left after metadata stripping
|
|
1341
|
+
|
|
1342
|
+
Use `sanitizeMessageForPersistence` for anything the built-in logic does not cover, such as redacting sensitive fields or trimming domain-specific tool outputs.
|
|
1343
|
+
|
|
1344
|
+
### Controlling LLM Context vs Storage
|
|
1345
|
+
|
|
1346
|
+
Storage (`maxPersistedMessages`) and LLM context are independent:
|
|
1347
|
+
|
|
1348
|
+
| Concern | Control | Scope |
|
|
1349
|
+
| ------------------------------- | --------------------------------- | ----------- |
|
|
1350
|
+
| How many messages SQLite stores | `maxPersistedMessages` | Persistence |
|
|
1351
|
+
| What the model sees | `pruneMessages()` | LLM context |
|
|
1352
|
+
| Custom pre-persist transforms | `sanitizeMessageForPersistence()` | Per-message |
|
|
1353
|
+
| Row size limits | Automatic compaction | Per-message |
|
|
1354
|
+
|
|
1355
|
+
```typescript
|
|
1356
|
+
export class ChatAgent extends AIChatAgent {
|
|
1357
|
+
maxPersistedMessages = 200; // Storage limit
|
|
1358
|
+
|
|
1359
|
+
async onChatMessage() {
|
|
1360
|
+
const result = streamText({
|
|
1361
|
+
model: workersai("@cf/moonshotai/kimi-k2.7-code"),
|
|
1362
|
+
messages: pruneMessages({
|
|
1363
|
+
// LLM context limit
|
|
1364
|
+
messages: await convertToModelMessages(this.messages),
|
|
1365
|
+
reasoning: "before-last-message",
|
|
1366
|
+
toolCalls: "before-last-2-messages"
|
|
1367
|
+
})
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
return result.toUIMessageStreamResponse();
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
```
|
|
1374
|
+
|
|
1375
|
+
## Using Different AI Providers
|
|
1376
|
+
|
|
1377
|
+
`AIChatAgent` works with any AI SDK-compatible provider. The server code determines which model to use — the client does not need to change.
|
|
1378
|
+
|
|
1379
|
+
### Workers AI (Cloudflare)
|
|
1380
|
+
|
|
1381
|
+
```typescript
|
|
1382
|
+
import { createWorkersAI } from "workers-ai-provider";
|
|
1383
|
+
|
|
1384
|
+
const workersai = createWorkersAI({ binding: this.env.AI });
|
|
1385
|
+
const result = streamText({
|
|
1386
|
+
model: workersai("@cf/moonshotai/kimi-k2.7-code"),
|
|
1387
|
+
messages: await convertToModelMessages(this.messages)
|
|
1388
|
+
});
|
|
1389
|
+
```
|
|
1390
|
+
|
|
1391
|
+
### OpenAI
|
|
1392
|
+
|
|
1393
|
+
```typescript
|
|
1394
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
1395
|
+
|
|
1396
|
+
const openai = createOpenAI({ apiKey: this.env.OPENAI_API_KEY });
|
|
1397
|
+
const result = streamText({
|
|
1398
|
+
model: openai.chat("gpt-4o"),
|
|
1399
|
+
messages: await convertToModelMessages(this.messages)
|
|
1400
|
+
});
|
|
1401
|
+
```
|
|
1402
|
+
|
|
1403
|
+
### Anthropic
|
|
1404
|
+
|
|
1405
|
+
```typescript
|
|
1406
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
1407
|
+
|
|
1408
|
+
const anthropic = createAnthropic({ apiKey: this.env.ANTHROPIC_API_KEY });
|
|
1409
|
+
const result = streamText({
|
|
1410
|
+
model: anthropic("claude-sonnet-4-20250514"),
|
|
1411
|
+
messages: await convertToModelMessages(this.messages)
|
|
1412
|
+
});
|
|
1413
|
+
```
|
|
1414
|
+
|
|
1415
|
+
## Advanced Patterns
|
|
1416
|
+
|
|
1417
|
+
Since `onChatMessage` gives you full control over the `streamText` call, you can use any AI SDK feature directly. The patterns below all work out of the box — no special `AIChatAgent` configuration is needed.
|
|
1418
|
+
|
|
1419
|
+
### Dynamic Model and Tool Control
|
|
1420
|
+
|
|
1421
|
+
Use [`prepareStep`](https://ai-sdk.dev/docs/agents/loop-control) to change the model, available tools, or system prompt between steps in a multi-step agent loop:
|
|
1422
|
+
|
|
1423
|
+
```typescript
|
|
1424
|
+
import { streamText, convertToModelMessages, tool, stepCountIs } from "ai";
|
|
1425
|
+
import { z } from "zod";
|
|
1426
|
+
|
|
1427
|
+
async onChatMessage() {
|
|
1428
|
+
const result = streamText({
|
|
1429
|
+
model: cheapModel, // Default model for simple steps
|
|
1430
|
+
messages: await convertToModelMessages(this.messages),
|
|
1431
|
+
tools: {
|
|
1432
|
+
search: searchTool,
|
|
1433
|
+
analyze: analyzeTool,
|
|
1434
|
+
summarize: summarizeTool
|
|
1435
|
+
},
|
|
1436
|
+
stopWhen: stepCountIs(10),
|
|
1437
|
+
prepareStep: async ({ stepNumber, messages }) => {
|
|
1438
|
+
// Phase 1: Search (steps 0-2)
|
|
1439
|
+
if (stepNumber <= 2) {
|
|
1440
|
+
return {
|
|
1441
|
+
activeTools: ["search"],
|
|
1442
|
+
toolChoice: "required" // Force tool use
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
// Phase 2: Analyze with a stronger model (steps 3-5)
|
|
1447
|
+
if (stepNumber <= 5) {
|
|
1448
|
+
return {
|
|
1449
|
+
model: expensiveModel,
|
|
1450
|
+
activeTools: ["analyze"]
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// Phase 3: Summarize
|
|
1455
|
+
return { activeTools: ["summarize"] };
|
|
1456
|
+
}
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
return result.toUIMessageStreamResponse();
|
|
1460
|
+
}
|
|
1461
|
+
```
|
|
1462
|
+
|
|
1463
|
+
`prepareStep` runs before each step and can return overrides for `model`, `activeTools`, `toolChoice`, `system`, and `messages`. Use it to:
|
|
1464
|
+
|
|
1465
|
+
- **Switch models** — use a cheap model for simple steps, escalate for reasoning
|
|
1466
|
+
- **Phase tools** — restrict which tools are available at each step
|
|
1467
|
+
- **Manage context** — prune or transform messages to stay within token limits
|
|
1468
|
+
- **Force tool calls** — use `toolChoice: { type: "tool", toolName: "search" }` to require a specific tool
|
|
1469
|
+
|
|
1470
|
+
### Language Model Middleware
|
|
1471
|
+
|
|
1472
|
+
Use [`wrapLanguageModel`](https://ai-sdk.dev/docs/ai-sdk-core/middleware) to add guardrails, RAG, caching, or logging without modifying your chat logic:
|
|
1473
|
+
|
|
1474
|
+
```typescript
|
|
1475
|
+
import { streamText, convertToModelMessages, wrapLanguageModel } from "ai";
|
|
1476
|
+
import type { LanguageModelV3Middleware } from "@ai-sdk/provider";
|
|
1477
|
+
|
|
1478
|
+
const guardrailMiddleware: LanguageModelV3Middleware = {
|
|
1479
|
+
wrapGenerate: async ({ doGenerate }) => {
|
|
1480
|
+
const { text, ...rest } = await doGenerate();
|
|
1481
|
+
// Filter PII or sensitive content from the response
|
|
1482
|
+
const cleaned = text?.replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[REDACTED]");
|
|
1483
|
+
return { text: cleaned, ...rest };
|
|
1484
|
+
}
|
|
1485
|
+
};
|
|
1486
|
+
|
|
1487
|
+
async onChatMessage() {
|
|
1488
|
+
const model = wrapLanguageModel({
|
|
1489
|
+
model: baseModel,
|
|
1490
|
+
middleware: [guardrailMiddleware]
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
const result = streamText({
|
|
1494
|
+
model,
|
|
1495
|
+
messages: await convertToModelMessages(this.messages)
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
return result.toUIMessageStreamResponse();
|
|
1499
|
+
}
|
|
1500
|
+
```
|
|
1501
|
+
|
|
1502
|
+
The AI SDK includes built-in middlewares:
|
|
1503
|
+
|
|
1504
|
+
- `extractReasoningMiddleware` — surface chain-of-thought from models like DeepSeek R1
|
|
1505
|
+
- `defaultSettingsMiddleware` — apply default temperature, max tokens, etc.
|
|
1506
|
+
- `simulateStreamingMiddleware` — add streaming to non-streaming models
|
|
1507
|
+
|
|
1508
|
+
Multiple middlewares compose in order: `middleware: [first, second]` applies as `first(second(model))`.
|
|
1509
|
+
|
|
1510
|
+
### Structured Output
|
|
1511
|
+
|
|
1512
|
+
Use [`generateObject`](https://ai-sdk.dev/docs/ai-sdk-core/generating-structured-data) inside tools for structured data extraction:
|
|
1513
|
+
|
|
1514
|
+
```typescript
|
|
1515
|
+
import {
|
|
1516
|
+
streamText, generateObject, convertToModelMessages, tool, stepCountIs
|
|
1517
|
+
} from "ai";
|
|
1518
|
+
import { z } from "zod";
|
|
1519
|
+
|
|
1520
|
+
async onChatMessage() {
|
|
1521
|
+
const result = streamText({
|
|
1522
|
+
model: myModel,
|
|
1523
|
+
messages: await convertToModelMessages(this.messages),
|
|
1524
|
+
tools: {
|
|
1525
|
+
extractContactInfo: tool({
|
|
1526
|
+
description: "Extract structured contact information from the conversation",
|
|
1527
|
+
inputSchema: z.object({
|
|
1528
|
+
text: z.string().describe("The text to extract contact info from")
|
|
1529
|
+
}),
|
|
1530
|
+
execute: async ({ text }) => {
|
|
1531
|
+
const { object } = await generateObject({
|
|
1532
|
+
model: myModel,
|
|
1533
|
+
schema: z.object({
|
|
1534
|
+
name: z.string(),
|
|
1535
|
+
email: z.string().email(),
|
|
1536
|
+
phone: z.string().optional()
|
|
1537
|
+
}),
|
|
1538
|
+
prompt: `Extract contact information from: ${text}`
|
|
1539
|
+
});
|
|
1540
|
+
return object;
|
|
1541
|
+
}
|
|
1542
|
+
})
|
|
1543
|
+
},
|
|
1544
|
+
stopWhen: stepCountIs(5)
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1547
|
+
return result.toUIMessageStreamResponse();
|
|
1548
|
+
}
|
|
1549
|
+
```
|
|
1550
|
+
|
|
1551
|
+
### Subagent Delegation
|
|
1552
|
+
|
|
1553
|
+
Tools can delegate work to focused sub-calls with their own context. Use [`ToolLoopAgent`](https://ai-sdk.dev/docs/reference/ai-sdk-core/tool-loop-agent) to define a reusable agent, then call it from a tool's `execute`:
|
|
1554
|
+
|
|
1555
|
+
```typescript
|
|
1556
|
+
import {
|
|
1557
|
+
ToolLoopAgent, streamText, convertToModelMessages, tool, stepCountIs
|
|
1558
|
+
} from "ai";
|
|
1559
|
+
import { z } from "zod";
|
|
1560
|
+
|
|
1561
|
+
// Define a reusable research agent with its own tools and instructions
|
|
1562
|
+
const researchAgent = new ToolLoopAgent({
|
|
1563
|
+
model: researchModel,
|
|
1564
|
+
instructions: "You are a research assistant. Be thorough and cite sources.",
|
|
1565
|
+
tools: { webSearch: webSearchTool },
|
|
1566
|
+
stopWhen: stepCountIs(10)
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
async onChatMessage() {
|
|
1570
|
+
const result = streamText({
|
|
1571
|
+
model: orchestratorModel,
|
|
1572
|
+
messages: await convertToModelMessages(this.messages),
|
|
1573
|
+
tools: {
|
|
1574
|
+
deepResearch: tool({
|
|
1575
|
+
description: "Research a topic in depth",
|
|
1576
|
+
inputSchema: z.object({
|
|
1577
|
+
topic: z.string().describe("The topic to research")
|
|
1578
|
+
}),
|
|
1579
|
+
execute: async ({ topic }) => {
|
|
1580
|
+
const { text } = await researchAgent.generate({ prompt: topic });
|
|
1581
|
+
return { summary: text };
|
|
1582
|
+
}
|
|
1583
|
+
})
|
|
1584
|
+
},
|
|
1585
|
+
stopWhen: stepCountIs(5)
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
return result.toUIMessageStreamResponse();
|
|
1589
|
+
}
|
|
1590
|
+
```
|
|
1591
|
+
|
|
1592
|
+
The research agent runs in its own context — its token budget is separate from the orchestrator's. Only the summary goes back to the parent model.
|
|
1593
|
+
|
|
1594
|
+
`ToolLoopAgent` is best suited for subagents, not as a replacement for `streamText` in `onChatMessage` itself. The main `onChatMessage` benefits from direct access to `this.env`, `this.messages`, and `options.body` — things that a pre-configured `ToolLoopAgent` instance cannot reference.
|
|
1595
|
+
|
|
1596
|
+
#### Streaming progress with preliminary results
|
|
1597
|
+
|
|
1598
|
+
By default, a tool part appears as loading until `execute` returns. Use an async generator (`async function*`) to stream progress updates to the client while the tool is still working:
|
|
1599
|
+
|
|
1600
|
+
```typescript
|
|
1601
|
+
deepResearch: tool({
|
|
1602
|
+
description: "Research a topic in depth",
|
|
1603
|
+
inputSchema: z.object({
|
|
1604
|
+
topic: z.string().describe("The topic to research")
|
|
1605
|
+
}),
|
|
1606
|
+
async *execute({ topic }) {
|
|
1607
|
+
// Preliminary result — the client sees "searching" immediately
|
|
1608
|
+
yield { status: "searching", topic, summary: undefined };
|
|
1609
|
+
|
|
1610
|
+
const { text } = await researchAgent.generate({ prompt: topic });
|
|
1611
|
+
|
|
1612
|
+
// Final result — sent to the model for its next step
|
|
1613
|
+
yield { status: "done", topic, summary: text };
|
|
1614
|
+
}
|
|
1615
|
+
});
|
|
1616
|
+
```
|
|
1617
|
+
|
|
1618
|
+
Each `yield` updates the tool part on the client in real-time (with `preliminary: true`). The last yielded value becomes the final output that the model sees. This is useful for long-running tools where you want to show status (searching, analyzing, summarizing) as the work progresses.
|
|
1619
|
+
|
|
1620
|
+
This pattern is useful when:
|
|
1621
|
+
|
|
1622
|
+
- A task requires exploring large amounts of information that would bloat the main context
|
|
1623
|
+
- You want to show real-time progress for long-running tools
|
|
1624
|
+
- You want to parallelize independent research (multiple tool calls run concurrently)
|
|
1625
|
+
- You need different models or system prompts for different subtasks
|
|
1626
|
+
|
|
1627
|
+
For more, see the [AI SDK Agents docs](https://ai-sdk.dev/docs/agents/overview), [Subagents](https://ai-sdk.dev/docs/agents/subagents), and [Preliminary Tool Results](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling#preliminary-tool-results).
|
|
1628
|
+
|
|
1629
|
+
## Multi-Client Sync
|
|
1630
|
+
|
|
1631
|
+
When multiple clients connect to the same agent instance, messages are automatically broadcast to all connections. If one client sends a message, all other connected clients receive the updated message list.
|
|
1632
|
+
|
|
1633
|
+
```
|
|
1634
|
+
Client A ──── sendMessage("Hello") ────▶ AIChatAgent
|
|
1635
|
+
│
|
|
1636
|
+
persist + stream
|
|
1637
|
+
│
|
|
1638
|
+
Client A ◀── CF_AGENT_USE_CHAT_RESPONSE ──────┤
|
|
1639
|
+
Client B ◀── CF_AGENT_CHAT_MESSAGES ──────────┘
|
|
1640
|
+
```
|
|
1641
|
+
|
|
1642
|
+
The originating client receives the streaming response. All other clients receive the final messages via a `CF_AGENT_CHAT_MESSAGES` broadcast.
|
|
1643
|
+
|
|
1644
|
+
## API Reference
|
|
1645
|
+
|
|
1646
|
+
### Exports
|
|
1647
|
+
|
|
1648
|
+
| Import path | Exports |
|
|
1649
|
+
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1650
|
+
| `@cloudflare/ai-chat` | `AIChatAgent`, `createToolsFromClientSchemas`, `ChatRecoveryContext`, `ChatRecoveryOptions`, `ChatRecoveryConfig`, `ChatRecoveryExhaustedContext`, `ResolvedChatRecoveryConfig` |
|
|
1651
|
+
| `@cloudflare/ai-chat/react` | `useAgentChat` |
|
|
1652
|
+
| `@cloudflare/ai-chat/types` | `MessageType`, `OutgoingMessage`, `IncomingMessage` |
|
|
1653
|
+
|
|
1654
|
+
### WebSocket Protocol
|
|
1655
|
+
|
|
1656
|
+
The chat protocol uses typed JSON messages over WebSocket:
|
|
1657
|
+
|
|
1658
|
+
| Message | Direction | Purpose |
|
|
1659
|
+
| -------------------------------- | --------------- | --------------------------- |
|
|
1660
|
+
| `CF_AGENT_USE_CHAT_REQUEST` | Client → Server | Send a chat message |
|
|
1661
|
+
| `CF_AGENT_USE_CHAT_RESPONSE` | Server → Client | Stream response chunks |
|
|
1662
|
+
| `CF_AGENT_CHAT_MESSAGES` | Server → Client | Broadcast updated messages |
|
|
1663
|
+
| `CF_AGENT_CHAT_CLEAR` | Bidirectional | Clear conversation |
|
|
1664
|
+
| `CF_AGENT_CHAT_REQUEST_CANCEL` | Client → Server | Cancel active stream |
|
|
1665
|
+
| `CF_AGENT_TOOL_RESULT` | Client → Server | Provide tool output |
|
|
1666
|
+
| `CF_AGENT_TOOL_APPROVAL` | Client → Server | Approve or reject a tool |
|
|
1667
|
+
| `CF_AGENT_MESSAGE_UPDATED` | Server → Client | Notify of message update |
|
|
1668
|
+
| `CF_AGENT_STREAM_RESUMING` | Server → Client | Notify of stream resumption |
|
|
1669
|
+
| `CF_AGENT_STREAM_RESUME_REQUEST` | Client → Server | Request stream resume check |
|
|
1670
|
+
|
|
1671
|
+
## Examples
|
|
1672
|
+
|
|
1673
|
+
- [AI Chat Example](https://github.com/cloudflare/agents/tree/main/examples/ai-chat) — Modern example with server tools, client tools, and approval
|
|
1674
|
+
- [Dynamic Tools](https://github.com/cloudflare/agents/tree/main/examples/dynamic-tools) — SDK/platform pattern with dynamic client-defined tools
|
|
1675
|
+
- [Resumable Stream Chat](https://github.com/cloudflare/agents/tree/main/examples/resumable-stream-chat) — Automatic stream resumption demo
|
|
1676
|
+
- [Human in the Loop Guide](https://github.com/cloudflare/agents/tree/main/guides/human-in-the-loop) — Tool approval with `needsApproval` and `onToolCall`
|
|
1677
|
+
- [Playground](https://github.com/cloudflare/agents/tree/main/examples/playground) — Kitchen-sink demo of all SDK features
|
|
1678
|
+
|
|
1679
|
+
## Related Docs
|
|
1680
|
+
|
|
1681
|
+
- [Client SDK](./client-sdk.md) — `useAgent` hook and `AgentClient` class
|
|
1682
|
+
- [Durable Execution](./durable-execution.md) — `runFiber()`, `stash()`, and crash recovery
|
|
1683
|
+
- [Long-Running Agents](./long-running-agents.md) — lifecycle, recovery patterns, and provider-specific strategies
|
|
1684
|
+
- [Human in the Loop](./human-in-the-loop.md) — Approval flows and manual intervention patterns
|
|
1685
|
+
- [Resumable Streaming](./resumable-streaming.md) — How stream resumption works
|
|
1686
|
+
- [Client Tools Continuation](./client-tools-continuation.md) — Advanced client-side tool patterns
|
|
1687
|
+
- [Migration to AI SDK v6](./migration-to-ai-sdk-v6.md) — Upgrading from AI SDK v5
|