Skip to main content

agent_sdk/agent/
compaction.rs

1use std::collections::HashMap;
2
3use crate::types::chat::ChatMessage;
4
5/// Metrics collected during compaction operations
6#[derive(Debug, Clone, Default)]
7pub struct CompactionMetrics {
8    pub total_compactions: u64,
9    pub total_messages_before: u64,
10    pub total_messages_after: u64,
11    pub total_chars_saved: u64,
12    pub total_time_spent: std::time::Duration,
13    pub strategy_usage: HashMap<String, u64>,
14}
15
16impl CompactionMetrics {
17    pub fn new() -> Self {
18        Self::default()
19    }
20
21    pub fn record_compaction(
22        &mut self,
23        messages_before: usize,
24        messages_after: usize,
25        chars_before: usize,
26        chars_after: usize,
27        time_spent: std::time::Duration,
28        strategy: &str,
29    ) {
30        self.total_compactions += 1;
31        self.total_messages_before += messages_before as u64;
32        self.total_messages_after += messages_after as u64;
33        self.total_chars_saved += (chars_before - chars_after) as u64;
34        self.total_time_spent += time_spent;
35        
36        *self.strategy_usage.entry(strategy.to_string()).or_insert(0) += 1;
37    }
38
39    pub fn avg_messages_before(&self) -> f64 {
40        if self.total_compactions == 0 {
41            0.0
42        } else {
43            self.total_messages_before as f64 / self.total_compactions as f64
44        }
45    }
46
47    pub fn avg_messages_after(&self) -> f64 {
48        if self.total_compactions == 0 {
49            0.0
50        } else {
51            self.total_messages_after as f64 / self.total_compactions as f64
52        }
53    }
54
55    pub fn avg_chars_saved(&self) -> f64 {
56        if self.total_compactions == 0 {
57            0.0
58        } else {
59            self.total_chars_saved as f64 / self.total_compactions as f64
60        }
61    }
62
63    pub fn avg_time_per_compaction(&self) -> std::time::Duration {
64        if self.total_compactions == 0 {
65            std::time::Duration::from_nanos(0)
66        } else {
67            std::time::Duration::from_nanos((self.total_time_spent.as_nanos() / self.total_compactions as u128) as u64)
68        }
69    }
70}
71
72/// A trait for defining custom compaction rules
73pub trait CompactionRule: Send + Sync {
74    /// Determine if compaction should be performed on the given messages
75    fn should_compact(&self, messages: &[ChatMessage]) -> bool;
76    
77    /// Select which message indices should be targeted for compaction
78    fn select_targets(&self, messages: &[ChatMessage]) -> Vec<usize>;
79    
80    /// Apply the compaction to the selected messages
81    fn apply_compaction(&self, messages: &mut [ChatMessage], targets: &[usize]);
82}
83
84/// A basic compaction rule that compresses large tool results and assistant messages
85pub struct BasicCompactionRule {
86    pub tool_result_limit: usize,
87    pub assistant_content_limit: usize,
88    pub keep_recent: usize,
89}
90
91impl Default for BasicCompactionRule {
92    fn default() -> Self {
93        Self {
94            tool_result_limit: 200,
95            assistant_content_limit: 500,
96            keep_recent: 10,
97        }
98    }
99}
100
101impl CompactionRule for BasicCompactionRule {
102    fn should_compact(&self, messages: &[ChatMessage]) -> bool {
103        // Should compact if we have more messages than our keep_recent threshold
104        messages.len() > self.keep_recent
105    }
106
107    fn select_targets(&self, messages: &[ChatMessage]) -> Vec<usize> {
108        let total = messages.len();
109        if total <= self.keep_recent {
110            return vec![];
111        }
112
113        let mut targets = Vec::new();
114        // Don't compact the most recent messages
115        let keep_after = total - self.keep_recent;
116
117        for i in 1..keep_after {
118            match &messages[i] {
119                ChatMessage::Tool { content, .. } => {
120                    if content.len() > self.tool_result_limit {
121                        targets.push(i);
122                    }
123                }
124                ChatMessage::Assistant { content, .. } => {
125                    if content.as_ref().is_some_and(|c| c.len() > self.assistant_content_limit) {
126                        targets.push(i);
127                    }
128                }
129                _ => {}
130            }
131        }
132
133        targets
134    }
135
136    fn apply_compaction(&self, messages: &mut [ChatMessage], targets: &[usize]) {
137        for &index in targets {
138            if index >= messages.len() {
139                continue;
140            }
141
142            match &mut messages[index] {
143                ChatMessage::Tool {
144                    tool_call_id: _,
145                    content,
146                } => {
147                    if content.len() > self.tool_result_limit {
148                        let summary = format!(
149                            "[compacted: {} chars] {}",
150                            content.len(),
151                            safe_prefix(content, self.tool_result_limit.saturating_sub(50))
152                        );
153                        *content = summary;
154                    }
155                }
156                ChatMessage::Assistant {
157                    content,
158                    tool_calls: _,
159                } => {
160                    if content.as_ref().is_some_and(|c| c.len() > self.assistant_content_limit) {
161                        *content = Some(truncate(
162                            content.as_ref().unwrap(),
163                            self.assistant_content_limit.saturating_sub(100),
164                        ));
165                    }
166                }
167                _ => {}
168            }
169        }
170    }
171}
172
173/// Helper function to safely truncate strings without breaking UTF-8 boundaries
174pub fn safe_prefix(s: &str, max_len: usize) -> &str {
175    if s.len() <= max_len {
176        return s;
177    }
178
179    match s.char_indices().map(|(idx, _)| idx).take_while(|&idx| idx <= max_len).last() {
180        Some(0) | None => "",
181        Some(idx) => &s[..idx],
182    }
183}
184
185/// Helper function to truncate a string with ellipsis
186pub fn truncate(s: &str, max_len: usize) -> String {
187    if s.len() <= max_len {
188        s.to_string()
189    } else {
190        format!("{}...", safe_prefix(s, max_len.saturating_sub(3)))
191    }
192}
193
194/// Helper function to calculate the estimated token count for a message
195pub fn estimate_token_count(message: &ChatMessage) -> usize {
196    const CHARS_PER_TOKEN: usize = 4;
197    message.char_len() / CHARS_PER_TOKEN
198}
199
200/// Helper function to calculate the estimated token count for a set of messages
201pub fn estimate_total_token_count(messages: &[ChatMessage]) -> usize {
202    messages.iter().map(estimate_token_count).sum()
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn test_safe_prefix() {
211        let s = "Hello, world!";
212        assert_eq!(safe_prefix(s, 5), "Hello");
213        assert_eq!(safe_prefix(s, 13), s); // Length of "Hello, world!" is 13
214        assert_eq!(safe_prefix(s, 0), "");
215    }
216
217    #[test]
218    fn test_truncate() {
219        let s = "This is a long string";
220        assert_eq!(truncate(s, 10), "This is...");
221        assert_eq!(truncate(s, 100), s);
222    }
223
224    #[test]
225    fn test_basic_compaction_rule() {
226        let rule = BasicCompactionRule {
227            tool_result_limit: 50,
228            assistant_content_limit: 100,
229            keep_recent: 1, // Keep only 1 recent message
230        };
231
232        let mut messages = vec![
233            ChatMessage::system("system".to_string()),
234            ChatMessage::assistant("This is a short assistant message".to_string()),
235            ChatMessage::tool_result("call1", &"x".repeat(60)), // This should be compacted
236            ChatMessage::assistant(&"x".repeat(120)), // This should be kept (most recent)
237        ];
238
239        assert!(rule.should_compact(&messages));
240        let targets = rule.select_targets(&messages);
241        // With 4 messages and keep_recent=1, we keep the last message (index 3) and consider indices 0,1,2
242        // Index 0 is system, so not targeted
243        // Index 1 is assistant but under limit, so not targeted
244        // Index 2 is tool result over limit, so targeted
245        assert_eq!(targets, vec![2]);
246        
247        rule.apply_compaction(&mut messages, &targets);
248        
249        // Check that the large tool result was compressed
250        if let ChatMessage::Tool { content, .. } = &messages[2] {
251            assert!(content.starts_with("[compacted:"));
252        } else {
253            panic!("Expected tool message at index 2");
254        }
255    }
256
257    #[test]
258    fn test_metrics() {
259        let mut metrics = CompactionMetrics::new();
260        metrics.record_compaction(20, 10, 5000, 3000, std::time::Duration::from_millis(100), "default");
261        metrics.record_compaction(15, 8, 4000, 2500, std::time::Duration::from_millis(80), "conservative");
262
263        assert_eq!(metrics.total_compactions, 2);
264        assert_eq!(metrics.avg_chars_saved(), 1750.0); // (2000 + 1500) / 2
265        assert_eq!(metrics.avg_time_per_compaction(), std::time::Duration::from_millis(90));
266    }
267}