agent_sdk/agent/
compaction.rs1use std::collections::HashMap;
2
3use crate::types::chat::ChatMessage;
4
5#[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
72pub trait CompactionRule: Send + Sync {
74 fn should_compact(&self, messages: &[ChatMessage]) -> bool;
76
77 fn select_targets(&self, messages: &[ChatMessage]) -> Vec<usize>;
79
80 fn apply_compaction(&self, messages: &mut [ChatMessage], targets: &[usize]);
82}
83
84pub 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 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 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
173pub 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
185pub 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
194pub fn estimate_token_count(message: &ChatMessage) -> usize {
196 const CHARS_PER_TOKEN: usize = 4;
197 message.char_len() / CHARS_PER_TOKEN
198}
199
200pub 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); 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, };
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)), ChatMessage::assistant(&"x".repeat(120)), ];
238
239 assert!(rule.should_compact(&messages));
240 let targets = rule.select_targets(&messages);
241 assert_eq!(targets, vec![2]);
246
247 rule.apply_compaction(&mut messages, &targets);
248
249 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); assert_eq!(metrics.avg_time_per_compaction(), std::time::Duration::from_millis(90));
266 }
267}