Skip to content

Commit f703f87

Browse files
authored
fix(core): resume parallel sub-agent approvals in any order (#19450)
1 parent 2ab01ea commit f703f87

6 files changed

Lines changed: 162 additions & 17 deletions

File tree

.changeset/puny-roses-fetch.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 parallel sub-agent approvals so they can be handled in any order. listSuspendedRuns() now returns each pending sub-agent call, and approving one resumes that specific call instead of using another call’s suspended state.

packages/core/src/agent/__tests__/parallel-delegation-approval.test.ts

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,11 @@ function buildSupervisorAgent(storage: InMemoryStore = new InMemoryStore()) {
186186
return mastra.getAgent('supervisor');
187187
}
188188

189+
// NOTE: default engine only. The evented engine (MASTRA_EVENTED_EXECUTION) has a
190+
// separate pre-existing run-lifecycle bug with parallel delegations: resuming the
191+
// first suspended iteration completes the outer run and deletes its snapshot,
192+
// permanently orphaning the sibling. Its foreach aggregation does not persist
193+
// per-iteration suspend payloads, so the pending check cannot see the parked sibling.
189194
describe('parallel sub-agent delegation (suspend/resume)', () => {
190195
it('emits two distinct approval requests, one per order', async () => {
191196
processedOrders.length = 0;
@@ -214,17 +219,14 @@ describe('parallel sub-agent delegation (suspend/resume)', () => {
214219
memory: { resource: 'rep_approval', thread: 'thread-wrong-target' },
215220
});
216221

217-
const approvals = await collectApprovals(stream);
218-
const [{ toolCalls }] = (await supervisor.listSuspendedRuns()).runs;
219-
const suspendedToolCallIds = new Set(toolCalls.map(toolCall => toolCall.toolCallId));
220-
const phantomApproval = approvals.find(approval => !suspendedToolCallIds.has(approval.toolCallId));
222+
await collectApprovals(stream);
223+
const bogusToolCallId = 'sup-tc-nonexistent';
221224

222-
expect(phantomApproval).toBeDefined();
223225
let resumeError: unknown;
224226
try {
225227
const resumed = await supervisor.approveToolCall({
226228
runId: stream.runId,
227-
toolCallId: phantomApproval!.toolCallId,
229+
toolCallId: bogusToolCallId,
228230
});
229231
for await (const _chunk of resumed.fullStream) {
230232
// Drain the stream so unintended tool execution cannot leak into later tests.
@@ -235,12 +237,62 @@ describe('parallel sub-agent delegation (suspend/resume)', () => {
235237

236238
expect(resumeError).toMatchObject({ id: 'AGENT_RESUME_TOOL_CALL_NOT_SUSPENDED' });
237239
await expect(
238-
supervisor.resumeGenerate({ approved: true }, { runId: stream.runId, toolCallId: phantomApproval!.toolCallId }),
240+
supervisor.resumeGenerate({ approved: true }, { runId: stream.runId, toolCallId: bogusToolCallId }),
239241
).rejects.toMatchObject({ id: 'AGENT_RESUME_TOOL_CALL_NOT_SUSPENDED' });
240242
await expect(
241243
supervisor.resumeGenerate({ approved: true }, { runId: stream.runId, toolCallId: '' }),
242244
).rejects.toMatchObject({ id: 'AGENT_RESUME_TOOL_CALL_NOT_SUSPENDED' });
243245
expect(processedOrders).toEqual([]);
246+
}, 30_000);
247+
248+
it('surfaces BOTH suspended delegations in listSuspendedRuns', async () => {
249+
processedOrders.length = 0;
250+
const supervisor = buildSupervisorAgent();
251+
252+
const stream = await supervisor.stream('Process both orders in parallel.', {
253+
maxSteps: 6,
254+
memory: { resource: 'rep_approval', thread: 'thread-surface' },
255+
});
256+
257+
const approvals = await collectApprovals(stream);
258+
expect(approvals.length).toBe(2);
259+
260+
const [{ toolCalls }] = (await supervisor.listSuspendedRuns()).runs;
261+
const suspendedToolCallIds = toolCalls.map(toolCall => toolCall.toolCallId).sort();
262+
expect(suspendedToolCallIds).toEqual(['sup-tc-A', 'sup-tc-B']);
263+
for (const toolCall of toolCalls) {
264+
expect(toolCall.toolName).toBe('agent-subAgent');
265+
expect(toolCall.requiresApproval).toBe(true);
266+
}
267+
});
268+
269+
it('approving the delegations OUT OF ORDER (B first) processes both orders correctly', async () => {
270+
processedOrders.length = 0;
271+
const supervisor = buildSupervisorAgent();
272+
273+
const stream = await supervisor.stream('Process both orders in parallel.', {
274+
maxSteps: 6,
275+
memory: { resource: 'rep_approval', thread: 'thread-out-of-order' },
276+
});
277+
278+
const approvals = await collectApprovals(stream);
279+
const runId = stream.runId;
280+
expect(approvals.length).toBe(2);
281+
282+
// Approve the SECOND emitted card first — the field failure ("approve the
283+
// bottom card") that previously resumed the wrong delegation.
284+
const outOfOrder = [...approvals].reverse();
285+
const resumeErrors: string[] = [];
286+
for (const a of outOfOrder) {
287+
const resumed = await supervisor.approveToolCall({ runId, toolCallId: a.toolCallId });
288+
for await (const chunk of resumed.fullStream) {
289+
if (chunk.type === 'tool-error') resumeErrors.push(JSON.stringify((chunk as any).payload ?? chunk));
290+
}
291+
}
292+
293+
expect(resumeErrors).toEqual([]);
294+
// Approval order must map to execution order: B was approved first.
295+
expect(processedOrders).toEqual([ORDER_B, ORDER_A]);
244296
});
245297

246298
it('approving both parallel delegations one at a time processes BOTH orders', async () => {

packages/core/src/agent/agent.ts

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6310,12 +6310,8 @@ export class Agent<
63106310

63116311
#getSuspendedToolCalls(existingSnapshot: WorkflowRunState | null | undefined): AgentRunToolCall[] {
63126312
const toolCalls: AgentRunToolCall[] = [];
6313-
for (const key in existingSnapshot?.context) {
6314-
const step = existingSnapshot?.context[key];
6315-
if (step?.status !== 'suspended') continue;
6316-
const payload = step.suspendPayload;
6317-
if (!payload) continue;
63186313

6314+
const collectFromPayload = (payload: Record<string, any>, stepKey: string) => {
63196315
if (payload.requireToolApproval) {
63206316
toolCalls.push({
63216317
toolCallId: payload.requireToolApproval.toolCallId,
@@ -6325,17 +6321,55 @@ export class Agent<
63256321
});
63266322
} else if (payload.toolCallSuspended || payload.toolName || payload.toolCallId) {
63276323
toolCalls.push({
6328-
toolCallId: payload.toolCallId ?? this.#findResumeLabelForStep(existingSnapshot, key),
6324+
toolCallId: payload.toolCallId ?? this.#findResumeLabelForStep(existingSnapshot, stepKey),
63296325
toolName: payload.toolName,
63306326
requiresApproval: false,
63316327
suspendPayload: payload.toolCallSuspended,
63326328
});
63336329
}
6330+
};
6331+
6332+
for (const key in existingSnapshot?.context) {
6333+
const step = existingSnapshot?.context[key];
6334+
if (step?.status !== 'suspended') continue;
6335+
const payload = step.suspendPayload;
6336+
if (!payload) continue;
6337+
6338+
// A foreach step (e.g. parallel tool calls in the agentic loop) can park several
6339+
// iterations at once, but its step-level suspendPayload only carries the first
6340+
// suspended iteration. The full set lives in `__workflow_meta.foreachOutput`,
6341+
// where each suspended entry keeps its own per-iteration payload — surface every
6342+
// one of them so all pending tool calls are discoverable and resumable.
6343+
const suspendedIterations = this.#getSuspendedForeachIterations(payload);
6344+
if (suspendedIterations.length > 0) {
6345+
for (const iteration of suspendedIterations) {
6346+
collectFromPayload(iteration.suspendPayload, key);
6347+
}
6348+
} else {
6349+
collectFromPayload(payload, key);
6350+
}
63346351
}
63356352

63366353
return toolCalls;
63376354
}
63386355

6356+
#getSuspendedForeachIterations(
6357+
payload: Record<string, any>,
6358+
): { status: 'suspended'; suspendPayload: Record<string, any> }[] {
6359+
// The default engine persists foreach aggregation as an array; the evented
6360+
// engine persists it as an object keyed by iteration index.
6361+
const foreachOutput = payload.__workflow_meta?.foreachOutput;
6362+
const entries = Array.isArray(foreachOutput)
6363+
? foreachOutput
6364+
: foreachOutput && typeof foreachOutput === 'object'
6365+
? Object.values(foreachOutput)
6366+
: [];
6367+
return entries.filter(
6368+
(entry: any): entry is { status: 'suspended'; suspendPayload: Record<string, any> } =>
6369+
entry?.status === 'suspended' && !!entry.suspendPayload,
6370+
);
6371+
}
6372+
63396373
async #validateSuspendedToolCallTarget({
63406374
snapshot,
63416375
toolCallId,
@@ -6397,9 +6431,19 @@ export class Agent<
63976431

63986432
#getSuspendedToolInfo(
63996433
existingSnapshot: WorkflowRunState | null | undefined,
6434+
targetToolCallId?: string,
64006435
): { toolCallId?: string; toolName?: string } | undefined {
6401-
const [first] = this.#getSuspendedToolCalls(existingSnapshot);
6402-
return first ? { toolCallId: first.toolCallId, toolName: first.toolName } : undefined;
6436+
const suspendedToolCalls = this.#getSuspendedToolCalls(existingSnapshot);
6437+
// Several tool calls can be parked at once. Never label an explicitly targeted
6438+
// resume with a sibling if this snapshot predates the target's persistence.
6439+
const info =
6440+
targetToolCallId !== undefined
6441+
? (suspendedToolCalls.find(toolCall => toolCall.toolCallId === targetToolCallId) ?? {
6442+
toolCallId: targetToolCallId,
6443+
toolName: undefined,
6444+
})
6445+
: suspendedToolCalls[0];
6446+
return info ? { toolCallId: info.toolCallId, toolName: info.toolName } : undefined;
64036447
}
64046448

64056449
#getResumeSpanInput(resumeData: unknown, suspendedToolInfo?: { toolCallId?: string; toolName?: string }): unknown {
@@ -6690,7 +6734,9 @@ export class Agent<
66906734
// For resumed runs, surface resumeData as the span input and link the resumed
66916735
// span back to the original suspended trace. Mirrors Workflow.resume tracing.
66926736
const isResume = !!resumeContext;
6693-
const suspendedToolInfo = isResume ? this.#getSuspendedToolInfo(resumeContext?.snapshot) : undefined;
6737+
const suspendedToolInfo = isResume
6738+
? this.#getSuspendedToolInfo(resumeContext?.snapshot, options.toolCallId)
6739+
: undefined;
66946740
const persistedTracingContext = isResume
66956741
? (resumeContext?.snapshot?.tracingContext as
66966742
| { traceId?: string; spanId?: string; parentSpanId?: string }

packages/core/src/workflows/evented/step-executor.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,17 @@ export class StepExecutor extends MastraBase {
120120
let suspendDataToUse =
121121
params.stepResults[step.id]?.status === 'suspended' ? params.stepResults[step.id]?.suspendPayload : undefined;
122122

123+
// A suspended foreach step's step-level suspendPayload only carries the FIRST suspended
124+
// iteration's payload. When resuming a specific iteration, use that iteration's own payload
125+
// from `__workflow_meta.foreachOutput` so parallel suspensions don't read a sibling's data
126+
// (e.g. another tool call's suspended run id).
127+
if (suspendDataToUse && typeof params.foreachIdx === 'number') {
128+
const iterationResult = suspendDataToUse.__workflow_meta?.foreachOutput?.[params.foreachIdx];
129+
if (iterationResult?.status === 'suspended' && iterationResult.suspendPayload) {
130+
suspendDataToUse = iterationResult.suspendPayload;
131+
}
132+
}
133+
123134
// Filter out internal workflow metadata before exposing to step code
124135
if (suspendDataToUse && '__workflow_meta' in suspendDataToUse) {
125136
const { __workflow_meta, ...userSuspendData } = suspendDataToUse;

packages/core/src/workflows/handlers/step.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,18 @@ export async function executeStep(
135135
let suspendDataToUse =
136136
stepResults[step.id]?.status === 'suspended' ? stepResults[step.id]?.suspendPayload : undefined;
137137

138+
// A suspended foreach step's step-level suspendPayload only carries the FIRST suspended
139+
// iteration's payload. When resuming a specific iteration, use that iteration's own payload
140+
// from `__workflow_meta.foreachOutput` so parallel suspensions don't read a sibling's data
141+
// (e.g. another tool call's suspended run id).
142+
const foreachIndex = executionContext.foreachIndex;
143+
if (suspendDataToUse && foreachIndex !== undefined) {
144+
const iterationResult = suspendDataToUse.__workflow_meta?.foreachOutput?.[foreachIndex];
145+
if (iterationResult?.status === 'suspended' && iterationResult.suspendPayload) {
146+
suspendDataToUse = iterationResult.suspendPayload;
147+
}
148+
}
149+
138150
// Filter out internal workflow metadata before exposing to step code
139151
if (suspendDataToUse && '__workflow_meta' in suspendDataToUse) {
140152
const { __workflow_meta, ...userSuspendData } = suspendDataToUse;

packages/core/src/workflows/workflow.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2660,10 +2660,29 @@ export class Workflow<
26602660
for (const [stepName, stepResult] of suspendedSteps) {
26612661
// @ts-expect-error - context type mismatch
26622662
const suspendPath: string[] = [stepName, ...(stepResult?.suspendPayload?.__workflow_meta?.path ?? [])];
2663+
const nestedMeta = (stepResult as any)?.suspendPayload?.__workflow_meta ?? {};
2664+
// Keep the nested workflow metadata (foreachIndex, foreachOutput, resumeLabels) when
2665+
// propagating a suspension to the parent — mirrors the evented engine — so the parent
2666+
// snapshot is self-describing about EVERY parked iteration, not just the first one.
2667+
// Only runId and path change as we propagate up. Per-iteration `__streamState` blobs
2668+
// are stripped from the propagated copies: they can be large and resume reads them
2669+
// from the nested run's own snapshot, so the parent only needs the identifying fields.
2670+
const propagatedForeachOutput = Array.isArray(nestedMeta.foreachOutput)
2671+
? nestedMeta.foreachOutput.map((entry: any) => {
2672+
if (entry?.status !== 'suspended' || !entry.suspendPayload) return entry;
2673+
const { __streamState: _streamState, ...suspendPayload } = entry.suspendPayload;
2674+
return { ...entry, suspendPayload };
2675+
})
2676+
: undefined;
26632677
await suspend(
26642678
{
26652679
...(stepResult as any)?.suspendPayload,
2666-
__workflow_meta: { runId: run.runId, path: suspendPath },
2680+
__workflow_meta: {
2681+
...nestedMeta,
2682+
...(propagatedForeachOutput ? { foreachOutput: propagatedForeachOutput } : {}),
2683+
runId: run.runId,
2684+
path: suspendPath,
2685+
},
26672686
},
26682687
{
26692688
resumeLabel: Object.keys(res.resumeLabels ?? {}),

0 commit comments

Comments
 (0)