diff --git a/.changeset/compaction-resume-anchor.md b/.changeset/compaction-resume-anchor.md new file mode 100644 index 00000000000..2daf11e9631 --- /dev/null +++ b/.changeset/compaction-resume-anchor.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the agent resuming the wrong request after automatic context compaction in long sessions. diff --git a/apps/vis/server/src/lib/context-projector.ts b/apps/vis/server/src/lib/context-projector.ts index af7b01216be..9cc3aeb48df 100644 --- a/apps/vis/server/src/lib/context-projector.ts +++ b/apps/vis/server/src/lib/context-projector.ts @@ -346,7 +346,9 @@ export function projectContext( }, }; const legacyTail = rec.legacyTail === true || rec.keptUserMessageCount === undefined; - const summaryIndex = legacyTail ? 0 : shape.messages.length - 1; + const summaryIndex = legacyTail + ? 0 + : shape.messages.findIndex((message) => message.origin?.kind === 'compaction_summary'); const modelSummaryBubble: ProjectedMessage = { ...summaryBubble, message: modelFacingMessage(shape.messages[summaryIndex] ?? summaryBubble.message), diff --git a/apps/vis/server/test/lib/context-projector.test.ts b/apps/vis/server/test/lib/context-projector.test.ts index 19ce3646bb5..3bfe8c2d8e4 100644 --- a/apps/vis/server/test/lib/context-projector.test.ts +++ b/apps/vis/server/test/lib/context-projector.test.ts @@ -1,6 +1,7 @@ // apps/vis/server/test/lib/context-projector.test.ts import { describe, it, expect, afterEach } from 'vitest'; import { estimateTokensForMessages } from '@moonshot-ai/agent-core-v2/llm-adapter/contract/tokens'; +import { buildCompactionContinuationText } from '@moonshot-ai/agent-core-v2/agent/contextMemory/compactionHandoff'; import { buildSessionFixture } from '../fixtures/build'; import { projectContext } from '../../src/lib/context-projector'; import { readAgentWire } from '../../src/lib/wire-reader'; @@ -440,16 +441,23 @@ describe('context-projector', () => { keptUserMessageCount: 2 }, raw: {} }, ]; const proj = projectContext(entries as any); - // [m0, m1, summary] — real user prompts are kept verbatim, the assistant - // tail is dropped. - expect(proj.messages).toHaveLength(3); + // [m0, m1, summary, anchor] — real user prompts are kept verbatim, the + // assistant tail is dropped, and the continuation anchor follows the summary. + expect(proj.messages).toHaveLength(4); expect(proj.messages.map((m) => m.source)).toEqual([ - 'append_message', 'append_message', 'compaction_summary', + 'append_message', 'append_message', 'compaction_summary', 'append_message', ]); expect(proj.messages[0]!.message.content[0]).toMatchObject({ text: 'm0' }); expect(proj.messages[1]!.message.content[0]).toMatchObject({ text: 'm1' }); expect(proj.messages[2]!.compaction).toEqual({ compactedCount: 3, tokensBefore: 100, tokensAfter: 10 }); expect(proj.messages[2]!.message.content[0]).toMatchObject({ text: 'sum' }); + expect(proj.messages[3]!.message.origin).toEqual({ + kind: 'injection', + variant: 'compaction_continuation', + }); + expect(proj.messages[3]!.message.content[0]).toMatchObject({ + text: buildCompactionContinuationText(), + }); }); it('apply_compaction mirrors the legacy verbatim tail for records without keptUserMessageCount (model)', () => { @@ -499,9 +507,10 @@ describe('context-projector', () => { ]; const proj = projectContext(entries as any); - // [FIRST, head slice of middle, marker, tail slice of middle, LAST, summary] - // — mirrors the engine's selectCompactionUserMessages + elision marker. - expect(proj.messages).toHaveLength(6); + // [FIRST, head slice of middle, marker, tail slice of middle, LAST, summary, anchor] + // — mirrors the engine's selectCompactionUserMessages + elision marker, with + // the continuation anchor after the summary. + expect(proj.messages).toHaveLength(7); const texts = proj.messages.map((m) => m.message.content.map((p: any) => (p.type === 'text' ? p.text : '')).join(''), ); @@ -517,9 +526,14 @@ describe('context-projector', () => { expect(middle.endsWith(texts[3]!)).toBe(true); expect(texts[4]).toBe(last); expect(proj.messages[5]!.source).toBe('compaction_summary'); + expect(proj.messages[6]!.message.origin).toEqual({ + kind: 'injection', + variant: 'compaction_continuation', + }); + expect(texts[6]).toBe(buildCompactionContinuationText()); // Synthesized entries (the head slice of the same message that anchors the // tail, and the marker) get fractional lineNos so keys stay unique. - expect(new Set(proj.messages.map((m) => m.lineNo)).size).toBe(6); + expect(new Set(proj.messages.map((m) => m.lineNo)).size).toBe(7); }); it('apply_compaction drops shell/local-command/background messages in model mode only', () => { @@ -543,10 +557,10 @@ describe('context-projector', () => { const model = projectContext(entries as any); expect(model.messages.map((m) => m.source)).toEqual([ - 'append_message', 'compaction_summary', 'append_message', + 'append_message', 'compaction_summary', 'append_message', 'append_message', ]); expect(model.messages.map((m) => m.message.content[0])).toMatchObject([ - { text: 'real user' }, { text: 'sum' }, { text: 'new' }, + { text: 'real user' }, { text: 'sum' }, { text: buildCompactionContinuationText() }, { text: 'new' }, ]); const full = projectContext(entries as any, 'full'); @@ -591,12 +605,14 @@ describe('context-projector', () => { keptUserMessageCount: 3 }, raw: {} }, ]; const proj = projectContext(entries as any); - // Correct: [u1, u3, u4, summary]. The marker is gone, all real prompts kept. + // Correct: [u1, u3, u4, summary, anchor]. The marker is gone, all real + // prompts kept, and the continuation anchor follows the summary. expect(proj.messages.map((m) => m.source)).toEqual([ - 'append_message', 'append_message', 'append_message', 'compaction_summary', + 'append_message', 'append_message', 'append_message', 'compaction_summary', 'append_message', ]); expect(proj.messages.map((m) => m.message.content[0])).toMatchObject([ { text: 'u1' }, { text: 'u3' }, { text: 'u4' }, { text: 'sum' }, + { text: buildCompactionContinuationText() }, ]); }); @@ -994,10 +1010,10 @@ describe('context-projector', () => { keptUserMessageCount: 2 }, raw: {} }, ]; // No 2nd arg → 'model' default: the real user prompts are kept verbatim and - // the summary is appended after them. + // the summary is appended after them, followed by the continuation anchor. const proj = projectContext(entries as any); expect(proj.messages.map((m) => m.source)).toEqual([ - 'append_message', 'append_message', 'compaction_summary', + 'append_message', 'append_message', 'compaction_summary', 'append_message', ]); expect(proj.messages[0]!.message.content[0]).toMatchObject({ text: 'm0' }); expect(proj.messages[1]!.message.content[0]).toMatchObject({ text: 'm1' }); diff --git a/apps/vis/server/test/routes/context.test.ts b/apps/vis/server/test/routes/context.test.ts index 6352747e955..c2b6649fb53 100644 --- a/apps/vis/server/test/routes/context.test.ts +++ b/apps/vis/server/test/routes/context.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, afterEach } from 'vitest'; +import { buildCompactionContinuationText } from '@moonshot-ai/agent-core-v2/agent/contextMemory/compactionHandoff'; import { buildSessionFixture } from '../fixtures/build'; import { contextRoute } from '../../src/routes/context'; @@ -77,10 +78,11 @@ describe('context route', () => { messages: { source: string; message: { content: { type: string; text?: string }[] } }[]; }; expect(modelBody.messages.map((m) => m.source)).toEqual([ - 'append_message', 'compaction_summary', 'append_message', + 'append_message', 'compaction_summary', 'append_message', 'append_message', ]); expect(modelBody.messages[0]!.message.content[0]).toMatchObject({ text: 'before compaction' }); - expect(modelBody.messages[2]!.message.content[0]).toMatchObject({ text: 'after compaction' }); + expect(modelBody.messages[2]!.message.content[0]).toMatchObject({ text: buildCompactionContinuationText() }); + expect(modelBody.messages[3]!.message.content[0]).toMatchObject({ text: 'after compaction' }); // Full history: every pre-compaction message (user prompt + assistant reply) // is KEPT, then the summary marker, then the post-compaction tail. diff --git a/packages/agent-core-v2/src/agent/contextMemory/compaction-summary-prefix.md b/packages/agent-core-v2/src/agent/contextMemory/compaction-summary-prefix.md index f814a9f84ca..3b8345bf345 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/compaction-summary-prefix.md +++ b/packages/agent-core-v2/src/agent/contextMemory/compaction-summary-prefix.md @@ -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. diff --git a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts index ad2bcdcc883..60f43a0155f 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts @@ -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; @@ -94,11 +95,12 @@ export function buildContextCompactionShape( ? [...selection.head, ...selection.tail] : [...selection.head, elisionMessage, ...selection.tail]; const contextSummary = input.contextSummary ?? input.summary; + const continuationMessage = createCompactionContinuationMessage(); const tokensAfter = input.tokensAfter ?? (input.requestOverheadTokens ?? 0) + (input.summaryOutputTokens ?? estimate.text(contextSummary)) + - estimate.messages(keptMessages); + estimate.messages([...keptMessages, continuationMessage]); const keptUserMessageCount = input.keptUserMessageCount ?? selection.head.length + selection.tail.length; const keptHeadUserMessageCount = @@ -113,7 +115,11 @@ export function buildContextCompactionShape( keptUserMessageCount, keptHeadUserMessageCount, droppedCount: input.droppedCount, - messages: [...keptMessages, createCompactionSummaryMessage(contextSummary)], + messages: [ + ...keptMessages, + createCompactionSummaryMessage(contextSummary), + continuationMessage, + ], }; } @@ -146,6 +152,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.', + ); +} + export function collectCompactableUserMessages(messages: readonly T[]): T[] { return messages.filter( (message) => isRealUserInput(message) && !isCompactionSummaryMessage(message), diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index b5e348a8559..5f7fd4cee15 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -190,7 +190,7 @@ function recoverFoldedLength( const keptHeadUserMessageCount = readNumber(record, 'keptHeadUserMessageCount'); const compactedCount = readNumber(record, 'compactedCount'); if (keptUserMessageCount !== undefined) { - return keptUserMessageCount + (keptHeadUserMessageCount === undefined ? 1 : 2); + return keptUserMessageCount + (keptHeadUserMessageCount === undefined ? 2 : 3); } if (compactedCount !== undefined && compactedCount < foldedLength) { return 1 + (foldedLength - compactedCount); diff --git a/packages/agent-core-v2/src/agent/fullCompaction/compaction-instruction.md b/packages/agent-core-v2/src/agent/fullCompaction/compaction-instruction.md index fc30e61a353..90742b820bc 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/compaction-instruction.md +++ b/packages/agent-core-v2/src/agent/fullCompaction/compaction-instruction.md @@ -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 @@ -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 @@ -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. diff --git a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts index 53d0acde0c7..e9e597fa19f 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts @@ -788,8 +788,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', () => { diff --git a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts index 9eb10056f3a..15dac4645d3 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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); }); diff --git a/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts b/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts index 9c3330ecd84..ad2988e4b2b 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts @@ -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'; @@ -376,11 +377,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 () => { @@ -406,7 +415,12 @@ describe('AgentContextMemoryService (wire-backed)', () => { ); const model = replay.agentState.get(contextMemoryKey); - expect(model.map(textOf)).toEqual(['old user', 'recent user', 'OLD SUMMARY']); + expect(model.map(textOf)).toEqual([ + 'old user', + 'recent user', + 'OLD SUMMARY', + buildCompactionContinuationText(), + ]); expect(model[2]).toMatchObject({ role: 'user', origin: { kind: 'compaction_summary' }, diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index 1d0d8a9bb00..e3a6a146143 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -19,7 +19,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { DefaultCompactionStrategy, } from '#/agent/fullCompaction/strategy'; -import { COMPACTION_SUMMARY_PREFIX } from '#/agent/contextMemory/compactionHandoff'; +import { + buildCompactionContinuationText, + COMPACTION_SUMMARY_PREFIX, +} from '#/agent/contextMemory/compactionHandoff'; import { makeHookRunner } from '../../features/externalHooks/runner-stub'; import type { IExternalHooksRunnerService } from '#/features/externalHooks/app/externalHooksRunner'; import { MASTER_ENV } from '#/app/flag/flagService'; @@ -294,11 +297,16 @@ describe('FullCompaction', () => { role: 'user', text: expect.stringContaining('Compacted summary.'), }, + { role: 'user', text: buildCompactionContinuationText() }, ]); - expect(ctx.context.get().at(-1)?.content[0]).toMatchObject({ + expect(ctx.context.get().at(-2)?.content[0]).toMatchObject({ type: 'text', text: expect.stringContaining('The conversation so far has been compacted'), }); + expect(ctx.context.get().at(-1)).toMatchObject({ + role: 'user', + origin: { kind: 'injection', variant: 'compaction_continuation' }, + }); expect(records).toContainEqual({ event: 'compaction_finished', properties: expect.objectContaining({ @@ -310,7 +318,7 @@ describe('FullCompaction', () => { compacted_count: 6, retry_count: 0, thinking_effort: 'off', - input_tokens: 1247, + input_tokens: 1192, output_tokens: 8, input_cache_read: 0, input_cache_creation: 0, @@ -535,6 +543,7 @@ describe('FullCompaction', () => { role: 'user', text: expect.stringContaining('Recovered compacted summary.'), }, + { role: 'user', text: buildCompactionContinuationText() }, ]); await ctx.expectResumeMatches(); }); @@ -837,6 +846,7 @@ describe('FullCompaction', () => { { role: 'user', text: 'old user one' }, { role: 'user', text: 'recent user two' }, { role: 'user', text: `${COMPACTION_SUMMARY_PREFIX}\nRecovered compacted summary.` }, + { role: 'user', text: buildCompactionContinuationText() }, ]); expect( ctx.allEvents.filter((event) => event.event === 'compaction.completed'), @@ -889,6 +899,7 @@ describe('FullCompaction', () => { { role: 'user', text: 'old user one' }, { role: 'user', text: 'recent user two' }, { role: 'user', text: `${COMPACTION_SUMMARY_PREFIX}\nRecovered compacted summary.` }, + { role: 'user', text: buildCompactionContinuationText() }, ]); vi.useRealTimers(); await ctx.expectResumeMatches(); @@ -1455,6 +1466,7 @@ describe('FullCompaction', () => { 'user', 'user', 'user', + 'user', ]); await ctx.dispatch({ type: 'context.append_loop_event', @@ -1469,6 +1481,7 @@ describe('FullCompaction', () => { 'user', 'user', 'user', + 'user', ]); await ctx.expectResumeMatches(); }); @@ -1527,9 +1540,15 @@ describe('FullCompaction', () => { }, { "role": "user", - "text": "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. + "text": "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. Compacted prefix.", }, + { + "role": "user", + "text": " + Context compaction is complete — continue the work that was in progress when it began. + ", + }, ] `); await ctx.expectResumeMatches(); @@ -1756,14 +1775,15 @@ describe('FullCompaction', () => { call 2: messages: user: text "old user one\\n\\nold user two\\n\\nrecent user three\\n\\nAnswer after compacting" - user: text "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.\\nAuto compacted summary." + user: text "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.\\nAuto compacted summary." + user: text "\\nContext compaction is complete — continue the work that was in progress when it began.\\n" `); expect(records).toContainEqual({ event: 'compaction_finished', properties: expect.objectContaining({ source: 'auto', tokens_before: 6_142, - tokens_after: 6_126, + tokens_after: 6_159, compacted_count: 7, retry_count: 0, }), @@ -1865,8 +1885,10 @@ describe('FullCompaction', () => { 'user', 'user', 'user', + 'user', ]); - expect(ctx.context.get().at(-1)?.origin).toEqual({ kind: 'compaction_summary' }); + expect(ctx.context.get().at(-2)?.origin).toEqual({ kind: 'compaction_summary' }); + expect(ctx.context.get().at(-1)?.origin).toEqual({ kind: 'injection', variant: 'compaction_continuation' }); await ctx.dispatch({ type: 'context.append_loop_event', @@ -1890,6 +1912,7 @@ describe('FullCompaction', () => { 'user', 'user', 'user', + 'user', ]); }); @@ -1933,8 +1956,10 @@ describe('FullCompaction', () => { 'user', 'user', 'user', + 'user', ]); - expect(ctx.context.get().at(-1)?.origin).toEqual({ kind: 'compaction_summary' }); + expect(ctx.context.get().at(-2)?.origin).toEqual({ kind: 'compaction_summary' }); + expect(ctx.context.get().at(-1)?.origin).toEqual({ kind: 'injection', variant: 'compaction_continuation' }); await ctx.dispatch({ type: 'context.append_loop_event', @@ -1949,6 +1974,7 @@ describe('FullCompaction', () => { 'user', 'user', 'user', + 'user', ]); }); @@ -1974,6 +2000,7 @@ describe('FullCompaction', () => { role: 'user', text: `${COMPACTION_SUMMARY_PREFIX}\nSingle message summary.`, }, + { role: 'user', text: buildCompactionContinuationText() }, ]); await ctx.expectResumeMatches(); }); @@ -2009,6 +2036,7 @@ describe('FullCompaction', () => { role: 'user', text: expect.stringContaining('Compacted after single-message compact.'), }, + { role: 'user', text: buildCompactionContinuationText() }, ]); await ctx.expectResumeMatches(); }); @@ -2137,7 +2165,7 @@ describe('FullCompaction', () => { expect(ctx.llmCalls).toHaveLength(2); const [compactionCall, answerCall] = ctx.llmCalls; - expect(messageText(compactionCall?.history.at(-1))).toContain('first-person handoff note'); + expect(messageText(compactionCall?.history.at(-1))).toContain('Create a handoff summary for the'); expect( answerCall?.history.map(messageText).some((text) => text.includes('Reserved compacted summary.')), ).toBe(true); @@ -2284,14 +2312,87 @@ describe('FullCompaction', () => { "user: old user one", "assistant: old assistant one", "user: Retry after provider overflow", - "user: ", + "user: 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 --- + + 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 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 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 + the next move, and re-quote any still-relevant earlier request that may have + scrolled out of the kept messages. + - The instructions and constraints currently in force (user preferences, + project rules, environment and tooling limits) — condensed to what still + matters, keeping decisions you have already settled (what you chose and why) + separate from questions still open, so you neither silently reopen a closed + choice nor treat an undecided point as decided. + - What has actually been done, at high fidelity: keep the exact commands that + were run, the exact file paths touched, and whether each succeeded or failed — + and the results themselves, not just the commands: the concrete values + returned, the key lines or error text, the schema or signature a lookup + revealed, since re-running to recover them may be slow or impossible. Keep only + the final working version of any code; drop intermediate attempts and + already-resolved errors. + - What you still don't know: context the next step depends on that this + conversation never established — files or paths referenced but not yet read, + schemas or APIs assumed but unseen, questions the user has not answered. Name + these gaps so the next turn goes and checks them instead of assuming. + - The forward plan — and this is the moment to invest in it. Right now you + hold more context on this task than you ever will again; the next turn + resumes with less, so the plan you commit here is the one it will follow. + Give the exact next command or tool call, but don't stop at the next step: + set out the remaining sequence to finish, the decisions you have already + made for those upcoming steps (so the next turn doesn't reopen them), the + obstacles or edge cases you can already foresee and how you mean to handle + them, and any work you can commit to now — the exact patch, query, or shape + of the final answer you already know you will produce. Anything you settle + 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 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 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 + record that instead. + + Be honest about uncertainty. If an earlier step claimed something was done but + 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 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. + + Respond with text only. Do not call any tools — you already have everything you + need in the conversation history.", ], [ "user: old user one Retry after provider overflow", - "user: 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. + "user: 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. Overflow compacted summary.", + "user: + Context compaction is complete — continue the work that was in progress when it began. + ", ], ] `); @@ -3005,18 +3106,161 @@ describe('FullCompaction', () => { "user: old user one", "assistant: old assistant one", "user: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - "user: ", + "user: 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 --- + + 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 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 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 + the next move, and re-quote any still-relevant earlier request that may have + scrolled out of the kept messages. + - The instructions and constraints currently in force (user preferences, + project rules, environment and tooling limits) — condensed to what still + matters, keeping decisions you have already settled (what you chose and why) + separate from questions still open, so you neither silently reopen a closed + choice nor treat an undecided point as decided. + - What has actually been done, at high fidelity: keep the exact commands that + were run, the exact file paths touched, and whether each succeeded or failed — + and the results themselves, not just the commands: the concrete values + returned, the key lines or error text, the schema or signature a lookup + revealed, since re-running to recover them may be slow or impossible. Keep only + the final working version of any code; drop intermediate attempts and + already-resolved errors. + - What you still don't know: context the next step depends on that this + conversation never established — files or paths referenced but not yet read, + schemas or APIs assumed but unseen, questions the user has not answered. Name + these gaps so the next turn goes and checks them instead of assuming. + - The forward plan — and this is the moment to invest in it. Right now you + hold more context on this task than you ever will again; the next turn + resumes with less, so the plan you commit here is the one it will follow. + Give the exact next command or tool call, but don't stop at the next step: + set out the remaining sequence to finish, the decisions you have already + made for those upcoming steps (so the next turn doesn't reopen them), the + obstacles or edge cases you can already foresee and how you mean to handle + them, and any work you can commit to now — the exact patch, query, or shape + of the final answer you already know you will produce. Anything you settle + 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 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 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 + record that instead. + + Be honest about uncertainty. If an earlier step claimed something was done but + 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 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. + + Respond with text only. Do not call any tools — you already have everything you + need in the conversation history.", ], [ "user: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - "user: ", + "user: 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 --- + + 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 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 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 + the next move, and re-quote any still-relevant earlier request that may have + scrolled out of the kept messages. + - The instructions and constraints currently in force (user preferences, + project rules, environment and tooling limits) — condensed to what still + matters, keeping decisions you have already settled (what you chose and why) + separate from questions still open, so you neither silently reopen a closed + choice nor treat an undecided point as decided. + - What has actually been done, at high fidelity: keep the exact commands that + were run, the exact file paths touched, and whether each succeeded or failed — + and the results themselves, not just the commands: the concrete values + returned, the key lines or error text, the schema or signature a lookup + revealed, since re-running to recover them may be slow or impossible. Keep only + the final working version of any code; drop intermediate attempts and + already-resolved errors. + - What you still don't know: context the next step depends on that this + conversation never established — files or paths referenced but not yet read, + schemas or APIs assumed but unseen, questions the user has not answered. Name + these gaps so the next turn goes and checks them instead of assuming. + - The forward plan — and this is the moment to invest in it. Right now you + hold more context on this task than you ever will again; the next turn + resumes with less, so the plan you commit here is the one it will follow. + Give the exact next command or tool call, but don't stop at the next step: + set out the remaining sequence to finish, the decisions you have already + made for those upcoming steps (so the next turn doesn't reopen them), the + obstacles or edge cases you can already foresee and how you mean to handle + them, and any work you can commit to now — the exact patch, query, or shape + of the final answer you already know you will produce. Anything you settle + 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 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 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 + record that instead. + + Be honest about uncertainty. If an earlier step claimed something was done but + 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 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. + + Respond with text only. Do not call any tools — you already have everything you + need in the conversation history.", ], [ "user: old user one xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - "user: 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. + "user: 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. Placeholder compacted summary.", + "user: + Context compaction is complete — continue the work that was in progress when it began. + ", ], ] `); @@ -3049,7 +3293,7 @@ describe('FullCompaction', () => { await completed; const history = ctx.compactHistory(); - expect(history).toHaveLength(3); + expect(history).toHaveLength(4); expect(history[0]).toMatchObject({ role: 'user', text: 'old user one', @@ -3064,10 +3308,18 @@ describe('FullCompaction', () => { 'Compacted summary.\n\n## TODO List\n [in_progress] Fix the auth bug\n [pending] Add tests', ), }); - expect(ctx.context.get().at(-1)?.content[0]).toMatchObject({ + expect(history[3]).toMatchObject({ + role: 'user', + text: buildCompactionContinuationText(), + }); + expect(ctx.context.get().at(-2)?.content[0]).toMatchObject({ type: 'text', text: expect.stringContaining('The conversation so far has been compacted'), }); + expect(ctx.context.get().at(-1)).toMatchObject({ + role: 'user', + origin: { kind: 'injection', variant: 'compaction_continuation' }, + }); await ctx.expectResumeMatches(); }); }); @@ -3116,7 +3368,7 @@ describe('FullCompaction context recovery pointer', () => { } function noteText(ctx: TestAgentContext): string { - const part = ctx.context.get().at(-1)?.content[0]; + const part = ctx.context.get().at(-2)?.content[0]; return part?.type === 'text' ? part.text : ''; } @@ -3235,11 +3487,11 @@ describe('FullCompaction context recovery pointer', () => { ); }); - it('tells the summarizer a recovery pointer follows the note', () => { + it('tells the summarizer a recovery pointer follows the summary', () => { const withPointer = renderCompactionInstruction({}); const withCustom = renderCompactionInstruction({ customInstruction: ' keep the API facts ' }); - expect(withPointer).toContain('a recovery pointer is appended below your note automatically'); + expect(withPointer).toContain('a recovery pointer is appended below this summary automatically'); expect(withPointer).toContain('format for the final answer.\n\nThis conversation'); expect(withPointer).not.toContain('${'); expect(withCustom).toContain('Optional user instruction:\nkeep the API facts'); diff --git a/packages/agent-core-v2/test/agent/tokenCounting/tokenCounting.test.ts b/packages/agent-core-v2/test/agent/tokenCounting/tokenCounting.test.ts index 60a6590f81b..9fd7291da1b 100644 --- a/packages/agent-core-v2/test/agent/tokenCounting/tokenCounting.test.ts +++ b/packages/agent-core-v2/test/agent/tokenCounting/tokenCounting.test.ts @@ -131,7 +131,7 @@ describe('Agent token counting', () => { }); const history = context.get(); - const kept = estimateTokensForMessages(history.filter((m) => m.origin?.kind === 'user')); + const kept = estimateTokensForMessages(history.filter((m) => m.origin?.kind !== 'compaction_summary')); const expected = 500 + kept; expect(tokenCountingState(ctx).anchors).toEqual([ { length: history.length, tokens: expected, measured: false }, diff --git a/packages/agent-core-v2/test/agent/undo/undo.test.ts b/packages/agent-core-v2/test/agent/undo/undo.test.ts index cf25612cca1..6477ebb8657 100644 --- a/packages/agent-core-v2/test/agent/undo/undo.test.ts +++ b/packages/agent-core-v2/test/agent/undo/undo.test.ts @@ -182,8 +182,9 @@ describe('AgentConversationUndoService', () => { await undo.undo(1); const history = ctx.context.get(); - expect(history.map((m) => m.role)).toEqual(['user', 'user']); + expect(history.map((m) => m.role)).toEqual(['user', 'user', 'user']); expect(history[1]?.origin?.kind).toBe('compaction_summary'); + expect(history[2]?.origin).toEqual({ kind: 'injection', variant: 'compaction_continuation' }); }); it('refuses loudly when a legacy compaction leaves anchors without checkpoints', async () => { diff --git a/packages/agent-core-v2/test/harness/snapshots.ts b/packages/agent-core-v2/test/harness/snapshots.ts index ab281035be1..d99d13433a6 100644 --- a/packages/agent-core-v2/test/harness/snapshots.ts +++ b/packages/agent-core-v2/test/harness/snapshots.ts @@ -237,7 +237,7 @@ function formatText(text: string): string { if (isDateReminder(text)) { return ''; } - if (text.includes('first-person handoff note')) { + if (text.includes('You are about to run out of context.')) { return ''; } return JSON.stringify(text);