@trigger.dev/sdk 0.0.0-prerelease-20260908122921 → 0.0.0-prerelease-streamfix-20260909094302
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/commonjs/imports/ai-runtime-cjs.cjs.map +1 -1
- package/dist/commonjs/imports/ai-runtime.js +0 -2
- package/dist/commonjs/v3/ai.d.ts +16 -199
- package/dist/commonjs/v3/ai.js +102 -983
- package/dist/commonjs/v3/ai.js.map +1 -1
- package/dist/commonjs/v3/chat-client.d.ts +2 -3
- package/dist/commonjs/v3/chat-client.js +5 -31
- package/dist/commonjs/v3/chat-client.js.map +1 -1
- package/dist/commonjs/v3/chat-react.d.ts +0 -34
- package/dist/commonjs/v3/chat-react.js +1 -47
- package/dist/commonjs/v3/chat-react.js.map +1 -1
- package/dist/commonjs/v3/chat-server.d.ts +6 -42
- package/dist/commonjs/v3/chat-server.js +7 -52
- package/dist/commonjs/v3/chat-server.js.map +1 -1
- package/dist/commonjs/v3/chat.d.ts +10 -81
- package/dist/commonjs/v3/chat.js +46 -292
- package/dist/commonjs/v3/chat.js.map +1 -1
- package/dist/commonjs/v3/sessions.d.ts +2 -15
- package/dist/commonjs/v3/sessions.js +1 -12
- package/dist/commonjs/v3/sessions.js.map +1 -1
- package/dist/commonjs/v3/shared.js +36 -30
- package/dist/commonjs/v3/shared.js.map +1 -1
- package/dist/commonjs/v3/test/mock-chat-agent.d.ts +0 -43
- package/dist/commonjs/v3/test/mock-chat-agent.js +0 -90
- package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
- package/dist/commonjs/v3/test/test-session-handle.js +0 -6
- package/dist/commonjs/v3/test/test-session-handle.js.map +1 -1
- package/dist/commonjs/version.js +1 -1
- package/dist/esm/imports/ai-runtime.d.ts +2 -2
- package/dist/esm/imports/ai-runtime.js +2 -2
- package/dist/esm/imports/ai-runtime.js.map +1 -1
- package/dist/esm/v3/ai.d.ts +16 -199
- package/dist/esm/v3/ai.js +103 -984
- package/dist/esm/v3/ai.js.map +1 -1
- package/dist/esm/v3/chat-client.d.ts +2 -3
- package/dist/esm/v3/chat-client.js +5 -31
- package/dist/esm/v3/chat-client.js.map +1 -1
- package/dist/esm/v3/chat-react.d.ts +0 -34
- package/dist/esm/v3/chat-react.js +1 -46
- package/dist/esm/v3/chat-react.js.map +1 -1
- package/dist/esm/v3/chat-server.d.ts +6 -42
- package/dist/esm/v3/chat-server.js +8 -53
- package/dist/esm/v3/chat-server.js.map +1 -1
- package/dist/esm/v3/chat.d.ts +10 -81
- package/dist/esm/v3/chat.js +47 -293
- package/dist/esm/v3/chat.js.map +1 -1
- package/dist/esm/v3/sessions.d.ts +2 -15
- package/dist/esm/v3/sessions.js +1 -11
- package/dist/esm/v3/sessions.js.map +1 -1
- package/dist/esm/v3/shared.js +23 -17
- package/dist/esm/v3/shared.js.map +1 -1
- package/dist/esm/v3/test/mock-chat-agent.d.ts +0 -43
- package/dist/esm/v3/test/mock-chat-agent.js +2 -92
- package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
- package/dist/esm/v3/test/test-session-handle.js +0 -6
- package/dist/esm/v3/test/test-session-handle.js.map +1 -1
- package/dist/esm/version.js +1 -1
- package/docs/ai-chat/actions.mdx +23 -55
- package/docs/ai-chat/anatomy.mdx +3 -3
- package/docs/ai-chat/backend.mdx +48 -125
- package/docs/ai-chat/background-injection.mdx +19 -67
- package/docs/ai-chat/client-protocol.mdx +4 -5
- package/docs/ai-chat/compaction.mdx +7 -11
- package/docs/ai-chat/custom-agents.mdx +0 -23
- package/docs/ai-chat/fast-starts.mdx +20 -27
- package/docs/ai-chat/frontend.mdx +14 -17
- package/docs/ai-chat/migrating-from-a-route-handler.mdx +14 -16
- package/docs/ai-chat/patterns/skills.mdx +10 -7
- package/docs/ai-chat/patterns/version-upgrades.mdx +6 -79
- package/docs/ai-chat/pending-messages.mdx +3 -3
- package/docs/ai-chat/prompt-caching.mdx +25 -23
- package/docs/ai-chat/quick-start.mdx +11 -11
- package/docs/ai-chat/reference.mdx +5 -12
- package/docs/ai-chat/sessions.mdx +1 -6
- package/docs/ai-chat/testing.mdx +1 -2
- package/docs/ai-chat/tools.mdx +13 -18
- package/docs/ai-chat/upgrade-guide.mdx +2 -2
- package/docs/apikeys.mdx +45 -27
- package/docs/deployment/overview.mdx +8 -4
- package/docs/deployment/preview-branches.mdx +4 -4
- package/docs/deployment/version-skew-protection.mdx +0 -62
- package/docs/manual-setup.mdx +7 -7
- package/docs/mcp-tools.mdx +0 -9
- package/docs/quick-start.mdx +3 -3
- package/docs/realtime/auth.mdx +1 -1
- package/docs/self-hosting/security.mdx +0 -5
- package/docs/tasks/scheduled.mdx +0 -24
- package/docs/triggering.mdx +1 -1
- package/package.json +4 -4
- package/skills/trigger-authoring-chat-agent/SKILL.md +27 -38
- package/skills/trigger-chat-agent-advanced/SKILL.md +12 -31
- package/dist/commonjs/v3/chatVersionSkew.d.ts +0 -12
- package/dist/commonjs/v3/chatVersionSkew.js +0 -30
- package/dist/commonjs/v3/chatVersionSkew.js.map +0 -1
- package/dist/commonjs/v3/externalDeploymentId.d.ts +0 -23
- package/dist/commonjs/v3/externalDeploymentId.js +0 -43
- package/dist/commonjs/v3/externalDeploymentId.js.map +0 -1
- package/dist/esm/v3/chatVersionSkew.d.ts +0 -12
- package/dist/esm/v3/chatVersionSkew.js +0 -27
- package/dist/esm/v3/chatVersionSkew.js.map +0 -1
- package/dist/esm/v3/externalDeploymentId.d.ts +0 -23
- package/dist/esm/v3/externalDeploymentId.js +0 -38
- package/dist/esm/v3/externalDeploymentId.js.map +0 -1
- package/docs/ai-chat/patterns/native-compaction.mdx +0 -310
- package/docs/reports.mdx +0 -157
- package/docs/troubleshooting-zod.mdx +0 -158
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "Background injection"
|
|
3
3
|
sidebarTitle: "Background injection"
|
|
4
|
-
description: "Inject context from background work into the agent's conversation
|
|
4
|
+
description: "Inject context from background work into the agent's conversation — self-review, RAG augmentation, or any async analysis."
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
## Overview
|
|
8
8
|
|
|
9
9
|
`chat.inject()` queues model messages for injection into the conversation. Messages are picked up at the start of the next turn or at the next `prepareStep` boundary (between tool-call steps).
|
|
10
10
|
|
|
11
|
-
This is the backend counterpart to [pending messages](/ai-chat/pending-messages)
|
|
11
|
+
This is the backend counterpart to [pending messages](/ai-chat/pending-messages) — pending messages come from the user via the frontend, while `chat.inject()` comes from your task code.
|
|
12
12
|
|
|
13
13
|
## Basic usage
|
|
14
14
|
|
|
@@ -33,9 +33,8 @@ The most powerful pattern combines `chat.defer()` (background work) with `chat.i
|
|
|
33
33
|
```ts
|
|
34
34
|
export const myChat = chat.agent({
|
|
35
35
|
id: "my-chat",
|
|
36
|
-
registry,
|
|
37
36
|
onTurnComplete: async ({ messages }) => {
|
|
38
|
-
// Kick off background analysis
|
|
37
|
+
// Kick off background analysis — doesn't block the turn
|
|
39
38
|
chat.defer(
|
|
40
39
|
(async () => {
|
|
41
40
|
const analysis = await analyzeConversation(messages);
|
|
@@ -48,8 +47,9 @@ export const myChat = chat.agent({
|
|
|
48
47
|
})()
|
|
49
48
|
);
|
|
50
49
|
},
|
|
51
|
-
run: async ({ messages, signal
|
|
50
|
+
run: async ({ messages, signal }) => {
|
|
52
51
|
return streamText({
|
|
52
|
+
...chat.toStreamTextOptions({ registry }),
|
|
53
53
|
messages,
|
|
54
54
|
abortSignal: signal,
|
|
55
55
|
stopWhen: stepCountIs(15),
|
|
@@ -77,7 +77,7 @@ A cheap model reviews the agent's response after each turn and injects coaching
|
|
|
77
77
|
```ts
|
|
78
78
|
import { chat } from "@trigger.dev/sdk/ai";
|
|
79
79
|
import { prompts } from "@trigger.dev/sdk";
|
|
80
|
-
import { generateObject, createProviderRegistry, stepCountIs } from "ai";
|
|
80
|
+
import { streamText, generateObject, createProviderRegistry, stepCountIs } from "ai";
|
|
81
81
|
import { anthropic } from "@ai-sdk/anthropic";
|
|
82
82
|
import { z } from "zod";
|
|
83
83
|
|
|
@@ -98,7 +98,6 @@ Be concise. Only flag issues worth fixing.`,
|
|
|
98
98
|
|
|
99
99
|
export const myChat = chat.agent({
|
|
100
100
|
id: "my-chat",
|
|
101
|
-
registry,
|
|
102
101
|
onTurnComplete: async ({ messages }) => {
|
|
103
102
|
chat.defer(
|
|
104
103
|
(async () => {
|
|
@@ -140,8 +139,9 @@ export const myChat = chat.agent({
|
|
|
140
139
|
})()
|
|
141
140
|
);
|
|
142
141
|
},
|
|
143
|
-
run: async ({ messages, signal
|
|
142
|
+
run: async ({ messages, signal }) => {
|
|
144
143
|
return streamText({
|
|
144
|
+
...chat.toStreamTextOptions({ registry }),
|
|
145
145
|
messages,
|
|
146
146
|
abortSignal: signal,
|
|
147
147
|
stopWhen: stepCountIs(15),
|
|
@@ -150,7 +150,7 @@ export const myChat = chat.agent({
|
|
|
150
150
|
});
|
|
151
151
|
```
|
|
152
152
|
|
|
153
|
-
The self-review runs on `claude-haiku-4-5` (fast, cheap) in the background. If the user sends another message before it completes, the coaching is still injected
|
|
153
|
+
The self-review runs on `claude-haiku-4-5` (fast, cheap) in the background. If the user sends another message before it completes, the coaching is still injected — `chat.inject()` persists across the idle wait.
|
|
154
154
|
|
|
155
155
|
## Other use cases
|
|
156
156
|
|
|
@@ -161,25 +161,25 @@ The self-review runs on `claude-haiku-4-5` (fast, cheap) in the background. If t
|
|
|
161
161
|
|
|
162
162
|
## `chat.defer` standalone
|
|
163
163
|
|
|
164
|
-
`chat.defer()` is also useful on its own, without `chat.inject()`. Any work whose timing has no resume implication
|
|
164
|
+
`chat.defer()` is also useful on its own, without `chat.inject()`. Any work whose timing has no resume implication — analytics, audit logs, search-index writes, cache warming — can run in parallel with streaming instead of in the critical path. All deferred promises are awaited (with a 5s timeout) before `onTurnComplete` fires.
|
|
165
165
|
|
|
166
166
|
```ts
|
|
167
167
|
export const myChat = chat.agent({
|
|
168
168
|
id: "my-chat",
|
|
169
169
|
onTurnStart: async ({ chatId, runId }) => {
|
|
170
|
-
// Analytics
|
|
170
|
+
// Analytics — fire-and-forget, irrelevant to resume.
|
|
171
171
|
chat.defer(analytics.track("turn_started", { chatId, runId }));
|
|
172
172
|
},
|
|
173
|
-
run: async ({ messages, signal
|
|
173
|
+
run: async ({ messages, signal }) => {
|
|
174
174
|
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
|
|
175
175
|
},
|
|
176
176
|
});
|
|
177
177
|
```
|
|
178
178
|
|
|
179
|
-
`chat.defer()` can be called from anywhere during a turn
|
|
179
|
+
`chat.defer()` can be called from anywhere during a turn — hooks, `run()`, or nested helpers. All deferred promises are collected and awaited together before `onTurnComplete`.
|
|
180
180
|
|
|
181
181
|
<Warning>
|
|
182
|
-
**Don't use `chat.defer()` for the message-history write in `onTurnStart`.** That write must land *before* the model starts streaming, otherwise a mid-stream page refresh will read `[]` from your DB and lose the user's message from the rendered conversation. See [Database persistence
|
|
182
|
+
**Don't use `chat.defer()` for the message-history write in `onTurnStart`.** That write must land *before* the model starts streaming, otherwise a mid-stream page refresh will read `[]` from your DB and lose the user's message from the rendered conversation. See [Database persistence — `onTurnStart`](/ai-chat/patterns/database-persistence#onturnstart). Reserve `chat.defer` for writes whose timing has no resume implication.
|
|
183
183
|
</Warning>
|
|
184
184
|
|
|
185
185
|
## How it differs from pending messages
|
|
@@ -189,57 +189,9 @@ export const myChat = chat.agent({
|
|
|
189
189
|
| **Source** | Backend task code | Frontend user input |
|
|
190
190
|
| **Triggered by** | Your code (e.g. `onTurnComplete` + `chat.defer()`) | User sending a message during streaming |
|
|
191
191
|
| **Injection point** | Start of next turn, or next `prepareStep` boundary | Next `prepareStep` boundary only |
|
|
192
|
-
| **Message role** | Any
|
|
192
|
+
| **Message role** | Any (`system`, `user`, `assistant`) | Typically `user` |
|
|
193
193
|
| **Frontend visibility** | Not visible unless you write custom `data-*` chunks | Visible via `usePendingMessages` hook |
|
|
194
194
|
|
|
195
|
-
## Two lanes: trusted and untrusted
|
|
196
|
-
|
|
197
|
-
The role you inject with decides more than position. It decides whether the model
|
|
198
|
-
treats the content as trustworthy.
|
|
199
|
-
|
|
200
|
-
**`role: "system"` goes to the instructions lane.** The block is appended to the
|
|
201
|
-
system instructions for subsequent inference calls, so it carries the same standing
|
|
202
|
-
as your system prompt. This is the lane for context the agent should believe:
|
|
203
|
-
entitlements, plan changes, operational notices.
|
|
204
|
-
|
|
205
|
-
It has to work this way. On AI SDK 7 a system message inside `messages` is rejected
|
|
206
|
-
for every provider. `standardizePrompt` throws before any provider is called, and
|
|
207
|
-
its own advice is to use the instructions option, so the injected block goes there
|
|
208
|
-
rather than into the transcript.
|
|
209
|
-
|
|
210
|
-
<Warning>
|
|
211
|
-
The instructions lane is delivered by `chat.toStreamTextOptions()`, because that
|
|
212
|
-
is the only place the SDK can set `streamText`'s instructions for you. If your
|
|
213
|
-
`run()` calls `streamText({ model, messages, abortSignal })` without spreading
|
|
214
|
-
`chat.toStreamTextOptions()`, a `role: "system"` injection never reaches the
|
|
215
|
-
model. The conversational lane has no such requirement: it arrives through
|
|
216
|
-
`messages` either way.
|
|
217
|
-
</Warning>
|
|
218
|
-
|
|
219
|
-
- An injection applies to the next turn only. A block injected in `onTurnComplete`
|
|
220
|
-
shapes the following turn and is cleared after it, so it is not repeated on every
|
|
221
|
-
turn from then on. Within that turn it is consumed once rather than once per read,
|
|
222
|
-
so a `run()` that builds options more than once sees the same instructions in
|
|
223
|
-
every build.
|
|
224
|
-
- The injected text is merged into a single instruction rather than added as a
|
|
225
|
-
second block, because AI SDK 5 rejects an array of system blocks while accepting
|
|
226
|
-
one structured block. Merging changes the cached prefix, so a cached system prompt
|
|
227
|
-
gets no cache hit for as long as an injection is live. If you rely on prompt
|
|
228
|
-
caching, inject sparingly and prefer facts that go stale, so the injection clears.
|
|
229
|
-
|
|
230
|
-
**Any other role joins the conversation, and is untrusted by construction.** A
|
|
231
|
-
message injected as `user` is indistinguishable from something the user typed, and a
|
|
232
|
-
well-aligned model treats it accordingly, and may say so and re-derive the answer
|
|
233
|
-
from tools instead of taking it at face value:
|
|
234
|
-
|
|
235
|
-
> "that text arrived embedded in your message, not from a tool I called, so I
|
|
236
|
-
> verified it myself rather than trusting it"
|
|
237
|
-
|
|
238
|
-
That is correct behaviour, not a bug. So inject **checkable facts** in the
|
|
239
|
-
conversational lane and put **directives** in the instructions lane. A conclusion
|
|
240
|
-
injected as a user message is the worst of both: the model neither trusts it nor
|
|
241
|
-
ignores it, and may contradict it in front of the user.
|
|
242
|
-
|
|
243
195
|
## API reference
|
|
244
196
|
|
|
245
197
|
### chat.inject()
|
|
@@ -248,7 +200,7 @@ ignores it, and may contradict it in front of the user.
|
|
|
248
200
|
chat.inject(messages: ModelMessage[]): void
|
|
249
201
|
```
|
|
250
202
|
|
|
251
|
-
Queue model messages for injection at the next opportunity. Messages persist across the idle wait between turns
|
|
203
|
+
Queue model messages for injection at the next opportunity. Messages persist across the idle wait between turns — they are not reset when a new turn starts.
|
|
252
204
|
|
|
253
205
|
**Parameters:**
|
|
254
206
|
|
|
@@ -257,9 +209,9 @@ Queue model messages for injection at the next opportunity. Messages persist acr
|
|
|
257
209
|
| `messages` | `ModelMessage[]` | Model messages to inject (from the `ai` package) |
|
|
258
210
|
|
|
259
211
|
Messages are drained (consumed) when:
|
|
260
|
-
1. A new turn starts
|
|
261
|
-
2. A `prepareStep` boundary is reached
|
|
212
|
+
1. A new turn starts — before `run()` executes
|
|
213
|
+
2. A `prepareStep` boundary is reached — between tool-call steps during streaming
|
|
262
214
|
|
|
263
215
|
<Note>
|
|
264
|
-
`chat.inject()` writes to an in-memory queue in the current process. It works from any code running in the same task
|
|
216
|
+
`chat.inject()` writes to an in-memory queue in the current process. It works from any code running in the same task — lifecycle hooks, deferred work, tool execute functions, etc. It does not work from subtasks or other runs.
|
|
265
217
|
</Note>
|
|
@@ -54,7 +54,7 @@ A single-shell walk-through of the whole protocol — copy, fill in `BASE_URL` /
|
|
|
54
54
|
|
|
55
55
|
```bash
|
|
56
56
|
BASE_URL="https://api.trigger.dev" # or your local webapp
|
|
57
|
-
SECRET_KEY="
|
|
57
|
+
SECRET_KEY="tr_dev_..." # secret API key for the env
|
|
58
58
|
TASK_ID="ai-chat" # your chat.agent task id
|
|
59
59
|
CHAT_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
|
60
60
|
|
|
@@ -210,7 +210,6 @@ Pick `"preload"` when the UI has rendered but the user hasn't typed (warms the a
|
|
|
210
210
|
| `triggerConfig.maxAttempts` | `number` | Per-run retry cap (1–10). |
|
|
211
211
|
| `triggerConfig.maxDuration` | `number` | Per-run wall-clock cap, seconds. |
|
|
212
212
|
| `triggerConfig.lockToVersion` | `string` | Pin every run to a specific worker version. |
|
|
213
|
-
| `triggerConfig.externalDeploymentId` | `string \| null` | Pin every run to the deployment carrying this [external deployment id](/deployment/version-skew-protection#chat-sessions). Discovered from the environment when omitted; `null` opts the chat out. |
|
|
214
213
|
| `triggerConfig.region` | `string` | Region preference. |
|
|
215
214
|
| `triggerConfig.idleTimeoutInSeconds` | `number` | Surfaced to the agent through the wire payload (1–3600). |
|
|
216
215
|
|
|
@@ -267,7 +266,7 @@ x-trigger-jwt-claims: {"sub":"...","scopes":["read:runs:run_abc123","write:input
|
|
|
267
266
|
Re-calling `POST /api/v1/sessions` with the same `(taskIdentifier, externalId)` pair is **idempotent for the lifetime of the session**:
|
|
268
267
|
|
|
269
268
|
- If the session is still alive: returns the existing row with `isCached: true`, `runId` unchanged, and a **fresh** 60-minute `publicAccessToken`. No duplicate run is triggered. (Idle/exited runs are different — see [Continuations](#continuations).)
|
|
270
|
-
- If the session has been closed (`POST /api/v1/sessions/{id}/close
|
|
269
|
+
- If the session has been closed (`POST /api/v1/sessions/{id}/close`): returns **HTTP 409**. Closed is one-way; reuse a different `externalId` to start a new conversation.
|
|
271
270
|
- Any tags / metadata / expiresAt / triggerConfig fields you send on the cached path are written through to the row, so you can update e.g. `triggerConfig.basePayload.metadata` mid-conversation. The new fields apply to **future** runs (continuations); the currently-live run keeps its original config.
|
|
272
271
|
|
|
273
272
|
<Warning>
|
|
@@ -694,7 +693,7 @@ The body is a JSON-serialized [`ChatInputChunk`](#chatinputchunk), a tagged unio
|
|
|
694
693
|
| --- | --- |
|
|
695
694
|
| `401` | Missing or invalid `Authorization` header. |
|
|
696
695
|
| `403` | Token doesn't carry `write:sessions:{externalId}`. |
|
|
697
|
-
| `409` | The session is closed
|
|
696
|
+
| `409` | The session is closed — `{ "ok": false, "error": "Cannot append to a closed session" }`. |
|
|
698
697
|
| `413` | Body exceeds 1 MiB **or** the wrapped record would exceed S2's ~1 MiB per-record metered ceiling. A normal `kind: "message"` payload is a few KB; if you hit this you're shipping more than one message per record or pushing a single tool output that's itself oversized. Carries CORS headers so browser fetches can read the status. |
|
|
699
698
|
| `500` | Transient backend failure on the durable stream. Safe to retry — appends are idempotent on `(externalId, X-Part-Id)` if you set the optional `X-Part-Id` request header (the built-in clients set it from a UUID). |
|
|
700
699
|
|
|
@@ -833,7 +832,7 @@ Custom actions (undo, rollback, edit) ride on the same `.in` channel using `kind
|
|
|
833
832
|
}
|
|
834
833
|
```
|
|
835
834
|
|
|
836
|
-
For managed `chat.agent()` tasks, actions wake the agent from suspension (same as messages) and fire the `onAction` hook — they are not turns, so `run()` and turn lifecycle hooks do not fire. If `onAction` returns `
|
|
835
|
+
For managed `chat.agent()` tasks, actions wake the agent from suspension (same as messages) and fire the `onAction` hook — they are not turns, so `run()` and turn lifecycle hooks do not fire. If `onAction` returns a `StreamTextResult`, the response is auto-piped to the frontend (but still no `run()` or `onTurnComplete`). The `action` payload is validated against the agent's `actionSchema`. If the agent didn't register an `actionSchema` (or your `action` payload doesn't match it), validation fails the same way `metadata` does — `.in/append` returns `200 OK`, but the run trace shows `chat turn N [ERROR]` and the wire emits a `turn-complete` control record with no other chunks. See [Actions](/ai-chat/actions) for the agent-side schema setup.
|
|
837
836
|
|
|
838
837
|
Raw `chat.customAgent()` tasks receive `action` as `unknown` and must validate it in their own loop.
|
|
839
838
|
|
|
@@ -19,12 +19,11 @@ Provide `shouldCompact` to decide when to compact and `summarize` to generate th
|
|
|
19
19
|
|
|
20
20
|
```ts
|
|
21
21
|
import { chat } from "@trigger.dev/sdk/ai";
|
|
22
|
-
import { generateText, stepCountIs } from "ai";
|
|
22
|
+
import { streamText, generateText, stepCountIs } from "ai";
|
|
23
23
|
import { anthropic } from "@ai-sdk/anthropic";
|
|
24
24
|
|
|
25
25
|
export const myChat = chat.agent({
|
|
26
26
|
id: "my-chat",
|
|
27
|
-
registry,
|
|
28
27
|
compaction: {
|
|
29
28
|
shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000,
|
|
30
29
|
summarize: async ({ messages }) => {
|
|
@@ -35,8 +34,9 @@ export const myChat = chat.agent({
|
|
|
35
34
|
return result.text;
|
|
36
35
|
},
|
|
37
36
|
},
|
|
38
|
-
run: async ({ messages, signal
|
|
37
|
+
run: async ({ messages, signal }) => {
|
|
39
38
|
return streamText({
|
|
39
|
+
...chat.toStreamTextOptions({ registry }),
|
|
40
40
|
messages,
|
|
41
41
|
abortSignal: signal,
|
|
42
42
|
stopWhen: stepCountIs(15),
|
|
@@ -61,10 +61,6 @@ After each turn completes:
|
|
|
61
61
|
|
|
62
62
|
On the next turn, the LLM receives the compact summary instead of the full history — dramatically reducing token usage while preserving context.
|
|
63
63
|
|
|
64
|
-
<Note>
|
|
65
|
-
This is Trigger.dev's provider-agnostic compaction. To persist a **provider's own** compaction across turns instead (Anthropic context editing or OpenAI stored responses), and to fall back between providers without re-sending history, see [Native compaction & provider fallback](/ai-chat/patterns/native-compaction).
|
|
66
|
-
</Note>
|
|
67
|
-
|
|
68
64
|
## Customizing what gets persisted
|
|
69
65
|
|
|
70
66
|
By default, compaction only affects model messages — UI messages stay intact so users see the full conversation after a page refresh. You can customize this with `compactUIMessages`:
|
|
@@ -95,7 +91,7 @@ export const myChat = chat.agent({
|
|
|
95
91
|
...uiMessages.slice(-4), // Keep the last 4 messages
|
|
96
92
|
],
|
|
97
93
|
},
|
|
98
|
-
run: async ({ messages, signal
|
|
94
|
+
run: async ({ messages, signal }) => {
|
|
99
95
|
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
|
|
100
96
|
},
|
|
101
97
|
});
|
|
@@ -189,7 +185,7 @@ export const myChat = chat.agent({
|
|
|
189
185
|
data: { chatId, summary, totalTokens, messageCount },
|
|
190
186
|
});
|
|
191
187
|
},
|
|
192
|
-
run: async ({ messages, signal
|
|
188
|
+
run: async ({ messages, signal }) => {
|
|
193
189
|
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
|
|
194
190
|
},
|
|
195
191
|
});
|
|
@@ -205,7 +201,7 @@ Define a `compact` action that reuses your existing `summarize` function:
|
|
|
205
201
|
|
|
206
202
|
```ts
|
|
207
203
|
import { chat } from "@trigger.dev/sdk/ai";
|
|
208
|
-
import { generateText, generateId, convertToModelMessages } from "ai";
|
|
204
|
+
import { streamText, generateText, generateId, convertToModelMessages } from "ai";
|
|
209
205
|
import { anthropic } from "@ai-sdk/anthropic";
|
|
210
206
|
import { z } from "zod";
|
|
211
207
|
|
|
@@ -247,7 +243,7 @@ export const myChat = chat.agent({
|
|
|
247
243
|
]);
|
|
248
244
|
},
|
|
249
245
|
|
|
250
|
-
run: async ({ messages, signal
|
|
246
|
+
run: async ({ messages, signal }) => {
|
|
251
247
|
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
|
|
252
248
|
},
|
|
253
249
|
});
|
|
@@ -290,29 +290,6 @@ Read `turn.stopped` to tell a user stop from a full run cancel:
|
|
|
290
290
|
|
|
291
291
|
A hand-rolled loop wires this itself with `chat.createStopSignal()` and `chat.cleanupAbortedParts()`. Two things `createSession` handles for you are easy to get wrong there — see the [hand-rolled loop checklist](#hand-rolled-loop-checklist).
|
|
292
292
|
|
|
293
|
-
### Ending the conversation
|
|
294
|
-
|
|
295
|
-
`chat.close({ reason })` works in a custom agent exactly as it does in [`chat.agent`](/ai-chat/backend#ending-the-conversation): the session row is closed, further sends are refused with HTTP 409, and no continuation run is scheduled. Call it from anywhere in your loop.
|
|
296
|
-
|
|
297
|
-
```ts trigger/my-chat.ts
|
|
298
|
-
for await (const turn of session) {
|
|
299
|
-
const result = streamText({ model, messages: turn.messages, abortSignal: turn.signal });
|
|
300
|
-
|
|
301
|
-
// Close BEFORE turn.complete(): that call writes the turn boundary the
|
|
302
|
-
// browser stops reading at, and it carries the closed state out with it.
|
|
303
|
-
if (await overBudget(turn.chatId)) {
|
|
304
|
-
chat.close({ reason: "Monthly budget reached" });
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
await turn.complete(result);
|
|
308
|
-
if (turn.stopped) break;
|
|
309
|
-
}
|
|
310
|
-
```
|
|
311
|
-
|
|
312
|
-
The close is performed when your `run()` returns, so it lands whether you `break` out of the loop, return early, or keep iterating. A hand-rolled loop with no iterator at all works the same way, as long as you call `chat.close()` before the `chat.writeTurnComplete()` that ends the turn.
|
|
313
|
-
|
|
314
|
-
Only `chat.agent` and `chat.customAgent` bind the run to its Session, so `chat.close()` throws in a plain `task()`.
|
|
315
|
-
|
|
316
293
|
## Hand-rolled loop with primitives
|
|
317
294
|
|
|
318
295
|
For full control, skip `createSession` and compose the primitives directly:
|
|
@@ -239,11 +239,11 @@ This is an **import-chain** problem, not a runtime one. A "we'll strip the execu
|
|
|
239
239
|
|
|
240
240
|
export const myChat = chat.agent({
|
|
241
241
|
id: "my-chat",
|
|
242
|
-
run: async ({ messages, signal
|
|
242
|
+
run: async ({ messages, signal }) =>
|
|
243
243
|
streamText({
|
|
244
|
+
...chat.toStreamTextOptions({ tools: chatTools }),
|
|
244
245
|
model: anthropic("claude-sonnet-4-6"),
|
|
245
246
|
messages,
|
|
246
|
-
tools: chatTools,
|
|
247
247
|
stopWhen: stepCountIs(10),
|
|
248
248
|
abortSignal: signal,
|
|
249
249
|
}),
|
|
@@ -251,7 +251,7 @@ This is an **import-chain** problem, not a runtime one. A "we'll strip the execu
|
|
|
251
251
|
```
|
|
252
252
|
</Step>
|
|
253
253
|
<Step title="Build the head-start handler">
|
|
254
|
-
Call `chat.headStart({ agentId, run })`. It returns a standard Web Fetch handler: `(req: Request) => Promise<Response>`.
|
|
254
|
+
Call `chat.headStart({ agentId, run })`. It returns a standard Web Fetch handler: `(req: Request) => Promise<Response>`. Inside the `run` callback you call `streamText` yourself and spread `chat.toStreamTextOptions({ tools })` to inherit the SDK-owned wiring (messages, schema-only tools, `stopWhen: stepCountIs(1)`, abort signal). Add your own `model` and `system` on top.
|
|
255
255
|
|
|
256
256
|
```ts lib/chat-handler.ts
|
|
257
257
|
import { chat } from "@trigger.dev/sdk/chat-server";
|
|
@@ -261,23 +261,18 @@ This is an **import-chain** problem, not a runtime one. A "we'll strip the execu
|
|
|
261
261
|
|
|
262
262
|
export const chatHandler = chat.headStart({
|
|
263
263
|
agentId: "my-chat",
|
|
264
|
-
run: async ({
|
|
264
|
+
run: async ({ chat: helper }) =>
|
|
265
265
|
streamText({
|
|
266
|
+
...helper.toStreamTextOptions({ tools: headStartTools }),
|
|
266
267
|
model: anthropic("claude-sonnet-4-6"),
|
|
267
268
|
system: "You are a helpful assistant.",
|
|
268
|
-
tools: headStartTools,
|
|
269
269
|
}),
|
|
270
270
|
});
|
|
271
271
|
```
|
|
272
272
|
|
|
273
|
-
<
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
running past step 1 would splice a stream the agent is supposed to own. Setting
|
|
277
|
-
any of the four at the call site is a type error, and a throw if you get past
|
|
278
|
-
the types, rather than breaking the handover quietly. `chat.toStreamTextOptions()` is still there if you want to build the
|
|
279
|
-
options yourself.
|
|
280
|
-
</Note>
|
|
273
|
+
<Warning>
|
|
274
|
+
Don't set `stopWhen` here. The spread pins it to `stepCountIs(1)`, and overriding it makes the handler run steps the agent is supposed to own — the handover then splices a stream that has already moved past step 1.
|
|
275
|
+
</Warning>
|
|
281
276
|
|
|
282
277
|
<Tip>
|
|
283
278
|
Use the **same model** on both sides (route handler and `chat.agent`) to avoid a tone or style shift between step 1 and step 2+. Your LLM provider keys stay server-side in your warm process — Trigger.dev never holds them in this design.
|
|
@@ -630,27 +625,27 @@ chat.headStart<TTools>({
|
|
|
630
625
|
export const chatHandler = chat.headStart({
|
|
631
626
|
agentId: "my-chat",
|
|
632
627
|
triggerConfig: { tags: ["org:acme"], queue: "chat", machine: "small-2x" },
|
|
633
|
-
run: async ({
|
|
628
|
+
run: async ({ chat: helper }) =>
|
|
629
|
+
streamText({ ...helper.toStreamTextOptions({ tools: headStartTools }), model, system }),
|
|
634
630
|
});
|
|
635
631
|
```
|
|
636
632
|
|
|
637
633
|
The `run` callback receives:
|
|
638
634
|
|
|
639
|
-
- `messages: UIMessage[]
|
|
640
|
-
- `signal: AbortSignal
|
|
641
|
-
- `
|
|
642
|
-
- `chat: HeadStartChatHelper<TTools>`: exposes `chat.toStreamTextOptions({ tools })` for building the options by hand, plus a `chat.session` escape hatch for power users.
|
|
635
|
+
- `messages: UIMessage[]` — user messages parsed from the request body.
|
|
636
|
+
- `signal: AbortSignal` — fires when the request closes or the SDK times out the handover.
|
|
637
|
+
- `chat: HeadStartChatHelper<TTools>` — exposes `chat.toStreamTextOptions({ tools })` and a `chat.session` escape hatch for power users.
|
|
643
638
|
|
|
644
|
-
|
|
639
|
+
`chat.toStreamTextOptions({ tools })` returns options to spread into `streamText`. The SDK owns these keys — overriding them will break the protocol:
|
|
645
640
|
|
|
646
641
|
| Key | What the SDK sets | Why |
|
|
647
642
|
| --- | --- | --- |
|
|
648
643
|
| `messages` | `convertToModelMessages(uiMessages)` | First-turn user history |
|
|
649
|
-
| `
|
|
650
|
-
| `stopWhen` | `stepCountIs(1)` | Step 1 only
|
|
644
|
+
| `tools` | What you pass | Schema-only tools for step 1 |
|
|
645
|
+
| `stopWhen` | `stepCountIs(1)` | Step 1 only — agent picks up step 2+ |
|
|
651
646
|
| `abortSignal` | Combined request + idle timeout | Safe cleanup on disconnect |
|
|
652
647
|
|
|
653
|
-
You bring `model`, `system`, `providerOptions`, `prepareStep`, anything else `streamText` accepts.
|
|
648
|
+
You bring `model`, `system`, `providerOptions`, `prepareStep`, anything else `streamText` accepts.
|
|
654
649
|
|
|
655
650
|
#### The transport option
|
|
656
651
|
|
|
@@ -689,11 +684,11 @@ export async function POST(req: Request) {
|
|
|
689
684
|
agentId: "my-chat",
|
|
690
685
|
chatId, // session externalId; reuse it on the destination page
|
|
691
686
|
messages, // first-turn user history
|
|
692
|
-
run: async ({
|
|
687
|
+
run: async ({ chat: helper }) =>
|
|
693
688
|
streamText({
|
|
689
|
+
...helper.toStreamTextOptions({ tools: headStartTools }),
|
|
694
690
|
model: anthropic("claude-sonnet-4-6"),
|
|
695
691
|
system: "You are a helpful assistant.",
|
|
696
|
-
tools: headStartTools,
|
|
697
692
|
}),
|
|
698
693
|
});
|
|
699
694
|
|
|
@@ -740,13 +735,11 @@ chat.startHeadStart<TTools>({
|
|
|
740
735
|
triggerConfig?: Partial<SessionTriggerConfig>, // tags, queue, machine, …
|
|
741
736
|
apiClient?: ApiClientConfiguration, // when the agent lives in another project/env
|
|
742
737
|
metadata?: Record<string, unknown>, // merged into the run payload; never sent to the browser
|
|
743
|
-
}): Promise<{ chatId: string;
|
|
738
|
+
}): Promise<{ chatId: string; completion: Promise<void> }>
|
|
744
739
|
```
|
|
745
740
|
|
|
746
741
|
`completion` resolves once the head start finishes; `await` it or hand it to `waitUntil`. It rejects if the warm step or the dispatch fails.
|
|
747
742
|
|
|
748
|
-
`pendingVersion` is `true` when the agent run is parked waiting for the deployment carrying the session's [external deployment id](/deployment/version-skew-protection#chat-sessions). Step 1 still runs in your process and still reaches the browser, so pass the flag to the destination page if you want it to say a deploy is in progress rather than appear to stall on step 2.
|
|
749
|
-
|
|
750
743
|
### Limitations
|
|
751
744
|
|
|
752
745
|
- **First turn only.** Step 2+ and turn 2+ run on the trigger side. There's no per-turn "head start every turn" mode — the win comes from amortizing agent boot across the LLM call once.
|
|
@@ -442,48 +442,45 @@ function Chat({ chatId, transport }) {
|
|
|
442
442
|
|
|
443
443
|
## Sending actions
|
|
444
444
|
|
|
445
|
-
Send custom actions (undo, rollback, edit
|
|
445
|
+
Send custom actions (undo, rollback, edit) to the agent via `transport.sendAction()`. Actions wake the agent and fire only `hydrateMessages` (if configured) and `onAction` — they're not turns, so `onTurnStart` / `prepareMessages` / `onBeforeTurnComplete` / `onTurnComplete` and `run()` do not fire.
|
|
446
446
|
|
|
447
|
-
|
|
448
|
-
import { useChat } from "@ai-sdk/react";
|
|
449
|
-
import { useChatActions, useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
|
|
447
|
+
For optimistic UI, mirror the action's effect on the `useChat` state via `setMessages` while the request is in flight:
|
|
450
448
|
|
|
449
|
+
```tsx
|
|
451
450
|
function ChatControls({ chatId }: { chatId: string }) {
|
|
452
451
|
const transport = useTriggerChatTransport({
|
|
453
452
|
task: "my-chat",
|
|
454
453
|
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
|
|
455
|
-
startSession: ({ chatId, clientData }) =>
|
|
454
|
+
startSession: ({ chatId, clientData }) =>
|
|
455
|
+
startChatSession({ chatId, clientData }),
|
|
456
456
|
});
|
|
457
|
-
|
|
458
|
-
const {
|
|
457
|
+
|
|
458
|
+
const { setMessages } = useChat({ transport });
|
|
459
459
|
|
|
460
460
|
return (
|
|
461
461
|
<div>
|
|
462
462
|
<button
|
|
463
463
|
onClick={() => {
|
|
464
|
-
|
|
465
|
-
// push history changes back.
|
|
464
|
+
void transport.sendAction(chatId, { type: "undo" });
|
|
466
465
|
setMessages((prev) => prev.slice(0, -2));
|
|
467
|
-
void sendAction({ type: "undo" });
|
|
468
466
|
}}
|
|
469
467
|
>
|
|
470
468
|
Undo last exchange
|
|
471
469
|
</button>
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
470
|
+
<button
|
|
471
|
+
onClick={() => transport.sendAction(chatId, { type: "rollback", targetMessageId: "msg-5" })}
|
|
472
|
+
>
|
|
473
|
+
Rollback to message
|
|
475
474
|
</button>
|
|
476
475
|
</div>
|
|
477
476
|
);
|
|
478
477
|
}
|
|
479
478
|
```
|
|
480
479
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
The action payload is validated against the agent's `actionSchema` on the backend; invalid actions are rejected. See [Actions](/ai-chat/actions) for the backend setup.
|
|
480
|
+
The action payload is validated against the agent's `actionSchema` on the backend — invalid actions are rejected. See [Actions](/ai-chat/actions) for the backend setup.
|
|
484
481
|
|
|
485
482
|
<Note>
|
|
486
|
-
`
|
|
483
|
+
`sendAction` returns a `ReadableStream<UIMessageChunk>`. For side-effect-only actions (where `onAction` returns `void`), the stream completes immediately with `trigger:turn-complete`. For actions where `onAction` returns a `StreamTextResult`, the stream carries the assistant chunks the same way `sendMessages` does — `useChat` consumes them automatically.
|
|
487
484
|
</Note>
|
|
488
485
|
|
|
489
486
|
For server-to-server usage, `AgentChat` has the same method:
|
|
@@ -56,7 +56,7 @@ Then make these changes:
|
|
|
56
56
|
- Create a `chat.agent` task in `trigger/chat.ts`. Move the existing `streamText` call
|
|
57
57
|
into its `run` function UNCHANGED — same model, same `system`, same `temperature`,
|
|
58
58
|
same `stopWhen`, same provider options. Do not rewrite the prompt or swap the model.
|
|
59
|
-
-
|
|
59
|
+
- Spread `...chat.toStreamTextOptions({ tools })` as the FIRST property of that
|
|
60
60
|
`streamText` call, so the explicit options after it still win.
|
|
61
61
|
- `run` receives `ModelMessage[]` already. Delete the `convertToModelMessages` call.
|
|
62
62
|
- Forward the `signal` from `run` as `abortSignal` so Stop works.
|
|
@@ -145,9 +145,10 @@ import { tools } from "@/lib/tools";
|
|
|
145
145
|
export const myChat = chat.agent({
|
|
146
146
|
id: "my-chat",
|
|
147
147
|
tools,
|
|
148
|
-
run: async ({ messages, tools, signal
|
|
148
|
+
run: async ({ messages, tools, signal }) =>
|
|
149
149
|
streamText({
|
|
150
|
-
|
|
150
|
+
// Spread first, so every option below still wins.
|
|
151
|
+
...chat.toStreamTextOptions({ tools }),
|
|
151
152
|
model: anthropic("claude-sonnet-4-5"),
|
|
152
153
|
system: "You are a helpful assistant.",
|
|
153
154
|
messages,
|
|
@@ -162,10 +163,10 @@ Four things changed inside the `streamText` call, and `tools` moved onto the age
|
|
|
162
163
|
- **`messages` arrives as `ModelMessage[]`.** The runtime converts the frontend's `UIMessage[]` for you, so `convertToModelMessages` is gone.
|
|
163
164
|
- **`abortSignal` comes from `signal` on the payload**, not `req.signal`. It fires on stop and on cancel.
|
|
164
165
|
- **Return the `StreamTextResult`.** It's piped to the frontend automatically — no `toUIMessageStreamResponse`. If `streamText` is buried in a helper, call `await chat.pipe(result)` from anywhere in the task instead and let `run` resolve `void`.
|
|
165
|
-
-
|
|
166
|
+
- **`...chat.toStreamTextOptions()` is spread first.** It wires up the `prepareStep` callback behind [compaction](/ai-chat/compaction), [mid-turn steering](/ai-chat/pending-messages), and [background injection](/ai-chat/background-injection), plus the system prompt set via [`chat.prompt()`](/ai-chat/backend#using-prompts) and telemetry.
|
|
166
167
|
|
|
167
168
|
<Warning>
|
|
168
|
-
|
|
169
|
+
Omitting `...chat.toStreamTextOptions()` throws no error — compaction, steering, and background injection just silently never run. Spread it first so any explicit override you write after it takes precedence.
|
|
169
170
|
</Warning>
|
|
170
171
|
|
|
171
172
|
There's no `maxDuration` equivalent to set. A turn isn't bounded by a function timeout; a run stays alive across turns and suspends when nothing is happening.
|
|
@@ -356,9 +357,9 @@ export const myChat = chat.agent({
|
|
|
356
357
|
}),
|
|
357
358
|
]);
|
|
358
359
|
},
|
|
359
|
-
run: async ({ messages, tools, signal
|
|
360
|
+
run: async ({ messages, tools, signal }) =>
|
|
360
361
|
streamText({
|
|
361
|
-
tools,
|
|
362
|
+
...chat.toStreamTextOptions({ tools }),
|
|
362
363
|
model: anthropic("claude-sonnet-4-5"),
|
|
363
364
|
system: "You are a helpful assistant.",
|
|
364
365
|
messages,
|
|
@@ -467,21 +468,18 @@ Head Start brings the route handler back for exactly that first turn. It runs st
|
|
|
467
468
|
|
|
468
469
|
export const chatHandler = chat.headStart({
|
|
469
470
|
agentId: "my-chat",
|
|
470
|
-
run: async ({
|
|
471
|
+
run: async ({ chat: helper }) =>
|
|
471
472
|
streamText({
|
|
473
|
+
...helper.toStreamTextOptions({ tools: headStartTools }),
|
|
472
474
|
model: anthropic("claude-sonnet-4-5"),
|
|
473
475
|
system: "You are a helpful assistant.",
|
|
474
|
-
tools: headStartTools,
|
|
475
476
|
}),
|
|
476
477
|
});
|
|
477
478
|
```
|
|
478
479
|
|
|
479
|
-
<
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
handover rather than degrading it: `stopWhen` is pinned to `stepCountIs(1)`
|
|
483
|
-
because the agent, not the handler, runs step 2 onward.
|
|
484
|
-
</Note>
|
|
480
|
+
<Warning>
|
|
481
|
+
Spread `toStreamTextOptions()` first and add only your own keys after it. It owns `messages`, `tools`, `abortSignal`, and `stopWhen` — and unlike the agent-side spread, re-setting any of those breaks the handover rather than degrading it. `stopWhen` in particular is pinned to `stepCountIs(1)`: the agent, not the handler, runs step 2 onward.
|
|
482
|
+
</Warning>
|
|
485
483
|
|
|
486
484
|
Your provider keys never leave your server — the first-turn model call runs in your process, so that environment needs whatever the model requires.
|
|
487
485
|
</Step>
|
|
@@ -560,7 +558,7 @@ The shape is identical outside Next.js. The agent task and the React component d
|
|
|
560
558
|
|
|
561
559
|
**The head-start route dies mid-turn on Vercel.** The handler holds the SSE response open until the agent signals turn-complete, so the function timeout has to cover the whole turn, not just step 1. Set `maxDuration` on that route segment.
|
|
562
560
|
|
|
563
|
-
**Compaction and steering do nothing.**
|
|
561
|
+
**Compaction and steering do nothing.** The `...chat.toStreamTextOptions()` spread is missing, or something before it in the object is overwriting `prepareStep`. Spread it as the first property.
|
|
564
562
|
|
|
565
563
|
**`toModelOutput` works on the first turn, then stops.** Tools are declared only on `streamText`. Declare the same set on `chat.agent({ tools })` too, and read it back off the `run` payload.
|
|
566
564
|
|