You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(langgraph)!: classify any namespaced event as child content (#844)
* feat(langgraph): classify any namespaced event as child content
One classification question, answered once: an event with any namespace
belongs to a child graph. Consistent with the terminal-evidence guard,
which has always refused ANY namespaced event — the transcript merge was
the only site still using the narrow tools:-only test.
- Child message events route to their child stream and never merge into
the parent transcript (kills the mid-stream leak class structurally)
- A child's values/updates no longer replace or spread-merge into the
parent's values$
- Plain subgraph children now appear in subagents(), keyed by namespace
segment, named by node prefix, settled by the run's terminal outcome
- filterSubagentMessages removed (exclusion is the semantic, not an option)
- Attribution ladder scoped to tool children so a plain child can never
be absorbed by an unrelated pending tool call
340/340 lib tests; classification mutation-tested (narrowing it back to
tools: fails exactly the 4 new pinning tests).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs+example: plain subgraph children are tracked streams now
Sweeps every surface that taught the old limitation: the subgraphs guide's
warning callout (now describes where child tokens actually go), the
provide-agent option table and workaround paragraph, agent-architecture,
langgraph-basics, the blog post's two stale sections, and the cockpit
example's prompts/guide/docstrings.
The cockpit example's sidebar gains a 'Child streams' section fed by
agent.subagents() — the same child shown as state boundary (value()) and
as stream, and the e2e asserts 'research — complete' renders, which
exercises the new tracker path against a real langgraph server.
api-docs regenerated (option removed, transcriptNodeNames doc updated).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: apps/website/content/blog/2026-08-27-langgraph-subgraphs-when-to-split.mdx
+21-17Lines changed: 21 additions & 17 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -99,12 +99,16 @@ Our `cockpit/chat/subagents` demo originally ran its three specialists as a flat
99
99
A working feature was restructured so a UI card would appear.
100
100
101
101
In both of those graphs the compiled child is invoked from inside a `@tool` body, not wired in as a plain node.
102
-
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.
102
+
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.
103
103
104
-
Which cuts the other way from how it sounds — plain `add_node` subgraphs make the point sharper, not weaker.
105
-
Those still get a namespace, so they're still observable in the raw stream.
106
-
They just don't get a name, so nothing downstream can attribute them to anything.
107
-
The subgraph is what makes the events observable; the tool call is what gives them an identity.
104
+
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.
105
+
That's no longer true.
106
+
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.
107
+
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.
108
+
109
+
Which cuts the other way from how it sounds — plain `add_node` subgraphs make the visibility point sharper, not weaker.
110
+
Nothing about them was ever invisible.
111
+
The framework was simply the last to admit it.
108
112
109
113
## What does the frontend see while a child runs?
110
114
@@ -134,21 +138,21 @@ If you ever write a transport against this stream yourself, that's the bug you'l
134
138
135
139
### Where child text goes
136
140
137
-
Into your main transcript, by default.
138
-
Our `filterSubagentMessages` is off unless you set it, so a child's tokens flow into `messages()` alongside the parent's.
141
+
Onto the child's stream — and, as of this week, nowhere else.
139
142
140
-
That isn't a quirk of our config.
141
-
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.
143
+
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.
144
+
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.
142
145
143
-
There's a trap in that option's name, and it bites the exact graph shape this post has been holding up.
144
-
`filterSubagentMessages` only fires inside a branch guarded by the `tools:` namespace check.
145
-
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.
146
-
The lever for that shape is `transcriptNodeNames`, which whitelists the graph nodes whose messages count as transcript.
146
+
What made that bug expensive is that it self-corrected.
147
+
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.
148
+
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.
149
+
A final-state test cannot catch it — we found it by watching a live model with the DOM under a polling probe.
147
150
148
-
It's also a mid-stream bug with a clean end state, which is the part that will waste your afternoon.
149
-
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.
150
-
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.
151
-
A final-state test cannot catch it.
151
+
The fix was to stop making it a decision at all.
152
+
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.
153
+
The opt-out flag is gone because there's nothing left to opt out of.
154
+
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.
155
+
`transcriptNodeNames` still exists for the genuinely separate problem of *top-level* side-effect nodes, like routers and title generators.
Copy file name to clipboardExpand all lines: apps/website/content/docs/langgraph/api/api-docs.json
+2-14Lines changed: 2 additions & 14 deletions
Original file line number
Diff line number
Diff line change
@@ -898,12 +898,6 @@
898
898
"description": "Tuning options for the default transport's LangGraph SDK client (e.g. retry budget).",
899
899
"optional": true
900
900
},
901
-
{
902
-
"name": "filterSubagentMessages",
903
-
"type": "boolean",
904
-
"description": "When true, subagent messages are filtered from the main messages signal.",
905
-
"optional": true
906
-
},
907
901
{
908
902
"name": "initialValues",
909
903
"type": "Partial<T>",
@@ -949,7 +943,7 @@
949
943
{
950
944
"name": "transcriptNodeNames",
951
945
"type": "string[]",
952
-
"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.",
946
+
"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()`.",
953
947
"optional": true
954
948
},
955
949
{
@@ -1046,12 +1040,6 @@
1046
1040
"description": "Tuning options for the default transport's LangGraph SDK client (e.g. retry budget).",
1047
1041
"optional": true
1048
1042
},
1049
-
{
1050
-
"name": "filterSubagentMessages",
1051
-
"type": "boolean",
1052
-
"description": "When true, subagent messages are filtered from the main messages signal.",
1053
-
"optional": true
1054
-
},
1055
1043
{
1056
1044
"name": "initialValues",
1057
1045
"type": "Partial<T>",
@@ -1097,7 +1085,7 @@
1097
1085
{
1098
1086
"name": "transcriptNodeNames",
1099
1087
"type": "string[]",
1100
-
"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.",
1088
+
"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.",
|`telemetry`|`AgentRuntimeTelemetrySink \| false`| Optional app-owned telemetry sink. No telemetry is emitted unless this is provided. |
42
-
|`filterSubagentMessages`|`boolean`| When true, subagent messages are filtered from the main messages signal. |
43
42
|`subagentToolNames`|`string[]`| Tool names that indicate a subagent invocation. |
44
43
|`transcriptNodeNames`|`string[]`| LangGraph node names whose `messages-tuple` chunks should stream into the main chat transcript. Omit to accept all top-level chunks. |
45
44
@@ -61,7 +60,7 @@ const chat = injectAgent();
61
60
62
61
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()`.
63
62
64
-
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).
63
+
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).
Copy file name to clipboardExpand all lines: apps/website/content/docs/langgraph/concepts/agent-architecture.mdx
+2-2Lines changed: 2 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -459,7 +459,7 @@ export class MultiAgentComponent {
459
459
</Tabs>
460
460
461
461
<Callouttype="tip"title="subagentToolNames is the key">
462
-
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.
462
+
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.
Copy file name to clipboardExpand all lines: apps/website/content/docs/langgraph/guides/subgraphs.mdx
+8-12Lines changed: 8 additions & 12 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -3,7 +3,7 @@
3
3
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.
4
4
5
5
<Callouttype="info"title="Subgraphs vs subagents">
6
-
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`.
6
+
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.
7
7
</Callout>
8
8
9
9
## How subgraph composition works
@@ -105,10 +105,10 @@ export class OrchestratorComponent {
105
105
</Tab>
106
106
</Tabs>
107
107
108
-
<Callouttype="warning"title="Child messages land in the parent transcript">
109
-
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.
108
+
<Callouttitle="Where the child's tokens go">
109
+
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.
110
110
111
-
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.
111
+
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).
112
112
</Callout>
113
113
114
114
## Giving the child its own state
@@ -167,7 +167,7 @@ Because `ResearchState` has no `messages` key, the child cannot read the transcr
167
167
168
168
## Tracking delegated subagent execution
169
169
170
-
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.
170
+
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.
171
171
172
172
```typescript
173
173
// In a shared file (e.g. agent.ts):
@@ -239,7 +239,6 @@ The orchestrator pattern delegates specialised work to subagents and merges thei
239
239
// provideAgent(PIPELINE, {
240
240
// apiUrl: '...',
241
241
// subagentToolNames: ['task'],
242
-
// filterSubagentMessages: true,
243
242
// });
244
243
245
244
const pipeline =injectAgent(PIPELINE);
@@ -306,11 +305,9 @@ export class SubagentProgressComponent {
306
305
</Tab>
307
306
</Tabs>
308
307
309
-
## Filtering subagent messages
308
+
## Child messages and the parent transcript
310
309
311
-
By default, subagent messages appear in the parent's `messages()` signal. Filter them out for a cleaner parent view.
312
-
313
-
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.
310
+
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:
314
311
315
312
```typescript
316
313
// In a shared file (e.g. agent.ts):
@@ -320,13 +317,12 @@ This applies to tool-dispatched subagents — the `tools:`-namespaced streams th
320
317
// Configure in app.config.ts:
321
318
// provideAgent(ORCHESTRATOR, {
322
319
// apiUrl: '...',
323
-
// filterSubagentMessages: true, // Hide subagent messages from parent
324
320
// subagentToolNames: ['task'],
325
321
// });
326
322
327
323
const orchestrator =injectAgent(ORCHESTRATOR);
328
324
329
-
//Parent messages only (no subagent chatter)
325
+
//The parent's transcript — child chatter is structurally absent
0 commit comments