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_command(&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: {}ms\n", duration_ms));
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
93                        .total_lines
94                        .saturating_sub(self.max_head_lines + self.max_tail_lines);
95                    if omitted > 0 {
96                        summary.push_str(&format!("[...{} more lines]\n", omitted));
97                    }
98                }
99            }
100
101            // Tail lines for long output
102            if result.total_lines > self.max_head_lines + 1 && !result.tail_lines.is_empty() {
103                summary.push_str("\nLast lines:\n");
104                for line in &result.tail_lines {
105                    summary.push_str(&truncate_line(line, 120));
106                    summary.push('\n');
107                }
108            }
109        } else if !result.stderr.is_empty() {
110            // Show stderr if no stdout
111            summary.push_str("\nError output:\n");
112            for line in result.stderr.lines().take(self.max_head_lines) {
113                summary.push_str(&truncate_line(line, 120));
114                summary.push('\n');
115            }
116        } else {
117            summary.push_str("\n(No output)\n");
118        }
119
120        Ok(truncate_to_tokens(&summary, self.max_tokens))
121    }
122}
123
124/// Execution result statistics
125#[derive(Debug, Default)]
126struct BashResult {
127    command: Option<String>,
128    exit_code: i32,
129    success: bool,
130    duration_ms: Option<u64>,
131    total_lines: usize,
132    total_bytes: usize,
133    head_lines: Vec<String>,
134    tail_lines: Vec<String>,
135    stderr: String,
136}
137
138/// Parse bash execution output
139fn parse_bash_output(output: &str, metadata: Option<&Value>) -> BashResult {
140    let mut result = BashResult::default();
141
142    // Try to parse as JSON first (structured output from bash tool)
143    if let Ok(json) = serde_json::from_str::<Value>(output) {
144        result.command = json
145            .get("command")
146            .and_then(|c| c.as_str())
147            .map(|s| s.to_string());
148
149        result.exit_code = json
150            .get("exit_code")
151            .or_else(|| json.get("exitCode"))
152            .and_then(|e| e.as_i64())
153            .unwrap_or(0) as i32;
154
155        result.success = json
156            .get("success")
157            .and_then(|s| s.as_bool())
158            .unwrap_or(result.exit_code == 0);
159
160        result.duration_ms = json
161            .get("duration_ms")
162            .or_else(|| json.get("durationMs"))
163            .and_then(|d| d.as_u64());
164
165        // Extract stdout
166        if let Some(stdout) = json.get("stdout").or_else(|| json.get("output")) {
167            let stdout_str = if let Some(s) = stdout.as_str() {
168                s
169            } else {
170                &serde_json::to_string_pretty(stdout).unwrap_or_default()
171            };
172
173            parse_output_lines(stdout_str, &mut result);
174        }
175
176        // Extract stderr
177        if let Some(stderr) = json.get("stderr").or_else(|| json.get("error")) {
178            result.stderr = stderr.as_str().unwrap_or("").to_string();
179        }
180    } else {
181        // Fallback: treat entire output as stdout
182        parse_output_lines(output, &mut result);
183        result.success = !output.to_lowercase().contains("error");
184
185        // Extract command from metadata if available
186        if let Some(meta) = metadata {
187            result.command = meta
188                .get("command")
189                .and_then(|c| c.as_str())
190                .map(|s| s.to_string());
191        }
192    }
193
194    result
195}
196
197/// Parse output text into head/tail lines
198fn parse_output_lines(output: &str, result: &mut BashResult) {
199    const HEAD_LINES: usize = 5;
200    const TAIL_LINES: usize = 3;
201
202    result.total_lines = 0;
203    result.total_bytes = output.len();
204    result.head_lines.clear();
205    result.tail_lines.clear();
206
207    let mut tail: VecDeque<String> = VecDeque::with_capacity(TAIL_LINES);
208    for line in output.lines() {
209        result.total_lines += 1;
210        if result.head_lines.len() < HEAD_LINES {
211            result.head_lines.push(line.to_string());
212        }
213
214        if TAIL_LINES > 0 {
215            if tail.len() == TAIL_LINES {
216                tail.pop_front();
217            }
218            tail.push_back(line.to_string());
219        }
220    }
221
222    // Tail lines (last 3) if output is long.
223    if result.total_lines > HEAD_LINES + TAIL_LINES {
224        result.tail_lines = tail.into_iter().collect();
225    }
226}
227
228/// Truncate command string to max length
229fn truncate_command(cmd: &str, max_len: usize) -> String {
230    if cmd.len() <= max_len {
231        cmd.to_string()
232    } else {
233        let target = max_len.saturating_sub(3);
234        let end = cmd
235            .char_indices()
236            .map(|(i, _)| i)
237            .rfind(|&i| i <= target)
238            .unwrap_or(0);
239        format!("{}...", &cmd[..end])
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn test_bash_summarizer_json_success() {
249        let full_output = r#"{
250            "command": "ls -la /tmp",
251            "exit_code": 0,
252            "success": true,
253            "duration_ms": 42,
254            "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"
255        }"#;
256
257        let summarizer = BashSummarizer::default();
258        let summary = summarizer.summarize(full_output, None).unwrap();
259
260        assert!(summary.contains("Command: ls -la /tmp"));
261        assert!(summary.contains("Exit code: 0 (success)"));
262        assert!(summary.contains("Duration: 42ms"));
263        assert!(summary.contains("Output: 4 lines"));
264        assert!(summary.contains("total 100"));
265
266        // Verify some savings (small test input has lower percentage)
267        let (_llm, _ui, pct) = summarizer.estimate_savings(full_output, &summary);
268        assert!(pct > 15.0, "Should save >15% (got {:.1}%)", pct);
269    }
270
271    #[test]
272    fn test_bash_summarizer_json_failure() {
273        let full_output = r#"{
274            "command": "cat nonexistent.txt",
275            "exit_code": 1,
276            "success": false,
277            "stderr": "cat: nonexistent.txt: No such file or directory"
278        }"#;
279
280        let summarizer = BashSummarizer::default();
281        let summary = summarizer.summarize(full_output, None).unwrap();
282
283        assert!(summary.contains("Exit code: 1 (failed)"));
284        assert!(summary.contains("cat nonexistent.txt"));
285        assert!(summary.contains("Error output:") || summary.contains("No such file"));
286    }
287
288    #[test]
289    fn test_bash_summarizer_large_output() {
290        let mut lines = Vec::new();
291        for i in 1..=100 {
292            lines.push(format!("Line {}: Some output here", i));
293        }
294        let stdout = lines.join("\n");
295
296        let full_output = serde_json::json!({
297            "command": "generate_output",
298            "exit_code": 0,
299            "success": true,
300            "stdout": stdout
301        })
302        .to_string();
303
304        let summarizer = BashSummarizer::default();
305        let summary = summarizer.summarize(&full_output, None).unwrap();
306
307        assert!(summary.contains("Output: 100 lines"));
308        assert!(summary.contains("Line 1:"));
309        assert!(summary.contains("more lines"));
310
311        // Should show significant savings on large output
312        let (_llm, _ui, pct) = summarizer.estimate_savings(&full_output, &summary);
313        assert!(
314            pct > 70.0,
315            "Should save >70% on large output (got {:.1}%)",
316            pct
317        );
318    }
319
320    #[test]
321    fn test_bash_summarizer_plain_text() {
322        let full_output = "Hello World\nLine 2\nLine 3";
323
324        let summarizer = BashSummarizer::default();
325        let summary = summarizer.summarize(full_output, None).unwrap();
326
327        assert!(summary.contains("Output: 3 lines"));
328        assert!(summary.contains("Hello World"));
329    }
330
331    #[test]
332    fn test_bash_summarizer_with_metadata() {
333        let full_output = "Command output here";
334        let metadata = serde_json::json!({
335            "command": "echo 'test'"
336        });
337
338        let summarizer = BashSummarizer::default();
339        let summary = summarizer.summarize(full_output, Some(&metadata)).unwrap();
340
341        assert!(summary.contains("echo 'test'"));
342    }
343
344    #[test]
345    fn test_truncate_command() {
346        let long_cmd = "a".repeat(200);
347        let truncated = truncate_command(&long_cmd, 50);
348
349        assert!(truncated.len() <= 50);
350        assert!(truncated.ends_with("..."));
351    }
352
353    #[test]
354    fn test_parse_bash_output_json() {
355        let output = r#"{"command": "test", "exit_code": 0, "stdout": "ok"}"#;
356        let result = parse_bash_output(output, None);
357
358        assert_eq!(result.command, Some("test".to_string()));
359        assert_eq!(result.exit_code, 0);
360        assert!(result.success);
361    }
362
363    #[test]
364    fn test_parse_output_lines() {
365        let mut result = BashResult::default();
366        let output = "Line 1\nLine 2\nLine 3";
367        parse_output_lines(output, &mut result);
368
369        assert_eq!(result.total_lines, 3);
370        assert_eq!(result.head_lines.len(), 3);
371        assert_eq!(result.head_lines[0], "Line 1");
372    }
373}