Skip to content

Commit 4984f70

Browse files
committed
feat(chat): hand run() a streamText that already has the managed options
Spreading chat.toStreamTextOptions() is the integration point for six things: the managed prompt and its cache control, the resolved model, the prompt's sampling config, telemetry, the skill tools, and the prepareStep that delivers steering, compaction and injected context. Forgetting the spread drops all six in silence, and spread order decides whether passing your own tools or prepareStep clobbers the managed ones. run() now receives a streamText with those options applied, so the managed state cannot be lost by omission and the merge happens inside rather than at the call site: tools go into the helper so skills survive, a caller system becomes the base the prompt and injections append to, and a caller prepareStep composes after the managed one instead of replacing it. The signature is borrowed with typeof import("ai").streamText rather than restated, so it resolves to whichever of ai v5/v6/v7 the user installed. The runtime value rides the existing ESM/CJS shim that already isolates value imports from ai. PROTOTYPE. Typechecks and passes the suite on ai@6.0.116 and ai@7.0.66, but adds a public registry option, does not settle what happens when caller and managed system are both structured, and has no test for the composed prepareStep.
1 parent 692c060 commit 4984f70

23 files changed

Lines changed: 1131 additions & 196 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@trigger.dev/sdk": minor
3+
---
4+
5+
`run()` now receives a `streamText` with your agent's managed options already applied, so they cannot be lost by leaving out the spread:
6+
7+
```ts
8+
run: async ({ messages, signal, streamText }) =>
9+
streamText({ model, messages, abortSignal: signal });
10+
```
11+
12+
Spreading `chat.toStreamTextOptions()` still works and is equivalent. The difference is what happens when your options collide with the managed ones. Passing `tools` after the spread replaces the skill tools, and passing your own `prepareStep` replaces the managed one, which silently switches off steering, compaction and injected context. The managed `streamText` merges tools and composes `prepareStep` instead, so neither can be turned off by accident.
13+
14+
`system` can be set at the call site, on `chat.agent({ system })`, or through `chat.prompt.set()`, but only in one of them: setting it in two places throws, because no single shape merges two system values across every supported AI SDK version, and dropping one silently is the failure this seam exists to prevent. Injected instructions append to whichever one is in play.
15+
16+
`chat.agent()` also takes `registry`, `cacheControl` and `systemProviderOptions` now, so a managed prompt's model and its cache breakpoint no longer have to be passed at the call site.
17+
18+
`onAction` receives the same `streamText`, so a response produced from an action, a regenerate especially, answers with the agent's own system prompt and tools. Built with the `streamText` imported from `ai` it answered with none, and the reply still looked fine, which is what made the difference easy to miss.
19+
20+
`chat.headStart` and `chat.startHeadStart` hand their `run` the same thing, carrying the four options the handover protocol depends on. There it matters more: re-setting `messages`, `stopWhen` or `abortSignal` after a spread breaks the handover rather than degrading a feature, and nothing caught it. On the managed one those keys are a type error.

docs/ai-chat/actions.mdx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export const myChat = chat.agent({
4444
// returning void → side-effect-only, no model call
4545
},
4646

47-
run: async ({ messages, signal }) => {
47+
run: async ({ messages, signal, streamText }) => {
4848
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
4949
},
5050
});
@@ -56,8 +56,10 @@ export const myChat = chat.agent({
5656

5757
`onAction` can return a `StreamTextResult`, `string`, or `UIMessage` to produce a response. The returned stream is auto-piped to the frontend just like a normal turn, but the rest of the turn machinery (`onTurnStart`, `onTurnComplete`, etc.) still does not fire.
5858

59+
Build it with the `streamText` from `onAction`'s own argument, the same one `run()` receives. It carries the agent's system prompt, skill tools, resolved model and telemetry, so a regenerated answer is produced under the same configuration as every other turn. The `streamText` imported from `ai` carries none of that, and the reply still looks fine, which is what makes the difference easy to miss.
60+
5961
```ts
60-
onAction: async ({ action, messages }) => {
62+
onAction: async ({ action, messages, streamText }) => {
6163
if (action.type === "regenerate") {
6264
chat.history.slice(0, -1); // drop the last assistant
6365
return streamText({
@@ -81,7 +83,7 @@ An action is not a turn, so `onTurnComplete` never fires, and that is where an a
8183
**Your own store** (`hydrateMessages` registered): the runtime deliberately does not write, because your store is the source of truth. A history mutation and a returned response both live only in the running worker until you persist them, and a continuation rehydrates from your store, not from what the worker had in memory. `chat.pipeAndCapture` hands you the same assistant message the runtime would have captured:
8284

8385
```ts
84-
onAction: async ({ action, messages }) => {
86+
onAction: async ({ action, messages, streamText }) => {
8587
if (action.type === "undo") {
8688
chat.history.slice(0, -2);
8789
await db.deleteLastExchange(chatId); // the rollback is yours to persist
@@ -107,7 +109,7 @@ Returning the stream instead of piping it yourself still works and still reaches
107109
If you have a [human-in-the-loop](/ai-chat/patterns/human-in-the-loop) tool waiting on `addToolOutput`, you usually want to refuse competing actions like `regenerate` until the answer arrives. [`chat.history.getPendingToolCalls()`](/ai-chat/backend#chat-history) gives you exactly that signal:
108110

109111
```ts
110-
onAction: async ({ action, messages, signal }) => {
112+
onAction: async ({ action, messages, signal, streamText }) => {
111113
if (action.type === "regenerate") {
112114
if (chat.history.getPendingToolCalls().length > 0) return; // gated
113115
chat.history.slice(0, -1);

docs/ai-chat/anatomy.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Everything below maps onto one annotated agent:
1818

1919
```ts trigger/my-agent.ts
2020
import { chat } from "@trigger.dev/sdk/ai";
21-
import { streamText, stepCountIs } from "ai";
21+
import { stepCountIs } from "ai";
2222
import { anthropic } from "@ai-sdk/anthropic";
2323

2424
export const myAgent = chat.agent({
@@ -36,9 +36,9 @@ export const myAgent = chat.agent({
3636

3737
// The turn loop. Messages arrive accumulated; you stream back.
3838
// Options, levels, and alternatives — see Backend.
39-
run: async ({ messages, tools, signal }) =>
39+
run: async ({ messages, tools, signal, streamText }) =>
4040
streamText({
41-
...chat.toStreamTextOptions({ tools }),
41+
tools,
4242
model: anthropic("claude-sonnet-4-5"),
4343
messages,
4444
abortSignal: signal,

docs/ai-chat/backend.mdx

Lines changed: 67 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,13 @@ Return the `streamText` result from `run` and it's automatically piped to the fr
3030

3131
```ts
3232
import { chat } from "@trigger.dev/sdk/ai";
33-
import { streamText, stepCountIs } from "ai";
33+
import { stepCountIs } from "ai";
3434
import { anthropic } from "@ai-sdk/anthropic";
3535

3636
export const simpleChat = chat.agent({
3737
id: "simple-chat",
38-
run: async ({ messages, signal }) => {
38+
run: async ({ messages, signal, streamText }) => {
3939
return streamText({
40-
...chat.toStreamTextOptions(), // prepareStep, system, telemetry (see note below)
4140
model: anthropic("claude-sonnet-4-5"),
4241
system: "You are a helpful assistant.",
4342
messages,
@@ -48,23 +47,56 @@ export const simpleChat = chat.agent({
4847
});
4948
```
5049

51-
<Warning>
52-
**Always spread `chat.toStreamTextOptions()` first** (as above) so your explicit overrides win. It wires up the `prepareStep` callback behind [compaction](/ai-chat/compaction), [steering](/ai-chat/pending-messages), and [background injection](/ai-chat/background-injection), all of which silently no-op without it, and injects the system prompt from `chat.prompt()`, the resolved model (when you pass a `registry`), and telemetry metadata. Examples below keep the spread implicit for brevity, so include it in real code.
53-
</Warning>
50+
<Note>
51+
The `streamText` destructured from `run`'s argument is the SDK's, not the one
52+
imported from `ai`. It carries the agent's managed options, so nothing has to be
53+
spread in. [The managed streamText](#the-managed-streamtext) covers what those
54+
options are and what happens when yours collide with them.
55+
</Note>
56+
57+
### The managed streamText
58+
59+
`run()` is handed a `streamText` that already carries everything the spread provides, so the managed state cannot be lost by leaving the spread out:
60+
61+
```ts
62+
export const simpleChat = chat.agent({
63+
id: "simple-chat",
64+
run: async ({ messages, signal, streamText }) =>
65+
streamText({
66+
model: anthropic("claude-sonnet-4-5"),
67+
messages,
68+
abortSignal: signal,
69+
stopWhen: stepCountIs(15),
70+
}),
71+
});
72+
```
73+
74+
Note the destructured `streamText`: it shadows the one imported from `ai` inside `run`, so the managed options apply without a spread. Spreading `chat.toStreamTextOptions()` into the imported `streamText` is still supported and equivalent.
75+
76+
It differs from the spread in three ways, all of them about what happens when your options collide with the managed ones:
77+
78+
| Option | Spread | Managed `streamText` |
79+
| --- | --- | --- |
80+
| `tools` | Passing `tools` after the spread replaces the skill tools | Merged, so skill tools survive |
81+
| `prepareStep` | Passing your own after the spread replaces the managed one, silently disabling steering, compaction and injection | Composed, yours runs after the managed one |
82+
| `system` | Yours replaces the managed prompt and any injected instructions | Throws |
83+
84+
`system` throws rather than merging because there is no shape that combines two system values on every supported AI SDK version: v5 rejects an array of blocks, and a structured block carries the provider options that make [prompt caching](/ai-chat/prompt-caching) work, so concatenating discards the cache entry. Set a static prompt with [`chat.prompt.set()`](#using-prompts) and add per-turn context with [`chat.inject()`](/ai-chat/background-injection).
85+
86+
If the managed prompt names a model, pass a registry on the agent so the runtime can resolve it: `chat.agent({ registry, run })`.
5487

5588
### Using chat.pipe() for complex flows
5689

5790
For complex agent flows where `streamText` is called deep inside your code, use `chat.pipe()`. It works from **anywhere inside a task** — even nested function calls.
5891

5992
```ts trigger/agent-chat.ts
6093
import { chat } from "@trigger.dev/sdk/ai";
61-
import { streamText } from "ai";
6294
import { anthropic } from "@ai-sdk/anthropic";
6395
import type { ModelMessage } from "ai";
6496

6597
export const agentChat = chat.agent({
6698
id: "agent-chat",
67-
run: async ({ messages }) => {
99+
run: async ({ messages, streamText }) => {
68100
// Don't return anything — chat.pipe is called inside
69101
await runAgentLoop(messages);
70102
},
@@ -102,7 +134,7 @@ export const myChat = chat.agent({
102134
// responseMessage.parts includes the data-metadata part
103135
await db.messages.save(responseMessage);
104136
},
105-
run: async ({ messages, signal }) => {
137+
run: async ({ messages, signal, streamText }) => {
106138
// Also works from run() via chat.response
107139
chat.response.write({
108140
type: "data-context",
@@ -177,9 +209,9 @@ const tools = { searchDocs };
177209
export const myChat = chat.agent({
178210
id: "my-chat",
179211
tools,
180-
run: async ({ messages, tools, signal }) =>
212+
run: async ({ messages, tools, signal, streamText }) =>
181213
streamText({
182-
...chat.toStreamTextOptions({ tools }),
214+
tools,
183215
model: anthropic("claude-sonnet-4-5"),
184216
messages,
185217
abortSignal: signal,
@@ -200,12 +232,12 @@ See [Tools](/ai-chat/tools) for `toModelOutput` across turns, per-turn dynamic t
200232

201233
### Using prompts
202234

203-
Use [AI Prompts](/ai/prompts) to manage your system prompt as versioned, overridable config. Store the resolved prompt in a lifecycle hook with `chat.prompt.set()`, then spread `chat.toStreamTextOptions()` into `streamText`it includes the system prompt, model, config, and telemetry automatically.
235+
Use [AI Prompts](/ai/prompts) to manage your system prompt as versioned, overridable config. Store the resolved prompt in a lifecycle hook with `chat.prompt.set()`. The `streamText` from `run`'s argument picks it up: system prompt, model, config and telemetry.
204236

205237
```ts
206238
import { chat } from "@trigger.dev/sdk/ai";
207239
import { prompts } from "@trigger.dev/sdk";
208-
import { streamText, createProviderRegistry } from "ai";
240+
import { createProviderRegistry } from "ai";
209241
import { anthropic } from "@ai-sdk/anthropic";
210242
import { z } from "zod";
211243

@@ -221,15 +253,15 @@ const systemPrompt = prompts.define({
221253

222254
export const myChat = chat.agent({
223255
id: "my-chat",
256+
registry,
224257
clientDataSchema: z.object({ userId: z.string() }),
225258
onChatStart: async ({ clientData }) => {
226259
const user = await db.user.findUnique({ where: { id: clientData.userId } });
227260
const resolved = await systemPrompt.resolve({ name: user.name });
228261
chat.prompt.set(resolved);
229262
},
230-
run: async ({ messages, signal }) => {
263+
run: async ({ messages, signal, streamText }) => {
231264
return streamText({
232-
...chat.toStreamTextOptions({ registry }), // system, model, config, telemetry
233265
messages,
234266
abortSignal: signal,
235267
stopWhen: stepCountIs(15),
@@ -238,16 +270,9 @@ export const myChat = chat.agent({
238270
});
239271
```
240272

241-
`chat.toStreamTextOptions()` returns an object with `system`, `model` (resolved via the registry), `temperature`, and `experimental_telemetry` — all from the stored prompt. Properties you set after the spread (like a client-selected model) take precedence.
242-
243-
**Which form to call:**
273+
The managed `streamText` carries the stored prompt's `system`, `model` (resolved through the agent's `registry`), sampling config, and `experimental_telemetry`. Options you pass at the call site win, apart from `system`, which throws when the prompt already set one.
244274

245-
| Form | Use when |
246-
|---|---|
247-
| `chat.toStreamTextOptions()` | Default. Wires up `prepareStep` (compaction, steering, background injection), the stored prompt's `system` / `model` / `config`, and telemetry metadata. |
248-
| `chat.toStreamTextOptions({ registry })` | You're using [Prompts](/ai/prompts) with a provider-prefixed model string (e.g. `"anthropic:claude-sonnet-4-5"`). The registry resolves the prefix to a real model instance via `createProviderRegistry({ anthropic, openai, ... })`. |
249-
| `chat.toStreamTextOptions({ tools })` | You want HITL tool approvals — pass the same `tools` object you give to `streamText`. The SDK then knows which tool calls need to pause on `needsApproval: true`. |
250-
| `chat.toStreamTextOptions({ registry, tools })` | Both of the above. |
275+
`chat.toStreamTextOptions()` remains available for the same job, and is the only option in a [custom agent](#custom-agents) or a `chat.headStart` route, where there is no `run` argument to take it from. Pass `{ registry }` when a prompt names a provider-prefixed model, and `{ tools }` when you want HITL tool approvals, so the SDK knows which calls pause on `needsApproval`.
251276

252277
<Tip>
253278
See [Prompts](/ai/prompts) for the full guide — defining templates, variable schemas, dashboard
@@ -273,7 +298,7 @@ The `run` function receives three abort signals:
273298
```ts
274299
export const myChat = chat.agent({
275300
id: "my-chat",
276-
run: async ({ messages, signal, stopSignal, cancelSignal }) => {
301+
run: async ({ messages, signal, stopSignal, cancelSignal, streamText }) => {
277302
return streamText({
278303
model: anthropic("claude-sonnet-4-5"),
279304
messages,
@@ -302,7 +327,7 @@ export const myChat = chat.agent({
302327
data: { messages: uiMessages, lastStoppedAt: stopped ? new Date() : undefined },
303328
});
304329
},
305-
run: async ({ messages, signal }) => {
330+
run: async ({ messages, signal, streamText }) => {
306331
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
307332
},
308333
});
@@ -312,11 +337,10 @@ You can also check stop status from **anywhere** during a turn using `chat.isSto
312337

313338
```ts
314339
import { chat } from "@trigger.dev/sdk/ai";
315-
import { streamText } from "ai";
316340

317341
export const myChat = chat.agent({
318342
id: "my-chat",
319-
run: async ({ messages, signal }) => {
343+
run: async ({ messages, signal, streamText }) => {
320344
return streamText({
321345
model: anthropic("claude-sonnet-4-5"),
322346
messages,
@@ -369,7 +393,7 @@ const sendEmail = tool({
369393

370394
export const myChat = chat.agent({
371395
id: "my-chat",
372-
run: async ({ messages, signal }) => {
396+
run: async ({ messages, signal, streamText }) => {
373397
return streamText({
374398
model: anthropic("claude-sonnet-4-5"),
375399
messages,
@@ -405,12 +429,12 @@ Users can send messages while the agent is executing tool calls. With `pendingMe
405429
```ts
406430
export const myChat = chat.agent({
407431
id: "my-chat",
432+
registry,
408433
pendingMessages: {
409434
shouldInject: ({ steps }) => steps.length > 0,
410435
},
411-
run: async ({ messages, signal }) => {
436+
run: async ({ messages, signal, streamText }) => {
412437
return streamText({
413-
...chat.toStreamTextOptions({ registry }),
414438
messages,
415439
tools: {
416440
/* ... */
@@ -436,6 +460,7 @@ Inject context from background work into the conversation using `chat.inject()`.
436460
```ts
437461
export const myChat = chat.agent({
438462
id: "my-chat",
463+
registry,
439464
onTurnComplete: async ({ messages }) => {
440465
chat.defer(
441466
(async () => {
@@ -453,8 +478,8 @@ export const myChat = chat.agent({
453478
})()
454479
);
455480
},
456-
run: async ({ messages, signal }) => {
457-
return streamText({ ...chat.toStreamTextOptions({ registry }), messages, abortSignal: signal });
481+
run: async ({ messages, signal, streamText }) => {
482+
return streamText({ messages, abortSignal: signal });
458483
},
459484
});
460485
```
@@ -565,7 +590,7 @@ export const myChat = chat.agent({
565590
},
566591
];
567592
},
568-
run: async ({ messages, signal }) => {
593+
run: async ({ messages, signal, streamText }) => {
569594
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
570595
},
571596
});
@@ -590,7 +615,7 @@ By default, a chat agent stays idle after each turn waiting for the next user me
590615
```ts
591616
chat.agent({
592617
id: "one-shot",
593-
run: async ({ messages, signal }) => {
618+
run: async ({ messages, signal, streamText }) => {
594619
// Single-response agent — exit after this turn.
595620
chat.endRun();
596621
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
@@ -613,7 +638,7 @@ Use this when the agent knows its work is done (budget exhausted, goal achieved,
613638
Override how long the run stays suspended waiting for the next message. Call from inside `run()`:
614639

615640
```ts
616-
run: async ({ messages, signal }) => {
641+
run: async ({ messages, signal, streamText }) => {
617642
chat.setTurnTimeout("2h"); // Wait longer for this conversation
618643
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
619644
},
@@ -624,7 +649,7 @@ run: async ({ messages, signal }) => {
624649
Override how long the run stays idle (active, using compute) after each turn:
625650

626651
```ts
627-
run: async ({ messages, signal }) => {
652+
run: async ({ messages, signal, streamText }) => {
628653
chat.setIdleTimeoutInSeconds(60); // Stay idle for 1 minute
629654
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
630655
},
@@ -659,7 +684,7 @@ export const myChat = chat.agent({
659684
return "Something went wrong. Please try again.";
660685
},
661686
},
662-
run: async ({ messages, signal }) => {
687+
run: async ({ messages, signal, streamText }) => {
663688
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
664689
},
665690
});
@@ -690,7 +715,7 @@ export const myChat = chat.agent({
690715
sendReasoning: true, // Forward model reasoning (default: true)
691716
sendSources: true, // Forward source citations (default: false)
692717
},
693-
run: async ({ messages, signal }) => {
718+
run: async ({ messages, signal, streamText }) => {
694719
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
695720
},
696721
});
@@ -708,7 +733,7 @@ export const myChat = chat.agent({
708733
uiMessageStreamOptions: {
709734
generateMessageId: () => uuidv7(),
710735
},
711-
run: async ({ messages, signal }) => {
736+
run: async ({ messages, signal, streamText }) => {
712737
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
713738
},
714739
});
@@ -728,7 +753,7 @@ export const myChat = chat
728753
})
729754
.agent({
730755
id: "my-chat",
731-
run: async ({ messages, signal }) => {
756+
run: async ({ messages, signal, streamText }) => {
732757
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
733758
},
734759
});
@@ -746,7 +771,7 @@ export const myChat = chat
746771
Override per-turn with `chat.setUIMessageStreamOptions()` — per-turn values merge with the static config (per-turn wins on conflicts). The override is cleared automatically after each turn.
747772

748773
```ts
749-
run: async ({ messages, clientData, signal }) => {
774+
run: async ({ messages, clientData, signal, streamText }) => {
750775
// Enable reasoning only for certain models
751776
if (clientData.model?.includes("claude")) {
752777
chat.setUIMessageStreamOptions({ sendReasoning: true });
@@ -772,7 +797,6 @@ If you need full control over task options, use the standard `task()` with `Chat
772797
```ts
773798
import { task } from "@trigger.dev/sdk";
774799
import { chat, type ChatTaskPayload } from "@trigger.dev/sdk/ai";
775-
import { streamText } from "ai";
776800
import { anthropic } from "@ai-sdk/anthropic";
777801

778802
export const manualChat = task({

0 commit comments

Comments
 (0)