syncable_cli/agent/tools/
truncation.rs1pub struct TruncationLimits {
8 pub max_file_lines: usize,
10 pub shell_prefix_lines: usize,
12 pub shell_suffix_lines: usize,
14 pub max_line_length: usize,
16 pub max_dir_entries: usize,
18 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, }
32 }
33}
34
35pub struct TruncatedJsonOutput {
37 pub content: String,
39 pub original_bytes: usize,
41 pub final_bytes: usize,
43 pub was_truncated: bool,
45}
46
47pub 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 let json: serde_json::Value = match serde_json::from_str(json_str) {
63 Ok(v) => v,
64 Err(_) => {
65 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 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
93fn 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 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 let priority_keys = [
130 "summary", "name", "type", "error", "message", "status", "total", "count", "path",
131 "severity", "issues", "findings",
132 ];
133
134 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 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 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
194pub struct TruncatedFileContent {
196 pub content: String,
198 pub total_lines: usize,
200 pub returned_lines: usize,
202 pub was_truncated: bool,
204 #[allow(dead_code)]
206 pub lines_char_truncated: usize,
207}
208
209pub 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 (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 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
256pub struct TruncatedShellOutput {
258 pub stdout: String,
260 pub stderr: String,
262 pub stdout_total_lines: usize,
264 pub stderr_total_lines: usize,
266 pub stdout_truncated: bool,
268 pub stderr_truncated: bool,
270}
271
272pub 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
303fn 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 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 let mut result = Vec::new();
325
326 for line in lines.iter().take(prefix_lines) {
328 result.push(truncate_line(line, max_line_length));
329 }
330
331 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 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
346fn 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
357pub struct TruncatedDirListing {
359 pub entries: Vec<serde_json::Value>,
361 pub total_entries: usize,
363 pub was_truncated: bool,
365}
366
367pub 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); 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}