380 lines
14 KiB
Plaintext
380 lines
14 KiB
Plaintext
---
|
|
title: "Sub-Agents"
|
|
sidebarTitle: "Sub-Agents"
|
|
description: "Delegate work to durable sub-agents from within a parent agent's tool calls, with streaming preliminary results."
|
|
---
|
|
|
|
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.
|
|
|
|
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.
|
|
|
|
## How it works
|
|
|
|
1. The parent LLM calls a tool (e.g., `researchAgent`)
|
|
2. The tool's `execute` is an `async function*` (async generator)
|
|
3. Inside, it creates an `AgentChat` and sends a message to the sub-agent
|
|
4. `yield* stream.messages()` streams each accumulated `UIMessage` snapshot as a preliminary tool result
|
|
5. The frontend renders the sub-agent's response building up inside the parent's tool card
|
|
6. `toModelOutput` compresses the full output into a summary for the parent LLM
|
|
|
|
```
|
|
Parent LLM
|
|
│
|
|
├─ calls researchAgent tool
|
|
│ │
|
|
│ ├─ AgentChat triggers sub-agent run
|
|
│ ├─ sub-agent streams response (text, tool calls, etc.)
|
|
│ ├─ yield* sends UIMessage snapshots as preliminary results
|
|
│ └─ toModelOutput compresses for parent LLM
|
|
│
|
|
└─ parent LLM reads compressed summary, continues reasoning
|
|
```
|
|
|
|
## Single-turn sub-agent
|
|
|
|
The simplest pattern: one tool call, one sub-agent turn, conversation closes.
|
|
|
|
```ts
|
|
import { tool, stepCountIs } from "ai";
|
|
import { AgentChat } from "@trigger.dev/sdk/chat";
|
|
import { z } from "zod";
|
|
import type { prReviewAgent } from "./trigger/pr-review";
|
|
|
|
const prReviewTool = tool({
|
|
description: "Delegate a PR review to the PR review agent.",
|
|
inputSchema: z.object({
|
|
prNumber: z.number().describe("The PR number to review"),
|
|
repo: z.string().describe("The GitHub repo URL"),
|
|
}),
|
|
execute: async function* ({ prNumber, repo }, { abortSignal }) {
|
|
const chat = new AgentChat<typeof prReviewAgent>({
|
|
agent: "pr-review",
|
|
id: `review-${prNumber}`,
|
|
clientData: { userId: "parent-agent", githubUrl: repo },
|
|
});
|
|
|
|
const stream = await chat.sendMessage(`Review PR #${prNumber}`, { abortSignal });
|
|
|
|
// Each yield sends a UIMessage snapshot to the frontend
|
|
yield* stream.messages();
|
|
|
|
await chat.close();
|
|
},
|
|
// The parent LLM only sees this compressed summary
|
|
toModelOutput: ({ output: message }) => {
|
|
const lastText = message?.parts?.findLast(
|
|
(p: { type: string }) => p.type === "text"
|
|
) as { text?: string } | undefined;
|
|
return { type: "text", value: lastText?.text ?? "Review complete." };
|
|
},
|
|
});
|
|
```
|
|
|
|
Use this tool in a parent agent's `streamText` call:
|
|
|
|
```ts
|
|
import { streamText } from "ai";
|
|
import { anthropic } from "@ai-sdk/anthropic";
|
|
|
|
const result = streamText({
|
|
model: anthropic("claude-sonnet-4-6"),
|
|
tools: { prReview: prReviewTool },
|
|
prompt: "Review PR #42 on triggerdotdev/trigger.dev",
|
|
stopWhen: stepCountIs(15),
|
|
});
|
|
```
|
|
|
|
## Multi-turn sub-agent (LLM-driven)
|
|
|
|
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.
|
|
|
|
```ts
|
|
import { tool } from "ai";
|
|
import { AgentChat } from "@trigger.dev/sdk/chat";
|
|
import { z } from "zod";
|
|
|
|
// Track active sub-agent conversations
|
|
const subAgents = new Map<string, AgentChat>();
|
|
|
|
const researchTool = tool({
|
|
description:
|
|
"Talk to a research agent. Use the same conversationId to continue " +
|
|
"an existing conversation — the agent remembers full context.",
|
|
inputSchema: z.object({
|
|
conversationId: z
|
|
.string()
|
|
.describe("Unique ID for this research thread. Reuse to continue."),
|
|
message: z.string().describe("Your message to the research agent"),
|
|
}),
|
|
execute: async function* ({ conversationId, message }, { abortSignal }) {
|
|
let agent = subAgents.get(conversationId);
|
|
if (!agent) {
|
|
agent = new AgentChat({
|
|
agent: "research-agent",
|
|
id: conversationId,
|
|
});
|
|
subAgents.set(conversationId, agent);
|
|
}
|
|
|
|
const stream = await agent.sendMessage(message, { abortSignal });
|
|
yield* stream.messages();
|
|
},
|
|
toModelOutput: ({ output: message }) => {
|
|
const lastText = message?.parts?.findLast(
|
|
(p: { type: string }) => p.type === "text"
|
|
) as { text?: string } | undefined;
|
|
return { type: "text", value: lastText?.text ?? "Done." };
|
|
},
|
|
});
|
|
```
|
|
|
|
The parent LLM naturally calls this tool multiple times:
|
|
|
|
1. `researchAgent({ conversationId: "competitors", message: "Research competitors in AI agents" })` — first call triggers a new sub-agent run
|
|
2. `researchAgent({ conversationId: "competitors", message: "Go deeper on pricing" })` — same run, sub-agent has full context
|
|
3. `researchAgent({ conversationId: "new-topic", message: "..." })` — different ID = different sub-agent
|
|
|
|
### Cross-turn persistence
|
|
|
|
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.
|
|
|
|
```ts
|
|
export const orchestrator = chat
|
|
.withClientData({ schema: z.object({ userId: z.string() }) })
|
|
.customAgent({
|
|
id: "orchestrator",
|
|
run: async (payload, { signal: runSignal }) => {
|
|
// These survive across parent turns via snapshot/restore
|
|
const subAgents = new Map<string, AgentChat>();
|
|
|
|
const researchTool = tool({
|
|
// ... closes over subAgents Map
|
|
});
|
|
|
|
// Turn loop — subAgents persist across all turns
|
|
for (let turn = 0; turn < 50; turn++) {
|
|
// ... streamText with researchTool
|
|
}
|
|
|
|
// Cleanup when parent exits
|
|
await Promise.all(
|
|
Array.from(subAgents.values()).map((a) => a.close().catch(() => {}))
|
|
);
|
|
},
|
|
});
|
|
```
|
|
|
|
## How sub-agents clean up
|
|
|
|
Sub-agents clean up through three mechanisms:
|
|
|
|
1. **Explicit close**: Call `chat.close()` or `agent.close()` when done
|
|
2. **Idle timeout**: The sub-agent's idle timeout expires, it suspends
|
|
3. **Suspend timeout**: The sub-agent's suspend timeout expires, the run ends
|
|
|
|
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.
|
|
|
|
## What the frontend sees
|
|
|
|
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`.
|
|
|
|
The frontend renders the tool part with:
|
|
- `state: "output-available"` and `preliminary: true` while streaming
|
|
- `state: "output-available"` and `preliminary: false` (or absent) when done
|
|
|
|
The tool output contains the full `UIMessage` with nested parts — text, the sub-agent's own tool calls and results, reasoning, etc.
|
|
|
|
### Controlling what the parent LLM sees
|
|
|
|
`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:
|
|
|
|
```ts
|
|
toModelOutput: ({ output: message }) => {
|
|
// Extract just the final text — the model doesn't need
|
|
// to see all the sub-agent's tool calls and intermediate work
|
|
const lastText = message?.parts?.findLast(
|
|
(p: { type: string }) => p.type === "text"
|
|
) as { text?: string } | undefined;
|
|
return { type: "text", value: lastText?.text ?? "Done." };
|
|
},
|
|
```
|
|
|
|
This is important for token efficiency: the sub-agent might use 100K tokens exploring and reasoning, but the parent LLM only consumes the summary.
|
|
|
|
<Warning>
|
|
`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).
|
|
</Warning>
|
|
|
|
## ChatStream.messages()
|
|
|
|
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.
|
|
|
|
```ts
|
|
const stream = await chat.sendMessage("Research this topic");
|
|
|
|
// Each yield is a complete UIMessage with all accumulated parts
|
|
for await (const message of stream.messages()) {
|
|
console.log(message.parts.length, "parts so far");
|
|
}
|
|
```
|
|
|
|
For the sub-agent pattern, use `yield*` to delegate all yields to the parent tool's generator:
|
|
|
|
```ts
|
|
execute: async function* ({ topic }, { abortSignal }) {
|
|
const stream = await chat.sendMessage(topic, { abortSignal });
|
|
yield* stream.messages();
|
|
},
|
|
```
|
|
|
|
<Tip>
|
|
`stream.messages()` consumes the stream. You can't also call `stream.text()` or iterate over chunks on the same stream. Pick one consumption mode.
|
|
</Tip>
|
|
|
|
## Combining with chat.agent()
|
|
|
|
Sub-agent tools work inside both `chat.agent()` (managed) and `chat.customAgent()` (manual lifecycle):
|
|
|
|
```ts
|
|
// Managed agent with sub-agent tool
|
|
const tools = { research: researchTool };
|
|
|
|
export const myAgent = chat.agent({
|
|
id: "orchestrator",
|
|
tools, // declare here so toModelOutput survives across turns
|
|
run: async ({ messages, tools, stopSignal }) => {
|
|
return streamText({
|
|
model: anthropic("claude-sonnet-4-6"),
|
|
messages,
|
|
tools,
|
|
abortSignal: stopSignal,
|
|
stopWhen: stepCountIs(15),
|
|
});
|
|
},
|
|
});
|
|
```
|
|
|
|
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).
|
|
|
|
## Streaming progress from a subtask to the parent chat
|
|
|
|
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:
|
|
|
|
```ts
|
|
import { chat, ai } from "@trigger.dev/sdk/ai";
|
|
import { schemaTask } from "@trigger.dev/sdk";
|
|
import { streamText, tool, generateId } from "ai";
|
|
import { anthropic } from "@ai-sdk/anthropic";
|
|
import { z } from "zod";
|
|
|
|
export const researchTask = schemaTask({
|
|
id: "research",
|
|
schema: z.object({ query: z.string() }),
|
|
run: async ({ query }) => {
|
|
const partId = generateId();
|
|
|
|
// Stream a data-* chunk to the root run's chat stream.
|
|
const { waitUntilComplete } = chat.stream.writer({
|
|
target: "root",
|
|
execute: ({ write }) => {
|
|
write({
|
|
type: "data-research-status",
|
|
id: partId,
|
|
data: { query, status: "in-progress" },
|
|
});
|
|
},
|
|
});
|
|
await waitUntilComplete();
|
|
|
|
const result = await doResearch(query);
|
|
|
|
// Update the same part with the final status — same type + id replaces it.
|
|
const { waitUntilComplete: waitDone } = chat.stream.writer({
|
|
target: "root",
|
|
execute: ({ write }) => {
|
|
write({
|
|
type: "data-research-status",
|
|
id: partId,
|
|
data: { query, status: "done", resultCount: result.length },
|
|
});
|
|
},
|
|
});
|
|
await waitDone();
|
|
|
|
return result;
|
|
},
|
|
});
|
|
|
|
const research = tool({
|
|
description: researchTask.description ?? "",
|
|
inputSchema: researchTask.schema!,
|
|
execute: ai.toolExecute(researchTask),
|
|
});
|
|
```
|
|
|
|
On the frontend, render the custom data part:
|
|
|
|
```tsx
|
|
{message.parts.map((part, i) => {
|
|
if (part.type === "data-research-status") {
|
|
const { query, status, resultCount } = part.data;
|
|
return (
|
|
<div key={i}>
|
|
{status === "done" ? `Found ${resultCount} results` : `Researching "${query}"...`}
|
|
</div>
|
|
);
|
|
}
|
|
// ...other part types
|
|
})}
|
|
```
|
|
|
|
The `target` option accepts:
|
|
- `"self"` — current run (default)
|
|
- `"parent"` — parent task's run
|
|
- `"root"` — root task's run (the chat agent)
|
|
- A specific run ID string
|
|
|
|
## Inside `ai.toolExecute`: accessing tool + chat context
|
|
|
|
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:
|
|
|
|
```ts
|
|
import { ai, chat } from "@trigger.dev/sdk/ai";
|
|
import type { myChat } from "./chat";
|
|
|
|
export const mySubtask = schemaTask({
|
|
id: "my-subtask",
|
|
schema: z.object({ query: z.string() }),
|
|
run: async ({ query }) => {
|
|
// The AI SDK tool call ID — useful as a stable `data-*` chunk id
|
|
const toolCallId = ai.toolCallId();
|
|
|
|
// Typed chat context — `clientData` is typed off your chat's `clientDataSchema`
|
|
const { chatId, clientData } = ai.chatContextOrThrow<typeof myChat>();
|
|
|
|
const { waitUntilComplete } = chat.stream.writer({
|
|
target: "root",
|
|
execute: ({ write }) => {
|
|
write({
|
|
type: "data-progress",
|
|
id: toolCallId,
|
|
data: { status: "working", query, userId: clientData?.userId },
|
|
});
|
|
},
|
|
});
|
|
await waitUntilComplete();
|
|
|
|
return { result: "done" };
|
|
},
|
|
});
|
|
```
|
|
|
|
| Helper | Returns | Description |
|
|
|--------|---------|-------------|
|
|
| `ai.toolCallId()` | `string \| undefined` | The AI SDK tool call ID |
|
|
| `ai.chatContext<typeof myChat>()` | `{ chatId, turn, continuation, clientData } \| undefined` | Chat context with typed `clientData`. Returns `undefined` if not in a chat context. |
|
|
| `ai.chatContextOrThrow<typeof myChat>()` | `{ chatId, turn, continuation, clientData }` | Same as above but throws if not in a chat context |
|
|
| `ai.currentToolOptions()` | `ToolCallExecutionOptions \| undefined` | Full tool execution options |
|
|
|
|
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.
|