Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion cockpit/chat/subagents/python/src/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
from langchain_core.messages import SystemMessage, HumanMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from typing import Annotated

from langchain_core.runnables import RunnableConfig
from langchain_core.tools import InjectedToolCallId
from langgraph.graph import StateGraph, MessagesState, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
Expand Down Expand Up @@ -154,8 +158,45 @@ def _final_text(messages: list) -> str:
return "(no subagent output)"


def _announce_subagent(config, tool_call_id: str) -> None:
"""Bind this tool call's child stream to its tool-call id, for the UI.

LangGraph streams the child under a `tools:<uuid>` namespace whose uuid is
a checkpoint id — nothing on the wire links it to the `call_*` id, so a
frontend showing per-subagent progress would otherwise have to guess.
Inside the tool body both halves are known; emit them as one custom event
that `@threadplane/langgraph` recognizes.

Inlined per the cockpit standalone rule — the canonical helper is
`threadplane.middleware.langgraph.announce_subagent`.
"""
if not tool_call_id:
return
meta = dict((config or {}).get("metadata") or {})
namespace = meta.get("checkpoint_ns")
if not namespace:
return
try:
from langgraph.config import get_stream_writer

get_stream_writer()(
{
"type": "threadplane.subagent_binding",
"namespace": namespace,
"tool_call_id": tool_call_id,
}
)
except Exception:
pass


@tool
async def task(subagent_type: Literal["research", "booking", "itinerary"], task_description: str) -> str:
async def task(
subagent_type: Literal["research", "booking", "itinerary"],
task_description: str,
tool_call_id: Annotated[str, InjectedToolCallId] = None,
config: RunnableConfig = None,
) -> str:
"""Delegate a subtask to a specialized subagent subgraph.

Args:
Expand All @@ -169,6 +210,7 @@ async def task(subagent_type: Literal["research", "booking", "itinerary"], task_
Returns:
The subagent's final answer as a string.
"""
_announce_subagent(config, tool_call_id)
result = await subagent_subgraph.ainvoke(
{"subagent_type": subagent_type, "task_description": task_description, "messages": []}
)
Expand Down
95 changes: 95 additions & 0 deletions libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2906,6 +2906,101 @@ describe('createStreamManagerBridge', () => {
destroy$.next();
});

it('binding events attribute concurrent children exactly, even out of order', async () => {
// The case #864 deliberately left unattributed: two children outstanding,
// streams arriving in reverse dispatch order. With server-announced
// bindings (threadplane-middleware announce_subagent) both resolve
// deterministically — no guessing, no empty cards.
const transport = new MockAgentTransport();
const subjects = makeSubjects();
const destroy$ = new Subject<void>();
const bridge = createStreamManagerBridge({
options: { apiUrl: '', assistantId: 'test', transport, subagentToolNames: ['task'] },
subjects, threadId$: of(null), destroy$: destroy$.asObservable(),
});
bridge.submit({});
transport.emit([{
type: 'messages',
messages: [{
id: 'ai-1', type: 'ai', content: '',
tool_calls: [
{ id: 'call_ALPHA', name: 'task', args: { subagent_type: 'alpha', task_description: 'a' } },
{ id: 'call_BETA', name: 'task', args: { subagent_type: 'beta', task_description: 'b' } },
],
}],
} satisfies StreamEvent]);
// BETA's chunk arrives BEFORE any binding — it must buffer, not misroute.
transport.emit([{
type: 'messages|tools:ns-BETA' as StreamEvent['type'], namespace: ['tools:ns-BETA'],
messages: [{ id: 'm-beta', type: 'AIMessageChunk', content: 'beta output' }],
messageMetadata: { checkpoint_ns: 'tools:ns-BETA' },
} satisfies StreamEvent]);
// Bindings arrive (order irrelevant), exactly as announce_subagent emits them.
transport.emit([{
type: 'custom',
data: { type: 'threadplane.subagent_binding', namespace: 'tools:ns-BETA', tool_call_id: 'call_BETA' },
} as StreamEvent]);
transport.emit([{
type: 'custom',
data: { type: 'threadplane.subagent_binding', namespace: 'tools:ns-ALPHA', tool_call_id: 'call_ALPHA' },
} as StreamEvent]);
transport.emit([{
type: 'messages|tools:ns-ALPHA' as StreamEvent['type'], namespace: ['tools:ns-ALPHA'],
messages: [{ id: 'm-alpha', type: 'AIMessageChunk', content: 'alpha output' }],
messageMetadata: { checkpoint_ns: 'tools:ns-ALPHA' },
} satisfies StreamEvent]);
transport.close();
await new Promise(r => setTimeout(r, 10));

const txt = (x: unknown) => (x as { content?: string } | undefined)?.content;
// Exact attribution both ways — including the pre-binding buffered chunk.
expect(txt(subjects.subagents$.value.get('call_ALPHA')?.messages()[0])).toBe('alpha output');
expect(txt(subjects.subagents$.value.get('call_BETA')?.messages()[0])).toBe('beta output');
// Protocol chatter is consumed, not surfaced to customEvents().
expect(subjects.custom$.value.filter(e =>
typeof e.data === 'object' && e.data !== null
&& (e.data as Record<string, unknown>)['type'] === 'threadplane.subagent_binding',
)).toHaveLength(0);
destroy$.next();
});

it('a binding never overrides an established mapping', async () => {
const transport = new MockAgentTransport();
const subjects = makeSubjects();
const destroy$ = new Subject<void>();
const bridge = createStreamManagerBridge({
options: { apiUrl: '', assistantId: 'test', transport, subagentToolNames: ['task'] },
subjects, threadId$: of(null), destroy$: destroy$.asObservable(),
});
bridge.submit({});
transport.emit([{
type: 'messages',
messages: [{
id: 'ai-1', type: 'ai', content: '',
tool_calls: [{ id: 'call_X', name: 'task', args: { subagent_type: 'xray', task_description: 'x' } }],
}],
} satisfies StreamEvent]);
transport.emit([{
type: 'custom',
data: { type: 'threadplane.subagent_binding', namespace: 'tools:ns-X', tool_call_id: 'call_X' },
} as StreamEvent]);
// A duplicate binding for the same pair is a no-op, not a re-establish.
transport.emit([{
type: 'custom',
data: { type: 'threadplane.subagent_binding', namespace: 'tools:ns-X', tool_call_id: 'call_X' },
} as StreamEvent]);
transport.emit([{
type: 'messages|tools:ns-X' as StreamEvent['type'], namespace: ['tools:ns-X'],
messages: [{ id: 'm-x', type: 'AIMessageChunk', content: 'x output' }],
messageMetadata: { checkpoint_ns: 'tools:ns-X' },
} satisfies StreamEvent]);
transport.close();
await new Promise(r => setTimeout(r, 10));
const txt = (x: unknown) => (x as { content?: string } | undefined)?.content;
expect(txt(subjects.subagents$.value.get('call_X')?.messages()[0])).toBe('x output');
destroy$.next();
});

it('never cross-wires concurrent children when arrival order != dispatch order', async () => {
const transport = new MockAgentTransport();
const subjects = makeSubjects();
Expand Down
16 changes: 16 additions & 0 deletions libs/langgraph/src/lib/internals/stream-manager.bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,22 @@ export function createStreamManagerBridge<T, ResolvedBag extends BagTemplate = B
break;
case 'custom': {
const eventData = event['data'] as Record<string, unknown> | undefined;
// Server-announced subagent identity (threadplane-middleware's
// `announce_subagent`). Consumed here rather than forwarded: it is
// protocol chatter, not application data.
if (
isRecord(eventData)
&& eventData['type'] === 'threadplane.subagent_binding'
&& typeof eventData['namespace'] === 'string'
&& typeof eventData['tool_call_id'] === 'string'
) {
const bound = childStreamRefFromNamespace([eventData['namespace']]);
if (bound?.kind === 'tool') {
subagentManager.bindChildStream(bound.key, eventData['tool_call_id']);
publishSubagents();
}
break;
}
const name = (event['name'] ?? eventData?.['name'] ?? '') as string;
const data = eventData?.['data'] ?? eventData;
const current = subjects.custom$.value;
Expand Down
26 changes: 26 additions & 0 deletions libs/langgraph/src/lib/internals/subagent-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,32 @@ export class SubagentTracker {
this.onSubagentChange?.();
}

/**
* Authoritative namespace→tool-call binding, from the server.
*
* `threadplane.middleware.langgraph.announce_subagent` emits a custom event
* pairing the child's checkpoint namespace with its tool-call id — the two
* halves that are never linked on the wire otherwise. Unlike the matching
* ladder this is not a heuristic: it overrides nothing that is already
* mapped, works with any number of children outstanding, and replays any
* chunks that streamed before the binding arrived.
*/
bindChildStream(namespaceId: string, toolCallId: string): void {
if (this.namespaceToToolCallId.get(namespaceId) === toolCallId) return;
this.namespaceToToolCallId.set(namespaceId, toolCallId);
const subagent = this.subagents.get(toolCallId);
if (subagent) {
const buffered = this.unattributedMessages.get(namespaceId);
this.subagents.set(toolCallId, {
...subagent,
status: subagent.status === 'complete' || subagent.status === 'error' ? subagent.status : 'running',
messages: buffered ? mergeMessages(subagent.messages, buffered) : subagent.messages,
});
this.unattributedMessages.delete(namespaceId);
}
this.onSubagentChange?.();
}

/**
* Attribute a `tools:` child stream to its parent tool call as soon as the
* child is seen, without requiring a description to match on.
Expand Down
2 changes: 1 addition & 1 deletion packages/threadplane-middleware/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "threadplane-middleware"
version = "0.0.1"
version = "0.0.2"
description = "LangGraph middleware for binding client-declared tool stubs and routing client tool calls to END so the browser executes them."
readme = "README.md"
license = { text = "MIT" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from threadplane.middleware.langgraph.middleware import (
a2ui_client_capabilities,
announce_subagent,
bind_client_tools,
client_tool_names,
client_tool_specs,
Expand All @@ -14,6 +15,7 @@

__all__ = [
"a2ui_client_capabilities",
"announce_subagent",
"bind_client_tools",
"client_tool_names",
"client_tool_specs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,64 @@ def route_after_agent(
if has_server_tool_call(state, server_tool_names):
return tools_node
return end

def announce_subagent(config: Any, tool_call_id: str) -> bool:
"""Bind this tool call's child-graph stream to its tool-call id, for UIs.

LangGraph streams a child graph invoked inside a ``@tool`` body under a
``tools:<uuid>`` namespace, where the uuid is a *checkpoint* id assigned
independently of the tool-call id — nothing on the wire links the two. A
frontend showing per-subagent progress therefore has to guess which stream
belongs to which call, which is unsound the moment two children run at
once.

Inside the tool body both halves are known: the namespace is the config's
``checkpoint_ns`` and the tool-call id arrives via
``InjectedToolCallId``. This helper emits them as one custom event::

from typing import Annotated
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import InjectedToolCallId, tool

@tool
async def task(
description: str,
tool_call_id: Annotated[str, InjectedToolCallId] = None,
config: RunnableConfig = None,
) -> str:
announce_subagent(config, tool_call_id)
result = await child_graph.ainvoke({...})
...

``@threadplane/langgraph`` recognizes the event and attributes the child's
stream deterministically, before any tokens arrive.

Returns ``True`` if the event was emitted, ``False`` when anything needed
is unavailable (no stream writer outside a run, no namespace at the top
level, missing tool_call_id) — callers never need to guard it.
"""
if not tool_call_id:
return False
namespace = None
if isinstance(config, dict):
for section in ("metadata", "configurable"):
raw = config.get(section)
if isinstance(raw, dict) and raw.get("checkpoint_ns"):
namespace = raw["checkpoint_ns"]
break
if not namespace:
return False
try:
from langgraph.config import get_stream_writer

writer = get_stream_writer()
writer(
{
"type": "threadplane.subagent_binding",
"namespace": namespace,
"tool_call_id": tool_call_id,
}
)
return True
except Exception:
return False
51 changes: 51 additions & 0 deletions packages/threadplane-middleware/tests/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,3 +365,54 @@ def test_a2ui_client_capabilities_missing_or_malformed_is_none():
assert a2ui_client_capabilities({}) is None
assert a2ui_client_capabilities({"a2ui_client_capabilities": "nope"}) is None
assert a2ui_client_capabilities({"a2ui_client_capabilities": ["x"]}) is None

# ── announce_subagent ────────────────────────────────────────────────────────

from threadplane.middleware.langgraph import announce_subagent


def test_announce_subagent_requires_tool_call_id():
assert announce_subagent({"metadata": {"checkpoint_ns": "tools:abc"}}, None) is False
assert announce_subagent({"metadata": {"checkpoint_ns": "tools:abc"}}, "") is False


def test_announce_subagent_requires_namespace():
# Top-level invocation: no checkpoint_ns anywhere.
assert announce_subagent({"metadata": {}, "configurable": {}}, "call_1") is False
assert announce_subagent(None, "call_1") is False


def test_announce_subagent_no_writer_outside_run():
# Valid inputs, but get_stream_writer() raises outside a LangGraph run —
# the helper must swallow that and report False, never raise.
cfg = {"metadata": {"checkpoint_ns": "tools:abc-123"}}
assert announce_subagent(cfg, "call_1") is False


def test_announce_subagent_emits_when_writer_available(monkeypatch):
captured = []

def fake_writer(payload):
captured.append(payload)

import langgraph.config as lg_config

monkeypatch.setattr(lg_config, "get_stream_writer", lambda: fake_writer)
cfg = {"metadata": {"checkpoint_ns": "tools:abc-123"}}
assert announce_subagent(cfg, "call_9") is True
assert captured == [
{
"type": "threadplane.subagent_binding",
"namespace": "tools:abc-123",
"tool_call_id": "call_9",
}
]


def test_announce_subagent_falls_back_to_configurable():
cfg = {"metadata": {}, "configurable": {"checkpoint_ns": "tools:xyz"}}
# No writer in this test context, so False — but it must have gotten past
# the namespace check (i.e. not short-circuited on metadata being empty).
# Verified via the emit test above; here we just pin no-raise behavior.
assert announce_subagent(cfg, "call_1") in (True, False)

2 changes: 1 addition & 1 deletion packages/threadplane-middleware/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading