Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
fix(core): preserve Anthropic signed thinking across memory replay
  • Loading branch information
Akash504-ai committed Jun 5, 2026
commit 2dd2f0150022d2cd0b6caae7c0298fe5acc65661
7 changes: 7 additions & 0 deletions .changeset/signed-thinking-memory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@mastra/core': patch
---

**Fixed** Anthropic signed thinking blocks now replay from memory with their original thinking text and signature together. Legacy history that already lost the thinking text is sanitized before Anthropic requests so invalid empty signed thinking blocks are not forwarded.

Fixes #17457.
33 changes: 24 additions & 9 deletions packages/core/src/agent/message-list/adapters/AIV5Adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,27 @@ function getMastraCreatedAt(providerMetadata?: AIV5Type.ProviderMetadata): numbe
return typeof createdAt === 'number' ? createdAt : undefined;
}

function hasAnthropicSignature(providerMetadata?: AIV5Type.ProviderMetadata): boolean {
const anthropic = providerMetadata?.anthropic;
return Boolean(
anthropic &&
typeof anthropic === 'object' &&
typeof (anthropic as Record<string, unknown>).signature === 'string' &&
(anthropic as Record<string, unknown>).signature,
);
}

function getReasoningText(part: Extract<MastraMessagePart, { type: 'reasoning' }>): string {
return (
part.reasoning ||
(part.details?.reduce((text: string, detail) => {
if (detail.type === 'text' && detail.text) return text + detail.text;
return text;
}, '') ??
'')
);
}

function getDisplayTransform(
providerMetadata: unknown,
phase: 'input-available' | 'output-available' | 'error' | 'approval' | 'suspend',
Expand Down Expand Up @@ -320,13 +341,7 @@ export class AIV5Adapter {

// Handle reasoning parts
if (part.type === 'reasoning') {
const text =
part.reasoning ||
(part.details?.reduce((p: string, c) => {
if (c.type === `text` && c.text) return p + c.text;
return p;
}, '') ??
'');
const text = getReasoningText(part);
if (text || part.details?.length) {
const v5UIPart: AIV5Type.ReasoningUIPart = {
type: 'reasoning' as const,
Expand Down Expand Up @@ -633,7 +648,7 @@ export class AIV5Adapter {
if (p.type === 'reasoning') {
return {
type: 'reasoning' as const,
reasoning: '',
reasoning: hasAnthropicSignature(p.providerMetadata) ? p.text : '',
details: [
{
type: 'text' as const,
Expand Down Expand Up @@ -872,7 +887,7 @@ export class AIV5Adapter {
} else if (part.type === 'reasoning') {
const v2ReasoningPart: MastraDBMessage['content']['parts'][number] = {
type: 'reasoning',
reasoning: '',
reasoning: hasAnthropicSignature(part.providerOptions) ? part.text : '',
details: [{ type: 'text', text: part.text }],
};
if (part.providerOptions) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import type { LanguageModelV2Prompt } from '@ai-sdk/provider-v5';
import { describe, expect, it } from 'vitest';

import { ProviderHistoryCompat } from '../../../processors/provider-history-compat';
import { AIV5Adapter } from '../adapters/AIV5Adapter';
import { MessageList } from '../index';

const anthropicThinkingPart = {
type: 'reasoning' as const,
text: 'I should preserve this thinking exactly.',
providerOptions: {
anthropic: {
signature: 'sig-anthropic-thinking',
},
},
};

function getAssistantReasoningParts(prompt: LanguageModelV2Prompt) {
return prompt
.filter(message => message.role === 'assistant')
.flatMap(message => (Array.isArray(message.content) ? message.content : []))
.filter((part): part is typeof anthropicThinkingPart => part.type === 'reasoning');
}

describe('Anthropic signed thinking round-trip', () => {
it('persists and replays Anthropic thinking text with its signature', () => {
const dbMessage = AIV5Adapter.fromModelMessage({
id: 'msg-anthropic-thinking',
role: 'assistant',
content: [anthropicThinkingPart, { type: 'text', text: 'Done.' }],
});

expect(dbMessage.content.parts[0]).toMatchObject({
type: 'reasoning',
reasoning: anthropicThinkingPart.text,
details: [{ type: 'text', text: anthropicThinkingPart.text }],
providerMetadata: anthropicThinkingPart.providerOptions,
});

const list = new MessageList();
list.add({ role: 'user', content: 'Think briefly.' }, 'input');
list.add(dbMessage, 'memory');
list.add({ role: 'user', content: 'Continue.' }, 'input');

const reasoning = getAssistantReasoningParts(list.get.all.aiV5.prompt());
expect(reasoning).toHaveLength(1);
expect(reasoning[0]).toEqual(anthropicThinkingPart);
});

it('keeps signed Anthropic thinking valid through multi-turn replay processing', async () => {
const list = new MessageList();
list.add({ role: 'user', content: 'Think briefly.' }, 'input');
list.add(
AIV5Adapter.fromModelMessage({
id: 'msg-anthropic-thinking',
role: 'assistant',
content: [anthropicThinkingPart, { type: 'text', text: 'Done.' }],
}),
'memory',
);
list.add({ role: 'user', content: 'Use that context.' }, 'input');

const prompt = list.get.all.aiV5.prompt();
const result = await new ProviderHistoryCompat().processLLMRequest({
prompt,
model: { provider: 'anthropic.messages' },
stepNumber: 0,
steps: [],
state: {},
retryCount: 0,
abort: (() => {
throw new Error('abort');
}) as any,
});

const processedPrompt = result?.prompt ?? prompt;
expect(getAssistantReasoningParts(processedPrompt)).toEqual([anthropicThinkingPart]);
});

it('does not forward legacy empty Anthropic thinking with a non-empty signature', async () => {
const prompt: LanguageModelV2Prompt = [
{ role: 'user', content: [{ type: 'text', text: 'Hi' }] },
{
role: 'assistant',
content: [
{
type: 'reasoning',
text: '',
providerOptions: { anthropic: { signature: 'sig-without-thinking-text' } },
},
{ type: 'text', text: 'Hello.' },
],
},
];

const result = await new ProviderHistoryCompat().processLLMRequest({
prompt,
model: { provider: 'anthropic.messages' },
stepNumber: 0,
steps: [],
state: {},
retryCount: 0,
abort: (() => {
throw new Error('abort');
}) as any,
});

expect(result).toEqual({ prompt: expect.any(Array) });
const assistant = result!.prompt.find(message => message.role === 'assistant')!;
expect(Array.isArray(assistant.content)).toBe(true);
expect((assistant.content as any[]).map(part => part.type)).toEqual(['text']);
});

it('does not change Gemini provider metadata on non-Anthropic replay', async () => {
const prompt: LanguageModelV2Prompt = [
{
role: 'assistant',
content: [
{
type: 'file',
data: 'data:image/png;base64,abcd',
mediaType: 'image/png',
providerOptions: { google: { thoughtSignature: 'gemini-sig' } },
},
],
},
];

const result = await new ProviderHistoryCompat().processLLMRequest({
prompt,
model: { provider: 'google.generative-ai' },
stepNumber: 0,
steps: [],
state: {},
retryCount: 0,
abort: (() => {
throw new Error('abort');
}) as any,
});

expect(result).toBeUndefined();
expect((prompt[0]!.content as any[])[0].providerOptions.google.thoughtSignature).toBe('gemini-sig');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,22 @@ describe('buildMessagesFromChunks', () => {
});
});

it('should preserve Anthropic signed reasoning text in the primary reasoning field', () => {
const result = parts([
{ type: 'reasoning-start', payload: { id: 'r1' } },
{ type: 'reasoning-delta', payload: { id: 'r1', text: 'Signed ' } },
{ type: 'reasoning-delta', payload: { id: 'r1', text: 'thinking.' } },
{ type: 'reasoning-end', payload: { id: 'r1', providerMetadata: { anthropic: { signature: 'sig' } } } },
]);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
type: 'reasoning',
reasoning: 'Signed thinking.',
details: [{ type: 'text', text: 'Signed thinking.' }],
providerMetadata: { anthropic: { signature: 'sig' } },
});
});

it('should emit empty reasoning parts (needed for OpenAI item_reference)', () => {
const result = parts([
{ type: 'reasoning-start', payload: { id: 'r1' } },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ import { findProviderToolByName, inferProviderExecuted } from '../../../tools/pr
*/
export type CollectedChunk = { type: string; payload: any; metadata?: Record<string, any> };

function hasAnthropicSignature(providerMetadata?: Record<string, any>): boolean {
return typeof providerMetadata?.anthropic?.signature === 'string' && providerMetadata.anthropic.signature.length > 0;
}

function getReasoningDetailText(part: { details: any[] }): string {
return part.details.reduce((text, detail) => (detail?.type === 'text' ? text + (detail.text ?? '') : text), '');
}

/**
* Build MastraDBMessage entries from the full sequence of stream chunks.
*
Expand Down Expand Up @@ -176,6 +184,9 @@ export function buildMessagesFromChunks({
if (p.providerMetadata) {
ref.providerMetadata = p.providerMetadata;
}
if (hasAnthropicSignature(ref.providerMetadata)) {
ref.reasoning = getReasoningDetailText(ref);
}
} else {
// No deltas arrived — emit empty reasoning part.
// OpenAI requires item_reference for tool calls that follow reasoning.
Expand Down
41 changes: 41 additions & 0 deletions packages/core/src/processors/provider-history-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,28 @@ function isAnthropicReasoningPart(part: { providerOptions?: unknown; providerMet
return false;
}

function getProviderMetadataForProvider(metadata: unknown, provider: string): Record<string, unknown> | undefined {
if (!metadata || typeof metadata !== 'object') return undefined;
const value = (metadata as Record<string, unknown>)[provider];
return value && typeof value === 'object' ? (value as Record<string, unknown>) : undefined;
}

function hasAnthropicSignatureWithoutText(part: {
text?: unknown;
providerOptions?: unknown;
providerMetadata?: unknown;
}): boolean {
const anthropic =
getProviderMetadataForProvider(part.providerOptions, 'anthropic') ??
getProviderMetadataForProvider(part.providerMetadata, 'anthropic');

return (
typeof anthropic?.signature === 'string' &&
anthropic.signature.length > 0 &&
(typeof part.text !== 'string' || part.text.length === 0)
);
}

/**
* Cerebras's API rejects assistant messages carrying a `reasoning_content`
* field with HTTP 400 (`property '...reasoning_content' is unsupported`).
Expand Down Expand Up @@ -291,6 +313,20 @@ export const cerebrasStripReasoningContent: CompatRule = {
},
};

/**
* Legacy records could contain Anthropic signed thinking metadata with an
* empty reasoning text. Anthropic signs the exact thinking text, so forwarding
* that mismatched pair is worse than dropping the invalid block at the provider
* boundary.
*/
export const anthropicStripEmptySignedReasoningContent: CompatRule = {
name: 'anthropic-strip-empty-signed-reasoning-content',
applyToPrompt({ prompt, model }) {
if (!isMaybeAnthropic(model)) return undefined;
return stripReasoningFromPrompt(prompt, hasAnthropicSignatureWithoutText);
},
};

/**
* Anthropic accepts its own thinking/reasoning history, but rejects reasoning
* parts emitted by other providers. Strip only foreign reasoning parts at the
Expand All @@ -316,6 +352,7 @@ export const anthropicStripForeignReasoningContent: CompatRule = {
export const DEFAULT_COMPAT_RULES: CompatRule[] = [
anthropicToolIdFormat,
cerebrasStripReasoningContent,
anthropicStripEmptySignedReasoningContent,
anthropicStripForeignReasoningContent,
];

Expand All @@ -340,6 +377,10 @@ export const DEFAULT_COMPAT_RULES: CompatRule[] = [
* that serializes them as `reasoning_content` (a field Cerebras's API
* rejects). Preemptive; runs in `processLLMRequest` so the persisted
* message list keeps the reasoning trace.
* - **anthropic-strip-empty-signed-reasoning-content** — strips legacy
* Anthropic signed reasoning blocks whose text was already lost before
* replay, preventing an empty thinking block with a non-empty signature from
* reaching Anthropic.
* - **anthropic-strip-foreign-reasoning-content** — strips non-Anthropic
* `reasoning` parts from assistant messages in the outbound prompt when the
* resolved model is Anthropic. Anthropic-native reasoning parts are kept.
Expand Down
Loading