Skip to main content

heartbit_core/agent/
pruner.rs

1//! Session context pruner — trims old messages before LLM calls to control context size.
2
3use crate::llm::types::{ContentBlock, Message, Role};
4use crate::tool::builtins::floor_char_boundary;
5
6/// Configuration for session-level pruning of old tool results.
7///
8/// Before each LLM call, old tool results are truncated in-place to reduce
9/// token usage. Recent messages are preserved at full fidelity.
10#[derive(Debug, Clone)]
11pub struct SessionPruneConfig {
12    /// Number of recent user/assistant message pairs to keep at full fidelity.
13    /// Default: 2.
14    pub keep_recent_n: usize,
15    /// Maximum bytes for a pruned tool result. Content exceeding this is
16    /// replaced with head + tail + `[pruned: N bytes]`. Default: 200.
17    pub pruned_tool_result_max_bytes: usize,
18    /// Whether to preserve the first user message (task) from pruning.
19    /// Default: true.
20    pub preserve_task: bool,
21}
22
23impl Default for SessionPruneConfig {
24    fn default() -> Self {
25        Self {
26            keep_recent_n: 2,
27            pruned_tool_result_max_bytes: 200,
28            preserve_task: true,
29        }
30    }
31}
32
33/// Statistics from a session pruning pass.
34#[derive(Debug, Clone, Default, PartialEq, Eq)]
35pub struct PruneStats {
36    /// Number of tool results that were truncated.
37    pub tool_results_pruned: usize,
38    /// Total bytes removed across all truncated tool results.
39    pub bytes_saved: usize,
40    /// Total number of tool results inspected (pruned + skipped).
41    pub tool_results_total: usize,
42}
43
44impl PruneStats {
45    /// Returns `true` if any pruning actually occurred.
46    pub fn did_prune(&self) -> bool {
47        self.tool_results_pruned > 0
48    }
49}
50
51/// Prune old tool results in a message list, returning a new list and stats.
52///
53/// Messages in the "recent" tail (last `keep_recent_n * 2` messages) are kept
54/// intact. Older messages containing tool results have their content truncated
55/// to `max_bytes` with a `[pruned: N bytes]` marker.
56///
57/// The first message (task) is always preserved if `preserve_task` is true.
58/// Message count and roles are never changed — only content is shortened.
59pub fn prune_old_tool_results(
60    messages: &[Message],
61    config: &SessionPruneConfig,
62) -> (Vec<Message>, PruneStats) {
63    if messages.is_empty() {
64        return (vec![], PruneStats::default());
65    }
66
67    let mut stats = PruneStats::default();
68
69    // Recent tail: keep the last N*2 messages (user+assistant pairs)
70    let recent_count = config.keep_recent_n * 2;
71    let recent_start = messages.len().saturating_sub(recent_count);
72
73    let pruned = messages
74        .iter()
75        .enumerate()
76        .map(|(i, msg)| {
77            // Preserve task message
78            if i == 0 && config.preserve_task {
79                return msg.clone();
80            }
81            // Preserve recent messages
82            if i >= recent_start {
83                return msg.clone();
84            }
85            // Only prune User messages with tool results
86            if msg.role != Role::User {
87                return msg.clone();
88            }
89            let has_tool_results = msg
90                .content
91                .iter()
92                .any(|b| matches!(b, ContentBlock::ToolResult { .. }));
93            if !has_tool_results {
94                return msg.clone();
95            }
96            // Prune tool result content
97            let pruned_content = msg
98                .content
99                .iter()
100                .map(|block| match block {
101                    ContentBlock::ToolResult {
102                        tool_use_id,
103                        content,
104                        is_error,
105                    } => {
106                        stats.tool_results_total += 1;
107                        let max = config.pruned_tool_result_max_bytes;
108                        let pruned = truncate_with_marker(content, max, tool_use_id);
109                        if pruned.len() < content.len() {
110                            stats.tool_results_pruned += 1;
111                            stats.bytes_saved += content.len() - pruned.len();
112                        }
113                        ContentBlock::ToolResult {
114                            tool_use_id: tool_use_id.clone(),
115                            content: pruned,
116                            is_error: *is_error,
117                        }
118                    }
119                    other => other.clone(),
120                })
121                .collect();
122            Message {
123                role: msg.role.clone(),
124                content: pruned_content,
125            }
126        })
127        .collect();
128
129    (pruned, stats)
130}
131
132/// Truncate content to `max_bytes` with a `[pruned: N bytes omitted, id=… — call fetch_full_output(…)]` marker.
133///
134/// If content fits within `max_bytes`, returns it unchanged.
135/// Otherwise, keeps head bytes up to a char boundary and appends marker.
136pub(crate) fn truncate_with_marker(content: &str, max_bytes: usize, tool_use_id: &str) -> String {
137    truncate_with_marker_ext(content, max_bytes, tool_use_id, true)
138}
139
140/// Like [`truncate_with_marker`], but `restorable` controls the marker text.
141///
142/// `restorable = true` promises `fetch_full_output(<id>)` restore (the content
143/// is indexed in a `ContextRecallStore`). `restorable = false` emits an honest
144/// `[truncated: …]` marker without a restore hint — used when no recall store
145/// is wired, so the LLM is never trained to make a guaranteed-failing call.
146pub(crate) fn truncate_with_marker_ext(
147    content: &str,
148    max_bytes: usize,
149    tool_use_id: &str,
150    restorable: bool,
151) -> String {
152    if content.len() <= max_bytes {
153        return content.to_string();
154    }
155    let omitted = content.len() - max_bytes;
156    let marker = if restorable {
157        format!(
158            "\n[pruned: {omitted} bytes omitted, id={tool_use_id} — call fetch_full_output(\"{tool_use_id}\") to restore]"
159        )
160    } else {
161        format!("\n[truncated: {omitted} bytes omitted — full content not retained]")
162    };
163    let head_budget = max_bytes.saturating_sub(marker.len());
164    let boundary = floor_char_boundary(content, head_budget);
165    let head = &content[..boundary];
166    format!("{head}{marker}")
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::llm::types::ToolResult;
173    use serde_json::json;
174
175    fn tool_use_msg(id: &str, name: &str) -> Message {
176        Message {
177            role: Role::Assistant,
178            content: vec![ContentBlock::ToolUse {
179                id: id.into(),
180                name: name.into(),
181                input: json!({}),
182            }],
183        }
184    }
185
186    fn tool_result_msg(id: &str, content: &str) -> Message {
187        Message::tool_results(vec![ToolResult::success(id, content)])
188    }
189
190    #[test]
191    fn prune_preserves_recent_messages() {
192        let messages = vec![
193            Message::user("task"),
194            tool_use_msg("c1", "search"),
195            tool_result_msg("c1", &"x".repeat(1000)),
196            tool_use_msg("c2", "read"),
197            tool_result_msg("c2", &"y".repeat(1000)),
198            Message::assistant("final answer"),
199        ];
200
201        let config = SessionPruneConfig {
202            keep_recent_n: 2,
203            pruned_tool_result_max_bytes: 50,
204            preserve_task: true,
205        };
206        let (pruned, stats) = prune_old_tool_results(&messages, &config);
207
208        assert_eq!(pruned.len(), messages.len(), "message count unchanged");
209
210        // Last 4 messages (2 pairs) should be intact
211        let last_result = &pruned[4];
212        if let ContentBlock::ToolResult { content, .. } = &last_result.content[0] {
213            assert_eq!(content.len(), 1000, "recent tool result should be intact");
214        }
215
216        // Only 1 tool result is outside the recent window (c1), but it's
217        // also the task-adjacent one — the first user msg (task) is index 0,
218        // c1 result is index 2. With keep_recent_n=2 the recent window
219        // starts at index 2 (6-4=2), so c1 is at the boundary and preserved.
220        assert!(!stats.did_prune());
221    }
222
223    #[test]
224    fn prune_trims_old_tool_results() {
225        let messages = vec![
226            Message::user("task"),
227            tool_use_msg("c1", "search"),
228            tool_result_msg("c1", &"a".repeat(1000)),
229            tool_use_msg("c2", "read"),
230            tool_result_msg("c2", &"b".repeat(500)),
231            tool_use_msg("c3", "write"),
232            tool_result_msg("c3", "short result"),
233            Message::assistant("done"),
234        ];
235
236        let config = SessionPruneConfig {
237            keep_recent_n: 1,
238            pruned_tool_result_max_bytes: 100,
239            preserve_task: true,
240        };
241        let (pruned, stats) = prune_old_tool_results(&messages, &config);
242
243        // messages[2] (old tool result with 1000 bytes) should be pruned
244        if let ContentBlock::ToolResult { content, .. } = &pruned[2].content[0] {
245            assert!(
246                content.len() <= 200,
247                "old tool result should be truncated, got {} bytes",
248                content.len()
249            );
250            assert!(content.contains("[pruned:"));
251        }
252
253        // messages[4] (old tool result with 500 bytes) should also be pruned
254        if let ContentBlock::ToolResult { content, .. } = &pruned[4].content[0] {
255            assert!(
256                content.len() <= 200,
257                "old tool result should be truncated, got {} bytes",
258                content.len()
259            );
260            assert!(content.contains("[pruned:"));
261        }
262
263        assert!(stats.did_prune());
264        assert_eq!(stats.tool_results_pruned, 2);
265        assert!(stats.bytes_saved > 0);
266        assert_eq!(stats.tool_results_total, 2);
267    }
268
269    #[test]
270    fn prune_preserves_task_message() {
271        let messages = vec![
272            Message::user("important initial task"),
273            tool_use_msg("c1", "search"),
274            tool_result_msg("c1", &"x".repeat(1000)),
275            Message::assistant("answer"),
276        ];
277
278        let config = SessionPruneConfig {
279            keep_recent_n: 0,
280            pruned_tool_result_max_bytes: 50,
281            preserve_task: true,
282        };
283        let (pruned, _stats) = prune_old_tool_results(&messages, &config);
284
285        // Task message should be unchanged
286        if let ContentBlock::Text { text } = &pruned[0].content[0] {
287            assert_eq!(text, "important initial task");
288        }
289    }
290
291    #[test]
292    fn prune_preserves_message_count() {
293        let messages = vec![
294            Message::user("task"),
295            tool_use_msg("c1", "search"),
296            tool_result_msg("c1", &"x".repeat(1000)),
297            tool_use_msg("c2", "read"),
298            tool_result_msg("c2", &"y".repeat(1000)),
299            Message::assistant("done"),
300        ];
301
302        let config = SessionPruneConfig::default();
303        let (pruned, _stats) = prune_old_tool_results(&messages, &config);
304
305        assert_eq!(pruned.len(), messages.len());
306        // Verify roles are preserved
307        for (original, pruned) in messages.iter().zip(pruned.iter()) {
308            assert_eq!(original.role, pruned.role);
309        }
310    }
311
312    #[test]
313    fn prune_utf8_safe() {
314        // Multi-byte UTF-8 content should not be split at invalid boundaries
315        let emoji_content = "🦀".repeat(100); // 400 bytes, 100 chars
316        let messages = vec![
317            Message::user("task"),
318            tool_use_msg("c1", "search"),
319            tool_result_msg("c1", &emoji_content),
320            Message::assistant("done"),
321        ];
322
323        let config = SessionPruneConfig {
324            keep_recent_n: 0,
325            // Large enough that the head budget (max - marker) leaves a real
326            // multi-byte content head to slice at a char boundary.
327            pruned_tool_result_max_bytes: 120,
328            preserve_task: true,
329        };
330        let (pruned, _stats) = prune_old_tool_results(&messages, &config);
331
332        // Should not panic and content should be valid UTF-8
333        if let ContentBlock::ToolResult { content, .. } = &pruned[2].content[0] {
334            assert!(content.is_char_boundary(0));
335            // A real emoji head must survive (the char-boundary slice ran).
336            assert!(content.starts_with('🦀'));
337            // Verify it's valid UTF-8 by iterating
338            for _ in content.chars() {}
339        }
340    }
341
342    #[test]
343    fn prune_empty_messages() {
344        let (pruned, stats) = prune_old_tool_results(&[], &SessionPruneConfig::default());
345        assert!(pruned.is_empty());
346        assert!(!stats.did_prune());
347    }
348
349    #[test]
350    fn prune_no_tool_results_is_noop() {
351        let messages = vec![
352            Message::user("task"),
353            Message::assistant("response 1"),
354            Message::user("follow up"),
355            Message::assistant("response 2"),
356        ];
357
358        let config = SessionPruneConfig {
359            keep_recent_n: 0,
360            pruned_tool_result_max_bytes: 10,
361            preserve_task: true,
362        };
363        let (pruned, stats) = prune_old_tool_results(&messages, &config);
364
365        // No tool results to prune, all messages should be unchanged
366        for (original, pruned) in messages.iter().zip(pruned.iter()) {
367            assert_eq!(original.content.len(), pruned.content.len());
368        }
369        assert!(!stats.did_prune());
370    }
371
372    #[test]
373    fn prune_short_tool_results_unchanged() {
374        let messages = vec![
375            Message::user("task"),
376            tool_use_msg("c1", "search"),
377            tool_result_msg("c1", "short"),
378            Message::assistant("done"),
379        ];
380
381        let config = SessionPruneConfig {
382            keep_recent_n: 0,
383            pruned_tool_result_max_bytes: 200,
384            preserve_task: true,
385        };
386        let (pruned, stats) = prune_old_tool_results(&messages, &config);
387
388        if let ContentBlock::ToolResult { content, .. } = &pruned[2].content[0] {
389            assert_eq!(content, "short", "short results should not be modified");
390        }
391        // Tool result was inspected but not truncated (under max_bytes)
392        assert!(!stats.did_prune());
393        assert_eq!(stats.tool_results_total, 1);
394        assert_eq!(stats.tool_results_pruned, 0);
395    }
396
397    #[test]
398    fn pruned_marker_includes_the_tool_use_id() {
399        let big = "x".repeat(5000);
400        // Build a message list where an old ToolResult (id "tc_abc", content `big`)
401        // is followed by enough recent user/assistant messages to push it out of the
402        // kept tail (keep_recent_n=2 → keeps last 4 messages).
403        let messages = vec![
404            Message::user("task"),
405            tool_use_msg("tc_abc", "fetch"),
406            tool_result_msg("tc_abc", &big), // index 2 — will be outside recent window
407            tool_use_msg("c2", "read"),
408            tool_result_msg("c2", "recent result 1"),
409            Message::assistant("response 1"),
410            Message::user("follow up"),
411            Message::assistant("final answer"),
412        ];
413        let config = SessionPruneConfig {
414            keep_recent_n: 2,
415            pruned_tool_result_max_bytes: 200,
416            preserve_task: true,
417        };
418        let (out, stats) = prune_old_tool_results(&messages, &config);
419        assert!(stats.did_prune());
420        let pruned_text: String = out
421            .iter()
422            .flat_map(|m| m.content.iter())
423            .filter_map(|b| match b {
424                ContentBlock::ToolResult { content, .. } => Some(content.clone()),
425                _ => None,
426            })
427            .collect();
428        assert!(
429            pruned_text.contains("tc_abc"),
430            "marker must name the ref: {pruned_text}"
431        );
432        assert!(
433            pruned_text.contains("pruned"),
434            "marker should still say pruned: {pruned_text}"
435        );
436    }
437
438    #[test]
439    fn truncate_with_marker_short_content() {
440        let result = truncate_with_marker("hello", 100, "tc_x");
441        assert_eq!(result, "hello");
442    }
443
444    #[test]
445    fn truncate_with_marker_long_content() {
446        let content = "a".repeat(1000);
447        let result = truncate_with_marker(&content, 100, "tc_x");
448        assert!(result.len() <= 200); // head + marker
449        assert!(result.contains("[pruned:"));
450        assert!(result.contains("bytes omitted"));
451        assert!(result.contains("id=tc_x"));
452    }
453
454    #[test]
455    fn truncate_with_marker_ext_restorable_matches_existing_marker() {
456        let content = "a".repeat(1000);
457        let ext = truncate_with_marker_ext(&content, 100, "tc_x", true);
458        let legacy = truncate_with_marker(&content, 100, "tc_x");
459        assert_eq!(ext, legacy, "restorable=true must match the legacy marker");
460        assert!(ext.contains("fetch_full_output"));
461    }
462
463    #[test]
464    fn truncate_with_marker_ext_not_restorable_omits_fetch_hint() {
465        let content = "a".repeat(1000);
466        let result = truncate_with_marker_ext(&content, 100, "tc_x", false);
467        assert!(result.len() <= 200, "head + marker stays bounded");
468        assert!(result.contains("[truncated:"));
469        assert!(result.contains("bytes omitted"));
470        assert!(
471            !result.contains("fetch_full_output"),
472            "must not promise a restore that cannot happen: {result}"
473        );
474    }
475
476    #[test]
477    fn truncate_with_marker_ext_not_restorable_short_content_unchanged() {
478        assert_eq!(
479            truncate_with_marker_ext("hello", 100, "tc_x", false),
480            "hello"
481        );
482    }
483
484    #[test]
485    fn truncate_with_marker_ext_not_restorable_utf8_safe() {
486        let content = "🦀".repeat(100); // 400 bytes
487        let result = truncate_with_marker_ext(&content, 120, "tc_x", false);
488        assert!(result.starts_with('🦀'), "char-boundary head survives");
489        for _ in result.chars() {} // valid UTF-8 throughout
490    }
491
492    #[test]
493    fn prune_stats_bytes_saved_accurate() {
494        let messages = vec![
495            Message::user("task"),
496            tool_use_msg("c1", "search"),
497            tool_result_msg("c1", &"a".repeat(1000)),
498            tool_use_msg("c2", "read"),
499            tool_result_msg("c2", &"b".repeat(2000)),
500            Message::assistant("done"),
501        ];
502
503        let config = SessionPruneConfig {
504            keep_recent_n: 0,
505            pruned_tool_result_max_bytes: 100,
506            preserve_task: true,
507        };
508        let (pruned, stats) = prune_old_tool_results(&messages, &config);
509
510        assert!(stats.did_prune());
511        assert_eq!(stats.tool_results_pruned, 2);
512        assert_eq!(stats.tool_results_total, 2);
513
514        // bytes_saved = original bytes - pruned bytes for each truncated result
515        let pruned_c1_len = if let ContentBlock::ToolResult { content, .. } = &pruned[2].content[0]
516        {
517            content.len()
518        } else {
519            panic!("expected tool result");
520        };
521        let pruned_c2_len = if let ContentBlock::ToolResult { content, .. } = &pruned[4].content[0]
522        {
523            content.len()
524        } else {
525            panic!("expected tool result");
526        };
527        let expected_saved = (1000 - pruned_c1_len) + (2000 - pruned_c2_len);
528        assert_eq!(stats.bytes_saved, expected_saved);
529    }
530}