Skip to content
Draft
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/compaction-resume-anchor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix the agent resuming the wrong request after automatic context compaction in long sessions.
Original file line number Diff line number Diff line change
@@ -1 +1 @@
The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary.
The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary. The summary records which earlier requests were already addressed.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const COMPACTION_SUMMARY_PREFIX = summaryPrefixTemplate.trimEnd();
export const COMPACT_USER_MESSAGE_MAX_TOKENS = 20_000;
export const COMPACT_USER_MESSAGE_HEAD_TOKENS = 2_000;
export const COMPACTION_ELISION_VARIANT = 'compaction_elision';
export const COMPACTION_CONTINUATION_VARIANT = 'compaction_continuation';

type MessageLike = ContextMessage;

Expand Down Expand Up @@ -42,6 +43,7 @@ export interface ContextCompactionShapeInput {
readonly keptUserMessageCount?: number;
readonly keptHeadUserMessageCount?: number;
readonly droppedCount?: number;
readonly hasContinuation?: boolean;
readonly legacyTail?: boolean;
}

Expand All @@ -54,6 +56,7 @@ export interface ContextCompactionShape {
readonly keptUserMessageCount: number;
readonly keptHeadUserMessageCount?: number;
readonly droppedCount?: number;
readonly hasContinuation: boolean;
readonly messages: readonly ContextMessage[];
}

Expand All @@ -76,6 +79,7 @@ export function buildContextCompactionShape(
tokensAfter: input.tokensAfter ?? estimate.messages(messages),
keptUserMessageCount: 0,
droppedCount: input.droppedCount,
hasContinuation: false,
messages,
};
}
Expand All @@ -94,11 +98,17 @@ export function buildContextCompactionShape(
? [...selection.head, ...selection.tail]
: [...selection.head, elisionMessage, ...selection.tail];
const contextSummary = input.contextSummary ?? input.summary;
const continuationMessage =
input.hasContinuation === false ? undefined : createCompactionContinuationMessage();
const tokensAfter =
input.tokensAfter ??
(input.requestOverheadTokens ?? 0) +
(input.summaryOutputTokens ?? estimate.text(contextSummary)) +
estimate.messages(keptMessages);
estimate.messages(
continuationMessage === undefined
? keptMessages
: [...keptMessages, continuationMessage],
);
const keptUserMessageCount =
input.keptUserMessageCount ?? selection.head.length + selection.tail.length;
const keptHeadUserMessageCount =
Expand All @@ -113,7 +123,11 @@ export function buildContextCompactionShape(
keptUserMessageCount,
keptHeadUserMessageCount,
droppedCount: input.droppedCount,
messages: [...keptMessages, createCompactionSummaryMessage(contextSummary)],
hasContinuation: continuationMessage !== undefined,
messages:
continuationMessage === undefined
? [...keptMessages, createCompactionSummaryMessage(contextSummary)]
: [...keptMessages, createCompactionSummaryMessage(contextSummary), continuationMessage],
};
}

Expand Down Expand Up @@ -146,6 +160,21 @@ export function buildCompactionElisionText(omittedTokens: number): string {
);
}

export function createCompactionContinuationMessage(): ContextMessage {
return {
role: 'user',
content: [{ type: 'text', text: buildCompactionContinuationText() }],
toolCalls: [],
origin: { kind: 'injection', variant: COMPACTION_CONTINUATION_VARIANT },
};
}

export function buildCompactionContinuationText(): string {
return wrapSystemReminder(
'Context compaction is complete — continue the work that was in progress when it began.',
);
Comment on lines +172 to +175

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 the active non-user turn after compaction

When automatic compaction runs during a system_trigger, task, cron_job, or similar turn, this reminder redirects the model to an unrelated earlier user request. The full-compaction hook runs before every step in fullCompactionService.ts, while compactionUserMessageDisposition deliberately removes those non-user inputs from the rebuilt context, leaving their instructions only in the summary. The continuation should therefore refer to the in-flight task or summary rather than unconditionally selecting the latest user message.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 8d4968c — the continuation no longer unconditionally selects the latest user message; it now says to continue the work that was in progress when compaction began, which covers non-user turns (cron/system_trigger/task) whose prompts are dropped from the rebuilt context.

}

export function collectCompactableUserMessages<T extends MessageLike>(messages: readonly T[]): T[] {
return messages.filter(
(message) => isRealUserInput(message) && !isCompactionSummaryMessage(message),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const contextCompactionBaseShape = {
keptUserMessageCount: z.number().optional(),
keptHeadUserMessageCount: z.number().optional(),
droppedCount: z.number().optional(),
hasContinuation: z.boolean().optional(),
legacyTail: z.boolean().optional(),
wireLines: z
.object({ start: z.number().int().nonnegative(), end: z.number().int().nonnegative() })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface ContextCompactionInput {
readonly keptUserMessageCount?: number;
readonly keptHeadUserMessageCount?: number;
readonly droppedCount?: number;
readonly hasContinuation?: boolean;
readonly wireLines?: WireLineRange;
}

Expand All @@ -28,6 +29,7 @@ export interface ContextCompactionResult {
keptUserMessageCount: number;
keptHeadUserMessageCount?: number;
droppedCount?: number;
hasContinuation: boolean;
}

export interface IAgentContextMemoryService {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
keptUserMessageCount: result.keptUserMessageCount,
keptHeadUserMessageCount: result.keptHeadUserMessageCount,
droppedCount: result.droppedCount,
hasContinuation: result.hasContinuation,
wireLines: input.wireLines,
}),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export function readContextCompactionShapeInput(
keptUserMessageCount,
keptHeadUserMessageCount: readOptionalNumber(fields, 'keptHeadUserMessageCount'),
droppedCount: readOptionalNumber(fields, 'droppedCount'),
hasContinuation: readOptionalBoolean(fields, 'hasContinuation') ?? false,
legacyTail: readOptionalBoolean(fields, 'legacyTail') ?? keptUserMessageCount === undefined,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@ function recoverFoldedLength(
const keptHeadUserMessageCount = readNumber(record, 'keptHeadUserMessageCount');
const compactedCount = readNumber(record, 'compactedCount');
if (keptUserMessageCount !== undefined) {
return keptUserMessageCount + (keptHeadUserMessageCount === undefined ? 1 : 2);
const continuation = record['hasContinuation'] === true ? 1 : 0;
return keptUserMessageCount + continuation + (keptHeadUserMessageCount === undefined ? 1 : 2);
}
if (compactedCount !== undefined && compactedCount < foldedLength) {
return 1 + (foldedLength - compactedCount);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,20 @@
You are about to run out of context. Write a first-person handoff note to
yourself so you can seamlessly continue this task after the earlier
conversation is cleared.
You are about to run out of context. Create a handoff summary for the
model that will resume this task after the earlier conversation is cleared.

--- This message is a direct task, not part of the above conversation ---

Write the note as your own continuing train of thought — first person, present
tense, the way you would reason through the next move. Do not write a
third-party report about someone else's work, and do not impose rigid section
headings; let the shape follow the task. Write the note in the same language the
conversation has been using — do not switch to English just because these
instructions happen to be in English.
Do not impose rigid section headings; let the shape follow the task. Write it
in the same language the conversation has been using — do not switch to English
just because these instructions happen to be in English.

Make the note self-sufficient: the next turn will see only your most recent user
messages and this note — every assistant message, tool call, and tool result
above will be gone. In your own words, preserve what you genuinely need to
continue:
Make the summary self-sufficient: the next turn will see only the preserved
messages and this summary — every other assistant message, tool call, and tool
result above will be gone. In your own words, preserve what you genuinely need
to continue:

- What the latest request is actually asking for: your reading of its intent and
any ambiguity you have already resolved — not a re-transcription, since what
fits is kept verbatim in your most recent messages. But those kept messages are
fits is kept verbatim in the preserved messages. But those kept messages are
size-capped, so a long request is truncated there: if the latest request is
large (a big paste or file), preserve the parts at risk of being dropped —
above all the actual ask. If several requests are in play, say which one governs
Expand Down Expand Up @@ -52,9 +48,9 @@ continue:
here is one less thing the next turn must rediscover. Include any required
format for the final answer.

This conversation's event log stays on disk and a recovery pointer is appended below your note automatically, so you need not reproduce long outputs verbatim — keep exact identifiers, key values and error lines, and name anything the next turn should look up.
This conversation's event log stays on disk and a recovery pointer is appended below this summary automatically, so you need not reproduce long outputs verbatim — keep exact identifiers, key values and error lines, and name anything the next turn should look up.

Your TODO list is re-attached automatically below this note from its live
Your TODO list is re-attached automatically below this summary from its live
source, so do not transcribe it — copying it wastes space and can contradict the
live version. What that list cannot hold is the reasoning between tasks — why one
was reordered or dropped, or a decision on one that constrains another — so
Expand All @@ -65,9 +61,9 @@ was never verified (tests "passing", a fix "working", a file "created"), say so
plainly and treat it as unverified rather than fact — re-check before relying
on it.

Be concise, and keep the note proportional to the task: a long multi-step task
warrants detail, but a trivial or nearly finished exchange needs only a sentence
or two — do not pad it out. Include the critical data, identifiers, and
Be concise, and keep the summary proportional to the task: a long multi-step
task warrants detail, but a trivial or nearly finished exchange needs only a
sentence or two — do not pad it out. Include the critical data, identifiers, and
references needed to continue, and omit anything that does not change the next
move.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -787,8 +787,9 @@ describe('Agent context', () => {
);

expect(shape.tokensAfter).toBe(0);
expect(shape.messages.map((m) => m.role)).toEqual(['user', 'user']);
expect(shape.messages.map((m) => m.role)).toEqual(['user', 'user', 'user']);
expect(shape.messages[1]?.origin?.kind).toBe('compaction_summary');
expect(shape.messages[2]?.origin).toEqual({ kind: 'injection', variant: 'compaction_continuation' });
});

it('prefers the measured summary output tokens over the text estimate', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ function compaction(
compactedCount,
tokensBefore: 1000,
tokensAfter: 100,
...(keptUserMessageCount === undefined ? {} : { keptUserMessageCount }),
...(keptUserMessageCount === undefined ? {} : { keptUserMessageCount, hasContinuation: true }),
...(keptHeadUserMessageCount === undefined ? {} : { keptHeadUserMessageCount }),
};
}
Expand Down Expand Up @@ -109,7 +109,7 @@ describe('reduceContextTranscript', () => {
compaction('SUM', 3, 1),
appendMessage(userMessage('u4')),
]);
expect(result.foldedLength).toBe(3);
expect(result.foldedLength).toBe(4);
});

it('accounts for the elision marker when the record kept a head segment', () => {
Expand All @@ -119,7 +119,7 @@ describe('reduceContextTranscript', () => {
...assistantStep('s1', 'a1'),
compaction('SUM', 3, 2, 1),
]);
expect(result.foldedLength).toBe(4);
expect(result.foldedLength).toBe(5);
});

it('carries the originating wire record time per entry', () => {
Expand Down Expand Up @@ -159,7 +159,7 @@ describe('reduceContextTranscript', () => {
]);
expect(texts(result)).toEqual(['message A', 'reply A', 'summary text']);
expect(result.entries.map((m) => m.role)).toEqual(['user', 'assistant', 'user']);
expect(result.foldedLength).toBe(2);
expect(result.foldedLength).toBe(3);
});

it('undo without compaction keeps the earlier exchange intact', () => {
Expand Down Expand Up @@ -388,9 +388,10 @@ describe('live fold parity', () => {
];
const live = foldLive(records);
const transcript = reduceContextTranscript(records);
expect(live).toHaveLength(5);
expect(live).toHaveLength(6);
expect(transcript.foldedLength).toBe(live.length);
expect(live[2]!.origin).toEqual({ kind: 'compaction_summary' });
expect(live[3]!.origin).toEqual({ kind: 'injection', variant: 'compaction_continuation' });
});

it('settles a frame left open by a failed attempt when compaction lands mid-fold', () => {
Expand All @@ -403,7 +404,7 @@ describe('live fold parity', () => {
];
const live = foldLive(records);
const transcript = reduceContextTranscript(records);
expect(live.map((m) => m.role)).toEqual(['user', 'user', 'assistant']);
expect(live.map((m) => m.role)).toEqual(['user', 'user', 'user', 'assistant']);
expect(texts(transcript)).toEqual(['u1', 'a1', 'SUM', 'a3']);
expect(transcript.foldedLength).toBe(live.length);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
ContextUndo,
} from '#/agent/contextMemory/contextEvents';
import { contextMemoryKey } from '#/agent/contextMemory/contextOps';
import { buildCompactionContinuationText } from '#/agent/contextMemory/compactionHandoff';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting';
import { IEventBus } from '#/app/event/eventBus';
Expand Down Expand Up @@ -364,6 +365,7 @@ describe('AgentContextMemoryService (wire-backed)', () => {
tokensBefore: 100,
tokensAfter: 20,
keptUserMessageCount: 2,
hasContinuation: true,
},
];

Expand All @@ -376,11 +378,19 @@ describe('AgentContextMemoryService (wire-backed)', () => {
);

const model = replay.agentState.get(contextMemoryKey);
expect(model.map((message) => message.role)).toEqual(['user', 'user', 'user']);
expect(model.map(textOf)).toEqual(['old user', 'recent user', 'model-facing summary']);
expect(model.map((message) => message.role)).toEqual(['user', 'user', 'user', 'user']);
expect(model.map(textOf)).toEqual([
'old user',
'recent user',
'model-facing summary',
buildCompactionContinuationText(),
]);
expect(model[2]).toMatchObject({
origin: { kind: 'compaction_summary' },
});
expect(model[3]).toMatchObject({
origin: { kind: 'injection', variant: 'compaction_continuation' },
});
});

it('replays pre-contextSummary kept-user records without adding a new prefix', async () => {
Expand Down
Loading
Loading