1use serde_json::Value;
36use std::collections::HashMap;
37
38const MIN_ARG_STRING_CAP: usize = 32;
43
44#[derive(Debug, Clone)]
46pub struct PruneConfig {
47 pub keep_last: usize,
50 pub dedupe_min_chars: usize,
52 pub summarize_min_chars: usize,
54 pub args_max_chars: usize,
57 pub arg_string_cap: usize,
60}
61
62impl Default for PruneConfig {
63 fn default() -> Self {
64 Self {
65 keep_last: 10,
66 dedupe_min_chars: 200,
67 summarize_min_chars: 200,
68 args_max_chars: 500,
69 arg_string_cap: 200,
70 }
71 }
72}
73
74impl PruneConfig {
75 fn effective_arg_string_cap(&self) -> usize {
76 self.arg_string_cap.max(MIN_ARG_STRING_CAP)
77 }
78
79 fn aged_len(&self, total: usize) -> usize {
81 total.saturating_sub(self.keep_last)
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct PruneOutcome {
89 pub messages: Vec<Value>,
91 pub chars_reclaimed: usize,
95}
96
97pub fn prune(messages: &[Value], cfg: &PruneConfig) -> PruneOutcome {
123 let before = serialized_len(messages);
124 let out = collapse_duplicate_tool_results(messages, cfg);
125 let out = summarize_aged_tool_results(&out, cfg);
126 let out = shrink_tool_call_args(&out, cfg);
127 let after = serialized_len(&out);
128 PruneOutcome {
129 messages: out,
130 chars_reclaimed: before.saturating_sub(after),
131 }
132}
133
134pub fn serialized_len(messages: &[Value]) -> usize {
137 messages.iter().map(json_len).sum()
138}
139
140pub fn collapse_duplicate_tool_results(messages: &[Value], cfg: &PruneConfig) -> Vec<Value> {
148 let mut out = messages.to_vec();
149 let aged = cfg.aged_len(out.len());
150 let paired = pair_tool_results(&out);
151 let mut first_seen: HashMap<[u8; 32], usize> = HashMap::new();
152 let mut replacements: Vec<(usize, String)> = Vec::new();
153
154 for (i, msg) in out.iter().enumerate() {
155 if msg["role"].as_str() != Some("tool") {
156 continue;
157 }
158 let Some(content) = msg["content"].as_str() else {
159 continue;
160 };
161 if content.chars().count() <= cfg.dedupe_min_chars
162 || already_pruned(content, paired_name(&paired, i))
163 {
164 continue;
165 }
166 let key = *blake3::hash(content.as_bytes()).as_bytes();
167 match first_seen.get(&key) {
168 None => {
169 first_seen.insert(key, i);
170 }
171 Some(&first) if i < aged && out[first]["content"].as_str() == Some(content) => {
175 let n = content.chars().count();
176 let line = format!(
177 "[duplicate of message {first}: identical {n}-char tool result elided]"
178 );
179 if json_str_len(&line) < json_str_len(content) {
180 replacements.push((i, line));
181 }
182 }
183 Some(_) => {}
184 }
185 }
186 for (i, line) in replacements {
187 out[i]["content"] = Value::String(line);
188 }
189 out
190}
191
192pub fn summarize_aged_tool_results(messages: &[Value], cfg: &PruneConfig) -> Vec<Value> {
202 let mut out = messages.to_vec();
203 let aged = cfg.aged_len(out.len());
204 let paired = pair_tool_results(&out);
205
206 for (i, msg) in out.iter_mut().enumerate().take(aged) {
207 if msg["role"].as_str() != Some("tool") {
208 continue;
209 }
210 let line = {
211 let Some(content) = msg["content"].as_str() else {
212 continue;
213 };
214 if content.chars().count() <= cfg.summarize_min_chars
215 || already_pruned(content, paired_name(&paired, i))
216 {
217 continue;
218 }
219 let (name, args) = match &paired[i] {
220 Some(p) => (p.name.as_str(), Some(&p.args)),
221 None => ("tool", None),
222 };
223 let line = one_line_summary(name, args, content);
224 if json_str_len(&line) >= json_str_len(content) {
225 continue;
226 }
227 line
228 };
229 msg["content"] = Value::String(line);
230 }
231 out
232}
233
234pub fn shrink_tool_call_args(messages: &[Value], cfg: &PruneConfig) -> Vec<Value> {
244 let mut out = messages.to_vec();
245 let aged = cfg.aged_len(out.len());
246 let cap = cfg.effective_arg_string_cap();
247
248 for msg in out.iter_mut().take(aged) {
249 if msg["role"].as_str() != Some("assistant") {
250 continue;
251 }
252 let Some(tcs) = msg.get_mut("tool_calls").and_then(Value::as_array_mut) else {
253 continue;
254 };
255 for tc in tcs {
256 let Some(arguments) = tc.get_mut("function").and_then(|f| f.get_mut("arguments"))
257 else {
258 continue;
259 };
260 shrink_arguments(arguments, cfg.args_max_chars, cap);
261 }
262 }
263 out
264}
265
266struct PairedCall {
273 name: String,
274 args: Value,
275}
276
277fn pair_tool_results(messages: &[Value]) -> Vec<Option<PairedCall>> {
282 let mut paired = Vec::with_capacity(messages.len());
283 let mut pending: Vec<(String, PairedCall)> = Vec::new();
284
285 for msg in messages {
286 let role = msg["role"].as_str().unwrap_or("");
287 if role == "tool" {
288 let id = msg["tool_call_id"].as_str().unwrap_or("");
289 let pos = if id.is_empty() {
290 (!pending.is_empty()).then_some(0)
291 } else {
292 pending.iter().position(|(pid, _)| pid == id)
293 };
294 paired.push(pos.map(|p| pending.remove(p).1));
295 continue;
296 }
297 pending.clear();
298 if role == "assistant" {
299 if let Some(tcs) = msg["tool_calls"].as_array() {
300 for tc in tcs {
301 let id = tc["id"].as_str().unwrap_or("").to_string();
302 let name = tc["function"]["name"]
303 .as_str()
304 .unwrap_or("tool")
305 .to_string();
306 let args = match &tc["function"]["arguments"] {
307 Value::String(s) => serde_json::from_str(s).unwrap_or(Value::Null),
308 v => v.clone(),
309 };
310 pending.push((id, PairedCall { name, args }));
311 }
312 }
313 }
314 paired.push(None);
315 }
316 paired
317}
318
319fn paired_name(paired: &[Option<PairedCall>], i: usize) -> &str {
320 paired[i].as_ref().map_or("tool", |p| p.name.as_str())
321}
322
323fn already_pruned(content: &str, paired_name: &str) -> bool {
326 content.starts_with("[duplicate of message ")
327 || content.starts_with(&format!("[{paired_name}] "))
328}
329
330fn one_line_summary(name: &str, args: Option<&Value>, content: &str) -> String {
332 let lines = content.lines().count();
333 let chars = content.chars().count();
334 let status = if looks_like_error(content) {
335 "error"
336 } else {
337 "ok"
338 };
339 let arg = |key: &str| -> String {
340 args.and_then(|a| a.get(key))
341 .and_then(Value::as_str)
342 .map_or_else(|| "?".to_string(), |s| excerpt(s, 80))
343 };
344 match name {
345 "run_command" => {
349 format!(
350 "[run_command] ran '{}' -> {status}, {lines} lines output",
351 arg("command")
352 )
353 }
354 "read_file" => {
355 format!(
356 "[read_file] read '{}' -> {status}, {lines} lines ({chars} chars)",
357 arg("path")
358 )
359 }
360 "write_file" => {
361 format!(
362 "[write_file] wrote '{}' -> {status}, {lines} lines result",
363 arg("path")
364 )
365 }
366 "edit_file" => {
367 format!(
368 "[edit_file] edited '{}' -> {status}, {lines} lines result",
369 arg("path")
370 )
371 }
372 "list_dir" => format!(
373 "[list_dir] listed '{}' -> {status}, {lines} entries",
374 arg("path")
375 ),
376 "search" => {
377 let q = args
378 .and_then(|a| a.get("query").or_else(|| a.get("pattern")))
379 .and_then(Value::as_str)
380 .map_or_else(|| "?".to_string(), |s| excerpt(s, 80));
381 format!("[search] searched '{q}' -> {status}, {lines} matching lines")
382 }
383 "web_fetch" => {
384 format!(
385 "[web_fetch] fetched '{}' -> {status}, {lines} lines ({chars} chars)",
386 arg("url")
387 )
388 }
389 _ => format!("[{name}] result elided -> {status}, {lines} lines ({chars} chars)"),
390 }
391}
392
393fn excerpt(s: &str, max_chars: usize) -> String {
395 let cleaned: String = s
396 .chars()
397 .map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
398 .collect();
399 if cleaned.chars().count() <= max_chars {
400 cleaned
401 } else {
402 let head: String = cleaned.chars().take(max_chars).collect();
403 format!("{head}…")
404 }
405}
406
407fn looks_like_error(content: &str) -> bool {
410 let t = content.trim_start();
411 t.starts_with("error") || t.starts_with("Error") || t.starts_with("capability denied")
412}
413
414fn shrink_arguments(arguments: &mut Value, max_chars: usize, cap: usize) {
419 match arguments {
420 Value::String(s) => {
421 if s.chars().count() <= max_chars {
422 return;
423 }
424 let shrunk = match serde_json::from_str::<Value>(s) {
425 Ok(mut v) => {
426 shrink_value(&mut v, cap);
427 v
428 }
429 Err(_) => {
430 let n = s.chars().count();
433 let mut ph = serde_json::json!({
434 "truncated": format!("original arguments were not valid JSON ({n} chars elided)"),
435 });
436 shrink_value(&mut ph, cap);
437 ph
438 }
439 };
440 let new = serde_json::to_string(&shrunk).unwrap_or_else(|_| "{}".to_string());
441 if json_str_len(&new) < json_str_len(s) {
442 *arguments = Value::String(new);
443 }
444 }
445 other => {
446 if json_len(other) <= max_chars {
447 return;
448 }
449 let mut v = other.clone();
450 if shrink_value(&mut v, cap) && json_len(&v) < json_len(other) {
451 *other = v;
452 }
453 }
454 }
455}
456
457fn shrink_value(v: &mut Value, cap: usize) -> bool {
460 match v {
461 Value::String(s) => match truncate_chars(s, cap) {
462 Some(t) => {
463 *s = t;
464 true
465 }
466 None => false,
467 },
468 Value::Array(items) => {
472 let mut changed = false;
473 for it in items.iter_mut() {
474 changed |= shrink_value(it, cap);
475 }
476 changed
477 }
478 Value::Object(map) => {
479 let mut changed = false;
480 for it in map.values_mut() {
481 changed |= shrink_value(it, cap);
482 }
483 changed
484 }
485 _ => false,
486 }
487}
488
489fn truncate_chars(s: &str, cap: usize) -> Option<String> {
495 let total = s.chars().count();
496 if total <= cap {
497 return None;
498 }
499 let marker_reserve = format!("… [+{total} chars]").chars().count();
500 let keep = cap.saturating_sub(marker_reserve);
501 let head: String = s.chars().take(keep).collect();
502 let omitted = total - keep;
503 Some(format!("{head}… [+{omitted} chars]"))
504}
505
506fn json_len(v: &Value) -> usize {
508 serde_json::to_string(v).map_or(0, |s| s.len())
509}
510
511fn json_str_len(s: &str) -> usize {
514 json_len(&Value::String(s.to_string()))
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520 use serde_json::json;
521
522 fn user(text: &str) -> Value {
525 json!({"role": "user", "content": text})
526 }
527
528 fn assistant_text(text: &str) -> Value {
529 json!({"role": "assistant", "content": text})
530 }
531
532 fn assistant_calls(calls: &[(&str, Value)]) -> Value {
534 let tcs: Vec<Value> = calls
535 .iter()
536 .map(|(name, args)| json!({"function": {"name": name, "arguments": args}}))
537 .collect();
538 json!({"role": "assistant", "content": "", "tool_calls": tcs})
539 }
540
541 fn assistant_calls_openai(calls: &[(&str, &str, Value)]) -> Value {
543 let tcs: Vec<Value> = calls
544 .iter()
545 .map(|(id, name, args)| {
546 json!({"id": id, "type": "function",
547 "function": {"name": name, "arguments": args.to_string()}})
548 })
549 .collect();
550 json!({"role": "assistant", "content": "", "tool_calls": tcs})
551 }
552
553 fn tool_result(content: &str) -> Value {
554 json!({"role": "tool", "content": content})
555 }
556
557 fn tool_result_id(id: &str, content: &str) -> Value {
558 json!({"role": "tool", "tool_call_id": id, "content": content})
559 }
560
561 fn text_lines(n: usize) -> String {
563 (0..n)
564 .map(|i| format!("test line {i:03}"))
565 .collect::<Vec<_>>()
566 .join("\n")
567 }
568
569 fn pad_tail(msgs: &mut Vec<Value>, n: usize) {
570 for i in 0..n {
571 msgs.push(user(&format!("tail filler {i}")));
572 }
573 }
574
575 fn content_of(msg: &Value) -> &str {
576 msg["content"].as_str().unwrap()
577 }
578
579 #[test]
582 fn dedupe_collapses_later_identical_aged_result() {
583 let big = text_lines(40); let mut msgs = vec![
585 user("task"),
586 assistant_calls(&[("run_command", json!({"command": "cargo test"}))]),
587 tool_result(&big),
588 assistant_calls(&[("run_command", json!({"command": "cargo test"}))]),
589 tool_result(&big),
590 ];
591 pad_tail(&mut msgs, 10);
592 let out = collapse_duplicate_tool_results(&msgs, &PruneConfig::default());
593 assert_eq!(content_of(&out[2]), big);
595 assert_eq!(
596 content_of(&out[4]),
597 format!(
598 "[duplicate of message 2: identical {}-char tool result elided]",
599 big.chars().count()
600 ),
601 );
602 }
603
604 #[test]
605 fn dedupe_ignores_short_results_and_non_tool_roles() {
606 let small = "short duplicate";
607 let big = text_lines(40);
608 let mut msgs = vec![
609 user(&big), tool_result(small),
611 tool_result(small),
612 user(&big),
613 ];
614 pad_tail(&mut msgs, 4);
615 let cfg = PruneConfig {
616 keep_last: 4,
617 ..PruneConfig::default()
618 };
619 let out = collapse_duplicate_tool_results(&msgs, &cfg);
620 assert_eq!(out, msgs);
621 }
622
623 #[test]
624 fn dedupe_never_touches_protected_tail() {
625 let big = text_lines(40);
626 let mut msgs = vec![user("task"), tool_result(&big)];
627 pad_tail(&mut msgs, 3);
628 msgs.push(tool_result(&big)); let cfg = PruneConfig {
630 keep_last: 2,
631 ..PruneConfig::default()
632 };
633 let out = collapse_duplicate_tool_results(&msgs, &cfg);
634 assert_eq!(content_of(out.last().unwrap()), big);
635 }
636
637 #[test]
638 fn dedupe_leaves_distinct_results_alone() {
639 let mut msgs = vec![
640 user("task"),
641 tool_result(&text_lines(40)),
642 tool_result(&text_lines(41)),
643 ];
644 pad_tail(&mut msgs, 10);
645 let out = collapse_duplicate_tool_results(&msgs, &PruneConfig::default());
646 assert_eq!(out, msgs);
647 }
648
649 fn summarize_one(name: &str, args: Value, content: &str) -> String {
654 let mut msgs = vec![
655 user("task"),
656 assistant_calls(&[(name, args)]),
657 tool_result(content),
658 ];
659 pad_tail(&mut msgs, 10);
660 let out = summarize_aged_tool_results(&msgs, &PruneConfig::default());
661 content_of(&out[2]).to_string()
662 }
663
664 #[test]
665 fn one_liner_run_command() {
666 let line = summarize_one(
667 "run_command",
668 json!({"command": "npm test"}),
669 &text_lines(47),
670 );
671 assert_eq!(line, "[run_command] ran 'npm test' -> ok, 47 lines output");
672 }
673
674 #[test]
675 fn one_liner_run_command_error() {
676 let content = format!("error: build failed\n{}", text_lines(30));
677 let line = summarize_one("run_command", json!({"command": "cargo build"}), &content);
678 assert_eq!(
679 line,
680 "[run_command] ran 'cargo build' -> error, 31 lines output"
681 );
682 }
683
684 #[test]
685 fn one_liner_never_replaces_with_something_longer() {
686 let cfg = PruneConfig {
690 summarize_min_chars: 4,
691 ..PruneConfig::default()
692 };
693 let mut msgs = vec![
694 user("task"),
695 assistant_calls(&[("run_command", json!({"command": "true"}))]),
696 tool_result("(exit 0)"),
697 ];
698 pad_tail(&mut msgs, 10);
699 let out = summarize_aged_tool_results(&msgs, &cfg);
700 assert_eq!(content_of(&out[2]), "(exit 0)");
701 }
702
703 #[test]
704 fn one_liner_read_file() {
705 let content = text_lines(120);
706 let chars = content.chars().count();
707 let line = summarize_one("read_file", json!({"path": "src/main.rs"}), &content);
708 assert_eq!(
709 line,
710 format!("[read_file] read 'src/main.rs' -> ok, 120 lines ({chars} chars)")
711 );
712 }
713
714 #[test]
715 fn one_liner_write_edit_list_search_fetch_and_generic() {
716 let content = text_lines(20);
717 let chars = content.chars().count();
718 assert_eq!(
719 summarize_one(
720 "write_file",
721 json!({"path": "a.rs", "content": "xx"}),
722 &content
723 ),
724 "[write_file] wrote 'a.rs' -> ok, 20 lines result",
725 );
726 assert_eq!(
727 summarize_one("edit_file", json!({"path": "b.rs"}), &content),
728 "[edit_file] edited 'b.rs' -> ok, 20 lines result",
729 );
730 assert_eq!(
731 summarize_one("list_dir", json!({"path": "src"}), &content),
732 "[list_dir] listed 'src' -> ok, 20 entries",
733 );
734 assert_eq!(
735 summarize_one("search", json!({"query": "fn main"}), &content),
736 "[search] searched 'fn main' -> ok, 20 matching lines",
737 );
738 assert_eq!(
739 summarize_one("search", json!({"pattern": "TODO"}), &content),
740 "[search] searched 'TODO' -> ok, 20 matching lines",
741 );
742 assert_eq!(
743 summarize_one(
744 "web_fetch",
745 json!({"url": "https://example.com/doc"}),
746 &content
747 ),
748 format!(
749 "[web_fetch] fetched 'https://example.com/doc' -> ok, 20 lines ({chars} chars)"
750 ),
751 );
752 assert_eq!(
753 summarize_one("gitea__issue_view", json!({"index": 1}), &content),
754 format!("[gitea__issue_view] result elided -> ok, 20 lines ({chars} chars)"),
755 );
756 }
757
758 #[test]
759 fn one_liner_missing_args_uses_placeholder() {
760 let line = summarize_one("read_file", json!(null), &text_lines(20));
761 assert!(
762 line.starts_with("[read_file] read '?' -> ok, 20 lines"),
763 "{line}"
764 );
765 }
766
767 #[test]
768 fn one_liner_long_command_excerpted_and_newlines_flattened() {
769 let cmd = format!("echo {}\nsecond", "x".repeat(100));
770 let line = summarize_one("run_command", json!({"command": cmd}), &text_lines(20));
771 assert!(!line.contains('\n'), "{line}");
772 assert!(line.contains('…'), "{line}");
773 assert!(
774 line.chars().count() < 200,
775 "one-liners stay under the default threshold"
776 );
777 }
778
779 #[test]
780 fn openai_results_pair_by_id_even_out_of_order() {
781 let read = text_lines(50);
782 let listing = text_lines(30);
783 let mut msgs = vec![
784 user("task"),
785 assistant_calls_openai(&[
786 ("call_a", "read_file", json!({"path": "x.rs"})),
787 ("call_b", "list_dir", json!({"path": "src"})),
788 ]),
789 tool_result_id("call_b", &listing),
791 tool_result_id("call_a", &read),
792 ];
793 pad_tail(&mut msgs, 10);
794 let out = summarize_aged_tool_results(&msgs, &PruneConfig::default());
795 assert!(
796 content_of(&out[2]).starts_with("[list_dir] listed 'src'"),
797 "{}",
798 content_of(&out[2])
799 );
800 assert!(
801 content_of(&out[3]).starts_with("[read_file] read 'x.rs'"),
802 "{}",
803 content_of(&out[3])
804 );
805 }
806
807 #[test]
808 fn ollama_results_pair_positionally() {
809 let mut msgs = vec![
810 user("task"),
811 assistant_calls(&[
812 ("read_file", json!({"path": "x.rs"})),
813 ("list_dir", json!({"path": "src"})),
814 ]),
815 tool_result(&text_lines(50)),
816 tool_result(&text_lines(30)),
817 ];
818 pad_tail(&mut msgs, 10);
819 let out = summarize_aged_tool_results(&msgs, &PruneConfig::default());
820 assert!(content_of(&out[2]).starts_with("[read_file] read 'x.rs'"));
821 assert!(content_of(&out[3]).starts_with("[list_dir] listed 'src'"));
822 }
823
824 #[test]
825 fn orphan_tool_result_gets_generic_one_liner() {
826 let content = text_lines(25);
827 let chars = content.chars().count();
828 let mut msgs = vec![user("task"), tool_result(&content)]; pad_tail(&mut msgs, 10);
830 let out = summarize_aged_tool_results(&msgs, &PruneConfig::default());
831 assert_eq!(
832 content_of(&out[1]),
833 format!("[tool] result elided -> ok, 25 lines ({chars} chars)"),
834 );
835 }
836
837 #[test]
838 fn summarize_protects_tail_and_skips_existing_markers() {
839 let big = text_lines(40);
840 let marker = "[read_file] read 'x' -> ok, 3 lines (5 chars)";
841 let mut msgs = vec![
842 user("task"),
843 assistant_calls(&[("read_file", json!({"path": "x"}))]),
844 json!({"role": "tool", "content": marker}),
845 ];
846 pad_tail(&mut msgs, 2);
847 msgs.push(tool_result(&big)); let cfg = PruneConfig {
849 keep_last: 1,
850 summarize_min_chars: 10,
851 ..PruneConfig::default()
852 };
853 let out = summarize_aged_tool_results(&msgs, &cfg);
854 assert_eq!(content_of(&out[2]), marker, "existing marker not rewritten");
855 assert_eq!(content_of(out.last().unwrap()), big, "tail untouched");
856 }
857
858 #[test]
861 fn shrink_truncates_inside_object_args() {
862 let body = "fn main() {}\n".repeat(200); let mut msgs = vec![
864 user("task"),
865 assistant_calls(&[(
866 "write_file",
867 json!({"path": "src/main.rs", "content": body}),
868 )]),
869 tool_result("ok"),
870 ];
871 pad_tail(&mut msgs, 10);
872 let out = shrink_tool_call_args(&msgs, &PruneConfig::default());
873 let args = &out[1]["tool_calls"][0]["function"]["arguments"];
874 assert!(args.is_object(), "object args stay objects");
875 assert_eq!(args["path"], "src/main.rs", "short values untouched");
876 let content = args["content"].as_str().unwrap();
877 assert!(content.chars().count() <= 200, "truncated to the cap");
878 assert!(content.contains("… [+"), "{content}");
879 assert!(content.ends_with("chars]"), "{content}");
880 }
881
882 #[test]
883 fn shrink_keeps_string_args_as_valid_json_strings() {
884 let args =
885 json!({"path": "a.rs", "old_string": "x".repeat(900), "new_string": "y".repeat(900)});
886 let mut msgs = vec![
887 user("task"),
888 assistant_calls_openai(&[("call_1", "edit_file", args)]),
889 tool_result_id("call_1", "ok"),
890 ];
891 pad_tail(&mut msgs, 10);
892 let before_len = msgs[1]["tool_calls"][0]["function"]["arguments"]
893 .as_str()
894 .unwrap()
895 .len();
896 let out = shrink_tool_call_args(&msgs, &PruneConfig::default());
897 let s = out[1]["tool_calls"][0]["function"]["arguments"]
898 .as_str()
899 .expect("still a string");
900 let parsed: Value = serde_json::from_str(s).expect("still valid JSON");
901 assert_eq!(parsed["path"], "a.rs");
902 assert!(parsed["old_string"].as_str().unwrap().chars().count() <= 200);
903 assert!(s.len() < before_len);
904 }
905
906 #[test]
907 fn shrink_recurses_into_arrays_and_nested_objects() {
908 let args = json!({"items": ["z".repeat(700), {"inner": "w".repeat(700)}], "n": 7});
909 let mut msgs = vec![
910 user("task"),
911 assistant_calls(&[("custom", args)]),
912 tool_result("ok"),
913 ];
914 pad_tail(&mut msgs, 10);
915 let out = shrink_tool_call_args(&msgs, &PruneConfig::default());
916 let a = &out[1]["tool_calls"][0]["function"]["arguments"];
917 assert!(a["items"][0].as_str().unwrap().chars().count() <= 200);
918 assert!(a["items"][1]["inner"].as_str().unwrap().chars().count() <= 200);
919 assert_eq!(a["n"], 7);
920 }
921
922 #[test]
923 fn shrink_replaces_unparseable_oversized_string_args() {
924 let bad = format!("{{not json {}", "x".repeat(800));
925 let mut msgs = vec![
926 user("task"),
927 json!({"role": "assistant", "content": "",
928 "tool_calls": [{"id": "c1", "type": "function",
929 "function": {"name": "custom", "arguments": bad}}]}),
930 tool_result_id("c1", "ok"),
931 ];
932 pad_tail(&mut msgs, 10);
933 let out = shrink_tool_call_args(&msgs, &PruneConfig::default());
934 let s = out[1]["tool_calls"][0]["function"]["arguments"]
935 .as_str()
936 .unwrap();
937 let parsed: Value = serde_json::from_str(s).expect("placeholder is valid JSON");
938 assert!(parsed["truncated"]
939 .as_str()
940 .unwrap()
941 .contains("not valid JSON"));
942 }
943
944 #[test]
945 fn shrink_leaves_small_args_and_tail_untouched() {
946 let small = json!({"path": "a.rs"});
947 let big = json!({"content": "q".repeat(2000)});
948 let mut msgs = vec![user("task"), assistant_calls(&[("read_file", small)])];
949 pad_tail(&mut msgs, 3);
950 msgs.push(assistant_calls(&[("write_file", big)])); let cfg = PruneConfig {
952 keep_last: 2,
953 ..PruneConfig::default()
954 };
955 let out = shrink_tool_call_args(&msgs, &cfg);
956 assert_eq!(out, msgs);
957 }
958
959 #[test]
960 fn shrink_cap_floor_prevents_marker_oscillation() {
961 let cfg = PruneConfig {
962 arg_string_cap: 0,
963 ..PruneConfig::default()
964 };
965 let mut msgs = vec![
966 user("task"),
967 assistant_calls(&[("write_file", json!({"content": "r".repeat(3000)}))]),
968 tool_result("ok"),
969 ];
970 pad_tail(&mut msgs, 10);
971 let once = shrink_tool_call_args(&msgs, &cfg);
972 let s = once[1]["tool_calls"][0]["function"]["arguments"]["content"]
973 .as_str()
974 .unwrap();
975 assert!(
976 s.chars().count() <= MIN_ARG_STRING_CAP,
977 "floored cap respected: {s}"
978 );
979 let twice = shrink_tool_call_args(&once, &cfg);
980 assert_eq!(twice, once);
981 }
982
983 #[test]
984 fn truncate_chars_is_exact_and_stable() {
985 assert_eq!(truncate_chars("short", 32), None);
986 let t = truncate_chars(&"é".repeat(100), 32).unwrap(); assert!(t.chars().count() <= 32);
988 assert_eq!(
989 truncate_chars(&t, 32),
990 None,
991 "second application is a no-op"
992 );
993 }
994
995 #[test]
998 fn prune_empty_and_all_tail_are_noops() {
999 let cfg = PruneConfig::default();
1000 let out = prune(&[], &cfg);
1001 assert!(out.messages.is_empty());
1002 assert_eq!(out.chars_reclaimed, 0);
1003
1004 let msgs = vec![user("task"), tool_result(&text_lines(100))];
1005 let out = prune(&msgs, &cfg); assert_eq!(out.messages, msgs);
1007 assert_eq!(out.chars_reclaimed, 0);
1008 }
1009
1010 #[test]
1014 fn realistic_transcript_pass_by_pass_breakdown() {
1015 let cargo_fail = format!("error: test failed\n{}", text_lines(300));
1016 let lib_rs = text_lines(600);
1017 let cargo_pass = text_lines(60);
1018 let new_body = "fn lossy_op() { /* generated */ }\n".repeat(120);
1019 let msgs = vec![
1020 json!({"role": "system", "content": "You are newt, a coding agent."}),
1021 user("fix the failing test in newt-core"),
1022 assistant_calls(&[("read_file", json!({"path": "newt-core/src/lib.rs"}))]),
1023 tool_result(&lib_rs),
1024 assistant_calls(&[("run_command", json!({"command": "cargo test -p newt-core"}))]),
1025 tool_result(&cargo_fail),
1026 assistant_calls(&[(
1027 "edit_file",
1028 json!({
1029 "path": "newt-core/src/lib.rs",
1030 "old_string": text_lines(60),
1031 "new_string": text_lines(62),
1032 }),
1033 )]),
1034 tool_result("edited newt-core/src/lib.rs (+2 lines)"),
1035 assistant_calls(&[("run_command", json!({"command": "cargo test -p newt-core"}))]),
1036 tool_result(&cargo_fail), assistant_calls(&[(
1038 "write_file",
1039 json!({"path": "newt-core/src/fix.rs", "content": new_body}),
1040 )]),
1041 tool_result("wrote newt-core/src/fix.rs"),
1042 assistant_calls(&[("run_command", json!({"command": "cargo test -p newt-core"}))]),
1043 tool_result(&cargo_pass),
1044 assistant_text("All tests pass now. The bug was an off-by-one."),
1045 user("great — open the PR"),
1046 ];
1047
1048 let cfg = PruneConfig {
1049 keep_last: 4,
1050 ..PruneConfig::default()
1051 };
1052 let s0 = serialized_len(&msgs);
1053 let p1 = collapse_duplicate_tool_results(&msgs, &cfg);
1054 let s1 = serialized_len(&p1);
1055 let p2 = summarize_aged_tool_results(&p1, &cfg);
1056 let s2 = serialized_len(&p2);
1057 let p3 = shrink_tool_call_args(&p2, &cfg);
1058 let s3 = serialized_len(&p3);
1059 println!("baseline: {s0} chars");
1060 println!("pass 1 (dedupe): -{} chars -> {s1}", s0 - s1);
1061 println!("pass 2 (one-liners): -{} chars -> {s2}", s1 - s2);
1062 println!("pass 3 (arg shrink): -{} chars -> {s3}", s2 - s3);
1063 assert!(s1 < s0, "dedupe reclaims");
1064 assert!(s2 < s1, "one-liners reclaim");
1065 assert!(s3 < s2, "arg shrink reclaims");
1066
1067 let outcome = prune(&msgs, &cfg);
1068 assert_eq!(outcome.messages, p3, "prune == the three passes in order");
1069 assert_eq!(
1070 outcome.chars_reclaimed,
1071 s0 - s3,
1072 "accounting matches the pass chain"
1073 );
1074 println!(
1075 "total: {} of {s0} chars reclaimed ({:.0}%)",
1076 outcome.chars_reclaimed,
1077 100.0 * outcome.chars_reclaimed as f64 / s0 as f64
1078 );
1079 assert_eq!(outcome.messages[15], msgs[15]);
1081 assert_eq!(outcome.messages[14], msgs[14]);
1082 }
1083
1084 struct Rng(u64);
1088
1089 impl Rng {
1090 fn next(&mut self) -> u64 {
1091 let mut x = self.0;
1092 x ^= x >> 12;
1093 x ^= x << 25;
1094 x ^= x >> 27;
1095 self.0 = x;
1096 x.wrapping_mul(0x2545_F491_4F6C_DD1D)
1097 }
1098
1099 fn below(&mut self, n: usize) -> usize {
1100 (self.next() % n as u64) as usize
1101 }
1102 }
1103
1104 fn synth_transcript(seed: u64) -> Vec<Value> {
1107 let mut rng = Rng(seed.max(1));
1108 let tools = [
1109 "run_command",
1110 "read_file",
1111 "write_file",
1112 "edit_file",
1113 "list_dir",
1114 "search",
1115 "web_fetch",
1116 "use_skill",
1117 ];
1118 let pool: Vec<String> = (0..5).map(|k| text_lines(10 + k * 37)).collect();
1120 let mut msgs = vec![
1121 json!({"role": "system", "content": "You are newt."}),
1122 user("do the task"),
1123 ];
1124 for round in 0..(4 + rng.below(5)) {
1125 if rng.below(4) == 0 {
1126 msgs.push(assistant_text("thinking out loud"));
1127 msgs.push(user(&format!("continue ({round})")));
1128 }
1129 let openai = rng.below(2) == 0;
1130 let ncalls = 1 + rng.below(3);
1131 let calls: Vec<(String, String, Value)> = (0..ncalls)
1132 .map(|j| {
1133 let name = tools[rng.below(tools.len())];
1134 let key = match name {
1135 "run_command" => "command",
1136 "search" => "query",
1137 "web_fetch" => "url",
1138 _ => "path",
1139 };
1140 let mut args = json!({key: format!("target-{}-{}", round, j)});
1141 if rng.below(3) == 0 {
1142 args["content"] = Value::String("b".repeat(50 + rng.below(2000)));
1143 }
1144 (format!("call_{round}_{j}"), name.to_string(), args)
1145 })
1146 .collect();
1147 if openai {
1148 let refs: Vec<(&str, &str, Value)> = calls
1149 .iter()
1150 .map(|(id, n, a)| (id.as_str(), n.as_str(), a.clone()))
1151 .collect();
1152 msgs.push(assistant_calls_openai(&refs));
1153 let mut order: Vec<usize> = (0..ncalls).collect();
1155 if ncalls > 1 && rng.below(2) == 0 {
1156 order.swap(0, 1);
1157 }
1158 for &j in &order {
1159 let content = if rng.below(2) == 0 {
1160 pool[rng.below(pool.len())].clone()
1161 } else {
1162 text_lines(1 + rng.below(80))
1163 };
1164 msgs.push(tool_result_id(&calls[j].0, &content));
1165 }
1166 } else {
1167 let refs: Vec<(&str, Value)> = calls
1168 .iter()
1169 .map(|(_, n, a)| (n.as_str(), a.clone()))
1170 .collect();
1171 msgs.push(assistant_calls(&refs));
1172 for _ in 0..ncalls {
1173 let content = if rng.below(2) == 0 {
1174 pool[rng.below(pool.len())].clone()
1175 } else {
1176 text_lines(1 + rng.below(80))
1177 };
1178 msgs.push(tool_result(&content));
1179 }
1180 }
1181 }
1182 msgs.push(assistant_text("done"));
1183 msgs.push(user("thanks"));
1184 msgs
1185 }
1186
1187 fn property_configs() -> Vec<PruneConfig> {
1188 vec![
1189 PruneConfig::default(),
1190 PruneConfig {
1191 keep_last: 4,
1192 ..PruneConfig::default()
1193 },
1194 PruneConfig {
1195 keep_last: 0,
1196 dedupe_min_chars: 50,
1197 summarize_min_chars: 50,
1198 args_max_chars: 100,
1199 arg_string_cap: 0,
1200 },
1201 ]
1202 }
1203
1204 #[test]
1205 fn property_output_tool_args_always_parse_and_keep_their_shape() {
1206 for seed in 1..=40 {
1207 let msgs = synth_transcript(seed);
1208 for cfg in property_configs() {
1209 let out = prune(&msgs, &cfg).messages;
1210 for (i, msg) in out.iter().enumerate() {
1211 let Some(tcs) = msg["tool_calls"].as_array() else {
1212 continue;
1213 };
1214 for (j, tc) in tcs.iter().enumerate() {
1215 let before = &msgs[i]["tool_calls"][j]["function"]["arguments"];
1216 let after = &tc["function"]["arguments"];
1217 match after {
1218 Value::String(s) => {
1219 assert!(
1220 before.is_string(),
1221 "seed {seed}: string args stay strings"
1222 );
1223 serde_json::from_str::<Value>(s).unwrap_or_else(|e| {
1224 panic!("seed {seed} msg {i} call {j}: invalid JSON ({e}): {s}")
1225 });
1226 }
1227 v => assert!(
1228 !before.is_string() && (v.is_object() || v.is_null()),
1229 "seed {seed}: structured args stay structured"
1230 ),
1231 }
1232 }
1233 }
1234 }
1235 }
1236 }
1237
1238 #[test]
1239 fn property_tool_pairing_structure_is_preserved() {
1240 for seed in 1..=40 {
1241 let msgs = synth_transcript(seed);
1242 for cfg in property_configs() {
1243 let out = prune(&msgs, &cfg).messages;
1244 assert_eq!(
1245 out.len(),
1246 msgs.len(),
1247 "seed {seed}: no messages added or removed"
1248 );
1249 for (a, b) in msgs.iter().zip(&out) {
1250 assert_eq!(a["role"], b["role"], "seed {seed}: roles preserved");
1251 assert_eq!(
1252 a["tool_call_id"], b["tool_call_id"],
1253 "seed {seed}: result ids preserved"
1254 );
1255 let (atc, btc) = (a["tool_calls"].as_array(), b["tool_calls"].as_array());
1256 assert_eq!(
1257 atc.map(Vec::len),
1258 btc.map(Vec::len),
1259 "seed {seed}: tool_call counts preserved"
1260 );
1261 if let (Some(atc), Some(btc)) = (atc, btc) {
1262 for (x, y) in atc.iter().zip(btc) {
1263 assert_eq!(x["id"], y["id"], "seed {seed}: call ids preserved");
1264 assert_eq!(
1265 x["function"]["name"], y["function"]["name"],
1266 "seed {seed}: call names preserved"
1267 );
1268 }
1269 }
1270 }
1271 }
1272 }
1273 }
1274
1275 #[test]
1276 fn property_prune_is_idempotent() {
1277 for seed in 1..=40 {
1278 let msgs = synth_transcript(seed);
1279 for cfg in property_configs() {
1280 let once = prune(&msgs, &cfg);
1281 let twice = prune(&once.messages, &cfg);
1282 assert_eq!(
1283 twice.messages, once.messages,
1284 "seed {seed}: prune(prune(x)) == prune(x)"
1285 );
1286 assert_eq!(
1287 twice.chars_reclaimed, 0,
1288 "seed {seed}: second pass reclaims nothing"
1289 );
1290 }
1291 }
1292 }
1293
1294 #[test]
1295 fn property_protected_tail_is_byte_identical() {
1296 for seed in 1..=40 {
1297 let msgs = synth_transcript(seed);
1298 for cfg in property_configs() {
1299 let out = prune(&msgs, &cfg).messages;
1300 let tail_start = msgs.len().saturating_sub(cfg.keep_last);
1301 for i in tail_start..msgs.len() {
1302 assert_eq!(
1303 serde_json::to_string(&msgs[i]).unwrap(),
1304 serde_json::to_string(&out[i]).unwrap(),
1305 "seed {seed}: tail message {i} must be byte-identical"
1306 );
1307 }
1308 }
1309 }
1310 }
1311
1312 #[test]
1313 fn property_chars_reclaimed_accounting_is_exact() {
1314 for seed in 1..=40 {
1315 let msgs = synth_transcript(seed);
1316 for cfg in property_configs() {
1317 let outcome = prune(&msgs, &cfg);
1318 let before = serialized_len(&msgs);
1319 let after = serialized_len(&outcome.messages);
1320 assert!(
1321 after <= before,
1322 "seed {seed}: prune never grows the transcript"
1323 );
1324 assert_eq!(
1325 outcome.chars_reclaimed,
1326 before - after,
1327 "seed {seed}: exact accounting"
1328 );
1329 }
1330 }
1331 }
1332}