1use 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
21const BASH_MAX_LINES: usize = 50;
23
24const 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
61fn 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 if let Some(&'[') = chars.peek() {
70 chars.next(); 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 } 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 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 if let Some(ref tx) = partial_tx {
116 let _ = tx.send(output.content.clone());
117 }
118
119 let cleaned = strip_ansi(&output.content);
121
122 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 }
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 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}