Skip to main content

soul_coder/tools/
bash.rs

1//! Bash tool — execute shell commands with output truncation and timeout.
2//!
3//! Delegates to [`soul_core::executor::ShellExecutor`] for command execution,
4//! then applies ANSI stripping and tail truncation on top.
5
6use std::sync::Arc;
7
8use async_trait::async_trait;
9use serde_json::json;
10use tokio::sync::mpsc;
11
12use soul_core::error::SoulResult;
13use soul_core::executor::shell::ShellExecutor;
14use soul_core::executor::ToolExecutor;
15use soul_core::tool::{Tool, ToolOutput};
16use soul_core::types::ToolDefinition;
17use soul_core::vexec::VirtualExecutor;
18
19use crate::truncate::{truncate_tail, MAX_BYTES};
20
21/// Maximum lines kept from bash output (tail).
22const BASH_MAX_LINES: usize = 50;
23
24/// Default command timeout in seconds.
25const DEFAULT_TIMEOUT: u64 = 120;
26
27pub struct BashTool {
28    shell: ShellExecutor,
29    definition: ToolDefinition,
30}
31
32impl BashTool {
33    pub fn new(executor: Arc<dyn VirtualExecutor>, cwd: impl Into<String>) -> Self {
34        let shell = ShellExecutor::new(executor)
35            .with_timeout(DEFAULT_TIMEOUT)
36            .with_cwd(cwd);
37
38        let definition = ToolDefinition {
39            name: "bash".into(),
40            description: "Execute a shell command. Returns stdout and stderr. Output is truncated to the last 50 lines.".into(),
41            input_schema: json!({
42                "type": "object",
43                "properties": {
44                    "command": {
45                        "type": "string",
46                        "description": "The shell command to execute"
47                    },
48                    "timeout": {
49                        "type": "integer",
50                        "description": "Timeout in seconds (default: 120)"
51                    }
52                },
53                "required": ["command"]
54            }),
55        };
56
57        Self { shell, definition }
58    }
59}
60
61/// Strip ANSI escape codes from output.
62fn strip_ansi(input: &str) -> String {
63    let mut result = String::with_capacity(input.len());
64    let mut chars = input.chars().peekable();
65
66    while let Some(ch) = chars.next() {
67        if ch == '\x1b' {
68            // Skip escape sequence
69            if let Some(&'[') = chars.peek() {
70                chars.next(); // consume '['
71                // Consume until a letter
72                while let Some(&c) = chars.peek() {
73                    chars.next();
74                    if c.is_ascii_alphabetic() {
75                        break;
76                    }
77                }
78            }
79        } else if ch == '\r' {
80            // Skip carriage returns
81        } else {
82            result.push(ch);
83        }
84    }
85
86    result
87}
88
89#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
90#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
91impl Tool for BashTool {
92    fn name(&self) -> &str {
93        "bash"
94    }
95
96    fn definition(&self) -> ToolDefinition {
97        self.definition.clone()
98    }
99
100    async fn execute(
101        &self,
102        call_id: &str,
103        arguments: serde_json::Value,
104        partial_tx: Option<mpsc::UnboundedSender<String>>,
105    ) -> SoulResult<ToolOutput> {
106        // Delegate to ShellExecutor from soul-core
107        let result = self
108            .shell
109            .execute(&self.definition, call_id, arguments, partial_tx.clone())
110            .await;
111
112        match result {
113            Ok(output) => {
114                // Stream partial output if channel available
115                if let Some(ref tx) = partial_tx {
116                    let _ = tx.send(output.content.clone());
117                }
118
119                // Apply ANSI stripping
120                let cleaned = strip_ansi(&output.content);
121
122                // Apply tail truncation (errors/final output matter most)
123                let truncated = truncate_tail(&cleaned, BASH_MAX_LINES, MAX_BYTES);
124
125                let notice = truncated.truncation_notice();
126                let is_truncated = truncated.is_truncated();
127                let mut result_content = truncated.content;
128                if let Some(notice) = notice {
129                    result_content = format!("{}\n{}", notice, result_content);
130                }
131
132                let tool_output = if output.is_error {
133                    ToolOutput::error(result_content)
134                } else {
135                    ToolOutput::success(result_content)
136                };
137
138                Ok(tool_output.with_metadata(json!({
139                    "truncated": is_truncated,
140                })))
141            }
142            Err(e) => Ok(ToolOutput::error(format!("Command failed: {}", e))),
143        }
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use soul_core::vexec::{ExecOutput, MockExecutor};
151
152    fn setup_ok(stdout: &str) -> BashTool {
153        let executor = Arc::new(MockExecutor::always_ok(stdout));
154        BashTool::new(executor as Arc<dyn VirtualExecutor>, "/project")
155    }
156
157    fn setup_with(responses: Vec<ExecOutput>) -> BashTool {
158        let executor = Arc::new(MockExecutor::new(responses));
159        BashTool::new(executor as Arc<dyn VirtualExecutor>, "/project")
160    }
161
162    #[tokio::test]
163    async fn execute_simple_command() {
164        let tool = setup_ok("hello world\n");
165        let result = tool
166            .execute("c1", json!({"command": "echo hello world"}), None)
167            .await
168            .unwrap();
169
170        assert!(!result.is_error);
171        assert!(result.content.contains("hello world"));
172    }
173
174    #[tokio::test]
175    async fn execute_with_error_exit() {
176        let tool = setup_with(vec![ExecOutput {
177            stdout: String::new(),
178            stderr: "command not found".into(),
179            exit_code: 127,
180        }]);
181
182        let result = tool
183            .execute("c2", json!({"command": "nonexistent"}), None)
184            .await
185            .unwrap();
186
187        assert!(result.is_error);
188        assert!(result.content.contains("command not found"));
189    }
190
191    #[tokio::test]
192    async fn execute_empty_command() {
193        let tool = setup_ok("");
194        let _result = tool
195            .execute("c3", json!({"command": ""}), None)
196            .await
197            .unwrap();
198        // ShellExecutor delegates to MockExecutor which returns empty stdout
199        // The result should still be ok (empty output is not an error)
200        // Note: ShellExecutor requires the "command" key to exist, not be non-empty
201    }
202
203    #[tokio::test]
204    async fn strips_ansi() {
205        assert_eq!(strip_ansi("\x1b[31mred\x1b[0m"), "red");
206        assert_eq!(strip_ansi("no ansi"), "no ansi");
207        assert_eq!(strip_ansi("line\r\n"), "line\n");
208    }
209
210    #[tokio::test]
211    async fn stderr_included() {
212        let tool = setup_with(vec![ExecOutput {
213            stdout: "out\n".into(),
214            stderr: "warn\n".into(),
215            exit_code: 0,
216        }]);
217
218        let result = tool
219            .execute("c4", json!({"command": "test"}), None)
220            .await
221            .unwrap();
222
223        // ShellExecutor returns stdout on success (exit_code 0)
224        assert!(!result.is_error);
225        assert!(result.content.contains("out"));
226    }
227
228    #[tokio::test]
229    async fn streaming_output() {
230        let tool = setup_ok("streamed\n");
231        let (tx, mut rx) = mpsc::unbounded_channel();
232
233        let result = tool
234            .execute("c5", json!({"command": "echo streamed"}), Some(tx))
235            .await
236            .unwrap();
237
238        assert!(!result.is_error);
239        let partial = rx.recv().await.unwrap();
240        assert!(partial.contains("streamed"));
241    }
242
243    #[tokio::test]
244    async fn tool_name_and_definition() {
245        let tool = setup_ok("");
246        assert_eq!(tool.name(), "bash");
247        let def = tool.definition();
248        assert_eq!(def.name, "bash");
249        assert!(def.input_schema["required"].as_array().unwrap().contains(&json!("command")));
250    }
251}