Skip to main content

hotl_context/
compaction.rs

1//! Compaction planning + assembly (M2).
2//!
3//! Pure functions: the engine owns the trigger and the summarize call; this
4//! module decides *what* folds and assembles the new projection. The shape is
5//! always `preserved prefix + typed digest + verbatim tail`, and the tail
6//! snaps to a clean boundary so tool_use/tool_result pairing survives
7//! (split-turn handling): a tail may start at a User or an Assistant item,
8//! never at ToolResults (results must follow their assistant message).
9
10use hotl_types::{Item, SyntheticReason};
11
12use crate::tokens::estimate_items;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct Plan {
16    /// Leading items preserved verbatim (system/instructions/memory).
17    pub prefix_end: usize,
18    /// Index where the verbatim tail starts; `[prefix_end..kept_from)` folds.
19    pub kept_from: usize,
20}
21
22/// Choose what to fold. Picks the earliest clean boundary whose tail fits
23/// `tail_budget` tokens (keeping the most verbatim history that fits); if no
24/// tail fits, keeps the minimal clean tail and folds everything else.
25/// `None` means nothing can fold — the caller must surface context exhaustion
26/// rather than loop.
27pub fn plan(items: &[Item], tail_budget: u64) -> Option<Plan> {
28    let prefix_end = preserved_prefix_len(items);
29    let boundaries: Vec<usize> = (prefix_end + 1..items.len())
30        .filter(|&i| matches!(items[i], Item::User { .. } | Item::Assistant { .. }))
31        .collect();
32    let latest = *boundaries.last()?;
33    let mut chosen = latest;
34    for &b in boundaries.iter().rev() {
35        if estimate_items(&items[b..]) <= tail_budget {
36            chosen = b;
37        } else if chosen != latest || b != latest {
38            break;
39        }
40    }
41    Some(Plan {
42        prefix_end,
43        kept_from: chosen,
44    })
45}
46
47/// The new projection: preserved prefix + digest + verbatim tail.
48pub fn apply(items: &[Item], plan: &Plan, digest: &[Item]) -> Vec<Item> {
49    let mut out = Vec::with_capacity(plan.prefix_end + digest.len() + items.len() - plan.kept_from);
50    out.extend_from_slice(&items[..plan.prefix_end]);
51    out.extend_from_slice(digest);
52    out.extend_from_slice(&items[plan.kept_from..]);
53    out
54}
55
56/// Leading System / ProjectInstructions / Memory items never fold — they are
57/// the byte-stable prefix (L6) and the cheapest tokens in the window.
58fn preserved_prefix_len(items: &[Item]) -> usize {
59    items
60        .iter()
61        .position(|i| {
62            !matches!(
63                i,
64                Item::System { .. }
65                    | Item::User {
66                        synthetic: Some(
67                            SyntheticReason::ProjectInstructions | SyntheticReason::Memory
68                        ),
69                        ..
70                    }
71            )
72        })
73        .unwrap_or(items.len())
74}
75
76pub const SUMMARIZE_SYSTEM: &str = "\
77You compress an agent-session transcript into a working digest. Output only \
78the digest, structured exactly as:\n\
79GOAL: what the user is trying to accomplish\n\
80STATE: what has been done and what is true now\n\
81DECISIONS: choices made and their reasons\n\
82FILES: files touched and how\n\
83NEXT: what remains\n\
84Be specific (paths, names, values). Omit pleasantries and tool mechanics.";
85
86/// Render the folded items as a plain transcript for the summarize call.
87/// Tool results are clipped per-item — the digest needs their gist, and the
88/// summarize call must stay far smaller than the window being compacted.
89pub fn summarize_prompt(folded: &[Item]) -> String {
90    const RESULT_CLIP: usize = 600;
91    let mut out = String::from("Transcript to compress:\n\n");
92    for item in folded {
93        match item {
94            Item::System { .. } | Item::Unknown => {}
95            Item::User { text, synthetic } => {
96                let label = if synthetic.is_some() {
97                    "user (injected)"
98                } else {
99                    "user"
100                };
101                out.push_str(&format!("[{label}] {text}\n"));
102            }
103            Item::Assistant { blocks } => {
104                let text = hotl_types::assistant_text(blocks);
105                if !text.is_empty() {
106                    out.push_str(&format!("[assistant] {text}\n"));
107                }
108                for tu in hotl_types::assistant_tool_uses(blocks) {
109                    out.push_str(&format!("[tool call] {}({})\n", tu.name, tu.input));
110                }
111            }
112            Item::ToolResults { results } => {
113                for r in results {
114                    let clipped = clip(&r.content, RESULT_CLIP);
115                    out.push_str(&format!("[tool result] {clipped}\n"));
116                }
117            }
118        }
119    }
120    out
121}
122
123fn clip(s: &str, max: usize) -> &str {
124    if s.len() <= max {
125        return s;
126    }
127    let mut end = max;
128    while !s.is_char_boundary(end) {
129        end -= 1;
130    }
131    &s[..end]
132}
133
134/// The digest as a provenance-tagged user item.
135pub fn digest_item(summary: &str) -> Item {
136    Item::User {
137        text: format!(
138            "<compaction-summary>\n{summary}\n</compaction-summary>\n\
139             Earlier conversation was compacted into the summary above; \
140             the messages that follow it are verbatim."
141        ),
142        synthetic: Some(SyntheticReason::CompactionSummary),
143    }
144}
145
146/// The degradation floor: every summarize attempt failed, so the
147/// session continues with an honest placeholder instead of bricking.
148pub fn floor_digest() -> Item {
149    Item::User {
150        text: "<compaction-summary degraded=\"true\">\n\
151               Earlier conversation was dropped to stay within the context \
152               window; a summary could not be generated. Ask the user to \
153               restate anything essential from before this point.\n\
154               </compaction-summary>"
155            .into(),
156        synthetic: Some(SyntheticReason::CompactionSummary),
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use hotl_types::ToolResultItem;
164    use serde_json::json;
165
166    fn user(text: &str) -> Item {
167        Item::User {
168            text: text.into(),
169            synthetic: None,
170        }
171    }
172    fn assistant(text: &str) -> Item {
173        Item::Assistant {
174            blocks: vec![json!({"type":"text","text":text})],
175        }
176    }
177    fn results(content: &str) -> Item {
178        Item::ToolResults {
179            results: vec![ToolResultItem {
180                tool_use_id: "t".into(),
181                content: content.into(),
182                is_error: false,
183            }],
184        }
185    }
186
187    #[test]
188    fn tail_never_starts_at_tool_results() {
189        let items = vec![
190            user("start"),
191            assistant("calling"),
192            results(&"x".repeat(3000)),
193            assistant("calling again"),
194            results(&"y".repeat(3000)),
195        ];
196        // Tiny budget: even the minimal tail exceeds it — the plan must still
197        // pick a clean boundary (the last assistant), never the results item.
198        let plan = plan(&items, 10).expect("plan");
199        assert_eq!(plan.kept_from, 3);
200        assert!(matches!(items[plan.kept_from], Item::Assistant { .. }));
201    }
202
203    #[test]
204    fn generous_budget_keeps_more_history() {
205        let items = vec![user("a"), assistant("b"), user("c"), assistant("d")];
206        let plan = plan(&items, 10_000).expect("plan");
207        // Everything after the first foldable position fits: keep from index 1.
208        assert_eq!(plan.kept_from, 1);
209    }
210
211    #[test]
212    fn prefix_is_preserved_and_nothing_to_fold_is_none() {
213        let items = vec![
214            Item::User {
215                text: "<project-instructions>…</project-instructions>".into(),
216                synthetic: Some(SyntheticReason::ProjectInstructions),
217            },
218            user("only prompt"),
219        ];
220        // Only boundary candidates strictly after the prompt exist — none do.
221        assert_eq!(plan(&items, 10), None);
222
223        let with_history = {
224            let mut v = items.clone();
225            v.push(assistant("did things"));
226            v.push(user("more"));
227            v.push(assistant("done"));
228            v
229        };
230        let p = plan(&with_history, 10).expect("plan");
231        assert_eq!(p.prefix_end, 1, "instructions stay out of the fold");
232        let digest = [digest_item("GOAL: test")];
233        let applied = apply(&with_history, &p, &digest);
234        assert!(matches!(
235            applied[0],
236            Item::User {
237                synthetic: Some(SyntheticReason::ProjectInstructions),
238                ..
239            }
240        ));
241        assert!(matches!(
242            applied[1],
243            Item::User {
244                synthetic: Some(SyntheticReason::CompactionSummary),
245                ..
246            }
247        ));
248    }
249
250    #[test]
251    fn summarize_prompt_clips_results() {
252        let folded = vec![user("goal"), results(&"z".repeat(5000))];
253        let prompt = summarize_prompt(&folded);
254        assert!(prompt.len() < 2000);
255        assert!(prompt.contains("[user] goal"));
256    }
257}