Skip to main content

talos_session/
tool_compression.rs

1//! Threshold-based tool output compression (ADR-037, Mechanism A).
2//!
3//! When a tool's output exceeds a configured byte threshold, the content is
4//! summarized and the original is preserved in `raw_content` for later retrieval.
5//! Compression is applied BEFORE the entry is written to the session log.
6
7/// Result of applying threshold-based compression to a tool's output.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct ToolOutputCompression {
10    /// The content visible to the model (summarized or original).
11    pub model_content: String,
12    /// The original unmodified content, present only when compression occurred.
13    pub raw_content: Option<String>,
14    /// `0` = no compression applied, `1` = compressed.
15    pub raw_flag: u8,
16}
17
18/// Compress tool output based on a byte-length threshold.
19///
20/// When `content.len()` is at or below `threshold`, returns the content
21/// unchanged with `raw_flag = 0`.
22///
23/// When `content.len()` exceeds `threshold`, returns a summary consisting of
24/// the first `threshold` characters followed by a truncation notice, and stores
25/// the original content in `raw_content` with `raw_flag = 1`.
26pub fn compress_tool_output(content: &str, threshold: usize) -> ToolOutputCompression {
27    if content.is_empty() || content.len() <= threshold {
28        return ToolOutputCompression {
29            model_content: content.to_string(),
30            raw_content: None,
31            raw_flag: 0,
32        };
33    }
34
35    let total = content.len();
36    let prefix_end = content
37        .char_indices()
38        .map(|(index, _)| index)
39        .take_while(|index| *index <= threshold)
40        .last()
41        .unwrap_or(0);
42    let summary = format!(
43        "{}\n... [truncated, {total} bytes total]",
44        &content[..prefix_end]
45    );
46
47    ToolOutputCompression {
48        model_content: summary,
49        raw_content: Some(content.to_string()),
50        raw_flag: 1,
51    }
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57
58    #[test]
59    fn under_threshold_no_compression() {
60        let result = compress_tool_output("hello world", 100);
61        assert_eq!(result.model_content, "hello world");
62        assert_eq!(result.raw_content, None);
63        assert_eq!(result.raw_flag, 0);
64    }
65
66    #[test]
67    fn over_threshold_summary_and_raw() {
68        let content = "A".repeat(200);
69        let result = compress_tool_output(&content, 50);
70
71        assert_eq!(result.raw_flag, 1);
72        assert_eq!(result.raw_content, Some(content.clone()));
73        assert_eq!(
74            result.model_content,
75            format!("{}\n... [truncated, 200 bytes total]", "A".repeat(50))
76        );
77    }
78
79    #[test]
80    fn empty_content_no_compression() {
81        let result = compress_tool_output("", 100);
82        assert_eq!(result.model_content, "");
83        assert_eq!(result.raw_content, None);
84        assert_eq!(result.raw_flag, 0);
85    }
86
87    #[test]
88    fn exactly_at_threshold_no_compression() {
89        let content = "B".repeat(50);
90        let result = compress_tool_output(&content, 50);
91        assert_eq!(result.model_content, content);
92        assert_eq!(result.raw_content, None);
93        assert_eq!(result.raw_flag, 0);
94    }
95
96    #[test]
97    fn truncation_preserves_utf8_boundaries() {
98        let result = compress_tool_output("你好世界", 5);
99        assert_eq!(result.model_content, "你\n... [truncated, 12 bytes total]");
100        assert_eq!(result.raw_content.as_deref(), Some("你好世界"));
101    }
102}