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 stuck on every turn with a provider "message must not be empty" error after a content-filtered response.
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
* - `context.append_message` → append (deferred while a tool exchange is open)
* - `context.append_loop_event` → step.begin/content.part/tool.call mutate the
* open assistant; tool.result appends a tool
* message with the raw output
* message with the raw output; settling a step
* (at its end or at the next begin) drops an
* output-free assistant, mirroring the live fold
* - `context.apply_compaction` → keep the full history, append the user-role
* summary marker, recover `foldedLength` from
* the recorded kept-count fields
Expand All @@ -39,6 +41,7 @@ import {
} from './compactionHandoff';
import type { LoopRecordedEvent } from './loopEventFold';
import type { ContextMessage } from './types';
import { isVacuousContentPart } from './vacuousContent';

const TOOL_INTERRUPTED_ON_RESUME_OUTPUT =
'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.';
Expand Down Expand Up @@ -82,6 +85,7 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer {
const openSteps = new Map<string, MutableEntry>();
const pendingToolResultIds = new Set<string>();
let deferred: MutableEntry[] = [];
let lastOpenStepUuid: string | undefined;

const push = (...entries: MutableEntry[]): void => {
transcript.push(...entries);
Expand Down Expand Up @@ -114,22 +118,37 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer {
openSteps.clear();
pendingToolResultIds.clear();
deferred = [];
lastOpenStepUuid = undefined;
};
const settleStep = (uuid: string): void => {
const entry = openSteps.get(uuid);
if (entry === undefined) return;
openSteps.delete(uuid);
if (entry.message.toolCalls.length > 0) return;
if (!entry.message.content.every(isVacuousContentPart)) return;
const index = transcript.indexOf(entry);
if (index === -1) return;
transcript.splice(index, 1);
foldedLength = Math.max(0, foldedLength - 1);
};

const applyLoopEvent = (event: LoopRecordedEvent, time: number | undefined): void => {
switch (event.type) {
case 'step.begin': {
closePendingToolResults(time);
if (lastOpenStepUuid !== undefined) settleStep(lastOpenStepUuid);
const entry: MutableEntry = {
message: { role: 'assistant', content: [], toolCalls: [] },
time,
};
push(entry);
openSteps.set(event.uuid, entry);
lastOpenStepUuid = event.uuid;
return;
}
case 'step.end': {
openSteps.delete(event.uuid);
settleStep(event.uuid);
if (lastOpenStepUuid === event.uuid) lastOpenStepUuid = undefined;
flushDeferredIfToolExchangeClosed();
return;
}
Expand Down
10 changes: 6 additions & 4 deletions packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@
* wrapping), clear its pending id
* - `step.end` → settle the assistant
* "Settle" closes any tool exchange left open (interrupted result messages),
* then drops the partial assistant when it is empty (no content, no tool
* calls — an empty assistant only trips provider message validation) and
* seals it (`partial: undefined`) when it carries output. v1 never produced
* then drops the partial assistant when nothing sendable was recorded (no
* tool calls; every content part vacuous — an output-free assistant only
* trips provider message validation) and seals it (`partial: undefined`)
* when it carries output. v1 never produced
* `step.begin` without `step.end` (its retries stayed inside one request), so
* the drop/seal rule is the v2 extension that makes loop-level retries — a
* retried attempt is its own `step.begin` — replay to the same history the
Expand All @@ -47,6 +48,7 @@ import { createToolMessage, type ContentPart, type ToolCall } from '#/app/llmPro
import type { TokenUsage } from '#/app/llmProtocol/usage';

import type { ContextMessage } from './types';
import { isVacuousContentPart } from './vacuousContent';

const TOOL_INTERRUPTED_ON_RESUME_OUTPUT =
'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.';
Expand Down Expand Up @@ -215,7 +217,7 @@ function settleOpenStep(
const index = findOpenAssistantIndex(closed);
if (index === -1) return closed;
const open = closed[index]!;
if (open.content.length === 0 && open.toolCalls.length === 0) {
if (open.toolCalls.length === 0 && open.content.every(isVacuousContentPart)) {
return [...closed.slice(0, index), ...closed.slice(index + 1)];
Comment thread
RealKai42 marked this conversation as resolved.
Comment on lines +220 to 221

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve legacy compaction tails when dropping vacuous steps

When replaying an older v2 wire log whose context.apply_compaction record lacks keptUserMessageCount, buildContextCompactionShape keeps the old tail with history.slice(compactedCount). If a vacuous assistant before that compaction is now removed here, the replayed history is shorter than the persisted compactedCount, so the slice skips the first real retained tail message; for example [user, empty-think step, user] with compactedCount: 2 replays to just the summary instead of summary plus the tail user. Please compensate the legacy count for dropped steps or avoid shrinking the replay state before legacy compaction applies.

Useful? React with 👍 / 👎.

}
const next = closed.slice();
Expand Down
18 changes: 18 additions & 0 deletions packages/agent-core-v2/src/agent/contextMemory/vacuousContent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* `contextMemory` vacuous-content predicate — shared test for content parts
* that carry nothing the provider wire can represent, used by the loop-event
* fold (settle-time drop of output-free steps) and the context projector
* (wire-time drop of wholly-vacuous messages). Vacuous means 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.
*/

import type { ContentPart } from '#/app/llmProtocol/message';

export 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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
* outgoing wire valid (a displaced result moved back to its call, a synthetic
* result invented for a lost one, an orphan/duplicate dropped, leading
* non-user messages dropped, consecutive assistants merged, blank text
* dropped) are reported through an optional sink and surfaced once here as a
* dropped, wholly-vacuous messages — nothing sendable was recorded, e.g. an
* assistant step that kept only an empty thinking part — dropped whole) are
* reported through an optional sink and surfaced once here as a
* single deduped warning plus a `context_projection_repaired` telemetry event,
* so a silently-mangled history always leaves a trace.
*
Expand All @@ -27,6 +29,7 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { ILogService } from '#/_base/log/log';
import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent';
import { ErrorCodes, Error2 } from '#/errors';
import type { ContentPart, Message } from '#/app/llmProtocol/message';
import { ITelemetryService } from '#/app/telemetry/telemetry';
Expand Down Expand Up @@ -108,6 +111,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 +120,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 +137,7 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi
leadingDropped,
assistantsMerged,
whitespaceDropped,
vacuousDropped,
toolCallIds,
});
this.telemetry.track2('context_projection_repaired', {
Expand All @@ -143,6 +149,7 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi
leading_dropped: leadingDropped,
assistants_merged: assistantsMerged,
whitespace_dropped: whitespaceDropped,
vacuous_dropped: vacuousDropped,
});
}
}
Expand All @@ -155,7 +162,8 @@ 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 }
| { readonly kind: 'vacuous_message_dropped'; readonly role: string };

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

Expand Down Expand Up @@ -314,8 +322,10 @@ function dedupeDuplicateToolCalls(messages: readonly Message[], onAnomaly?: OnAn
});
if (kept.length === message.toolCalls.length) {
out.push(message);
} else if (kept.length > 0 || message.content.length > 0) {
} else if (kept.length > 0 || !message.content.every(isVacuousContentPart)) {
out.push({ ...message, toolCalls: kept });
} else if (message.content.length > 0) {
onAnomaly?.({ kind: 'vacuous_message_dropped', role: message.role });
}
continue;
}
Expand Down Expand Up @@ -407,7 +417,13 @@ 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;
if (content.every(isVacuousContentPart)) {
onAnomaly?.({ kind: 'vacuous_message_dropped', role: source.role });
return;
}
}

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

Expand Down
1 change: 1 addition & 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,7 @@ const STRUCTURAL_REQUEST_MESSAGE_PATTERNS = [
/roles must alternate/,
/multiple .*(?:user|assistant).* roles in a row/,
/tool_use[\s\S]*ids must be unique/,
/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 @@ -227,4 +227,50 @@ describe('reduceContextTranscript', () => {
expect(result.entries[2]!.toolCallId).toBe('call_1');
expect(result.foldedLength).toBe(3);
});

it('drops an output-free assistant at step.end, mirroring the live fold', () => {
const result = reduceContextTranscript([
appendMessage(userMessage('q')),
loopEvent({ type: 'step.begin', uuid: 's1' }),
loopEvent({ type: 'content.part', stepUuid: 's1', part: { type: 'think', think: '' } }),
loopEvent({ type: 'step.end', uuid: 's1' }),
]);
expect(result.entries.map((m) => m.role)).toEqual(['user']);
expect(result.foldedLength).toBe(1);
});

it('drops a failed attempt left open when the retry begins', () => {
const result = reduceContextTranscript([
appendMessage(userMessage('q')),
loopEvent({ type: 'step.begin', uuid: 's1' }),
loopEvent({ type: 'step.begin', uuid: 's2' }),
loopEvent({ type: 'content.part', stepUuid: 's2', part: { type: 'text', text: 'recovered' } }),
loopEvent({ type: 'step.end', uuid: 's2' }),
]);
expect(result.entries.map((m) => m.role)).toEqual(['user', 'assistant']);
expect(texts(result)).toEqual(['q', 'recovered']);
expect(result.foldedLength).toBe(2);
});

it('keeps settled steps that carry any sendable output', () => {
const result = reduceContextTranscript([
appendMessage(userMessage('q')),
loopEvent({ type: 'step.begin', uuid: 's1' }),
loopEvent({ type: 'content.part', stepUuid: 's1', part: { type: 'think', think: 'real' } }),
loopEvent({ type: 'step.end', uuid: 's1' }),
loopEvent({ type: 'step.begin', uuid: 's2' }),
loopEvent({
type: 'content.part',
stepUuid: 's2',
part: { type: 'think', think: '', encrypted: 'sig' },
}),
loopEvent({ type: 'step.end', uuid: 's2' }),
loopEvent({ type: 'step.begin', uuid: 's3' }),
loopEvent({ type: 'content.part', stepUuid: 's3', part: { type: 'think', think: '' } }),
loopEvent({ type: 'content.part', stepUuid: 's3', part: { type: 'text', text: 'answer' } }),
loopEvent({ type: 'step.end', uuid: 's3' }),
]);
expect(result.entries.map((m) => m.role)).toEqual(['user', 'assistant', 'assistant', 'assistant']);
expect(result.foldedLength).toBe(4);
});
});
Loading
Loading