bamboo_tools/tools/
js_repl.rs1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolError, ToolResult};
3use serde::Deserialize;
4use serde_json::json;
5use std::path::PathBuf;
6use std::process::Stdio;
7use tokio::process::Command;
8use tokio::time::{timeout, Duration};
9
10const DEFAULT_TIMEOUT_MS: u64 = 30_000;
11const MAX_TIMEOUT_MS: u64 = 120_000;
12const MAX_OUTPUT_BYTES: usize = 256 * 1024;
13
14#[derive(Debug, Deserialize)]
15struct JsReplArgs {
16 code: String,
17 #[serde(default)]
18 timeout_ms: Option<u64>,
19}
20
21pub struct JsReplTool;
30
31impl JsReplTool {
32 pub fn new() -> Self {
33 Self
34 }
35
36 fn effective_timeout(requested: Option<u64>) -> Duration {
37 let ms = requested
38 .unwrap_or(DEFAULT_TIMEOUT_MS)
39 .clamp(1, MAX_TIMEOUT_MS);
40 Duration::from_millis(ms)
41 }
42
43 fn truncate_output(s: &str) -> (&str, bool) {
44 if s.len() <= MAX_OUTPUT_BYTES {
45 (s, false)
46 } else {
47 let mut end = MAX_OUTPUT_BYTES;
48 while end > 0 && !s.is_char_boundary(end) {
49 end -= 1;
50 }
51 (&s[..end], true)
52 }
53 }
54}
55
56impl Default for JsReplTool {
57 fn default() -> Self {
58 Self::new()
59 }
60}
61
62fn resolve_node() -> Option<PathBuf> {
65 if let Ok(path) = std::env::var("BAMBOO_JS_REPL_NODE_PATH") {
66 let p = PathBuf::from(&path);
67 if p.exists() {
68 return Some(p);
69 }
70 }
71 find_in_path("node")
72}
73
74fn find_in_path(name: &str) -> Option<PathBuf> {
76 let path_var = std::env::var_os("PATH")?;
77 for dir in std::env::split_paths(&path_var) {
78 let candidate = dir.join(name);
79 if candidate.is_file() {
80 return Some(candidate);
81 }
82 #[cfg(windows)]
84 for ext in &["exe", "cmd", "bat"] {
85 let with_ext = dir.join(format!("{}.{}", name, ext));
86 if with_ext.is_file() {
87 return Some(with_ext);
88 }
89 }
90 }
91 None
92}
93
94#[async_trait]
95impl Tool for JsReplTool {
96 fn name(&self) -> &str {
97 "js_repl"
98 }
99
100 fn description(&self) -> &str {
101 "Execute JavaScript code using Node.js. Supports top-level await and ES modules. The code is run in a fresh process each time; use js_repl_reset is not needed since state is not shared between calls."
102 }
103
104 fn parameters_schema(&self) -> serde_json::Value {
105 json!({
106 "type": "object",
107 "properties": {
108 "code": {
109 "type": "string",
110 "description": "JavaScript code to execute"
111 },
112 "timeout_ms": {
113 "type": "number",
114 "description": "Optional timeout in milliseconds (default 30000, max 120000)"
115 }
116 },
117 "required": ["code"],
118 "additionalProperties": false
119 })
120 }
121
122 async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
123 let parsed: JsReplArgs = serde_json::from_value(args)
124 .map_err(|e| ToolError::InvalidArguments(format!("Invalid js_repl args: {}", e)))?;
125
126 let code = parsed.code.trim();
127 if code.is_empty() {
128 return Err(ToolError::InvalidArguments(
129 "'code' cannot be empty".to_string(),
130 ));
131 }
132
133 let node_path = resolve_node().ok_or_else(|| {
134 ToolError::Execution(
135 "Node.js not found. Install Node.js or set BAMBOO_JS_REPL_NODE_PATH.".to_string(),
136 )
137 })?;
138
139 let effective_timeout = Self::effective_timeout(parsed.timeout_ms);
140
141 let wrapper = format!(
143 r#"(async () => {{
144{}
145}})().catch(e => {{ console.error(e); process.exit(1); }});"#,
146 code
147 );
148
149 let child = Command::new(&node_path)
153 .arg("-e")
154 .arg(&wrapper)
155 .stdin(Stdio::null())
156 .stdout(Stdio::piped())
157 .stderr(Stdio::piped())
158 .kill_on_drop(true)
159 .spawn()
160 .map_err(|e| {
161 ToolError::Execution(format!(
162 "Failed to start Node.js ({}): {}",
163 node_path.display(),
164 e
165 ))
166 })?;
167
168 match timeout(effective_timeout, child.wait_with_output()).await {
169 Ok(Ok(output)) => {
170 let stdout_raw = String::from_utf8_lossy(&output.stdout);
171 let stderr_raw = String::from_utf8_lossy(&output.stderr);
172 let (stdout, stdout_truncated) = Self::truncate_output(&stdout_raw);
173 let (stderr, stderr_truncated) = Self::truncate_output(&stderr_raw);
174 let exit_code = output.status.code();
175 let success = output.status.success();
176
177 Ok(ToolResult {
178 success,
179 result: json!({
180 "exit_code": exit_code,
181 "stdout": stdout,
182 "stderr": stderr,
183 "stdout_truncated": stdout_truncated,
184 "stderr_truncated": stderr_truncated,
185 "timed_out": false,
186 })
187 .to_string(),
188 display_preference: Some("Collapsible".to_string()),
189 images: Vec::new(),
190 })
191 }
192 Ok(Err(e)) => Err(ToolError::Execution(format!(
193 "Node.js process error: {}",
194 e
195 ))),
196 Err(_) => {
197 Ok(ToolResult {
199 success: false,
200 result: json!({
201 "exit_code": null,
202 "stdout": "",
203 "stderr": "Execution timed out",
204 "stdout_truncated": false,
205 "stderr_truncated": false,
206 "timed_out": true,
207 })
208 .to_string(),
209 display_preference: Some("Collapsible".to_string()),
210 images: Vec::new(),
211 })
212 }
213 }
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn test_tool_name() {
223 let tool = JsReplTool::new();
224 assert_eq!(tool.name(), "js_repl");
225 }
226
227 fn has_node() -> bool {
229 find_in_path("node").is_some()
230 }
231
232 #[test]
233 fn test_resolve_node_finds_system_node() {
234 if !has_node() {
235 return;
236 }
237 assert!(resolve_node().is_some());
238 }
239
240 #[tokio::test]
241 async fn test_execute_simple_expression() {
242 if !has_node() {
243 return;
244 }
245 let tool = JsReplTool::new();
246 let result = tool
247 .execute(json!({ "code": "console.log(2 + 2)" }))
248 .await
249 .unwrap();
250
251 assert!(result.success);
252 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
253 assert_eq!(payload["timed_out"], false);
254 assert_eq!(payload["exit_code"], 0);
255 assert!(payload["stdout"].as_str().unwrap().contains("4"));
256 }
257
258 #[tokio::test]
259 async fn test_execute_async_await() {
260 if !has_node() {
261 return;
262 }
263 let tool = JsReplTool::new();
264 let result = tool
265 .execute(json!({
266 "code": "const result = await Promise.resolve(42); console.log(result)"
267 }))
268 .await
269 .unwrap();
270
271 assert!(result.success);
272 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
273 assert!(payload["stdout"].as_str().unwrap().contains("42"));
274 }
275
276 #[tokio::test]
277 async fn test_execute_error_returns_nonzero_exit() {
278 if !has_node() {
279 return;
280 }
281 let tool = JsReplTool::new();
282 let result = tool
283 .execute(json!({ "code": "throw new Error('test error')" }))
284 .await
285 .unwrap();
286
287 assert!(!result.success);
288 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
289 assert_ne!(payload["exit_code"], 0);
290 assert!(payload["stderr"].as_str().unwrap().contains("test error"));
291 }
292
293 #[tokio::test]
294 async fn test_empty_code_rejected() {
295 let tool = JsReplTool::new();
296 let err = tool.execute(json!({ "code": " " })).await.unwrap_err();
297 assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("empty")));
298 }
299
300 #[tokio::test]
301 async fn test_missing_code_rejected() {
302 let tool = JsReplTool::new();
303 let err = tool.execute(json!({})).await.unwrap_err();
304 assert!(matches!(err, ToolError::InvalidArguments(_)));
305 }
306
307 #[test]
308 fn test_effective_timeout() {
309 assert_eq!(
310 JsReplTool::effective_timeout(None),
311 Duration::from_millis(30_000)
312 );
313 assert_eq!(
314 JsReplTool::effective_timeout(Some(500_000)),
315 Duration::from_millis(MAX_TIMEOUT_MS)
316 );
317 assert_eq!(
318 JsReplTool::effective_timeout(Some(5_000)),
319 Duration::from_millis(5_000)
320 );
321 }
322
323 #[test]
324 fn test_truncate_output() {
325 let short = "hello";
326 let (out, trunc) = JsReplTool::truncate_output(short);
327 assert_eq!(out, "hello");
328 assert!(!trunc);
329 }
330
331 #[tokio::test]
332 async fn test_multiline_code() {
333 if !has_node() {
334 return;
335 }
336 let tool = JsReplTool::new();
337 let result = tool
338 .execute(json!({
339 "code": "const a = 10;\nconst b = 20;\nconsole.log(a + b);"
340 }))
341 .await
342 .unwrap();
343
344 assert!(result.success);
345 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
346 assert!(payload["stdout"].as_str().unwrap().contains("30"));
347 }
348}