1use crate::llm::types::{ContentBlock, Message, Role};
4use crate::tool::builtins::floor_char_boundary;
5
6#[derive(Debug, Clone)]
11pub struct SessionPruneConfig {
12 pub keep_recent_n: usize,
15 pub pruned_tool_result_max_bytes: usize,
18 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
35pub struct PruneStats {
36 pub tool_results_pruned: usize,
38 pub bytes_saved: usize,
40 pub tool_results_total: usize,
42}
43
44impl PruneStats {
45 pub fn did_prune(&self) -> bool {
47 self.tool_results_pruned > 0
48 }
49}
50
51pub 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 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 if i == 0 && config.preserve_task {
79 return msg.clone();
80 }
81 if i >= recent_start {
83 return msg.clone();
84 }
85 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 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
132pub(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
140pub(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 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 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 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 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 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 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 let emoji_content = "🦀".repeat(100); 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 pruned_tool_result_max_bytes: 120,
328 preserve_task: true,
329 };
330 let (pruned, _stats) = prune_old_tool_results(&messages, &config);
331
332 if let ContentBlock::ToolResult { content, .. } = &pruned[2].content[0] {
334 assert!(content.is_char_boundary(0));
335 assert!(content.starts_with('🦀'));
337 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 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 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 let messages = vec![
404 Message::user("task"),
405 tool_use_msg("tc_abc", "fetch"),
406 tool_result_msg("tc_abc", &big), 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); 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); 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() {} }
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 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}