Skip to main content

oxicode_agent/tools/
eval_tool.rs

1//! Eval tool — execute code and capture output.
2//!
3//! Provides a multi-language code-execution environment (Python and
4//! JavaScript today). Each call writes code to a temp file, executes
5//! it via the appropriate runtime (`python3` or `bun`/`node`), and
6//! captures stdout/stderr + exit code.
7//!
8//! Each call runs in a fresh process — persistent kernel sessions
9//! across calls are a future enhancement. For interactive or
10//! multi-step sessions, use `bash` with `python3 -i` or `bun -i`.
11
12use 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
19/// `eval` agent tool — run code and capture output.
20pub 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        // Write code to a temp file and execute it.
111        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                // Check if bun is available, fall back to node
135                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                // Clean up temp file.
159                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        // Clean up temp file.
168        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
202/// `eval` agent tool routed through persistent [`EvalKernel`]s.
203///
204/// Schema-compatible with [`EvalTool`]; cells execute in one long-lived
205/// interpreter per language, so imports and variables persist across
206/// calls and `reset: true` actually drops kernel state. The pack picks
207/// this variant when the host provides eval kernels and falls back to
208/// the per-call [`EvalTool`] otherwise.
209pub struct KernelEvalTool {
210    kernels: Vec<Arc<dyn crate::runtime::EvalKernel>>,
211}
212
213impl KernelEvalTool {
214    /// Route cells through `kernels` (one per supported language).
215    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        // One interpreter per language = shared mutable state.
288        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        // js may be unavailable (no bun/node); error is acceptable.
427        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}