1use std::borrow::Cow;
18use std::cmp::min;
19
20use crate::tokenizer::Tokenizer;
21use crate::types::{ContentBlock, Message, Role};
22
23const COMPRESSED_TOOL_RESULT_STUB: &str = "[tool result compressed]";
24const HEAD_TRIM_PROBE_MSGS: usize = 64;
25
26fn contains_rule_sentinel(text: &str) -> bool {
29 text.contains(crate::rules::RULE_SENTINEL)
30}
31
32fn message_has_rule_injection(m: &Message) -> bool {
33 m.content.iter().any(|b| match b {
34 ContentBlock::Text { text } => contains_rule_sentinel(text),
35 ContentBlock::ToolResult { content, .. } => contains_rule_sentinel(content),
36 _ => false,
37 })
38}
39
40pub trait HistoryCompressor: Send + Sync {
42 fn compress(
49 &self,
50 messages: &mut Vec<Message>,
51 target_tokens: u32,
52 tokenizer: &dyn Tokenizer,
53 ) -> u32;
54}
55
56#[derive(Debug, Default, Clone, Copy)]
58pub struct DefaultHistoryCompressor;
59
60impl HistoryCompressor for DefaultHistoryCompressor {
61 fn compress(
62 &self,
63 messages: &mut Vec<Message>,
64 target_tokens: u32,
65 tokenizer: &dyn Tokenizer,
66 ) -> u32 {
67 let mut total = count_total(messages, tokenizer);
68 if total <= target_tokens {
69 return total;
70 }
71
72 for i in 0..messages.len() {
74 for block in messages[i].content.iter_mut() {
75 if let ContentBlock::ToolResult { content, .. } = block
76 && content.as_str() != COMPRESSED_TOOL_RESULT_STUB
77 && !contains_rule_sentinel(content)
78 {
79 *content = COMPRESSED_TOOL_RESULT_STUB.to_string();
80 }
81 }
82 total = count_total(messages, tokenizer);
83 if total <= target_tokens {
84 return total;
85 }
86 }
87
88 let preserved_first = !messages.is_empty() && is_user_anchor(&messages[0]);
95 let anchor_floor = if preserved_first { 1 } else { 0 };
96 let mut floor = anchor_floor;
98 let mut attempts = 0;
99 while count_total(messages, tokenizer) > target_tokens
100 && attempts < HEAD_TRIM_PROBE_MSGS
101 && messages.len() > floor + 1
102 {
103 let Some(turn_start) = messages
105 .iter()
106 .enumerate()
107 .skip(floor)
108 .find(|(_, m)| is_user_anchor(m))
109 .map(|(i, _)| i)
110 else {
111 break;
112 };
113 let turn_end = messages
115 .iter()
116 .enumerate()
117 .skip(turn_start + 1)
118 .find(|(_, m)| is_user_anchor(m))
119 .map_or(messages.len(), |(i, _)| i);
120 if turn_end == messages.len() {
123 break;
124 }
125 if messages[turn_start..turn_end]
128 .iter()
129 .any(message_has_rule_injection)
130 {
131 floor = turn_end;
132 continue;
133 }
134 messages.drain(turn_start..min(turn_end, messages.len()));
135 attempts += 1;
136 }
137
138 count_total(messages, tokenizer)
139 }
140}
141
142fn is_user_anchor(m: &Message) -> bool {
148 if m.role != Role::User {
149 return false;
150 }
151 if m.content
156 .iter()
157 .any(|b| matches!(b, ContentBlock::ToolResult { .. }))
158 {
159 return false;
160 }
161 m.content
162 .iter()
163 .any(|b| matches!(b, ContentBlock::Text { .. }))
164}
165
166fn count_total(messages: &[Message], tokenizer: &dyn Tokenizer) -> u32 {
170 let mut total: u32 = 0;
171 for m in messages {
172 total = total.saturating_add(4); for b in &m.content {
174 total = total.saturating_add(tokenizer.count(&block_text(b)));
175 }
176 }
177 total
178}
179
180fn block_text(b: &ContentBlock) -> Cow<'_, str> {
181 match b {
182 ContentBlock::Text { text } => Cow::Borrowed(text),
183 ContentBlock::Thinking { thinking, .. } => Cow::Borrowed(thinking),
184 ContentBlock::ToolUse { input, .. } => Cow::Owned(input.to_string()),
187 ContentBlock::ToolResult { content, .. } => Cow::Borrowed(content),
188 }
189}
190
191#[cfg(test)]
192#[allow(clippy::expect_used, clippy::unwrap_used)]
193mod tests {
194 use super::*;
195 use crate::tokenizer::ApproxTokenizer;
196 use crate::types::{Message, Role};
197
198 fn tool_result(id: &str, content: &str) -> Message {
199 Message {
200 role: Role::User,
201 content: vec![ContentBlock::ToolResult {
202 tool_use_id: id.into(),
203 content: content.into(),
204 is_error: Some(false),
205 }],
206 }
207 }
208
209 #[test]
210 fn noop_when_already_under_budget() {
211 let mut msgs = vec![Message::user_text("hi")];
212 let t = ApproxTokenizer;
213 let c = DefaultHistoryCompressor;
214 let n = c.compress(&mut msgs, 1000, &t);
215 assert!(n < 100);
216 assert_eq!(msgs.len(), 1);
217 }
218
219 #[test]
220 fn stubs_tool_results_when_over_budget() {
221 let big = "x".repeat(4000); let mut msgs = vec![
223 Message::user_text("hi"),
224 tool_result("t1", &big),
225 Message::assistant_text("ok"),
226 ];
227 let t = ApproxTokenizer;
228 let c = DefaultHistoryCompressor;
229 let _ = c.compress(&mut msgs, 50, &t);
230 match &msgs[1].content[0] {
232 ContentBlock::ToolResult { content, .. } => {
233 assert_eq!(content, COMPRESSED_TOOL_RESULT_STUB);
234 }
235 _ => panic!("expected tool result"),
236 }
237 }
238
239 #[test]
240 fn trims_head_preserving_first_message() {
241 let t = ApproxTokenizer;
242 let c = DefaultHistoryCompressor;
243 let mut msgs = vec![
244 Message::user_text("<brief>"),
245 Message::user_text("a".repeat(4000)),
246 Message::assistant_text("b".repeat(4000)),
247 Message::user_text("c".repeat(4000)),
248 Message::assistant_text("final"),
249 ];
250 let before = msgs.len();
251 c.compress(&mut msgs, 20, &t);
252 assert!(msgs.len() < before);
253 match &msgs[0].content[0] {
255 ContentBlock::Text { text } => assert_eq!(text, "<brief>"),
256 _ => panic!("first block should be the brief"),
257 }
258 }
259
260 #[test]
261 fn stops_when_only_two_messages_remain() {
262 let t = ApproxTokenizer;
263 let c = DefaultHistoryCompressor;
264 let mut msgs = vec![
267 Message::user_text("a".repeat(1000)),
268 Message::assistant_text("b".repeat(1000)),
269 ];
270 c.compress(&mut msgs, 1, &t);
271 assert!(!msgs.is_empty());
272 }
273
274 #[test]
279 fn trim_preserves_tool_use_tool_result_pairing() {
280 let t = ApproxTokenizer;
281 let c = DefaultHistoryCompressor;
282
283 fn tool_use_msg(id: &str, name: &str) -> Message {
284 Message {
285 role: Role::Assistant,
286 content: vec![ContentBlock::ToolUse {
287 id: id.into(),
288 name: name.into(),
289 input: serde_json::json!({"path": "x".repeat(2000)}),
290 }],
291 }
292 }
293 fn tool_result_msg(id: &str, body: &str) -> Message {
294 Message {
295 role: Role::User,
296 content: vec![ContentBlock::ToolResult {
297 tool_use_id: id.into(),
298 content: body.into(),
299 is_error: Some(false),
300 }],
301 }
302 }
303
304 let mut msgs = vec![
305 Message::user_text("<brief>"),
307 Message::user_text("turn1 user"),
309 tool_use_msg("t1", "file_read"),
310 tool_result_msg("t1", &"r".repeat(4000)),
311 Message::assistant_text("turn1 done"),
312 Message::user_text("turn2 user"),
314 tool_use_msg("t2", "file_write"),
315 tool_result_msg("t2", &"r".repeat(4000)),
316 Message::assistant_text("turn2 done"),
317 Message::user_text("turn3 user"),
319 ];
320 c.compress(&mut msgs, 50, &t);
321
322 let mut tool_use_ids = std::collections::BTreeSet::new();
323 let mut tool_result_ids = std::collections::BTreeSet::new();
324 for m in &msgs {
325 for b in &m.content {
326 match b {
327 ContentBlock::ToolUse { id, .. } => {
328 tool_use_ids.insert(id.clone());
329 }
330 ContentBlock::ToolResult { tool_use_id, .. } => {
331 tool_result_ids.insert(tool_use_id.clone());
332 }
333 _ => {}
334 }
335 }
336 }
337 assert_eq!(
338 tool_use_ids, tool_result_ids,
339 "tool_use ids and tool_result ids must remain in sync after trim"
340 );
341 match &msgs.last().unwrap().content[0] {
343 ContentBlock::Text { text } => assert_eq!(text, "turn3 user"),
344 _ => panic!("expected the live tail to be preserved"),
345 }
346 }
347
348 #[test]
349 fn tool_result_with_rule_note_is_never_stubbed() {
350 let t = ApproxTokenizer;
351 let c = DefaultHistoryCompressor;
352 let big = "x".repeat(4000);
353 let mut msgs = vec![
354 Message::user_text("hi"),
355 tool_result("t1", &big),
356 tool_result(
357 "t2",
358 &format!("{big}\n\n[stream rule `r` reminder]\nnote body"),
359 ),
360 Message::assistant_text("ok"),
361 ];
362 let _ = c.compress(&mut msgs, 50, &t);
363 match &msgs[1].content[0] {
364 ContentBlock::ToolResult { content, .. } => {
365 assert_eq!(content, COMPRESSED_TOOL_RESULT_STUB)
366 }
367 _ => panic!("expected tool result"),
368 }
369 match &msgs[2].content[0] {
370 ContentBlock::ToolResult { content, .. } => {
371 assert!(
372 content.contains("[stream rule `r` reminder]"),
373 "sentinel-bearing tool result must not be stubbed"
374 )
375 }
376 _ => panic!("expected tool result"),
377 }
378 }
379
380 #[test]
381 fn turn_with_rule_injection_survives_head_trim() {
382 let t = ApproxTokenizer;
383 let c = DefaultHistoryCompressor;
384 let mut msgs = vec![
385 Message::user_text("<brief>"),
386 Message::user_text("do the thing\n\n[stream rule `r` fired]\n\nrule body"),
388 Message::assistant_text("a".repeat(4000)),
389 Message::user_text("b".repeat(4000)),
391 Message::assistant_text("c".repeat(4000)),
392 Message::user_text("d".repeat(4000)),
393 Message::assistant_text("e".repeat(4000)),
394 Message::user_text("latest question"),
396 ];
397 c.compress(&mut msgs, 50, &t);
398 assert!(
399 msgs.iter()
400 .any(|m| m.text_content().contains("[stream rule `r` fired]")),
401 "the turn containing the rule injection must survive compression"
402 );
403 assert_eq!(msgs.last().unwrap().text_content(), "latest question");
404 }
405
406 #[test]
413 fn tool_result_with_appended_text_is_never_an_anchor() {
414 let t = ApproxTokenizer;
415 let c = DefaultHistoryCompressor;
416
417 fn tool_use_msg(id: &str, name: &str) -> Message {
418 Message {
419 role: Role::Assistant,
420 content: vec![ContentBlock::ToolUse {
421 id: id.into(),
422 name: name.into(),
423 input: serde_json::json!({"path": "x".repeat(2000)}),
424 }],
425 }
426 }
427
428 let mut msgs = vec![
429 Message::user_text("<brief>"),
431 Message::user_text("turn1 user"),
434 tool_use_msg("t1", "file_read"),
435 Message {
436 role: Role::User,
437 content: vec![
438 ContentBlock::ToolResult {
439 tool_use_id: "t1".into(),
440 content: "r".repeat(4000),
441 is_error: Some(false),
442 },
443 ContentBlock::Text {
444 text: "\n\n[stream rule `r` fired]\n\nbody".into(),
445 },
446 ],
447 },
448 Message::assistant_text("turn1 done"),
449 Message::user_text("b".repeat(4000)),
451 Message::assistant_text("c".repeat(4000)),
452 Message::user_text("d".repeat(4000)),
453 Message::assistant_text("e".repeat(4000)),
454 Message::user_text("latest question"),
456 ];
457 c.compress(&mut msgs, 50, &t);
458
459 let mut tool_use_ids = std::collections::BTreeSet::new();
460 let mut tool_result_ids = std::collections::BTreeSet::new();
461 for m in &msgs {
462 for b in &m.content {
463 match b {
464 ContentBlock::ToolUse { id, .. } => {
465 tool_use_ids.insert(id.clone());
466 }
467 ContentBlock::ToolResult { tool_use_id, .. } => {
468 tool_result_ids.insert(tool_use_id.clone());
469 }
470 _ => {}
471 }
472 }
473 }
474 assert_eq!(
475 tool_use_ids, tool_result_ids,
476 "tool_use ids and tool_result ids must remain in sync after trim"
477 );
478 assert!(
479 msgs.iter()
480 .any(|m| m.text_content().contains("[stream rule `r` fired]")),
481 "the rule injection must survive compression"
482 );
483 }
484}