Skip to main content

roma_core/
history.rs

1//! History compression strategies.
2//!
3//! Compression runs in two passes:
4//!
5//! 1. **Tag compression**: replace the `content` string of `ToolResult`
6//!    blocks (typically the largest source of bloat) with a short stub
7//!    once total tokens exceed the target.
8//! 2. **Head trimming**: if still over budget, drop the oldest
9//!    user/assistant pair. The very first message is preserved whenever
10//!    possible because it often carries the task brief.
11//!
12//! The strategy is conservative: we never touch `ContentBlock::Text` on
13//! assistant turns (which contain reasoning) or the final assistant
14//! message of a tool call (which the next turn's tool_result must match
15//! by tool_use_id).
16
17use 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
26/// Messages containing a stream-rule injection are never stubbed or
27/// trimmed (spec C5/D6): long sessions are exactly where rules matter.
28fn 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
40/// Strategy for shrinking chat history under a target token count.
41pub trait HistoryCompressor: Send + Sync {
42    /// Compress `messages` in place until the total token count reported
43    /// by `tokenizer` is at most `target_tokens`, or we run out of
44    /// reversible compression moves.
45    ///
46    /// Returns the token count after compression (which may still exceed
47    /// `target_tokens` if no further moves were safe).
48    fn compress(
49        &self,
50        messages: &mut Vec<Message>,
51        target_tokens: u32,
52        tokenizer: &dyn Tokenizer,
53    ) -> u32;
54}
55
56/// Default two-pass compressor.
57#[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        // Pass 1: stub out ToolResult content blocks, oldest first.
73        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        // Pass 2: trim from the head one *turn* at a time. A turn is a
89        // sequence of messages anchored by a user-text message (not a
90        // tool_result), running through any assistant + tool_result
91        // interleaving up to the next user-text message. Removing a
92        // whole turn preserves the Anthropic invariant that every
93        // ToolUse block has a matching ToolResult later in history.
94        let preserved_first = !messages.is_empty() && is_user_anchor(&messages[0]);
95        let anchor_floor = if preserved_first { 1 } else { 0 };
96        // `floor` advances past pinned turns so the search never revisits them.
97        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            // Find the first user-anchor at or after floor.
104            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            // Find the next user-anchor (exclusive end of this turn).
114            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 the turn is the entire remainder, stop — we'd otherwise
121            // delete the live conversation tail.
122            if turn_end == messages.len() {
123                break;
124            }
125            // C5: turns containing a rule injection are pinned — skip past
126            // them instead of draining.
127            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
142/// A user message anchors a new turn iff it carries text content (not
143/// just `tool_result` blocks). This is the boundary at which trimming is
144/// safe: dropping `[turn_start..turn_end)` removes a self-contained
145/// chunk of conversation including any internal tool_use/tool_result
146/// pairs.
147fn is_user_anchor(m: &Message) -> bool {
148    if m.role != Role::User {
149        return false;
150    }
151    // A tool-result carrier is interior to its turn, never a boundary —
152    // even when it also carries text (memory snapshots and stream-rule
153    // injections are appended to tool-result messages). Treating it as an
154    // anchor would let a drain separate tool_result from its tool_use.
155    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
166/// Count tokens across all blocks of all messages, approximating a
167/// provider-side payload. We count each block's textual payload and add
168/// a small per-message overhead.
169fn 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); // per-message overhead
173        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        // `input` is a serde_json::Value; `as_str()` returns None for
185        // Objects/Arrays, so we use `to_string()` to get the JSON text.
186        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); // ~1000 tokens under approx.
222        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        // The tool result content must have been replaced with the stub.
231        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        // First message (brief) preserved.
254        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        // Extreme case: tight budget but only 2 messages; the compressor
265        // must stop trimming rather than empty the transcript.
266        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    /// Regression for P0-3: head trimming must not orphan a ToolUse
275    /// without its ToolResult, or vice versa. After compression the set
276    /// of `tool_use` ids and the set of `tool_use_id`s referenced by
277    /// `tool_result`s must be equal.
278    #[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            // Brief — preserved.
306            Message::user_text("<brief>"),
307            // Turn 1 — tool round trip.
308            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            // Turn 2 — tool round trip.
313            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            // Tail — newest user prompt.
318            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        // The very last user message (live tail) must also survive.
342        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            // Pinned turn: the user message carries a rule injection.
387            Message::user_text("do the thing\n\n[stream rule `r` fired]\n\nrule body"),
388            Message::assistant_text("a".repeat(4000)),
389            // Unpinned padding turns.
390            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            // Live tail.
395            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    /// Regression: a tool-result message that also carries a text block
407    /// (stream-rule injections and memory snapshots are appended to
408    /// tool-result messages) must never be treated as a turn anchor.
409    /// Otherwise pass 2 can drain the turn *ending* at that pseudo-anchor,
410    /// orphaning the ToolResult from its ToolUse — every subsequent
411    /// provider request then 400s, permanently corrupting the session.
412    #[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            // Brief — preserved.
430            Message::user_text("<brief>"),
431            // Turn with a real tool round trip; the tool-result message
432            // also carries a stream-rule injection as a Text block.
433            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            // Unpinned padding turns.
450            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            // Live tail.
455            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}