From a2f8869e919884444ef0c4a573a75b58b1ce37d6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 11:31:34 -0700 Subject: [PATCH 1/8] docs: spec + plan for migrating the AG-UI subagent demos to SUBAGENT_* events Co-Authored-By: Claude Fable 5.1 --- .../2026-09-02-agui-demo-subagent-events.md | 80 +++++++++++++++++++ ...-09-02-agui-demo-subagent-events-design.md | 74 +++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-02-agui-demo-subagent-events.md create mode 100644 docs/superpowers/specs/2026-09-02-agui-demo-subagent-events-design.md 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. From bca9098b2e4f78c312147f6f75cbefe73aa1990f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 11:43:16 -0700 Subject: [PATCH 2/8] docs(cockpit): ag-ui subagents baseline wire capture; ag-ui-protocol >=0.1.22 Baseline SSE capture of the ACTIVITY convention (TOOL_CALL_START precedes the first ACTIVITY event; TOOL_CALL_END lands before the tool runs), the serializer probe confirming SubagentStartedEvent camelCases on the wire, and the SDK bump (pyproject + uv.lock + re-exported requirements.txt). Co-Authored-By: Claude Fable 5.1 --- .../python/docs/wire-capture-subagents.md | 155 ++++++++++++++++++ cockpit/ag-ui/subagents/python/pyproject.toml | 1 + .../ag-ui/subagents/python/requirements.txt | 6 +- cockpit/ag-ui/subagents/python/uv.lock | 8 +- 4 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 cockpit/ag-ui/subagents/python/docs/wire-capture-subagents.md 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..8bbd01855 --- /dev/null +++ b/cockpit/ag-ui/subagents/python/docs/wire-capture-subagents.md @@ -0,0 +1,155 @@ +# 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. 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/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" }, From 611a28f31df38ca9f162a670b8eb94475b7647e7 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 11:44:15 -0700 Subject: [PATCH 3/8] feat(cockpit): ag-ui subagents graph emits per-token subagent deltas SubagentStreamHandler drops the text_so_far accumulator: it emits one message_start {message_id} before the first token and message {message_id, delta} per token. The task tool emits an error phase (then re-raises) when the child fails. Co-Authored-By: Claude Fable 5.1 --- cockpit/ag-ui/subagents/python/src/graph.py | 31 ++++++++---- .../src/streaming/subagent_stream_handler.py | 32 +++++++++--- .../tests/test_subagent_stream_handler.py | 49 ++++++++++++++----- 3 files changed, 82 insertions(+), 30 deletions(-) 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/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_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 From 3f7eed0e438a665ab08201f27045e232e97c5d8f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 11:46:59 -0700 Subject: [PATCH 4/8] feat(cockpit): ag-ui subagents emits standard SUBAGENT_* events via a run-wrapping emitter SubagentEmittingAgent wraps LangGraphAgent.run and expands the graph's subagent_activity CUSTOM events 1:N into SUBAGENT_STARTED / attributed TEXT_MESSAGE_START/CONTENT/END / SUBAGENT_FINISHED / SUBAGENT_ERROR (ids -sub and -sub-m, tid = the task tool call id). The CUSTOM event is consumed; other CUSTOM events pass through. Replaces the private ACTIVITY_SNAPSHOT/DELTA translator (activity_transform + ActivityEmittingAgent). Co-Authored-By: Claude Fable 5.1 --- .../angular/src/app/subagents.component.ts | 9 +- .../subagents/python/prompts/subagents.md | 4 +- cockpit/ag-ui/subagents/python/src/server.py | 12 +- .../src/streaming/activity_emitting_agent.py | 11 - .../src/streaming/activity_transform.py | 58 --- .../src/streaming/subagent_emitting_agent.py | 193 ++++++++++ .../python/tests/test_activity_transform.py | 89 ----- .../tests/test_subagent_emitting_agent.py | 356 ++++++++++++++++++ 8 files changed, 563 insertions(+), 169 deletions(-) delete mode 100644 cockpit/ag-ui/subagents/python/src/streaming/activity_emitting_agent.py delete mode 100644 cockpit/ag-ui/subagents/python/src/streaming/activity_transform.py create mode 100644 cockpit/ag-ui/subagents/python/src/streaming/subagent_emitting_agent.py delete mode 100644 cockpit/ag-ui/subagents/python/tests/test_activity_transform.py create mode 100644 cockpit/ag-ui/subagents/python/tests/test_subagent_emitting_agent.py 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/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/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/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) From e294f0afb8f1cc4678b6237e6540761e2a6ebc83 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 11:47:14 -0700 Subject: [PATCH 5/8] chore(deployments): regenerate ag-ui-dev with the subagents SUBAGENT_* emitter Co-Authored-By: Claude Fable 5.1 --- .../subagents/docs/wire-capture-subagents.md | 155 ++++++++ .../deps/subagents/prompts/subagents.md | 4 +- .../ag-ui-dev/deps/subagents/pyproject.toml | 1 + .../ag-ui-dev/deps/subagents/requirements.txt | 6 +- .../ag-ui-dev/deps/subagents/src/graph.py | 31 +- .../ag-ui-dev/deps/subagents/src/server.py | 12 +- .../src/streaming/activity_emitting_agent.py | 11 - .../src/streaming/activity_transform.py | 58 --- .../src/streaming/subagent_emitting_agent.py | 193 ++++++++++ .../src/streaming/subagent_stream_handler.py | 32 +- .../tests/test_activity_transform.py | 89 ----- .../tests/test_subagent_emitting_agent.py | 356 ++++++++++++++++++ .../tests/test_subagent_stream_handler.py | 49 ++- deployments/ag-ui-dev/deps/subagents/uv.lock | 8 +- deployments/ag-ui-dev/requirements.txt | 1 + 15 files changed, 806 insertions(+), 200 deletions(-) create mode 100644 deployments/ag-ui-dev/deps/subagents/docs/wire-capture-subagents.md delete mode 100644 deployments/ag-ui-dev/deps/subagents/src/streaming/activity_emitting_agent.py delete mode 100644 deployments/ag-ui-dev/deps/subagents/src/streaming/activity_transform.py create mode 100644 deployments/ag-ui-dev/deps/subagents/src/streaming/subagent_emitting_agent.py delete mode 100644 deployments/ag-ui-dev/deps/subagents/tests/test_activity_transform.py create mode 100644 deployments/ag-ui-dev/deps/subagents/tests/test_subagent_emitting_agent.py 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..8bbd01855 --- /dev/null +++ b/deployments/ag-ui-dev/deps/subagents/docs/wire-capture-subagents.md @@ -0,0 +1,155 @@ +# 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. 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 From 0559fcbcdd0f90024bd398d9705bb49d932d423d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 11:56:35 -0700 Subject: [PATCH 6/8] docs(cockpit): ag-ui subagents guide + live verification guide.md describes the standard SUBAGENT_* events and SubagentEmittingAgent (replacing the ACTIVITY walkthrough); the wire-capture doc gains the after-emitter capture (per-token attributed deltas, START -> END -> SUBAGENT_STARTED order measured) and the live browser verification with the running-card screenshot; e2e comments name the new pipeline (assertions unchanged). ag-ui-dev mirror regenerated for the mirrored docs. Co-Authored-By: Claude Fable 5.1 --- .../angular/e2e/manual/subagent-card-live.png | Bin 0 -> 48788 bytes .../subagents/angular/e2e/subagents.spec.ts | 20 ++-- cockpit/ag-ui/subagents/python/docs/guide.md | 66 +++++++++---- .../python/docs/wire-capture-subagents.md | 92 ++++++++++++++++++ .../ag-ui-dev/deps/subagents/docs/guide.md | 66 +++++++++---- .../subagents/docs/wire-capture-subagents.md | 92 ++++++++++++++++++ 6 files changed, 287 insertions(+), 49 deletions(-) create mode 100644 cockpit/ag-ui/subagents/angular/e2e/manual/subagent-card-live.png 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 0000000000000000000000000000000000000000..45f2fb73c3206c437052786b68c05151ceea8d45 GIT binary patch literal 48788 zcmeFZS6GwV+wO}pL4^sZGywq>LFpn*x($>rC82}TNeESX6BHE?m`D>4kPaynA@oqB zDZPafAoLEQNN-ug|NOpht!wRly3e*J$$?jR^S)z@=YH3e@&o1l8bVMA=&x>H@pyh767eC+Dwm3KgU zokaS;>C!OWJsnudd7VD=F~5fwtHA2kexm&AizO2XFZG*~J-Z2O*7tvZvfexF3c9~l z4#T&D&!?c+G+31U&zre(=kNV_b3^p;p?f@1sp zxodx3|6#q@@aOeS?L!LcKX2x!{{N5o|NLImJ8%-R5%#|mV$MSQhR*EOi}>$TP)Otz z7tmE33|1v#HZ@FVIO;m32^98Hg;0z!8^up|F|Xk%A`Tuc5RMh2pg3`bnW^V7$s$9LFJNZi!cxx#vI?xzLk zV>w5|fJd*;*mD#wQ>UZjE9ek9^GT5PBshfx=lOeYPVkDA>$!LX?@v7x6va`l9L{aM z^pLcw@J8x~jIJCMKh<-lxi}yeYcuyQQWzR6M-RTov&Mz`aZqT!OXGf1%jE8?8&EaO z#7;pm^y)|ttFx2e1q*juYjslve2!8)%k&v_ z13M1|uHB&8e(@HxI@Fu5OUPRg_9Hh2yzSnuujt*6Y~;Rec^*QNL6xj0 zzvzWz;Hq1(5BBYu)7aS8qY(n^l_mYk8uluUDS`@mngazcJ^EG>Hou=4uFn$FbsWl2 z;IG$GQ*3LvHl5%6GUK1Bx2jjKJG}u)f=#GIbN9p z6jUWFY|p+JAN;sV-Wl+7 z)0wgOeTcqWQyAbf)`cwcr0(7i5JT>ir+IwXEmCM@1*`G2- zRl#N&pow!&nk8?@lkao{SgjQyo;<0h+C#9iTMktl?^~|p-=duE(qlWs2v;{4Vk6R5 zE{_utwY#KPdp;Q{C`qBdUEJMri-+K!0<~~R{&{~g|56J>1p{I}3=_GbKU|Q>-V-8~ z(YW`b*0&9Z&H6QYgQ^eu-_Pvq{q9XmfrRD_Ik(!tp{k%Hy-o8RFXLXU+l%$2h3FT{ zNm7|RN5TTmA{U(#T;H9;=6TSNbXnG4v0IgFTsR%KX`zKLs%KaprJp)`{yt4#UA#q> z?prDL!%r4~9kK9UFB}NA>M9Ebp$4u$IG9JMQXX&cR#b8?2K(ziG~Dnx#z} z7Z`puL_#(c!?2?JX%<||I9I%lFwbg=k}xNCE;Fw9cw5?$!W=hCB=pTW{}W;er+gWC z<;}S{&gd=a*PVts>D5{5vuBcd{uZ8xYd^e>@O0Q;c{*|5Ok1%Fvvc0BRLe@wl2nlH zq7KYlmG76ObLLUF8&~g3($Xjjekw0;7d>r}Ln6G3ISVh*BV|@QSn{Iy&xVKMy$UvJ z8uqaQN~bU_0mO-JQJQBXriN<&m)K(r{8x0&@IjU^wSn1&`0jWSwi|1@Y`{>C^8WUK zpu-^h!IN}#eD?TzL>u$uuZ!(E7q!MElN_^NfR`g- zURj;ptuqY!cVqV6FxMNDtUP|OF~^+tOG}^>>^)5pU*Ax#%sUGfqP4%?BoEM9Zr%F* z^RvlGUqViwlQ=CAz13+VT2=syXB!~Mlk>!B&a!CSU9;Uk^p*#}KjgkkF9;Rw6!11- zP>CNWMPo;5w-?xZPPSSYz+v&IDe+JRVdP_%V|TO`;#E=P-JFE|ydIaiD6vZ4xb8Q| z*)OnN!;wE&%_@k2_}H#)8O-NZTtX;*+as^h(pq8g3;oZdg;7qlNh=P@q6I=mtLB+k z?S~u85m)hl(~%2LesFnQfH$%p)a&=4gSs4qn@-zUm1;-G2%YRF(G8Cj&RHoCj z9*YzVx?2MaCjXv;V4O%tl6e{eftH129_Dv#tdm8L;XMsE`SmVVCNPNBj2npv`%P9K zRG16z(WmQe#t@+Fq&_K$r+HGz=JwF;pajHS?yGbIbAD>-<5spJ#n) z(pJ<{q#$dx(p~PQtm$n$-H}|k_fJ$~svMPSBKMP^bhiw z6pI)YOdu0!ei?3#LeE+ilyVR$1{0fz!zx+!*L0-A2(PlJo0RbG!DGu{viQ0gXNVm5 ztNmsci^W4&{hFo6lf%B_yv2#4(Buw5(w&y?#5mX}vVLWhXO{^{9@nGSOjo z4=IP1Ab1q)=Ur-vevKNN1Q!s>tya`$jWuT9r9M|Yidg@1wj6m?n-9R%E*ZW;1>-V|~!kqvlyWQ7UT_yX{gB_6)>3 zS1?gzU*lsvkr}_n$E1Y3F@Ykv7<3-HFAFzy6n?Bee+tunkreQFt7%0uSw)xCV%>pl zc~Yi0h2^H>%I9=QKGZ+e@Z=zxcUTbSWau8~Yd66u*gKQ|H!8@t_tv;JWuk8omyV}d zq@7bb!K&kyf*lj?K5{O~enw!Q?~1!J_6j~=vUZz~YWRKaQ^A4TQuggG9FYPF;ydC? z<{1uWrfv~KLBkDo#?wlPMA*iDR^VQ=je+o9z_3Dby6+f4B}@G*sqU%Fo=7S-$#}<9 zr_3y!Fbj)6Qgd=QXLb?AYBe~CPUV`D$vt$~*6-4==1c_DFH zu%mJ;z{|3(Ow$B=)%mn7QT|@mROA6;J;{$jFvp8_&EWd7M8y=(y3B4Z41a9t*?8Qp zG(W9KBi_{BW3qN^F6Xkk`98c+oW=rZyGu2&g(LE+L}11SM^~ZE9>->GE!?f_<_Zn( zkTs+qX>N{~ESF&wXlr$|9{;UJ$h9u4`pZmkH%{UG5;~GE{}+qO7>s54LORnG#_JH@ z)wPg)!n^XNtK;s0jpSSw+?0Gxr#RH4v{j*A`$QeC4ox_it zX*zDGN);@r;_R4@IsR+_DV=lFZ>V)}&u!=w?AewR<>i-^^N4z!URH}5*r`25(mdoFWb^`riub%sV84Oi3yl=z8D zkGA2d5pJ`IY{!Au4FeK{o$dw&LIRUvwPVn;jwmCR3Un>-5_N)z$WzS6x&z!UE`Bv_htGw)W9e zeS^Unp{<~zwr;mb!8!}&WsJba_u0>S-b_N~-kzJj7~x7sq^ z(00H8f0-W1>f2rdxX-WYQ#?o(J9`FKn}~ zZgq;&4Tx1*mFF=DeC-foW-|@W*$Z2BG1NZ+X%75)9pzr5r!PY9AM-!!(pPPjNO8oRdJHs+W}WUL z@Oh*!xWu3rTvr?*i0rXU@q%;2>ExNAFUGU@dPU`hVv%oPEmVy})r9SbRT#t!92$P(nM3M&xCWCW zK8fx>3qrADsvTSeco+M4z4#%?t-r17(S;KI3Wvj=?H8-UXgqPBH%fKqlX7mP`j6f7 zxS)CRViB&#% zMd=h_JZm&nHoo1WhTj(0cO1C(7TNR5lM$Ve@#qQCi3{r4cQA9oV<_w9WHkPMzjN*e z9b`^db|0U1$Y;KLv@Q!{9$2F!7x9ZF80LFFJ}5sKpRAYK9aJsHb#(I{3N|`I9r#9# z;nJF_x!$>#ZLS`+vsw1=E@*euG>L<4M8vo?3OY1oR|(E`^n68X@m%*3ZM zJ^X`NNR8-rfZbIKZ5ubWz~MRjCyy%ACL}u^WBOX#0~WLAR7Z`PrPcPF@ff!r)$5Ej zfqr--|KM#xRkO7JFJ#j~(YjYpX`Xy|>_QH|y=-!F!N#?vNW8mJl{MJyy|^FE+DWH- z`lJoUZCgF>@=6U(j_k-x`Nc>FQ3XT_Hl{(dUHeM|Jkgb2E7hhf0!g zbSv)O;jrxfRX+qGE8nGTMw~Usp7Y=9^F#u zg1R*nOJ1ZV8!f?F#<6$Os$5QRwQd6i8@=nxXnFa{mG^VOB*PWX z?$UKL<-P;SLXScl<%MBG$l@9AVQ5CS_KAwwxl-HjSgGW@uZOD>v+JARx6ZK;0z_pD zrSPbJk4vOnoZuYNbc$T-{xVu+LKH)kde`N=DsBnmxYy__HI)2O8-~BrwraXJF~MhN zC7XSn@vB!tz^e^Hmfv6WYJp+&*?rAyl$jd zm9uGn&$ni0Yd0m5mR=`tomElgS!46gdo>=*^%0@bI<$+Y@+*jkma*{&mIFl2WCPs z+@%N_b53D}-h7U18@$os$XJH+iLG=qF>`0$$wbkZL@?UmzE6JWd;>oWGU2+9*^{)En6lo$C}n8)~+!1$x<^k-0;$B5pIb`jlIy@lrxl6JRfPba?|Ks&Iu&<)5k;X;LEm?*4p zyR+N7V;&EGTaFG(pFP-}ts&gx*sfX~=MYRSU6gKqOiomdu6MIdj5L#7{PK;FH{!Hu z&5BulS7qR8fh}CR%rg+N)Qe0BbkJCA4o?lq9r|MR@@rRmazV5l82P?Cr;$ z%;mV>zNh-WPl~E}OvQ>$rRa|XZbxQ*FP|ahe;zv?N&fy&CGgI{ERr zu8vMizewZ6BC|hQ_SLp<+(hdDf8{FZwg;;dm|BN8^!%f#LCaEysop72&^p}}$MAp3o8A*zuRt`KpDRRO|98s^)nksGkTMb$Ut ztH#f3n>oO!T4|GA@9;N-K4>fItHtK>+Gfqf`D1?lAj0;S?=Bnb0vpbY8MKaklg z{A`~G{1Q8s){S12UE)!oY1~DJ1Tz?>1Qf5P!WC8Jq@uUaC+`Fa)xdDQxqE(j>?6jA zKjU(3ZfYoKi^$@e!5aaGm$^`f|Vo|C(eZ2aOK z{$5`P9wYF_zNKb(ct44)gxwhHmqgMLZvCcJ?AzHLG$jXlR5$0&Tz7LuGx7V?6+{!C zW?7LP840F&G7_|iXI)xNZA@hK(ABOWi!$jg?Kvj};q6QXKKs1nb@Gj>LX_A1$Y(#5 zgmD5WVj~c%4L|Ps@wIG1mI`%<(@_)S#jEemB^#!yrk!cCf3S^vD8mOx{L_oWdpFj z_xeQW^>v9hGT5WXjNFnIqVsh9*B9x|p{W5?vDWL6ykCB~FOQTDl!J%sCm)DRTc8d` zaNz0M!7}ThlWD**aAQGl`0}cBn9kgn^yO`(-RZ%quf{v$_8X~kq0&ExxSrKaL6I)V zrB_#_S)eckn~~B0YTO~+(EUzVnYGLOBqVn5@#ym3NX21d%*aXggTldO8|TmB)+J<< zQ+H}4H@dDU6U%bX<^()urrSWDxwoAF!TcUOmZe61WzhXC*}eZb3-kwtrPM2pV;0h=>TdJ$YS^Ru zru3zKWvVn(qUS* z@cYiPxR3a#K6>X#E_7C~Getticuc0uJv-MJ3$1a8yC;pV>lHZwR~jER_)=0ARpK}C z=oGH)O4vLW)CuzYdJ2JVo7}fqBu{W;=)GLzt(yklcnlr@AEzx9Hamj5m%WNz5eyW- z^`)6Ru#V^Iy-b8u@N`$dsFH?;$0;b`5Z$#xm~}QPvQC0{m>j3Qif0`#V(gtY((6Q_ zQk6#pS7nA#s}Pn)dH1eP8Z!k!hNsfnabZOd!h>{T`@xl!?yAu8I;G^{a(;*hy#AHS zw^edT2kZInrSdcI?@pJ095{*3H!S8oQ}4klwnww=+}D{AKT_5!XO?a1bk6N>Lo#LF9K--*#(#1OFE1##>nTcdvKEvZMAm z>5tNC6x2hP0AOPGW!0oOfRFecUdSy7)kF+3tC7`G-qThKn2JT8<|bUvc)IL*Tw&A=k~k!ZqMnIE-e^=&?p13;=+Ukik@kPO?15}r)p*+8&QJ*rBy8u?A$ttIb?v#Wm+=L1 z3H-igA0q28f4!ir7PO$254+famuu_yiZVJl0e1y=O33zIK9Dcn)Wp@xzAG<%!pEe5 zEWIR$_WvQ1G31i$@e`8s7DnmpiEnu8Ng2=^ZlXa652v|DArX8K;9F`@I((V#@19*x zm|p(dC8OD$<5hpM7H?D^H+Yv48eciiA|&p~P{U51+Xd%p;>c~lN` zVJmw=wnMjr!z~tJmp&Nw_J@7S_~E^0@KQl)Jt{^U^j zcT}p*0c@;yVN2V)a68dR_Jg#sDHmxm%%*bb#$4fZe?ONC1NxRuT_~cqoOHS-sxZxb zr~m4PHc%5w*(w_ZE8k&G2Z}jfkzne7ypm_H+EY*@b0j{bghzp!2mbxPKy13|U^$AH z7y#tHu98Orf3>BnW8llc`d%^Z=&ZIjGf$`Ww-DKpJn49z@@c5`Q;0O^CT;!qv@0$Y zZfYbOZ9)g%b;;)b5Pt%9<8&OcU_h}yZ2IFS{mrDVl0b~xD%gcQ2yfo^N7J$#)hFwpyt@M(QK(EevXl6AWV*nXmyY3it1%Fk{Ie&g!q-xn6Y2<0 z{sG`cmOAM<+X%*q{0M88`!{;K^Q+;>^AA&d0FEUorYcQvJ-{-Gi~aa-FJLp2_iP&T zgd_f$UvjQg?m-$T(4%bxL*T8J(=e<6OZow!jyNo`b#|}nfFHBCaKQ3aVE^mCNG_y$s#K6}0y!Iy;G9pm8Bca;NnX=Mf9a1DGow6ad1@j{Y4Y?a zH`?fb7PjeA|PQ3Fm<_Y-bM6Q80DhEvcD6T9 zfHGM%^V6uW&Yk<0?Z2>0(s@7*n0eZ^h0qfsFB`)G*MF8Oh1vuUdhyTh(| z{QA5m{(aodIz!dODH5H}E7CN41nFU<1_vgg{>FnyJkE*y-F|<3w(?Vrp8cJHYpa94 zS`He2&>3Ei0MnZu_Bmb^Y65Hsn3)pa=Z3maPNwkPR;A%UqW%z}&kbwf9O%S4)e{Bo zSft*n@wU}}AVa^1L^FMo?!k#DQ3r4ar=V2GX7_pWB48{4n*wV;?+;_~Px9)V`(c-O z+{&aZTgHGGsPN1O^!vDPG&7OqsBUOhBp9dv)MJ5JEZ8AbRCF`QXg0|JC_IJYpu{Ps zc+x0vp2tz7pWTgSYxaOSK1&?$E85H6%fNgcey_3XHP5=2FXs*DS?;&jg!+ZDgz2-{ z2yXK=G8D0NHiWzz`P!s@VQ>-} zYPSZ+4eMdOjyNDP->!s^n3of0hpGaT^mpcf0rpWaKLM^`Qr4-kb$wNQ^k1|WS~3LC zo>1h@$oG}X#*^LeO5QAweA|PaVQong>|9M%$X30 zibitT5(LdBWt@NTcJTtb|LM!)ssqm=e&V+U$C@ zb*K^M@Ffn4F!yMxm|p~UEgSCAvoJ=9*?}PcK17UCVVd5SQLR6Cr6-ME3!%?IVAZgK zTB5!_85?77{E~(|i>jN}TB>EFs%*rK$jVvCPO=U0Ag3I-#%9;oiM zPy$B@`~KtO!anOoCjabt0lIyA4)*>$lD(fbd~ocxk0*gU7X(CpN~3`XW@F!bAj~x44zp4nXoRU@ zyRbarD6BZe*^BYNXerq5tk4uhFnU5{o}4Gmw%l3X6~sx$oxX7$La)}>ricxx~F{dAW6X%TEsuSlP{P5L;Uo^ca&o(Pob=)we|Ak zZl%G*6%bKCy&qo_qhh}*nenSd8(L|1Tcr@M7p~!p*2=D4Q%W4qbnv8GL;VLGIqPIW zwz8lRUXDH0O@w#U(ZW?;fW+ZRhXt#nPwPZ$9AknE^QH=cB!l|qG6*V75?=|nHMDr!FMlygiSU@b^G&%jUDf}6dG~3ActLT24o7BKjY^I- z^hwzpU~UW*A>KY2lrwl&DvJIsg!B;AFz#U+f{hGD-Nij}Rj<)pY)JbfNSj*ZMho*D zisPUKjye1Q2c>v1bbeQhzuCc~5Z|~~XgQNE%Vjp7THt|-R7B400YoTp?7^^B(Y${O z8X)Sc>zyTil!eR?6!DGB(4PO~qLmtnrb+<+C1lB$P$Th3b4bD*XbHZ+*_>s*k}O-e z`+X{5;lN10x&=~XJo3GK))d`w96i-QxcS|1&tA1cchSCusQPGAk1(mA3~)*qx=s>i z)o-N&4wPw~KfNAek{IgOVsdp{Ihs-M)#1NTE>9|-*H1fIMbbGR-Kf-N^YK$|2+4AWqt%b zO2jO;(P&y59#(bKW_kR?KQa{a_8zs_%c7w;d#L~B+_zu(46+3WVIsi_(r6ir`n`+S zPc%nI86(n0s#m&g>e3~xn^tHMbQuuuhpBQzN|U)`sN@BR-(XvR(Moy%B=Eg>W9IX= zNR;Su+)^p>V6Q&m8L0gEnUS_)*d?FcW00KC_Cx;2t*Z<8nkm#jVV+-bu%mgH1Oi5& zadP3%Zky7wD0`rqYV5JW(xNnUFx#+Fy4Ho#XFEflNHB+2U``TKKN?HsFE}1o62ymZ zpMQhHenvu)yRVY>=RMd+N)p8jogg=JwL|X3skg^B$gd=w17zm*5VO~qpoe6LZ0K?k zI767E&8@08!)qL9abQCLBgN*2y3+L2_>i6Q5> zLGO=$PhT0-H%ezGyjv*e_H=m{2_YCPW2ytY_$MeY_-dTIx1Ox*(R<`}N{9tv8XYFn zX^y=6brUg6_?2<>3X{X)0F(GoKf!Y%44>5NI?c72@z`|Toi!lA*Sl*Q_B}}Zb$F4= z9R5Drqt?Q8UZVGJSt_q^QG=~hWU3RyCsCB8o-0GXU!5bsVd~Ce_^0c-M7{)QH-N8l z)Se^*uUcS#j(Y|+-pR;jjFQP;s@CbE^8NNu4!w3h)xWQXuPx?wTS-2#EBVZ6-ONkB zy~Hxd4%Ulvd%NA7RT%gl`Y>jtLaVHLn_BdYh+%qbxzXzY9C$weqlJ!U!H$7 zG2cD6jhXnXn|1oO^e?x<*Z9|ir)H$oe_Tn3C-t5oCZRKCJoEzuv46okTlHWhl=o2W*7$A3-tZVS_Y7gl;l-c7o z-vS9?c@>M^VYM|y!9J-H{sOur6>8@FHAc-&?M8R~6jImP7lj5w!5!TXouY&OC8izCc-rC}AG2OKVtI`yU$gmDTeiVp!1S z3-TMt9(L5uu8c>(!Sg*)ypYm#Q3Xm|qJm1OVgr2y&G2Qz?FjQj1$t2M9 z6(4u#l3>_7uKP6>MXeR8JtTc#<=-@G&Dp8@HdX3CRis~S$J{!kS6r5uJgmNGzebtL zw8S-Tz>z%o5-BR;+Pq^$JVaBgij zE*D|FJ~VI?!1>KSUDMqxC@_})NsoQqbUsb*8e&$As{3b4%%p5+gnv^j3t64h%kJa) zhj`_h_z%YgQX{;TH7gZ-Tz|i}cKl2OM{%`o41V59Q-Guyg-IQx-dyQf&cn&>?=v38 zixw~><{#VC<@a3TIJ4MDTNZ#C9sF(u~Qh*AJ8(Dw#ChaDbGt~#Y zmuf2)497}F#>kIan0$F{V(Yg$Ww^j22Pf* zK*|IACDEttZvM#XO9S88g4yHkh}gEGos)#z`%XJdY{TVd??Mbwad}Yfc)38%rxDNV z-nFNwHa?^Q* z$>ldjCy)8Lt)MuA41JwqiT;2LGAa;zhtIaU!6$4!B7VgM_}z$=Sj2}oO{C~R)ej&y zG^Ml)d;l`Q=FzUkpG@e7#vE9sdiG88j{Vmb2M_NXZpQX;UzU~HjCAObNv_5E7NY4b z-5Vt)&{IL5dE?tyHBa>7>dm8JJ^Ri&KJJGbtIXx72|1Ui(G86PRr#BeS(}m`Ffa2% zbok?liOGVC3%9?@?&}`km@+=pTaFa4Ll_3UGRjW$O|VELcU=+yv<;Ak7rf(CXFYNk z_MX?Ll~3z`P<426!_}vprOneN1=`Vj!^EsaVsf6i)e@t{pH)zuuLuXyK;UCR=oNzr zR$(oq*Nn`Nol{kXS@lOjhYK!Lnx*{FJI0`)k$W2(BTx%sw+?j*_x1(WuZ=tSP78L_ zTE&;de>1-Aq^AFWP@d946n`PKRPKvFW@=YaJZ#ab3Dszx>8KPs_K%cc%d*`cdUT@r zlV7m#xjsF_%&R?vnK;>A4s;uI)u_yMmshyyt$&}c%@3b5ZOsL$HFWIxaFbt@6Jqth z_}l!HY&xZ;y9SLBLUJJruU>5^#-FgzrpTstyCm4`68YMRCeR7!s&VzWw698|38)?X zALGYpx!tsjyRn@=+EA;5GAM+xgZud!b*PrrQHtk1|V`UPV>UzKl3(3b$mq8pmy54@i1pf!J*>8u(pNn{1M zn4Esq+03n%wj zK-|wBVIiBcY&rb;&r_2rPqo=dwqjNpBhQ@vI+UFE9nQ5;YLY2GNc)C*EH`FKZX?L% zE=x>ueesJ25fvCWI81#r$Q>e$3XYw{HJ0&qxxYVK)GFmo#yUva+c_~4Tx_Q~B5+F0 z8f8ORYmF_1Upz})*Tm@5?2|*<2{d2#L|)K2lzP6jdC7UVw!~5{$~Ldv2N-YI?3V?4 zmmnk`F-8+o(gTnZIu^h^+R*}mapuTDcL{ZRFQp+I`qsUA z;9vyp_xk1WI(3)*__TsBKjIpyU9|!07)&nRc2Qy$1a4#+i;t`Ruk`>}H2+5|fF2F} zc_Sy^j7#JeBC3Dr(WYxVyisQlNnSM@h{_3i7>qe&mA zhFw`6la;r(ot{gNaNf8~@XYb*uMHt5+h#?MZ-V~ zV4lMl$dRG2X6r$sbo6Suk|S*&69Gxy3y?0FiVc>u!-N~apLz^Bcl5C7cw$Z^50lpc z6U`Hj=BM2!7H*&IunI7Z%1K?A=t%T^2JyZFkNKcagy~sQj*aK-RhIDXe-f&K&~kX< zNBs0wOZdJU;qgjwX1kCGz4b4$rNuwVkz9ATR$gQkU@sOGX^K}&HL>@|FaEv=`bv!% zsR>~J7j%v&xHOE1M88ioALViRX1Ry{Q4v>BQBWyrV-4uMQ8x1QVZ&(A4x)L!Uap)}K-xZFuTCi1NuykAN_OaVwu?as;ig`G^p z8lSUh&6#cpUD7wt@_fPk_7h9;KC%)Pakpae-*vQC_{AUHTLf53#!cM$4^>v?xOj~j z`j6BN+~L5b+I?QXhWM9FxPl{+Z_9zG6>PS~8-px#eBmBd{bi+Wv@WXpfD$%i;YkoK z)^5OQP%bjnf}q4j2f3K`hPS99PJ^A^?3p`jgM%3w<6np7qvH$i$mY&*d1UP9*C)pg zDrh>X&XlVgzkaO4KW0PhTS=~ zHk`YRU-^}`_&UqT@Z9*4gSL(N7)XoB>C4x3%H{Tpm)`DIKAfw^(3|d_FWM{lw69a4 zm-6M{$TZe_aSx@S^Q!02+B-_>&W4BCTR(yQZV_*1eTNe{XXbEf{8t3e>y%Afxk7K> zBxn5wTrI@@+t`64h16DW;Qxkuj|bSw4pAb(PMpMiGZ#U;jDh!^GLu7eH`Gzbr&LSq zlW^E%L)G1Wz;Wr=HaJ1lDZiwJ+-?N_n+mGM*2N+V=An!irYuu^w@&D`q+ zm{!s3=24BZ(&_F>?kkFjbwiI7A7!d4M@Qeqbr%wa1xco{4C$2iSO04Y>5iW$xj`cI zpyh*D1(ryYy3@`pC>?&r~Fc6>u-6=xi?h z3u=nxKTivOggzO3hfhS1&PFXaHfc7>fE$J&=DQ0YqvuOfDQgh=?lHdxvq3GflG+>a z8XdC3eMiW}9{6Wb%Qi7`d;NWTvMyf+h+E<9Ak=tHdntlAY-5LHs#rLf|(lR}ZN*Naz3Va&+e78Ihc!fia;BKk!>j#*wA2h(A0zfdz)0kx*6d!j61Kqy5=(T^uL>n*1E>~?cE~Z z!&8pE3&imj`9=CYrE_#w%lw^ui#caE>IF$4%+rX(i8^`(BLBbkRoi212efkzHCz`` zKHcdHzH4JwxQ|ns+&=_qiAVct5Iymze=sqqQ>kC&H>&P@85zCr82ahi=l9nmpw0#w zMmYc7E5%wF=fMD&1cd55Ohq@cwNNEBtHpo4jEho)L)g?@y0||Ub zPB!a*lq3E6;(pixuP4+Df~54iNSgBhv=_Z?H%(O*OQx#N4xy$Z6axW0b#;3m1n6ZRD)Kxs-o(%dUv z_%mqMZSiMta{KbRYliNo=js0NssHCp<>T0Z8VZV+W-Knkz%i;E!j|b2^&bq-nxou> zOY2oMtxLQPfDdor%{T9jKR=mB8!-7rNlAF2Go&Ju&yj+n!Q#(U=Jw-Azv(ZZYmf@1 z9J@#mFydoYDgimxQ;)}eJix^Qn@I(0XI`Fo_cqzocXjD=P_oj^<8?*jzm8W2f3Sjt zJvtbSa-Q(IT#}%;fdZP#zodV!5wTW8dumb0N_ZWCOfhSXU*~n2WdpHXgEeiR5RW?Y zZ@>@M>Q3l{LjyTq_<>m|5t}})|9lRZCH)pC#@KP?@T}IoQ;hXEP82%hKaXC?e6-)5 z5PWV|$`QAzlFNoZ#k{z>jt+|SRV`O3JZ+_JYc+JnhNbMwn^oa%1Q-Y`fc8H<(~ zO;%e`7RG}_3ouyyK8gB7)q6jS77hFcW^%yvh4~D(`8C7j-$XFzKtR=6HS>B3Ry`Pp7_B_|&c6RcRatu_6dV|abL48Tbuq!(Sp(lY z&PF~T{P5|Y#aj05%fL|4|Hc+_ja(idpx(Ji-|MbaKqD+!{0>so%iQAOlfZ29YGT^O zYph(_{)-O8e@#mVV5VE>q~|f&0EVb1%z{EORLSa~FkGE*V(7Vhgw3-TTmJ&27`a6b zhC+_57lE5#G)4BW^702k5rFQ>Xq{hu7_MZ|76|kY+jyoh)NTrB&WvFVw|bG$0%zXM z|7Hw$P2a=+8lAd*6Pbr@G?Lp%GwvQ++9T)56E-t-Z6zOCBB~6jQ?>`ipno&j5 zIyn_xcl|N)<3-OV9xFqzXePa zf&Rf}_TLehyV))2F3R{fuDtvFqXI80F78KE!X&|LXiHyIITN7S1Hgh`$pqzA@MGcT znXfeZ9~8`~@!5O~(bsg*NPpFQNy7!qtuPb$fv4}VCD{#*yggn37?Pknk9EqPfH@K1 z397^qFQ+G>L~^iLk}|l+f?`4zX-aBZvo&r!%eyF^+d{bTAM>k#mhfB{??~;D(H8aA z3?py|<85{C55r0TRD=ENHXq~Dz+G_B^Z|ae7$bspyjo~no#!<@DYI>>pbDywNlyM= z&GIzw>Y#C?eBE3y83GuuTUO0kC%Xw6?{#RorDp4sJq#pb0(GFSe2QbI+Qx0XE#$*abGSt4 ze4Q&&oA7N!wl*Uy@%jh-s>Sk)`&|zPx`QXE@o5DYkRJp4X0mzDHab>)MYaR-zDgAz z37L4TU3svC+8$^H^K*##dR=qH@&ohusEUHxF;>BWB0U~aFq$-!YyV-)8_clf_)R@D z2{~&GAU>X~08?L%>B685KyGW9NSLpj{-PI(-T}or#dK$5_Xo#5{q>BSEiqBZS@7qq z)RWa~d3H+mT=I5!1^yjEh~C=A$dUeQ3PE@2Qv*l_R{ym(UcBHRIRFa5m7HPG-N@%b zhh;wuu;6+0>MCdv}`QPV*4N3&cqcXuGbEIrl!SCED}S+7?_= zZlExekqt!N)~}6JSxjuPBcd*X+3kOSFMtTfuz~OUSk;vijC0!nk#GYA*soOo7jF&1 zQzewVIHV7=)u}$tiELK1ao5lB@&!)K;|(|G+Rl899EocT2E_E;RWLV5^%Eqp6iAl| zDq>)~OmbFZ>_ho?hVU#-rqcC60eL;mnFRek{vfnzv|w#44`18~!D5VMq$Ag2SYfJU)?6yK05pqrRS}eD1XBpFbH_#GpQ2g@pYa_*?yL;O7mLE`qlEXFE4yS-w2xzO6>2U2vFz zq}*)<2y)zHJ`K|j1M_pp(>@Ioe=73bBq| z>1btE0uhmdUkm(*Kc!~x@9Wk;Sa84llWMTidy(o_Ab#6%2xt43*l? zuBaQozi^nD6K)3(c5*A6k*M)2D8~5>3ciI4P=g;VNmb$gyi6$&U&dnON&xE~8$omLO)aBX z;~YH9;0e$K)iN;e6~*n=*IIe zUShvXG!O=57mF+|!zHBypUwNUXWC-(SL8}J>Ef;b{M11{>yc$n3dHR6$tc7E`v6bx z01%hXElBPX*8`Wp#yq72^8p;`+ZVz6xXt-VK1;PSp+6~MSqA_shshS$=3Gn6m{#xv zi1V_#;+G2qdW2HnFa5_n^P*#e97RJj==`YY-P>wGCoyn<`9Z01a$EUiv3aIV_JWst zo$;?`!OeBK3!W)P2i(dAnusJXbh$%C^(o_UzTJG(VO#w8V%XvF@Kgu0x* z#ra)sfUWN|>Q7iTjh15WvKxeh0{-dGFIoIG(ZTUmKBz{cb%@&Re{uJoQB5y@zbK00 z7DcxO-89)2x`1>EQdEkR&_i#6v_R;++5qV)O#-2Zk`Oupq$&ae(g`J@cLD*Wckbl> zJkLGryuWvywGJYdRROY zdgIJq)%vRG0t;H$;%P;+{YM{`fYQ(35`x*ykD5U5Rhz+BobQgwyqv`9eTBkWP>=>B zTto`8^yPL)kvYrB%5`pkkiYf~Xs6{&v+!PJ9`QaYPLiul<$&IqJX+hGWU#+xI61NU z^|>8MEgP-=Xy5g}L<%E^`%`8UyrR8`047?O&KHefBz5E2^xcYsV3+kus?6WLnqe=i zpULZI0j9j=42xw7Rbs;Bh-EXtyS0e=s}?Iny$k|gv$_+@!9>QIthFEW<6K;uDQf={?z93o6Y_rGZzq|Th z3}4BltbV+x5X)=4u=miZsj{ENJnQWzj;j2uT59@7=TBs!)ml{Nb&=B74mGlRX${F` zxHJCFh(k85Wt~f3_43`EnD4)WTNb%=MCZoI_A$_GAGApM0f0u=gq+q8d|jtAojjy_ z=JBrg;;WltpLly#%{-Nmgsh))PI3og40a^As7wn;Q-EVD&X4nfZP!lB?G6!%zHI;n z<+@u`fn_v5QK}jsi@$&0mXX4MN}qn!DhSn#0mYfi+5bX8b2a6dWZJ4R5@!>U3b$SVOb7Lj45?LR!tA z;&cY*iUsqCOEhvi1#UgZnOkJW23I<{xEY6!G)-T=)Pi&vIH=`j3lSWLbn>{e2o2m# z%hRscmEFaSsv>(3F%=qoB z7MyssHs+-`eO-=V}rVxvVF7=}$_eHn`1jK{g9IF31fBw9a_W*m( z@E^vN-5?qQG2;VSsM z-@B_!YrWdogXk?NUcAKZF3(s^-f;xPr{lv1|6=|7MtZW9uoA; zkAF8`mBj0xo*eIv+OqS1&yJNb($dPj>%~TXW7goY+^=sQSc%7b?T&j7f7zW3ZZyiz z8+5Fn8?{X*Sf%&mnUgB)v)im%qxvl2EpS!)xkP^BG(U2I`@%2Z{rMyvq^Y*WlP}kEqA0wI|Um}+Ew_qKKg*MPxG0%$S|y-z!uWnB{3PWI#QZs=2zer zQx;}+hdx9x_71Kc!iCx1z!rVQy7M^ZgXb- z6!ZtX0iR|{)npK7sUhP{PXUWKsCZw_styaH;?vIGIA4U}z?b>{?(BB;6md#IJ4ZQg$UOKsp0~owM&Vd`XXN?Iy=2?8zK%(kW(LHiVPj`$O1pY4 zL5}hUj9%Tuzw{K&PpOq|6+Kt~dGxPUN@PH3)YKQ>#LAvLe)aQjHboCVt+wY3gY1HAiIi|j6w{!V zR@<%U9il^VvivqI_+(2${r(a+#DB-JYbHvhyZ4jQ4c|jbmFL)iZ&RE)M44+{v>_$} z(sesr-lK4U-2)}d5Y`OY#Wd#WoB5&@d($P^+C5kM(>uf1gGTQ7U%0}WZ&iS>Jz!1n zC%}4d%AD+3(OTf9uG?iSFUOi!IgNKqF0#TFSO1poE#}p~b@OIUZ1Khv9gAZ|V{HVV ziuFM`MiV33-@&3HmSo}<-bHhIv?yEav7FU7#@oiDJmK2L#+?5+?|OP}@u%&Trx)0A zWQ+VfEzMVF#Aub055A$#l*ZE>BTH7`B? zdv2Jl&rosvIkY#&!{%um;Xvyh^xgm*77W%jd4}7BC&ykH^$EtUNfICT(6OZ__b&Z- z17RY^Z!HgS?XG%sPjq*hT__UEH7AT~Wz|0VcF`tc%qW?DntXi}yAdzR7%CQ{{%RFZ za`F=OB#N3mHRaRsf6HG*?7)e?=iE3sT*sE9LxjUS@EvJJrNnBo7Pzz7 zEBIiU%QLfL^PypxykNy_p-GK4|Dd1C(C;+=wPh2#yhOX>Ei!>`)~x_WUOWcRPjG15 zUtozYKISLwXrHc!+&1ey&@c~!zf-TYqaE!p%FVbTO@GAcN-u}dGu7WDkCx}rOA)z^ z1m;~9yKnl{=q0&%wxV)IoRNfIjA>X|eiN-)#bAz<+D~$3eNRuCugd=N`K%=bYdcj( zL6&cpoFp#y;yy7x^?agrPb^&47SgxVuPV44Ro=a_?2$v4=JNgGY-@Dw@eaxJ-gbU= zO#eq}4ILP+S$NqT%I5a-E;3hfNNWic%)mb_x3|NrYAN^uJIMEyl7hR%bsG_iCBdVf z@&r|nU%F)k_8&Jip7cHF<|1?3_UPHBW-r}@uTRu7ysiwPf7H9B`$K0-+eTBRX1(E1 z4Q$CWPtef4(Ybeh`y^I!;U7}pu)QPW^^cJbv&q}!4bTse-|2@w=H-6RSP~|oUe#mr=SoMg?p6Sc6zegC}tu}(;~^Z zd$XJIbgNBF#J9w3btBG8Hb z8(8BpAs;9&#Ww#EM~V-Dio*xFk1O&X2=|*&D#)ON>3b@e_S}*7tJ_~lVa{?&_qs0i z{o4ih!gQk9&gyIUePpd(!I1I6F|)Mc=jGn$w|A13wQr&H=MO9sclV;-4hLTgrg)ix z-9C{|M6=}XFAaBF_h|<^?mg)t#m%OyBH75C0-jglw=ag7va4-I8%!-5FiQt+HX+P! zJ530c*|)j;yGouAhIl3)&byuopOh`=RHCkE4E3^LN*;JvwwUksNfnR$om*d%ggXYd-g@tvHz4pMm#3(O2*QvkfGsbWu@5_jN zL?vMg@#^`HQvCxMAJ}#w(F0`2cE&K-6)!|kCwXg~Dhd&pisrT16^>cXTg3gW5_XxA zL$Aea+|C8gEc%Qpn@Q*0BqE&~kMSZK0~)fpsVV^ExG(TlILsn{h?k)r zA$Uk_WjE~-$c*c-wihugpkbxYSh}&q@5BDl7l2*&I$MJ81fW#(8-w_W97(rv!A!t& z70kNQfG&-f21aOj=-HBqzsO&Z^2$nHW3EbKt6+Yq-7!?n`BAi1C_V7$`)rJLU6)(3 z>`=kOgASk5I(mI@B2wn=uxmtJY1!K^BH!cgADbHe*jc`D;^pLFA6@&yR`hr2wewQj zf2}tLx%_zh%zF4)Y$qk6vBW*u7x8v+GkM~M_(i8N8DYnw-D-ws=%CG|4{y8Y`)ChB zk_KNjOv3PrXYH=+2o|2GJq}Yk^Eo97DUunjk!e*+&NajyH|0+ zMY{VQ$lRYzjXPg*>+jcZY%*n=d5ehOi-`|HWhn;SZ)!+@n89DiBC zFYIefeuljF(Vw=J)eS8rHtX`I8$XdrWvTQn?=D;&N{7v}D>y91Rw>KhUo16C`{O3u zSz{+TDaO9WsPVFy}C-SO$PEi$Ya;8fM#{M|K7oZ zd^&yOL6v9HFZa@DQJSGMkinH7b{&1IwdanN3G4#^TrF>O3J%{r2xW1yBVZELRqnVn zoq-&C|G1`P_bbJ@!7zLmG{bz%nqA(Y<5faT0I!>T6 z753mGS6Dc^|EiF+5zLzL{gw`+#d*fKY5~V3WtmL*UNGPGGes0MIXVdLM!1eV3A(;W_iGvxa#{(fbfP z6l?kUKmt>d0#3wnJXd*~@X9aTU8bR6MCUTH(2^ujySf2tK1t-PEy`1G!;XBJqs;M3Zk@R&zTuYR2ajJjB;_@q^}% zSC^W;O-@#g&PFI$7R_xw@_PTpl_UM=V(4t+xqD1pdn@mnim%QHdN_T9O|=^>Fjz;o z(hTvUb=ZN}s{T=ysRO3A?`JK2To4^n`+0q#Ws)N?Fg zv&(a}Z1>tA*26|8?+Ru724ha_lZ_W_6Jl>S|Cpk!Q7J=D{g3wk@NSa1aU+iFU`Xm~dbWr)%8#!`rOjKn@x@Uf`b{Ki7l)G@7eka&@(M0%v?oF zWZ!SugM1c#izq-o;z+;!!n_EOQ}R&@UvXVl{m%z@6n|r$vtT9Ep~=jrSlq5wD&4C* zY0`o3fw}R7F8pln2)p}yU6YUXzbC5;sCzdOze>O-<6mwqhTMV02Bz(WMR#5jj=dpL zN~U47us~(Vq9C}!0Eg@5KX(DZ;~9uymE8Dp&R;|}uKHjB^z)%~MmD;}lLH5~t_j~o z6zFZoVA>G&p^HF8h7BPS;FQ)n5RPqOEkNLK~4v$fA6inlUG2om`eLNO*B4jUYS$Y&1!^4utkwQU$4*zVzXZ#fTI_sNC0b1iQ} zHHo^2A!W>HqQLzYRa9(mc(8QPGCIleq5drN-aYsSHmGSb1y52}@TugQpBUz;SnVjH z-{FPkqq-T?Vag+b6e3>hXEvodxG)!>htf*g?)cZ8o*WY2lATcj8||Ebq?@vpV!MT; zpimYC8aBn#;9e>s1HQ|p6m#wE)e@(?4kd6rEai2e5 zUnC}9y9oKS7%-%0%OzMd_j&Ga*1(6I+9ByXu~$608O@40a-s?^l4FzV(Rv50{##3# zdZX*EG$mfu{p+jr7N%2fHD5hf#DhB8mk)2E8Xi#r>|Kggg z;Y|sRP41pBcH(7p$2h6zF2WxOAk*)y~SxsVno|1M)W zSa5v&&pPlT;p;I&O`#9p>P71Hu~(M_$M0B|`mI-(OofM7arWmJdhD8IY6q{*GEImp zl#0-WO?+884w_h3=T^$H(c%CbVI2C+7e#uW3cRcI&G)-yJ$&A@cCvQGt0k*+Qrqb)(j}TENJI}% zq)*0fK6ZG%5G&Vv9tiWHzzPOG|0}*`P@A4~c0)T!z?AwQuVtBmRStgsPu~^bL~AiF zZr7UuRIG{h-IT^ea1Q=BSTVLVC4l?NE z+{yju&aoPI=JlKnt~Z`G?5k={d$+N9 z2LKj;NE@a_PYPMJngkzjH-}wy9xb!ZPOQ)`HdA5`tO#87k~n;cD=b{iiWGqCj=6Vz zy?ZNC8hlh*y2}7hN>hid4EPi)+%REAN8S7NTm7oe4f_lHfW@_c=8y&|SqxS@^`3jH z`3hUwVL{Z%PF3UbB7oxd=GZ577T?ao0;#a7C5Z7?<(Cy)l^Jc`4uDz?_d0sEV@5~3 zirTTvGM@-vf;v>wd>5FGpUg&y{I56VkV^;--k-mHi2YvjY_@)P!htWC2XYZ#mvjA&0RA}w@=YMH0&KcdC4p2eG2xS~5P6F?zgu~4wY*0~MMad@&Xr!6 zlpiJOw`IqWE^7aIW04A`F6<0b1z-2v;7Ct&axZ{D=CsP&?Vh6Eq4 zm^X(qq<{wo(5zVBeyAHOdcU45d%CZ2(2oG3%Qp+n@}b!x)7tKI*;63Lc&4$*2Gs>Q zm3*ViJ)^s$8>Gj=sh;}9ssi9HS0Jn#y=M}!u|qswyaw@D$~Y)bRMiLsk~T(_G_4`M z9nV}`Df;sJIcYS9^x({~m`<^ zVvOvGk5mAd7@bLi&Qr|5WBO@)&ccEXt!7|iQfRO=c9RTRBXz1G9ta2uI+O4?5x12g z9HWX@gYv7KGqiN8b(`JHjagaG&{pW;ljB1$*M?P-1Sq7c;<8dUD7A2_#-rks8TA;a zPyWOP|4NZqCpk9~a*Ar#ARMC6zTo%y>_VR1oo{C31G37K5gX^mNf6fbTgn7~ME~DY zGPd}ZfJy#COzVgq3W#FJnB2q$4`i7lK9(^J5IR+B|HVGmr2j`U4U|7|il zFJ6-1!(WTcOR`fItX&o6FVo)oJ>4w!%-R}#t1A(8db}PC*4YWfH0STNh zoZ~C1e#WossOJEXqrQJ;+3`rZO}$;;J!zPckzX@|gkAJLYb^iNv`C<0yxkUQPd=G8Spq)xEU=HknvUtvm-Kg+$Eriw&gi|!>ZpoL-|&-`T^ zTM0HspF0cMO!(VGax=Xnfk0?aXhkBKyA%ks06OmhYh&Q=yw3%Wg*CDpKkxH&mDd<0 z^h8BB0)<@c`(k=<5mWL3_Ykkk$!7Yg?y*Rlop?+ds8#@^P5@DLAAmY_06w1!H0)V+ ztx#2cKbfi5wB3(qFXP^WdPwliY#rP);$l{EfDP<#4QNodNtoR$Y!F=sMPJXVaj$_H zyLmeT6_yiSjOcCe@k05g*?p$ogt2*jQBJ+x+XD)oX5OQCtEy2O7HKpL=ZQ8dRUSK1 z>(XpDf@(MGJ{#ttt5;!I8p5T(8$pr$kf_juQCUr8*Ye*g_gQy~zGAx&?iiv5iBso^c=(fbHTk^DH#-qs zdb(cttGW<9ar{74f-!80qJx?Aq7Vr(L)t0w+av0|P90Q2g7!x__1eMT=~ZU!mA2hk z5pC8R1nz<*_>w-J2YGbw0*tTeGiBv=n7%2xB(FamUM_p|`}x{!x_95^% zxROz_V88N1lOx!B`P2WKD+T!384NJy>QVcn5RUk6od?#<5k zRUJ@K!Acn8cq(|hHyewJh4Znb{9wcUzsu`h%RlEE`?8ft9k~9f1bwrDzd+#$!(4Hs zExDc{(r~=xETYx@kegex{SWYzbgu-Q>@On1g|mTaCO?ZczE9*p6JNn{+~Y+lN+Gbm%Ep5LnPaNyo2!xTge_|iy~2iJ%-36f^42Wh zcYCStIlnytLjao3N7g;}RKcHifYouj3VC(tOW2#*TY-}ewM*$&dGqqp|N4tF=f^Sz z!+U%Lria9c#k;5P=-5Dl9+S8ZIwdd@%hhAP{0K|j))}1uqxtV88QRN*k@1nbOYT`H z>l@kiV)5EKdFrUtyi}>XVv;b2go=b_P+#g`@oS^ywlRMg&%)-O08bcpI=I(oCmP6V z4E2<$gX@h)7^1c2zlH{+eA)(#4f^HLOCqoMJzj$t76}3CUsPd%GOXec_zD;^+32Zl zKhQ%lJYVF){dahoMfTm6q8y)jojn4r3zH6cIjtri+XFl>9rIaeKSwXGl*3O@C#fe!$JKE~MQda3J>zgJZg2`1t?N>2)!D@2mQgT+#L70?U20{2A;miX^Mt%jO<(Q) z%(TRDLXAKVO1KO2#^vXa0C9ls>Yc*xQ@}-#jn25Y2n43bjw6reTfqt-nz55uMIFvR z3Yh>9OJj5cxGWoo$2)$@j=Jay?;bZ9xs+$;DVp4KV@1k0j==nZ{V=O`nQp)6wHGPa zV0T-bg%A-Jd_3HEf~MaG-8bz{W);b)wtk1Gx@`cPNH;hJ4uL|84rCS4g zt-nRE1D(5SvI7e#vBZePBuaSGsJ}G+T)Gf29da8kKuusUNQ_xrPhXW@tDfDG6Wac8 zKgI`$suV6CvGbLqL6OdD+nd0#vJ>TSIX?)J5BZ}JT@ejCn@#i;JE1*HA|;6Feza<( zN_ybVDC&mjZ@^Ehc3J#^u<8sjA;4+oh=F2=p$k}Tyq8!00AI?5gs4J!^ecNo0Le#g zYE1(z-(f{`ZfF!tAKy;vLlg~C12C1;tCix3!z-{iRTULm;L8EAQf&ZujPZ^I)z-*# z`aItAeDOn4x!MQEyOYIo`7UV)R+wer=y~)v^ebl=;^nm8H2EY=K4FPX$jy6qrjw)8_u-jVd!;1OB{{DVxd!Ue? zTEYCL&DM*D&++K5f-XgMBJJVSK#<-72sGuj0s7?wIuPKE!ZD+22u;Vw0K)%D9{_Yk z#A8YGaBt+?p#jSp$R|7o%C>u*xbKloS!l{b-$}aW+vNL7e?$tn*Av`Nqe-#TY8CD={z`bU8dT+H@g zdjx7tidgUGU$CnE7fi+?kw^br#>nN}mf4?+>uzekBBi!C&KQ;MyO6xKoc|lETo1+~ zAtAx5!4ergKm_Zu?MQjBmcI~)U#4tM$D$6AdJ77-O?CD3Y=WsY_YOeo0N;#=a&>7f z>%+RDdWuluN4};g3-++)=@mc^0KRdY+Ut77?_BoZ4>k+B13EijFXq^j-|<7`^RP?x zA*flG4$Gp>$9S44ktVr@L!kBLrk5Hxf>;vt&Iv%7>3}mFA8hN~UIaKBrdI>Rv#h=) z!1t4^Ank8_D|t9D(lu?PfQ3KL{MfUo!!$=-UXxBh8nm~uxfMq{M(!)Iy?6iREf(WUb7BdijlR(O_dqCi1HB8->s)dZ=n&A2a%w{B zs~9Gz!fE^ftX|%S4+k~;L)^cDhNkB^3joWo)PSkiXp7n9CqP&Sd_YamBke1&?{n2r z7{jH-#*^)0N1YL%{~A@;{Q=F>86Xr;dF|Z__NichJ^1txC>B&#dH~STU!Y&FKf(IV zR=dtF0NLZ^_xGTd_<(jnrKEwF=V4cP%}M%Hp4yEL5p ziAh?6%hCkvUkTnH)LX*oK_?|rKc8s4RT%1^ zjTsr~j8%$e76Ta_>0Y;bO~JescbkP3cw9gt*VIwfEd2+`sQVopz|`X)61qyd%37C8(Tf#_78 zDK~B>TAcVIdpR#R^(eC!kUv9z$Bi5hb(Pt8U}6I=2=fLA1&3hDi3r%V{(&Y+*8nx2 zS;<)%s_C$>znEV2eC_Lsz@ju(+8tDqK?j0c3E8JOcI`&LB8np4w(8SXoK(tt zRhJJgcMT2o{f#I9W7^$tt%5VoJKWLVvtuZDK0&{KGWH7FThVKa!=Qh_y}FByN|6PL z`hp-IbefEK{^dszcCw;NR$KL5-!u0K*y$l%3BTX zO+Jfw%-;2MuOZ~@0~a<_0)WV(*-lXWi*e2v4dht`+g;mY<8e|oc4GlMWy+%3FLGKjvokMMUGoDgoAympf5e`8H9&Gup)hur5XcXMf+To7obJW$< zr78#j3cI(4k6fY*ihBkQ*3I<{7SM`XG`$AGzW%gIoOaubxJr$25It*Sb&rC4i;zY( zbP!uwTEvU&?vY0j|C>UfOQUf*IN6)|T(I%Tt?QXiJ{AjvjAKy8To2tUb3j56`4dbo zTu}}LSyN2YGt^z&aG-+dr9B=r0pZ2OYy`aS*3xxIG+N|C$$SG4If zDMitzS1an6^BLlWd~8`{Noj=yEZq_=4`t|}=%3Bzwl^Jy{Y0#S!TwXaH!ne)xcTjn z#51ckujiong~QP2DcUKDTq+e++#?=qWBZ_?RBA}7q9z>07~ zzz@_<$AjZ!YCr`i2Q|#L=ivSXf7@LS1lG5Do{w4aSY0KpS}_zuDe#W@k{vD#c>v*q zi?T?J|8KDim$mr#=h9I(RAM{_-TTroGTU90_%@;tVzOk&2B~-$QQd6`&$zQ>no$6d z;0sB^OZiso7r9bDx*qcW>PeS1U%#Z_dfwu&&xGQLe4(ZFNd!6J-5yye53V4*R2i`E z*7YLHT&nJF8@;VkWo`6CDCCNrc4cK{z5i~*0un@C8zE;O{k8?sK)|iOp5*fth_{Es zN$ymQ$#W-gz9RwjPS1|NRO-kxBfi$yy3x>OTes0iDFA~Qw`)=+9g6F3ViXhO_d1(Lp zx8`TbnGg53bWDvGIc}Q=l-`;K5|9?lmB&*wF!JDiHH||R!|t1+uAT;!H_4W3ARB6w z4@3Z{@gfk8^d!{T96Upy>P(~pk!z6q8MfOxJPM09$%vG%CGgLdIetxEKLPNf+IyJ( zB7J)SB{t7^I?O!eQ|4Mm_gTA`h;w8fc=Zro_=~d1kF+C6mz4;Uh1scbq$afqdai)It^kE0!^4EuvWFsG1c0 zb&tGZ28?X@2$AabjJ*tFyLmdeIi#qoHwx;Fm@7qrp^oLFTclSM`JD6%fnBkHBS-(TKxHU2T6h{@ z6N+x=w$XVl3TY=-#}8+QzM;Wvd41R;r^lONmmv(_P?n_^kHkncOl;HVw|lR;=SUbU zU92VCBvnMg!*Lah6If{f&@frWSC+Gi!&C2q6q; zn2{tr)9VRB5b-$VC7aMHCElI&)#?mb$h09bybwIqcMkDBOukK>?M83fafCCPFJaeo zFJ&cUOPg$VdDRNzo=0a$e`~lv=_u5H^v@uRPKpLe7tzba&2L2;%g9q9;sCQ-?|ufV5X?!Q%&*PfoZ(F-7A+!s_no(sJwBVExQ;Q*A3}42V?%TZ?OuP%4HI4t zHj8ZceTk^-zUWU77(@_!y`}Zo!#8tojGj*=^Cr^?TMl@#{lf~U+$LeW7@f-ND{Va|< zZxY~i@@q_Qjzaz}Afu#Y8KZiHyMUBOzPJ;6-9`r=r>o7k2i?}(c?CQ9?=y7hh>_&u zdw*|-i^aUnI-9~~DR{wCJ^q#PIfPVn!D{Asdl23PjoVr#rT8q$BxDzr@BE!$Hz+ln zGdK!cbK7B8vPyCSvM^E6NERH~EkH7Jqe?f|QY+!Z69B3@2g7=drEnt1dRG0uJ|{NR z3l%e2p46T~%wLYzlRNCAlmjBa7+PV7(ue-b=BkNS8u!k z$4Kdr=?9Ci9IBlk>pBl6YA^M7GPXKK=uIiJi`0)o*fY!00v!bjdT&Z6>WQ!XG_1F} zrPl4hg&gR|I7)upefo4SxQZUH;xSpM&}6{!^i! zu=ueQwVX{LiRbMuxac9JZC`R>oxb|DM<4WGBTRQ;d~A4l3X+z8#5zX006{GcP^$LZ zZ){COtslfyuMN-T1aAD7vRUtiDo|d3E8(@QJLL!D$vD~IwC{#k{AcIlca@)aSr6A< zjB=ED&Qw}GI3*WKbNwom?1G@*Zv&eP{iB4{e*@S-85cTQdEzRKJ05H#D8CZdNf#TF zV1@MZw29BTBuzM;_gwswua&JfbN`?1H3H{=Kkt3S<9e@m4C0!WgOWEXXh@RnrVFr9 zJ};&{H#J3Vj@-%U*Z(kapC=5;U)W8H{IS@olQmSOA%>DRKA0WyRpdgT*S9zYWtb63{#2Fu5&!x8k#pnhPaF(P}8&)-u#J@}-i z+jrRj!;_yQ3)D^6Ygoa~egF`j)9ml@a!OS#m#L_#QqFv%v*Iwwn{XN^97?b+8N0y5 z7G0@9HqGFutTFl{(>A!!lU}^}8N?A7Bsq}JH|%9%+v1LN-Dn-RD)aK0-3n=F!2Jy; zY7?M!0`;XpUkHHwsHYXvH>C&~+TUIR0ySotKqX-slI9*cF)`nNgW>fKK_u8hXDrL3 z6vX{+HBM+4C(CQ7N=q(sw(sZ(;n&@OO5`8_|12E-9e)teo|I!m9>~uw$Vwbohz73l z&gv++d!gZ@oAuiRMf^byTF=Zgt+M4+%kU^Rp97fvE*m!o`*hbeOI7NN|Hl2HwTB z4&TFCE{sLDD(22fP3D#lzoO=nARL*6Xt$5wa|7~ZNqPm9A znJa|cZ*Huww&WtSWg&v~Tc0O5{aDDcZ@vEU@BQi1KcL=KbIwRo@3}{>ID^e;26qF| zys}h#ji(wY=aN;(^5JuVfU`45>e0LgXEz z15T-y05Eu9gs6)*I7hAuP$Onw7&ViYfY4CER``l{I`moQoL{7JK!u=n1BQ#*xp|lC zfS(D>@R$Qp3@^9oNG*;PY^K1laNFb)vrnS^PM1x!4?UT$%IsNe$Pv~CL#S$1QR9hU z9I8VY({7Sxab3Sb@}=3hEB~P9uxXy0jCDsmD9%dbYg>0xc>9?VBPB24b$&rRk?y)L zHNQ;*600|D6q)OKmFG<3$jj)xO>gDzAaJ&%ARTx_Y z?NZF+A3Q(I3p=A3>FiCjC=$nD_%%xRbnxDaZYOY0AGu65#b0+4MwRt(4vjJ`hAMVM zch{r=RdN>WAsph=$u};wZ0S>C$K8N*GX$|X09DH9@0Mo>bDX>Ji;6Q3TfiQoggD01 z!JvAf6+r&?Mt9~EP46}`GdBR>fYuM~BOimvMf?YHz2n_v?G@nQp}F%n*&vH(23y(Y zvHW_-{PCB7OQLa)AJM&gnxH{hOsyPVK6=Ys^YCsU9L%f4?k4~X*F$pbr1P2Ds~85D z*lQzXfHuJYX(oN4Xx6KPW1wdK@lQu!81Hl&Q+J7Vi-74hl)#Yhm*HFRrC64JXDk|> z3m=Bz%wXOO`X(T@fLN<7%j@N=or&P{C{td|4XsVo`(n%i6XD?i9n8~L|VKxeBN&s&;nPop60p)C7=GE9LWnK-1|ONUWnHiu0OHU2y|{lpsBVmK!QW1ePlp?Z z)E7ZyM_XDGWr||czb46%4tyzt6=D_T z8}ONfYv@RN>kn5o2#*Ll!#tOPx+T4@7SZi(*M+6YhpvjYegz>P#_RE);(sUH5GB63 zaA#?2RYc$|uOqRzf-9dEgTPc$0y>1yzK~1VcbST&| zZfhs++%^@f7-1p$l^~Q^&_&#*FodCeIV1!F>FJX@nv4|vZ`;j7q|{^TA%XXJ*4wsj z5lQmPsc(;AcPAgX*|%1HD!c2pda>OjrpHX*Qc$jUnWb{nS1(Mj@@d{QNH0VIJYXKC zWPGHSTp5_!;0o=godUbV@;`!^261x0+H>_?u`)-yXCvS0p*GowUT={x$Iog(D#B2( z&-?~1bt{FuID+s%${mJxe}q334FQs#da&^4tDhl%E%{*c;)BHQCmhhekpnD<$@>nC zZwq0JeeX;!kS+YHNR8qsKmF^PTI;5#wld>B~|eTO&jJM(MX(81IygYrNpGgFkEaH_vkz$c5V5+ix)B3yYU&t(fx} z*3XSerFiKwMkvMJp=?cgURUzuYI!}^<3n1H#qADFG7MHa=x^_>99tp?-*i==HJw`7 ztvz@y39R34v_sUs6o}_%1{SG-Pc|J-1mGmeDTBFjEj|hF8$)@&W(B~1_%`J zjog>udZK-BScYirlZ#E9u00P9Q4uz%oI)%-)(>2bWf=?6AXgL|RE3%<7T)nc`)odY zES=lzb`+C{VlW?7K@O<>M#sV(RnV4x=pXv0P31}x9nI?p;BdZ-_EfOjc7A?-N1$sZ zgPP6&Gh^51iPUPz^TQoV9yA3H;(c*Jr`*63S^`C^YAw$^ZjW~=J?KFBFlvXDu?S>` z4hap-OhYkf)g+AGsP1`Va5~?`|J``I4Q{OZ|HycAVWF|R2b@T-d3of_1y&2aVQRRf z_vwY?g<+6bGpqMYwgHB7Ej9mW#J?R=GjuD!G_me$H)N&ol0bD6_#C1<0AcHUwJ_@c z>Y4!Io&!vUYV1mfIan`hoBP{XN$ zCvZ1`>F{#E@e1IDR2>I~)#GWdH!JQR2k>d>vSIXj4ZOI-77&g;Jqg790xz$Crij}_ za}*08a)IyN^`B2dg@heJKrsZu>yXTZWh8%mE!ehV6AZHxf>MBzr_BlqtaleN>x&0~ zdbK73(s)^S9nS-#L(#>sW~y>)Z9RWYiM4bx=Zw#wTQF0&?sgmv>K5_yY z&}02lOzIx>g8^qDUD_YNkts44&Xx8B{6=dqz@xec{@{rg9|VA`!H#pX?U zrh{$t0PXX^)xSJ?I%~HFB8!w?2Z_c`edS(57?#^$AF{+caNk)aoKjnpg{(ps5`yl! zf*{A18{-o&zOR@S`3HMIwzKc#tJ}QF{2~ZIMOmEy>!ELpKm$Dm^QpYKQr;*LWSey) zbu{clsJ?4AmJaNPHd6X-rKv;#~NNOMTfO z;Fs;kRiq~6IwLQ=G*}QP(nXC?1Nj8`VMq=yHrhn6mfq@pZsY}SY#d*EVPuL#da2s7 zQr;sg7p%ePQZskH7!arZkH4_93bwufG%UBFMop>G_b^K%ul%%L`zw%1(+t&;DSb|N z`JWOX3R5>dgR!3z+4f`noz}e|TZf=5vu#nI1DS(>9(7yDG)aWAT9{5`;^D36~FL-9T9ST0t zm*##0aXZQ50IvRLFH~*L( z9r3ajSWtKTS6^PgEsm{RDQ;}|3~u7mD@apGFVj8-QOE6!va$`}Yp{X+t!yA*nBtHC zXVBNz-z5`N7tgQ$2Kf*(u%3~{ibYtg4(E z_A`DeXQ&ns;Eemg=-LOt3_b+!8|vs-7Uu92+W$PdhcNEv5MJX6U<-?IhE(}^!pp44vX3@AW-fBYVuqGsI&SPHJ=We zT`n{SF5lk@f3aB4gIIK>WS6!Fb$7Jxicb9?9g(_ef>8k~FKNibH*0bya7%06AdY(< zZq~t1pZ-Q}3M7{$R|?DHxBj2@&O92*{_o?LETt@MN+fl6$BjFb292$iHY5g(J(6gU zY?1wLA);*AN!iBCDA}?niYyhzGWIOlg&CPKJfExkJkKA`InN)@d49k1`#tA0f6Q?P z*Id`8})MK5}?u90M+P|%44`}Lt;S4qwzYPF{e_AS>LHH6?_JlrWqwI*3LwFwxV>ky$qIwd4kAk>zF z;zYNL1M1g6reb)`sJ>fq^r`iu5|}UIM%^5-K2YhtKACVT$zqqW$pkD39SPQ5X3X@O zNN$+2J(pn%B;E1r*Rv(aTc#K;c5;{UZ&B)b$g)Yb1xecE|mGuA&XFl{jINtxCYXN~!v6C9v7@2i#&z-qyiO1g-Zvoz2 z$YDGveUu$?uE2e_&`^}BP(YL~-9dS`dEt$?z>9Sys?h2f)#y|P$rX9EZVo!@oPE+K za$Iv=UT!TYr(8Px;z~3Ya8C}L3OZsyJ@WGrC`6rA_bF}&MGY3_C(x|RBKFVA&tg2j zeL7xp{(HM-g$>%Ro_&6p+sUNW98?~O*KluM3%-sq*k~wrDO?E_-Xe?l<@=nIGJ4rW z#fzT67$^8%1wovBPJE{3glQ0-h%fT7hvGQEI(=7T&Ayx;+T}LeXJ*n`AA^>Eo@GBF zDFohXsM!%fcx>uweloqlzFk@kdB`rNFu1}}BUWI3mt!|;|Yd0xX znjWIFK1(21K@J@XF&(Dqzf+K@C04Q#zn1>}QI)?+Qgh@p=U z`^pPnSjCN;YekyP9!F_3<%nWrzWgCPw(4%$eo%>aL*Z)9!)+k4dyXa4IYjGc$rGO@ zshgIZ;rNlAnLO{QdajJ|Wd~jbqtBy(p<7l1j(MwomQ;$=6X_2_|UVyMhcby1DLCA_0h|zTgjId80W0p32g zK4|Ewl~r2#ddih2fenTxgsbs!Ut(>s0B3+GZ35P4E@EC?Ye^nYPXbs(B(1QJ@gml| z8}gXCFwrr(SE~k3&mYo?()+W2#N8>f@=pJ%m#%pr{Q7L&A}kXSbFw#dL!Bz6#b|bM z+AI~FfU=0TQHMGcaP$SJF2^&oJc)`9*pNr==dT~IJMT8r({-e=?Dhh_$dN!%+7TUv zlS{%l#9YT_Cb`F-P1HW)C;cAnYhR`48R7gG(q4C9MDDy69fAvh46O#YnL4vQOcOBK zW9|mxrapUdkVy)Lp~)7GwF0yxijlXqwM{`yT3Rn>`*n@JsUN^tQa~7h zyBA#*9Rr=uhkF-REY_H@=FePn@ZSQtHwl0-kGIjZE1nm>dql1MG%A~7kv9!K;KeZTP~#f#%EST~J~wTVN{aDtq$t8<@3w z6bN1zva$>0y0ol@w&KFwdBD3}Ph+~y>=@CSWpv@>Y6 zcVYmLF>mwuIzdl`t zW+lTP9)~T#}-%1nHA;yV-!ZC`y*ejoY>l65FbpMNnM39d7@j0B7bT_u%KC%Nrm z!hg6UHyqY$P_|(DLbB0Dn(fE}uwW+#-C;pQ6ziJxTMGM=OI@t$_jlVw$ZcaiuVhNR z;>u3#G;>8)3OjRfM0CweCJn}Uh_>$XI~M`*J97b4t3S41iZck%-{b<256#=*T94)~ zT7>#a)n722-71H2!E3w6=Uy>3dFsRRp~NkU>@}=cs{X$2v)g?N>Z5!?IfHg&*!+tT zioxfZ1QK9`d)FH^T&qEjl*pRBX-rb-$R$qgEV3>0Ex4RbCjL3xv!uKq_h)oKbW}V; zfhh6aM-^|+;-%d%7(+EnAbslS6x~$h(4nJoOD_KGpJoqt95nECI^(JotSBSf&9Wx( zc+Za9^Ybj`9WjsOH#KwrEr&-&T2fHsTWZiu<(luS(;UurT%nY zyB+Y~Wo~eII0|^PfyX+$B6fnWJG}hMjv**(i~Nhd+6L@FQ2+DP0k-)ccR&VxA-w)M zu;>3N9(JAa86ytB1rAF;87|Fk5*&C{;WW=k`@ z%FS5{2ax+UR{WoJYX^qUr=u}DA_M5HO=37px?g$;0RCBNk!P7tS}B*^j=t_};Y4M5 zNK-I;$F4EvHQhiLO8@L^JFx>wYrPrT3CH%LJ`4cL{b`(6_50hr@{QQ#W@Ge!;=?nu zvOXYo8x`5z^OR>4=_*K@kB&c&#JW#qSzz*ti;F=s0!eQ!ne47EIMo(_Di-`lhZT`{ z0~bESX`@5NnQ>FNPmG!>m~ZH=wxdTimI0euyL0sxYs5PalskrTO4~P{-fA#@P)<&F z)$8}eKZE6tdR;tUG%+zDri4_$*f;3hDPe^^!j>c`xog*#q(`$K%!0;o+K5ADQJO8y zCxf&kt0mAZ(1e7GX{9|E^gu>^!P> zL5^s^eSVjCY?8||e`3|R-r`Br3i|oqhctItY!bd1T!DVjtp(q)NoptjY@7)@7@&Xk z|E^E?_vo2^oyEV~Sn=zQ{PXU}4?o7t!8-DrnXhy4C$DwK9X?11aFO7C?!VV%t9bp~N!{o z{;@~?mmkd^R{FWtK^o`3;n(}G2>gn`uL%5#z^@4WiomZ3{EEP@2>gn`uL%5aBET@$ z($ezqSORJTtD-`q(I&669`GQR5>vv5R8>v=?%%st=+v(+FMm;2)RMBnveVkk%xpok z?Q8c2kao4ZXBD0$X&Z1N^0)B!I~@Gd-LlFl6en;80z#sHut7f5$hs1|Fg*l$5lanR=^?k>{mSxFIr$uQ#>LK71o<{OWKLYK z{mq@_`Kupe=jCZnk5_$TdGzHd!B30UXmIeej9v+^Rp)H8K0~U*w z_CfzPIXT%@kj`Kv1B4HrHWpw_wf$jxg|>x-MHr`&KXNYywzP0}bK@5fP(+aYXN(#R z_PA~eSXwky4g&YBF_D#SNHJIIq8J$P;oqgl+LOZS z8H)ilWrYSsWLMzTbInQ%D=Yopa^Jgu-(j=o!e|r!yKPl-6mJVRSJxZ;73CaA=TbL~ zra{lvZ92m)Y?Jl5ihiH!;OCY2&~V9T@>$ zG_QsFQL2FMG~-BwHWKLw^?9B3>kwNl6BAnPPK%x?1ysN;nSfJKg=dhkHvy4ILU<6{ zCh%!>2g#!ns}T%BbdQci@m^~e2!AMk9N9*4o6k?Vw;r*5uZ~^5oeR*8)7$Zan%2I) znFhJHZi<_8OG~{iEiJ>%FNsE!_+~nU>u0`v=so6tJFi`-`5D;0RSJ_puNA z!xKlJu-R-}p7nV*2ne7pj3aVGD9$9ASOJ(#_wx0a(a-z!Y;82$j`$lIk}hz#W6MrUWH)qX-9T?yjN>lR>DJ2hhjGg^$t48H9yj zZ$U{JG^E7AnJ`f$vhmAj5`{a|!R4BZDB z;K-KYILft)Qii#PqiVCaTfk52HB@P#tUm@{LY(ykb%O4i31^k4CSg9K#HM|g+=0WD zRArR)TrmE@~&d$kcR>;Wo5K|)E{uaGf&i8x7NrB7I_o7Be z56x~hVW1)@Qe-?CBn!kI9`vraGa42gELeD)WBD+M5KN&z!g)E=pKE2N;_I28a#}-% z1%2m&(!~vCO%wX!aW`3aFxrJ<$B$T>pfmRq#ACdE0&dqPmN&5;2WR7AOW{)>4QBoICM+(dt6VWM=fh1=ywo>AaT3L8d z;=Mu2qe^en$4r7xa5=~)O&pY)ocIx^-wT!jsDe%k3JS7rx#al58a_QAZO(0V*W#ds{={7;uhs%@3SXY-d$5(+Yn5} z3SjetsxYg!fygnU<5t7&*`1$s{T4<MQIoCkQY4LirM7UT%^zFY{cy(rasz$Fvq_ zb=qhUG<0f*J2TTej)wcTQt!iE4?ykZIu8I_h_79p1Tq4o0B@X6;n+^l--sOZ1?W{W zQN1<OdGoik8nLOIhcqV{l)70?ruC-L&OqEZ3&|}rq)F_l5$QmjKja&;L zztp5>t8@1?LX%8rW&EOwq3VvBhdm)Hpbx}Wu8hD_f5?3h2GB4cFMvvoY`_I1AG$hS z{$c7LGqDwmFKnIABhk@4s!*SmIvppoo3CBo!87Fpo<>RVNTM6^pi(z+?BJpowkxEa zT1215+eZ~sC=`0lN0LzIbP5H%?(@k|qO$bF)R(m6ZO4nfUX-f;&qrIq2glL!!X zYH4YyTU+r9fkTzk1gC`2j}{;)=gsVJ3`Zmq|Gv5v^9Qg8mLK;VU`%f&r?aK~$68X> zz{82kLXCaWc{7b# zbqcRws%RpzvP~a82S7ukZn-a2;U=b148UlPOC@xz9ZvuwR-K)fkvzZK zAZna+UT}@c)m|v=qmyl&!c@34AV=F^`HawC;Zrwzx7dq5L48q+d-~ND1;&6w(Zg8~ zyz7#E??B7HGpdl1{k@$kmIm2Y99V#cdJ{%0!eR|KqCO(@d6ZXuyGahJ(PPE6b=q-r z*C%IzvpEWOHbrG42WP$eS@DmpzkqnpTI{-czO8ooZT?}DP{ovIv@LdMUQqT45h4z; zf#0^U?!^?{i$|4oV0KU_V5!R{-Pa1;vp}{bSCUL{;R)ObD;Dc)n$R1k#=2otAhf4^ zZG@a(*ZT+8m)A|rZX4#91~rwKfurn&#~JDCX3^LKeGb|%=I3B&nA%tS&>pFKv|WDG zMp6QN9_&!$I4nhV9pe0pr?CYlu2qzj}u$Jj3Y z$X*Zo@C^8Rq58~ZFZ(cr0x1gmv=+>fIlq=xIQOhQdP%!Z-&EyG00#*c*59MIt*~wN zEJ3qfZg6F@;<)q|@X2g#i~yV|uc&AVOaw$&3zbc0zG}N`)G6hGOXBcof>R%K%^2r) zr|30c=zjoXgFzxtQWZ5*Oz;^6tn=yA${4g_Gu^4cBRT;RWe{X$-R81K}VhuixU@6<#m#OC@~jtzyX~yh=Z*l9ZIB zu7c9I8FO^61eckyR4nh;Zb7!?4VyQ?nFNcPgLgpc}BhqOH}cO^8<1G3g6b1l;kbs2oA)yAD)Ao)!aM(gfXV*JlUdh74Wr$X3}$_ zuM)H+7`b_b_xTU5UCy&S?(NM&XO@WkWDM+{^y#i_>+k zuC7@YozciW-Zq|ekzyi|2$Kv-o4|$XI@szkjl+GPztt6tKp7;p5H^?sYUq{97H}#w zJ^X85M9T5Nv;2H~ZT%4RNQ*WHs)wK-1W3QV{IWr{u ztppcCzkU0bwRO+#-ADHFA&nK?Lf6?>PEUtGV|`IsS;>*1h1BesoS4Yr7{m^A_w`jk zAmK$l{T6(05JVx<=hU=QTYq 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/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 index 8bbd01855..267aea8da 100644 --- a/cockpit/ag-ui/subagents/python/docs/wire-capture-subagents.md +++ b/cockpit/ag-ui/subagents/python/docs/wire-capture-subagents.md @@ -153,3 +153,95 @@ 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/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 index 8bbd01855..267aea8da 100644 --- a/deployments/ag-ui-dev/deps/subagents/docs/wire-capture-subagents.md +++ b/deployments/ag-ui-dev/deps/subagents/docs/wire-capture-subagents.md @@ -153,3 +153,95 @@ 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. From 1a7f0edce81e0624105891e46b31f5921f900fc9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 13:43:19 -0700 Subject: [PATCH 7/8] feat(scripts): ag-ui deployment generator mounts a topic's own bridge agent class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aggregated ag-ui-dev server emitted `LangGraphAgent(name=..., graph=...)` for every LangGraph topic, but the subagents demo mounts `SubagentEmittingAgent` — a LangGraphAgent subclass that expands the graph's `subagent_activity` CUSTOM events into standard SUBAGENT_* events. The Railway lane therefore served raw CUSTOM events and rendered no subagent cards (the earlier ACTIVITY translator had the same gap). Convention: the topic's own `src/server.py` is the source of truth — no new marker file that could drift from what the demo actually mounts. The generator looks for `agent = (` plus a package-relative `from . import ` line; anything other than the stock `LangGraphAgent` is imported from `deps..src.` and constructed with the same name/graph args. An inline `LangGraphAgent(...)` mount keeps the plain wrapper; a subclass that is mounted but not importable from the topic package fails at generation time instead of at container boot. Spec: generator case asserting subagents mounts SubagentEmittingAgent (and interrupts keeps LangGraphAgent), a buildServerPy adapter case, and four detectBridgeAgent parsing cases. Co-Authored-By: Claude Fable 5.1 --- .../generate-ag-ui-deployment-config.spec.ts | 85 ++++++++++++++++- scripts/generate-ag-ui-deployment-config.ts | 91 ++++++++++++++++--- 2 files changed, 164 insertions(+), 12 deletions(-) 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)); From 016ef219bec2032065b7e70da42f838961969cef Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 13:43:19 -0700 Subject: [PATCH 8/8] =?UTF-8?q?chore(deployments):=20regenerate=20ag-ui-de?= =?UTF-8?q?v=20=E2=80=94=20subagents=20mounts=20SubagentEmittingAgent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npx tsx scripts/generate-ag-ui-deployment-config.ts`. Only server.py changes: one added import and the subagents mount now constructs SubagentEmittingAgent. requirements.txt and deps/ are byte-identical. Boot-checked locally from the deployment root (pip-installed requirements.txt as the Dockerfile does): /ok → 200, /agent/subagents → 401 without X-Internal-Token. Co-Authored-By: Claude Fable 5.1 --- deployments/ag-ui-dev/server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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(