@trigger.dev/sdk 4.5.0-rc.6 → 4.5.0-rc.7
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/v3/ai.d.ts +171 -5
- package/dist/commonjs/v3/ai.js +309 -22
- package/dist/commonjs/v3/ai.js.map +1 -1
- package/dist/commonjs/v3/chat-server.d.ts +8 -0
- package/dist/commonjs/v3/chat-server.js +32 -10
- package/dist/commonjs/v3/chat-server.js.map +1 -1
- package/dist/commonjs/v3/chat-server.test.js +51 -0
- package/dist/commonjs/v3/chat-server.test.js.map +1 -1
- package/dist/commonjs/v3/createStartSessionAction.test.js +30 -0
- package/dist/commonjs/v3/createStartSessionAction.test.js.map +1 -1
- package/dist/commonjs/v3/sessions.d.ts +3 -2
- package/dist/commonjs/v3/sessions.js +3 -2
- package/dist/commonjs/v3/sessions.js.map +1 -1
- package/dist/commonjs/version.js +1 -1
- package/dist/esm/v3/ai.d.ts +171 -5
- package/dist/esm/v3/ai.js +309 -22
- package/dist/esm/v3/ai.js.map +1 -1
- package/dist/esm/v3/chat-server.d.ts +8 -0
- package/dist/esm/v3/chat-server.js +32 -10
- package/dist/esm/v3/chat-server.js.map +1 -1
- package/dist/esm/v3/chat-server.test.js +51 -0
- package/dist/esm/v3/chat-server.test.js.map +1 -1
- package/dist/esm/v3/createStartSessionAction.test.js +30 -0
- package/dist/esm/v3/createStartSessionAction.test.js.map +1 -1
- package/dist/esm/v3/sessions.d.ts +3 -2
- package/dist/esm/v3/sessions.js +3 -2
- package/dist/esm/v3/sessions.js.map +1 -1
- package/dist/esm/version.js +1 -1
- package/docs/ai/prompts.mdx +430 -0
- package/docs/ai-chat/actions.mdx +115 -0
- package/docs/ai-chat/anatomy.mdx +71 -0
- package/docs/ai-chat/backend.mdx +817 -0
- package/docs/ai-chat/background-injection.mdx +221 -0
- package/docs/ai-chat/changelog.mdx +850 -0
- package/docs/ai-chat/chat-local.mdx +174 -0
- package/docs/ai-chat/client-protocol.mdx +1081 -0
- package/docs/ai-chat/compaction.mdx +411 -0
- package/docs/ai-chat/custom-agents.mdx +364 -0
- package/docs/ai-chat/error-handling.mdx +415 -0
- package/docs/ai-chat/fast-starts.mdx +672 -0
- package/docs/ai-chat/frontend.mdx +580 -0
- package/docs/ai-chat/how-it-works.mdx +230 -0
- package/docs/ai-chat/lifecycle-hooks.mdx +530 -0
- package/docs/ai-chat/mcp.mdx +101 -0
- package/docs/ai-chat/overview.mdx +90 -0
- package/docs/ai-chat/patterns/branching-conversations.mdx +284 -0
- package/docs/ai-chat/patterns/code-sandbox.mdx +126 -0
- package/docs/ai-chat/patterns/database-persistence.mdx +414 -0
- package/docs/ai-chat/patterns/human-in-the-loop.mdx +275 -0
- package/docs/ai-chat/patterns/large-payloads.mdx +169 -0
- package/docs/ai-chat/patterns/oom-resilience.mdx +120 -0
- package/docs/ai-chat/patterns/persistence-and-replay.mdx +211 -0
- package/docs/ai-chat/patterns/recovery-boot.mdx +230 -0
- package/docs/ai-chat/patterns/skills.mdx +221 -0
- package/docs/ai-chat/patterns/sub-agents.mdx +383 -0
- package/docs/ai-chat/patterns/tool-result-auditing.mdx +148 -0
- package/docs/ai-chat/patterns/trusted-edge-signals.mdx +337 -0
- package/docs/ai-chat/patterns/version-upgrades.mdx +172 -0
- package/docs/ai-chat/pending-messages.mdx +343 -0
- package/docs/ai-chat/prompt-caching.mdx +206 -0
- package/docs/ai-chat/quick-start.mdx +161 -0
- package/docs/ai-chat/reference.mdx +909 -0
- package/docs/ai-chat/server-chat.mdx +263 -0
- package/docs/ai-chat/sessions.mdx +333 -0
- package/docs/ai-chat/testing.mdx +682 -0
- package/docs/ai-chat/tools.mdx +191 -0
- package/docs/ai-chat/types.mdx +242 -0
- package/docs/ai-chat/upgrade-guide.mdx +515 -0
- package/docs/apikeys.mdx +54 -0
- package/docs/building-with-ai.mdx +261 -0
- package/docs/bulk-actions.mdx +49 -0
- package/docs/changelog.mdx +6 -0
- package/docs/cli-deploy-commands.mdx +9 -0
- package/docs/cli-dev-commands.mdx +9 -0
- package/docs/cli-dev.mdx +8 -0
- package/docs/cli-init-commands.mdx +58 -0
- package/docs/cli-introduction.mdx +25 -0
- package/docs/cli-list-profiles-commands.mdx +42 -0
- package/docs/cli-login-commands.mdx +33 -0
- package/docs/cli-logout-commands.mdx +33 -0
- package/docs/cli-preview-archive.mdx +59 -0
- package/docs/cli-promote-commands.mdx +9 -0
- package/docs/cli-switch.mdx +43 -0
- package/docs/cli-update-commands.mdx +42 -0
- package/docs/cli-whoami-commands.mdx +33 -0
- package/docs/community.mdx +6 -0
- package/docs/config/config-file.mdx +602 -0
- package/docs/config/extensions/additionalFiles.mdx +38 -0
- package/docs/config/extensions/additionalPackages.mdx +40 -0
- package/docs/config/extensions/aptGet.mdx +34 -0
- package/docs/config/extensions/audioWaveform.mdx +20 -0
- package/docs/config/extensions/custom.mdx +380 -0
- package/docs/config/extensions/emitDecoratorMetadata.mdx +29 -0
- package/docs/config/extensions/esbuildPlugin.mdx +31 -0
- package/docs/config/extensions/ffmpeg.mdx +45 -0
- package/docs/config/extensions/lightpanda.mdx +56 -0
- package/docs/config/extensions/overview.mdx +67 -0
- package/docs/config/extensions/playwright.mdx +195 -0
- package/docs/config/extensions/prismaExtension.mdx +1014 -0
- package/docs/config/extensions/puppeteer.mdx +30 -0
- package/docs/config/extensions/pythonExtension.mdx +182 -0
- package/docs/config/extensions/syncEnvVars.mdx +291 -0
- package/docs/context.mdx +235 -0
- package/docs/database-connections.mdx +213 -0
- package/docs/deploy-environment-variables.mdx +435 -0
- package/docs/deployment/atomic-deployment.mdx +172 -0
- package/docs/deployment/overview.mdx +257 -0
- package/docs/deployment/preview-branches.mdx +224 -0
- package/docs/errors-retrying.mdx +379 -0
- package/docs/github-actions.mdx +222 -0
- package/docs/github-integration.mdx +136 -0
- package/docs/github-repo.mdx +8 -0
- package/docs/help-email.mdx +6 -0
- package/docs/help-slack.mdx +11 -0
- package/docs/hidden-tasks.mdx +56 -0
- package/docs/how-it-works.mdx +454 -0
- package/docs/how-to-reduce-your-spend.mdx +217 -0
- package/docs/idempotency.mdx +504 -0
- package/docs/introduction.mdx +223 -0
- package/docs/limits.mdx +241 -0
- package/docs/logging.mdx +195 -0
- package/docs/machines.mdx +952 -0
- package/docs/manual-setup.mdx +632 -0
- package/docs/mcp-agent-rules.mdx +41 -0
- package/docs/mcp-introduction.mdx +385 -0
- package/docs/mcp-tools.mdx +273 -0
- package/docs/migrating-from-v3.mdx +334 -0
- package/docs/observability/dashboards.mdx +102 -0
- package/docs/observability/query.mdx +585 -0
- package/docs/open-source-contributing.mdx +16 -0
- package/docs/open-source-self-hosting.mdx +541 -0
- package/docs/private-networking/aws-console-setup.mdx +304 -0
- package/docs/private-networking/overview.mdx +144 -0
- package/docs/private-networking/troubleshooting.mdx +78 -0
- package/docs/queue-concurrency.mdx +354 -0
- package/docs/quick-start.mdx +97 -0
- package/docs/realtime/auth.mdx +208 -0
- package/docs/realtime/backend/overview.mdx +45 -0
- package/docs/realtime/backend/streams.mdx +418 -0
- package/docs/realtime/backend/subscribe.mdx +225 -0
- package/docs/realtime/how-it-works.mdx +94 -0
- package/docs/realtime/overview.mdx +63 -0
- package/docs/realtime/react-hooks/overview.mdx +73 -0
- package/docs/realtime/react-hooks/streams.mdx +449 -0
- package/docs/realtime/react-hooks/subscribe.mdx +674 -0
- package/docs/realtime/react-hooks/swr.mdx +87 -0
- package/docs/realtime/react-hooks/triggering.mdx +194 -0
- package/docs/realtime/react-hooks/use-wait-token.mdx +34 -0
- package/docs/realtime/run-object.mdx +174 -0
- package/docs/replaying.mdx +72 -0
- package/docs/request-feature.mdx +6 -0
- package/docs/roadmap.mdx +6 -0
- package/docs/run-tests.mdx +20 -0
- package/docs/run-usage.mdx +113 -0
- package/docs/runs/heartbeats.mdx +38 -0
- package/docs/runs/max-duration.mdx +139 -0
- package/docs/runs/metadata.mdx +734 -0
- package/docs/runs/priority.mdx +31 -0
- package/docs/runs.mdx +396 -0
- package/docs/self-hosting/docker.mdx +458 -0
- package/docs/self-hosting/env/supervisor.mdx +74 -0
- package/docs/self-hosting/env/webapp.mdx +276 -0
- package/docs/self-hosting/kubernetes.mdx +601 -0
- package/docs/self-hosting/overview.mdx +108 -0
- package/docs/skills.mdx +85 -0
- package/docs/tags.mdx +120 -0
- package/docs/tasks/overview.mdx +697 -0
- package/docs/tasks/scheduled.mdx +382 -0
- package/docs/tasks/schemaTask.mdx +413 -0
- package/docs/tasks/streams.mdx +884 -0
- package/docs/triggering.mdx +1320 -0
- package/docs/troubleshooting-alerts.mdx +385 -0
- package/docs/troubleshooting-debugging-in-vscode.mdx +8 -0
- package/docs/troubleshooting-github-issues.mdx +6 -0
- package/docs/troubleshooting-uptime-status.mdx +6 -0
- package/docs/troubleshooting.mdx +398 -0
- package/docs/upgrading-packages.mdx +80 -0
- package/docs/vercel-integration.mdx +207 -0
- package/docs/versioning.mdx +56 -0
- package/docs/video-walkthrough.mdx +23 -0
- package/docs/wait-for-token.mdx +540 -0
- package/docs/wait-for.mdx +42 -0
- package/docs/wait-until.mdx +53 -0
- package/docs/wait.mdx +18 -0
- package/docs/writing-tasks-introduction.mdx +33 -0
- package/package.json +8 -5
- package/skills/trigger-authoring-chat-agent/SKILL.md +296 -0
- package/skills/trigger-authoring-tasks/SKILL.md +254 -0
- package/skills/trigger-chat-agent-advanced/SKILL.md +368 -0
- package/skills/trigger-cost-savings/SKILL.md +116 -0
- package/skills/trigger-realtime-and-frontend/SKILL.md +276 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Custom agents"
|
|
3
|
+
sidebarTitle: "Custom agents"
|
|
4
|
+
description: "Build chat agents without chat.agent()'s managed lifecycle: register with chat.customAgent(), then drive turns with the createSession iterator or a hand-rolled loop."
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
import RcBanner from "/snippets/ai-chat-rc-banner.mdx";
|
|
8
|
+
|
|
9
|
+
<RcBanner />
|
|
10
|
+
|
|
11
|
+
**A custom agent is a task you register with `chat.customAgent()` and drive yourself — either with the managed turn iterator from `chat.createSession()`, or with a fully hand-rolled loop over the raw chat primitives.** You give up `chat.agent()`'s lifecycle hooks and automatic continuation recovery; you gain inline control over every turn, and (at the lowest level) full control over the stream conversion.
|
|
12
|
+
|
|
13
|
+
See the [comparison table](/ai-chat/backend) before dropping down. The frontend is unchanged either way: all levels speak the same wire protocol, so [`useTriggerChatTransport`](/ai-chat/frontend) points at a custom agent exactly like a `chat.agent()`.
|
|
14
|
+
|
|
15
|
+
## chat.customAgent()
|
|
16
|
+
|
|
17
|
+
`chat.customAgent()` is a thin wrapper around `task()` that does two things: it registers the task as an agent (so it appears in the agent dashboard, the playground, and the MCP server's `list_agents`), and it binds the run to its backing [Session](/ai-chat/sessions) so the `chat.*` primitives resolve to the right `.in`/`.out` channels. There is no managed lifecycle — no turn loop, no hooks, no preload handling.
|
|
18
|
+
|
|
19
|
+
A plain `task()` works with the same primitives but stays invisible to the agent surfaces, so prefer `customAgent` unless you specifically don't want the task listed as an agent.
|
|
20
|
+
|
|
21
|
+
Inside the wrapper, pick one of two loop styles:
|
|
22
|
+
|
|
23
|
+
- **[Managed loop](#managed-loop-chatcreatesession)** — `chat.createSession()` yields turns; the SDK handles stop signals, accumulation, idle suspend/resume, and turn-complete signaling. You write the turn body.
|
|
24
|
+
- **[Hand-rolled loop](#hand-rolled-loop-with-primitives)** — you write the loop itself with `chat.messages`, `MessageAccumulator`, `pipeAndCapture`, and `writeTurnComplete`. The right choice when you need complete control over `.toUIMessageStream()` (e.g. `onFinish`, `originalMessages`) beyond what `chat.setUIMessageStreamOptions()` provides, or you're implementing a custom protocol.
|
|
25
|
+
|
|
26
|
+
## Managed loop: chat.createSession()
|
|
27
|
+
|
|
28
|
+
`chat.createSession()` gives you an async iterator of `ChatTurn` objects. Each turn arrives with the accumulated history, a combined stop+cancel signal, and helpers to finish the turn:
|
|
29
|
+
|
|
30
|
+
```ts trigger/my-chat.ts
|
|
31
|
+
import { chat, type ChatTaskWirePayload } from "@trigger.dev/sdk/ai";
|
|
32
|
+
import { streamText, stepCountIs } from "ai";
|
|
33
|
+
import { anthropic } from "@ai-sdk/anthropic";
|
|
34
|
+
|
|
35
|
+
export const myChat = chat.customAgent({
|
|
36
|
+
id: "my-chat",
|
|
37
|
+
run: async (payload: ChatTaskWirePayload, { signal }) => {
|
|
38
|
+
// One-time initialization — plain code, no hooks. Upsert, not create:
|
|
39
|
+
// continuation runs boot with the row already in place.
|
|
40
|
+
const clientData = payload.metadata as { userId: string };
|
|
41
|
+
await db.chat.upsert({
|
|
42
|
+
where: { id: payload.chatId },
|
|
43
|
+
create: { id: payload.chatId, userId: clientData.userId },
|
|
44
|
+
update: {},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const session = chat.createSession(payload, {
|
|
48
|
+
signal,
|
|
49
|
+
idleTimeoutInSeconds: 60,
|
|
50
|
+
timeout: "1h",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
for await (const turn of session) {
|
|
54
|
+
// Persist the incoming user message BEFORE streaming — this is your
|
|
55
|
+
// onTurnStart equivalent. Without it, a page reload mid-stream
|
|
56
|
+
// restores the assistant text (replayed from the session) but loses
|
|
57
|
+
// the user message that prompted it.
|
|
58
|
+
await db.chat.update({
|
|
59
|
+
where: { id: turn.chatId },
|
|
60
|
+
data: { messages: turn.uiMessages },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const result = streamText({
|
|
64
|
+
model: anthropic("claude-sonnet-4-5"),
|
|
65
|
+
messages: turn.messages,
|
|
66
|
+
abortSignal: turn.signal,
|
|
67
|
+
stopWhen: stepCountIs(15),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Pipe, capture, accumulate, and signal turn-complete — all in one call
|
|
71
|
+
await turn.complete(result);
|
|
72
|
+
|
|
73
|
+
// Persist the full exchange after the turn — your onTurnComplete equivalent
|
|
74
|
+
await db.chat.update({
|
|
75
|
+
where: { id: turn.chatId },
|
|
76
|
+
data: { messages: turn.uiMessages },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
<Warning>
|
|
84
|
+
If you pass `compaction` or `pendingMessages` to `chat.createSession()`, you must also pass `prepareStep: turn.prepareStep()` to `streamText` (or spread `chat.toStreamTextOptions()`, which wires it automatically). Without it, both features silently no-op.
|
|
85
|
+
</Warning>
|
|
86
|
+
|
|
87
|
+
### ChatSessionOptions
|
|
88
|
+
|
|
89
|
+
| Option | Type | Default | Description |
|
|
90
|
+
| ---------------------- | ---------------------------- | ----------- | -------------------------------------------------------------------------------------------------- |
|
|
91
|
+
| `signal` | `AbortSignal` | required | Run-level cancel signal (from task context) |
|
|
92
|
+
| `idleTimeoutInSeconds` | `number` | `30` | Seconds to stay idle between turns before suspending |
|
|
93
|
+
| `timeout` | `string` | `"1h"` | Duration string for suspend timeout |
|
|
94
|
+
| `maxTurns` | `number` | `100` | Max turns before ending |
|
|
95
|
+
| `compaction` | `ChatAgentCompactionOptions` | `undefined` | Automatic context [compaction](/ai-chat/compaction) — same options as on `chat.agent()` |
|
|
96
|
+
| `pendingMessages` | `PendingMessagesOptions` | `undefined` | Mid-execution [message injection](/ai-chat/pending-messages) — same options as on `chat.agent()` |
|
|
97
|
+
|
|
98
|
+
Between turns the run idles on `waitWithIdleTimeout`: after `idleTimeoutInSeconds` with no message it suspends (compute is freed), and the next message restores it on the same run — the same warm/suspended pipeline `chat.agent()` uses.
|
|
99
|
+
|
|
100
|
+
### ChatTurn
|
|
101
|
+
|
|
102
|
+
Each turn yielded by the iterator provides:
|
|
103
|
+
|
|
104
|
+
| Field | Type | Description |
|
|
105
|
+
| ------------------- | --------------------------------- | -------------------------------------------------------- |
|
|
106
|
+
| `number` | `number` | Turn number (0-indexed) |
|
|
107
|
+
| `chatId` | `string` | Chat session ID |
|
|
108
|
+
| `trigger` | `string` | What triggered this turn |
|
|
109
|
+
| `clientData` | `unknown` | Client data from the transport |
|
|
110
|
+
| `messages` | `ModelMessage[]` | Full accumulated model messages — pass to `streamText` |
|
|
111
|
+
| `uiMessages` | `UIMessage[]` | Full accumulated UI messages — use for persistence |
|
|
112
|
+
| `signal` | `AbortSignal` | Combined stop+cancel signal (fresh each turn) |
|
|
113
|
+
| `stopped` | `boolean` | Whether the user stopped generation this turn |
|
|
114
|
+
| `continuation` | `boolean` | Whether this is a continuation run |
|
|
115
|
+
| `previousTurnUsage` | `LanguageModelUsage \| undefined` | Token usage from the previous turn (undefined on turn 0) |
|
|
116
|
+
| `totalUsage` | `LanguageModelUsage` | Cumulative token usage across all completed turns |
|
|
117
|
+
| `handover` | `{ isFinal: boolean } \| null` | The [`chat.headStart`](/ai-chat/fast-starts#handover-with-custom-agents) handover for this turn (turn 0 only); `null` otherwise |
|
|
118
|
+
|
|
119
|
+
| Method | Description |
|
|
120
|
+
| ----------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
121
|
+
| `turn.complete(source?)` | Pipe stream, capture response, accumulate, and signal turn-complete. Call with no source on a final head-start handover (`turn.handover.isFinal`), where the warm step-1 partial is already the response |
|
|
122
|
+
| `turn.done()` | Signal turn-complete only (when you have piped manually) |
|
|
123
|
+
| `turn.addResponse(response)` | Add a response to the accumulator manually |
|
|
124
|
+
| `turn.setMessages(uiMessages)`| Replace the accumulated messages — continuation seeding and on-demand compaction |
|
|
125
|
+
| `turn.prepareStep()` | `prepareStep` callback wiring compaction + injection — pass to `streamText` when not spreading `chat.toStreamTextOptions()` |
|
|
126
|
+
|
|
127
|
+
### Continuation runs and history seeding
|
|
128
|
+
|
|
129
|
+
`chat.agent()` rebuilds conversation history automatically when a chat continues on a fresh run (after a cancel, crash, version upgrade, or TTL expiry) — via its snapshot/replay boot or your `hydrateMessages` hook. Custom agents do none of that: a continuation run starts with an **empty accumulator**, and history restoration is your job.
|
|
130
|
+
|
|
131
|
+
With `createSession`, check `turn.continuation` on the first turn and seed from your store with `turn.setMessages()`:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
for await (const turn of session) {
|
|
135
|
+
if (turn.continuation && turn.number === 0) {
|
|
136
|
+
const row = await db.chat.findUnique({ where: { id: turn.chatId } });
|
|
137
|
+
const stored = (row?.messages ?? []) as UIMessage[];
|
|
138
|
+
if (stored.length > 0) {
|
|
139
|
+
// Keep any incoming message that isn't already persisted
|
|
140
|
+
const incoming = turn.uiMessages.filter((m) => !stored.some((s) => s.id === m.id));
|
|
141
|
+
await turn.setMessages([...stored, ...incoming]);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ... streamText + turn.complete as usual
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Without this, a resumed chat silently loses its history: the model sees only the message that triggered the continuation. In a hand-rolled loop, seed by passing the stored history into the turn-0 `addIncoming` call — shown in the example below.
|
|
150
|
+
|
|
151
|
+
### turn.complete() vs manual control
|
|
152
|
+
|
|
153
|
+
`turn.complete(result)` is the one-call path — it handles piping, capturing the response, accumulating messages, cleaning up aborted parts on a stop, and writing the turn-complete chunk.
|
|
154
|
+
|
|
155
|
+
For more control, you can do each step manually:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
for await (const turn of session) {
|
|
159
|
+
const result = streamText({
|
|
160
|
+
model: anthropic("claude-sonnet-4-5"),
|
|
161
|
+
messages: turn.messages,
|
|
162
|
+
abortSignal: turn.signal,
|
|
163
|
+
stopWhen: stepCountIs(15),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Manual: pipe and capture separately
|
|
167
|
+
const response = await chat.pipeAndCapture(result, { signal: turn.signal });
|
|
168
|
+
|
|
169
|
+
if (response) {
|
|
170
|
+
// Custom processing before accumulating
|
|
171
|
+
await turn.addResponse(response);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Custom persistence, analytics, etc.
|
|
175
|
+
await db.chat.update({ ... });
|
|
176
|
+
|
|
177
|
+
// Must call done() when not using complete()
|
|
178
|
+
await turn.done();
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Hand-rolled loop with primitives
|
|
183
|
+
|
|
184
|
+
For full control, skip `createSession` and compose the primitives directly:
|
|
185
|
+
|
|
186
|
+
| Primitive | Description |
|
|
187
|
+
| ------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
188
|
+
| `chat.messages` | Input stream for incoming messages — use `.waitWithIdleTimeout()` to wait for the next turn |
|
|
189
|
+
| `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream |
|
|
190
|
+
| `chat.pipeAndCapture(result)` | Pipe a `StreamTextResult` to the chat stream and capture the response |
|
|
191
|
+
| `chat.writeTurnComplete()` | Signal the frontend that the current turn is complete |
|
|
192
|
+
| `chat.MessageAccumulator` | Accumulates conversation messages across turns |
|
|
193
|
+
| `chat.pipe(stream)` | Pipe a stream to the frontend (no response capture) |
|
|
194
|
+
| `chat.cleanupAbortedParts(msg)` | Clean up incomplete parts from a stopped response |
|
|
195
|
+
|
|
196
|
+
A complete loop:
|
|
197
|
+
|
|
198
|
+
```ts trigger/my-chat-raw.ts
|
|
199
|
+
import { chat, type ChatTaskWirePayload } from "@trigger.dev/sdk/ai";
|
|
200
|
+
import { streamText, stepCountIs } from "ai";
|
|
201
|
+
import { anthropic } from "@ai-sdk/anthropic";
|
|
202
|
+
|
|
203
|
+
export const myChat = chat.customAgent({
|
|
204
|
+
id: "my-chat-raw",
|
|
205
|
+
run: async (payload: ChatTaskWirePayload, { signal: runSignal }) => {
|
|
206
|
+
let currentPayload = payload;
|
|
207
|
+
|
|
208
|
+
// Handle preload — wait for the first real message
|
|
209
|
+
if (currentPayload.trigger === "preload") {
|
|
210
|
+
const result = await chat.messages.waitWithIdleTimeout({
|
|
211
|
+
idleTimeoutInSeconds: 60,
|
|
212
|
+
timeout: "1h",
|
|
213
|
+
spanName: "waiting for first message",
|
|
214
|
+
});
|
|
215
|
+
if (!result.ok) return;
|
|
216
|
+
currentPayload = result.output;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const stop = chat.createStopSignal();
|
|
220
|
+
const conversation = new chat.MessageAccumulator();
|
|
221
|
+
|
|
222
|
+
// Continuation runs (cancel, crash, upgrade) start with an empty
|
|
223
|
+
// accumulator — fetch stored history so turn 0 can seed it.
|
|
224
|
+
let continuationSeed: UIMessage[] = [];
|
|
225
|
+
if (currentPayload.continuation) {
|
|
226
|
+
const row = await db.chat.findUnique({ where: { id: currentPayload.chatId } });
|
|
227
|
+
continuationSeed = (row?.messages ?? []) as UIMessage[];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for (let turn = 0; turn < 100; turn++) {
|
|
231
|
+
stop.reset();
|
|
232
|
+
|
|
233
|
+
// The wire payload carries at most one new message per turn. Turn 0
|
|
234
|
+
// REPLACES the accumulator, so seed stored history through
|
|
235
|
+
// addIncoming together with the incoming message — a setMessages
|
|
236
|
+
// call before the loop would be wiped here.
|
|
237
|
+
const incoming = currentPayload.message ? [currentPayload.message] : [];
|
|
238
|
+
const turnInput =
|
|
239
|
+
turn === 0 && continuationSeed.length > 0
|
|
240
|
+
? [...continuationSeed.filter((s) => !incoming.some((m) => m.id === s.id)), ...incoming]
|
|
241
|
+
: incoming;
|
|
242
|
+
const messages = await conversation.addIncoming(turnInput, currentPayload.trigger, turn);
|
|
243
|
+
|
|
244
|
+
// Persist the incoming user message before streaming so a
|
|
245
|
+
// mid-stream reload doesn't lose it.
|
|
246
|
+
await db.chat.update({
|
|
247
|
+
where: { id: currentPayload.chatId },
|
|
248
|
+
data: { messages: conversation.uiMessages },
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const combinedSignal = AbortSignal.any([runSignal, stop.signal]);
|
|
252
|
+
|
|
253
|
+
const result = streamText({
|
|
254
|
+
model: anthropic("claude-sonnet-4-5"),
|
|
255
|
+
messages,
|
|
256
|
+
abortSignal: combinedSignal,
|
|
257
|
+
stopWhen: stepCountIs(15),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
let response;
|
|
261
|
+
try {
|
|
262
|
+
response = await chat.pipeAndCapture(result, { signal: combinedSignal });
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
265
|
+
if (runSignal.aborted) break;
|
|
266
|
+
// Stop — fall through to accumulate partial
|
|
267
|
+
} else {
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (response) {
|
|
273
|
+
const cleaned =
|
|
274
|
+
stop.signal.aborted && !runSignal.aborted ? chat.cleanupAbortedParts(response) : response;
|
|
275
|
+
await conversation.addResponse(cleaned);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (runSignal.aborted) break;
|
|
279
|
+
|
|
280
|
+
// Persist, analytics, etc.
|
|
281
|
+
await db.chat.update({
|
|
282
|
+
where: { id: currentPayload.chatId },
|
|
283
|
+
data: { messages: conversation.uiMessages },
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await chat.writeTurnComplete();
|
|
287
|
+
|
|
288
|
+
// Wait for the next message
|
|
289
|
+
const next = await chat.messages.waitWithIdleTimeout({
|
|
290
|
+
idleTimeoutInSeconds: 60,
|
|
291
|
+
timeout: "1h",
|
|
292
|
+
spanName: "waiting for next message",
|
|
293
|
+
});
|
|
294
|
+
if (!next.ok) break;
|
|
295
|
+
currentPayload = next.output;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
stop.cleanup();
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### MessageAccumulator
|
|
304
|
+
|
|
305
|
+
`addIncoming(messages, trigger, turn)` has two modes:
|
|
306
|
+
|
|
307
|
+
- **Turn 0 or `trigger === "regenerate-message"`: replaces** the accumulator with exactly what you pass. This is why continuation seeding goes through `addIncoming` (above), and why a regenerate needs you to slice your own history — the wire omits the message on regenerate, so pass the stored history minus the last assistant message.
|
|
308
|
+
- **Every other turn: appends** what you pass (the wire carries at most the one new user message).
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
const conversation = new chat.MessageAccumulator();
|
|
312
|
+
|
|
313
|
+
// Returns full accumulated ModelMessage[] for streamText
|
|
314
|
+
const messages = await conversation.addIncoming(
|
|
315
|
+
payload.message ? [payload.message] : [],
|
|
316
|
+
payload.trigger,
|
|
317
|
+
turn
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// After piping, add the response
|
|
321
|
+
const response = await chat.pipeAndCapture(result);
|
|
322
|
+
if (response) await conversation.addResponse(response);
|
|
323
|
+
|
|
324
|
+
// Access accumulated messages for persistence
|
|
325
|
+
conversation.uiMessages; // UIMessage[]
|
|
326
|
+
conversation.modelMessages; // ModelMessage[]
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
The constructor also accepts `compaction` and `pendingMessages` options (same shapes as on `chat.agent()`); pass `prepareStep: conversation.prepareStep()` to `streamText` to activate them. See [pending messages](/ai-chat/pending-messages#backend-messageaccumulator-raw-task) for the manual steering wiring.
|
|
330
|
+
|
|
331
|
+
### Hand-rolled loop checklist
|
|
332
|
+
|
|
333
|
+
Things the managed levels do for you that a raw loop has to get right:
|
|
334
|
+
|
|
335
|
+
- **Don't bare-await `result.totalUsage`.** On a stop-abort the AI SDK's `totalUsage` promise never settles, which wedges the loop forever. Race it with a timeout:
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
const turnUsage = await Promise.race([
|
|
339
|
+
result.totalUsage,
|
|
340
|
+
new Promise((resolve) => setTimeout(() => resolve(undefined), 2000)),
|
|
341
|
+
]);
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
- **Persist the user message before streaming** (shown in the example above). The session replay restores the assistant's streamed text after a page reload, but nothing restores a user message you haven't written down.
|
|
345
|
+
- **Seed history on continuation runs through the turn-0 `addIncoming`** (shown above). `payload.continuation` is `true` when this run picked up an existing chat; the accumulator starts empty — and because turn 0 replaces the accumulator, a `setMessages` call before the loop gets wiped.
|
|
346
|
+
- **Clean up aborted parts on a stop** with `chat.cleanupAbortedParts()` before accumulating, or the partial response carries half-open tool calls into the next turn's prompt.
|
|
347
|
+
- **Read `payload.message` (singular).** The wire payload carries at most one new message per turn; there is no `messages` array on the payload.
|
|
348
|
+
|
|
349
|
+
## Next steps
|
|
350
|
+
|
|
351
|
+
<CardGroup cols={2}>
|
|
352
|
+
<Card title="Backend overview" icon="layer-group" href="/ai-chat/backend">
|
|
353
|
+
The three abstraction levels compared, and everything chat.agent() adds on top.
|
|
354
|
+
</Card>
|
|
355
|
+
<Card title="Sessions" icon="wave-pulse" href="/ai-chat/sessions">
|
|
356
|
+
The durable stream pair every agent — managed or custom — is built on.
|
|
357
|
+
</Card>
|
|
358
|
+
<Card title="Compaction" icon="compress" href="/ai-chat/compaction">
|
|
359
|
+
Automatic context compression — works with createSession and MessageAccumulator.
|
|
360
|
+
</Card>
|
|
361
|
+
<Card title="Client protocol" icon="plug" href="/ai-chat/client-protocol">
|
|
362
|
+
The wire format your loop is speaking, chunk by chunk.
|
|
363
|
+
</Card>
|
|
364
|
+
</CardGroup>
|