From 56ac58c45f8c557bbd337f45f6491c0554bf47a8 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 13:41:59 -0700 Subject: [PATCH 1/6] docs(examples): ag-ui subagent baseline wire capture; ag-ui-protocol >=0.1.22 Co-Authored-By: Claude Fable 5.1 --- .../python/docs/wire-capture-subagents.md | 162 ++++++++++++++++++ examples/ag-ui/python/pyproject.toml | 1 + examples/ag-ui/python/uv.lock | 8 +- 3 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 examples/ag-ui/python/docs/wire-capture-subagents.md diff --git a/examples/ag-ui/python/docs/wire-capture-subagents.md b/examples/ag-ui/python/docs/wire-capture-subagents.md new file mode 100644 index 000000000..c2f68da0d --- /dev/null +++ b/examples/ag-ui/python/docs/wire-capture-subagents.md @@ -0,0 +1,162 @@ +# examples/ag-ui (LangGraph): subagent wire capture + emitter-seam decision + +Evidence for migrating this demo's research subagent from the private +ACTIVITY convention (`ACTIVITY_SNAPSHOT`/`ACTIVITY_DELTA` with `activityType: +"subagent"`) to the protocol's standard `SUBAGENT_*` events plus +`subagentRunId`-attributed `TEXT_MESSAGE_*` / `TOOL_CALL_*` events. Captured +2026-09-02 against the live backend (`src/server.py`, `uv run uvicorn +src.server:app --port 8000`, real `OPENAI_API_KEY`, `gpt-5-mini` for the +orchestrator and the research child) with `ag-ui-langgraph 0.0.40` and +`ag-ui-protocol 0.1.22` (bumped in the same commit as this doc; the previous +transitive pin was 0.1.19). + +This demo is the richer fork of `cockpit/ag-ui/subagents`: the child is a +compiled LangGraph subgraph running a reason → `lookup` tool → answer loop, so +the transcript has two assistant turns and one child tool call (the flat +cockpit variant has one turn and no tools). The cockpit capture lives at +`cockpit/ag-ui/subagents/python/docs/wire-capture-subagents.md`; this doc +records only what differs. + +Bridge citations are into the installed venv source: +`.venv/lib/python3.12/site-packages/ag_ui_langgraph/agent.py` and +`.venv/lib/python3.12/site-packages/ag_ui/core/events.py`. + +## 1. SDK check + +``` +$ uv run python -c "from ag_ui.core import SubagentStartedEvent, TextMessageContentEvent, ToolCallStartEvent, ToolCallResultEvent; print(TextMessageContentEvent.model_fields['subagent_run_id']); print(list(ToolCallStartEvent.model_fields)); print(list(ToolCallResultEvent.model_fields))" +annotation=Union[str, NoneType] required=False default=None alias='subagentRunId' alias_priority=1 +['metadata', 'type', 'timestamp', 'raw_event', 'tool_call_id', 'tool_call_name', 'parent_message_id', 'subagent_run_id'] +['metadata', 'type', 'timestamp', 'raw_event', 'message_id', 'tool_call_id', 'content', 'role', 'subagent_run_id'] +``` + +`ToolCallStartEvent` carries `parent_message_id` + `subagent_run_id`, and +`ToolCallResultEvent` carries `message_id`, `tool_call_id`, `content`, `role`, +`subagent_run_id` — the fields the contract's `tool_call` / `tool_result` +expansions need. `SubagentStartedEvent` exposes `parent_tool_call_id`. + +## 2. Baseline (before the emitter) + +`RunAgentInput` POSTed to `/agent` (`Accept: text/event-stream`): + +```json +{"threadId":"capture-thread-2","runId":"capture-thread-2-run", + "messages":[{"id":"u1","role":"user","content":"I want an in-depth research deep-dive on Angular signals: history, motivation, and how they compare to zone.js. Dispatch your research subagent (the research tool, subagent_type research) now; do not use search_documents."}], + "tools":[],"context":[],"state":{},"forwardedProps":{}} +``` + +(The e2e's bare prompt *"Research Angular signals and summarize"* delegates +under aimock replay, but the live orchestrator answered it with +`search_documents` — the system prompt routes "simple lookups" there and +reserves `research` for "in-depth research". The longer prompt above +delegated on the first attempt.) + +Scrubbed capture — line numbers are event indices (1-based) in the SSE +stream; `rawEvent` mirrors are dropped from every line and repetitive runs +are elided with `# [elided: ...]`. No keys or org ids appeared in the stream. + +``` +1 {"type":"RUN_STARTED","threadId":"capture-thread-2","runId":"capture-thread-2-run"} +3 {"type":"STEP_STARTED","stepName":"generate"} +9 {"type":"TOOL_CALL_START","toolCallId":"call_9n4N3xc350eeejpCUahSoH4v","toolCallName":"research","parentMessageId":"lc_run--01a063d9-03e5-7521-90c4-cc6e84ddf9fa"} + # [elided: 29 TOOL_CALL_ARGS deltas spelling {"topic":"Angular signals: history, motivation, ...","subagent_type":"research"}] +69 {"type":"TOOL_CALL_END","toolCallId":"call_9n4N3xc350eeejpCUahSoH4v"} +77 {"type":"STEP_FINISHED","stepName":"generate"} +78 {"type":"STEP_STARTED","stepName":"tools"} +80 {"type":"RAW","event":{"event":"on_tool_start","name":"research"}} +82 {"type":"ACTIVITY_SNAPSHOT","messageId":"call_9n4N3xc350eeejpCUahSoH4v","activityType":"subagent","content":{"toolCallId":"call_9n4N3xc350eeejpCUahSoH4v","name":"research","status":"running","messages":[],"toolCalls":[]},"replace":true} +86 {"type":"STEP_FINISHED","stepName":"tools"} +87 {"type":"STEP_STARTED","stepName":"agent"} # the CHILD subgraph's node +90 {"type":"ACTIVITY_DELTA", ... "patch":[{"op":"add","path":"/messages/-","value":{"id":"call_9n4N3xc350eeejpCUahSoH4v-0","role":"assistant","content":"","toolCallIds":[]}}]} +93 {"type":"TOOL_CALL_START","toolCallId":"call_fgoFFeLMn9eA1V2voGF8pa2Q","toolCallName":"lookup","parentMessageId":"lc_run--01a063d9-0b11-7b50-95c5-ddf0404f2be9"} # UNATTRIBUTED — the child's own call, streamed by the bridge as if it were the parent's + # [elided: 12 TOOL_CALL_ARGS deltas for lookup] +120 {"type":"TOOL_CALL_END","toolCallId":"call_fgoFFeLMn9eA1V2voGF8pa2Q"} +124 {"type":"ACTIVITY_DELTA", ... "patch":[{"op":"add","path":"/toolCalls/-","value":{"id":"call_fgoFFeLMn9eA1V2voGF8pa2Q","name":"lookup","args":{"query":"Angular signals history m..."},"status":"running"}},{"op":"add","path":"/messages/0/toolCallIds/-","value":"call_fgoFFeLMn9eA1V2voGF8pa2Q"}]} +130 {"type":"STEP_FINISHED","stepName":"agent"} +131 {"type":"STEP_STARTED","stepName":"tools"} +134 {"type":"RAW","event":{"event":"on_tool_start","name":"lookup"}} +135 {"type":"RAW","event":{"event":"on_tool_end","name":"lookup"}} +137 {"type":"ACTIVITY_DELTA", ... "patch":[{"op":"replace","path":"/toolCalls/0/status","value":"complete"},{"op":"replace","path":"/toolCalls/0/result","value":"Angular signals are ..."}]} +141 {"type":"STEP_FINISHED","stepName":"tools"} +142 {"type":"STEP_STARTED","stepName":"agent"} +146 {"type":"ACTIVITY_DELTA", ... "patch":[{"op":"add","path":"/messages/-","value":{"id":"call_9n4N3xc350eeejpCUahSoH4v-1","role":"assistant","content":"","toolCallIds":[]}}]} +150 {"type":"TEXT_MESSAGE_START","messageId":"lc_run--01a063d9-195b-7dc2-9a29-2e20f8b34638","role":"assistant"} # UNATTRIBUTED — the child's answer, streamed by the bridge into the PARENT transcript +153 {"type":"ACTIVITY_DELTA", ... "patch":[{"op":"replace","path":"/messages/1/content","value":"-"}]} + # [elided: 343 more (TEXT_MESSAGE_CONTENT + RAW + RAW on_custom_event + ACTIVITY_DELTA) quads — each ACTIVITY_DELTA carries the FULL accumulated text ("- What", "- What signals", ...): 306,482 bytes of `value` across 344 deltas for a 1,810-char answer] +1525 {"type":"ACTIVITY_DELTA", ... "patch":[{"op":"replace","path":"/messages/1/content","value":""}]} +1530 {"type":"TEXT_MESSAGE_END","messageId":"lc_run--01a063d9-195b-7dc2-9a29-2e20f8b34638"} +1537 {"type":"STEP_FINISHED","stepName":"agent"} +1538 {"type":"STEP_STARTED","stepName":"tools"} +1541 {"type":"ACTIVITY_DELTA", ... "patch":[{"op":"replace","path":"/status","value":"complete"}]} +1542 {"type":"RAW","event":{"event":"on_tool_end","name":"research"}} +1543 {"type":"TOOL_CALL_RESULT","messageId":"c5fafb3e-cf02-4ee5-b247-c841b97ec603","toolCallId":"call_9n4N3xc350eeejpCUahSoH4v","content":"- What signals are: a fine-grained reactivity primitive in Angular ..."} +1550 {"type":"STEP_FINISHED","stepName":"tools"} +1551 {"type":"STEP_STARTED","stepName":"generate"} +1557 {"type":"TEXT_MESSAGE_START","messageId":"lc_run--01a063d9-565b-7eb3-a7ac-837d933d2e97","role":"assistant"} + # [elided: 68 TEXT_MESSAGE_CONTENT deltas — the ORCHESTRATOR's own answer] +1695 {"type":"TEXT_MESSAGE_END","messageId":"lc_run--01a063d9-565b-7eb3-a7ac-837d933d2e97"} +1704 {"type":"STEP_STARTED","stepName":"attach_citations"} +1711 {"type":"STEP_STARTED","stepName":"generate_title"} +1720 {"type":"MESSAGES_SNAPSHOT", ...} # user, assistant(research call), tool(result), assistant(answer) — the child's lc_run message is NOT in it +1721 {"type":"RUN_FINISHED","threadId":"capture-thread-2","runId":"capture-thread-2-run"} +``` + +Event tally (1,721 events): 1 RUN_STARTED, 9 STEP_STARTED, 9 STEP_FINISHED, +2 TOOL_CALL_START, 41 TOOL_CALL_ARGS, 2 TOOL_CALL_END, 1 ACTIVITY_SNAPSHOT, +349 ACTIVITY_DELTA, 1 TOOL_CALL_RESULT, 2 TEXT_MESSAGE_START, +412 TEXT_MESSAGE_CONTENT, 2 TEXT_MESSAGE_END, 10 STATE_SNAPSHOT, +2 MESSAGES_SNAPSHOT, 1 RUN_FINISHED, 877 RAW. No CUSTOM (the +`ActivityEmittingAgent` swallowed all 350 `subagent_activity` CUSTOM events +and emitted an ACTIVITY event in each one's place), no SUBAGENT_*, zero +events carrying `subagentRunId`. + +RAW breakdown: 469 `on_chat_model_stream`, 350 `on_custom_event`, 16 +`on_chain_stream`, 15 `on_chain_start`, 15 `on_chain_end`, 4 +`on_chat_model_start`, 4 `on_chat_model_end`, 2 `on_tool_start`, 2 +`on_tool_end`. + +### 2a. Ordering finding (design §6) + +**`TOOL_CALL_START` for `research` precedes the first ACTIVITY event:** START +at 9, ARGS through 68, END at 69, `STEP_FINISHED(generate)` / +`STEP_STARTED(tools)` at 77/78, `on_tool_start` at 80, ACTIVITY_SNAPSHOT at +82. The tool call is fully announced before the tool body runs, and the +delegation window nests between `TOOL_CALL_END` (69) and `TOOL_CALL_RESULT` +(1543) — the same nesting the cockpit lane measured. The reducer's +`parentToolCallId` lookup therefore always finds an already-announced tool +call; the card never renders nameless. + +### 2b. The bridge streams the child subgraph unattributed + +Unlike the cockpit lane (whose child is a bare `llm.astream` inside the tool +body), this child is a compiled subgraph and `ag-ui-langgraph` streams +subgraphs by default (`forwarded_props.stream_subgraphs`, `agent.py:257`, +`:590-595`). The bridge therefore emits the child's nodes as `STEP_*` +(`agent` / `tools`, 87-141) AND the child's own content as bridge-native, +unattributed events: the `lookup` `TOOL_CALL_START/ARGS/END` (93-120) and the +answer's `TEXT_MESSAGE_START/CONTENT/END` (150-1530, 344 deltas) land in the +PARENT transcript while the run streams. The trailing `MESSAGES_SNAPSHOT` +(1720) omits the child's `lc_run--…` message, so the parent bubble reconciles +after the run — which is why the e2e's "child text must not leak into the +parent bubble" assertion (checked post-finalization) passes today. On the +wire, however, the child's answer is shipped twice: once verbatim as +unattributed `TEXT_MESSAGE_CONTENT` and once accumulated inside +`ACTIVITY_DELTA`. + +### 2c. Wire volume + +The `SubagentStreamHandler` accumulated `text_so_far` and shipped it in every +`message` event; the transform turned each into a JSON-patch `replace` of the +whole message. 344 deltas carried 306,482 bytes of `value` for a 1,810-char +answer — quadratic in the answer length. The per-token contract (each event +carries only the raw token) makes this linear. + +### 2d. Why the 1:1 `_dispatch_event` seam cannot carry the migration + +Identical to the cockpit finding: `ActivityEmittingAgent` overrode +`LangGraphAgent._dispatch_event`, which is called inline as `yield +self._dispatch_event(...)` at every yield site — strictly one in / one out. +The standard sequence needs 1:N expansion (`tool_call` → three `TOOL_CALL_*` +events; `finished` → `TEXT_MESSAGE_END` + `SUBAGENT_FINISHED`; the CUSTOM +event itself consumed → zero out), so the seam is `LangGraphAgent.run`, the +async generator the FastAPI endpoint consumes. diff --git a/examples/ag-ui/python/pyproject.toml b/examples/ag-ui/python/pyproject.toml index 80df87b99..8b3ebd38a 100644 --- a/examples/ag-ui/python/pyproject.toml +++ b/examples/ag-ui/python/pyproject.toml @@ -6,6 +6,7 @@ dependencies = [ "langgraph>=0.3", "langchain-openai>=0.3", "ag-ui-langgraph>=0.0.37", + "ag-ui-protocol>=0.1.22", "fastapi>=0.115", "uvicorn>=0.30", "python-dotenv>=1.0", diff --git a/examples/ag-ui/python/uv.lock b/examples/ag-ui/python/uv.lock index 69ff4bb94..3c80a1987 100644 --- a/examples/ag-ui/python/uv.lock +++ b/examples/ag-ui/python/uv.lock @@ -30,14 +30,14 @@ wheels = [ [[package]] name = "ag-ui-protocol" -version = "0.1.19" +version = "0.1.22" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/10/4ad299267a7d04b89935aa99eef62979758fcf95aee9f8bb5d70c35b1be1/ag_ui_protocol-0.1.19.tar.gz", hash = "sha256:43c27f60d41712dcad0e9e0a203cbdf1c8e248b22417374c5c68321c448af4ea", size = 10720, upload-time = "2026-06-02T17:26:15.627Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/f7/9bf788e7d3608725d022a248a58427040f4f930ab87ddb54bd29ee4d9a51/ag_ui_protocol-0.1.22.tar.gz", hash = "sha256:d21f265284a50d9fc87ad7bcbd58f737b4b16eef7b5375f13a6e925117b52046", size = 18110, upload-time = "2026-08-31T18:20:04.334Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/0a/bcad8116eb058e4b4a305e3fc37ebd7efc879deeb86b854f1c5b8b6e97dd/ag_ui_protocol-0.1.19-py3-none-any.whl", hash = "sha256:898843b1410d378824da0c6a776486288b9c5828689d0bf563118868e37f390f", size = 13490, upload-time = "2026-06-02T17:26:16.313Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/af3d577e68c9474c99600e65b2c6283772aae187edca6f0f2f5cbd9f565a/ag_ui_protocol-0.1.22-py3-none-any.whl", hash = "sha256:fca13ee7820f8f53e869c19e09ddd75826c1799b27c2adb6f2e567295433c704", size = 22068, upload-time = "2026-08-31T18:20:03.43Z" }, ] [[package]] @@ -189,6 +189,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "ag-ui-langgraph" }, + { name = "ag-ui-protocol" }, { name = "fastapi" }, { name = "langchain-openai" }, { name = "langgraph" }, @@ -206,6 +207,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "ag-ui-langgraph", specifier = ">=0.0.37" }, + { name = "ag-ui-protocol", specifier = ">=0.1.22" }, { name = "fastapi", specifier = ">=0.115" }, { name = "langchain-openai", specifier = ">=0.3" }, { name = "langgraph", specifier = ">=0.3" }, From 5e359619e44cca8ede9b3e2a9005041e6c4b2574 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 13:43:51 -0700 Subject: [PATCH 2/6] feat(examples): ag-ui demo emits per-token subagent deltas The research subgraph opens each assistant turn with a message_start carrying a -sub-m message id (SubagentRunState owns the counter), the SubagentStreamHandler forwards each token as a raw delta instead of the accumulated text-so-far, and tool_call / tool_result payloads carry tool_call_id / name / args / content. The research tool body emits an error phase before re-raising. Co-Authored-By: Claude Fable 5.1 --- examples/ag-ui/python/src/graph.py | 57 +++++----- .../src/streaming/subagent_stream_handler.py | 84 +++++++++------ .../python/tests/test_subagent_emission.py | 20 ++-- .../tests/test_subagent_stream_handler.py | 101 +++++++++++++----- 4 files changed, 161 insertions(+), 101 deletions(-) diff --git a/examples/ag-ui/python/src/graph.py b/examples/ag-ui/python/src/graph.py index 642a68660..63d284cd2 100644 --- a/examples/ag-ui/python/src/graph.py +++ b/examples/ag-ui/python/src/graph.py @@ -278,10 +278,11 @@ def request_approval(reason: str) -> str: # node loops back to `agent`. A per-run `iterations` counter caps the loop # so it always terminates (the agent is told to answer after one lookup). # Each node emits the structured transcript (`message_start` / `tool_call` / -# `tool_result`) as `subagent_activity` CUSTOM events the L2 transform turns -# into AG-UI ACTIVITY DELTAs; live token text is streamed separately by the -# SubagentStreamHandler (which tags each `message` with the same -# `message_index` the subgraph opened the turn with). +# `tool_result`) as `subagent_activity` CUSTOM events that the server's +# SubagentEmittingAgent expands into the protocol's `subagentRunId`-attributed +# TEXT_MESSAGE_* / TOOL_CALL_* events; live token text is streamed +# separately by the SubagentStreamHandler as per-token `message` deltas +# tagged with the message id the subgraph opened the turn with. class ResearchState(TypedDict): messages: Annotated[list, add_messages] topic: Optional[str] @@ -346,7 +347,8 @@ def _build_research_subgraph(emit, run_state, llm_factory=_make_research_llm): `emit(payload)` dispatches a `subagent_activity` CUSTOM event already keyed by the parent tool_call_id. `run_state` is the SubagentRunState - the SubagentStreamHandler reads `message_index` from for live tokens. + that derives the `-sub-m` message ids; the + SubagentStreamHandler reads the open id from it for live tokens. `llm_factory(force_answer)` returns the model for a turn — overridable in tests with a fake tool-calling chat model. """ @@ -354,10 +356,10 @@ def _build_research_subgraph(emit, run_state, llm_factory=_make_research_llm): async def agent_node(state: ResearchState) -> dict: topic = state.get("topic") or "" iterations = state.get("iterations") or 0 - # Open a new assistant turn. The handler reads this index for the - # live `message` tokens it streams during this LLM call. - run_state.message_index = iterations - await emit({"phase": "message_start", "message_index": iterations}) + # Open a new assistant turn. The handler reads this id for the + # live `message` deltas it streams during this LLM call. + message_id = run_state.open_message() + await emit({"phase": "message_start", "message_id": message_id}) force_answer = iterations >= _RESEARCH_MAX_ITERATIONS system = SystemMessage(content=( @@ -380,15 +382,16 @@ async def agent_node(state: ResearchState) -> dict: call_llm = llm_factory(force_answer) response = await call_llm.ainvoke(messages) - # Emit one tool_call event per call on the returned AIMessage. + # Emit one tool_call event per call on the returned AIMessage. The + # emitter anchors it to the turn that is open (this one). tool_calls = getattr(response, "tool_calls", None) or [] for tc in tool_calls: await emit({ "phase": "tool_call", - "message_index": iterations, + "message_id": message_id, "tool_call_id": tc.get("id"), "name": tc.get("name"), - "args": tc.get("args"), + "args": tc.get("args") or {}, }) return {"messages": [response], "iterations": iterations + 1} @@ -405,15 +408,11 @@ async def tools_node(state: ResearchState) -> dict: else: result = f"(unknown tool: {name})" out.append(ToolMessage(content=result, tool_call_id=tc.get("id"))) - # tool_index is the 0-based position of this call in the run's - # toolCalls[] (same order tool_call events were emitted). await emit({ "phase": "tool_result", - "tool_index": run_state.tool_index, - "result": result, - "status": "complete", + "tool_call_id": tc.get("id"), + "content": result, }) - run_state.tool_index += 1 return {"messages": out} def should_continue(state: ResearchState) -> Literal["tools", "__end__"]: @@ -450,9 +449,11 @@ async def research( to populate `agent.subagents()` for the chat-subagents primitive). Always pass a stable identifier like "research". - The subagent run is also surfaced to the UI as a native AG-UI ACTIVITY - (activityType "subagent"): started → reason/tool/answer transcript → - finished, keyed by this tool's own call id. + The subagent run is also surfaced to the UI as the protocol's standard + subagent events (SUBAGENT_STARTED → attributed reason/tool/answer + transcript → SUBAGENT_FINISHED / SUBAGENT_ERROR), keyed by this tool's + own call id: the graph dispatches `subagent_activity` CUSTOM payloads + and the server's SubagentEmittingAgent expands them on the wire. """ async def _emit(payload: dict) -> None: @@ -463,14 +464,18 @@ async def _emit(payload: dict) -> None: except Exception: pass - run_state = SubagentRunState() + run_state = SubagentRunState(tool_call_id) subgraph = _build_research_subgraph(_emit, run_state) await _emit({"phase": "started", "name": subagent_type}) - result = await subgraph.ainvoke( - {"topic": topic, "messages": [], "iterations": 0}, - config={"callbacks": [SubagentStreamHandler(tool_call_id, run_state)]}, - ) + try: + result = await subgraph.ainvoke( + {"topic": topic, "messages": [], "iterations": 0}, + config={"callbacks": [SubagentStreamHandler(tool_call_id, run_state)]}, + ) + except Exception as exc: + await _emit({"phase": "error", "message": f"{type(exc).__name__}: {exc}"}) + raise await _emit({"phase": "finished", "status": "complete"}) msgs = result.get("messages") if isinstance(result, dict) else None diff --git a/examples/ag-ui/python/src/streaming/subagent_stream_handler.py b/examples/ag-ui/python/src/streaming/subagent_stream_handler.py index 1a00a1cc1..b1efaf058 100644 --- a/examples/ag-ui/python/src/streaming/subagent_stream_handler.py +++ b/examples/ag-ui/python/src/streaming/subagent_stream_handler.py @@ -1,60 +1,74 @@ -"""Taps a child subagent LLM's text tokens and emits them as `subagent_activity` -`message` events, keyed by the parent tool_call_id. Accumulates `text_so_far` -so the L2 transform stays stateless. `started`/`finished` are emitted by the -research tool body. Uses adispatch_custom_event (the bridge reads on_custom_event -from astream_events; get_stream_writer would surface only as a RAW event). - -Each `message` event also carries the current `message_index` — the 0-based -ordinal of the assistant turn the tokens belong to. The subgraph owns the -counter (it opens each turn with a `message_start`); the handler reads it -through a shared mutable ref (`SubagentRunState`) so the index it tags stays -in lock-step with the transcript the subgraph emits. The handler resets its -text buffer whenever the subgraph advances to a new turn so each message's -`text_so_far` starts fresh.""" +"""Taps a child subagent LLM's text tokens and forwards each one as a +`subagent_activity` payload keyed by the parent tool_call_id: + + message_start {subagent_id, message_id} once per assistant turn + message {subagent_id, message_id, delta} one per token (raw delta) + +`SubagentEmittingAgent` turns those into `subagentRunId`-attributed +TEXT_MESSAGE_START / TEXT_MESSAGE_CONTENT events and closes the message +(TEXT_MESSAGE_END) itself at the next `message_start` / `tool_call` / +`finished` / `error`. `started` / `tool_call` / `tool_result` / `finished` / +`error` are emitted by the research subgraph nodes and the `research` tool +body. + +Message ids follow the `-sub-m` convention. The research +subgraph runs several assistant turns per delegation (reason → tool → answer), +so it owns the turn counter: its `agent` node calls +`SubagentRunState.open_message()` and dispatches `message_start` BEFORE +invoking the model, and the handler reads the open id for the tokens that +follow. When no turn is open (a bare LLM call outside the subgraph, or the +unit tests) the handler opens one itself so a token is never orphaned. + +Uses adispatch_custom_event (the bridge reads on_custom_event from +astream_events; get_stream_writer would surface only as a RAW event).""" from typing import Any, Optional from uuid import UUID from langchain_core.callbacks import AsyncCallbackHandler, adispatch_custom_event +CUSTOM_NAME = "subagent_activity" + class SubagentRunState: - """Per-research-run shared state. The subgraph nodes own `message_index` - (bumping it as each assistant turn opens) and `tool_index` (the running - position in the run's toolCalls[]); the SubagentStreamHandler reads - `message_index` so its streamed `message` events tag the right turn.""" + """Per-delegation shared state: the message counter that derives + `-sub-m` ids and the id of the currently open assistant + turn. The research subgraph's nodes advance it; the SubagentStreamHandler + reads it so its streamed `message` deltas tag the right turn.""" + + def __init__(self, subagent_id: str) -> None: + self.subagent_id = subagent_id + self.message_count: int = 0 + self.message_id: Optional[str] = None - def __init__(self) -> None: - self.message_index: int = 0 - self.tool_index: int = 0 + def open_message(self) -> str: + """Advance to the next assistant turn and return its message id.""" + self.message_count += 1 + self.message_id = f"{self.subagent_id}-sub-m{self.message_count}" + return self.message_id class SubagentStreamHandler(AsyncCallbackHandler): def __init__(self, subagent_id: str, run_state: Optional[SubagentRunState] = None) -> None: self._id = subagent_id - self._buffer = "" - self._run_state = run_state if run_state is not None else SubagentRunState() - # Track which turn the current buffer belongs to so we reset the - # accumulated text when the subgraph advances to a new assistant turn. - self._buffer_index = self._run_state.message_index + self._run_state = run_state if run_state is not None else SubagentRunState(subagent_id) async def on_llm_new_token(self, token: str, *, run_id: UUID | None = None, **kwargs: Any) -> None: if not token: return - index = self._run_state.message_index - if index != self._buffer_index: - # New assistant turn opened since the last token — start fresh so - # `text_so_far` is scoped to this message, not the whole run. - self._buffer = "" - self._buffer_index = index - self._buffer += token try: + if self._run_state.message_id is None: + message_id = self._run_state.open_message() + await adispatch_custom_event( + CUSTOM_NAME, + {"subagent_id": self._id, "phase": "message_start", "message_id": message_id}, + ) await adispatch_custom_event( - "subagent_activity", + CUSTOM_NAME, { "subagent_id": self._id, "phase": "message", - "message_index": index, - "text": self._buffer, + "message_id": self._run_state.message_id, + "delta": token, }, ) except Exception: diff --git a/examples/ag-ui/python/tests/test_subagent_emission.py b/examples/ag-ui/python/tests/test_subagent_emission.py index 051953cff..1ba507c73 100644 --- a/examples/ag-ui/python/tests/test_subagent_emission.py +++ b/examples/ag-ui/python/tests/test_subagent_emission.py @@ -63,7 +63,7 @@ async def fake_emit(payload: dict) -> None: events.append({"subagent_id": "tc-research", **payload}) fake_model = _FakeToolCallingModel() - run_state = SubagentRunState() + run_state = SubagentRunState("tc-research") subgraph = _build_research_subgraph( fake_emit, run_state, llm_factory=lambda force_answer: fake_model ) @@ -85,23 +85,21 @@ async def fake_emit(payload: dict) -> None: by_phase = {p: e for p, e in phases} - # message_start indices: 0 then 1. - starts = [e["message_index"] for p, e in phases if p == "message_start"] - assert starts == [0, 1], starts + # message_start ids follow -sub-m: m1 then m2. + starts = [e["message_id"] for p, e in phases if p == "message_start"] + assert starts == ["tc-research-sub-m1", "tc-research-sub-m2"], starts - # tool_call carries id/name/args + the originating message_index (0). + # tool_call carries id/name/args + the originating message id (m1). tc = by_phase["tool_call"] - assert tc["message_index"] == 0 + assert tc["message_id"] == "tc-research-sub-m1" assert tc["tool_call_id"] == "call_lookup_1" assert tc["name"] == "lookup" assert tc["args"] == {"query": "angular signals"} - # tool_result carries the matching tool_index (0), the lookup result text, - # and a complete status. + # tool_result carries the matching tool_call_id and the lookup result text. tr = by_phase["tool_result"] - assert tr["tool_index"] == 0 - assert tr["status"] == "complete" - assert isinstance(tr["result"], str) and "signal" in tr["result"].lower() + assert tr["tool_call_id"] == "call_lookup_1" + assert isinstance(tr["content"], str) and "signal" in tr["content"].lower() # Loop terminates: the forced-answer turn returns a plain answer (no tool # calls), so the run ends with a final AIMessage and exactly two turns. diff --git a/examples/ag-ui/python/tests/test_subagent_stream_handler.py b/examples/ag-ui/python/tests/test_subagent_stream_handler.py index 8d5100ef0..55f6452ac 100644 --- a/examples/ag-ui/python/tests/test_subagent_stream_handler.py +++ b/examples/ag-ui/python/tests/test_subagent_stream_handler.py @@ -1,61 +1,104 @@ -"""Tests for SubagentStreamHandler — accumulates child LLM text tokens and -emits `subagent_activity` `message` events carrying the full `text_so_far`.""" +"""Tests for SubagentStreamHandler — forwards each child LLM token as a +`subagent_activity` payload: one `message_start` (carrying the message id) +before the first token of a message, then a `message` per token whose `delta` +is the raw token (no accumulation — the emitter turns these into attributed +TEXT_MESSAGE_START / TEXT_MESSAGE_CONTENT events). + +The research subgraph's `agent` node opens each assistant turn itself +(`SubagentRunState.open_message()` + its own `message_start` dispatch) before +invoking the model, so the handler only opens a message when none is open — +the subgraph and the handler never double-announce a turn.""" from unittest.mock import AsyncMock, patch from uuid import uuid4 import pytest from src.streaming.subagent_stream_handler import ( - SubagentStreamHandler, SubagentRunState, + SubagentStreamHandler, ) +TID = "call_7sxPY1sC236nPyHRTWAZMJB9" +M1 = f"{TID}-sub-m1" +M2 = f"{TID}-sub-m2" + + +class TestSubagentRunState: + def test_open_message_derives_sequential_ids(self): + state = SubagentRunState(TID) + assert state.message_id is None + assert state.open_message() == M1 + assert state.message_id == M1 + assert state.open_message() == M2 + assert state.message_id == M2 + assert state.message_count == 2 + class TestSubagentStreamHandler: @pytest.mark.asyncio - async def test_emits_accumulated_text_so_far(self): - handler = SubagentStreamHandler(subagent_id="tc-1") + async def test_emits_message_start_then_per_token_deltas(self): + handler = SubagentStreamHandler(subagent_id=TID) with patch("src.streaming.subagent_stream_handler.adispatch_custom_event", new_callable=AsyncMock) as dispatch: await handler.on_llm_new_token("Paris ", run_id=uuid4()) await handler.on_llm_new_token("is", run_id=uuid4()) - assert dispatch.call_args_list[0].args == ( - "subagent_activity", - {"subagent_id": "tc-1", "phase": "message", "message_index": 0, "text": "Paris "}) - assert dispatch.call_args_list[1].args == ( - "subagent_activity", - {"subagent_id": "tc-1", "phase": "message", "message_index": 0, "text": "Paris is"}) + assert [c.args for c in dispatch.call_args_list] == [ + ("subagent_activity", + {"subagent_id": TID, "phase": "message_start", "message_id": M1}), + ("subagent_activity", + {"subagent_id": TID, "phase": "message", "message_id": M1, "delta": "Paris "}), + ("subagent_activity", + {"subagent_id": TID, "phase": "message", "message_id": M1, "delta": "is"}), + ] @pytest.mark.asyncio - async def test_buffers_isolated_across_instances(self): - h1, h2 = SubagentStreamHandler("a"), SubagentStreamHandler("b") + async def test_empty_token_emits_nothing(self): + handler = SubagentStreamHandler(subagent_id=TID) with patch("src.streaming.subagent_stream_handler.adispatch_custom_event", new_callable=AsyncMock) as dispatch: - await h1.on_llm_new_token("x", run_id=uuid4()) - await h2.on_llm_new_token("y", run_id=uuid4()) - assert dispatch.call_args_list[0].args[1]["text"] == "x" - assert dispatch.call_args_list[1].args[1]["text"] == "y" + await handler.on_llm_new_token("", run_id=uuid4()) + assert dispatch.call_args_list == [] @pytest.mark.asyncio - async def test_tags_message_index_from_run_state(self): - run_state = SubagentRunState() - handler = SubagentStreamHandler(subagent_id="tc-1", run_state=run_state) + async def test_tags_the_message_the_subgraph_opened(self): + # The subgraph opens the turn (and dispatches message_start itself); + # the handler must reuse that id and NOT re-announce the message. + run_state = SubagentRunState(TID) + handler = SubagentStreamHandler(subagent_id=TID, run_state=run_state) with patch("src.streaming.subagent_stream_handler.adispatch_custom_event", new_callable=AsyncMock) as dispatch: + run_state.open_message() await handler.on_llm_new_token("first", run_id=uuid4()) # Subgraph advances to the next assistant turn. - run_state.message_index = 1 - await handler.on_llm_new_token("second", run_id=uuid4()) - first, second = dispatch.call_args_list - assert first.args[1]["message_index"] == 0 - assert first.args[1]["text"] == "first" - # Buffer resets per turn so text_so_far is scoped to the new turn. - assert second.args[1]["message_index"] == 1 - assert second.args[1]["text"] == "second" + run_state.open_message() + await handler.on_llm_new_token("sec", run_id=uuid4()) + await handler.on_llm_new_token("ond", run_id=uuid4()) + assert [c.args[1] for c in dispatch.call_args_list] == [ + {"subagent_id": TID, "phase": "message", "message_id": M1, "delta": "first"}, + {"subagent_id": TID, "phase": "message", "message_id": M2, "delta": "sec"}, + {"subagent_id": TID, "phase": "message", "message_id": M2, "delta": "ond"}, + ] + + @pytest.mark.asyncio + async def test_message_ids_isolated_across_instances(self): + h1, h2 = SubagentStreamHandler("a"), SubagentStreamHandler("b") + with patch("src.streaming.subagent_stream_handler.adispatch_custom_event", + new_callable=AsyncMock) as dispatch: + await h1.on_llm_new_token("x", run_id=uuid4()) + await h2.on_llm_new_token("y", run_id=uuid4()) + payloads = [c.args[1] for c in dispatch.call_args_list] + assert [p["phase"] for p in payloads] == [ + "message_start", "message", "message_start", "message"] + assert payloads[0]["message_id"] == "a-sub-m1" + assert payloads[1] == {"subagent_id": "a", "phase": "message", + "message_id": "a-sub-m1", "delta": "x"} + assert payloads[2]["message_id"] == "b-sub-m1" + assert payloads[3] == {"subagent_id": "b", "phase": "message", + "message_id": "b-sub-m1", "delta": "y"} @pytest.mark.asyncio async def test_dispatch_failure_is_silent(self): - handler = SubagentStreamHandler(subagent_id="tc-1") + handler = SubagentStreamHandler(subagent_id=TID) with patch("src.streaming.subagent_stream_handler.adispatch_custom_event", new_callable=AsyncMock, side_effect=RuntimeError): await handler.on_llm_new_token("hi", run_id=uuid4()) # must not raise From b2f95d1eb712ed58822d30ef031bf14b8bd63ec1 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 13:47:19 -0700 Subject: [PATCH 3/6] feat(examples): ag-ui demo emits standard SUBAGENT_* events SubagentEmittingAgent wraps LangGraphAgent.run and expands the graph's subagent_activity CUSTOM events 1:N into SUBAGENT_STARTED/FINISHED/ERROR plus subagentRunId-attributed TEXT_MESSAGE_* and TOOL_CALL_* events (the child's lookup call and result included). It replaces the 1:1 ActivityEmittingAgent/_dispatch_event translator and the ACTIVITY_* convention. Because the child is a streamed subgraph, the bridge also emitted the child's text and tool call unattributed into the parent transcript; the wrapper drops those duplicates inside the delegation window. Co-Authored-By: Claude Fable 5.1 --- .../ag-ui/angular/e2e/subagent-card.spec.ts | 16 +- examples/ag-ui/python/src/server.py | 8 +- .../src/streaming/activity_emitting_agent.py | 11 - .../src/streaming/activity_transform.py | 143 ------ .../src/streaming/subagent_emitting_agent.py | 318 ++++++++++++ .../python/tests/test_activity_transform.py | 212 -------- .../python/tests/test_subagent_emission.py | 133 +++-- .../tests/test_subagent_emitting_agent.py | 478 ++++++++++++++++++ 8 files changed, 909 insertions(+), 410 deletions(-) delete mode 100644 examples/ag-ui/python/src/streaming/activity_emitting_agent.py delete mode 100644 examples/ag-ui/python/src/streaming/activity_transform.py create mode 100644 examples/ag-ui/python/src/streaming/subagent_emitting_agent.py delete mode 100644 examples/ag-ui/python/tests/test_activity_transform.py create mode 100644 examples/ag-ui/python/tests/test_subagent_emitting_agent.py diff --git a/examples/ag-ui/angular/e2e/subagent-card.spec.ts b/examples/ag-ui/angular/e2e/subagent-card.spec.ts index 33e60206c..1cddcd456 100644 --- a/examples/ag-ui/angular/e2e/subagent-card.spec.ts +++ b/examples/ag-ui/angular/e2e/subagent-card.spec.ts @@ -26,9 +26,9 @@ interface SubagentProbe { // each carrying `toolCallIds`/reasoning) and `toolCalls()` (the child's own // `lookup` calls, rendered as ). We read the projected map // directly rather than scraping the rendered card: it IS the data the card -// renders, and asserting on it proves the ACTIVITY snapshot/delta pipeline -// reconstructed the full reason→tool→answer transcript and settled to -// `complete`, independent of card layout. +// renders, and asserting on it proves the SUBAGENT_* + subagentRunId-attributed +// event stream reconstructed the full reason→tool→answer transcript and +// settled to `complete`, independent of card layout. async function readSubagents(page: Page): Promise { return page.evaluate(() => { const ng = (window as unknown as { ng?: { getComponent?: (el: Element) => unknown } }).ng; @@ -64,10 +64,12 @@ async function readSubagents(page: Page): Promise { // `research` tool, the langgraph child subgraph runs a genuine reason → tool → // answer loop (an LLM call that returns a `lookup` tool_call, the offline // `lookup` tool, then a second plain LLM call that writes the summary), and the -// AG-UI server converts the subagent_activity CUSTOM events into native -// ACTIVITY_SNAPSHOT/ACTIVITY_DELTA. The @threadplane/ag-ui reducer projects the -// activity to agent.subagents() (the ordered transcript chat-subagent-card -// renders) and the child's research text must stay OUT of the parent's bubble. +// AG-UI server's SubagentEmittingAgent expands the subagent_activity CUSTOM +// events into the protocol's SUBAGENT_STARTED/FINISHED plus subagentRunId- +// attributed TEXT_MESSAGE_* / TOOL_CALL_* events. The @threadplane/ag-ui +// reducer projects them to agent.subagents() (the ordered transcript +// chat-subagent-card renders) and the child's research text must stay OUT of +// the parent's bubble. test('research delegation reconstructs the multi-message subagent transcript', async ({ page, }) => { diff --git a/examples/ag-ui/python/src/server.py b/examples/ag-ui/python/src/server.py index 9db4e41e7..2c47b35ab 100644 --- a/examples/ag-ui/python/src/server.py +++ b/examples/ag-ui/python/src/server.py @@ -15,7 +15,7 @@ from ag_ui_langgraph import add_langgraph_fastapi_endpoint from .graph import _builder -from .streaming.activity_emitting_agent import ActivityEmittingAgent +from .streaming.subagent_emitting_agent import SubagentEmittingAgent # The exported graph is checkpointer-free for LangGraph Platform (which manages # persistence). The standalone ag-ui-langgraph endpoint reads graph state via @@ -42,4 +42,8 @@ def ok() -> dict: return {"ok": True} -add_langgraph_fastapi_endpoint(app, ActivityEmittingAgent(name="chat", graph=graph), path="/agent") +# SubagentEmittingAgent expands the research subagent's `subagent_activity` +# CUSTOM events into the protocol's SUBAGENT_* + subagentRunId-attributed +# content events (see streaming/subagent_emitting_agent.py). +agent = SubagentEmittingAgent(name="chat", graph=graph) +add_langgraph_fastapi_endpoint(app, agent, path="/agent") diff --git a/examples/ag-ui/python/src/streaming/activity_emitting_agent.py b/examples/ag-ui/python/src/streaming/activity_emitting_agent.py deleted file mode 100644 index e1b8f6fac..000000000 --- a/examples/ag-ui/python/src/streaming/activity_emitting_agent.py +++ /dev/null @@ -1,11 +0,0 @@ -"""LangGraphAgent subclass that converts subagent_activity CUSTOM events to -native AG-UI ACTIVITY events at the bridge's 1:1 dispatch point. Owned transport -adapter — keeps the wire protocol-native without patching the bridge.""" -from ag_ui_langgraph import LangGraphAgent -from src.streaming.activity_transform import subagent_custom_to_activity - - -class ActivityEmittingAgent(LangGraphAgent): - def _dispatch_event(self, event): - activity = subagent_custom_to_activity(event) - return super()._dispatch_event(activity if activity is not None else event) diff --git a/examples/ag-ui/python/src/streaming/activity_transform.py b/examples/ag-ui/python/src/streaming/activity_transform.py deleted file mode 100644 index e992f8eb8..000000000 --- a/examples/ag-ui/python/src/streaming/activity_transform.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Maps a `subagent_activity` CUSTOM event (emitted by the research tool / -SubagentStreamHandler via adispatch_custom_event) to a native AG-UI ACTIVITY event. - -Pure and stateless (1:1): build patches purely from the event fields — never -track state in the transform. Anything that is not a `subagent_activity` CUSTOM -event returns None. - -Supported phases: started, message_start, message, tool_call, tool_result, -finished. Unknown phases return None. -""" -import json -from typing import Optional - -from ag_ui.core import ActivityDeltaEvent, ActivitySnapshotEvent, BaseEvent, EventType - -ACTIVITY_TYPE = "subagent" -_CUSTOM_NAME = "subagent_activity" - - -def subagent_custom_to_activity(event: BaseEvent) -> Optional[BaseEvent]: - if getattr(event, "type", None) != EventType.CUSTOM: - return None - if getattr(event, "name", None) != _CUSTOM_NAME: - return None - value = getattr(event, "value", None) - if isinstance(value, str): # bridge may JSON-serialize custom values - try: - value = json.loads(value) - except json.JSONDecodeError: - return None - if not isinstance(value, dict): - return None - - sid = value.get("subagent_id") - phase = value.get("phase") - if not sid or not phase: - return None - - if phase == "started": - return ActivitySnapshotEvent( - type=EventType.ACTIVITY_SNAPSHOT, - message_id=sid, - activity_type=ACTIVITY_TYPE, - content={ - "toolCallId": sid, - "name": value.get("name"), - "status": "running", - "messages": [], - "toolCalls": [], - }, - replace=True, - ) - - if phase == "message_start": - message_index = value.get("message_index") - return ActivityDeltaEvent( - type=EventType.ACTIVITY_DELTA, - message_id=sid, - activity_type=ACTIVITY_TYPE, - patch=[ - { - "op": "add", - "path": "/messages/-", - "value": { - "id": f"{sid}-{message_index}", - "role": "assistant", - "content": "", - "toolCallIds": [], - }, - } - ], - ) - - if phase == "message": - message_index = value.get("message_index") - return ActivityDeltaEvent( - type=EventType.ACTIVITY_DELTA, - message_id=sid, - activity_type=ACTIVITY_TYPE, - patch=[ - { - "op": "replace", - "path": f"/messages/{message_index}/content", - "value": value.get("text", ""), - } - ], - ) - - if phase == "tool_call": - message_index = value.get("message_index") - tool_call_id = value.get("tool_call_id") - return ActivityDeltaEvent( - type=EventType.ACTIVITY_DELTA, - message_id=sid, - activity_type=ACTIVITY_TYPE, - patch=[ - { - "op": "add", - "path": "/toolCalls/-", - "value": { - "id": tool_call_id, - "name": value.get("name"), - "args": value.get("args"), - "status": "running", - }, - }, - { - "op": "add", - "path": f"/messages/{message_index}/toolCallIds/-", - "value": tool_call_id, - }, - ], - ) - - if phase == "tool_result": - tool_index = value.get("tool_index") - return ActivityDeltaEvent( - type=EventType.ACTIVITY_DELTA, - message_id=sid, - activity_type=ACTIVITY_TYPE, - patch=[ - { - "op": "replace", - "path": f"/toolCalls/{tool_index}/status", - "value": value.get("status", "complete"), - }, - { - "op": "replace", - "path": f"/toolCalls/{tool_index}/result", - "value": value.get("result"), - }, - ], - ) - - if phase == "finished": - return ActivityDeltaEvent( - type=EventType.ACTIVITY_DELTA, - message_id=sid, - activity_type=ACTIVITY_TYPE, - patch=[{"op": "replace", "path": "/status", "value": value.get("status", "complete")}], - ) - - return None diff --git a/examples/ag-ui/python/src/streaming/subagent_emitting_agent.py b/examples/ag-ui/python/src/streaming/subagent_emitting_agent.py new file mode 100644 index 000000000..14db19bd7 --- /dev/null +++ b/examples/ag-ui/python/src/streaming/subagent_emitting_agent.py @@ -0,0 +1,318 @@ +# SPDX-License-Identifier: MIT +"""SUBAGENT_* emitter for the `research` delegation tool. + +The graph cannot reach the AG-UI wire directly: the research subgraph's nodes, +the `research` tool body and `SubagentStreamHandler` dispatch +`subagent_activity` CUSTOM events through LangChain's +``adispatch_custom_event``, which the ag-ui-langgraph bridge forwards 1:1 as +``CustomEvent`` items in ``LangGraphAgent.run`` (the async generator the +FastAPI endpoint consumes). The standard sequence needs 1:N expansion — a +``tool_call`` phase becomes three ``TOOL_CALL_*`` events, a ``finished`` +phase must close the open child message AND finish the subagent, and the +CUSTOM event itself must be consumed — so the seam is ``run`` rather than the +bridge's strictly one-in/one-out ``_dispatch_event`` hook (measured in +docs/wire-capture-subagents.md). + +Expansion contract (``tid`` = the payload's ``subagent_id`` = the ``research`` +tool call id, identical to the bridge's ``TOOL_CALL_START.toolCallId``): + + started {subagent_id, name} → SUBAGENT_STARTED {subagentRunId: -sub, name, parentToolCallId: } + message_start {subagent_id, message_id} → TEXT_MESSAGE_START {messageId: -sub-m, role: assistant, subagentRunId} + message {subagent_id, message_id, delta} → TEXT_MESSAGE_CONTENT {messageId, delta, subagentRunId} + (next message_start / tool_call / finished / error) → TEXT_MESSAGE_END for any open message first + tool_call {subagent_id, tool_call_id, name, args} → TOOL_CALL_START {toolCallId, toolCallName, parentMessageId: , subagentRunId} + + TOOL_CALL_ARGS {delta: json(args)} + TOOL_CALL_END + tool_result {subagent_id, tool_call_id, content} → TOOL_CALL_RESULT {messageId: -result, toolCallId, content, role: tool, subagentRunId} + finished {subagent_id} → SUBAGENT_FINISHED {subagentRunId, outcome: success} + error {subagent_id, message} → SUBAGENT_ERROR {subagentRunId, message} + +Unknown phases are dropped with a warning; malformed payloads are dropped; +CUSTOM events with any other name pass through untouched. No queue merge is +needed: the CUSTOM events already flow through the bridge generator live, +interleaved with the bridge's own events, so a plain ``for out in +expand(ev): yield out`` preserves streaming. Delegation state is per +``run()`` call (the endpoint clones the agent per request anyway). + +Because the child here is a compiled SUBGRAPH and the bridge streams +subgraphs, the bridge ALSO emits the child's own LLM text and ``lookup`` +tool call as unattributed, bridge-native ``TEXT_MESSAGE_*`` / +``TOOL_CALL_*`` events — the same content the attributed expansion carries, +landing in the parent transcript (wire capture §2b). While a delegation +window is open (``started`` → ``finished`` / ``error``) the parent is blocked +in its tools node, so every unattributed content event in that window is the +child's duplicate; the wrapper drops them (remembering their ids so a +trailing ``TEXT_MESSAGE_END`` / ``TOOL_CALL_END`` after the window is dropped +too). ``STEP_*``, ``STATE_SNAPSHOT``, RAW mirrors and the parent's own +``TOOL_CALL_RESULT`` (which arrives after ``finished``) are untouched. + +The encoder requires pydantic ``BaseEvent`` instances — raw dicts crash the +stream — so only typed ``ag_ui.core`` events are yielded. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator, Iterator + +from ag_ui.core import ( + BaseEvent, + EventType, + SubagentErrorEvent, + SubagentFinishedEvent, + SubagentFinishedSuccessOutcome, + SubagentStartedEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, + ToolCallArgsEvent, + ToolCallEndEvent, + ToolCallResultEvent, + ToolCallStartEvent, +) +from ag_ui_langgraph import LangGraphAgent + +CUSTOM_NAME = "subagent_activity" + +# Bridge-native content events the child subgraph duplicates inside a +# delegation window (see module docstring). +_CHILD_MESSAGE_TYPES = frozenset({ + EventType.TEXT_MESSAGE_START, EventType.TEXT_MESSAGE_CONTENT, EventType.TEXT_MESSAGE_END, +}) +_CHILD_TOOL_TYPES = frozenset({ + EventType.TOOL_CALL_START, EventType.TOOL_CALL_ARGS, EventType.TOOL_CALL_END, +}) + +logger = logging.getLogger(__name__) + + +@dataclass +class _Delegation: + """Lifecycle of one delegation call, keyed by parent tool-call id.""" + + run_id: str + open_message_id: str | None = None + message_count: int = 0 + active: bool = False + + +@dataclass +class _RunState: + """Per-``run()`` expansion state.""" + + delegations: dict[str, _Delegation] = field(default_factory=dict) + # Ids of bridge-native child messages / tool calls dropped inside a window, + # so their trailing END events are dropped after the window closes too. + dropped_message_ids: set[str] = field(default_factory=set) + dropped_tool_call_ids: set[str] = field(default_factory=set) + + def in_window(self) -> bool: + return any(d.active for d in self.delegations.values()) + + +def _subagent_run_id(tid: str) -> str: + return f"{tid}-sub" + + +def _message_id(tid: str, n: int) -> str: + return f"{tid}-sub-m{n}" + + +def _as_text(content: Any) -> str: + if isinstance(content, str): + return content + if content is None: + return "" + try: + return json.dumps(content) + except (TypeError, ValueError): + return str(content) + + +def _payload(event: BaseEvent) -> dict[str, Any] | None: + """Return the `subagent_activity` payload dict, or None if `event` is not + one (or is malformed).""" + if getattr(event, "type", None) != EventType.CUSTOM: + return None + if getattr(event, "name", None) != CUSTOM_NAME: + return None + value = getattr(event, "value", None) + if isinstance(value, str): # the bridge may JSON-serialize custom values + try: + value = json.loads(value) + except json.JSONDecodeError: + logger.warning("subagent_activity payload is not JSON; dropped") + return {} + if not isinstance(value, dict): + logger.warning("subagent_activity payload is not an object; dropped") + return {} + return value + + +class SubagentEmittingAgent(LangGraphAgent): + """LangGraphAgent whose ``run`` expands the graph's `subagent_activity` + CUSTOM events into standard SUBAGENT_* + attributed TEXT_MESSAGE_* / + TOOL_CALL_* events, and drops the bridge's unattributed duplicates of the + child subgraph's stream. + + Keeps the bridge's ``__init__`` signature so ``clone()`` (called by the + FastAPI endpoint per request) reconstructs this subclass. + """ + + async def run(self, *args: Any, **kwargs: Any) -> AsyncGenerator[BaseEvent, None]: + state = _RunState() + async for event in super().run(*args, **kwargs): + for out in self._expand(event, state): + yield out + + def _expand(self, event: BaseEvent, state: _RunState) -> Iterator[BaseEvent]: + payload = _payload(event) + if payload is None: + if not self._is_child_duplicate(event, state): + yield event + return + if not payload: + return # malformed — already logged + tid = payload.get("subagent_id") + phase = payload.get("phase") + if not isinstance(tid, str) or not tid or not isinstance(phase, str): + logger.warning("subagent_activity missing subagent_id/phase; dropped: %r", payload) + return + + delegation = state.delegations.get(tid) + if delegation is None: + delegation = _Delegation(run_id=_subagent_run_id(tid)) + state.delegations[tid] = delegation + run_id = delegation.run_id + + if phase == "started": + delegation.active = True + yield SubagentStartedEvent( + type=EventType.SUBAGENT_STARTED, + subagent_run_id=run_id, + name=str(payload.get("name") or tid), + parent_tool_call_id=tid, + ) + elif phase == "message_start": + yield from self._close_message(delegation) + yield from self._open_message(delegation, tid, payload.get("message_id")) + elif phase == "message": + delta = payload.get("delta") + if not isinstance(delta, str) or not delta: + return + if delegation.open_message_id is None: + yield from self._open_message(delegation, tid, payload.get("message_id")) + yield TextMessageContentEvent( + type=EventType.TEXT_MESSAGE_CONTENT, + message_id=delegation.open_message_id, + delta=delta, + subagent_run_id=run_id, + ) + elif phase == "tool_call": + tool_call_id = payload.get("tool_call_id") + if not isinstance(tool_call_id, str) or not tool_call_id: + logger.warning("subagent_activity tool_call missing tool_call_id; dropped: %r", payload) + return + parent_message_id = delegation.open_message_id + yield from self._close_message(delegation) + yield ToolCallStartEvent( + type=EventType.TOOL_CALL_START, + tool_call_id=tool_call_id, + tool_call_name=str(payload.get("name") or tool_call_id), + parent_message_id=parent_message_id, + subagent_run_id=run_id, + ) + args = payload.get("args") + yield ToolCallArgsEvent( + type=EventType.TOOL_CALL_ARGS, + tool_call_id=tool_call_id, + delta=_as_text(args if args is not None else {}), + subagent_run_id=run_id, + ) + yield ToolCallEndEvent( + type=EventType.TOOL_CALL_END, + tool_call_id=tool_call_id, + subagent_run_id=run_id, + ) + elif phase == "tool_result": + tool_call_id = payload.get("tool_call_id") + if not isinstance(tool_call_id, str) or not tool_call_id: + logger.warning("subagent_activity tool_result missing tool_call_id; dropped: %r", payload) + return + yield ToolCallResultEvent( + type=EventType.TOOL_CALL_RESULT, + message_id=f"{tool_call_id}-result", + tool_call_id=tool_call_id, + content=_as_text(payload.get("content")), + role="tool", + subagent_run_id=run_id, + ) + elif phase == "finished": + yield from self._close_message(delegation) + delegation.active = False + yield SubagentFinishedEvent( + type=EventType.SUBAGENT_FINISHED, + subagent_run_id=run_id, + outcome=SubagentFinishedSuccessOutcome(), + ) + elif phase == "error": + yield from self._close_message(delegation) + delegation.active = False + yield SubagentErrorEvent( + type=EventType.SUBAGENT_ERROR, + subagent_run_id=run_id, + message=str(payload.get("message") or "subagent failed"), + ) + else: + logger.warning("subagent_activity phase %r not supported; dropped", phase) + + @staticmethod + def _is_child_duplicate(event: BaseEvent, state: _RunState) -> bool: + """True for a bridge-native, unattributed content event that duplicates + the child subgraph's stream (inside a delegation window, or a trailing + END for an id dropped inside one).""" + event_type = getattr(event, "type", None) + if getattr(event, "subagent_run_id", None): + return False # already attributed — someone else's business + if event_type in _CHILD_MESSAGE_TYPES: + message_id = getattr(event, "message_id", None) + if state.in_window(): + if message_id: + state.dropped_message_ids.add(message_id) + return True + return message_id in state.dropped_message_ids + if event_type in _CHILD_TOOL_TYPES: + tool_call_id = getattr(event, "tool_call_id", None) + if state.in_window(): + if tool_call_id: + state.dropped_tool_call_ids.add(tool_call_id) + return True + return tool_call_id in state.dropped_tool_call_ids + return False + + @staticmethod + def _open_message( + delegation: _Delegation, tid: str, message_id: Any + ) -> Iterator[BaseEvent]: + delegation.message_count += 1 + if not isinstance(message_id, str) or not message_id: + message_id = _message_id(tid, delegation.message_count) + delegation.open_message_id = message_id + yield TextMessageStartEvent( + type=EventType.TEXT_MESSAGE_START, + message_id=message_id, + role="assistant", + subagent_run_id=delegation.run_id, + ) + + @staticmethod + def _close_message(delegation: _Delegation) -> Iterator[BaseEvent]: + if delegation.open_message_id is None: + return + message_id, delegation.open_message_id = delegation.open_message_id, None + yield TextMessageEndEvent( + type=EventType.TEXT_MESSAGE_END, + message_id=message_id, + subagent_run_id=delegation.run_id, + ) diff --git a/examples/ag-ui/python/tests/test_activity_transform.py b/examples/ag-ui/python/tests/test_activity_transform.py deleted file mode 100644 index 24c4baf5b..000000000 --- a/examples/ag-ui/python/tests/test_activity_transform.py +++ /dev/null @@ -1,212 +0,0 @@ -"""Tests for subagent_custom_to_activity — maps a `subagent_activity` CUSTOM -event to a native ACTIVITY event (1:1, stateless). Non-subagent events → None.""" -from ag_ui.core import CustomEvent, EventType, TextMessageStartEvent -from src.streaming.activity_transform import subagent_custom_to_activity - - -def _custom(data: dict) -> CustomEvent: - return CustomEvent(type=EventType.CUSTOM, name="subagent_activity", value=data) - - -def test_started_maps_to_activity_snapshot(): - ev = subagent_custom_to_activity(_custom( - {"subagent_id": "tc-1", "phase": "started", "name": "research"})) - assert ev.type == EventType.ACTIVITY_SNAPSHOT - assert ev.message_id == "tc-1" - assert ev.activity_type == "subagent" - assert ev.content == { - "toolCallId": "tc-1", - "name": "research", - "status": "running", - "messages": [], - "toolCalls": [], - } - assert ev.replace is True - - -def test_message_start_maps_to_activity_delta_add_message(): - ev = subagent_custom_to_activity(_custom( - {"subagent_id": "tc-1", "phase": "message_start", "message_index": 0})) - assert ev.type == EventType.ACTIVITY_DELTA - assert ev.message_id == "tc-1" - assert ev.patch == [ - { - "op": "add", - "path": "/messages/-", - "value": {"id": "tc-1-0", "role": "assistant", "content": "", "toolCallIds": []}, - } - ] - - -def test_message_start_uses_message_index_in_id(): - ev = subagent_custom_to_activity(_custom( - {"subagent_id": "tc-1", "phase": "message_start", "message_index": 2})) - assert ev.patch[0]["value"]["id"] == "tc-1-2" - - -def test_message_maps_to_activity_delta_replace_content(): - ev = subagent_custom_to_activity(_custom( - {"subagent_id": "tc-1", "phase": "message", "message_index": 0, "text": "Paris is"})) - assert ev.type == EventType.ACTIVITY_DELTA - assert ev.message_id == "tc-1" - assert ev.patch == [{"op": "replace", "path": "/messages/0/content", "value": "Paris is"}] - - -def test_message_uses_correct_index_in_path(): - ev = subagent_custom_to_activity(_custom( - {"subagent_id": "tc-1", "phase": "message", "message_index": 3, "text": "hello"})) - assert ev.patch == [{"op": "replace", "path": "/messages/3/content", "value": "hello"}] - - -def test_tool_call_maps_to_two_op_patch(): - ev = subagent_custom_to_activity(_custom({ - "subagent_id": "tc-1", - "phase": "tool_call", - "message_index": 0, - "tool_call_id": "call-abc", - "name": "search", - "args": {"query": "Paris"}, - })) - assert ev.type == EventType.ACTIVITY_DELTA - assert ev.message_id == "tc-1" - assert ev.patch == [ - { - "op": "add", - "path": "/toolCalls/-", - "value": {"id": "call-abc", "name": "search", "args": {"query": "Paris"}, "status": "running"}, - }, - { - "op": "add", - "path": "/messages/0/toolCallIds/-", - "value": "call-abc", - }, - ] - - -def test_tool_call_uses_message_index_in_path(): - ev = subagent_custom_to_activity(_custom({ - "subagent_id": "tc-1", - "phase": "tool_call", - "message_index": 2, - "tool_call_id": "call-xyz", - "name": "lookup", - "args": {}, - })) - assert ev.patch[1]["path"] == "/messages/2/toolCallIds/-" - - -def test_tool_result_maps_to_two_op_patch(): - ev = subagent_custom_to_activity(_custom({ - "subagent_id": "tc-1", - "phase": "tool_result", - "tool_index": 0, - "result": "Paris, France", - "status": "complete", - })) - assert ev.type == EventType.ACTIVITY_DELTA - assert ev.message_id == "tc-1" - assert ev.patch == [ - {"op": "replace", "path": "/toolCalls/0/status", "value": "complete"}, - {"op": "replace", "path": "/toolCalls/0/result", "value": "Paris, France"}, - ] - - -def test_tool_result_defaults_status_to_complete(): - ev = subagent_custom_to_activity(_custom({ - "subagent_id": "tc-1", - "phase": "tool_result", - "tool_index": 1, - "result": "some result", - })) - assert ev.patch[0] == {"op": "replace", "path": "/toolCalls/1/status", "value": "complete"} - - -def test_tool_result_uses_tool_index_in_path(): - ev = subagent_custom_to_activity(_custom({ - "subagent_id": "tc-1", - "phase": "tool_result", - "tool_index": 3, - "result": "done", - })) - assert ev.patch[0]["path"] == "/toolCalls/3/status" - assert ev.patch[1]["path"] == "/toolCalls/3/result" - - -def test_finished_maps_to_activity_delta_replace_status(): - ev = subagent_custom_to_activity(_custom( - {"subagent_id": "tc-1", "phase": "finished", "status": "complete"})) - assert ev.type == EventType.ACTIVITY_DELTA - assert ev.patch == [{"op": "replace", "path": "/status", "value": "complete"}] - - -def test_finished_defaults_status_to_complete(): - ev = subagent_custom_to_activity(_custom( - {"subagent_id": "tc-1", "phase": "finished"})) - assert ev.patch == [{"op": "replace", "path": "/status", "value": "complete"}] - - -def test_finished_custom_status(): - ev = subagent_custom_to_activity(_custom( - {"subagent_id": "tc-1", "phase": "finished", "status": "error"})) - assert ev.patch == [{"op": "replace", "path": "/status", "value": "error"}] - - -def test_unknown_phase_returns_none(): - assert subagent_custom_to_activity(_custom( - {"subagent_id": "tc-1", "phase": "unknown_phase"})) is None - - -def test_non_subagent_event_returns_none(): - assert subagent_custom_to_activity( - CustomEvent(type=EventType.CUSTOM, name="state_update", value={})) is None - assert subagent_custom_to_activity( - TextMessageStartEvent(type=EventType.TEXT_MESSAGE_START, message_id="m", role="assistant")) is None - - -def test_malformed_json_string_value_returns_none(): - ev = CustomEvent(type=EventType.CUSTOM, name="subagent_activity", value="not json {{{") - assert subagent_custom_to_activity(ev) is None - - -import pytest -from langgraph.graph import StateGraph, END -from langchain_core.callbacks.manager import adispatch_custom_event -from langgraph.checkpoint.memory import MemorySaver -from typing_extensions import TypedDict -from ag_ui.core import RunAgentInput -from src.streaming.activity_emitting_agent import ActivityEmittingAgent - - -class _S(TypedDict): - messages: list - - -# Emit via adispatch_custom_event (the LangChain callback API) — the SPIKE found -# that a plain get_stream_writer() payload surfaces only as an on_chain_stream -# RAW event in this bridge/LangGraph version and never becomes a discrete CUSTOM -# event at _dispatch_event, whereas adispatch_custom_event does. Layer 3's -# SubagentStreamHandler must use this same mechanism. -async def _emit_node(state: _S) -> dict: - await adispatch_custom_event( - "subagent_activity", - {"subagent_id": "tc-1", "phase": "started", "name": "research"}) - return {"messages": []} - - -def _tiny_graph(): - g = StateGraph(_S) - g.add_node("emit", _emit_node) - g.set_entry_point("emit") - g.add_edge("emit", END) - return g.compile(checkpointer=MemorySaver()) - - -@pytest.mark.asyncio -async def test_dispatch_event_seam_converts_custom_to_activity(): - agent = ActivityEmittingAgent(name="t", graph=_tiny_graph()) - run_input = RunAgentInput(thread_id="th", run_id="r", messages=[], - tools=[], context=[], state={}, forwarded_props={}) - types = [getattr(ev, "type", None) async for ev in agent.run(run_input)] - assert EventType.ACTIVITY_SNAPSHOT in types - assert EventType.CUSTOM not in [t for t in types] - assert isinstance(agent.clone(), ActivityEmittingAgent) diff --git a/examples/ag-ui/python/tests/test_subagent_emission.py b/examples/ag-ui/python/tests/test_subagent_emission.py index 1ba507c73..ecdbb0235 100644 --- a/examples/ag-ui/python/tests/test_subagent_emission.py +++ b/examples/ag-ui/python/tests/test_subagent_emission.py @@ -1,28 +1,41 @@ """In-process verification of the research subagent's reason → tool → answer -loop and its structured `subagent_activity` transcript emission. +loop and the standard SUBAGENT_* sequence it produces on the wire. Runs the enriched subgraph with a FAKE tool-calling chat model (no network): turn 0 returns an AIMessage carrying a `lookup` tool_call; turn 1 returns the -final answer. We intercept every `adispatch_custom_event` the subgraph nodes -fire and assert the ORDER + payloads of the structured phases: - - message_start(0) → tool_call(0, …) → tool_result(0, …) - → message_start(1) → finished-by-tool? no → final answer - -(Live `message` token events come from SubagentStreamHandler, which the fake -model doesn't drive — so this test asserts the node-emitted phases only, which -is exactly the transcript skeleton the L2 transform consumes.) +final answer. We intercept every `subagent_activity` payload the subgraph +nodes dispatch, bracket them with the `research` tool body's `started` / +`finished`, and push them through `SubagentEmittingAgent` exactly as the +bridge would (as CUSTOM events in `LangGraphAgent.run`) — asserting the +ORDER + fields of the standard events: + + SUBAGENT_STARTED → TEXT_MESSAGE_START(m1) → TEXT_MESSAGE_END(m1) + → TOOL_CALL_START/ARGS/END(lookup, parent m1) → TOOL_CALL_RESULT(lookup) + → TEXT_MESSAGE_START(m2) → TEXT_MESSAGE_END(m2) → SUBAGENT_FINISHED + +(Live `message` deltas come from SubagentStreamHandler, which the fake model +does not drive — so the answer turn has no TEXT_MESSAGE_CONTENT here; the +handler's own tests cover the per-token deltas.) """ +import json from typing import Any import pytest +from ag_ui.core import CustomEvent, EventType, RunAgentInput +from ag_ui_langgraph import LangGraphAgent from langchain_core.messages import AIMessage from langchain_core.runnables import Runnable +from langgraph.graph import END, MessagesState, StateGraph import src.graph as graph_mod from src.graph import _build_research_subgraph +from src.streaming.subagent_emitting_agent import SubagentEmittingAgent from src.streaming.subagent_stream_handler import SubagentRunState +TID = "tc-research" +RUN_ID = f"{TID}-sub" +M1, M2 = f"{TID}-sub-m1", f"{TID}-sub-m2" + class _FakeToolCallingModel(Runnable): """A tiny Runnable standing in for ChatOpenAI. First invocation returns an @@ -55,48 +68,61 @@ async def ainvoke(self, input: Any, config: Any = None, **kwargs: Any) -> AIMess return self.invoke(input, config, **kwargs) -@pytest.mark.asyncio -async def test_subgraph_emits_reason_tool_answer_transcript(): - events: list[dict] = [] +def _noop_graph(): + g = StateGraph(MessagesState) + g.add_node("noop", lambda state: {}) + g.set_entry_point("noop") + g.add_edge("noop", END) + return g.compile() + + +async def _run_subgraph_and_collect_payloads() -> tuple[list[dict], dict, _FakeToolCallingModel]: + payloads: list[dict] = [] async def fake_emit(payload: dict) -> None: - events.append({"subagent_id": "tc-research", **payload}) + payloads.append({"subagent_id": TID, **payload}) fake_model = _FakeToolCallingModel() - run_state = SubagentRunState("tc-research") subgraph = _build_research_subgraph( - fake_emit, run_state, llm_factory=lambda force_answer: fake_model + fake_emit, SubagentRunState(TID), llm_factory=lambda force_answer: fake_model ) + result = await subgraph.ainvoke({"topic": "Angular signals", "messages": [], "iterations": 0}) + return payloads, result, fake_model - result = await subgraph.ainvoke( - {"topic": "Angular signals", "messages": [], "iterations": 0} - ) - phases = [(e["phase"], e) for e in events] - phase_names = [p for p, _ in phases] +async def _expand(monkeypatch, payloads: list[dict]) -> list: + async def fake_run(self, input): + for p in payloads: + yield CustomEvent(type=EventType.CUSTOM, name="subagent_activity", value=p) + + monkeypatch.setattr(LangGraphAgent, "run", fake_run) + agent = SubagentEmittingAgent(name="chat", graph=_noop_graph()) + run_input = RunAgentInput( + thread_id="t", run_id="r", messages=[], tools=[], context=[], state={}, forwarded_props={} + ) + return [ev async for ev in agent.run(run_input)] - # Full structured phase sequence the node loop emits. - assert phase_names == [ - "message_start", # turn 0 opens - "tool_call", # turn 0 calls lookup - "tool_result", # tool node runs lookup - "message_start", # turn 1 opens (forced-answer turn) - ], phase_names - by_phase = {p: e for p, e in phases} +@pytest.mark.asyncio +async def test_subgraph_emits_reason_tool_answer_phases(): + payloads, result, fake_model = await _run_subgraph_and_collect_payloads() - # message_start ids follow -sub-m: m1 then m2. - starts = [e["message_id"] for p, e in phases if p == "message_start"] - assert starts == ["tc-research-sub-m1", "tc-research-sub-m2"], starts + # Full structured phase sequence the node loop dispatches. + assert [p["phase"] for p in payloads] == [ + "message_start", # turn 1 opens + "tool_call", # turn 1 calls lookup + "tool_result", # tool node runs lookup + "message_start", # turn 2 opens (forced-answer turn) + ] + by_phase = {p["phase"]: p for p in payloads} + assert [p["message_id"] for p in payloads if p["phase"] == "message_start"] == [M1, M2] - # tool_call carries id/name/args + the originating message id (m1). tc = by_phase["tool_call"] - assert tc["message_id"] == "tc-research-sub-m1" + assert tc["message_id"] == M1 assert tc["tool_call_id"] == "call_lookup_1" assert tc["name"] == "lookup" assert tc["args"] == {"query": "angular signals"} - # tool_result carries the matching tool_call_id and the lookup result text. tr = by_phase["tool_result"] assert tr["tool_call_id"] == "call_lookup_1" assert isinstance(tr["content"], str) and "signal" in tr["content"].lower() @@ -110,6 +136,43 @@ async def fake_emit(payload: dict) -> None: assert "Signals" in last.content +@pytest.mark.asyncio +async def test_subgraph_run_expands_to_the_standard_subagent_sequence(monkeypatch): + payloads, _, _ = await _run_subgraph_and_collect_payloads() + # Bracket with what the `research` tool body dispatches around ainvoke. + script = [ + {"subagent_id": TID, "phase": "started", "name": "research"}, + *payloads, + {"subagent_id": TID, "phase": "finished", "status": "complete"}, + ] + out = await _expand(monkeypatch, script) + + assert [(ev.type, getattr(ev, "message_id", None) or getattr(ev, "tool_call_id", None)) for ev in out] == [ + (EventType.SUBAGENT_STARTED, None), + (EventType.TEXT_MESSAGE_START, M1), + (EventType.TEXT_MESSAGE_END, M1), + (EventType.TOOL_CALL_START, "call_lookup_1"), + (EventType.TOOL_CALL_ARGS, "call_lookup_1"), + (EventType.TOOL_CALL_END, "call_lookup_1"), + (EventType.TOOL_CALL_RESULT, "call_lookup_1-result"), + (EventType.TEXT_MESSAGE_START, M2), + (EventType.TEXT_MESSAGE_END, M2), + (EventType.SUBAGENT_FINISHED, None), + ] + assert not any(ev.type == EventType.CUSTOM for ev in out) + assert all(ev.subagent_run_id == RUN_ID for ev in out) + + assert out[0].name == "research" + assert out[0].parent_tool_call_id == TID + assert out[3].tool_call_name == "lookup" + assert out[3].parent_message_id == M1 + assert json.loads(out[4].delta) == {"query": "angular signals"} + assert out[6].tool_call_id == "call_lookup_1" + assert out[6].role == "tool" + assert "signal" in out[6].content.lower() + assert out[9].outcome.type == "success" + + @pytest.mark.asyncio async def test_lookup_tool_is_deterministic_and_offline(): # The canned fact lookup must be reproducible for the aimock fixture. diff --git a/examples/ag-ui/python/tests/test_subagent_emitting_agent.py b/examples/ag-ui/python/tests/test_subagent_emitting_agent.py new file mode 100644 index 000000000..3ea7bd7ff --- /dev/null +++ b/examples/ag-ui/python/tests/test_subagent_emitting_agent.py @@ -0,0 +1,478 @@ +"""Tests for SubagentEmittingAgent — the run-wrapping emitter that expands the +graph's `subagent_activity` CUSTOM events (started / message_start / message / +tool_call / tool_result / finished / error) into the protocol's standard +SUBAGENT_* + attributed TEXT_MESSAGE_* / TOOL_CALL_* events. Drives the +wrapper with a scripted inner `LangGraphAgent.run` generator and asserts the +exact output sequence field-for-field.""" +import json +import logging +from typing import Any + +import pytest +from ag_ui.core import ( + CustomEvent, + EventType, + RunAgentInput, + RunFinishedEvent, + RunStartedEvent, + StepStartedEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, + ToolCallArgsEvent, + ToolCallEndEvent, + ToolCallResultEvent, + ToolCallStartEvent, +) +from ag_ui_langgraph import LangGraphAgent +from langgraph.graph import END, MessagesState, StateGraph + +from src.streaming.subagent_emitting_agent import SubagentEmittingAgent + +TID = "call_1" +TID2 = "call_2" +RUN_ID = f"{TID}-sub" +M1 = f"{TID}-sub-m1" +M2 = f"{TID}-sub-m2" +LOOKUP = "call_lookup_1" +LOOKUP_ARGS = {"query": "angular signals"} +LOOKUP_RESULT = "Angular signals are a reactivity primitive." + + +def _graph(): + g = StateGraph(MessagesState) + g.add_node("noop", lambda state: {}) + g.set_entry_point("noop") + g.add_edge("noop", END) + return g.compile() + + +def _input() -> RunAgentInput: + return RunAgentInput( + thread_id="t", run_id="r", messages=[], tools=[], context=[], state={}, forwarded_props={} + ) + + +def _activity(payload: dict[str, Any]) -> CustomEvent: + return CustomEvent(type=EventType.CUSTOM, name="subagent_activity", value=payload) + + +def _run_started(): + return RunStartedEvent(type=EventType.RUN_STARTED, thread_id="t", run_id="r") + + +def _run_finished(): + return RunFinishedEvent(type=EventType.RUN_FINISHED, thread_id="t", run_id="r") + + +def _tool_call(tid: str, name: str = "research", args: str = '{"topic":"signals"}'): + return [ + ToolCallStartEvent(type=EventType.TOOL_CALL_START, tool_call_id=tid, tool_call_name=name), + ToolCallArgsEvent(type=EventType.TOOL_CALL_ARGS, tool_call_id=tid, delta=args), + ToolCallEndEvent(type=EventType.TOOL_CALL_END, tool_call_id=tid), + ] + + +def _tool_result(tid: str, content: str): + return ToolCallResultEvent( + type=EventType.TOOL_CALL_RESULT, message_id=f"{tid}-result", tool_call_id=tid, content=content + ) + + +def _text(message_id: str, delta: str): + return [ + TextMessageStartEvent(type=EventType.TEXT_MESSAGE_START, message_id=message_id, role="assistant"), + TextMessageContentEvent(type=EventType.TEXT_MESSAGE_CONTENT, message_id=message_id, delta=delta), + TextMessageEndEvent(type=EventType.TEXT_MESSAGE_END, message_id=message_id), + ] + + +def _delegation(tid: str = TID, name: str = "research") -> list: + """The research subgraph's phase sequence for one reason → tool → answer + loop, as the graph dispatches it (see test_subagent_emission.py).""" + m1, m2 = f"{tid}-sub-m1", f"{tid}-sub-m2" + return [ + _activity({"subagent_id": tid, "phase": "started", "name": name}), + _activity({"subagent_id": tid, "phase": "message_start", "message_id": m1}), + _activity({"subagent_id": tid, "phase": "tool_call", "message_id": m1, + "tool_call_id": LOOKUP, "name": "lookup", "args": LOOKUP_ARGS}), + _activity({"subagent_id": tid, "phase": "tool_result", + "tool_call_id": LOOKUP, "content": LOOKUP_RESULT}), + _activity({"subagent_id": tid, "phase": "message_start", "message_id": m2}), + _activity({"subagent_id": tid, "phase": "message", "message_id": m2, "delta": "- Signals"}), + _activity({"subagent_id": tid, "phase": "message", "message_id": m2, "delta": " are reactive."}), + _activity({"subagent_id": tid, "phase": "finished", "status": "complete"}), + ] + + +async def _collect(monkeypatch, script: list) -> list: + async def fake_run(self, input): + for ev in script: + yield ev + + monkeypatch.setattr(LangGraphAgent, "run", fake_run) + agent = SubagentEmittingAgent(name="chat", graph=_graph()) + return [ev async for ev in agent.run(_input())] + + +async def test_expands_the_reason_tool_answer_loop_field_for_field(monkeypatch): + script = [_run_started(), *_tool_call(TID), *_delegation(), _tool_result(TID, "- Signals are reactive."), _run_finished()] + out = await _collect(monkeypatch, script) + + assert [ev.type for ev in out] == [ + EventType.RUN_STARTED, + EventType.TOOL_CALL_START, + EventType.TOOL_CALL_ARGS, + EventType.TOOL_CALL_END, + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, # m1 — the tool-calling turn + EventType.TEXT_MESSAGE_END, # closed before the child's tool call + EventType.TOOL_CALL_START, # lookup (attributed) + EventType.TOOL_CALL_ARGS, + EventType.TOOL_CALL_END, + EventType.TOOL_CALL_RESULT, # lookup result (attributed) + EventType.TEXT_MESSAGE_START, # m2 — the answer turn + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_FINISHED, + EventType.TOOL_CALL_RESULT, # the parent's research result (bridge-native) + EventType.RUN_FINISHED, + ] + # The CUSTOM subagent_activity events are consumed, never forwarded. + assert not any(ev.type == EventType.CUSTOM for ev in out) + + started = out[4] + assert started.subagent_run_id == RUN_ID + assert started.name == "research" + assert started.parent_tool_call_id == TID + + m1_start, m1_end = out[5], out[6] + assert (m1_start.message_id, m1_start.role, m1_start.subagent_run_id) == (M1, "assistant", RUN_ID) + assert (m1_end.message_id, m1_end.subagent_run_id) == (M1, RUN_ID) + + tc_start, tc_args, tc_end, tc_result = out[7:11] + assert tc_start.tool_call_id == LOOKUP + assert tc_start.tool_call_name == "lookup" + assert tc_start.parent_message_id == M1 + assert tc_start.subagent_run_id == RUN_ID + assert tc_args.tool_call_id == LOOKUP + assert json.loads(tc_args.delta) == LOOKUP_ARGS + assert tc_args.subagent_run_id == RUN_ID + assert tc_end.tool_call_id == LOOKUP + assert tc_end.subagent_run_id == RUN_ID + assert tc_result.tool_call_id == LOOKUP + assert tc_result.message_id == f"{LOOKUP}-result" + assert tc_result.content == LOOKUP_RESULT + assert tc_result.role == "tool" + assert tc_result.subagent_run_id == RUN_ID + + m2_start = out[11] + assert (m2_start.message_id, m2_start.role, m2_start.subagent_run_id) == (M2, "assistant", RUN_ID) + deltas = out[12:14] + assert [ev.delta for ev in deltas] == ["- Signals", " are reactive."] + for ev in deltas: + assert ev.message_id == M2 + assert ev.subagent_run_id == RUN_ID + m2_end = out[14] + assert (m2_end.message_id, m2_end.subagent_run_id) == (M2, RUN_ID) + + finished = out[15] + assert finished.subagent_run_id == RUN_ID + assert finished.outcome.type == "success" + + # Bridge-native events pass through untouched (same objects, unattributed). + assert out[1] is script[1] + assert out[16] is script[12] + assert out[16].subagent_run_id is None + + +async def test_bridge_native_child_stream_is_dropped_inside_the_delegation_window(monkeypatch): + # ag-ui-langgraph streams the child SUBGRAPH's own LLM/tool events as + # unattributed bridge-native events while the research tool runs (measured + # in docs/wire-capture-subagents.md §2b). Inside the delegation window the + # parent is blocked in its tools node, so every unattributed content event + # is the child's duplicate of what the attributed expansion already + # carries — drop them, including a trailing END for a dropped id. + child_text_id = "lc_run--child" + script = [ + _run_started(), + *_tool_call(TID), + _activity({"subagent_id": TID, "phase": "started", "name": "research"}), + _activity({"subagent_id": TID, "phase": "message_start", "message_id": M1}), + *_tool_call(LOOKUP, name="lookup", args=json.dumps(LOOKUP_ARGS)), # bridge-native copy of the child's call + _activity({"subagent_id": TID, "phase": "tool_call", "message_id": M1, + "tool_call_id": LOOKUP, "name": "lookup", "args": LOOKUP_ARGS}), + StepStartedEvent(type=EventType.STEP_STARTED, step_name="tools"), # child node steps pass through + _activity({"subagent_id": TID, "phase": "tool_result", "tool_call_id": LOOKUP, "content": LOOKUP_RESULT}), + _activity({"subagent_id": TID, "phase": "message_start", "message_id": M2}), + *_text(child_text_id, "- Signals")[:2], # bridge-native copy of the child's answer + _activity({"subagent_id": TID, "phase": "message", "message_id": M2, "delta": "- Signals"}), + _activity({"subagent_id": TID, "phase": "finished"}), + _text(child_text_id, "")[2], # trailing END for the dropped id + _tool_result(TID, "- Signals"), + *_text("lc_run--parent", "Here is what the subagent found."), # the orchestrator's own answer + _run_finished(), + ] + out = await _collect(monkeypatch, script) + assert [(ev.type, getattr(ev, "subagent_run_id", None)) for ev in out] == [ + (EventType.RUN_STARTED, None), + (EventType.TOOL_CALL_START, None), + (EventType.TOOL_CALL_ARGS, None), + (EventType.TOOL_CALL_END, None), + (EventType.SUBAGENT_STARTED, RUN_ID), + (EventType.TEXT_MESSAGE_START, RUN_ID), + (EventType.TEXT_MESSAGE_END, RUN_ID), + (EventType.TOOL_CALL_START, RUN_ID), + (EventType.TOOL_CALL_ARGS, RUN_ID), + (EventType.TOOL_CALL_END, RUN_ID), + (EventType.STEP_STARTED, None), + (EventType.TOOL_CALL_RESULT, RUN_ID), + (EventType.TEXT_MESSAGE_START, RUN_ID), + (EventType.TEXT_MESSAGE_CONTENT, RUN_ID), + (EventType.TEXT_MESSAGE_END, RUN_ID), + (EventType.SUBAGENT_FINISHED, RUN_ID), + (EventType.TOOL_CALL_RESULT, None), + (EventType.TEXT_MESSAGE_START, None), + (EventType.TEXT_MESSAGE_CONTENT, None), + (EventType.TEXT_MESSAGE_END, None), + (EventType.RUN_FINISHED, None), + ] + # Exactly one lookup call on the wire, and it is the attributed one. + lookups = [ev for ev in out if ev.type == EventType.TOOL_CALL_START and ev.tool_call_id == LOOKUP] + assert len(lookups) == 1 and lookups[0].subagent_run_id == RUN_ID + assert not any(getattr(ev, "message_id", None) == child_text_id for ev in out) + # The orchestrator's answer after the window is untouched. + assert out[17] is script[-4] + + +async def test_outside_a_delegation_window_nothing_is_dropped(monkeypatch): + script = [_run_started(), *_tool_call("call_search", name="search_documents"), + _tool_result("call_search", "[]"), *_text("lc_run--parent", "hi"), _run_finished()] + out = await _collect(monkeypatch, script) + assert out == script + + +async def test_serialized_custom_value_is_decoded(monkeypatch): + # The bridge may JSON-serialize custom values; the expansion must cope. + script = [ + CustomEvent(type=EventType.CUSTOM, name="subagent_activity", + value='{"subagent_id": "call_1", "phase": "started", "name": "research"}'), + _activity({"subagent_id": TID, "phase": "finished"}), + ] + out = await _collect(monkeypatch, script) + assert [ev.type for ev in out] == [EventType.SUBAGENT_STARTED, EventType.SUBAGENT_FINISHED] + assert out[0].name == "research" + + +async def test_no_deltas_still_brackets_with_started_and_finished(monkeypatch): + script = [ + _activity({"subagent_id": TID, "phase": "started", "name": "research"}), + _activity({"subagent_id": TID, "phase": "finished"}), + ] + out = await _collect(monkeypatch, script) + assert [ev.type for ev in out] == [EventType.SUBAGENT_STARTED, EventType.SUBAGENT_FINISHED] + + +async def test_unrelated_custom_event_passes_through_untouched(monkeypatch): + other = CustomEvent(type=EventType.CUSTOM, name="PredictState", value={"x": 1}) + out = await _collect(monkeypatch, [_run_started(), other, _run_finished()]) + assert [ev.type for ev in out] == [EventType.RUN_STARTED, EventType.CUSTOM, EventType.RUN_FINISHED] + assert out[1] is other + + +async def test_error_closes_open_message_then_reports(monkeypatch): + script = [ + _activity({"subagent_id": TID, "phase": "started", "name": "research"}), + _activity({"subagent_id": TID, "phase": "message_start", "message_id": M1}), + _activity({"subagent_id": TID, "phase": "message", "message_id": M1, "delta": "Par"}), + _activity({"subagent_id": TID, "phase": "error", "message": "RuntimeError: child exploded"}), + ] + out = await _collect(monkeypatch, script) + assert [ev.type for ev in out] == [ + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, # open message is closed before the error + EventType.SUBAGENT_ERROR, + ] + assert out[3].message_id == M1 + assert out[3].subagent_run_id == RUN_ID + err = out[4] + assert err.subagent_run_id == RUN_ID + assert err.message == "RuntimeError: child exploded" + + +async def test_tool_call_without_an_open_message_has_no_parent(monkeypatch): + script = [ + _activity({"subagent_id": TID, "phase": "started", "name": "research"}), + _activity({"subagent_id": TID, "phase": "tool_call", "tool_call_id": LOOKUP, "name": "lookup", "args": {}}), + _activity({"subagent_id": TID, "phase": "tool_result", "tool_call_id": LOOKUP, "content": {"ok": True}}), + _activity({"subagent_id": TID, "phase": "finished"}), + ] + out = await _collect(monkeypatch, script) + assert [ev.type for ev in out] == [ + EventType.SUBAGENT_STARTED, + EventType.TOOL_CALL_START, + EventType.TOOL_CALL_ARGS, + EventType.TOOL_CALL_END, + EventType.TOOL_CALL_RESULT, + EventType.SUBAGENT_FINISHED, + ] + assert out[1].parent_message_id is None + assert out[2].delta == "{}" + # Non-string tool content is JSON-serialized so the encoder never sees a dict. + assert out[4].content == '{"ok": true}' + + +async def test_message_start_without_message_id_derives_it(monkeypatch): + # Defensive: a message_start missing message_id gets -sub-m. + script = [ + _activity({"subagent_id": TID, "phase": "started", "name": "research"}), + _activity({"subagent_id": TID, "phase": "message_start"}), + _activity({"subagent_id": TID, "phase": "message", "delta": "x"}), + _activity({"subagent_id": TID, "phase": "message_start"}), + _activity({"subagent_id": TID, "phase": "finished"}), + ] + out = await _collect(monkeypatch, script) + starts = [ev for ev in out if ev.type == EventType.TEXT_MESSAGE_START] + assert [ev.message_id for ev in starts] == [M1, M2] + content = [ev for ev in out if ev.type == EventType.TEXT_MESSAGE_CONTENT] + assert content[0].message_id == M1 + + +async def test_message_before_message_start_opens_the_message_lazily(monkeypatch): + script = [ + _activity({"subagent_id": TID, "phase": "started", "name": "research"}), + _activity({"subagent_id": TID, "phase": "message", "message_id": M1, "delta": "x"}), + _activity({"subagent_id": TID, "phase": "finished"}), + ] + out = await _collect(monkeypatch, script) + assert [ev.type for ev in out] == [ + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_FINISHED, + ] + assert out[1].message_id == M1 + + +async def test_two_sequential_delegations_get_distinct_run_ids(monkeypatch): + script = [ + _run_started(), + *_tool_call(TID), + *_delegation(TID, "research"), + _tool_result(TID, "intel"), + *_tool_call(TID2), + *_delegation(TID2, "research"), + _tool_result(TID2, "more intel"), + _run_finished(), + ] + out = await _collect(monkeypatch, script) + + started = [ev for ev in out if ev.type == EventType.SUBAGENT_STARTED] + assert [ev.subagent_run_id for ev in started] == [f"{TID}-sub", f"{TID2}-sub"] + assert [ev.parent_tool_call_id for ev in started] == [TID, TID2] + + expected = [ + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_END, + EventType.TOOL_CALL_START, + EventType.TOOL_CALL_ARGS, + EventType.TOOL_CALL_END, + EventType.TOOL_CALL_RESULT, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_FINISHED, + ] + a = [ev for ev in out if getattr(ev, "subagent_run_id", None) == f"{TID}-sub"] + b = [ev for ev in out if getattr(ev, "subagent_run_id", None) == f"{TID2}-sub"] + assert [ev.type for ev in a] == expected + assert [ev.type for ev in b] == expected + assert {ev.message_id for ev in a if ev.type == EventType.TEXT_MESSAGE_START} == {f"{TID}-sub-m1", f"{TID}-sub-m2"} + assert {ev.message_id for ev in b if ev.type == EventType.TEXT_MESSAGE_START} == {f"{TID2}-sub-m1", f"{TID2}-sub-m2"} + # Every child block sits between its own tool call's END and RESULT. + types = [ev.type for ev in out] + parent_results = [i for i, ev in enumerate(out) if ev.type == EventType.TOOL_CALL_RESULT and ev.subagent_run_id is None] + assert types.index(EventType.SUBAGENT_STARTED) > types.index(EventType.TOOL_CALL_END) + assert types.index(EventType.SUBAGENT_FINISHED) < parent_results[0] + second_started = [i for i, ev in enumerate(out) if ev.type == EventType.SUBAGENT_STARTED][1] + assert parent_results[0] < second_started < parent_results[1] + + +async def test_unknown_phase_is_dropped_with_a_warning(monkeypatch, caplog): + script = [ + _activity({"subagent_id": TID, "phase": "started", "name": "research"}), + _activity({"subagent_id": TID, "phase": "reasoning", "delta": "hmm"}), + _activity({"subagent_id": TID, "phase": "finished"}), + ] + with caplog.at_level(logging.WARNING): + out = await _collect(monkeypatch, script) + assert [ev.type for ev in out] == [EventType.SUBAGENT_STARTED, EventType.SUBAGENT_FINISHED] + assert any("reasoning" in rec.getMessage() for rec in caplog.records) + + +async def test_malformed_payload_is_dropped(monkeypatch, caplog): + script = [ + _run_started(), + CustomEvent(type=EventType.CUSTOM, name="subagent_activity", value="not json"), + _activity({"phase": "started", "name": "research"}), # no subagent_id + _activity({"subagent_id": TID}), # no phase + _activity({"subagent_id": TID, "phase": "tool_call", "name": "lookup"}), # no tool_call_id + _activity({"subagent_id": TID, "phase": "tool_result", "content": "x"}), # no tool_call_id + _run_finished(), + ] + with caplog.at_level(logging.WARNING): + out = await _collect(monkeypatch, script) + assert [ev.type for ev in out] == [EventType.RUN_STARTED, EventType.RUN_FINISHED] + + +async def test_delegation_state_is_per_run(monkeypatch): + # A second run on the same agent must not see the first run's open message + # or its open delegation window. + script = [ + _activity({"subagent_id": TID, "phase": "started", "name": "research"}), + _activity({"subagent_id": TID, "phase": "message_start", "message_id": M1}), + _activity({"subagent_id": TID, "phase": "message", "message_id": M1, "delta": "x"}), + # run ends with the message still open (client disconnect, say) + ] + + async def fake_run(self, input): + for ev in script: + yield ev + + monkeypatch.setattr(LangGraphAgent, "run", fake_run) + agent = SubagentEmittingAgent(name="chat", graph=_graph()) + first = [ev async for ev in agent.run(_input())] + assert first[-1].type == EventType.TEXT_MESSAGE_CONTENT + + script[:] = [*_text("lc_run--parent", "hello"), _activity({"subagent_id": TID, "phase": "finished"})] + second = [ev async for ev in agent.run(_input())] + # No stale TEXT_MESSAGE_END from run 1 leaks into run 2, the parent's + # text is not suppressed by run 1's window, and the unknown delegation's + # finished is still expanded (bracketing the card). + assert [ev.type for ev in second] == [ + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_FINISHED, + ] + + +def test_clone_preserves_the_subclass(): + # The FastAPI endpoint runs agent.clone() per request; the emitter must + # survive cloning or SUBAGENT_* events would silently vanish from the wire. + agent = SubagentEmittingAgent(name="chat", graph=_graph()) + assert isinstance(agent.clone(), SubagentEmittingAgent) + + +def test_server_mounts_the_emitting_agent(monkeypatch): + # ChatOpenAI validates credentials at construction (graph import time). + monkeypatch.setenv("OPENAI_API_KEY", "sk-test-not-a-real-key") + from src import server + + assert isinstance(server.agent, SubagentEmittingAgent) From b6baf59f3916510e25211fc4c20774611128ffe9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 13:53:29 -0700 Subject: [PATCH 4/6] docs(examples): ag-ui subagent wire capture + live verification Co-Authored-By: Claude Fable 5.1 --- .../angular/e2e/manual/subagent-card-live.png | Bin 0 -> 62757 bytes .../python/docs/wire-capture-subagents.md | 148 ++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 examples/ag-ui/angular/e2e/manual/subagent-card-live.png diff --git a/examples/ag-ui/angular/e2e/manual/subagent-card-live.png b/examples/ag-ui/angular/e2e/manual/subagent-card-live.png new file mode 100644 index 0000000000000000000000000000000000000000..4ac03b2dca8ac257bd779bf6fffdfc66c75ac11a GIT binary patch literal 62757 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, +messages `-sub-m1` / `-m2`), `SUBAGENT_STARTED.parentToolCallId` +matches the bridge-native `TOOL_CALL_START.toolCallId` verbatim, and the +child's `lookup` call is a fully attributed `TOOL_CALL_START` (with +`parentMessageId` = the tool-calling turn `-sub-m1`) → `ARGS` → `END` → +`TOOL_CALL_RESULT` (`role: tool`, `messageId: -result`). The +`subagent_activity` CUSTOM events were consumed (0 on the wire); their RAW +`on_custom_event` mirrors (540) still pass through because the bridge yields +them before `_handle_single_event`, and the client ignores RAW. + +**The §2b duplicates are gone.** Exactly one `lookup` `TOOL_CALL_START` and +exactly two child `TEXT_MESSAGE_START`s are on the wire, all attributed; the +only unattributed `TEXT_MESSAGE_START` is the orchestrator's own answer +(1809). The wrapper drops the bridge-native copies of the child subgraph's +stream inside the delegation window (`SUBAGENT_STARTED` → `SUBAGENT_FINISHED`; +the parent is blocked in its tools node for the whole window, so nothing of +the parent's is lost), so the child text no longer transits the parent +transcript at all — the post-run `MESSAGES_SNAPSHOT` reconciliation is no +longer doing any work. `STEP_*` for the child nodes still pass through. + +**Measured order, `TOOL_CALL_START` vs `SUBAGENT_STARTED`:** START 9 → ARGS → +END 107 → `on_tool_start` 118 → SUBAGENT_STARTED 120 → … → SUBAGENT_FINISHED +1793 → TOOL_CALL_RESULT 1795. The tool call is fully announced before the tool +body runs, so the reducer attaches the card to an already-known +`parentToolCallId`; the whole `SUBAGENT_*` block nests between +`TOOL_CALL_END` and `TOOL_CALL_RESULT`, as in the cockpit lane. + +## Browser verification + +2026-09-02, live backend (real `OPENAI_API_KEY`, uvicorn on :8000) + `npx nx +serve examples-ag-ui-angular --port 4201`, driven headlessly with Playwright +(the §2 prompt typed into the composer). Screenshot, taken while the research +card was still `running` with the answer turn mid-stream: +`examples/ag-ui/angular/e2e/manual/subagent-card-live.png`. + +What rendered: the `research` dispatch produced an inline +`` anchored to its tool call — header `research` + wire +`toolCallId` + `running` badge + "2 message(s)" — with the child's transcript +inside it: the tool-calling turn as the first `.sac__msg` (empty text), then +the answer turn streaming below it as a second `.sac__msg`. Once the child +finished, the card flipped to `complete` and collapsed, and the +orchestrator's own summary streamed in the parent bubble. The child text +never appeared in the parent bubble (`chat-streaming-md` of the final +assistant message does not contain the child's "History & motivation" +sentence), and no stray `lookup` tool-call card appeared in the parent +transcript. + +The `lookup` call reached the projection — `agent.subagents()` reports +`toolCalls: [{name: "lookup", result: ...}]` (what the e2e asserts) — but the +card does not yet draw a `` for it: the card looks tool +calls up through `message.toolCallIds`, and the reducer's attributed +`TOOL_CALL_START` route (`libs/ag-ui/src/lib/reducer.ts` +`routeSubagentContentEvent`) pushes onto the entry's `toolCalls` without +linking the id into the open message (the legacy ACTIVITY transform used to +patch `/messages//toolCallIds/-` explicitly). The wire carries +`parentMessageId` on the attributed `TOOL_CALL_START`, so this is a reducer +follow-up, not a demo defect. + +Did the card stream mid-run: **yes**. Polling `agent.subagents()`, the +card's `innerText` and the `.sac__msg` count every 150ms: + +- t≈2.5s — card mounts on `SUBAGENT_STARTED`: `running`, 1 message (the + tool-calling turn, empty content), 0 tool calls, 1 `.sac__msg`. +- t≈18.9s — after gpt-5-mini's reasoning latency: `lookup` tool call present + with its result (`hasResult: true`), 2 messages, 2 `.sac__msg`, answer + turn at 46 chars. +- t≈18.9s → 22.5s — the answer turn grows monotonically while `running`: + 46 → 72 → 174 → 235 → 340 → 422 → 503 → 603 → 682 → 759 → 847 → 929 → + 1004 → … → 1862 chars across consecutive 150ms samples (card `innerText` + 103 → 1907 chars in step). +- t≈22.6s — `complete` at 1,878 chars; the card collapses (`innerText` + 1907 → 58 chars, 0 expanded `.sac__msg`). + +This confirms the attributed `TEXT_MESSAGE_CONTENT` deltas render +progressively in the card's second message while the attributed `lookup` +`TOOL_CALL_*` / `TOOL_CALL_RESULT` events render as the first message's tool +call — not as one post-hoc paste, and not as a stray tool call or bubble in +the parent transcript. From 3d83bcd5de9715de5e2b52cd34b8e9a1bcb0cbd1 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 13:53:29 -0700 Subject: [PATCH 5/6] docs(website): subagent demos emit the protocol's SUBAGENT_* events Co-Authored-By: Claude Fable 5.1 --- .../2026-08-27-langgraph-subgraphs-when-to-split.mdx | 11 +++++++---- ...26-08-31-what-changes-when-the-runtime-changes.mdx | 4 ++-- 2 files changed, 9 insertions(+), 6 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 3a818b27d..f062d52f7 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 @@ -182,11 +182,14 @@ Its module docstring says so outright: ```text Mirrors cockpit/chat/subagents' orchestrator + `task` tool + `_run_subagent` -structure, but each dispatch emits `subagent_activity` CUSTOM events +structure, but each dispatch emits `subagent_activity` CUSTOM events [...] +The backend's SubagentEmittingAgent expands those CUSTOM events into the +protocol's standard SUBAGENT_STARTED / TEXT_MESSAGE_* (attributed via +subagentRunId) / SUBAGENT_FINISHED / SUBAGENT_ERROR events ``` -The thing that differs is the transport: AG-UI's already carries a first-class delegation event. -So the specialists stayed a flat `async` helper and progress reaches the frontend as a custom event dispatched from the tool body. +The thing that differs is the transport: AG-UI already carries first-class delegation events — `SUBAGENT_STARTED`, `SUBAGENT_FINISHED`, and content events attributed to a child run. +So the specialists stayed a flat `async` helper, the tool body dispatches its progress as custom events, and a thin wrapper on the server expands those into the protocol's standard subagent events on the wire. The subgraph was never required by the feature. It was required by the transport. @@ -209,7 +212,7 @@ A node is already a unit. When the child really is a different graph — and the repo has exactly one of those, which is the case I owe you after arguing the other side this whole time. -Our `examples/ag-ui` demo runs on that same AG-UI transport, and it emits the same `subagent_activity` events from the tool body. +Our `examples/ag-ui` demo runs on that same AG-UI transport, and its research tool reaches the frontend the same way: the protocol's standard `SUBAGENT_*` events, with the child's messages and its own `lookup` tool call attributed to the child run. So it is not buying observability. It already had it. It compiles a child graph anyway. diff --git a/apps/website/content/blog/2026-08-31-what-changes-when-the-runtime-changes.mdx b/apps/website/content/blog/2026-08-31-what-changes-when-the-runtime-changes.mdx index f5b9ddd62..7adc34cd7 100644 --- a/apps/website/content/blog/2026-08-31-what-changes-when-the-runtime-changes.mdx +++ b/apps/website/content/blog/2026-08-31-what-changes-when-the-runtime-changes.mdx @@ -224,8 +224,8 @@ Our LangGraph subagent tracker is 543 lines. It infers subagent identity from stream namespaces, correlates namespaces back to tool-call ids, and requires you to configure `subagentToolNames: ['task']` in the provider so it knows which tool calls are delegations. That is client-side inference of a server-side fact, and inference is exactly as reliable as it sounds. -AG-UI ships `ACTIVITY_SNAPSHOT` and `ACTIVITY_DELTA` as first-class events. -The server declares "this is a subagent, here is its type, here is its status, here is its content." +AG-UI ships `SUBAGENT_STARTED`, `SUBAGENT_FINISHED`, and `SUBAGENT_ERROR` as first-class events, and every content event can carry a `subagentRunId`. +The server declares "this is a subagent, here is its name, here is the tool call that dispatched it, and these messages and tool calls belong to it." Our reducer projects those onto the neutral `Subagent` contract, and the AG-UI provider config for the subagents demo needs no subagent option at all. Declared beats inferred. From 267e04497184773724bfe4f4451a4243d6c82d57 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 14:07:52 -0700 Subject: [PATCH 6/6] refactor(examples): declare the child subgraph silent via the bridge's emit-* metadata; drop the inferred duplicate filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ag-ui-langgraph streams compiled subgraphs and would emit the research child's own `lookup` TOOL_CALL_* and answer TEXT_MESSAGE_* as unattributed events in the parent transcript. The bridge honors a declared opt-out (`ag_ui_langgraph/agent.py:993-994` reads run metadata `emit-messages` / `emit-tool-calls`), so the `research` tool now passes `metadata={"emit-messages": False, "emit-tool-calls": False}` on the subgraph invocation; callbacks, CUSTOM events and STEP_* still flow. SubagentEmittingAgent is back to a pure 1:N expander: the delegation window flag, `_is_child_duplicate` and the remembered-id sets are gone along with their four tests. New tests: bridge-native events pass through untouched even inside a delegation; the research tool declares the opt-out on its invocation config. Wire doc: "Declared opt-out (review follow-up)" re-capture — zero unattributed content events inside the SUBAGENT_* window with no wrapper-side filtering, parent TOOL_CALL_RESULT / answer and STEP_* present; earlier "wrapper drops duplicates" wording marked superseded. Co-Authored-By: Claude Fable 5.1 --- .../python/docs/wire-capture-subagents.md | 96 +++++++++++++++++-- examples/ag-ui/python/src/graph.py | 16 +++- .../src/streaming/subagent_emitting_agent.py | 71 +++----------- .../python/tests/test_subagent_emission.py | 36 ++++++- .../tests/test_subagent_emitting_agent.py | 55 ++++------- 5 files changed, 170 insertions(+), 104 deletions(-) diff --git a/examples/ag-ui/python/docs/wire-capture-subagents.md b/examples/ag-ui/python/docs/wire-capture-subagents.md index 084a2464f..c456a7f07 100644 --- a/examples/ag-ui/python/docs/wire-capture-subagents.md +++ b/examples/ag-ui/python/docs/wire-capture-subagents.md @@ -184,7 +184,7 @@ org ids appeared in the stream; only repetitive delta runs, 124 {"type":"STEP_FINISHED","stepName":"tools"} 125 {"type":"STEP_STARTED","stepName":"agent"} # child subgraph node (passes through, as before) 128 {"type":"TEXT_MESSAGE_START","messageId":"call_Ax1IOxHNk2UEIdCaKlDvCLtc-sub-m1","role":"assistant","subagentRunId":"call_Ax1IOxHNk2UEIdCaKlDvCLtc-sub"} - # [elided: the bridge-native, UNATTRIBUTED TOOL_CALL_START/ARGS/END for lookup that §2b showed here are GONE — dropped inside the delegation window; their RAW on_chat_model_stream mirrors remain] + # [elided: the bridge-native, UNATTRIBUTED TOOL_CALL_START/ARGS/END for lookup that §2b showed here are GONE (at this capture, filtered by the wrapper — superseded, see "Declared opt-out" below); their RAW on_chat_model_stream mirrors remain] 148 {"type":"TEXT_MESSAGE_END","messageId":"call_Ax1IOxHNk2UEIdCaKlDvCLtc-sub-m1","subagentRunId":"call_Ax1IOxHNk2UEIdCaKlDvCLtc-sub"} 149 {"type":"TOOL_CALL_START","toolCallId":"call_9FAovVPV9iy8ZR4l2XI6w5sE","toolCallName":"lookup","parentMessageId":"call_Ax1IOxHNk2UEIdCaKlDvCLtc-sub-m1","subagentRunId":"call_Ax1IOxHNk2UEIdCaKlDvCLtc-sub"} 150 {"type":"TOOL_CALL_ARGS","toolCallId":"call_9FAovVPV9iy8ZR4l2XI6w5sE","delta":"{\"query\": \"Angular Signals introduced in Angular 16 release\"}","subagentRunId":"call_Ax1IOxHNk2UEIdCaKlDvCLtc-sub"} @@ -243,12 +243,11 @@ them before `_handle_single_event`, and the client ignores RAW. **The §2b duplicates are gone.** Exactly one `lookup` `TOOL_CALL_START` and exactly two child `TEXT_MESSAGE_START`s are on the wire, all attributed; the only unattributed `TEXT_MESSAGE_START` is the orchestrator's own answer -(1809). The wrapper drops the bridge-native copies of the child subgraph's -stream inside the delegation window (`SUBAGENT_STARTED` → `SUBAGENT_FINISHED`; -the parent is blocked in its tools node for the whole window, so nothing of -the parent's is lost), so the child text no longer transits the parent -transcript at all — the post-run `MESSAGES_SNAPSHOT` reconciliation is no -longer doing any work. `STEP_*` for the child nodes still pass through. +(1809). At this capture the wrapper achieved that by *inferring* the +duplicates — dropping every unattributed content event between +`SUBAGENT_STARTED` and `SUBAGENT_FINISHED`. That filter was replaced in review +by the bridge's declared opt-out (next section); the wire shape is the same, +the mechanism is not. `STEP_*` for the child nodes still pass through. **Measured order, `TOOL_CALL_START` vs `SUBAGENT_STARTED`:** START 9 → ARGS → END 107 → `on_tool_start` 118 → SUBAGENT_STARTED 120 → … → SUBAGENT_FINISHED @@ -257,6 +256,89 @@ body runs, so the reducer attaches the card to an already-known `parentToolCallId`; the whole `SUBAGENT_*` block nests between `TOOL_CALL_END` and `TOOL_CALL_RESULT`, as in the cockpit lane. +## Declared opt-out (review follow-up) + +Review of the emitter asked why the wrapper *inferred* the child's duplicates +(any unattributed `TEXT_MESSAGE_*` / `TOOL_CALL_*` inside a delegation window) +when the bridge already honors a declared opt-out. It does: +`ag_ui_langgraph/agent.py:993-994` reads the LangChain run metadata keys +`emit-messages` and `emit-tool-calls` (default `True`) and skips emitting +`TEXT_MESSAGE_*` / `TOOL_CALL_*` for runs that carry them as `False` — while +callbacks (`SubagentStreamHandler`, `adispatch_custom_event`) still fire, +`STEP_*` still pass, and the parent's own `TOOL_CALL_RESULT` / answer are +untouched. Metadata set on the tool's `subgraph.ainvoke(..., config=...)` +inherits into every run of the child subgraph. + +So the `research` tool now invokes the subgraph with +`config={"callbacks": [...], "metadata": {"emit-messages": False, +"emit-tool-calls": False}}` (`src/graph.py`), and `SubagentEmittingAgent` is +back to a pure 1:N expander: no window flag, no remembered ids, every +non-`subagent_activity` event passes through untouched +(`tests/test_subagent_emitting_agent.py` +`test_bridge_native_events_pass_through_untouched_even_inside_a_delegation`; +`tests/test_subagent_emission.py` +`test_research_tool_declares_the_child_silent_via_bridge_metadata` asserts the +metadata is on the invocation). + +Re-captured 2026-09-02 with the opt-out and no wrapper-side filtering, same +`RunAgentInput` as §2 (thread `capture-optout-1788383126`, real +`OPENAI_API_KEY`, `gpt-5-mini`): + +``` +1 {"type":"RUN_STARTED", ...} +9 {"type":"TOOL_CALL_START","toolCallId":"call_zDfLSjalNBgmmB45oNQ7ERvm","toolCallName":"research", ...} +69 {"type":"TOOL_CALL_END","toolCallId":"call_zDfLSjalNBgmmB45oNQ7ERvm"} +78 {"type":"STEP_STARTED","stepName":"tools"} +80 {"type":"RAW","event":{"event":"on_tool_start","name":"research"}} +82 {"type":"SUBAGENT_STARTED","subagentRunId":"call_zDfLSjalNBgmmB45oNQ7ERvm-sub","name":"research","parentToolCallId":"call_zDfLSjalNBgmmB45oNQ7ERvm"} +87 {"type":"STEP_STARTED","stepName":"agent"} # child node — still on the wire +90 {"type":"TEXT_MESSAGE_START","messageId":"call_zDfLSjalNBgmmB45oNQ7ERvm-sub-m1","role":"assistant","subagentRunId":"call_zDfLSjalNBgmmB45oNQ7ERvm-sub"} + # [no bridge-native TOOL_CALL_START for lookup here — the bridge skipped it (emit-tool-calls=False); its RAW on_chat_model_stream mirrors remain] +122 {"type":"TEXT_MESSAGE_END","messageId":"call_zDfLSjalNBgmmB45oNQ7ERvm-sub-m1","subagentRunId":"call_zDfLSjalNBgmmB45oNQ7ERvm-sub"} +123 {"type":"TOOL_CALL_START","toolCallId":"call_1E9G6oUvZ43IDK58Hc0TSnVC","toolCallName":"lookup","parentMessageId":"call_zDfLSjalNBgmmB45oNQ7ERvm-sub-m1","subagentRunId":"call_zDfLSjalNBgmmB45oNQ7ERvm-sub"} +124 {"type":"TOOL_CALL_ARGS", ... "subagentRunId":"call_zDfLSjalNBgmmB45oNQ7ERvm-sub"} +125 {"type":"TOOL_CALL_END","toolCallId":"call_1E9G6oUvZ43IDK58Hc0TSnVC","subagentRunId":"call_zDfLSjalNBgmmB45oNQ7ERvm-sub"} +132 {"type":"STEP_STARTED","stepName":"tools"} # child node +138 {"type":"TOOL_CALL_RESULT","messageId":"call_1E9G6oUvZ43IDK58Hc0TSnVC-result","toolCallId":"call_1E9G6oUvZ43IDK58Hc0TSnVC","role":"tool","subagentRunId":"call_zDfLSjalNBgmmB45oNQ7ERvm-sub", ...} +143 {"type":"STEP_STARTED","stepName":"agent"} + # [259 attributed TEXT_MESSAGE_CONTENT deltas on -sub-m2 — the child's answer; no bridge-native TEXT_MESSAGE_START/CONTENT/END copy (emit-messages=False)] +941 {"type":"TEXT_MESSAGE_END","messageId":"call_zDfLSjalNBgmmB45oNQ7ERvm-sub-m2","subagentRunId":"call_zDfLSjalNBgmmB45oNQ7ERvm-sub"} +942 {"type":"SUBAGENT_FINISHED","subagentRunId":"call_zDfLSjalNBgmmB45oNQ7ERvm-sub","outcome":{"type":"success"}} +944 {"type":"TOOL_CALL_RESULT","toolCallId":"call_zDfLSjalNBgmmB45oNQ7ERvm","content":"- What they are: Angular Signals are a fine-grained reactivity primitive ..."} # the PARENT's result, untouched +952 {"type":"STEP_STARTED","stepName":"generate"} +958 {"type":"TEXT_MESSAGE_START","messageId":"lc_run--01a063f1-275c-79f0-8b5d-f6175c48fc09","role":"assistant"} # the ORCHESTRATOR's own answer — the only unattributed TEXT_MESSAGE_START + # [elided: 49 TEXT_MESSAGE_CONTENT deltas, no subagentRunId] +1084 {"type":"RUN_FINISHED", ...} +``` + +Event tally (1,084 events): 1 RUN_STARTED, 9 STEP_STARTED, 9 STEP_FINISHED, +1 TOOL_CALL_START, 29 TOOL_CALL_ARGS, 1 TOOL_CALL_END, 1 SUBAGENT_STARTED, +2 TEXT_MESSAGE_START(sub), 259 TEXT_MESSAGE_CONTENT(sub), +2 TEXT_MESSAGE_END(sub), 1 TOOL_CALL_START(sub), 1 TOOL_CALL_ARGS(sub), +1 TOOL_CALL_END(sub), 1 TOOL_CALL_RESULT(sub), 1 SUBAGENT_FINISHED, +1 TOOL_CALL_RESULT, 1 TEXT_MESSAGE_START, 49 TEXT_MESSAGE_CONTENT, +1 TEXT_MESSAGE_END, 10 STATE_SNAPSHOT, 2 MESSAGES_SNAPSHOT, 1 RUN_FINISHED, +700 RAW (377 `on_chat_model_stream`, 265 `on_custom_event`, 16 +`on_chain_stream`, 15/15 `on_chain_start`/`_end`, 4/4 +`on_chat_model_start`/`_end`, 2/2 `on_tool_start`/`_end`). No CUSTOM, no +ACTIVITY_*, no SUBAGENT_ERROR. + +- (a) **Zero** unattributed `TEXT_MESSAGE_*` / `TOOL_CALL_*` events between + `SUBAGENT_STARTED` (82) and `SUBAGENT_FINISHED` (942), with nothing + filtering them: the `lookup` call appears exactly once (123, attributed) and + the child's answer only as the 259 attributed deltas on `-sub-m2`. The only + unattributed `TEXT_MESSAGE_START` in the whole run is the orchestrator's + (958). The joined child deltas (1,386 chars) equal the parent's + `TOOL_CALL_RESULT.content` byte-for-byte. +- (b) The parent's `TOOL_CALL_RESULT` for the delegation (944) and its own + answer (958 → 1 START / 49 CONTENT / 1 END) are present and unattributed. +- (c) `STEP_*` still passes: 9 `STEP_STARTED` / 9 `STEP_FINISHED` + (`generate` ×2, `tools` ×3, `agent` ×2, `attach_citations`, + `generate_title`) — the child's `agent` / `tools` nodes included. + +The e2e suite (`examples/ag-ui/angular/e2e`, aimock replay) passed unchanged +after the swap. + ## Browser verification 2026-09-02, live backend (real `OPENAI_API_KEY`, uvicorn on :8000) + `npx nx diff --git a/examples/ag-ui/python/src/graph.py b/examples/ag-ui/python/src/graph.py index 63d284cd2..44a68ad55 100644 --- a/examples/ag-ui/python/src/graph.py +++ b/examples/ag-ui/python/src/graph.py @@ -471,7 +471,21 @@ async def _emit(payload: dict) -> None: try: result = await subgraph.ainvoke( {"topic": topic, "messages": [], "iterations": 0}, - config={"callbacks": [SubagentStreamHandler(tool_call_id, run_state)]}, + config={ + "callbacks": [SubagentStreamHandler(tool_call_id, run_state)], + # ag-ui-langgraph streams this compiled subgraph by default and + # would put the child's own LLM text and `lookup` tool call on + # the wire as UNATTRIBUTED TEXT_MESSAGE_* / TOOL_CALL_* events + # in the parent transcript. `emit-messages` / `emit-tool-calls` + # is the bridge's declared opt-out (read from LangChain run + # metadata, which inherits into the subgraph run): with them + # False the child's raw events never reach the wire, while + # callbacks (SubagentStreamHandler, adispatch_custom_event) and + # the child's STEP_* still fire. The attributed copies come from + # the `subagent_activity` payloads the SubagentEmittingAgent + # expands. + "metadata": {"emit-messages": False, "emit-tool-calls": False}, + }, ) except Exception as exc: await _emit({"phase": "error", "message": f"{type(exc).__name__}: {exc}"}) diff --git a/examples/ag-ui/python/src/streaming/subagent_emitting_agent.py b/examples/ag-ui/python/src/streaming/subagent_emitting_agent.py index 14db19bd7..7134c1d4e 100644 --- a/examples/ag-ui/python/src/streaming/subagent_emitting_agent.py +++ b/examples/ag-ui/python/src/streaming/subagent_emitting_agent.py @@ -33,17 +33,16 @@ expand(ev): yield out`` preserves streaming. Delegation state is per ``run()`` call (the endpoint clones the agent per request anyway). -Because the child here is a compiled SUBGRAPH and the bridge streams -subgraphs, the bridge ALSO emits the child's own LLM text and ``lookup`` -tool call as unattributed, bridge-native ``TEXT_MESSAGE_*`` / -``TOOL_CALL_*`` events — the same content the attributed expansion carries, -landing in the parent transcript (wire capture §2b). While a delegation -window is open (``started`` → ``finished`` / ``error``) the parent is blocked -in its tools node, so every unattributed content event in that window is the -child's duplicate; the wrapper drops them (remembering their ids so a -trailing ``TEXT_MESSAGE_END`` / ``TOOL_CALL_END`` after the window is dropped -too). ``STEP_*``, ``STATE_SNAPSHOT``, RAW mirrors and the parent's own -``TOOL_CALL_RESULT`` (which arrives after ``finished``) are untouched. +The child here is a compiled SUBGRAPH, and the bridge streams subgraphs — +so left alone it would ALSO emit the child's own LLM text and ``lookup`` tool +call as unattributed, bridge-native ``TEXT_MESSAGE_*`` / ``TOOL_CALL_*`` +events in the parent transcript (wire capture §2b). The graph opts the child +out at the source: the ``research`` tool invokes the subgraph with LangChain +run metadata ``emit-messages`` / ``emit-tool-calls`` = ``False``, the bridge's +declared switch for skipping those emissions while callbacks, CUSTOM events +and ``STEP_*`` still flow (see ``src/graph.py``). This wrapper therefore never +filters bridge-native events — every non-``subagent_activity`` event passes +through untouched. The encoder requires pydantic ``BaseEvent`` instances — raw dicts crash the stream — so only typed ``ag_ui.core`` events are yielded. @@ -75,15 +74,6 @@ CUSTOM_NAME = "subagent_activity" -# Bridge-native content events the child subgraph duplicates inside a -# delegation window (see module docstring). -_CHILD_MESSAGE_TYPES = frozenset({ - EventType.TEXT_MESSAGE_START, EventType.TEXT_MESSAGE_CONTENT, EventType.TEXT_MESSAGE_END, -}) -_CHILD_TOOL_TYPES = frozenset({ - EventType.TOOL_CALL_START, EventType.TOOL_CALL_ARGS, EventType.TOOL_CALL_END, -}) - logger = logging.getLogger(__name__) @@ -94,7 +84,6 @@ class _Delegation: run_id: str open_message_id: str | None = None message_count: int = 0 - active: bool = False @dataclass @@ -102,13 +91,6 @@ class _RunState: """Per-``run()`` expansion state.""" delegations: dict[str, _Delegation] = field(default_factory=dict) - # Ids of bridge-native child messages / tool calls dropped inside a window, - # so their trailing END events are dropped after the window closes too. - dropped_message_ids: set[str] = field(default_factory=set) - dropped_tool_call_ids: set[str] = field(default_factory=set) - - def in_window(self) -> bool: - return any(d.active for d in self.delegations.values()) def _subagent_run_id(tid: str) -> str: @@ -153,8 +135,7 @@ def _payload(event: BaseEvent) -> dict[str, Any] | None: class SubagentEmittingAgent(LangGraphAgent): """LangGraphAgent whose ``run`` expands the graph's `subagent_activity` CUSTOM events into standard SUBAGENT_* + attributed TEXT_MESSAGE_* / - TOOL_CALL_* events, and drops the bridge's unattributed duplicates of the - child subgraph's stream. + TOOL_CALL_* events. Everything else passes through untouched. Keeps the bridge's ``__init__`` signature so ``clone()`` (called by the FastAPI endpoint per request) reconstructs this subclass. @@ -169,8 +150,7 @@ async def run(self, *args: Any, **kwargs: Any) -> AsyncGenerator[BaseEvent, None def _expand(self, event: BaseEvent, state: _RunState) -> Iterator[BaseEvent]: payload = _payload(event) if payload is None: - if not self._is_child_duplicate(event, state): - yield event + yield event return if not payload: return # malformed — already logged @@ -187,7 +167,6 @@ def _expand(self, event: BaseEvent, state: _RunState) -> Iterator[BaseEvent]: run_id = delegation.run_id if phase == "started": - delegation.active = True yield SubagentStartedEvent( type=EventType.SUBAGENT_STARTED, subagent_run_id=run_id, @@ -250,7 +229,6 @@ def _expand(self, event: BaseEvent, state: _RunState) -> Iterator[BaseEvent]: ) elif phase == "finished": yield from self._close_message(delegation) - delegation.active = False yield SubagentFinishedEvent( type=EventType.SUBAGENT_FINISHED, subagent_run_id=run_id, @@ -258,7 +236,6 @@ def _expand(self, event: BaseEvent, state: _RunState) -> Iterator[BaseEvent]: ) elif phase == "error": yield from self._close_message(delegation) - delegation.active = False yield SubagentErrorEvent( type=EventType.SUBAGENT_ERROR, subagent_run_id=run_id, @@ -267,30 +244,6 @@ def _expand(self, event: BaseEvent, state: _RunState) -> Iterator[BaseEvent]: else: logger.warning("subagent_activity phase %r not supported; dropped", phase) - @staticmethod - def _is_child_duplicate(event: BaseEvent, state: _RunState) -> bool: - """True for a bridge-native, unattributed content event that duplicates - the child subgraph's stream (inside a delegation window, or a trailing - END for an id dropped inside one).""" - event_type = getattr(event, "type", None) - if getattr(event, "subagent_run_id", None): - return False # already attributed — someone else's business - if event_type in _CHILD_MESSAGE_TYPES: - message_id = getattr(event, "message_id", None) - if state.in_window(): - if message_id: - state.dropped_message_ids.add(message_id) - return True - return message_id in state.dropped_message_ids - if event_type in _CHILD_TOOL_TYPES: - tool_call_id = getattr(event, "tool_call_id", None) - if state.in_window(): - if tool_call_id: - state.dropped_tool_call_ids.add(tool_call_id) - return True - return tool_call_id in state.dropped_tool_call_ids - return False - @staticmethod def _open_message( delegation: _Delegation, tid: str, message_id: Any diff --git a/examples/ag-ui/python/tests/test_subagent_emission.py b/examples/ag-ui/python/tests/test_subagent_emission.py index ecdbb0235..b51c26546 100644 --- a/examples/ag-ui/python/tests/test_subagent_emission.py +++ b/examples/ag-ui/python/tests/test_subagent_emission.py @@ -28,7 +28,7 @@ from langgraph.graph import END, MessagesState, StateGraph import src.graph as graph_mod -from src.graph import _build_research_subgraph +from src.graph import _build_research_subgraph, research from src.streaming.subagent_emitting_agent import SubagentEmittingAgent from src.streaming.subagent_stream_handler import SubagentRunState @@ -181,3 +181,37 @@ async def test_lookup_tool_is_deterministic_and_offline(): # Unknown topic falls back to the default fact, never raises / hits network. assert graph_mod.lookup.invoke({"query": "quantum widgets"}) == \ graph_mod._RESEARCH_DEFAULT_FACT + + +@pytest.mark.asyncio +async def test_research_tool_declares_the_child_silent_via_bridge_metadata(monkeypatch): + # ag-ui-langgraph streams compiled subgraphs and would emit the child's + # own text / `lookup` call as UNATTRIBUTED TEXT_MESSAGE_* / TOOL_CALL_* + # events in the parent transcript. The bridge honors a declared opt-out: + # runs whose LangChain metadata carries `emit-messages` / `emit-tool-calls` + # = False are skipped for those emissions (callbacks and STEP_* still + # fire). The tool must pass it on the subgraph invocation so it inherits + # into the child run — no wrapper-side duplicate filtering. + captured: dict[str, Any] = {} + + class _CapturingSubgraph: + async def ainvoke(self, state: Any, config: Any = None, **kwargs: Any) -> dict: + captured["state"] = state + captured["config"] = config + return {"messages": [AIMessage(content="- Signals are reactive.")]} + + monkeypatch.setattr(graph_mod, "_build_research_subgraph", lambda *a, **k: _CapturingSubgraph()) + + result = await research.ainvoke( + {"type": "tool_call", "id": TID, "name": "research", "args": {"topic": "Angular signals"}} + ) + + assert result.content == "- Signals are reactive." + assert captured["state"]["topic"] == "Angular signals" + metadata = captured["config"]["metadata"] + assert metadata["emit-messages"] is False + assert metadata["emit-tool-calls"] is False + # The streaming callback still rides along — the opt-out silences only the + # bridge's raw emission, not the per-token subagent_activity source. + handler_types = [type(cb).__name__ for cb in captured["config"]["callbacks"]] + assert "SubagentStreamHandler" in handler_types diff --git a/examples/ag-ui/python/tests/test_subagent_emitting_agent.py b/examples/ag-ui/python/tests/test_subagent_emitting_agent.py index 3ea7bd7ff..71486aaab 100644 --- a/examples/ag-ui/python/tests/test_subagent_emitting_agent.py +++ b/examples/ag-ui/python/tests/test_subagent_emitting_agent.py @@ -187,31 +187,26 @@ async def test_expands_the_reason_tool_answer_loop_field_for_field(monkeypatch): assert out[16].subagent_run_id is None -async def test_bridge_native_child_stream_is_dropped_inside_the_delegation_window(monkeypatch): - # ag-ui-langgraph streams the child SUBGRAPH's own LLM/tool events as - # unattributed bridge-native events while the research tool runs (measured - # in docs/wire-capture-subagents.md §2b). Inside the delegation window the - # parent is blocked in its tools node, so every unattributed content event - # is the child's duplicate of what the attributed expansion already - # carries — drop them, including a trailing END for a dropped id. - child_text_id = "lc_run--child" +async def test_bridge_native_events_pass_through_untouched_even_inside_a_delegation(monkeypatch): + # The wrapper is a pure 1:N expander: it never filters bridge-native + # events. The child subgraph's own LLM text / tool call are kept off the + # wire at the SOURCE — the research tool invokes the subgraph with the + # bridge's `emit-messages` / `emit-tool-calls` = False run metadata (see + # test_subagent_emission.py) — so an unattributed event that does arrive + # between started and finished (the child's STEP_*, or any future + # bridge-native event) is forwarded as-is, same object, in order. + step = StepStartedEvent(type=EventType.STEP_STARTED, step_name="tools") + parent_text = _text("lc_run--parent", "Here is what the subagent found.") script = [ _run_started(), *_tool_call(TID), _activity({"subagent_id": TID, "phase": "started", "name": "research"}), _activity({"subagent_id": TID, "phase": "message_start", "message_id": M1}), - *_tool_call(LOOKUP, name="lookup", args=json.dumps(LOOKUP_ARGS)), # bridge-native copy of the child's call - _activity({"subagent_id": TID, "phase": "tool_call", "message_id": M1, - "tool_call_id": LOOKUP, "name": "lookup", "args": LOOKUP_ARGS}), - StepStartedEvent(type=EventType.STEP_STARTED, step_name="tools"), # child node steps pass through - _activity({"subagent_id": TID, "phase": "tool_result", "tool_call_id": LOOKUP, "content": LOOKUP_RESULT}), - _activity({"subagent_id": TID, "phase": "message_start", "message_id": M2}), - *_text(child_text_id, "- Signals")[:2], # bridge-native copy of the child's answer - _activity({"subagent_id": TID, "phase": "message", "message_id": M2, "delta": "- Signals"}), + step, + _activity({"subagent_id": TID, "phase": "message", "message_id": M1, "delta": "- Signals"}), _activity({"subagent_id": TID, "phase": "finished"}), - _text(child_text_id, "")[2], # trailing END for the dropped id _tool_result(TID, "- Signals"), - *_text("lc_run--parent", "Here is what the subagent found."), # the orchestrator's own answer + *parent_text, _run_finished(), ] out = await _collect(monkeypatch, script) @@ -222,13 +217,7 @@ async def test_bridge_native_child_stream_is_dropped_inside_the_delegation_windo (EventType.TOOL_CALL_END, None), (EventType.SUBAGENT_STARTED, RUN_ID), (EventType.TEXT_MESSAGE_START, RUN_ID), - (EventType.TEXT_MESSAGE_END, RUN_ID), - (EventType.TOOL_CALL_START, RUN_ID), - (EventType.TOOL_CALL_ARGS, RUN_ID), - (EventType.TOOL_CALL_END, RUN_ID), (EventType.STEP_STARTED, None), - (EventType.TOOL_CALL_RESULT, RUN_ID), - (EventType.TEXT_MESSAGE_START, RUN_ID), (EventType.TEXT_MESSAGE_CONTENT, RUN_ID), (EventType.TEXT_MESSAGE_END, RUN_ID), (EventType.SUBAGENT_FINISHED, RUN_ID), @@ -238,15 +227,11 @@ async def test_bridge_native_child_stream_is_dropped_inside_the_delegation_windo (EventType.TEXT_MESSAGE_END, None), (EventType.RUN_FINISHED, None), ] - # Exactly one lookup call on the wire, and it is the attributed one. - lookups = [ev for ev in out if ev.type == EventType.TOOL_CALL_START and ev.tool_call_id == LOOKUP] - assert len(lookups) == 1 and lookups[0].subagent_run_id == RUN_ID - assert not any(getattr(ev, "message_id", None) == child_text_id for ev in out) - # The orchestrator's answer after the window is untouched. - assert out[17] is script[-4] + assert out[6] is step + assert out[11:14] == parent_text -async def test_outside_a_delegation_window_nothing_is_dropped(monkeypatch): +async def test_without_any_delegation_the_stream_is_the_identity(monkeypatch): script = [_run_started(), *_tool_call("call_search", name="search_documents"), _tool_result("call_search", "[]"), *_text("lc_run--parent", "hi"), _run_finished()] out = await _collect(monkeypatch, script) @@ -432,8 +417,7 @@ async def test_malformed_payload_is_dropped(monkeypatch, caplog): async def test_delegation_state_is_per_run(monkeypatch): - # A second run on the same agent must not see the first run's open message - # or its open delegation window. + # A second run on the same agent must not see the first run's open message. script = [ _activity({"subagent_id": TID, "phase": "started", "name": "research"}), _activity({"subagent_id": TID, "phase": "message_start", "message_id": M1}), @@ -452,9 +436,8 @@ async def fake_run(self, input): script[:] = [*_text("lc_run--parent", "hello"), _activity({"subagent_id": TID, "phase": "finished"})] second = [ev async for ev in agent.run(_input())] - # No stale TEXT_MESSAGE_END from run 1 leaks into run 2, the parent's - # text is not suppressed by run 1's window, and the unknown delegation's - # finished is still expanded (bracketing the card). + # No stale TEXT_MESSAGE_END from run 1 leaks into run 2, and the unknown + # delegation's finished is still expanded (bracketing the card). assert [ev.type for ev in second] == [ EventType.TEXT_MESSAGE_START, EventType.TEXT_MESSAGE_CONTENT,