Skip to content

Commit ed425d7

Browse files
authored
ix(core): prevent requestContext reserved keys from leaking into sub-agent memory (#15022)
1 parent ba6f7e9 commit ed425d7

3 files changed

Lines changed: 172 additions & 1 deletion

File tree

.changeset/green-impalas-slide.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 sub-agent memory isolation so sub-agents no longer inherit parent requestContext keys and write to their own threads when `mastra__threadId` or `mastra__resourceId` are set.

packages/core/src/agent/__tests__/supervisor-integration.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { z } from 'zod/v4';
66
import { Mastra } from '../../mastra';
77
import { MockMemory } from '../../memory/mock';
88
import type { Processor, ProcessOutputResultArgs } from '../../processors/index';
9+
import { RequestContext, MASTRA_THREAD_ID_KEY, MASTRA_RESOURCE_ID_KEY } from '../../request-context';
910
import { InMemoryStore } from '../../storage';
1011
import { createTool } from '../../tools';
1112
import { Agent } from '../agent';
@@ -3232,6 +3233,128 @@ describe('Supervisor Pattern - Message history transfer to sub-agents', () => {
32323233
}
32333234
});
32343235

3236+
it('should isolate sub-agent memory when threadId and resourceId are set via requestContext reserved keys', async () => {
3237+
const subAgentMockModel = new MockLanguageModelV2({
3238+
doGenerate: async () => ({
3239+
rawCall: { rawPrompt: null, rawSettings: {} },
3240+
finishReason: 'stop',
3241+
usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 },
3242+
content: [{ type: 'text', text: 'Sub-agent response' }],
3243+
warnings: [],
3244+
}),
3245+
});
3246+
3247+
const memoryStore = new InMemoryStore();
3248+
const subAgentMemory = new MockMemory({ storage: memoryStore });
3249+
3250+
const subAgent = new Agent({
3251+
id: 'sub-agent-reserved-keys-test',
3252+
name: 'Sub Agent Reserved Keys Test',
3253+
description: 'A sub-agent for testing reserved key isolation',
3254+
instructions: 'Answer questions.',
3255+
model: subAgentMockModel,
3256+
memory: subAgentMemory,
3257+
});
3258+
3259+
let supervisorCallCount = 0;
3260+
const resourceId = randomUUID();
3261+
const threadId = randomUUID();
3262+
3263+
const supervisorAgent = new Agent({
3264+
id: 'supervisor-reserved-keys',
3265+
name: 'Supervisor Reserved Keys',
3266+
instructions: 'Delegate to sub-agent.',
3267+
model: new MockLanguageModelV2({
3268+
doGenerate: async () => {
3269+
supervisorCallCount++;
3270+
if (supervisorCallCount === 1) {
3271+
return {
3272+
rawCall: { rawPrompt: null, rawSettings: {} },
3273+
finishReason: 'tool-calls' as const,
3274+
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
3275+
text: '',
3276+
content: [
3277+
{
3278+
type: 'tool-call' as const,
3279+
toolCallId: 'call-1',
3280+
toolName: 'agent-subAgent',
3281+
input: JSON.stringify({ prompt: 'What is my name?', threadId, resourceId }),
3282+
},
3283+
],
3284+
warnings: [],
3285+
};
3286+
}
3287+
return {
3288+
rawCall: { rawPrompt: null, rawSettings: {} },
3289+
finishReason: 'stop' as const,
3290+
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
3291+
content: [{ type: 'text', text: 'Sub-agent says: Sub-agent response' }],
3292+
warnings: [],
3293+
};
3294+
},
3295+
}),
3296+
agents: { subAgent },
3297+
memory: new MockMemory(),
3298+
});
3299+
3300+
// Set reserved keys on requestContext (simulates middleware + body merge)
3301+
const requestContext = new RequestContext();
3302+
requestContext.set(MASTRA_THREAD_ID_KEY, threadId);
3303+
requestContext.set(MASTRA_RESOURCE_ID_KEY, resourceId);
3304+
3305+
await supervisorAgent.generate([{ role: 'user', content: 'What is my name?' }], {
3306+
maxSteps: 3,
3307+
requestContext,
3308+
memory: {
3309+
resource: resourceId,
3310+
thread: threadId,
3311+
},
3312+
});
3313+
3314+
// Sub-agent should have its own isolated thread, not the parent's
3315+
const subAgentResourceId = `${resourceId}-subAgent`;
3316+
const memoryStorage = await subAgentMemory.storage.getStore('memory');
3317+
expect(memoryStorage).toBeDefined();
3318+
3319+
if (memoryStorage) {
3320+
const allThreadsResult = await memoryStorage.listThreads({ filter: { resourceId: subAgentResourceId } });
3321+
const allThreads = allThreadsResult.threads;
3322+
3323+
// Sub-agent should have its own thread
3324+
expect(allThreads.length).toBeGreaterThan(0);
3325+
3326+
const subAgentThread = allThreads[0];
3327+
expect(subAgentThread).toBeDefined();
3328+
3329+
if (subAgentThread) {
3330+
// Sub-agent thread ID should NOT be the parent's thread ID
3331+
expect(subAgentThread.id).not.toBe(threadId);
3332+
3333+
const subAgentMessages = await memoryStorage.listMessages({
3334+
threadId: subAgentThread.id,
3335+
perPage: 100,
3336+
});
3337+
3338+
expect(subAgentMessages.messages.length).toBeGreaterThanOrEqual(2);
3339+
3340+
// First message should be the delegation prompt
3341+
expect(subAgentMessages.messages[0].role).toBe('user');
3342+
const userContent =
3343+
typeof subAgentMessages.messages[0].content === 'string'
3344+
? subAgentMessages.messages[0].content
3345+
: JSON.stringify(subAgentMessages.messages[0].content);
3346+
expect(userContent).toContain('What is my name');
3347+
3348+
// Second message should be the sub-agent's response
3349+
expect(subAgentMessages.messages[1].role).toBe('assistant');
3350+
}
3351+
}
3352+
3353+
// Verify reserved keys are restored for the parent after sub-agent execution
3354+
expect(requestContext.get(MASTRA_THREAD_ID_KEY)).toBe(threadId);
3355+
expect(requestContext.get(MASTRA_RESOURCE_ID_KEY)).toBe(resourceId);
3356+
});
3357+
32353358
describe('Sub-agent instructions merge', () => {
32363359
it('should preserve sub-agent own instructions when parent LLM provides instructions via tool call', async () => {
32373360
const capturedSystemMessages: string[] = [];

packages/core/src/agent/agent.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3016,6 +3016,19 @@ export class Agent<
30163016
// see the correct context on subsequent steps.
30173017
const savedMastraMemory = requestContext.get('MastraMemory');
30183018

3019+
// Save and clear reserved thread/resource keys so they don't override the
3020+
// sub-agent's isolated memory config. These keys take precedence over the
3021+
// memory option in generate/stream, so leaving them would cause the
3022+
// sub-agent to write to the parent's thread instead of its own.
3023+
const savedThreadIdKey = requestContext.get(MASTRA_THREAD_ID_KEY) as string | undefined;
3024+
const savedResourceIdKey = requestContext.get(MASTRA_RESOURCE_ID_KEY) as string | undefined;
3025+
if (savedThreadIdKey !== undefined) {
3026+
requestContext.delete(MASTRA_THREAD_ID_KEY);
3027+
}
3028+
if (savedResourceIdKey !== undefined) {
3029+
requestContext.delete(MASTRA_RESOURCE_ID_KEY);
3030+
}
3031+
30193032
if (
30203033
(methodType === 'generate' ||
30213034
methodType === 'generateLegacy' ||
@@ -3119,6 +3132,13 @@ export class Agent<
31193132
}
31203133
}
31213134

3135+
if (savedThreadIdKey !== undefined) {
3136+
requestContext.set(MASTRA_THREAD_ID_KEY, savedThreadIdKey);
3137+
}
3138+
if (savedResourceIdKey !== undefined) {
3139+
requestContext.set(MASTRA_RESOURCE_ID_KEY, savedResourceIdKey);
3140+
}
3141+
31223142
return {
31233143
text: `[Delegation Rejected] ${rejectionMessage}`,
31243144
subAgentThreadId,
@@ -3294,6 +3314,12 @@ export class Agent<
32943314
}
32953315

32963316
if (generateResult.finishReason === 'suspended') {
3317+
if (savedThreadIdKey !== undefined) {
3318+
requestContext.set(MASTRA_THREAD_ID_KEY, savedThreadIdKey);
3319+
}
3320+
if (savedResourceIdKey !== undefined) {
3321+
requestContext.set(MASTRA_RESOURCE_ID_KEY, savedResourceIdKey);
3322+
}
32973323
return suspend?.(generateResult.suspendPayload, {
32983324
resumeSchema: generateResult.resumeSchema,
32993325
runId: generateResult.runId,
@@ -3435,6 +3461,12 @@ export class Agent<
34353461
}
34363462

34373463
if (requireToolApproval || suspendedPayload || resumeSchema) {
3464+
if (savedThreadIdKey !== undefined) {
3465+
requestContext.set(MASTRA_THREAD_ID_KEY, savedThreadIdKey);
3466+
}
3467+
if (savedResourceIdKey !== undefined) {
3468+
requestContext.set(MASTRA_RESOURCE_ID_KEY, savedResourceIdKey);
3469+
}
34383470
return suspend?.(suspendedPayload, {
34393471
resumeSchema,
34403472
requireToolApproval,
@@ -3549,6 +3581,12 @@ export class Agent<
35493581
if (savedMastraMemory !== undefined) {
35503582
requestContext.set('MastraMemory', savedMastraMemory);
35513583
}
3584+
if (savedThreadIdKey !== undefined) {
3585+
requestContext.set(MASTRA_THREAD_ID_KEY, savedThreadIdKey);
3586+
}
3587+
if (savedResourceIdKey !== undefined) {
3588+
requestContext.set(MASTRA_RESOURCE_ID_KEY, savedResourceIdKey);
3589+
}
35523590

35533591
return result;
35543592
} catch (err) {
@@ -3622,12 +3660,17 @@ export class Agent<
36223660
}
36233661
}
36243662

3625-
// Wrap error in MastraError
36263663
// Restore even on error so the parent's retry/fallback logic
36273664
// sees the correct memory context
36283665
if (savedMastraMemory !== undefined) {
36293666
requestContext.set('MastraMemory', savedMastraMemory);
36303667
}
3668+
if (savedThreadIdKey !== undefined) {
3669+
requestContext.set(MASTRA_THREAD_ID_KEY, savedThreadIdKey);
3670+
}
3671+
if (savedResourceIdKey !== undefined) {
3672+
requestContext.set(MASTRA_RESOURCE_ID_KEY, savedResourceIdKey);
3673+
}
36313674

36323675
const mastraError = new MastraError(
36333676
{

0 commit comments

Comments
 (0)