Skip to content

Commit e24aacb

Browse files
roaminroMastra Code (openai/gpt-5.5)
andauthored
fix(core): filter delegated agent stream chunks (#16071)
This fixes supervisor output processors so they can see chunks streamed by delegated sub-agents and filter them before consumers receive them. The existing data chunk path is unchanged, and streams without output processors keep the same passthrough behavior. Before, filtering inside `processOutputStream` could not stop nested sub-agent chunks because they bypassed the processor pipeline. ```ts async processOutputStream({ part }) { if (part.type === "tool-output") return null; return part; } ``` After this change, supervisor agents using `agents:` can use that same processor to drop or rewrite delegated sub-agent stream chunks. Closes #15899. Tested with `pnpm --filter ./packages/core check`, the new supervisor stream processor tests, and focused non-E2E core unit coverage for processors, loop, and agent tests. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## ELI5 This PR makes supervisor agents apply their configured output filters to messages coming from delegated sub-agents, so those forwarded messages can be observed, rewritten, or dropped just like regular tool output. ## Summary of Changes ### Changeset Entry Adds a changeset noting a patch release for @mastra/core: supervisor output processors can now filter streamed chunks emitted by delegated sub-agents. ### Stream Processing Pipeline Update Updates packages/core/src/loop/workflows/stream.ts to route non-data-* chunks injected via the output writer (e.g., `tool-output` wrappers for forwarded sub-agent streams) through the configured output processor pipeline (dataChunkProcessorRunner.processPart). Behavior details: - Chunks are processed by processOutputStream and may be rewritten, passed through, or dropped. - If a processor blocks content, the stream emits a `tripwire` chunk (including reason, retry, metadata, and processorId) and the blocked chunk is not enqueued. - If processing returns falsy, the chunk is dropped. - If no output processors are configured, the previous passthrough behavior is preserved. ### New Tests Adds packages/core/src/agent/__tests__/supervisor-output-processor-stream.test.ts covering supervisor behavior with nested agent streams: - Confirms nested-agent `tool-output` wrapper chunks reach processOutputStream. - Verifies processors can drop forwarded sub-agent chunks while leaving the supervisor’s own `tool-call`/`tool-result` envelopes intact. - Confirms passthrough behavior when no processors are configured. - Validates processors can rewrite nested-agent payloads before delivery. - Validates processor-triggered aborts produce `tripwire` chunks with processor id and reason, and prevent the blocked nested `text-delta` from reaching the consumer. ### Tests / Validation PR includes focused unit tests (supervisor stream processor tests and processor/loop/agent coverage) and was validated with project check/test steps (e.g., pnpm --filter ./packages/core check and new unit tests). ## Linked Issue Closes #15899 — enables supervisor output processors to filter nested delegated sub-agent stream chunks. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Mastra Code (openai/gpt-5.5) <noreply@mastra.ai>
1 parent 5adc55e commit e24aacb

3 files changed

Lines changed: 357 additions & 0 deletions

File tree

.changeset/humble-kids-post.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/core': patch
3+
---
4+
5+
Fixed supervisor output processors so they can filter streamed chunks from delegated sub-agents.
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
import { convertArrayToReadableStream, MockLanguageModelV2 } from '@internal/ai-sdk-v5/test';
2+
import { describe, expect, it } from 'vitest';
3+
import { Mastra } from '../../mastra';
4+
import type { Processor, ProcessOutputStreamArgs } from '../../processors/index';
5+
import { InMemoryStore } from '../../storage';
6+
import type { ChunkType } from '../../stream';
7+
import { Agent } from '../agent';
8+
9+
/**
10+
* Verifies that output processors on a supervisor agent can observe and filter
11+
* chunks forwarded from sub-agents delegated via the `agents:` option.
12+
*
13+
* Sub-agent chunks are forwarded through the parent stream's writer (not the
14+
* LLM's own fullStream). Previously the writer only routed `data-*` chunks
15+
* through output processors; everything else (including the `tool-output`
16+
* wrapper that the synthetic agent-* tool emits around sub-agent chunks)
17+
* bypassed `processOutputStream`. This test locks in the fix that runs every
18+
* writer-injected chunk through processors.
19+
*/
20+
21+
function makeSubAgent() {
22+
const subAgentModel = new MockLanguageModelV2({
23+
doStream: async () => ({
24+
rawCall: { rawPrompt: null, rawSettings: {} },
25+
warnings: [],
26+
stream: convertArrayToReadableStream([
27+
{ type: 'stream-start', warnings: [] },
28+
{ type: 'response-metadata', id: 'sub-id-0', modelId: 'mock', timestamp: new Date(0) },
29+
{ type: 'text-start', id: 'sub-text-1' },
30+
{ type: 'text-delta', id: 'sub-text-1', delta: 'sub-agent says hi' },
31+
{ type: 'text-end', id: 'sub-text-1' },
32+
{
33+
type: 'finish',
34+
finishReason: 'stop',
35+
usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 },
36+
},
37+
]),
38+
}),
39+
});
40+
41+
return new Agent({
42+
id: 'sub-agent',
43+
name: 'sub-agent',
44+
description: 'A sub-agent.',
45+
instructions: 'You answer briefly.',
46+
model: subAgentModel,
47+
});
48+
}
49+
50+
function makeSupervisorModel() {
51+
let callCount = 0;
52+
return new MockLanguageModelV2({
53+
doStream: async () => {
54+
callCount++;
55+
if (callCount === 1) {
56+
return {
57+
rawCall: { rawPrompt: null, rawSettings: {} },
58+
warnings: [],
59+
stream: convertArrayToReadableStream([
60+
{ type: 'stream-start', warnings: [] },
61+
{ type: 'response-metadata', id: 'sup-id-0', modelId: 'mock', timestamp: new Date(0) },
62+
{
63+
type: 'tool-call',
64+
toolCallId: 'sup-call-1',
65+
toolName: 'agent-subAgent',
66+
input: JSON.stringify({ prompt: 'do the thing' }),
67+
},
68+
{
69+
type: 'finish',
70+
finishReason: 'tool-calls',
71+
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
72+
},
73+
]),
74+
};
75+
}
76+
return {
77+
rawCall: { rawPrompt: null, rawSettings: {} },
78+
warnings: [],
79+
stream: convertArrayToReadableStream([
80+
{ type: 'stream-start', warnings: [] },
81+
{ type: 'response-metadata', id: 'sup-id-1', modelId: 'mock', timestamp: new Date(0) },
82+
{ type: 'text-start', id: 'sup-text-1' },
83+
{ type: 'text-delta', id: 'sup-text-1', delta: 'all done' },
84+
{ type: 'text-end', id: 'sup-text-1' },
85+
{
86+
type: 'finish',
87+
finishReason: 'stop',
88+
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
89+
},
90+
]),
91+
};
92+
},
93+
});
94+
}
95+
96+
describe('Supervisor pattern: output processor stream visibility', () => {
97+
it('routes nested-agent tool-output chunks through processOutputStream', async () => {
98+
const capturedChunkTypes: string[] = [];
99+
100+
class RecordingProcessor implements Processor {
101+
readonly id = 'recording-processor';
102+
readonly name = 'Recording Processor';
103+
104+
async processOutputStream({ part }: ProcessOutputStreamArgs) {
105+
capturedChunkTypes.push(part.type);
106+
return part;
107+
}
108+
}
109+
110+
const supervisor = new Agent({
111+
id: 'supervisor',
112+
name: 'supervisor',
113+
instructions: 'You orchestrate sub-agents.',
114+
model: makeSupervisorModel(),
115+
agents: { subAgent: makeSubAgent() },
116+
outputProcessors: [new RecordingProcessor()],
117+
});
118+
119+
new Mastra({
120+
agents: { supervisor },
121+
storage: new InMemoryStore(),
122+
});
123+
124+
const stream = await supervisor.stream('Please delegate', { maxSteps: 5 });
125+
for await (const _chunk of stream.fullStream) {
126+
// drain
127+
}
128+
129+
// Sub-agent chunks are wrapped as `tool-output` by the synthetic agent-*
130+
// tool's ToolStream (prefix: 'tool'). With the fix, these reach
131+
// processOutputStream so processors can observe and filter them.
132+
expect(capturedChunkTypes).toContain('tool-output');
133+
});
134+
135+
it('lets a processor drop nested-agent chunks before they reach the consumer', async () => {
136+
class FilterNestedAgentChunks implements Processor {
137+
readonly id = 'filter-nested-agent-chunks';
138+
readonly name = 'Filter Nested Agent Chunks';
139+
140+
async processOutputStream({ part }: ProcessOutputStreamArgs) {
141+
// tool-output chunks where the wrapped output is itself an agent chunk
142+
// (i.e. a chunk that has a `from` field of 'AGENT') indicate forwarded
143+
// sub-agent stream content. Drop them.
144+
if (part?.type === 'tool-output' && part?.payload?.output?.from === 'AGENT') {
145+
return null;
146+
}
147+
return part;
148+
}
149+
}
150+
151+
const supervisor = new Agent({
152+
id: 'supervisor',
153+
name: 'supervisor',
154+
instructions: 'You orchestrate sub-agents.',
155+
model: makeSupervisorModel(),
156+
agents: { subAgent: makeSubAgent() },
157+
outputProcessors: [new FilterNestedAgentChunks()],
158+
});
159+
160+
new Mastra({
161+
agents: { supervisor },
162+
storage: new InMemoryStore(),
163+
});
164+
165+
const stream = await supervisor.stream('Please delegate', { maxSteps: 5 });
166+
const consumerChunks: ChunkType[] = [];
167+
for await (const chunk of stream.fullStream) {
168+
consumerChunks.push(chunk);
169+
}
170+
171+
// The consumer must NOT see any forwarded sub-agent chunks.
172+
const forwardedSubAgentChunks = consumerChunks.filter(
173+
c => c.type === 'tool-output' && c?.payload?.output?.from === 'AGENT',
174+
);
175+
expect(forwardedSubAgentChunks).toHaveLength(0);
176+
177+
// The surrounding tool-call / tool-result envelope (from the supervisor's
178+
// own LLM stream) must still pass through unaffected.
179+
const consumerChunkTypes = consumerChunks.map(c => c.type);
180+
expect(consumerChunkTypes).toContain('tool-call');
181+
expect(consumerChunkTypes).toContain('tool-result');
182+
});
183+
184+
it('passes nested-agent chunks through unchanged when no output processors are configured', async () => {
185+
// No outputProcessors: dataChunkProcessorRunner is undefined and the new
186+
// branch must short-circuit to the original safeEnqueue fallthrough so
187+
// existing behavior is preserved.
188+
const supervisor = new Agent({
189+
id: 'supervisor',
190+
name: 'supervisor',
191+
instructions: 'You orchestrate sub-agents.',
192+
model: makeSupervisorModel(),
193+
agents: { subAgent: makeSubAgent() },
194+
});
195+
196+
new Mastra({
197+
agents: { supervisor },
198+
storage: new InMemoryStore(),
199+
});
200+
201+
const stream = await supervisor.stream('Please delegate', { maxSteps: 5 });
202+
const consumerChunks: ChunkType[] = [];
203+
for await (const chunk of stream.fullStream) {
204+
consumerChunks.push(chunk);
205+
}
206+
207+
const forwardedSubAgentChunks = consumerChunks.filter(
208+
c => c.type === 'tool-output' && c?.payload?.output?.from === 'AGENT',
209+
);
210+
// Sub-agent chunks must still reach the consumer when no processors are configured.
211+
expect(forwardedSubAgentChunks.length).toBeGreaterThan(0);
212+
});
213+
214+
it('lets a processor rewrite a nested-agent chunk before it reaches the consumer', async () => {
215+
class RewriteNestedAgentChunks implements Processor {
216+
readonly id = 'rewrite-nested-agent-chunks';
217+
readonly name = 'Rewrite Nested Agent Chunks';
218+
219+
async processOutputStream({ part }: ProcessOutputStreamArgs) {
220+
if (part?.type === 'tool-output' && part?.payload?.output?.from === 'AGENT') {
221+
return {
222+
...part,
223+
payload: { ...part.payload, rewritten: true },
224+
};
225+
}
226+
return part;
227+
}
228+
}
229+
230+
const supervisor = new Agent({
231+
id: 'supervisor',
232+
name: 'supervisor',
233+
instructions: 'You orchestrate sub-agents.',
234+
model: makeSupervisorModel(),
235+
agents: { subAgent: makeSubAgent() },
236+
outputProcessors: [new RewriteNestedAgentChunks()],
237+
});
238+
239+
new Mastra({
240+
agents: { supervisor },
241+
storage: new InMemoryStore(),
242+
});
243+
244+
const stream = await supervisor.stream('Please delegate', { maxSteps: 5 });
245+
const consumerChunks: ChunkType[] = [];
246+
for await (const chunk of stream.fullStream) {
247+
consumerChunks.push(chunk);
248+
}
249+
250+
const forwardedSubAgentChunks = consumerChunks.filter(
251+
c => c.type === 'tool-output' && c?.payload?.output?.from === 'AGENT',
252+
);
253+
expect(forwardedSubAgentChunks.length).toBeGreaterThan(0);
254+
// Every forwarded sub-agent chunk must carry the rewritten marker.
255+
for (const chunk of forwardedSubAgentChunks) {
256+
expect(chunk.payload.rewritten).toBe(true);
257+
}
258+
});
259+
260+
it('emits a tripwire when a processor aborts on a nested-agent chunk', async () => {
261+
class TripwireOnNestedAgentChunks implements Processor {
262+
readonly id = 'tripwire-on-nested-agent-chunks';
263+
readonly name = 'Tripwire On Nested Agent Chunks';
264+
265+
async processOutputStream({ part, abort }: ProcessOutputStreamArgs) {
266+
if (
267+
part?.type === 'tool-output' &&
268+
part?.payload?.output?.from === 'AGENT' &&
269+
part?.payload?.output?.type === 'text-delta'
270+
) {
271+
abort('nested agent chunk blocked');
272+
}
273+
return part;
274+
}
275+
}
276+
277+
const supervisor = new Agent({
278+
id: 'supervisor',
279+
name: 'supervisor',
280+
instructions: 'You orchestrate sub-agents.',
281+
model: makeSupervisorModel(),
282+
agents: { subAgent: makeSubAgent() },
283+
outputProcessors: [new TripwireOnNestedAgentChunks()],
284+
});
285+
286+
new Mastra({
287+
agents: { supervisor },
288+
storage: new InMemoryStore(),
289+
});
290+
291+
const stream = await supervisor.stream('Please delegate', { maxSteps: 5 });
292+
const consumerChunks: ChunkType[] = [];
293+
for await (const chunk of stream.fullStream) {
294+
consumerChunks.push(chunk);
295+
}
296+
297+
const tripwireChunks = consumerChunks.filter(c => c.type === 'tripwire');
298+
expect(tripwireChunks.length).toBeGreaterThan(0);
299+
expect(tripwireChunks[0].payload.reason).toBe('nested agent chunk blocked');
300+
expect(tripwireChunks[0].payload.processorId).toBe('tripwire-on-nested-agent-chunks');
301+
302+
// The specific blocked sub-agent text delta must NOT reach the consumer.
303+
const forwardedSubAgentTextDeltas = consumerChunks.filter(
304+
c =>
305+
c.type === 'tool-output' && c?.payload?.output?.from === 'AGENT' && c?.payload?.output?.type === 'text-delta',
306+
);
307+
expect(forwardedSubAgentTextDeltas).toHaveLength(0);
308+
});
309+
});

packages/core/src/loop/workflows/stream.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,49 @@ export function workflowLoopStream<Tools extends ToolSet = ToolSet, OUTPUT = und
128128
safeEnqueue(controller, processedChunk);
129129
return;
130130
}
131+
132+
// Non data-* chunks injected via this writer (e.g. `tool-output` from
133+
// sub-agents delegated through the `agents:` option, or
134+
// `workflow-step-output` from workflow tools) bypass the LLM's own
135+
// processor pipeline. Route them through configured output processors
136+
// here so users can filter/redact nested chunks via processOutputStream.
137+
if (dataChunkProcessorRunner) {
138+
const {
139+
part: processed,
140+
blocked,
141+
reason,
142+
tripwireOptions,
143+
processorId,
144+
} = await dataChunkProcessorRunner.processPart(
145+
chunk,
146+
(rest.processorStates ?? dataChunkProcessorStates!) as Map<string, ProcessorState<OUTPUT>>,
147+
undefined,
148+
requestContext,
149+
messageList,
150+
0,
151+
dataChunkStreamWriter,
152+
);
153+
154+
if (blocked) {
155+
safeEnqueue(controller, {
156+
type: 'tripwire',
157+
runId,
158+
from: ChunkFrom.AGENT,
159+
payload: {
160+
reason: reason || 'Output processor blocked content',
161+
retry: tripwireOptions?.retry,
162+
metadata: tripwireOptions?.metadata,
163+
processorId,
164+
},
165+
} as ChunkType<OUTPUT>);
166+
return;
167+
}
168+
169+
if (!processed) return;
170+
safeEnqueue(controller, processed as ChunkType<OUTPUT>);
171+
return;
172+
}
173+
131174
safeEnqueue(controller, chunk);
132175
};
133176

0 commit comments

Comments
 (0)