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/clean-empty-text-blocks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix provider requests failing when restored conversation history contains empty text content blocks.
44 changes: 33 additions & 11 deletions packages/agent-core/src/agent/context/projector.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,18 @@
import type { ContentPart, Message, TextPart } from '@moonshot-ai/kosong';

import { ErrorCodes, KimiError } from '../../errors';
import type { ContextMessage } from './types';

export function project(history: readonly ContextMessage[]): Message[] {
// Keep partial or empty assistant placeholders away from providers.
// They can appear when a turn is aborted or errors before any content
// or tool call is appended.
const usable = history.filter((message) => {
return (
message.partial !== true &&
!(message.role === 'assistant' && message.content.length === 0 && message.toolCalls.length === 0)
);
});
return mergeAdjacentUserMessages(usable);
return mergeAdjacentUserMessages(history);
}

function mergeAdjacentUserMessages(history: readonly ContextMessage[]): Message[] {
const out: ContextMessage[] = [];
for (const message of history) {
for (const source of history) {
const message = prepareMessageForProjection(source);
if (message === null) continue;

const previous = out.at(-1);
if (
canMergeUserMessage(message) &&
Expand All @@ -32,6 +27,33 @@ function mergeAdjacentUserMessages(history: readonly ContextMessage[]): Message[
return out.map(stripContextMetadata);
}

function prepareMessageForProjection(message: ContextMessage): ContextMessage | null {
if (message.partial === true) return null;

let content: ContentPart[] | undefined;
for (const [index, part] of message.content.entries()) {
if (part.type === 'text' && part.text.length === 0) {
content ??= message.content.slice(0, index);
continue;
}
content?.push(part);
}

const next = content === undefined ? message : { ...message, content };
if (next.role === 'tool' && next.content.length === 0) {
throw new KimiError(
ErrorCodes.REQUEST_INVALID,
'Tool result message content cannot be empty after removing empty text blocks.',
{
details: {
toolCallId: next.toolCallId,
},
},
);
}
return next.content.length === 0 && next.toolCalls.length === 0 ? null : next;
Comment thread
kermanx marked this conversation as resolved.
}

function canMergeUserMessage(message: ContextMessage): boolean {
return message.role === 'user' && message.origin?.kind === 'user';
}
Expand Down
81 changes: 81 additions & 0 deletions packages/agent-core/test/agent/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,87 @@ describe('Agent context', () => {
]);
});

it('drops empty text parts only in LLM projection', () => {
const history: ContextMessage[] = [
{
role: 'user',
content: [
{ type: 'text', text: '' },
{ type: 'text', text: 'Run the tool' },
],
toolCalls: [],
},
{
role: 'assistant',
content: [{ type: 'text', text: '' }],
toolCalls: [],
},
{
role: 'assistant',
content: [{ type: 'text', text: '' }],
toolCalls: [{ type: 'function', id: 'call_empty', name: 'empty', arguments: '{}' }],
},
{
role: 'assistant',
content: [{ type: 'think', think: '', encrypted: 'enc_empty_thinking' }],
toolCalls: [],
},
{
role: 'user',
content: [{ type: 'text', text: ' ' }],
toolCalls: [],
},
];

expect(project(history)).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'Run the tool' }],
toolCalls: [],
},
{
role: 'assistant',
content: [],
toolCalls: [{ type: 'function', id: 'call_empty', name: 'empty', arguments: '{}' }],
},
{
role: 'assistant',
content: [{ type: 'think', think: '', encrypted: 'enc_empty_thinking' }],
toolCalls: [],
},
{
role: 'user',
content: [{ type: 'text', text: ' ' }],
toolCalls: [],
},
]);
expect(history[0]?.content).toEqual([
{ type: 'text', text: '' },
{ type: 'text', text: 'Run the tool' },
]);
expect(history[1]?.content).toEqual([{ type: 'text', text: '' }]);
});

it('rejects tool result messages left empty by LLM projection cleanup', () => {
const history: ContextMessage[] = [
{
role: 'assistant',
content: [],
toolCalls: [{ type: 'function', id: 'call_empty', name: 'empty', arguments: '{}' }],
},
{
role: 'tool',
content: [{ type: 'text', text: '' }],
toolCallId: 'call_empty',
toolCalls: [],
},
];

expect(() => project(history)).toThrow(
'Tool result message content cannot be empty after removing empty text blocks.',
);
});

it('projects hook result messages into LLM projection', async () => {
const ctx = testAgent();
ctx.configure();
Expand Down
Loading