Skip to content

feat(examples): ag-ui demo emits the protocol's SUBAGENT_* events - #964

Merged
blove merged 6 commits into
mainfrom
blove/agui-examples-subagent-events
Sep 2, 2026
Merged

feat(examples): ag-ui demo emits the protocol's SUBAGENT_* events#964
blove merged 6 commits into
mainfrom
blove/agui-examples-subagent-events

Conversation

@blove

@blove blove commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Second demo in the SUBAGENT_* migration spec, Tasks 4–5 of the plan (the cockpit demo landed in #962; this branch is rebased on top of it): examples/ag-ui — the richer fork whose research child is a compiled subgraph running a reason → lookup tool → answer loop — now emits the protocol's SUBAGENT_STARTED/FINISHED/ERROR plus subagentRunId-attributed TEXT_MESSAGE_* and TOOL_CALL_* events instead of the private ACTIVITY_* convention, with per-token deltas. Standalone rule honored: the emitter is a copy (with tool branches), nothing imports across examples.

What changed

  • SDK pin ag-ui-protocol>=0.1.22 (was transitively 0.1.19, pre-Subagent classes); uv lock.
  • Graph + handler (src/graph.py, src/streaming/subagent_stream_handler.py): message_start {message_id} once per assistant turn (<toolCallId>-sub-m<n>, SubagentRunState owns the counter), message {message_id, delta} per raw token (accumulator gone), tool_call {tool_call_id, name, args}, tool_result {tool_call_id, content}, error {message} on the tool's except path.
  • SubagentEmittingAgent (src/streaming/subagent_emitting_agent.py) wraps LangGraphAgent.run for 1:N expansion per the contract; tool_call → attributed TOOL_CALL_START (parentMessageId = open child message) / ARGS / END; tool_result → attributed TOOL_CALL_RESULT (role: tool, <id>-result). Replaces ActivityEmittingAgent + activity_transform.py (deleted with its 17 tests). It is a pure expander: every non-subagent_activity event passes through untouched.
  • The child subgraph is declared silent via the bridge's opt-out (src/graph.py, the research tool): ag-ui-langgraph streams compiled subgraphs and would emit the child's own lookup TOOL_CALL_* and answer TEXT_MESSAGE_* as unattributed events into the parent transcript (baseline §2b). The bridge honors a declared opt-out — ag_ui_langgraph/agent.py:993-994 reads LangChain run metadata emit-messages / emit-tool-calls and skips those emissions for runs carrying them as False, while callbacks (SubagentStreamHandler, adispatch_custom_event), STEP_*, and the parent's own TOOL_CALL_RESULT / answer are untouched — so the tool passes config={"callbacks": [...], "metadata": {"emit-messages": False, "emit-tool-calls": False}} on subgraph.ainvoke, and the metadata inherits into the child run. (Review follow-up: an earlier revision of this PR inferred the duplicates in the wrapper by dropping every unattributed content event inside the delegation window; that filter, its remembered-id sets and its four tests are gone.)
  • Docs: examples/ag-ui/python/docs/wire-capture-subagents.md (baseline, after, "Declared opt-out (review follow-up)" re-capture, browser verification); blog 2026-08-27-langgraph-subgraphs-when-to-split.mdx :185/:212 and 2026-08-31-what-changes-when-the-runtime-changes.mdx :227 now describe the standard events. Adapter docs that describe the legacy ACTIVITY_* support are untouched (out of scope per spec).

Evidence

  • uv run pytest -q26 passed (6 handler, 15 emitter incl. test_bridge_native_events_pass_through_untouched_even_inside_a_delegation, 4 emission incl. test_research_tool_declares_the_child_silent_via_bridge_metadata, which drives the research tool against a capturing subgraph and asserts metadata["emit-messages"] is False / metadata["emit-tool-calls"] is False on the invocation config).
  • npx playwright test --config examples/ag-ui/angular/e2e/playwright.config.ts37 passed, subagent-card.spec.ts assertions unchanged (≥2 messages, lookup call + result, child text out of the parent bubble); fixtures unchanged.
  • npx nx test website → green.
  • Wire, live gpt-5-mini with the opt-out and no wrapper-side filtering (1,084 events, wire doc "Declared opt-out"): zero unattributed TEXT_MESSAGE_* / TOOL_CALL_* between SUBAGENT_STARTED (82) and SUBAGENT_FINISHED (942) — the lookup call appears exactly once (123, attributed) and the child's answer only as 259 attributed deltas; the parent's TOOL_CALL_RESULT (944) and its own answer (958, 1 START / 49 CONTENT / 1 END, the only unattributed TEXT_MESSAGE_START in the run) are present; STEP_* still passes (9/9, the child's agent / tools nodes included). The joined child deltas equal the parent TOOL_CALL_RESULT.content byte-for-byte.
  • Before → after volume (same prompt): child answer shipped as 344 ACTIVITY_DELTA carrying 306,482 bytes of accumulated text (1,810-char answer) → per-token attributed TEXT_MESSAGE_CONTENT carrying exactly the answer's bytes; 0 CUSTOM / 0 ACTIVITY on the wire.
  • Ordering (design §6): TOOL_CALL_START 9 → TOOL_CALL_END 69 → SUBAGENT_STARTED 82 → … → SUBAGENT_FINISHED 942 → TOOL_CALL_RESULT 944 — the block nests between END and RESULT, card never nameless.
  • Live browser (earlier capture, unchanged shape): card mounts at t≈2.5s, lookup + result projected at t≈18.9s, answer turn grows 46 → 1,878 chars over 150ms samples while running, then complete + collapse. Screenshot examples/ag-ui/angular/e2e/manual/subagent-card-live.png.

Deviations / findings

  1. Reducer follow-up (not in this PR): the card draws <chat-tool-call-card> via message.toolCallIds, which routeSubagentContentEvent does not populate on attributed TOOL_CALL_START — the lookup call is in agent.subagents()[..].toolCalls (what the e2e asserts) but not drawn inside the card. The wire carries parentMessageId, so the fix is reducer-side.
  2. The e2e's bare prompt does not delegate live (the orchestrator routes it to search_documents); the capture prompt asks for the deep-dive explicitly. Replay is unaffected.
  3. Cockpit demo (feat(cockpit): ag-ui subagents demo emits the protocol's SUBAGENT_* events #962) checked for the same leak — none. Its child is a bare llm.astream inside the tool body, not a compiled subgraph, so the bridge never streams it: the after-emitter capture in cockpit/ag-ui/subagents/python/docs/wire-capture-subagents.md shows only attributed events between SUBAGENT_STARTED (306) and SUBAGENT_FINISHED (1295) and exactly one unattributed TEXT_MESSAGE_START in the run (3896, the orchestrator). No change needed there.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
threadplane Ready Ready Preview Sep 2, 2026 9:10pm UTC

Request Review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

blove and others added 6 commits September 2, 2026 14:02
…>=0.1.22

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The research subgraph opens each assistant turn with a message_start
carrying a <tool_call_id>-sub-m<n> message id (SubagentRunState owns the
counter), the SubagentStreamHandler forwards each token as a raw delta
instead of the accumulated text-so-far, and tool_call / tool_result
payloads carry tool_call_id / name / args / content. The research tool
body emits an error phase before re-raising.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
SubagentEmittingAgent wraps LangGraphAgent.run and expands the graph's
subagent_activity CUSTOM events 1:N into SUBAGENT_STARTED/FINISHED/ERROR
plus subagentRunId-attributed TEXT_MESSAGE_* and TOOL_CALL_* events
(the child's lookup call and result included). It replaces the 1:1
ActivityEmittingAgent/_dispatch_event translator and the ACTIVITY_*
convention. Because the child is a streamed subgraph, the bridge also
emitted the child's text and tool call unattributed into the parent
transcript; the wrapper drops those duplicates inside the delegation
window.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s emit-* metadata; drop the inferred duplicate filter

ag-ui-langgraph streams compiled subgraphs and would emit the research
child's own `lookup` TOOL_CALL_* and answer TEXT_MESSAGE_* as unattributed
events in the parent transcript. The bridge honors a declared opt-out
(`ag_ui_langgraph/agent.py:993-994` reads run metadata `emit-messages` /
`emit-tool-calls`), so the `research` tool now passes
`metadata={"emit-messages": False, "emit-tool-calls": False}` on the
subgraph invocation; callbacks, CUSTOM events and STEP_* still flow.

SubagentEmittingAgent is back to a pure 1:N expander: the delegation
window flag, `_is_child_duplicate` and the remembered-id sets are gone
along with their four tests. New tests: bridge-native events pass through
untouched even inside a delegation; the research tool declares the
opt-out on its invocation config.

Wire doc: "Declared opt-out (review follow-up)" re-capture — zero
unattributed content events inside the SUBAGENT_* window with no
wrapper-side filtering, parent TOOL_CALL_RESULT / answer and STEP_*
present; earlier "wrapper drops duplicates" wording marked superseded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@blove
blove force-pushed the blove/agui-examples-subagent-events branch from acd93c3 to 267e044 Compare September 2, 2026 21:08
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

@blove
blove enabled auto-merge (squash) September 2, 2026 21:10
@blove
blove merged commit c2bc3f9 into main Sep 2, 2026
31 checks passed
@blove
blove deleted the blove/agui-examples-subagent-events branch September 2, 2026 21:15
blove added a commit that referenced this pull request Sep 2, 2026
…966)

* fix(examples): re-export requirements.txt so the Railway image installs ag-ui-protocol 0.1.22

PR #964 bumped pyproject.toml/uv.lock to ag-ui-protocol>=0.1.22 but left
requirements.txt (what the Dockerfile actually installs from) pinned to
0.1.19. The new SubagentStartedEvent import doesn't exist in 0.1.19, so the
Railway ag-ui-demo container fails at boot, /ok never comes up, and
`railway up --detach` silently leaves the old deployment live.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci: fail when a Railway lane's requirements.txt drifts from uv.lock

requirements.txt is what the Dockerfile actually installs from, but uv sync/
uv.lock is what's kept in sync during normal dependency bumps — nothing
previously caught the two falling out of sync (see the prior commit). Add a
`uv export --no-hashes` + diff step to the examples/ag-ui e2e job and the
per-cap cockpit e2e job (skipped for caps that don't check in a
requirements.txt) so drift fails CI with a clear fix-it message instead of
surfacing as a silent production boot failure.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant