@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,383 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Sub-Agents"
|
|
3
|
+
sidebarTitle: "Sub-Agents"
|
|
4
|
+
description: "Delegate work to durable sub-agents from within a parent agent's tool calls, with streaming preliminary results."
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
import RcBanner from "/snippets/ai-chat-rc-banner.mdx";
|
|
8
|
+
|
|
9
|
+
<RcBanner />
|
|
10
|
+
|
|
11
|
+
Sub-agents let a parent agent delegate work to other agents running as durable Trigger.dev tasks. The sub-agent's response streams back through the parent as preliminary tool results, so the frontend sees the sub-agent working inside the parent's tool call card.
|
|
12
|
+
|
|
13
|
+
This builds on the AI SDK's [async generator tool pattern](https://ai-sdk.dev/docs/agents/subagents) and Trigger.dev's [AgentChat](/ai-chat/server-chat) for server-side agent interaction.
|
|
14
|
+
|
|
15
|
+
## How it works
|
|
16
|
+
|
|
17
|
+
1. The parent LLM calls a tool (e.g., `researchAgent`)
|
|
18
|
+
2. The tool's `execute` is an `async function*` (async generator)
|
|
19
|
+
3. Inside, it creates an `AgentChat` and sends a message to the sub-agent
|
|
20
|
+
4. `yield* stream.messages()` streams each accumulated `UIMessage` snapshot as a preliminary tool result
|
|
21
|
+
5. The frontend renders the sub-agent's response building up inside the parent's tool card
|
|
22
|
+
6. `toModelOutput` compresses the full output into a summary for the parent LLM
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
Parent LLM
|
|
26
|
+
│
|
|
27
|
+
├─ calls researchAgent tool
|
|
28
|
+
│ │
|
|
29
|
+
│ ├─ AgentChat triggers sub-agent run
|
|
30
|
+
│ ├─ sub-agent streams response (text, tool calls, etc.)
|
|
31
|
+
│ ├─ yield* sends UIMessage snapshots as preliminary results
|
|
32
|
+
│ └─ toModelOutput compresses for parent LLM
|
|
33
|
+
│
|
|
34
|
+
└─ parent LLM reads compressed summary, continues reasoning
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Single-turn sub-agent
|
|
38
|
+
|
|
39
|
+
The simplest pattern: one tool call, one sub-agent turn, conversation closes.
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { tool, stepCountIs } from "ai";
|
|
43
|
+
import { AgentChat } from "@trigger.dev/sdk/chat";
|
|
44
|
+
import { z } from "zod";
|
|
45
|
+
import type { prReviewAgent } from "./trigger/pr-review";
|
|
46
|
+
|
|
47
|
+
const prReviewTool = tool({
|
|
48
|
+
description: "Delegate a PR review to the PR review agent.",
|
|
49
|
+
inputSchema: z.object({
|
|
50
|
+
prNumber: z.number().describe("The PR number to review"),
|
|
51
|
+
repo: z.string().describe("The GitHub repo URL"),
|
|
52
|
+
}),
|
|
53
|
+
execute: async function* ({ prNumber, repo }, { abortSignal }) {
|
|
54
|
+
const chat = new AgentChat<typeof prReviewAgent>({
|
|
55
|
+
agent: "pr-review",
|
|
56
|
+
id: `review-${prNumber}`,
|
|
57
|
+
clientData: { userId: "parent-agent", githubUrl: repo },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const stream = await chat.sendMessage(`Review PR #${prNumber}`, { abortSignal });
|
|
61
|
+
|
|
62
|
+
// Each yield sends a UIMessage snapshot to the frontend
|
|
63
|
+
yield* stream.messages();
|
|
64
|
+
|
|
65
|
+
await chat.close();
|
|
66
|
+
},
|
|
67
|
+
// The parent LLM only sees this compressed summary
|
|
68
|
+
toModelOutput: ({ output: message }) => {
|
|
69
|
+
const lastText = message?.parts?.findLast(
|
|
70
|
+
(p: { type: string }) => p.type === "text"
|
|
71
|
+
) as { text?: string } | undefined;
|
|
72
|
+
return { type: "text", value: lastText?.text ?? "Review complete." };
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Use this tool in a parent agent's `streamText` call:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { streamText } from "ai";
|
|
81
|
+
import { anthropic } from "@ai-sdk/anthropic";
|
|
82
|
+
|
|
83
|
+
const result = streamText({
|
|
84
|
+
model: anthropic("claude-sonnet-4-6"),
|
|
85
|
+
tools: { prReview: prReviewTool },
|
|
86
|
+
prompt: "Review PR #42 on triggerdotdev/trigger.dev",
|
|
87
|
+
stopWhen: stepCountIs(15),
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Multi-turn sub-agent (LLM-driven)
|
|
92
|
+
|
|
93
|
+
The parent LLM drives a persistent conversation with a sub-agent across multiple tool calls. Each call with the same `conversationId` hits the same durable agent run.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { tool } from "ai";
|
|
97
|
+
import { AgentChat } from "@trigger.dev/sdk/chat";
|
|
98
|
+
import { z } from "zod";
|
|
99
|
+
|
|
100
|
+
// Track active sub-agent conversations
|
|
101
|
+
const subAgents = new Map<string, AgentChat>();
|
|
102
|
+
|
|
103
|
+
const researchTool = tool({
|
|
104
|
+
description:
|
|
105
|
+
"Talk to a research agent. Use the same conversationId to continue " +
|
|
106
|
+
"an existing conversation — the agent remembers full context.",
|
|
107
|
+
inputSchema: z.object({
|
|
108
|
+
conversationId: z
|
|
109
|
+
.string()
|
|
110
|
+
.describe("Unique ID for this research thread. Reuse to continue."),
|
|
111
|
+
message: z.string().describe("Your message to the research agent"),
|
|
112
|
+
}),
|
|
113
|
+
execute: async function* ({ conversationId, message }, { abortSignal }) {
|
|
114
|
+
let agent = subAgents.get(conversationId);
|
|
115
|
+
if (!agent) {
|
|
116
|
+
agent = new AgentChat({
|
|
117
|
+
agent: "research-agent",
|
|
118
|
+
id: conversationId,
|
|
119
|
+
});
|
|
120
|
+
subAgents.set(conversationId, agent);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const stream = await agent.sendMessage(message, { abortSignal });
|
|
124
|
+
yield* stream.messages();
|
|
125
|
+
},
|
|
126
|
+
toModelOutput: ({ output: message }) => {
|
|
127
|
+
const lastText = message?.parts?.findLast(
|
|
128
|
+
(p: { type: string }) => p.type === "text"
|
|
129
|
+
) as { text?: string } | undefined;
|
|
130
|
+
return { type: "text", value: lastText?.text ?? "Done." };
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The parent LLM naturally calls this tool multiple times:
|
|
136
|
+
|
|
137
|
+
1. `researchAgent({ conversationId: "competitors", message: "Research competitors in AI agents" })` — first call triggers a new sub-agent run
|
|
138
|
+
2. `researchAgent({ conversationId: "competitors", message: "Go deeper on pricing" })` — same run, sub-agent has full context
|
|
139
|
+
3. `researchAgent({ conversationId: "new-topic", message: "..." })` — different ID = different sub-agent
|
|
140
|
+
|
|
141
|
+
### Cross-turn persistence
|
|
142
|
+
|
|
143
|
+
Sub-agent conversations persist across **parent turns** because the `Map` lives in the parent's process heap. When the parent suspends and restores via snapshot, the heap is preserved — the Map still has the conversations, the sessions still have the run IDs.
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
export const orchestrator = chat
|
|
147
|
+
.withClientData({ schema: z.object({ userId: z.string() }) })
|
|
148
|
+
.customAgent({
|
|
149
|
+
id: "orchestrator",
|
|
150
|
+
run: async (payload, { signal: runSignal }) => {
|
|
151
|
+
// These survive across parent turns via snapshot/restore
|
|
152
|
+
const subAgents = new Map<string, AgentChat>();
|
|
153
|
+
|
|
154
|
+
const researchTool = tool({
|
|
155
|
+
// ... closes over subAgents Map
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Turn loop — subAgents persist across all turns
|
|
159
|
+
for (let turn = 0; turn < 50; turn++) {
|
|
160
|
+
// ... streamText with researchTool
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Cleanup when parent exits
|
|
164
|
+
await Promise.all(
|
|
165
|
+
Array.from(subAgents.values()).map((a) => a.close().catch(() => {}))
|
|
166
|
+
);
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## How sub-agents clean up
|
|
172
|
+
|
|
173
|
+
Sub-agents clean up through three mechanisms:
|
|
174
|
+
|
|
175
|
+
1. **Explicit close**: Call `chat.close()` or `agent.close()` when done
|
|
176
|
+
2. **Idle timeout**: The sub-agent's idle timeout expires, it suspends
|
|
177
|
+
3. **Suspend timeout**: The sub-agent's suspend timeout expires, the run ends
|
|
178
|
+
|
|
179
|
+
For the multi-turn pattern, the parent should clean up sub-agents when it exits (in `onComplete` for managed agents, or at the end of the loop for custom agents). Without explicit cleanup, sub-agents close on their own via timeouts — no leaked resources or cost while suspended.
|
|
180
|
+
|
|
181
|
+
## What the frontend sees
|
|
182
|
+
|
|
183
|
+
Each `yield` from `stream.messages()` sends a complete `UIMessage` containing all the sub-agent's parts accumulated so far. The AI SDK delivers these as `tool-output-available` chunks with `preliminary: true`.
|
|
184
|
+
|
|
185
|
+
The frontend renders the tool part with:
|
|
186
|
+
- `state: "output-available"` and `preliminary: true` while streaming
|
|
187
|
+
- `state: "output-available"` and `preliminary: false` (or absent) when done
|
|
188
|
+
|
|
189
|
+
The tool output contains the full `UIMessage` with nested parts — text, the sub-agent's own tool calls and results, reasoning, etc.
|
|
190
|
+
|
|
191
|
+
### Controlling what the parent LLM sees
|
|
192
|
+
|
|
193
|
+
`toModelOutput` transforms the tool's output before it enters the parent LLM's context. The full UIMessage streams to the frontend, but the model only sees the compressed version:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
toModelOutput: ({ output: message }) => {
|
|
197
|
+
// Extract just the final text — the model doesn't need
|
|
198
|
+
// to see all the sub-agent's tool calls and intermediate work
|
|
199
|
+
const lastText = message?.parts?.findLast(
|
|
200
|
+
(p: { type: string }) => p.type === "text"
|
|
201
|
+
) as { text?: string } | undefined;
|
|
202
|
+
return { type: "text", value: lastText?.text ?? "Done." };
|
|
203
|
+
},
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
This is important for token efficiency: the sub-agent might use 100K tokens exploring and reasoning, but the parent LLM only consumes the summary.
|
|
207
|
+
|
|
208
|
+
<Warning>
|
|
209
|
+
`toModelOutput` only runs when the SDK has your tools at conversion time. On a multi-turn parent, the SDK re-converts the persisted history at the start of each turn, so you must declare the sub-agent tool on the agent config (`chat.agent({ tools })`) for the compression to survive. Without it, the summary holds on turn 1 but turn 2 onward re-ingests the full sub-agent output. In a `chat.customAgent` loop you own the conversion, so pass the tools to `convertToModelMessages(uiMessages, { tools })` yourself. See [Tools: toModelOutput across turns](/ai-chat/tools#tomodeloutput-across-turns).
|
|
210
|
+
</Warning>
|
|
211
|
+
|
|
212
|
+
## ChatStream.messages()
|
|
213
|
+
|
|
214
|
+
The `messages()` method on `ChatStream` wraps the AI SDK's `readUIMessageStream`. It reads the raw `UIMessageChunk` stream and yields complete `UIMessage` snapshots — each containing all parts received so far.
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
const stream = await chat.sendMessage("Research this topic");
|
|
218
|
+
|
|
219
|
+
// Each yield is a complete UIMessage with all accumulated parts
|
|
220
|
+
for await (const message of stream.messages()) {
|
|
221
|
+
console.log(message.parts.length, "parts so far");
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
For the sub-agent pattern, use `yield*` to delegate all yields to the parent tool's generator:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
execute: async function* ({ topic }, { abortSignal }) {
|
|
229
|
+
const stream = await chat.sendMessage(topic, { abortSignal });
|
|
230
|
+
yield* stream.messages();
|
|
231
|
+
},
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
<Tip>
|
|
235
|
+
`stream.messages()` consumes the stream. You can't also call `stream.text()` or iterate over chunks on the same stream. Pick one consumption mode.
|
|
236
|
+
</Tip>
|
|
237
|
+
|
|
238
|
+
## Combining with chat.agent()
|
|
239
|
+
|
|
240
|
+
Sub-agent tools work inside both `chat.agent()` (managed) and `chat.customAgent()` (manual lifecycle):
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
// Managed agent with sub-agent tool
|
|
244
|
+
const tools = { research: researchTool };
|
|
245
|
+
|
|
246
|
+
export const myAgent = chat.agent({
|
|
247
|
+
id: "orchestrator",
|
|
248
|
+
tools, // declare here so toModelOutput survives across turns
|
|
249
|
+
run: async ({ messages, tools, stopSignal }) => {
|
|
250
|
+
return streamText({
|
|
251
|
+
model: anthropic("claude-sonnet-4-6"),
|
|
252
|
+
messages,
|
|
253
|
+
tools,
|
|
254
|
+
abortSignal: stopSignal,
|
|
255
|
+
stopWhen: stepCountIs(15),
|
|
256
|
+
});
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
For `chat.customAgent()`, define the tool and sub-agent Map inside the `run` closure so they survive across turns. Since you own the turn loop there, convert history with your tools in scope so `toModelOutput` is re-applied each turn: `convertToModelMessages(uiMessages, { tools })`. See [Tools: manual turn loops](/ai-chat/tools#manual-turn-loops-chatcustomagent).
|
|
262
|
+
|
|
263
|
+
## Streaming progress from a subtask to the parent chat
|
|
264
|
+
|
|
265
|
+
When a tool invokes a subtask via `triggerAndWait`, the subtask can stream custom data parts directly to the parent chat using `chat.stream.writer({ target: "root" })`. The frontend receives these as `DataUIPart` objects in `message.parts` on the **parent's** message stream:
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
import { chat, ai } from "@trigger.dev/sdk/ai";
|
|
269
|
+
import { schemaTask } from "@trigger.dev/sdk";
|
|
270
|
+
import { streamText, tool, generateId } from "ai";
|
|
271
|
+
import { anthropic } from "@ai-sdk/anthropic";
|
|
272
|
+
import { z } from "zod";
|
|
273
|
+
|
|
274
|
+
export const researchTask = schemaTask({
|
|
275
|
+
id: "research",
|
|
276
|
+
schema: z.object({ query: z.string() }),
|
|
277
|
+
run: async ({ query }) => {
|
|
278
|
+
const partId = generateId();
|
|
279
|
+
|
|
280
|
+
// Stream a data-* chunk to the root run's chat stream.
|
|
281
|
+
const { waitUntilComplete } = chat.stream.writer({
|
|
282
|
+
target: "root",
|
|
283
|
+
execute: ({ write }) => {
|
|
284
|
+
write({
|
|
285
|
+
type: "data-research-status",
|
|
286
|
+
id: partId,
|
|
287
|
+
data: { query, status: "in-progress" },
|
|
288
|
+
});
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
await waitUntilComplete();
|
|
292
|
+
|
|
293
|
+
const result = await doResearch(query);
|
|
294
|
+
|
|
295
|
+
// Update the same part with the final status — same type + id replaces it.
|
|
296
|
+
const { waitUntilComplete: waitDone } = chat.stream.writer({
|
|
297
|
+
target: "root",
|
|
298
|
+
execute: ({ write }) => {
|
|
299
|
+
write({
|
|
300
|
+
type: "data-research-status",
|
|
301
|
+
id: partId,
|
|
302
|
+
data: { query, status: "done", resultCount: result.length },
|
|
303
|
+
});
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
await waitDone();
|
|
307
|
+
|
|
308
|
+
return result;
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const research = tool({
|
|
313
|
+
description: researchTask.description ?? "",
|
|
314
|
+
inputSchema: researchTask.schema!,
|
|
315
|
+
execute: ai.toolExecute(researchTask),
|
|
316
|
+
});
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
On the frontend, render the custom data part:
|
|
320
|
+
|
|
321
|
+
```tsx
|
|
322
|
+
{message.parts.map((part, i) => {
|
|
323
|
+
if (part.type === "data-research-status") {
|
|
324
|
+
const { query, status, resultCount } = part.data;
|
|
325
|
+
return (
|
|
326
|
+
<div key={i}>
|
|
327
|
+
{status === "done" ? `Found ${resultCount} results` : `Researching "${query}"...`}
|
|
328
|
+
</div>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
// ...other part types
|
|
332
|
+
})}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
The `target` option accepts:
|
|
336
|
+
- `"self"` — current run (default)
|
|
337
|
+
- `"parent"` — parent task's run
|
|
338
|
+
- `"root"` — root task's run (the chat agent)
|
|
339
|
+
- A specific run ID string
|
|
340
|
+
|
|
341
|
+
## Inside `ai.toolExecute`: accessing tool + chat context
|
|
342
|
+
|
|
343
|
+
When a subtask runs via `execute: ai.toolExecute(task)`, it can read the parent's tool call ID and chat context from inside the subtask body:
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
import { ai, chat } from "@trigger.dev/sdk/ai";
|
|
347
|
+
import type { myChat } from "./chat";
|
|
348
|
+
|
|
349
|
+
export const mySubtask = schemaTask({
|
|
350
|
+
id: "my-subtask",
|
|
351
|
+
schema: z.object({ query: z.string() }),
|
|
352
|
+
run: async ({ query }) => {
|
|
353
|
+
// The AI SDK tool call ID — useful as a stable `data-*` chunk id
|
|
354
|
+
const toolCallId = ai.toolCallId();
|
|
355
|
+
|
|
356
|
+
// Typed chat context — `clientData` is typed off your chat's `clientDataSchema`
|
|
357
|
+
const { chatId, clientData } = ai.chatContextOrThrow<typeof myChat>();
|
|
358
|
+
|
|
359
|
+
const { waitUntilComplete } = chat.stream.writer({
|
|
360
|
+
target: "root",
|
|
361
|
+
execute: ({ write }) => {
|
|
362
|
+
write({
|
|
363
|
+
type: "data-progress",
|
|
364
|
+
id: toolCallId,
|
|
365
|
+
data: { status: "working", query, userId: clientData?.userId },
|
|
366
|
+
});
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
await waitUntilComplete();
|
|
370
|
+
|
|
371
|
+
return { result: "done" };
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
| Helper | Returns | Description |
|
|
377
|
+
|--------|---------|-------------|
|
|
378
|
+
| `ai.toolCallId()` | `string \| undefined` | The AI SDK tool call ID |
|
|
379
|
+
| `ai.chatContext<typeof myChat>()` | `{ chatId, turn, continuation, clientData } \| undefined` | Chat context with typed `clientData`. Returns `undefined` if not in a chat context. |
|
|
380
|
+
| `ai.chatContextOrThrow<typeof myChat>()` | `{ chatId, turn, continuation, clientData }` | Same as above but throws if not in a chat context |
|
|
381
|
+
| `ai.currentToolOptions()` | `ToolCallExecutionOptions \| undefined` | Full tool execution options |
|
|
382
|
+
|
|
383
|
+
The subtask body also has read-only access to any [`chat.local`](/ai-chat/chat-local) values initialized in the parent — auto-hydrated from the parent's metadata on first access.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Tool result auditing"
|
|
3
|
+
sidebarTitle: "Tool result auditing"
|
|
4
|
+
description: "Fire side effects exactly once per resolved tool call — audit logs, billing, notifications — using extractNewToolResults inside hydrateMessages or onTurnComplete."
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
import RcBanner from "/snippets/ai-chat-rc-banner.mdx";
|
|
8
|
+
|
|
9
|
+
<RcBanner />
|
|
10
|
+
|
|
11
|
+
When a chat agent uses [tools](/ai-chat/tools) (especially [human-in-the-loop](/ai-chat/patterns/human-in-the-loop) tools that wait on `addToolOutput` from the frontend), you often need to fire side effects exactly once per resolved tool call:
|
|
12
|
+
|
|
13
|
+
- **Audit logs** — record every tool result for compliance.
|
|
14
|
+
- **Billing** — charge per tool invocation.
|
|
15
|
+
- **Notifications** — alert downstream systems when a specific tool resolves.
|
|
16
|
+
- **Search-index updates** — reflect tool outputs into a derived store.
|
|
17
|
+
|
|
18
|
+
The naive approach — "log every tool part you see" — over-counts. The same assistant message gets re-shown across re-renders, replays, and retries. You want a function of the form **"is this tool result one I haven't already logged?"** That's exactly what [`chat.history.extractNewToolResults`](/ai-chat/backend#chat-history) returns.
|
|
19
|
+
|
|
20
|
+
## The pattern
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { chat } from "@trigger.dev/sdk/ai";
|
|
24
|
+
import { auditLog } from "@/lib/audit";
|
|
25
|
+
|
|
26
|
+
export const myChat = chat.agent({
|
|
27
|
+
id: "my-chat",
|
|
28
|
+
hydrateMessages: async ({ chatId, incomingMessages }) => {
|
|
29
|
+
for (const msg of incomingMessages) {
|
|
30
|
+
for (const r of chat.history.extractNewToolResults(msg)) {
|
|
31
|
+
await auditLog.record({
|
|
32
|
+
chatId,
|
|
33
|
+
toolCallId: r.toolCallId,
|
|
34
|
+
toolName: r.toolName,
|
|
35
|
+
output: r.output,
|
|
36
|
+
errorText: r.errorText,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return await db.getMessages(chatId);
|
|
41
|
+
},
|
|
42
|
+
run: async ({ messages, signal }) => {
|
|
43
|
+
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The hook fires per turn. `incomingMessages` is the new wire message (0-or-1-length, see [v4.5 wire format change](/ai-chat/upgrade-guide#v45-wire-format-change)). For each new tool result on that message, write one audit row. Then return the canonical chain from your DB.
|
|
49
|
+
|
|
50
|
+
`extractNewToolResults` compares the message against the current `chat.history` chain and returns only tool parts whose `toolCallId` is **not** already resolved. That's what makes the call exactly-once:
|
|
51
|
+
|
|
52
|
+
- A re-emitted message (same id, same toolCallId) returns `[]` — no duplicate log.
|
|
53
|
+
- A genuinely new tool result on a known assistant message returns just the new ones.
|
|
54
|
+
- A first-time tool result returns the full set.
|
|
55
|
+
|
|
56
|
+
## Why `hydrateMessages` is the right hook
|
|
57
|
+
|
|
58
|
+
The pattern works in any pre-merge callback, but `hydrateMessages` is the canonical spot for two reasons:
|
|
59
|
+
|
|
60
|
+
1. **It fires before the runtime merges** the incoming message into the accumulator. Once merged, the tool results are already on the chain, and `extractNewToolResults` returns `[]` for them.
|
|
61
|
+
2. **It always fires per turn** — including HITL turns where the user resolved a tool with `addToolOutput`, which is the highest-volume audit event in most apps.
|
|
62
|
+
|
|
63
|
+
By the time `onTurnComplete` fires, the chain already contains `responseMessage`, so calling `extractNewToolResults(responseMessage)` there returns `[]`. Don't put audit logging there for the resolution path.
|
|
64
|
+
|
|
65
|
+
## Without `hydrateMessages` — `onTurnComplete` for self-emitted tool calls
|
|
66
|
+
|
|
67
|
+
If you don't use `hydrateMessages`, the runtime's snapshot+replay path handles persistence. You can still audit the agent's **own** tool executions in `onTurnComplete` — but compare against the prior message rather than the just-emitted one:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
onTurnComplete: async ({ chatId, newUIMessages }) => {
|
|
71
|
+
// The assistant message from this turn is in newUIMessages.
|
|
72
|
+
for (const msg of newUIMessages) {
|
|
73
|
+
if (msg.role !== "assistant") continue;
|
|
74
|
+
for (const part of msg.parts) {
|
|
75
|
+
if (
|
|
76
|
+
typeof part.type === "string" &&
|
|
77
|
+
part.type.startsWith("tool-") &&
|
|
78
|
+
((part as any).state === "output-available" ||
|
|
79
|
+
(part as any).state === "output-error")
|
|
80
|
+
) {
|
|
81
|
+
await auditLog.record({
|
|
82
|
+
chatId,
|
|
83
|
+
toolCallId: (part as any).toolCallId,
|
|
84
|
+
toolName: (part as any).type.slice("tool-".length),
|
|
85
|
+
output: (part as any).output,
|
|
86
|
+
errorText: (part as any).errorText,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`newUIMessages` is just the messages this turn produced — no prior-chain noise. Each tool part shows up exactly once.
|
|
95
|
+
|
|
96
|
+
This works for tools the agent itself calls (no HITL pause). For HITL flows where the user resolves a tool with `addToolOutput`, the resolution arrives on the **next** turn's wire message, not in `newUIMessages` of the resolving turn — use `hydrateMessages` for those.
|
|
97
|
+
|
|
98
|
+
## Idempotency at the storage layer
|
|
99
|
+
|
|
100
|
+
Even with `extractNewToolResults`, transient failures (e.g. an audit-log POST that times out and is retried) can produce duplicates. Make the audit-log writer idempotent on `toolCallId`:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
await auditLog.upsert({
|
|
104
|
+
where: { toolCallId: r.toolCallId },
|
|
105
|
+
create: { /* ... */ },
|
|
106
|
+
update: { /* timestamp, retry count, etc. */ },
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`toolCallId` is unique per tool invocation (assigned by the AI SDK when the model emits the tool call) and stable across retries — perfect for an idempotency key.
|
|
111
|
+
|
|
112
|
+
## What `extractNewToolResults` returns
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
type ChatNewToolResult = {
|
|
116
|
+
toolCallId: string;
|
|
117
|
+
toolName: string;
|
|
118
|
+
output: unknown; // The tool's return value (carries the resolved value; in output-error state see errorText)
|
|
119
|
+
errorText?: string; // Set iff the part is in output-error state
|
|
120
|
+
};
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Tool parts in `input-available` state (the model called the tool but it hasn't resolved yet) are not returned — only **resolved** results count.
|
|
124
|
+
|
|
125
|
+
## Combining with HITL
|
|
126
|
+
|
|
127
|
+
[Human-in-the-loop](/ai-chat/patterns/human-in-the-loop) tools pause the turn waiting for `addToolOutput` from the frontend. When the user submits, the wire message carries an updated assistant message with the tool now in `output-available` state. `extractNewToolResults` against that message returns the just-resolved tool — exactly one audit row per user resolution:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
hydrateMessages: async ({ chatId, incomingMessages }) => {
|
|
131
|
+
for (const msg of incomingMessages) {
|
|
132
|
+
for (const r of chat.history.extractNewToolResults(msg)) {
|
|
133
|
+
// Fires once per ask_user / approval / similar resolution
|
|
134
|
+
await auditLog.record({ chatId, /* ... */ });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return await db.getMessages(chatId);
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
This is the original motivator for the helper — see the [HITL pattern's net-new-tool-result section](/ai-chat/patterns/human-in-the-loop#acting-once-per-net-new-tool-result).
|
|
142
|
+
|
|
143
|
+
## See also
|
|
144
|
+
|
|
145
|
+
- [`chat.history`](/ai-chat/backend#chat-history) — full reference for `extractNewToolResults`, `getPendingToolCalls`, `getResolvedToolCalls`
|
|
146
|
+
- [Human-in-the-loop](/ai-chat/patterns/human-in-the-loop) — the pattern this auditing hook complements
|
|
147
|
+
- [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) — where pre-merge auditing lives
|
|
148
|
+
- [Persistence and replay](/ai-chat/patterns/persistence-and-replay) — how the runtime rebuilds chains, and why `extractNewToolResults` works against them
|