Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,16 @@ Our `cockpit/chat/subagents` demo originally ran its three specialists as a flat
A working feature was restructured so a UI card would appear.

In both of those graphs the compiled child is invoked from inside a `@tool` body, not wired in as a plain node.
That's deliberate: the tool call is what the tracker registers, and our own docs are blunt that [plain subgraph nodes](/docs/langgraph/guides/subgraphs) don't show up in that map at all.
That's deliberate: the tool call carries the identity — an id the tracker can attribute the child's stream to, and a `subagent_type` to name it.

Which cuts the other way from how it sounds — plain `add_node` subgraphs make the point sharper, not weaker.
Those still get a namespace, so they're still observable in the raw stream.
They just don't get a name, so nothing downstream can attribute them to anything.
The subgraph is what makes the events observable; the tool call is what gives them an identity.
For a long time that was also the only way into the map: plain `add_node` subgraphs streamed under a namespace nobody claimed, so [our own docs](/docs/langgraph/guides/subgraphs) were blunt that they didn't show up at all.
That's no longer true.
The namespace segment is itself a workable identity — unique per invocation, prefixed with the node name — so a plain subgraph child now registers in `subagents()` under its namespace key the moment it first streams, named by its node.
The subgraph is what makes the events observable; the tool call upgrades that identity from a node name to a real delegation record, with arguments a UI can render.

Which cuts the other way from how it sounds — plain `add_node` subgraphs make the visibility point sharper, not weaker.
Nothing about them was ever invisible.
The framework was simply the last to admit it.

## What does the frontend see while a child runs?

Expand Down Expand Up @@ -134,21 +138,21 @@ If you ever write a transport against this stream yourself, that's the bug you'l

### Where child text goes

Into your main transcript, by default.
Our `filterSubagentMessages` is off unless you set it, so a child's tokens flow into `messages()` alongside the parent's.
Onto the child's stream — and, as of this week, nowhere else.

That isn't a quirk of our config.
Any consumer reading a namespaced stream has to decide what a child's tokens mean, and "append them like everything else" is the path of least resistance — so unless something opts out, child text lands in the parent transcript and the same content renders twice.
Any consumer reading a namespaced stream has to decide what a child's tokens mean, and "append them like everything else" is the path of least resistance.
Ours took that path for a long time: child tokens merged into `messages()` unless an opt-out flag was set, and the flag itself only fired for `tools:` namespaces — so for a plain subgraph node it silently did nothing, and the child's internal notes rendered as their own chat bubble mid-stream.

There's a trap in that option's name, and it bites the exact graph shape this post has been holding up.
`filterSubagentMessages` only fires inside a branch guarded by the `tools:` namespace check.
A plain subgraph node's namespace looks like `research:<uuid>`, never reaches that branch, and so ignores the option entirely — its tokens merge into the transcript however you set it.
The lever for that shape is `transcriptNodeNames`, which whitelists the graph nodes whose messages count as transcript.
What made that bug expensive is that it self-corrected.
The parent's final `values` event rewrites the message list from authoritative graph state, so the stray bubble disappeared on its own once the run settled.
Assert on the finished DOM and everything looks right; watch the streaming pass and you'd see the child's notes appear and then vanish.
A final-state test cannot catch it — we found it by watching a live model with the DOM under a polling probe.

It's also a mid-stream bug with a clean end state, which is the part that will waste your afternoon.
The parent's final `values` event rewrites the message list from authoritative graph state, so the stray bubble disappears on its own once the run settles.
Assert on the finished DOM and everything looks right; watch the streaming pass and you'll see the child's internal notes render as their own message and then vanish.
A final-state test cannot catch it.
The fix was to stop making it a decision at all.
A namespaced event belongs to its child, structurally: it feeds that child's `messages()` on the subagent stream and never merges into the parent transcript.
The opt-out flag is gone because there's nothing left to opt out of.
What the transcript shows at settle is decided by state — a shared `messages` key delivers the child's message through the final `values` sync; an isolated child schema means it never arrives.
`transcriptNodeNames` still exists for the genuinely separate problem of *top-level* side-effect nodes, like routers and title generators.

### How does a child get attributed?

Expand Down
16 changes: 2 additions & 14 deletions apps/website/content/docs/langgraph/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -898,12 +898,6 @@
"description": "Tuning options for the default transport's LangGraph SDK client (e.g. retry budget).",
"optional": true
},
{
"name": "filterSubagentMessages",
"type": "boolean",
"description": "When true, subagent messages are filtered from the main messages signal.",
"optional": true
},
{
"name": "initialValues",
"type": "Partial<T>",
Expand Down Expand Up @@ -949,7 +943,7 @@
{
"name": "transcriptNodeNames",
"type": "string[]",
"description": "LangGraph node names whose `messages-tuple` LLM chunks should be projected\ninto the main chat transcript. Omit to accept all top-level message chunks.",
"description": "LangGraph node names whose `messages-tuple` LLM chunks should be projected\ninto the main chat transcript. Omit to accept all top-level message chunks.\nChild-graph (namespaced) chunks never reach the transcript regardless of\nthis option — they belong to their child stream in `subagents()`.",
"optional": true
},
{
Expand Down Expand Up @@ -1046,12 +1040,6 @@
"description": "Tuning options for the default transport's LangGraph SDK client (e.g. retry budget).",
"optional": true
},
{
"name": "filterSubagentMessages",
"type": "boolean",
"description": "When true, subagent messages are filtered from the main messages signal.",
"optional": true
},
{
"name": "initialValues",
"type": "Partial<T>",
Expand Down Expand Up @@ -1097,7 +1085,7 @@
{
"name": "transcriptNodeNames",
"type": "string[]",
"description": "LangGraph node names whose `messages-tuple` LLM chunks should be projected\ninto the main chat transcript. Omit to accept all top-level message chunks.\n\nUse this when a graph has side-effect LLM nodes, such as title generation,\nwhose streamed model output should not render as assistant chat content.",
"description": "LangGraph node names whose `messages-tuple` LLM chunks should be projected\ninto the main chat transcript. Omit to accept all top-level message chunks.\nChild-graph (namespaced) chunks never reach the transcript regardless of\nthis option — they belong to their child stream in `subagents()`.\n\nUse this when a graph has side-effect LLM nodes, such as title generation,\nwhose streamed model output should not render as assistant chat content.",
"optional": true
},
{
Expand Down
3 changes: 1 addition & 2 deletions apps/website/content/docs/langgraph/api/provide-agent.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ bootstrapApplication(AppComponent, {
| `transport` | `AgentTransport` | Optional transport instance. Defaults to `FetchStreamTransport` when omitted. |
| `clientOptions` | `LangGraphClientOptions` | LangGraph SDK client tuning (e.g. `maxRetries`). See [Client tuning](#client-tuning-retry-budget) below. |
| `telemetry` | `AgentRuntimeTelemetrySink \| false` | Optional app-owned telemetry sink. No telemetry is emitted unless this is provided. |
| `filterSubagentMessages` | `boolean` | When true, subagent messages are filtered from the main messages signal. |
| `subagentToolNames` | `string[]` | Tool names that indicate a subagent invocation. |
| `transcriptNodeNames` | `string[]` | LangGraph node names whose `messages-tuple` chunks should stream into the main chat transcript. Omit to accept all top-level chunks. |

Expand All @@ -61,7 +60,7 @@ const chat = injectAgent();

LangGraph streams `messages-tuple` chunks for every LLM node in a run. If your graph has side-effect LLM nodes, such as a title generator or evaluator, set `transcriptNodeNames` so only your conversational node updates `messages()`.

This is also the lever for a plain subgraph node. A compiled child added with `add_node` streams under a namespace like `research:<uuid>`, which is not a `tools:` subagent namespace, so `filterSubagentMessages` never applies to it and its tokens merge into the transcript. Naming your answering node here keeps the child's internal output out of the chat. See [Subgraphs](/docs/langgraph/guides/subgraphs).
Child-graph streams are a separate concern and need no configuration: any namespaced event — a compiled child added with `add_node` (`research:<uuid>`) or a tool-dispatched subagent (`tools:<id>`) — belongs to its child stream in `subagents()` and never merges into the transcript. See [Subgraphs](/docs/langgraph/guides/subgraphs).

```ts
provideAgent({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ export class MultiAgentComponent {
</Tabs>

<Callout type="tip" title="subagentToolNames is the key">
The `subagentToolNames` option tells `injectAgent()` which tool calls spawn subagents. The default Deep Agents tool name is `task`; set this option when your graph uses custom delegation tool names. Ordinary LangGraph subgraph nodes stream through the parent signals, but they do not appear in `subagents()` unless they are represented by matching delegation tool calls.
The `subagentToolNames` option tells `injectAgent()` which tool calls spawn subagents. The default Deep Agents tool name is `task`; set this option when your graph uses custom delegation tool names. Ordinary LangGraph subgraph nodes need no configuration: they appear in `subagents()` under their namespace key, named by node, and their streamed output stays on that child stream rather than the parent transcript.
</Callout>

## Error Handling and Recovery
Expand Down Expand Up @@ -677,7 +677,7 @@ builder.add_node("analyst", analyst_subgraph)
builder.add_conditional_edges("supervisor", route_to_agent)
```

**Angular signals used:** `messages()`, `toolCalls()`, `status()`; `subagents()` only when delegation happens through tracked tool calls
**Angular signals used:** `messages()`, `toolCalls()`, `status()`; `subagents()` for every child graph — tool-dispatched or a plain subgraph node

### Decision Matrix

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,6 @@ provideAgent({
apiUrl: '...',
assistantId: 'orchestrator',
subagentToolNames: ['task'],
filterSubagentMessages: true,
});
```

Expand Down
20 changes: 8 additions & 12 deletions apps/website/content/docs/langgraph/guides/subgraphs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Subgraphs let you compose larger agents from smaller, focused units. `injectAgent()` streams their output through the same message, state, tool-call, and custom-event signals as the parent graph.

<Callout type="info" title="Subgraphs vs subagents">
LangGraph subgraphs are graph nodes. Deep Agents-style subagents are delegated tool calls. `injectAgent()` requests subgraph streams by default, but the `subagents()` signal is populated only for tool calls whose names match `subagentToolNames` and whose args include a `subagent_type`.
LangGraph subgraphs are graph nodes. Deep Agents-style subagents are delegated tool calls. `injectAgent()` requests subgraph streams by default, and every namespaced child run appears in the `subagents()` signal — tool-dispatched children under their tool-call id (matched via `subagentToolNames` + `subagent_type`), plain subgraph nodes under their namespace key, named by node. A child's tokens live on its stream and never merge into the parent transcript.
</Callout>

## How subgraph composition works
Expand Down Expand Up @@ -105,10 +105,10 @@ export class OrchestratorComponent {
</Tab>
</Tabs>

<Callout type="warning" title="Child messages land in the parent transcript">
Both graphs above share `MessagesState`, so the child appends to the same message list the parent is building — its intermediate output renders as its own chat bubble. `filterSubagentMessages` does not help here: that option is only consulted for `tools:`-namespaced streams, and a plain subgraph node emits `research:<uuid>`. The lever for this shape is [`transcriptNodeNames`](/docs/langgraph/api/provide-agent), which whitelists the graph nodes whose messages count as transcript.
<Callout title="Where the child's tokens go">
A child's streamed tokens never merge into the parent transcript — they land on the child's own stream in `subagents()`, keyed by the `research:<uuid>` namespace. What the transcript shows at settle is decided by state: because both graphs above share `MessagesState`, the child's message enters the parent's message list and arrives with the final `values` sync. Give the child its own schema (below) and it never does.

The leak is mid-stream with a clean end state — the parent's final `values` event rewrites the message list from authoritative graph state, so the stray bubble disappears once the run settles. A final-state test cannot catch it.
Streamed chunks from *top-level* side-effect nodes — a router, a title generator — are a separate concern: whitelist your conversational nodes with [`transcriptNodeNames`](/docs/langgraph/api/provide-agent).
</Callout>

## Giving the child its own state
Expand Down Expand Up @@ -167,7 +167,7 @@ Because `ResearchState` has no `messages` key, the child cannot read the transcr

## Tracking delegated subagent execution

The `subagents()` signal contains a Map of active delegated subagent streams. Use it when your graph delegates through tool calls, such as Deep Agents' default `task` tool or your own delegation tools. Plain subgraph nodes do not appear in this map.
The `subagents()` signal contains a Map of active child streams. Tool-dispatched children — Deep Agents' default `task` tool or your own delegation tools — are keyed by tool-call id and named by their `subagent_type`. Plain subgraph nodes are keyed by their namespace segment and named by node; they register on their first streamed event and settle with the run.

```typescript
// In a shared file (e.g. agent.ts):
Expand Down Expand Up @@ -239,7 +239,6 @@ The orchestrator pattern delegates specialised work to subagents and merges thei
// provideAgent(PIPELINE, {
// apiUrl: '...',
// subagentToolNames: ['task'],
// filterSubagentMessages: true,
// });

const pipeline = injectAgent(PIPELINE);
Expand Down Expand Up @@ -306,11 +305,9 @@ export class SubagentProgressComponent {
</Tab>
</Tabs>

## Filtering subagent messages
## Child messages and the parent transcript

By default, subagent messages appear in the parent's `messages()` signal. Filter them out for a cleaner parent view.

This applies to tool-dispatched subagents — the `tools:`-namespaced streams that populate `subagents()`. For a plain subgraph node, use [`transcriptNodeNames`](/docs/langgraph/api/provide-agent) instead; `filterSubagentMessages` has no effect on that shape.
Child messages never appear in the parent's `messages()` signal — a namespaced stream belongs to its child, and `messages()` is the parent's transcript. Render a child's live output from its own stream:

```typescript
// In a shared file (e.g. agent.ts):
Expand All @@ -320,13 +317,12 @@ This applies to tool-dispatched subagents — the `tools:`-namespaced streams th
// Configure in app.config.ts:
// provideAgent(ORCHESTRATOR, {
// apiUrl: '...',
// filterSubagentMessages: true, // Hide subagent messages from parent
// subagentToolNames: ['task'],
// });

const orchestrator = injectAgent(ORCHESTRATOR);

// Parent messages only (no subagent chatter)
// The parent's transcript — child chatter is structurally absent
const parentMessages = computed(() => orchestrator.messages());
```

Expand Down
5 changes: 5 additions & 0 deletions cockpit/langgraph/subgraphs/angular/e2e/subgraphs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ test.describe('cockpit subgraphs: conditional nesting', () => {
await expect(panel.getByTestId('research-topic')).toContainText('checkpointer persists');
await expect(panel.getByTestId('research-brief')).toContainText(BRIEF_MARKER);
await expect(finalAssistant).toContainText('Checkpointing saves');

// The child also appears as a stream: plain subgraph children register in
// agent.subagents() under their namespace, named by node, and settle with
// the run.
await expect(panel.getByTestId('child-stream')).toContainText('research — complete');
});

test("the child's brief never reaches the transcript", async ({ page }) => {
Expand Down
17 changes: 10 additions & 7 deletions cockpit/langgraph/subgraphs/angular/prompts/subgraphs.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ directly. The child graph's state has no `messages` key, so it exchanges only
`research_topic` and `research_brief` with the parent and never touches the
transcript.

The sidebar reads the parent graph's own state through `agent.value()` to show
which branch ran and what the child returned. It deliberately does **not** use
`agent.subagents()`: that signal is populated only by delegation *tool calls*
(`subagentToolNames` + `subagent_type`), not by plain subgraph nodes. For that
pattern see the Chat Subagents capability.
The sidebar shows the child from two angles. `agent.value()` reads the parent
graph's own state — watching the shared keys is watching the boundary itself.
`agent.subagents()` shows the child as a stream: plain subgraph nodes appear
there under their namespace key, named by node, and settle with the run
(tool-dispatched children appear under their tool-call id — see the Chat
Subagents capability for that shape).

Key components used: `<chat>`. `provideAgent({ transcriptNodeNames: ['answer'] })`
keeps the router's and the subgraph's tokens out of the chat transcript.
Key components used: `<chat>`. Child tokens stay on the child's stream and
never merge into the transcript; `provideAgent({ transcriptNodeNames:
['answer'] })` additionally keeps the top-level router node's
structured-output chunks out of the chat.
Loading
Loading