1use async_trait::async_trait;
4use serde::Deserialize;
5use serde_json::Value;
6use std::path::PathBuf;
7use tokio::process::Command;
8use tokio::time::{timeout, Duration};
9use encoding_rs::GBK;
10
11use super::{Tool, ToolContext, ToolResult};
12use crate::error::Result;
13
14const DEFAULT_TIMEOUT_MS: u64 = 120_000;
16
17pub struct BashTool {
18 max_output_bytes: usize,
20}
21
22#[derive(Debug, Deserialize)]
23struct BashArgs {
24 command: String,
25 #[serde(default)]
26 timeout: Option<u64>,
27 #[serde(default)]
28 working_dir: Option<String>,
29}
30
31impl BashTool {
32 pub fn new(max_output_bytes: usize) -> Self {
33 Self { max_output_bytes }
34 }
35}
36
37#[async_trait]
38impl Tool for BashTool {
39 fn name(&self) -> &str {
40 "bash"
41 }
42
43 fn description(&self) -> &str {
44 "Execute shell commands. Uses cmd.exe on Windows, sh on Linux/macOS. Avoid cd, use absolute paths."
45 }
46
47 fn parameters_schema(&self) -> Value {
48 serde_json::json!({
49 "type": "object",
50 "properties": {
51 "command": {
52 "type": "string",
53 "description": "The command to execute"
54 },
55 "timeout": {
56 "type": "integer",
57 "description": "Timeout in milliseconds, default 120000"
58 },
59 "working_dir": {
60 "type": "string",
61 "description": "Working directory (optional, defaults to project root)"
62 }
63 },
64 "required": ["command"]
65 })
66 }
67
68 fn requires_confirmation(&self) -> bool {
69 true
70 }
71
72 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<ToolResult> {
73 let parsed: BashArgs = match serde_json::from_value(args) {
74 Ok(a) => a,
75 Err(e) => return Ok(ToolResult::error(format!("Argument parsing failed: {}", e))),
76 };
77
78 let work_dir = parsed
79 .working_dir
80 .map(PathBuf::from)
81 .unwrap_or_else(|| ctx.working_dir.clone());
82
83 let timeout_ms = parsed.timeout.unwrap_or(DEFAULT_TIMEOUT_MS);
84
85 let mut cmd = build_shell_command(&parsed.command);
87 cmd.current_dir(&work_dir);
88
89 tracing::trace!(
90 "[tool:bash] spawning command: cmd='{}', work_dir='{}', timeout_ms={}",
91 parsed.command,
92 work_dir.display(),
93 timeout_ms
94 );
95
96 let started = std::time::Instant::now();
98 let result = timeout(Duration::from_millis(timeout_ms), cmd.output()).await;
99 let elapsed = started.elapsed();
100
101 match result {
102 Ok(Ok(output)) => {
103 let stdout = decode_output(&output.stdout);
105 let stderr = decode_output(&output.stderr);
106 let exit_code = output.status.code().unwrap_or(-1);
107
108 tracing::trace!(
109 "[tool:bash] command completed: exit_code={}, elapsed_ms={}, stdout_len={}, stderr_len={}",
110 exit_code,
111 elapsed.as_millis(),
112 stdout.len(),
113 stderr.len()
114 );
115
116 let mut content = String::new();
117
118 if !stdout.is_empty() {
120 content.push_str(&truncate_output(&stdout, self.max_output_bytes));
121 }
122
123 if !stderr.is_empty() {
125 if !content.is_empty() {
126 content.push_str("\n");
127 }
128 content.push_str("[stderr]\n");
129 content.push_str(&truncate_output(&stderr, self.max_output_bytes));
130 }
131
132 if exit_code != 0 {
134 if !content.is_empty() {
135 content.push_str("\n");
136 }
137 content.push_str(&format!("[exit code: {}]", exit_code));
138 }
139
140 if content.is_empty() {
141 content = "(Command executed successfully, no output)".to_string();
142 }
143
144 Ok(ToolResult {
145 content,
146 is_error: exit_code != 0,
147 images: Vec::new(),
148 is_pending: false,
149 pending_task_id: None,
150 })
151 }
152 Ok(Err(e)) => {
153 tracing::trace!(
154 "[tool:bash] command failed to spawn after {:?}: {}",
155 elapsed,
156 e
157 );
158 Ok(ToolResult::error(format!("Command execution failed: {}", e)))
159 }
160 Err(_) => {
161 tracing::trace!(
162 "[tool:bash] command timed out after {}ms (elapsed={:?})",
163 timeout_ms,
164 elapsed
165 );
166 Ok(ToolResult::error(format!(
167 "Command timed out ({}ms limit)",
168 timeout_ms
169 )))
170 }
171 }
172 }
173}
174
175fn build_shell_command(command: &str) -> Command {
177 if cfg!(target_os = "windows") {
178 let mut cmd = Command::new("cmd");
179 cmd.args(["/C", command]);
180 cmd
181 } else {
182 let mut cmd = Command::new("sh");
183 cmd.args(["-c", command]);
184 cmd
185 }
186}
187
188fn decode_output(bytes: &[u8]) -> String {
190 #[cfg(target_os = "windows")]
191 {
192 let (cow, _, has_error) = GBK.decode(bytes);
194 if !has_error {
195 return cow.to_string();
196 }
197 }
198 String::from_utf8_lossy(bytes).to_string()
200}
201
202fn truncate_output(output: &str, max_bytes: usize) -> String {
204 if output.len() <= max_bytes {
205 output.to_string()
206 } else {
207 let truncated: String = output.chars().take(max_bytes).collect();
208 format!(
209 "{}\n... (Output truncated, {} bytes total, showing first {} bytes)",
210 truncated,
211 output.len(),
212 max_bytes
213 )
214 }
215}