1use async_trait::async_trait;
2use serde_json::{json, Value};
3use tokio::process::Command;
4use tokio::time::{timeout, Duration};
5
6use crate::types::{AgentTool, AgentToolResult};
7
8pub struct BashTool;
9
10#[async_trait]
11impl AgentTool for BashTool {
12 fn name(&self) -> &str {
13 "bash"
14 }
15 fn requires_permission(&self) -> bool {
16 true
17 }
18 fn description(&self) -> &str {
19 "Run a shell command via `bash -lc <cmd>`. Returns combined stdout/stderr and exit code."
20 }
21 fn parameters(&self) -> Value {
22 json!({
23 "type": "object",
24 "properties": {
25 "command": {"type": "string"},
26 "timeout_ms": {"type": "integer", "default": 120000}
27 },
28 "required": ["command"]
29 })
30 }
31 async fn execute(&self, _id: &str, args: Value) -> Result<AgentToolResult, String> {
32 let cmd = args
33 .get("command")
34 .and_then(|v| v.as_str())
35 .ok_or("missing 'command'")?;
36 let timeout_ms = args
37 .get("timeout_ms")
38 .and_then(|v| v.as_u64())
39 .unwrap_or(120_000);
40
41 let fut = Command::new("bash").arg("-lc").arg(cmd).output();
42 let output = match timeout(Duration::from_millis(timeout_ms), fut).await {
43 Ok(Ok(o)) => o,
44 Ok(Err(e)) => return Err(format!("spawn: {e}")),
45 Err(_) => return Err(format!("command timed out after {timeout_ms}ms")),
46 };
47 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
48 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
49 let code = output.status.code().unwrap_or(-1);
50 let mut combined = String::new();
51 if !stdout.is_empty() {
52 combined.push_str(&stdout);
53 }
54 if !stderr.is_empty() {
55 if !combined.is_empty() && !combined.ends_with('\n') {
56 combined.push('\n');
57 }
58 combined.push_str("[stderr]\n");
59 combined.push_str(&stderr);
60 }
61 combined.push_str(&format!("\n[exit {code}]"));
62 Ok(AgentToolResult::text(combined))
63 }
64}