codetether_agent/tool/
bash.rs1use super::{Tool, ToolResult};
4use anyhow::Result;
5use async_trait::async_trait;
6use serde_json::{json, Value};
7use std::process::Stdio;
8use tokio::process::Command;
9use tokio::time::{timeout, Duration};
10
11pub struct BashTool {
13 timeout_secs: u64,
14}
15
16impl BashTool {
17 pub fn new() -> Self {
18 Self { timeout_secs: 120 }
19 }
20
21 #[allow(dead_code)]
23 pub fn with_timeout(timeout_secs: u64) -> Self {
24 Self { timeout_secs }
25 }
26}
27
28#[async_trait]
29impl Tool for BashTool {
30 fn id(&self) -> &str {
31 "bash"
32 }
33
34 fn name(&self) -> &str {
35 "Bash"
36 }
37
38 fn description(&self) -> &str {
39 "Execute a shell command. Commands run in a bash shell with the current working directory."
40 }
41
42 fn parameters(&self) -> Value {
43 json!({
44 "type": "object",
45 "properties": {
46 "command": {
47 "type": "string",
48 "description": "The shell command to execute"
49 },
50 "cwd": {
51 "type": "string",
52 "description": "Working directory for the command (optional)"
53 },
54 "timeout": {
55 "type": "integer",
56 "description": "Timeout in seconds (default: 120)"
57 }
58 },
59 "required": ["command"]
60 })
61 }
62
63 async fn execute(&self, args: Value) -> Result<ToolResult> {
64 let command = args["command"]
65 .as_str()
66 .ok_or_else(|| anyhow::anyhow!("command is required"))?;
67 let cwd = args["cwd"].as_str();
68 let timeout_secs = args["timeout"]
69 .as_u64()
70 .unwrap_or(self.timeout_secs);
71
72 let mut cmd = Command::new("bash");
73 cmd.arg("-c")
74 .arg(command)
75 .stdout(Stdio::piped())
76 .stderr(Stdio::piped());
77
78 if let Some(dir) = cwd {
79 cmd.current_dir(dir);
80 }
81
82 let result = timeout(Duration::from_secs(timeout_secs), cmd.output()).await;
83
84 match result {
85 Ok(Ok(output)) => {
86 let stdout = String::from_utf8_lossy(&output.stdout);
87 let stderr = String::from_utf8_lossy(&output.stderr);
88 let exit_code = output.status.code().unwrap_or(-1);
89
90 let combined = if stderr.is_empty() {
91 stdout.to_string()
92 } else if stdout.is_empty() {
93 stderr.to_string()
94 } else {
95 format!("{}\n--- stderr ---\n{}", stdout, stderr)
96 };
97
98 let success = output.status.success();
99
100 let max_len = 50_000;
102 let (output_str, truncated) = if combined.len() > max_len {
103 let truncated_output = format!(
104 "{}...\n[Output truncated, {} bytes total]",
105 &combined[..max_len],
106 combined.len()
107 );
108 (truncated_output, true)
109 } else {
110 (combined, false)
111 };
112
113 Ok(ToolResult {
114 output: output_str,
115 success,
116 metadata: [
117 ("exit_code".to_string(), json!(exit_code)),
118 ("truncated".to_string(), json!(truncated)),
119 ]
120 .into_iter()
121 .collect(),
122 })
123 }
124 Ok(Err(e)) => Ok(ToolResult::error(format!("Failed to execute command: {}", e))),
125 Err(_) => Ok(ToolResult::error(format!(
126 "Command timed out after {} seconds",
127 timeout_secs
128 ))),
129 }
130 }
131}
132
133impl Default for BashTool {
134 fn default() -> Self {
135 Self::new()
136 }
137}