lellm_agent/runtime/context/
local_compactor.rs1use lellm_core::{Message, text_block};
12
13use super::budget::ContextBudget;
14use super::compactor::{CompactionResult, ContextCompactor};
15
16#[derive(Debug, Default)]
18pub struct LocalCompactor;
19
20impl LocalCompactor {
21 pub fn new() -> Self {
22 Self
23 }
24}
25
26impl ContextCompactor for LocalCompactor {
27 fn compact(&self, messages: &[Message], budget: &ContextBudget) -> CompactionResult {
28 let before_tokens = super::estimation::estimate_tokens(messages);
29 let before_count = messages.len();
30
31 let (system_msgs, conversation): (Vec<_>, Vec<_>) = messages
33 .iter()
34 .partition(|m| matches!(m, Message::System { .. }));
35
36 let turns = extract_turns(&conversation);
38
39 let keep = budget.keep_recent_turns;
41 if turns.len() <= keep {
42 return CompactionResult {
44 messages: messages.to_vec(),
45 before_tokens,
46 after_tokens: before_tokens,
47 removed_messages: 0,
48 };
49 }
50
51 let recent_turns: Vec<_> = turns.iter().skip(turns.len() - keep).collect();
53 let old_turns: Vec<_> = turns.iter().take(turns.len() - keep).collect();
54
55 let summary = summarize_turns(&old_turns);
57
58 let mut result = system_msgs.into_iter().cloned().collect::<Vec<_>>();
60
61 if !summary.is_empty() {
62 result.push(Message::System {
63 content: text_block(format!("[Previous conversation summary]\n{summary}")),
64 });
65 }
66
67 for turn in recent_turns {
68 for msg in turn {
69 result.push((*msg).clone());
70 }
71 }
72
73 let after_tokens = super::estimation::estimate_tokens(&result);
74 let removed = before_count.saturating_sub(result.len());
75
76 if removed > 0 {
77 tracing::debug!(
78 before_tokens,
79 after_tokens,
80 removed_messages = removed,
81 before_count,
82 after_count = result.len(),
83 "LocalCompactor: context compressed"
84 );
85 }
86
87 CompactionResult {
88 messages: result,
89 before_tokens,
90 after_tokens,
91 removed_messages: removed,
92 }
93 }
94}
95
96fn extract_turns<'a>(messages: &[&'a Message]) -> Vec<Vec<&'a Message>> {
105 let mut turns: Vec<Vec<&Message>> = Vec::new();
106 let mut current_turn: Vec<&Message> = Vec::new();
107
108 for msg in messages {
109 match msg {
110 Message::Assistant { .. } => {
111 if !current_turn.is_empty() {
113 turns.push(current_turn);
114 current_turn = Vec::new();
115 }
116 current_turn.push(msg);
117 }
118 Message::ToolResult { .. } => {
119 current_turn.push(msg);
121 }
122 Message::User { .. } => {
123 if current_turn.is_empty() {
126 current_turn.push(msg);
127 } else {
128 turns.push(current_turn);
131 current_turn = vec![msg];
132 }
133 }
134 Message::System { .. } => {
135 }
137 }
138 }
139
140 if !current_turn.is_empty() {
141 turns.push(current_turn);
142 }
143
144 turns
145}
146
147fn summarize_turns(turns: &[&Vec<&Message>]) -> String {
156 let mut lines = Vec::new();
157
158 for (idx, turn) in turns.iter().enumerate() {
159 let _prefix = format!("Turn {}:", idx + 1);
160
161 for msg in *turn {
162 match msg {
163 Message::Assistant { content } => {
164 let texts: Vec<_> = content.iter().filter_map(|b| b.as_text()).collect();
165 let tool_calls = msg.extract_tool_calls();
166
167 if !texts.is_empty() {
168 let text = texts.join(" ");
169 let summary = truncate_chars(&text, 200);
170 lines.push(format!(" Assistant: {}", summary));
171 }
172
173 if !tool_calls.is_empty() {
174 for tc in &tool_calls {
175 let args_summary = truncate_chars(&tc.arguments.to_string(), 100);
176 lines.push(format!(" Tool({}): {}", tc.name, args_summary));
177 }
178 }
179 }
180 Message::ToolResult {
181 is_error, content, ..
182 } => {
183 let status = if *is_error { "ERROR" } else { "OK" };
184 let text: String = content
185 .iter()
186 .filter_map(|b| b.as_text())
187 .collect::<Vec<_>>()
188 .join(" ");
189 let summary = truncate_chars(&text, 100);
190 lines.push(format!(" {} Result: {}", status, summary));
191 }
192 Message::User { content } => {
193 let text: String = content
194 .iter()
195 .filter_map(|b| b.as_text())
196 .collect::<Vec<_>>()
197 .join(" ");
198 let summary = truncate_chars(&text, 200);
199 lines.push(format!(" User: {}", summary));
200 }
201 _ => {}
202 }
203 }
204 }
205
206 if lines.is_empty() {
207 return String::new();
208 }
209
210 let total_turns = turns.len();
212 format!("[Compressed {} turns]\n{}", total_turns, lines.join("\n"))
213}
214
215fn truncate_chars(s: &str, max: usize) -> String {
216 let count = s.chars().count();
217 if count <= max {
218 return s.to_string();
219 }
220 let truncated: String = s.chars().take(max).collect();
221 format!("{}… ({} chars)", truncated, count)
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use lellm_core::ContentBlock;
228
229 #[test]
230 fn test_extract_turns_atomic() {
231 let assistant = Message::Assistant {
232 content: vec![ContentBlock::ToolCall(lellm_core::ToolCall {
233 id: "call_1".into(),
234 name: "test".into(),
235 arguments: serde_json::json!({}),
236 })],
237 };
238 let tool_result = Message::ToolResult {
239 tool_call_id: "call_1".to_string(),
240 is_error: false,
241 content: text_block("ok".to_string()),
242 };
243
244 let messages = vec![&assistant, &tool_result];
245 let turns = extract_turns(&messages);
246
247 assert_eq!(turns.len(), 1);
249 assert_eq!(turns[0].len(), 2);
250 }
251
252 #[test]
253 fn test_extract_turns_multiple() {
254 let user = Message::User {
255 content: text_block("hello".to_string()),
256 };
257 let assistant = Message::Assistant {
258 content: vec![ContentBlock::ToolCall(lellm_core::ToolCall {
259 id: "call_1".into(),
260 name: "test".into(),
261 arguments: serde_json::json!({}),
262 })],
263 };
264 let tool_result = Message::ToolResult {
265 tool_call_id: "call_1".to_string(),
266 is_error: false,
267 content: text_block("ok".to_string()),
268 };
269 let assistant2 = Message::Assistant {
270 content: text_block("final answer".to_string()),
271 };
272
273 let messages = vec![&user, &assistant, &tool_result, &assistant2];
274 let turns = extract_turns(&messages);
275
276 assert_eq!(turns.len(), 3);
280 assert_eq!(turns[0].len(), 1); assert_eq!(turns[1].len(), 2); assert_eq!(turns[2].len(), 1); }
284}