Skip to content
8 changes: 8 additions & 0 deletions .changeset/fix-vacuous-assistant-wedge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/agent-core-v2": patch
"@moonshot-ai/kosong": patch
"@moonshot-ai/kimi-code": patch
---

Fix sessions getting permanently stuck after a provider-filtered response: an assistant message holding only an empty thinking part could persist in the conversation history and was then rejected by the provider ("the message ... with role 'assistant' must not be empty") on every later request. Such wholly-empty messages are now dropped from outgoing requests — thinking content is still round-tripped verbatim — so affected sessions resume normally instead of failing on every turn.
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi
let leadingDropped = 0;
let assistantsMerged = 0;
let whitespaceDropped = 0;
let vacuousDropped = 0;
for (const anomaly of notable) {
if (anomaly.kind === 'tool_result_reordered') reordered += 1;
else if (anomaly.kind === 'tool_result_synthesized') synthesized += 1;
Expand All @@ -116,6 +117,7 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi
else if (anomaly.kind === 'duplicate_tool_result_dropped') duplicateResultsDropped += 1;
else if (anomaly.kind === 'leading_non_user_dropped') leadingDropped += 1;
else if (anomaly.kind === 'consecutive_assistants_merged') assistantsMerged += 1;
else if (anomaly.kind === 'vacuous_message_dropped') vacuousDropped += 1;
else whitespaceDropped += 1;
}
const toolCallIds = [
Expand All @@ -132,6 +134,7 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi
leadingDropped,
assistantsMerged,
whitespaceDropped,
vacuousDropped,
toolCallIds,
});
this.telemetry.track2('context_projection_repaired', {
Expand All @@ -143,6 +146,7 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi
leading_dropped: leadingDropped,
assistants_merged: assistantsMerged,
whitespace_dropped: whitespaceDropped,
vacuous_dropped: vacuousDropped,
});
}
}
Expand All @@ -155,7 +159,15 @@ type ProjectionAnomaly =
| { readonly kind: 'duplicate_tool_result_dropped'; readonly toolCallId: string }
| { readonly kind: 'leading_non_user_dropped'; readonly role: string }
| { readonly kind: 'consecutive_assistants_merged' }
| { readonly kind: 'whitespace_text_dropped'; readonly role: string };
| { readonly kind: 'whitespace_text_dropped'; readonly role: string }
/**
* Every recorded part serialized to nothing on the wire (e.g. an assistant
* step that recorded only an empty thinking part from a provider-filtered
* response), so the whole message was dropped. Distinct from the silent
* empty-content drop: parts were recorded, yet none of them was sendable —
* a genuine defect signal, not routine cleanup.
*/
| { readonly kind: 'vacuous_message_dropped'; readonly role: string };

type OnAnomaly = (anomaly: ProjectionAnomaly) => void;

Expand Down Expand Up @@ -407,7 +419,22 @@ function project(history: readonly ContextMessage[], onAnomaly?: OnAnomaly): Mes

const emit = (source: ContextMessage): void => {
const content = projectedContent(source, onAnomaly);
if (content.length === 0 && source.toolCalls.length === 0 && !hasDeclaredTools(source)) return;
if (source.toolCalls.length === 0 && !hasDeclaredTools(source)) {
if (content.length === 0) return;
// Every remaining part serializes to nothing on the wire — e.g. an
// assistant step that recorded only an empty thinking part from a
// provider-filtered response. Sent as-is it becomes an assistant
// message with no content and no tool calls, which strict providers
// reject ("the message ... with role 'assistant' must not be empty") on
Comment thread
RealKai42 marked this conversation as resolved.
Outdated
// every resend, permanently wedging the session. Drop the whole message
// here. A message that carries any real content keeps every part
// verbatim — including empty thinking blocks, which preserved-thinking
// providers require back.
if (content.every(isVacuousContentPart)) {
onAnomaly?.({ kind: 'vacuous_message_dropped', role: source.role });
return;
}
}

if (openSlots.size > 0) markForeignBetween();

Expand Down Expand Up @@ -510,6 +537,19 @@ function projectedContent(source: ContextMessage, onAnomaly?: OnAnomaly): Conten
return cleanContent(source, content, onAnomaly);
}

/**
* 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.
*/
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;
return false;
}

function cleanContent(
source: ContextMessage,
rawContent: readonly ContentPart[],
Expand Down
6 changes: 6 additions & 0 deletions packages/agent-core-v2/src/app/llmProtocol/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,12 @@ const STRUCTURAL_REQUEST_MESSAGE_PATTERNS = [
/roles must alternate/,
/multiple .*(?:user|assistant).* roles in a row/,
/tool_use[\s\S]*ids must be unique/,
// Moonshot / Kimi rejects a message whose serialized form carries nothing —
// no content, no tool_calls, an empty reasoning_content: "the message at
// position N with role 'assistant' must not be empty". Seen when a filtered
// response left an assistant message holding only an empty thinking part in
// the history; the strict resend's projection drops such vacuous messages.
/message at position \d+ with role ['"`]?[a-z]+['"`]? must not be empty/,
] as const;

export function isRecoverableRequestStructureError(error: unknown): boolean {
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core-v2/src/app/telemetry/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ export interface ContextProjectionRepairedEvent {
leading_dropped: number;
assistants_merged: number;
whitespace_dropped: number;
vacuous_dropped: number;
}

export interface BackgroundTaskCreatedEvent {
Expand Down Expand Up @@ -615,6 +616,7 @@ export const telemetryEventDefinitions = {
leading_dropped: 'Leading non-user messages dropped',
assistants_merged: 'Consecutive assistant messages merged',
whitespace_dropped: 'Whitespace-only text blocks dropped',
vacuous_dropped: 'Messages dropped because every recorded part serialized to nothing',
},
}),
background_task_created: defineTelemetryEvent<BackgroundTaskCreatedEvent>({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@ describe('projector tool-exchange normalization', () => {
leading_dropped: 0,
assistants_merged: 0,
whitespace_dropped: 0,
vacuous_dropped: 0,
},
},
]);
Expand All @@ -524,6 +525,98 @@ describe('projector tool-exchange normalization', () => {
});
});

describe('vacuous (thinking-only) messages', () => {
function thinkingAssistant(content: ContextMessage['content']): ContextMessage {
return { role: 'assistant', content: [...content], toolCalls: [] };
}

it('drops an assistant message whose only part is an empty think block', () => {
// A provider-filtered response records exactly this shape; sending it
// back is rejected as "message ... with role 'assistant' must not be
// empty" on every resend, permanently wedging the session.
const history = [
user('u1'),
thinkingAssistant([{ type: 'think', think: '' }]),
reminder('ping'),
];
expect(shape(history)).toEqual(['user', 'user']);
expect(repairPayloads(warnings)).toEqual([expect.objectContaining({ vacuousDropped: 1 })]);
expect(telemetryRecords).toEqual([
{
event: 'context_projection_repaired',
properties: expect.objectContaining({ vacuous_dropped: 1 }),
},
]);
});

it('un-wedges a history poisoned by a filtered step (session regression)', () => {
const history = [
user('u1'),
assistant('', ['c1']),
toolResult('c1', 'one'),
// sealed by the filtered step: no text, no tool calls, one empty think
thinkingAssistant([{ type: 'think', think: '' }]),
reminder('ping'),
];
expect(shape(history)).toEqual(['user', 'assistant', 'tool:c1', 'user']);
expect(repairPayloads(warnings)).toEqual([expect.objectContaining({ vacuousDropped: 1 })]);
});

it('keeps a message with real text intact — including its empty think part', () => {
// Preserved-thinking providers require even empty reasoning back; only
// wholly-vacuous messages may be dropped, never parts of a real message.
const history = [
user('u1'),
thinkingAssistant([{ type: 'think', think: '' }, { type: 'text', text: 'answer' }]),
];
expect(project(history)[1]?.content).toEqual([
{ type: 'think', think: '' },
{ type: 'text', text: 'answer' },
]);
expect(repairPayloads(warnings)).toEqual([]);
});

it('keeps a message whose think block has real content', () => {
const history = [user('u1'), thinkingAssistant([{ type: 'think', think: 'real reasoning' }])];
expect(shape(history)).toEqual(['user', 'assistant']);
expect(repairPayloads(warnings)).toEqual([]);
});

it('keeps a signed think block even when its text is empty', () => {
const history = [
user('u1'),
thinkingAssistant([{ type: 'think', think: '', encrypted: 'sig' }]),
];
expect(shape(history)).toEqual(['user', 'assistant']);
expect(project(history)[1]?.content).toEqual([{ type: 'think', think: '', encrypted: 'sig' }]);
});

it('drops a message whose think block is whitespace-only', () => {
const history = [
user('u1'),
thinkingAssistant([{ type: 'think', think: ' ' }]),
reminder('ping'),
];
expect(shape(history)).toEqual(['user', 'user']);
expect(repairPayloads(warnings)).toEqual([expect.objectContaining({ vacuousDropped: 1 })]);
});

it('keeps an assistant message with tool calls even when its think part is empty', () => {
const history = [
user('u1'),
{
role: 'assistant' as const,
content: [{ type: 'think' as const, think: '' }],
toolCalls: [{ type: 'function' as const, id: 'c1', name: 'Lookup', arguments: '{}' }],
},
toolResult('c1', 'one'),
];
expect(shape(history)).toEqual(['user', 'assistant', 'tool:c1']);
expect(project(history)[1]?.content).toEqual([{ type: 'think', think: '' }]);
expect(repairPayloads(warnings)).toEqual([]);
});
});

describe('projectMediaDegraded', () => {
function imageMessage(url: string): ContextMessage {
return {
Expand Down
11 changes: 11 additions & 0 deletions packages/agent-core-v2/test/app/llmProtocol/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,17 @@ describe('isRecoverableRequestStructureError', () => {
).toBe(true);
});

it('matches the Moonshot/Kimi vacuous-message rejection', () => {
expect(
isRecoverableRequestStructureError(
new APIStatusError(
400,
"400 the message at position 105 with role 'assistant' must not be empty",
),
),
).toBe(true);
});

it('does not match context overflow, auth, or non-status errors', () => {
expect(
isRecoverableRequestStructureError(new APIContextOverflowError(400, 'context length exceeded')),
Expand Down
4 changes: 4 additions & 0 deletions packages/agent-core/src/agent/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,7 @@ export class ContextMemory {
let leadingDropped = 0;
let assistantsMerged = 0;
let whitespaceDropped = 0;
let vacuousDropped = 0;
for (const anomaly of notable) {
if (anomaly.kind === 'tool_result_reordered') reordered += 1;
else if (anomaly.kind === 'tool_result_synthesized') synthesized += 1;
Expand All @@ -506,6 +507,7 @@ export class ContextMemory {
else if (anomaly.kind === 'duplicate_tool_result_dropped') duplicateResultsDropped += 1;
else if (anomaly.kind === 'leading_non_user_dropped') leadingDropped += 1;
else if (anomaly.kind === 'consecutive_assistants_merged') assistantsMerged += 1;
else if (anomaly.kind === 'vacuous_message_dropped') vacuousDropped += 1;
else whitespaceDropped += 1;
}
const toolCallIds = [
Expand All @@ -522,6 +524,7 @@ export class ContextMemory {
leadingDropped,
assistantsMerged,
whitespaceDropped,
vacuousDropped,
toolCallIds,
});
this.agent.telemetry.track('context_projection_repaired', {
Expand All @@ -533,6 +536,7 @@ export class ContextMemory {
leading_dropped: leadingDropped,
assistants_merged: assistantsMerged,
whitespace_dropped: whitespaceDropped,
vacuous_dropped: vacuousDropped,
});
}

Expand Down
39 changes: 37 additions & 2 deletions packages/agent-core/src/agent/context/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,15 @@ export type ProjectionAnomaly =
/** Two adjacent assistant turns were merged into one (strict). */
| { readonly kind: 'consecutive_assistants_merged' }
/** A non-empty but all-whitespace text block was dropped (always). */
| { readonly kind: 'whitespace_text_dropped'; readonly role: string };
| { readonly kind: 'whitespace_text_dropped'; readonly role: string }
/**
* Every recorded part serialized to nothing on the wire (e.g. an assistant
* step that recorded only an empty thinking part from a provider-filtered
* response), so the whole message was dropped. Distinct from the silent
* empty-content drop: parts were recorded, yet none of them was sendable —
* a genuine defect signal, not routine cleanup.
*/
| { readonly kind: 'vacuous_message_dropped'; readonly role: string };

export function project(history: readonly ContextMessage[], options?: ProjectOptions): Message[] {
let result = mergeAdjacentUserMessages(history, options?.onAnomaly);
Expand Down Expand Up @@ -405,7 +413,34 @@ function prepareMessageForProjection(
// content-free — it must survive the empty-message cleanup or the loaded
// schemas silently vanish from every outgoing request.
if (next.tools !== undefined && next.tools.length > 0) return next;
return next.content.length === 0 && next.toolCalls.length === 0 ? null : next;
if (next.toolCalls.length > 0) return next;
Comment thread
RealKai42 marked this conversation as resolved.
if (next.content.length === 0) return null;
// Every remaining part serializes to nothing on the wire — e.g. an
// assistant step that recorded only an empty thinking part from a
// provider-filtered response. Sent as-is it becomes an assistant message
// with no content and no tool calls, which strict providers reject ("the
// message ... with role 'assistant' must not be empty") on every resend,
// permanently wedging the session. Drop the whole message here. A message
// that carries any real content keeps every part verbatim — including
// empty thinking blocks, which preserved-thinking providers require back.
if (next.content.every(isVacuousContentPart)) {
onAnomaly?.({ kind: 'vacuous_message_dropped', role: next.role });
return null;
}
return next;
}

/**
* 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.
*/
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;
return false;
}

function canMergeUserMessage(message: ContextMessage): boolean {
Expand Down
Loading
Loading