From a39827f36abfe900ae326acc4aeeffae3c5c9483 Mon Sep 17 00:00:00 2001 From: Mark Buckaway Date: Fri, 4 Sep 2026 16:50:01 -0400 Subject: [PATCH] fix: stop cross-provider thinking blocks from bricking a session ThinkPart carried a single untagged `encrypted` field that the Anthropic, OpenAI Responses and Google GenAI adapters all wrote mutually incompatible values into. After a mid-session model switch the Anthropic adapter replayed a foreign reasoning blob as its own signature, and the API rejected it: messages.1.content.0: Invalid `signature` in `thinking` block Because the block sat in the first assistant message, every later turn resent it, so the session failed identically forever and could not be recovered by retrying, forking or switching Claude models. Tag the blob with the protocol that produced it and emit it only to that protocol, falling through to the existing unsigned branch otherwise. An untagged blob is still treated as compatible, so stored sessions keep their current behaviour rather than relying on format guesswork. Sessions poisoned before this change recover at runtime instead. Classify the rejection with isThinkingSignatureError, add a `thinking: 'strip'` projection axis that removes every thinking and redacted_thinking block from the history while leaving text and tool calls in place, and resend once. The recovery is recorded as a durable event and folded into replayable agent state, so a session pays the error at most once across later turns and reloads. A total strip is the documented remedy; partial stripping is what triggers the separate "blocks in the latest assistant message cannot be modified" error. Verified against the live API on both an adaptive and an extended-thinking model that a total strip is accepted even when the latest assistant turn carried thinking alongside a tool call. The legacy engine gets the same rung through a message builder, but keeps its recovery flags in runTurn locals and has no durable state, so a v1 session re-pays one rejection per turn. --- .changeset/thinking-signature-recovery.md | 5 + .../agent-core-v2/docs/state-manifest.d.ts | 7 +- .../agent-core-v2/docs/wire-manifest.d.ts | 15 +- .../contextProjector/contextProjector.ts | 1 + .../contextProjectorService.ts | 8 +- .../src/agent/contextProjector/projection.ts | 30 +- .../src/agent/llmRequester/llmRequestOps.ts | 37 +- .../agent/llmRequester/llmRequesterService.ts | 99 +++- .../src/kosong/contract/errors.ts | 13 + .../src/kosong/contract/message.ts | 11 + .../provider/bases/anthropic/anthropic.ts | 23 +- .../bases/google-genai/google-genai.ts | 8 +- .../provider/bases/openai/openai-responses.ts | 29 +- .../projector-tool-exchanges.test.ts | 238 ++++++++++ .../llmRequesterMediaStrip.test.ts | 31 +- .../llmRequester/llmRequesterService.test.ts | 262 ++++++++++- .../test/app/llmProtocol/errors.test.ts | 115 +++++ packages/agent-core-v2/test/index.test.ts | 1 + .../test/kosong/provider/composition.test.ts | 286 ++++++++++- .../test/state/builtinReplayableKeys.ts | 6 +- .../agent-core/src/agent/context/projector.ts | 97 +++- .../agent-core/src/agent/records/types.ts | 5 +- packages/agent-core/src/agent/turn/index.ts | 7 + packages/agent-core/src/loop/llm.ts | 8 +- packages/agent-core/src/loop/run-turn.ts | 111 ++++- packages/agent-core/src/loop/turn-step.ts | 85 +++- .../test/agent/context/projector.test.ts | 221 +++++++++ .../loop/tool-exchange-fallback.e2e.test.ts | 445 ++++++++++++++++++ packages/kosong/src/errors.ts | 30 ++ packages/kosong/src/index.ts | 3 + packages/kosong/src/message.ts | 50 +- packages/kosong/src/providers/anthropic.ts | 31 +- packages/kosong/src/providers/google-genai.ts | 11 +- .../kosong/src/providers/openai-responses.ts | 35 +- packages/kosong/test/anthropic.test.ts | 261 +++++++++- packages/kosong/test/errors.test.ts | 115 +++++ packages/kosong/test/google-genai.test.ts | 121 ++++- packages/kosong/test/message.test.ts | 186 ++++++++ packages/kosong/test/openai-responses.test.ts | 313 +++++++++++- 39 files changed, 3244 insertions(+), 116 deletions(-) create mode 100644 .changeset/thinking-signature-recovery.md diff --git a/.changeset/thinking-signature-recovery.md b/.changeset/thinking-signature-recovery.md new file mode 100644 index 00000000000..92e47aae6c2 --- /dev/null +++ b/.changeset/thinking-signature-recovery.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix conversations breaking after switching between models from different providers mid-session. diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 855528a25f0..a8ee43b0f3d 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -27,7 +27,7 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 80 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 81 keys) // App // Workspace // workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts @@ -71,6 +71,7 @@ // llmRequester.lastConfigLogSignature src/agent/llmRequester/llmRequesterService.ts // llmRequester.mediaDegradedTurns src/agent/llmRequester/llmRequesterService.ts // llmRequester.mediaStrippedTurns src/agent/llmRequester/llmRequesterService.ts +// llmRequester.thinkingStripped src/agent/llmRequester/llmRequesterService.ts // llmRequester.turnConfigs src/agent/llmRequester/llmRequesterService.ts // loop.disposing src/agent/loop/loopService.ts // loop.lastRequestTraceId src/agent/loop/loopService.ts @@ -1051,6 +1052,7 @@ export interface AgentStateSnapshot { type: 'think'; think: string; encrypted?: string; + encryptedProtocol?: 'anthropic' | 'openai' | 'openai_responses' | 'google-genai'; } | /* ImageURLPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { type: 'image_url'; imageUrl: { @@ -1197,6 +1199,8 @@ export interface AgentStateSnapshot { 'llmRequester.mediaDegradedTurns': Set; // replayable · durable — folds: MediaStripped 'llmRequester.mediaStrippedTurns': readonly string[]; + // replayable · durable — folds: ThinkingStripped + 'llmRequester.thinkingStripped': boolean; 'llmRequester.turnConfigs': Map part.type !== 'think'), + }; + if (isWireSendableMessage(stripped)) out.push(stripped); + } + return out; +} + interface SliceLayout { readonly sizing: boolean; readonly lastNonToolIndex: number; @@ -416,11 +433,22 @@ function wireSendableContent(content: readonly ContentPart[]): ContentPart[] { return content.filter((part) => part.type !== 'think' || part.encrypted !== undefined); } +function hasThinkPart(message: Message): boolean { + return message.content.some((part) => part.type === 'think'); +} + +function isWireSendableMessage(message: Message): boolean { + if (message.role === 'tool') return true; + if (message.toolCalls.length > 0) return true; + if (hasDeclaredTools(message)) return true; + return !message.content.every(isVacuousContentPart); +} + function canMergeUserMessage(message: ContextMessage): boolean { return message.role === 'user' && message.origin?.kind === 'user'; } -function hasDeclaredTools(message: ContextMessage): boolean { +function hasDeclaredTools(message: Message): boolean { return message.tools !== undefined && message.tools.length > 0; } diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts index b8f54f03f6b..1c805b034ab 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts @@ -38,6 +38,20 @@ export interface LlmToolsSnapshot { readonly tools: readonly LlmRequestToolSchema[]; } +export const llmRequestProjectionSchema = z.enum([ + 'strict', + 'media-degraded', + 'media-stripped', + 'strict-media-degraded', + 'strict-media-stripped', + 'thinking-stripped', + 'strict-thinking-stripped', + 'media-degraded-thinking-stripped', + 'media-stripped-thinking-stripped', + 'strict-media-degraded-thinking-stripped', + 'strict-media-stripped-thinking-stripped', +]); + const llmRequestSchema = z.object({ agentId: z.string(), kind: z.enum(['loop', 'compaction']), @@ -57,7 +71,7 @@ const llmRequestSchema = z.object({ messageCount: z.number(), turnStep: z.string().optional(), attempt: z.string().optional(), - projection: z.enum(['strict', 'media-degraded', 'media-stripped', 'strict-media-degraded', 'strict-media-stripped']).optional(), + projection: llmRequestProjectionSchema.optional(), droppedCount: z.number().optional(), }); @@ -92,7 +106,13 @@ export interface LlmRequest { | 'media-degraded' | 'media-stripped' | 'strict-media-degraded' - | 'strict-media-stripped'; + | 'strict-media-stripped' + | 'thinking-stripped' + | 'strict-thinking-stripped' + | 'media-degraded-thinking-stripped' + | 'media-stripped-thinking-stripped' + | 'strict-media-degraded-thinking-stripped' + | 'strict-media-stripped-thinking-stripped'; readonly droppedCount?: number; } @@ -111,6 +131,19 @@ export interface MediaStripped { readonly keys: readonly string[]; } +const thinkingStrippedSchema = z.object({ + agentId: z.string(), +}); + +export class ThinkingStripped extends AgentEvent2> { + static override readonly type = 'llm.thinking_stripped'; + static override readonly durable = true; + static override readonly schema = thinkingStrippedSchema; +} +export interface ThinkingStripped { + readonly agentId: string; +} + export const llmRequestTraceKey = defineState( 'llm.requestTrace', (): LlmRequestTraceState => ({ seenToolsHashes: [] }), diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 13527c287f5..1ddfd2738b0 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -29,6 +29,7 @@ import { isImageFormatError, isRecoverableRequestStructureError, isRetryableGenerateError, + isThinkingSignatureError, } from '#/kosong/contract/errors'; import { isToolCall, type Message, type StreamedMessagePart } from '#/kosong/contract/message'; import { type ThinkingEffort } from '#/kosong/contract/provider'; @@ -72,9 +73,11 @@ import { } from './toolCallIdNormalizer'; import { LlmRequest, + llmRequestProjectionSchema, llmRequestTraceKey, LlmToolsSnapshot, MediaStripped, + ThinkingStripped, type LlmRequestPayload, type LlmRequestToolSchema, } from './llmRequestOps'; @@ -154,6 +157,12 @@ export const llmRequesterMediaStrippedTurnsKey = defineState( } } }); +export const llmRequesterThinkingStrippedKey = defineState( + 'llmRequester.thinkingStripped', + (): boolean => false, +) + .replayable({ schema: z.boolean() }) + .on(ThinkingStripped, () => true); export const llmRequesterEmittedThinkingEffortWarningsKey = defineState>( 'llmRequester.emittedThinkingEffortWarnings', () => new Set(), @@ -188,6 +197,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { this.states.contributeState(llmRequesterTurnConfigsKey); this.states.contributeState(llmRequesterMediaDegradedTurnsKey); this.states.contributeState(llmRequesterMediaStrippedTurnsKey); + this.states.contributeState(llmRequesterThinkingStrippedKey); this.states.contributeState(llmRequesterEmittedThinkingEffortWarningsKey); } @@ -330,12 +340,15 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { this.toolCallIdNormalizer.seedFrom(this.context.get()); const shaped = this.toolSelect.shapeHistory(request.messages); const recoveredStrip = this.mediaStripSnapshotForTurn(request.source); - let policy: ProjectionPolicy | undefined = + const recoveredMedia: ProjectionPolicy | undefined = recoveredStrip !== undefined ? { media: { strip: recoveredStrip } } : this.isRecoveryTurn(this.mediaDegradedTurns, request.source) ? { media: 'degraded' } : undefined; + let policy: ProjectionPolicy | undefined = this.thinkingStrippedForTurn(request.source) + ? { ...recoveredMedia, thinking: 'strip' } + : recoveredMedia; const captureMediaStripPolicy = (): { readonly strip: MediaStripSnapshot } => { const snapshot = this.projector.captureMediaStripSnapshot(shaped); this.markMediaStrippedRecoveryTurn(snapshot, request.source); @@ -551,6 +564,27 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { }); return { ...policy, structure: 'strict' }; } + if (isThinkingSignatureError(raw)) { + if (policy?.thinking === undefined) { + signal?.throwIfAborted(); + this.log.warn( + 'provider rejected a thinking block signature; resending with thinking stripped', + { + model: request.model.name, + ...request.logFields, + }, + ); + this.markThinkingStrippedRecovery(request.source); + return { ...policy, thinking: 'strip' }; + } + this.log.warn( + 'provider still rejects thinking blocks after a full thinking strip; no projection recovery left', + { + model: request.model.name, + ...request.logFields, + }, + ); + } return undefined; } @@ -622,6 +656,16 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { ); } + private thinkingStrippedForTurn(_source: AgentLLMRequestSource | undefined): boolean { + return this.states.get(llmRequesterThinkingStrippedKey); + } + + private markThinkingStrippedRecovery(_source: AgentLLMRequestSource | undefined): void { + void this.dispatcher.dispatch( + new ThinkingStripped({ agentId: this.scopeContext.agentId }), + ); + } + private markRecoveryTurn(set: Set, source: AgentLLMRequestSource | undefined): void { if (source?.type !== 'turn') return; for (const id of set) { @@ -853,30 +897,45 @@ function numberField(fields: AgentLLMRequestLogFields, key: string): number | un type LlmRequestProjection = NonNullable; +type ProjectionStructureAxis = 'default' | 'strict'; +type ProjectionMediaAxis = 'default' | 'media-degraded' | 'media-stripped'; +type ProjectionThinkingAxis = 'default' | 'thinking-stripped'; + +const PROJECTION_NAMES = { + 'default|default|default': undefined, + 'default|default|thinking-stripped': 'thinking-stripped', + 'default|media-degraded|default': 'media-degraded', + 'default|media-degraded|thinking-stripped': 'media-degraded-thinking-stripped', + 'default|media-stripped|default': 'media-stripped', + 'default|media-stripped|thinking-stripped': 'media-stripped-thinking-stripped', + 'strict|default|default': 'strict', + 'strict|default|thinking-stripped': 'strict-thinking-stripped', + 'strict|media-degraded|default': 'strict-media-degraded', + 'strict|media-degraded|thinking-stripped': 'strict-media-degraded-thinking-stripped', + 'strict|media-stripped|default': 'strict-media-stripped', + 'strict|media-stripped|thinking-stripped': 'strict-media-stripped-thinking-stripped', +} as const satisfies Record< + `${ProjectionStructureAxis}|${ProjectionMediaAxis}|${ProjectionThinkingAxis}`, + LlmRequestProjection | undefined +>; + function projectionNameOf(policy: ProjectionPolicy | undefined): LlmRequestProjection | undefined { - if (policy?.structure === 'strict') { - if (policy.media === 'degraded') return 'strict-media-degraded'; - if (typeof policy.media === 'object') return 'strict-media-stripped'; - return 'strict'; - } if (policy === undefined) return undefined; - if (policy.media === 'degraded') return 'media-degraded'; - if (typeof policy.media === 'object') return 'media-stripped'; - return undefined; + const structure: ProjectionStructureAxis = policy.structure === 'strict' ? 'strict' : 'default'; + const media: ProjectionMediaAxis = + policy.media === 'degraded' + ? 'media-degraded' + : typeof policy.media === 'object' + ? 'media-stripped' + : 'default'; + const thinking: ProjectionThinkingAxis = + policy.thinking === 'strip' ? 'thinking-stripped' : 'default'; + return PROJECTION_NAMES[`${structure}|${media}|${thinking}`]; } function projectionField(fields: AgentLLMRequestLogFields): LlmRequestProjection | undefined { - const value = fields['projection']; - switch (value) { - case 'strict': - case 'media-degraded': - case 'media-stripped': - case 'strict-media-degraded': - case 'strict-media-stripped': - return value; - default: - return undefined; - } + const parsed = llmRequestProjectionSchema.safeParse(fields['projection']); + return parsed.success ? parsed.data : undefined; } function fingerprint(content: string): string { diff --git a/packages/agent-core-v2/src/kosong/contract/errors.ts b/packages/agent-core-v2/src/kosong/contract/errors.ts index 01dba0d470b..2b22ec8ad31 100644 --- a/packages/agent-core-v2/src/kosong/contract/errors.ts +++ b/packages/agent-core-v2/src/kosong/contract/errors.ts @@ -450,6 +450,19 @@ export function isRecoverableRequestStructureError(error: unknown): boolean { return STRUCTURAL_REQUEST_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); } +const THINKING_BLOCK_MESSAGE_PATTERNS = [ + /invalid\s+['"`]?signature['"`]?\s+in\s+['"`]?thinking['"`]?\s+block/, + /thinking[\s\S]*blocks in the latest assistant message cannot be modified/, +] as const; + +export function isThinkingSignatureError(error: unknown): boolean { + if (!(error instanceof APIStatusError)) return false; + if (error instanceof APIContextOverflowError) return false; + if (error.statusCode !== 400 && error.statusCode !== 422) return false; + const lowerMessage = error.message.toLowerCase(); + return THINKING_BLOCK_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); +} + export function isProviderRateLimitError(error: unknown): boolean { if (error instanceof APIProviderQuotaExhaustedError) return false; if (error instanceof APIProviderRateLimitError) return true; diff --git a/packages/agent-core-v2/src/kosong/contract/message.ts b/packages/agent-core-v2/src/kosong/contract/message.ts index aeee9140207..ba4611dabdd 100644 --- a/packages/agent-core-v2/src/kosong/contract/message.ts +++ b/packages/agent-core-v2/src/kosong/contract/message.ts @@ -1,3 +1,5 @@ +import type { Protocol } from '#/kosong/protocol/protocol'; + import type { Tool } from './tool'; export type Role = 'system' | 'user' | 'assistant' | 'tool'; @@ -11,6 +13,14 @@ export interface ThinkPart { type: 'think'; think: string; encrypted?: string; + encryptedProtocol?: Protocol; +} + +export function encryptedForProtocol(part: ThinkPart, protocol: Protocol): string | undefined { + if (part.encryptedProtocol !== undefined && part.encryptedProtocol !== protocol) { + return undefined; + } + return part.encrypted; } export interface ImageURLPart { @@ -104,6 +114,7 @@ export function mergeInPlace(target: StreamedMessagePart, source: StreamedMessag target.think += source.think; if (source.encrypted !== undefined) { target.encrypted = source.encrypted; + target.encryptedProtocol = source.encryptedProtocol; } return true; } diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts index 77576d00fcc..c4993eae487 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts @@ -35,7 +35,7 @@ import type { StreamedMessagePart, ToolCall, } from '#/kosong/contract/message'; -import { isToolDeclarationOnlyMessage } from '#/kosong/contract/message'; +import { encryptedForProtocol, isToolDeclarationOnlyMessage } from '#/kosong/contract/message'; import type { ChatProvider, FinishReason, @@ -436,11 +436,12 @@ function convertMessage(message: Message, model: string): MessageParam { } else if (part.type === 'image_url') { blocks.push(imageUrlPartToAnthropic(part.imageUrl.url) as unknown as ContentBlockParam); } else if (part.type === 'think') { - if (part.encrypted !== undefined) { + const signature = encryptedForProtocol(part, 'anthropic'); + if (signature !== undefined) { blocks.push({ type: 'thinking', thinking: part.think, - signature: part.encrypted, + signature, } satisfies ThinkingBlockParam); } else if (shouldPreserveUnsignedThinking(model)) { blocks.push({ type: 'thinking', thinking: part.think } as unknown as ThinkingBlockParam); @@ -644,12 +645,22 @@ class AnthropicStreamedMessage implements StreamedMessage { break; case 'thinking': yield block.signature !== undefined - ? { type: 'think' as const, think: block.thinking ?? '', encrypted: block.signature } + ? { + type: 'think' as const, + think: block.thinking ?? '', + encrypted: block.signature, + encryptedProtocol: 'anthropic' as const, + } : { type: 'think' as const, think: block.thinking ?? '' }; break; case 'redacted_thinking': yield block.data !== undefined - ? { type: 'think' as const, think: '', encrypted: block.data } + ? { + type: 'think' as const, + think: '', + encrypted: block.data, + encryptedProtocol: 'anthropic' as const, + } : { type: 'think' as const, think: '' }; break; case 'tool_use': @@ -701,6 +712,7 @@ class AnthropicStreamedMessage implements StreamedMessage { type: 'think', think: '', encrypted: (block as unknown as { data: string }).data, + encryptedProtocol: 'anthropic', }; break; case 'tool_use': @@ -737,6 +749,7 @@ class AnthropicStreamedMessage implements StreamedMessage { type: 'think', think: '', encrypted: delta.signature, + encryptedProtocol: 'anthropic', }; break; } diff --git a/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts b/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts index b6bc1d5c0b1..adfabfd41fb 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts @@ -7,7 +7,7 @@ import { normalizeAPIStatusError, } from '#/kosong/contract/errors'; import type { Message, StreamedMessagePart, ThinkPart, ToolCall } from '#/kosong/contract/message'; -import { isToolDeclarationOnlyMessage } from '#/kosong/contract/message'; +import { encryptedForProtocol, isToolDeclarationOnlyMessage } from '#/kosong/contract/message'; import type { ChatProvider, FinishReason, @@ -225,8 +225,9 @@ function messageToGoogleGenAI(message: Message): GoogleContent { break; case 'think': { const thoughtPart: GooglePart = { text: part.think, thought: true }; - if (part.encrypted !== undefined && part.encrypted.length > 0) { - thoughtPart.thoughtSignature = part.encrypted; + const thoughtSignature = encryptedForProtocol(part, 'google-genai'); + if (thoughtSignature !== undefined && thoughtSignature.length > 0) { + thoughtPart.thoughtSignature = thoughtSignature; } parts.push(thoughtPart); break; @@ -516,6 +517,7 @@ export class GoogleGenAIStreamedMessage implements StreamedMessage { const thinkPart: ThinkPart = { type: 'think', think: p['text'] }; if (typeof thoughtSignature === 'string' && thoughtSignature.length > 0) { thinkPart.encrypted = thoughtSignature; + thinkPart.encryptedProtocol = 'google-genai'; } parts.push(thinkPart); } else if (p['text']) { diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts index 19808bc2f1e..2b8c249bd31 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts @@ -12,9 +12,14 @@ import type { ContentPart, Message, StreamedMessagePart, + ThinkPart, ToolCall, } from '#/kosong/contract/message'; -import { extractText, isToolDeclarationOnlyMessage } from '#/kosong/contract/message'; +import { + encryptedForProtocol, + extractText, + isToolDeclarationOnlyMessage, +} from '#/kosong/contract/message'; import type { ChatProvider, FinishReason, @@ -583,14 +588,17 @@ function convertMessage( if (part === undefined) break; if (part.type === 'think') { flushPendingParts(); - const encryptedValue = part.encrypted; + const encryptedRaw = part.encrypted; + const encryptedTag = part.encryptedProtocol; + const encryptedValue = encryptedForProtocol(part, 'openai_responses'); const summaries: unknown[] = [{ type: 'summary_text', text: part.think }]; i += 1; while (i < n) { const nextPart = message.content[i]; if (nextPart === undefined) break; if (nextPart.type !== 'think') break; - if (nextPart.encrypted !== encryptedValue) break; + if (nextPart.encrypted !== encryptedRaw) break; + if (nextPart.encryptedProtocol !== encryptedTag) break; summaries.push({ type: 'summary_text', text: nextPart.think }); i += 1; } @@ -766,19 +774,21 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { const text = readStringField(summary, 'text'); if (text === undefined) continue; hasReasoningSummary = true; - const thinkPart: StreamedMessagePart = { + const thinkPart: ThinkPart = { type: 'think', think: text, }; if (outputItem.encryptedContent !== undefined) { - (thinkPart as { encrypted: string }).encrypted = outputItem.encryptedContent; + thinkPart.encrypted = outputItem.encryptedContent; + thinkPart.encryptedProtocol = 'openai_responses'; } yield thinkPart; } if (!hasReasoningSummary) { - const thinkPart: StreamedMessagePart = { type: 'think', think: '' }; + const thinkPart: ThinkPart = { type: 'think', think: '' }; if (outputItem.encryptedContent !== undefined) { - (thinkPart as { encrypted: string }).encrypted = outputItem.encryptedContent; + thinkPart.encrypted = outputItem.encryptedContent; + thinkPart.encryptedProtocol = 'openai_responses'; } yield thinkPart; } @@ -917,9 +927,10 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { const item = readResponseOutputItem(chunk['item'], `${type}.item`); const outputIndex = readNumberField(chunk, 'output_index'); if (item.type === 'reasoning') { - const thinkPart: StreamedMessagePart = { type: 'think', think: '' }; + const thinkPart: ThinkPart = { type: 'think', think: '' }; if (item.encryptedContent !== undefined) { - (thinkPart as { encrypted: string }).encrypted = item.encryptedContent; + thinkPart.encrypted = item.encryptedContent; + thinkPart.encryptedProtocol = 'openai_responses'; } yield thinkPart; } else if (item.type === 'function_call' && typeof item.arguments === 'string') { diff --git a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts index 82d976528f8..a452a8835c1 100644 --- a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts +++ b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts @@ -662,6 +662,244 @@ describe('projector tool-exchange normalization', () => { }); }); + describe('thinking strip projection', () => { + function thinkingAssistant( + content: ContextMessage['content'], + toolCallIds: readonly string[] = [], + ): ContextMessage { + return { + role: 'assistant', + content: [...content], + toolCalls: toolCallIds.map((id) => ({ + type: 'function', + id, + name: 'Lookup', + arguments: '{}', + })), + }; + } + + function imageMessage(url: string): ContextMessage { + return { + role: 'user', + content: [{ type: 'image_url', imageUrl: { url } }], + toolCalls: [], + origin: { kind: 'user' }, + }; + } + + function projectStripped(history: readonly ContextMessage[]): readonly Message[] { + return projector.project(history, { thinking: 'strip' }); + } + + function roles(messages: readonly Message[]): string[] { + return messages.map((message) => + message.role === 'tool' ? `tool:${message.toolCallId}` : message.role, + ); + } + + it('drops every think part from every message, signed and unsigned alike', () => { + const history = [ + user('u1'), + thinkingAssistant([ + { type: 'think', think: 'unsigned reasoning' }, + { type: 'text', text: 'first answer' }, + ]), + user('u2'), + thinkingAssistant([ + { type: 'think', think: 'signed reasoning', encrypted: 'sig' }, + { type: 'text', text: 'second answer' }, + ]), + ]; + + const projected = projectStripped(history); + + expect(projected.map((message) => message.content)).toEqual([ + [{ type: 'text', text: 'u1' }], + [{ type: 'text', text: 'first answer' }], + [{ type: 'text', text: 'u2' }], + [{ type: 'text', text: 'second answer' }], + ]); + }); + + it('keeps text parts and tool calls untouched', () => { + const history = [ + user('go'), + thinkingAssistant( + [ + { type: 'think', think: 'planning', encrypted: 'sig' }, + { type: 'text', text: 'calling the tool' }, + ], + ['c1'], + ), + toolResult('c1', 'one'), + ]; + + const projected = projectStripped(history); + + expect(roles(projected)).toEqual(['user', 'assistant', 'tool:c1']); + expect(projected[1]?.content).toEqual([{ type: 'text', text: 'calling the tool' }]); + expect(projected[1]?.toolCalls).toEqual([ + { type: 'function', id: 'c1', name: 'Lookup', arguments: '{}' }, + ]); + expect(projected[2]?.content).toEqual([{ type: 'text', text: 'one' }]); + }); + + it('drops a message left with nothing sendable at the projector', () => { + const history = [ + user('u1'), + thinkingAssistant([{ type: 'think', think: 'signed reasoning', encrypted: 'sig' }]), + ]; + + const projected = projectStripped(history); + + expect(roles(projected)).toEqual(['user']); + expect(projected.every((message) => message.content.length > 0)).toBe(true); + }); + + it('keeps a stripped assistant that still carries tool calls so its result stays paired', () => { + const history = [ + user('go'), + thinkingAssistant([{ type: 'think', think: 'only reasoning', encrypted: 'sig' }], ['c1']), + toolResult('c1', 'one'), + ]; + + const projected = projectStripped(history); + + expect(roles(projected)).toEqual(['user', 'assistant', 'tool:c1']); + expect(projected[1]?.content).toEqual([]); + expect(projected[1]?.toolCalls).toEqual([ + { type: 'function', id: 'c1', name: 'Lookup', arguments: '{}' }, + ]); + }); + + it('never drops a tool result whose only part was a think block', () => { + const history: ContextMessage[] = [ + user('go'), + assistant('', ['c1']), + { + role: 'tool', + content: [{ type: 'think', think: 'tool-side reasoning', encrypted: 'sig' }], + toolCalls: [], + toolCallId: 'c1', + }, + ]; + + const projected = projectStripped(history); + + expect(roles(projected)).toEqual(['user', 'assistant', 'tool:c1']); + expect(projected[2]?.content).toEqual([]); + }); + + it('keeps a schema-only message the strip empties because it still declares tools', () => { + const schema = schemaMessage('Lookup'); + const history: ContextMessage[] = [ + user('u1'), + { ...schema, content: [{ type: 'think', think: 'schema reasoning', encrypted: 'sig' }] }, + ]; + + const projected = projectStripped(history); + + expect(roles(projected)).toEqual(['user', 'system']); + expect(projected[1]?.content).toEqual([]); + expect(projected[1]?.tools).toEqual(schema.tools); + }); + + it('leaves the projection unchanged when the thinking policy is absent', () => { + const history = [ + user('u1'), + thinkingAssistant([ + { type: 'think', think: 'unsigned reasoning' }, + { type: 'text', text: 'answer' }, + ]), + thinkingAssistant([{ type: 'think', think: '', encrypted: 'sig' }]), + ]; + + const baseline = projector.project(history); + + expect(baseline.map((message) => message.content)).toEqual([ + [{ type: 'text', text: 'u1' }], + [ + { type: 'think', think: 'unsigned reasoning' }, + { type: 'text', text: 'answer' }, + ], + [{ type: 'think', think: '', encrypted: 'sig' }], + ]); + expect(projector.project(history, { thinking: undefined })).toEqual(baseline); + }); + + it('leaves a history without think parts identical to the default projection', () => { + const history = [user('go'), assistant('', ['c1']), toolResult('c1', 'one'), user('next')]; + + expect(projectStripped(history)).toEqual(project(history)); + }); + + it('projects an empty history to an empty result', () => { + expect(projectStripped([])).toEqual([]); + }); + + it('does not report the strip as a projection repair', () => { + const history = [ + user('u1'), + thinkingAssistant([{ type: 'think', think: 'signed reasoning', encrypted: 'sig' }]), + ]; + + projectStripped(history); + + expect(repairPayloads(warnings)).toEqual([]); + expect(telemetryRecords).toEqual([]); + }); + + it('strips after structure: strict has merged consecutive assistants', () => { + const history = [ + user('u1'), + thinkingAssistant([{ type: 'think', think: 'signed reasoning', encrypted: 'sig' }]), + thinkingAssistant([{ type: 'text', text: 'answer' }]), + ]; + + const projected = projector.project(history, { structure: 'strict', thinking: 'strip' }); + + expect(roles(projected)).toEqual(['user', 'assistant']); + expect(projected[1]?.content).toEqual([{ type: 'text', text: 'answer' }]); + }); + + it('drops a strict-projected assistant the strip empties, keeping the head user message', () => { + const history = [ + user('u1'), + thinkingAssistant([{ type: 'think', think: 'signed reasoning', encrypted: 'sig' }]), + user('u2'), + ]; + + const projected = projector.project(history, { structure: 'strict', thinking: 'strip' }); + + expect(roles(projected)).toEqual(['user', 'user']); + expect(projected.map((message) => message.content)).toEqual([ + [{ type: 'text', text: 'u1' }], + [{ type: 'text', text: 'u2' }], + ]); + }); + + it('composes with the media axis, stripping think parts and degrading older media', () => { + const history = [ + imageMessage('data:image/png;base64,OLD1'), + thinkingAssistant([ + { type: 'think', think: 'signed reasoning', encrypted: 'sig' }, + { type: 'text', text: 'looking' }, + ]), + imageMessage('data:image/png;base64,KEEP1'), + imageMessage('data:image/png;base64,KEEP2'), + ]; + + const projected = projector.project(history, { thinking: 'strip', media: 'degraded' }); + const parts = projected.flatMap((message) => message.content); + + expect(parts.some((part) => part.type === 'think')).toBe(false); + expect( + parts.filter((part) => part.type === 'image_url').map((part) => part.imageUrl.url), + ).toEqual(['data:image/png;base64,KEEP1', 'data:image/png;base64,KEEP2']); + }); + }); + describe('project with media: degraded policy', () => { function imageMessage(url: string): ContextMessage { return { diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterMediaStrip.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterMediaStrip.test.ts index c7344a58634..1aca335505c 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterMediaStrip.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterMediaStrip.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { llmRequesterMediaStrippedTurnsKey } from '#/agent/llmRequester/llmRequesterService'; -import { MediaStripped } from '#/agent/llmRequester/llmRequestOps'; +import { + llmRequesterMediaStrippedTurnsKey, + llmRequesterThinkingStrippedKey, +} from '#/agent/llmRequester/llmRequesterService'; +import { MediaStripped, ThinkingStripped } from '#/agent/llmRequester/llmRequestOps'; import { testAgent } from '../../harness'; describe('llmRequester media-strip durability', () => { @@ -23,3 +26,27 @@ describe('llmRequester media-strip durability', () => { } }); }); + +describe('llmRequester thinking-strip durability', () => { + it('leaves the stripped-thinking flag false when no ThinkingStripped event was persisted', async () => { + const ctx = testAgent({ autoConfigure: false }); + try { + await ctx.restorePersisted(); + expect(ctx.agentState.get(llmRequesterThinkingStrippedKey)).toBe(false); + } finally { + await ctx.dispose(); + } + }); + + it('reconstructs the stripped-thinking flag from a persisted ThinkingStripped event', async () => { + const ctx = testAgent({ autoConfigure: false }); + const agentId = ctx.get(IAgentScopeContext).agentId; + try { + await ctx.dispatcher.dispatch(new ThinkingStripped({ agentId })); + await ctx.restorePersisted(); + expect(ctx.agentState.get(llmRequesterThinkingStrippedKey)).toBe(true); + } finally { + await ctx.dispose(); + } + }); +}); diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts index f260522fac6..a448ae1a103 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts @@ -211,7 +211,13 @@ function createService( const config: Partial = { get: (() => undefined) as IConfigService['get'], }; - const log = { info: () => undefined, warn: () => undefined }; + const logWarnings: string[] = []; + const log = { + info: () => undefined, + warn: (message: string) => { + logWarnings.push(message); + }, + }; const telemetryRecords: TelemetryRecord[] = []; const telemetry = recordingTelemetry(telemetryRecords); const toolSelect: Partial = { @@ -273,6 +279,7 @@ function createService( events, telemetryRecords, measuredCalls, + logWarnings, }; } @@ -779,6 +786,259 @@ describe('AgentLLMRequesterService combined recovery projections', () => { }); }); +describe('AgentLLMRequesterService thinking-stripped resend', () => { + const THINKING_SIGNATURE_400 = new APIStatusError( + 400, + 'messages.1.content.0: Invalid `signature` in `thinking` block', + ); + const THINKING_CONFIG_400 = new APIStatusError( + 400, + '"thinking.type.enabled" is not supported for this model. Use "thinking.type.adaptive" and "output_config.effort" to control thinking behavior.', + ); + const STRUCTURAL_400 = new APIStatusError(400, 'messages: `tool_use` ids must be unique'); + const IMAGE_FORMAT_400 = new APIStatusError( + 400, + 'unsupported image format: image/avif is not supported', + ); + const BODY_TOO_LARGE_413 = new APIRequestTooLargeError(413, 'Request Entity Too Large'); + + function recordPolicies( + policies: (ProjectionPolicy | undefined)[], + ): Pick { + return { + project: (messages: readonly ContextMessage[], policy) => { + policies.push(policy); + return messages; + }, + }; + } + + function projectionLabels(records: readonly WireRecord[]): unknown[] { + return records + .filter((record) => record.type === 'llm.request') + .map((record) => record['projection']); + } + + it('resends once with the thinking stripped after a thinking-signature 400', async () => { + const calls = { value: 0 }; + const policies: (ProjectionPolicy | undefined)[] = []; + const { service } = createService( + createRequester(calls, THINKING_SIGNATURE_400), + recordPolicies(policies), + ); + + const result = await service.request(); + + expect(result.message.content).toEqual([{ type: 'text', text: 'ok' }]); + expect(calls.value).toBe(2); + expect(policies).toEqual([undefined, { thinking: 'strip' }]); + }); + + it('records the thinking-stripped projection label on the resent request', async () => { + const calls = { value: 0 }; + const { service, dispatcher, records } = createService( + createRequester(calls, THINKING_SIGNATURE_400), + recordPolicies([]), + ); + + await service.request(); + await dispatcher.flush(); + + expect(projectionLabels(records)).toEqual([undefined, 'thinking-stripped']); + }); + + it('persists the recovery as a durable llm.thinking_stripped record', async () => { + const calls = { value: 0 }; + const { service, dispatcher, records } = createService( + createRequester(calls, THINKING_SIGNATURE_400), + recordPolicies([]), + ); + + await service.request(); + await dispatcher.flush(); + + expect(records.filter((record) => record.type === 'llm.thinking_stripped')).toHaveLength(1); + }); + + it('keeps a later turn on the thinking-stripped projection after an earlier recovery', async () => { + const calls = { value: 0 }; + const policies: (ProjectionPolicy | undefined)[] = []; + const { service } = createService( + createRequester(calls, THINKING_SIGNATURE_400), + recordPolicies(policies), + ); + + await service.request({ source: { type: 'turn', turnId: 1, step: 1 } }); + await service.request({ source: { type: 'turn', turnId: 2, step: 1 } }); + + expect(calls.value).toBe(3); + expect(policies).toEqual([undefined, { thinking: 'strip' }, { thinking: 'strip' }]); + }); + + it('stops after the thinking-stripped resend also fails with a thinking-signature 400', async () => { + const calls = { value: 0 }; + const policies: (ProjectionPolicy | undefined)[] = []; + const { service } = createService( + createRequester(calls, THINKING_SIGNATURE_400, [THINKING_SIGNATURE_400]), + recordPolicies(policies), + ); + + await expect(service.request()).rejects.toBe(THINKING_SIGNATURE_400); + expect(calls.value).toBe(2); + expect(policies).toEqual([undefined, { thinking: 'strip' }]); + }); + + it('logs the terminal case when the thinking-stripped resend still fails', async () => { + const calls = { value: 0 }; + const { service, logWarnings } = createService( + createRequester(calls, THINKING_SIGNATURE_400, [THINKING_SIGNATURE_400]), + recordPolicies([]), + ); + + await expect(service.request()).rejects.toBe(THINKING_SIGNATURE_400); + expect(logWarnings).toContain( + 'provider still rejects thinking blocks after a full thinking strip; no projection recovery left', + ); + }); + + it('does not resend for a thinking configuration 400', async () => { + const calls = { value: 0 }; + const policies: (ProjectionPolicy | undefined)[] = []; + const { service } = createService( + createRequester(calls, THINKING_CONFIG_400), + recordPolicies(policies), + ); + + await expect(service.request()).rejects.toBe(THINKING_CONFIG_400); + expect(calls.value).toBe(1); + expect(policies).toEqual([undefined]); + }); + + it('applies the thinking strip on top of strict after a structural rejection', async () => { + const calls = { value: 0 }; + const policies: (ProjectionPolicy | undefined)[] = []; + const { service, dispatcher, records } = createService( + createRequester(calls, STRUCTURAL_400, [THINKING_SIGNATURE_400]), + recordPolicies(policies), + ); + + await service.request(); + await dispatcher.flush(); + + expect(calls.value).toBe(3); + expect(policies).toEqual([ + undefined, + { structure: 'strict' }, + { structure: 'strict', thinking: 'strip' }, + ]); + expect(projectionLabels(records)).toEqual([undefined, 'strict', 'strict-thinking-stripped']); + }); + + it('applies the strict repair on top of a stripped thinking projection', async () => { + const calls = { value: 0 }; + const policies: (ProjectionPolicy | undefined)[] = []; + const { service, dispatcher, records } = createService( + createRequester(calls, THINKING_SIGNATURE_400, [STRUCTURAL_400]), + recordPolicies(policies), + ); + + await service.request(); + await dispatcher.flush(); + + expect(calls.value).toBe(3); + expect(policies).toEqual([ + undefined, + { thinking: 'strip' }, + { thinking: 'strip', structure: 'strict' }, + ]); + expect(projectionLabels(records)).toEqual([ + undefined, + 'thinking-stripped', + 'strict-thinking-stripped', + ]); + }); + + it('strips rejected media on top of a stripped thinking projection', async () => { + const calls = { value: 0 }; + const policies: (ProjectionPolicy | undefined)[] = []; + const { service, dispatcher, records } = createService( + createRequester(calls, THINKING_SIGNATURE_400, [IMAGE_FORMAT_400]), + recordPolicies(policies), + ); + + await service.request(); + await dispatcher.flush(); + + expect(calls.value).toBe(3); + expect(policies).toEqual([ + undefined, + { thinking: 'strip' }, + { thinking: 'strip', media: { strip: expect.anything() } }, + ]); + expect(projectionLabels(records)).toEqual([ + undefined, + 'thinking-stripped', + 'media-stripped-thinking-stripped', + ]); + }); + + it('degrades media on top of a stripped thinking projection after a 413', async () => { + const calls = { value: 0 }; + const policies: (ProjectionPolicy | undefined)[] = []; + const { service, dispatcher, records } = createService( + createRequester(calls, THINKING_SIGNATURE_400, [BODY_TOO_LARGE_413]), + recordPolicies(policies), + ); + + await service.request(); + await dispatcher.flush(); + + expect(calls.value).toBe(3); + expect(policies).toEqual([ + undefined, + { thinking: 'strip' }, + { thinking: 'strip', media: 'degraded' }, + ]); + expect(projectionLabels(records)).toEqual([ + undefined, + 'thinking-stripped', + 'media-degraded-thinking-stripped', + ]); + }); + + it('composes the whole ladder into the fully repaired projection label', async () => { + const calls = { value: 0 }; + const policies: (ProjectionPolicy | undefined)[] = []; + const { service, dispatcher, records } = createService( + createRequester(calls, STRUCTURAL_400, [ + THINKING_SIGNATURE_400, + BODY_TOO_LARGE_413, + BODY_TOO_LARGE_413, + ]), + recordPolicies(policies), + ); + + await service.request({ source: { type: 'turn', turnId: 1, step: 1 } }); + await dispatcher.flush(); + + expect(calls.value).toBe(5); + expect(policies).toEqual([ + undefined, + { structure: 'strict' }, + { structure: 'strict', thinking: 'strip' }, + { structure: 'strict', thinking: 'strip', media: 'degraded' }, + { structure: 'strict', thinking: 'strip', media: { strip: expect.anything() } }, + ]); + expect(projectionLabels(records)).toEqual([ + undefined, + 'strict', + 'strict-thinking-stripped', + 'strict-media-degraded-thinking-stripped', + 'strict-media-stripped-thinking-stripped', + ]); + }); +}); + describe('AgentLLMRequesterService trace id', () => { const passthroughProjector = { project: (messages: readonly ContextMessage[]) => messages, diff --git a/packages/agent-core-v2/test/app/llmProtocol/errors.test.ts b/packages/agent-core-v2/test/app/llmProtocol/errors.test.ts index 2e96216f833..f5e9f97f650 100644 --- a/packages/agent-core-v2/test/app/llmProtocol/errors.test.ts +++ b/packages/agent-core-v2/test/app/llmProtocol/errors.test.ts @@ -13,6 +13,7 @@ import { isProviderRateLimitError, isRecoverableRequestStructureError, isRetryableGenerateError, + isThinkingSignatureError, isToolExchangeAdjacencyError, normalizeAPIStatusError, parseRetryAfterMs, @@ -717,6 +718,120 @@ describe('isRecoverableRequestStructureError', () => { }); }); +const ANTHROPIC_INVALID_THINKING_SIGNATURE = + 'messages.1.content.0: Invalid `signature` in `thinking` block'; + +const ANTHROPIC_THINKING_PREFIX_MISMATCH = + 'messages.3.content.0: Invalid `signature` in `thinking` block. The block is bound to a ' + + 'different conversation. Remove the block, or set ' + + '`thinking.block_binding.prefix_mismatch_behavior` to "drop_block".'; + +const ANTHROPIC_THINKING_PREFIX_MISMATCH_BETA = + `${ANTHROPIC_THINKING_PREFIX_MISMATCH} That setting requires the ` + + '`thinking-binding-controls-2026-08-01` value in the `anthropic-beta` header.'; + +const ANTHROPIC_LATEST_ASSISTANT_THINKING_MODIFIED = + 'messages.5.content.0: `thinking` or `redacted_thinking` blocks in the latest assistant ' + + 'message cannot be modified. These blocks must remain as they were in the original response.'; + +const THINKING_CONFIGURATION_REJECTIONS = [ + '"thinking.type.enabled" is not supported for this model. Use "thinking.type.adaptive" and ' + + '"output_config.effort" to control thinking behavior.', + '"thinking.type.disabled" is not supported for this model.', + 'adaptive thinking is not supported on this model', + 'block_binding: Extra inputs are not permitted', + 'tool_choice: type "tool" and "any" are not supported for this model.', + 'This model does not support assistant message prefill. The conversation must end with a user message.', +]; + +describe('isThinkingSignatureError', () => { + it.each([ + ['the bare invalid-signature 400', ANTHROPIC_INVALID_THINKING_SIGNATURE], + ['the prefix-mismatch invalid-signature 400', ANTHROPIC_THINKING_PREFIX_MISMATCH], + ['the prefix-mismatch 400 with the beta-header suffix', ANTHROPIC_THINKING_PREFIX_MISMATCH_BETA], + ['the modified-latest-assistant-thinking 400', ANTHROPIC_LATEST_ASSISTANT_THINKING_MODIFIED], + ])('matches %s', (_label, message) => { + expect(isThinkingSignatureError(new APIStatusError(400, message))).toBe(true); + }); + + it('also matches a 422 with the same shape', () => { + expect( + isThinkingSignatureError(new APIStatusError(422, ANTHROPIC_INVALID_THINKING_SIGNATURE)), + ).toBe(true); + expect( + isThinkingSignatureError( + new APIStatusError(422, ANTHROPIC_LATEST_ASSISTANT_THINKING_MODIFIED), + ), + ).toBe(true); + }); + + it('matches on a substring, independent of the messages.{i}.content.{j} prefix', () => { + expect( + isThinkingSignatureError( + new APIStatusError(400, 'messages.417.content.12: Invalid `signature` in `thinking` block'), + ), + ).toBe(true); + expect( + isThinkingSignatureError(new APIStatusError(400, 'Invalid `signature` in `thinking` block')), + ).toBe(true); + }); + + it.each(THINKING_CONFIGURATION_REJECTIONS)( + 'does not match the configuration-family rejection "%s"', + (message) => { + expect(isThinkingSignatureError(new APIStatusError(400, message))).toBe(false); + }, + ); + + it('does not match a context-overflow 400 or an unrelated 400', () => { + expect( + isThinkingSignatureError( + new APIContextOverflowError(400, ANTHROPIC_INVALID_THINKING_SIGNATURE), + ), + ).toBe(false); + expect(isThinkingSignatureError(new APIStatusError(400, 'Bad request'))).toBe(false); + expect( + isThinkingSignatureError(new APIStatusError(400, 'messages: roles must alternate')), + ).toBe(false); + }); + + it.each([401, 413, 429, 500])('does not match a %i outside the 400/422 window', (statusCode) => { + expect( + isThinkingSignatureError(new APIStatusError(statusCode, ANTHROPIC_INVALID_THINKING_SIGNATURE)), + ).toBe(false); + }); + + it('does not match non-APIStatusError values', () => { + expect(isThinkingSignatureError(new Error(ANTHROPIC_INVALID_THINKING_SIGNATURE))).toBe(false); + expect(isThinkingSignatureError(ANTHROPIC_INVALID_THINKING_SIGNATURE)).toBe(false); + expect(isThinkingSignatureError({ statusCode: 400, message: 'Invalid `signature`' })).toBe( + false, + ); + expect(isThinkingSignatureError(null)).toBe(false); + expect(isThinkingSignatureError(undefined)).toBe(false); + }); +}); + +describe('thinking-signature errors stay out of the strict re-projection ladder', () => { + it.each([ + ANTHROPIC_INVALID_THINKING_SIGNATURE, + ANTHROPIC_THINKING_PREFIX_MISMATCH, + ANTHROPIC_THINKING_PREFIX_MISMATCH_BETA, + ANTHROPIC_LATEST_ASSISTANT_THINKING_MODIFIED, + ])('is not classified as a recoverable request-structure error: "%s"', (message) => { + expect(isRecoverableRequestStructureError(new APIStatusError(400, message))).toBe(false); + }); + + it.each([ + ANTHROPIC_INVALID_THINKING_SIGNATURE, + ANTHROPIC_THINKING_PREFIX_MISMATCH, + ANTHROPIC_THINKING_PREFIX_MISMATCH_BETA, + ANTHROPIC_LATEST_ASSISTANT_THINKING_MODIFIED, + ])('is not classified as a tool-exchange adjacency error: "%s"', (message) => { + expect(isToolExchangeAdjacencyError(new APIStatusError(400, message))).toBe(false); + }); +}); + describe('isProviderRateLimitError', () => { it('matches explicit HTTP 429 status errors', () => { expect(isProviderRateLimitError(new APIProviderRateLimitError('rate limited'))).toBe(true); diff --git a/packages/agent-core-v2/test/index.test.ts b/packages/agent-core-v2/test/index.test.ts index 5f1d8c332e9..905d918a115 100644 --- a/packages/agent-core-v2/test/index.test.ts +++ b/packages/agent-core-v2/test/index.test.ts @@ -89,6 +89,7 @@ const V2_RECORD_TYPES: ReadonlySet = new Set([ 'plan.revision', 'interruptionReminder.recorded', 'llm.media_stripped', + 'llm.thinking_stripped', 'plugin.session_start', 'runtime.set_binding', 'turn.ended', diff --git a/packages/agent-core-v2/test/kosong/provider/composition.test.ts b/packages/agent-core-v2/test/kosong/provider/composition.test.ts index cb3ad7a6868..c8b05ed27b3 100644 --- a/packages/agent-core-v2/test/kosong/provider/composition.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/composition.test.ts @@ -12,7 +12,7 @@ import { APIStatusError, isRetryableGenerateError, } from '#/kosong/contract/errors'; -import type { Message } from '#/kosong/contract/message'; +import type { Message, ThinkPart } from '#/kosong/contract/message'; import type { ChatProvider, GenerateOptions, @@ -613,6 +613,7 @@ async function captureGoogleBody( async function captureResponsesBody( provider: ChatProvider, options?: GenerateOptions, + history: Message[] = PROBE_HISTORY, ): Promise> { let captured: Record | undefined; const client = sdkClient(provider) as { responses: { create: unknown } }; @@ -620,7 +621,7 @@ async function captureResponsesBody( captured = params as Record; return Promise.resolve(responsesEventStream()); }); - await drain(await provider.generate('', [], PROBE_HISTORY, options)); + await drain(await provider.generate('', [], history, options)); if (captured === undefined) throw new Error('expected responses.create to be called'); return captured; } @@ -744,6 +745,287 @@ describe('reasoning-only assistant history projection', () => { }); }); +const ANTHROPIC_SIGNATURE = 'ErUBCkYIBRgCIkCanthropic-thinking-signature-probe'; +const OPENAI_RESPONSES_BLOB = 'gAAAAABpQ29wZW5haS1mZXJuZXQtcmVhc29uaW5nLXByb2Jl'; +const GOOGLE_THOUGHT_SIGNATURE = 'CpEBCkYIBRgCIkBnb29nbGUtdGhvdWdodC1zaWduYXR1cmU='; + +function thinkOnlyHistory(think: ThinkPart): Message[] { + return [{ role: 'assistant', content: [think], toolCalls: [] }, ...PROBE_HISTORY]; +} + +describe('think provenance tagging across providers (encryptedProtocol)', () => { + it('drops a foreign-tagged think part from the Anthropic wire', async () => { + const provider = registry.createChatProvider({ + protocol: 'anthropic', + modelName: 'claude-opus-4-6', + apiKey: 'sk-probe', + }); + + const { params } = await captureAnthropicBody( + provider, + undefined, + thinkOnlyHistory({ + type: 'think', + think: 'earlier reasoning', + encrypted: OPENAI_RESPONSES_BLOB, + encryptedProtocol: 'openai_responses', + }), + ); + const messages = params['messages'] as Array>; + + expect(messages.map((message) => message['role'])).toEqual(['user']); + expect(JSON.stringify(params)).not.toContain(OPENAI_RESPONSES_BLOB); + }); + + it('keeps an anthropic-tagged think part as a signed thinking block on the Anthropic wire', async () => { + const provider = registry.createChatProvider({ + protocol: 'anthropic', + modelName: 'claude-opus-4-6', + apiKey: 'sk-probe', + }); + + const { params } = await captureAnthropicBody( + provider, + undefined, + thinkOnlyHistory({ + type: 'think', + think: 'earlier reasoning', + encrypted: ANTHROPIC_SIGNATURE, + encryptedProtocol: 'anthropic', + }), + ); + const messages = params['messages'] as Array>; + + expect(messages[0]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'earlier reasoning', signature: ANTHROPIC_SIGNATURE }, + ], + }); + }); + + it('keeps an untagged think part as a signed thinking block on the Anthropic wire', async () => { + const provider = registry.createChatProvider({ + protocol: 'anthropic', + modelName: 'claude-opus-4-6', + apiKey: 'sk-probe', + }); + + const { params } = await captureAnthropicBody( + provider, + undefined, + thinkOnlyHistory({ + type: 'think', + think: 'earlier reasoning', + encrypted: ANTHROPIC_SIGNATURE, + }), + ); + const messages = params['messages'] as Array>; + + expect(messages[0]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'earlier reasoning', signature: ANTHROPIC_SIGNATURE }, + ], + }); + }); + + it('omits encrypted_content for a foreign-tagged think part on the Responses wire', async () => { + const provider = new OpenAIResponsesChatProvider({ model: 'gpt-4.1', apiKey: 'sk-probe' }); + + const body = await captureResponsesBody( + provider, + undefined, + thinkOnlyHistory({ + type: 'think', + think: 'earlier reasoning', + encrypted: ANTHROPIC_SIGNATURE, + encryptedProtocol: 'anthropic', + }), + ); + const input = body['input'] as Array>; + + expect(input[0]).toEqual({ + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'earlier reasoning' }], + encrypted_content: undefined, + }); + expect(JSON.stringify(body)).not.toContain(ANTHROPIC_SIGNATURE); + }); + + it('keeps encrypted_content for an openai_responses-tagged think part on the Responses wire', async () => { + const provider = new OpenAIResponsesChatProvider({ model: 'gpt-4.1', apiKey: 'sk-probe' }); + + const body = await captureResponsesBody( + provider, + undefined, + thinkOnlyHistory({ + type: 'think', + think: 'earlier reasoning', + encrypted: OPENAI_RESPONSES_BLOB, + encryptedProtocol: 'openai_responses', + }), + ); + const input = body['input'] as Array>; + + expect(input[0]).toEqual({ + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'earlier reasoning' }], + encrypted_content: OPENAI_RESPONSES_BLOB, + }); + }); + + it('keeps encrypted_content for an untagged think part on the Responses wire', async () => { + const provider = new OpenAIResponsesChatProvider({ model: 'gpt-4.1', apiKey: 'sk-probe' }); + + const body = await captureResponsesBody( + provider, + undefined, + thinkOnlyHistory({ + type: 'think', + think: 'earlier reasoning', + encrypted: OPENAI_RESPONSES_BLOB, + }), + ); + const input = body['input'] as Array>; + + expect(input[0]).toEqual({ + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'earlier reasoning' }], + encrypted_content: OPENAI_RESPONSES_BLOB, + }); + }); + + it('does not merge distinct foreign-tagged think parts into one reasoning item', async () => { + const provider = new OpenAIResponsesChatProvider({ model: 'gpt-4.1', apiKey: 'sk-probe' }); + + const history: Message[] = [ + { + role: 'assistant', + content: [ + { + type: 'think', + think: 'first', + encrypted: ANTHROPIC_SIGNATURE, + encryptedProtocol: 'anthropic', + }, + { + type: 'think', + think: 'second', + encrypted: GOOGLE_THOUGHT_SIGNATURE, + encryptedProtocol: 'google-genai', + }, + ], + toolCalls: [], + }, + ...PROBE_HISTORY, + ]; + + const body = await captureResponsesBody(provider, undefined, history); + const input = body['input'] as Array>; + + expect(input.filter((item) => item['type'] === 'reasoning')).toEqual([ + { + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'first' }], + encrypted_content: undefined, + }, + { + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'second' }], + encrypted_content: undefined, + }, + ]); + }); + + it('omits thoughtSignature for a foreign-tagged think part on the Google GenAI wire', async () => { + const provider = new GoogleGenAIChatProvider({ + model: 'gemini-2.5-flash', + apiKey: 'sk-probe', + stream: false, + }); + + const body = await captureGoogleBody( + provider, + undefined, + thinkOnlyHistory({ + type: 'think', + think: 'earlier reasoning', + encrypted: OPENAI_RESPONSES_BLOB, + encryptedProtocol: 'openai_responses', + }), + ); + const contents = body['contents'] as Array>; + + expect(contents[0]).toEqual({ + role: 'model', + parts: [{ text: 'earlier reasoning', thought: true }], + }); + expect(JSON.stringify(body)).not.toContain(OPENAI_RESPONSES_BLOB); + }); + + it('keeps thoughtSignature for a google-genai-tagged think part on the Google GenAI wire', async () => { + const provider = new GoogleGenAIChatProvider({ + model: 'gemini-2.5-flash', + apiKey: 'sk-probe', + stream: false, + }); + + const body = await captureGoogleBody( + provider, + undefined, + thinkOnlyHistory({ + type: 'think', + think: 'earlier reasoning', + encrypted: GOOGLE_THOUGHT_SIGNATURE, + encryptedProtocol: 'google-genai', + }), + ); + const contents = body['contents'] as Array>; + + expect(contents[0]).toEqual({ + role: 'model', + parts: [ + { + text: 'earlier reasoning', + thought: true, + thoughtSignature: GOOGLE_THOUGHT_SIGNATURE, + }, + ], + }); + }); + + it('keeps thoughtSignature for an untagged think part on the Google GenAI wire', async () => { + const provider = new GoogleGenAIChatProvider({ + model: 'gemini-2.5-flash', + apiKey: 'sk-probe', + stream: false, + }); + + const body = await captureGoogleBody( + provider, + undefined, + thinkOnlyHistory({ + type: 'think', + think: 'earlier reasoning', + encrypted: GOOGLE_THOUGHT_SIGNATURE, + }), + ); + const contents = body['contents'] as Array>; + + expect(contents[0]).toEqual({ + role: 'model', + parts: [ + { + text: 'earlier reasoning', + thought: true, + thoughtSignature: GOOGLE_THOUGHT_SIGNATURE, + }, + ], + }); + }); +}); + describe('tool-call-only assistant history projection (issue #3017)', () => { it('emits content: null for an assistant message carrying only tool_calls', async () => { const provider = new OpenAILegacyChatProvider({ diff --git a/packages/agent-core-v2/test/state/builtinReplayableKeys.ts b/packages/agent-core-v2/test/state/builtinReplayableKeys.ts index 0ed14e5445a..ff273ff759f 100644 --- a/packages/agent-core-v2/test/state/builtinReplayableKeys.ts +++ b/packages/agent-core-v2/test/state/builtinReplayableKeys.ts @@ -5,7 +5,10 @@ import { staleGuardKey } from '#/features/staleGuard/staleGuardOps'; import { fullCompactionKey } from '#/agent/fullCompaction/compactionOps'; import { interruptionReminderKey } from '#/agent/interruptionReminder/interruptionReminderOps'; import { llmRequestTraceKey } from '#/agent/llmRequester/llmRequestOps'; -import { llmRequesterMediaStrippedTurnsKey } from '#/agent/llmRequester/llmRequesterService'; +import { + llmRequesterMediaStrippedTurnsKey, + llmRequesterThinkingStrippedKey, +} from '#/agent/llmRequester/llmRequesterService'; import { turnKey } from '#/agent/loop/turnOps'; import { mcpDiscoveryKey } from '#/agent/mcp/mcpDiscoveryOps'; import { @@ -32,6 +35,7 @@ export const BUILTIN_REPLAYABLE_STATE_KEYS: readonly ReplayableStateKey[] = interruptionReminderKey, llmRequestTraceKey, llmRequesterMediaStrippedTurnsKey, + llmRequesterThinkingStrippedKey, turnKey, mcpDiscoveryKey, permissionModeKey, diff --git a/packages/agent-core/src/agent/context/projector.ts b/packages/agent-core/src/agent/context/projector.ts index a9f4bbc1561..f91d129531f 100644 --- a/packages/agent-core/src/agent/context/projector.ts +++ b/packages/agent-core/src/agent/context/projector.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; -import type { ContentPart, Message, TextPart } from '@moonshot-ai/kosong'; +import type { ContentPart, Message, TextPart, ThinkPart } from '@moonshot-ai/kosong'; import { ErrorCodes, KimiError } from '../../errors'; import { renderToolResultForModel } from './tool-result-render'; @@ -450,29 +450,49 @@ function prepareMessageForProjection( return next; } +/** + * True when a thinking part carries a reasoning blob at all. + * + * The single place both `encrypted` predicates below agree on, so they cannot + * drift apart. Presence only — deliberately NOT provenance-aware. A blob now + * carries an `encryptedProtocol` tag (kosong's `encryptedForProtocol`) so a + * signature minted by one wire is never replayed as another's, but that check + * needs a target protocol and the projector runs before one is chosen. The + * projector therefore preserves every blob and the adapter decides: on a + * mismatch kosong's Anthropic base falls through to its unsigned-thinking + * branch, and its own empty-message guard removes any assistant message that + * leaves behind. + */ +function hasReasoningBlob(part: ThinkPart): boolean { + return part.encrypted !== undefined; +} + /** * True when a content part carries nothing the provider wire can represent: * an empty or whitespace-only text block, or an empty thinking block with no - * provider signature. A signed thinking block (`encrypted`) is never vacuous - * — reasoning providers require it back verbatim — and media parts always - * carry content. + * provider signature. A thinking block carrying a reasoning blob is never + * vacuous — reasoning providers require it back, and whether this particular + * wire may have it back is the adapter's call, not ours (see + * {@link hasReasoningBlob}). Media parts always carry content. */ function isVacuousContentPart(part: ContentPart): boolean { if (part.type === 'text') return part.text.trim().length === 0; - if (part.type === 'think') return part.encrypted === undefined && part.think.trim().length === 0; + if (part.type === 'think') return !hasReasoningBlob(part) && part.think.trim().length === 0; return false; } /** * The parts of a message that the provider wire can actually carry as - * message content. Unencrypted thinking blocks are excluded: every protocol + * message content. Blob-less thinking blocks are excluded: every protocol * base moves them out of `content` (OpenAI → `reasoning_content`, Anthropic → - * `thinking` blocks), so a message whose only parts are unencrypted thinking - * would reach the wire with neither content nor tool_calls. Signed thinking - * (`encrypted`) must survive — reasoning providers require it back verbatim. + * `thinking` blocks), so a message whose only parts are blob-less thinking + * would reach the wire with neither content nor tool_calls. Thinking that + * carries a reasoning blob must survive — reasoning providers require it back, + * and the per-protocol usability of that blob is decided downstream (see + * {@link hasReasoningBlob}). */ function wireSendableContent(content: readonly ContentPart[]): ContentPart[] { - return content.filter((part) => part.type !== 'think' || part.encrypted !== undefined); + return content.filter((part) => part.type !== 'think' || hasReasoningBlob(part)); } function canMergeUserMessage(message: ContextMessage): boolean { @@ -664,6 +684,63 @@ export function stripMediaPartsBySnapshot( return changed ? result : (messages as Message[]); } +/** + * Remove EVERY thinking part from every message, keeping text and tool calls. + * + * This is the thinking-stripped projection used to resend a request the + * provider rejected because it cannot accept its own `thinking` blocks back — + * an unverifiable `signature` (a reasoning blob minted by a different wire and + * replayed here after a mid-session model switch), or thinking in the latest + * assistant message that was altered since the original response. The offending + * block lives in history that is replayed every turn, so without this the + * session stays wedged on the same 400 forever. + * + * The strip is TOTAL, with no special-casing by position or error subtype: a + * full strip is accepted by the live API even when the latest assistant turn + * carried thinking alongside `tool_use`, on both adaptive and + * extended-thinking models, and guessing which single block is poison from the + * error text is not possible. + * + * A message the strip empties is dropped only when it has nothing else to + * carry — an assistant that still holds tool calls stays, or its tool results + * would be orphaned and a thinking 400 would be traded for an adjacency 400. + * Purely read-side: the stored history keeps its thinking. Untouched messages + * are returned by reference, and a history with no thinking at all returns the + * input array itself. + */ +export function stripThinkingParts(messages: readonly Message[]): Message[] { + if (!messages.some(hasThinkPart)) return messages as Message[]; + const result: Message[] = []; + for (const message of messages) { + if (!hasThinkPart(message)) { + result.push(message); + continue; + } + const stripped: Message = { + ...message, + content: message.content.filter((part) => part.type !== 'think'), + }; + if (isWireSendableMessage(stripped)) result.push(stripped); + } + return result; +} + +function hasThinkPart(message: Message): boolean { + return message.content.some((part) => part.type === 'think'); +} + +/** + * True when a message still has something to say after the strip: a tool result + * (dropping it would orphan its call), any tool call, a tool-schema payload, or + * at least one non-vacuous content part. + */ +function isWireSendableMessage(message: Message): boolean { + if (message.role === 'tool') return true; + if (message.toolCalls.length > 0) return true; + if (message.tools !== undefined && message.tools.length > 0) return true; + return !message.content.every(isVacuousContentPart); +} + /** * Replace all but the `keepRecent` most recent media parts with deterministic * text markers. This is the media-degraded projection used to resend a request diff --git a/packages/agent-core/src/agent/records/types.ts b/packages/agent-core/src/agent/records/types.ts index e81a0c246d9..bff58e04a59 100644 --- a/packages/agent-core/src/agent/records/types.ts +++ b/packages/agent-core/src/agent/records/types.ts @@ -218,8 +218,9 @@ export interface AgentRecordEvents { turnStep?: string; attempt?: string; /** Set when this request is a fallback resend (strict rebuild, - * media-degraded rebuild, or media-stripped rebuild). */ - projection?: 'strict' | 'media-degraded' | 'media-stripped'; + * media-degraded rebuild, media-stripped rebuild, or thinking-stripped + * rebuild). */ + projection?: 'strict' | 'media-degraded' | 'media-stripped' | 'thinking-stripped'; /** Compaction only: messages dropped so far by overflow/empty shrinking. */ droppedCount?: number; }; diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index adeed94fefc..0778843c945 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -43,6 +43,7 @@ import { USER_PROMPT_ORIGIN, type PromptOrigin } from '../context'; import { captureMediaStripSnapshot, stripMediaPartsBySnapshot, + stripThinkingParts, } from '../context/projector'; import { renderUserPromptHookBlockResult, renderUserPromptHookResult } from '../../session/hooks'; import { canonicalTelemetryArgs, isPlainRecord } from './canonical-args'; @@ -862,6 +863,11 @@ export class TurnFlow { } return stripMediaPartsBySnapshot(messages, this.mediaStripAccumulator); }; + // Composed on top of `buildMessages`, so it inherits whatever media the + // strip accumulator has already removed this session: recovering from a + // thinking rejection must not silently reintroduce media the provider has + // already rejected. + const buildMessagesThinkingStripped = (): Message[] => stripThinkingParts(buildMessages()); while (true) { signal.throwIfAborted(); const model = this.agent.config.model; @@ -878,6 +884,7 @@ export class TurnFlow { buildMessagesStrict: () => this.agent.context.strictMessages, buildMessagesMediaDegraded: () => this.agent.context.mediaDegradedMessages, buildMessagesMediaStripped, + buildMessagesThinkingStripped, dispatchEvent: this.buildDispatchEvent(turnId), // Re-read per step (not snapshotted per turn) so a select_tools load // is dispatchable on the very next step of the same turn. diff --git a/packages/agent-core/src/loop/llm.ts b/packages/agent-core/src/loop/llm.ts index 160c4dac64e..8176ad8c57b 100644 --- a/packages/agent-core/src/loop/llm.ts +++ b/packages/agent-core/src/loop/llm.ts @@ -35,9 +35,11 @@ export interface LLMRequestLogFields { readonly kind?: 'loop' | 'compaction'; /** Set when the messages are a fallback resend projection: the strict * wire-compliant rebuild, the media-degraded rebuild after a - * request-too-large rejection, or the media-stripped rebuild after an - * image-format rejection / a second request-too-large rejection. */ - readonly projection?: 'strict' | 'media-degraded' | 'media-stripped'; + * request-too-large rejection, the media-stripped rebuild after an + * image-format rejection / a second request-too-large rejection, or the + * thinking-stripped rebuild after the provider refused its own `thinking` + * blocks (unverifiable signature / altered latest-assistant thinking). */ + readonly projection?: 'strict' | 'media-degraded' | 'media-stripped' | 'thinking-stripped'; /** Compaction only: messages dropped so far by overflow/empty shrinking. */ readonly droppedCount?: number; } diff --git a/packages/agent-core/src/loop/run-turn.ts b/packages/agent-core/src/loop/run-turn.ts index f1a785467d4..7b016ef56e2 100644 --- a/packages/agent-core/src/loop/run-turn.ts +++ b/packages/agent-core/src/loop/run-turn.ts @@ -59,6 +59,22 @@ export interface RunTurnInput { * this projection directly. */ readonly buildMessagesMediaStripped?: LoopMessageBuilder | undefined; + /** + * Optional thinking-stripped rebuild of the request messages: EVERY thinking + * part removed from every message, text and tool calls kept. Used to resend + * once after the provider rejects its own `thinking` blocks — an + * unverifiable `signature` (a reasoning blob from a different wire, replayed + * after a mid-session model switch) or altered thinking in the latest + * assistant message (see `executeLoopStep`). After a successful stripped + * resend, later steps of the same turn build from this projection directly. + * + * The latch is turn-local, so a later turn starts from the normal projection + * and re-pays one rejection. v1 keeps no durable recovery state of any kind + * (`mediaDegradedActive` / `mediaStrippedActive` are locals too) and is + * reachable only behind `KIMI_CODE_LEGACY_FLAG` / `kimi.useAgentCoreV1`, so + * that cost is accepted rather than papered over with a persistence layer. + */ + readonly buildMessagesThinkingStripped?: LoopMessageBuilder | undefined; readonly dispatchEvent: LoopEventDispatcher; readonly tools?: readonly ExecutableTool[] | undefined; /** @@ -95,6 +111,7 @@ export async function runTurn(input: RunTurnInput): Promise { buildMessagesStrict, buildMessagesMediaDegraded, buildMessagesMediaStripped, + buildMessagesThinkingStripped, dispatchEvent, tools, buildTools, @@ -121,6 +138,14 @@ export async function runTurn(input: RunTurnInput): Promise { // second 413: the rejected media is still in history, so later steps stay // stripped. let mediaStrippedActive = false; + // Same for the thinking-stripped resend after the provider refused its own + // `thinking` blocks: the offending block is still in history, so later steps + // of this turn skip it. Ranked BELOW both media projections — the + // thinking-stripped rebuild carries full media, and re-earning a 413 leads + // into a recovery ladder that has no thinking rung to fall back on. When both + // axes are active the thinking rung simply re-fires per step, which is + // bounded at one extra rejection plus one resend. + let thinkingStrippedActive = false; const recordStepUsage = async ( stepUsage: TokenUsage, ): Promise => { @@ -143,23 +168,25 @@ export async function runTurn(input: RunTurnInput): Promise { steps += 1; activeStep = steps; activeRequestTrace = undefined; + const projection = selectStepProjection({ + buildMessages, + buildMessagesMediaDegraded, + buildMessagesMediaStripped, + buildMessagesThinkingStripped, + mediaDegradedActive, + mediaStrippedActive, + thinkingStrippedActive, + }); const stepResult = await executeLoopStep({ turnId, signal, - buildMessages: - mediaStrippedActive && buildMessagesMediaStripped !== undefined - ? buildMessagesMediaStripped - : mediaDegradedActive && buildMessagesMediaDegraded !== undefined - ? buildMessagesMediaDegraded - : buildMessages, - initialMediaProjection: mediaStrippedActive - ? 'media-stripped' - : mediaDegradedActive - ? 'media-degraded' - : 'normal', + buildMessages: projection.buildMessages, + initialMediaProjection: projection.mediaProjection, + initialThinkingStripped: projection.thinkingStripped, buildMessagesStrict, buildMessagesMediaDegraded, buildMessagesMediaStripped, + buildMessagesThinkingStripped, dispatchEvent, llm, tools, @@ -179,6 +206,8 @@ export async function runTurn(input: RunTurnInput): Promise { activeStep = undefined; mediaDegradedActive = mediaDegradedActive || stepResult.mediaDegradedResendUsed === true; mediaStrippedActive = mediaStrippedActive || stepResult.mediaStrippedResendUsed === true; + thinkingStrippedActive = + thinkingStrippedActive || stepResult.thinkingStrippedResendUsed === true; if (stepResult.stopReason === 'tool_use') { continue; @@ -235,6 +264,66 @@ export async function runTurn(input: RunTurnInput): Promise { return { stopReason, steps, usage }; } +interface StepProjectionInput { + readonly buildMessages: LoopMessageBuilder; + readonly buildMessagesMediaDegraded: LoopMessageBuilder | undefined; + readonly buildMessagesMediaStripped: LoopMessageBuilder | undefined; + readonly buildMessagesThinkingStripped: LoopMessageBuilder | undefined; + readonly mediaDegradedActive: boolean; + readonly mediaStrippedActive: boolean; + readonly thinkingStrippedActive: boolean; +} + +interface StepProjection { + readonly buildMessages: LoopMessageBuilder; + readonly mediaProjection: 'normal' | 'media-degraded' | 'media-stripped'; + readonly thinkingStripped: boolean; +} + +/** + * Pick the builder for a step from the turn's latched recovery state, and + * report which projection it produced so `executeLoopStep` does not re-attempt + * a rung the messages are already past. + * + * Media outranks thinking. The thinking-stripped rebuild carries full media, so + * preferring it after a media rejection would re-earn that rejection every + * step, and the media rung's inner recovery has no thinking fallback. With both + * latched, the thinking rung simply re-fires per step — bounded at one extra + * rejection plus one resend, and strictly better than looping on media. + */ +function selectStepProjection(input: StepProjectionInput): StepProjection { + if (input.mediaStrippedActive && input.buildMessagesMediaStripped !== undefined) { + return { + buildMessages: input.buildMessagesMediaStripped, + mediaProjection: 'media-stripped', + thinkingStripped: false, + }; + } + if (input.mediaDegradedActive && input.buildMessagesMediaDegraded !== undefined) { + return { + buildMessages: input.buildMessagesMediaDegraded, + mediaProjection: 'media-degraded', + thinkingStripped: false, + }; + } + if (input.thinkingStrippedActive && input.buildMessagesThinkingStripped !== undefined) { + return { + buildMessages: input.buildMessagesThinkingStripped, + mediaProjection: 'normal', + thinkingStripped: true, + }; + } + // No latch matched a builder — a latch can only be set by a successful + // resend through its own builder, so this is the plain first-choice path. + // The reported projection describes the messages actually sent, which is + // whatever the host's own `buildMessages` produces. + return { + buildMessages: input.buildMessages, + mediaProjection: 'normal', + thinkingStripped: false, + }; +} + function makeInterruptedEvent( reason: LoopInterruptReason, attemptedSteps: number, diff --git a/packages/agent-core/src/loop/turn-step.ts b/packages/agent-core/src/loop/turn-step.ts index 2890cab7e89..572780934ea 100644 --- a/packages/agent-core/src/loop/turn-step.ts +++ b/packages/agent-core/src/loop/turn-step.ts @@ -13,6 +13,7 @@ import { APIRequestTooLargeError, isImageFormatError, isRecoverableRequestStructureError, + isThinkingSignatureError, type TokenUsage, } from '@moonshot-ai/kosong'; import type { Logger } from '#/logging/types'; @@ -24,6 +25,7 @@ import { type LLM, type LLMChatParams, type LLMChatResponse, + type LLMRequestLogFields, type LLMRequestTrace, } from './llm'; import { chatWithRetry } from './retry'; @@ -56,6 +58,15 @@ export interface ExecuteLoopStepDeps { readonly buildMessagesMediaDegraded?: LoopMessageBuilder | undefined; /** See RunTurnInput.buildMessagesMediaStripped. */ readonly buildMessagesMediaStripped?: LoopMessageBuilder | undefined; + /** See RunTurnInput.buildMessagesThinkingStripped. */ + readonly buildMessagesThinkingStripped?: LoopMessageBuilder | undefined; + /** + * True when `buildMessages` for this step already produced the + * thinking-stripped projection. Recovery only moves forward: a step that is + * already stripped has no further thinking to remove, so a repeat rejection + * propagates instead of re-sending byte-identical messages. + */ + readonly initialThinkingStripped?: boolean; readonly dispatchEvent: LoopEventDispatcher; readonly llm: LLM; readonly tools?: readonly ExecutableTool[] | undefined; @@ -93,6 +104,13 @@ export async function executeLoopStep(deps: ExecuteLoopStepDeps): Promise<{ * projection for the same reason as above. */ readonly mediaStrippedResendUsed?: boolean; + /** + * True when this step only succeeded after resending with every thinking + * part stripped. The turn loop keeps later steps on that projection: the + * rejected block is still in history, so rebuilding it would pay a fresh + * 400 on every step of the turn. + */ + readonly thinkingStrippedResendUsed?: boolean; }> { const { turnId, @@ -102,6 +120,8 @@ export async function executeLoopStep(deps: ExecuteLoopStepDeps): Promise<{ buildMessagesStrict, buildMessagesMediaDegraded, buildMessagesMediaStripped, + buildMessagesThinkingStripped, + initialThinkingStripped = false, dispatchEvent, llm, tools, @@ -166,10 +186,10 @@ export async function executeLoopStep(deps: ExecuteLoopStepDeps): Promise<{ messages, tools: stepTools ?? [], signal, - requestLogFields: - initialMediaProjection === 'normal' - ? undefined - : { projection: initialMediaProjection }, + requestLogFields: initialProjectionLogFields( + initialMediaProjection, + initialThinkingStripped, + ), trace, ...createChatStreamingCallbacks({ dispatchEvent, @@ -206,6 +226,7 @@ export async function executeLoopStep(deps: ExecuteLoopStepDeps): Promise<{ let response: LLMChatResponse; let mediaDegradedResendUsed = false; let mediaStrippedResendUsed = false; + let thinkingStrippedResendUsed = false; try { response = await chatWithRetry({ ...retryInput, params: chatParams }); } catch (error) { @@ -385,6 +406,46 @@ export async function executeLoopStep(deps: ExecuteLoopStepDeps): Promise<{ log?.info('recovered after strict resend', { turnStep: `${turnId}.${String(currentStep)}`, }); + } else if (buildMessagesThinkingStripped !== undefined && isThinkingSignatureError(error)) { + // The provider cannot accept its own `thinking` blocks back: the + // signature does not verify (a reasoning blob minted by a different wire, + // replayed here after the user switched models mid-session), or the + // latest assistant message's thinking was altered since the response. + // Both are deterministic 400s on history that is re-sent every turn, so + // the session stays wedged forever without intervention — and the strict + // rebuild above cannot help, because its repairs never touch thinking + // blocks. Resend ONCE with every thinking part removed. Read-side only: + // the stored history keeps its reasoning. + if (initialThinkingStripped) throw error; + signal.throwIfAborted(); + log?.warn('provider rejected the thinking blocks in the request; resending with thinking stripped', { + turnStep: `${turnId}.${String(currentStep)}`, + model: llm.modelName, + }); + const thinkingStrippedMessages = await buildMessagesThinkingStripped(); + signal.throwIfAborted(); + try { + response = await chatWithRetry({ + ...retryInput, + params: { + ...chatParams, + messages: thinkingStrippedMessages, + requestLogFields: { projection: 'thinking-stripped' }, + }, + }); + } catch (thinkingStrippedError) { + log?.error('thinking-stripped resend still rejected by provider', { + turnStep: `${turnId}.${String(currentStep)}`, + model: llm.modelName, + originalError: errorMessage(error), + thinkingStrippedError: errorMessage(thinkingStrippedError), + }); + throw thinkingStrippedError; + } + thinkingStrippedResendUsed = true; + log?.info('recovered after thinking-stripped resend', { + turnStep: `${turnId}.${String(currentStep)}`, + }); } else { throw error; } @@ -465,9 +526,25 @@ export async function executeLoopStep(deps: ExecuteLoopStepDeps): Promise<{ stopTurnAfterStep && effectiveStopReason === 'tool_use' ? 'end_turn' : effectiveStopReason, mediaDegradedResendUsed, mediaStrippedResendUsed, + thinkingStrippedResendUsed, }; } +/** + * `requestLogFields` for the step's FIRST attempt. Media and thinking are + * orthogonal recovery axes; the media label wins when both are active because + * that is the projection `buildMessages` actually produced (see the builder + * precedence in `runTurn`). + */ +function initialProjectionLogFields( + mediaProjection: 'normal' | 'media-degraded' | 'media-stripped', + thinkingStripped: boolean, +): Pick | undefined { + if (mediaProjection !== 'normal') return { projection: mediaProjection }; + if (thinkingStripped) return { projection: 'thinking-stripped' }; + return undefined; +} + /** * Emit a per-step completion log with the LLM response timing. TTFT is split * into the client-side request-build portion and the network + API-server diff --git a/packages/agent-core/test/agent/context/projector.test.ts b/packages/agent-core/test/agent/context/projector.test.ts index 2eeabbca70d..4c63f57f4b8 100644 --- a/packages/agent-core/test/agent/context/projector.test.ts +++ b/packages/agent-core/test/agent/context/projector.test.ts @@ -6,6 +6,7 @@ import { degradeOlderMediaParts, project, stripMediaPartsBySnapshot, + stripThinkingParts, type ProjectionAnomaly, } from '../../../src/agent/context/projector'; import type { ContextMessage } from '../../../src/agent/context/types'; @@ -650,6 +651,226 @@ describe('project drops vacuous (thinking-only) messages', () => { }); }); +// --------------------------------------------------------------------------- +// Signed thinking is protocol-tagged, but the projector stays protocol-agnostic +// --------------------------------------------------------------------------- +// +// `ThinkPart.encrypted` now carries an `encryptedProtocol` provenance tag, and +// the provider adapters read it through `encryptedForProtocol` so a blob minted +// by one wire is never replayed as another's signature. The projector runs +// BEFORE a provider is chosen and has no protocol in scope, so its two +// `encrypted` predicates — `isVacuousContentPart` and `wireSendableContent` — +// deliberately gate on presence only. These tests pin that split: the projector +// keeps every signed block regardless of tag, and the adapter decides. + +function taggedThinkPart( + think: string, + encrypted: string, + encryptedProtocol: 'anthropic' | 'openai' | 'openai_responses' | 'google-genai', +): ContentPart { + return { type: 'think', think, encrypted, encryptedProtocol }; +} + +describe('project signed-thinking predicates are provenance-agnostic', () => { + it.each(['anthropic', 'openai', 'openai_responses', 'google-genai'] as const)( + 'keeps an empty think block signed by %s (isVacuousContentPart)', + (protocol) => { + const part = taggedThinkPart('', 'blob', protocol); + + const projected = project([user('u1'), thinkingAssistant([part])]); + + expect(projected.map((m) => m.role)).toEqual(['user', 'assistant']); + expect(projected[1]?.content).toEqual([part]); + }, + ); + + it.each(['anthropic', 'openai', 'openai_responses', 'google-genai'] as const)( + 'keeps a whitespace-only think block signed by %s (isVacuousContentPart)', + (protocol) => { + const part = taggedThinkPart(' ', 'blob', protocol); + + const projected = project([user('u1'), thinkingAssistant([part]), user('u2')]); + + expect(projected.map((m) => m.role)).toEqual(['user', 'assistant', 'user']); + }, + ); + + it('keeps a foreign-tagged signed think block as the sole content of a message (wireSendableContent)', () => { + // The adapter, not the projector, drops the unusable blob: kosong's + // Anthropic adapter falls through to its unsigned branch and its + // `shouldKeepConvertedMessage` guard removes an assistant message left + // with no blocks, so nothing invalid reaches the wire. + const part = taggedThinkPart('reasoning from the previous model', 'blob', 'openai_responses'); + + const projected = project([user('u1'), thinkingAssistant([part])]); + + expect(projected.map((m) => m.role)).toEqual(['user', 'assistant']); + expect(projected[1]?.content).toEqual([part]); + }); + + it('still drops an unsigned think block regardless of a stray tag', () => { + // A tag with no blob is not a signature; the part stays unsendable. + const projected = project([ + user('u1'), + thinkingAssistant([ + { type: 'think', think: 'reasoning', encryptedProtocol: 'anthropic' } as ContentPart, + ]), + ]); + + expect(projected.map((m) => m.role)).toEqual(['user']); + }); + + it('preserves the provenance tag verbatim through projection', () => { + const part = taggedThinkPart('reasoning', 'blob', 'google-genai'); + + const projected = project([user('u1'), thinkingAssistant([part, textPart('answer')])]); + + expect(projected[1]?.content).toEqual([part, textPart('answer')]); + }); +}); + +// --------------------------------------------------------------------------- +// stripThinkingParts — the v1 thinking-stripped recovery projection +// --------------------------------------------------------------------------- + +describe('stripThinkingParts', () => { + it('removes every think part from every message, signed and unsigned alike', () => { + const messages = project([ + user('u1'), + thinkingAssistant([thinkPart('unsigned'), textPart('first answer')]), + user('u2'), + thinkingAssistant([thinkPart('signed', 'sig'), textPart('second answer')]), + ]); + + const stripped = stripThinkingParts(messages); + + expect(stripped.map((m) => m.content)).toEqual([ + [textPart('u1')], + [textPart('first answer')], + [textPart('u2')], + [textPart('second answer')], + ]); + }); + + it('removes a foreign-tagged signed think part too', () => { + const messages = project([ + user('u1'), + thinkingAssistant([ + taggedThinkPart('reasoning', 'gAAAAABfernet', 'openai_responses'), + textPart('answer'), + ]), + ]); + + const stripped = stripThinkingParts(messages); + + expect(stripped[1]?.content).toEqual([textPart('answer')]); + expect(JSON.stringify(stripped)).not.toContain('gAAAAABfernet'); + }); + + it('keeps text parts and tool calls untouched', () => { + const messages = project([ + user('go'), + { + role: 'assistant', + content: [thinkPart('planning', 'sig'), textPart('calling the tool')], + toolCalls: [{ type: 'function', id: 'a', name: 'Run', arguments: '{}' }], + }, + tool('a'), + ]); + + const stripped = stripThinkingParts(messages); + + expect(stripped.map((m) => m.role)).toEqual(['user', 'assistant', 'tool']); + expect(stripped[1]?.content).toEqual([textPart('calling the tool')]); + expect(stripped[1]?.toolCalls).toEqual([ + { type: 'function', id: 'a', name: 'Run', arguments: '{}' }, + ]); + }); + + it('keeps an assistant the strip empties when it still carries tool calls', () => { + // Dropping it would orphan the following tool result and trade a thinking + // 400 for an adjacency 400. + const messages = project([ + user('go'), + { + role: 'assistant', + content: [thinkPart('only reasoning', 'sig')], + toolCalls: [{ type: 'function', id: 'a', name: 'Run', arguments: '{}' }], + }, + tool('a'), + ]); + + const stripped = stripThinkingParts(messages); + + expect(stripped.map((m) => m.role)).toEqual(['user', 'assistant', 'tool']); + expect(stripped[1]?.content).toEqual([]); + }); + + it('drops an assistant message the strip leaves with nothing sendable', () => { + const messages = project([user('u1'), thinkingAssistant([thinkPart('signed', 'sig')])]); + + const stripped = stripThinkingParts(messages); + + expect(stripped.map((m) => m.role)).toEqual(['user']); + }); + + it('never drops a tool result whose only part was a think block', () => { + const messages: Message[] = [ + { role: 'user', content: [textPart('go')], toolCalls: [] }, + { + role: 'assistant', + content: [], + toolCalls: [{ type: 'function', id: 'a', name: 'Run', arguments: '{}' }], + }, + { + role: 'tool', + content: [thinkPart('tool-side reasoning', 'sig')], + toolCalls: [], + toolCallId: 'a', + }, + ]; + + const stripped = stripThinkingParts(messages); + + expect(stripped.map((m) => m.role)).toEqual(['user', 'assistant', 'tool']); + expect(stripped[2]?.content).toEqual([]); + }); + + it('returns the identical array reference when no message carries thinking', () => { + const messages = project([user('go'), assistant(['a']), tool('a'), user('next')]); + + expect(stripThinkingParts(messages)).toBe(messages); + }); + + it('returns an empty result for an empty history', () => { + expect(stripThinkingParts([])).toEqual([]); + }); + + it('does not mutate the messages it is given', () => { + const messages = project([ + user('u1'), + thinkingAssistant([thinkPart('signed', 'sig'), textPart('answer')]), + ]); + const before = structuredClone(messages); + + stripThinkingParts(messages); + + expect(messages).toEqual(before); + }); + + it('is idempotent — a second strip changes nothing', () => { + const messages = project([ + user('u1'), + thinkingAssistant([thinkPart('signed', 'sig'), textPart('answer')]), + thinkingAssistant([thinkPart('dropped', 'sig')]), + ]); + + const once = stripThinkingParts(messages); + + expect(stripThinkingParts(once)).toEqual(once); + }); +}); + describe('project strict-provider sanitizers', () => { it('drops leading non-user messages so the first message is a user turn', () => { // History that (pathologically) starts with an assistant turn. diff --git a/packages/agent-core/test/loop/tool-exchange-fallback.e2e.test.ts b/packages/agent-core/test/loop/tool-exchange-fallback.e2e.test.ts index 9fdddd00fc7..ff2b557a753 100644 --- a/packages/agent-core/test/loop/tool-exchange-fallback.e2e.test.ts +++ b/packages/agent-core/test/loop/tool-exchange-fallback.e2e.test.ts @@ -50,6 +50,29 @@ const OPENAI_ROLE_TOOL_400 = new APIStatusError( "Messages with role 'tool' must be a response to a preceding message with 'tool_calls'", ); +// Verbatim from the live Anthropic API after a mid-session model switch left an +// OpenAI Responses Fernet blob (`gAAAAAB…`) in a ThinkPart's `encrypted` slot, +// which the Anthropic adapter then replayed as its own `signature`. +const THINKING_SIGNATURE_400 = new APIStatusError( + 400, + 'messages.1.content.0: Invalid `signature` in `thinking` block', +); + +// The sibling replay rejection: the latest assistant turn's thinking blocks +// were altered since the original response. +const THINKING_MODIFIED_400 = new APIStatusError( + 400, + 'messages.5.content.0: thinking or redacted_thinking blocks in the latest assistant message ' + + 'cannot be modified', +); + +// The configuration family, which must NOT trigger a strip: the request shape +// is wrong, and no amount of history repair fixes it. +const THINKING_CONFIG_400 = new APIStatusError( + 400, + 'thinking.type.enabled is not supported for this model', +); + function userMessage(text: string): Message { return { role: 'user', content: [{ type: 'text', text }], toolCalls: [] }; } @@ -437,6 +460,141 @@ describe('executeLoopStep — request-too-large media-degraded fallback', () => expect(attempts).toBe(3); }); + it('gives up when the all-media-stripped resend from an already-degraded step is rejected', async () => { + // Step 1 recovers via the degraded projection; step 2 starts degraded, is + // rejected again, and its final all-media-stripped attempt also fails — + // the request cannot be reduced further, so that error propagates. + const echo = new EchoTool(); + const llm = new FakeLLM({ responses: [] }); + const strippedRejection = new APIRequestTooLargeError(413, 'still too large after stripping'); + let attempts = 0; + llm.chat = async (params) => { + llm.calls.push(params); + attempts += 1; + if (attempts === 1) throw REQUEST_TOO_LARGE; + if (attempts === 2) { + return makeToolUseResponse([makeToolCall('echo', { text: 'hi' }, 'tc-1')]); + } + if (attempts === 3) throw REQUEST_TOO_LARGE; + throw strippedRejection; + }; + const sink = new CollectingSink({}); + const context = new RecordingContext({ messages: [userMessage('normal projection')] }); + const degradedMessages = [userMessage('media-degraded projection')]; + const strippedMessages = [userMessage('media-stripped projection')]; + const input: RunTurnInput = { + turnId: 'turn-1', + signal: new AbortController().signal, + llm, + buildMessages: context.buildMessages, + buildMessagesMediaDegraded: () => degradedMessages, + buildMessagesMediaStripped: () => strippedMessages, + tools: [echo], + dispatchEvent: createLoopEventDispatcher({ + appendTranscriptRecord: context.appendTranscriptRecord, + emitLiveEvent: sink.emit, + }), + }; + + await expect(runTurn(input)).rejects.toBe(strippedRejection); + + expect(attempts).toBe(4); + expect(llm.calls[3]?.messages).toBe(strippedMessages); + expect(llm.calls[3]?.requestLogFields).toMatchObject({ projection: 'media-stripped' }); + }); + + it('ranks the degraded projection above the thinking-stripped one once both latch', async () => { + // Both latches can be set within one turn. Media wins for later steps: the + // thinking-stripped rebuild carries full media and would deterministically + // re-earn the 413, whose inner ladder has no thinking rung to fall back on. + const echo = new EchoTool(); + const llm = new FakeLLM({ responses: [] }); + let attempts = 0; + llm.chat = async (params) => { + llm.calls.push(params); + attempts += 1; + // Step 1: thinking 400 -> stripped resend -> tool call. + if (attempts === 1) throw THINKING_SIGNATURE_400; + if (attempts === 2) { + return makeToolUseResponse([makeToolCall('echo', { text: 'a' }, 'tc-1')]); + } + // Step 2 (already thinking-stripped): 413 -> degraded resend -> tool call. + if (attempts === 3) throw REQUEST_TOO_LARGE; + if (attempts === 4) { + return makeToolUseResponse([makeToolCall('echo', { text: 'b' }, 'tc-2')]); + } + return makeEndTurnResponse('done'); + }; + const sink = new CollectingSink({}); + const context = new RecordingContext({ messages: [userMessage('normal projection')] }); + const degradedMessages = [userMessage('media-degraded projection')]; + const thinkingStrippedMessages = [userMessage('thinking-stripped projection')]; + const input: RunTurnInput = { + turnId: 'turn-1', + signal: new AbortController().signal, + llm, + buildMessages: context.buildMessages, + buildMessagesMediaDegraded: () => degradedMessages, + buildMessagesThinkingStripped: () => thinkingStrippedMessages, + tools: [echo], + dispatchEvent: createLoopEventDispatcher({ + appendTranscriptRecord: context.appendTranscriptRecord, + emitLiveEvent: sink.emit, + }), + }; + + const result = await runTurn(input); + + expect(result.stopReason).toBe('end_turn'); + expect(llm.calls[0]?.messages).toEqual([userMessage('normal projection')]); + expect(llm.calls[1]?.messages).toBe(thinkingStrippedMessages); + expect(llm.calls[2]?.messages).toBe(thinkingStrippedMessages); + expect(llm.calls[3]?.messages).toBe(degradedMessages); + // Step 3 builds from the media projection, not the thinking-stripped one. + expect(llm.calls[4]?.messages).toBe(degradedMessages); + expect(llm.calls[4]?.requestLogFields).toMatchObject({ projection: 'media-degraded' }); + expect(echo.calls).toHaveLength(2); + }); + + it('propagates a thinking rejection raised by the media-degraded resend (v1 ladder is flat)', async () => { + // Documents a real v1 limitation: the 413 rung's inner recovery handles + // only media errors, so a thinking 400 surfacing there is not caught by the + // sibling thinking rung. v2's policy object composes the two axes; v1's + // if/else-if chain cannot, and deepening the nesting is not worth it for + // the legacy engine. + const llm = new FakeLLM({ responses: [] }); + let attempts = 0; + llm.chat = async (params) => { + llm.calls.push(params); + attempts += 1; + if (attempts === 1) throw REQUEST_TOO_LARGE; + throw THINKING_SIGNATURE_400; + }; + const sink = new CollectingSink({}); + const context = new RecordingContext({ messages: [userMessage('normal projection')] }); + let thinkingCount = 0; + const input: RunTurnInput = { + turnId: 'turn-1', + signal: new AbortController().signal, + llm, + buildMessages: context.buildMessages, + buildMessagesMediaDegraded: () => [userMessage('media-degraded projection')], + buildMessagesThinkingStripped: () => { + thinkingCount += 1; + return [userMessage('thinking-stripped projection')]; + }, + dispatchEvent: createLoopEventDispatcher({ + appendTranscriptRecord: context.appendTranscriptRecord, + emitLiveEvent: sink.emit, + }), + }; + + await expect(runTurn(input)).rejects.toBe(THINKING_SIGNATURE_400); + + expect(attempts).toBe(2); + expect(thinkingCount).toBe(0); + }); + it('keeps using the degraded projection for later steps of the same turn', async () => { // Step 1 is rejected with a 413 and recovers via the degraded projection, // then issues a tool call; step 2 must build from the degraded projection @@ -472,3 +630,290 @@ describe('executeLoopStep — request-too-large media-degraded fallback', () => expect(echo.calls).toHaveLength(1); }); }); + +/** + * Thinking-stripped resend. + * + * `ThinkPart.encrypted` is one untagged slot written by three incompatible + * providers. Switching models mid-session used to hand Anthropic another + * provider's blob as its own `signature`; Anthropic verifies it and answers + * `400 messages.1.content.0: Invalid \`signature\` in \`thinking\` block`. The + * offending block sits in the replayed history, so every later turn re-earns + * the same 400 and the session is permanently bricked. + * + * The rung resends ONCE with every think part removed from every message + * (text and tool calls kept). A total strip is verified against the live API + * as accepted even when the latest assistant turn carried thinking plus + * `tool_use`, so there is no special-casing by position or error subtype. + * + * v1 has no durable recovery state: the latch is a `runTurn` local, so a + * session re-pays one 400 per turn. v1 is reachable only via + * `KIMI_CODE_LEGACY_FLAG` / the VS Code `kimi.useAgentCoreV1` setting, so that + * cost is accepted rather than papered over with a persistence layer. + */ +describe('executeLoopStep — thinking-signature stripped-thinking fallback', () => { + interface ThinkingHarness { + readonly input: RunTurnInput; + readonly llm: FakeLLM; + readonly thinkingCalls: { count: number }; + readonly thinkingStrippedMessages: Message[]; + readonly strictCalls: { count: number }; + readonly normalCalls: { count: number }; + } + + function makeThinkingHarness( + error: unknown, + extra: { readonly withBuilder?: boolean; readonly tools?: RunTurnInput['tools'] } = {}, + ): ThinkingHarness { + const llm = new FakeLLM({ + responses: [makeEndTurnResponse('unused'), makeEndTurnResponse('recovered')], + throwOnIndex: { index: 0, error }, + }); + const sink = new CollectingSink({}); + const normalMessages: Message[] = [userMessage('normal projection')]; + const context = new RecordingContext({ messages: normalMessages }); + const normalCalls = { count: 0 }; + const buildMessages: LoopMessageBuilder = () => { + normalCalls.count += 1; + return normalMessages; + }; + const thinkingStrippedMessages: Message[] = [userMessage('thinking-stripped projection')]; + const thinkingCalls = { count: 0 }; + const buildMessagesThinkingStripped: LoopMessageBuilder = () => { + thinkingCalls.count += 1; + return thinkingStrippedMessages; + }; + const strictCalls = { count: 0 }; + const buildMessagesStrict: LoopMessageBuilder = () => { + strictCalls.count += 1; + return [userMessage('strict projection')]; + }; + const input: RunTurnInput = { + turnId: 'turn-1', + signal: new AbortController().signal, + llm, + buildMessages, + buildMessagesStrict, + buildMessagesThinkingStripped: + extra.withBuilder === false ? undefined : buildMessagesThinkingStripped, + tools: extra.tools, + dispatchEvent: createLoopEventDispatcher({ + appendTranscriptRecord: context.appendTranscriptRecord, + emitLiveEvent: sink.emit, + }), + }; + return { input, llm, thinkingCalls, thinkingStrippedMessages, strictCalls, normalCalls }; + } + + it('resends once with thinking stripped after an invalid-signature 400 and recovers', async () => { + const { input, llm, thinkingCalls, thinkingStrippedMessages, strictCalls } = + makeThinkingHarness(THINKING_SIGNATURE_400); + + const result = await runTurn(input); + + expect(result.stopReason).toBe('end_turn'); + // Exactly two provider calls: the rejected one and the stripped resend — + // and the strict builder is never consulted, because a strict + // re-projection does not touch thinking blocks and would fail identically. + expect(llm.callCount).toBe(2); + expect(thinkingCalls.count).toBe(1); + expect(strictCalls.count).toBe(0); + expect(llm.calls[0]?.messages).toEqual([userMessage('normal projection')]); + expect(llm.calls[1]?.messages).toBe(thinkingStrippedMessages); + }); + + it('labels the stripped resend with the thinking-stripped projection', async () => { + const { input, llm } = makeThinkingHarness(THINKING_SIGNATURE_400); + + await runTurn(input); + + expect(llm.calls[1]?.requestLogFields).toMatchObject({ projection: 'thinking-stripped' }); + }); + + it('resends once after a latest-assistant-thinking-modified 400 and recovers', async () => { + const { input, llm, thinkingCalls, thinkingStrippedMessages } = makeThinkingHarness( + THINKING_MODIFIED_400, + ); + + const result = await runTurn(input); + + expect(result.stopReason).toBe('end_turn'); + expect(llm.callCount).toBe(2); + expect(thinkingCalls.count).toBe(1); + expect(llm.calls[1]?.messages).toBe(thinkingStrippedMessages); + }); + + it('does not strip for an Anthropic thinking CONFIGURATION 400 — the error propagates', async () => { + // A request-shape problem, not a poisoned history: stripping cannot help. + const { input, llm, thinkingCalls } = makeThinkingHarness(THINKING_CONFIG_400); + + await expect(runTurn(input)).rejects.toThrow(APIStatusError); + + expect(llm.callCount).toBe(1); + expect(thinkingCalls.count).toBe(0); + }); + + it('does not strip for an unrelated 400 — the error propagates', async () => { + const { input, llm, thinkingCalls } = makeThinkingHarness(new APIStatusError(400, 'Bad request')); + + await expect(runTurn(input)).rejects.toThrow(/Bad request/); + + expect(llm.callCount).toBe(1); + expect(thinkingCalls.count).toBe(0); + }); + + it('propagates the 400 unchanged when the host supplied no thinking-stripped builder', async () => { + const { input, llm, strictCalls } = makeThinkingHarness(THINKING_SIGNATURE_400, { + withBuilder: false, + }); + + await expect(runTurn(input)).rejects.toBe(THINKING_SIGNATURE_400); + + expect(llm.callCount).toBe(1); + expect(strictCalls.count).toBe(0); + }); + + it('resends only once: a stripped rebuild that is also rejected gives up (no loop)', async () => { + const llm = new FakeLLM({ responses: [] }); + let calls = 0; + llm.chat = async () => { + calls += 1; + throw THINKING_SIGNATURE_400; + }; + const sink = new CollectingSink({}); + const context = new RecordingContext({ messages: [userMessage('normal')] }); + let thinkingCount = 0; + const input: RunTurnInput = { + turnId: 'turn-1', + signal: new AbortController().signal, + llm, + buildMessages: context.buildMessages, + buildMessagesThinkingStripped: () => { + thinkingCount += 1; + return [userMessage('thinking-stripped')]; + }, + dispatchEvent: createLoopEventDispatcher({ + appendTranscriptRecord: context.appendTranscriptRecord, + emitLiveEvent: sink.emit, + }), + }; + + await expect(runTurn(input)).rejects.toBe(THINKING_SIGNATURE_400); + expect(calls).toBe(2); // first attempt + one stripped resend, then give up + expect(thinkingCount).toBe(1); + }); + + it('keeps using the thinking-stripped projection for later steps of the same turn', async () => { + // Step 1 is rejected and recovers via the strip, then issues a tool call; + // step 2 must build from the stripped projection directly — the poisoned + // block is still in history and would earn a fresh 400 on every step. + const echo = new EchoTool(); + const llm = new FakeLLM({ + responses: [ + makeEndTurnResponse('unused'), + makeToolUseResponse([makeToolCall('echo', { text: 'hi' }, 'tc-1')]), + makeEndTurnResponse('done'), + ], + throwOnIndex: { index: 0, error: THINKING_SIGNATURE_400 }, + }); + const harness = makeThinkingHarness(THINKING_SIGNATURE_400, { tools: [echo] }); + const input: RunTurnInput = { ...harness.input, llm, tools: [echo] }; + + const result = await runTurn(input); + + expect(result.stopReason).toBe('end_turn'); + expect(llm.callCount).toBe(3); + expect(llm.calls[0]?.messages).toEqual([userMessage('normal projection')]); + expect(llm.calls[1]?.messages).toBe(harness.thinkingStrippedMessages); + expect(llm.calls[2]?.messages).toBe(harness.thinkingStrippedMessages); + expect(harness.normalCalls.count).toBe(1); + expect(harness.thinkingCalls.count).toBe(2); + expect(echo.calls).toHaveLength(1); + }); + + it('propagates without a duplicate resend when the step is already thinking-stripped', async () => { + // Step 2 already builds from the stripped projection; re-sending the very + // same messages could not change the outcome, so the 400 propagates. + const echo = new EchoTool(); + const llm = new FakeLLM({ responses: [] }); + let attempts = 0; + llm.chat = async (params) => { + llm.calls.push(params); + attempts += 1; + if (attempts === 1) throw THINKING_SIGNATURE_400; + if (attempts === 2) { + return makeToolUseResponse([makeToolCall('echo', { text: 'hi' }, 'tc-1')]); + } + throw THINKING_SIGNATURE_400; + }; + const sink = new CollectingSink({}); + const context = new RecordingContext({ messages: [userMessage('normal projection')] }); + const thinkingStrippedMessages = [userMessage('thinking-stripped projection')]; + let thinkingCount = 0; + const input: RunTurnInput = { + turnId: 'turn-1', + signal: new AbortController().signal, + llm, + buildMessages: context.buildMessages, + buildMessagesThinkingStripped: () => { + thinkingCount += 1; + return thinkingStrippedMessages; + }, + tools: [echo], + dispatchEvent: createLoopEventDispatcher({ + appendTranscriptRecord: context.appendTranscriptRecord, + emitLiveEvent: sink.emit, + }), + }; + + await expect(runTurn(input)).rejects.toBe(THINKING_SIGNATURE_400); + + // Step 1 attempt + stripped resend + step 2 (already stripped, no resend). + expect(attempts).toBe(3); + expect(thinkingCount).toBe(2); + expect(llm.calls[2]?.messages).toBe(thinkingStrippedMessages); + expect(llm.calls[2]?.requestLogFields).toMatchObject({ projection: 'thinking-stripped' }); + }); + + it('re-pays one 400 on the next turn — v1 keeps no recovery state across turns', async () => { + // Documents the accepted v1 limitation: `thinkingStrippedActive` is a + // `runTurn` local, so a fresh turn starts from the normal projection again. + const first = makeThinkingHarness(THINKING_SIGNATURE_400); + await runTurn(first.input); + const second = makeThinkingHarness(THINKING_SIGNATURE_400); + + await runTurn(second.input); + + expect(second.llm.callCount).toBe(2); + expect(second.llm.calls[0]?.messages).toEqual([userMessage('normal projection')]); + expect(second.thinkingCalls.count).toBe(1); + }); + + it('still routes a structural 400 to the strict builder, not the thinking one', async () => { + const { input, llm, strictCalls, thinkingCalls } = makeThinkingHarness(ADJACENCY_400); + + const result = await runTurn(input); + + expect(result.stopReason).toBe('end_turn'); + expect(llm.callCount).toBe(2); + expect(strictCalls.count).toBe(1); + expect(thinkingCalls.count).toBe(0); + }); + + it('still routes a 413 to the media builder, not the thinking one', async () => { + const harness = makeThinkingHarness( + new APIRequestTooLargeError(413, 'Request exceeds the maximum size'), + ); + const degradedMessages = [userMessage('media-degraded projection')]; + const input: RunTurnInput = { + ...harness.input, + buildMessagesMediaDegraded: () => degradedMessages, + }; + + const result = await runTurn(input); + + expect(result.stopReason).toBe('end_turn'); + expect(harness.llm.calls[1]?.messages).toBe(degradedMessages); + expect(harness.thinkingCalls.count).toBe(0); + }); +}); diff --git a/packages/kosong/src/errors.ts b/packages/kosong/src/errors.ts index 61accd6af3b..fee43a7e396 100644 --- a/packages/kosong/src/errors.ts +++ b/packages/kosong/src/errors.ts @@ -608,6 +608,36 @@ export function isRecoverableRequestStructureError(error: unknown): boolean { return STRUCTURAL_REQUEST_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); } +// Anthropic rejects a replayed history whose `thinking` blocks it cannot accept +// back: an unverifiable `signature`, a block bound to a different conversation +// prefix, or thinking blocks in the latest assistant message that were altered +// since the original response. All are deterministic 4xx replay failures on +// history that is re-sent every turn, so an unclassified one bricks the session +// permanently. The remedy is specific — drop the offending thinking blocks and +// resend — which is why these patterns are deliberately kept OUT of +// STRUCTURAL_REQUEST_MESSAGE_PATTERNS: the strict re-projection those trigger +// does not touch thinking blocks, so it would burn a retry and fail identically. +// +// Matching is substring-based: the wire message carries a +// `messages.{i}.content.{j}: ` position prefix, and the prefix-mismatch variant +// appends a remedy sentence (and sometimes a required-beta-header sentence) that +// the first anchored pattern covers for free. The configuration family +// ("thinking.type.enabled" is not supported, `block_binding: Extra inputs are +// not permitted`, …) must NOT match — those need a request-shape change, not a +// history repair. +const THINKING_BLOCK_MESSAGE_PATTERNS = [ + /invalid\s+['"`]?signature['"`]?\s+in\s+['"`]?thinking['"`]?\s+block/, + /thinking[\s\S]*blocks in the latest assistant message cannot be modified/, +] as const; + +export function isThinkingSignatureError(error: unknown): boolean { + if (!(error instanceof APIStatusError)) return false; + if (error instanceof APIContextOverflowError) return false; + if (error.statusCode !== 400 && error.statusCode !== 422) return false; + const lowerMessage = error.message.toLowerCase(); + return THINKING_BLOCK_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); +} + export function isProviderRateLimitError(error: unknown): boolean { // Quota exhaustion is a 429 but not a rate limit: the rate-limit reactions // (retry, requeue, suspend) cannot help until the account is recharged. diff --git a/packages/kosong/src/index.ts b/packages/kosong/src/index.ts index 65a34dd165b..0cd64685e25 100644 --- a/packages/kosong/src/index.ts +++ b/packages/kosong/src/index.ts @@ -3,6 +3,7 @@ export { createAssistantMessage, createToolMessage, createUserMessage, + encryptedForProtocol, extractText, isContentPart, isToolCall, @@ -15,6 +16,7 @@ export type { FilePart, ImageURLPart, Message, + Protocol, Role, StreamedMessagePart, TextPart, @@ -86,6 +88,7 @@ export { isRecoverableRequestStructureError, isRequestTooLargeStatusError, isRetryableGenerateError, + isThinkingSignatureError, isToolExchangeAdjacencyError, throwIfAbortError, } from './errors'; diff --git a/packages/kosong/src/message.ts b/packages/kosong/src/message.ts index 8d0aedd8b6e..648e3e5aae9 100644 --- a/packages/kosong/src/message.ts +++ b/packages/kosong/src/message.ts @@ -7,10 +7,51 @@ export interface TextPart { text: string; } +/** + * Wire protocol that owns a reasoning blob's format. + * + * A subset of {@link ProviderType}: only these four wires produce or consume an + * opaque reasoning blob. `kimi` speaks the OpenAI chat wire and `vertexai` the + * Google one, and neither emits a blob of its own, so they have no entry here. + */ +export type Protocol = 'anthropic' | 'openai' | 'openai_responses' | 'google-genai'; + export interface ThinkPart { type: 'think'; think: string; - encrypted?: string; // Provider-specific reasoning signature + /** + * Provider-specific reasoning signature — ONE opaque slot written by + * mutually incompatible producers: Anthropic's `signature`, the OpenAI + * Responses API's Fernet `reasoning.encrypted_content`, Google's + * `thoughtSignature`. Always read it through + * {@link encryptedForProtocol}, never directly, when serializing to a wire. + */ + encrypted?: string; + /** + * Which wire produced {@link encrypted}. Absent means "unknown, assume + * compatible": history recorded before this field existed must keep working, + * and we deliberately do not sniff blob formats. Set by every adapter that + * emits a blob, so a mid-session model switch cannot hand one provider + * another's blob as its own signature (Anthropic verifies it and answers + * `400 ... Invalid \`signature\` in \`thinking\` block`, permanently + * poisoning the replayed history). + */ + encryptedProtocol?: Protocol; +} + +/** + * The reasoning blob of `part` if `protocol` may legitimately receive it back, + * otherwise `undefined`. + * + * An untagged blob is treated as compatible with every wire — see + * {@link ThinkPart.encryptedProtocol}. Callers that get `undefined` must fall + * back to their unsigned-thinking behaviour rather than emitting the raw blob. + */ +export function encryptedForProtocol(part: ThinkPart, protocol: Protocol): string | undefined { + if (part.encryptedProtocol !== undefined && part.encryptedProtocol !== protocol) { + return undefined; + } + return part.encrypted; } export interface ImageURLPart { @@ -165,7 +206,11 @@ export function isToolCallPart(part: StreamedMessagePart): part is ToolCallPart * * Supported combinations: * - TextPart + TextPart -> concatenate text - * - ThinkPart + ThinkPart -> concatenate think (refuse if target.encrypted already set) + * - ThinkPart + ThinkPart -> concatenate think (refuse if target.encrypted already + * set), latching the source's reasoning blob together with its + * {@link ThinkPart.encryptedProtocol} provenance tag. The two always travel as + * a pair: a blob that loses its tag reads as untagged, i.e. compatible with + * every wire, which is exactly the replay bug the tag exists to prevent. * - ToolCall + ToolCallPart -> append arguments * * **Routing for parallel tool calls**: When OpenAI (or compatible) APIs stream @@ -193,6 +238,7 @@ export function mergeInPlace(target: StreamedMessagePart, source: StreamedMessag target.think += source.think; if (source.encrypted !== undefined) { target.encrypted = source.encrypted; + target.encryptedProtocol = source.encryptedProtocol; } return true; } diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index 5af85106e2a..d32f767e53b 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -8,7 +8,7 @@ import { throwIfAbortError, } from '#/errors'; import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '#/message'; -import { isToolDeclarationOnlyMessage } from '#/message'; +import { encryptedForProtocol, isToolDeclarationOnlyMessage } from '#/message'; import type { ChatProvider, FinishReason, @@ -531,6 +531,14 @@ function convertMessage(message: Message, model: string): MessageParam { // valid signature and always supplies one, so Anthropic-sourced history // always takes this branch. // + // Foreign-signed: the blob belongs to another wire (the user switched + // models mid-session and the history carries an OpenAI Responses Fernet + // token or a Google thoughtSignature). Anthropic verifies signatures + // cryptographically and answers `400 ... Invalid `signature` in + // `thinking` block` — which, since history is replayed every turn, + // poisons the session permanently. `encryptedForProtocol` withholds such + // a blob so the part falls through to the unsigned handling below. + // // Unsigned: still PRESERVE the thinking, emitted *without* a `signature` // field. Anthropic-compatible backends (e.g. Kimi) stream thinking with // no signature_delta, yet reject a tool-call turn whose thinking is gone @@ -538,11 +546,12 @@ function convertMessage(message: Message, model: string): MessageParam { // here is what broke multi-step tool use on those backends. Claude // models reject unsigned thinking blocks, so those are only preserved // for non-Claude Anthropic-compatible models. - if (part.encrypted !== undefined) { + const signature = encryptedForProtocol(part, 'anthropic'); + if (signature !== undefined) { blocks.push({ type: 'thinking', thinking: part.think, - signature: part.encrypted, + signature, } satisfies ThinkingBlockParam); } else if (shouldPreserveUnsignedThinking(model)) { blocks.push({ type: 'thinking', thinking: part.think } as unknown as ThinkingBlockParam); @@ -761,12 +770,22 @@ class AnthropicStreamedMessage implements StreamedMessage { break; case 'thinking': yield block.signature !== undefined - ? { type: 'think' as const, think: block.thinking ?? '', encrypted: block.signature } + ? { + type: 'think' as const, + think: block.thinking ?? '', + encrypted: block.signature, + encryptedProtocol: 'anthropic' as const, + } : { type: 'think' as const, think: block.thinking ?? '' }; break; case 'redacted_thinking': yield block.data !== undefined - ? { type: 'think' as const, think: '', encrypted: block.data } + ? { + type: 'think' as const, + think: '', + encrypted: block.data, + encryptedProtocol: 'anthropic' as const, + } : { type: 'think' as const, think: '' }; break; case 'tool_use': @@ -819,6 +838,7 @@ class AnthropicStreamedMessage implements StreamedMessage { type: 'think', think: '', encrypted: (block as unknown as { data: string }).data, + encryptedProtocol: 'anthropic', }; break; case 'tool_use': @@ -861,6 +881,7 @@ class AnthropicStreamedMessage implements StreamedMessage { type: 'think', think: '', encrypted: delta.signature, + encryptedProtocol: 'anthropic', }; break; } diff --git a/packages/kosong/src/providers/google-genai.ts b/packages/kosong/src/providers/google-genai.ts index 5015e0a4b6e..46d906b1184 100644 --- a/packages/kosong/src/providers/google-genai.ts +++ b/packages/kosong/src/providers/google-genai.ts @@ -5,7 +5,7 @@ import { normalizeAPIStatusError, } from '#/errors'; import type { Message, StreamedMessagePart, ThinkPart, ToolCall } from '#/message'; -import { isToolDeclarationOnlyMessage } from '#/message'; +import { encryptedForProtocol, isToolDeclarationOnlyMessage } from '#/message'; import type { ChatProvider, FinishReason, @@ -263,8 +263,12 @@ function messageToGoogleGenAI(message: Message): GoogleContent { break; case 'think': { const thoughtPart: GooglePart = { text: part.think, thought: true }; - if (part.encrypted !== undefined && part.encrypted.length > 0) { - thoughtPart.thoughtSignature = part.encrypted; + // A blob minted by another wire is not a Google thoughtSignature; + // withhold it and send the thought text alone rather than replaying a + // foreign signature as ours. + const thoughtSignature = encryptedForProtocol(part, 'google-genai'); + if (thoughtSignature !== undefined && thoughtSignature.length > 0) { + thoughtPart.thoughtSignature = thoughtSignature; } parts.push(thoughtPart); break; @@ -594,6 +598,7 @@ export class GoogleGenAIStreamedMessage implements StreamedMessage { const thinkPart: ThinkPart = { type: 'think', think: p['text'] }; if (typeof thoughtSignature === 'string' && thoughtSignature.length > 0) { thinkPart.encrypted = thoughtSignature; + thinkPart.encryptedProtocol = 'google-genai'; } parts.push(thinkPart); } else if (p['text']) { diff --git a/packages/kosong/src/providers/openai-responses.ts b/packages/kosong/src/providers/openai-responses.ts index bd4e86e08a4..e8164ffcf49 100644 --- a/packages/kosong/src/providers/openai-responses.ts +++ b/packages/kosong/src/providers/openai-responses.ts @@ -5,8 +5,8 @@ import { ChatProviderError, isContextOverflowErrorCode, } from '#/errors'; -import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '#/message'; -import { extractText, isToolDeclarationOnlyMessage } from '#/message'; +import type { ContentPart, Message, StreamedMessagePart, ThinkPart, ToolCall } from '#/message'; +import { encryptedForProtocol, extractText, isToolDeclarationOnlyMessage } from '#/message'; import type { ChatProvider, FinishReason, @@ -582,15 +582,25 @@ function convertMessage( if (part.type === 'think') { // Flush accumulated non-reasoning parts first flushPendingParts(); - // Aggregate consecutive ThinkParts with the same `encrypted` value - const encryptedValue = part.encrypted; + // Aggregate consecutive ThinkParts of the SAME provenance: identical + // `encrypted` blob AND identical `encryptedProtocol` tag. Grouping on + // the blob alone would fuse parts recorded from different wires into + // one reasoning item, and the group's single `encrypted_content` would + // then speak for a part that never carried it. + const encryptedRaw = part.encrypted; + const encryptedTag = part.encryptedProtocol; + // A blob minted by another wire is meaningless as + // `reasoning.encrypted_content` here, so it is withheld and the + // reasoning item goes out with its summaries only. + const encryptedValue = encryptedForProtocol(part, 'openai_responses'); const summaries: unknown[] = [{ type: 'summary_text', text: part.think }]; i += 1; while (i < n) { const nextPart = message.content[i]; if (nextPart === undefined) break; if (nextPart.type !== 'think') break; - if (nextPart.encrypted !== encryptedValue) break; + if (nextPart.encrypted !== encryptedRaw) break; + if (nextPart.encryptedProtocol !== encryptedTag) break; summaries.push({ type: 'summary_text', text: nextPart.think }); i += 1; } @@ -773,19 +783,21 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { const text = readStringField(summary, 'text'); if (text === undefined) continue; hasReasoningSummary = true; - const thinkPart: StreamedMessagePart = { + const thinkPart: ThinkPart = { type: 'think', think: text, }; if (outputItem.encryptedContent !== undefined) { - (thinkPart as { encrypted: string }).encrypted = outputItem.encryptedContent; + thinkPart.encrypted = outputItem.encryptedContent; + thinkPart.encryptedProtocol = 'openai_responses'; } yield thinkPart; } if (!hasReasoningSummary) { - const thinkPart: StreamedMessagePart = { type: 'think', think: '' }; + const thinkPart: ThinkPart = { type: 'think', think: '' }; if (outputItem.encryptedContent !== undefined) { - (thinkPart as { encrypted: string }).encrypted = outputItem.encryptedContent; + thinkPart.encrypted = outputItem.encryptedContent; + thinkPart.encryptedProtocol = 'openai_responses'; } yield thinkPart; } @@ -940,9 +952,10 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { const outputIndex = readNumberField(chunk, 'output_index'); // Same as output_item.added: `item.id` is not the response id. if (item.type === 'reasoning') { - const thinkPart: StreamedMessagePart = { type: 'think', think: '' }; + const thinkPart: ThinkPart = { type: 'think', think: '' }; if (item.encryptedContent !== undefined) { - (thinkPart as { encrypted: string }).encrypted = item.encryptedContent; + thinkPart.encrypted = item.encryptedContent; + thinkPart.encryptedProtocol = 'openai_responses'; } yield thinkPart; } else if (item.type === 'function_call' && typeof item.arguments === 'string') { diff --git a/packages/kosong/test/anthropic.test.ts b/packages/kosong/test/anthropic.test.ts index 6b079a75a62..2cbf393fdd5 100644 --- a/packages/kosong/test/anthropic.test.ts +++ b/packages/kosong/test/anthropic.test.ts @@ -5,20 +5,24 @@ * Run: pnpm exec vitest run packages/kosong/test/anthropic.test.ts */ import { ChatProviderError } from '#/errors'; -import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '#/message'; +import type { ContentPart, Message, StreamedMessagePart, ThinkPart, ToolCall } from '#/message'; +import { mergeInPlace } from '#/message'; import { AnthropicChatProvider, resolveDefaultMaxTokens } from '#/providers/anthropic'; import { matchKnownAnthropicModelProfile, matchUnknownClaudeProfile, LATEST_OPUS_PROFILE } from '#/providers/anthropic-profile'; import type { GenerateOptions } from '#/provider'; import type { Tool } from '#/tool'; import { describe, it, expect, vi } from 'vitest'; -function makeAnthropicResponse(model: string = 'k25') { +function makeAnthropicResponse( + model: string = 'k25', + overrides?: { content?: unknown[] }, +) { return { id: 'msg_test_123', type: 'message', role: 'assistant', model, - content: [{ type: 'text', text: 'Hello' }], + content: overrides?.content ?? [{ type: 'text', text: 'Hello' }], stop_reason: 'end_turn', usage: { input_tokens: 10, output_tokens: 5 }, }; @@ -1760,6 +1764,246 @@ describe('AnthropicChatProvider', () => { }); }); + // ----------------------------------------------------------------------- + // Reasoning-blob provenance + // ----------------------------------------------------------------------- + // + // A mid-session model switch used to hand Anthropic another provider's + // opaque blob verbatim as its own `signature`; Anthropic verifies it + // cryptographically and answers + // `400 messages.1.content.0: Invalid `signature` in `thinking` block`, + // permanently poisoning the replayed history. Real Anthropic signatures + // look like `CAIShQ0KjgEI…`; an OpenAI Responses blob is a Fernet token + // (`gAAAAAB…`) and a Google one a `thoughtSignature` — nothing alike, but + // we gate on the provenance tag rather than sniffing the format. + describe('reasoning-blob provenance', () => { + const ANTHROPIC_SIGNATURE = 'CAIShQ0KjgEIBRgCIkCanthropic-signature-probe'; + const OPENAI_RESPONSES_FERNET = + 'gAAAAABpQ29wZW5haS1mZXJuZXQtcmVhc29uaW5nLWJsb2ItcHJvYmU='; + const GOOGLE_THOUGHT_SIGNATURE = 'CpEBCkYIBRgCIkBnb29nbGUtdGhvdWdodC1zaWduYXR1cmU='; + + function signedThinkingHistory(think: ThinkPart): Message[] { + return [ + { role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }, + { role: 'assistant', content: [think, { type: 'text', text: 'Hello!' }], toolCalls: [] }, + ]; + } + + it('emits the signature for a blob tagged anthropic', async () => { + const messages = await captureAnthropicMessages( + 'claude-opus-4-6', + signedThinkingHistory({ + type: 'think', + think: 'Let me think...', + encrypted: ANTHROPIC_SIGNATURE, + encryptedProtocol: 'anthropic', + }), + ); + + expect(messages[1]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'Let me think...', signature: ANTHROPIC_SIGNATURE }, + { type: 'text', text: 'Hello!', cache_control: { type: 'ephemeral' } }, + ], + }); + }); + + it('emits the signature for an untagged blob (legacy history stays compatible)', async () => { + const messages = await captureAnthropicMessages( + 'claude-opus-4-6', + signedThinkingHistory({ + type: 'think', + think: 'Let me think...', + encrypted: ANTHROPIC_SIGNATURE, + }), + ); + + expect(messages[1]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'Let me think...', signature: ANTHROPIC_SIGNATURE }, + { type: 'text', text: 'Hello!', cache_control: { type: 'ephemeral' } }, + ], + }); + }); + + it.each([ + ['openai_responses', OPENAI_RESPONSES_FERNET], + ['google-genai', GOOGLE_THOUGHT_SIGNATURE], + ['openai', 'openai-chat-reasoning-blob-probe'], + ] as const)( + 'drops a blob tagged %s for a Claude model instead of replaying it as a signature', + async (tag, blob) => { + const messages = await captureAnthropicMessages( + 'claude-opus-4-6', + signedThinkingHistory({ + type: 'think', + think: 'Let me think...', + encrypted: blob, + encryptedProtocol: tag, + }), + ); + + // Claude rejects unsigned thinking, so the mismatch falls through to + // `shouldPreserveUnsignedThinking`, which drops the block entirely. + expect(messages[1]).toEqual({ + role: 'assistant', + content: [{ type: 'text', text: 'Hello!', cache_control: { type: 'ephemeral' } }], + }); + expect(JSON.stringify(messages)).not.toContain(blob); + }, + ); + + it('falls through to the unsigned branch on mismatch for Anthropic-compatible models', async () => { + const messages = await captureAnthropicMessages( + 'compatible-model', + signedThinkingHistory({ + type: 'think', + think: 'Let me think...', + encrypted: OPENAI_RESPONSES_FERNET, + encryptedProtocol: 'openai_responses', + }), + ); + + // PR #222 behaviour: a non-Claude Anthropic-protocol backend still + // needs the thinking back, just without a signature it cannot verify. + expect(messages[1]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'Let me think...' }, + { type: 'text', text: 'Hello!', cache_control: { type: 'ephemeral' } }, + ], + }); + expect(JSON.stringify(messages)).not.toContain(OPENAI_RESPONSES_FERNET); + }); + + it('tags a signed thinking block from a non-stream response as anthropic', async () => { + const provider = createProvider(); + (provider as any)._client.messages.create = vi.fn().mockResolvedValue( + makeAnthropicResponse('k25', { + content: [{ type: 'thinking', thinking: 'reasoned', signature: ANTHROPIC_SIGNATURE }], + }), + ); + + const parts = await collectParts(await provider.generate('', [], [])); + + expect(parts).toEqual([ + { + type: 'think', + think: 'reasoned', + encrypted: ANTHROPIC_SIGNATURE, + encryptedProtocol: 'anthropic', + }, + ]); + }); + + it('tags a redacted thinking block from a non-stream response as anthropic', async () => { + const provider = createProvider(); + (provider as any)._client.messages.create = vi.fn().mockResolvedValue( + makeAnthropicResponse('k25', { + content: [{ type: 'redacted_thinking', data: 'redacted-blob' }], + }), + ); + + const parts = await collectParts(await provider.generate('', [], [])); + + expect(parts).toEqual([ + { + type: 'think', + think: '', + encrypted: 'redacted-blob', + encryptedProtocol: 'anthropic', + }, + ]); + }); + + it('leaves an unsigned non-stream thinking block untagged', async () => { + const provider = createProvider(); + (provider as any)._client.messages.create = vi.fn().mockResolvedValue( + makeAnthropicResponse('k25', { + content: [{ type: 'thinking', thinking: 'reasoned' }], + }), + ); + + const parts = await collectParts(await provider.generate('', [], [])); + + expect(parts).toEqual([{ type: 'think', think: 'reasoned' }]); + }); + + it('tags a streamed signature_delta as anthropic', async () => { + const parts = await collectAnthropicStreamParts([ + { + type: 'content_block_delta', + index: 0, + delta: { type: 'signature_delta', signature: ANTHROPIC_SIGNATURE }, + }, + ]); + + expect(parts).toEqual([ + { + type: 'think', + think: '', + encrypted: ANTHROPIC_SIGNATURE, + encryptedProtocol: 'anthropic', + }, + ]); + }); + + it('tags a streamed redacted_thinking block as anthropic', async () => { + const parts = await collectAnthropicStreamParts([ + { + type: 'content_block_start', + index: 0, + content_block: { type: 'redacted_thinking', data: 'redacted-blob' }, + }, + ]); + + expect(parts).toEqual([ + { + type: 'think', + think: '', + encrypted: 'redacted-blob', + encryptedProtocol: 'anthropic', + }, + ]); + }); + + it('round-trips a streamed signature back onto the Anthropic wire', async () => { + // End-to-end provenance: what the adapter produced must be exactly what + // it is willing to send back, with no tag-driven downgrade. + const streamed = await collectAnthropicStreamParts([ + { type: 'content_block_start', index: 0, content_block: { type: 'thinking' } }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'reasoned' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'signature_delta', signature: ANTHROPIC_SIGNATURE }, + }, + ]); + const merged: ThinkPart = { type: 'think', think: '' }; + for (const part of streamed) mergeInPlace(merged, part); + + const messages = await captureAnthropicMessages( + 'claude-opus-4-6', + signedThinkingHistory(merged), + ); + + expect(merged.encryptedProtocol).toBe('anthropic'); + expect(messages[1]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'reasoned', signature: ANTHROPIC_SIGNATURE }, + { type: 'text', text: 'Hello!', cache_control: { type: 'ephemeral' } }, + ], + }); + }); + }); + it.each([ 'claude-opus-4-6', 'opus-4-6', @@ -2757,7 +3001,12 @@ describe('AnthropicChatProvider', () => { } expect(parts).toEqual([ - { type: 'think', think: 'Let me think...', encrypted: 'sig_abc' }, + { + type: 'think', + think: 'Let me think...', + encrypted: 'sig_abc', + encryptedProtocol: 'anthropic', + }, { type: 'text', text: 'The answer is 4.' }, { type: 'function', @@ -2867,7 +3116,7 @@ describe('AnthropicChatProvider', () => { { type: 'think', think: '' }, { type: 'think', think: 'Let me think' }, { type: 'think', think: ' about this' }, - { type: 'think', think: '', encrypted: 'sig_xyz' }, + { type: 'think', think: '', encrypted: 'sig_xyz', encryptedProtocol: 'anthropic' }, { type: 'text', text: '' }, { type: 'text', text: 'The answer is 4.' }, ]); @@ -3218,7 +3467,7 @@ describe('AnthropicChatProvider', () => { const parts = await collectParts(result); expect(parts).toEqual([ - { type: 'think', think: '', encrypted: 'enc_data_123' }, + { type: 'think', think: '', encrypted: 'enc_data_123', encryptedProtocol: 'anthropic' }, { type: 'text', text: '' }, { type: 'text', text: 'Done.' }, ]); diff --git a/packages/kosong/test/errors.test.ts b/packages/kosong/test/errors.test.ts index f93c2d7354f..15947726171 100644 --- a/packages/kosong/test/errors.test.ts +++ b/packages/kosong/test/errors.test.ts @@ -12,6 +12,7 @@ import { isProviderRateLimitError, isRecoverableRequestStructureError, isRetryableGenerateError, + isThinkingSignatureError, isToolExchangeAdjacencyError, normalizeAPIStatusError, } from '#/errors'; @@ -545,6 +546,120 @@ describe('isRecoverableRequestStructureError', () => { }); }); +const ANTHROPIC_INVALID_THINKING_SIGNATURE = + 'messages.1.content.0: Invalid `signature` in `thinking` block'; + +const ANTHROPIC_THINKING_PREFIX_MISMATCH = + 'messages.3.content.0: Invalid `signature` in `thinking` block. The block is bound to a ' + + 'different conversation. Remove the block, or set ' + + '`thinking.block_binding.prefix_mismatch_behavior` to "drop_block".'; + +const ANTHROPIC_THINKING_PREFIX_MISMATCH_BETA = + `${ANTHROPIC_THINKING_PREFIX_MISMATCH} That setting requires the ` + + '`thinking-binding-controls-2026-08-01` value in the `anthropic-beta` header.'; + +const ANTHROPIC_LATEST_ASSISTANT_THINKING_MODIFIED = + 'messages.5.content.0: `thinking` or `redacted_thinking` blocks in the latest assistant ' + + 'message cannot be modified. These blocks must remain as they were in the original response.'; + +const THINKING_CONFIGURATION_REJECTIONS = [ + '"thinking.type.enabled" is not supported for this model. Use "thinking.type.adaptive" and ' + + '"output_config.effort" to control thinking behavior.', + '"thinking.type.disabled" is not supported for this model.', + 'adaptive thinking is not supported on this model', + 'block_binding: Extra inputs are not permitted', + 'tool_choice: type "tool" and "any" are not supported for this model.', + 'This model does not support assistant message prefill. The conversation must end with a user message.', +]; + +describe('isThinkingSignatureError', () => { + it.each([ + ['the bare invalid-signature 400', ANTHROPIC_INVALID_THINKING_SIGNATURE], + ['the prefix-mismatch invalid-signature 400', ANTHROPIC_THINKING_PREFIX_MISMATCH], + ['the prefix-mismatch 400 with the beta-header suffix', ANTHROPIC_THINKING_PREFIX_MISMATCH_BETA], + ['the modified-latest-assistant-thinking 400', ANTHROPIC_LATEST_ASSISTANT_THINKING_MODIFIED], + ])('matches %s', (_label, message) => { + expect(isThinkingSignatureError(new APIStatusError(400, message))).toBe(true); + }); + + it('also matches a 422 with the same shape', () => { + expect( + isThinkingSignatureError(new APIStatusError(422, ANTHROPIC_INVALID_THINKING_SIGNATURE)), + ).toBe(true); + expect( + isThinkingSignatureError( + new APIStatusError(422, ANTHROPIC_LATEST_ASSISTANT_THINKING_MODIFIED), + ), + ).toBe(true); + }); + + it('matches on a substring, independent of the messages.{i}.content.{j} prefix', () => { + expect( + isThinkingSignatureError( + new APIStatusError(400, 'messages.417.content.12: Invalid `signature` in `thinking` block'), + ), + ).toBe(true); + expect( + isThinkingSignatureError(new APIStatusError(400, 'Invalid `signature` in `thinking` block')), + ).toBe(true); + }); + + it.each(THINKING_CONFIGURATION_REJECTIONS)( + 'does not match the configuration-family rejection "%s"', + (message) => { + expect(isThinkingSignatureError(new APIStatusError(400, message))).toBe(false); + }, + ); + + it('does not match a context-overflow 400 or an unrelated 400', () => { + expect( + isThinkingSignatureError( + new APIContextOverflowError(400, ANTHROPIC_INVALID_THINKING_SIGNATURE), + ), + ).toBe(false); + expect(isThinkingSignatureError(new APIStatusError(400, 'Bad request'))).toBe(false); + expect( + isThinkingSignatureError(new APIStatusError(400, 'messages: roles must alternate')), + ).toBe(false); + }); + + it.each([401, 413, 429, 500])('does not match a %i outside the 400/422 window', (statusCode) => { + expect( + isThinkingSignatureError(new APIStatusError(statusCode, ANTHROPIC_INVALID_THINKING_SIGNATURE)), + ).toBe(false); + }); + + it('does not match non-APIStatusError values', () => { + expect(isThinkingSignatureError(new Error(ANTHROPIC_INVALID_THINKING_SIGNATURE))).toBe(false); + expect(isThinkingSignatureError(ANTHROPIC_INVALID_THINKING_SIGNATURE)).toBe(false); + expect(isThinkingSignatureError({ statusCode: 400, message: 'Invalid `signature`' })).toBe( + false, + ); + expect(isThinkingSignatureError(null)).toBe(false); + expect(isThinkingSignatureError(undefined)).toBe(false); + }); +}); + +describe('thinking-signature errors stay out of the strict re-projection ladder', () => { + it.each([ + ANTHROPIC_INVALID_THINKING_SIGNATURE, + ANTHROPIC_THINKING_PREFIX_MISMATCH, + ANTHROPIC_THINKING_PREFIX_MISMATCH_BETA, + ANTHROPIC_LATEST_ASSISTANT_THINKING_MODIFIED, + ])('is not classified as a recoverable request-structure error: "%s"', (message) => { + expect(isRecoverableRequestStructureError(new APIStatusError(400, message))).toBe(false); + }); + + it.each([ + ANTHROPIC_INVALID_THINKING_SIGNATURE, + ANTHROPIC_THINKING_PREFIX_MISMATCH, + ANTHROPIC_THINKING_PREFIX_MISMATCH_BETA, + ANTHROPIC_LATEST_ASSISTANT_THINKING_MODIFIED, + ])('is not classified as a tool-exchange adjacency error: "%s"', (message) => { + expect(isToolExchangeAdjacencyError(new APIStatusError(400, message))).toBe(false); + }); +}); + describe('isProviderRateLimitError', () => { it('matches explicit HTTP 429 status errors', () => { expect(isProviderRateLimitError(new APIProviderRateLimitError('rate limited'))).toBe(true); diff --git a/packages/kosong/test/google-genai.test.ts b/packages/kosong/test/google-genai.test.ts index eea6746738b..85534f3e2b7 100644 --- a/packages/kosong/test/google-genai.test.ts +++ b/packages/kosong/test/google-genai.test.ts @@ -6,7 +6,7 @@ import { APITimeoutError, ChatProviderError, } from '#/errors'; -import type { Message, StreamedMessagePart, ToolCall } from '#/message'; +import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '#/message'; import { convertGoogleGenAIError, GoogleGenAIChatProvider, @@ -153,6 +153,118 @@ describe('GoogleGenAIChatProvider', () => { ]); }); + // ----------------------------------------------------------------------- + // Reasoning-blob provenance + // ----------------------------------------------------------------------- + // + // `thoughtSignature` is Google's own opaque token. Sending an Anthropic + // signature or an OpenAI Fernet blob in that slot would be a foreign blob + // replayed as ours — the same class of bug that yields + // "Invalid `signature` in `thinking` block" on the Anthropic wire. + describe('reasoning-blob provenance', () => { + const GOOGLE_THOUGHT_SIGNATURE = 'CpEBCkYIBRgCIkBnb29nbGUtdGhvdWdodC1zaWduYXR1cmU='; + const ANTHROPIC_SIGNATURE = 'CAIShQ0KjgEIBRgCIkCanthropic-signature-probe'; + const OPENAI_RESPONSES_FERNET = + 'gAAAAABpQ29wZW5haS1mZXJuZXQtcmVhc29uaW5nLWJsb2ItcHJvYmU='; + + async function captureThoughtParts(think: ContentPart): Promise { + const provider = createProvider({ stream: false }); + const body = await captureRequestBody(provider, '', [], [ + { role: 'assistant', content: [think], toolCalls: [] }, + ]); + const contents = body['contents'] as Array<{ parts: unknown[] }>; + return contents[0]!.parts; + } + + async function collectThoughtParts(part: Record): Promise { + const provider = createProvider({ stream: false }); + ((provider as any)._client.models as Record)['generateContent'] = vi + .fn() + .mockResolvedValue({ + candidates: [{ content: { role: 'model', parts: [part] } }], + }); + + return collectParts(await provider.generate('', [], [])); + } + + it('emits thoughtSignature for a blob tagged google-genai', async () => { + const parts = await captureThoughtParts({ + type: 'think', + think: 'reasoned', + encrypted: GOOGLE_THOUGHT_SIGNATURE, + encryptedProtocol: 'google-genai', + }); + + expect(parts).toEqual([ + { text: 'reasoned', thought: true, thoughtSignature: GOOGLE_THOUGHT_SIGNATURE }, + ]); + }); + + it('emits thoughtSignature for an untagged blob (legacy history stays compatible)', async () => { + const parts = await captureThoughtParts({ + type: 'think', + think: 'reasoned', + encrypted: GOOGLE_THOUGHT_SIGNATURE, + }); + + expect(parts).toEqual([ + { text: 'reasoned', thought: true, thoughtSignature: GOOGLE_THOUGHT_SIGNATURE }, + ]); + }); + + it.each([ + ['anthropic', ANTHROPIC_SIGNATURE], + ['openai_responses', OPENAI_RESPONSES_FERNET], + ['openai', 'openai-chat-reasoning-blob-probe'], + ] as const)('withholds a blob tagged %s from thoughtSignature', async (tag, blob) => { + const parts = await captureThoughtParts({ + type: 'think', + think: 'reasoned', + encrypted: blob, + encryptedProtocol: tag, + }); + + // Only the signature is dropped; the reasoning text still round-trips. + expect(parts).toEqual([{ text: 'reasoned', thought: true }]); + expect(JSON.stringify(parts)).not.toContain(blob); + }); + + it('tags a thought signature from a non-stream response as google-genai', async () => { + const parts = await collectThoughtParts({ + text: 'reasoned', + thought: true, + thoughtSignature: GOOGLE_THOUGHT_SIGNATURE, + }); + + expect(parts).toEqual([ + { + type: 'think', + think: 'reasoned', + encrypted: GOOGLE_THOUGHT_SIGNATURE, + encryptedProtocol: 'google-genai', + }, + ]); + }); + + it('leaves a thought part without a signature untagged', async () => { + const parts = await collectThoughtParts({ text: 'reasoned', thought: true }); + + expect(parts).toEqual([{ type: 'think', think: 'reasoned' }]); + }); + + it('leaves a thought part with an empty signature untagged', async () => { + // Boundary: `thoughtSignature: ''` is already treated as absent by the + // adapter, so there is no blob to label. + const parts = await collectThoughtParts({ + text: 'reasoned', + thought: true, + thoughtSignature: '', + }); + + expect(parts).toEqual([{ type: 'think', think: 'reasoned' }]); + }); + }); + it('maps json_schema response format to response config', async () => { const provider = createProvider(); const history: Message[] = [ @@ -1141,7 +1253,12 @@ describe('GoogleGenAIChatProvider', () => { const parts = await collectParts(stream); expect(parts).toEqual([ - { type: 'think', think: '', encrypted: 'thought-signature' }, + { + type: 'think', + think: '', + encrypted: 'thought-signature', + encryptedProtocol: 'google-genai', + }, { type: 'function', id: expect.stringMatching(/^lookup_/), diff --git a/packages/kosong/test/message.test.ts b/packages/kosong/test/message.test.ts index f0661b55ed1..098a38d3498 100644 --- a/packages/kosong/test/message.test.ts +++ b/packages/kosong/test/message.test.ts @@ -10,10 +10,12 @@ import type { ToolCallPart, VideoURLPart, } from '#/message'; +import type { Protocol } from '#/message'; import { createAssistantMessage, createToolMessage, createUserMessage, + encryptedForProtocol, extractText, getTextContent, isContentPart, @@ -21,6 +23,11 @@ import { isToolCallPart, mergeInPlace, } from '#/message'; +import { + encryptedForProtocol as barrelEncryptedForProtocol, + isThinkingSignatureError as barrelIsThinkingSignatureError, +} from '#/index'; +import { isThinkingSignatureError } from '#/errors'; import { describe, expect, it } from 'vitest'; describe('createUserMessage', () => { it('creates a user message with single text part', () => { @@ -289,6 +296,145 @@ describe('type guards', () => { expect(isToolCallPart(part)).toBe(false); }); }); +// --------------------------------------------------------------------------- +// Reasoning-blob provenance (`ThinkPart.encryptedProtocol`) +// --------------------------------------------------------------------------- +// +// `encrypted` is ONE untagged slot written by three mutually incompatible +// producers: Anthropic puts its `signature` there, the OpenAI Responses API its +// Fernet `reasoning.encrypted_content`, Google its `thoughtSignature`. Replaying +// a foreign blob as our own signature is what produced +// `400 messages.1.content.0: Invalid `signature` in `thinking` block` after a +// mid-session model switch. The tag makes provenance explicit; an ABSENT tag is +// deliberately treated as compatible (legacy history predates the field, and we +// refuse to sniff blob formats). +const ALL_PROTOCOLS: readonly Protocol[] = [ + 'anthropic', + 'openai', + 'openai_responses', + 'google-genai', +]; + +const MISMATCHED_PAIRS: ReadonlyArray = ALL_PROTOCOLS.flatMap( + (tag) => ALL_PROTOCOLS.filter((wire) => wire !== tag).map((wire) => [tag, wire] as const), +); + +describe('encryptedForProtocol', () => { + it.each(ALL_PROTOCOLS)('returns an untagged blob for the %s wire (legacy = compatible)', (wire) => { + const part: ThinkPart = { type: 'think', think: 'reasoning', encrypted: 'blob' }; + + expect(encryptedForProtocol(part, wire)).toBe('blob'); + }); + + it.each(ALL_PROTOCOLS)('returns a blob tagged %s for its own wire', (wire) => { + const part: ThinkPart = { + type: 'think', + think: 'reasoning', + encrypted: 'blob', + encryptedProtocol: wire, + }; + + expect(encryptedForProtocol(part, wire)).toBe('blob'); + }); + + it.each(MISMATCHED_PAIRS)('withholds a blob tagged %s from the %s wire', (tag, wire) => { + const part: ThinkPart = { + type: 'think', + think: 'reasoning', + encrypted: 'blob', + encryptedProtocol: tag, + }; + + expect(encryptedForProtocol(part, wire)).toBeUndefined(); + }); + + it('returns undefined when the part carries no blob and no tag', () => { + const part: ThinkPart = { type: 'think', think: 'reasoning' }; + + expect(encryptedForProtocol(part, 'anthropic')).toBeUndefined(); + }); + + it('returns undefined when the part carries a matching tag but no blob', () => { + const part: ThinkPart = { + type: 'think', + think: 'reasoning', + encryptedProtocol: 'anthropic', + }; + + expect(encryptedForProtocol(part, 'anthropic')).toBeUndefined(); + }); + + it('returns undefined when the part carries a foreign tag but no blob', () => { + const part: ThinkPart = { + type: 'think', + think: 'reasoning', + encryptedProtocol: 'openai_responses', + }; + + expect(encryptedForProtocol(part, 'anthropic')).toBeUndefined(); + }); + + it('treats an explicitly undefined tag as untagged, not as a mismatch', () => { + const part: ThinkPart = { + type: 'think', + think: 'reasoning', + encrypted: 'blob', + encryptedProtocol: undefined, + }; + + expect(encryptedForProtocol(part, 'google-genai')).toBe('blob'); + }); + + it('passes through an empty-string blob when untagged (present-but-empty boundary)', () => { + const part: ThinkPart = { type: 'think', think: 'reasoning', encrypted: '' }; + + expect(encryptedForProtocol(part, 'anthropic')).toBe(''); + }); + + it('withholds an empty-string blob tagged for a foreign wire', () => { + const part: ThinkPart = { + type: 'think', + think: 'reasoning', + encrypted: '', + encryptedProtocol: 'google-genai', + }; + + expect(encryptedForProtocol(part, 'anthropic')).toBeUndefined(); + }); + + it('does not mutate the part it inspects', () => { + const part: ThinkPart = { + type: 'think', + think: 'reasoning', + encrypted: 'blob', + encryptedProtocol: 'anthropic', + }; + + encryptedForProtocol(part, 'openai'); + + expect(part).toEqual({ + type: 'think', + think: 'reasoning', + encrypted: 'blob', + encryptedProtocol: 'anthropic', + }); + }); +}); + +describe('package barrel', () => { + // `isThinkingSignatureError` and `encryptedForProtocol` are consumed by + // `@moonshot-ai/agent-core` through the package root, not through `#/...` + // subpaths. A missing re-export here is invisible inside kosong and only + // breaks the downstream build, so pin both. + it('re-exports encryptedForProtocol from the package root', () => { + expect(barrelEncryptedForProtocol).toBe(encryptedForProtocol); + }); + + it('re-exports isThinkingSignatureError from the package root', () => { + expect(barrelIsThinkingSignatureError).toBe(isThinkingSignatureError); + }); +}); + describe('mergeInPlace', () => { it('merges TextPart + TextPart', () => { const target: TextPart = { type: 'text', text: 'hello' }; @@ -319,6 +465,46 @@ describe('mergeInPlace', () => { expect(target.think).toBe('done'); }); + it('carries the provenance tag along with the blob it labels', () => { + // The tag must never be separated from its blob: a latched blob with a + // lost tag reads as untagged, i.e. compatible with every wire — exactly + // the pre-fix behaviour that replays a foreign signature. + const target: ThinkPart = { type: 'think', think: 'thought' }; + const source: ThinkPart = { + type: 'think', + think: '', + encrypted: 'sig-123', + encryptedProtocol: 'anthropic', + }; + + expect(mergeInPlace(target, source)).toBe(true); + expect(target).toEqual({ + type: 'think', + think: 'thought', + encrypted: 'sig-123', + encryptedProtocol: 'anthropic', + }); + }); + + it('latches an untagged blob without inventing a tag', () => { + const target: ThinkPart = { type: 'think', think: 'thought' }; + const source: ThinkPart = { type: 'think', think: '', encrypted: 'sig-123' }; + + expect(mergeInPlace(target, source)).toBe(true); + expect(target.encrypted).toBe('sig-123'); + expect(target.encryptedProtocol).toBeUndefined(); + }); + + it('leaves the target tag untouched when the source carries no blob', () => { + const target: ThinkPart = { type: 'think', think: 'a' }; + const source: ThinkPart = { type: 'think', think: 'b', encryptedProtocol: 'google-genai' }; + + expect(mergeInPlace(target, source)).toBe(true); + expect(target.think).toBe('ab'); + expect(target.encrypted).toBeUndefined(); + expect(target.encryptedProtocol).toBeUndefined(); + }); + it('merges ToolCall + ToolCallPart (null -> part)', () => { const target: ToolCall = { type: 'function', diff --git a/packages/kosong/test/openai-responses.test.ts b/packages/kosong/test/openai-responses.test.ts index d8c7a6efcb1..c8633f719e4 100644 --- a/packages/kosong/test/openai-responses.test.ts +++ b/packages/kosong/test/openai-responses.test.ts @@ -403,6 +403,285 @@ describe('OpenAIResponsesChatProvider', () => { expect(reasoningItems[1]).toMatchObject({ encrypted_content: 'enc_2' }); }); + // ----------------------------------------------------------------------- + // Reasoning-blob provenance + // ----------------------------------------------------------------------- + // + // `reasoning.encrypted_content` is an OpenAI Fernet token (`gAAAAAB…`). + // Replaying an Anthropic signature or a Google thoughtSignature in that + // slot is meaningless to the Responses API, so a foreign-tagged blob is + // withheld. Untagged blobs stay compatible (legacy history). + describe('reasoning-blob provenance', () => { + const OPENAI_RESPONSES_FERNET = + 'gAAAAABpQ29wZW5haS1mZXJuZXQtcmVhc29uaW5nLWJsb2ItcHJvYmU='; + const ANTHROPIC_SIGNATURE = 'CAIShQ0KjgEIBRgCIkCanthropic-signature-probe'; + const GOOGLE_THOUGHT_SIGNATURE = 'CpEBCkYIBRgCIkBnb29nbGUtdGhvdWdodC1zaWduYXR1cmU='; + + async function captureReasoningItems( + content: ContentPart[], + ): Promise>> { + const provider = createProvider(); + const body = await captureRequestBody(provider, '', [], [ + { role: 'assistant', content, toolCalls: [] }, + ]); + const input = body['input'] as Array>; + return input.filter((item) => item['type'] === 'reasoning'); + } + + async function collectNonStreamParts( + output: unknown[], + ): Promise { + const provider = createProvider(); + (provider as any)._stream = false; + ((provider as any)._client.responses as unknown as Record)['create'] = vi + .fn() + .mockResolvedValue({ + id: 'resp_provenance', + object: 'response', + status: 'completed', + output, + usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 }, + }); + + const stream = await provider.generate('', [], []); + const parts: StreamedMessagePart[] = []; + for await (const part of stream) parts.push(part); + return parts; + } + + it('keeps encrypted_content for a blob tagged openai_responses', async () => { + const items = await captureReasoningItems([ + { + type: 'think', + think: 'reasoned', + encrypted: OPENAI_RESPONSES_FERNET, + encryptedProtocol: 'openai_responses', + }, + ]); + + expect(items).toEqual([ + { + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'reasoned' }], + encrypted_content: OPENAI_RESPONSES_FERNET, + }, + ]); + }); + + it('keeps encrypted_content for an untagged blob (legacy history stays compatible)', async () => { + const items = await captureReasoningItems([ + { type: 'think', think: 'reasoned', encrypted: OPENAI_RESPONSES_FERNET }, + ]); + + expect(items).toEqual([ + { + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'reasoned' }], + encrypted_content: OPENAI_RESPONSES_FERNET, + }, + ]); + }); + + it.each([ + ['anthropic', ANTHROPIC_SIGNATURE], + ['google-genai', GOOGLE_THOUGHT_SIGNATURE], + ['openai', 'openai-chat-reasoning-blob-probe'], + ] as const)('withholds a blob tagged %s from encrypted_content', async (tag, blob) => { + const items = await captureReasoningItems([ + { type: 'think', think: 'reasoned', encrypted: blob, encryptedProtocol: tag }, + ]); + + expect(items).toEqual([ + { + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'reasoned' }], + encrypted_content: undefined, + }, + ]); + expect(JSON.stringify(items)).not.toContain(blob); + }); + + it('aggregates consecutive think parts that share both blob and tag', async () => { + const items = await captureReasoningItems([ + { + type: 'think', + think: 'first', + encrypted: OPENAI_RESPONSES_FERNET, + encryptedProtocol: 'openai_responses', + }, + { + type: 'think', + think: 'second', + encrypted: OPENAI_RESPONSES_FERNET, + encryptedProtocol: 'openai_responses', + }, + ]); + + expect(items).toEqual([ + { + type: 'reasoning', + summary: [ + { type: 'summary_text', text: 'first' }, + { type: 'summary_text', text: 'second' }, + ], + encrypted_content: OPENAI_RESPONSES_FERNET, + }, + ]); + }); + + it('does not merge same-blob think parts carrying different tags', async () => { + // Grouping on `encrypted` alone would silently fuse two parts of + // different provenance into one reasoning item. + const items = await captureReasoningItems([ + { type: 'think', think: 'first', encrypted: 'shared', encryptedProtocol: 'openai_responses' }, + { type: 'think', think: 'second', encrypted: 'shared', encryptedProtocol: 'anthropic' }, + ]); + + expect(items).toEqual([ + { + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'first' }], + encrypted_content: 'shared', + }, + { + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'second' }], + encrypted_content: undefined, + }, + ]); + }); + + it('does not merge a tagged think part with an untagged one carrying the same blob', async () => { + const items = await captureReasoningItems([ + { type: 'think', think: 'first', encrypted: 'shared' }, + { type: 'think', think: 'second', encrypted: 'shared', encryptedProtocol: 'openai_responses' }, + ]); + + expect(items).toEqual([ + { + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'first' }], + encrypted_content: 'shared', + }, + { + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'second' }], + encrypted_content: 'shared', + }, + ]); + }); + + it('does not merge two distinct foreign-tagged think parts that both serialize to undefined', async () => { + const items = await captureReasoningItems([ + { type: 'think', think: 'first', encrypted: ANTHROPIC_SIGNATURE, encryptedProtocol: 'anthropic' }, + { + type: 'think', + think: 'second', + encrypted: GOOGLE_THOUGHT_SIGNATURE, + encryptedProtocol: 'google-genai', + }, + ]); + + expect(items).toEqual([ + { + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'first' }], + encrypted_content: undefined, + }, + { + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'second' }], + encrypted_content: undefined, + }, + ]); + }); + + it('tags a non-stream reasoning item as openai_responses', async () => { + const parts = await collectNonStreamParts([ + { + type: 'reasoning', + encrypted_content: OPENAI_RESPONSES_FERNET, + summary: [{ type: 'summary_text', text: 'Step 1' }], + }, + ]); + + expect(parts).toEqual([ + { + type: 'think', + think: 'Step 1', + encrypted: OPENAI_RESPONSES_FERNET, + encryptedProtocol: 'openai_responses', + }, + ]); + }); + + it('tags a summary-less non-stream reasoning item as openai_responses', async () => { + const parts = await collectNonStreamParts([ + { type: 'reasoning', encrypted_content: OPENAI_RESPONSES_FERNET, summary: [] }, + ]); + + expect(parts).toEqual([ + { + type: 'think', + think: '', + encrypted: OPENAI_RESPONSES_FERNET, + encryptedProtocol: 'openai_responses', + }, + ]); + }); + + it('leaves a non-stream reasoning item without encrypted_content untagged', async () => { + const parts = await collectNonStreamParts([ + { type: 'reasoning', summary: [{ type: 'summary_text', text: 'Step 1' }] }, + ]); + + expect(parts).toEqual([{ type: 'think', think: 'Step 1' }]); + }); + + it('tags a streamed response.output_item.done reasoning item as openai_responses', async () => { + const stream = new OpenAIResponsesStreamedMessage( + makeAsyncIterable([ + { + type: 'response.output_item.done', + item: { + type: 'reasoning', + id: 'reasoning_item_1', + encrypted_content: OPENAI_RESPONSES_FERNET, + }, + }, + ]), + true, + ); + + const parts = await collectStreamParts(stream); + + expect(parts).toEqual([ + { + type: 'think', + think: '', + encrypted: OPENAI_RESPONSES_FERNET, + encryptedProtocol: 'openai_responses', + }, + ]); + }); + + it('leaves a streamed response.output_item.done reasoning item without encrypted_content untagged', async () => { + const stream = new OpenAIResponsesStreamedMessage( + makeAsyncIterable([ + { + type: 'response.output_item.done', + item: { type: 'reasoning', id: 'reasoning_item_2' }, + }, + ]), + true, + ); + + const parts = await collectStreamParts(stream); + + expect(parts).toEqual([{ type: 'think', think: '' }]); + }); + }); + it('toolMessageConversion=extract_text flattens tool result content to a plain string', async () => { const provider = new OpenAIResponsesChatProvider({ model: 'gpt-4.1', @@ -1312,8 +1591,18 @@ describe('OpenAIResponsesChatProvider', () => { for await (const p of stream) parts.push(p); expect(parts).toEqual([ - { type: 'think', think: 'Step 1', encrypted: 'enc_token_abc' }, - { type: 'think', think: 'Step 2', encrypted: 'enc_token_abc' }, + { + type: 'think', + think: 'Step 1', + encrypted: 'enc_token_abc', + encryptedProtocol: 'openai_responses', + }, + { + type: 'think', + think: 'Step 2', + encrypted: 'enc_token_abc', + encryptedProtocol: 'openai_responses', + }, { type: 'text', text: 'done' }, ]); }); @@ -1339,7 +1628,14 @@ describe('OpenAIResponsesChatProvider', () => { const parts: StreamedMessagePart[] = []; for await (const part of stream) parts.push(part); - expect(parts).toEqual([{ type: 'think', think: '', encrypted: 'enc_empty' }]); + expect(parts).toEqual([ + { + type: 'think', + think: '', + encrypted: 'enc_empty', + encryptedProtocol: 'openai_responses', + }, + ]); }); it('non-stream reasoning without encrypted_content yields ThinkPart without encrypted field', async () => { @@ -1700,7 +1996,7 @@ describe('OpenAIResponsesChatProvider', () => { { type: 'think', think: '' }, { type: 'think', think: 'Thinking about' }, { type: 'think', think: ' the answer...' }, - { type: 'think', think: '', encrypted: 'enc_xyz' }, + { type: 'think', think: '', encrypted: 'enc_xyz', encryptedProtocol: 'openai_responses' }, { type: 'text', text: '42' }, ]); }); @@ -1876,7 +2172,14 @@ describe('OpenAIResponsesChatProvider', () => { const parts: StreamedMessagePart[] = []; for await (const p of stream) parts.push(p); - expect(parts).toEqual([{ type: 'think', think: '', encrypted: 'enc_done' }]); + expect(parts).toEqual([ + { + type: 'think', + think: '', + encrypted: 'enc_done', + encryptedProtocol: 'openai_responses', + }, + ]); }); it('yields ThinkPart from response.output_item.done reasoning item without encrypted_content', async () => {