| 1 | //! Output truncation and summarization helpers for shell tools. |
| 2 | |
| 3 | /// Maximum output size before truncation (30KB like Claude Code). |
| 4 | const MAX_OUTPUT_SIZE: usize = 30_000; |
| 5 | /// Limits for summary strings in tool metadata. |
| 6 | const SUMMARY_MAX_LINES: usize = 3; |
| 7 | const SUMMARY_MAX_CHARS: usize = 240; |
| 8 | /// Maximum number of preserved high-signal lines extracted from the tail |
| 9 | /// when output is truncated (#242). Bounded so the preserved summary |
| 10 | /// itself can never blow up the context window. |
| 11 | const MAX_PRESERVED_SUMMARY_LINES: usize = 80; |
| 12 | |
| 13 | #[derive(Debug, Clone, Copy, Default)] |
| 14 | pub(crate) struct TruncationMeta { |
| 15 | pub(crate) original_len: usize, |
| 16 | pub(crate) omitted: usize, |
| 17 | pub(crate) truncated: bool, |
| 18 | } |
| 19 | |
| 20 | pub(crate) fn truncate_with_meta(output: &str) -> (String, TruncationMeta) { |
| 21 | let original_len = output.len(); |
| 22 | if original_len <= MAX_OUTPUT_SIZE { |
| 23 | return ( |
| 24 | output.to_string(), |
| 25 | TruncationMeta { |
| 26 | original_len, |
| 27 | omitted: 0, |
| 28 | truncated: false, |
| 29 | }, |
| 30 | ); |
| 31 | } |
| 32 | |
| 33 | let cut_index = char_boundary_at_or_before(output, MAX_OUTPUT_SIZE); |
| 34 | let head = &output[..cut_index]; |
| 35 | let tail = &output[cut_index..]; |
| 36 | let omitted = original_len.saturating_sub(cut_index); |
| 37 | let note = |
| 38 | format!("...\n\n[Output truncated at {MAX_OUTPUT_SIZE} bytes. {omitted} bytes omitted.]"); |
| 39 | |
| 40 | // Preserve high-signal summary lines from the tail (cargo test results, |
| 41 | // rustc errors, panics, completion markers). Without this the agent |
| 42 | // re-runs `cargo test | tail` repeatedly to find pass/fail (#242). |
| 43 | let mut combined = format!("{head}{note}"); |
| 44 | let preserved = collect_summary_lines(tail); |
| 45 | if !preserved.is_empty() { |
| 46 | combined.push_str("\n\n[Preserved summary lines from omitted tail]\n"); |
| 47 | combined.push_str(&preserved.join("\n")); |
| 48 | } |
| 49 | |
| 50 | ( |
| 51 | combined, |
| 52 | TruncationMeta { |
| 53 | original_len, |
| 54 | omitted, |
| 55 | truncated: true, |
| 56 | }, |
| 57 | ) |
| 58 | } |
| 59 | |
| 60 | /// Extract high-signal summary lines from a chunk of output that would |
| 61 | /// otherwise be discarded by truncation. Recognises Cargo/rustc output, |
| 62 | /// generic test framework summaries, panic markers, exit-status lines, |
| 63 | /// and `Finished`/`running ...` markers. Returns at most |
| 64 | /// `MAX_PRESERVED_SUMMARY_LINES` lines, oldest-first within each match |
| 65 | /// class so the most actionable signal is at the end. |
| 66 | pub(crate) fn collect_summary_lines(text: &str) -> Vec<String> { |
| 67 | let mut preserved: Vec<String> = Vec::new(); |
| 68 | for line in text.lines() { |
| 69 | if preserved.len() >= MAX_PRESERVED_SUMMARY_LINES { |
| 70 | break; |
| 71 | } |
| 72 | if is_summary_line(line) { |
| 73 | preserved.push(line.to_string()); |
| 74 | } |
| 75 | } |
| 76 | preserved |
| 77 | } |
| 78 | |
| 79 | /// Heuristics for "this line is worth preserving even when most of the |
| 80 | /// output is dropped." Tuned for Cargo/rustc and generic test runner |
| 81 | /// vocabulary. Intentionally conservative: false positives only cost a |
| 82 | /// handful of bytes; false negatives force the agent to re-run gates. |
| 83 | fn is_summary_line(line: &str) -> bool { |
| 84 | let trimmed = line.trim_start(); |
| 85 | if trimmed.is_empty() { |
| 86 | return false; |
| 87 | } |
| 88 | // Cargo / rustc canonical markers. Note `trim_start` already stripped |
| 89 | // any leading whitespace, so match the bare word — the indentation |
| 90 | // Cargo prints (e.g. " Finished") would never reach this point. |
| 91 | if trimmed.starts_with("test result:") |
| 92 | || trimmed.starts_with("failures:") |
| 93 | || trimmed.starts_with("FAILED") |
| 94 | || trimmed.starts_with("error[") |
| 95 | || trimmed.starts_with("error:") |
| 96 | || trimmed.starts_with("warning:") |
| 97 | || trimmed.starts_with("panicked at") |
| 98 | || trimmed.starts_with("note:") |
| 99 | || trimmed.starts_with("help:") |
| 100 | || trimmed.starts_with("Finished") |
| 101 | || trimmed.starts_with("Compiling") |
| 102 | || trimmed.starts_with("Building") |
| 103 | || trimmed.starts_with("Running") |
| 104 | || trimmed.starts_with("running ") |
| 105 | || trimmed.starts_with("Doc-tests") |
| 106 | || trimmed.starts_with("---- ") |
| 107 | { |
| 108 | return true; |
| 109 | } |
| 110 | // Generic test runner vocabulary. |
| 111 | if trimmed.contains("PASS") || trimmed.contains("FAIL") || trimmed.contains("ASSERT") { |
| 112 | return true; |
| 113 | } |
| 114 | // Process-level signal lines. |
| 115 | if trimmed.starts_with("Killed") |
| 116 | || trimmed.starts_with("Aborted") |
| 117 | || trimmed.starts_with("Segmentation fault") |
| 118 | || trimmed.starts_with("Error:") |
| 119 | || trimmed.starts_with("exit status") |
| 120 | || trimmed.starts_with("exit code") |
| 121 | { |
| 122 | return true; |
| 123 | } |
| 124 | // `test some::name ... ok|FAILED|ignored` is the per-test result line in |
| 125 | // libtest. Cheap to match and useful for pinpointing the failing case. |
| 126 | if trimmed.starts_with("test ") && (trimmed.ends_with("FAILED") || trimmed.ends_with("ignored")) |
| 127 | { |
| 128 | return true; |
| 129 | } |
| 130 | false |
| 131 | } |
| 132 | |
| 133 | fn char_boundary_at_or_before(text: &str, max_bytes: usize) -> usize { |
| 134 | if max_bytes >= text.len() { |
| 135 | return text.len(); |
| 136 | } |
| 137 | |
| 138 | let mut last_end = 0usize; |
| 139 | for (idx, ch) in text.char_indices() { |
| 140 | let end = idx.saturating_add(ch.len_utf8()); |
| 141 | if end > max_bytes { |
| 142 | break; |
| 143 | } |
| 144 | last_end = end; |
| 145 | } |
| 146 | |
| 147 | last_end.min(text.len()) |
| 148 | } |
| 149 | |
| 150 | fn strip_truncation_note(text: &str) -> &str { |
| 151 | text.split_once("\n\n[Output truncated at") |
| 152 | .map_or(text, |(prefix, _)| prefix) |
| 153 | } |
| 154 | |
| 155 | fn truncate_chars(text: &str, max_chars: usize) -> String { |
| 156 | if text.chars().count() <= max_chars { |
| 157 | return text.to_string(); |
| 158 | } |
| 159 | |
| 160 | let mut end = text.len(); |
| 161 | for (count, (idx, _)) in text.char_indices().enumerate() { |
| 162 | if count == max_chars { |
| 163 | end = idx; |
| 164 | break; |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | format!("{}...", &text[..end]) |
| 169 | } |
| 170 | |
| 171 | pub(crate) fn summarize_output(text: &str) -> String { |
| 172 | let stripped = strip_truncation_note(text); |
| 173 | let summary = stripped |
| 174 | .lines() |
| 175 | .take(SUMMARY_MAX_LINES) |
| 176 | .collect::<Vec<_>>() |
| 177 | .join("\n") |
| 178 | .trim() |
| 179 | .to_string(); |
| 180 | |
| 181 | if summary.is_empty() { |
| 182 | String::new() |
| 183 | } else { |
| 184 | truncate_chars(&summary, SUMMARY_MAX_CHARS) |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | #[cfg(test)] |
| 189 | mod tests { |
| 190 | use super::*; |
| 191 | |
| 192 | #[test] |
| 193 | fn truncation_preserves_cargo_test_summary_lines_from_tail() { |
| 194 | let mut head = String::with_capacity(MAX_OUTPUT_SIZE + 4_000); |
| 195 | head.push_str("running 5 tests\n"); |
| 196 | for i in 0..3_000 { |
| 197 | head.push_str(&format!("test test::case_{i} ... ok\n")); |
| 198 | } |
| 199 | // Pad to force tail truncation |
| 200 | while head.len() < MAX_OUTPUT_SIZE { |
| 201 | head.push_str("...padding line below threshold...\n"); |
| 202 | } |
| 203 | head.push_str("\ntest result: ok. 1687 passed; 0 failed; 2 ignored\n"); |
| 204 | head.push_str(" Finished `dev` profile target(s) in 4.87s\n"); |
| 205 | |
| 206 | let (truncated, meta) = truncate_with_meta(&head); |
| 207 | assert!(meta.truncated, "expected truncation"); |
| 208 | assert!( |
| 209 | truncated.contains("test result: ok. 1687 passed"), |
| 210 | "summary line must be preserved\nGot: {}", |
| 211 | &truncated[truncated.len().saturating_sub(400)..] |
| 212 | ); |
| 213 | assert!( |
| 214 | truncated.contains("Finished"), |
| 215 | "Finished marker must be preserved" |
| 216 | ); |
| 217 | } |
| 218 | |
| 219 | #[test] |
| 220 | fn truncation_preserves_failure_lines_from_tail() { |
| 221 | let mut head = String::with_capacity(MAX_OUTPUT_SIZE + 1_000); |
| 222 | for _ in 0..MAX_OUTPUT_SIZE { |
| 223 | head.push('a'); |
| 224 | } |
| 225 | head.push_str("\nfailures:\n test::flaky_thing FAILED\n"); |
| 226 | head.push_str("test result: FAILED. 0 passed; 1 failed\n"); |
| 227 | |
| 228 | let (truncated, _meta) = truncate_with_meta(&head); |
| 229 | assert!(truncated.contains("failures:"), "must preserve failures:"); |
| 230 | assert!(truncated.contains("FAILED"), "must preserve FAILED"); |
| 231 | } |
| 232 | |
| 233 | #[test] |
| 234 | fn collect_summary_lines_skips_noise() { |
| 235 | let body = "\nblah blah\nrandom line\nokay\n\n"; |
| 236 | assert!(collect_summary_lines(body).is_empty()); |
| 237 | } |
| 238 | |
| 239 | #[test] |
| 240 | fn collect_summary_lines_picks_rustc_errors() { |
| 241 | let body = "\ |
| 242 | some preamble |
| 243 | error[E0277]: the trait `Foo` is not implemented for `Bar` |
| 244 | --> src/lib.rs:42:9 |
| 245 | warning: unused variable |
| 246 | note: see help |
| 247 | "; |
| 248 | let preserved = collect_summary_lines(body); |
| 249 | assert!(preserved.iter().any(|line| line.contains("error[E0277]"))); |
| 250 | assert!(preserved.iter().any(|line| line.contains("warning:"))); |
| 251 | } |
| 252 | } |
| 253 |