Skip to content

Commit 57bb078

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 57bb078

23 files changed

Lines changed: 1342 additions & 217 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: 20 additions & 7 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,13 +56,19 @@ 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+
The `messages` argument is captured before `onAction` runs, so a handler that mutates `chat.history` and then passes `messages` straight through sends the model the state it just changed. Rebuild from `chat.history.all()` after the mutation.
60+
61+
Build the response 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.
62+
5963
```ts
60-
onAction: async ({ action, messages }) => {
64+
onAction: async ({ action, streamText }) => {
6165
if (action.type === "regenerate") {
6266
chat.history.slice(0, -1); // drop the last assistant
6367
return streamText({
6468
model: anthropic("claude-sonnet-4-5"),
65-
messages,
69+
// Rebuild from the mutated history. The `messages` argument was captured
70+
// before the slice, so it still contains the answer being replaced.
71+
messages: await convertToModelMessages(chat.history.all()),
6672
stopWhen: stepCountIs(15),
6773
});
6874
}
@@ -81,7 +87,7 @@ An action is not a turn, so `onTurnComplete` never fires, and that is where an a
8187
**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:
8288

8389
```ts
84-
onAction: async ({ action, messages }) => {
90+
onAction: async ({ action, messages, streamText }) => {
8591
if (action.type === "undo") {
8692
chat.history.slice(0, -2);
8793
await db.deleteLastExchange(chatId); // the rollback is yours to persist
@@ -91,7 +97,10 @@ onAction: async ({ action, messages }) => {
9197
chat.history.slice(0, -1);
9298
await db.deleteLastAssistant(chatId); // drop the answer being replaced
9399
const { message } = await chat.pipeAndCapture(
94-
streamText({ model: anthropic("claude-sonnet-4-5"), messages })
100+
streamText({
101+
model: anthropic("claude-sonnet-4-5"),
102+
messages: await convertToModelMessages(chat.history.all()),
103+
})
95104
);
96105
if (message) await db.saveMessage(message); // then store the new one
97106
}
@@ -107,11 +116,15 @@ Returning the stream instead of piping it yourself still works and still reaches
107116
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:
108117

109118
```ts
110-
onAction: async ({ action, messages, signal }) => {
119+
onAction: async ({ action, signal, streamText }) => {
111120
if (action.type === "regenerate") {
112121
if (chat.history.getPendingToolCalls().length > 0) return; // gated
113122
chat.history.slice(0, -1);
114-
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
123+
return streamText({
124+
model: anthropic("claude-sonnet-4-5"),
125+
messages: await convertToModelMessages(chat.history.all()),
126+
abortSignal: signal,
127+
});
115128
}
116129
},
117130
```

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,

0 commit comments

Comments
 (0)