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        // Execute with timeout
90        let result = timeout(Duration::from_millis(timeout_ms), cmd.output()).await;
91
92        match result {
93            Ok(Ok(output)) => {
94                // Decode output, using GBK on Windows
95                let stdout = decode_output(&output.stdout);
96                let stderr = decode_output(&output.stderr);
97                let exit_code = output.status.code().unwrap_or(-1);
98
99                let mut content = String::new();
100
101                // Append stdout (truncated)
102                if !stdout.is_empty() {
103                    content.push_str(&truncate_output(&stdout, self.max_output_bytes));
104                }
105
106                // Append stderr
107                if !stderr.is_empty() {
108                    if !content.is_empty() {
109                        content.push_str("\n");
110                    }
111                    content.push_str("[stderr]\n");
112                    content.push_str(&truncate_output(&stderr, self.max_output_bytes));
113                }
114
115                // Append exit code if non-zero
116                if exit_code != 0 {
117                    if !content.is_empty() {
118                        content.push_str("\n");
119                    }
120                    content.push_str(&format!("[exit code: {}]", exit_code));
121                }
122
123                if content.is_empty() {
124                    content = "(Command executed successfully, no output)".to_string();
125                }
126
127                Ok(ToolResult {
128                    content,
129                    is_error: exit_code != 0,
130                })
131            }
132            Ok(Err(e)) => Ok(ToolResult::error(format!("Command execution failed: {}", e))),
133            Err(_) => Ok(ToolResult::error(format!(
134                "Command timed out ({}ms limit)",
135                timeout_ms
136            ))),
137        }
138    }
139}
140
141/// Build a shell command appropriate for the current platform.
142fn build_shell_command(command: &str) -> Command {
143    if cfg!(target_os = "windows") {
144        let mut cmd = Command::new("cmd");
145        cmd.args(["/C", command]);
146        cmd
147    } else {
148        let mut cmd = Command::new("sh");
149        cmd.args(["-c", command]);
150        cmd
151    }
152}
153
154/// Decode command output, using GBK on Windows for compatibility.
155fn decode_output(bytes: &[u8]) -> String {
156    #[cfg(target_os = "windows")]
157    {
158        // On Windows, try GBK first, fall back to UTF-8
159        let (cow, _, has_error) = GBK.decode(bytes);
160        if !has_error {
161            return cow.to_string();
162        }
163    }
164    // Default to UTF-8 lossy decode on non-Windows or if GBK fails
165    String::from_utf8_lossy(bytes).to_string()
166}
167
168/// Truncate output to max_bytes, appending a notice if truncated.
169fn truncate_output(output: &str, max_bytes: usize) -> String {
170    if output.len() <= max_bytes {
171        output.to_string()
172    } else {
173        let truncated: String = output.chars().take(max_bytes).collect();
174        format!(
175            "{}\n... (Output truncated, {} bytes total, showing first {} bytes)",
176            truncated,
177            output.len(),
178            max_bytes
179        )
180    }
181}