Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/managed-streamtext-in-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@trigger.dev/sdk": minor
---

`run()` now receives a `streamText` with your agent's managed options already applied, so they cannot be lost by leaving out the spread:

```ts
run: async ({ messages, signal, streamText }) =>
streamText({ model, messages, abortSignal: signal });
```

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.

`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.

`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. `chat.toStreamTextOptions()` applies them as well, so spreading it into the `streamText` imported from `ai` stays equivalent to the one `run()` receives.

`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.
64 changes: 34 additions & 30 deletions docs/ai-chat/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,74 +44,78 @@ export const myChat = chat.agent({
// returning void → side-effect-only, no model call
},

run: async ({ messages, signal }) => {
run: async ({ messages, signal, streamText }) => {
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
},
});
```

**Lifecycle flow:** Wake → parse action against `actionSchema` → `hydrateMessages` (if set) → **`onAction`** → apply `chat.history` mutations → emit `trigger:turn-complete` → wait for next message.

## Returning a model response from an action
## Answering after an action

`onAction` can return a `StreamTextResult`, `string`, or `UIMessage` to produce a response. All three are sent to the frontend and added to the conversation just like a normal turn's answer, but the rest of the turn machinery (`onTurnStart`, `onTurnComplete`, etc.) still does not fire. A returned `UIMessage` must have `role: "assistant"`; its text and `data-*` parts are delivered, and other part types are dropped.
An action is a state edit. To answer after the edit, return `chat.turn()`: the edit is applied and snapshotted, then a turn runs on the edited history exactly as a message turn does. `onTurnStart`, `run()`, `onBeforeTurnComplete` and `onTurnComplete` fire, the turn counter advances, and the answer gets everything a turn has: the agent's system prompt and tools, steering, compaction, injected instructions and persistence.

```ts
onAction: async ({ action, messages }) => {
if (action.type === "regenerate") {
chat.history.slice(0, -1); // drop the last assistant
return streamText({
model: anthropic("claude-sonnet-4-5"),
messages,
stopWhen: stepCountIs(15),
});
onAction: async ({ action }) => {
switch (action.type) {
case "undo":
chat.history.slice(0, -2);
return; // edit only, no turn

case "regenerate":
chat.history.slice(0, -1);
return chat.turn(); // answer the edited history

case "retry-formal":
chat.history.slice(0, -1);
chat.inject([{ role: "system", content: "Answer formally this time." }]);
return chat.turn(); // with a one-shot instruction
}
// other actions return void → side-effect only
}
```

This is useful for actions that both mutate state and want a fresh model response (regenerate-from-here, retry-with-different-style).
`run()` receives the edited history with no incoming user message, the same shape as a `regenerate-message` turn, and its `trigger` is `"action"`. Returning anything other than `chat.turn()` or nothing is an error; a response can no longer be returned from `onAction` directly.

### Actions and persistence

An action is not a turn, so `onTurnComplete` never fires, and that is where an app that owns its own transcript normally writes. What that means depends on which persistence model you use.
An action that returns nothing does not fire `onTurnComplete`, and that is where an app that owns its own transcript normally writes. What that means depends on which persistence model you use.

**Platform-managed** (no `hydrateMessages`): nothing to do. After an action that changed the conversation (a `chat.history` mutation, a response returned from `onAction`, or both), the runtime writes the snapshot, so the change survives the run ending.
**Platform-managed** (no `hydrateMessages`): nothing to do. After an action that changed the conversation, the runtime writes the snapshot, so the edit survives the run ending. An action that returns `chat.turn()` is followed by a turn, which persists its answer the way every turn does.

**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:
**Your own store** (`hydrateMessages` registered): the runtime deliberately does not write, because your store is the source of truth. A history edit lives only in the running worker until you persist it, and a continuation rehydrates from your store, not from what the worker had in memory. Mirror each edit in your store, not only additions: a regenerate is a delete *and* an insert. The answer that follows `chat.turn()` reaches your store through `onTurnComplete`, like any turn's answer.

```ts
onAction: async ({ action, messages }) => {
onAction: async ({ action, chatId }) => {
if (action.type === "undo") {
chat.history.slice(0, -2);
await db.deleteLastExchange(chatId); // the rollback is yours to persist
}

if (action.type === "regenerate") {
chat.history.slice(0, -1);
await db.deleteLastAssistant(chatId); // drop the answer being replaced
const { message } = await chat.pipeAndCapture(
streamText({ model: anthropic("claude-sonnet-4-5"), messages })
);
if (message) await db.saveMessage(message); // then store the new one
await db.deleteLastAssistant(chatId); // the delete half
return chat.turn(); // the insert half arrives in onTurnComplete
}
},
onTurnComplete: async ({ chatId, newUIMessages }) => {
await db.saveMessages(chatId, newUIMessages);
},
```

Mirror each mutation in your store, not only the additions. A `chat.history` mutation is invisible to your database, so a regenerate is a delete *and* an insert. Saving the new answer without removing the old one leaves both in the canonical transcript, and the next hydration returns the two of them. (An append-only or branching store is the exception: there you write a new version and resolve the head on read.)

Returning the stream instead of piping it yourself still works and still reaches the browser, but you have no message to store, so the next run does not know about it.

## Gating actions on HITL state

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:

```ts
onAction: async ({ action, messages, signal }) => {
onAction: async ({ action, signal, streamText }) => {
if (action.type === "regenerate") {
if (chat.history.getPendingToolCalls().length > 0) return; // gated
chat.history.slice(0, -1);
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
return streamText({
model: anthropic("claude-sonnet-4-5"),
messages: await convertToModelMessages(chat.history.all()),
abortSignal: signal,
});
}
},
```
Expand All @@ -135,7 +139,7 @@ The action payload is validated against `actionSchema` on the backend; invalid a
## See also

- [`chat.history`](/ai-chat/backend#chat-history): the imperative API actions use to mutate state
- [Sending actions from the frontend](/ai-chat/frontend#sending-actions): `transport.sendAction` ergonomics
- [Sending actions from the frontend](/ai-chat/frontend#sending-actions): sending actions through `useChat` so a turn that follows one renders like any turn
- [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages): fires before `onAction` when set
- [Branching conversations](/ai-chat/patterns/branching-conversations): pairs action handlers with backend-controlled history
- [Human-in-the-loop](/ai-chat/patterns/human-in-the-loop): gating fresh actions while a tool is waiting
6 changes: 3 additions & 3 deletions docs/ai-chat/anatomy.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Everything below maps onto one annotated agent:

```ts trigger/my-agent.ts
import { chat } from "@trigger.dev/sdk/ai";
import { streamText, stepCountIs } from "ai";
import { stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";

export const myAgent = chat.agent({
Expand All @@ -36,9 +36,9 @@ export const myAgent = chat.agent({

// The turn loop. Messages arrive accumulated; you stream back.
// Options, levels, and alternatives — see Backend.
run: async ({ messages, tools, signal }) =>
run: async ({ messages, tools, signal, streamText }) =>
streamText({
...chat.toStreamTextOptions({ tools }),
tools,
model: anthropic("claude-sonnet-4-5"),
messages,
abortSignal: signal,
Expand Down
Loading
Loading