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    /// Maximum JSON output size in bytes (default: 30KB)
19    pub max_json_bytes: usize,
20}
21
22impl Default for TruncationLimits {
23    fn default() -> Self {
24        Self {
25            max_file_lines: 2000,
26            shell_prefix_lines: 200,
27            shell_suffix_lines: 200,
28            max_line_length: 2000,
29            max_dir_entries: 500,
30            max_json_bytes: 30_000, // 30KB - safe for most LLM context windows
31        }
32    }
33}
34
35/// Result of truncating JSON output
36pub struct TruncatedJsonOutput {
37    /// The (possibly truncated) JSON string
38    pub content: String,
39    /// Original size in bytes
40    pub original_bytes: usize,
41    /// Final size in bytes
42    pub final_bytes: usize,
43    /// Whether output was truncated
44    pub was_truncated: bool,
45}
46
47/// Truncate JSON output to fit within context limits.
48/// Intelligently summarizes large arrays and nested objects.
49pub fn truncate_json_output(json_str: &str, max_bytes: usize) -> TruncatedJsonOutput {
50    let original_bytes = json_str.len();
51
52    if original_bytes <= max_bytes {
53        return TruncatedJsonOutput {
54            content: json_str.to_string(),
55            original_bytes,
56            final_bytes: original_bytes,
57            was_truncated: false,
58        };
59    }
60
61    // Parse as JSON to intelligently truncate
62    let json: serde_json::Value = match serde_json::from_str(json_str) {
63        Ok(v) => v,
64        Err(_) => {
65            // Not valid JSON, fall back to simple truncation
66            let truncated = &json_str[..max_bytes.saturating_sub(100)];
67            let content = format!(
68                "{}...\n\n[OUTPUT TRUNCATED: {} bytes → {} bytes. Original too large for context.]",
69                truncated, original_bytes, max_bytes
70            );
71            return TruncatedJsonOutput {
72                content: content.clone(),
73                original_bytes,
74                final_bytes: content.len(),
75                was_truncated: true,
76            };
77        }
78    };
79
80    // Truncate the JSON value
81    let truncated = truncate_json_value(&json, max_bytes);
82    let content = serde_json::to_string_pretty(&truncated).unwrap_or_else(|_| "{}".to_string());
83    let final_bytes = content.len();
84
85    TruncatedJsonOutput {
86        content,
87        original_bytes,
88        final_bytes,
89        was_truncated: true,
90    }
91}
92
93/// Recursively truncate a JSON value to reduce size
94fn truncate_json_value(value: &serde_json::Value, budget: usize) -> serde_json::Value {
95    use serde_json::{Value, json};
96
97    match value {
98        Value::Array(arr) => {
99            if arr.is_empty() {
100                return Value::Array(vec![]);
101            }
102
103            // Show first few items + summary
104            let max_items = 10.min(arr.len());
105            let mut result: Vec<Value> = arr
106                .iter()
107                .take(max_items)
108                .map(|v| truncate_json_value(v, budget / max_items.max(1)))
109                .collect();
110
111            if arr.len() > max_items {
112                result.push(json!({
113                    "_truncated": format!("... and {} more items (showing {}/{})",
114                        arr.len() - max_items, max_items, arr.len())
115                }));
116            }
117
118            Value::Array(result)
119        }
120        Value::Object(obj) => {
121            if obj.is_empty() {
122                return Value::Object(serde_json::Map::new());
123            }
124
125            let mut result = serde_json::Map::new();
126            let mut remaining_budget = budget;
127
128            // Priority keys to always include (truncated if needed)
129            let priority_keys = [
130                "summary", "name", "type", "error", "message", "status", "total", "count", "path",
131                "severity", "issues", "findings",
132            ];
133
134            // Add priority keys first
135            for key in &priority_keys {
136                if let Some(v) = obj.get(*key) {
137                    let truncated = truncate_json_value(v, remaining_budget / 4);
138                    let size = serde_json::to_string(&truncated)
139                        .map(|s| s.len())
140                        .unwrap_or(0);
141                    remaining_budget = remaining_budget.saturating_sub(size);
142                    result.insert(key.to_string(), truncated);
143                }
144            }
145
146            // Add other keys up to budget
147            let non_priority: Vec<_> = obj
148                .iter()
149                .filter(|(k, _)| !priority_keys.contains(&k.as_str()))
150                .collect();
151
152            let keys_to_add = 20.min(non_priority.len());
153            for (key, val) in non_priority.iter().take(keys_to_add) {
154                let truncated = truncate_json_value(val, remaining_budget / (keys_to_add.max(1)));
155                let size = serde_json::to_string(&truncated)
156                    .map(|s| s.len())
157                    .unwrap_or(0);
158                if size < remaining_budget {
159                    remaining_budget = remaining_budget.saturating_sub(size);
160                    result.insert(key.to_string(), truncated);
161                }
162            }
163
164            // Add truncation notice if keys were omitted
165            if non_priority.len() > keys_to_add {
166                result.insert(
167                    "_truncated_keys".to_string(),
168                    json!(format!(
169                        "{} keys omitted (showing {}/{})",
170                        non_priority.len() - keys_to_add,
171                        result.len(),
172                        obj.len()
173                    )),
174                );
175            }
176
177            Value::Object(result)
178        }
179        Value::String(s) => {
180            if s.len() > 1000 {
181                Value::String(format!(
182                    "{}... [truncated {} chars]",
183                    &s[..500],
184                    s.len() - 500
185                ))
186            } else {
187                value.clone()
188            }
189        }
190        _ => value.clone(),
191    }
192}
193
194/// Result of truncating file content
195pub struct TruncatedFileContent {
196    /// The (possibly truncated) content
197    pub content: String,
198    /// Total lines in original file
199    pub total_lines: usize,
200    /// Lines actually returned
201    pub returned_lines: usize,
202    /// Whether content was truncated
203    pub was_truncated: bool,
204    /// Number of lines with truncated characters
205    #[allow(dead_code)]
206    pub lines_char_truncated: usize,
207}
208
209/// Truncate file content to max lines, with per-line character limit
210pub fn truncate_file_content(content: &str, limits: &TruncationLimits) -> TruncatedFileContent {
211    let lines: Vec<&str> = content.lines().collect();
212    let total_lines = lines.len();
213
214    let (selected_lines, was_truncated) = if total_lines <= limits.max_file_lines {
215        (lines.clone(), false)
216    } else {
217        // Take first max_file_lines lines
218        (lines[..limits.max_file_lines].to_vec(), true)
219    };
220
221    let mut lines_char_truncated = 0;
222    let processed: Vec<String> = selected_lines
223        .iter()
224        .map(|line| {
225            if line.chars().count() > limits.max_line_length {
226                lines_char_truncated += 1;
227                let truncated: String = line.chars().take(limits.max_line_length).collect();
228                let extra = line.chars().count() - limits.max_line_length;
229                format!("{}...[{} chars truncated]", truncated, extra)
230            } else {
231                line.to_string()
232            }
233        })
234        .collect();
235
236    let returned_lines = processed.len();
237    let mut result = processed.join("\n");
238
239    // Add truncation notice at the end
240    if was_truncated {
241        result.push_str(&format!(
242            "\n\n[OUTPUT TRUNCATED: Showing first {} of {} lines. Use start_line/end_line to read specific sections.]",
243            returned_lines, total_lines
244        ));
245    }
246
247    TruncatedFileContent {
248        content: result,
249        total_lines,
250        returned_lines,
251        was_truncated,
252        lines_char_truncated,
253    }
254}
255
256/// Result of truncating shell output
257pub struct TruncatedShellOutput {
258    /// The truncated stdout
259    pub stdout: String,
260    /// The truncated stderr
261    pub stderr: String,
262    /// Total stdout lines
263    pub stdout_total_lines: usize,
264    /// Total stderr lines
265    pub stderr_total_lines: usize,
266    /// Whether stdout was truncated
267    pub stdout_truncated: bool,
268    /// Whether stderr was truncated
269    pub stderr_truncated: bool,
270}
271
272/// Truncate shell output using prefix/suffix strategy
273/// Shows first N lines + last M lines, hiding the middle
274pub fn truncate_shell_output(
275    stdout: &str,
276    stderr: &str,
277    limits: &TruncationLimits,
278) -> TruncatedShellOutput {
279    let stdout_result = truncate_stream(
280        stdout,
281        limits.shell_prefix_lines,
282        limits.shell_suffix_lines,
283        limits.max_line_length,
284    );
285
286    let stderr_result = truncate_stream(
287        stderr,
288        limits.shell_prefix_lines,
289        limits.shell_suffix_lines,
290        limits.max_line_length,
291    );
292
293    TruncatedShellOutput {
294        stdout: stdout_result.0,
295        stderr: stderr_result.0,
296        stdout_total_lines: stdout_result.1,
297        stderr_total_lines: stderr_result.1,
298        stdout_truncated: stdout_result.2,
299        stderr_truncated: stderr_result.2,
300    }
301}
302
303/// Truncate a single stream (stdout or stderr) with prefix/suffix strategy
304fn truncate_stream(
305    content: &str,
306    prefix_lines: usize,
307    suffix_lines: usize,
308    max_line_length: usize,
309) -> (String, usize, bool) {
310    let lines: Vec<&str> = content.lines().collect();
311    let total_lines = lines.len();
312    let max_total = prefix_lines + suffix_lines;
313
314    if total_lines <= max_total {
315        // No truncation needed, just apply character limit
316        let processed: Vec<String> = lines
317            .iter()
318            .map(|line| truncate_line(line, max_line_length))
319            .collect();
320        return (processed.join("\n"), total_lines, false);
321    }
322
323    // Need truncation: take prefix + suffix
324    let mut result = Vec::new();
325
326    // Add prefix lines
327    for line in lines.iter().take(prefix_lines) {
328        result.push(truncate_line(line, max_line_length));
329    }
330
331    // Add truncation marker
332    let hidden = total_lines - prefix_lines - suffix_lines;
333    result.push(format!(
334        "\n... [{} lines hidden, showing first {} and last {} of {} total] ...\n",
335        hidden, prefix_lines, suffix_lines, total_lines
336    ));
337
338    // Add suffix lines
339    for line in lines.iter().skip(total_lines - suffix_lines) {
340        result.push(truncate_line(line, max_line_length));
341    }
342
343    (result.join("\n"), total_lines, true)
344}
345
346/// Truncate a single line if it exceeds max length
347fn truncate_line(line: &str, max_length: usize) -> String {
348    if line.chars().count() <= max_length {
349        line.to_string()
350    } else {
351        let truncated: String = line.chars().take(max_length).collect();
352        let extra = line.chars().count() - max_length;
353        format!("{}...[{} chars]", truncated, extra)
354    }
355}
356
357/// Result of truncating directory listing
358pub struct TruncatedDirListing {
359    /// The (possibly truncated) entries
360    pub entries: Vec<serde_json::Value>,
361    /// Total entries in directory
362    pub total_entries: usize,
363    /// Whether list was truncated
364    pub was_truncated: bool,
365}
366
367/// Truncate directory listing to max entries
368pub fn truncate_dir_listing(
369    entries: Vec<serde_json::Value>,
370    max_entries: usize,
371) -> TruncatedDirListing {
372    let total_entries = entries.len();
373
374    if total_entries <= max_entries {
375        TruncatedDirListing {
376            entries,
377            total_entries,
378            was_truncated: false,
379        }
380    } else {
381        TruncatedDirListing {
382            entries: entries.into_iter().take(max_entries).collect(),
383            total_entries,
384            was_truncated: true,
385        }
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn test_truncate_file_no_truncation_needed() {
395        let content = "line1\nline2\nline3";
396        let limits = TruncationLimits::default();
397        let result = truncate_file_content(content, &limits);
398
399        assert_eq!(result.total_lines, 3);
400        assert_eq!(result.returned_lines, 3);
401        assert!(!result.was_truncated);
402        assert_eq!(result.content, content);
403    }
404
405    #[test]
406    fn test_truncate_file_exceeds_limit() {
407        let lines: Vec<String> = (0..100).map(|i| format!("line {}", i)).collect();
408        let content = lines.join("\n");
409        let limits = TruncationLimits {
410            max_file_lines: 10,
411            ..Default::default()
412        };
413        let result = truncate_file_content(&content, &limits);
414
415        assert_eq!(result.total_lines, 100);
416        assert_eq!(result.returned_lines, 10);
417        assert!(result.was_truncated);
418        assert!(result.content.contains("[OUTPUT TRUNCATED"));
419    }
420
421    #[test]
422    fn test_truncate_shell_prefix_suffix() {
423        let lines: Vec<String> = (0..500).map(|i| format!("output line {}", i)).collect();
424        let stdout = lines.join("\n");
425        let limits = TruncationLimits {
426            shell_prefix_lines: 5,
427            shell_suffix_lines: 5,
428            ..Default::default()
429        };
430        let result = truncate_shell_output(&stdout, "", &limits);
431
432        assert_eq!(result.stdout_total_lines, 500);
433        assert!(result.stdout_truncated);
434        assert!(result.stdout.contains("output line 0"));
435        assert!(result.stdout.contains("output line 499"));
436        assert!(result.stdout.contains("lines hidden"));
437    }
438
439    #[test]
440    fn test_truncate_long_line() {
441        let long_line = "x".repeat(3000);
442        let result = truncate_line(&long_line, 100);
443
444        assert!(result.len() < 200); // Should be truncated
445        assert!(result.contains("chars]"));
446    }
447
448    #[test]
449    fn test_truncate_dir_listing() {
450        let entries: Vec<serde_json::Value> = (0..100)
451            .map(|i| serde_json::json!({"name": format!("file{}", i)}))
452            .collect();
453
454        let result = truncate_dir_listing(entries, 10);
455
456        assert_eq!(result.total_entries, 100);
457        assert_eq!(result.entries.len(), 10);
458        assert!(result.was_truncated);
459    }
460}