llm_coding_tools_serdesai/
bash.rs

1//! Shell command execution tool.
2//!
3//! Provides cross-platform shell command execution with timeout support.
4
5use crate::convert::to_serdes_result;
6use async_trait::async_trait;
7use llm_coding_tools_core::context::ToolContext;
8use llm_coding_tools_core::operations::execute_command;
9use llm_coding_tools_core::tool_names;
10use serde::Deserialize;
11use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult};
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14
15/// Default timeout: 2 minutes.
16const DEFAULT_TIMEOUT_MS: u64 = 120_000;
17
18/// Arguments for the bash tool.
19#[derive(Debug, Clone, Deserialize)]
20struct BashArgs {
21    /// The shell command to execute.
22    command: String,
23    /// Optional working directory (must be absolute path).
24    workdir: Option<String>,
25    /// Timeout in milliseconds. Optional - falls back to constructor default or 120000ms.
26    timeout_ms: Option<u64>,
27}
28
29/// Tool for executing shell commands.
30///
31/// Uses bash on Unix, cmd on Windows.
32#[derive(Debug, Clone, Default)]
33pub struct BashTool {
34    /// Default timeout for commands when not specified in args.
35    default_timeout: Option<Duration>,
36    /// Default working directory when not specified in args.
37    default_workdir: Option<PathBuf>,
38}
39
40impl BashTool {
41    /// Creates a new bash tool instance with default settings.
42    #[inline]
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    /// Sets the default timeout for commands.
48    ///
49    /// This timeout is used when `timeout_ms` is not provided in the tool arguments.
50    pub fn with_default_timeout(mut self, timeout: Duration) -> Self {
51        self.default_timeout = Some(timeout);
52        self
53    }
54
55    /// Sets the default working directory.
56    ///
57    /// This directory is used when `workdir` is not provided in the tool arguments.
58    pub fn with_default_workdir(mut self, workdir: impl Into<PathBuf>) -> Self {
59        self.default_workdir = Some(workdir.into());
60        self
61    }
62}
63
64#[async_trait]
65impl<Deps: Send + Sync> Tool<Deps> for BashTool {
66    fn definition(&self) -> ToolDefinition {
67        ToolDefinition::new(
68            tool_names::BASH,
69            "Execute a shell command with optional working directory and timeout.",
70        )
71        .with_parameters(
72            SchemaBuilder::new()
73                .string_constrained(
74                    "command",
75                    "The shell command to execute",
76                    true,
77                    Some(1),
78                    None,
79                    None,
80                )
81                .string(
82                    "workdir",
83                    "Working directory for command execution (must be absolute path)",
84                    false,
85                )
86                .integer_constrained(
87                    "timeout_ms",
88                    "Timeout in milliseconds. Defaults to 120000 (2 minutes).",
89                    false,
90                    Some(1),
91                    Some(600_000),
92                )
93                .build()
94                .expect("schema serialization should never fail"),
95        )
96    }
97
98    async fn call(&self, _ctx: &RunContext<Deps>, args: serde_json::Value) -> ToolResult {
99        let args: BashArgs = serde_json::from_value(args)
100            .map_err(|e| ToolError::validation_error(tool_names::BASH, None, e.to_string()))?;
101
102        // Use arg workdir, falling back to default_workdir
103        let workdir: Option<&Path> = args
104            .workdir
105            .as_ref()
106            .map(|s| Path::new(s.as_str()))
107            .or(self.default_workdir.as_deref());
108
109        // Priority: args.timeout_ms > self.default_timeout > DEFAULT_TIMEOUT_MS
110        let timeout = args
111            .timeout_ms
112            .map(Duration::from_millis)
113            .or(self.default_timeout)
114            .unwrap_or(Duration::from_millis(DEFAULT_TIMEOUT_MS));
115
116        let result = execute_command(&args.command, workdir, timeout).await;
117
118        to_serdes_result(
119            tool_names::BASH,
120            result.map(|output| output.format_output()),
121        )
122    }
123}
124
125impl ToolContext for BashTool {
126    const NAME: &'static str = tool_names::BASH;
127
128    fn context(&self) -> &'static str {
129        llm_coding_tools_core::context::BASH
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    fn mock_ctx() -> RunContext<()> {
138        RunContext::minimal("test-model")
139    }
140
141    #[tokio::test]
142    async fn executes_echo() {
143        let tool = BashTool::new();
144        let args = serde_json::json!({
145            "command": "echo hello",
146            "timeout_ms": 5000
147        });
148        let result = tool.call(&mock_ctx(), args).await.unwrap();
149        assert!(result.as_text().unwrap().contains("hello"));
150    }
151
152    #[tokio::test]
153    async fn timeout_returns_error() {
154        let tool = BashTool::new();
155        let cmd = if cfg!(target_os = "windows") {
156            "ping -n 10 127.0.0.1"
157        } else {
158            "sleep 10"
159        };
160        let args = serde_json::json!({
161            "command": cmd,
162            "timeout_ms": 100
163        });
164        let result = tool.call(&mock_ctx(), args).await;
165        assert!(result.is_err());
166    }
167
168    #[tokio::test]
169    async fn workdir_parameter_changes_directory() {
170        let temp = tempfile::TempDir::new().unwrap();
171        let temp_path = temp.path().to_string_lossy();
172        let cmd = if cfg!(target_os = "windows") {
173            "cd"
174        } else {
175            "pwd"
176        };
177        let tool = BashTool::new();
178        let args = serde_json::json!({
179            "command": cmd,
180            "workdir": temp_path,
181            "timeout_ms": 5000
182        });
183        let result = tool.call(&mock_ctx(), args).await.unwrap();
184        let output = result.as_text().unwrap();
185        assert!(output.contains(temp_path.as_ref()));
186    }
187
188    #[tokio::test]
189    async fn default_workdir_is_used() {
190        let temp = tempfile::TempDir::new().unwrap();
191        let temp_path = temp.path().to_string_lossy();
192        let cmd = if cfg!(target_os = "windows") {
193            "cd"
194        } else {
195            "pwd"
196        };
197        let tool = BashTool::new().with_default_workdir(temp_path.as_ref());
198        let args = serde_json::json!({
199            "command": cmd
200        });
201        let result = tool.call(&mock_ctx(), args).await.unwrap();
202        let output = result.as_text().unwrap();
203        assert!(output.contains(temp_path.as_ref()));
204    }
205
206    #[tokio::test]
207    async fn per_call_timeout_overrides_default() {
208        // Constructor sets 10s default, but per-call arg specifies 100ms
209        let tool = BashTool::new().with_default_timeout(Duration::from_secs(10));
210        let cmd = if cfg!(target_os = "windows") {
211            "ping -n 10 127.0.0.1"
212        } else {
213            "sleep 10"
214        };
215        let args = serde_json::json!({
216            "command": cmd,
217            "timeout_ms": 100  // Should override the 10s default
218        });
219        let result = tool.call(&mock_ctx(), args).await;
220        // Should timeout with the 100ms, not wait 10s
221        assert!(result.is_err());
222    }
223
224    #[tokio::test]
225    async fn default_timeout_used_when_arg_omitted() {
226        let tool = BashTool::new().with_default_timeout(Duration::from_millis(100));
227        let cmd = if cfg!(target_os = "windows") {
228            "ping -n 10 127.0.0.1"
229        } else {
230            "sleep 10"
231        };
232        // No timeout_ms in args - should use constructor default
233        let args = serde_json::json!({
234            "command": cmd
235        });
236        let result = tool.call(&mock_ctx(), args).await;
237        assert!(result.is_err());
238    }
239}