Skip to main content

recursive/compact/
prompt.rs

1//! Free-text compaction prompt template + post-processing.
2//!
3//! This module provides the 9-section prompt template used as the free-text
4//! fallback when structured compaction is unavailable, and the
5//! [`format_compact_summary`] function that strips the `<analysis>` drafting
6//! scratchpad and renders the `<summary>` block into a readable form.
7
8/// Preamble telling the model to produce text only (no tool calls during
9/// compaction). The compaction call uses an empty tool list already, but
10/// this reinforces it for models that hallucinate tool calls.
11pub const FREE_TEXT_COMPACT_PROMPT: &str = "\
12Your task is to create a detailed summary of the conversation so far for a \
13coding agent. This summary will be placed back into the agent's context, so \
14it must be comprehensive enough that the agent can continue working without \
15loss of important context. Do not attempt to call any tools — produce text only.
16
17Before providing your final summary, wrap your analysis in <analysis> tags. \
18This analysis is scratchpad thinking and will be discarded — only the content \
19between <summary> tags will be preserved.
20
21Your summary should include the following sections:
221. Primary Request and Intent
232. Key Technical Concepts
243. Files and Code Sections (file paths modified, full code snippets where applicable)
254. Errors and fixes
265. Problem Solving
276. All user messages (non-tool-result)
287. Pending Tasks
298. Current Work (what was being worked on immediately before this summary)
309. Optional Next Step (directly in line with the most recent request; include verbatim quotes)
31
32Wrap the final summary in <summary>…</summary>.";
33
34/// Strip the `<analysis>…</analysis>` drafting scratchpad from the model's
35/// response and convert `<summary>…</summary>` into readable section headers.
36/// Returns the cleaned summary.
37///
38/// Processing steps:
39/// 1. Remove the first `<analysis>…</analysis>` block (non-greedy).
40/// 2. If `<summary>…</summary>` is present, replace the whole match with
41///    `Summary:\n<content trimmed>`.
42/// 3. Collapse 3+ consecutive newlines to 2.
43/// 4. Trim whitespace.
44/// 5. If neither tag is present, return `raw.trim()` unchanged (graceful
45///    degradation for models that ignore the template).
46pub fn format_compact_summary(raw: &str) -> String {
47    let mut result = raw.to_string();
48
49    // Step 1: Remove the first <analysis>...</analysis> block (non-greedy).
50    if let Some(start) = result.find("<analysis>") {
51        if let Some(end) = result[start..].find("</analysis>") {
52            let end_absolute = start + end + "</analysis>".len();
53            result.replace_range(start..end_absolute, "");
54        }
55    }
56
57    // Step 2: Extract <summary>...</summary> content if present.
58    if let Some(start) = result.find("<summary>") {
59        if let Some(end) = result[start..].find("</summary>") {
60            let content_start = start + "<summary>".len();
61            let content_end = start + end;
62            let summary_content = result[content_start..content_end].trim().to_string();
63            let end_absolute = start + end + "</summary>".len();
64            result.replace_range(start..end_absolute, &format!("Summary:\n{summary_content}"));
65        }
66    } else {
67        // No tags present — graceful degradation.
68        return result.trim().to_string();
69    }
70
71    // Step 3: Collapse 3+ newlines to 2.
72    // Use a simple loop-based approach since regex isn't available as a dep.
73    let mut collapsed = String::with_capacity(result.len());
74    let mut newline_count = 0_usize;
75    for ch in result.chars() {
76        if ch == '\n' {
77            newline_count += 1;
78            if newline_count <= 2 {
79                collapsed.push(ch);
80            }
81        } else {
82            newline_count = 0;
83            collapsed.push(ch);
84        }
85    }
86
87    // Step 4: Trim.
88    collapsed.trim().to_string()
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn prompt_contains_all_nine_sections() {
97        let prompt = FREE_TEXT_COMPACT_PROMPT;
98        let sections = [
99            "Primary Request and Intent",
100            "Key Technical Concepts",
101            "Files and Code Sections",
102            "Errors and fixes",
103            "Problem Solving",
104            "All user messages",
105            "Pending Tasks",
106            "Current Work",
107            "Optional Next Step",
108        ];
109        for section in &sections {
110            assert!(
111                prompt.contains(section),
112                "prompt must contain section title: '{section}'"
113            );
114        }
115    }
116
117    #[test]
118    fn format_strips_analysis_block() {
119        let input = "<analysis>this is scratchpad thinking</analysis>\n\
120                     <summary>this is the real summary</summary>";
121        let output = format_compact_summary(input);
122        assert!(
123            !output.contains("scratchpad thinking"),
124            "analysis content must be stripped"
125        );
126        assert!(
127            output.contains("this is the real summary"),
128            "summary content must be preserved"
129        );
130    }
131
132    #[test]
133    fn format_converts_summary_tag_to_header() {
134        let input = "<summary>Key decision: added tool X.</summary>";
135        let output = format_compact_summary(input);
136        assert!(
137            output.starts_with("Summary:"),
138            "output must start with 'Summary:' header, got: {output:?}"
139        );
140        assert!(
141            output.contains("Key decision: added tool X."),
142            "summary content must be present"
143        );
144    }
145
146    #[test]
147    fn format_preserves_content_when_no_tags() {
148        let input = "  plain text without any tags  ";
149        let output = format_compact_summary(input);
150        assert_eq!(output, "plain text without any tags");
151    }
152
153    #[test]
154    fn format_collapses_excess_newlines() {
155        // Use a summary with lots of newlines before/after.
156        let input = "<summary>line1\n\n\n\nline2</summary>";
157        let output = format_compact_summary(input);
158        // After tag->header conversion and collapse, should have at most 2
159        // consecutive newlines between "Summary:" and "line1" and between
160        // "line1" and "line2".
161        assert!(
162            !output.contains("\n\n\n"),
163            "output must not contain 3+ consecutive newlines, got: {output:?}"
164        );
165        assert!(output.contains("line1"));
166        assert!(output.contains("line2"));
167    }
168
169    #[test]
170    fn format_handles_tags_embedded_in_other_text() {
171        let input = "Some text before.\n\
172                     <analysis>scratchpad here</analysis>\n\
173                     <summary>Real summary content</summary>\n\
174                     Some text after.";
175        let output = format_compact_summary(input);
176        assert!(!output.contains("scratchpad"), "analysis must be stripped");
177        assert!(
178            output.contains("Real summary content"),
179            "summary content must be preserved"
180        );
181        assert!(
182            output.contains("Summary:"),
183            "must contain Summary: header, got: {output:?}"
184        );
185        // Text outside tags is preserved (graceful, not stripped)
186        assert!(output.contains("Some text before."));
187        assert!(output.contains("Some text after."));
188    }
189
190    #[test]
191    fn format_handles_missing_summary_tag() {
192        // Only analysis, no summary → graceful degradation (trimmed input).
193        let input = "  <analysis>thinking</analysis>  ";
194        let output = format_compact_summary(input);
195        // Without <summary> tag, the code takes the else branch and returns
196        // the trimmed result. Since <analysis> was removed first, then the
197        // summary check fails, it returns the trimmed remaining text.
198        // The remaining text after analysis removal is "  " (just spaces).
199        assert_eq!(
200            output, "",
201            "without summary tag, should return trimmed post-analysis removal"
202        );
203    }
204
205    #[test]
206    fn format_handles_only_summary_tag_with_no_analysis() {
207        let input = "<summary>Just a summary</summary>";
208        let output = format_compact_summary(input);
209        assert!(output.starts_with("Summary:"));
210        assert!(output.contains("Just a summary"));
211    }
212}