llm_coding_tools_serdesai/
bash.rs1use 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
15const DEFAULT_TIMEOUT_MS: u64 = 120_000;
17
18#[derive(Debug, Clone, Deserialize)]
20struct BashArgs {
21 command: String,
23 workdir: Option<String>,
25 timeout_ms: Option<u64>,
27}
28
29#[derive(Debug, Clone, Default)]
33pub struct BashTool {
34 default_timeout: Option<Duration>,
36 default_workdir: Option<PathBuf>,
38}
39
40impl BashTool {
41 #[inline]
43 pub fn new() -> Self {
44 Self::default()
45 }
46
47 pub fn with_default_timeout(mut self, timeout: Duration) -> Self {
51 self.default_timeout = Some(timeout);
52 self
53 }
54
55 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 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 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 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 });
219 let result = tool.call(&mock_ctx(), args).await;
220 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 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}