bamboo_tools/tools/
js_repl.rs1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, 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 classify(&self, _args: &serde_json::Value) -> ToolClass {
105 ToolClass::MUTATING_SERIAL.promotable()
106 }
107
108 fn parameters_schema(&self) -> serde_json::Value {
109 json!({
110 "type": "object",
111 "properties": {
112 "code": {
113 "type": "string",
114 "description": "JavaScript code to execute"
115 },
116 "timeout_ms": {
117 "type": "number",
118 "description": "Optional timeout in milliseconds (default 30000, max 120000)"
119 }
120 },
121 "required": ["code"],
122 "additionalProperties": false
123 })
124 }
125
126 async fn invoke(&self, args: serde_json::Value, _ctx: ToolCtx) -> Result<ToolOutcome, ToolError> {
127 let parsed: JsReplArgs = serde_json::from_value(args)
128 .map_err(|e| ToolError::InvalidArguments(format!("Invalid js_repl args: {}", e)))?;
129
130 let code = parsed.code.trim();
131 if code.is_empty() {
132 return Err(ToolError::InvalidArguments(
133 "'code' cannot be empty".to_string(),
134 ));
135 }
136
137 let node_path = resolve_node().ok_or_else(|| {
138 ToolError::Execution(
139 "Node.js not found. Install Node.js or set BAMBOO_JS_REPL_NODE_PATH.".to_string(),
140 )
141 })?;
142
143 let effective_timeout = Self::effective_timeout(parsed.timeout_ms);
144
145 let wrapper = format!(
147 r#"(async () => {{
148{}
149}})().catch(e => {{ console.error(e); process.exit(1); }});"#,
150 code
151 );
152
153 let child = Command::new(&node_path)
157 .arg("-e")
158 .arg(&wrapper)
159 .stdin(Stdio::null())
160 .stdout(Stdio::piped())
161 .stderr(Stdio::piped())
162 .kill_on_drop(true)
163 .spawn()
164 .map_err(|e| {
165 ToolError::Execution(format!(
166 "Failed to start Node.js ({}): {}",
167 node_path.display(),
168 e
169 ))
170 })?;
171
172 match timeout(effective_timeout, child.wait_with_output()).await {
173 Ok(Ok(output)) => {
174 let stdout_raw = String::from_utf8_lossy(&output.stdout);
175 let stderr_raw = String::from_utf8_lossy(&output.stderr);
176 let (stdout, stdout_truncated) = Self::truncate_output(&stdout_raw);
177 let (stderr, stderr_truncated) = Self::truncate_output(&stderr_raw);
178 let exit_code = output.status.code();
179 let success = output.status.success();
180
181 Ok(ToolOutcome::Completed(ToolResult {
182 success,
183 result: json!({
184 "exit_code": exit_code,
185 "stdout": stdout,
186 "stderr": stderr,
187 "stdout_truncated": stdout_truncated,
188 "stderr_truncated": stderr_truncated,
189 "timed_out": false,
190 })
191 .to_string(),
192 display_preference: Some("Collapsible".to_string()),
193 images: Vec::new(),
194 }))
195 }
196 Ok(Err(e)) => Err(ToolError::Execution(format!(
197 "Node.js process error: {}",
198 e
199 ))),
200 Err(_) => {
201 Ok(ToolOutcome::Completed(ToolResult {
203 success: false,
204 result: json!({
205 "exit_code": null,
206 "stdout": "",
207 "stderr": "Execution timed out",
208 "stdout_truncated": false,
209 "stderr_truncated": false,
210 "timed_out": true,
211 })
212 .to_string(),
213 display_preference: Some("Collapsible".to_string()),
214 images: Vec::new(),
215 }))
216 }
217 }
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 async fn run(tool: &JsReplTool, args: serde_json::Value) -> ToolResult {
226 let out = tool.invoke(args, ToolCtx::none("t")).await.unwrap();
227 let ToolOutcome::Completed(r) = out else {
228 panic!("expected Completed")
229 };
230 r
231 }
232
233 #[test]
234 fn test_tool_name() {
235 let tool = JsReplTool::new();
236 assert_eq!(tool.name(), "js_repl");
237 }
238
239 fn has_node() -> bool {
241 find_in_path("node").is_some()
242 }
243
244 #[test]
245 fn test_resolve_node_finds_system_node() {
246 if !has_node() {
247 return;
248 }
249 assert!(resolve_node().is_some());
250 }
251
252 #[tokio::test]
253 async fn test_execute_simple_expression() {
254 if !has_node() {
255 return;
256 }
257 let tool = JsReplTool::new();
258 let result = run(&tool, json!({ "code": "console.log(2 + 2)" })).await;
259
260 assert!(result.success);
261 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
262 assert_eq!(payload["timed_out"], false);
263 assert_eq!(payload["exit_code"], 0);
264 assert!(payload["stdout"].as_str().unwrap().contains("4"));
265 }
266
267 #[tokio::test]
268 async fn test_execute_async_await() {
269 if !has_node() {
270 return;
271 }
272 let tool = JsReplTool::new();
273 let result = run(
274 &tool,
275 json!({
276 "code": "const result = await Promise.resolve(42); console.log(result)"
277 }),
278 )
279 .await;
280
281 assert!(result.success);
282 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
283 assert!(payload["stdout"].as_str().unwrap().contains("42"));
284 }
285
286 #[tokio::test]
287 async fn test_execute_error_returns_nonzero_exit() {
288 if !has_node() {
289 return;
290 }
291 let tool = JsReplTool::new();
292 let result = run(&tool, json!({ "code": "throw new Error('test error')" })).await;
293
294 assert!(!result.success);
295 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
296 assert_ne!(payload["exit_code"], 0);
297 assert!(payload["stderr"].as_str().unwrap().contains("test error"));
298 }
299
300 #[tokio::test]
301 async fn test_empty_code_rejected() {
302 let tool = JsReplTool::new();
303 let err = tool
304 .invoke(json!({ "code": " " }), ToolCtx::none("t"))
305 .await
306 .unwrap_err();
307 assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("empty")));
308 }
309
310 #[tokio::test]
311 async fn test_missing_code_rejected() {
312 let tool = JsReplTool::new();
313 let err = tool
314 .invoke(json!({}), ToolCtx::none("t"))
315 .await
316 .unwrap_err();
317 assert!(matches!(err, ToolError::InvalidArguments(_)));
318 }
319
320 #[test]
321 fn test_effective_timeout() {
322 assert_eq!(
323 JsReplTool::effective_timeout(None),
324 Duration::from_millis(30_000)
325 );
326 assert_eq!(
327 JsReplTool::effective_timeout(Some(500_000)),
328 Duration::from_millis(MAX_TIMEOUT_MS)
329 );
330 assert_eq!(
331 JsReplTool::effective_timeout(Some(5_000)),
332 Duration::from_millis(5_000)
333 );
334 }
335
336 #[test]
337 fn test_truncate_output() {
338 let short = "hello";
339 let (out, trunc) = JsReplTool::truncate_output(short);
340 assert_eq!(out, "hello");
341 assert!(!trunc);
342 }
343
344 #[tokio::test]
345 async fn test_multiline_code() {
346 if !has_node() {
347 return;
348 }
349 let tool = JsReplTool::new();
350 let result = run(
351 &tool,
352 json!({
353 "code": "const a = 10;\nconst b = 20;\nconsole.log(a + b);"
354 }),
355 )
356 .await;
357
358 assert!(result.success);
359 let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
360 assert!(payload["stdout"].as_str().unwrap().contains("30"));
361 }
362}