diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index 3c90da24ef..e61de71592 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -1148,6 +1148,113 @@ describe('AnthropicContentConverter', () => { ]); }); + it('cascade-strips a signed thinking block when its sibling tool_use is orphaned in the same pass', () => { + // A thinking block's signature is computed over the full sibling + // content of its turn. If a sibling tool_use is stripped as an + // orphan, the signature no longer matches and replaying it 400s: + // "thinking blocks in the latest assistant message cannot be + // modified". So the thinking block must go with it. + const { messages } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { + role: 'model', + parts: [ + { text: 'reasoning', thought: true, thoughtSignature: 'sig' }, + { text: 'Let me help' }, + { functionCall: { id: 'orphan', name: 'tool', args: {} } }, + ], + }, + { role: 'user', parts: [{ text: 'never mind' }] }, + ], + }); + + const assistantMsg = messages.find((m) => m.role === 'assistant'); + expect(assistantMsg).toBeDefined(); + expect(assistantMsg!.content).toEqual([ + { type: 'text', text: 'Let me help' }, + ]); + }); + + it('does not cascade-strip thinking when a sibling tool_use survives alongside an orphaned one', () => { + // Partial-orphan case: turn = [thinking, tool_use A, tool_use B], + // only A's result comes back -- B is a genuine orphan and is + // stripped, but A survives. The thinking sibling must stay too: it's + // still needed to satisfy Anthropic's manual-mode "final turn must + // begin with thinking when a tool_use is present" rule, and + // cascading here would trade one 400 for another. + const { messages } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { + role: 'model', + parts: [ + { text: 'reasoning', thought: true, thoughtSignature: 'sig' }, + { functionCall: { id: 'a', name: 'tool', args: {} } }, + { functionCall: { id: 'b', name: 'tool', args: {} } }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'a', + name: 'tool', + response: { output: 'ok' }, + }, + }, + ], + }, + ], + }); + + const assistantMsg = messages.find((m) => m.role === 'assistant'); + expect(assistantMsg).toBeDefined(); + const blocks = assistantMsg!.content as Array<{ type: string }>; + expect(blocks[0]?.type).toBe('thinking'); + expect(blocks.some((b) => b.type === 'tool_use')).toBe(true); + expect(blocks).toHaveLength(2); + }); + + it('drops the whole message and merges surrounding user turns when a cascade empties out the turn entirely', () => { + // The bot review's flagged coverage gap: the only existing cascade + // test leaves a surviving `text` block, so `finalBlocks` is never + // empty and the `else` drop branch in cleanOrphanedToolCalls is + // never exercised. Here the turn's only blocks are a signed thinking + // part and an orphaned tool_use, so after the cascade strips both, + // finalBlocks is empty and the whole assistant message must be + // dropped -- and the surrounding user messages must merge. + const { messages } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'before' }] }, + { + role: 'model', + parts: [ + { text: 'reasoning', thought: true, thoughtSignature: 'sig' }, + { functionCall: { id: 'orphan', name: 'tool', args: {} } }, + ], + }, + { role: 'user', parts: [{ text: 'after' }] }, + ], + }); + + expect(messages.some((m) => m.role === 'assistant')).toBe(false); + expect(messages).toHaveLength(1); + expect(messages[0]!.role).toBe('user'); + expect(messages[0]!.content).toEqual([ + { type: 'text', text: 'before' }, + { + type: 'text', + text: 'after', + cache_control: { type: 'ephemeral' }, + }, + ]); + }); + it('cleans orphaned tool_result blocks without matching tool_use', () => { const { messages } = converter.convertGeminiRequestToAnthropic({ model: 'models/test', @@ -1944,6 +2051,77 @@ describe('AnthropicContentConverter', () => { ).toThrow('proxy omitted the thinking signature'); }); + it('fails locally, rather than silently dropping the block, when an EMPTY-text unsigned thinking block belongs to a non-latest step of an active tool-use loop', () => { + // Regression guard for pipeline ordering: dropEmptyTextThinkingBlocks + // must run AFTER this check, not before. An empty-text thinking + // block with no signature is unsigned by the same definition this + // active-loop check uses -- if the empty-text guard ran first it + // would delete the block before this check ever saw it, silently + // swallowing exactly the proxy bug this throw exists to surface + // (the same pass-ordering hazard identified against the removed + // PATCH-B heuristic). + // + // Needs a two-step loop: a single assistant turn is always "the + // latest", and dropEmptyTextThinkingBlocks unconditionally exempts + // the latest turn regardless of ordering, so a one-step fixture + // can't distinguish the two orderings. Step 1's empty-text thinking + // must be on a NON-latest turn that is still part of the unbroken + // tool_use/tool_result chain reaching the end of history. + expect(() => + converter.convertGeminiRequestToAnthropic( + { + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Run tool' }] }, + { + role: 'model', + parts: [ + { text: '', thought: true }, + { functionCall: { id: 't1', name: 'tool', args: {} } }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 't1', + name: 'tool', + response: { output: 'ok' }, + }, + }, + ], + }, + { + role: 'model', + parts: [ + { + text: 'signed reasoning', + thought: true, + thoughtSignature: 'sig', + }, + { functionCall: { id: 't2', name: 'tool', args: {} } }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 't2', + name: 'tool', + response: { output: 'ok' }, + }, + }, + ], + }, + ], + }, + { dropUnsignedAssistantThinking: true }, + ), + ).toThrow('proxy omitted the thinking signature'); + }); + it('drops unsigned thinking from a completed tool-use turn', () => { const { messages } = converter.convertGeminiRequestToAnthropic( { @@ -2039,6 +2217,95 @@ describe('AnthropicContentConverter', () => { }); }); + describe('dropEmptyTextThinkingBlocks', () => { + it('leaves a signed, non-empty thinking block on a non-latest turn untouched', () => { + // A broader cross-turn heuristic here (detecting "this turn's + // tool_use went stale in an earlier trim" and downgrading its + // thinking to text) was removed after review: it couldn't + // distinguish that state from "this turn was always thinking-only", + // and live verification showed it rewriting turns that were never + // actually invalid. Only an empty-text thinking block is + // unconditionally invalid regardless of tool_use presence; a + // populated, signed thinking block is left exactly as-is. + const { messages } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { + role: 'model', + parts: [ + { + text: 'stale reasoning', + thought: true, + thoughtSignature: 'sig', + }, + ], + }, + { role: 'user', parts: [{ text: 'anything else?' }] }, + { role: 'model', parts: [{ text: 'Sure, here you go.' }] }, + ], + }); + + const olderAssistant = messages[1]; + expect(olderAssistant.role).toBe('assistant'); + expect(olderAssistant.content).toEqual([ + { type: 'thinking', thinking: 'stale reasoning', signature: 'sig' }, + ]); + }); + + it('drops an empty redacted_thinking-derived turn entirely (defensive, no plaintext fallback)', () => { + // convertAnthropicResponseToGemini represents a redacted_thinking + // block as `{ text: '', thought: true }` (its opaque `data` doesn't + // survive the Gemini-Part round trip -- see that method's doc). When + // this round-trips back through processContent it becomes an + // empty-text `thinking` block on the wire, which this defensive + // guard drops outright, dropping the whole message since nothing + // else survives. + const { messages } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { + role: 'model', + parts: [{ text: '', thought: true }], + }, + { role: 'user', parts: [{ text: 'anything else?' }] }, + { role: 'model', parts: [{ text: 'Sure, here you go.' }] }, + ], + }); + + const assistantMessages = messages.filter((m) => m.role === 'assistant'); + expect(assistantMessages).toHaveLength(1); + expect(assistantMessages[0].content).toEqual([ + { type: 'text', text: 'Sure, here you go.' }, + ]); + }); + + it('leaves the latest assistant turn untouched even with empty-text thinking', () => { + // The latestAssistantIdx short-circuit fires before the empty-text + // filter runs at all, so this must hold regardless of content -- use + // an actually-empty-text block (matching the title) rather than a + // populated one, so this test would fail if the exemption were ever + // narrowed to "non-empty-text latest turns only". + const { messages } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { + role: 'model', + parts: [{ text: '', thought: true, thoughtSignature: 'sig' }], + }, + ], + }); + + const lastMsg = messages[messages.length - 1]; + expect(lastMsg.role).toBe('assistant'); + expect(lastMsg.content).toEqual([ + { type: 'thinking', thinking: '', signature: 'sig' }, + ]); + }); + }); + // https://github.com/QwenLM/qwen-code/issues/3786 — DeepSeek's // anthropic-compatible API rejects requests in thinking mode when a prior // assistant turn carrying `tool_use` omits a thinking block. Plain-text diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index b1bdb5d7f3..bdc3f91e18 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -276,9 +276,33 @@ export class AnthropicContentConverter { messages = mergeConsecutiveAssistantMessages(messages); messages = cleanOrphanedToolCalls(messages); messages = mergeConsecutiveAssistantMessages(messages); + // Must run BEFORE dropEmptyTextThinkingBlocks: dropUnsignedThinking... + // throws when an unsigned thinking block belongs to a turn that's part + // of an unbroken, still-active tool_use/tool_result chain reaching the + // end of history -- a real proxy bug that should fail loudly rather + // than silently continue. An empty-text thinking block with no + // signature is unsigned by this same definition; if the empty-text + // guard ran first it would delete the block outright before this + // check ever saw it, silently swallowing exactly the proxy bug this + // throw exists to surface (the same pass-ordering hazard raised + // against the removed PATCH-B heuristic, which retyped instead of + // deleted but had the identical effect of hiding the block from this + // check). if (options.dropUnsignedAssistantThinking) { messages = this.dropUnsignedThinkingFromAssistantMessages(messages); } + // Defense-in-depth against an empty-text thinking block surviving into + // a non-latest turn (see dropEmptyTextThinkingBlocks's doc) -- e.g. one + // that DOES carry a signature, so dropUnsignedThinkingFromAssistant... + // above leaves it alone. Skipped for DeepSeek's injectThinkingOnToolUseTurns + // path: DeepSeek's synthetic thinking placeholder (injected above) is + // deliberately `{type:'thinking', thinking:'', signature:''}` on every + // tool-use turn, and DeepSeek doesn't validate a signature the way + // Anthropic does, so this guard would strip the very placeholder + // DeepSeek needs. + if (!options.injectThinkingOnToolUseTurns) { + messages = dropEmptyTextThinkingBlocks(messages); + } if (options.stripAssistantThinking) { this.stripThinkingFromAssistantMessages(messages); } @@ -1427,6 +1451,19 @@ function makeToolResultDeduper(): (id: string | undefined) => boolean { * Remove tool_use blocks that have no matching tool_result in the * immediately following user message, and remove tool_result blocks that * have no matching tool_use in the immediately preceding assistant message. + * Also cascade-strips `thinking`/`redacted_thinking` blocks from an + * assistant turn whenever a `tool_use` is removed from that same turn by + * this pass AND no other `tool_use` survives in it -- the signature on + * those blocks was computed over content that included the now-removed + * `tool_use`, so replaying it produces Anthropic 400 "thinking blocks in + * the latest assistant message cannot be modified". The model regenerates + * thinking on its next turn regardless. Scoped to "no surviving tool_use" + * rather than "any tool_use removed": a turn with `[thinking, tool_use A, + * tool_use B]` where only B is a genuine orphan still sends A on the wire, + * and per Anthropic's manual-mode extended-thinking contract the final + * assistant turn of a thinking-enabled request must begin with a thinking + * block when any `tool_use` remains in it -- stripping the thinking here + * would trade one 400 for another. * * A `tool_use` in the very last message (no message follows it at all) is * never condemned as orphaned here -- "no result yet" isn't the same as @@ -1438,8 +1475,10 @@ function makeToolResultDeduper(): (id: string | undefined) => boolean { * matching `tool_result` is a genuine orphan. * * Empty messages produced by the cleanup are dropped entirely. A subsequent - * mergeConsecutiveAssistantMessages call fixes any alternation issues - * created by dropped messages. + * mergeConsecutiveAssistantMessages call fixes alternation issues created + * by a dropped assistant message sandwiched between two other assistant + * messages; mergeConsecutiveUserMessages (later in the pipeline) does the + * same when the sandwiching messages are user turns instead. * * Mirrors the same-name function in the OpenAI converter. */ @@ -1525,13 +1564,16 @@ function cleanOrphanedToolCalls( continue; } + let toolUseRemoved = false; const keepToolResult = makeToolResultDeduper(); const filtered = blocks.filter((b) => { const t = (b as { type?: string }).type; if (t === 'tool_use') { const id = (b as { id?: string }).id; - return !id || validToolUseBlocks.has(b as object); + const keep = !id || validToolUseBlocks.has(b as object); + if (!keep) toolUseRemoved = true; + return keep; } if (t === 'tool_result') { const id = (b as { tool_use_id?: string }).tool_use_id; @@ -1542,8 +1584,28 @@ function cleanOrphanedToolCalls( return true; }); - if (filtered.length > 0) { - cleaned.push({ ...message, content: filtered }); + // A tool_use was stripped from this turn and none survives -- any + // thinking/redacted_thinking sibling in the same turn is now + // untrustworthy (see function doc). If a tool_use survives, the + // thinking sibling is left in place: it's still needed to satisfy + // Anthropic's manual-mode "final turn must begin with thinking when a + // tool_use is present" rule, and only cascading on total removal keeps + // this narrower than a blanket "any removal" rule. tool_use/thinking + // only ever co-occur on assistant messages, but the role check is + // defensive. + const survivingToolUse = filtered.some( + (b) => (b as { type?: string }).type === 'tool_use', + ); + const finalBlocks = + toolUseRemoved && !survivingToolUse && message.role === 'assistant' + ? filtered.filter((b) => { + const t = (b as { type?: string }).type; + return t !== 'thinking' && t !== 'redacted_thinking'; + }) + : filtered; + + if (finalBlocks.length > 0) { + cleaned.push({ ...message, content: finalBlocks }); } else { debugLogger.debug( 'cleanOrphanedToolCalls: dropping message with only orphaned tool blocks', @@ -1554,6 +1616,85 @@ function cleanOrphanedToolCalls( return cleaned; } +/** + * Drops any `thinking` block with empty text from a non-latest assistant + * turn (dropping the whole message if that empties it out). An Anthropic + * `thinking` block's signature is computed over its own text content; a + * block with no text at all cannot represent valid signed reasoning + * regardless of whether a signature is present. This arises when a + * `redacted_thinking` block -- whose opaque `data` doesn't survive the + * Gemini-`Part` round trip, see + * {@link AnthropicContentConverter.convertAnthropicResponseToGemini} -- + * is replayed back through history construction as an empty-text + * `thinking` block. + * + * Scoped to non-latest assistant turns, matching Anthropic's contract that + * the latest assistant turn's signatures must replay byte-exact. + * + * This was originally one guard inside a larger `pruneUntrustworthyThinking` + * pass that also tried to detect and downgrade a non-latest, thinking-only + * turn whose `tool_use` had gone stale in an earlier trim (a cross-turn + * complement to {@link cleanOrphanedToolCalls}'s same-turn cascade). That + * broader heuristic was removed after review: it could not distinguish "this + * turn's tool_use was removed by an earlier trim" from "this turn was + * always thinking-only" (both are structurally identical by the time it + * ran), it ran before the passes that already handle unsigned thinking + * correctly (reordering caused them to stop recognizing thinking it had + * already re-typed as text), its DeepSeek exclusion only covered one of + * DeepSeek's two thinking modes, and live A/B verification against a real + * session showed it re-typing a thinking-only turn on the very next + * request just because a newer assistant turn had been appended -- + * invalidating a cache breakpoint and adding token cost for content that + * was never actually invalid. Investigation into this codebase's actual + * compaction (`chatCompressionService` is full-history, not a partial + * trim that could strand a `tool_use`) and orphan-repair + * (`repairOrphanedToolUseTurns` already synthesizes an error + * `tool_result` for a genuine cross-turn orphan before it would reach this + * pass) did not reproduce the state the broader heuristic existed to + * clean up. This guard is the one part of that pass that is unconditionally + * correct regardless of that heuristic's premise, so it's kept on its own. + */ +function dropEmptyTextThinkingBlocks( + messages: AnthropicMessageParam[], +): AnthropicMessageParam[] { + let latestAssistantIdx = -1; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i]!.role === 'assistant') { + latestAssistantIdx = i; + break; + } + } + + const out: AnthropicMessageParam[] = []; + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]!; + if (msg.role !== 'assistant' || !Array.isArray(msg.content)) { + out.push(msg); + continue; + } + if (i === latestAssistantIdx) { + out.push(msg); + continue; + } + + const blocks = msg.content as AnthropicContentBlockParam[]; + const filtered = blocks.filter((raw) => { + const bType = (raw as { type?: string }).type; + const bThinkingRaw = (raw as { thinking?: unknown }).thinking; + const bThinking = + typeof bThinkingRaw === 'string' ? bThinkingRaw : undefined; + return !( + bType === 'thinking' && + (bThinking === undefined || bThinking.length === 0) + ); + }); + + if (filtered.length === 0) continue; + out.push({ role: msg.role, content: filtered }); + } + return out; +} + function mergeConsecutiveUserMessages( messages: AnthropicMessageParam[], ): AnthropicMessageParam[] {