How it works
- The parent LLM calls a tool (e.g.,
researchAgent) - The tool’s
executeis anasync function*(async generator) - Inside, it creates an
AgentChatand sends a message to the sub-agent yield* stream.messages()streams each accumulatedUIMessagesnapshot as a preliminary tool result- The frontend renders the sub-agent’s response building up inside the parent’s tool card
toModelOutputcompresses the full output into a summary for the parent LLM
Single-turn sub-agent
The simplest pattern: one tool call, one sub-agent turn, conversation closes.streamText call:
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 sameconversationId hits the same durable agent run.
researchAgent({ conversationId: "competitors", message: "Research competitors in AI agents" })— first call triggers a new sub-agent runresearchAgent({ conversationId: "competitors", message: "Go deeper on pricing" })— same run, sub-agent has full contextresearchAgent({ conversationId: "new-topic", message: "..." })— different ID = different sub-agent
Cross-turn persistence
Sub-agent conversations persist across parent turns because theMap 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.
How sub-agents clean up
Sub-agents clean up through three mechanisms:- Explicit close: Call
chat.close()oragent.close()when done - Idle timeout: The sub-agent’s idle timeout expires, it suspends
- Suspend timeout: The sub-agent’s suspend timeout expires, the run ends
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
Eachyield 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"andpreliminary: truewhile streamingstate: "output-available"andpreliminary: false(or absent) when done
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:
ChatStream.messages()
Themessages() 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.
yield* to delegate all yields to the parent tool’s generator:
Combining with chat.agent()
Sub-agent tools work inside bothchat.agent() (managed) and chat.customAgent() (manual lifecycle):
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.
Streaming progress from a subtask to the parent chat
When a tool invokes a subtask viatriggerAndWait, 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:
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:
The subtask body also has read-only access to any
chat.local values initialized in the parent — auto-hydrated from the parent’s metadata on first access.
