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