Skip to main content

oxi_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            "oxi_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            _ => unreachable!(),
143        };
144
145        let output = match tokio::process::Command::new(runner)
146            .arg(tmp.to_str().unwrap_or(""))
147            .output()
148            .await
149        {
150            Ok(o) => o,
151            Err(e) => {
152                // Clean up temp file.
153                let _ = tokio::fs::remove_file(&tmp).await;
154                return Ok(AgentToolResult::error(format!(
155                    "Failed to execute code via {}: {}",
156                    runner, e
157                )));
158            }
159        };
160
161        // Clean up temp file.
162        let _ = tokio::fs::remove_file(&tmp).await;
163
164        let mut result_parts = Vec::new();
165
166        if !output.stdout.is_empty() {
167            let stdout = String::from_utf8_lossy(&output.stdout);
168            if !stdout.trim().is_empty() {
169                result_parts.push(format!("── stdout ──\n{}", stdout.trim()));
170            }
171        }
172
173        if !output.stderr.is_empty() {
174            let stderr = String::from_utf8_lossy(&output.stderr);
175            if !stderr.trim().is_empty() {
176                result_parts.push(format!("── stderr ──\n{}", stderr.trim()));
177            }
178        }
179
180        let exit_code = output.status.code().unwrap_or(-1);
181
182        if exit_code != 0 {
183            result_parts.push(format!("── exit code: {} ──", exit_code));
184        }
185
186        let result_text = if result_parts.is_empty() {
187            "Code executed successfully (exit code 0, no output)".to_string()
188        } else {
189            result_parts.join("\n")
190        };
191
192        Ok(AgentToolResult::success(result_text))
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use serde_json::json;
200
201    fn ctx() -> ToolContext {
202        ToolContext::default()
203    }
204
205    #[tokio::test]
206    async fn rejects_missing_code() {
207        let result = EvalTool
208            .execute("c1", json!({"language": "py"}), None, &ctx())
209            .await;
210        assert!(result.is_err());
211        assert!(result.unwrap_err().contains("code"));
212    }
213
214    #[tokio::test]
215    async fn rejects_empty_code() {
216        let result = EvalTool
217            .execute("c2", json!({"code": "   \n\t  "}), None, &ctx())
218            .await;
219        assert!(result.is_err());
220    }
221
222    #[tokio::test]
223    async fn rejects_unknown_language() {
224        let result = EvalTool
225            .execute("c3", json!({"code": "x", "language": "ruby"}), None, &ctx())
226            .await;
227        assert!(result.is_err());
228        assert!(result.unwrap_err().contains("ruby"));
229    }
230
231    #[tokio::test]
232    async fn executes_python_code() {
233        let result = EvalTool
234            .execute(
235                "c4",
236                json!({"code": "print(1+1)", "language": "py"}),
237                None,
238                &ctx(),
239            )
240            .await
241            .expect("py execution should succeed");
242        assert!(result.success);
243        assert!(result.output.contains("2"), "output: {}", result.output);
244    }
245
246    #[tokio::test]
247    async fn executes_js_code() {
248        let result = EvalTool
249            .execute(
250                "c5",
251                json!({"code": "console.log(2+2)", "language": "js"}),
252                None,
253                &ctx(),
254            )
255            .await;
256        // js may be unavailable (no bun/node); error is acceptable.
257        if let Ok(result) = result {
258            assert!(result.success);
259            assert!(result.output.contains("4"), "output: {}", result.output);
260        }
261    }
262
263    #[tokio::test]
264    async fn captures_stderr() {
265        let result = EvalTool
266            .execute(
267                "c6",
268                json!({"code": "import sys; print('ok', file=sys.stderr); print('stdout')", "language": "py"}),
269                None,
270                &ctx(),
271            )
272            .await
273            .expect("py execution should succeed");
274        assert!(result.success);
275        assert!(
276            result.output.contains("stdout"),
277            "output: {}",
278            result.output
279        );
280    }
281}