Skip to content

Commit 8687258

Browse files
authored
fix(langgraph): harden subagent attribution — ladder tests, empty-description guard, nested-delegation streams (#945)
* test(langgraph): direct unit coverage for the subagent attribution ladder * fix(langgraph): empty description must not exact-match at the attribution ladder's first rung * fix(langgraph): register nested delegations as their own stream instead of corrupting the outer card * refactor(langgraph): drop dead extractToolCallIdFromNamespace helper * fix(langgraph): truncate nested-delegation keys at the innermost tools: segment * test(langgraph): correct stale attribution comment in bridge spec * docs(website): document nested-delegation stream behavior in the subgraphs guide
1 parent 5af3d9f commit 8687258

4 files changed

Lines changed: 224 additions & 25 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ Because `ResearchState` has no `messages` key, the child cannot read the transcr
169169

170170
The `subagents()` signal contains a Map of active child streams. Tool-dispatched children — Deep Agents' default `task` tool or your own delegation tools — are keyed by tool-call id and named by their `subagent_type`. Plain subgraph nodes are keyed by their namespace segment and named by node; they register on their first streamed event and settle with the run.
171171

172+
Nested delegation — a subagent that itself dispatches a delegation tool — surfaces as its own entry too, keyed by its namespace path (truncated at the innermost delegation segment) and treated like a plain subgraph stream. Each level of delegation gets its own stream; the map stays flat, so there's no parent/child linking between the entries.
173+
172174
```typescript
173175
// In a shared file (e.g. agent.ts):
174176
// import { createAgentRef } from '@threadplane/chat';

libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2847,8 +2847,10 @@ describe('createStreamManagerBridge', () => {
28472847
messageMetadata: { checkpoint_ns: 'tools:aa5c61a1-e3ee-ea36|model' },
28482848
} satisfies StreamEvent]);
28492849

2850-
// Attribution only arrives later, via a values event carrying the child's
2851-
// first human message, which the description ladder matches on.
2850+
// The messages event above already attributed the namespace positionally
2851+
// (ensureToolStreamAttribution, #847), so this values event finds the
2852+
// mapping in place — the description ladder is short-circuited here. See
2853+
// subagent-tracker.spec.ts for direct ladder coverage.
28522854
transport.emit([{
28532855
type: 'values|tools:aa5c61a1-e3ee-ea36' as StreamEvent['type'],
28542856
namespace: ['tools:aa5c61a1-e3ee-ea36'],
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// SPDX-License-Identifier: MIT
2+
//
3+
// Direct unit coverage for the subagent attribution ladder. The tracker is a
4+
// plain class, so these tests drive it without the stream-manager bridge.
5+
// Reaching rungs 1/2 THROUGH the bridge additionally requires the child's
6+
// `values` event (carrying a human first message) to arrive before any child
7+
// `messages` event — an ordering no bridge test encodes, which is why ladder
8+
// coverage lives here instead.
9+
import { describe, it, expect } from 'vitest';
10+
import type { BaseMessage } from '@langchain/core/messages';
11+
import { SubagentTracker, childStreamRefFromNamespace } from './subagent-tracker';
12+
13+
function taskCall(id: string, args: Record<string, unknown>) {
14+
return { id, name: 'task', args: { subagent_type: 'researcher', ...args } };
15+
}
16+
17+
function aiMsg(id: string, content: string): BaseMessage {
18+
return { id, type: 'ai', content } as unknown as BaseMessage;
19+
}
20+
21+
describe('SubagentTracker attribution ladder', () => {
22+
it('rung 1: exact description match wins even with multiple candidates', () => {
23+
const t = new SubagentTracker();
24+
t.registerFromToolCalls([
25+
taskCall('call_a', { description: 'Summarize the meeting notes' }),
26+
taskCall('call_b', { description: 'Research quantum signals' }),
27+
]);
28+
// Namespace id is an internal UUID — deliberately NOT a tool-call id, so
29+
// nothing but the ladder can resolve it. Two candidates outstanding, so
30+
// the positional rung would refuse; only the exact rung can attribute.
31+
const winner = t.matchSubgraphToSubagent('ns-uuid-1', 'Research quantum signals');
32+
expect(winner).toBe('call_b');
33+
});
34+
35+
it('rung 2: substring match (either direction) wins when exact fails', () => {
36+
const t = new SubagentTracker();
37+
t.registerFromToolCalls([
38+
taskCall('call_a', { description: 'Summarize the meeting notes' }),
39+
taskCall('call_b', { description: 'Research quantum signals' }),
40+
]);
41+
// The child's first human message elaborates on the stored description.
42+
const winner = t.matchSubgraphToSubagent(
43+
'ns-uuid-2',
44+
'Research quantum signals across the 2025 arxiv corpus',
45+
);
46+
expect(winner).toBe('call_b');
47+
});
48+
49+
it('rung 2: an empty stored description is never a substring match', () => {
50+
const t = new SubagentTracker();
51+
t.registerFromToolCalls([
52+
taskCall('call_a', { description: '' }),
53+
taskCall('call_b', { description: 'Book a flight' }),
54+
]);
55+
// 'anything' contains '' — without the guard at the substring rung,
56+
// call_a would claim every stream. It must not.
57+
const winner = t.matchSubgraphToSubagent('ns-uuid-3', 'anything unrelated');
58+
expect(winner).toBeUndefined();
59+
});
60+
61+
it('rung 3: positional fallback attributes only when exactly one candidate is outstanding', () => {
62+
const t = new SubagentTracker();
63+
t.registerFromToolCalls([taskCall('call_solo', { task_description: 'x' })]);
64+
expect(t.matchSubgraphToSubagent('ns-uuid-4', '')).toBe('call_solo');
65+
});
66+
67+
it('rung 3: refuses with two outstanding candidates and buffers instead', () => {
68+
const t = new SubagentTracker();
69+
t.registerFromToolCalls([
70+
taskCall('call_a', { task_description: 'x' }),
71+
taskCall('call_b', { task_description: 'y' }),
72+
]);
73+
expect(t.matchSubgraphToSubagent('ns-uuid-5', '')).toBeUndefined();
74+
75+
// Unattributed messages are held, not dropped and not mis-assigned.
76+
t.addMessageToSubagent('ns-uuid-5', aiMsg('m1', 'early chunk'));
77+
// getSubagents() hides 'pending' entries — this loop is empty when
78+
// correct, and bites only when a mutant wrongly establishes the match
79+
// and promotes a candidate to 'running'.
80+
for (const subagent of t.getSubagents().values()) {
81+
expect(subagent.messages).toHaveLength(0);
82+
}
83+
});
84+
85+
it('deferred retry: a pending match resolves when the tool call registers later', () => {
86+
const t = new SubagentTracker();
87+
// Child stream arrives BEFORE the parent's tool call — nothing to match yet.
88+
expect(t.matchSubgraphToSubagent('ns-uuid-6', 'Find flights to Lisbon')).toBeUndefined();
89+
t.addMessageToSubagent('ns-uuid-6', aiMsg('m1', 'checking fares'));
90+
91+
// Parent tool call registers; registerFromToolCalls drains pendingMatches.
92+
t.registerFromToolCalls([taskCall('call_late', { description: 'Find flights to Lisbon' })]);
93+
94+
const subagent = t.getSubagents().get('call_late');
95+
expect(subagent?.status).toBe('running');
96+
expect(subagent?.messages).toEqual([
97+
expect.objectContaining({ id: 'm1', content: 'checking fares' }),
98+
]);
99+
});
100+
101+
it('empty-description attribution never exact-matches an empty stored description', () => {
102+
const t = new SubagentTracker();
103+
t.registerFromToolCalls([
104+
taskCall('call_a', { description: '' }),
105+
taskCall('call_b', { description: 'Book a flight' }),
106+
]);
107+
// ensureToolStreamAttribution runs the ladder with '' — with two
108+
// candidates outstanding it must refuse (positional rung), not let
109+
// '' === '' claim call_a at the exact rung.
110+
t.ensureToolStreamAttribution('ns-uuid-7');
111+
t.addMessageToSubagent('ns-uuid-7', aiMsg('m1', 'child token'));
112+
// getSubagents() hides 'pending' entries — this loop is empty when
113+
// correct, and bites only when a mutant wrongly establishes the match
114+
// and promotes a candidate to 'running'.
115+
for (const subagent of t.getSubagents().values()) {
116+
expect(subagent.messages).toHaveLength(0);
117+
}
118+
});
119+
});
120+
121+
describe('childStreamRefFromNamespace', () => {
122+
it('single tools: segment resolves to a tool child by tool-call id', () => {
123+
expect(childStreamRefFromNamespace(['tools:call-1'])).toEqual({
124+
key: 'call-1', name: '', kind: 'tool',
125+
});
126+
});
127+
128+
it('a tool child followed by its own internal nodes stays a tool child', () => {
129+
// `model`/`agent` segments after the tools: segment are the child's own
130+
// graph internals, not a second delegation.
131+
expect(childStreamRefFromNamespace(['tools:call-1', 'agent:step-2'])).toEqual({
132+
key: 'call-1', name: '', kind: 'tool',
133+
});
134+
});
135+
136+
it('plain subgraph namespace resolves to the first segment, named by node', () => {
137+
expect(childStreamRefFromNamespace(['research:uuid-1'])).toEqual({
138+
key: 'research:uuid-1', name: 'research', kind: 'subgraph',
139+
});
140+
});
141+
142+
it('nested delegation registers as its own subgraph stream, never the outer tool child', () => {
143+
expect(childStreamRefFromNamespace(['tools:call-1', 'tools:call-2'])).toEqual({
144+
key: 'tools:call-1|tools:call-2', name: 'tools', kind: 'subgraph',
145+
});
146+
});
147+
148+
it('nested delegation with intermediate segments still keys the full path', () => {
149+
expect(childStreamRefFromNamespace(['tools:call-1', 'agent:x', 'tools:call-2'])).toEqual({
150+
key: 'tools:call-1|agent:x|tools:call-2', name: 'tools', kind: 'subgraph',
151+
});
152+
});
153+
154+
it('trailing internal segments after the innermost tools: segment do not fragment the key', () => {
155+
expect(childStreamRefFromNamespace(['tools:call-1', 'tools:call-2', 'agent:x'])).toEqual({
156+
key: 'tools:call-1|tools:call-2', name: 'tools', kind: 'subgraph',
157+
});
158+
expect(childStreamRefFromNamespace(['tools:call-1', 'tools:call-2', 'model:y'])).toEqual({
159+
key: 'tools:call-1|tools:call-2', name: 'tools', kind: 'subgraph',
160+
});
161+
});
162+
});

libs/langgraph/src/lib/internals/subagent-tracker.ts

Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -170,19 +170,26 @@ export class SubagentTracker {
170170
return toolCallId;
171171
};
172172

173-
for (const [toolCallId, subagent] of this.subagents) {
174-
if (subagent.kind !== 'tool' || mapped.has(toolCallId)) continue;
175-
if (subagent.toolCall.args['description'] === description) {
176-
return establish(toolCallId);
173+
// The description rungs are only meaningful with a real description.
174+
// ensureToolStreamAttribution calls this with '' precisely to skip them:
175+
// without this guard, a subagent whose `description` arg is literally ''
176+
// exact-matches every empty-description probe, bypassing the positional
177+
// rung's one-candidate safety check.
178+
if (description) {
179+
for (const [toolCallId, subagent] of this.subagents) {
180+
if (subagent.kind !== 'tool' || mapped.has(toolCallId)) continue;
181+
if (subagent.toolCall.args['description'] === description) {
182+
return establish(toolCallId);
183+
}
177184
}
178-
}
179185

180-
for (const [toolCallId, subagent] of this.subagents) {
181-
if (subagent.kind !== 'tool' || mapped.has(toolCallId)) continue;
182-
const subagentDescription = subagent.toolCall.args['description'];
183-
if (typeof subagentDescription !== 'string' || !subagentDescription) continue;
184-
if (description.includes(subagentDescription) || subagentDescription.includes(description)) {
185-
return establish(toolCallId);
186+
for (const [toolCallId, subagent] of this.subagents) {
187+
if (subagent.kind !== 'tool' || mapped.has(toolCallId)) continue;
188+
const subagentDescription = subagent.toolCall.args['description'];
189+
if (typeof subagentDescription !== 'string' || !subagentDescription) continue;
190+
if (description.includes(subagentDescription) || subagentDescription.includes(description)) {
191+
return establish(toolCallId);
192+
}
186193
}
187194
}
188195

@@ -417,7 +424,13 @@ export function isChildNamespace(namespace: string[] | string | undefined): bool
417424

418425
/** Resolved identity of a child stream, derived from its event namespace. */
419426
export interface ChildStreamRef {
420-
/** Map key: the tool-call id for `tools:` namespaces, else the namespace segment itself. */
427+
/**
428+
* Map key: the tool-call id for a single `tools:` namespace; for a nested
429+
* delegation, the namespace path truncated at (and including) the
430+
* innermost `tools:` segment, so trailing internal-node segments don't
431+
* fragment one grandchild into several entries; else the namespace segment
432+
* itself.
433+
*/
421434
key: string;
422435
/** Display name; for subgraph nodes, the node name. Unused on the tool path. */
423436
name: string;
@@ -431,27 +444,47 @@ export interface ChildStreamRef {
431444
* Any other segment (e.g. `research:<uuid>` from a compiled graph added with
432445
* `add_node`) identifies a plain subgraph child: the full segment is the key
433446
* (unique per invocation) and the part before the first ':' is the node name.
447+
*
448+
* A namespace with MORE than one `tools:` segment is a nested delegation — a
449+
* subagent that itself dispatched a delegation tool. That stream registers as
450+
* its own subgraph-kind entry keyed by the namespace path truncated at the
451+
* innermost `tools:` segment (inclusive), so trailing internal-node segments
452+
* after it (the grandchild's own `model:`/`agent:` steps) don't fragment one
453+
* grandchild into several map entries. Subgraph entries are skipped by every
454+
* attribution-ladder rung, so a grandchild can neither merge into the outer
455+
* child's card nor mis-attach to a sibling. Linking it to its parent card (a
456+
* delegation tree) is deliberately not modeled; the flat map is the contract.
434457
*/
435458
export function childStreamRefFromNamespace(namespace: string[]): ChildStreamRef | undefined {
436-
for (const segment of namespace) {
437-
if (segment.startsWith('tools:')) {
438-
return { key: segment.slice(6), name: '', kind: 'tool' };
459+
const toolSegments = namespace.filter((segment) => segment.startsWith('tools:'));
460+
if (toolSegments.length > 1) {
461+
let lastToolsIndex = -1;
462+
for (let i = namespace.length - 1; i >= 0; i -= 1) {
463+
if (namespace[i].startsWith('tools:')) {
464+
lastToolsIndex = i;
465+
break;
466+
}
439467
}
468+
const innermost = namespace[lastToolsIndex];
469+
const colon = innermost.indexOf(':');
470+
return {
471+
// The innermost segment's node name is 'tools' for a delegation-tool
472+
// child — an intentionally generic card label; there's no
473+
// subagent_type to name it from.
474+
key: namespace.slice(0, lastToolsIndex + 1).join('|'),
475+
name: colon > 0 ? innermost.slice(0, colon) : innermost,
476+
kind: 'subgraph',
477+
};
478+
}
479+
if (toolSegments.length === 1) {
480+
return { key: toolSegments[0].slice(6), name: '', kind: 'tool' };
440481
}
441482
const first = namespace[0];
442483
if (!first) return undefined;
443484
const colon = first.indexOf(':');
444485
return { key: first, name: colon > 0 ? first.slice(0, colon) : first, kind: 'subgraph' };
445486
}
446487

447-
export function extractToolCallIdFromNamespace(namespace: string[] | undefined): string | undefined {
448-
if (!namespace) return undefined;
449-
for (const segment of namespace) {
450-
if (segment.startsWith('tools:')) return segment.slice(6);
451-
}
452-
return undefined;
453-
}
454-
455488
function parseToolCallArgs(args: Record<string, unknown> | string): Record<string, unknown> {
456489
if (typeof args !== 'string') return args;
457490
try {

0 commit comments

Comments
 (0)