diff --git a/cockpit/ag-ui/subagents/angular/e2e/manual/subagent-card-live.png b/cockpit/ag-ui/subagents/angular/e2e/manual/subagent-card-live.png new file mode 100644 index 000000000..45f2fb73c Binary files /dev/null and b/cockpit/ag-ui/subagents/angular/e2e/manual/subagent-card-live.png differ diff --git a/cockpit/ag-ui/subagents/angular/e2e/subagents.spec.ts b/cockpit/ag-ui/subagents/angular/e2e/subagents.spec.ts index 19b583c87..f7a176078 100644 --- a/cockpit/ag-ui/subagents/angular/e2e/subagents.spec.ts +++ b/cockpit/ag-ui/subagents/angular/e2e/subagents.spec.ts @@ -21,9 +21,10 @@ interface SubagentProbe { // inline AS a anchored to its message, and the card // PERSISTS (collapsed) after the subagent completes — there is no separate // active-only mount, and the `task` call no longer renders a generic tool-call -// chip. The map read here is the data the card binds to: it proves the ACTIVITY -// snapshot/delta pipeline populated the subagent (name + streamed child text) -// and that it settled to `complete`. The card element and this projection are +// chip. The map read here is the data the card binds to: it proves the +// SUBAGENT_STARTED + subagentRunId-attributed TEXT_MESSAGE_* pipeline populated +// the subagent (name + streamed child text) and that SUBAGENT_FINISHED settled +// it to `complete`. The card element and this projection are // asserted together below (card presence/persistence + projection contents). async function readSubagents(page: Page): Promise { return page.evaluate(() => { @@ -52,12 +53,13 @@ async function readSubagents(page: Page): Promise { } // Research delegation over the AG-UI transport: the orchestrator LLM calls the -// `task` tool, the subagent LLM streams a 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(), which the inline (rendered in place of -// the `task` tool call) binds to. The child's research text must stay OUT of the -// parent's bubble. +// `task` tool, the subagent LLM streams a summary, and the AG-UI server's +// SubagentEmittingAgent expands the graph's subagent_activity CUSTOM events into +// the protocol's standard SUBAGENT_STARTED / TEXT_MESSAGE_* (carrying +// subagentRunId) / SUBAGENT_FINISHED events. The @threadplane/ag-ui reducer +// projects them to agent.subagents(), which the inline +// (rendered in place of the `task` tool call) binds to. The child's research +// text must stay OUT of the parent's bubble. test('AG-UI subagents: orchestrator dispatches subagent cards that settle complete', async ({ page, }) => { diff --git a/cockpit/ag-ui/subagents/angular/src/app/subagents.component.ts b/cockpit/ag-ui/subagents/angular/src/app/subagents.component.ts index 409e54338..6dc351dd1 100644 --- a/cockpit/ag-ui/subagents/angular/src/app/subagents.component.ts +++ b/cockpit/ag-ui/subagents/angular/src/app/subagents.component.ts @@ -10,10 +10,11 @@ import { ExampleChatLayoutComponent } from '@threadplane/example-layouts'; * Retrieves the agent with injectAgent() (provided by provideAgent / * provideFakeAgent) and passes it to the prebuilt composition. No * subagent-specific wiring is needed in the component: when the orchestrator - * dispatches a `task` tool call, the backend converts the subagent_activity - * CUSTOM events into native AG-UI ACTIVITY events, the @threadplane/ag-ui - * reducer projects them onto `agent.subagents()`, and renders each - * dispatch inline as a persistent `chat-subagent-card`. + * dispatches a `task` tool call, the backend emits the protocol's standard + * SUBAGENT_STARTED / TEXT_MESSAGE_* (attributed via subagentRunId) / + * SUBAGENT_FINISHED events, the @threadplane/ag-ui reducer projects them onto + * `agent.subagents()`, and renders each dispatch inline as a persistent + * `chat-subagent-card`. * * Demonstrates the chat-runtime decoupling: same composition as the * LangGraph cockpit, AG-UI runtime instead of LangGraph. diff --git a/cockpit/ag-ui/subagents/python/docs/guide.md b/cockpit/ag-ui/subagents/python/docs/guide.md index 555518aed..9e95455d9 100644 --- a/cockpit/ag-ui/subagents/python/docs/guide.md +++ b/cockpit/ag-ui/subagents/python/docs/guide.md @@ -4,13 +4,14 @@ Render live subagent cards in an Angular chat UI using `provideAgent()` and `injectAgent()` from `@threadplane/ag-ui`. An orchestrator LangGraph agent delegates focused subtasks to specialized subagents via a `task` tool; the -backend converts each subagent's streamed tokens into native AG-UI ACTIVITY -events, which the `@threadplane/ag-ui` reducer projects onto -`agent.subagents()` for the `` primitive to render. +backend emits each subagent's run as the protocol's standard `SUBAGENT_STARTED`, +`subagentRunId`-attributed `TEXT_MESSAGE_*` and `SUBAGENT_FINISHED` events, +which the `@threadplane/ag-ui` reducer projects onto `agent.subagents()` for +the `` primitive to render. -Add subagent cards to this Angular component using `@threadplane/ag-ui`. Configure `provideAgent({ url })` in the app config, call `injectAgent()` in the component, and pass the agent to the `` component from `@threadplane/chat`. The orchestrator graph dispatches subagents with a `task` tool and emits `subagent_activity` custom events; the backend's `ActivityEmittingAgent` converts those into native AG-UI ACTIVITY events so the chat composition renders a live card per subagent — all Signals, no subscriptions needed. +Add subagent cards to this Angular component using `@threadplane/ag-ui`. Configure `provideAgent({ url })` in the app config, call `injectAgent()` in the component, and pass the agent to the `` component from `@threadplane/chat`. The orchestrator graph dispatches subagents with a `task` tool and emits `subagent_activity` custom events; the backend's `SubagentEmittingAgent` expands those into the standard AG-UI `SUBAGENT_STARTED` / attributed `TEXT_MESSAGE_*` / `SUBAGENT_FINISHED` events so the chat composition renders a live card per subagent — all Signals, no subscriptions needed. @@ -62,10 +63,11 @@ export class SubagentsComponent { The orchestrator binds a `task` tool that dispatches a role-specific -subagent. Before running the subagent it emits a `started` activity; while -the subagent streams it forwards each token as a `message` activity (via -`SubagentStreamHandler`); after it emits a `finished` activity — all keyed -by the tool's own call id: +subagent. Before running the subagent it emits a `started` payload; while +the subagent streams, `SubagentStreamHandler` forwards a `message_start` +once and then one `message` per token (the raw delta); after it emits +`finished` — or `error` if the child fails — all keyed by the tool's own +call id: ```python # graph.py @@ -79,36 +81,56 @@ async def task(role, task_description, tool_call_id: Annotated[str, InjectedTool "subagent_activity", {"subagent_id": tool_call_id, "phase": "started", "name": role}, ) - result = await _run_subagent( - role, task_description, - config={"callbacks": [SubagentStreamHandler(tool_call_id)]}, - ) + try: + result = await _run_subagent( + role, task_description, + config={"callbacks": [SubagentStreamHandler(tool_call_id)]}, + ) + except Exception as exc: + await adispatch_custom_event( + "subagent_activity", + {"subagent_id": tool_call_id, "phase": "error", "message": str(exc)}, + ) + raise await adispatch_custom_event( "subagent_activity", - {"subagent_id": tool_call_id, "phase": "finished", "status": "complete"}, + {"subagent_id": tool_call_id, "phase": "finished"}, ) return result ``` - + The backend wraps the LangGraph `graph` in a FastAPI app using -`ag-ui-langgraph`. `ActivityEmittingAgent` subclasses the bridge's -`LangGraphAgent` and converts each `subagent_activity` CUSTOM event into a -native AG-UI ACTIVITY event (snapshot/delta) at the bridge's 1:1 dispatch -point: +`ag-ui-langgraph`. `SubagentEmittingAgent` subclasses the bridge's +`LangGraphAgent` and wraps its `run()` generator, expanding each +`subagent_activity` CUSTOM event into the protocol's standard events (ids +derived from the `task` tool call id, `tid`): + +| phase | wire event | +| --- | --- | +| `started {name}` | `SUBAGENT_STARTED {subagentRunId: -sub, name, parentToolCallId: }` | +| `message_start {message_id}` | `TEXT_MESSAGE_START {messageId: -sub-m1, role: assistant, subagentRunId}` | +| `message {message_id, delta}` | `TEXT_MESSAGE_CONTENT {messageId, delta, subagentRunId}` | +| `finished` | `TEXT_MESSAGE_END` for the open message, then `SUBAGENT_FINISHED {outcome: success}` | +| `error {message}` | `TEXT_MESSAGE_END` for the open message, then `SUBAGENT_ERROR {message}` | + +The CUSTOM event itself is consumed; every other bridge event passes through +untouched. Because `parentToolCallId` equals the bridge-native +`TOOL_CALL_START.toolCallId` (which is fully streamed before the tool body +runs), the reducer anchors the card to the `task` call with no bookkeeping: ```python # server.py from fastapi import FastAPI from ag_ui_langgraph import add_langgraph_fastapi_endpoint from .graph import graph -from .streaming.activity_emitting_agent import ActivityEmittingAgent +from .streaming.subagent_emitting_agent import SubagentEmittingAgent app = FastAPI(title="cockpit-ag-ui-subagents") add_langgraph_fastapi_endpoint( - app, ActivityEmittingAgent(name="subagents", graph=graph), path="/agent" + app, SubagentEmittingAgent(name="subagents", graph=graph), path="/agent" ) @app.get("/ok") @@ -126,6 +148,10 @@ uv run uvicorn src.server:app --port 5326 A checkpointer is required for `ag-ui-langgraph` to work. Without it, the library cannot call `graph.aget_state()`. The graph in `src/graph.py` uses `MemorySaver` for development. + +The AG-UI encoder only serializes pydantic `ag_ui.core` event classes — yielding a raw dict crashes the stream. `SubagentEmittingAgent` constructs `SubagentStartedEvent`, `TextMessageStartEvent`, and friends from `ag_ui.core` (`ag-ui-protocol>=0.1.22`, the first release with `subagent_run_id` on every event). + + diff --git a/cockpit/ag-ui/subagents/python/docs/wire-capture-subagents.md b/cockpit/ag-ui/subagents/python/docs/wire-capture-subagents.md new file mode 100644 index 000000000..267aea8da --- /dev/null +++ b/cockpit/ag-ui/subagents/python/docs/wire-capture-subagents.md @@ -0,0 +1,247 @@ +# AG-UI subagents (LangGraph): wire capture + emitter-seam decision + +Evidence for migrating this demo from the private ACTIVITY convention +(`ACTIVITY_SNAPSHOT`/`ACTIVITY_DELTA` with `activityType: "subagent"`) to the +protocol's standard `SUBAGENT_*` events plus `subagentRunId`-attributed +`TEXT_MESSAGE_*` events. Captured 2026-09-02 against the live backend +(`src/server.py`, `uv run uvicorn src.server:app --port 5326`, real +`OPENAI_API_KEY`, `gpt-5-mini` for orchestrator and subagents) with +`ag-ui-langgraph 0.0.37` and `ag-ui-protocol 0.1.22` (bumped in the same +commit as this doc; the previous transitive pin was 0.1.19). + +All bridge citations are into the installed venv source: +`.venv/lib/python3.14/site-packages/ag_ui_langgraph/agent.py` and +`.venv/lib/python3.14/site-packages/ag_ui/core/events.py`. + +## 1. SDK check + +``` +$ uv run python -c "from ag_ui.core import SubagentStartedEvent, TextMessageContentEvent; print(TextMessageContentEvent.model_fields['subagent_run_id'])" +annotation=Union[str, NoneType] required=False default=None alias='subagentRunId' alias_priority=1 +``` + +`ag-ui-protocol 0.1.22` ships `SubagentStartedEvent` (`subagent_run_id`, +`name`, `description`, `parent_subagent_run_id`, `parent_tool_call_id`, +`parent_message_id`), `SubagentFinishedEvent` (`subagent_run_id`, `result`, +`outcome` = `SubagentFinishedSuccessOutcome | SubagentFinishedSuspendedOutcome`) +and `SubagentErrorEvent` (`subagent_run_id`, `message`, `code`) +(`events.py:455-512`), and every `TextMessage*` / `ToolCall*` / `Custom` event +carries an optional `subagent_run_id` (`events.py:127-314`). The endpoint +serializes with `EventEncoder` → `model_dump_json(by_alias=True)`, so the +snake_case fields reach the wire camelCased (confirmed in §3). + +## 2. Baseline (before the emitter) + +`RunAgentInput` POSTed to `/agent` (`Accept: text/event-stream`): + +```json +{"threadId":"capture-thread-2","runId":"capture-run-2", + "messages":[{"id":"u1","role":"user","content":"Plan a trip from LAX to JFK. One adult, economy, round trip, departing next Tuesday morning and returning Friday evening. Delegate to your subagents now; no clarifying questions."}], + "tools":[],"context":[],"state":{},"forwardedProps":{}} +``` + +(The e2e's bare prompt *"Plan a trip from LAX to JFK"* is enough under aimock +replay, but the live orchestrator answered it with five clarifying questions +and never called `task` — the system prompt tells it to ask when dates are +missing. The longer prompt above delegated on the first attempt: research → +booking → itinerary, exactly the prompt's prescribed order.) + +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-run-2"} +3 {"type":"STEP_STARTED","stepName":"orchestrator"} +7 {"type":"TOOL_CALL_START","toolCallId":"call_KUdUz8CR6t3X2NEb1ucXbntO","toolCallName":"task","parentMessageId":"lc_run--01a06367-6022-77a3-938b-65acb68640d4"} +9 {"type":"TOOL_CALL_ARGS","toolCallId":"call_KUdUz8CR6t3X2NEb1ucXbntO","delta":"{\""} + # [elided: 195 more TOOL_CALL_ARGS deltas spelling {"role":"research","task_description":"Gather current intel for a trip from LAX ... to JFK ..."}, each followed by its RAW on_chat_model_stream mirror] +400 {"type":"TOOL_CALL_END","toolCallId":"call_KUdUz8CR6t3X2NEb1ucXbntO"} +406 {"type":"STATE_SNAPSHOT", ...} +409 {"type":"STEP_FINISHED","stepName":"orchestrator"} +410 {"type":"STEP_STARTED","stepName":"tools"} +411 {"type":"RAW","event":{"event":"on_chain_start","name":"tools"}} +412 {"type":"RAW","event":{"event":"on_tool_start","name":"task"}} +413 {"type":"RAW","event":{"event":"on_custom_event","name":"subagent_activity"}} # phase=started +414 {"type":"ACTIVITY_SNAPSHOT","messageId":"call_KUdUz8CR6t3X2NEb1ucXbntO","activityType":"subagent","content":{"toolCallId":"call_KUdUz8CR6t3X2NEb1ucXbntO","name":"research","status":"running","text":""},"replace":true} +415 {"type":"RAW","event":{"event":"on_custom_event","name":"subagent_activity"}} # phase=message +416 {"type":"ACTIVITY_DELTA","messageId":"call_KUdUz8CR6t3X2NEb1ucXbntO","activityType":"subagent","patch":[{"op":"replace","path":"/text","value":"L"}]} + # [elided: 470 more RAW+ACTIVITY_DELTA pairs, each DELTA carrying the FULL accumulated text ("LAX", "LAX (", ... ) — quadratic bytes on the wire] +1357 {"type":"RAW","event":{"event":"on_custom_event","name":"subagent_activity"}} # phase=finished +1358 {"type":"ACTIVITY_DELTA","messageId":"call_KUdUz8CR6t3X2NEb1ucXbntO","activityType":"subagent","patch":[{"op":"replace","path":"/status","value":"complete"}]} +1359 {"type":"RAW","event":{"event":"on_tool_end","name":"task"}} +1360 {"type":"TOOL_CALL_RESULT","messageId":"d2c0584d-0046-49aa-8fe6-0859492dc35f","toolCallId":"call_KUdUz8CR6t3X2NEb1ucXbntO","content":"LAX/JFK basics: At LAX the major domestic carriers typically operate from these terminals ..."} +1362 {"type":"STATE_SNAPSHOT", ...} +1365 {"type":"STEP_FINISHED","stepName":"tools"} +1366 {"type":"STEP_STARTED","stepName":"orchestrator"} +1370 {"type":"TOOL_CALL_START","toolCallId":"call_Oh1rxCKsmmkoFHf9E5wQGEWx","toolCallName":"task", ...} + # [elided: booking round — shape-identical: ARGS×167 → TOOL_CALL_END (1707) → STEP_FINISHED/STARTED → ACTIVITY_SNAPSHOT name=booking (1721) → 1104 ACTIVITY_DELTA → status=complete (3931) → TOOL_CALL_RESULT (3933)] +3943 {"type":"TOOL_CALL_START","toolCallId":"call_4WqxTvu8atX6yZzxXsmiSTSz","toolCallName":"task", ...} + # [elided: itinerary round — ARGS×145 → TOOL_CALL_END (4236) → ACTIVITY_SNAPSHOT name=itinerary (4250) → 499 ACTIVITY_DELTA → status=complete (5250) → TOOL_CALL_RESULT (5252)] +5263 {"type":"TEXT_MESSAGE_START","messageId":"lc_run--01a06369-b8aa-7601-84c2-b7a96b9399cb","role":"assistant"} + # [elided: 144 TEXT_MESSAGE_CONTENT deltas — the ORCHESTRATOR's own final summary] +5555 {"type":"TEXT_MESSAGE_END","messageId":"lc_run--01a06369-b8aa-7601-84c2-b7a96b9399cb"} +5563 {"type":"STEP_STARTED","stepName":"generate_title"} +5570 {"type":"STEP_FINISHED","stepName":"generate_title"} +5572 {"type":"MESSAGES_SNAPSHOT", ...} +5573 {"type":"RUN_FINISHED","threadId":"capture-thread-2","runId":"01a06367-6020-7a61-bdc8-ffcea4df5a2b"} +``` + +Event tally (5,573 events): 1 RUN_STARTED, 8 STEP_STARTED, 8 STEP_FINISHED, +3 TOOL_CALL_START, 507 TOOL_CALL_ARGS, 3 TOOL_CALL_END, 3 ACTIVITY_SNAPSHOT, +2,077 ACTIVITY_DELTA, 3 TOOL_CALL_RESULT, 1 TEXT_MESSAGE_START, +144 TEXT_MESSAGE_CONTENT, 1 TEXT_MESSAGE_END, 9 STATE_SNAPSHOT, +1 MESSAGES_SNAPSHOT, 1 RUN_FINISHED, 2,803 RAW. No CUSTOM (the +`ActivityEmittingAgent` swallowed all 2,080 `subagent_activity` CUSTOM events +and emitted an ACTIVITY event in each one's place), no SUBAGENT_*. + +RAW breakdown: 2,080 `on_custom_event` (one mirror per `subagent_activity` +dispatch — the bridge yields `RawEvent(event=...)` for EVERY astream_events +item at `agent.py:404-406` before `_handle_single_event` translates it), +667 `on_chat_model_stream`, 16 `on_chain_stream`, 13 `on_chain_start`, +13 `on_chain_end`, 4 `on_chat_model_start`, 4 `on_chat_model_end`, +3 `on_tool_start`, 3 `on_tool_end`. + +### 2a. Ordering finding (design §6) + +**`TOOL_CALL_START` for `task` precedes the first ACTIVITY event by a wide +margin in every delegation round:** START at 7 / 1370 / 3943, the matching +ACTIVITY_SNAPSHOT at 414 / 1721 / 4250. Between them the bridge streams every +`TOOL_CALL_ARGS` delta, `TOOL_CALL_END`, a `STATE_SNAPSHOT`, and the +`STEP_FINISHED(orchestrator)` / `STEP_STARTED(tools)` pair — the tool body +only runs once LangGraph enters the `tools` node, and `on_tool_start` (412) is +the immediately preceding RAW mirror. `TOOL_CALL_END` therefore arrives BEFORE +the subagent runs (it marks the end of the args stream, not tool execution), +and the delegation window nests between `TOOL_CALL_END` and +`TOOL_CALL_RESULT` — same nesting as the Strands lane, opposite of the MAF +lane where END lands after the tool returns. The reducer's `parentToolCallId` +lookup will always find an already-announced tool call, so the card never +renders nameless. + +### 2b. Why the 1:1 `_dispatch_event` seam cannot carry the migration + +`ActivityEmittingAgent` overrode `LangGraphAgent._dispatch_event` +(`agent.py:159-165`), which is strictly one-event-in / one-event-out: it is +called inline as `yield self._dispatch_event(...)` at every yield site. The +standard sequence needs 1:N expansion — a `message_start` phase must open a +`TEXT_MESSAGE_START`, a `finished` phase must close the open message +(`TEXT_MESSAGE_END`) AND emit `SUBAGENT_FINISHED`, and the CUSTOM event itself +must be consumed (0 out). `LangGraphAgent.run(self, input: RunAgentInput) -> +AsyncGenerator[ProcessedEvents, None]` (`agent.py:167-178`) is the method +the FastAPI endpoint consumes (`endpoint.py:26`, `async for event in +request_agent.run(input_data)`), so wrapping `run` is the seam: iterate +`super().run(input)` and expand each event. No queue merge is needed — unlike +MAF, the graph's CUSTOM events already flow through this generator live +(they are `astream_events` items), so a straight `for out in expand(ev): +yield out` keeps the interleaving. + +## 3. Serializer probe + +From an UNCOMMITTED scratch `_dispatch_event` override that replaced the +`started` ACTIVITY_SNAPSHOT with a `SubagentStartedEvent(subagent_run_id= +f"{tid}-sub", name=..., parent_tool_call_id=tid)`, same prompt (the run +delegated three times again): + +``` +260 {"type":"SUBAGENT_STARTED","subagentRunId":"call_Tiif951yDSxR3bBrG1Tkuwnj-sub","name":"research","parentToolCallId":"call_Tiif951yDSxR3bBrG1Tkuwnj"} + # (TOOL_CALL_START for call_Tiif951yDSxR3bBrG1Tkuwnj at 7, TOOL_CALL_END at 246, STEP_STARTED(tools) at 256) +2617 {"type":"SUBAGENT_STARTED","subagentRunId":"call_KZQWQKoEDNcn3LpcTwjtmU4F-sub","name":"booking","parentToolCallId":"call_KZQWQKoEDNcn3LpcTwjtmU4F"} +5407 {"type":"SUBAGENT_STARTED","subagentRunId":"call_UJ5vqgEMAq6715iAuvCtLlUZ-sub","name":"itinerary","parentToolCallId":"call_UJ5vqgEMAq6715iAuvCtLlUZ"} +``` + +The stock `EventEncoder` camelCases the pydantic fields (`subagentRunId`, +`parentToolCallId`) with no extra configuration, and the ordering from §2a +held (START 7 → END 246 → SUBAGENT_STARTED 260). The scratch edit was reverted; +only this doc and the SDK bump land from Task 0. + +## After the emitter + +Captured 2026-09-02 against the shipped scenario (`SubagentEmittingAgent` +mounted in `src/server.py`, per-token `subagent_activity` deltas from +`SubagentStreamHandler`), same `RunAgentInput` as §2. No keys or org ids +appeared in the stream; only repetitive delta runs, `STATE_SNAPSHOT`s and the +bridge's RAW mirrors are elided, marked with `# [elided: ...]`. The model +delegated three times again (research → booking → itinerary); the first round +is shown, the other two are shape-identical. + +``` +1 {"type":"RUN_STARTED","threadId":"capture-thread-2","runId":"capture-run-2"} +3 {"type":"STEP_STARTED","stepName":"orchestrator"} +7 {"type":"TOOL_CALL_START","toolCallId":"call_CN5GEDy9byHqg18I9dAhnUDB","toolCallName":"task","parentMessageId":"lc_run--01a06373-7a61-7cb2-a616-3b1e3ee01e57"} +9 {"type":"TOOL_CALL_ARGS","toolCallId":"call_CN5GEDy9byHqg18I9dAhnUDB","delta":"{\""} + # [elided: 141 more TOOL_CALL_ARGS deltas spelling {"role":"research","task_description":"..."}] +292 {"type":"TOOL_CALL_END","toolCallId":"call_CN5GEDy9byHqg18I9dAhnUDB"} +301 {"type":"STEP_FINISHED","stepName":"orchestrator"} +302 {"type":"STEP_STARTED","stepName":"tools"} +304 {"type":"RAW","event":{"event":"on_tool_start","name":"task"}} +306 {"type":"SUBAGENT_STARTED","subagentRunId":"call_CN5GEDy9byHqg18I9dAhnUDB-sub","name":"research","parentToolCallId":"call_CN5GEDy9byHqg18I9dAhnUDB"} +308 {"type":"TEXT_MESSAGE_START","messageId":"call_CN5GEDy9byHqg18I9dAhnUDB-sub-m1","role":"assistant","subagentRunId":"call_CN5GEDy9byHqg18I9dAhnUDB-sub"} +310 {"type":"TEXT_MESSAGE_CONTENT","messageId":"call_CN5GEDy9byHqg18I9dAhnUDB-sub-m1","delta":"L","subagentRunId":"call_CN5GEDy9byHqg18I9dAhnUDB-sub"} +312 {"type":"TEXT_MESSAGE_CONTENT","messageId":"call_CN5GEDy9byHqg18I9dAhnUDB-sub-m1","delta":"AX","subagentRunId":"call_CN5GEDy9byHqg18I9dAhnUDB-sub"} + # [elided: 490 more TEXT_MESSAGE_CONTENT deltas — the CHILD's text, one raw token each, every one carrying subagentRunId] +1294 {"type":"TEXT_MESSAGE_END","messageId":"call_CN5GEDy9byHqg18I9dAhnUDB-sub-m1","subagentRunId":"call_CN5GEDy9byHqg18I9dAhnUDB-sub"} +1295 {"type":"SUBAGENT_FINISHED","subagentRunId":"call_CN5GEDy9byHqg18I9dAhnUDB-sub","outcome":{"type":"success"}} +1296 {"type":"RAW","event":{"event":"on_tool_end","name":"task"}} +1297 {"type":"TOOL_CALL_RESULT","messageId":"605c4334-e164-4569-86a4-f12476801d87","toolCallId":"call_CN5GEDy9byHqg18I9dAhnUDB","content":"LAX: Central Terminal Area with Terminals 1–8 plus the Tom Bradley International ..."} +1302 {"type":"STEP_FINISHED","stepName":"tools"} + # [elided: booking round — TOOL_CALL_START call_bCmw9AhTCKRuYWOLAb7hzTQF (1307) → ARGS → END (1586) → SUBAGENT_STARTED name=booking (1600) → TEXT_MESSAGE_START -sub-m1 (1602) → 710 deltas → TEXT_MESSAGE_END (3024) → SUBAGENT_FINISHED success (3025) → TOOL_CALL_RESULT (3027)] + # [elided: itinerary round — TOOL_CALL_START call_E725YycIoO2TUaKOug1lcdR7 (3037) → END (3312) → SUBAGENT_STARTED name=itinerary (3326) → 276 deltas → TEXT_MESSAGE_END (3882) → SUBAGENT_FINISHED success (3883) → TOOL_CALL_RESULT (3885)] +3896 {"type":"TEXT_MESSAGE_START","messageId":"lc_run--01a06375-457d-7611-adea-cd992b954a70","role":"assistant"} + # [elided: 243 TEXT_MESSAGE_CONTENT deltas — the ORCHESTRATOR's own summary, no subagentRunId] +4386 {"type":"TEXT_MESSAGE_END","messageId":"lc_run--01a06375-457d-7611-adea-cd992b954a70"} +4403 {"type":"MESSAGES_SNAPSHOT", ...} +4404 {"type":"RUN_FINISHED","threadId":"capture-thread-2","runId":"01a06373-7a57-7222-b98e-9e82a76738a9"} +``` + +Event tally (4,404 events): 1 RUN_STARTED, 8 STEP_STARTED, 8 STEP_FINISHED, +3 TOOL_CALL_START, 415 TOOL_CALL_ARGS, 3 TOOL_CALL_END, 3 SUBAGENT_STARTED, +3 TEXT_MESSAGE_START(sub), 1,478 TEXT_MESSAGE_CONTENT(sub), +3 TEXT_MESSAGE_END(sub), 3 SUBAGENT_FINISHED, 3 TOOL_CALL_RESULT, +1 TEXT_MESSAGE_START, 243 TEXT_MESSAGE_CONTENT, 1 TEXT_MESSAGE_END, +9 STATE_SNAPSHOT, 1 MESSAGES_SNAPSHOT, 1 RUN_FINISHED, 2,217 RAW. No CUSTOM, +no ACTIVITY_*, no SUBAGENT_ERROR. + +**Child deltas: streaming, one raw token per event** (1,478 attributed content +events across three rounds: 492 + 710 + 276) — the §2 accumulator is gone and +each delta is a few bytes instead of the full text-so-far. Every child event +carries `subagentRunId` derived from the wire `toolCallId` (`-sub` +/ `-sub-m1`), `SUBAGENT_STARTED.parentToolCallId` matches the +bridge-native `TOOL_CALL_START.toolCallId` verbatim, and the `TOOL_CALL_RESULT` +content equals the joined child deltas. The `subagent_activity` CUSTOM events +were consumed (0 on the wire); their RAW `on_custom_event` mirrors (1,487) +still pass through because the bridge yields them before +`_handle_single_event` — the same mirror the ACTIVITY pipeline shipped, and +the client ignores RAW. + +**Measured order, `TOOL_CALL_START` vs `SUBAGENT_STARTED`:** START 7 → END +292 → SUBAGENT_STARTED 306 (and 1307 → 1586 → 1600; 3037 → 3312 → 3326). The +tool call is fully announced (start, args, end) before the tool body runs, +so the reducer attaches the card to an already-known `parentToolCallId`; the +`SUBAGENT_*` block nests between `TOOL_CALL_END` and `TOOL_CALL_RESULT`. + +## Browser verification + +2026-09-02, live backend (real `OPENAI_API_KEY`, uvicorn on :5326) + `npx nx +serve cockpit-ag-ui-subagents-angular --port 4326`, driven headlessly with +Playwright (the §2 prompt typed into the composer). Screenshot, taken while +the research card was still `running`: +`cockpit/ag-ui/subagents/angular/e2e/manual/subagent-card-live.png`. + +What rendered: the `task` dispatch produced an inline `` +anchored to its tool call — header `research` + wire `toolCallId` + +`running` badge + "1 message(s)" — with the specialist's transcript streaming +inside it, then booking and itinerary cards in turn, then the orchestrator's +own summary bubble. The child text never leaked into the parent bubble, and +each card persists (collapsed, `complete`) after its subagent finishes. + +Did the card text stream mid-run: **yes**. Polling `agent.subagents()` and +the card's `innerText` every 150ms showed the research card mount at t≈8.9s +(empty, `running` — `SUBAGENT_STARTED` lands before the child's first token; +gpt-5-mini's reasoning latency kept it empty until t≈47.7s) and then grow +monotonically while `running`: message lengths 22 → 56 → 113 → 147 → 223 → +262 → 299 → 330 → 367 → 401 → 431 → 506 → 540 chars across consecutive +150ms samples (t≈47.7s → 49.6s), reaching 5,336 chars before flipping to +`complete` and collapsing (card `innerText` 592 → 58 chars). Booking +(2,890 chars) and itinerary (1,206 chars) behaved identically. This confirms +the attributed `TEXT_MESSAGE_CONTENT` deltas render progressively in the +card, not as one post-hoc paste. diff --git a/cockpit/ag-ui/subagents/python/prompts/subagents.md b/cockpit/ag-ui/subagents/python/prompts/subagents.md index 07c8feb6f..6e5a2c4f1 100644 --- a/cockpit/ag-ui/subagents/python/prompts/subagents.md +++ b/cockpit/ag-ui/subagents/python/prompts/subagents.md @@ -12,8 +12,8 @@ The three roles, in the order you should always call them: When the user asks about a trip (e.g., "plan a trip from LAX to JFK" or "I want to fly from Boston to Miami next week"), call task() three times in that order, then summarize the final plan in 1-2 sentences. Each subagent -dispatch surfaces a live subagent card in the UI: the backend converts the -subagent's streamed tokens into native AG-UI ACTIVITY events, which the +dispatch surfaces a live subagent card in the UI: the backend emits the +subagent's streamed tokens as standard AG-UI subagent events, which the `@threadplane/ag-ui` reducer projects onto `agent.subagents()` for the `` primitive to render. diff --git a/cockpit/ag-ui/subagents/python/pyproject.toml b/cockpit/ag-ui/subagents/python/pyproject.toml index 3af16bc7f..c7f13209b 100644 --- a/cockpit/ag-ui/subagents/python/pyproject.toml +++ b/cockpit/ag-ui/subagents/python/pyproject.toml @@ -7,6 +7,7 @@ dependencies = [ "langchain-openai>=0.3", "langsmith>=0.2", "ag-ui-langgraph>=0.0.25", + "ag-ui-protocol>=0.1.22", "fastapi>=0.110", "uvicorn[standard]>=0.29", ] diff --git a/cockpit/ag-ui/subagents/python/requirements.txt b/cockpit/ag-ui/subagents/python/requirements.txt index 51b619083..5562b76a1 100644 --- a/cockpit/ag-ui/subagents/python/requirements.txt +++ b/cockpit/ag-ui/subagents/python/requirements.txt @@ -5,8 +5,10 @@ ag-ui-a2ui-toolkit==0.0.1 # via ag-ui-langgraph ag-ui-langgraph==0.0.37 # via cockpit-ag-ui-subagents -ag-ui-protocol==0.1.19 - # via ag-ui-langgraph +ag-ui-protocol==0.1.22 + # via + # ag-ui-langgraph + # cockpit-ag-ui-subagents annotated-doc==0.0.4 # via fastapi annotated-types==0.7.0 diff --git a/cockpit/ag-ui/subagents/python/src/graph.py b/cockpit/ag-ui/subagents/python/src/graph.py index 385731896..68ca9c542 100644 --- a/cockpit/ag-ui/subagents/python/src/graph.py +++ b/cockpit/ag-ui/subagents/python/src/graph.py @@ -4,10 +4,13 @@ Mirrors cockpit/chat/subagents' orchestrator + `task` tool + `_run_subagent` structure, but each dispatch emits `subagent_activity` CUSTOM events (like the -examples/ag-ui `research` tool): `started` before the run, `message` per -streamed token (via SubagentStreamHandler), `finished` after. The backend's -ActivityEmittingAgent converts those CUSTOM events into native AG-UI ACTIVITY -events, which the @threadplane/ag-ui reducer projects onto agent.subagents(). +examples/ag-ui `research` tool): `started {name}` before the run, +`message_start {message_id}` + `message {message_id, delta}` per streamed +token (via SubagentStreamHandler), `finished` after — or `error {message}` if +the child fails. 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, which the +@threadplane/ag-ui reducer projects onto agent.subagents(). Self-contained: no imports from examples/ or other cockpit capabilities. """ @@ -111,8 +114,9 @@ async def _run_subagent( tool_call_id: str, ) -> str: """Run a single subagent LLM, streaming its tokens through - SubagentStreamHandler so they surface as `subagent_activity` `message` - events keyed by the parent tool_call_id.""" + SubagentStreamHandler so they surface as `subagent_activity` + `message_start` / `message` (per-token delta) events keyed by the parent + tool_call_id.""" llm = ChatOpenAI(model="gpt-5-mini", streaming=True) messages = [ SystemMessage(content=system_prompt), @@ -157,9 +161,10 @@ async def task( Returns: The subagent's final answer as a string. - The subagent run is surfaced to the UI as a native AG-UI ACTIVITY - (activityType "subagent"): started → message-per-token → finished, keyed - by this tool's own call id. + The subagent run is surfaced to the UI as the protocol's standard + subagent events: SUBAGENT_STARTED → attributed TEXT_MESSAGE_* per token → + SUBAGENT_FINISHED (or SUBAGENT_ERROR), with ids derived from this tool's + own call id (`-sub`). """ async def _emit(payload: dict) -> None: @@ -180,7 +185,13 @@ async def _emit(payload: dict) -> None: return f"Unknown role: {role}" await _emit({"phase": "started", "name": role}) - result = await _run_subagent(role, task_description, system_prompt, tool_call_id) + try: + result = await _run_subagent(role, task_description, system_prompt, tool_call_id) + except Exception as exc: + # Surface the failure on the subagent card (SUBAGENT_ERROR), then + # re-raise so the bridge's own tool-error path still runs. + await _emit({"phase": "error", "message": f"{type(exc).__name__}: {exc}"}) + raise await _emit({"phase": "finished", "status": "complete"}) return result diff --git a/cockpit/ag-ui/subagents/python/src/server.py b/cockpit/ag-ui/subagents/python/src/server.py index a2fdad067..a9e0608c4 100644 --- a/cockpit/ag-ui/subagents/python/src/server.py +++ b/cockpit/ag-ui/subagents/python/src/server.py @@ -2,12 +2,14 @@ from fastapi import FastAPI from ag_ui_langgraph import add_langgraph_fastapi_endpoint from .graph import graph -from .streaming.activity_emitting_agent import ActivityEmittingAgent +from .streaming.subagent_emitting_agent import SubagentEmittingAgent -# ActivityEmittingAgent subclasses the ag-ui-langgraph bridge to convert the -# graph's `subagent_activity` CUSTOM events into native AG-UI ACTIVITY events -# (snapshot/delta) so the chat composition renders a live subagent card. -agent = ActivityEmittingAgent(name="subagents", graph=graph) +# SubagentEmittingAgent subclasses the ag-ui-langgraph bridge and wraps its +# run() generator to expand the graph's `subagent_activity` CUSTOM events into +# the protocol's standard SUBAGENT_STARTED / TEXT_MESSAGE_* (attributed via +# subagentRunId) / SUBAGENT_FINISHED / SUBAGENT_ERROR events, so the chat +# composition renders a live subagent card. +agent = SubagentEmittingAgent(name="subagents", graph=graph) app = FastAPI(title="cockpit-ag-ui-subagents") add_langgraph_fastapi_endpoint(app, agent, path="/agent") diff --git a/cockpit/ag-ui/subagents/python/src/streaming/activity_emitting_agent.py b/cockpit/ag-ui/subagents/python/src/streaming/activity_emitting_agent.py deleted file mode 100644 index 2d8b6e203..000000000 --- a/cockpit/ag-ui/subagents/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 .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/cockpit/ag-ui/subagents/python/src/streaming/activity_transform.py b/cockpit/ag-ui/subagents/python/src/streaming/activity_transform.py deleted file mode 100644 index dd09c01ea..000000000 --- a/cockpit/ag-ui/subagents/python/src/streaming/activity_transform.py +++ /dev/null @@ -1,58 +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): the handler sends accumulated `text_so_far`, so each -DELTA carries the full text via JSON-patch `replace` (JSON-patch has no string -append). Anything that is not a `subagent_activity` CUSTOM event returns 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", "text": ""}, - replace=True, - ) - if phase == "message": - return ActivityDeltaEvent( - type=EventType.ACTIVITY_DELTA, - message_id=sid, - activity_type=ACTIVITY_TYPE, - patch=[{"op": "replace", "path": "/text", "value": value.get("text", "")}], - ) - 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/cockpit/ag-ui/subagents/python/src/streaming/subagent_emitting_agent.py b/cockpit/ag-ui/subagents/python/src/streaming/subagent_emitting_agent.py new file mode 100644 index 000000000..42f765689 --- /dev/null +++ b/cockpit/ag-ui/subagents/python/src/streaming/subagent_emitting_agent.py @@ -0,0 +1,193 @@ +# SPDX-License-Identifier: MIT +"""SUBAGENT_* emitter for the `task` delegation tool. + +The graph cannot reach the AG-UI wire directly: the `task` 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 ``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 ``task`` +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 / finished / error) → TEXT_MESSAGE_END for any open message first + 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 (unlike the MAF lane): 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). + +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 +from typing import Any, AsyncGenerator, Iterator + +from ag_ui.core import ( + BaseEvent, + EventType, + SubagentErrorEvent, + SubagentFinishedEvent, + SubagentFinishedSuccessOutcome, + SubagentStartedEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, +) +from ag_ui_langgraph import LangGraphAgent + +CUSTOM_NAME = "subagent_activity" + +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 + + +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 _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_* events. + + 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]: + delegations: dict[str, _Delegation] = {} + async for event in super().run(*args, **kwargs): + for out in self._expand(event, delegations): + yield out + + def _expand(self, event: BaseEvent, delegations: dict[str, _Delegation]) -> Iterator[BaseEvent]: + payload = _payload(event) + if payload is None: + 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 = delegations.get(tid) + if delegation is None: + delegation = _Delegation(run_id=_subagent_run_id(tid)) + delegations[tid] = delegation + run_id = delegation.run_id + + if phase == "started": + 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 == "finished": + yield from self._close_message(delegation) + yield SubagentFinishedEvent( + type=EventType.SUBAGENT_FINISHED, + subagent_run_id=run_id, + outcome=SubagentFinishedSuccessOutcome(), + ) + elif phase == "error": + yield from self._close_message(delegation) + 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 _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/cockpit/ag-ui/subagents/python/src/streaming/subagent_stream_handler.py b/cockpit/ag-ui/subagents/python/src/streaming/subagent_stream_handler.py index 09a1623dd..684a575b7 100644 --- a/cockpit/ag-ui/subagents/python/src/streaming/subagent_stream_handler.py +++ b/cockpit/ag-ui/subagents/python/src/streaming/subagent_stream_handler.py @@ -1,8 +1,18 @@ -"""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).""" +"""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, before the first token + message {subagent_id, message_id, delta} one per token (raw delta) + +`SubagentEmittingAgent` turns those into `subagentRunId`-attributed +TEXT_MESSAGE_START / TEXT_MESSAGE_CONTENT events; it closes the message +(TEXT_MESSAGE_END) itself on `finished` / `error`. `started` / `finished` / +`error` are emitted by the `task` tool body. The message id follows the +`-sub-m` convention; this demo's child runs a single +completion, so n is always 1. + +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 from uuid import UUID @@ -12,16 +22,22 @@ class SubagentStreamHandler(AsyncCallbackHandler): def __init__(self, subagent_id: str) -> None: self._id = subagent_id - self._buffer = "" + self._message_id = f"{subagent_id}-sub-m1" + self._message_open = False async def on_llm_new_token(self, token: str, *, run_id: UUID | None = None, **kwargs: Any) -> None: if not token: return - self._buffer += token try: + if not self._message_open: + self._message_open = True + await adispatch_custom_event( + "subagent_activity", + {"subagent_id": self._id, "phase": "message_start", "message_id": self._message_id}, + ) await adispatch_custom_event( "subagent_activity", - {"subagent_id": self._id, "phase": "message", "text": self._buffer}, + {"subagent_id": self._id, "phase": "message", "message_id": self._message_id, "delta": token}, ) except Exception: return # no ambient run context (some unit-test paths) — best-effort diff --git a/cockpit/ag-ui/subagents/python/tests/test_activity_transform.py b/cockpit/ag-ui/subagents/python/tests/test_activity_transform.py deleted file mode 100644 index ea30b93fc..000000000 --- a/cockpit/ag-ui/subagents/python/tests/test_activity_transform.py +++ /dev/null @@ -1,89 +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", "text": ""} - assert ev.replace is True - - -def test_message_maps_to_activity_delta_replace_text(): - ev = subagent_custom_to_activity(_custom( - {"subagent_id": "tc-1", "phase": "message", "text": "Paris is"})) - assert ev.type == EventType.ACTIVITY_DELTA - assert ev.message_id == "tc-1" - assert ev.patch == [{"op": "replace", "path": "/text", "value": "Paris is"}] - - -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_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/cockpit/ag-ui/subagents/python/tests/test_subagent_emitting_agent.py b/cockpit/ag-ui/subagents/python/tests/test_subagent_emitting_agent.py new file mode 100644 index 000000000..2de7c5f56 --- /dev/null +++ b/cockpit/ag-ui/subagents/python/tests/test_subagent_emitting_agent.py @@ -0,0 +1,356 @@ +"""Tests for SubagentEmittingAgent — the run-wrapping emitter that expands the +graph's `subagent_activity` CUSTOM events (started / message_start / message / +finished / error) into the protocol's standard SUBAGENT_* + attributed +TEXT_MESSAGE_* events. Drives the wrapper with a scripted inner +`LangGraphAgent.run` generator and asserts the exact output sequence +field-for-field.""" +import logging +from typing import Any + +import pytest +from ag_ui.core import ( + CustomEvent, + EventType, + RunAgentInput, + RunFinishedEvent, + RunStartedEvent, + 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" +MESSAGE_ID = f"{TID}-sub-m1" + + +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): + return [ + ToolCallStartEvent(type=EventType.TOOL_CALL_START, tool_call_id=tid, tool_call_name="task"), + ToolCallArgsEvent(type=EventType.TOOL_CALL_ARGS, tool_call_id=tid, delta='{"role":"research"}'), + 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 + ) + + +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="subagents", graph=_graph()) + return [ev async for ev in agent.run(_input())] + + +async def test_expands_one_delegation_field_for_field(monkeypatch): + script = [ + _run_started(), + *_tool_call(TID), + _activity({"subagent_id": TID, "phase": "started", "name": "research"}), + _activity({"subagent_id": TID, "phase": "message_start", "message_id": MESSAGE_ID}), + _activity({"subagent_id": TID, "phase": "message", "message_id": MESSAGE_ID, "delta": "Paris "}), + _activity({"subagent_id": TID, "phase": "message", "message_id": MESSAGE_ID, "delta": "is"}), + _activity({"subagent_id": TID, "phase": "finished", "status": "complete"}), + _tool_result(TID, "Paris is"), + _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, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_FINISHED, + EventType.TOOL_CALL_RESULT, + 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 + + start = out[5] + assert start.message_id == MESSAGE_ID + assert start.role == "assistant" + assert start.subagent_run_id == RUN_ID + + deltas = out[6:8] + assert [ev.delta for ev in deltas] == ["Paris ", "is"] + for ev in deltas: + assert ev.message_id == MESSAGE_ID + assert ev.subagent_run_id == RUN_ID + + end = out[8] + assert end.message_id == MESSAGE_ID + assert end.subagent_run_id == RUN_ID + + finished = out[9] + 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[10] is script[9] + assert out[10].subagent_run_id is None + + +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": "booking"}'), + _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 == "booking" + + +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": MESSAGE_ID}), + _activity({"subagent_id": TID, "phase": "message", "message_id": MESSAGE_ID, "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 == MESSAGE_ID + 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_second_message_start_closes_the_first(monkeypatch): + m2 = f"{TID}-sub-m2" + script = [ + _activity({"subagent_id": TID, "phase": "started", "name": "research"}), + _activity({"subagent_id": TID, "phase": "message_start", "message_id": MESSAGE_ID}), + _activity({"subagent_id": TID, "phase": "message", "message_id": MESSAGE_ID, "delta": "a"}), + _activity({"subagent_id": TID, "phase": "message_start", "message_id": m2}), + _activity({"subagent_id": TID, "phase": "message", "message_id": m2, "delta": "b"}), + _activity({"subagent_id": TID, "phase": "finished"}), + ] + out = await _collect(monkeypatch, script) + assert [(ev.type, getattr(ev, "message_id", None)) for ev in out] == [ + (EventType.SUBAGENT_STARTED, None), + (EventType.TEXT_MESSAGE_START, MESSAGE_ID), + (EventType.TEXT_MESSAGE_CONTENT, MESSAGE_ID), + (EventType.TEXT_MESSAGE_END, MESSAGE_ID), + (EventType.TEXT_MESSAGE_START, m2), + (EventType.TEXT_MESSAGE_CONTENT, m2), + (EventType.TEXT_MESSAGE_END, m2), + (EventType.SUBAGENT_FINISHED, None), + ] + + +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] == [f"{TID}-sub-m1", f"{TID}-sub-m2"] + content = [ev for ev in out if ev.type == EventType.TEXT_MESSAGE_CONTENT] + assert content[0].message_id == f"{TID}-sub-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": MESSAGE_ID, "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 == MESSAGE_ID + + +async def test_two_sequential_delegations_get_distinct_run_ids(monkeypatch): + script = [ + _run_started(), + *_tool_call(TID), + _activity({"subagent_id": TID, "phase": "started", "name": "research"}), + _activity({"subagent_id": TID, "phase": "message_start", "message_id": MESSAGE_ID}), + _activity({"subagent_id": TID, "phase": "message", "message_id": MESSAGE_ID, "delta": "intel"}), + _activity({"subagent_id": TID, "phase": "finished"}), + _tool_result(TID, "intel"), + *_tool_call(TID2), + _activity({"subagent_id": TID2, "phase": "started", "name": "booking"}), + _activity({"subagent_id": TID2, "phase": "message_start", "message_id": f"{TID2}-sub-m1"}), + _activity({"subagent_id": TID2, "phase": "message", "message_id": f"{TID2}-sub-m1", "delta": "flights"}), + _activity({"subagent_id": TID2, "phase": "finished"}), + _tool_result(TID2, "flights"), + _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] + assert [ev.name for ev in started] == ["research", "booking"] + + 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"] + expected = [ + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_FINISHED, + ] + 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 hasattr(ev, "message_id")} == {f"{TID}-sub-m1"} + assert {ev.message_id for ev in b if hasattr(ev, "message_id")} == {f"{TID2}-sub-m1"} + # Every child event sits between its own tool call's END and RESULT. + types = [ev.type for ev in out] + tool_results = [i for i, ev in enumerate(out) if ev.type == EventType.TOOL_CALL_RESULT] + assert types.index(EventType.SUBAGENT_STARTED) > types.index(EventType.TOOL_CALL_END) + assert types.index(EventType.SUBAGENT_FINISHED) < tool_results[0] + + +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": "tool_call", "tool_call_id": "x"}), + _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("tool_call" 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 + _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. + script = [ + _activity({"subagent_id": TID, "phase": "started", "name": "research"}), + _activity({"subagent_id": TID, "phase": "message_start", "message_id": MESSAGE_ID}), + _activity({"subagent_id": TID, "phase": "message", "message_id": MESSAGE_ID, "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="subagents", graph=_graph()) + first = [ev async for ev in agent.run(_input())] + assert first[-1].type == EventType.TEXT_MESSAGE_CONTENT + + script[:] = [_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 unknown + # delegation's finished is still expanded (bracketing the card). + assert [ev.type for ev in second] == [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="subagents", 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) diff --git a/cockpit/ag-ui/subagents/python/tests/test_subagent_stream_handler.py b/cockpit/ag-ui/subagents/python/tests/test_subagent_stream_handler.py index ef2aec688..27370ec80 100644 --- a/cockpit/ag-ui/subagents/python/tests/test_subagent_stream_handler.py +++ b/cockpit/ag-ui/subagents/python/tests/test_subagent_stream_handler.py @@ -1,5 +1,8 @@ -"""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 derived +message id) before the first token, 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).""" from unittest.mock import AsyncMock, patch from uuid import uuid4 @@ -7,33 +10,55 @@ from src.streaming.subagent_stream_handler import SubagentStreamHandler +TID = "call_7sxPY1sC236nPyHRTWAZMJB9" +MESSAGE_ID = f"{TID}-sub-m1" + 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", "text": "Paris "}) - assert dispatch.call_args_list[1].args == ( - "subagent_activity", {"subagent_id": "tc-1", "phase": "message", "text": "Paris is"}) + assert [c.args for c in dispatch.call_args_list] == [ + ("subagent_activity", + {"subagent_id": TID, "phase": "message_start", "message_id": MESSAGE_ID}), + ("subagent_activity", + {"subagent_id": TID, "phase": "message", "message_id": MESSAGE_ID, "delta": "Paris "}), + ("subagent_activity", + {"subagent_id": TID, "phase": "message", "message_id": MESSAGE_ID, "delta": "is"}), + ] + + @pytest.mark.asyncio + 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 handler.on_llm_new_token("", run_id=uuid4()) + assert dispatch.call_args_list == [] @pytest.mark.asyncio - async def test_buffers_isolated_across_instances(self): + 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()) - assert dispatch.call_args_list[0].args[1]["text"] == "x" - assert dispatch.call_args_list[1].args[1]["text"] == "y" + 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 diff --git a/cockpit/ag-ui/subagents/python/uv.lock b/cockpit/ag-ui/subagents/python/uv.lock index 7fcd6ebfe..12f400bdb 100644 --- a/cockpit/ag-ui/subagents/python/uv.lock +++ b/cockpit/ag-ui/subagents/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]] @@ -171,6 +171,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "ag-ui-langgraph" }, + { name = "ag-ui-protocol" }, { name = "fastapi" }, { name = "langchain-openai" }, { name = "langgraph" }, @@ -187,6 +188,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "ag-ui-langgraph", specifier = ">=0.0.25" }, + { name = "ag-ui-protocol", specifier = ">=0.1.22" }, { name = "fastapi", specifier = ">=0.110" }, { name = "langchain-openai", specifier = ">=0.3" }, { name = "langgraph", specifier = ">=0.3" }, diff --git a/deployments/ag-ui-dev/deps/subagents/docs/guide.md b/deployments/ag-ui-dev/deps/subagents/docs/guide.md index 555518aed..9e95455d9 100644 --- a/deployments/ag-ui-dev/deps/subagents/docs/guide.md +++ b/deployments/ag-ui-dev/deps/subagents/docs/guide.md @@ -4,13 +4,14 @@ Render live subagent cards in an Angular chat UI using `provideAgent()` and `injectAgent()` from `@threadplane/ag-ui`. An orchestrator LangGraph agent delegates focused subtasks to specialized subagents via a `task` tool; the -backend converts each subagent's streamed tokens into native AG-UI ACTIVITY -events, which the `@threadplane/ag-ui` reducer projects onto -`agent.subagents()` for the `` primitive to render. +backend emits each subagent's run as the protocol's standard `SUBAGENT_STARTED`, +`subagentRunId`-attributed `TEXT_MESSAGE_*` and `SUBAGENT_FINISHED` events, +which the `@threadplane/ag-ui` reducer projects onto `agent.subagents()` for +the `` primitive to render. -Add subagent cards to this Angular component using `@threadplane/ag-ui`. Configure `provideAgent({ url })` in the app config, call `injectAgent()` in the component, and pass the agent to the `` component from `@threadplane/chat`. The orchestrator graph dispatches subagents with a `task` tool and emits `subagent_activity` custom events; the backend's `ActivityEmittingAgent` converts those into native AG-UI ACTIVITY events so the chat composition renders a live card per subagent — all Signals, no subscriptions needed. +Add subagent cards to this Angular component using `@threadplane/ag-ui`. Configure `provideAgent({ url })` in the app config, call `injectAgent()` in the component, and pass the agent to the `` component from `@threadplane/chat`. The orchestrator graph dispatches subagents with a `task` tool and emits `subagent_activity` custom events; the backend's `SubagentEmittingAgent` expands those into the standard AG-UI `SUBAGENT_STARTED` / attributed `TEXT_MESSAGE_*` / `SUBAGENT_FINISHED` events so the chat composition renders a live card per subagent — all Signals, no subscriptions needed. @@ -62,10 +63,11 @@ export class SubagentsComponent { The orchestrator binds a `task` tool that dispatches a role-specific -subagent. Before running the subagent it emits a `started` activity; while -the subagent streams it forwards each token as a `message` activity (via -`SubagentStreamHandler`); after it emits a `finished` activity — all keyed -by the tool's own call id: +subagent. Before running the subagent it emits a `started` payload; while +the subagent streams, `SubagentStreamHandler` forwards a `message_start` +once and then one `message` per token (the raw delta); after it emits +`finished` — or `error` if the child fails — all keyed by the tool's own +call id: ```python # graph.py @@ -79,36 +81,56 @@ async def task(role, task_description, tool_call_id: Annotated[str, InjectedTool "subagent_activity", {"subagent_id": tool_call_id, "phase": "started", "name": role}, ) - result = await _run_subagent( - role, task_description, - config={"callbacks": [SubagentStreamHandler(tool_call_id)]}, - ) + try: + result = await _run_subagent( + role, task_description, + config={"callbacks": [SubagentStreamHandler(tool_call_id)]}, + ) + except Exception as exc: + await adispatch_custom_event( + "subagent_activity", + {"subagent_id": tool_call_id, "phase": "error", "message": str(exc)}, + ) + raise await adispatch_custom_event( "subagent_activity", - {"subagent_id": tool_call_id, "phase": "finished", "status": "complete"}, + {"subagent_id": tool_call_id, "phase": "finished"}, ) return result ``` - + The backend wraps the LangGraph `graph` in a FastAPI app using -`ag-ui-langgraph`. `ActivityEmittingAgent` subclasses the bridge's -`LangGraphAgent` and converts each `subagent_activity` CUSTOM event into a -native AG-UI ACTIVITY event (snapshot/delta) at the bridge's 1:1 dispatch -point: +`ag-ui-langgraph`. `SubagentEmittingAgent` subclasses the bridge's +`LangGraphAgent` and wraps its `run()` generator, expanding each +`subagent_activity` CUSTOM event into the protocol's standard events (ids +derived from the `task` tool call id, `tid`): + +| phase | wire event | +| --- | --- | +| `started {name}` | `SUBAGENT_STARTED {subagentRunId: -sub, name, parentToolCallId: }` | +| `message_start {message_id}` | `TEXT_MESSAGE_START {messageId: -sub-m1, role: assistant, subagentRunId}` | +| `message {message_id, delta}` | `TEXT_MESSAGE_CONTENT {messageId, delta, subagentRunId}` | +| `finished` | `TEXT_MESSAGE_END` for the open message, then `SUBAGENT_FINISHED {outcome: success}` | +| `error {message}` | `TEXT_MESSAGE_END` for the open message, then `SUBAGENT_ERROR {message}` | + +The CUSTOM event itself is consumed; every other bridge event passes through +untouched. Because `parentToolCallId` equals the bridge-native +`TOOL_CALL_START.toolCallId` (which is fully streamed before the tool body +runs), the reducer anchors the card to the `task` call with no bookkeeping: ```python # server.py from fastapi import FastAPI from ag_ui_langgraph import add_langgraph_fastapi_endpoint from .graph import graph -from .streaming.activity_emitting_agent import ActivityEmittingAgent +from .streaming.subagent_emitting_agent import SubagentEmittingAgent app = FastAPI(title="cockpit-ag-ui-subagents") add_langgraph_fastapi_endpoint( - app, ActivityEmittingAgent(name="subagents", graph=graph), path="/agent" + app, SubagentEmittingAgent(name="subagents", graph=graph), path="/agent" ) @app.get("/ok") @@ -126,6 +148,10 @@ uv run uvicorn src.server:app --port 5326 A checkpointer is required for `ag-ui-langgraph` to work. Without it, the library cannot call `graph.aget_state()`. The graph in `src/graph.py` uses `MemorySaver` for development. + +The AG-UI encoder only serializes pydantic `ag_ui.core` event classes — yielding a raw dict crashes the stream. `SubagentEmittingAgent` constructs `SubagentStartedEvent`, `TextMessageStartEvent`, and friends from `ag_ui.core` (`ag-ui-protocol>=0.1.22`, the first release with `subagent_run_id` on every event). + + diff --git a/deployments/ag-ui-dev/deps/subagents/docs/wire-capture-subagents.md b/deployments/ag-ui-dev/deps/subagents/docs/wire-capture-subagents.md new file mode 100644 index 000000000..267aea8da --- /dev/null +++ b/deployments/ag-ui-dev/deps/subagents/docs/wire-capture-subagents.md @@ -0,0 +1,247 @@ +# AG-UI subagents (LangGraph): wire capture + emitter-seam decision + +Evidence for migrating this demo from the private ACTIVITY convention +(`ACTIVITY_SNAPSHOT`/`ACTIVITY_DELTA` with `activityType: "subagent"`) to the +protocol's standard `SUBAGENT_*` events plus `subagentRunId`-attributed +`TEXT_MESSAGE_*` events. Captured 2026-09-02 against the live backend +(`src/server.py`, `uv run uvicorn src.server:app --port 5326`, real +`OPENAI_API_KEY`, `gpt-5-mini` for orchestrator and subagents) with +`ag-ui-langgraph 0.0.37` and `ag-ui-protocol 0.1.22` (bumped in the same +commit as this doc; the previous transitive pin was 0.1.19). + +All bridge citations are into the installed venv source: +`.venv/lib/python3.14/site-packages/ag_ui_langgraph/agent.py` and +`.venv/lib/python3.14/site-packages/ag_ui/core/events.py`. + +## 1. SDK check + +``` +$ uv run python -c "from ag_ui.core import SubagentStartedEvent, TextMessageContentEvent; print(TextMessageContentEvent.model_fields['subagent_run_id'])" +annotation=Union[str, NoneType] required=False default=None alias='subagentRunId' alias_priority=1 +``` + +`ag-ui-protocol 0.1.22` ships `SubagentStartedEvent` (`subagent_run_id`, +`name`, `description`, `parent_subagent_run_id`, `parent_tool_call_id`, +`parent_message_id`), `SubagentFinishedEvent` (`subagent_run_id`, `result`, +`outcome` = `SubagentFinishedSuccessOutcome | SubagentFinishedSuspendedOutcome`) +and `SubagentErrorEvent` (`subagent_run_id`, `message`, `code`) +(`events.py:455-512`), and every `TextMessage*` / `ToolCall*` / `Custom` event +carries an optional `subagent_run_id` (`events.py:127-314`). The endpoint +serializes with `EventEncoder` → `model_dump_json(by_alias=True)`, so the +snake_case fields reach the wire camelCased (confirmed in §3). + +## 2. Baseline (before the emitter) + +`RunAgentInput` POSTed to `/agent` (`Accept: text/event-stream`): + +```json +{"threadId":"capture-thread-2","runId":"capture-run-2", + "messages":[{"id":"u1","role":"user","content":"Plan a trip from LAX to JFK. One adult, economy, round trip, departing next Tuesday morning and returning Friday evening. Delegate to your subagents now; no clarifying questions."}], + "tools":[],"context":[],"state":{},"forwardedProps":{}} +``` + +(The e2e's bare prompt *"Plan a trip from LAX to JFK"* is enough under aimock +replay, but the live orchestrator answered it with five clarifying questions +and never called `task` — the system prompt tells it to ask when dates are +missing. The longer prompt above delegated on the first attempt: research → +booking → itinerary, exactly the prompt's prescribed order.) + +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-run-2"} +3 {"type":"STEP_STARTED","stepName":"orchestrator"} +7 {"type":"TOOL_CALL_START","toolCallId":"call_KUdUz8CR6t3X2NEb1ucXbntO","toolCallName":"task","parentMessageId":"lc_run--01a06367-6022-77a3-938b-65acb68640d4"} +9 {"type":"TOOL_CALL_ARGS","toolCallId":"call_KUdUz8CR6t3X2NEb1ucXbntO","delta":"{\""} + # [elided: 195 more TOOL_CALL_ARGS deltas spelling {"role":"research","task_description":"Gather current intel for a trip from LAX ... to JFK ..."}, each followed by its RAW on_chat_model_stream mirror] +400 {"type":"TOOL_CALL_END","toolCallId":"call_KUdUz8CR6t3X2NEb1ucXbntO"} +406 {"type":"STATE_SNAPSHOT", ...} +409 {"type":"STEP_FINISHED","stepName":"orchestrator"} +410 {"type":"STEP_STARTED","stepName":"tools"} +411 {"type":"RAW","event":{"event":"on_chain_start","name":"tools"}} +412 {"type":"RAW","event":{"event":"on_tool_start","name":"task"}} +413 {"type":"RAW","event":{"event":"on_custom_event","name":"subagent_activity"}} # phase=started +414 {"type":"ACTIVITY_SNAPSHOT","messageId":"call_KUdUz8CR6t3X2NEb1ucXbntO","activityType":"subagent","content":{"toolCallId":"call_KUdUz8CR6t3X2NEb1ucXbntO","name":"research","status":"running","text":""},"replace":true} +415 {"type":"RAW","event":{"event":"on_custom_event","name":"subagent_activity"}} # phase=message +416 {"type":"ACTIVITY_DELTA","messageId":"call_KUdUz8CR6t3X2NEb1ucXbntO","activityType":"subagent","patch":[{"op":"replace","path":"/text","value":"L"}]} + # [elided: 470 more RAW+ACTIVITY_DELTA pairs, each DELTA carrying the FULL accumulated text ("LAX", "LAX (", ... ) — quadratic bytes on the wire] +1357 {"type":"RAW","event":{"event":"on_custom_event","name":"subagent_activity"}} # phase=finished +1358 {"type":"ACTIVITY_DELTA","messageId":"call_KUdUz8CR6t3X2NEb1ucXbntO","activityType":"subagent","patch":[{"op":"replace","path":"/status","value":"complete"}]} +1359 {"type":"RAW","event":{"event":"on_tool_end","name":"task"}} +1360 {"type":"TOOL_CALL_RESULT","messageId":"d2c0584d-0046-49aa-8fe6-0859492dc35f","toolCallId":"call_KUdUz8CR6t3X2NEb1ucXbntO","content":"LAX/JFK basics: At LAX the major domestic carriers typically operate from these terminals ..."} +1362 {"type":"STATE_SNAPSHOT", ...} +1365 {"type":"STEP_FINISHED","stepName":"tools"} +1366 {"type":"STEP_STARTED","stepName":"orchestrator"} +1370 {"type":"TOOL_CALL_START","toolCallId":"call_Oh1rxCKsmmkoFHf9E5wQGEWx","toolCallName":"task", ...} + # [elided: booking round — shape-identical: ARGS×167 → TOOL_CALL_END (1707) → STEP_FINISHED/STARTED → ACTIVITY_SNAPSHOT name=booking (1721) → 1104 ACTIVITY_DELTA → status=complete (3931) → TOOL_CALL_RESULT (3933)] +3943 {"type":"TOOL_CALL_START","toolCallId":"call_4WqxTvu8atX6yZzxXsmiSTSz","toolCallName":"task", ...} + # [elided: itinerary round — ARGS×145 → TOOL_CALL_END (4236) → ACTIVITY_SNAPSHOT name=itinerary (4250) → 499 ACTIVITY_DELTA → status=complete (5250) → TOOL_CALL_RESULT (5252)] +5263 {"type":"TEXT_MESSAGE_START","messageId":"lc_run--01a06369-b8aa-7601-84c2-b7a96b9399cb","role":"assistant"} + # [elided: 144 TEXT_MESSAGE_CONTENT deltas — the ORCHESTRATOR's own final summary] +5555 {"type":"TEXT_MESSAGE_END","messageId":"lc_run--01a06369-b8aa-7601-84c2-b7a96b9399cb"} +5563 {"type":"STEP_STARTED","stepName":"generate_title"} +5570 {"type":"STEP_FINISHED","stepName":"generate_title"} +5572 {"type":"MESSAGES_SNAPSHOT", ...} +5573 {"type":"RUN_FINISHED","threadId":"capture-thread-2","runId":"01a06367-6020-7a61-bdc8-ffcea4df5a2b"} +``` + +Event tally (5,573 events): 1 RUN_STARTED, 8 STEP_STARTED, 8 STEP_FINISHED, +3 TOOL_CALL_START, 507 TOOL_CALL_ARGS, 3 TOOL_CALL_END, 3 ACTIVITY_SNAPSHOT, +2,077 ACTIVITY_DELTA, 3 TOOL_CALL_RESULT, 1 TEXT_MESSAGE_START, +144 TEXT_MESSAGE_CONTENT, 1 TEXT_MESSAGE_END, 9 STATE_SNAPSHOT, +1 MESSAGES_SNAPSHOT, 1 RUN_FINISHED, 2,803 RAW. No CUSTOM (the +`ActivityEmittingAgent` swallowed all 2,080 `subagent_activity` CUSTOM events +and emitted an ACTIVITY event in each one's place), no SUBAGENT_*. + +RAW breakdown: 2,080 `on_custom_event` (one mirror per `subagent_activity` +dispatch — the bridge yields `RawEvent(event=...)` for EVERY astream_events +item at `agent.py:404-406` before `_handle_single_event` translates it), +667 `on_chat_model_stream`, 16 `on_chain_stream`, 13 `on_chain_start`, +13 `on_chain_end`, 4 `on_chat_model_start`, 4 `on_chat_model_end`, +3 `on_tool_start`, 3 `on_tool_end`. + +### 2a. Ordering finding (design §6) + +**`TOOL_CALL_START` for `task` precedes the first ACTIVITY event by a wide +margin in every delegation round:** START at 7 / 1370 / 3943, the matching +ACTIVITY_SNAPSHOT at 414 / 1721 / 4250. Between them the bridge streams every +`TOOL_CALL_ARGS` delta, `TOOL_CALL_END`, a `STATE_SNAPSHOT`, and the +`STEP_FINISHED(orchestrator)` / `STEP_STARTED(tools)` pair — the tool body +only runs once LangGraph enters the `tools` node, and `on_tool_start` (412) is +the immediately preceding RAW mirror. `TOOL_CALL_END` therefore arrives BEFORE +the subagent runs (it marks the end of the args stream, not tool execution), +and the delegation window nests between `TOOL_CALL_END` and +`TOOL_CALL_RESULT` — same nesting as the Strands lane, opposite of the MAF +lane where END lands after the tool returns. The reducer's `parentToolCallId` +lookup will always find an already-announced tool call, so the card never +renders nameless. + +### 2b. Why the 1:1 `_dispatch_event` seam cannot carry the migration + +`ActivityEmittingAgent` overrode `LangGraphAgent._dispatch_event` +(`agent.py:159-165`), which is strictly one-event-in / one-event-out: it is +called inline as `yield self._dispatch_event(...)` at every yield site. The +standard sequence needs 1:N expansion — a `message_start` phase must open a +`TEXT_MESSAGE_START`, a `finished` phase must close the open message +(`TEXT_MESSAGE_END`) AND emit `SUBAGENT_FINISHED`, and the CUSTOM event itself +must be consumed (0 out). `LangGraphAgent.run(self, input: RunAgentInput) -> +AsyncGenerator[ProcessedEvents, None]` (`agent.py:167-178`) is the method +the FastAPI endpoint consumes (`endpoint.py:26`, `async for event in +request_agent.run(input_data)`), so wrapping `run` is the seam: iterate +`super().run(input)` and expand each event. No queue merge is needed — unlike +MAF, the graph's CUSTOM events already flow through this generator live +(they are `astream_events` items), so a straight `for out in expand(ev): +yield out` keeps the interleaving. + +## 3. Serializer probe + +From an UNCOMMITTED scratch `_dispatch_event` override that replaced the +`started` ACTIVITY_SNAPSHOT with a `SubagentStartedEvent(subagent_run_id= +f"{tid}-sub", name=..., parent_tool_call_id=tid)`, same prompt (the run +delegated three times again): + +``` +260 {"type":"SUBAGENT_STARTED","subagentRunId":"call_Tiif951yDSxR3bBrG1Tkuwnj-sub","name":"research","parentToolCallId":"call_Tiif951yDSxR3bBrG1Tkuwnj"} + # (TOOL_CALL_START for call_Tiif951yDSxR3bBrG1Tkuwnj at 7, TOOL_CALL_END at 246, STEP_STARTED(tools) at 256) +2617 {"type":"SUBAGENT_STARTED","subagentRunId":"call_KZQWQKoEDNcn3LpcTwjtmU4F-sub","name":"booking","parentToolCallId":"call_KZQWQKoEDNcn3LpcTwjtmU4F"} +5407 {"type":"SUBAGENT_STARTED","subagentRunId":"call_UJ5vqgEMAq6715iAuvCtLlUZ-sub","name":"itinerary","parentToolCallId":"call_UJ5vqgEMAq6715iAuvCtLlUZ"} +``` + +The stock `EventEncoder` camelCases the pydantic fields (`subagentRunId`, +`parentToolCallId`) with no extra configuration, and the ordering from §2a +held (START 7 → END 246 → SUBAGENT_STARTED 260). The scratch edit was reverted; +only this doc and the SDK bump land from Task 0. + +## After the emitter + +Captured 2026-09-02 against the shipped scenario (`SubagentEmittingAgent` +mounted in `src/server.py`, per-token `subagent_activity` deltas from +`SubagentStreamHandler`), same `RunAgentInput` as §2. No keys or org ids +appeared in the stream; only repetitive delta runs, `STATE_SNAPSHOT`s and the +bridge's RAW mirrors are elided, marked with `# [elided: ...]`. The model +delegated three times again (research → booking → itinerary); the first round +is shown, the other two are shape-identical. + +``` +1 {"type":"RUN_STARTED","threadId":"capture-thread-2","runId":"capture-run-2"} +3 {"type":"STEP_STARTED","stepName":"orchestrator"} +7 {"type":"TOOL_CALL_START","toolCallId":"call_CN5GEDy9byHqg18I9dAhnUDB","toolCallName":"task","parentMessageId":"lc_run--01a06373-7a61-7cb2-a616-3b1e3ee01e57"} +9 {"type":"TOOL_CALL_ARGS","toolCallId":"call_CN5GEDy9byHqg18I9dAhnUDB","delta":"{\""} + # [elided: 141 more TOOL_CALL_ARGS deltas spelling {"role":"research","task_description":"..."}] +292 {"type":"TOOL_CALL_END","toolCallId":"call_CN5GEDy9byHqg18I9dAhnUDB"} +301 {"type":"STEP_FINISHED","stepName":"orchestrator"} +302 {"type":"STEP_STARTED","stepName":"tools"} +304 {"type":"RAW","event":{"event":"on_tool_start","name":"task"}} +306 {"type":"SUBAGENT_STARTED","subagentRunId":"call_CN5GEDy9byHqg18I9dAhnUDB-sub","name":"research","parentToolCallId":"call_CN5GEDy9byHqg18I9dAhnUDB"} +308 {"type":"TEXT_MESSAGE_START","messageId":"call_CN5GEDy9byHqg18I9dAhnUDB-sub-m1","role":"assistant","subagentRunId":"call_CN5GEDy9byHqg18I9dAhnUDB-sub"} +310 {"type":"TEXT_MESSAGE_CONTENT","messageId":"call_CN5GEDy9byHqg18I9dAhnUDB-sub-m1","delta":"L","subagentRunId":"call_CN5GEDy9byHqg18I9dAhnUDB-sub"} +312 {"type":"TEXT_MESSAGE_CONTENT","messageId":"call_CN5GEDy9byHqg18I9dAhnUDB-sub-m1","delta":"AX","subagentRunId":"call_CN5GEDy9byHqg18I9dAhnUDB-sub"} + # [elided: 490 more TEXT_MESSAGE_CONTENT deltas — the CHILD's text, one raw token each, every one carrying subagentRunId] +1294 {"type":"TEXT_MESSAGE_END","messageId":"call_CN5GEDy9byHqg18I9dAhnUDB-sub-m1","subagentRunId":"call_CN5GEDy9byHqg18I9dAhnUDB-sub"} +1295 {"type":"SUBAGENT_FINISHED","subagentRunId":"call_CN5GEDy9byHqg18I9dAhnUDB-sub","outcome":{"type":"success"}} +1296 {"type":"RAW","event":{"event":"on_tool_end","name":"task"}} +1297 {"type":"TOOL_CALL_RESULT","messageId":"605c4334-e164-4569-86a4-f12476801d87","toolCallId":"call_CN5GEDy9byHqg18I9dAhnUDB","content":"LAX: Central Terminal Area with Terminals 1–8 plus the Tom Bradley International ..."} +1302 {"type":"STEP_FINISHED","stepName":"tools"} + # [elided: booking round — TOOL_CALL_START call_bCmw9AhTCKRuYWOLAb7hzTQF (1307) → ARGS → END (1586) → SUBAGENT_STARTED name=booking (1600) → TEXT_MESSAGE_START -sub-m1 (1602) → 710 deltas → TEXT_MESSAGE_END (3024) → SUBAGENT_FINISHED success (3025) → TOOL_CALL_RESULT (3027)] + # [elided: itinerary round — TOOL_CALL_START call_E725YycIoO2TUaKOug1lcdR7 (3037) → END (3312) → SUBAGENT_STARTED name=itinerary (3326) → 276 deltas → TEXT_MESSAGE_END (3882) → SUBAGENT_FINISHED success (3883) → TOOL_CALL_RESULT (3885)] +3896 {"type":"TEXT_MESSAGE_START","messageId":"lc_run--01a06375-457d-7611-adea-cd992b954a70","role":"assistant"} + # [elided: 243 TEXT_MESSAGE_CONTENT deltas — the ORCHESTRATOR's own summary, no subagentRunId] +4386 {"type":"TEXT_MESSAGE_END","messageId":"lc_run--01a06375-457d-7611-adea-cd992b954a70"} +4403 {"type":"MESSAGES_SNAPSHOT", ...} +4404 {"type":"RUN_FINISHED","threadId":"capture-thread-2","runId":"01a06373-7a57-7222-b98e-9e82a76738a9"} +``` + +Event tally (4,404 events): 1 RUN_STARTED, 8 STEP_STARTED, 8 STEP_FINISHED, +3 TOOL_CALL_START, 415 TOOL_CALL_ARGS, 3 TOOL_CALL_END, 3 SUBAGENT_STARTED, +3 TEXT_MESSAGE_START(sub), 1,478 TEXT_MESSAGE_CONTENT(sub), +3 TEXT_MESSAGE_END(sub), 3 SUBAGENT_FINISHED, 3 TOOL_CALL_RESULT, +1 TEXT_MESSAGE_START, 243 TEXT_MESSAGE_CONTENT, 1 TEXT_MESSAGE_END, +9 STATE_SNAPSHOT, 1 MESSAGES_SNAPSHOT, 1 RUN_FINISHED, 2,217 RAW. No CUSTOM, +no ACTIVITY_*, no SUBAGENT_ERROR. + +**Child deltas: streaming, one raw token per event** (1,478 attributed content +events across three rounds: 492 + 710 + 276) — the §2 accumulator is gone and +each delta is a few bytes instead of the full text-so-far. Every child event +carries `subagentRunId` derived from the wire `toolCallId` (`-sub` +/ `-sub-m1`), `SUBAGENT_STARTED.parentToolCallId` matches the +bridge-native `TOOL_CALL_START.toolCallId` verbatim, and the `TOOL_CALL_RESULT` +content equals the joined child deltas. The `subagent_activity` CUSTOM events +were consumed (0 on the wire); their RAW `on_custom_event` mirrors (1,487) +still pass through because the bridge yields them before +`_handle_single_event` — the same mirror the ACTIVITY pipeline shipped, and +the client ignores RAW. + +**Measured order, `TOOL_CALL_START` vs `SUBAGENT_STARTED`:** START 7 → END +292 → SUBAGENT_STARTED 306 (and 1307 → 1586 → 1600; 3037 → 3312 → 3326). The +tool call is fully announced (start, args, end) before the tool body runs, +so the reducer attaches the card to an already-known `parentToolCallId`; the +`SUBAGENT_*` block nests between `TOOL_CALL_END` and `TOOL_CALL_RESULT`. + +## Browser verification + +2026-09-02, live backend (real `OPENAI_API_KEY`, uvicorn on :5326) + `npx nx +serve cockpit-ag-ui-subagents-angular --port 4326`, driven headlessly with +Playwright (the §2 prompt typed into the composer). Screenshot, taken while +the research card was still `running`: +`cockpit/ag-ui/subagents/angular/e2e/manual/subagent-card-live.png`. + +What rendered: the `task` dispatch produced an inline `` +anchored to its tool call — header `research` + wire `toolCallId` + +`running` badge + "1 message(s)" — with the specialist's transcript streaming +inside it, then booking and itinerary cards in turn, then the orchestrator's +own summary bubble. The child text never leaked into the parent bubble, and +each card persists (collapsed, `complete`) after its subagent finishes. + +Did the card text stream mid-run: **yes**. Polling `agent.subagents()` and +the card's `innerText` every 150ms showed the research card mount at t≈8.9s +(empty, `running` — `SUBAGENT_STARTED` lands before the child's first token; +gpt-5-mini's reasoning latency kept it empty until t≈47.7s) and then grow +monotonically while `running`: message lengths 22 → 56 → 113 → 147 → 223 → +262 → 299 → 330 → 367 → 401 → 431 → 506 → 540 chars across consecutive +150ms samples (t≈47.7s → 49.6s), reaching 5,336 chars before flipping to +`complete` and collapsing (card `innerText` 592 → 58 chars). Booking +(2,890 chars) and itinerary (1,206 chars) behaved identically. This confirms +the attributed `TEXT_MESSAGE_CONTENT` deltas render progressively in the +card, not as one post-hoc paste. diff --git a/deployments/ag-ui-dev/deps/subagents/prompts/subagents.md b/deployments/ag-ui-dev/deps/subagents/prompts/subagents.md index 07c8feb6f..6e5a2c4f1 100644 --- a/deployments/ag-ui-dev/deps/subagents/prompts/subagents.md +++ b/deployments/ag-ui-dev/deps/subagents/prompts/subagents.md @@ -12,8 +12,8 @@ The three roles, in the order you should always call them: When the user asks about a trip (e.g., "plan a trip from LAX to JFK" or "I want to fly from Boston to Miami next week"), call task() three times in that order, then summarize the final plan in 1-2 sentences. Each subagent -dispatch surfaces a live subagent card in the UI: the backend converts the -subagent's streamed tokens into native AG-UI ACTIVITY events, which the +dispatch surfaces a live subagent card in the UI: the backend emits the +subagent's streamed tokens as standard AG-UI subagent events, which the `@threadplane/ag-ui` reducer projects onto `agent.subagents()` for the `` primitive to render. diff --git a/deployments/ag-ui-dev/deps/subagents/pyproject.toml b/deployments/ag-ui-dev/deps/subagents/pyproject.toml index 3af16bc7f..c7f13209b 100644 --- a/deployments/ag-ui-dev/deps/subagents/pyproject.toml +++ b/deployments/ag-ui-dev/deps/subagents/pyproject.toml @@ -7,6 +7,7 @@ dependencies = [ "langchain-openai>=0.3", "langsmith>=0.2", "ag-ui-langgraph>=0.0.25", + "ag-ui-protocol>=0.1.22", "fastapi>=0.110", "uvicorn[standard]>=0.29", ] diff --git a/deployments/ag-ui-dev/deps/subagents/requirements.txt b/deployments/ag-ui-dev/deps/subagents/requirements.txt index 51b619083..5562b76a1 100644 --- a/deployments/ag-ui-dev/deps/subagents/requirements.txt +++ b/deployments/ag-ui-dev/deps/subagents/requirements.txt @@ -5,8 +5,10 @@ ag-ui-a2ui-toolkit==0.0.1 # via ag-ui-langgraph ag-ui-langgraph==0.0.37 # via cockpit-ag-ui-subagents -ag-ui-protocol==0.1.19 - # via ag-ui-langgraph +ag-ui-protocol==0.1.22 + # via + # ag-ui-langgraph + # cockpit-ag-ui-subagents annotated-doc==0.0.4 # via fastapi annotated-types==0.7.0 diff --git a/deployments/ag-ui-dev/deps/subagents/src/graph.py b/deployments/ag-ui-dev/deps/subagents/src/graph.py index 385731896..68ca9c542 100644 --- a/deployments/ag-ui-dev/deps/subagents/src/graph.py +++ b/deployments/ag-ui-dev/deps/subagents/src/graph.py @@ -4,10 +4,13 @@ Mirrors cockpit/chat/subagents' orchestrator + `task` tool + `_run_subagent` structure, but each dispatch emits `subagent_activity` CUSTOM events (like the -examples/ag-ui `research` tool): `started` before the run, `message` per -streamed token (via SubagentStreamHandler), `finished` after. The backend's -ActivityEmittingAgent converts those CUSTOM events into native AG-UI ACTIVITY -events, which the @threadplane/ag-ui reducer projects onto agent.subagents(). +examples/ag-ui `research` tool): `started {name}` before the run, +`message_start {message_id}` + `message {message_id, delta}` per streamed +token (via SubagentStreamHandler), `finished` after — or `error {message}` if +the child fails. 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, which the +@threadplane/ag-ui reducer projects onto agent.subagents(). Self-contained: no imports from examples/ or other cockpit capabilities. """ @@ -111,8 +114,9 @@ async def _run_subagent( tool_call_id: str, ) -> str: """Run a single subagent LLM, streaming its tokens through - SubagentStreamHandler so they surface as `subagent_activity` `message` - events keyed by the parent tool_call_id.""" + SubagentStreamHandler so they surface as `subagent_activity` + `message_start` / `message` (per-token delta) events keyed by the parent + tool_call_id.""" llm = ChatOpenAI(model="gpt-5-mini", streaming=True) messages = [ SystemMessage(content=system_prompt), @@ -157,9 +161,10 @@ async def task( Returns: The subagent's final answer as a string. - The subagent run is surfaced to the UI as a native AG-UI ACTIVITY - (activityType "subagent"): started → message-per-token → finished, keyed - by this tool's own call id. + The subagent run is surfaced to the UI as the protocol's standard + subagent events: SUBAGENT_STARTED → attributed TEXT_MESSAGE_* per token → + SUBAGENT_FINISHED (or SUBAGENT_ERROR), with ids derived from this tool's + own call id (`-sub`). """ async def _emit(payload: dict) -> None: @@ -180,7 +185,13 @@ async def _emit(payload: dict) -> None: return f"Unknown role: {role}" await _emit({"phase": "started", "name": role}) - result = await _run_subagent(role, task_description, system_prompt, tool_call_id) + try: + result = await _run_subagent(role, task_description, system_prompt, tool_call_id) + except Exception as exc: + # Surface the failure on the subagent card (SUBAGENT_ERROR), then + # re-raise so the bridge's own tool-error path still runs. + await _emit({"phase": "error", "message": f"{type(exc).__name__}: {exc}"}) + raise await _emit({"phase": "finished", "status": "complete"}) return result diff --git a/deployments/ag-ui-dev/deps/subagents/src/server.py b/deployments/ag-ui-dev/deps/subagents/src/server.py index a2fdad067..a9e0608c4 100644 --- a/deployments/ag-ui-dev/deps/subagents/src/server.py +++ b/deployments/ag-ui-dev/deps/subagents/src/server.py @@ -2,12 +2,14 @@ from fastapi import FastAPI from ag_ui_langgraph import add_langgraph_fastapi_endpoint from .graph import graph -from .streaming.activity_emitting_agent import ActivityEmittingAgent +from .streaming.subagent_emitting_agent import SubagentEmittingAgent -# ActivityEmittingAgent subclasses the ag-ui-langgraph bridge to convert the -# graph's `subagent_activity` CUSTOM events into native AG-UI ACTIVITY events -# (snapshot/delta) so the chat composition renders a live subagent card. -agent = ActivityEmittingAgent(name="subagents", graph=graph) +# SubagentEmittingAgent subclasses the ag-ui-langgraph bridge and wraps its +# run() generator to expand the graph's `subagent_activity` CUSTOM events into +# the protocol's standard SUBAGENT_STARTED / TEXT_MESSAGE_* (attributed via +# subagentRunId) / SUBAGENT_FINISHED / SUBAGENT_ERROR events, so the chat +# composition renders a live subagent card. +agent = SubagentEmittingAgent(name="subagents", graph=graph) app = FastAPI(title="cockpit-ag-ui-subagents") add_langgraph_fastapi_endpoint(app, agent, path="/agent") diff --git a/deployments/ag-ui-dev/deps/subagents/src/streaming/activity_emitting_agent.py b/deployments/ag-ui-dev/deps/subagents/src/streaming/activity_emitting_agent.py deleted file mode 100644 index 2d8b6e203..000000000 --- a/deployments/ag-ui-dev/deps/subagents/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 .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/deployments/ag-ui-dev/deps/subagents/src/streaming/activity_transform.py b/deployments/ag-ui-dev/deps/subagents/src/streaming/activity_transform.py deleted file mode 100644 index dd09c01ea..000000000 --- a/deployments/ag-ui-dev/deps/subagents/src/streaming/activity_transform.py +++ /dev/null @@ -1,58 +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): the handler sends accumulated `text_so_far`, so each -DELTA carries the full text via JSON-patch `replace` (JSON-patch has no string -append). Anything that is not a `subagent_activity` CUSTOM event returns 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", "text": ""}, - replace=True, - ) - if phase == "message": - return ActivityDeltaEvent( - type=EventType.ACTIVITY_DELTA, - message_id=sid, - activity_type=ACTIVITY_TYPE, - patch=[{"op": "replace", "path": "/text", "value": value.get("text", "")}], - ) - 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/deployments/ag-ui-dev/deps/subagents/src/streaming/subagent_emitting_agent.py b/deployments/ag-ui-dev/deps/subagents/src/streaming/subagent_emitting_agent.py new file mode 100644 index 000000000..42f765689 --- /dev/null +++ b/deployments/ag-ui-dev/deps/subagents/src/streaming/subagent_emitting_agent.py @@ -0,0 +1,193 @@ +# SPDX-License-Identifier: MIT +"""SUBAGENT_* emitter for the `task` delegation tool. + +The graph cannot reach the AG-UI wire directly: the `task` 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 ``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 ``task`` +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 / finished / error) → TEXT_MESSAGE_END for any open message first + 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 (unlike the MAF lane): 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). + +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 +from typing import Any, AsyncGenerator, Iterator + +from ag_ui.core import ( + BaseEvent, + EventType, + SubagentErrorEvent, + SubagentFinishedEvent, + SubagentFinishedSuccessOutcome, + SubagentStartedEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, +) +from ag_ui_langgraph import LangGraphAgent + +CUSTOM_NAME = "subagent_activity" + +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 + + +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 _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_* events. + + 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]: + delegations: dict[str, _Delegation] = {} + async for event in super().run(*args, **kwargs): + for out in self._expand(event, delegations): + yield out + + def _expand(self, event: BaseEvent, delegations: dict[str, _Delegation]) -> Iterator[BaseEvent]: + payload = _payload(event) + if payload is None: + 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 = delegations.get(tid) + if delegation is None: + delegation = _Delegation(run_id=_subagent_run_id(tid)) + delegations[tid] = delegation + run_id = delegation.run_id + + if phase == "started": + 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 == "finished": + yield from self._close_message(delegation) + yield SubagentFinishedEvent( + type=EventType.SUBAGENT_FINISHED, + subagent_run_id=run_id, + outcome=SubagentFinishedSuccessOutcome(), + ) + elif phase == "error": + yield from self._close_message(delegation) + 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 _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/deployments/ag-ui-dev/deps/subagents/src/streaming/subagent_stream_handler.py b/deployments/ag-ui-dev/deps/subagents/src/streaming/subagent_stream_handler.py index 09a1623dd..684a575b7 100644 --- a/deployments/ag-ui-dev/deps/subagents/src/streaming/subagent_stream_handler.py +++ b/deployments/ag-ui-dev/deps/subagents/src/streaming/subagent_stream_handler.py @@ -1,8 +1,18 @@ -"""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).""" +"""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, before the first token + message {subagent_id, message_id, delta} one per token (raw delta) + +`SubagentEmittingAgent` turns those into `subagentRunId`-attributed +TEXT_MESSAGE_START / TEXT_MESSAGE_CONTENT events; it closes the message +(TEXT_MESSAGE_END) itself on `finished` / `error`. `started` / `finished` / +`error` are emitted by the `task` tool body. The message id follows the +`-sub-m` convention; this demo's child runs a single +completion, so n is always 1. + +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 from uuid import UUID @@ -12,16 +22,22 @@ class SubagentStreamHandler(AsyncCallbackHandler): def __init__(self, subagent_id: str) -> None: self._id = subagent_id - self._buffer = "" + self._message_id = f"{subagent_id}-sub-m1" + self._message_open = False async def on_llm_new_token(self, token: str, *, run_id: UUID | None = None, **kwargs: Any) -> None: if not token: return - self._buffer += token try: + if not self._message_open: + self._message_open = True + await adispatch_custom_event( + "subagent_activity", + {"subagent_id": self._id, "phase": "message_start", "message_id": self._message_id}, + ) await adispatch_custom_event( "subagent_activity", - {"subagent_id": self._id, "phase": "message", "text": self._buffer}, + {"subagent_id": self._id, "phase": "message", "message_id": self._message_id, "delta": token}, ) except Exception: return # no ambient run context (some unit-test paths) — best-effort diff --git a/deployments/ag-ui-dev/deps/subagents/tests/test_activity_transform.py b/deployments/ag-ui-dev/deps/subagents/tests/test_activity_transform.py deleted file mode 100644 index ea30b93fc..000000000 --- a/deployments/ag-ui-dev/deps/subagents/tests/test_activity_transform.py +++ /dev/null @@ -1,89 +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", "text": ""} - assert ev.replace is True - - -def test_message_maps_to_activity_delta_replace_text(): - ev = subagent_custom_to_activity(_custom( - {"subagent_id": "tc-1", "phase": "message", "text": "Paris is"})) - assert ev.type == EventType.ACTIVITY_DELTA - assert ev.message_id == "tc-1" - assert ev.patch == [{"op": "replace", "path": "/text", "value": "Paris is"}] - - -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_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/deployments/ag-ui-dev/deps/subagents/tests/test_subagent_emitting_agent.py b/deployments/ag-ui-dev/deps/subagents/tests/test_subagent_emitting_agent.py new file mode 100644 index 000000000..2de7c5f56 --- /dev/null +++ b/deployments/ag-ui-dev/deps/subagents/tests/test_subagent_emitting_agent.py @@ -0,0 +1,356 @@ +"""Tests for SubagentEmittingAgent — the run-wrapping emitter that expands the +graph's `subagent_activity` CUSTOM events (started / message_start / message / +finished / error) into the protocol's standard SUBAGENT_* + attributed +TEXT_MESSAGE_* events. Drives the wrapper with a scripted inner +`LangGraphAgent.run` generator and asserts the exact output sequence +field-for-field.""" +import logging +from typing import Any + +import pytest +from ag_ui.core import ( + CustomEvent, + EventType, + RunAgentInput, + RunFinishedEvent, + RunStartedEvent, + 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" +MESSAGE_ID = f"{TID}-sub-m1" + + +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): + return [ + ToolCallStartEvent(type=EventType.TOOL_CALL_START, tool_call_id=tid, tool_call_name="task"), + ToolCallArgsEvent(type=EventType.TOOL_CALL_ARGS, tool_call_id=tid, delta='{"role":"research"}'), + 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 + ) + + +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="subagents", graph=_graph()) + return [ev async for ev in agent.run(_input())] + + +async def test_expands_one_delegation_field_for_field(monkeypatch): + script = [ + _run_started(), + *_tool_call(TID), + _activity({"subagent_id": TID, "phase": "started", "name": "research"}), + _activity({"subagent_id": TID, "phase": "message_start", "message_id": MESSAGE_ID}), + _activity({"subagent_id": TID, "phase": "message", "message_id": MESSAGE_ID, "delta": "Paris "}), + _activity({"subagent_id": TID, "phase": "message", "message_id": MESSAGE_ID, "delta": "is"}), + _activity({"subagent_id": TID, "phase": "finished", "status": "complete"}), + _tool_result(TID, "Paris is"), + _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, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_FINISHED, + EventType.TOOL_CALL_RESULT, + 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 + + start = out[5] + assert start.message_id == MESSAGE_ID + assert start.role == "assistant" + assert start.subagent_run_id == RUN_ID + + deltas = out[6:8] + assert [ev.delta for ev in deltas] == ["Paris ", "is"] + for ev in deltas: + assert ev.message_id == MESSAGE_ID + assert ev.subagent_run_id == RUN_ID + + end = out[8] + assert end.message_id == MESSAGE_ID + assert end.subagent_run_id == RUN_ID + + finished = out[9] + 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[10] is script[9] + assert out[10].subagent_run_id is None + + +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": "booking"}'), + _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 == "booking" + + +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": MESSAGE_ID}), + _activity({"subagent_id": TID, "phase": "message", "message_id": MESSAGE_ID, "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 == MESSAGE_ID + 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_second_message_start_closes_the_first(monkeypatch): + m2 = f"{TID}-sub-m2" + script = [ + _activity({"subagent_id": TID, "phase": "started", "name": "research"}), + _activity({"subagent_id": TID, "phase": "message_start", "message_id": MESSAGE_ID}), + _activity({"subagent_id": TID, "phase": "message", "message_id": MESSAGE_ID, "delta": "a"}), + _activity({"subagent_id": TID, "phase": "message_start", "message_id": m2}), + _activity({"subagent_id": TID, "phase": "message", "message_id": m2, "delta": "b"}), + _activity({"subagent_id": TID, "phase": "finished"}), + ] + out = await _collect(monkeypatch, script) + assert [(ev.type, getattr(ev, "message_id", None)) for ev in out] == [ + (EventType.SUBAGENT_STARTED, None), + (EventType.TEXT_MESSAGE_START, MESSAGE_ID), + (EventType.TEXT_MESSAGE_CONTENT, MESSAGE_ID), + (EventType.TEXT_MESSAGE_END, MESSAGE_ID), + (EventType.TEXT_MESSAGE_START, m2), + (EventType.TEXT_MESSAGE_CONTENT, m2), + (EventType.TEXT_MESSAGE_END, m2), + (EventType.SUBAGENT_FINISHED, None), + ] + + +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] == [f"{TID}-sub-m1", f"{TID}-sub-m2"] + content = [ev for ev in out if ev.type == EventType.TEXT_MESSAGE_CONTENT] + assert content[0].message_id == f"{TID}-sub-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": MESSAGE_ID, "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 == MESSAGE_ID + + +async def test_two_sequential_delegations_get_distinct_run_ids(monkeypatch): + script = [ + _run_started(), + *_tool_call(TID), + _activity({"subagent_id": TID, "phase": "started", "name": "research"}), + _activity({"subagent_id": TID, "phase": "message_start", "message_id": MESSAGE_ID}), + _activity({"subagent_id": TID, "phase": "message", "message_id": MESSAGE_ID, "delta": "intel"}), + _activity({"subagent_id": TID, "phase": "finished"}), + _tool_result(TID, "intel"), + *_tool_call(TID2), + _activity({"subagent_id": TID2, "phase": "started", "name": "booking"}), + _activity({"subagent_id": TID2, "phase": "message_start", "message_id": f"{TID2}-sub-m1"}), + _activity({"subagent_id": TID2, "phase": "message", "message_id": f"{TID2}-sub-m1", "delta": "flights"}), + _activity({"subagent_id": TID2, "phase": "finished"}), + _tool_result(TID2, "flights"), + _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] + assert [ev.name for ev in started] == ["research", "booking"] + + 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"] + expected = [ + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_FINISHED, + ] + 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 hasattr(ev, "message_id")} == {f"{TID}-sub-m1"} + assert {ev.message_id for ev in b if hasattr(ev, "message_id")} == {f"{TID2}-sub-m1"} + # Every child event sits between its own tool call's END and RESULT. + types = [ev.type for ev in out] + tool_results = [i for i, ev in enumerate(out) if ev.type == EventType.TOOL_CALL_RESULT] + assert types.index(EventType.SUBAGENT_STARTED) > types.index(EventType.TOOL_CALL_END) + assert types.index(EventType.SUBAGENT_FINISHED) < tool_results[0] + + +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": "tool_call", "tool_call_id": "x"}), + _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("tool_call" 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 + _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. + script = [ + _activity({"subagent_id": TID, "phase": "started", "name": "research"}), + _activity({"subagent_id": TID, "phase": "message_start", "message_id": MESSAGE_ID}), + _activity({"subagent_id": TID, "phase": "message", "message_id": MESSAGE_ID, "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="subagents", graph=_graph()) + first = [ev async for ev in agent.run(_input())] + assert first[-1].type == EventType.TEXT_MESSAGE_CONTENT + + script[:] = [_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 unknown + # delegation's finished is still expanded (bracketing the card). + assert [ev.type for ev in second] == [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="subagents", 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) diff --git a/deployments/ag-ui-dev/deps/subagents/tests/test_subagent_stream_handler.py b/deployments/ag-ui-dev/deps/subagents/tests/test_subagent_stream_handler.py index ef2aec688..27370ec80 100644 --- a/deployments/ag-ui-dev/deps/subagents/tests/test_subagent_stream_handler.py +++ b/deployments/ag-ui-dev/deps/subagents/tests/test_subagent_stream_handler.py @@ -1,5 +1,8 @@ -"""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 derived +message id) before the first token, 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).""" from unittest.mock import AsyncMock, patch from uuid import uuid4 @@ -7,33 +10,55 @@ from src.streaming.subagent_stream_handler import SubagentStreamHandler +TID = "call_7sxPY1sC236nPyHRTWAZMJB9" +MESSAGE_ID = f"{TID}-sub-m1" + 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", "text": "Paris "}) - assert dispatch.call_args_list[1].args == ( - "subagent_activity", {"subagent_id": "tc-1", "phase": "message", "text": "Paris is"}) + assert [c.args for c in dispatch.call_args_list] == [ + ("subagent_activity", + {"subagent_id": TID, "phase": "message_start", "message_id": MESSAGE_ID}), + ("subagent_activity", + {"subagent_id": TID, "phase": "message", "message_id": MESSAGE_ID, "delta": "Paris "}), + ("subagent_activity", + {"subagent_id": TID, "phase": "message", "message_id": MESSAGE_ID, "delta": "is"}), + ] + + @pytest.mark.asyncio + 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 handler.on_llm_new_token("", run_id=uuid4()) + assert dispatch.call_args_list == [] @pytest.mark.asyncio - async def test_buffers_isolated_across_instances(self): + 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()) - assert dispatch.call_args_list[0].args[1]["text"] == "x" - assert dispatch.call_args_list[1].args[1]["text"] == "y" + 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 diff --git a/deployments/ag-ui-dev/deps/subagents/uv.lock b/deployments/ag-ui-dev/deps/subagents/uv.lock index 7fcd6ebfe..12f400bdb 100644 --- a/deployments/ag-ui-dev/deps/subagents/uv.lock +++ b/deployments/ag-ui-dev/deps/subagents/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]] @@ -171,6 +171,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "ag-ui-langgraph" }, + { name = "ag-ui-protocol" }, { name = "fastapi" }, { name = "langchain-openai" }, { name = "langgraph" }, @@ -187,6 +188,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "ag-ui-langgraph", specifier = ">=0.0.25" }, + { name = "ag-ui-protocol", specifier = ">=0.1.22" }, { name = "fastapi", specifier = ">=0.110" }, { name = "langchain-openai", specifier = ">=0.3" }, { name = "langgraph", specifier = ">=0.3" }, diff --git a/deployments/ag-ui-dev/requirements.txt b/deployments/ag-ui-dev/requirements.txt index 2aef33aaf..367fcafa3 100644 --- a/deployments/ag-ui-dev/requirements.txt +++ b/deployments/ag-ui-dev/requirements.txt @@ -1,5 +1,6 @@ # GENERATED — do not edit. Source: scripts/generate-ag-ui-deployment-config.ts ag-ui-langgraph==0.0.41 +ag-ui-protocol==0.1.22 ag-ui-strands @ git+https://github.com/ag-ui-protocol/ag-ui.git@363d3878e30887e88c1fd5ca1916ec3a5962b6be#subdirectory=integrations/aws-strands/python agent-framework-ag-ui==1.2.1 agent-framework-core==1.16.0 diff --git a/deployments/ag-ui-dev/server.py b/deployments/ag-ui-dev/server.py index 8c6baff74..25845cffd 100644 --- a/deployments/ag-ui-dev/server.py +++ b/deployments/ag-ui-dev/server.py @@ -18,6 +18,7 @@ from deps.microsoft_agent_framework.src.agent import agent as microsoft_agent_framework_agent from deps.streaming.src.graph import graph as streaming_graph from deps.subagents.src.graph import graph as subagents_graph +from deps.subagents.src.streaming.subagent_emitting_agent import SubagentEmittingAgent from deps.tool_views.src.graph import graph as tool_views_graph AG_UI_INTERNAL_TOKEN = os.environ["AG_UI_INTERNAL_TOKEN"] @@ -79,7 +80,7 @@ def ok() -> dict: ) add_langgraph_fastapi_endpoint( app, - LangGraphAgent(name="subagents", graph=subagents_graph), + SubagentEmittingAgent(name="subagents", graph=subagents_graph), path="/agent/subagents", ) add_langgraph_fastapi_endpoint( diff --git a/docs/superpowers/plans/2026-09-02-agui-demo-subagent-events.md b/docs/superpowers/plans/2026-09-02-agui-demo-subagent-events.md new file mode 100644 index 000000000..e9c4d9566 --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-agui-demo-subagent-events.md @@ -0,0 +1,80 @@ +# AG-UI Demo SUBAGENT_* Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Our two LangGraph-backed AG-UI subagent demos emit the protocol's standard `SUBAGENT_*` + attributed content events instead of the private ACTIVITY convention, with per-token deltas. + +**Architecture:** See `docs/superpowers/specs/2026-09-02-agui-demo-subagent-events-design.md`. A `SubagentEmittingAgent` wraps `LangGraphAgent.run` (1:N expansion of the graph's `subagent_activity` CUSTOM events into pydantic `ag_ui.core` events); the graph emits per-token deltas; the SDK pin is bumped. Cockpit first (flat variant, generator-mirrored into `deployments/ag-ui-dev`), then examples (richer fork). + +**Tech Stack:** Python 3.12 + uv, `ag-ui-protocol>=0.1.22`, `ag_ui_langgraph 0.0.37`, LangGraph; Playwright + aimock replay. + +**Branch:** `blove/agui-demo-subagent-events` (off origin/main; spec + this plan committed on it). + +## Reference implementations (copy style, never import across examples) + +- Seam + tests: `cockpit/runtimes/microsoft-agent-framework/python/src/subagent_emitter.py`, `tests/test_subagent_emitter.py` (run-wrapper generator; sequence assertions). +- Id derivation: `cockpit/runtimes/aws-strands/python/src/subagent_emitter.py` (`-sub`, `-sub-m{n}`). +- Wire-capture doc convention: `cockpit/runtimes/*/python/docs/wire-capture-subagents.md`. + +## Expansion contract (exact) + +``` +started {subagent_id: tid, name} → SubagentStartedEvent(subagent_run_id=f"{tid}-sub", name, parent_tool_call_id=tid) +message_start {subagent_id, message_id} → TextMessageStartEvent(message_id, role="assistant", subagent_run_id) +message {subagent_id, message_id, delta} → TextMessageContentEvent(message_id, delta, subagent_run_id) +(next message_start / tool_call / finished / error) → TextMessageEndEvent for any open message first +tool_call {subagent_id, tool_call_id, name, args} → ToolCallStartEvent(tool_call_id, tool_call_name=name, subagent_run_id) + ToolCallArgsEvent(json.dumps(args)) + ToolCallEndEvent +tool_result {subagent_id, tool_call_id, content} → ToolCallResultEvent(message_id=f"{tool_call_id}-result", tool_call_id, content, subagent_run_id) +finished {subagent_id} → SubagentFinishedEvent(subagent_run_id, outcome=SubagentFinishedSuccessOutcome()) +error {subagent_id, message} → SubagentErrorEvent(subagent_run_id, message) +``` +Message ids: `f"{tid}-sub-m{n}"` where `n` increments per `message_start` (cockpit emits exactly one message, so `-m1`). The CUSTOM event is consumed, never forwarded. All events are pydantic `ag_ui.core` classes; verify exact field spellings in the installed `ag_ui/core/events.py`. + +--- + +### Task 0 (cockpit): SDK bump + baseline wire capture + +**Files:** `cockpit/ag-ui/subagents/python/pyproject.toml`, `uv.lock`; create `cockpit/ag-ui/subagents/python/docs/wire-capture-subagents.md`. + +- [ ] Add `"ag-ui-protocol>=0.1.22",` to `[project].dependencies`; `uv lock && uv sync`; verify: `uv run python -c "from ag_ui.core import SubagentStartedEvent, TextMessageContentEvent; print(TextMessageContentEvent.model_fields['subagent_run_id'])"`. +- [ ] Baseline capture: export the key silently (`export OPENAI_API_KEY=$(grep '^OPENAI_API_KEY=' /Users/blove/repos/angular-agent-framework/.env | cut -d= -f2-)`), `uv run uvicorn src.server:app --port `, POST the demo's delegation prompt (read `angular/e2e/subagents.spec.ts` for the prompt + `angular/e2e/fixtures/subagents.json`), tee the SSE. Record: today's ACTIVITY_SNAPSHOT/DELTA sequence and the position of `TOOL_CALL_START` for `task` relative to the first ACTIVITY event (the ordering datum for the design's §6). +- [ ] Serializer probe: temporarily emit one `SubagentStartedEvent` from a scratch `_dispatch_event` override (uncommitted) and confirm it appears on the wire with `subagentRunId` camelCased; revert. +- [ ] Write the doc (baseline section + probe result + ordering finding). Commit: `docs(cockpit): ag-ui subagents baseline wire capture; ag-ui-protocol >=0.1.22` + trailer `Co-Authored-By: Claude Fable 5.1 ` (pyproject/uv.lock in the same commit). + +### Task 1 (cockpit): graph payloads emit deltas + +**Files:** `cockpit/ag-ui/subagents/python/src/graph.py` (`_emit` closure ~:165-171, phases at ~:182/:184), `src/streaming/subagent_stream_handler.py`, `tests/test_subagent_stream_handler.py`. + +- [ ] Failing test first: rewrite `test_emits_accumulated_text_so_far` into `test_emits_per_token_deltas` — feed tokens "Paris ", "is" and assert two `subagent_activity` payloads `{"phase":"message","message_id": "-sub-m1","delta":"Paris "}` then `delta:"is"` (no accumulation) — plus a `message_start` payload emitted once before the first delta. Run `uv run pytest -q tests/test_subagent_stream_handler.py` → FAIL. +- [ ] Implement: handler emits `message_start` on first token and `message` with `delta=token`; drop `_buffer`. Graph: `started` payload carries `name`; `finished` unchanged; add `error` emission in the tool body's except path (re-raise after). +- [ ] Green → commit: `feat(cockpit): ag-ui subagents graph emits per-token subagent deltas` + trailer. + +### Task 2 (cockpit): SubagentEmittingAgent replaces the ACTIVITY translator + +**Files:** create `src/streaming/subagent_emitting_agent.py`; delete `src/streaming/activity_transform.py`, `activity_emitting_agent.py`, `tests/test_activity_transform.py`; modify `src/server.py` (mount the new class); create `tests/test_subagent_emitting_agent.py`. + +- [ ] Failing tests (MAF style): feed a scripted inner `run()` generator (RUN_STARTED, TOOL_CALL_START for `task` with id `call_1`, CUSTOM `subagent_activity` started/message_start/message×2/finished, TOOL_CALL_RESULT, RUN_FINISHED) and assert the exact output sequence field-for-field per the contract table; the CUSTOM events are absent from the output; unrelated CUSTOM events pass through untouched; `error` phase → SubagentErrorEvent and any open message is closed first; two sequential delegations in one run get distinct run ids. +- [ ] Implement `SubagentEmittingAgent(LangGraphAgent)`: override `run` as an async generator wrapping `super().run(...)`; per-run `_Delegation` state keyed by tid (open message id, message counter); `expand(event)` per the contract; unknown phases → drop with a `logging.warning`. Mount in `server.py` exactly where `ActivityEmittingAgent` was. +- [ ] `uv run pytest -q` green → commit: `feat(cockpit): ag-ui subagents emits standard SUBAGENT_* events via a run-wrapping emitter` + trailer. + +### Task 3 (cockpit): regen, e2e, live verification, guide + +- [ ] `npx tsx scripts/generate-ag-ui-deployment-config.ts` → commit `chore(deployments): regenerate ag-ui-dev with the subagents SUBAGENT_* emitter` + trailer. +- [ ] Update `angular/e2e/subagents.spec.ts` comments (:24-25, :56-57) that name the ACTIVITY pipeline; assertions stay. Free the cap's ports; run `npx playwright test --config cockpit/ag-ui/subagents/angular/e2e/playwright.config.ts` → green. +- [ ] Live browser check (real key + `nx serve` the cap): card streams token by token; screenshot to `angular/e2e/manual/subagent-card-live.png`; append "## After the emitter" + "## Browser verification" to the wire-capture doc, with the measured `TOOL_CALL_START` vs `SUBAGENT_STARTED` order. +- [ ] Rewrite `cockpit/ag-ui/subagents/python/docs/guide.md` (:13, :79, :87, :98) to describe the standard events + `SubagentEmittingAgent`; regenerate ag-ui-dev again if guide.md is mirrored. Commit: `docs(cockpit): ag-ui subagents guide + live verification` + trailer. +- [ ] Open PR 1: `feat(cockpit): ag-ui subagents demo emits the protocol's SUBAGENT_* events`. Two-stage review; arm auto-merge after. + +### Task 4 (examples): same migration on the richer fork + +**Files:** `examples/ag-ui/python/{pyproject.toml,uv.lock}`, `src/graph.py` (phases at ~:359-360, :387-389, :411-416, :458-474), `src/streaming/*` (replace transform/emitting agent; adapt handler + `SubagentRunState`), `tests/test_activity_transform.py` (delete), `tests/test_subagent_stream_handler.py`, `tests/test_subagent_emission.py` (rewrite to the standard sequence), `src/server.py`; create `examples/ag-ui/python/docs/wire-capture-subagents.md`. + +- [ ] `uv sync` first (no .venv exists); SDK bump identical to Task 0; baseline capture with the examples delegation prompt (`examples/ag-ui/angular/e2e/subagent-card.spec.ts`). +- [ ] Graph: `message_start` → carries `message_id=f"{tid}-sub-m{n}"` from the run state's message counter; `message` → delta; `tool_call`/`tool_result` payloads carry `tool_call_id`/`name`/`args`/`content` per the contract; `SubagentRunState` keeps only the message counter. +- [ ] `SubagentEmittingAgent` as in Task 2 (copy the file — standalone rule), plus the tool_call/tool_result branches; tests assert the multi-message + tool-call ordering `message_start(m1) → tool_call → tool_result → message_start(m2) → …` from `test_subagent_emission.py`'s fake-model run, now as standard events. +- [ ] `uv run pytest -q` green; `npx playwright test --config examples/ag-ui/angular/e2e/playwright.config.ts -g subagent` green (whole config if fast); live browser check + wire-capture doc sections. +- [ ] Commits: `feat(examples): ag-ui demo emits per-token subagent deltas`, `feat(examples): ag-ui demo emits standard SUBAGENT_* events`, `docs(examples): ag-ui subagent wire capture + live verification` (+ trailers). Open PR 2. + +### Task 5: docs + +- [ ] `apps/website/content/blog/2026-08-27-langgraph-subgraphs-when-to-split.mdx` :185 and :212 — replace "emits `subagent_activity` CUSTOM events" phrasing with the standard-events description (no contractions, one sentence per line); check `choosing-an-adapter/index.mdx` for any surviving "convention our own demo backend adopts" sentence. `npx nx test website` green. Fold into PR 2 or open PR 3 `docs(website): subagent demos emit the protocol's SUBAGENT_* events`. diff --git a/docs/superpowers/specs/2026-09-02-agui-demo-subagent-events-design.md b/docs/superpowers/specs/2026-09-02-agui-demo-subagent-events-design.md new file mode 100644 index 000000000..575336bee --- /dev/null +++ b/docs/superpowers/specs/2026-09-02-agui-demo-subagent-events-design.md @@ -0,0 +1,74 @@ +# Migrate the AG-UI subagent demos to the standard SUBAGENT_* events + +**Date:** 2026-09-02 +**Status:** Approved (Brian: "we should use standard events; consider bumping the stale pin") + +## Problem + +`@threadplane/ag-ui` consumes the protocol's `SUBAGENT_STARTED/FINISHED/ERROR` + +`subagentRunId`-attributed content (#955), and all three third-party runtime demos emit +them (#956–#958). Our own LangGraph-backed AG-UI subagent demos still emit the private +`subagent_activity` CUSTOM event translated to `ACTIVITY_*` with `activityType: +'subagent'` — the legacy path. Two defects ride along: the per-token delta is discarded +into an accumulator and shipped as a full-string JSON-patch `replace` (O(n²) wire +volume), and the translator sits on `LangGraphAgent._dispatch_event`, a strictly 1:1 +seam that cannot expand one CUSTOM into the N standard events. + +## Scope + +- `cockpit/ag-ui/subagents/python` — source of truth for the flat variant; + `deployments/ag-ui-dev/deps/subagents/**` is CI-gated generator output (regenerate, + never edit). +- `examples/ag-ui/python` — an independent, richer fork (multi-message + tool calls); + migrated second with the same emitter pattern. +- Out of scope: `deployments/shared-dev/deps/{c-,da-}subagents` (chat-lane LangGraph + mechanism, untouched); removing the adapter's legacy ACTIVITY support (stays). + +## Design + +1. **SDK pin.** Both demos pin `ag-ui-protocol==0.1.19`, which predates the Subagent + event classes. Add an explicit `ag-ui-protocol>=0.1.22` to each `pyproject.toml` + (transitive today; `ag_ui_langgraph 0.0.37` only requires `>=0.1.15`), re-lock with uv. + First act per demo: a wire capture proving `ag_ui_langgraph`'s serializer round-trips + `subagent_run_id` and the `SUBAGENT_*` types. +2. **Seam.** Replace `ActivityEmittingAgent` (`_dispatch_event`, 1:1) with a + `SubagentEmittingAgent(LangGraphAgent)` that wraps `run()`: `async for ev in + super().run(input): for out in expand(ev): yield out`. No queue merge — the CUSTOM + events already flow through that generator (simpler than the MAF lane). +3. **Graph payloads.** `subagent_activity` phases become: `started {name}`, + `message_start {message_id}`, `message {message_id, delta}` (the raw `token` from + `on_llm_new_token` — the accumulator goes away), `tool_call {tool_call_id, name, + args}`, `tool_result {tool_call_id, content}`, `finished {status}` / + `error {message}`. The payload's `subagent_id` is the `task` tool's injected + `tool_call_id` — identical to the bridge's `TOOL_CALL_START.toolCallId`. +4. **Expansion contract** (ids derived from `tid = subagent_id`, distinct run id per the + #956–#958 convention): `started` → `SubagentStartedEvent(subagent_run_id=f"{tid}-sub", + name, parent_tool_call_id=tid)`; `message_start` → `TextMessageStartEvent(message_id= + f"{tid}-sub-m{n}", role="assistant", subagent_run_id)`; `message` → + `TextMessageContentEvent(delta, subagent_run_id)`; end-of-message inferred at the next + `message_start`/`tool_call`/`finished` → `TextMessageEndEvent`; `tool_call` → + `ToolCallStart/Args/End` attributed; `tool_result` → `ToolCallResultEvent` attributed; + `finished` → `SubagentFinishedEvent(outcome=success)`; `error` → + `SubagentErrorEvent(message)`. The CUSTOM event itself is consumed (not forwarded). + Pydantic `ag_ui.core` classes only (encoders reject raw dicts). +5. **Tests.** Python: the ACTIVITY transform/handler tests are replaced by an emitter + suite in the MAF style (exact sequence field-for-field, error path, multi-message + ordering for examples). Angular e2e assertions are projection-level and survive; + aimock fixtures drive the model and do not change. +6. **Ordering check.** LangGraph-specific: verify on the wire that the bridge's + `TOOL_CALL_START` for `task` precedes the tool body's `SUBAGENT_STARTED`. The reducer + tolerates the reverse (buffer-not-drop) but the card would briefly render nameless; + record the measured order in each demo's `docs/wire-capture-subagents.md`. +7. **Docs.** The subgraphs blog post's two "emits `subagent_activity` CUSTOM events" + sentences and the cockpit `docs/guide.md` walkthrough are rewritten to the standard + events (no-contraction register in the post). + +## Verification gates (per demo) + +Wire capture (before + after), live browser check of the card streaming, e2e replay +green (cockpit `subagents.spec.ts`; examples `subagent-card.spec.ts`), `deployments/ +ag-ui-dev` regenerated in the same PR (deploy workflow fails on drift). + +## PR staging + +PR 1 cockpit demo (+ regen), PR 2 examples demo, PR 3 docs — or fold docs into PR 2. diff --git a/scripts/generate-ag-ui-deployment-config.spec.ts b/scripts/generate-ag-ui-deployment-config.spec.ts index 8c7b9ca0f..408ec9f6b 100644 --- a/scripts/generate-ag-ui-deployment-config.spec.ts +++ b/scripts/generate-ag-ui-deployment-config.spec.ts @@ -2,7 +2,12 @@ import { describe, expect, it, beforeEach } from 'vitest'; import { mkdtempSync, rmSync, existsSync, readFileSync, statSync } from 'fs'; import { tmpdir } from 'os'; import { join, resolve } from 'path'; -import { buildServerPy, generateAgUiDeployment, type AgUiTopic } from './generate-ag-ui-deployment-config'; +import { + buildServerPy, + detectBridgeAgent, + generateAgUiDeployment, + type AgUiTopic, +} from './generate-ag-ui-deployment-config'; const REPO_ROOT = resolve(__dirname, '..'); @@ -54,6 +59,26 @@ describe('generateAgUiDeployment', () => { expect(statSync(join(outDir, 'deps/tool_views/src/graph.py')).isFile()).toBe(true); }); + it('mounts a topic\'s own LangGraphAgent subclass when its src/server.py declares one', () => { + // The subagents demo mounts SubagentEmittingAgent (a LangGraphAgent + // subclass that expands `subagent_activity` CUSTOM events into standard + // SUBAGENT_* events). The aggregated Railway server must mount the same + // class or production serves raw CUSTOM events and no subagent cards. + generateAgUiDeployment({ repoRoot: REPO_ROOT, outDir }); + const server = readFileSync(join(outDir, 'server.py'), 'utf8'); + expect(server).toContain( + 'from deps.subagents.src.streaming.subagent_emitting_agent import SubagentEmittingAgent', + ); + expect(server).toContain('SubagentEmittingAgent(name="subagents", graph=subagents_graph)'); + expect(server).not.toContain('LangGraphAgent(name="subagents"'); + // Topics without a subclass keep the plain bridge wrapper. + expect(server).toContain('LangGraphAgent(name="interrupts", graph=interrupts_graph)'); + // The subclass module must be staged so the import resolves from the deployment root. + expect( + statSync(join(outDir, 'deps/subagents/src/streaming/subagent_emitting_agent.py')).isFile(), + ).toBe(true); + }); + it('server.py enforces X-Internal-Token on /agent/*', () => { generateAgUiDeployment({ repoRoot: REPO_ROOT, outDir }); const server = readFileSync(join(outDir, 'server.py'), 'utf8'); @@ -164,6 +189,26 @@ describe('buildServerPy framework adapters', () => { expect(server).not.toContain('LangGraphAgent'); }); + it('langgraph topics with a bridgeAgent import the subclass and construct it with name/graph', () => { + const server = buildServerPy([ + { ...lg('subagents'), bridgeAgent: { module: 'streaming.subagent_emitting_agent', cls: 'SubagentEmittingAgent' } }, + lg('interrupts'), + ]); + expect(server).toContain('from deps.subagents.src.graph import graph as subagents_graph'); + expect(server).toContain( + 'from deps.subagents.src.streaming.subagent_emitting_agent import SubagentEmittingAgent', + ); + expect(server).toContain( + 'add_langgraph_fastapi_endpoint(\n' + + ' app,\n' + + ' SubagentEmittingAgent(name="subagents", graph=subagents_graph),\n' + + ' path="/agent/subagents",\n' + + ')', + ); + expect(server).toContain('LangGraphAgent(name="interrupts", graph=interrupts_graph)'); + expect(server).not.toContain('LangGraphAgent(name="subagents"'); + }); + it('mixed sets emit both bridge imports (langgraph first) and per-topic mounts', () => { const server = buildServerPy([lg('interrupts'), maf('microsoft-agent-framework')]); const lgImport = server.indexOf('from ag_ui_langgraph import'); @@ -178,3 +223,41 @@ describe('buildServerPy framework adapters', () => { expect(server).not.toContain('LangGraphAgent(name="microsoft-agent-framework"'); }); }); + +describe('detectBridgeAgent', () => { + it('returns undefined for the plain bridge wrapper', () => { + expect( + detectBridgeAgent( + 'from ag_ui_langgraph import add_langgraph_fastapi_endpoint, LangGraphAgent\n' + + 'from .graph import graph\n' + + 'agent = LangGraphAgent(name="interrupts", graph=graph)\n', + ), + ).toBeUndefined(); + }); + + it('returns undefined when the wrapper is constructed inline in the mount call', () => { + expect( + detectBridgeAgent( + 'add_langgraph_fastapi_endpoint(app, LangGraphAgent(name="x", graph=graph), path="/agent")\n', + ), + ).toBeUndefined(); + }); + + it('resolves a subclass to its package-relative module', () => { + expect( + detectBridgeAgent( + 'from .graph import graph\n' + + 'from .streaming.subagent_emitting_agent import SubagentEmittingAgent\n' + + 'agent = SubagentEmittingAgent(name="subagents", graph=graph)\n', + ), + ).toEqual({ module: 'streaming.subagent_emitting_agent', cls: 'SubagentEmittingAgent' }); + }); + + it('throws when a subclass is mounted but not imported from the topic package', () => { + // A class the generator cannot re-import from deps//src would emit a + // server.py that fails at boot; fail at generation time instead. + expect(() => + detectBridgeAgent('from somewhere import FancyAgent\nagent = FancyAgent(name="x", graph=graph)\n'), + ).toThrow(/FancyAgent/); + }); +}); diff --git a/scripts/generate-ag-ui-deployment-config.ts b/scripts/generate-ag-ui-deployment-config.ts index 645a89b27..9f198a7ec 100644 --- a/scripts/generate-ag-ui-deployment-config.ts +++ b/scripts/generate-ag-ui-deployment-config.ts @@ -1,7 +1,31 @@ -import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { resolve } from 'path'; import { capabilities, type CapabilityFramework } from '../apps/cockpit/scripts/capability-registry'; +/** + * Bridge-agent detection (langgraph topics only). + * + * A langgraph topic normally mounts the stock `LangGraphAgent` wrapper. Some + * topics subclass it — e.g. `subagents` mounts `SubagentEmittingAgent`, which + * expands the graph's `subagent_activity` CUSTOM events into standard + * SUBAGENT_* events. The aggregated server must mount the same subclass or + * production serves the raw CUSTOM events (no subagent cards). + * + * Convention: the topic's own `src/server.py` is the source of truth. The + * generator reads it and looks for + * + * from . import # package-relative, inside src/ + * agent = (name=..., graph=...) + * + * If `` is anything other than `LangGraphAgent`, the generated server + * imports `` from `deps..src.` and constructs it with the + * same `name`/`graph` arguments it already uses for the stock wrapper. A + * topic that constructs the wrapper inline in `add_langgraph_fastapi_endpoint` + * (no `agent = ...` line) keeps the plain `LangGraphAgent`. A subclass that is + * mounted but not imported package-relatively is a generation error, because + * the aggregated server could not re-import it from the staged deps tree. + */ + const GENERATED_HEADER = '# GENERATED — do not edit. Source: scripts/generate-ag-ui-deployment-config.ts'; export interface GenerateOptions { @@ -16,10 +40,44 @@ export interface GenerateOptions { */ export type PythonHostedFramework = Exclude; +/** + * A `LangGraphAgent` subclass the topic mounts instead of the stock wrapper. + * `module` is dotted and relative to the topic's `src/` package + * (e.g. `streaming.subagent_emitting_agent`). + */ +export interface BridgeAgent { + module: string; + cls: string; +} + export interface AgUiTopic { topic: string; pythonDir: string; framework: PythonHostedFramework; + /** langgraph only; undefined means mount the plain `LangGraphAgent`. */ + bridgeAgent?: BridgeAgent; +} + +const STOCK_LANGGRAPH_AGENT = 'LangGraphAgent'; + +/** + * Parse a topic's `src/server.py` for a mounted `LangGraphAgent` subclass. + * See the header comment for the convention. Exported for unit tests. + */ +export function detectBridgeAgent(serverPy: string): BridgeAgent | undefined { + const assignment = serverPy.match(/^agent\s*=\s*([A-Za-z_]\w*)\s*\(/m); + if (!assignment) return undefined; + const cls = assignment[1]; + if (cls === STOCK_LANGGRAPH_AGENT) return undefined; + const importRe = /^from\s+\.([\w.]+)\s+import\s+([^\n]+)$/gm; + for (const m of serverPy.matchAll(importRe)) { + const names = m[2].split(',').map((n) => n.trim().split(/\s+as\s+/)[0]); + if (names.includes(cls)) return { module: m[1], cls }; + } + throw new Error( + `server.py mounts \`agent = ${cls}(...)\` but does not import ${cls} package-relatively ` + + `(\`from . import ${cls}\`); the aggregated server cannot re-import it from deps/.`, + ); } /** @@ -43,9 +101,9 @@ interface FrameworkAdapter { /** Module-level import line for the framework's AG-UI bridge package. */ bridgeImport: string; /** Per-topic import of the staged module's exported object. */ - topicImport(mod: string): string; + topicImport(mod: string, topic: AgUiTopic): string; /** Per-topic FastAPI mount block. */ - mount(topic: string, mod: string): string; + mount(topic: string, mod: string, t: AgUiTopic): string; } /** @@ -56,11 +114,15 @@ interface FrameworkAdapter { const FRAMEWORK_ADAPTERS: Record = { langgraph: { bridgeImport: 'from ag_ui_langgraph import add_langgraph_fastapi_endpoint, LangGraphAgent', - topicImport: (mod) => `from deps.${mod}.src.graph import graph as ${mod}_graph`, - mount: (topic, mod) => + topicImport: (mod, t) => { + const graphImport = `from deps.${mod}.src.graph import graph as ${mod}_graph`; + if (!t.bridgeAgent) return graphImport; + return `${graphImport}\nfrom deps.${mod}.src.${t.bridgeAgent.module} import ${t.bridgeAgent.cls}`; + }, + mount: (topic, mod, t) => `add_langgraph_fastapi_endpoint(\n` + ` app,\n` + - ` LangGraphAgent(name="${topic}", graph=${mod}_graph),\n` + + ` ${t.bridgeAgent?.cls ?? STOCK_LANGGRAPH_AGENT}(name="${topic}", graph=${mod}_graph),\n` + ` path="/agent/${topic}",\n` + `)`, }, @@ -98,7 +160,7 @@ function pyModule(topic: string): string { return topic.replace(/-/g, '_'); } -function collectTopics(): AgUiTopic[] { +function collectTopics(repoRoot: string): AgUiTopic[] { const topics = capabilities // 'ag-ui' and 'runtimes' products are both AG-UI-served FastAPI backends // aggregated into the single ag-ui-dev deployment. @@ -109,10 +171,17 @@ function collectTopics(): AgUiTopic[] { // pythonDir — its backend is deployments/ag-ui-mastra. throw new Error(`Capability ${c.id} declares framework 'mastra' with a pythonDir; mastra topics are Node-hosted.`); } + const framework = c.framework ?? 'langgraph'; + const serverPy = resolve(repoRoot, c.pythonDir!, 'src/server.py'); + const bridgeAgent = + framework === 'langgraph' && existsSync(serverPy) + ? detectBridgeAgent(readFileSync(serverPy, 'utf8')) + : undefined; return { topic: c.topic, pythonDir: c.pythonDir!, - framework: c.framework ?? 'langgraph', + framework, + ...(bridgeAgent ? { bridgeAgent } : {}), }; }); topics.sort((a, b) => a.topic.localeCompare(b.topic)); @@ -152,10 +221,10 @@ export function buildServerPy(topics: AgUiTopic[]): string { .map((framework) => FRAMEWORK_ADAPTERS[framework].bridgeImport) .join('\n'); const imports = topics - .map((t) => FRAMEWORK_ADAPTERS[t.framework].topicImport(pyModule(t.topic))) + .map((t) => FRAMEWORK_ADAPTERS[t.framework].topicImport(pyModule(t.topic), t)) .join('\n'); const mounts = topics - .map((t) => FRAMEWORK_ADAPTERS[t.framework].mount(t.topic, pyModule(t.topic))) + .map((t) => FRAMEWORK_ADAPTERS[t.framework].mount(t.topic, pyModule(t.topic), t)) .join('\n'); return `${GENERATED_HEADER} # Multi-topic AG-UI FastAPI server. Aggregates each AG-UI-served python topic @@ -330,7 +399,7 @@ function compareVersions(a: string, b: string): number { } export function generateAgUiDeployment(options: GenerateOptions): void { - const topics = collectTopics(); + const topics = collectTopics(options.repoRoot); mkdirSync(options.outDir, { recursive: true }); stageDeps(options.repoRoot, options.outDir, topics); writeFileSync(resolve(options.outDir, 'server.py'), buildServerPy(topics));