Skip to content

Commit 0aaed36

Browse files
bloveclaude
andauthored
feat(runtimes): stand-down guard so the mastra subagent injector retires (#971)
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>
1 parent 93db300 commit 0aaed36

3 files changed

Lines changed: 183 additions & 4 deletions

File tree

deployments/ag-ui-mastra/server.mjs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,14 @@ import { pathToFileURL } from 'node:url';
2222
import { dirname, resolve } from 'node:path';
2323
import { MastraAgent } from '@ag-ui/mastra';
2424
import { createMastra } from './agents.mjs';
25-
import { createSubagentInjector } from './subagent-emitter.mjs';
25+
import { createSubagentInjector, createBridgeCapability } from './subagent-emitter.mjs';
2626
import { withDelegationTee } from './streaming-tee.mjs';
2727

28+
// Process-wide, so the stand-down latch survives across runs: once the
29+
// installed @ag-ui/mastra is seen emitting SUBAGENT_* itself, every later run
30+
// starts already retired instead of re-learning (and double-emitting) each time.
31+
const bridgeCapability = createBridgeCapability();
32+
2833
const AG_UI_INTERNAL_TOKEN = process.env.AG_UI_INTERNAL_TOKEN;
2934
if (!AG_UI_INTERNAL_TOKEN) {
3035
// Same boot contract as ag-ui-dev's `os.environ["AG_UI_INTERNAL_TOKEN"]`:
@@ -110,7 +115,7 @@ export function createAgUiServer() {
110115
// SUBAGENT_FINISHED/ERROR on `tool-result`.
111116
// - `eventsFor()`: the bridge's own AG-UI events, with its later buffered
112117
// TOOL_CALL_START/ARGS/END copies for a synthesized id dropped.
113-
const injector = createSubagentInjector();
118+
const injector = createSubagentInjector(bridgeCapability);
114119
const write = (event) => res.write(sseFrame(event));
115120
const observe = (chunk) => {
116121
for (const e of injector.chunk(chunk)) write(e);

deployments/ag-ui-mastra/subagent-emitter.mjs

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,41 @@
4242
// Terminal cleanup: RUN_ERROR / RUN_FINISHED with delegations still pending
4343
// closes open child messages and emits SUBAGENT_ERROR per pending id,
4444
// exactly once, before the terminal frame.
45+
//
46+
// STAND-DOWN (forward compatibility). This whole module exists only because
47+
// @ag-ui/mastra 1.1.2 drops the child stream. Upstream is expected to grow its
48+
// own sub-agent surface (@ag-ui/core 0.0.59 already ships the SUBAGENT_*
49+
// schemas, and the LangGraph integration already emits them behind
50+
// `subagent_visibility`). The day the installed bridge emits SUBAGENT_* itself,
51+
// injecting too would put duplicates on the wire — and a duplicate
52+
// SUBAGENT_STARTED for one subagentRunId is a HARD AG-UI verifier error, while
53+
// two distinct ids paint two cards for one delegation. So the first bridge
54+
// SUBAGENT_* latches `bridgeEmitsSubagentEvents` (see createBridgeCapability),
55+
// after which `chunk()` injects nothing and `eventsFor()` is a passthrough.
56+
//
57+
// KNOWN LIMIT: the tee sees a chunk BEFORE the bridge does, so the delegation
58+
// already in flight at the moment of detection has our SUBAGENT_STARTED on the
59+
// wire already and cannot be retracted — it is closed neutrally instead. Only
60+
// that first delegation of the first run after an upgrade is affected; the
61+
// latch is process-wide, so every run after it starts retired. The clean exit
62+
// is still to DELETE this module and the tee once upstream lands.
4563

4664
const AGENT_TOOL_PREFIX = 'agent-';
4765

66+
/**
67+
* Shared, sticky record of whether the installed bridge emits SUBAGENT_* itself.
68+
*
69+
* Create ONE per process and pass it to every run's injector. The latch must
70+
* outlive a single run: `createSubagentInjector` is called per request, so a
71+
* per-injector flag would re-learn on every run and duplicate the first
72+
* delegation of each one.
73+
*
74+
* @returns {{ bridgeEmitsSubagentEvents: boolean }}
75+
*/
76+
export function createBridgeCapability() {
77+
return { bridgeEmitsSubagentEvents: false };
78+
}
79+
4880
/**
4981
* @typedef {object} Entry
5082
* @property {string} subagentRunId
@@ -83,12 +115,15 @@ function failureMessage(parsed) {
83115
/**
84116
* Create a per-run injector.
85117
*
118+
* @param {{ bridgeEmitsSubagentEvents: boolean }} [capability] the shared
119+
* stand-down latch (see {@link createBridgeCapability}). Defaults to a
120+
* private one, which is only right for a single-run test.
86121
* @returns {{ chunk(chunk: object): object[], eventsFor(event: object): object[] }}
87122
* `chunk` returns the AG-UI events to write for a raw Mastra chunk (usually
88123
* none); `eventsFor` returns, for each outbound bridge event, the ordered
89124
* list of frames to write (injections plus, unless deduped, the event).
90125
*/
91-
export function createSubagentInjector() {
126+
export function createSubagentInjector(capability = createBridgeCapability()) {
92127
/** @type {Map<string, Entry>} pending delegations by toolCallId */
93128
const pending = new Map();
94129
/** Ids whose TOOL_CALL_START/ARGS/END were synthesized — bridge copies drop. */
@@ -153,8 +188,29 @@ export function createSubagentInjector() {
153188
return [...closeMessage(entry), { type: 'SUBAGENT_ERROR', subagentRunId: entry.subagentRunId, message }];
154189
}
155190

191+
/**
192+
* The bridge emitted SUBAGENT_* itself, so it owns the surface from here on
193+
* and this injector retires (for this run and, via the shared latch, every
194+
* later one). Anything we already announced has to be closed first: an open
195+
* subagent at RUN_FINISHED is a hard verifier error. The close is NEUTRAL —
196+
* SUBAGENT_FINISHED with no `outcome`, since the delegation neither succeeded
197+
* nor failed, we simply stopped owning it. `synthesized` is deliberately kept
198+
* so the bridge's buffered TOOL_CALL_* copies still dedupe against the eager
199+
* ones already on the wire.
200+
*/
201+
function standDown() {
202+
capability.bridgeEmitsSubagentEvents = true;
203+
const out = [...pending.values()].flatMap((entry) => [
204+
...closeMessage(entry),
205+
{ type: 'SUBAGENT_FINISHED', subagentRunId: entry.subagentRunId },
206+
]);
207+
pending.clear();
208+
return out;
209+
}
210+
156211
return {
157212
chunk(chunk) {
213+
if (capability.bridgeEmitsSubagentEvents) return [];
158214
const payload = chunk?.payload ?? {};
159215
switch (chunk?.type) {
160216
case 'start':
@@ -243,6 +299,19 @@ export function createSubagentInjector() {
243299

244300
eventsFor(event) {
245301
switch (event.type) {
302+
// Forward compatibility: the day the bridge emits these itself, retire
303+
// rather than double-emit (a duplicate SUBAGENT_STARTED for one
304+
// subagentRunId is a hard verifier error; two distinct ids paint two
305+
// cards). Our own injections are written straight to the socket and
306+
// never re-enter here, so a SUBAGENT_* at this point is always the
307+
// bridge's.
308+
case 'SUBAGENT_STARTED':
309+
case 'SUBAGENT_FINISHED':
310+
case 'SUBAGENT_ERROR': {
311+
if (capability.bridgeEmitsSubagentEvents) return [event];
312+
return [...standDown(), event];
313+
}
314+
246315
case 'TOOL_CALL_START': {
247316
if (synthesized.has(event.toolCallId)) return []; // eager copy already on the wire
248317
const name = event.toolCallName ?? '';

deployments/ag-ui-mastra/test/subagent-emitter.test.mjs

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// cockpit/runtimes/mastra/angular/docs/wire-capture-subagents.md.
55
import { test } from 'node:test';
66
import assert from 'node:assert/strict';
7-
import { createSubagentInjector } from '../subagent-emitter.mjs';
7+
import { createSubagentInjector, createBridgeCapability } from '../subagent-emitter.mjs';
88

99
const TID = 'call_aUfV9K0RCDZdZK3NWt9dRDKx';
1010

@@ -464,3 +464,108 @@ test('chunk: agent-* tool-call with NO prior start/step-start omits parentMessag
464464
assert.equal(start.type, 'TOOL_CALL_START');
465465
assert.equal('parentMessageId' in start, false, 'key must be absent, not present with an undefined value');
466466
});
467+
468+
// --- stand-down guard ------------------------------------------------------
469+
// Forward compatibility: the day @ag-ui/mastra emits its own SUBAGENT_* events,
470+
// this injector must retire rather than double-emit. A duplicate
471+
// SUBAGENT_STARTED for one subagentRunId is a hard AG-UI verifier error, and
472+
// two distinct ids paint two cards for one delegation.
473+
474+
const bridgeStarted = (tid = TID) => ({
475+
type: 'SUBAGENT_STARTED',
476+
subagentRunId: `bridge-${tid}`,
477+
name: 'weather_forecaster',
478+
parentToolCallId: tid,
479+
});
480+
481+
test('stand-down: a bridge SUBAGENT_STARTED stops all further chunk injection', () => {
482+
const injector = createSubagentInjector();
483+
injector.eventsFor(bridgeStarted());
484+
485+
assert.deepEqual(injector.chunk(stepStartChunk), []);
486+
assert.deepEqual(injector.chunk(toolCallChunk()), [], 'must not synthesize TOOL_CALL_*/SUBAGENT_STARTED');
487+
assert.deepEqual(injector.chunk(childTextStart), []);
488+
assert.deepEqual(injector.chunk(childDelta('hi')), []);
489+
assert.deepEqual(injector.chunk(toolResultChunk(SUCCESS_RESULT)), []);
490+
});
491+
492+
test('stand-down: the bridge SUBAGENT_* event itself passes through untouched', () => {
493+
const injector = createSubagentInjector();
494+
const ev = bridgeStarted();
495+
assert.deepEqual(injector.eventsFor(ev), [ev]);
496+
});
497+
498+
test('stand-down: SUBAGENT_FINISHED and SUBAGENT_ERROR also latch it', () => {
499+
for (const ev of [
500+
{ type: 'SUBAGENT_FINISHED', subagentRunId: 'b-1', outcome: { type: 'success' } },
501+
{ type: 'SUBAGENT_ERROR', subagentRunId: 'b-1', message: 'nope' },
502+
]) {
503+
const injector = createSubagentInjector();
504+
assert.deepEqual(injector.eventsFor(ev), [ev]);
505+
assert.deepEqual(injector.chunk(toolCallChunk()), [], `${ev.type} must latch stand-down`);
506+
}
507+
});
508+
509+
test('stand-down mid-delegation closes our in-flight child message and subagent first', () => {
510+
const injector = createSubagentInjector();
511+
injector.chunk(stepStartChunk);
512+
injector.chunk(toolCallChunk());
513+
injector.chunk(childTextStart);
514+
injector.chunk(childDelta('partial'));
515+
516+
const ev = bridgeStarted();
517+
// Our already-announced subagent must be closed, or RUN_FINISHED trips the
518+
// verifier's "subagents are still active" rule. Neutral close: no outcome —
519+
// the delegation did not actually succeed or fail, we simply stopped owning it.
520+
assert.deepEqual(injector.eventsFor(ev), [
521+
{ type: 'TEXT_MESSAGE_END', messageId: M1, subagentRunId: SUB },
522+
{ type: 'SUBAGENT_FINISHED', subagentRunId: SUB },
523+
ev,
524+
]);
525+
});
526+
527+
test('stand-down still drops bridge copies of TOOL_CALL_* we already synthesized', () => {
528+
const injector = createSubagentInjector();
529+
injector.chunk(stepStartChunk);
530+
injector.chunk(toolCallChunk());
531+
injector.eventsFor(bridgeStarted());
532+
533+
// Those three are already on the wire from the eager synthesis — letting the
534+
// bridge's buffered copies through now would duplicate the tool call.
535+
assert.deepEqual(injector.eventsFor(delegationStart()), []);
536+
assert.deepEqual(injector.eventsFor({ type: 'TOOL_CALL_ARGS', toolCallId: TID, delta: '{}' }), []);
537+
assert.deepEqual(injector.eventsFor({ type: 'TOOL_CALL_END', toolCallId: TID }), []);
538+
});
539+
540+
test('stand-down: TOOL_CALL_RESULT and RUN_FINISHED pass through with no injection', () => {
541+
const injector = createSubagentInjector();
542+
injector.chunk(stepStartChunk);
543+
injector.chunk(toolCallChunk());
544+
injector.eventsFor(bridgeStarted());
545+
546+
const result = delegationResult(JSON.stringify(SUCCESS_RESULT));
547+
assert.deepEqual(injector.eventsFor(result), [result]);
548+
const finished = { type: 'RUN_FINISHED', threadId: 't-1', runId: 'r-1' };
549+
assert.deepEqual(injector.eventsFor(finished), [finished], 'no cleanup — we no longer own any subagent');
550+
});
551+
552+
test('stand-down latches across runs through a shared capability', () => {
553+
const capability = createBridgeCapability();
554+
const firstRun = createSubagentInjector(capability);
555+
firstRun.eventsFor(bridgeStarted());
556+
557+
// A per-injector flag would re-learn every run and duplicate the first
558+
// delegation of each one; the latch is shared so later runs start retired.
559+
const secondRun = createSubagentInjector(capability);
560+
secondRun.chunk(stepStartChunk);
561+
assert.deepEqual(secondRun.chunk(toolCallChunk()), []);
562+
});
563+
564+
test('injectors with independent capabilities do not affect each other', () => {
565+
const stoodDown = createSubagentInjector(createBridgeCapability());
566+
stoodDown.eventsFor(bridgeStarted());
567+
568+
const fresh = createSubagentInjector(createBridgeCapability());
569+
fresh.chunk(stepStartChunk);
570+
assert.deepEqual(fresh.chunk(toolCallChunk()), eagerToolCall);
571+
});

0 commit comments

Comments
 (0)