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