Skip to main content

robit_agent/tool/
bash.rs

1//! `bash` tool — executes shell commands.
2
3use 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
14/// Default timeout in milliseconds (120 seconds).
15const DEFAULT_TIMEOUT_MS: u64 = 120_000;
16
17pub struct BashTool {
18    /// Max output bytes before truncation.
19    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        // Build command based on platform
86        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        // Execute with timeout
97        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                // Decode output, using GBK on Windows
104                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                // Append stdout (truncated)
119                if !stdout.is_empty() {
120                    content.push_str(&truncate_output(&stdout, self.max_output_bytes));
121                }
122
123                // Append stderr
124                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                // Append exit code if non-zero
133                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
175/// Build a shell command appropriate for the current platform.
176fn 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
188/// Decode command output, using GBK on Windows for compatibility.
189fn decode_output(bytes: &[u8]) -> String {
190    #[cfg(target_os = "windows")]
191    {
192        // On Windows, try GBK first, fall back to UTF-8
193        let (cow, _, has_error) = GBK.decode(bytes);
194        if !has_error {
195            return cow.to_string();
196        }
197    }
198    // Default to UTF-8 lossy decode on non-Windows or if GBK fails
199    String::from_utf8_lossy(bytes).to_string()
200}
201
202/// Truncate output to max_bytes, appending a notice if truncated.
203fn 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}