workflow 5.0.0-beta.6 → 5.0.0-beta.8
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/internal/builtins.d.ts +17 -0
- package/dist/internal/builtins.d.ts.map +1 -1
- package/dist/internal/builtins.js +65 -1
- package/dist/observability.d.ts +1 -1
- package/dist/observability.js +2 -2
- package/docs/api-reference/workflow-next/with-workflow.mdx +2 -2
- package/docs/changelog/attributes-mvp.mdx +365 -0
- package/docs/cookbook/advanced/child-workflows.mdx +196 -244
- package/docs/cookbook/advanced/meta.json +6 -1
- package/docs/cookbook/advanced/upgrading-workflows.mdx +195 -0
- package/docs/cookbook/common-patterns/workflow-composition.mdx +4 -4
- package/docs/cookbook/index.mdx +1 -0
- package/docs/cookbook/integrations/ai-sdk.mdx +44 -25
- package/package.json +13 -13
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Upgrading Workflows
|
|
3
|
+
description: Identify a clean upgrade point in a long-running workflow and spawn a fresh run on the latest deployment carrying state forward.
|
|
4
|
+
type: guide
|
|
5
|
+
summary: 'Identify a clean upgrade point and hand off to a fresh run via `start(self, [state], { deploymentId: "latest" })` — either automatically on every iteration, or on demand via a dedicated upgrade hook.'
|
|
6
|
+
related:
|
|
7
|
+
- /docs/foundations/versioning
|
|
8
|
+
- /cookbook/common-patterns/workflow-composition
|
|
9
|
+
- /docs/api-reference/workflow-api/start
|
|
10
|
+
- /docs/foundations/hooks
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
Workflows that block on external events for days, weeks, or months can outlive many deployments. **The key is to identify a clean upgrade point in the workflow** — a moment where it's safe to checkpoint state and start fresh — and then call [`start()`](/docs/api-reference/workflow-api/start) with `deploymentId: "latest"` to spawn a new run carrying that state forward. The current run ends; the next run begins on whatever deployment is live at that moment, so shipped fixes apply immediately without ever migrating an in-flight run.
|
|
14
|
+
|
|
15
|
+
<Callout type="info">
|
|
16
|
+
For the underlying model — why runs pin to a deployment by default, how cancel-and-rerun works, and how state crosses the version boundary — see [Versioning](/docs/foundations/versioning). This recipe focuses on event-driven workflows that need to keep advancing across deployments.
|
|
17
|
+
</Callout>
|
|
18
|
+
|
|
19
|
+
A clean upgrade point is any spot in the workflow where:
|
|
20
|
+
|
|
21
|
+
- All in-progress side effects have completed (or aren't needed by the next iteration)
|
|
22
|
+
- The relevant state can be serialized into the workflow's input arguments
|
|
23
|
+
- It's natural for the workflow to "checkpoint" — typically right after handling an external event, completing a batch, or finishing a logical phase
|
|
24
|
+
|
|
25
|
+
There are two ways to apply this:
|
|
26
|
+
|
|
27
|
+
1. **Upgrade on every iteration** ([Method 1](#method-1-upgrade-on-every-iteration)). Each run handles a single event and unconditionally hands off to a fresh run on the latest deployment before exiting. Simple — no extra triggers — but every event pays the respawn cost.
|
|
28
|
+
2. **Upgrade on demand via a dedicated hook** ([Method 2](#method-2-upgrade-on-demand-via-a-dedicated-hook)). A single long-lived run handles many events in a loop and only respawns when an `upgradeHook` fires. A separate endpoint resumes that hook from your control plane (e.g. after a deploy). More control and fewer respawns, at the cost of an explicit trigger.
|
|
29
|
+
|
|
30
|
+
### When to use each
|
|
31
|
+
|
|
32
|
+
- **Method 1** when iterations are short and frequent, the work is cheap to checkpoint, and you want shipped fixes to apply on the very next event. Long-lived "session" workflows (subscriptions, queues, FSMs) that already process events one at a time fit this naturally.
|
|
33
|
+
- **Method 2** when iterations are infrequent or expensive (you don't want to respawn on every event), or when you need to roll out a fix to a fleet of in-flight runs after a deploy by fanning out to a control-plane endpoint. Also fits when "upgrade" should be an explicit operation rather than a side effect of handling each event.
|
|
34
|
+
|
|
35
|
+
## Method 1: Upgrade on every iteration
|
|
36
|
+
|
|
37
|
+
Each run inherits state via its argument, blocks on a hook, processes the resume, then unconditionally hands off to its successor by calling `start()` directly from the workflow body with `deploymentId: "latest"`.
|
|
38
|
+
|
|
39
|
+
```typescript lineNumbers
|
|
40
|
+
import { defineHook, getWorkflowMetadata } from "workflow";
|
|
41
|
+
import { start } from "workflow/api";
|
|
42
|
+
|
|
43
|
+
declare function processItem(itemId: string): Promise<void>; // @setup
|
|
44
|
+
|
|
45
|
+
interface QueueState {
|
|
46
|
+
processed: number;
|
|
47
|
+
cursor: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const nextItemHook = defineHook<{ itemId: string }>();
|
|
51
|
+
|
|
52
|
+
export async function longRunningQueue(
|
|
53
|
+
state: QueueState = { processed: 0, cursor: null },
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
"use workflow";
|
|
56
|
+
|
|
57
|
+
const { workflowRunId } = getWorkflowMetadata();
|
|
58
|
+
|
|
59
|
+
// Block until something fires the hook — could be hours, days, or longer.
|
|
60
|
+
// Per-run hook tokens (workflowRunId) keep concurrent chains isolated.
|
|
61
|
+
const { itemId } = await nextItemHook.create({ token: workflowRunId }); // [!code highlight]
|
|
62
|
+
|
|
63
|
+
await processItem(itemId);
|
|
64
|
+
|
|
65
|
+
// Hand off to a fresh run on the latest deployment. THIS run ends here.
|
|
66
|
+
// `deploymentId: "latest"` resolves to whichever deployment is current
|
|
67
|
+
// when this spawn lands — NOT the deployment running this code.
|
|
68
|
+
await start( // [!code highlight]
|
|
69
|
+
longRunningQueue, // [!code highlight]
|
|
70
|
+
[{ processed: state.processed + 1, cursor: itemId }], // [!code highlight]
|
|
71
|
+
{ deploymentId: "latest" }, // [!code highlight]
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Resuming the hook
|
|
77
|
+
|
|
78
|
+
Any server-side code can resume the currently-active iteration by calling `.resume()` with the run ID:
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
import { nextItemHook } from "@/workflows/long-running-queue";
|
|
82
|
+
|
|
83
|
+
export async function POST(req: Request) {
|
|
84
|
+
const { runId, itemId } = await req.json();
|
|
85
|
+
|
|
86
|
+
await nextItemHook.resume(runId, { itemId }); // [!code highlight]
|
|
87
|
+
|
|
88
|
+
return Response.json({ success: true });
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The caller tracks the active `runId` (e.g. in a database, KV, or returned from the previous iteration) and updates it whenever the chain advances.
|
|
93
|
+
|
|
94
|
+
## Method 2: Upgrade on demand via a dedicated hook
|
|
95
|
+
|
|
96
|
+
Use a single long-running workflow that handles events in a loop. Define a second hook — `upgradeHook` — alongside the work hook, and race them. While only the work hook fires, the run keeps handling events on its current deployment. When `upgradeHook` resumes, the workflow captures current state and respawns on the latest deployment, then exits.
|
|
97
|
+
|
|
98
|
+
```typescript lineNumbers
|
|
99
|
+
import { defineHook, getWorkflowMetadata } from "workflow";
|
|
100
|
+
import { start } from "workflow/api";
|
|
101
|
+
|
|
102
|
+
declare function processItem(itemId: string): Promise<void>; // @setup
|
|
103
|
+
|
|
104
|
+
interface QueueState {
|
|
105
|
+
processed: number;
|
|
106
|
+
cursor: string | null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const nextItemHook = defineHook<{ itemId: string }>();
|
|
110
|
+
export const upgradeHook = defineHook<{ reason?: string }>(); // [!code highlight]
|
|
111
|
+
|
|
112
|
+
export async function longRunningQueue(
|
|
113
|
+
state: QueueState = { processed: 0, cursor: null },
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
"use workflow";
|
|
116
|
+
|
|
117
|
+
const { workflowRunId } = getWorkflowMetadata();
|
|
118
|
+
|
|
119
|
+
while (true) {
|
|
120
|
+
// Race a normal work event against the upgrade signal.
|
|
121
|
+
const event = await Promise.race([ // [!code highlight]
|
|
122
|
+
nextItemHook
|
|
123
|
+
.create({ token: workflowRunId })
|
|
124
|
+
.then((payload) => ({ kind: "work" as const, payload })),
|
|
125
|
+
upgradeHook // [!code highlight]
|
|
126
|
+
.create({ token: workflowRunId }) // [!code highlight]
|
|
127
|
+
.then(() => ({ kind: "upgrade" as const })), // [!code highlight]
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
if (event.kind === "upgrade") { // [!code highlight]
|
|
131
|
+
// Checkpoint current state and hand off to a fresh run
|
|
132
|
+
// on whatever deployment is live now. THIS run ends here.
|
|
133
|
+
await start(longRunningQueue, [state], { // [!code highlight]
|
|
134
|
+
deploymentId: "latest", // [!code highlight]
|
|
135
|
+
}); // [!code highlight]
|
|
136
|
+
return; // [!code highlight]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await processItem(event.payload.itemId);
|
|
140
|
+
state = {
|
|
141
|
+
processed: state.processed + 1,
|
|
142
|
+
cursor: event.payload.itemId,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Triggering the upgrade
|
|
149
|
+
|
|
150
|
+
Expose a separate endpoint that resumes `upgradeHook` for a given run. Call it from your deploy pipeline, an admin UI, or a fan-out script that iterates over every active run after shipping a fix.
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
import { upgradeHook } from "@/workflows/long-running-queue";
|
|
154
|
+
|
|
155
|
+
export async function POST(req: Request) {
|
|
156
|
+
const { runId, reason } = await req.json();
|
|
157
|
+
|
|
158
|
+
// The workflow exits its loop, captures state, and respawns
|
|
159
|
+
// on the latest deployment.
|
|
160
|
+
await upgradeHook.resume(runId, { reason }); // [!code highlight]
|
|
161
|
+
|
|
162
|
+
return Response.json({ success: true });
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
To upgrade a fleet of runs after a deploy, list active runs (e.g. from a tracking store) and call this endpoint for each.
|
|
167
|
+
|
|
168
|
+
## How it works
|
|
169
|
+
|
|
170
|
+
1. **`deploymentId: "latest"` is the upgrade knob.** Without it, the spawn pins to the current deployment. With it, the new run resolves to whatever deployment is current when the runtime picks it up — so any shipped fix applies starting from that respawn. Both methods rely on this.
|
|
171
|
+
2. **`start()` runs directly from the workflow body.** In v5, [`start()`](/docs/api-reference/workflow-api/start) is step-backed, so it can be called from a workflow function and still records a deterministic step boundary in the event log — no manual `"use step"` wrapper is required.
|
|
172
|
+
3. **State carries through the function argument.** The accumulating context flows from run N to run N+1 as a serialized argument. No external store is required for the state itself.
|
|
173
|
+
4. **Per-run hook tokens.** Using `workflowRunId` as the hook token scopes each iteration's wait to its own run, so multiple chains can run concurrently without interfering.
|
|
174
|
+
5. **Method 1 vs Method 2 is just where the spawn happens.** In Method 1 every run spawns its successor unconditionally before exiting — there is no long-lived process to migrate. In Method 2 the spawn happens only when the upgrade hook fires; otherwise the loop keeps handling events on the same run.
|
|
175
|
+
|
|
176
|
+
## Adapting to your use case
|
|
177
|
+
|
|
178
|
+
- **Combine with a sleep.** Race the hook against `sleep()` so iterations also tick on a timer: `Promise.race([hook, sleep("1d")])` lets the workflow advance even if no external event arrives.
|
|
179
|
+
- **Stateless successors.** If the next iteration doesn't need the previous state (e.g. a pure event router), call `start(longRunningQueue, [], { deploymentId: "latest" })` and skip the argument plumbing.
|
|
180
|
+
- **Persist state externally.** If state needs to be readable from outside the workflow (dashboards, debugging, recovery), write it to a database in a step before spawning the next run.
|
|
181
|
+
- **Track the active runId externally.** Whatever resumes the hook needs to know the current run. Capture the `runId` returned by `start()` and write it to a KV/database keyed by a stable session identifier (in a step) so resumers always look up the latest one.
|
|
182
|
+
|
|
183
|
+
## Caveats
|
|
184
|
+
|
|
185
|
+
- **Backward compatibility matters.** Because the next run executes on a different deployment, the workflow's input arguments and return type must remain compatible across deployments. Adding required fields, removing fields, or changing types can cause serialization failures. See the [`deploymentId: "latest"` callout](/docs/api-reference/workflow-api/start#using-deploymentid-latest).
|
|
186
|
+
- **Workflow identity is the function name + file path.** Renaming the function or moving the file across a deployment changes the workflow ID — the next iteration will fail to resolve. Treat the workflow's name and location as stable interfaces.
|
|
187
|
+
- **There is a tiny gap between iterations.** The current run ends as soon as `start()` returns; the next run starts asynchronously. A resume that arrives in that window can fail with "hook not found." Make resumers retry, or have the API persist pending payloads and apply them once the next iteration is ready.
|
|
188
|
+
- **Method 2: track active runs externally.** Because Method 2's runs are long-lived, the set of in-flight runs only changes when one starts, completes, or upgrades. Persist run IDs (and clean them up on completion or upgrade) so a rollout script can fan out reliably. After resuming `upgradeHook`, also update the tracked run ID once the new run reports back, the same way you would in Method 1.
|
|
189
|
+
|
|
190
|
+
## Key APIs
|
|
191
|
+
|
|
192
|
+
- [`"use workflow"`](/docs/foundations/workflows-and-steps) — marks the orchestrator function
|
|
193
|
+
- [`start()`](/docs/api-reference/workflow-api/start) with [`deploymentId: "latest"`](/docs/api-reference/workflow-api/start#using-deploymentid-latest) — spawn the successor on the newest deployment
|
|
194
|
+
- [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspend the workflow until an external event resumes it
|
|
195
|
+
- [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata) — exposes `workflowRunId` for per-run hook tokens
|
|
@@ -9,7 +9,7 @@ related:
|
|
|
9
9
|
- /docs/api-reference/workflow-api/get-run
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
-
Workflows can call other workflows. Choose between two composition modes depending on whether the parent needs the child's result inline (direct await) or wants to fire the child off as an independent run (background spawn). For massive fan-out with
|
|
12
|
+
Workflows can call other workflows. Choose between two composition modes depending on whether the parent needs the child's result inline (direct await) or wants to fire the child off as an independent run (background spawn). For massive fan-out with hook-based waiting and partial-failure handling, see [Child Workflows](/cookbook/advanced/child-workflows).
|
|
13
13
|
|
|
14
14
|
## When to use this
|
|
15
15
|
|
|
@@ -99,9 +99,9 @@ If you want the child workflow to run on the latest deployment rather than the c
|
|
|
99
99
|
|
|
100
100
|
## Adapting to your use case
|
|
101
101
|
|
|
102
|
-
- **Spawn many children at once** — call `start()` in a loop from the workflow. For more advanced fan-out (chunking,
|
|
103
|
-
- **Wait for a background child to finish** — combine `start()` with
|
|
104
|
-
- **Pass results back from background children** —
|
|
102
|
+
- **Spawn many children at once** — call `start()` in a loop from the workflow. For more advanced fan-out (chunking, hook-based waiting, partial-failure handling), graduate to the [Child Workflows](/cookbook/advanced/child-workflows) recipe.
|
|
103
|
+
- **Wait for a background child to finish** — combine `start()` with a completion hook the child resumes when done. The [Child Workflows](/cookbook/advanced/child-workflows) page covers the recommended `startAndWait()` pattern.
|
|
104
|
+
- **Pass results back from background children** — the wrapped child resumes the parent's hook in `finally` with `{ status, value | error }`; the parent awaits the hook instead of polling `getRun().status`.
|
|
105
105
|
|
|
106
106
|
## Key APIs
|
|
107
107
|
|
package/docs/cookbook/index.mdx
CHANGED
|
@@ -33,5 +33,6 @@ A curated collection of workflow patterns with clean, copy-paste code examples f
|
|
|
33
33
|
## Advanced
|
|
34
34
|
|
|
35
35
|
- [**Child Workflows**](/cookbook/advanced/child-workflows) — Spawn and orchestrate child workflows from a parent
|
|
36
|
+
- [**Upgrading Workflows**](/cookbook/advanced/upgrading-workflows) — Identify a clean upgrade point in a long-running workflow and spawn a fresh run on the latest deployment carrying state forward
|
|
36
37
|
- [**Serializable Steps**](/cookbook/advanced/serializable-steps) — Wrap non-serializable third-party objects so they cross the workflow boundary
|
|
37
38
|
- [**Publishing Libraries**](/cookbook/advanced/publishing-libraries) — Ship npm packages that export reusable workflow functions
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: AI SDK
|
|
3
|
-
description: Use AI SDK's streamText directly inside durable workflows
|
|
3
|
+
description: Use AI SDK's streamText directly inside durable workflows when you need the raw AI SDK API or a per-turn durability boundary.
|
|
4
4
|
type: guide
|
|
5
|
-
summary: Use streamText() inside a workflow
|
|
5
|
+
summary: Use streamText() inside a workflow when the durability boundary is an entire user turn, or when you need AI SDK APIs not exposed by DurableAgent. Individual tool calls and LLM calls inside a turn are not separately durable.
|
|
6
6
|
related:
|
|
7
7
|
- /docs/ai
|
|
8
8
|
- /docs/ai/chat-session-modeling
|
|
@@ -11,22 +11,23 @@ related:
|
|
|
11
11
|
- /docs/api-reference/workflow-ai/durable-agent
|
|
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
|
|
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 the multi-turn loop durable: the conversation state, hooks, and per-turn responses survive restarts and timeouts. Note that in this pattern the durability boundary is the entire turn — individual tool calls inside a turn are **not** durable on their own (see [Pitfalls](#tools-are-not-individually-durable) below).
|
|
15
15
|
|
|
16
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.
|
|
17
17
|
|
|
18
18
|
<Callout type="info">
|
|
19
|
-
For most agent use cases, prefer [`DurableAgent`](/cookbook/agent-patterns/durable-agent) which
|
|
19
|
+
For most agent use cases, prefer [`DurableAgent`](/cookbook/agent-patterns/durable-agent), which implements the same agent loop as [`streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text), manages tool calling automatically, and runs tools at workflow scope — each tool can be marked `"use step"` for per-call durability and retries, or stay at workflow level to use primitives like `sleep()` and hooks. Use this page's raw `streamText()` pattern when you want the exact AI SDK API (for example `toUIMessageStream()`, `onChunk`, or `generateText`), or when the durability boundary should be an entire user turn in one step — accepting that tool calls inside that turn are not individually durable.
|
|
20
20
|
</Callout>
|
|
21
21
|
|
|
22
22
|
## When to use streamText directly
|
|
23
23
|
|
|
24
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
|
+
* **The raw AI SDK API** — `streamText().toUIMessageStream()`, `onChunk`, `smoothStream`, or other options that map directly to the [`streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) return value rather than `DurableAgent.stream()`
|
|
27
|
+
* **Per-turn durability** — wrap the entire agent response (model + tools) in a single `"use step"` function so one user turn is the atomic retry unit; useful when you want all tool calls inside a turn to re-execute together
|
|
28
|
+
* **Custom multi-turn orchestration** — manual hook loops, per-turn stream slicing (`sliceUntilFinish`), or other workflow patterns shown below that don't map cleanly to `DurableAgent`
|
|
29
|
+
|
|
30
|
+
`DurableAgent` already supports `stopWhen`, `prepareStep`, `onStepFinish`, structured output (`experimental_output`), per-step model switching, and [provider options](https://ai-sdk.dev/docs/ai-sdk-core/provider-options). See the [DurableAgent reference](/docs/api-reference/workflow-ai/durable-agent).
|
|
30
31
|
|
|
31
32
|
## Multi-turn pattern
|
|
32
33
|
|
|
@@ -52,14 +53,15 @@ export const turnHook = defineHook({ // [!code highlight]
|
|
|
52
53
|
schema: z.object({ message: z.string() }),
|
|
53
54
|
});
|
|
54
55
|
|
|
56
|
+
// `streamText` runs tool executes inside `runTurn` (a step), so tool calls
|
|
57
|
+
// are not individually durable — the entire turn retries together. See
|
|
58
|
+
// "Tools are not individually durable" below. Make side-effectful tools idempotent.
|
|
55
59
|
async function lookupOrder({ orderId }: { orderId: string }) {
|
|
56
|
-
"use step";
|
|
57
60
|
const res = await fetch(`https://api.store.com/orders/${orderId}`);
|
|
58
61
|
return res.json();
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
async function processRefund({ orderId, reason }: { orderId: string; reason: string }) {
|
|
62
|
-
"use step";
|
|
63
65
|
const res = await fetch("https://api.store.com/refunds", {
|
|
64
66
|
method: "POST",
|
|
65
67
|
body: JSON.stringify({ orderId, reason }),
|
|
@@ -294,16 +296,32 @@ export function SupportChat() {
|
|
|
294
296
|
## How it works
|
|
295
297
|
|
|
296
298
|
1. **One workflow = one conversation.** The workflow loops on a hook, keeping `allMessages`, tool history, and state alive across turns.
|
|
297
|
-
2.
|
|
298
|
-
3.
|
|
299
|
-
4. **`
|
|
300
|
-
5. **`
|
|
301
|
-
6.
|
|
299
|
+
2. **`runTurn` is the durability boundary.** Each turn is one step. The model request and all tool calls inside it run as plain inline functions within that step. If anything throws mid-turn, the whole `runTurn` retries — individual tool calls are not separately durable. See [Pitfalls](#tools-are-not-individually-durable).
|
|
300
|
+
3. **Hook is created once.** `turnHook.create({ token: workflowRunId })` outside the loop — calling it twice with the same token throws `HookConflictError`.
|
|
301
|
+
4. **`preventClose: true`** on `pipeTo` keeps the durable writable open so the next turn can write to it.
|
|
302
|
+
5. **`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.
|
|
303
|
+
6. **`startIndex: tailIndex + 1`** gives each follow-up response only the new chunks, avoiding replay of previous turns.
|
|
304
|
+
7. **`/done`** resumes the hook so the workflow exits cleanly, then returns a synthetic `start` + `finish` so `useChat` transitions out of "streaming".
|
|
302
305
|
|
|
303
306
|
## Pitfalls
|
|
304
307
|
|
|
305
308
|
Non-obvious correctness details worth knowing before adapting this pattern.
|
|
306
309
|
|
|
310
|
+
### Tools are not individually durable
|
|
311
|
+
|
|
312
|
+
`streamText()` is invoked from inside `runTurn` (a `"use step"` function), and the AI SDK calls each tool by directly invoking its `execute` function in that same step. Even if a tool body has its own `"use step"` directive, that directive is a [no-op when called from another step](/docs/foundations/workflows-and-steps#step-functions) — the function just runs inline.
|
|
313
|
+
|
|
314
|
+
The consequences:
|
|
315
|
+
|
|
316
|
+
- The atomic retry unit is the entire `runTurn`, not the individual tool call.
|
|
317
|
+
- If `processRefund` succeeds and then the model call (or a later tool) throws, the whole turn retries, and `processRefund` will run again.
|
|
318
|
+
- Tool calls do not appear as separate entries in the event log or observability dashboard.
|
|
319
|
+
|
|
320
|
+
**Mitigations:**
|
|
321
|
+
|
|
322
|
+
- Make side-effectful tool implementations idempotent — dedupe server-side on a stable key (e.g. `orderId`, an `Idempotency-Key` header, etc.).
|
|
323
|
+
- Or use [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent), which runs tools at workflow scope — each tool can be marked `"use step"` to become its own durable, retryable step, or stay at workflow level to use primitives like `sleep()` and hooks.
|
|
324
|
+
|
|
307
325
|
### Snapshot `tailIndex` *before* resuming the hook
|
|
308
326
|
|
|
309
327
|
{/* @skip-typecheck - fragment referencing variables from the surrounding multi-turn pattern */}
|
|
@@ -334,30 +352,31 @@ Clients can send a `runId` from a long-gone workflow (localStorage, back button,
|
|
|
334
352
|
|
|
335
353
|
## streamText vs DurableAgent
|
|
336
354
|
|
|
337
|
-
| | `streamText()` | `DurableAgent` |
|
|
355
|
+
| | `streamText()` (this pattern) | `DurableAgent` |
|
|
338
356
|
|---|---|---|
|
|
339
|
-
| **Tool loop** | AI SDK handles via `stopWhen` |
|
|
340
|
-
| **LLM call durability** | Re-executes
|
|
341
|
-
| **
|
|
342
|
-
| **
|
|
343
|
-
| **
|
|
344
|
-
| **
|
|
357
|
+
| **Tool loop** | AI SDK handles via `stopWhen` | Handles internally (AI SDK–compatible options) |
|
|
358
|
+
| **LLM call durability** | Re-executes with the parent turn | Each LLM call is a durable step |
|
|
359
|
+
| **Tool call durability** | Not individually durable — re-executes with the parent turn | Per tool — mark `"use step"` for a durable, retryable step, or keep at workflow level for `sleep()` / hooks |
|
|
360
|
+
| **Stop conditions** | `stopWhen`, `prepareStep` | `stopWhen`, `prepareStep` |
|
|
361
|
+
| **Structured output** | `Output.object()`, `Output.array()` | `experimental_output` (`Output.object()`, `Output.text()`) |
|
|
362
|
+
| **Step callbacks** | `onStepFinish`, `onChunk`, etc. | `onStepFinish`, `onFinish`, `onError`, `onAbort` (`onChunk` not available) |
|
|
363
|
+
| **Setup** | Manual stream piping and turn slicing | Automatic |
|
|
345
364
|
|
|
346
|
-
Use `DurableAgent` for most agent use cases. Use `streamText` when you need the
|
|
365
|
+
Use `DurableAgent` for most agent use cases. Use `streamText` when you need the raw AI SDK surface or a per-turn durability boundary.
|
|
347
366
|
|
|
348
367
|
## Key APIs
|
|
349
368
|
|
|
350
369
|
**AI SDK** ([docs](https://ai-sdk.dev/docs))
|
|
351
370
|
|
|
352
371
|
* [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) — core streaming function; `toUIMessageStream()` pipes into the durable writable
|
|
353
|
-
* [`tool()` / tool calling](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling) — tools
|
|
372
|
+
* [`tool()` / tool calling](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling) — tools are plain async functions invoked by `streamText` inside the turn step; they are **not** individually durable in this pattern (see [Pitfalls](#tools-are-not-individually-durable))
|
|
354
373
|
* [`stepCountIs()` / `stopWhen`](https://ai-sdk.dev/docs/ai-sdk-core/agents#stop-conditions) — bound the agent loop inside each turn
|
|
355
374
|
* [`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
|
|
356
375
|
* [`useChat()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat) — React hook that consumes the UI message stream on the client
|
|
357
376
|
|
|
358
377
|
**Workflow SDK**
|
|
359
378
|
|
|
360
|
-
* [`"use step"`](/docs/api-reference/workflow/use-step) —
|
|
379
|
+
* [`"use step"`](/docs/api-reference/workflow/use-step) — applied to `runTurn` to make each turn a durable, retryable unit
|
|
361
380
|
* [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspension point for follow-up messages
|
|
362
381
|
* [`getWritable()`](/docs/api-reference/workflow/get-writable) — resumable stream output
|
|
363
382
|
* [`getRun()`](/docs/api-reference/workflow-api/get-run) — `run.getReadable({ startIndex })` for slicing per-turn streams
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "workflow",
|
|
3
|
-
"version": "5.0.0-beta.
|
|
3
|
+
"version": "5.0.0-beta.8",
|
|
4
4
|
"description": "Workflow SDK - Build durable, resilient, and observable workflows",
|
|
5
5
|
"main": "dist/typescript-plugin.cjs",
|
|
6
6
|
"type": "module",
|
|
@@ -57,18 +57,18 @@
|
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"ms": "2.1.3",
|
|
60
|
-
"@workflow/astro": "5.0.0-beta.
|
|
61
|
-
"@workflow/
|
|
62
|
-
"@workflow/
|
|
63
|
-
"@workflow/errors": "5.0.0-beta.
|
|
64
|
-
"@workflow/typescript-plugin": "5.0.0-beta.
|
|
65
|
-
"@workflow/
|
|
66
|
-
"@workflow/
|
|
67
|
-
"@workflow/
|
|
68
|
-
"@workflow/nitro": "5.0.0-beta.
|
|
69
|
-
"@workflow/nuxt": "5.0.0-beta.
|
|
70
|
-
"@workflow/sveltekit": "5.0.0-beta.
|
|
71
|
-
"@workflow/rollup": "5.0.0-beta.
|
|
60
|
+
"@workflow/astro": "5.0.0-beta.8",
|
|
61
|
+
"@workflow/core": "5.0.0-beta.8",
|
|
62
|
+
"@workflow/cli": "5.0.0-beta.8",
|
|
63
|
+
"@workflow/errors": "5.0.0-beta.5",
|
|
64
|
+
"@workflow/typescript-plugin": "5.0.0-beta.4",
|
|
65
|
+
"@workflow/next": "5.0.0-beta.8",
|
|
66
|
+
"@workflow/nest": "5.0.0-beta.8",
|
|
67
|
+
"@workflow/utils": "5.0.0-beta.3",
|
|
68
|
+
"@workflow/nitro": "5.0.0-beta.8",
|
|
69
|
+
"@workflow/nuxt": "5.0.0-beta.8",
|
|
70
|
+
"@workflow/sveltekit": "5.0.0-beta.8",
|
|
71
|
+
"@workflow/rollup": "5.0.0-beta.8"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@types/ms": "2.1.0",
|