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/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 11a6a9f73c3..ccfa2f80bf7 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; @@ -42,6 +43,7 @@ export interface ContextCompactionShapeInput { readonly keptUserMessageCount?: number; readonly keptHeadUserMessageCount?: number; readonly droppedCount?: number; + readonly hasContinuation?: boolean; readonly legacyTail?: boolean; } @@ -54,6 +56,7 @@ export interface ContextCompactionShape { readonly keptUserMessageCount: number; readonly keptHeadUserMessageCount?: number; readonly droppedCount?: number; + readonly hasContinuation: boolean; readonly messages: readonly ContextMessage[]; } @@ -76,6 +79,7 @@ export function buildContextCompactionShape( tokensAfter: input.tokensAfter ?? estimate.messages(messages), keptUserMessageCount: 0, droppedCount: input.droppedCount, + hasContinuation: false, messages, }; } @@ -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 = @@ -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], }; } @@ -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.', + ); +} + 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/contextEvents.ts b/packages/agent-core-v2/src/agent/contextMemory/contextEvents.ts index 3300d4ce671..d8d0fa095ec 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextEvents.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextEvents.ts @@ -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() }) diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts index 9a490ec9a3e..8034c8402b4 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts @@ -16,6 +16,7 @@ export interface ContextCompactionInput { readonly keptUserMessageCount?: number; readonly keptHeadUserMessageCount?: number; readonly droppedCount?: number; + readonly hasContinuation?: boolean; readonly wireLines?: WireLineRange; } @@ -28,6 +29,7 @@ export interface ContextCompactionResult { keptUserMessageCount: number; keptHeadUserMessageCount?: number; droppedCount?: number; + hasContinuation: boolean; } export interface IAgentContextMemoryService { diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts index f29cc89e8fd..42ced45c53c 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts @@ -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, }), ); diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts index 80186c277f8..b4061b4b9be 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts @@ -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, }; } diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index 75df69068b8..36fc62b712d 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -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); 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 9e586008153..3fe6534721b 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts @@ -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', () => { 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..cf36396d07f 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -60,7 +60,7 @@ function compaction( compactedCount, tokensBefore: 1000, tokensAfter: 100, - ...(keptUserMessageCount === undefined ? {} : { keptUserMessageCount }), + ...(keptUserMessageCount === undefined ? {} : { keptUserMessageCount, hasContinuation: true }), ...(keptHeadUserMessageCount === undefined ? {} : { keptHeadUserMessageCount }), }; } @@ -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 0d9b036728c..e95ce377be3 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'; @@ -364,6 +365,7 @@ describe('AgentContextMemoryService (wire-backed)', () => { tokensBefore: 100, tokensAfter: 20, keptUserMessageCount: 2, + hasContinuation: true, }, ]; @@ -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 () => { 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 e1587aa304a..ead3cb7db48 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -17,7 +17,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'; @@ -293,11 +296,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({ @@ -309,7 +317,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, @@ -534,6 +542,7 @@ describe('FullCompaction', () => { role: 'user', text: expect.stringContaining('Recovered compacted summary.'), }, + { role: 'user', text: buildCompactionContinuationText() }, ]); await ctx.expectResumeMatches(); }); @@ -836,6 +845,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'), @@ -888,6 +898,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(); @@ -1454,6 +1465,7 @@ describe('FullCompaction', () => { 'user', 'user', 'user', + 'user', ]); await ctx.dispatch({ type: 'context.append_loop_event', @@ -1468,6 +1480,7 @@ describe('FullCompaction', () => { 'user', 'user', 'user', + 'user', ]); await ctx.expectResumeMatches(); }); @@ -1526,9 +1539,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(); @@ -1755,14 +1774,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_169, - tokens_after: 6_153, + tokens_after: 6_186, compacted_count: 7, retry_count: 0, }), @@ -1864,8 +1884,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', @@ -1889,6 +1911,7 @@ describe('FullCompaction', () => { 'user', 'user', 'user', + 'user', ]); }); @@ -1932,8 +1955,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', @@ -1948,6 +1973,7 @@ describe('FullCompaction', () => { 'user', 'user', 'user', + 'user', ]); }); @@ -1973,6 +1999,7 @@ describe('FullCompaction', () => { role: 'user', text: `${COMPACTION_SUMMARY_PREFIX}\nSingle message summary.`, }, + { role: 'user', text: buildCompactionContinuationText() }, ]); await ctx.expectResumeMatches(); }); @@ -2008,6 +2035,7 @@ describe('FullCompaction', () => { role: 'user', text: expect.stringContaining('Compacted after single-message compact.'), }, + { role: 'user', text: buildCompactionContinuationText() }, ]); await ctx.expectResumeMatches(); }); @@ -2136,7 +2164,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); @@ -2283,14 +2311,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. + ", ], ] `); @@ -3004,18 +3105,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. + ", ], ] `); @@ -3048,7 +3292,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', @@ -3063,10 +3307,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(); }); }); @@ -3115,7 +3367,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 : ''; } @@ -3323,11 +3575,11 @@ describe('FullCompaction context recovery pointer', () => { expect(budget.used).toBeGreaterThan(0); }); - 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 b6baeedefe5..241518c000e 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 d88b161d424..49f02c36473 100644 --- a/packages/agent-core-v2/test/agent/undo/undo.test.ts +++ b/packages/agent-core-v2/test/agent/undo/undo.test.ts @@ -188,8 +188,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 ef2a4cff100..721aef20778 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);