Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/remove-thinking-space-placeholder.md
Original file line number Diff line number Diff line change
@@ -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.
49 changes: 2 additions & 47 deletions packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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') {
Expand All @@ -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') {
Expand All @@ -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<string, unknown> = {};
Expand Down Expand Up @@ -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',
Expand Down
5 changes: 1 addition & 4 deletions packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] },
{
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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' } },
],
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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',
Expand All @@ -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.' },
]);
});
Expand Down Expand Up @@ -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',
Expand All @@ -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: '' },
]);
});

Expand Down
52 changes: 2 additions & 50 deletions packages/kosong/src/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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 -> <system>...</system> wrapped user message
Expand All @@ -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
Expand All @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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',
Expand Down
7 changes: 1 addition & 6 deletions packages/kosong/src/providers/kimi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading