codetether_agent/tool/
bash.rs1use 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
14pub struct BashTool {
16 timeout_secs: u64,
17}
18
19impl BashTool {
20 pub fn new() -> Self {
21 Self { timeout_secs: 120 }
22 }
23
24 #[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 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 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}