From bea32f778f901d50de19eab9e3594f5b94c2cf81 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 28 Aug 2026 17:26:16 -0700 Subject: [PATCH 1/2] feat(langgraph): classify any namespaced event as child content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- libs/langgraph/src/lib/agent.fn.spec.ts | 3 +- libs/langgraph/src/lib/agent.provider.ts | 5 +- libs/langgraph/src/lib/agent.types.ts | 4 +- .../internals/stream-manager.bridge.spec.ts | 154 +++++++++++++++++- .../lib/internals/stream-manager.bridge.ts | 85 ++++++---- .../src/lib/internals/subagent-tracker.ts | 97 ++++++++++- 6 files changed, 305 insertions(+), 43 deletions(-) diff --git a/libs/langgraph/src/lib/agent.fn.spec.ts b/libs/langgraph/src/lib/agent.fn.spec.ts index 31c7599b4..9ee35d649 100644 --- a/libs/langgraph/src/lib/agent.fn.spec.ts +++ b/libs/langgraph/src/lib/agent.fn.spec.ts @@ -174,7 +174,7 @@ describe('agent', () => { const ref = withInjectionContext(() => agent({ apiUrl: '', assistantId: 'a', transport, throttle: false, - subagentToolNames: ['task'], filterSubagentMessages: true, + subagentToolNames: ['task'], }) ); @@ -792,7 +792,6 @@ describe('agent', () => { transport, throttle: false, subagentToolNames: ['task'], - filterSubagentMessages: true, }) ); diff --git a/libs/langgraph/src/lib/agent.provider.ts b/libs/langgraph/src/lib/agent.provider.ts index d3400f8ab..8e9c5b8ee 100644 --- a/libs/langgraph/src/lib/agent.provider.ts +++ b/libs/langgraph/src/lib/agent.provider.ts @@ -43,13 +43,13 @@ export interface AgentConfig< clientOptions?: LangGraphClientOptions; /** Optional app-owned telemetry sink. No telemetry is emitted unless this is provided. */ telemetry?: AgentRuntimeTelemetrySink | false; - /** When true, subagent messages are filtered from the main messages signal. */ - filterSubagentMessages?: boolean; /** Tool names that indicate a subagent invocation. */ subagentToolNames?: string[]; /** * LangGraph node names whose `messages-tuple` LLM chunks should be projected * into the main chat transcript. Omit to accept all top-level message chunks. + * Child-graph (namespaced) chunks never reach the transcript regardless of + * this option — they belong to their child stream in `subagents()`. */ transcriptNodeNames?: string[]; } @@ -87,7 +87,6 @@ function agentFactory(): LangGraphAgent { ...(config.transport !== undefined ? { transport: config.transport } : {}), ...(config.clientOptions !== undefined ? { clientOptions: config.clientOptions } : {}), ...(config.telemetry !== undefined ? { telemetry: config.telemetry } : {}), - ...(config.filterSubagentMessages !== undefined ? { filterSubagentMessages: config.filterSubagentMessages } : {}), ...(config.subagentToolNames !== undefined ? { subagentToolNames: config.subagentToolNames } : {}), ...(config.transcriptNodeNames !== undefined ? { transcriptNodeNames: config.transcriptNodeNames } : {}), }); diff --git a/libs/langgraph/src/lib/agent.types.ts b/libs/langgraph/src/lib/agent.types.ts index 57b86a1db..064c2138d 100644 --- a/libs/langgraph/src/lib/agent.types.ts +++ b/libs/langgraph/src/lib/agent.types.ts @@ -279,8 +279,6 @@ export interface AgentOptions { clientOptions?: LangGraphClientOptions; /** Optional app-owned telemetry sink. No telemetry is emitted unless this is provided. */ telemetry?: AgentRuntimeTelemetrySink | false; - /** When true, subagent messages are filtered from the main messages signal. */ - filterSubagentMessages?: boolean; /** Tool names that indicate a subagent invocation. */ subagentToolNames?: string[]; /** @@ -295,6 +293,8 @@ export interface AgentOptions { /** * LangGraph node names whose `messages-tuple` LLM chunks should be projected * into the main chat transcript. Omit to accept all top-level message chunks. + * Child-graph (namespaced) chunks never reach the transcript regardless of + * this option — they belong to their child stream in `subagents()`. * * Use this when a graph has side-effect LLM nodes, such as title generation, * whose streamed model output should not render as assistant chat content. diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts index c6451c9e9..82a2964d9 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts @@ -2472,7 +2472,7 @@ describe('createStreamManagerBridge', () => { const bridge = createStreamManagerBridge({ options: { apiUrl: '', assistantId: 'test', transport, - subagentToolNames: ['task'], filterSubagentMessages: true, + subagentToolNames: ['task'], }, subjects, threadId$: of('thread-1'), @@ -2769,7 +2769,7 @@ describe('createStreamManagerBridge', () => { destroy$.next(); }); - it('routes subagent message tuples out of main messages when filtering is enabled', async () => { + it('routes tool-child message tuples to the child stream, never the transcript', async () => { const transport = new MockAgentTransport(); const subjects = makeSubjects(); const destroy$ = new Subject(); @@ -2779,7 +2779,6 @@ describe('createStreamManagerBridge', () => { assistantId: 'test', transport, subagentToolNames: ['task'], - filterSubagentMessages: true, }, subjects, threadId$: of(null), @@ -2818,6 +2817,155 @@ describe('createStreamManagerBridge', () => { destroy$.next(); }); + it('routes plain-subgraph message tuples to a namespace-keyed child stream, never the transcript', async () => { + const transport = new MockAgentTransport(); + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of(null), + destroy$: destroy$.asObservable(), + }); + + bridge.submit({}); + transport.emit([{ + type: 'messages', + messages: [{ id: 'parent-ai', type: 'ai', content: 'routing' }], + messageMetadata: { langgraph_node: 'orchestrate' }, + } satisfies StreamEvent]); + transport.emit([{ + type: 'messages|research:abc123' as StreamEvent['type'], + namespace: ['research:abc123'], + messages: [{ id: 'child-ai', type: 'ai', content: 'internal brief' }], + messageMetadata: { checkpoint_ns: 'research:abc123', langgraph_node: 'research' }, + } satisfies StreamEvent]); + transport.close(); + + await new Promise(r => setTimeout(r, 10)); + + // Transcript: parent only. The child's tokens never merge. + expect(subjects.messages$.value).toHaveLength(1); + expect(subjects.messages$.value[0]).toMatchObject({ id: 'parent-ai' }); + + // Child stream: keyed by the namespace segment, named by its node prefix. + const child = subjects.subagents$.value.get('research:abc123'); + expect(child).toBeDefined(); + expect(child?.name).toBe('research'); + expect(child?.messages()).toEqual([ + expect.objectContaining({ id: 'child-ai', content: 'internal brief' }), + ]); + destroy$.next(); + }); + + it("keeps a plain child's values and updates out of the parent's values$", async () => { + const transport = new MockAgentTransport(); + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of(null), + destroy$: destroy$.asObservable(), + }); + + bridge.submit({}); + transport.emit([ + { type: 'values', data: { messages: [], research_topic: 'checkpointing' } }, + { + type: 'values|research:abc123' as StreamEvent['type'], + namespace: ['research:abc123'], + data: { research_brief: 'child-only state' }, + }, + { + type: 'updates|research:abc123' as StreamEvent['type'], + namespace: ['research:abc123'], + data: { research: { research_brief: 'child-only state' } }, + }, + ] as StreamEvent[]); + transport.close(); + + await new Promise(r => setTimeout(r, 10)); + + // Parent values$ holds only what the parent emitted at top level — a + // child's values event must neither replace nor spread-merge into it. + expect(subjects.values$.value).toEqual({ messages: [], research_topic: 'checkpointing' }); + // The child's state landed on its own stream. + expect(subjects.subagents$.value.get('research:abc123')?.values()).toMatchObject({ + research_brief: 'child-only state', + }); + destroy$.next(); + }); + + it('settles running subgraph children when the run completes, but not on pause', async () => { + const transport = new MockAgentTransport(); + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects, + threadId$: of(null), + destroy$: destroy$.asObservable(), + }); + + const done = bridge.submit({}); + transport.emit([{ + type: 'messages|research:abc123' as StreamEvent['type'], + namespace: ['research:abc123'], + messages: [{ id: 'child-ai', type: 'ai', content: 'brief' }], + messageMetadata: { checkpoint_ns: 'research:abc123' }, + } satisfies StreamEvent]); + await new Promise(r => setTimeout(r, 0)); + expect(subjects.subagents$.value.get('research:abc123')?.status()).toBe('running'); + + transport.emit([{ type: 'values', data: { done: true } } as StreamEvent]); + transport.close(); + await done; + + expect(subjects.subagents$.value.get('research:abc123')?.status()).toBe('complete'); + destroy$.next(); + }); + + it('never attributes a plain child to a pending tool subagent via the fallback ladder', async () => { + const transport = new MockAgentTransport(); + const subjects = makeSubjects(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport, subagentToolNames: ['task'] }, + subjects, + threadId$: of(null), + destroy$: destroy$.asObservable(), + }); + + bridge.submit({}); + // A pending tool child exists… + transport.emit([{ + type: 'messages', + messages: [{ + id: 'ai-1', type: 'ai', content: '', + tool_calls: [{ id: 'call-1', name: 'task', args: { subagent_type: 'researcher', description: 'tool work' } }], + }], + } satisfies StreamEvent]); + // …and a plain subgraph child streams values with a human first message — + // the shape the description ladder keys on. + transport.emit([{ + type: 'values|research:abc123' as StreamEvent['type'], + namespace: ['research:abc123'], + data: { messages: [{ type: 'human', content: 'unrelated child input' }] }, + } as StreamEvent]); + transport.close(); + + await new Promise(r => setTimeout(r, 10)); + + // The plain child is its own entry; the tool child absorbed nothing. + expect(subjects.subagents$.value.get('research:abc123')?.name).toBe('research'); + // Before the ladder was scoped to tool children, the fallback would have + // mapped the namespace onto call-1 and flipped it to 'running', making it + // visible. It must remain pending — and pending entries stay hidden. + expect(subjects.subagents$.value.get('call-1')).toBeUndefined(); + destroy$.next(); + }); + it('clears tracked subagents when the thread changes', async () => { const transport = new MockAgentTransport(); const subjects = makeSubjects(); diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts index 1d9b2aea9..caab64fae 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts @@ -34,8 +34,8 @@ import { import { SubagentTracker, TrackedSubagent, - extractToolCallIdFromNamespace, - isSubagentNamespace, + childStreamRefFromNamespace, + isChildNamespace, } from './subagent-tracker'; import type { BaseMessage } from '@langchain/core/messages'; import type { Interrupt, Message as LangGraphMessage, ThreadState, ToolCallWithResult, ToolProgress } from '@langchain/langgraph-sdk'; @@ -241,6 +241,13 @@ export function createStreamManagerBridge { const id = (message as unknown as Record)['id']; return typeof id === 'string' && affectedMessageIds.has(id); @@ -810,7 +822,7 @@ export function createStreamManagerBridge(); const preserved = preserveIds(subjects.messages$.value, normalized, affectedMessageIds); subjects.messages$.next(preserved); - if (!isSubagentNamespace(namespace)) { + { trackAssistantMessages(preserved.filter(message => { const id = (message as unknown as Record)['id']; return typeof id === 'string' && affectedMessageIds.has(id); @@ -830,8 +842,10 @@ export function createStreamManagerBridge): void { - const namespaceId = namespace ? extractToolCallIdFromNamespace(namespace) : undefined; - if (!namespaceId) return; - - const messages = values['messages']; - if (Array.isArray(messages) && messages.length > 0) { - const first = messages[0]; - if (isRecord(first) && (first['type'] === 'human' || first['type'] === 'user') && typeof first['content'] === 'string') { - subagentManager.matchSubgraphToSubagent(namespaceId, first['content']); + const child = namespace ? childStreamRefFromNamespace(namespace) : undefined; + if (!child) return; + + if (child.kind === 'tool') { + // Attribution ladder applies to tool children only: their namespace id + // may need mapping onto a registered tool call. + const messages = values['messages']; + if (Array.isArray(messages) && messages.length > 0) { + const first = messages[0]; + if (isRecord(first) && (first['type'] === 'human' || first['type'] === 'user') && typeof first['content'] === 'string') { + subagentManager.matchSubgraphToSubagent(child.key, first['content']); + } } + } else { + subagentManager.ensureSubgraphStream(child.key, child.name); } - subagentManager.updateSubagentValues(namespaceId, values); + subagentManager.updateSubagentValues(child.key, values); publishSubagents(); } function markSubagentRunning(namespace: string[] | undefined): void { - const namespaceId = namespace ? extractToolCallIdFromNamespace(namespace) : undefined; - if (!namespaceId) return; - subagentManager.markRunningFromNamespace(namespaceId, namespace); + const child = namespace ? childStreamRefFromNamespace(namespace) : undefined; + if (!child) return; + if (child.kind === 'subgraph') { + subagentManager.ensureSubgraphStream(child.key, child.name); + } + subagentManager.markRunningFromNamespace(child.key, namespace); publishSubagents(); } @@ -1830,9 +1855,13 @@ function toSubagentRefs( subagents.forEach((subagent, key) => { refs.set(key, { toolCallId: subagent.id, + // Tool children are named by their `subagent_type` arg; subgraph + // children by their node name (stored as the synthetic toolCall name). name: typeof subagent.toolCall.args['subagent_type'] === 'string' ? subagent.toolCall.args['subagent_type'] - : undefined, + : subagent.kind === 'subgraph' + ? subagent.toolCall.name + : undefined, status: signal(subagent.status), values: signal(subagent.values), messages: signal(subagent.messages as unknown as BaseMessage[]), diff --git a/libs/langgraph/src/lib/internals/subagent-tracker.ts b/libs/langgraph/src/lib/internals/subagent-tracker.ts index 69781ac8b..895a86bf7 100644 --- a/libs/langgraph/src/lib/internals/subagent-tracker.ts +++ b/libs/langgraph/src/lib/internals/subagent-tracker.ts @@ -16,6 +16,13 @@ export interface TrackedToolCall { export interface TrackedSubagent { id: string; generation: string; + /** + * How this child stream came to exist. 'tool' children are delegation tool + * calls (registered from the parent's AI message, keyed by tool-call id); + * 'subgraph' children are plain compiled-graph nodes (registered from their + * first namespaced stream event, keyed by the namespace segment itself). + */ + kind: 'tool' | 'subgraph'; status: 'pending' | 'running' | 'complete' | 'error'; toolCall: { id: string; @@ -90,6 +97,7 @@ export class SubagentTracker { this.subagents.set(id, { id, generation: existing?.generation ?? createSubagentGeneration(), + kind: 'tool', status: existing?.status ?? 'pending', toolCall: { id, @@ -147,14 +155,14 @@ export class SubagentTracker { }; for (const [toolCallId, subagent] of this.subagents) { - if (mapped.has(toolCallId)) continue; + if (subagent.kind !== 'tool' || mapped.has(toolCallId)) continue; if (subagent.toolCall.args['description'] === description) { return establish(toolCallId); } } for (const [toolCallId, subagent] of this.subagents) { - if (mapped.has(toolCallId)) continue; + if (subagent.kind !== 'tool' || mapped.has(toolCallId)) continue; const subagentDescription = subagent.toolCall.args['description']; if (typeof subagentDescription !== 'string' || !subagentDescription) continue; if (description.includes(subagentDescription) || subagentDescription.includes(description)) { @@ -162,7 +170,10 @@ export class SubagentTracker { } } + // Last-resort fallback — tool children only. A subgraph child is keyed by + // its own namespace and must never absorb an unrelated child's events. for (const [toolCallId, subagent] of this.subagents) { + if (subagent.kind !== 'tool') continue; if (!mapped.has(toolCallId) && (subagent.status === 'pending' || subagent.status === 'running')) { return establish(toolCallId); } @@ -193,6 +204,45 @@ export class SubagentTracker { this.onSubagentChange?.(); } + /** + * Register a plain-subgraph child stream on its first namespaced event. + * + * Unlike tool children — announced ahead of time by the parent's tool call — + * a compiled child added as a plain node has no announcement: its existence + * is learned from the first event carrying its namespace. It starts + * 'running' because by the time we see an event, it is. + */ + ensureSubgraphStream(key: string, name: string): void { + if (this.subagents.has(key)) return; + this.subagents.set(key, { + id: key, + generation: createSubagentGeneration(), + kind: 'subgraph', + status: 'running', + toolCall: { id: key, name, args: {} }, + values: {}, + messages: [], + }); + this.onSubagentChange?.(); + } + + /** + * Settle still-running subgraph children when the run reaches a terminal + * outcome. Tool children settle through their tool result + * (`processToolMessage`); subgraph children have no result message, so the + * run's own settle is their completion signal. Paused/interrupted runs must + * NOT call this — a child can resume with the thread. + */ + settleRunningSubgraphs(outcome: 'complete' | 'error'): void { + let changed = false; + for (const [key, subagent] of this.subagents) { + if (subagent.kind !== 'subgraph' || subagent.status !== 'running') continue; + this.subagents.set(key, { ...subagent, status: outcome }); + changed = true; + } + if (changed) this.onSubagentChange?.(); + } + updateSubagentValues(namespaceId: string, values: Record): void { const toolCallId = this.resolveToolCallId(namespaceId); const subagent = this.subagents.get(toolCallId); @@ -262,10 +312,47 @@ export class SubagentTracker { } } -export function isSubagentNamespace(namespace: string[] | string | undefined): boolean { +/** + * True when a stream event belongs to a child graph rather than the parent — + * i.e. it carries any namespace at all. This is the single classification + * question; which child owns the event is a separate (attribution) question. + * + * Kept consistent with the terminal-evidence guard, which has always refused + * ANY namespaced event as proof the parent run finished. + */ +export function isChildNamespace(namespace: string[] | string | undefined): boolean { if (!namespace) return false; - if (typeof namespace === 'string') return namespace.includes('tools:'); - return namespace.some(segment => segment.startsWith('tools:')); + if (typeof namespace === 'string') return namespace.length > 0; + return namespace.length > 0; +} + +/** Resolved identity of a child stream, derived from its event namespace. */ +export interface ChildStreamRef { + /** Map key: the tool-call id for `tools:` namespaces, else the namespace segment itself. */ + key: string; + /** Display name; for subgraph nodes, the node name. Unused on the tool path. */ + name: string; + kind: 'tool' | 'subgraph'; +} + +/** + * Derive a child stream's identity from an event namespace. + * + * `tools:` segments identify a tool-dispatched child by its tool-call id. + * Any other segment (e.g. `research:` from a compiled graph added with + * `add_node`) identifies a plain subgraph child: the full segment is the key + * (unique per invocation) and the part before the first ':' is the node name. + */ +export function childStreamRefFromNamespace(namespace: string[]): ChildStreamRef | undefined { + for (const segment of namespace) { + if (segment.startsWith('tools:')) { + return { key: segment.slice(6), name: '', kind: 'tool' }; + } + } + const first = namespace[0]; + if (!first) return undefined; + const colon = first.indexOf(':'); + return { key: first, name: colon > 0 ? first.slice(0, colon) : first, kind: 'subgraph' }; } export function extractToolCallIdFromNamespace(namespace: string[] | undefined): string | undefined { From ec1f2b52598dbef527065ab0f96ea6a96b6c8567 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 28 Aug 2026 17:44:00 -0700 Subject: [PATCH 2/2] docs+example: plain subgraph children are tracked streams now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...8-27-langgraph-subgraphs-when-to-split.mdx | 38 +++++++------- .../content/docs/langgraph/api/api-docs.json | 16 +----- .../docs/langgraph/api/provide-agent.mdx | 3 +- .../langgraph/concepts/agent-architecture.mdx | 4 +- .../langgraph/concepts/langgraph-basics.mdx | 1 - .../docs/langgraph/guides/subgraphs.mdx | 20 +++----- .../subgraphs/angular/e2e/subgraphs.spec.ts | 5 ++ .../subgraphs/angular/prompts/subgraphs.md | 17 ++++--- .../angular/src/app/subgraphs.component.ts | 51 ++++++++++++++----- .../langgraph/subgraphs/python/docs/guide.md | 17 ++++--- .../langgraph/subgraphs/python/src/graph.py | 12 ++--- 11 files changed, 103 insertions(+), 81 deletions(-) diff --git a/apps/website/content/blog/2026-08-27-langgraph-subgraphs-when-to-split.mdx b/apps/website/content/blog/2026-08-27-langgraph-subgraphs-when-to-split.mdx index b37ae7a9b..20e9b31d8 100644 --- a/apps/website/content/blog/2026-08-27-langgraph-subgraphs-when-to-split.mdx +++ b/apps/website/content/blog/2026-08-27-langgraph-subgraphs-when-to-split.mdx @@ -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? @@ -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:`, 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? diff --git a/apps/website/content/docs/langgraph/api/api-docs.json b/apps/website/content/docs/langgraph/api/api-docs.json index ebc1ad96c..3ea413a1a 100644 --- a/apps/website/content/docs/langgraph/api/api-docs.json +++ b/apps/website/content/docs/langgraph/api/api-docs.json @@ -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", @@ -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 }, { @@ -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", @@ -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 }, { diff --git a/apps/website/content/docs/langgraph/api/provide-agent.mdx b/apps/website/content/docs/langgraph/api/provide-agent.mdx index 8cf03fc28..697581cfe 100644 --- a/apps/website/content/docs/langgraph/api/provide-agent.mdx +++ b/apps/website/content/docs/langgraph/api/provide-agent.mdx @@ -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. | @@ -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:`, 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:`) or a tool-dispatched subagent (`tools:`) — belongs to its child stream in `subagents()` and never merges into the transcript. See [Subgraphs](/docs/langgraph/guides/subgraphs). ```ts provideAgent({ diff --git a/apps/website/content/docs/langgraph/concepts/agent-architecture.mdx b/apps/website/content/docs/langgraph/concepts/agent-architecture.mdx index 0c83a294b..d0016db6e 100644 --- a/apps/website/content/docs/langgraph/concepts/agent-architecture.mdx +++ b/apps/website/content/docs/langgraph/concepts/agent-architecture.mdx @@ -459,7 +459,7 @@ export class MultiAgentComponent { -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. ## Error Handling and Recovery @@ -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 diff --git a/apps/website/content/docs/langgraph/concepts/langgraph-basics.mdx b/apps/website/content/docs/langgraph/concepts/langgraph-basics.mdx index 24718c8b1..fbc32c601 100644 --- a/apps/website/content/docs/langgraph/concepts/langgraph-basics.mdx +++ b/apps/website/content/docs/langgraph/concepts/langgraph-basics.mdx @@ -236,7 +236,6 @@ provideAgent({ apiUrl: '...', assistantId: 'orchestrator', subagentToolNames: ['task'], - filterSubagentMessages: true, }); ``` diff --git a/apps/website/content/docs/langgraph/guides/subgraphs.mdx b/apps/website/content/docs/langgraph/guides/subgraphs.mdx index b1b1ae8f8..e5f7f2f2f 100644 --- a/apps/website/content/docs/langgraph/guides/subgraphs.mdx +++ b/apps/website/content/docs/langgraph/guides/subgraphs.mdx @@ -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. -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. ## How subgraph composition works @@ -105,10 +105,10 @@ export class OrchestratorComponent { - -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:`. The lever for this shape is [`transcriptNodeNames`](/docs/langgraph/api/provide-agent), which whitelists the graph nodes whose messages count as transcript. + +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:` 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). ## Giving the child its own state @@ -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): @@ -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); @@ -306,11 +305,9 @@ export class SubagentProgressComponent { -## 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): @@ -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()); ``` diff --git a/cockpit/langgraph/subgraphs/angular/e2e/subgraphs.spec.ts b/cockpit/langgraph/subgraphs/angular/e2e/subgraphs.spec.ts index 4b73c26fd..ee2fd35ba 100644 --- a/cockpit/langgraph/subgraphs/angular/e2e/subgraphs.spec.ts +++ b/cockpit/langgraph/subgraphs/angular/e2e/subgraphs.spec.ts @@ -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 }) => { diff --git a/cockpit/langgraph/subgraphs/angular/prompts/subgraphs.md b/cockpit/langgraph/subgraphs/angular/prompts/subgraphs.md index f0a39bf84..b4e2e72c0 100644 --- a/cockpit/langgraph/subgraphs/angular/prompts/subgraphs.md +++ b/cockpit/langgraph/subgraphs/angular/prompts/subgraphs.md @@ -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: ``. `provideAgent({ transcriptNodeNames: ['answer'] })` -keeps the router's and the subgraph's tokens out of the chat transcript. +Key components used: ``. 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. diff --git a/cockpit/langgraph/subgraphs/angular/src/app/subgraphs.component.ts b/cockpit/langgraph/subgraphs/angular/src/app/subgraphs.component.ts index 824b6d405..b6c9d267b 100644 --- a/cockpit/langgraph/subgraphs/angular/src/app/subgraphs.component.ts +++ b/cockpit/langgraph/subgraphs/angular/src/app/subgraphs.component.ts @@ -27,19 +27,19 @@ const WELCOME_SUGGESTIONS = [ * a plain node. The parent's `orchestrate` node classifies each turn and a * conditional edge decides whether execution enters the child at all. * - * **Why this sidebar reads `agent.value()` and not `agent.subagents()`.** - * `subagents()` is populated by the SubagentTracker, which keys on delegation - * *tool calls* — a tool whose name is listed in `subagentToolNames` and whose - * args carry a `subagent_type`, producing `tools:` namespaced stream - * events. A plain subgraph node emits a `research:` namespace instead - * and never appears in that map. See the Chat Subagents capability for the - * tool-call path; this capability shows the composition primitive underneath - * it, so child activity is read straight off the parent's own graph state. + * **The sidebar shows the boundary twice, from two angles.** * - * `research_topic` and `research_brief` are the only two keys the parent - * shares with the child. The brief is rendered here, in the sidebar, and - * nowhere else — `transcriptNodeNames: ['answer']` in `app.config.ts` keeps - * everything except the parent's final turn out of the chat transcript. + * `agent.value()` reads the parent graph's own state: `research_topic` and + * `research_brief` are the only two keys the parent shares with the child, + * so watching them is watching the state boundary itself. + * + * `agent.subagents()` shows the child as a *stream*: every namespaced child + * run appears in that map — plain subgraph nodes under their namespace key + * (named by node, here `research`), tool-dispatched children under their + * tool-call id (see the Chat Subagents capability for that shape). The + * child's tokens live on its stream and never merge into the transcript; + * `transcriptNodeNames: ['answer']` additionally keeps the *top-level* + * router node's structured-output chunks out of the chat. */ @Component({ selector: 'app-subgraphs', @@ -154,6 +154,20 @@ const WELCOME_SUGGESTIONS = [ The child graph's state has no messages key, so this brief never entered the transcript.

+ +
+

Child streams

+ @for (child of childStreams(); track child.id) { +

+ + {{ child.name }} — {{ child.status }} +

+ } @empty { +

No child stream yet.

+ } +
} @else {

The orchestrator answered without entering the child graph. Ask a @@ -185,6 +199,19 @@ export class SubgraphsComponent { */ protected readonly delegated = computed(() => this.topic().length > 0); + /** + * The same child, seen as a stream. Plain subgraph children appear in + * `subagents()` keyed by their namespace segment; `name` is the node name + * and `status` settles with the run. + */ + protected readonly childStreams = computed(() => + [...this.agent.subagents().entries()].map(([id, ref]) => ({ + id, + name: ref.name, + status: ref.status(), + })), + ); + protected send(text: string): void { void this.agent.submit({ message: text }); } diff --git a/cockpit/langgraph/subgraphs/python/docs/guide.md b/cockpit/langgraph/subgraphs/python/docs/guide.md index 22ad5935d..29aef572c 100644 --- a/cockpit/langgraph/subgraphs/python/docs/guide.md +++ b/cockpit/langgraph/subgraphs/python/docs/guide.md @@ -12,13 +12,14 @@ child returned. Add a parent/child LangGraph composition to this Angular app using `provideAgent()` and `injectAgent()` from `@threadplane/langgraph`. The parent should route conditionally into a compiled child graph whose state has no `messages` key, and the component should read `agent.value()` for the shared `research_topic` / `research_brief` keys. Set `transcriptNodeNames` so only the parent's answer node reaches the chat transcript. - -A plain subgraph node is **not** a tracked subagent. `agent.subagents()` is populated only -by delegation *tool calls* whose name matches `subagentToolNames` and whose args carry a -`subagent_type` — that path emits `tools:` namespaced events. A subgraph added as a -plain node emits a `research:` namespace and never appears in that map. For the -tool-call pattern, see [Chat Subagents](/chat/core-capabilities/subagents/overview/python). - + +Every namespaced child run appears in `agent.subagents()`. A subgraph added as a plain +node emits a `research:` namespace and registers under that key, named by node — +no configuration needed. Delegation *tool calls* (`subagentToolNames` + `subagent_type`) +appear under their tool-call id instead, carrying the arguments a richer UI can render; +for that pattern see [Chat Subagents](/chat/core-capabilities/subagents/overview/python). +Either way, the child's tokens stay on its stream and never merge into the transcript. + @@ -180,5 +181,5 @@ variables or a proxy. -- [Chat Subagents](/chat/core-capabilities/subagents/overview/python) — tool-call delegation, the pattern that does populate `subagents()` +- [Chat Subagents](/chat/core-capabilities/subagents/overview/python) — tool-call delegation, the named-and-argumented flavor of `subagents()` diff --git a/cockpit/langgraph/subgraphs/python/src/graph.py b/cockpit/langgraph/subgraphs/python/src/graph.py index 18b059cc9..8a23d3d8c 100644 --- a/cockpit/langgraph/subgraphs/python/src/graph.py +++ b/cockpit/langgraph/subgraphs/python/src/graph.py @@ -19,12 +19,12 @@ sequence, and LangGraph emits its stream events under a namespace (`research:`) rather than flattening them into the parent's. -**A plain subgraph node is not a tracked "subagent".** `agent.subagents()` in -`@threadplane/langgraph` is populated only by delegation *tool calls* whose -name matches `subagentToolNames` and whose args carry a `subagent_type` — that -path emits `tools:` namespaces, and it is demonstrated in -`cockpit/chat/subagents`. This example therefore surfaces child activity by -reading the parent graph's own state through `agent.value()`. +**Two views of the child on the Angular side.** `agent.value()` reads the +parent's own state — the shared `research_topic` / `research_brief` keys are +the boundary made visible. `agent.subagents()` shows the child as a stream: +every namespaced child run appears there, plain subgraph nodes under their +namespace key (named by node) and tool-dispatched children under their +tool-call id (that shape is demonstrated in `cockpit/chat/subagents`). """ from pathlib import Path