Visitar URL original
feat(sdk): stream sub-agents through a parent tool call with chat.for… · triggerdotdev/trigger.dev@bdc994c · GitHub
Skip to content

Commit bdc994c

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(sdk): stream sub-agents through a parent tool call with chat.forwardSubAgent
Sub-agents can now stream through a parent tool call without resending the whole message on every chunk. ```ts const research = tool({ inputSchema: z.object({ topic: z.string() }), execute: async ({ topic }, { toolCallId, abortSignal }) => { const researcher = new AgentChat<typeof researchAgent>({ agent: "research-agent" }); try { const stream = await researcher.sendMessage(topic, { abortSignal }); return await chat.forwardSubAgent(stream, { toolCallId }); } finally { await researcher.close(); } }, toModelOutput: ({ output }) => ({ type: "text", value: lastText(output) }), }); ``` ```tsx const subAgents = useSubAgentMessages(); const { messages } = useChat({ transport, onData: subAgents.onData }); // in the tool card: live while it runs, the tool output once it finishes const message = subAgents.get(part.toolCallId) ?? part.output; ``` `chat.forwardSubAgent` writes each sub-agent chunk to the parent chat once, in batches of transient `data-subagent` parts tagged with the tool call id and a sequence number, and resolves with the sub-agent's final `UIMessage` as the tool output. `useSubAgentMessages` (or `createSubAgentMessages` outside React) rebuilds each sub-agent's message from those batches at most once per 100ms and skips batches a resumed stream replays. Yielding `stream.messages()` snapshots from an async generator tool resends the whole message on every chunk, so the bytes it writes grow with the square of the reply. A sub-agent that reads five 20KB tool results and then writes 2,000 deltas writes about 218MB that way, and about 0.5MB with `forwardSubAgent`. The sub-agent guide, API reference, client protocol page and SDK skill now use `forwardSubAgent`. Mono-RevId: 2f5132af70edacdd4ef8370383054e95c4f5f64d
1 parent dce5048 commit bdc994c

17 files changed

Lines changed: 970 additions & 74 deletions

File tree

‎.changeset/forward-sub-agent.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Stream a sub-agent's reply through a parent tool call with `chat.forwardSubAgent`. Each chunk is written to the parent chat once and the tool resolves with the sub-agent's final message, so streaming a sub-agent costs bytes in proportion to its reply. Yielding `stream.messages()` snapshots resends the whole message on every chunk, which grows with the square of the reply.
6+
7+
```ts
8+
execute: async ({ topic }, { toolCallId, abortSignal }) => {
9+
const stream = await researcher.sendMessage(topic, { abortSignal });
10+
return await chat.forwardSubAgent(stream, { toolCallId });
11+
},
12+
```
13+
14+
Render it while it runs with `useSubAgentMessages()` from `@trigger.dev/sdk/chat/react` (or `createSubAgentMessages()` outside React).

‎docs/ai-chat/client-protocol.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,7 @@ Within a single assistant turn the AI SDK chunk types you'll typically see, in o
577577
| `tool-input-start` / `tool-input-delta` / `tool-input-available` | tool-call argument streaming | The tool the model is calling. |
578578
| `tool-output-available` | tool result | After the agent runs the tool. |
579579
| `data-*` | `{ type: "data-<name>", data: ... }` | Custom data parts written by the agent's hooks. |
580+
| `data-subagent` | `{ type: "data-subagent", transient: true, data: { toolCallId, seq, chunks } }` | A batch of a sub-agent's chunks written by [`chat.forwardSubAgent`](/ai-chat/patterns/sub-agents). `seq` counts from 0 per `toolCallId`; skip batches you have already applied when you resume. |
580581
| `finish-step` / `finish` | end markers for the assistant message | Followed by the `turn-complete` control record. |
581582
582583
Refer to the AI SDK docs linked above for the full union — only the two control records below are Trigger.dev-specific.

‎docs/ai-chat/frontend.mdx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,21 @@ for await (const chunk of stream) {
503503
}
504504
```
505505

506+
## Sub-agents
507+
508+
When a tool delegates to a sub-agent with [`chat.forwardSubAgent`](/ai-chat/patterns/sub-agents), the parent's tool part has no output until the sub-agent finishes. `useSubAgentMessages` rebuilds the sub-agent's message from the chunks streamed so far:
509+
510+
```tsx app/components/chat.tsx
511+
import { useChat } from "@ai-sdk/react";
512+
import { useSubAgentMessages } from "@trigger.dev/sdk/chat/react";
513+
514+
const subAgents = useSubAgentMessages();
515+
const { messages } = useChat({ transport, onData: subAgents.onData });
516+
517+
// in the tool card: live while it runs, the tool output once it finishes
518+
const message = subAgents.get(part.toolCallId) ?? part.output;
519+
```
520+
506521
## Multi-tab coordination
507522

508523
When the same chat is open in multiple browser tabs, `multiTab: true` prevents duplicate messages and syncs conversation state across tabs. Only one tab can send at a time. Other tabs enter read-only mode with real-time message updates.

‎docs/ai-chat/overview.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ Four primitives, related but distinct:
6060

6161
- **Chat agents** — the SDK surface you define with [`chat.agent()`](/ai-chat/backend#chat-agent). Owns the turn loop, lifecycle hooks, and the response stream.
6262
- **Sessions** — the durable, bi-directional channel keyed on `chatId` that holds the conversation across run boundaries. A chat agent runs *on top of* a [Session](/ai-chat/sessions).
63-
- **Sub-agents** — Delegate work from one agent to another via [`AgentChat`](/ai-chat/patterns/sub-agents). The sub-agent runs as its own durable agent on its own session; its response streams back through the parent as preliminary tool results, so the frontend sees the sub-agent working inside the parent's tool card.
63+
- **Sub-agents**: delegate work from one agent to another via [`AgentChat`](/ai-chat/patterns/sub-agents). The sub-agent runs as its own durable agent on its own session; [`chat.forwardSubAgent`](/ai-chat/patterns/sub-agents) streams its reply through the parent's tool call, so the frontend sees the sub-agent working inside the parent's tool card.
6464
- **Channels** connect an agent to a surface other than your frontend. [`slack()`](/webhooks/slack) and [`linear()`](/webhooks/linear) turn an agent into a Slack or Linear agent, and [`chat.channels.custom()`](/webhooks/channels) connects any chat surface that sends webhooks. Each conversation there is still a durable session, and the reply posts back to the surface.
6565

6666
## Next steps

‎docs/ai-chat/patterns/sub-agents.mdx‎

Lines changed: 81 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,45 @@
11
---
22
title: "Sub-Agents"
33
sidebarTitle: "Sub-Agents"
4-
description: "Delegate work to durable sub-agents from within a parent agent's tool calls, with streaming preliminary results."
4+
description: "Delegate work to durable sub-agents from a parent agent's tool calls, and stream each sub-agent's work to the frontend as it happens."
55
---
66

7-
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.
7+
**Sub-agents let a parent agent delegate work to other agents running as durable Trigger.dev tasks, and `chat.forwardSubAgent` streams each sub-agent's reply to the frontend inside the parent's tool call.**
88

9-
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.
9+
Every chunk the sub-agent streams is written to the parent chat once, so the cost of streaming a sub-agent grows with the length of its reply. When the sub-agent finishes, its final message becomes the tool's output and persists with the parent's history.
10+
11+
This builds on Trigger.dev's [AgentChat](/ai-chat/server-chat) for server-side agent interaction.
1012

1113
## How it works
1214

13-
1. The parent LLM calls a tool (e.g., `researchAgent`)
14-
2. The tool's `execute` is an `async function*` (async generator)
15-
3. Inside, it creates an `AgentChat` and sends a message to the sub-agent
16-
4. `yield* stream.messages()` streams each accumulated `UIMessage` snapshot as a preliminary tool result
17-
5. The frontend renders the sub-agent's response building up inside the parent's tool card
18-
6. `toModelOutput` compresses the full output into a summary for the parent LLM
15+
1. The parent LLM calls a tool (for example `research`).
16+
2. The tool's `execute` creates an `AgentChat` and sends a message to the sub-agent.
17+
3. `chat.forwardSubAgent(stream, { toolCallId })` writes each sub-agent chunk to the parent chat as a transient `data-subagent` part.
18+
4. The frontend rebuilds the sub-agent's message from those chunks with `useSubAgentMessages` and renders it inside the parent's tool card.
19+
5. When the sub-agent finishes, `forwardSubAgent` resolves with its final `UIMessage`, which becomes the tool's output.
20+
6. `toModelOutput` compresses that output into a summary for the parent LLM.
1921

2022
```
2123
Parent LLM
2224
│
23-
├─ calls researchAgent tool
25+
├─ calls research tool
2426
│ │
2527
│ ├─ AgentChat triggers sub-agent run
26-
│ ├─ sub-agent streams response (text, tool calls, etc.)
27-
│ ├─ yield* sends UIMessage snapshots as preliminary results
28-
│ └─ toModelOutput compresses for parent LLM
28+
│ ├─ sub-agent streams its reply (text, tool calls, etc.)
29+
│ ├─ forwardSubAgent writes each chunk once as a transient data-subagent part
30+
│ ├─ the tool returns the sub-agent's final UIMessage
31+
│ └─ toModelOutput compresses it for the parent LLM
2932
│
30-
└─ parent LLM reads compressed summary, continues reasoning
33+
└─ parent LLM reads the summary and continues
3134
```
3235

3336
## Single-turn sub-agent
3437

3538
The simplest pattern: one tool call, one sub-agent turn, conversation closes.
3639

3740
```ts
38-
import { tool, stepCountIs } from "ai";
41+
import { tool } from "ai";
42+
import { chat } from "@trigger.dev/sdk/ai";
3943
import { AgentChat } from "@trigger.dev/sdk/chat";
4044
import { z } from "zod";
4145
import type { prReviewAgent } from "./trigger/pr-review";
@@ -46,19 +50,20 @@ const prReviewTool = tool({
4650
prNumber: z.number().describe("The PR number to review"),
4751
repo: z.string().describe("The GitHub repo URL"),
4852
}),
49-
execute: async function* ({ prNumber, repo }, { abortSignal }) {
50-
const chat = new AgentChat<typeof prReviewAgent>({
53+
execute: async ({ prNumber, repo }, { toolCallId, abortSignal }) => {
54+
const reviewer = new AgentChat<typeof prReviewAgent>({
5155
agent: "pr-review",
5256
id: `review-${prNumber}`,
5357
clientData: { userId: "parent-agent", githubUrl: repo },
5458
});
5559

56-
const stream = await chat.sendMessage(`Review PR #${prNumber}`, { abortSignal });
57-
58-
// Each yield sends a UIMessage snapshot to the frontend
59-
yield* stream.messages();
60-
61-
await chat.close();
60+
try {
61+
const stream = await reviewer.sendMessage(`Review PR #${prNumber}`, { abortSignal });
62+
// Streams the review to the frontend and resolves with the final message
63+
return await chat.forwardSubAgent(stream, { toolCallId });
64+
} finally {
65+
await reviewer.close();
66+
}
6267
},
6368
// The parent LLM only sees this compressed summary
6469
toModelOutput: ({ output: message }) => {
@@ -70,26 +75,42 @@ const prReviewTool = tool({
7075
});
7176
```
7277

73-
Use this tool in a parent agent's `streamText` call:
78+
`forwardSubAgent` needs the parent chat to write to, so call it from a tool that runs inside a `chat.agent` or `chat.customAgent` run. [Combining with chat.agent()](#combining-with-chat-agent) below shows the agent side.
7479

75-
```ts
76-
import { streamText } from "ai";
77-
import { anthropic } from "@ai-sdk/anthropic";
80+
## Render sub-agents in the frontend
7881

79-
const result = streamText({
80-
model: anthropic("claude-sonnet-4-6"),
81-
tools: { prReview: prReviewTool },
82-
prompt: "Review PR #42 on triggerdotdev/trigger.dev",
83-
stopWhen: stepCountIs(15),
84-
});
82+
While the sub-agent runs, the parent's tool part has no output yet. `useSubAgentMessages` collects the forwarded chunks and gives you the sub-agent's message so far for each tool call:
83+
84+
```tsx app/components/chat.tsx
85+
import { useChat } from "@ai-sdk/react";
86+
import type { TriggerChatTransport } from "@trigger.dev/sdk/chat";
87+
import { useSubAgentMessages } from "@trigger.dev/sdk/chat/react";
88+
import { ReviewCard } from "./review-card";
89+
90+
export function Chat({ transport }: { transport: TriggerChatTransport }) {
91+
const subAgents = useSubAgentMessages();
92+
const { messages } = useChat({ transport, onData: subAgents.onData });
93+
94+
return messages.map((message) =>
95+
message.parts.map((part) => {
96+
if (part.type !== "tool-prReview") return null;
97+
// Live while the sub-agent runs, the tool output once it finishes
98+
const review = subAgents.get(part.toolCallId) ?? part.output;
99+
return <ReviewCard key={part.toolCallId} message={review} />;
100+
})
101+
);
102+
}
85103
```
86104

105+
The hook rebuilds each message at most once per `throttleMs` (100ms by default), so a long reply costs a bounded number of rebuilds. After a reload mid-turn, the transport replays the current turn and the hook rebuilds the sub-agent's message from it; batches it has already seen are skipped. Outside React, `createSubAgentMessages()` from `@trigger.dev/sdk/chat` does the same job.
106+
87107
## Multi-turn sub-agent (LLM-driven)
88108

89109
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.
90110

91111
```ts
92112
import { tool } from "ai";
113+
import { chat } from "@trigger.dev/sdk/ai";
93114
import { AgentChat } from "@trigger.dev/sdk/chat";
94115
import { z } from "zod";
95116

@@ -106,7 +127,7 @@ const researchTool = tool({
106127
.describe("Unique ID for this research thread. Reuse to continue."),
107128
message: z.string().describe("Your message to the research agent"),
108129
}),
109-
execute: async function* ({ conversationId, message }, { abortSignal }) {
130+
execute: async ({ conversationId, message }, { toolCallId, abortSignal }) => {
110131
let agent = subAgents.get(conversationId);
111132
if (!agent) {
112133
agent = new AgentChat({
@@ -117,7 +138,7 @@ const researchTool = tool({
117138
}
118139

119140
const stream = await agent.sendMessage(message, { abortSignal });
120-
yield* stream.messages();
141+
return await chat.forwardSubAgent(stream, { toolCallId });
121142
},
122143
toModelOutput: ({ output: message }) => {
123144
const lastText = message?.parts?.findLast(
@@ -209,13 +230,19 @@ For the multi-turn pattern, the parent should clean up sub-agents when it exits
209230

210231
## What the frontend sees
211232

212-
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`.
233+
While the sub-agent runs, the parent's tool part stays in `state: "input-available"`, and the parent stream carries transient `data-subagent` parts. Each one holds a batch of the sub-agent's chunks, tagged with the parent's `toolCallId` and a sequence number:
213234

214-
The frontend renders the tool part with:
215-
- `state: "output-available"` and `preliminary: true` while streaming
216-
- `state: "output-available"` and `preliminary: false` (or absent) when done
235+
```ts
236+
{
237+
type: "data-subagent",
238+
transient: true,
239+
data: { toolCallId: "toolu_01...", seq: 3, chunks: [/* UIMessageChunk[] */] },
240+
}
241+
```
242+
243+
Transient parts reach `useChat`'s `onData` and are not added to `message.parts`, so they never persist. When the sub-agent finishes, the tool part moves to `state: "output-available"` with the full `UIMessage` as its output: text, the sub-agent's own tool calls and results, reasoning, and so on. That output is what reloaded history renders.
217244

218-
The tool output contains the full `UIMessage` with nested parts — text, the sub-agent's own tool calls and results, reasoning, etc.
245+
If the user stops the parent turn while a sub-agent is running, the tool call ends without an output, the same as any stopped tool. The sub-agent's work up to that point was only ever in the transient parts, so neither the parent's history nor the next turn's model context includes it.
219246

220247
### Controlling what the parent LLM sees
221248

@@ -238,30 +265,27 @@ This is important for token efficiency: the sub-agent might use 100K tokens expl
238265
`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).
239266
</Warning>
240267

241-
## ChatStream.messages()
268+
## Snapshots with `stream.messages()`
242269

243-
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.
270+
`ChatStream.messages()` wraps the AI SDK's `readUIMessageStream` and yields the whole accumulated `UIMessage` after almost every chunk. Yielding those snapshots from an `async function*` tool (`yield* stream.messages()`) turns each one into a preliminary tool result, which streams the sub-agent too, but every snapshot repeats the whole message so far. The bytes written grow with the square of the reply length:
244271

245-
```ts
246-
const stream = await chat.sendMessage("Research this topic");
272+
| Sub-agent reply | `yield* stream.messages()` | `chat.forwardSubAgent` |
273+
| --- | --- | --- |
274+
| 500 text deltas, no tools | 1.1 MB | 0.07 MB |
275+
| 5 tool results of 20 KB, then 2,000 deltas | 218 MB | 0.47 MB |
276+
| 10 tool results of 50 KB, then 4,000 deltas | 2.1 GB | 1.5 MB |
277+
278+
Every one of those bytes goes to the chat's stream storage and to each browser watching the chat. Use `forwardSubAgent` for sub-agents, and keep `stream.messages()` for reading a short reply in server code:
247279

248-
// Each yield is a complete UIMessage with all accumulated parts
280+
```ts
281+
const stream = await agent.sendMessage("Summarize this ticket in one sentence");
249282
for await (const message of stream.messages()) {
250283
console.log(message.parts.length, "parts so far");
251284
}
252285
```
253286

254-
For the sub-agent pattern, use `yield*` to delegate all yields to the parent tool's generator:
255-
256-
```ts
257-
execute: async function* ({ topic }, { abortSignal }) {
258-
const stream = await chat.sendMessage(topic, { abortSignal });
259-
yield* stream.messages();
260-
},
261-
```
262-
263287
<Tip>
264-
`stream.messages()` consumes the stream. You can't also call `stream.text()` or iterate over chunks on the same stream. Pick one consumption mode.
288+
A `ChatStream` can be consumed once: by `forwardSubAgent`, `stream.messages()`, `stream.text()`, or by iterating its chunks. Pick one.
265289
</Tip>
266290

267291
## Combining with chat.agent()
@@ -345,6 +369,8 @@ const research = tool({
345369
});
346370
```
347371

372+
Keep a part you update this way small. The frontend replaces the part on each write, but the chat stream stores every version, so writing a growing payload under one `id` (a full message history, say) stores every copy. Mark progress updates that don't need to persist with `transient: true`, and use `chat.forwardSubAgent` when the thing you are streaming is a sub-agent's reply.
373+
348374
On the frontend, render the custom data part:
349375

350376
```tsx

‎docs/ai-chat/quick-start.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,5 +159,5 @@ Run your frontend and `pnpm exec trigger dev`, then send a message. You should s
159159
- [Frontend](/ai-chat/frontend) — Session management, client data, reconnection
160160
- [Types](/ai-chat/types) — `chat.withUIMessage`, `InferChatUIMessage`, and related typing
161161
- [`chat.local`](/ai-chat/chat-local) — Per-run typed state across hooks, run, tools, subtasks
162-
- [Sub-agents pattern](/ai-chat/patterns/sub-agents) — Subtask-as-tool, `target: "root"` streaming, `ai.toolExecute` helpers
162+
- [Sub-agents pattern](/ai-chat/patterns/sub-agents): `chat.forwardSubAgent`, subtask-as-tool, `target: "root"` streaming, `ai.toolExecute` helpers
163163
- [Background injection](/ai-chat/background-injection) — `chat.inject()` and `chat.defer()` for between-turn work

0 commit comments

Comments
 (0)