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 tokio::sync::oneshot;
15
16use super::{AgentTool, AgentToolResult, ToolContext, ToolError, ToolExecutionMode};
17
18/// `eval` agent tool — run code and capture output.
19pub struct EvalTool;
20
21#[async_trait]
22impl AgentTool for EvalTool {
23    fn name(&self) -> &str {
24        "eval"
25    }
26
27    fn label(&self) -> &str {
28        "Eval"
29    }
30
31    fn description(&self) -> &str {
32        "Execute code in Python (`py`) or JavaScript (`js`) and capture \
33         stdout, stderr, and the return value. Each call runs in a fresh \
34         process — state does NOT persist across calls. Use `reset: true` \
35         to discard previous state explicitly.\n\n\
36         For interactive or multi-step sessions, prefer the `bash` tool \
37         with `python3 -i` or `bun -i` for persistent state. Use `eval` \
38         for quick one-shot computations where only the output matters."
39    }
40
41    fn essential(&self) -> bool {
42        false
43    }
44
45    fn parameters_schema(&self) -> Value {
46        json!({
47            "type": "object",
48            "properties": {
49                "language": {
50                    "type": "string",
51                    "enum": ["py", "js"],
52                    "description": "Language runtime: `py` (Python 3) or `js` (JavaScript / Bun)",
53                    "default": "py"
54                },
55                "code": {
56                    "type": "string",
57                    "description": "Code to execute. Imports and variable definitions persist across calls."
58                },
59                "title": {
60                    "type": "string",
61                    "description": "Optional cell label for readability in the transcript"
62                },
63                "reset": {
64                    "type": "boolean",
65                    "description": "Reset the kernel/session before executing this cell",
66                    "default": false
67                }
68            },
69            "required": ["code"]
70        })
71    }
72
73    fn intent(&self) -> Option<&str> {
74        Some("Execute code and capture output")
75    }
76
77    fn execution_mode(&self) -> ToolExecutionMode {
78        ToolExecutionMode::SequentialOnly
79    }
80
81    async fn execute(
82        &self,
83        _tool_call_id: &str,
84        params: Value,
85        _signal: Option<oneshot::Receiver<()>>,
86        _ctx: &ToolContext,
87    ) -> Result<AgentToolResult, ToolError> {
88        let code = params
89            .get("code")
90            .and_then(|v| v.as_str())
91            .ok_or_else(|| "Missing required parameter: code".to_string())?;
92
93        if code.trim().is_empty() {
94            return Err("Parameter `code` must be a non-empty string".to_string());
95        }
96
97        let language = params
98            .get("language")
99            .and_then(|v| v.as_str())
100            .unwrap_or("py");
101
102        let _title = params.get("title").and_then(|v| v.as_str());
103
104        let _reset = params
105            .get("reset")
106            .and_then(|v| v.as_bool())
107            .unwrap_or(false);
108
109        // Write code to a temp file and execute it.
110        let tmp = std::env::temp_dir().join(format!(
111            "oxicode_eval_{}.{}",
112            std::time::SystemTime::now()
113                .duration_since(std::time::UNIX_EPOCH)
114                .unwrap_or_default()
115                .as_nanos(),
116            match language {
117                "py" => "py",
118                "js" => "mjs",
119                other => return Err(format!("Unsupported language: {}", other)),
120            }
121        ));
122
123        if let Err(e) = tokio::fs::write(&tmp, code).await {
124            return Ok(AgentToolResult::error(format!(
125                "Failed to write temp file: {}",
126                e
127            )));
128        }
129
130        let runner = match language {
131            "py" => "python3",
132            "js" => {
133                // Check if bun is available, fall back to node
134                let has_bun = tokio::process::Command::new("which")
135                    .arg("bun")
136                    .output()
137                    .await
138                    .map(|o| o.status.success())
139                    .unwrap_or(false);
140                if has_bun { "bun" } else { "node" }
141            }
142            other => {
143                return Ok(AgentToolResult::error(format!(
144                    "Unsupported language: '{}'. Supported: py, js",
145                    other
146                )));
147            }
148        };
149
150        let output = match tokio::process::Command::new(runner)
151            .arg(tmp.to_str().unwrap_or(""))
152            .output()
153            .await
154        {
155            Ok(o) => o,
156            Err(e) => {
157                // Clean up temp file.
158                let _ = tokio::fs::remove_file(&tmp).await;
159                return Ok(AgentToolResult::error(format!(
160                    "Failed to execute code via {}: {}",
161                    runner, e
162                )));
163            }
164        };
165
166        // Clean up temp file.
167        let _ = tokio::fs::remove_file(&tmp).await;
168
169        let mut result_parts = Vec::new();
170
171        if !output.stdout.is_empty() {
172            let stdout = String::from_utf8_lossy(&output.stdout);
173            if !stdout.trim().is_empty() {
174                result_parts.push(format!("── stdout ──\n{}", stdout.trim()));
175            }
176        }
177
178        if !output.stderr.is_empty() {
179            let stderr = String::from_utf8_lossy(&output.stderr);
180            if !stderr.trim().is_empty() {
181                result_parts.push(format!("── stderr ──\n{}", stderr.trim()));
182            }
183        }
184
185        let exit_code = output.status.code().unwrap_or(-1);
186
187        if exit_code != 0 {
188            result_parts.push(format!("── exit code: {} ──", exit_code));
189        }
190
191        let result_text = if result_parts.is_empty() {
192            "Code executed successfully (exit code 0, no output)".to_string()
193        } else {
194            result_parts.join("\n")
195        };
196
197        Ok(AgentToolResult::success(result_text))
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use serde_json::json;
205
206    fn ctx() -> ToolContext {
207        ToolContext::default()
208    }
209
210    #[tokio::test]
211    async fn rejects_missing_code() {
212        let result = EvalTool
213            .execute("c1", json!({"language": "py"}), None, &ctx())
214            .await;
215        assert!(result.is_err());
216        assert!(result.unwrap_err().contains("code"));
217    }
218
219    #[tokio::test]
220    async fn rejects_empty_code() {
221        let result = EvalTool
222            .execute("c2", json!({"code": "   \n\t  "}), None, &ctx())
223            .await;
224        assert!(result.is_err());
225    }
226
227    #[tokio::test]
228    async fn rejects_unknown_language() {
229        let result = EvalTool
230            .execute("c3", json!({"code": "x", "language": "ruby"}), None, &ctx())
231            .await;
232        assert!(result.is_err());
233        assert!(result.unwrap_err().contains("ruby"));
234    }
235
236    #[tokio::test]
237    async fn executes_python_code() {
238        let result = EvalTool
239            .execute(
240                "c4",
241                json!({"code": "print(1+1)", "language": "py"}),
242                None,
243                &ctx(),
244            )
245            .await
246            .expect("py execution should succeed");
247        assert!(result.success);
248        assert!(result.output.contains("2"), "output: {}", result.output);
249    }
250
251    #[tokio::test]
252    async fn executes_js_code() {
253        let result = EvalTool
254            .execute(
255                "c5",
256                json!({"code": "console.log(2+2)", "language": "js"}),
257                None,
258                &ctx(),
259            )
260            .await;
261        // js may be unavailable (no bun/node); error is acceptable.
262        if let Ok(result) = result {
263            assert!(result.success);
264            assert!(result.output.contains("4"), "output: {}", result.output);
265        }
266    }
267
268    #[tokio::test]
269    async fn captures_stderr() {
270        let result = EvalTool
271            .execute(
272                "c6",
273                json!({"code": "import sys; print('ok', file=sys.stderr); print('stdout')", "language": "py"}),
274                None,
275                &ctx(),
276            )
277            .await
278            .expect("py execution should succeed");
279        assert!(result.success);
280        assert!(
281            result.output.contains("stdout"),
282            "output: {}",
283            result.output
284        );
285    }
286}