Skip to content

feat(runtimes): stand-down guard so the mastra subagent injector retires when upstream lands - #971

Merged
blove merged 4 commits into
mainfrom
blove/mastra-subagent-standdown-guard
Sep 3, 2026
Merged

feat(runtimes): stand-down guard so the mastra subagent injector retires when upstream lands#971
blove merged 4 commits into
mainfrom
blove/mastra-subagent-standdown-guard

Conversation

@blove

@blove blove commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Why

deployments/ag-ui-mastra/subagent-emitter.mjs exists only because @ag-ui/mastra 1.1.2 discards the sub-agent child stream (case "tool-output": break). That is not a defect — the comment's stated reason ("there is no AG-UI mapping for interim output") was true when written.

It is no longer true. @ag-ui/core 0.0.59 ships the SUBAGENT_* schemas, and the LangGraph integration already emits them behind subagent_visibility (inline / attributed / hidden). Mastra is not covered by the maintainer's open sub-agent PRs (#2137, #2351) yet, but it is a matter of time.

The day the installed bridge emits SUBAGENT_* itself, injecting alongside it puts duplicates on the wire:

  • a duplicate SUBAGENT_STARTED for one subagentRunId is a hard AG-UI verifier error (verify.ts rejects it for the whole run), and
  • two distinct ids paint two cards for one delegation.

This PR makes that upgrade a non-event instead of an outage.

What

The first bridge SUBAGENT_STARTED / SUBAGENT_FINISHED / SUBAGENT_ERROR latches bridgeEmitsSubagentEvents, after which chunk() injects nothing and eventsFor() is a passthrough.

Detection is sound by construction: chunk() output is written straight to the SSE socket and never re-enters eventsFor(), so a SUBAGENT_* observed there is always the bridge's, never our own echo.

Two details carry the design:

The latch is process-wide, not per-injector. createSubagentInjector() is called per request, so a per-run flag would re-learn on every run and duplicate the first delegation of each one. Hence createBridgeCapability(), created once in server.mjs and passed to every run.

Standing down mid-delegation closes what we already announced. Leaving our subagent open would trip the verifier's "subagents are still active" rule at RUN_FINISHED. The close is deliberately neutral — SUBAGENT_FINISHED with no outcome, which the schema allows — because the delegation neither succeeded nor failed; we simply stopped owning it. synthesized is kept so the bridge's buffered TOOL_CALL_* copies still dedupe against the eager ones already on the wire.

Known limit (documented in the module header)

The tee observes a chunk before the bridge does, so the delegation already in flight at the moment of detection has our SUBAGENT_STARTED on the wire already and cannot be retracted — it is closed neutrally instead. Only that first delegation of the first run after an upgrade is affected; the latch is process-wide, so every run after starts retired.

This converts "breaks in production" into "one cosmetic blip, then a silent no-op". It is a safety net, not a substitute for the real exit: delete the emitter and the tee once upstream lands.

Tests

TDD — the 5 behavioral tests were watched failing before implementation; the other 3 are regression guards on behavior the change must not break (notably that the synthesized dedupe survives stand-down, and that independent capabilities don't leak, which would fail had I used a module-level global).

npm test in deployments/ag-ui-mastra: 53 passing, 0 failing across all three suites.

🤖 Generated with Claude Code

The injector exists only because @ag-ui/mastra 1.1.2 drops the child
stream (`case "tool-output": break`). Upstream is expected to grow its own
sub-agent surface — @ag-ui/core 0.0.59 already ships the SUBAGENT_*
schemas and the LangGraph integration already emits them behind
`subagent_visibility`. Once the installed bridge emits SUBAGENT_* too,
injecting alongside it puts duplicates on the wire: a duplicate
SUBAGENT_STARTED for one subagentRunId is a hard AG-UI verifier error,
and two distinct ids paint two cards for one delegation.

The first bridge SUBAGENT_* now latches `bridgeEmitsSubagentEvents`,
after which `chunk()` injects nothing and `eventsFor()` is a passthrough.
Detection is sound because `chunk()` output is written straight to the
socket and never re-enters `eventsFor()`, so a SUBAGENT_* seen there is
always the bridge's.

Two details that carry the design:

- The latch is process-wide, not per-injector. `createSubagentInjector()`
  is called per request, so a per-run flag would re-learn every run and
  duplicate the first delegation of each one.
- Standing down mid-delegation closes what we already announced, or
  RUN_FINISHED trips the verifier's "subagents are still active" rule.
  The close is neutral (SUBAGENT_FINISHED with no `outcome`, which the
  schema allows) because the delegation neither succeeded nor failed.
  `synthesized` is kept so the bridge's buffered TOOL_CALL_* copies still
  dedupe against the eager ones already on the wire.

Known limit, documented in the module header: the tee sees a chunk before
the bridge does, so the delegation in flight at detection already has our
SUBAGENT_STARTED on the wire and cannot be retracted. Only the first
delegation of the first run after an upgrade is affected. The clean exit
remains deleting this module and the tee once upstream lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@blove
blove enabled auto-merge (squash) September 3, 2026 00:01
@vercel

vercel Bot commented Sep 3, 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 3, 2026 3:57am UTC

Request Review

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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


I'll analyze this and get back to you.

@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 3, 2026

Copy link
Copy Markdown
Contributor

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


I'll analyze this and get back to you.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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


I'll analyze this and get back to you.

@github-actions

github-actions Bot commented Sep 3, 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 merged commit 0aaed36 into main Sep 3, 2026
70 checks passed
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