Skip to content

Commit d567299

Browse files
TylerBarnesMastra Code (openai/gpt-5.3-codex)
andauthored
fix: add processInputStep response id rotation (#13887)
This adds the ability for `processInputStep` to rotate the active response message id, and wires the loop to honor that rotated id through streamed output and persistence. Before, the active response id was effectively fixed for the lifetime of the response. After, a processor can explicitly request a new response id during `processInputStep`, and the rest of the loop keeps using that new id consistently. ```ts // before processInputStep({ messageId }) // response continues under the original id ``` ```ts // after processInputStep({ messageId, rotateResponseMessageId }) // response continues under the rotated id ``` This also adds the small observational-memory compatibility guard needed for that new core capability, so OM fails fast when paired with an older `@mastra/core` that doesn't support response id rotation yet. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Response message ID rotation during processor execution and streaming. * New core feature flag: request-response-id-rotation. * **Bug Fixes** * Streamed assistant IDs and persisted memory now use the rotated response ID. * Avoid merging when a replacement target is sealed to preserve message integrity. * **Chores** * Observational Memory now fails fast if core lacks rotation support. * **Tests** * Added tests verifying rotation behavior across processors and streaming. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Mastra Code (openai/gpt-5.3-codex) <noreply@mastra.ai>
1 parent cd48e26 commit d567299

15 files changed

Lines changed: 291 additions & 33 deletions

File tree

.changeset/strict-squids-tease.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/memory': patch
3+
---
4+
5+
Added a compatibility guard so observational memory now fails fast when @mastra/core does not support request-response-id-rotation.

.changeset/vast-baboons-own.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@mastra/core': patch
3+
---
4+
5+
Added processor-driven response message ID rotation so streamed assistant IDs use the rotated ID.
6+
7+
Processors that run outside the agent loop no longer need synthetic response message IDs.

packages/core/src/agent/__tests__/stream-id.test.ts

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
66
import { z } from 'zod';
77
import { Mastra } from '../../mastra';
88
import { MockMemory } from '../../memory/mock';
9+
import type { Processor } from '../../processors';
910
import { createTool } from '../../tools';
1011
import { Agent } from '../agent';
1112

@@ -279,6 +280,73 @@ describe('Stream ID Consistency', () => {
279280
expect(customIdGenerator).toHaveBeenCalled();
280281
});
281282

283+
it('should let processInputStep rotate the active response message ID for stream output and persistence', async () => {
284+
let initialMessageId: string | undefined;
285+
let rotatedMessageId: string | undefined;
286+
287+
const rotateMessageIdProcessor = {
288+
id: 'rotate-response-message-id-processor',
289+
processInputStep: async ({ messageId, rotateResponseMessageId }) => {
290+
initialMessageId = messageId;
291+
rotatedMessageId = rotateResponseMessageId?.();
292+
return {};
293+
},
294+
} satisfies Processor;
295+
296+
const model = new MockLanguageModelV2({
297+
doStream: async () => ({
298+
rawCall: { rawPrompt: null, rawSettings: {} },
299+
warnings: [],
300+
stream: convertArrayToReadableStream([
301+
{ type: 'stream-start', warnings: [] },
302+
{ type: 'response-metadata', id: 'provider-msg-xyz123', modelId: 'mock-model-id', timestamp: new Date(0) },
303+
{ type: 'text-start', id: 'text-1' },
304+
{ type: 'text-delta', id: 'text-1', delta: 'rotated ' },
305+
{ type: 'text-delta', id: 'text-1', delta: 'response id' },
306+
{ type: 'text-end', id: 'text-1' },
307+
{
308+
type: 'finish',
309+
finishReason: 'stop',
310+
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
311+
},
312+
]),
313+
}),
314+
});
315+
316+
const agent = new Agent({
317+
id: 'test-agent',
318+
name: 'Test Agent V2 Rotated Response ID',
319+
instructions: 'You are a helpful assistant.',
320+
model,
321+
memory,
322+
inputProcessors: [rotateMessageIdProcessor],
323+
});
324+
325+
agent.__registerMastra(mastra);
326+
327+
const threadId = randomUUID();
328+
const resourceId = 'test-resource';
329+
const stream = await agent.stream('Hello!', { memory: { thread: threadId, resource: resourceId } });
330+
331+
for await (const _chunk of stream.fullStream) {
332+
}
333+
334+
const res = await stream.response;
335+
const messageId = res?.uiMessages?.[0]?.id;
336+
337+
expect(initialMessageId).toBeDefined();
338+
expect(rotatedMessageId).toBeDefined();
339+
expect(rotatedMessageId).not.toBe(initialMessageId);
340+
expect(messageId).toBe(rotatedMessageId);
341+
342+
const rotatedResult = await memory.recall({ threadId, perPage: 0, include: [{ id: rotatedMessageId! }] });
343+
expect(rotatedResult.messages).toHaveLength(1);
344+
expect(rotatedResult.messages[0].id).toBe(rotatedMessageId!);
345+
346+
const initialResult = await memory.recall({ threadId, perPage: 0, include: [{ id: initialMessageId! }] });
347+
expect(initialResult.messages).toHaveLength(0);
348+
});
349+
282350
it('should return generate response IDs that match database-saved message IDs (V2 model)', async () => {
283351
const model = new MockLanguageModelV2({
284352
doGenerate: async () => ({
@@ -338,13 +406,11 @@ describe('Stream ID Consistency', () => {
338406
const responseMessageId = result.response?.uiMessages?.[0]?.id;
339407
expect(responseMessageId).toBeDefined();
340408

341-
// Verify the response message ID can be found in memory
342409
const recalled = await memory.recall({ threadId, include: [{ id: responseMessageId! }] });
343410
const messageById = recalled.messages.find(m => m.id === responseMessageId);
344411
expect(messageById).toBeDefined();
345412
expect(messageById!.id).toBe(responseMessageId);
346413

347-
// Verify no duplicate assistant messages exist (only 1 user + 1 assistant)
348414
const allMessages = await memory.recall({ threadId });
349415
const assistantMessages = allMessages.messages.filter(m => m.role === 'assistant');
350416
expect(assistantMessages).toHaveLength(1);
@@ -440,25 +506,21 @@ describe('Stream ID Consistency', () => {
440506
memory: { thread: threadId, resource: resourceId },
441507
});
442508

443-
// Consume the entire stream
444509
await streamResult.consumeStream();
445510

446511
const response = await streamResult.response;
447512
const uiMessageIds = response?.uiMessages?.map(m => m.id) || [];
448513

449-
// Verify all response message IDs can be found in memory
450514
const allRecalled = await memory.recall({ threadId });
451515

452516
for (const uiMsgId of uiMessageIds) {
453517
const matchInMemory = allRecalled.messages.find(m => m.id === uiMsgId);
454518
expect(matchInMemory, `uiMessage ID ${uiMsgId} should exist in memory`).toBeDefined();
455519
}
456520

457-
// Verify fullOutput.messages IDs match response uiMessage IDs
458521
const fullOutput = await streamResult.getFullOutput();
459522
const responseMessageIds = fullOutput.messages.filter(m => m.role === 'assistant').map(m => m.id);
460523

461-
// Every uiMessage ID should appear in fullOutput.messages
462524
for (const uiMsgId of uiMessageIds) {
463525
expect(responseMessageIds, `uiMessage ID ${uiMsgId} should appear in fullOutput.messages`).toContain(uiMsgId);
464526
}

packages/core/src/agent/message-list/message-list.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -824,16 +824,17 @@ export class MessageList {
824824
}
825825
}
826826
}
827-
// If the last message is an assistant message and the new message is also an assistant message, merge them together and update tool calls with results
828-
// Use MessageMerger to handle the complex merge logic
827+
828+
const replacementTarget = exists && id ? this.messages.find(m => m.id === id) : undefined;
829+
const hasSealedReplacementTarget = !!replacementTarget && MessageMerger.isSealed(replacementTarget);
830+
831+
// Keep this replacement-target guard here instead of MessageMerger.shouldMerge().
832+
// shouldMerge() only decides whether to append to the latest assistant message,
833+
// but replace-by-id can target an older sealed message elsewhere in the list.
829834
const isLatestFromMemory = latestMessage ? this.memoryMessages.has(latestMessage) : false;
830-
const shouldMerge = MessageMerger.shouldMerge(
831-
latestMessage,
832-
messageV2,
833-
messageSource,
834-
isLatestFromMemory,
835-
this._agentNetworkAppend,
836-
);
835+
const shouldMerge =
836+
!hasSealedReplacementTarget &&
837+
MessageMerger.shouldMerge(latestMessage, messageV2, messageSource, isLatestFromMemory, this._agentNetworkAppend);
837838

838839
if (shouldMerge && latestMessage) {
839840
// Delegate merge logic to MessageMerger

packages/core/src/features/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,10 @@
1414
* ```
1515
*/
1616
// Add feature flags here as new features are introduced
17-
export const coreFeatures = new Set<string>(['observationalMemory', 'asyncBuffering', 'workspaces-v1', 'datasets']);
17+
export const coreFeatures = new Set<string>([
18+
'observationalMemory',
19+
'asyncBuffering',
20+
'request-response-id-rotation',
21+
'workspaces-v1',
22+
'datasets',
23+
]);

packages/core/src/loop/workflows/agentic-execution/llm-execution-step.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ReadableStream } from 'node:stream/web';
22
import { isAbortError } from '@ai-sdk/provider-utils-v5';
33
import type { LanguageModelV2Usage } from '@ai-sdk/provider-v5';
4-
import { APICallError } from '@internal/ai-sdk-v5';
4+
import { APICallError, generateId } from '@internal/ai-sdk-v5';
55
import type { CallSettings, ToolChoice, ToolSet } from '@internal/ai-sdk-v5';
66
import type { StructuredOutputOptions } from '../../../agent';
77
import type { MastraDBMessage, MessageList } from '../../../agent/message-list';
@@ -546,9 +546,9 @@ export function createLLMExecutionStep<TOOLS extends ToolSet = ToolSet, OUTPUT =
546546
execute: async ({ inputData, bail, tracingContext }) => {
547547
currentIteration++;
548548

549-
const messageId = inputData.isTaskCompleteCheckFailed
549+
let currentMessageId = inputData.isTaskCompleteCheckFailed
550550
? `${messageIdPassed}-${currentIteration}`
551-
: messageIdPassed;
551+
: inputData.messageId || messageIdPassed;
552552
// Start the MODEL_STEP span at the beginning of LLM execution
553553
modelSpanTracker?.startStep();
554554

@@ -583,6 +583,7 @@ export function createLLMExecutionStep<TOOLS extends ToolSet = ToolSet, OUTPUT =
583583
}
584584

585585
const currentStep: {
586+
messageId: string;
586587
model: MastraLanguageModel;
587588
tools?: TOOLS | undefined;
588589
toolChoice?: ToolChoice<TOOLS> | undefined;
@@ -592,6 +593,7 @@ export function createLLMExecutionStep<TOOLS extends ToolSet = ToolSet, OUTPUT =
592593
structuredOutput?: StructuredOutputOptions<OUTPUT>;
593594
workspace?: Workspace;
594595
} = {
596+
messageId: currentMessageId,
595597
model,
596598
tools,
597599
toolChoice,
@@ -631,6 +633,12 @@ export function createLLMExecutionStep<TOOLS extends ToolSet = ToolSet, OUTPUT =
631633
requestContext,
632634
model,
633635
steps: inputData.output?.steps || [],
636+
messageId: currentStep.messageId,
637+
rotateResponseMessageId: () => {
638+
currentMessageId = _internal?.generateId?.() ?? generateId();
639+
currentStep.messageId = currentMessageId;
640+
return currentMessageId;
641+
},
634642
tools,
635643
toolChoice,
636644
activeTools: activeTools as string[] | undefined,
@@ -679,7 +687,7 @@ export function createLLMExecutionStep<TOOLS extends ToolSet = ToolSet, OUTPUT =
679687
},
680688
}),
681689
messageList,
682-
messageId,
690+
messageId: currentStep.messageId,
683691
options: { runId },
684692
}),
685693
runState,
@@ -817,7 +825,7 @@ export function createLLMExecutionStep<TOOLS extends ToolSet = ToolSet, OUTPUT =
817825
payload: {
818826
request: request || {},
819827
warnings: warnings || [],
820-
messageId: messageId,
828+
messageId: currentStep.messageId,
821829
},
822830
});
823831
},
@@ -838,7 +846,7 @@ export function createLLMExecutionStep<TOOLS extends ToolSet = ToolSet, OUTPUT =
838846
},
839847
stream: modelResult as ReadableStream<ChunkType<OUTPUT>>,
840848
messageList,
841-
messageId,
849+
messageId: currentStep.messageId,
842850
options: {
843851
runId,
844852
toolCallStreaming,
@@ -863,7 +871,7 @@ export function createLLMExecutionStep<TOOLS extends ToolSet = ToolSet, OUTPUT =
863871
outputStream,
864872
includeRawChunks,
865873
tools: currentStep.tools,
866-
messageId,
874+
messageId: currentStep.messageId,
867875
messageList,
868876
runState,
869877
options,
@@ -965,7 +973,7 @@ export function createLLMExecutionStep<TOOLS extends ToolSet = ToolSet, OUTPUT =
965973
const text = outputStream._getImmediateText();
966974

967975
return bail({
968-
messageId,
976+
messageId: outputStream.messageId,
969977
stepResult: {
970978
reason: 'tripwire',
971979
warnings,
@@ -1012,7 +1020,7 @@ export function createLLMExecutionStep<TOOLS extends ToolSet = ToolSet, OUTPUT =
10121020

10131021
if (toolCalls.length > 0) {
10141022
const message: MastraDBMessage = {
1015-
id: messageId,
1023+
id: outputStream.messageId,
10161024
role: 'assistant' as const,
10171025
content: {
10181026
format: 2,
@@ -1166,7 +1174,7 @@ export function createLLMExecutionStep<TOOLS extends ToolSet = ToolSet, OUTPUT =
11661174
// Without this, the LLM sees the rejected assistant response in its prompt on retry,
11671175
// which confuses models and often causes empty text responses.
11681176
if (shouldRetry) {
1169-
messageList.removeByIds([messageId]);
1177+
messageList.removeByIds([outputStream.messageId]);
11701178
}
11711179

11721180
// Build retry feedback text if retrying
@@ -1195,7 +1203,7 @@ export function createLLMExecutionStep<TOOLS extends ToolSet = ToolSet, OUTPUT =
11951203
const nextProcessorRetryCount = shouldRetry ? currentProcessorRetryCount + 1 : currentProcessorRetryCount;
11961204

11971205
return {
1198-
messageId,
1206+
messageId: outputStream.messageId,
11991207
stepResult: {
12001208
reason: stepReason,
12011209
warnings,

packages/core/src/processors/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ export interface ProcessInputStepArgs<TTripwireMetadata = unknown> extends Proce
119119
/** The current step number (0-indexed) */
120120
stepNumber: number;
121121
steps: Array<StepResult<any>>;
122+
/** The active assistant response message ID for this step, when this processor is running inside an agent loop */
123+
messageId?: string;
124+
/** Mark the current assistant response message ID as complete and rotate to a fresh one, when supported by the caller */
125+
rotateResponseMessageId?: () => string;
122126

123127
/** All system messages (agent instructions, user-provided, memory) for read/modify access */
124128
systemMessages: CoreMessageV4[];
@@ -149,7 +153,14 @@ export interface ProcessInputStepArgs<TTripwireMetadata = unknown> extends Proce
149153
retryCount: number;
150154
}
151155

152-
export type RunProcessInputStepArgs = Omit<ProcessInputStepArgs, 'messages' | 'systemMessages' | 'abort' | 'state'>;
156+
export type RunProcessInputStepArgs = Omit<
157+
ProcessInputStepArgs,
158+
'messages' | 'systemMessages' | 'abort' | 'state' | 'messageId' | 'rotateResponseMessageId' | 'retryCount'
159+
> & {
160+
messageId?: string;
161+
rotateResponseMessageId?: () => string;
162+
retryCount?: number;
163+
};
153164

154165
/**
155166
* Result from processInputStep method
@@ -159,6 +170,8 @@ export type RunProcessInputStepArgs = Omit<ProcessInputStepArgs, 'messages' | 's
159170
*/
160171
export type ProcessInputStepResult = {
161172
model?: LanguageModelV2 | ModelRouterModelId | OpenAICompatibleConfig | MastraLanguageModel;
173+
/** Override the active assistant response message ID for this step */
174+
messageId?: string;
162175
/** Replace tools for this step - accepts both AI SDK tools and Mastra createTool results */
163176
tools?: Record<string, unknown>;
164177
toolChoice?: ToolChoice<any>;

packages/core/src/processors/process-input-step.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,55 @@ describe('processInputStep', () => {
139139
expect(typeof runner.runProcessInputStep).toBe('function');
140140
});
141141

142+
it('should rotate the active response message id for later processors and the final step result', async () => {
143+
const seenMessageIds: string[] = [];
144+
const rotateResponseMessageId = vi.fn(() => 'response-2');
145+
146+
const rotateProcessor: Processor = {
147+
id: 'rotate-processor',
148+
processInputStep: async ({ messageId, rotateResponseMessageId }) => {
149+
if (messageId) {
150+
seenMessageIds.push(messageId);
151+
}
152+
rotateResponseMessageId?.();
153+
return {};
154+
},
155+
};
156+
157+
const observeProcessor: Processor = {
158+
id: 'observe-processor',
159+
processInputStep: async ({ messageId }) => {
160+
if (messageId) {
161+
seenMessageIds.push(messageId);
162+
}
163+
return {};
164+
},
165+
};
166+
167+
const runner = new ProcessorRunner({
168+
inputProcessors: [rotateProcessor, observeProcessor],
169+
outputProcessors: [],
170+
logger: mockLogger,
171+
agentName: 'test-agent',
172+
});
173+
174+
const messageList = new MessageList({ threadId: 'test-thread' });
175+
messageList.add([createMessage('Hello')], 'input');
176+
177+
const result = await runner.runProcessInputStep({
178+
messageList,
179+
stepNumber: 0,
180+
model: createMockModel(),
181+
steps: [],
182+
messageId: 'response-1',
183+
rotateResponseMessageId,
184+
});
185+
186+
expect(rotateResponseMessageId).toHaveBeenCalledTimes(1);
187+
expect(seenMessageIds).toEqual(['response-1', 'response-2']);
188+
expect(result.messageId).toBe('response-2');
189+
});
190+
142191
it('should be callable at each step with growing message history', async () => {
143192
let processInputStepCallCount = 0;
144193
const stepNumbers: number[] = [];

0 commit comments

Comments
 (0)