workflow 5.0.0-beta.2 → 5.0.0-beta.4
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/dist/api-workflow.d.ts +1 -1
- package/dist/api-workflow.d.ts.map +1 -1
- package/dist/api-workflow.js +2 -2
- package/docs/cookbook/{common-patterns → advanced}/child-workflows.mdx +1 -1
- package/docs/cookbook/advanced/distributed-abort-controller.mdx +318 -0
- package/docs/cookbook/advanced/meta.json +2 -3
- package/docs/cookbook/advanced/publishing-libraries.mdx +83 -26
- package/docs/cookbook/advanced/serializable-steps.mdx +15 -3
- package/docs/cookbook/agent-patterns/agent-cancellation.mdx +205 -0
- package/docs/cookbook/agent-patterns/durable-agent.mdx +50 -91
- package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +148 -171
- package/docs/cookbook/agent-patterns/meta.json +1 -7
- package/docs/cookbook/common-patterns/batching.mdx +44 -118
- package/docs/cookbook/common-patterns/meta.json +4 -4
- package/docs/cookbook/common-patterns/saga.mdx +126 -31
- package/docs/cookbook/common-patterns/scheduling.mdx +70 -194
- package/docs/cookbook/common-patterns/sequential-and-parallel.mdx +155 -0
- package/docs/cookbook/common-patterns/timeouts.mdx +99 -0
- package/docs/cookbook/common-patterns/workflow-composition.mdx +118 -0
- package/docs/cookbook/index.mdx +13 -16
- package/docs/cookbook/integrations/ai-sdk.mdx +296 -140
- package/docs/cookbook/integrations/chat-sdk.mdx +251 -151
- package/docs/cookbook/integrations/sandbox.mdx +469 -81
- package/docs/cookbook/meta.json +1 -1
- package/docs/foundations/index.mdx +0 -3
- package/docs/foundations/meta.json +0 -1
- package/docs/foundations/serialization.mdx +1 -1
- package/docs/foundations/starting-workflows.mdx +1 -1
- package/docs/migration-guides/migrating-from-aws-step-functions.mdx +60 -8
- package/docs/migration-guides/migrating-from-inngest.mdx +38 -6
- package/docs/migration-guides/migrating-from-temporal.mdx +38 -4
- package/docs/migration-guides/migrating-from-trigger-dev.mdx +52 -11
- package/package.json +11 -11
- package/docs/cookbook/advanced/custom-serialization.mdx +0 -168
- package/docs/cookbook/advanced/durable-objects.mdx +0 -148
- package/docs/cookbook/advanced/isomorphic-packages.mdx +0 -145
- package/docs/cookbook/agent-patterns/stop-workflow.mdx +0 -216
- package/docs/cookbook/agent-patterns/tool-orchestration.mdx +0 -255
- package/docs/cookbook/agent-patterns/tool-streaming.mdx +0 -181
- package/docs/cookbook/common-patterns/content-router.mdx +0 -207
- package/docs/cookbook/common-patterns/fan-out.mdx +0 -208
- package/docs/foundations/common-patterns.mdx +0 -265
|
@@ -1,204 +1,360 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: AI SDK
|
|
3
|
-
description: Use AI SDK
|
|
3
|
+
description: Use AI SDK's streamText directly inside durable workflows for lower-level control over model calls and tool execution.
|
|
4
4
|
type: guide
|
|
5
|
-
summary:
|
|
5
|
+
summary: Use streamText() inside a workflow for full control over model options, stop conditions, and output schemas — while tools remain durable steps.
|
|
6
6
|
related:
|
|
7
7
|
- /docs/ai
|
|
8
|
+
- /docs/ai/chat-session-modeling
|
|
8
9
|
- /docs/ai/defining-tools
|
|
9
10
|
- /docs/ai/resumable-streams
|
|
10
11
|
- /docs/api-reference/workflow-ai/durable-agent
|
|
11
12
|
---
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
[AI SDK](https://ai-sdk.dev/) is Vercel's framework-agnostic TypeScript toolkit for building AI-powered apps and agents — unified provider access, streaming, tool calling, structured output, and UI hooks. Workflow SDK complements it by making those calls durable: the model request, the tool loop, and the multi-turn conversation all survive restarts and timeouts.
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
For the full AI SDK reference (providers, `streamText`, `generateObject`, `useChat`, tool calling, etc.) see the [AI SDK docs](https://ai-sdk.dev/docs). This page covers the Workflow-specific integration points.
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
- **Resumable streaming** -- Clients reconnect mid-stream without losing data
|
|
18
|
+
<Callout type="info">
|
|
19
|
+
For most agent use cases, prefer [`DurableAgent`](/cookbook/agent-patterns/durable-agent) which wraps [`streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) and manages the tool loop automatically. This page covers using `streamText()` directly when you need lower-level control.
|
|
20
|
+
</Callout>
|
|
21
21
|
|
|
22
|
-
## When to
|
|
22
|
+
## When to use streamText directly
|
|
23
23
|
|
|
24
|
-
Use
|
|
24
|
+
Use [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) instead of `DurableAgent` when you need:
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
-
|
|
29
|
-
|
|
26
|
+
* **Custom stop conditions** — [`stopWhen`](https://ai-sdk.dev/docs/ai-sdk-core/agents#stop-conditions), [`prepareStep`](https://ai-sdk.dev/docs/ai-sdk-core/agents#prepare-step), or [`onStepFinish`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text#on-step-finish) callbacks
|
|
27
|
+
* **Structured output** — [`Output.object()`](https://ai-sdk.dev/docs/ai-sdk-core/generating-structured-data) or `Output.array()` alongside tool calling
|
|
28
|
+
* **Step-level callbacks** — `onStepFinish` for logging, metrics, or branching logic
|
|
29
|
+
* **Provider options** — per-step model switching, reasoning budgets, or custom [provider options](https://ai-sdk.dev/docs/ai-sdk-core/provider-options)
|
|
30
30
|
|
|
31
|
-
##
|
|
31
|
+
## Multi-turn pattern
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
One workflow run = one full conversation. The workflow suspends between turns on a hook and resumes when the next user message arrives. Conversation state, tool history, and intermediate computation all live inside the run.
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
import { DurableAgent } from "@workflow/ai/agent";
|
|
37
|
-
import { convertToModelMessages, type UIMessage, type UIMessageChunk } from "ai";
|
|
38
|
-
import { getWritable } from "workflow";
|
|
39
|
-
import z from "zod/v4";
|
|
35
|
+
<Tabs items={['Workflow', 'API Route', 'Client']}>
|
|
40
36
|
|
|
41
|
-
|
|
37
|
+
<Tab value="Workflow">
|
|
38
|
+
|
|
39
|
+
```typescript title="workflows/support.ts" lineNumbers
|
|
40
|
+
import { streamText, stepCountIs } from "ai";
|
|
41
|
+
import { defineHook, getWritable, getWorkflowMetadata } from "workflow";
|
|
42
|
+
import type { ModelMessage, UIMessageChunk } from "ai";
|
|
43
|
+
import { z } from "zod";
|
|
44
|
+
|
|
45
|
+
const MAX_TURNS = 20;
|
|
46
|
+
|
|
47
|
+
export const turnHook = defineHook({ // [!code highlight]
|
|
48
|
+
schema: z.object({ message: z.string() }),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
async function lookupOrder({ orderId }: { orderId: string }) {
|
|
42
52
|
"use step";
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
);
|
|
46
|
-
const data = await response.json();
|
|
47
|
-
return { results: data.items.map((item: { title: string }) => item.title) };
|
|
53
|
+
const res = await fetch(`https://api.store.com/orders/${orderId}`);
|
|
54
|
+
return res.json();
|
|
48
55
|
}
|
|
49
56
|
|
|
50
|
-
async function
|
|
57
|
+
async function processRefund({ orderId, reason }: { orderId: string; reason: string }) {
|
|
51
58
|
"use step";
|
|
52
|
-
|
|
53
|
-
const response = await fetch("https://api.example.com/summarize", {
|
|
59
|
+
const res = await fetch("https://api.store.com/refunds", {
|
|
54
60
|
method: "POST",
|
|
55
|
-
body: JSON.stringify({
|
|
61
|
+
body: JSON.stringify({ orderId, reason }),
|
|
56
62
|
});
|
|
57
|
-
|
|
58
|
-
return { summary: data.summary };
|
|
63
|
+
return res.json();
|
|
59
64
|
}
|
|
60
65
|
|
|
61
|
-
|
|
66
|
+
const TOOLS = {
|
|
67
|
+
lookupOrder: {
|
|
68
|
+
description: "Look up an order by ID",
|
|
69
|
+
inputSchema: z.object({ orderId: z.string() }),
|
|
70
|
+
execute: lookupOrder,
|
|
71
|
+
},
|
|
72
|
+
processRefund: {
|
|
73
|
+
description: "Process a refund",
|
|
74
|
+
inputSchema: z.object({ orderId: z.string(), reason: z.string() }),
|
|
75
|
+
execute: processRefund,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Per-turn step — streams one agent response to the durable writable // [!code highlight]
|
|
80
|
+
async function runTurn(messages: ModelMessage[]) {
|
|
81
|
+
"use step";
|
|
82
|
+
|
|
83
|
+
const result = streamText({
|
|
84
|
+
model: "anthropic/claude-haiku-4.5",
|
|
85
|
+
system: "You are a customer support agent.",
|
|
86
|
+
messages,
|
|
87
|
+
tools: TOOLS,
|
|
88
|
+
stopWhen: stepCountIs(8),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const writable = getWritable<UIMessageChunk>();
|
|
92
|
+
// preventClose keeps the durable writable open so the next turn can // write to it. Each turn still emits its own start + finish chunks.
|
|
93
|
+
await result.toUIMessageStream().pipeTo(writable, { preventClose: true }); // [!code highlight]
|
|
94
|
+
|
|
95
|
+
const response = await result.response;
|
|
96
|
+
return { responseMessages: response.messages };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function supportWorkflow(initialMessages: ModelMessage[]) {
|
|
62
100
|
"use workflow";
|
|
63
101
|
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
102
|
+
const { workflowRunId } = getWorkflowMetadata();
|
|
103
|
+
// Create the hook once, outside the loop — same token = HookConflictError // [!code highlight]
|
|
104
|
+
const hook = turnHook.create({ token: workflowRunId }); // [!code highlight]
|
|
105
|
+
let allMessages = initialMessages;
|
|
106
|
+
|
|
107
|
+
for (let turn = 0; turn < MAX_TURNS; turn++) {
|
|
108
|
+
const { responseMessages } = await runTurn(allMessages);
|
|
109
|
+
allMessages = [...allMessages, ...responseMessages];
|
|
110
|
+
|
|
111
|
+
const { message } = await hook; // [!code highlight] suspend until next user message
|
|
112
|
+
if (message === "/done") break;
|
|
113
|
+
|
|
114
|
+
allMessages = [...allMessages, { role: "user", content: message }];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { turns: MAX_TURNS };
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
</Tab>
|
|
122
|
+
|
|
123
|
+
<Tab value="API Route">
|
|
124
|
+
|
|
125
|
+
One endpoint handles first turn, follow-ups, and the `/done` exit. The client sends `runId` in the body to distinguish first vs follow-up.
|
|
126
|
+
|
|
127
|
+
```typescript title="app/api/support/route.ts" lineNumbers
|
|
128
|
+
import type { UIMessage, UIMessageChunk } from "ai";
|
|
129
|
+
import { convertToModelMessages, createUIMessageStreamResponse } from "ai";
|
|
130
|
+
import { start, getRun } from "workflow/api";
|
|
131
|
+
import { supportWorkflow, turnHook } from "@/workflows/support";
|
|
132
|
+
|
|
133
|
+
// Pump the durable stream until this turn's `finish` chunk, then close // the HTTP response. The source reader is released (not cancelled) so the
|
|
134
|
+
// workflow's durable stream keeps flowing for the next turn.
|
|
135
|
+
function sliceUntilFinish( // [!code highlight]
|
|
136
|
+
source: ReadableStream<UIMessageChunk>
|
|
137
|
+
): ReadableStream<UIMessageChunk> {
|
|
138
|
+
return new ReadableStream<UIMessageChunk>({
|
|
139
|
+
async start(controller) {
|
|
140
|
+
const reader = source.getReader();
|
|
141
|
+
try {
|
|
142
|
+
while (true) {
|
|
143
|
+
const { done, value } = await reader.read();
|
|
144
|
+
if (done) break;
|
|
145
|
+
controller.enqueue(value);
|
|
146
|
+
if (value.type === "finish") break; // [!code highlight]
|
|
147
|
+
}
|
|
148
|
+
controller.close();
|
|
149
|
+
} catch (e) {
|
|
150
|
+
controller.error(e);
|
|
151
|
+
} finally {
|
|
152
|
+
reader.releaseLock();
|
|
153
|
+
}
|
|
82
154
|
},
|
|
83
155
|
});
|
|
156
|
+
}
|
|
84
157
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
158
|
+
// `/done` exits the workflow without emitting chunks. Return a synthetic
|
|
159
|
+
// start+finish so useChat's lifecycle terminates cleanly.
|
|
160
|
+
function emptyTurnStream(): ReadableStream<UIMessageChunk> {
|
|
161
|
+
return new ReadableStream<UIMessageChunk>({
|
|
162
|
+
start(controller) {
|
|
163
|
+
controller.enqueue({ type: "start", messageId: crypto.randomUUID() });
|
|
164
|
+
controller.enqueue({ type: "finish" });
|
|
165
|
+
controller.close();
|
|
166
|
+
},
|
|
88
167
|
});
|
|
168
|
+
}
|
|
89
169
|
|
|
90
|
-
|
|
170
|
+
export async function POST(req: Request) {
|
|
171
|
+
const { messages, runId }: { messages: UIMessage[]; runId?: string } =
|
|
172
|
+
await req.json();
|
|
173
|
+
const modelMessages = await convertToModelMessages(messages);
|
|
174
|
+
|
|
175
|
+
// Follow-up turn: resume hook, return stream starting AFTER the last turn // [!code highlight]
|
|
176
|
+
if (runId) {
|
|
177
|
+
try {
|
|
178
|
+
const run = getRun(runId);
|
|
179
|
+
|
|
180
|
+
// Snapshot tail before resuming so our slice only contains this turn // [!code highlight]
|
|
181
|
+
const probe = run.getReadable();
|
|
182
|
+
const tailIndex = await probe.getTailIndex();
|
|
183
|
+
await probe.cancel();
|
|
184
|
+
|
|
185
|
+
const lastUser = modelMessages.filter((m) => m.role === "user").at(-1);
|
|
186
|
+
const text =
|
|
187
|
+
typeof lastUser?.content === "string"
|
|
188
|
+
? lastUser.content
|
|
189
|
+
: Array.isArray(lastUser?.content)
|
|
190
|
+
? lastUser.content
|
|
191
|
+
.filter((p): p is { type: "text"; text: string } =>
|
|
192
|
+
"type" in p && p.type === "text"
|
|
193
|
+
)
|
|
194
|
+
.map((p) => p.text)
|
|
195
|
+
.join("")
|
|
196
|
+
: "";
|
|
197
|
+
|
|
198
|
+
await turnHook.resume(runId, { message: text }); // [!code highlight]
|
|
199
|
+
|
|
200
|
+
if (text === "/done") {
|
|
201
|
+
return createUIMessageStreamResponse({
|
|
202
|
+
stream: emptyTurnStream(),
|
|
203
|
+
headers: { "x-workflow-run-id": runId },
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const stream = sliceUntilFinish(
|
|
208
|
+
run.getReadable({ startIndex: tailIndex + 1 }) // [!code highlight]
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
return createUIMessageStreamResponse({
|
|
212
|
+
stream,
|
|
213
|
+
headers: { "x-workflow-run-id": runId },
|
|
214
|
+
});
|
|
215
|
+
} catch (e: unknown) {
|
|
216
|
+
const msg = e instanceof Error ? e.message.toLowerCase() : "";
|
|
217
|
+
if (!msg.includes("not found") && !msg.includes("expired")) throw e;
|
|
218
|
+
// Stale runId — fall through to start fresh
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// First turn: start a new workflow // [!code highlight]
|
|
223
|
+
const run = await start(supportWorkflow, [modelMessages]);
|
|
224
|
+
const stream = sliceUntilFinish(run.readable);
|
|
225
|
+
|
|
226
|
+
return createUIMessageStreamResponse({
|
|
227
|
+
stream,
|
|
228
|
+
headers: { "x-workflow-run-id": run.runId },
|
|
229
|
+
});
|
|
91
230
|
}
|
|
92
231
|
```
|
|
93
232
|
|
|
94
|
-
|
|
233
|
+
</Tab>
|
|
95
234
|
|
|
96
|
-
|
|
235
|
+
<Tab value="Client">
|
|
97
236
|
|
|
98
|
-
|
|
237
|
+
Store the `runId` in a ref and pass it in the body of every follow-up. `WorkflowChatTransport` forwards it for you.
|
|
99
238
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
239
|
+
```tsx title="components/support-chat.tsx" lineNumbers
|
|
240
|
+
"use client";
|
|
241
|
+
|
|
242
|
+
import { useChat } from "@ai-sdk/react";
|
|
243
|
+
import { WorkflowChatTransport } from "@workflow/ai";
|
|
244
|
+
import { useMemo, useRef, useState } from "react";
|
|
245
|
+
|
|
246
|
+
export function SupportChat() {
|
|
247
|
+
const [input, setInput] = useState("");
|
|
248
|
+
const runIdRef = useRef<string | null>(null); // [!code highlight]
|
|
249
|
+
|
|
250
|
+
const transport = useMemo(
|
|
251
|
+
() =>
|
|
252
|
+
new WorkflowChatTransport({
|
|
253
|
+
api: "/api/support",
|
|
254
|
+
prepareSendMessagesRequest: ({ messages, body }) => ({
|
|
255
|
+
body: { ...body, messages, runId: runIdRef.current }, // [!code highlight]
|
|
256
|
+
}),
|
|
257
|
+
onChatSendMessage: (response) => {
|
|
258
|
+
const id = response.headers.get("x-workflow-run-id");
|
|
259
|
+
if (id) runIdRef.current = id; // [!code highlight]
|
|
260
|
+
},
|
|
261
|
+
}),
|
|
262
|
+
[]
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const { messages, sendMessage, status } = useChat({ transport });
|
|
266
|
+
const busy = status === "streaming" || status === "submitted";
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<form
|
|
270
|
+
onSubmit={(e) => {
|
|
271
|
+
e.preventDefault();
|
|
272
|
+
if (busy || !input.trim()) return;
|
|
273
|
+
sendMessage({ text: input });
|
|
274
|
+
setInput("");
|
|
275
|
+
}}
|
|
276
|
+
>
|
|
277
|
+
{messages.map((m) => (
|
|
278
|
+
<div key={m.id}>{m.role}: {m.parts.map((p) => p.type === "text" ? p.text : "").join("")}</div>
|
|
279
|
+
))}
|
|
280
|
+
<input value={input} onChange={(e) => setInput(e.target.value)} disabled={busy} />
|
|
281
|
+
</form>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
107
284
|
```
|
|
108
285
|
|
|
109
|
-
|
|
286
|
+
</Tab>
|
|
110
287
|
|
|
111
|
-
|
|
288
|
+
</Tabs>
|
|
112
289
|
|
|
113
|
-
|
|
114
|
-
import { DurableAgent } from "@workflow/ai/agent";
|
|
115
|
-
import { openai } from "@workflow/ai/openai";
|
|
290
|
+
## How it works
|
|
116
291
|
|
|
117
|
-
|
|
118
|
-
|
|
292
|
+
1. **One workflow = one conversation.** The workflow loops on a hook, keeping `allMessages`, tool history, and state alive across turns.
|
|
293
|
+
2. **Hook is created once.** `turnHook.create({ token: workflowRunId })` outside the loop — calling it twice with the same token throws `HookConflictError`.
|
|
294
|
+
3. **`preventClose: true`** on `pipeTo` keeps the durable writable open so the next turn can write to it.
|
|
295
|
+
4. **`sliceUntilFinish`** in the API reads chunks until `type === "finish"`, then closes the HTTP response. The source reader is released — not cancelled — so the workflow stream keeps flowing.
|
|
296
|
+
5. **`startIndex: tailIndex + 1`** gives each follow-up response only the new chunks, avoiding replay of previous turns.
|
|
297
|
+
6. **`/done`** resumes the hook so the workflow exits cleanly, then returns a synthetic `start` + `finish` so `useChat` transitions out of "streaming".
|
|
298
|
+
|
|
299
|
+
## Pitfalls
|
|
119
300
|
|
|
120
|
-
|
|
301
|
+
Non-obvious correctness details worth knowing before adapting this pattern.
|
|
121
302
|
|
|
122
|
-
|
|
303
|
+
### Snapshot `tailIndex` *before* resuming the hook
|
|
123
304
|
|
|
305
|
+
{/* @skip-typecheck - fragment referencing variables from the surrounding multi-turn pattern */}
|
|
124
306
|
```typescript
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
},
|
|
130
|
-
// ...tools and instructions
|
|
131
|
-
});
|
|
307
|
+
const tailIndex = await probe.getTailIndex(); // [!code highlight] FIRST
|
|
308
|
+
await probe.cancel();
|
|
309
|
+
await turnHook.resume(runId, { message: text }); // [!code highlight] THEN
|
|
310
|
+
const stream = run.getReadable({ startIndex: tailIndex + 1 });
|
|
132
311
|
```
|
|
133
312
|
|
|
134
|
-
|
|
313
|
+
Reversing the order races the workflow: by the time you read `tailIndex`, the next turn has already written its `start` chunk, and your `startIndex + 1` skips past it.
|
|
135
314
|
|
|
136
|
-
|
|
315
|
+
### Don't call `writable.close()` inside a workflow function
|
|
137
316
|
|
|
138
|
-
|
|
139
|
-
- **Event logging** -- Inputs and outputs are recorded for observability and replay
|
|
140
|
-
- **Idempotency** -- On replay after a crash, completed steps return their cached result
|
|
317
|
+
I/O operations like closing streams must happen inside a `"use step"` function. Calling `writable.close()` directly in the workflow body throws `Not supported in workflow functions`. When the workflow returns, the runtime closes the underlying writable for you.
|
|
141
318
|
|
|
142
|
-
|
|
143
|
-
async function bookFlight(input: {
|
|
144
|
-
origin: string;
|
|
145
|
-
destination: string;
|
|
146
|
-
date: string;
|
|
147
|
-
}): Promise<{ confirmationId: string }> {
|
|
148
|
-
"use step";
|
|
149
|
-
// This call is retried on transient failures and its result is persisted
|
|
150
|
-
const response = await fetch("https://api.airline.com/book", {
|
|
151
|
-
method: "POST",
|
|
152
|
-
headers: { "Content-Type": "application/json" },
|
|
153
|
-
body: JSON.stringify(input),
|
|
154
|
-
});
|
|
155
|
-
if (!response.ok) throw new Error(`Booking failed: ${response.status}`);
|
|
156
|
-
return response.json();
|
|
157
|
-
}
|
|
158
|
-
```
|
|
319
|
+
### Don't use `TransformStream.terminate()` to slice the stream
|
|
159
320
|
|
|
160
|
-
|
|
321
|
+
A `TransformStream` with `controller.terminate()` on the `finish` chunk seems like the obvious fit for `sliceUntilFinish`, but throws `Invalid state: TransformStream has been terminated` when late-arriving chunks hit the transform callback. Manual pumping through a custom `ReadableStream` (as shown above) sidesteps the problem entirely.
|
|
161
322
|
|
|
162
|
-
|
|
323
|
+
### Release the source reader, don't cancel it
|
|
163
324
|
|
|
164
|
-
|
|
165
|
-
import { createUIMessageStreamResponse } from "ai";
|
|
166
|
-
import { start } from "workflow/api";
|
|
167
|
-
import { researchAgent } from "@/workflows/research";
|
|
325
|
+
In `sliceUntilFinish`, use `reader.releaseLock()` in the `finally` block rather than `source.cancel()`. Cancelling propagates upstream and closes the durable writable, breaking the next turn. Releasing the lock just detaches our reader; the durable stream keeps flowing.
|
|
168
326
|
|
|
169
|
-
|
|
170
|
-
const { messages } = await request.json();
|
|
171
|
-
const run = await start(researchAgent, [messages]); // [!code highlight]
|
|
327
|
+
### Handle stale `runId` gracefully
|
|
172
328
|
|
|
173
|
-
|
|
174
|
-
stream: run.readable, // [!code highlight]
|
|
175
|
-
headers: { "x-workflow-run-id": run.runId },
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
```
|
|
329
|
+
Clients can send a `runId` from a long-gone workflow (localStorage, back button, server restart). Wrap the follow-up path in a try/catch for `not found` / `expired` and fall through to the first-turn code path to start a fresh workflow.
|
|
179
330
|
|
|
180
|
-
|
|
181
|
-
"use client";
|
|
331
|
+
## streamText vs DurableAgent
|
|
182
332
|
|
|
183
|
-
|
|
184
|
-
|
|
333
|
+
| | `streamText()` | `DurableAgent` |
|
|
334
|
+
|---|---|---|
|
|
335
|
+
| **Tool loop** | AI SDK handles via `stopWhen` | DurableAgent handles internally |
|
|
336
|
+
| **LLM call durability** | Re-executes on replay | Each LLM call is a durable step |
|
|
337
|
+
| **Stop conditions** | `stopWhen`, `prepareStep` | `prepareStep` only |
|
|
338
|
+
| **Structured output** | `Output.object()`, `Output.array()` | Not available |
|
|
339
|
+
| **Step callbacks** | `onStepFinish`, `onChunk` | Not available |
|
|
340
|
+
| **Setup** | Manual stream piping | Automatic |
|
|
185
341
|
|
|
186
|
-
|
|
187
|
-
const chat = useChat({
|
|
188
|
-
transport: new WorkflowChatTransport({ // [!code highlight]
|
|
189
|
-
api: "/api/chat",
|
|
190
|
-
}),
|
|
191
|
-
});
|
|
342
|
+
Use `DurableAgent` for most agent use cases. Use `streamText` when you need the additional control.
|
|
192
343
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
344
|
+
## Key APIs
|
|
345
|
+
|
|
346
|
+
**AI SDK** ([docs](https://ai-sdk.dev/docs))
|
|
347
|
+
|
|
348
|
+
* [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) — core streaming function; `toUIMessageStream()` pipes into the durable writable
|
|
349
|
+
* [`tool()` / tool calling](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling) — tools wrap `"use step"` functions so each tool call is replayed from the log, not re-executed
|
|
350
|
+
* [`stepCountIs()` / `stopWhen`](https://ai-sdk.dev/docs/ai-sdk-core/agents#stop-conditions) — bound the agent loop inside each turn
|
|
351
|
+
* [`convertToModelMessages()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/convert-to-model-messages) / [`createUIMessageStreamResponse()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/create-ui-message-stream-response) — UI ↔ model message conversion at the API boundary
|
|
352
|
+
* [`useChat()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat) — React hook that consumes the UI message stream on the client
|
|
353
|
+
|
|
354
|
+
**Workflow SDK**
|
|
203
355
|
|
|
204
|
-
|
|
356
|
+
* [`"use step"`](/docs/api-reference/workflow/use-step) — makes tool executions durable
|
|
357
|
+
* [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspension point for follow-up messages
|
|
358
|
+
* [`getWritable()`](/docs/api-reference/workflow/get-writable) — resumable stream output
|
|
359
|
+
* [`getRun()`](/docs/api-reference/workflow-api/get-run) — `run.getReadable({ startIndex })` for slicing per-turn streams
|
|
360
|
+
* [`WorkflowChatTransport`](/docs/api-reference/workflow-ai/workflow-chat-transport) — passes `runId` between turns
|