Skip to main content

codetether_agent/tool/
bash.rs

1//! Bash tool: execute shell commands
2
3use super::{Tool, ToolResult};
4use anyhow::Result;
5use async_trait::async_trait;
6use serde_json::{Value, json};
7use std::process::Stdio;
8use std::time::Instant;
9use tokio::process::Command;
10use tokio::time::{Duration, timeout};
11
12use crate::telemetry::{TOOL_EXECUTIONS, ToolExecution, record_persistent};
13
14/// Execute shell commands
15pub struct BashTool {
16    timeout_secs: u64,
17}
18
19impl BashTool {
20    pub fn new() -> Self {
21        Self { timeout_secs: 120 }
22    }
23
24    /// Create a new BashTool with a custom timeout
25    #[allow(dead_code)]
26    pub fn with_timeout(timeout_secs: u64) -> Self {
27        Self { timeout_secs }
28    }
29}
30
31#[async_trait]
32impl Tool for BashTool {
33    fn id(&self) -> &str {
34        "bash"
35    }
36
37    fn name(&self) -> &str {
38        "Bash"
39    }
40
41    fn description(&self) -> &str {
42        "bash(command: string, cwd?: string, timeout?: int) - Execute a shell command. Commands run in a bash shell with the current working directory."
43    }
44
45    fn parameters(&self) -> Value {
46        json!({
47            "type": "object",
48            "properties": {
49                "command": {
50                    "type": "string",
51                    "description": "The shell command to execute"
52                },
53                "cwd": {
54                    "type": "string",
55                    "description": "Working directory for the command (optional)"
56                },
57                "timeout": {
58                    "type": "integer",
59                    "description": "Timeout in seconds (default: 120)"
60                }
61            },
62            "required": ["command"],
63            "example": {
64                "command": "ls -la src/",
65                "cwd": "/path/to/project"
66            }
67        })
68    }
69
70    async fn execute(&self, args: Value) -> Result<ToolResult> {
71        let exec_start = Instant::now();
72
73        let command = match args["command"].as_str() {
74            Some(c) => c,
75            None => {
76                return Ok(ToolResult::structured_error(
77                    "INVALID_ARGUMENT",
78                    "bash",
79                    "command is required",
80                    Some(vec!["command"]),
81                    Some(json!({"command": "ls -la", "cwd": "."})),
82                ));
83            }
84        };
85        let cwd = args["cwd"].as_str();
86        let timeout_secs = args["timeout"].as_u64().unwrap_or(self.timeout_secs);
87
88        let mut cmd = Command::new("bash");
89        cmd.arg("-c")
90            .arg(command)
91            .stdout(Stdio::piped())
92            .stderr(Stdio::piped());
93
94        if let Some(dir) = cwd {
95            cmd.current_dir(dir);
96        }
97
98        let result = timeout(Duration::from_secs(timeout_secs), cmd.output()).await;
99
100        match result {
101            Ok(Ok(output)) => {
102                let stdout = String::from_utf8_lossy(&output.stdout);
103                let stderr = String::from_utf8_lossy(&output.stderr);
104                let exit_code = output.status.code().unwrap_or(-1);
105
106                let combined = if stderr.is_empty() {
107                    stdout.to_string()
108                } else if stdout.is_empty() {
109                    stderr.to_string()
110                } else {
111                    format!("{}\n--- stderr ---\n{}", stdout, stderr)
112                };
113
114                let success = output.status.success();
115
116                // Truncate if too long
117                let max_len = 50_000;
118                let (output_str, truncated) = if combined.len() > max_len {
119                    let truncated_output = format!(
120                        "{}...\n[Output truncated, {} bytes total]",
121                        &combined[..max_len],
122                        combined.len()
123                    );
124                    (truncated_output, true)
125                } else {
126                    (combined.clone(), false)
127                };
128
129                let duration = exec_start.elapsed();
130
131                // Record telemetry
132                let exec = ToolExecution::start(
133                    "bash",
134                    json!({
135                        "command": command,
136                        "cwd": cwd,
137                        "timeout": timeout_secs,
138                    }),
139                );
140                let exec = if success {
141                    exec.complete_success(
142                        format!("exit_code={}, output_len={}", exit_code, combined.len()),
143                        duration,
144                    )
145                } else {
146                    exec.complete_error(
147                        format!(
148                            "exit_code={}: {}",
149                            exit_code,
150                            combined.lines().next().unwrap_or("(no output)")
151                        ),
152                        duration,
153                    )
154                };
155                TOOL_EXECUTIONS.record(exec.clone());
156                record_persistent(exec);
157
158                Ok(ToolResult {
159                    output: output_str,
160                    success,
161                    metadata: [
162                        ("exit_code".to_string(), json!(exit_code)),
163                        ("truncated".to_string(), json!(truncated)),
164                    ]
165                    .into_iter()
166                    .collect(),
167                })
168            }
169            Ok(Err(e)) => {
170                let duration = exec_start.elapsed();
171                let exec = ToolExecution::start(
172                    "bash",
173                    json!({
174                        "command": command,
175                        "cwd": cwd,
176                    }),
177                )
178                .complete_error(format!("Failed to execute: {}", e), duration);
179                TOOL_EXECUTIONS.record(exec.clone());
180                record_persistent(exec);
181
182                Ok(ToolResult::structured_error(
183                    "EXECUTION_FAILED",
184                    "bash",
185                    &format!("Failed to execute command: {}", e),
186                    None,
187                    Some(json!({"command": command})),
188                ))
189            }
190            Err(_) => {
191                let duration = exec_start.elapsed();
192                let exec = ToolExecution::start(
193                    "bash",
194                    json!({
195                        "command": command,
196                        "cwd": cwd,
197                    }),
198                )
199                .complete_error(format!("Timeout after {}s", timeout_secs), duration);
200                TOOL_EXECUTIONS.record(exec.clone());
201                record_persistent(exec);
202
203                Ok(ToolResult::structured_error(
204                    "TIMEOUT",
205                    "bash",
206                    &format!("Command timed out after {} seconds", timeout_secs),
207                    None,
208                    Some(json!({
209                        "command": command,
210                        "hint": "Consider increasing timeout or breaking into smaller commands"
211                    })),
212                ))
213            }
214        }
215    }
216}
217
218impl Default for BashTool {
219    fn default() -> Self {
220        Self::new()
221    }
222}