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 .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
47pub 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
56fn 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
86pub 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
134pub 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
146pub 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 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 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 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}