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 })
190 }
191 Ok(Err(e)) => Err(ToolError::Execution(format!(
192 "Node.js process error: {}",
193 e
194 ))),
195 Err(_) => {
196 Ok(ToolResult {
198 success: false,
199 result: json!({
200 "exit_code": null,
201 "stdout": "",
202 "stderr": "Execution timed out",
203 "stdout_truncated": false,
204 "stderr_truncated": false,
205 "timed_out": true,
206 })
207 .to_string(),
208 display_preference: Some("Collapsible".to_string()),
209 })
210 }
211 }
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn test_tool_name() {
221 let tool = JsReplTool::new();
222 assert_eq!(tool.name(), "js_repl");
223 }
224
225 fn has_node() -> bool {
227 find_in_path("node").is_some()
228 }
229
230 #[test]
231 fn test_resolve_node_finds_system_node() {
232 if !has_node() {
233 return;
234 }
235 assert!(resolve_node().is_some());
236 }
237
238 #[tokio::test]
239 async fn test_execute_simple_expression() {
240 if !has_node() {
241 return;
242 }
243 let tool = JsReplTool::new();
244 let result = tool
245 .execute(json!({ "code": "console.log(2 + 2)" }))
246 .await
247 .unwrap();
248
249 assert!(result.success);
250 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
251 assert_eq!(payload["timed_out"], false);
252 assert_eq!(payload["exit_code"], 0);
253 assert!(payload["stdout"].as_str().unwrap().contains("4"));
254 }
255
256 #[tokio::test]
257 async fn test_execute_async_await() {
258 if !has_node() {
259 return;
260 }
261 let tool = JsReplTool::new();
262 let result = tool
263 .execute(json!({
264 "code": "const result = await Promise.resolve(42); console.log(result)"
265 }))
266 .await
267 .unwrap();
268
269 assert!(result.success);
270 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
271 assert!(payload["stdout"].as_str().unwrap().contains("42"));
272 }
273
274 #[tokio::test]
275 async fn test_execute_error_returns_nonzero_exit() {
276 if !has_node() {
277 return;
278 }
279 let tool = JsReplTool::new();
280 let result = tool
281 .execute(json!({ "code": "throw new Error('test error')" }))
282 .await
283 .unwrap();
284
285 assert!(!result.success);
286 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
287 assert_ne!(payload["exit_code"], 0);
288 assert!(payload["stderr"].as_str().unwrap().contains("test error"));
289 }
290
291 #[tokio::test]
292 async fn test_empty_code_rejected() {
293 let tool = JsReplTool::new();
294 let err = tool.execute(json!({ "code": " " })).await.unwrap_err();
295 assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("empty")));
296 }
297
298 #[tokio::test]
299 async fn test_missing_code_rejected() {
300 let tool = JsReplTool::new();
301 let err = tool.execute(json!({})).await.unwrap_err();
302 assert!(matches!(err, ToolError::InvalidArguments(_)));
303 }
304
305 #[test]
306 fn test_effective_timeout() {
307 assert_eq!(
308 JsReplTool::effective_timeout(None),
309 Duration::from_millis(30_000)
310 );
311 assert_eq!(
312 JsReplTool::effective_timeout(Some(500_000)),
313 Duration::from_millis(MAX_TIMEOUT_MS)
314 );
315 assert_eq!(
316 JsReplTool::effective_timeout(Some(5_000)),
317 Duration::from_millis(5_000)
318 );
319 }
320
321 #[test]
322 fn test_truncate_output() {
323 let short = "hello";
324 let (out, trunc) = JsReplTool::truncate_output(short);
325 assert_eq!(out, "hello");
326 assert!(!trunc);
327 }
328
329 #[tokio::test]
330 async fn test_multiline_code() {
331 if !has_node() {
332 return;
333 }
334 let tool = JsReplTool::new();
335 let result = tool
336 .execute(json!({
337 "code": "const a = 10;\nconst b = 20;\nconsole.log(a + b);"
338 }))
339 .await
340 .unwrap();
341
342 assert!(result.success);
343 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
344 assert!(payload["stdout"].as_str().unwrap().contains("30"));
345 }
346}