Skip to content

Commit 6c583da

Browse files
bloveclaude
andcommitted
feat(langgraph): server-announced subagent identity
The tools:<uuid> namespace a child graph streams under is a checkpoint id assigned independently of the parent's call_* tool-call id — nothing on the wire links them (#864 established this and chose to refuse guessing under ambiguity, at the cost of empty cards for parallel fan-out). But the server KNOWS both halves. Probed live: inside the @tool body, config metadata carries checkpoint_ns (= the exact stream namespace, verified matching on the wire) and InjectedToolCallId provides the call id. threadplane-middleware 0.0.2 adds announce_subagent(config, tool_call_id): one custom event {type: 'threadplane.subagent_binding', namespace, tool_call_id}, emitted via get_stream_writer, safely no-op outside a run. @threadplane/langgraph recognizes it: bindChildStream() maps the namespace authoritatively, replays any chunks buffered before the binding arrived, and never overrides an established mapping. The event is consumed as protocol chatter, not forwarded to customEvents(). The ladder and the single-candidate fallback remain for graphs that don't announce. cockpit/chat/subagents adopts it with a ~20-line inline emitter (cockpit standalone rule; comment points at the canonical middleware helper). Verified: - wire: 3 binding events, every stream namespace bound to a real call id - live model in Chrome: all three cards populate on their own call ids - lib 346/346 — incl. the case #864 left unattributed (two children, reverse arrival order) now resolving exactly via bindings, with the pre-binding chunk replayed from the buffer - middleware 42/42 under the CI-exact uv flow; e2e 1/1; lint 0 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 93635ad commit 6c583da

9 files changed

Lines changed: 296 additions & 3 deletions

File tree

cockpit/chat/subagents/python/src/graph.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
from langchain_core.messages import SystemMessage, HumanMessage
1313
from langchain_core.tools import tool
1414
from langchain_openai import ChatOpenAI
15+
from typing import Annotated
16+
17+
from langchain_core.runnables import RunnableConfig
18+
from langchain_core.tools import InjectedToolCallId
1519
from langgraph.graph import StateGraph, MessagesState, END
1620
from langgraph.graph.message import add_messages
1721
from langgraph.prebuilt import ToolNode
@@ -154,8 +158,45 @@ def _final_text(messages: list) -> str:
154158
return "(no subagent output)"
155159

156160

161+
def _announce_subagent(config, tool_call_id: str) -> None:
162+
"""Bind this tool call's child stream to its tool-call id, for the UI.
163+
164+
LangGraph streams the child under a `tools:<uuid>` namespace whose uuid is
165+
a checkpoint id — nothing on the wire links it to the `call_*` id, so a
166+
frontend showing per-subagent progress would otherwise have to guess.
167+
Inside the tool body both halves are known; emit them as one custom event
168+
that `@threadplane/langgraph` recognizes.
169+
170+
Inlined per the cockpit standalone rule — the canonical helper is
171+
`threadplane.middleware.langgraph.announce_subagent`.
172+
"""
173+
if not tool_call_id:
174+
return
175+
meta = dict((config or {}).get("metadata") or {})
176+
namespace = meta.get("checkpoint_ns")
177+
if not namespace:
178+
return
179+
try:
180+
from langgraph.config import get_stream_writer
181+
182+
get_stream_writer()(
183+
{
184+
"type": "threadplane.subagent_binding",
185+
"namespace": namespace,
186+
"tool_call_id": tool_call_id,
187+
}
188+
)
189+
except Exception:
190+
pass
191+
192+
157193
@tool
158-
async def task(subagent_type: Literal["research", "booking", "itinerary"], task_description: str) -> str:
194+
async def task(
195+
subagent_type: Literal["research", "booking", "itinerary"],
196+
task_description: str,
197+
tool_call_id: Annotated[str, InjectedToolCallId] = None,
198+
config: RunnableConfig = None,
199+
) -> str:
159200
"""Delegate a subtask to a specialized subagent subgraph.
160201
161202
Args:
@@ -169,6 +210,7 @@ async def task(subagent_type: Literal["research", "booking", "itinerary"], task_
169210
Returns:
170211
The subagent's final answer as a string.
171212
"""
213+
_announce_subagent(config, tool_call_id)
172214
result = await subagent_subgraph.ainvoke(
173215
{"subagent_type": subagent_type, "task_description": task_description, "messages": []}
174216
)

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

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

2909+
it('binding events attribute concurrent children exactly, even out of order', async () => {
2910+
// The case #864 deliberately left unattributed: two children outstanding,
2911+
// streams arriving in reverse dispatch order. With server-announced
2912+
// bindings (threadplane-middleware announce_subagent) both resolve
2913+
// deterministically — no guessing, no empty cards.
2914+
const transport = new MockAgentTransport();
2915+
const subjects = makeSubjects();
2916+
const destroy$ = new Subject<void>();
2917+
const bridge = createStreamManagerBridge({
2918+
options: { apiUrl: '', assistantId: 'test', transport, subagentToolNames: ['task'] },
2919+
subjects, threadId$: of(null), destroy$: destroy$.asObservable(),
2920+
});
2921+
bridge.submit({});
2922+
transport.emit([{
2923+
type: 'messages',
2924+
messages: [{
2925+
id: 'ai-1', type: 'ai', content: '',
2926+
tool_calls: [
2927+
{ id: 'call_ALPHA', name: 'task', args: { subagent_type: 'alpha', task_description: 'a' } },
2928+
{ id: 'call_BETA', name: 'task', args: { subagent_type: 'beta', task_description: 'b' } },
2929+
],
2930+
}],
2931+
} satisfies StreamEvent]);
2932+
// BETA's chunk arrives BEFORE any binding — it must buffer, not misroute.
2933+
transport.emit([{
2934+
type: 'messages|tools:ns-BETA' as StreamEvent['type'], namespace: ['tools:ns-BETA'],
2935+
messages: [{ id: 'm-beta', type: 'AIMessageChunk', content: 'beta output' }],
2936+
messageMetadata: { checkpoint_ns: 'tools:ns-BETA' },
2937+
} satisfies StreamEvent]);
2938+
// Bindings arrive (order irrelevant), exactly as announce_subagent emits them.
2939+
transport.emit([{
2940+
type: 'custom',
2941+
data: { type: 'threadplane.subagent_binding', namespace: 'tools:ns-BETA', tool_call_id: 'call_BETA' },
2942+
} as StreamEvent]);
2943+
transport.emit([{
2944+
type: 'custom',
2945+
data: { type: 'threadplane.subagent_binding', namespace: 'tools:ns-ALPHA', tool_call_id: 'call_ALPHA' },
2946+
} as StreamEvent]);
2947+
transport.emit([{
2948+
type: 'messages|tools:ns-ALPHA' as StreamEvent['type'], namespace: ['tools:ns-ALPHA'],
2949+
messages: [{ id: 'm-alpha', type: 'AIMessageChunk', content: 'alpha output' }],
2950+
messageMetadata: { checkpoint_ns: 'tools:ns-ALPHA' },
2951+
} satisfies StreamEvent]);
2952+
transport.close();
2953+
await new Promise(r => setTimeout(r, 10));
2954+
2955+
const txt = (x: unknown) => (x as { content?: string } | undefined)?.content;
2956+
// Exact attribution both ways — including the pre-binding buffered chunk.
2957+
expect(txt(subjects.subagents$.value.get('call_ALPHA')?.messages()[0])).toBe('alpha output');
2958+
expect(txt(subjects.subagents$.value.get('call_BETA')?.messages()[0])).toBe('beta output');
2959+
// Protocol chatter is consumed, not surfaced to customEvents().
2960+
expect(subjects.custom$.value.filter(e =>
2961+
typeof e.data === 'object' && e.data !== null
2962+
&& (e.data as Record<string, unknown>)['type'] === 'threadplane.subagent_binding',
2963+
)).toHaveLength(0);
2964+
destroy$.next();
2965+
});
2966+
2967+
it('a binding never overrides an established mapping', async () => {
2968+
const transport = new MockAgentTransport();
2969+
const subjects = makeSubjects();
2970+
const destroy$ = new Subject<void>();
2971+
const bridge = createStreamManagerBridge({
2972+
options: { apiUrl: '', assistantId: 'test', transport, subagentToolNames: ['task'] },
2973+
subjects, threadId$: of(null), destroy$: destroy$.asObservable(),
2974+
});
2975+
bridge.submit({});
2976+
transport.emit([{
2977+
type: 'messages',
2978+
messages: [{
2979+
id: 'ai-1', type: 'ai', content: '',
2980+
tool_calls: [{ id: 'call_X', name: 'task', args: { subagent_type: 'xray', task_description: 'x' } }],
2981+
}],
2982+
} satisfies StreamEvent]);
2983+
transport.emit([{
2984+
type: 'custom',
2985+
data: { type: 'threadplane.subagent_binding', namespace: 'tools:ns-X', tool_call_id: 'call_X' },
2986+
} as StreamEvent]);
2987+
// A duplicate binding for the same pair is a no-op, not a re-establish.
2988+
transport.emit([{
2989+
type: 'custom',
2990+
data: { type: 'threadplane.subagent_binding', namespace: 'tools:ns-X', tool_call_id: 'call_X' },
2991+
} as StreamEvent]);
2992+
transport.emit([{
2993+
type: 'messages|tools:ns-X' as StreamEvent['type'], namespace: ['tools:ns-X'],
2994+
messages: [{ id: 'm-x', type: 'AIMessageChunk', content: 'x output' }],
2995+
messageMetadata: { checkpoint_ns: 'tools:ns-X' },
2996+
} satisfies StreamEvent]);
2997+
transport.close();
2998+
await new Promise(r => setTimeout(r, 10));
2999+
const txt = (x: unknown) => (x as { content?: string } | undefined)?.content;
3000+
expect(txt(subjects.subagents$.value.get('call_X')?.messages()[0])).toBe('x output');
3001+
destroy$.next();
3002+
});
3003+
29093004
it('never cross-wires concurrent children when arrival order != dispatch order', async () => {
29103005
const transport = new MockAgentTransport();
29113006
const subjects = makeSubjects();

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,22 @@ export function createStreamManagerBridge<T, ResolvedBag extends BagTemplate = B
945945
break;
946946
case 'custom': {
947947
const eventData = event['data'] as Record<string, unknown> | undefined;
948+
// Server-announced subagent identity (threadplane-middleware's
949+
// `announce_subagent`). Consumed here rather than forwarded: it is
950+
// protocol chatter, not application data.
951+
if (
952+
isRecord(eventData)
953+
&& eventData['type'] === 'threadplane.subagent_binding'
954+
&& typeof eventData['namespace'] === 'string'
955+
&& typeof eventData['tool_call_id'] === 'string'
956+
) {
957+
const bound = childStreamRefFromNamespace([eventData['namespace']]);
958+
if (bound?.kind === 'tool') {
959+
subagentManager.bindChildStream(bound.key, eventData['tool_call_id']);
960+
publishSubagents();
961+
}
962+
break;
963+
}
948964
const name = (event['name'] ?? eventData?.['name'] ?? '') as string;
949965
const data = eventData?.['data'] ?? eventData;
950966
const current = subjects.custom$.value;

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,32 @@ export class SubagentTracker {
238238
this.onSubagentChange?.();
239239
}
240240

241+
/**
242+
* Authoritative namespace→tool-call binding, from the server.
243+
*
244+
* `threadplane.middleware.langgraph.announce_subagent` emits a custom event
245+
* pairing the child's checkpoint namespace with its tool-call id — the two
246+
* halves that are never linked on the wire otherwise. Unlike the matching
247+
* ladder this is not a heuristic: it overrides nothing that is already
248+
* mapped, works with any number of children outstanding, and replays any
249+
* chunks that streamed before the binding arrived.
250+
*/
251+
bindChildStream(namespaceId: string, toolCallId: string): void {
252+
if (this.namespaceToToolCallId.get(namespaceId) === toolCallId) return;
253+
this.namespaceToToolCallId.set(namespaceId, toolCallId);
254+
const subagent = this.subagents.get(toolCallId);
255+
if (subagent) {
256+
const buffered = this.unattributedMessages.get(namespaceId);
257+
this.subagents.set(toolCallId, {
258+
...subagent,
259+
status: subagent.status === 'complete' || subagent.status === 'error' ? subagent.status : 'running',
260+
messages: buffered ? mergeMessages(subagent.messages, buffered) : subagent.messages,
261+
});
262+
this.unattributedMessages.delete(namespaceId);
263+
}
264+
this.onSubagentChange?.();
265+
}
266+
241267
/**
242268
* Attribute a `tools:` child stream to its parent tool call as soon as the
243269
* child is seen, without requiring a description to match on.

packages/threadplane-middleware/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "threadplane-middleware"
7-
version = "0.0.1"
7+
version = "0.0.2"
88
description = "LangGraph middleware for binding client-declared tool stubs and routing client tool calls to END so the browser executes them."
99
readme = "README.md"
1010
license = { text = "MIT" }

packages/threadplane-middleware/src/threadplane/middleware/langgraph/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from threadplane.middleware.langgraph.middleware import (
55
a2ui_client_capabilities,
6+
announce_subagent,
67
bind_client_tools,
78
client_tool_names,
89
client_tool_specs,
@@ -14,6 +15,7 @@
1415

1516
__all__ = [
1617
"a2ui_client_capabilities",
18+
"announce_subagent",
1719
"bind_client_tools",
1820
"client_tool_names",
1921
"client_tool_specs",

packages/threadplane-middleware/src/threadplane/middleware/langgraph/middleware.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,64 @@ def route_after_agent(
144144
if has_server_tool_call(state, server_tool_names):
145145
return tools_node
146146
return end
147+
148+
def announce_subagent(config: Any, tool_call_id: str) -> bool:
149+
"""Bind this tool call's child-graph stream to its tool-call id, for UIs.
150+
151+
LangGraph streams a child graph invoked inside a ``@tool`` body under a
152+
``tools:<uuid>`` namespace, where the uuid is a *checkpoint* id assigned
153+
independently of the tool-call id — nothing on the wire links the two. A
154+
frontend showing per-subagent progress therefore has to guess which stream
155+
belongs to which call, which is unsound the moment two children run at
156+
once.
157+
158+
Inside the tool body both halves are known: the namespace is the config's
159+
``checkpoint_ns`` and the tool-call id arrives via
160+
``InjectedToolCallId``. This helper emits them as one custom event::
161+
162+
from typing import Annotated
163+
from langchain_core.runnables import RunnableConfig
164+
from langchain_core.tools import InjectedToolCallId, tool
165+
166+
@tool
167+
async def task(
168+
description: str,
169+
tool_call_id: Annotated[str, InjectedToolCallId] = None,
170+
config: RunnableConfig = None,
171+
) -> str:
172+
announce_subagent(config, tool_call_id)
173+
result = await child_graph.ainvoke({...})
174+
...
175+
176+
``@threadplane/langgraph`` recognizes the event and attributes the child's
177+
stream deterministically, before any tokens arrive.
178+
179+
Returns ``True`` if the event was emitted, ``False`` when anything needed
180+
is unavailable (no stream writer outside a run, no namespace at the top
181+
level, missing tool_call_id) — callers never need to guard it.
182+
"""
183+
if not tool_call_id:
184+
return False
185+
namespace = None
186+
if isinstance(config, dict):
187+
for section in ("metadata", "configurable"):
188+
raw = config.get(section)
189+
if isinstance(raw, dict) and raw.get("checkpoint_ns"):
190+
namespace = raw["checkpoint_ns"]
191+
break
192+
if not namespace:
193+
return False
194+
try:
195+
from langgraph.config import get_stream_writer
196+
197+
writer = get_stream_writer()
198+
writer(
199+
{
200+
"type": "threadplane.subagent_binding",
201+
"namespace": namespace,
202+
"tool_call_id": tool_call_id,
203+
}
204+
)
205+
return True
206+
except Exception:
207+
return False

packages/threadplane-middleware/tests/test_middleware.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,3 +365,54 @@ def test_a2ui_client_capabilities_missing_or_malformed_is_none():
365365
assert a2ui_client_capabilities({}) is None
366366
assert a2ui_client_capabilities({"a2ui_client_capabilities": "nope"}) is None
367367
assert a2ui_client_capabilities({"a2ui_client_capabilities": ["x"]}) is None
368+
369+
# ── announce_subagent ────────────────────────────────────────────────────────
370+
371+
from threadplane.middleware.langgraph import announce_subagent
372+
373+
374+
def test_announce_subagent_requires_tool_call_id():
375+
assert announce_subagent({"metadata": {"checkpoint_ns": "tools:abc"}}, None) is False
376+
assert announce_subagent({"metadata": {"checkpoint_ns": "tools:abc"}}, "") is False
377+
378+
379+
def test_announce_subagent_requires_namespace():
380+
# Top-level invocation: no checkpoint_ns anywhere.
381+
assert announce_subagent({"metadata": {}, "configurable": {}}, "call_1") is False
382+
assert announce_subagent(None, "call_1") is False
383+
384+
385+
def test_announce_subagent_no_writer_outside_run():
386+
# Valid inputs, but get_stream_writer() raises outside a LangGraph run —
387+
# the helper must swallow that and report False, never raise.
388+
cfg = {"metadata": {"checkpoint_ns": "tools:abc-123"}}
389+
assert announce_subagent(cfg, "call_1") is False
390+
391+
392+
def test_announce_subagent_emits_when_writer_available(monkeypatch):
393+
captured = []
394+
395+
def fake_writer(payload):
396+
captured.append(payload)
397+
398+
import langgraph.config as lg_config
399+
400+
monkeypatch.setattr(lg_config, "get_stream_writer", lambda: fake_writer)
401+
cfg = {"metadata": {"checkpoint_ns": "tools:abc-123"}}
402+
assert announce_subagent(cfg, "call_9") is True
403+
assert captured == [
404+
{
405+
"type": "threadplane.subagent_binding",
406+
"namespace": "tools:abc-123",
407+
"tool_call_id": "call_9",
408+
}
409+
]
410+
411+
412+
def test_announce_subagent_falls_back_to_configurable():
413+
cfg = {"metadata": {}, "configurable": {"checkpoint_ns": "tools:xyz"}}
414+
# No writer in this test context, so False — but it must have gotten past
415+
# the namespace check (i.e. not short-circuited on metadata being empty).
416+
# Verified via the emit test above; here we just pin no-raise behavior.
417+
assert announce_subagent(cfg, "call_1") in (True, False)
418+

packages/threadplane-middleware/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)