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