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.
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 — true when a content part
Comment thread
RealKai42 marked this conversation as resolved.
Outdated
* 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. Shared by the loop-event fold (settle-time drop of
* output-free steps) and the context projector (wire-time drop of
* wholly-vacuous messages).
*/

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
119 changes: 119 additions & 0 deletions packages/agent-core-v2/test/agent/contextMemory/loopEventFold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,125 @@ describe('loop-event fold parity', () => {
expect(context.get()).toEqual([]);
});

it('drops an assistant whose only recorded part is an empty thinking block at step.end', () => {
context.appendLoopEvent({ type: 'step.begin', uuid: 's1' });
context.appendLoopEvent({
type: 'content.part',
stepUuid: 's1',
part: { type: 'think', think: '' },
});
context.appendLoopEvent({ type: 'step.end', uuid: 's1' });

expect(context.get()).toEqual([]);
});

it('drops a vacuous partial assistant left by a failed attempt when the retry begins', () => {
context.appendLoopEvent({ type: 'step.begin', uuid: 's1' });
context.appendLoopEvent({
type: 'content.part',
stepUuid: 's1',
part: { type: 'think', think: ' ' },
});
context.appendLoopEvent({ type: 'step.begin', uuid: 's2' });
context.appendLoopEvent({
type: 'content.part',
stepUuid: 's2',
part: { type: 'text', text: 'recovered' },
});
context.appendLoopEvent({ type: 'step.end', uuid: 's2' });

expect(shapes(context.get())).toEqual([
{
role: 'assistant',
content: [{ type: 'text', text: 'recovered' }],
toolCalls: [],
toolCallId: undefined,
isError: undefined,
partial: undefined,
},
]);
});

it('seals a step whose thinking block has real content', () => {
context.appendLoopEvent({ type: 'step.begin', uuid: 's1' });
context.appendLoopEvent({
type: 'content.part',
stepUuid: 's1',
part: { type: 'think', think: 'real reasoning' },
});
context.appendLoopEvent({ type: 'step.end', uuid: 's1' });

expect(context.get().at(-1)?.content).toEqual([{ type: 'think', think: 'real reasoning' }]);
});

it('seals a step whose empty thinking block carries a provider signature', () => {
context.appendLoopEvent({ type: 'step.begin', uuid: 's1' });
context.appendLoopEvent({
type: 'content.part',
stepUuid: 's1',
part: { type: 'think', think: '', encrypted: 'sig' },
});
context.appendLoopEvent({ type: 'step.end', uuid: 's1' });

expect(context.get().at(-1)?.content).toEqual([{ type: 'think', think: '', encrypted: 'sig' }]);
});

it('seals a step that pairs an empty thinking block with real text', () => {
context.appendLoopEvent({ type: 'step.begin', uuid: 's1' });
context.appendLoopEvent({
type: 'content.part',
stepUuid: 's1',
part: { type: 'think', think: '' },
});
context.appendLoopEvent({
type: 'content.part',
stepUuid: 's1',
part: { type: 'text', text: 'answer' },
});
context.appendLoopEvent({ type: 'step.end', uuid: 's1' });

expect(context.get().at(-1)?.content).toEqual([
{ type: 'think', think: '' },
{ type: 'text', text: 'answer' },
]);
});

it('seals an assistant with tool calls even when its thinking block is empty', () => {
context.appendLoopEvent({ type: 'step.begin', uuid: 's1' });
context.appendLoopEvent({
type: 'content.part',
stepUuid: 's1',
part: { type: 'think', think: '' },
});
context.appendLoopEvent({
type: 'tool.call',
stepUuid: 's1',
toolCallId: 'c1',
name: 'Lookup',
args: {},
});
context.appendLoopEvent({ type: 'step.end', uuid: 's1' });

expect(shapes(context.get())).toEqual([
{
role: 'assistant',
content: [{ type: 'think', think: '' }],
toolCalls: [{ type: 'function', id: 'c1', name: 'Lookup', arguments: '{}' }],
toolCallId: undefined,
isError: undefined,
partial: undefined,
},
{
role: 'tool',
content: expect.any(Array),
toolCalls: [],
toolCallId: 'c1',
isError: true,
partial: undefined,
},
]);
});

it('folds a tool-result note as structured model-only metadata', () => {
context.append(
{
Expand Down
Loading
Loading