Skip to content

Commit 5d7fc3b

Browse files
bloveclaude
andauthored
docs(blog): rewrite subgraphs and fixture-replay posts against the hardened codebase (#948)
* docs(specs): blog damage-control design — fix disclosed gaps, rewrite posts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plans): implementation plans for blog damage-control PRs 1-4 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(specs): record the stale-base discovery and aimock-restore redirection Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(blog): rewrite subgraphs post — announced bindings, structural routing, no incident arc Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(blog): rewrite fixture-replay post — deleted-dimension argument without the incident arc Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent c21c4d9 commit 5d7fc3b

7 files changed

Lines changed: 1490 additions & 102 deletions

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

Lines changed: 27 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -98,22 +98,17 @@ namespace events get emitted because no subgraph runs. The card would
9898
render empty. Rejected.
9999
```
100100

101-
Then there is the conversion.
102-
Our `cockpit/chat/subagents` demo originally ran its three specialists as a flat in-process helper, and was rewritten to dispatch a real compiled child graphbecause the flat version emitted no namespace events, so `subagents()` stayed empty and no card rendered.
103-
A working feature was restructured so a UI card would appear.
101+
Our `cockpit/chat/subagents` demo makes the same call.
102+
Its three specialists dispatch through a real compiled child graph because the subgraph run is what emits namespace eventsand the namespace events are what the tracker turns into per-child progress.
103+
Run the specialists as a flat in-process helper and the feature still works; the UI just cannot see it working.
104104

105105
In both of those graphs the compiled child is invoked from inside a `@tool` body, not wired in as a plain node.
106106
That is 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.
107107

108-
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 did not show up at all.
109-
That is no longer true.
110-
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.
108+
A plain `add_node` subgraph has an identity too, just a thinner one.
109+
Its namespace segment is unique per invocation and prefixed with the node name, so it registers in `subagents()` under its namespace key the moment it first streams, named by its node.
111110
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.
112111

113-
Which cuts the other way from how it sounds — plain `add_node` subgraphs make the visibility point sharper, not weaker.
114-
Nothing about them was ever invisible.
115-
The framework was simply the last to admit it.
116-
117112
## What does the frontend see while a child runs?
118113

119114
Namespaced events — and nearly everything interesting downstream follows from that one fact.
@@ -125,7 +120,7 @@ The event type carries the namespace after a pipe, so the base type is the part
125120

126121
```text
127122
messages # parent
128-
messages|tools:call-1 # child run dispatched by tool call "call-1"
123+
messages|tools:<uuid> # a child run, streaming under its own namespace
129124
```
130125

131126
Our transport requests those child streams by default — `streamSubgraphs` is `true` unless you turn it off.
@@ -142,43 +137,40 @@ If you ever write a transport against this stream yourself, that is the bug you
142137

143138
### Where child text goes
144139

145-
Onto the child's stream — and, as of this week, nowhere else.
140+
Onto the child's stream — and nowhere else.
146141

147142
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.
148-
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.
149-
150-
What made that bug expensive is that it self-corrected.
151-
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.
152-
Assert on the finished DOM and everything looks right.
153-
Watch the streaming pass and you would see the child's notes appear and then vanish.
154-
A final-state test cannot catch it — we found it by watching a live model with the DOM under a polling probe.
143+
It is also a trap, and a well-hidden one.
144+
Merge a child's tokens into the transcript and its internal notes render as their own chat bubble mid-stream — then the parent's final `values` event rewrites the message list from authoritative graph state, and the stray bubble disappears on its own.
145+
The end state looks right.
146+
The streaming pass did not.
155147

156-
The fix was to stop making it a decision at all.
148+
So we do not make it a decision at all.
157149
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.
158-
The opt-out flag is gone because there is nothing left to opt out of.
150+
There is no opt-out flag because there is nothing to opt out of.
159151
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.
160152
`transcriptNodeNames` still exists for the genuinely separate problem of *top-level* side-effect nodes, like routers and title generators.
161153

162154
### How does a child get attributed?
163155

164-
By id — and this is the part I find well-designed: the namespace segment _is_ the identifier.
156+
By an announced binding — and this is the part of the design I would steal for any protocol.
165157

166-
`tools:<id>` carries the parent tool call id, so the tracker slices the prefix off and looks the id up directly against what it recorded when the tool call came through.
167-
Marking a child running and routing its messages need no matching at all.
158+
The `tools:<uuid>` namespace a child streams under is a checkpoint id, assigned independently of the parent's tool-call id — nothing on the wire links the two.
159+
But the server knows both halves.
160+
So the graph announces the pair: one custom event pairing the child's checkpoint namespace with its tool-call id, emitted from inside the tool body.
161+
The tracker treats that binding as authoritative — it never overrides an established mapping, it works with any number of children in flight, and it replays any chunks that streamed before the binding arrived.
168162

169-
There is also a description-comparison ladder — exact match on the tool call's `description` argument, then substring either direction, then a last-resort fallback to any unmapped subagent still pending or running.
170-
It only runs for children whose state opens with a human message, and none of the graphs we ship reach it.
171-
The ones dispatched through a tool call invoke the child with an empty message list, so the first message in child state is the AI response.
172-
The one wired in as a plain node does not keep a `messages` key in child state at all.
173-
Treat that path as untested rather than as the mechanism.
163+
There is also a description-comparison ladder for graphs that do not announce — exact match on the tool call's `description` argument, then substring either direction, then a positional fallback that only fires when exactly one unmapped subagent is still pending or running.
164+
That last rung is deliberately conservative.
165+
With parallel children in flight, guessing would cross-wire one card's output into another, so an unattributed stream stays buffered instead — an empty card beats a confidently wrong one.
174166

175-
The general point survives, though, and it is the one worth carrying to any protocol.
176-
A consumer mapping child runs onto delegations is doing string matching unless the protocol gives it an id.
177-
LangGraph gives it an id — which is why the ladder is vestigial here and would be load-bearing in a fan-out graph with look-alike children.
167+
The general point is the one worth carrying to any protocol.
168+
A consumer mapping child runs onto delegations is doing string matching unless something hands it an id.
169+
Here the graph hands it one — which is why the ladder is a fallback, and why it would be load-bearing in a stack that cannot announce.
178170

179-
One limit, though: only the _first_ `tools:` segment of a namespace is read.
180-
A subagent that itself delegates will have its inner events attributed to the outer tool call.
181-
Nothing in this repo exercises deeper nesting, so do not build on it.
171+
Nesting is worth knowing about too.
172+
A subagent that itself delegates gets its own entry in `subagents()`, keyed by its namespace path, the same way a plain subgraph node does.
173+
Each level of delegation surfaces as its own stream; the map stays flat, so there is no parent/child tree to walk.
182174

183175
## When should you not split?
184176

0 commit comments

Comments
 (0)