hotl_context/
compaction.rs1use hotl_types::{Item, SyntheticReason};
11
12use crate::tokens::estimate_items;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct Plan {
16 pub prefix_end: usize,
18 pub kept_from: usize,
20}
21
22pub 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
48pub 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
57fn 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
67fn 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
97pub 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
145pub 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
157pub 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 #[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 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 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 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}