oxi_agent/tools/
eval_tool.rs1use async_trait::async_trait;
13use serde_json::{Value, json};
14use tokio::sync::oneshot;
15
16use super::{AgentTool, AgentToolResult, ToolContext, ToolError, ToolExecutionMode};
17
18pub 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 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 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 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 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 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}