Skip to main content

vtcode_core/tools/summarizers/
execution.rs

1//! Execution result summarization
2//!
3//! Summarizes bash and code execution outputs from full stdout/stderr
4//! into concise summaries suitable for LLM context.
5//!
6//! ## Strategy
7//!
8//! Instead of sending full command output (potentially megabytes),
9//! send essential information:
10//! - Command executed
11//! - Exit code (success/failure)
12//! - First N lines of output
13//! - Last N lines of output
14//! - Total output size indicator
15//!
16//! Target: ~150-250 tokens vs potentially thousands
17
18use super::{Summarizer, truncate_line, truncate_to_tokens};
19use anyhow::Result;
20use serde_json::Value;
21use std::collections::VecDeque;
22
23/// Summarizer for bash/shell execution results
24pub struct BashSummarizer {
25    /// Maximum lines to show from start of output
26    pub max_head_lines: usize,
27    /// Maximum lines to show from end of output
28    pub max_tail_lines: usize,
29    /// Maximum tokens for entire summary
30    pub max_tokens: usize,
31}
32
33impl Default for BashSummarizer {
34    fn default() -> Self {
35        Self {
36            max_head_lines: 20,
37            max_tail_lines: 10,
38            max_tokens: 500, // ~1000 chars for token efficiency
39        }
40    }
41}
42
43impl Summarizer for BashSummarizer {
44    fn summarize(&self, full_output: &str, metadata: Option<&Value>) -> Result<String> {
45        // Parse execution result
46        let result = parse_bash_output(full_output, metadata);
47
48        // Build summary
49        let mut summary = String::new();
50
51        // Command header
52        if let Some(cmd) = result.command {
53            summary.push_str(&format!("Command: {}\n", truncate_line(&cmd, 100)));
54        }
55
56        // Exit status
57        summary.push_str(&format!(
58            "Exit code: {} ({})\n",
59            result.exit_code,
60            if result.success { "success" } else { "failed" }
61        ));
62
63        // Execution time if available
64        if let Some(duration_ms) = result.duration_ms {
65            if duration_ms > 1000 {
66                summary.push_str(&format!("Duration: {:.1}s\n", duration_ms as f64 / 1000.0));
67            } else {
68                summary.push_str(&format!("Duration: {duration_ms}ms\n"));
69            }
70        }
71
72        // Output summary
73        if result.total_lines > 0 {
74            summary.push_str(&format!("\nOutput: {} lines", result.total_lines));
75
76            if result.total_bytes > 10_000 {
77                let kb = result.total_bytes / 1024;
78                summary.push_str(&format!(" ({kb} KB)"));
79            }
80
81            summary.push('\n');
82
83            // Head lines
84            if !result.head_lines.is_empty() {
85                summary.push_str("\nFirst lines:\n");
86                for line in &result.head_lines {
87                    summary.push_str(&truncate_line(line, 120));
88                    summary.push('\n');
89                }
90
91                if result.total_lines > self.max_head_lines {
92                    let omitted = result.total_lines.saturating_sub(self.max_head_lines + self.max_tail_lines);
93                    if omitted > 0 {
94                        summary.push_str(&format!("[...{omitted} more lines]\n"));
95                    }
96                }
97            }
98
99            // Tail lines for long output
100            if result.total_lines > self.max_head_lines + 1 && !result.tail_lines.is_empty() {
101                summary.push_str("\nLast lines:\n");
102                for line in &result.tail_lines {
103                    summary.push_str(&truncate_line(line, 120));
104                    summary.push('\n');
105                }
106            }
107        } else if !result.stderr.is_empty() {
108            // Show stderr if no stdout
109            summary.push_str("\nError output:\n");
110            for line in result.stderr.lines().take(self.max_head_lines) {
111                summary.push_str(&truncate_line(line, 120));
112                summary.push('\n');
113            }
114        } else {
115            summary.push_str("\n(No output)\n");
116        }
117
118        Ok(truncate_to_tokens(&summary, self.max_tokens))
119    }
120}
121
122/// Execution result statistics
123#[derive(Debug, Default)]
124struct BashResult {
125    command: Option<String>,
126    exit_code: i32,
127    success: bool,
128    duration_ms: Option<u64>,
129    total_lines: usize,
130    total_bytes: usize,
131    head_lines: Vec<String>,
132    tail_lines: Vec<String>,
133    stderr: String,
134}
135
136/// Parse bash execution output
137fn parse_bash_output(output: &str, metadata: Option<&Value>) -> BashResult {
138    let mut result = BashResult::default();
139
140    // Try to parse as JSON first (structured output from bash tool)
141    if let Ok(json) = serde_json::from_str::<Value>(output) {
142        result.command = json.get("command").and_then(|c| c.as_str()).map(|s| s.to_string());
143
144        result.exit_code = json
145            .get("exit_code")
146            .or_else(|| json.get("exitCode"))
147            .and_then(|e| e.as_i64())
148            .unwrap_or(0) as i32;
149
150        result.success = json.get("success").and_then(|s| s.as_bool()).unwrap_or(result.exit_code == 0);
151
152        result.duration_ms = json
153            .get("duration_ms")
154            .or_else(|| json.get("durationMs"))
155            .and_then(|d| d.as_u64());
156
157        // Extract stdout
158        if let Some(stdout) = json.get("stdout").or_else(|| json.get("output")) {
159            let stdout_str = if let Some(s) = stdout.as_str() {
160                s
161            } else {
162                &serde_json::to_string_pretty(stdout).unwrap_or_default()
163            };
164
165            parse_output_lines(stdout_str, &mut result);
166        }
167
168        // Extract stderr
169        if let Some(stderr) = json.get("stderr").or_else(|| json.get("error")) {
170            result.stderr = stderr.as_str().unwrap_or("").to_string();
171        }
172    } else {
173        // Fallback: treat entire output as stdout
174        parse_output_lines(output, &mut result);
175        result.success = !output.to_lowercase().contains("error");
176
177        // Extract command from metadata if available
178        if let Some(meta) = metadata {
179            result.command = meta.get("command").and_then(|c| c.as_str()).map(|s| s.to_string());
180        }
181    }
182
183    result
184}
185
186/// Parse output text into head/tail lines
187fn parse_output_lines(output: &str, result: &mut BashResult) {
188    const HEAD_LINES: usize = 5;
189    const TAIL_LINES: usize = 3;
190
191    result.total_lines = 0;
192    result.total_bytes = output.len();
193    result.head_lines.clear();
194    result.tail_lines.clear();
195
196    let mut tail: VecDeque<String> = VecDeque::with_capacity(TAIL_LINES);
197    for line in output.lines() {
198        result.total_lines += 1;
199        if result.head_lines.len() < HEAD_LINES {
200            result.head_lines.push(line.to_string());
201        }
202
203        if TAIL_LINES > 0 {
204            if tail.len() == TAIL_LINES {
205                tail.pop_front();
206            }
207            tail.push_back(line.to_string());
208        }
209    }
210
211    // Tail lines (last 3) if output is long.
212    if result.total_lines > HEAD_LINES + TAIL_LINES {
213        result.tail_lines = tail.into_iter().collect();
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn test_bash_summarizer_json_success() {
223        let full_output = r#"{
224            "command": "ls -la /tmp",
225            "exit_code": 0,
226            "success": true,
227            "duration_ms": 42,
228            "stdout": "total 100\ndrwx------  5 user  wheel  160 Dec 21 10:30 .\ndrwxr-xr-x  6 root  wheel  192 Dec 20 08:00 ..\n-rw-r--r--  1 user  wheel  512 Dec 21 10:30 file.txt"
229        }"#;
230
231        let summarizer = BashSummarizer::default();
232        let summary = summarizer.summarize(full_output, None).unwrap();
233
234        assert!(summary.contains("Command: ls -la /tmp"));
235        assert!(summary.contains("Exit code: 0 (success)"));
236        assert!(summary.contains("Duration: 42ms"));
237        assert!(summary.contains("Output: 4 lines"));
238        assert!(summary.contains("total 100"));
239
240        // Verify some savings (small test input has lower percentage with BPE tokenizer)
241        let savings = summarizer.estimate_savings(full_output, &summary);
242        assert!(savings.savings_percent > 10.0, "Should save >10% (got {:.1}%)", savings.savings_percent);
243    }
244
245    #[test]
246    fn test_bash_summarizer_json_failure() {
247        let full_output = r#"{
248            "command": "cat nonexistent.txt",
249            "exit_code": 1,
250            "success": false,
251            "stderr": "cat: nonexistent.txt: No such file or directory"
252        }"#;
253
254        let summarizer = BashSummarizer::default();
255        let summary = summarizer.summarize(full_output, None).unwrap();
256
257        assert!(summary.contains("Exit code: 1 (failed)"));
258        assert!(summary.contains("cat nonexistent.txt"));
259        assert!(summary.contains("Error output:") || summary.contains("No such file"));
260    }
261
262    #[test]
263    fn test_bash_summarizer_large_output() {
264        let mut lines = Vec::new();
265        for i in 1..=100 {
266            lines.push(format!("Line {i}: Some output here"));
267        }
268        let stdout = lines.join("\n");
269
270        let full_output = serde_json::json!({
271            "command": "generate_output",
272            "exit_code": 0,
273            "success": true,
274            "stdout": stdout
275        })
276        .to_string();
277
278        let summarizer = BashSummarizer::default();
279        let summary = summarizer.summarize(&full_output, None).unwrap();
280
281        assert!(summary.contains("Output: 100 lines"));
282        assert!(summary.contains("Line 1:"));
283        assert!(summary.contains("more lines"));
284
285        // Should show significant savings on large output
286        let savings = summarizer.estimate_savings(&full_output, &summary);
287        assert!(
288            savings.savings_percent > 70.0,
289            "Should save >70% on large output (got {:.1}%)",
290            savings.savings_percent
291        );
292    }
293
294    #[test]
295    fn test_bash_summarizer_plain_text() {
296        let full_output = "Hello World\nLine 2\nLine 3";
297
298        let summarizer = BashSummarizer::default();
299        let summary = summarizer.summarize(full_output, None).unwrap();
300
301        assert!(summary.contains("Output: 3 lines"));
302        assert!(summary.contains("Hello World"));
303    }
304
305    #[test]
306    fn test_bash_summarizer_with_metadata() {
307        let full_output = "Command output here";
308        let metadata = serde_json::json!({
309            "command": "echo 'test'"
310        });
311
312        let summarizer = BashSummarizer::default();
313        let summary = summarizer.summarize(full_output, Some(&metadata)).unwrap();
314
315        assert!(summary.contains("echo 'test'"));
316    }
317
318    #[test]
319    fn test_truncate_command() {
320        let long_cmd = "a".repeat(200);
321        let truncated = truncate_line(&long_cmd, 50);
322
323        assert!(truncated.len() <= 50);
324        assert!(truncated.ends_with("..."));
325    }
326
327    #[test]
328    fn truncate_line_rounds_down_to_char_boundary_and_borrows_short_input() {
329        // Budget of 6 bytes keeps 3 before "..."; byte 3 lands mid-`日` (bytes 2..5).
330        assert_eq!(truncate_line("AB日本語", 6), "AB...");
331        assert!(matches!(truncate_line("short", 10), std::borrow::Cow::Borrowed("short")));
332        assert_eq!(truncate_line("abcdef", 5), "ab...");
333    }
334
335    #[test]
336    fn test_parse_bash_output_json() {
337        let output = r#"{"command": "test", "exit_code": 0, "stdout": "ok"}"#;
338        let result = parse_bash_output(output, None);
339
340        assert_eq!(result.command, Some("test".to_string()));
341        assert_eq!(result.exit_code, 0);
342        assert!(result.success);
343    }
344
345    #[test]
346    fn test_parse_output_lines() {
347        let mut result = BashResult::default();
348        let output = "Line 1\nLine 2\nLine 3";
349        parse_output_lines(output, &mut result);
350
351        assert_eq!(result.total_lines, 3);
352        assert_eq!(result.head_lines.len(), 3);
353        assert_eq!(result.head_lines[0], "Line 1");
354    }
355}