| 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 | /// Head bytes preserved for large shell/test output. Qwen-style: head is |
| 6 | /// `threshold / 5` so the bulk of the budget stays on the tail (compiler |
| 7 | /// summaries, test failures) without a second command. |
| 8 | const TRUNCATED_HEAD_BYTES: usize = MAX_OUTPUT_SIZE / 5; |
| 9 | const TRUNCATED_TAIL_BYTES: usize = MAX_OUTPUT_SIZE - TRUNCATED_HEAD_BYTES; |
| 10 | /// Limits for summary strings in tool metadata. |
| 11 | const SUMMARY_MAX_LINES: usize = 3; |
| 12 | const SUMMARY_MAX_CHARS: usize = 240; |
| 13 | /// Maximum number of preserved high-signal lines extracted from the tail |
| 14 | /// when output is truncated (#242). |
| 15 | const MAX_PRESERVED_SUMMARY_LINES: usize = 80; |
| 16 | /// Byte ceiling for the whole preserved-summary block, and the character |
| 17 | /// ceiling applied to each line before it is admitted. |
| 18 | /// |
| 19 | /// The line count alone does not bound the block: a single rustc `error:` or |
| 20 | /// `note:` line carrying a long inferred type, a minified bundler frame, or a |
| 21 | /// `--verbose` link command is routinely hundreds of kilobytes, and eighty of |
| 22 | /// them are unbounded in every way that matters to a context budget. These two |
| 23 | /// limits are what make the block small next to [`MAX_OUTPUT_SIZE`]. |
| 24 | const MAX_PRESERVED_SUMMARY_BYTES: usize = 4 * 1024; |
| 25 | const MAX_PRESERVED_SUMMARY_LINE_CHARS: usize = 400; |
| 26 | |
| 27 | #[derive(Debug, Clone, Copy, Default)] |
| 28 | pub(crate) struct TruncationMeta { |
| 29 | pub(crate) original_len: usize, |
| 30 | pub(crate) omitted: usize, |
| 31 | pub(crate) truncated: bool, |
| 32 | } |
| 33 | |
| 34 | pub(crate) fn truncate_with_meta(output: &str) -> (String, TruncationMeta) { |
| 35 | let original_len = output.len(); |
| 36 | if original_len <= MAX_OUTPUT_SIZE { |
| 37 | return ( |
| 38 | output.to_string(), |
| 39 | TruncationMeta { |
| 40 | original_len, |
| 41 | omitted: 0, |
| 42 | truncated: false, |
| 43 | }, |
| 44 | ); |
| 45 | } |
| 46 | |
| 47 | let head_end = char_boundary_at_or_before(output, TRUNCATED_HEAD_BYTES); |
| 48 | let tail_start = |
| 49 | char_boundary_at_or_after(output, original_len.saturating_sub(TRUNCATED_TAIL_BYTES)); |
| 50 | let head = &output[..head_end]; |
| 51 | let omitted_middle = &output[head_end..tail_start]; |
| 52 | let tail = &output[tail_start..]; |
| 53 | let omitted = omitted_middle.len(); |
| 54 | let note = format!( |
| 55 | "...\n\n[Output truncated: showing first {head_bytes} bytes and last {tail_bytes} bytes. {omitted} bytes omitted.]", |
| 56 | head_bytes = head.len(), |
| 57 | tail_bytes = tail.len(), |
| 58 | ); |
| 59 | |
| 60 | // Preserve high-signal summary lines from the omitted middle (cargo test |
| 61 | // results, rustc errors, panics, completion markers). The raw tail is |
| 62 | // already included below; these snippets keep earlier failures visible |
| 63 | // without re-running `cargo test | tail` repeatedly (#242/#1450). |
| 64 | let mut combined = format!("{head}{note}"); |
| 65 | let preserved = collect_summary_lines(omitted_middle); |
| 66 | if !preserved.is_empty() { |
| 67 | combined.push_str("\n\n[Preserved summary lines from omitted middle]\n"); |
| 68 | combined.push_str(&preserved.join("\n")); |
| 69 | } |
| 70 | combined.push_str("\n\n[Output tail]\n"); |
| 71 | combined.push_str(tail); |
| 72 | |
| 73 | ( |
| 74 | combined, |
| 75 | TruncationMeta { |
| 76 | original_len, |
| 77 | omitted, |
| 78 | truncated: true, |
| 79 | }, |
| 80 | ) |
| 81 | } |
| 82 | |
| 83 | /// Extract high-signal summary lines from a chunk of output that would |
| 84 | /// otherwise be discarded by truncation. Recognises Cargo/rustc output, |
| 85 | /// generic test framework summaries, panic markers, exit-status lines, |
| 86 | /// and `Finished`/`running ...` markers. Returns at most |
| 87 | /// `MAX_PRESERVED_SUMMARY_LINES` lines, oldest-first within each match |
| 88 | /// class so the most actionable signal is at the end. |
| 89 | /// |
| 90 | /// Each line is clipped to `MAX_PRESERVED_SUMMARY_LINE_CHARS` and the block |
| 91 | /// stops at `MAX_PRESERVED_SUMMARY_BYTES`. Both bounds are load-bearing: the |
| 92 | /// line count alone let one very wide `error:` line put the entire omitted |
| 93 | /// middle back into a result the caller had just bounded to |
| 94 | /// `MAX_OUTPUT_SIZE`. |
| 95 | pub(crate) fn collect_summary_lines(text: &str) -> Vec<String> { |
| 96 | let mut preserved: Vec<String> = Vec::new(); |
| 97 | let mut remaining = MAX_PRESERVED_SUMMARY_BYTES; |
| 98 | for line in text.lines() { |
| 99 | if preserved.len() >= MAX_PRESERVED_SUMMARY_LINES { |
| 100 | break; |
| 101 | } |
| 102 | if !is_summary_line(line) { |
| 103 | continue; |
| 104 | } |
| 105 | let clipped = truncate_chars(line, MAX_PRESERVED_SUMMARY_LINE_CHARS); |
| 106 | // `+ 1` for the newline `truncate_with_meta` joins these lines with. |
| 107 | let cost = clipped.len().saturating_add(1); |
| 108 | if cost > remaining { |
| 109 | break; |
| 110 | } |
| 111 | remaining -= cost; |
| 112 | preserved.push(clipped); |
| 113 | } |
| 114 | preserved |
| 115 | } |
| 116 | |
| 117 | /// Heuristics for "this line is worth preserving even when most of the |
| 118 | /// output is dropped." Tuned for Cargo/rustc and generic test runner |
| 119 | /// vocabulary. Intentionally conservative: false positives only cost a |
| 120 | /// handful of bytes; false negatives force the agent to re-run gates. |
| 121 | fn is_summary_line(line: &str) -> bool { |
| 122 | let trimmed = line.trim_start(); |
| 123 | if trimmed.is_empty() { |
| 124 | return false; |
| 125 | } |
| 126 | // Cargo / rustc canonical markers. Note `trim_start` already stripped |
| 127 | // any leading whitespace, so match the bare word — the indentation |
| 128 | // Cargo prints (e.g. " Finished") would never reach this point. |
| 129 | if trimmed.starts_with("test result:") |
| 130 | || trimmed.starts_with("failures:") |
| 131 | || trimmed.starts_with("FAILED") |
| 132 | || trimmed.starts_with("error[") |
| 133 | || trimmed.starts_with("error:") |
| 134 | || trimmed.starts_with("warning:") |
| 135 | || trimmed.starts_with("panicked at") |
| 136 | || trimmed.starts_with("note:") |
| 137 | || trimmed.starts_with("help:") |
| 138 | || trimmed.starts_with("Finished") |
| 139 | || trimmed.starts_with("Compiling") |
| 140 | || trimmed.starts_with("Building") |
| 141 | || trimmed.starts_with("Running") |
| 142 | || trimmed.starts_with("running ") |
| 143 | || trimmed.starts_with("Doc-tests") |
| 144 | || trimmed.starts_with("---- ") |
| 145 | { |
| 146 | return true; |
| 147 | } |
| 148 | // Generic test runner vocabulary. |
| 149 | if trimmed.contains("PASS") || trimmed.contains("FAIL") || trimmed.contains("ASSERT") { |
| 150 | return true; |
| 151 | } |
| 152 | // Process-level signal lines. |
| 153 | if trimmed.starts_with("Killed") |
| 154 | || trimmed.starts_with("Aborted") |
| 155 | || trimmed.starts_with("Segmentation fault") |
| 156 | || trimmed.starts_with("Error:") |
| 157 | || trimmed.starts_with("exit status") |
| 158 | || trimmed.starts_with("exit code") |
| 159 | { |
| 160 | return true; |
| 161 | } |
| 162 | // `test some::name ... ok|FAILED|ignored` is the per-test result line in |
| 163 | // libtest. Cheap to match and useful for pinpointing the failing case. |
| 164 | if trimmed.starts_with("test ") && (trimmed.ends_with("FAILED") || trimmed.ends_with("ignored")) |
| 165 | { |
| 166 | return true; |
| 167 | } |
| 168 | false |
| 169 | } |
| 170 | |
| 171 | fn char_boundary_at_or_before(text: &str, max_bytes: usize) -> usize { |
| 172 | if max_bytes >= text.len() { |
| 173 | return text.len(); |
| 174 | } |
| 175 | |
| 176 | let mut last_end = 0usize; |
| 177 | for (idx, ch) in text.char_indices() { |
| 178 | let end = idx.saturating_add(ch.len_utf8()); |
| 179 | if end > max_bytes { |
| 180 | break; |
| 181 | } |
| 182 | last_end = end; |
| 183 | } |
| 184 | |
| 185 | last_end.min(text.len()) |
| 186 | } |
| 187 | |
| 188 | fn char_boundary_at_or_after(text: &str, min_bytes: usize) -> usize { |
| 189 | if min_bytes >= text.len() { |
| 190 | return text.len(); |
| 191 | } |
| 192 | if text.is_char_boundary(min_bytes) { |
| 193 | return min_bytes; |
| 194 | } |
| 195 | text.char_indices() |
| 196 | .map(|(idx, _)| idx) |
| 197 | .find(|&idx| idx > min_bytes) |
| 198 | .unwrap_or(text.len()) |
| 199 | } |
| 200 | |
| 201 | fn strip_truncation_note(text: &str) -> &str { |
| 202 | text.split_once("\n\n[Output truncated") |
| 203 | .map_or(text, |(prefix, _)| prefix) |
| 204 | } |
| 205 | |
| 206 | fn truncate_chars(text: &str, max_chars: usize) -> String { |
| 207 | if text.chars().count() <= max_chars { |
| 208 | return text.to_string(); |
| 209 | } |
| 210 | |
| 211 | let mut end = text.len(); |
| 212 | for (count, (idx, _)) in text.char_indices().enumerate() { |
| 213 | if count == max_chars { |
| 214 | end = idx; |
| 215 | break; |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | format!("{}...", &text[..end]) |
| 220 | } |
| 221 | |
| 222 | pub(crate) fn summarize_output(text: &str) -> String { |
| 223 | let stripped = strip_truncation_note(text); |
| 224 | let summary = stripped |
| 225 | .lines() |
| 226 | .take(SUMMARY_MAX_LINES) |
| 227 | .collect::<Vec<_>>() |
| 228 | .join("\n") |
| 229 | .trim() |
| 230 | .to_string(); |
| 231 | |
| 232 | if summary.is_empty() { |
| 233 | String::new() |
| 234 | } else { |
| 235 | truncate_chars(&summary, SUMMARY_MAX_CHARS) |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | #[cfg(test)] |
| 240 | mod tests { |
| 241 | use super::*; |
| 242 | |
| 243 | #[test] |
| 244 | fn truncation_preserves_cargo_test_summary_lines_from_tail() { |
| 245 | let mut head = String::with_capacity(MAX_OUTPUT_SIZE + 4_000); |
| 246 | head.push_str("running 5 tests\n"); |
| 247 | for i in 0..3_000 { |
| 248 | head.push_str(&format!("test test::case_{i} ... ok\n")); |
| 249 | } |
| 250 | // Pad to force tail truncation |
| 251 | while head.len() < MAX_OUTPUT_SIZE { |
| 252 | head.push_str("...padding line below threshold...\n"); |
| 253 | } |
| 254 | head.push_str("\ntest result: ok. 1687 passed; 0 failed; 2 ignored\n"); |
| 255 | head.push_str(" Finished `dev` profile target(s) in 4.87s\n"); |
| 256 | |
| 257 | let (truncated, meta) = truncate_with_meta(&head); |
| 258 | assert!(meta.truncated, "expected truncation"); |
| 259 | assert!( |
| 260 | truncated.contains("test result: ok. 1687 passed"), |
| 261 | "summary line must be preserved\nGot: {}", |
| 262 | &truncated[truncated.len().saturating_sub(400)..] |
| 263 | ); |
| 264 | assert!( |
| 265 | truncated.contains("Finished"), |
| 266 | "Finished marker must be preserved" |
| 267 | ); |
| 268 | } |
| 269 | |
| 270 | #[test] |
| 271 | fn truncation_preserves_failure_lines_from_tail() { |
| 272 | let mut head = String::with_capacity(MAX_OUTPUT_SIZE + 1_000); |
| 273 | for _ in 0..MAX_OUTPUT_SIZE { |
| 274 | head.push('a'); |
| 275 | } |
| 276 | head.push_str("\nfailures:\n test::flaky_thing FAILED\n"); |
| 277 | head.push_str("test result: FAILED. 0 passed; 1 failed\n"); |
| 278 | |
| 279 | let (truncated, _meta) = truncate_with_meta(&head); |
| 280 | assert!(truncated.contains("failures:"), "must preserve failures:"); |
| 281 | assert!(truncated.contains("FAILED"), "must preserve FAILED"); |
| 282 | } |
| 283 | |
| 284 | #[test] |
| 285 | fn truncation_includes_raw_tail_for_shell_output() { |
| 286 | let mut output = String::new(); |
| 287 | output.push_str("head-marker\n"); |
| 288 | output.push_str(&"middle noise\n".repeat(3_000)); |
| 289 | output.push_str("tail-marker: final compiler error\n"); |
| 290 | |
| 291 | let (truncated, meta) = truncate_with_meta(&output); |
| 292 | |
| 293 | assert!(meta.truncated, "expected truncation"); |
| 294 | assert!(truncated.contains("head-marker")); |
| 295 | assert!( |
| 296 | truncated.contains("[Output tail]"), |
| 297 | "tail section should be explicit: {truncated}" |
| 298 | ); |
| 299 | assert!( |
| 300 | truncated.contains("tail-marker: final compiler error"), |
| 301 | "raw tail must remain visible" |
| 302 | ); |
| 303 | } |
| 304 | |
| 305 | #[test] |
| 306 | fn preserved_summary_lines_are_bounded_in_bytes_not_only_in_count() { |
| 307 | // One rustc `error:` line can be megabytes wide (a long inferred type, |
| 308 | // a minified bundler frame, a `--verbose` link line). Landing in the |
| 309 | // omitted middle, it was re-inlined verbatim by the preserved-summary |
| 310 | // block, so the "30KB" truncation returned a 400KB tool result. |
| 311 | let mut output = String::from("head-marker\n"); |
| 312 | output.push_str(&"noise line\n".repeat(1_000)); |
| 313 | output.push_str(&format!("error: {}\n", "T".repeat(400_000))); |
| 314 | output.push_str(&"noise line\n".repeat(4_000)); |
| 315 | output.push_str("tail-marker\n"); |
| 316 | |
| 317 | let (truncated, meta) = truncate_with_meta(&output); |
| 318 | assert!(meta.truncated, "expected truncation"); |
| 319 | assert!( |
| 320 | truncated.contains("error: TTT"), |
| 321 | "the high-signal line must still be preserved" |
| 322 | ); |
| 323 | assert!( |
| 324 | truncated.len() <= MAX_OUTPUT_SIZE + 2 * MAX_PRESERVED_SUMMARY_BYTES, |
| 325 | "preserved summary blew the output budget: {} bytes", |
| 326 | truncated.len() |
| 327 | ); |
| 328 | } |
| 329 | |
| 330 | #[test] |
| 331 | fn collect_summary_lines_skips_noise() { |
| 332 | let body = "\nblah blah\nrandom line\nokay\n\n"; |
| 333 | assert!(collect_summary_lines(body).is_empty()); |
| 334 | } |
| 335 | |
| 336 | #[test] |
| 337 | fn collect_summary_lines_picks_rustc_errors() { |
| 338 | let body = "\ |
| 339 | some preamble |
| 340 | error[E0277]: the trait `Foo` is not implemented for `Bar` |
| 341 | --> src/lib.rs:42:9 |
| 342 | warning: unused variable |
| 343 | note: see help |
| 344 | "; |
| 345 | let preserved = collect_summary_lines(body); |
| 346 | assert!(preserved.iter().any(|line| line.contains("error[E0277]"))); |
| 347 | assert!(preserved.iter().any(|line| line.contains("warning:"))); |
| 348 | } |
| 349 | } |
| 350 |