syncable_cli/agent/tools/
truncation.rs

1//! Truncation utilities for tool outputs
2//!
3//! Limits the size of tool outputs to prevent context overflow.
4//! Based on Forge's approach: truncate proactively BEFORE sending to the LLM.
5
6/// Configuration for output truncation limits
7pub struct TruncationLimits {
8    /// Maximum lines to return from file reads (default: 2000)
9    pub max_file_lines: usize,
10    /// Lines to keep from start of shell output (default: 200)
11    pub shell_prefix_lines: usize,
12    /// Lines to keep from end of shell output (default: 200)
13    pub shell_suffix_lines: usize,
14    /// Maximum characters per line (default: 2000)
15    pub max_line_length: usize,
16    /// Maximum directory entries to return (default: 500)
17    pub max_dir_entries: usize,
18}
19
20impl Default for TruncationLimits {
21    fn default() -> Self {
22        Self {
23            max_file_lines: 2000,
24            shell_prefix_lines: 200,
25            shell_suffix_lines: 200,
26            max_line_length: 2000,
27            max_dir_entries: 500,
28        }
29    }
30}
31
32/// Result of truncating file content
33pub struct TruncatedFileContent {
34    /// The (possibly truncated) content
35    pub content: String,
36    /// Total lines in original file
37    pub total_lines: usize,
38    /// Lines actually returned
39    pub returned_lines: usize,
40    /// Whether content was truncated
41    pub was_truncated: bool,
42    /// Number of lines with truncated characters
43    pub lines_char_truncated: usize,
44}
45
46/// Truncate file content to max lines, with per-line character limit
47pub fn truncate_file_content(content: &str, limits: &TruncationLimits) -> TruncatedFileContent {
48    let lines: Vec<&str> = content.lines().collect();
49    let total_lines = lines.len();
50
51    let (selected_lines, was_truncated) = if total_lines <= limits.max_file_lines {
52        (lines.clone(), false)
53    } else {
54        // Take first max_file_lines lines
55        (lines[..limits.max_file_lines].to_vec(), true)
56    };
57
58    let mut lines_char_truncated = 0;
59    let processed: Vec<String> = selected_lines
60        .iter()
61        .map(|line| {
62            if line.chars().count() > limits.max_line_length {
63                lines_char_truncated += 1;
64                let truncated: String = line.chars().take(limits.max_line_length).collect();
65                let extra = line.chars().count() - limits.max_line_length;
66                format!("{}...[{} chars truncated]", truncated, extra)
67            } else {
68                line.to_string()
69            }
70        })
71        .collect();
72
73    let returned_lines = processed.len();
74    let mut result = processed.join("\n");
75
76    // Add truncation notice at the end
77    if was_truncated {
78        result.push_str(&format!(
79            "\n\n[OUTPUT TRUNCATED: Showing first {} of {} lines. Use start_line/end_line to read specific sections.]",
80            returned_lines, total_lines
81        ));
82    }
83
84    TruncatedFileContent {
85        content: result,
86        total_lines,
87        returned_lines,
88        was_truncated,
89        lines_char_truncated,
90    }
91}
92
93/// Result of truncating shell output
94pub struct TruncatedShellOutput {
95    /// The truncated stdout
96    pub stdout: String,
97    /// The truncated stderr
98    pub stderr: String,
99    /// Total stdout lines
100    pub stdout_total_lines: usize,
101    /// Total stderr lines
102    pub stderr_total_lines: usize,
103    /// Whether stdout was truncated
104    pub stdout_truncated: bool,
105    /// Whether stderr was truncated
106    pub stderr_truncated: bool,
107}
108
109/// Truncate shell output using prefix/suffix strategy
110/// Shows first N lines + last M lines, hiding the middle
111pub fn truncate_shell_output(
112    stdout: &str,
113    stderr: &str,
114    limits: &TruncationLimits,
115) -> TruncatedShellOutput {
116    let stdout_result = truncate_stream(
117        stdout,
118        limits.shell_prefix_lines,
119        limits.shell_suffix_lines,
120        limits.max_line_length,
121    );
122
123    let stderr_result = truncate_stream(
124        stderr,
125        limits.shell_prefix_lines,
126        limits.shell_suffix_lines,
127        limits.max_line_length,
128    );
129
130    TruncatedShellOutput {
131        stdout: stdout_result.0,
132        stderr: stderr_result.0,
133        stdout_total_lines: stdout_result.1,
134        stderr_total_lines: stderr_result.1,
135        stdout_truncated: stdout_result.2,
136        stderr_truncated: stderr_result.2,
137    }
138}
139
140/// Truncate a single stream (stdout or stderr) with prefix/suffix strategy
141fn truncate_stream(
142    content: &str,
143    prefix_lines: usize,
144    suffix_lines: usize,
145    max_line_length: usize,
146) -> (String, usize, bool) {
147    let lines: Vec<&str> = content.lines().collect();
148    let total_lines = lines.len();
149    let max_total = prefix_lines + suffix_lines;
150
151    if total_lines <= max_total {
152        // No truncation needed, just apply character limit
153        let processed: Vec<String> = lines
154            .iter()
155            .map(|line| truncate_line(line, max_line_length))
156            .collect();
157        return (processed.join("\n"), total_lines, false);
158    }
159
160    // Need truncation: take prefix + suffix
161    let mut result = Vec::new();
162
163    // Add prefix lines
164    for line in lines.iter().take(prefix_lines) {
165        result.push(truncate_line(line, max_line_length));
166    }
167
168    // Add truncation marker
169    let hidden = total_lines - prefix_lines - suffix_lines;
170    result.push(format!(
171        "\n... [{} lines hidden, showing first {} and last {} of {} total] ...\n",
172        hidden, prefix_lines, suffix_lines, total_lines
173    ));
174
175    // Add suffix lines
176    for line in lines.iter().skip(total_lines - suffix_lines) {
177        result.push(truncate_line(line, max_line_length));
178    }
179
180    (result.join("\n"), total_lines, true)
181}
182
183/// Truncate a single line if it exceeds max length
184fn truncate_line(line: &str, max_length: usize) -> String {
185    if line.chars().count() <= max_length {
186        line.to_string()
187    } else {
188        let truncated: String = line.chars().take(max_length).collect();
189        let extra = line.chars().count() - max_length;
190        format!("{}...[{} chars]", truncated, extra)
191    }
192}
193
194/// Result of truncating directory listing
195pub struct TruncatedDirListing {
196    /// The (possibly truncated) entries
197    pub entries: Vec<serde_json::Value>,
198    /// Total entries in directory
199    pub total_entries: usize,
200    /// Whether list was truncated
201    pub was_truncated: bool,
202}
203
204/// Truncate directory listing to max entries
205pub fn truncate_dir_listing(
206    entries: Vec<serde_json::Value>,
207    max_entries: usize,
208) -> TruncatedDirListing {
209    let total_entries = entries.len();
210
211    if total_entries <= max_entries {
212        TruncatedDirListing {
213            entries,
214            total_entries,
215            was_truncated: false,
216        }
217    } else {
218        TruncatedDirListing {
219            entries: entries.into_iter().take(max_entries).collect(),
220            total_entries,
221            was_truncated: true,
222        }
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn test_truncate_file_no_truncation_needed() {
232        let content = "line1\nline2\nline3";
233        let limits = TruncationLimits::default();
234        let result = truncate_file_content(content, &limits);
235
236        assert_eq!(result.total_lines, 3);
237        assert_eq!(result.returned_lines, 3);
238        assert!(!result.was_truncated);
239        assert_eq!(result.content, content);
240    }
241
242    #[test]
243    fn test_truncate_file_exceeds_limit() {
244        let lines: Vec<String> = (0..100).map(|i| format!("line {}", i)).collect();
245        let content = lines.join("\n");
246        let limits = TruncationLimits {
247            max_file_lines: 10,
248            ..Default::default()
249        };
250        let result = truncate_file_content(&content, &limits);
251
252        assert_eq!(result.total_lines, 100);
253        assert_eq!(result.returned_lines, 10);
254        assert!(result.was_truncated);
255        assert!(result.content.contains("[OUTPUT TRUNCATED"));
256    }
257
258    #[test]
259    fn test_truncate_shell_prefix_suffix() {
260        let lines: Vec<String> = (0..500).map(|i| format!("output line {}", i)).collect();
261        let stdout = lines.join("\n");
262        let limits = TruncationLimits {
263            shell_prefix_lines: 5,
264            shell_suffix_lines: 5,
265            ..Default::default()
266        };
267        let result = truncate_shell_output(&stdout, "", &limits);
268
269        assert_eq!(result.stdout_total_lines, 500);
270        assert!(result.stdout_truncated);
271        assert!(result.stdout.contains("output line 0"));
272        assert!(result.stdout.contains("output line 499"));
273        assert!(result.stdout.contains("lines hidden"));
274    }
275
276    #[test]
277    fn test_truncate_long_line() {
278        let long_line = "x".repeat(3000);
279        let result = truncate_line(&long_line, 100);
280
281        assert!(result.len() < 200); // Should be truncated
282        assert!(result.contains("chars]"));
283    }
284
285    #[test]
286    fn test_truncate_dir_listing() {
287        let entries: Vec<serde_json::Value> = (0..100)
288            .map(|i| serde_json::json!({"name": format!("file{}", i)}))
289            .collect();
290
291        let result = truncate_dir_listing(entries, 10);
292
293        assert_eq!(result.total_entries, 100);
294        assert_eq!(result.entries.len(), 10);
295        assert!(result.was_truncated);
296    }
297}