1use async_trait::async_trait;
13use serde_json::{Value, json};
14use std::sync::Arc;
15use tokio::sync::oneshot;
16
17use super::{AgentTool, AgentToolResult, ToolContext, ToolError, ToolExecutionMode};
18
19pub struct EvalTool;
21
22#[async_trait]
23impl AgentTool for EvalTool {
24 fn name(&self) -> &str {
25 "eval"
26 }
27
28 fn label(&self) -> &str {
29 "Eval"
30 }
31
32 fn description(&self) -> &str {
33 "Execute code in Python (`py`) or JavaScript (`js`) and capture \
34 stdout, stderr, and the return value. Each call runs in a fresh \
35 process — state does NOT persist across calls. Use `reset: true` \
36 to discard previous state explicitly.\n\n\
37 For interactive or multi-step sessions, prefer the `bash` tool \
38 with `python3 -i` or `bun -i` for persistent state. Use `eval` \
39 for quick one-shot computations where only the output matters."
40 }
41
42 fn essential(&self) -> bool {
43 false
44 }
45
46 fn parameters_schema(&self) -> Value {
47 json!({
48 "type": "object",
49 "properties": {
50 "language": {
51 "type": "string",
52 "enum": ["py", "js"],
53 "description": "Language runtime: `py` (Python 3) or `js` (JavaScript / Bun)",
54 "default": "py"
55 },
56 "code": {
57 "type": "string",
58 "description": "Code to execute. Imports and variable definitions persist across calls."
59 },
60 "title": {
61 "type": "string",
62 "description": "Optional cell label for readability in the transcript"
63 },
64 "reset": {
65 "type": "boolean",
66 "description": "Reset the kernel/session before executing this cell",
67 "default": false
68 }
69 },
70 "required": ["code"]
71 })
72 }
73
74 fn intent(&self) -> Option<&str> {
75 Some("Execute code and capture output")
76 }
77
78 fn execution_mode(&self) -> ToolExecutionMode {
79 ToolExecutionMode::SequentialOnly
80 }
81
82 async fn execute(
83 &self,
84 _tool_call_id: &str,
85 params: Value,
86 _signal: Option<oneshot::Receiver<()>>,
87 _ctx: &ToolContext,
88 ) -> Result<AgentToolResult, ToolError> {
89 let code = params
90 .get("code")
91 .and_then(|v| v.as_str())
92 .ok_or_else(|| "Missing required parameter: code".to_string())?;
93
94 if code.trim().is_empty() {
95 return Err("Parameter `code` must be a non-empty string".to_string());
96 }
97
98 let language = params
99 .get("language")
100 .and_then(|v| v.as_str())
101 .unwrap_or("py");
102
103 let _title = params.get("title").and_then(|v| v.as_str());
104
105 let _reset = params
106 .get("reset")
107 .and_then(|v| v.as_bool())
108 .unwrap_or(false);
109
110 let tmp = std::env::temp_dir().join(format!(
112 "oxicode_eval_{}.{}",
113 std::time::SystemTime::now()
114 .duration_since(std::time::UNIX_EPOCH)
115 .unwrap_or_default()
116 .as_nanos(),
117 match language {
118 "py" => "py",
119 "js" => "mjs",
120 other => return Err(format!("Unsupported language: {}", other)),
121 }
122 ));
123
124 if let Err(e) = tokio::fs::write(&tmp, code).await {
125 return Ok(AgentToolResult::error(format!(
126 "Failed to write temp file: {}",
127 e
128 )));
129 }
130
131 let runner = match language {
132 "py" => "python3",
133 "js" => {
134 let has_bun = tokio::process::Command::new("which")
136 .arg("bun")
137 .output()
138 .await
139 .map(|o| o.status.success())
140 .unwrap_or(false);
141 if has_bun { "bun" } else { "node" }
142 }
143 other => {
144 return Ok(AgentToolResult::error(format!(
145 "Unsupported language: '{}'. Supported: py, js",
146 other
147 )));
148 }
149 };
150
151 let output = match tokio::process::Command::new(runner)
152 .arg(tmp.to_str().unwrap_or(""))
153 .output()
154 .await
155 {
156 Ok(o) => o,
157 Err(e) => {
158 let _ = tokio::fs::remove_file(&tmp).await;
160 return Ok(AgentToolResult::error(format!(
161 "Failed to execute code via {}: {}",
162 runner, e
163 )));
164 }
165 };
166
167 let _ = tokio::fs::remove_file(&tmp).await;
169
170 let mut result_parts = Vec::new();
171
172 if !output.stdout.is_empty() {
173 let stdout = String::from_utf8_lossy(&output.stdout);
174 if !stdout.trim().is_empty() {
175 result_parts.push(format!("── stdout ──\n{}", stdout.trim()));
176 }
177 }
178
179 if !output.stderr.is_empty() {
180 let stderr = String::from_utf8_lossy(&output.stderr);
181 if !stderr.trim().is_empty() {
182 result_parts.push(format!("── stderr ──\n{}", stderr.trim()));
183 }
184 }
185
186 let exit_code = output.status.code().unwrap_or(-1);
187
188 if exit_code != 0 {
189 result_parts.push(format!("── exit code: {} ──", exit_code));
190 }
191
192 let result_text = if result_parts.is_empty() {
193 "Code executed successfully (exit code 0, no output)".to_string()
194 } else {
195 result_parts.join("\n")
196 };
197
198 Ok(AgentToolResult::success(result_text))
199 }
200}
201
202pub struct KernelEvalTool {
210 kernels: Vec<Arc<dyn crate::runtime::EvalKernel>>,
211}
212
213impl KernelEvalTool {
214 pub fn new(kernels: Vec<Arc<dyn crate::runtime::EvalKernel>>) -> Self {
216 Self { kernels }
217 }
218
219 fn kernel_for(&self, language: &str) -> Option<Arc<dyn crate::runtime::EvalKernel>> {
220 let wanted = match language {
221 "py" => crate::runtime::EvalLanguage::Python,
222 "js" => crate::runtime::EvalLanguage::JavaScript,
223 _ => return None,
224 };
225 self.kernels
226 .iter()
227 .find(|k| k.language() == wanted)
228 .cloned()
229 }
230}
231
232#[async_trait]
233impl AgentTool for KernelEvalTool {
234 fn name(&self) -> &str {
235 "eval"
236 }
237
238 fn label(&self) -> &str {
239 "Eval (persistent kernel)"
240 }
241
242 fn essential(&self) -> bool {
243 false
244 }
245
246 fn description(&self) -> &str {
247 "Execute code in Python (`py`) or JavaScript (`js`) inside a \
248 persistent kernel: imports and variable definitions persist \
249 across calls. Use `reset: true` to drop the kernel state before \
250 a cell. Errors are captured and reported without killing the \
251 kernel. Use `bash` for shell-level work."
252 }
253
254 fn parameters_schema(&self) -> Value {
255 json!({
256 "type": "object",
257 "properties": {
258 "language": {
259 "type": "string",
260 "enum": ["py", "js"],
261 "description": "Language runtime: `py` (Python 3) or `js` (JavaScript / Node or Bun)",
262 "default": "py"
263 },
264 "code": {
265 "type": "string",
266 "description": "Code to execute. Imports and variable definitions persist across calls."
267 },
268 "title": {
269 "type": "string",
270 "description": "Optional cell label for readability in the transcript"
271 },
272 "reset": {
273 "type": "boolean",
274 "description": "Reset the kernel/session before executing this cell",
275 "default": false
276 }
277 },
278 "required": ["code"]
279 })
280 }
281
282 fn intent(&self) -> Option<&str> {
283 Some("Execute code in a persistent kernel")
284 }
285
286 fn execution_mode(&self) -> ToolExecutionMode {
287 ToolExecutionMode::SequentialOnly
289 }
290
291 async fn execute(
292 &self,
293 _tool_call_id: &str,
294 params: Value,
295 _signal: Option<oneshot::Receiver<()>>,
296 _ctx: &ToolContext,
297 ) -> Result<AgentToolResult, ToolError> {
298 let code = params
299 .get("code")
300 .and_then(|v| v.as_str())
301 .ok_or_else(|| "Missing required parameter: code".to_string())?;
302 if code.trim().is_empty() {
303 return Err("Parameter `code` must be a non-empty string".to_string());
304 }
305
306 let language = params
307 .get("language")
308 .and_then(|v| v.as_str())
309 .unwrap_or("py");
310
311 let kernel = self
312 .kernel_for(language)
313 .ok_or_else(|| format!("No persistent kernel available for language `{language}`"))?;
314
315 let timeout = std::time::Duration::from_secs(
316 params
317 .get("timeout")
318 .and_then(|v| v.as_u64())
319 .unwrap_or(120),
320 );
321
322 if params
323 .get("reset")
324 .and_then(|v| v.as_bool())
325 .unwrap_or(false)
326 {
327 kernel
328 .reset()
329 .await
330 .map_err(|e| -> ToolError { format!("kernel reset failed: {e}") })?;
331 }
332
333 let out = kernel
334 .execute(code, timeout)
335 .await
336 .map_err(|e| -> ToolError { format!("kernel execution failed: {e}") })?;
337
338 let mut parts = Vec::new();
339 if !out.stdout.trim().is_empty() {
340 parts.push(format!("── stdout ──\n{}", out.stdout.trim()));
341 }
342 if !out.stderr.trim().is_empty() {
343 parts.push(format!("── stderr ──\n{}", out.stderr.trim()));
344 }
345 if let Some(error) = &out.error {
346 parts.push(format!("── error ──\n{error}"));
347 }
348 if out.truncated {
349 parts.push("[Kernel output bound applied]".to_string());
350 }
351
352 let text = if parts.is_empty() {
353 "Cell executed successfully (no output)".to_string()
354 } else {
355 parts.join("\n")
356 };
357
358 if out.error.is_some() {
359 Ok(AgentToolResult::error(text))
360 } else {
361 Ok(AgentToolResult::success(text))
362 }
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use serde_json::json;
370
371 fn ctx() -> ToolContext {
372 ToolContext::default()
373 }
374
375 #[tokio::test]
376 async fn rejects_missing_code() {
377 let result = EvalTool
378 .execute("c1", json!({"language": "py"}), None, &ctx())
379 .await;
380 assert!(result.is_err());
381 assert!(result.unwrap_err().contains("code"));
382 }
383
384 #[tokio::test]
385 async fn rejects_empty_code() {
386 let result = EvalTool
387 .execute("c2", json!({"code": " \n\t "}), None, &ctx())
388 .await;
389 assert!(result.is_err());
390 }
391
392 #[tokio::test]
393 async fn rejects_unknown_language() {
394 let result = EvalTool
395 .execute("c3", json!({"code": "x", "language": "ruby"}), None, &ctx())
396 .await;
397 assert!(result.is_err());
398 assert!(result.unwrap_err().contains("ruby"));
399 }
400
401 #[tokio::test]
402 async fn executes_python_code() {
403 let result = EvalTool
404 .execute(
405 "c4",
406 json!({"code": "print(1+1)", "language": "py"}),
407 None,
408 &ctx(),
409 )
410 .await
411 .expect("py execution should succeed");
412 assert!(result.success);
413 assert!(result.output.contains("2"), "output: {}", result.output);
414 }
415
416 #[tokio::test]
417 async fn executes_js_code() {
418 let result = EvalTool
419 .execute(
420 "c5",
421 json!({"code": "console.log(2+2)", "language": "js"}),
422 None,
423 &ctx(),
424 )
425 .await;
426 if let Ok(result) = result {
428 assert!(result.success);
429 assert!(result.output.contains("4"), "output: {}", result.output);
430 }
431 }
432
433 #[tokio::test]
434 async fn captures_stderr() {
435 let result = EvalTool
436 .execute(
437 "c6",
438 json!({"code": "import sys; print('ok', file=sys.stderr); print('stdout')", "language": "py"}),
439 None,
440 &ctx(),
441 )
442 .await
443 .expect("py execution should succeed");
444 assert!(result.success);
445 assert!(
446 result.output.contains("stdout"),
447 "output: {}",
448 result.output
449 );
450 }
451}