mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
refactor(core): rewrite compression prompt to <state_snapshot> XML with 9 claude-aligned sections
Replaces the 9-section numbered-text prompt with qwen-code's original <state_snapshot> XML envelope, but with the 9 inner section tags content-aligned to claude-code: <primary_request_and_intent> <key_technical_concepts> <files_and_code_sections> <errors_and_fixes> <problem_solving> <all_user_messages> <pending_tasks> <current_work> <next_step> Also: - <scratchpad> -> <analysis>, stripped by postProcessSummary (saves ~600-800 tokens of CoT noise per compaction). - "Resume directly..." trailer moved out of the prompt body and into postProcessSummary (no longer re-generated by the model every compaction; lives once in code with our own wording). - Section 6 verbatim-policed mandate relaxed to "chronological, include short messages like 'ok' / 'continue'" — matches claude-code intent without forcing the model to literally copy long user messages. E2E (qwen3.6-plus, 6 substantial .ts files + thorough analysis): raw history 6508 -> summary 1513 (after strip ~947), 38% history compression. Overall context 24642 -> 20647 reported (-16%), with another ~664 tokens actually saved by the post-strip but not reflected in the conservative token-math heuristic.
This commit is contained in:
parent
f1f7477aac
commit
641a0eadd4
6 changed files with 170 additions and 51 deletions
|
|
@ -764,32 +764,44 @@ describe('New Applications workflow deferred to skill', () => {
|
|||
});
|
||||
|
||||
describe('getCompressionPrompt', () => {
|
||||
it('contains all 9 required sections', () => {
|
||||
it('uses the <state_snapshot> 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('<state_snapshot>');
|
||||
expect(prompt).toContain('</state_snapshot>');
|
||||
expect(prompt).toContain('<primary_request_and_intent>');
|
||||
expect(prompt).toContain('<key_technical_concepts>');
|
||||
expect(prompt).toContain('<files_and_code_sections>');
|
||||
expect(prompt).toContain('<errors_and_fixes>');
|
||||
expect(prompt).toContain('<problem_solving>');
|
||||
expect(prompt).toContain('<all_user_messages>');
|
||||
expect(prompt).toContain('<pending_tasks>');
|
||||
expect(prompt).toContain('<current_work>');
|
||||
expect(prompt).toContain('<next_step>');
|
||||
});
|
||||
|
||||
it('mandates verbatim preservation of user messages', () => {
|
||||
it('instructs the model to wrap reasoning in an <analysis> 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('<analysis>');
|
||||
// Must signal that <analysis> is stripped (so the model knows it is a
|
||||
// drafting scratchpad, not part of the final summary).
|
||||
expect(prompt).toMatch(/<analysis>.*stripped|stripped.*<analysis>/is);
|
||||
});
|
||||
|
||||
it('instructs the model to resume directly without acknowledging the summary', () => {
|
||||
it('asks for the <all_user_messages> 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 <scratchpad>. 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 <analysis> 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 <analysis> 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:
|
||||
<state_snapshot>
|
||||
<primary_request_and_intent>
|
||||
<!-- Capture all of the user's explicit requests and intents in detail. Quote the user's exact phrasing where intent is at stake. -->
|
||||
</primary_request_and_intent>
|
||||
|
||||
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.
|
||||
<key_technical_concepts>
|
||||
<!-- List all important technical concepts, technologies, and frameworks discussed. -->
|
||||
</key_technical_concepts>
|
||||
|
||||
2. Key Technical Concepts:
|
||||
Bullet list of concepts, libraries, conventions, and constraints established in the conversation.
|
||||
<files_and_code_sections>
|
||||
<!-- Enumerate specific files and code sections examined, modified, or created. Pay special attention to the most recent messages. Include full code snippets where applicable, and a summary of why this file read or edit is important. -->
|
||||
</files_and_code_sections>
|
||||
|
||||
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").
|
||||
<errors_and_fixes>
|
||||
<!-- List every error encountered and how it was fixed. Include the verbatim error message when it was quoted to the agent. Pay special attention to specific user feedback on the error, especially if the user told you to do something differently. -->
|
||||
</errors_and_fixes>
|
||||
|
||||
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.
|
||||
<problem_solving>
|
||||
<!-- Document problems solved and any ongoing troubleshooting efforts. -->
|
||||
</problem_solving>
|
||||
|
||||
5. Problem Solving:
|
||||
Brief notes on non-trivial decisions and trade-offs that the agent made while working.
|
||||
<all_user_messages>
|
||||
<!-- List ALL user messages that are not tool results, in chronological order. These are critical for understanding the user's feedback and shifting intent. Include short messages like "ok" or "continue" — they are signal. -->
|
||||
</all_user_messages>
|
||||
|
||||
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.
|
||||
<pending_tasks>
|
||||
<!-- Outline any pending tasks that the user has explicitly asked the agent to work on but that are not yet complete. -->
|
||||
</pending_tasks>
|
||||
|
||||
7. Pending Tasks:
|
||||
Bullet list of work the user requested that has NOT yet been completed.
|
||||
<current_work>
|
||||
<!-- Describe in detail precisely what the agent was working on immediately before this summary was requested, paying special attention to the most recent messages from both user and assistant. Include file names and code snippets where applicable. -->
|
||||
</current_work>
|
||||
|
||||
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.
|
||||
<next_step>
|
||||
<!-- List the single next step the agent will take, related to the most recent work. The step MUST be DIRECTLY in line with the user's most recent explicit request and the task the agent was working on immediately before this summary. If the last task was concluded, list a next step only if it is explicitly in line with the user's request — do NOT start tangential or older work without confirming with the user first. If there is a next step, include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off. -->
|
||||
</next_step>
|
||||
</state_snapshot>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<trailer>" rather than a strict equality.
|
||||
expect(result.newHistory![0].parts![0].text).toContain('Summary');
|
||||
expect(mockGenerateContent).toHaveBeenCalled();
|
||||
expect(mockGetHookSystem).toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 <analysis> block. Then, produce the <state_snapshot> 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 <analysis> 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.
|
||||
|
|
|
|||
|
|
@ -637,4 +637,71 @@ describe('composePostCompactHistory', () => {
|
|||
/got it|acknowledged|continue/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('strips the <analysis> block from the raw summary before placing it in newHistory', async () => {
|
||||
const history: Content[] = [{ role: 'user', parts: [{ text: 'do x' }] }];
|
||||
const raw =
|
||||
'<analysis>\nthe model was thinking out loud here\nshould not leak\n</analysis>\n\n<state_snapshot>\n <primary_request_and_intent>actual summary</primary_request_and_intent>\n</state_snapshot>';
|
||||
const result = await composePostCompactHistory(history, raw);
|
||||
const summaryText = (result[0].parts?.[0] as { text: string }).text;
|
||||
expect(summaryText).not.toContain('<analysis>');
|
||||
expect(summaryText).not.toContain('thinking out loud');
|
||||
expect(summaryText).toContain('<state_snapshot>');
|
||||
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,
|
||||
'<state_snapshot>...</state_snapshot>',
|
||||
);
|
||||
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 <analysis> block is present', async () => {
|
||||
const { postProcessSummary } = await import('./postCompactAttachments.js');
|
||||
const out = postProcessSummary('<state_snapshot>body</state_snapshot>');
|
||||
expect(out).toContain('<state_snapshot>body</state_snapshot>');
|
||||
expect(out).toMatch(/resume.*prior task/i);
|
||||
});
|
||||
|
||||
it('strips <analysis> wrappers (greedy across newlines)', async () => {
|
||||
const { postProcessSummary } = await import('./postCompactAttachments.js');
|
||||
const out = postProcessSummary(
|
||||
'<analysis>\nlots of\nmulti-line\nreasoning\n</analysis>\n\n<state_snapshot>body</state_snapshot>',
|
||||
);
|
||||
expect(out).not.toContain('<analysis>');
|
||||
expect(out).not.toContain('multi-line');
|
||||
expect(out).toContain('<state_snapshot>body</state_snapshot>');
|
||||
});
|
||||
|
||||
it('strips multiple <analysis> blocks if the model emits more than one', async () => {
|
||||
const { postProcessSummary } = await import('./postCompactAttachments.js');
|
||||
const out = postProcessSummary(
|
||||
'<analysis>first</analysis>\n<state_snapshot>body</state_snapshot>\n<analysis>second</analysis>',
|
||||
);
|
||||
expect(out).not.toContain('<analysis>');
|
||||
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 <analysis> 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 = '<analysis>nothing else</analysis>';
|
||||
const out = postProcessSummary(raw);
|
||||
expect(out).toContain('nothing else');
|
||||
expect(out).toMatch(/resume.*prior task/i);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 `<analysis>...</analysis>`
|
||||
* 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(/<analysis>[\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!' }],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue