Skip to content

Commit 8e8892e

Browse files
bloveclaude
andauthored
fix(langgraph): don't guess subagent attribution when it's a coin flip (#864)
LangGraph's tools:<uuid> namespace is a checkpoint id assigned independently of the parent's call_* tool-call id. I checked the wire for a link between them and there is none — no metadata field, no co-occurrence in any payload of a live run. When a delegation tool carries no matchable description, position is genuinely the only signal left. That is fine with one outstanding child, and it is what every graph here produces: cockpit/chat/subagents dispatches ONE tool call per assistant turn, three sequential ToolNode round-trips. With several outstanding at once, arrival order is not dispatch order, and claiming the first unmapped call silently cross-wires them. Reproduced: two children whose streams arrive in reverse dispatch order, and call_ALPHA's card renders beta's output. A booking card showing research text is worse than an empty one. The fallback now fires only when exactly one candidate is outstanding. Ambiguous streams stay buffered rather than mis-attributed, and can still resolve later — as siblings complete, the candidate set shrinks back to one. The description rungs are untouched and still preferred. Verified: 344/344 (new cross-wiring guard included). Mutation-tested — restoring the greedy fallback fails exactly that guard. Sequential attribution re-checked against a live model: all three cards populate, each on its own call_* id. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 779b008 commit 8e8892e

2 files changed

Lines changed: 71 additions & 7 deletions

File tree

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2906,6 +2906,52 @@ describe('createStreamManagerBridge', () => {
29062906
destroy$.next();
29072907
});
29082908

2909+
it('never cross-wires concurrent children when arrival order != dispatch order', async () => {
2910+
const transport = new MockAgentTransport();
2911+
const subjects = makeSubjects();
2912+
const destroy$ = new Subject<void>();
2913+
const bridge = createStreamManagerBridge({
2914+
options: { apiUrl: '', assistantId: 'test', transport, subagentToolNames: ['task'] },
2915+
subjects, threadId$: of(null), destroy$: destroy$.asObservable(),
2916+
});
2917+
bridge.submit({});
2918+
// Parent dispatches TWO children in one AI message: alpha then beta.
2919+
transport.emit([{
2920+
type: 'messages',
2921+
messages: [{
2922+
id: 'ai-1', type: 'ai', content: '',
2923+
tool_calls: [
2924+
{ id: 'call_ALPHA', name: 'task', args: { subagent_type: 'alpha', task_description: 'a' } },
2925+
{ id: 'call_BETA', name: 'task', args: { subagent_type: 'beta', task_description: 'b' } },
2926+
],
2927+
}],
2928+
} satisfies StreamEvent]);
2929+
// BETA's child streams FIRST (parallel fan-out; arrival order != dispatch order).
2930+
transport.emit([{
2931+
type: 'messages|tools:ns-BETA' as StreamEvent['type'], namespace: ['tools:ns-BETA'],
2932+
messages: [{ id: 'm-beta', type: 'AIMessageChunk', content: 'beta output' }],
2933+
messageMetadata: { checkpoint_ns: 'tools:ns-BETA' },
2934+
} satisfies StreamEvent]);
2935+
transport.emit([{
2936+
type: 'messages|tools:ns-ALPHA' as StreamEvent['type'], namespace: ['tools:ns-ALPHA'],
2937+
messages: [{ id: 'm-alpha', type: 'AIMessageChunk', content: 'alpha output' }],
2938+
messageMetadata: { checkpoint_ns: 'tools:ns-ALPHA' },
2939+
} satisfies StreamEvent]);
2940+
transport.close();
2941+
await new Promise(r => setTimeout(r, 10));
2942+
const alpha = subjects.subagents$.value.get('call_ALPHA');
2943+
const beta = subjects.subagents$.value.get('call_BETA');
2944+
// Two children are outstanding at once and the namespaces carry no link to
2945+
// the tool-call ids, so neither stream can be attributed honestly. The old
2946+
// fallback claimed the first unmapped call and handed alpha's card beta's
2947+
// output. Refusing to guess is the correct outcome: an empty card, never a
2948+
// confidently wrong one.
2949+
const txt = (x: unknown) => (x as { content?: string } | undefined)?.content;
2950+
expect(txt(alpha?.messages()[0])).not.toBe('beta output');
2951+
expect(txt(beta?.messages()[0])).not.toBe('alpha output');
2952+
destroy$.next();
2953+
});
2954+
29092955
it('attributes a tool child whose values carry no human first message', async () => {
29102956
// The real shape emitted by cockpit/chat/subagents, captured off the wire:
29112957
// the delegation tool takes `task_description` (not `description`), and the

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

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -186,13 +186,31 @@ export class SubagentTracker {
186186
}
187187
}
188188

189-
// Last-resort fallback — tool children only. A subgraph child is keyed by
190-
// its own namespace and must never absorb an unrelated child's events.
191-
for (const [toolCallId, subagent] of this.subagents) {
192-
if (subagent.kind !== 'tool') continue;
193-
if (!mapped.has(toolCallId) && (subagent.status === 'pending' || subagent.status === 'running')) {
194-
return establish(toolCallId);
195-
}
189+
// Last-resort fallback — tool children only, and only when there is
190+
// nothing to guess between.
191+
//
192+
// LangGraph's `tools:<uuid>` namespace is a checkpoint id assigned
193+
// independently of the parent's `call_*` tool-call id; the two are not
194+
// linked anywhere on the wire (verified against a live run). So when a
195+
// delegation tool carries no matchable description, position is the only
196+
// signal left.
197+
//
198+
// That is sound with exactly one outstanding child — the shape every graph
199+
// in this repo produces, since each dispatches one tool call per assistant
200+
// turn. With several outstanding at once (parallel fan-out) arrival order
201+
// is NOT dispatch order, and claiming the first unmapped call cross-wires
202+
// the children: one card renders another's output. Leaving the stream
203+
// unattributed keeps its messages buffered instead, so an empty card is
204+
// the worst case rather than a confidently wrong one. It can still resolve
205+
// later: as siblings complete, the candidate set shrinks back to one.
206+
const candidates = [...this.subagents].filter(
207+
([toolCallId, subagent]) =>
208+
subagent.kind === 'tool' &&
209+
!mapped.has(toolCallId) &&
210+
(subagent.status === 'pending' || subagent.status === 'running'),
211+
);
212+
if (candidates.length === 1) {
213+
return establish(candidates[0][0]);
196214
}
197215

198216
if (description) {

0 commit comments

Comments
 (0)