Skip to content

Commit e4f1e1b

Browse files
bloveclaude
andauthored
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>
1 parent 959c6db commit e4f1e1b

17 files changed

Lines changed: 408 additions & 124 deletions

File tree

apps/website/content/blog/2026-08-27-langgraph-subgraphs-when-to-split.mdx

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,16 @@ Our `cockpit/chat/subagents` demo originally ran its three specialists as a flat
9999
A working feature was restructured so a UI card would appear.
100100

101101
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.
103103

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.
108112

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

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

135139
### Where child text goes
136140

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.
139142

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.
142145

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.
147150

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.
152156

153157
### How does a child get attributed?
154158

apps/website/content/docs/langgraph/api/api-docs.json

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -898,12 +898,6 @@
898898
"description": "Tuning options for the default transport's LangGraph SDK client (e.g. retry budget).",
899899
"optional": true
900900
},
901-
{
902-
"name": "filterSubagentMessages",
903-
"type": "boolean",
904-
"description": "When true, subagent messages are filtered from the main messages signal.",
905-
"optional": true
906-
},
907901
{
908902
"name": "initialValues",
909903
"type": "Partial<T>",
@@ -949,7 +943,7 @@
949943
{
950944
"name": "transcriptNodeNames",
951945
"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()`.",
953947
"optional": true
954948
},
955949
{
@@ -1046,12 +1040,6 @@
10461040
"description": "Tuning options for the default transport's LangGraph SDK client (e.g. retry budget).",
10471041
"optional": true
10481042
},
1049-
{
1050-
"name": "filterSubagentMessages",
1051-
"type": "boolean",
1052-
"description": "When true, subagent messages are filtered from the main messages signal.",
1053-
"optional": true
1054-
},
10551043
{
10561044
"name": "initialValues",
10571045
"type": "Partial<T>",
@@ -1097,7 +1085,7 @@
10971085
{
10981086
"name": "transcriptNodeNames",
10991087
"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.",
11011089
"optional": true
11021090
},
11031091
{

apps/website/content/docs/langgraph/api/provide-agent.mdx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ bootstrapApplication(AppComponent, {
3939
| `transport` | `AgentTransport` | Optional transport instance. Defaults to `FetchStreamTransport` when omitted. |
4040
| `clientOptions` | `LangGraphClientOptions` | LangGraph SDK client tuning (e.g. `maxRetries`). See [Client tuning](#client-tuning-retry-budget) below. |
4141
| `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. |
4342
| `subagentToolNames` | `string[]` | Tool names that indicate a subagent invocation. |
4443
| `transcriptNodeNames` | `string[]` | LangGraph node names whose `messages-tuple` chunks should stream into the main chat transcript. Omit to accept all top-level chunks. |
4544

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

6261
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()`.
6362

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).
6564

6665
```ts
6766
provideAgent({

apps/website/content/docs/langgraph/concepts/agent-architecture.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,7 @@ export class MultiAgentComponent {
459459
</Tabs>
460460

461461
<Callout type="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.
463463
</Callout>
464464

465465
## Error Handling and Recovery
@@ -677,7 +677,7 @@ builder.add_node("analyst", analyst_subgraph)
677677
builder.add_conditional_edges("supervisor", route_to_agent)
678678
```
679679

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

682682
### Decision Matrix
683683

apps/website/content/docs/langgraph/concepts/langgraph-basics.mdx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,6 @@ provideAgent({
236236
apiUrl: '...',
237237
assistantId: 'orchestrator',
238238
subagentToolNames: ['task'],
239-
filterSubagentMessages: true,
240239
});
241240
```
242241

apps/website/content/docs/langgraph/guides/subgraphs.mdx

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
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.
44

55
<Callout type="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.
77
</Callout>
88

99
## How subgraph composition works
@@ -105,10 +105,10 @@ export class OrchestratorComponent {
105105
</Tab>
106106
</Tabs>
107107

108-
<Callout type="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+
<Callout title="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.
110110

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).
112112
</Callout>
113113

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

168168
## Tracking delegated subagent execution
169169

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.
171171

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

245244
const pipeline = injectAgent(PIPELINE);
@@ -306,11 +305,9 @@ export class SubagentProgressComponent {
306305
</Tab>
307306
</Tabs>
308307

309-
## Filtering subagent messages
308+
## Child messages and the parent transcript
310309

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:
314311

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

327323
const orchestrator = injectAgent(ORCHESTRATOR);
328324

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

cockpit/langgraph/subgraphs/angular/e2e/subgraphs.spec.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ test.describe('cockpit subgraphs: conditional nesting', () => {
2121
await expect(panel.getByTestId('research-topic')).toContainText('checkpointer persists');
2222
await expect(panel.getByTestId('research-brief')).toContainText(BRIEF_MARKER);
2323
await expect(finalAssistant).toContainText('Checkpointing saves');
24+
25+
// The child also appears as a stream: plain subgraph children register in
26+
// agent.subagents() under their namespace, named by node, and settle with
27+
// the run.
28+
await expect(panel.getByTestId('child-stream')).toContainText('research — complete');
2429
});
2530

2631
test("the child's brief never reaches the transcript", async ({ page }) => {

cockpit/langgraph/subgraphs/angular/prompts/subgraphs.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ directly. The child graph's state has no `messages` key, so it exchanges only
1010
`research_topic` and `research_brief` with the parent and never touches the
1111
transcript.
1212

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

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

0 commit comments

Comments
 (0)