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        .filter(|&i| is_clean_boundary(items, i))
32        .collect();
33    let latest = *boundaries.last()?;
34    let mut chosen = latest;
35    for &b in boundaries.iter().rev() {
36        if estimate_items(&items[b..]) <= tail_budget {
37            chosen = b;
38        } else if chosen != latest || b != latest {
39            break;
40        }
41    }
42    Some(Plan {
43        prefix_end,
44        kept_from: chosen,
45    })
46}
47
48/// The new projection: preserved prefix + digest + verbatim tail.
49pub fn apply(items: &[Item], plan: &Plan, digest: &[Item]) -> Vec<Item> {
50    let mut out = Vec::with_capacity(plan.prefix_end + digest.len() + items.len() - plan.kept_from);
51    out.extend_from_slice(&items[..plan.prefix_end]);
52    out.extend_from_slice(digest);
53    out.extend_from_slice(&items[plan.kept_from..]);
54    out
55}
56
57/// A tail may only start where tool results still sit behind the assistant
58/// turn that called them. Starting at a user item that is answered by results
59/// would leave those results with no calls in front of them — the request is
60/// then rejected for having more tool_result blocks than the preceding turn
61/// has tool_use blocks, and the fold makes it permanent.
62fn is_clean_boundary(items: &[Item], i: usize) -> bool {
63    !matches!(items.get(i + 1), Some(Item::ToolResults { .. }))
64        || matches!(items[i], Item::Assistant { .. })
65}
66
67/// Leading System / ProjectInstructions / Memory items never fold — they are
68/// the byte-stable prefix (L6) and the cheapest tokens in the window.
69fn preserved_prefix_len(items: &[Item]) -> usize {
70    items
71        .iter()
72        .position(|i| {
73            !matches!(
74                i,
75                Item::System { .. }
76                    | Item::User {
77                        synthetic: Some(
78                            SyntheticReason::ProjectInstructions | SyntheticReason::Memory
79                        ),
80                        ..
81                    }
82            )
83        })
84        .unwrap_or(items.len())
85}
86
87pub const SUMMARIZE_SYSTEM: &str = "\
88You compress an agent-session transcript into a working digest. Output only \
89the digest, structured exactly as:\n\
90GOAL: what the user is trying to accomplish\n\
91STATE: what has been done and what is true now\n\
92DECISIONS: choices made and their reasons\n\
93FILES: files touched and how\n\
94NEXT: what remains\n\
95Be specific (paths, names, values). Omit pleasantries and tool mechanics.";
96
97/// Render the folded items as a plain transcript for the summarize call.
98/// Tool results are clipped per-item — the digest needs their gist, and the
99/// summarize call must stay far smaller than the window being compacted.
100pub fn summarize_prompt(folded: &[Item]) -> String {
101    const RESULT_CLIP: usize = 600;
102    let mut out = String::from("Transcript to compress:\n\n");
103    for item in folded {
104        match item {
105            Item::System { .. } | Item::Unknown => {}
106            Item::User { text, synthetic } => {
107                let label = if synthetic.is_some() {
108                    "user (injected)"
109                } else {
110                    "user"
111                };
112                out.push_str(&format!("[{label}] {text}\n"));
113            }
114            Item::Assistant { blocks } => {
115                let text = hotl_types::assistant_text(blocks);
116                if !text.is_empty() {
117                    out.push_str(&format!("[assistant] {text}\n"));
118                }
119                for tu in hotl_types::assistant_tool_uses(blocks) {
120                    out.push_str(&format!("[tool call] {}({})\n", tu.name, tu.input));
121                }
122            }
123            Item::ToolResults { results } => {
124                for r in results {
125                    let clipped = clip(&r.content, RESULT_CLIP);
126                    out.push_str(&format!("[tool result] {clipped}\n"));
127                }
128            }
129        }
130    }
131    out
132}
133
134fn clip(s: &str, max: usize) -> &str {
135    if s.len() <= max {
136        return s;
137    }
138    let mut end = max;
139    while !s.is_char_boundary(end) {
140        end -= 1;
141    }
142    &s[..end]
143}
144
145/// The digest as a provenance-tagged user item.
146pub fn digest_item(summary: &str) -> Item {
147    Item::User {
148        text: format!(
149            "<compaction-summary>\n{summary}\n</compaction-summary>\n\
150             Earlier conversation was compacted into the summary above; \
151             the messages that follow it are verbatim."
152        ),
153        synthetic: Some(SyntheticReason::CompactionSummary),
154    }
155}
156
157/// The degradation floor: every summarize attempt failed, so the
158/// session continues with an honest placeholder instead of bricking.
159pub fn floor_digest() -> Item {
160    Item::User {
161        text: "<compaction-summary degraded=\"true\">\n\
162               Earlier conversation was dropped to stay within the context \
163               window; a summary could not be generated. Ask the user to \
164               restate anything essential from before this point.\n\
165               </compaction-summary>"
166            .into(),
167        synthetic: Some(SyntheticReason::CompactionSummary),
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use hotl_types::ToolResultItem;
175    use serde_json::json;
176
177    fn user(text: &str) -> Item {
178        Item::User {
179            text: text.into(),
180            synthetic: None,
181        }
182    }
183    fn assistant(text: &str) -> Item {
184        Item::Assistant {
185            blocks: vec![json!({"type":"text","text":text})],
186        }
187    }
188    fn results(content: &str) -> Item {
189        Item::ToolResults {
190            results: vec![ToolResultItem {
191                tool_use_id: "t".into(),
192                content: content.into(),
193                is_error: false,
194            }],
195        }
196    }
197
198    /// History written before steers were held can hold a user item between an
199    /// assistant turn and its results. Cutting there would strand the results
200    /// permanently, so that boundary is not offered.
201    #[test]
202    fn tail_never_starts_where_it_would_strand_results() {
203        let items = vec![
204            user("start"),
205            assistant("calling"),
206            user("a steer that landed in the gap"),
207            results(&"x".repeat(3000)),
208            assistant("done"),
209        ];
210        let plan = plan(&items, 10).expect("plan");
211        assert_ne!(plan.kept_from, 2, "that cut orphans the results");
212        let tail = &items[plan.kept_from..];
213        assert!(
214            !matches!(tail.first(), Some(Item::ToolResults { .. })),
215            "and the tail itself never opens on results"
216        );
217    }
218
219    #[test]
220    fn tail_never_starts_at_tool_results() {
221        let items = vec![
222            user("start"),
223            assistant("calling"),
224            results(&"x".repeat(3000)),
225            assistant("calling again"),
226            results(&"y".repeat(3000)),
227        ];
228        // Tiny budget: even the minimal tail exceeds it — the plan must still
229        // pick a clean boundary (the last assistant), never the results item.
230        let plan = plan(&items, 10).expect("plan");
231        assert_eq!(plan.kept_from, 3);
232        assert!(matches!(items[plan.kept_from], Item::Assistant { .. }));
233    }
234
235    #[test]
236    fn generous_budget_keeps_more_history() {
237        let items = vec![user("a"), assistant("b"), user("c"), assistant("d")];
238        let plan = plan(&items, 10_000).expect("plan");
239        // Everything after the first foldable position fits: keep from index 1.
240        assert_eq!(plan.kept_from, 1);
241    }
242
243    #[test]
244    fn prefix_is_preserved_and_nothing_to_fold_is_none() {
245        let items = vec![
246            Item::User {
247                text: "<project-instructions>…</project-instructions>".into(),
248                synthetic: Some(SyntheticReason::ProjectInstructions),
249            },
250            user("only prompt"),
251        ];
252        // Only boundary candidates strictly after the prompt exist — none do.
253        assert_eq!(plan(&items, 10), None);
254
255        let with_history = {
256            let mut v = items.clone();
257            v.push(assistant("did things"));
258            v.push(user("more"));
259            v.push(assistant("done"));
260            v
261        };
262        let p = plan(&with_history, 10).expect("plan");
263        assert_eq!(p.prefix_end, 1, "instructions stay out of the fold");
264        let digest = [digest_item("GOAL: test")];
265        let applied = apply(&with_history, &p, &digest);
266        assert!(matches!(
267            applied[0],
268            Item::User {
269                synthetic: Some(SyntheticReason::ProjectInstructions),
270                ..
271            }
272        ));
273        assert!(matches!(
274            applied[1],
275            Item::User {
276                synthetic: Some(SyntheticReason::CompactionSummary),
277                ..
278            }
279        ));
280    }
281
282    #[test]
283    fn summarize_prompt_clips_results() {
284        let folded = vec![user("goal"), results(&"z".repeat(5000))];
285        let prompt = summarize_prompt(&folded);
286        assert!(prompt.len() < 2000);
287        assert!(prompt.contains("[user] goal"));
288    }
289}