diff --git a/.changeset/remove-thinking-space-placeholder.md b/.changeset/remove-thinking-space-placeholder.md new file mode 100644 index 0000000000..97acdc099b --- /dev/null +++ b/.changeset/remove-thinking-space-placeholder.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Replay empty thinking content verbatim instead of substituting a placeholder space on Anthropic-compatible and Kimi preserved-thinking endpoints. diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts index 1de66bfa1a..011fb34ea1 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts @@ -252,20 +252,6 @@ function shouldPreserveUnsignedThinking(model: string): boolean { ); } -function shouldBackfillPreservedThinking( - model: string, - thinking: MessageCreateParams['thinking'] | undefined, - contextManagement: AnthropicContextManagement | undefined, -): boolean { - return ( - shouldPreserveUnsignedThinking(model) && - thinking?.type !== 'disabled' && - contextManagement?.edits.some( - (edit) => edit.type === CLEAR_THINKING_EDIT && edit.keep === 'all', - ) === true - ); -} - const CACHEABLE_TYPES = new Set([ 'text', 'image', @@ -411,11 +397,7 @@ function toolResultToBlock(toolCallId: string, content: ContentPart[]): ToolResu content: blocks, } as ToolResultBlockParam; } -function convertMessage( - message: Message, - model: string, - backfillPreservedThinking: boolean, -): MessageParam { +function convertMessage(message: Message, model: string): MessageParam { const role = message.role; if (role === 'system') { @@ -438,26 +420,19 @@ function convertMessage( } const blocks: ContentBlockParam[] = []; - let hasThinkingPart = false; - let lastUnsignedThinkingBlockIndex: number | undefined; - let hasNonEmptyEmittedThinking = false; for (const part of message.content) { if (part.type === 'text') { blocks.push({ type: 'text', text: part.text } satisfies TextBlockParam); } else if (part.type === 'image_url') { blocks.push(imageUrlPartToAnthropic(part.imageUrl.url) as unknown as ContentBlockParam); } else if (part.type === 'think') { - hasThinkingPart = true; if (part.encrypted !== undefined) { - hasNonEmptyEmittedThinking ||= part.think.length > 0; blocks.push({ type: 'thinking', thinking: part.think, signature: part.encrypted, } satisfies ThinkingBlockParam); } else if (shouldPreserveUnsignedThinking(model)) { - lastUnsignedThinkingBlockIndex = blocks.length; - hasNonEmptyEmittedThinking ||= part.think.length > 0; blocks.push({ type: 'thinking', thinking: part.think } as unknown as ThinkingBlockParam); } } else if (part.type === 'video_url') { @@ -471,20 +446,6 @@ function convertMessage( } } - if (role === 'assistant' && backfillPreservedThinking) { - if (!hasThinkingPart) { - blocks.unshift({ type: 'thinking', thinking: ' ' } as unknown as ThinkingBlockParam); - } else if ( - lastUnsignedThinkingBlockIndex !== undefined && - !hasNonEmptyEmittedThinking - ) { - blocks[lastUnsignedThinkingBlockIndex] = { - type: 'thinking', - thinking: ' ', - } as unknown as ThinkingBlockParam; - } - } - if (message.toolCalls.length > 0) { for (const tc of message.toolCalls) { let toolInput: Record = {}; @@ -881,18 +842,12 @@ export class AnthropicChatProvider implements ChatProvider { ] : undefined; - const backfillPreservedThinking = shouldBackfillPreservedThinking( - this._model, - this._generationKwargs.thinking, - this._generationKwargs.contextManagement, - ); - const messages = mergeConsecutiveUserMessages( normalizeToolCallIdsForProvider( history.filter((msg) => !isToolDeclarationOnlyMessage(msg)), ANTHROPIC_TOOL_CALL_ID_POLICY, ) - .map((msg) => convertMessage(msg, this._model, backfillPreservedThinking)) + .map((msg) => convertMessage(msg, this._model)) .filter(shouldKeepConvertedMessage), { isUser: (message) => message.role === 'user', diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts index 74c3fda748..e02bb343f5 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts @@ -157,10 +157,7 @@ function convertMessage(message: Message, preservedThinkingEnabled: boolean): Op } if (hasReasoningPart || (preservedThinkingEnabled && message.role === 'assistant')) { - result.reasoning_content = - preservedThinkingEnabled && message.role === 'assistant' && reasoningContent.length === 0 - ? ' ' - : reasoningContent; + result.reasoning_content = reasoningContent; } if (message.tools !== undefined && message.tools.length > 0) { diff --git a/packages/agent-core-v2/test/app/llmProtocol/providers/anthropic-context-management.test.ts b/packages/agent-core-v2/test/app/llmProtocol/providers/anthropic-context-management.test.ts index 3f9c499346..95d352a5a8 100644 --- a/packages/agent-core-v2/test/app/llmProtocol/providers/anthropic-context-management.test.ts +++ b/packages/agent-core-v2/test/app/llmProtocol/providers/anthropic-context-management.test.ts @@ -97,7 +97,7 @@ describe('Anthropic withThinkingKeep context_management parity', () => { ]); }); - it('backfills non-empty thinking when compatible text history is replayed with keep all', async () => { + it('replays compatible text history without injecting thinking with keep all', async () => { const history: Message[] = [ { role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }, { @@ -116,14 +116,11 @@ describe('Anthropic withThinkingKeep context_management parity', () => { expect(messages[1]).toEqual({ role: 'assistant', - content: [ - { type: 'thinking', thinking: ' ' }, - { type: 'text', text: 'Hello' }, - ], + content: [{ type: 'text', text: 'Hello' }], }); }); - it('backfills non-empty thinking before a compatible assistant tool call with keep all', async () => { + it('replays a compatible assistant tool call without injecting thinking with keep all', async () => { const history: Message[] = [ { role: 'assistant', @@ -143,7 +140,6 @@ describe('Anthropic withThinkingKeep context_management parity', () => { expect(messages[0]).toEqual({ role: 'assistant', content: [ - { type: 'thinking', thinking: ' ' }, { type: 'tool_use', id: 'call_1', @@ -155,7 +151,7 @@ describe('Anthropic withThinkingKeep context_management parity', () => { }); }); - it('replaces an existing empty thinking block when compatible history uses keep all', async () => { + it('preserves an existing empty thinking block when compatible history uses keep all', async () => { const history: Message[] = [ { role: 'assistant', @@ -176,7 +172,7 @@ describe('Anthropic withThinkingKeep context_management parity', () => { expect(messages[0]).toEqual({ role: 'assistant', content: [ - { type: 'thinking', thinking: ' ' }, + { type: 'thinking', thinking: '' }, { type: 'text', text: 'Hello', cache_control: { type: 'ephemeral' } }, ], }); diff --git a/packages/agent-core-v2/test/app/llmProtocol/providers/empty-thinking-roundtrip.test.ts b/packages/agent-core-v2/test/app/llmProtocol/providers/empty-thinking-roundtrip.test.ts index 351135b956..fb91e0eaa4 100644 --- a/packages/agent-core-v2/test/app/llmProtocol/providers/empty-thinking-roundtrip.test.ts +++ b/packages/agent-core-v2/test/app/llmProtocol/providers/empty-thinking-roundtrip.test.ts @@ -157,7 +157,7 @@ describe('empty thinking round-trip', () => { expect(messages[0]).toHaveProperty('reasoning_content', ''); }); - it('Kimi backfills an assistant tool-call message when preserved thinking is active', async () => { + it('Kimi sends empty reasoning_content for an assistant tool-call message when preserved thinking is active', async () => { const history: Message[] = [ { role: 'assistant', @@ -172,10 +172,10 @@ describe('empty thinking round-trip', () => { provider.withExtraBody({ thinking: { type: 'enabled', keep: 'all' } }), ); - expect(messages[0]).toHaveProperty('reasoning_content', ' '); + expect(messages[0]).toHaveProperty('reasoning_content', ''); }); - it('Kimi backfills a text assistant message when keep=all omits thinking.type', async () => { + it('Kimi sends empty reasoning_content for a text assistant message when keep=all omits thinking.type', async () => { const history: Message[] = [ { role: 'assistant', @@ -188,15 +188,15 @@ describe('empty thinking round-trip', () => { provider.withExtraBody({ thinking: { keep: 'all' } }), ); - expect(messages[0]).toHaveProperty('reasoning_content', ' '); + expect(messages[0]).toHaveProperty('reasoning_content', ''); }); - it('Kimi replaces an existing empty ThinkPart when preserved thinking is active', async () => { + it('Kimi replays an existing empty ThinkPart unchanged when preserved thinking is active', async () => { const messages = await captureKimiMessages(EMPTY_THINKING_TOOL_HISTORY, (provider) => provider.withExtraBody({ thinking: { type: 'enabled', keep: 'all' } }), ); - expect(messages[0]).toHaveProperty('reasoning_content', ' '); + expect(messages[0]).toHaveProperty('reasoning_content', ''); }); it('Kimi sends existing non-empty thinking verbatim when preserved thinking is active', async () => { @@ -431,7 +431,7 @@ describe('empty thinking round-trip', () => { }); }); - it('Anthropic-compatible providers replace only the last empty unsigned thinking block', async () => { + it('Anthropic-compatible providers preserve all empty unsigned thinking blocks unchanged', async () => { const history: Message[] = [ { role: 'assistant', @@ -457,7 +457,7 @@ describe('empty thinking round-trip', () => { expect(messages[0]!.content).toEqual([ { type: 'thinking', thinking: '' }, - { type: 'thinking', thinking: ' ' }, + { type: 'thinking', thinking: '' }, { type: 'text', text: 'Done.' }, ]); }); @@ -506,7 +506,7 @@ describe('empty thinking round-trip', () => { ]); }); - it('Anthropic-compatible providers replace the last empty unsigned block after empty signed thinking', async () => { + it('Anthropic-compatible providers preserve an empty unsigned block after empty signed thinking', async () => { const history: Message[] = [ { role: 'assistant', @@ -526,7 +526,7 @@ describe('empty thinking round-trip', () => { expect(messages[0]!.content).toEqual([ { type: 'thinking', thinking: '', signature: 'signed-thinking' }, - { type: 'thinking', thinking: ' ' }, + { type: 'thinking', thinking: '' }, ]); }); diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index 2f6c00faee..43e4d7ca81 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -325,20 +325,6 @@ function shouldPreserveUnsignedThinking(model: string): boolean { ); } -function shouldBackfillPreservedThinking( - model: string, - thinking: MessageCreateParams['thinking'] | undefined, - contextManagement: AnthropicContextManagement | undefined, -): boolean { - return ( - shouldPreserveUnsignedThinking(model) && - thinking?.type !== 'disabled' && - contextManagement?.edits.some( - (edit) => edit.type === CLEAR_THINKING_EDIT && edit.keep === 'all', - ) === true - ); -} - /** * Content block types that support cache_control injection. */ @@ -497,11 +483,7 @@ function toolResultToBlock(toolCallId: string, content: ContentPart[]): ToolResu content: blocks, } as ToolResultBlockParam; } -function convertMessage( - message: Message, - model: string, - backfillPreservedThinking: boolean, -): MessageParam { +function convertMessage(message: Message, model: string): MessageParam { const role = message.role; // system role -> ... wrapped user message @@ -527,16 +509,12 @@ function convertMessage( // user or assistant const blocks: ContentBlockParam[] = []; - let hasThinkingPart = false; - let lastUnsignedThinkingBlockIndex: number | undefined; - let hasNonEmptyEmittedThinking = false; for (const part of message.content) { if (part.type === 'text') { blocks.push({ type: 'text', text: part.text } satisfies TextBlockParam); } else if (part.type === 'image_url') { blocks.push(imageUrlPartToAnthropic(part.imageUrl.url) as unknown as ContentBlockParam); } else if (part.type === 'think') { - hasThinkingPart = true; // ThinkPart -> ThinkingBlockParam. // // Signed: emit the block with its signature. api.anthropic.com requires a @@ -556,11 +534,8 @@ function convertMessage( thinking: part.think, signature: part.encrypted, } satisfies ThinkingBlockParam); - hasNonEmptyEmittedThinking ||= part.think.length > 0; } else if (shouldPreserveUnsignedThinking(model)) { - lastUnsignedThinkingBlockIndex = blocks.length; blocks.push({ type: 'thinking', thinking: part.think } as unknown as ThinkingBlockParam); - hasNonEmptyEmittedThinking ||= part.think.length > 0; } } else if (part.type === 'video_url') { blocks.push(videoUrlPartToAnthropic(part.videoUrl.url) as unknown as ContentBlockParam); @@ -573,23 +548,6 @@ function convertMessage( } } - if (role === 'assistant' && backfillPreservedThinking) { - // Some compatible endpoints require every replayed assistant message to - // carry non-empty thinking. Keep the placeholder wire-only, and never - // alter signed blocks because their text is covered by the signature. - if (!hasThinkingPart) { - blocks.unshift({ type: 'thinking', thinking: ' ' } as unknown as ThinkingBlockParam); - } else if ( - lastUnsignedThinkingBlockIndex !== undefined && - !hasNonEmptyEmittedThinking - ) { - blocks[lastUnsignedThinkingBlockIndex] = { - type: 'thinking', - thinking: ' ', - } as unknown as ThinkingBlockParam; - } - } - // Tool calls -> ToolUseBlockParam if (message.toolCalls.length > 0) { for (const tc of message.toolCalls) { @@ -1006,12 +964,6 @@ export class AnthropicChatProvider implements ChatProvider { ] : undefined; - const backfillPreservedThinking = shouldBackfillPreservedThinking( - this._model, - this._generationKwargs.thinking, - this._generationKwargs.contextManagement, - ); - // Convert messages, then merge consecutive user messages into one. Strict // Anthropic-compatible backends reject consecutive user messages with HTTP // 400 ("roles must alternate"), and api.anthropic.com concatenates them @@ -1031,7 +983,7 @@ export class AnthropicChatProvider implements ChatProvider { history.filter((msg) => !isToolDeclarationOnlyMessage(msg)), ANTHROPIC_TOOL_CALL_ID_POLICY, ) - .map((msg) => convertMessage(msg, this._model, backfillPreservedThinking)) + .map((msg) => convertMessage(msg, this._model)) .filter(shouldKeepConvertedMessage), { isUser: (message) => message.role === 'user', diff --git a/packages/kosong/src/providers/kimi.ts b/packages/kosong/src/providers/kimi.ts index c85531e21a..0bd6a14ebb 100644 --- a/packages/kosong/src/providers/kimi.ts +++ b/packages/kosong/src/providers/kimi.ts @@ -168,12 +168,7 @@ function convertMessage(message: Message, preservedThinkingEnabled: boolean): Op } if (hasReasoningPart || (preservedThinkingEnabled && message.role === 'assistant')) { - // Keep the non-empty replay placeholder on the wire; canonical history - // continues to retain the original empty reasoning value. - result.reasoning_content = - preservedThinkingEnabled && message.role === 'assistant' && reasoningContent.length === 0 - ? ' ' - : reasoningContent; + result.reasoning_content = reasoningContent; } // Message-level tool declarations: a system message carrying `tools` loads diff --git a/packages/kosong/test/anthropic.test.ts b/packages/kosong/test/anthropic.test.ts index 739f00ae62..012aa701d2 100644 --- a/packages/kosong/test/anthropic.test.ts +++ b/packages/kosong/test/anthropic.test.ts @@ -437,7 +437,7 @@ describe('withThinkingKeep (context_management)', () => { expect(body['betas']).toContain('context-management-2025-06-27'); }); - it('backfills non-empty thinking when compatible text history is replayed with keep all', async () => { + it('replays compatible text history without injecting thinking when keep all is active', async () => { const compatibleHistory: Message[] = [ { role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }, { @@ -455,14 +455,11 @@ describe('withThinkingKeep (context_management)', () => { expect(messages[1]).toEqual({ role: 'assistant', - content: [ - { type: 'thinking', thinking: ' ' }, - { type: 'text', text: 'Hello' }, - ], + content: [{ type: 'text', text: 'Hello' }], }); }); - it('backfills non-empty thinking before a compatible assistant tool call with keep all', async () => { + it('replays a compatible assistant tool call without injecting thinking when keep all is active', async () => { const compatibleHistory: Message[] = [ { role: 'assistant', @@ -481,7 +478,6 @@ describe('withThinkingKeep (context_management)', () => { expect(messages[0]).toEqual({ role: 'assistant', content: [ - { type: 'thinking', thinking: ' ' }, { type: 'tool_use', id: 'call_1', @@ -493,7 +489,7 @@ describe('withThinkingKeep (context_management)', () => { }); }); - it('makes an existing unsigned empty thinking block non-empty when keep all is active', async () => { + it('preserves an existing unsigned empty thinking block unchanged when keep all is active', async () => { const compatibleHistory: Message[] = [ { role: 'assistant', @@ -513,13 +509,13 @@ describe('withThinkingKeep (context_management)', () => { expect(messages[0]).toEqual({ role: 'assistant', content: [ - { type: 'thinking', thinking: ' ' }, + { type: 'thinking', thinking: '' }, { type: 'text', text: 'Hello', cache_control: { type: 'ephemeral' } }, ], }); }); - it('makes only the last unsigned block non-empty when every unsigned block is empty', async () => { + it('preserves all unsigned blocks unchanged when every one is empty', async () => { const compatibleHistory: Message[] = [ { role: 'assistant', @@ -540,12 +536,12 @@ describe('withThinkingKeep (context_management)', () => { expect(messages[0]!.content).toEqual([ { type: 'thinking', thinking: '' }, - { type: 'thinking', thinking: ' ' }, + { type: 'thinking', thinking: '' }, { type: 'text', text: 'Hello', cache_control: { type: 'ephemeral' } }, ]); }); - it('backfills each empty assistant message independently when keep all is active', async () => { + it('replays each empty assistant message without injecting thinking when keep all is active', async () => { const compatibleHistory: Message[] = [ { role: 'user', content: [{ type: 'text', text: 'First' }], toolCalls: [] }, { @@ -569,8 +565,8 @@ describe('withThinkingKeep (context_management)', () => { ); expect([messages[1]!.content[0], messages[3]!.content[0]]).toEqual([ - { type: 'thinking', thinking: ' ' }, - { type: 'thinking', thinking: ' ' }, + { type: 'text', text: 'First response' }, + { type: 'text', text: 'Second response' }, ]); }); @@ -624,7 +620,7 @@ describe('withThinkingKeep (context_management)', () => { ]); }); - it('makes the unsigned block non-empty when signed and unsigned thinking are empty', async () => { + it('preserves the unsigned empty block when signed and unsigned thinking are empty', async () => { const compatibleHistory: Message[] = [ { role: 'assistant', @@ -645,7 +641,7 @@ describe('withThinkingKeep (context_management)', () => { expect(messages[0]!.content).toEqual([ { type: 'thinking', thinking: '', signature: 'signature' }, - { type: 'thinking', thinking: ' ' }, + { type: 'thinking', thinking: '' }, { type: 'text', text: 'Hello', cache_control: { type: 'ephemeral' } }, ]); }); diff --git a/packages/kosong/test/e2e/anthropic-adapter.test.ts b/packages/kosong/test/e2e/anthropic-adapter.test.ts index f7851df721..e30c003084 100644 --- a/packages/kosong/test/e2e/anthropic-adapter.test.ts +++ b/packages/kosong/test/e2e/anthropic-adapter.test.ts @@ -153,10 +153,7 @@ describe('e2e: Anthropic adapter bridge', () => { { role: 'user', content: [{ type: 'text', text: 'Hi' }] }, { role: 'assistant', - content: [ - { type: 'thinking', thinking: ' ' }, - { type: 'text', text: 'Hello from Opus' }, - ], + content: [{ type: 'text', text: 'Hello from Opus' }], }, { role: 'user', diff --git a/packages/kosong/test/kimi.test.ts b/packages/kosong/test/kimi.test.ts index 35c9baf62a..12d8983969 100644 --- a/packages/kosong/test/kimi.test.ts +++ b/packages/kosong/test/kimi.test.ts @@ -651,7 +651,7 @@ describe('KimiChatProvider', () => { ]); }); - it('backfills non-empty reasoning for an assistant tool call when preserved thinking is active', async () => { + it('sends empty reasoning_content for an assistant tool call when preserved thinking is active', async () => { const history: Message[] = [ { role: 'assistant', @@ -666,10 +666,10 @@ describe('KimiChatProvider', () => { provider.withExtraBody({ thinking: { type: 'enabled', keep: 'all' } }), ); - expect(messages[0]).toHaveProperty('reasoning_content', ' '); + expect(messages[0]).toHaveProperty('reasoning_content', ''); }); - it('backfills non-empty reasoning for a text assistant when keep=all omits thinking.type', async () => { + it('sends empty reasoning_content for a text assistant when keep=all omits thinking.type', async () => { const history: Message[] = [ { role: 'assistant', @@ -682,10 +682,10 @@ describe('KimiChatProvider', () => { provider.withExtraBody({ thinking: { keep: 'all' } }), ); - expect(messages[0]).toHaveProperty('reasoning_content', ' '); + expect(messages[0]).toHaveProperty('reasoning_content', ''); }); - it('makes an existing empty ThinkPart non-empty when preserved thinking is active', async () => { + it('replays an existing empty ThinkPart unchanged when preserved thinking is active', async () => { const history: Message[] = [ { role: 'assistant', @@ -698,10 +698,10 @@ describe('KimiChatProvider', () => { provider.withExtraBody({ thinking: { type: 'enabled', keep: 'all' } }), ); - expect(messages[0]).toHaveProperty('reasoning_content', ' '); + expect(messages[0]).toHaveProperty('reasoning_content', ''); }); - it('makes aggregated empty ThinkParts non-empty when preserved thinking is active', async () => { + it('replays aggregated empty ThinkParts unchanged when preserved thinking is active', async () => { const history: Message[] = [ { role: 'assistant', @@ -717,7 +717,7 @@ describe('KimiChatProvider', () => { provider.withExtraBody({ thinking: { type: 'enabled', keep: 'all' } }), ); - expect(messages[0]).toHaveProperty('reasoning_content', ' '); + expect(messages[0]).toHaveProperty('reasoning_content', ''); }); it('preserves non-empty reasoning when preserved thinking is active', async () => {