diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index 130b791b4b..cf5e620afa 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -764,32 +764,44 @@ describe('New Applications workflow deferred to skill', () => { }); describe('getCompressionPrompt', () => { - it('contains all 9 required sections', () => { + it('uses the XML envelope with all 9 required section tags', () => { const prompt = getCompressionPrompt(); - expect(prompt).toContain('1. Primary Request and Intent'); - expect(prompt).toContain('2. Key Technical Concepts'); - expect(prompt).toContain('3. Files and Code Sections'); - expect(prompt).toContain('4. Errors and fixes'); - expect(prompt).toContain('5. Problem Solving'); - expect(prompt).toContain('6. All user messages'); - expect(prompt).toContain('7. Pending Tasks'); - expect(prompt).toContain('8. Current Work'); - expect(prompt).toContain('9. Optional Next Step'); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain(''); }); - it('mandates verbatim preservation of user messages', () => { + it('instructs the model to wrap reasoning in an block', () => { const prompt = getCompressionPrompt(); - // Must explicitly say "preserve every user message verbatim" — the - // word "verbatim" alone is insufficient because it also appears in - // section 4 ("error messages verbatim"). - expect(prompt).toContain('preserve every user message verbatim'); - expect(prompt).toContain('do NOT paraphrase'); + expect(prompt).toContain(''); + // Must signal that is stripped (so the model knows it is a + // drafting scratchpad, not part of the final summary). + expect(prompt).toMatch(/.*stripped|stripped.*/is); }); - it('instructs the model to resume directly without acknowledging the summary', () => { + it('asks for the section to be chronological and inclusive', () => { const prompt = getCompressionPrompt(); - expect(prompt).toMatch( - /resume.*directly|do not acknowledge|without.*recap/i, + // The actual mandate text — verbatim-but-not-VERBATIM-policed. + expect(prompt).toMatch(/all user messages.*chronological/i); + expect(prompt).toContain('"ok"'); + expect(prompt).toContain('"continue"'); + }); + + it('does NOT include the resume trailer in the prompt body', () => { + // The trailer lives in postCompactAttachments.postProcessSummary, not in + // the prompt. Keeping it out of the prompt saves output tokens per + // compaction and prevents wording drift. + const prompt = getCompressionPrompt(); + expect(prompt).not.toMatch( + /resume.*directly|continue the conversation from where it left off/i, ); }); }); diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index 1cde396730..5a7d2b9e5a 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -438,45 +438,49 @@ When you encounter an obstacle, do not use destructive actions as a shortcut to */ export function getCompressionPrompt(): string { return ` -You are the component that summarizes a conversation when its context window is about to overflow. The summary you produce will become the agent's ONLY memory of everything that happened before this point. The agent will resume its work based solely on this summary plus a small number of restored file/image attachments that follow. +You are the component that summarizes a conversation when its context window is about to overflow. The summary you produce will become the agent's ONLY memory of everything that happened before this point. The agent will resume its work based solely on this summary plus a small number of restored file / image attachments that follow. -First, think privately in a . Review the user's overall goal, every action the agent took, every tool output, every error and recovery, and any unresolved questions. Identify the information that is essential for continuing the work. +First, wrap your reasoning in an block. Inside it, walk through the conversation chronologically and identify, for each section: the user's explicit requests and intent, your approach to those requests, key decisions / technical concepts / code patterns, specific details (file names, code snippets, function signatures, file edits), errors and how they were fixed, and any specific user feedback — especially when the user told you to do something differently. The block is stripped before the summary reaches the next agent; it is purely a drafting scratchpad to improve the summary that follows. -Then produce the final summary in the EXACT structure below. Be dense with information. Omit conversational filler. Critically: **preserve every user message verbatim — do NOT paraphrase, summarize, or rewrite anything the user said**. The user's exact wording carries intent the agent must not lose. +Then produce the final summary as the EXACT XML structure below. Be dense. Omit conversational filler. -The structure MUST be: + + + + -Summary: -1. Primary Request and Intent: - A prose paragraph describing the user's high-level goal and any sub-goals. Quote the user's exact phrasing where intent is at stake. + + + -2. Key Technical Concepts: - Bullet list of concepts, libraries, conventions, and constraints established in the conversation. + + + -3. Files and Code Sections: - For each file touched, one block: file path, what was read / written / edited, the gist of the change, any critical learning (e.g., "this file expects UTF-8 with BOM"). + + + -4. Errors and fixes: - Bullet list of errors encountered and how they were resolved. Include error messages verbatim when they were quoted to the agent. + + + -5. Problem Solving: - Brief notes on non-trivial decisions and trade-offs that the agent made while working. + + + -6. All user messages (chronological): - List EVERY message the user sent during the conversation, in order, VERBATIM. Do not paraphrase. Do not omit short messages like "ok" or "continue" — they are signal. Use quotation marks to preserve exact text. + + + -7. Pending Tasks: - Bullet list of work the user requested that has NOT yet been completed. + + + -8. Current Work: - What the agent was doing in the very last turn before compaction triggered. Include the verbatim last sentence or two of the agent's previous output so the next turn can pick up exactly where this one stopped. - -9. Optional Next Step: - A single concrete next action that directly continues #8, derived from the user's most recent intent. If the user's last message asked a clarifying question or signaled a pause, say so instead — do not invent forward motion. - -After the summary, append exactly: - -Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, do not preface with "I'll continue" or similar. Pick up the last task as if the break never happened. + + + + `.trim(); } diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index d907be64cc..987aff84ff 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -274,7 +274,9 @@ describe('ChatCompressionService', () => { expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); expect(result.info.newTokenCount).toBe(250); // 800 - (1600 - 1000) + 50 expect(result.newHistory).not.toBeNull(); - expect(result.newHistory![0].parts![0].text).toBe('Summary'); + // postProcessSummary appends the resume trailer to the summary body, + // so it's "Summary\n\n" rather than a strict equality. + expect(result.newHistory![0].parts![0].text).toContain('Summary'); expect(mockGenerateContent).toHaveBeenCalled(); expect(mockGetHookSystem).toHaveBeenCalled(); }); diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 1260bcdb7d..34e02f4e5f 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -315,7 +315,7 @@ export class ChatCompressionService { role: 'user', parts: [ { - text: 'First, reason in your scratchpad. Then, produce the 9-section summary.', + text: 'First, reason in your block. Then, produce the XML.', }, ], }, @@ -403,7 +403,7 @@ export class ChatCompressionService { // Best-effort token math using *only* model-reported token counts. // // Note: compressionInputTokenCount includes the compression prompt and - // the extra "reason in your scratchpad" instruction(approx. 1000 tokens), and + // the extra "reason in your block" instruction (approx. 1000 tokens), and // compressionOutputTokenCount reflects the summary tokens only since // thinking is disabled. // We accept these inaccuracies to avoid local token estimation. diff --git a/packages/core/src/services/postCompactAttachments.test.ts b/packages/core/src/services/postCompactAttachments.test.ts index 953034d07a..943325d68f 100644 --- a/packages/core/src/services/postCompactAttachments.test.ts +++ b/packages/core/src/services/postCompactAttachments.test.ts @@ -637,4 +637,71 @@ describe('composePostCompactHistory', () => { /got it|acknowledged|continue/i, ); }); + + it('strips the block from the raw summary before placing it in newHistory', async () => { + const history: Content[] = [{ role: 'user', parts: [{ text: 'do x' }] }]; + const raw = + '\nthe model was thinking out loud here\nshould not leak\n\n\n\n actual summary\n'; + const result = await composePostCompactHistory(history, raw); + const summaryText = (result[0].parts?.[0] as { text: string }).text; + expect(summaryText).not.toContain(''); + expect(summaryText).not.toContain('thinking out loud'); + expect(summaryText).toContain(''); + expect(summaryText).toContain('actual summary'); + }); + + it('appends the resume trailer to the summary message text', async () => { + const history: Content[] = [{ role: 'user', parts: [{ text: 'do x' }] }]; + const result = await composePostCompactHistory( + history, + '...', + ); + const summaryText = (result[0].parts?.[0] as { text: string }).text; + // Trailer instructs the resuming agent not to greet / recap. + expect(summaryText).toMatch(/resume.*prior task|continue from/i); + expect(summaryText).toMatch( + /do not acknowledge|do not re-introduce|do not greet/i, + ); + }); +}); + +describe('postProcessSummary', () => { + it('returns body + trailer when no block is present', async () => { + const { postProcessSummary } = await import('./postCompactAttachments.js'); + const out = postProcessSummary('body'); + expect(out).toContain('body'); + expect(out).toMatch(/resume.*prior task/i); + }); + + it('strips wrappers (greedy across newlines)', async () => { + const { postProcessSummary } = await import('./postCompactAttachments.js'); + const out = postProcessSummary( + '\nlots of\nmulti-line\nreasoning\n\n\nbody', + ); + expect(out).not.toContain(''); + expect(out).not.toContain('multi-line'); + expect(out).toContain('body'); + }); + + it('strips multiple blocks if the model emits more than one', async () => { + const { postProcessSummary } = await import('./postCompactAttachments.js'); + const out = postProcessSummary( + 'first\nbody\nsecond', + ); + expect(out).not.toContain(''); + expect(out).not.toContain('first'); + expect(out).not.toContain('second'); + }); + + it('falls back to the raw summary when stripping leaves nothing', async () => { + const { postProcessSummary } = await import('./postCompactAttachments.js'); + // Pathological case: model produced only an block. We should + // NOT return only the trailer — that would lose the entire model + // response. Fall back to the raw text so caller has something to + // inspect / log. + const raw = 'nothing else'; + const out = postProcessSummary(raw); + expect(out).toContain('nothing else'); + expect(out).toMatch(/resume.*prior task/i); + }); }); diff --git a/packages/core/src/services/postCompactAttachments.ts b/packages/core/src/services/postCompactAttachments.ts index 43907baab0..3eb8f26090 100644 --- a/packages/core/src/services/postCompactAttachments.ts +++ b/packages/core/src/services/postCompactAttachments.ts @@ -364,6 +364,40 @@ export function buildImageRestorationBlock( * The ack message keeps role alternation correct: the next API call will * naturally append the model's continuation response. */ +/** + * Trailer appended to the post-compact summary message. Mirrors claude-code's + * "Resume directly" guidance for the resuming agent: it must NOT acknowledge + * the summary, re-greet, or recap — it picks up from where the prior turn + * left off based on the summary. + * + * Lives in the wrapper (not in the compression system prompt) so the summary + * model does not have to re-generate this text every compaction (saves + * output tokens, prevents wording drift). + */ +const RESUME_TRAILER = + 'Resume the prior task using the summary above. Continue from the last in-flight step; do not acknowledge the summary, do not re-introduce, do not greet the user again.'; + +/** + * Strip the model's drafting scratchpad before the summary becomes the new + * post-compact context. The compression prompt instructs the summary model + * to wrap its chain-of-thought reasoning in an `...` + * block, which is purely for the model's own benefit; keeping it in history + * wastes tokens and degrades signal-to-noise for the resuming agent. + * + * Defensive design: if the strip removes everything (model produced ONLY an + * analysis block with no summary content), fall back to the raw summary so + * the caller sees something rather than an empty string — the inflation + * guard upstream will still NOOP this round, but we don't want to silently + * lose the entire model response. + */ +export function postProcessSummary(rawSummary: string): string { + const stripped = rawSummary + .replace(/[\s\S]*?<\/analysis>\s*/g, '') + .trim(); + const body = stripped.length > 0 ? stripped : rawSummary.trim(); + return `${body}\n\n${RESUME_TRAILER}`; +} + export async function composePostCompactHistory( history: Content[], summary: string, @@ -381,7 +415,7 @@ export async function composePostCompactHistory( const imageBlock = buildImageRestorationBlock(images); const out: Content[] = [ - { role: 'user', parts: [{ text: summary }] }, + { role: 'user', parts: [{ text: postProcessSummary(summary) }] }, { role: 'model', parts: [{ text: 'Got it. Thanks for the additional context!' }],