Skip to main content

agent_code_lib/tools/
repl_tool.rs

1//! REPL tool: execute code in a Python or Node.js REPL.
2//!
3//! Spawns an interpreter subprocess, sends code, and captures output.
4//! Useful for data exploration, quick calculations, and testing
5//! code snippets without writing files.
6
7use async_trait::async_trait;
8use serde_json::json;
9use std::process::Stdio;
10use std::time::Duration;
11use tokio::io::AsyncReadExt;
12use tokio::process::Command;
13
14use super::{Tool, ToolContext, ToolResult};
15use crate::error::ToolError;
16
17pub struct ReplTool;
18
19#[async_trait]
20impl Tool for ReplTool {
21    fn name(&self) -> &'static str {
22        "REPL"
23    }
24
25    fn description(&self) -> &'static str {
26        "Execute code in a Python or Node.js interpreter and return the output."
27    }
28
29    fn input_schema(&self) -> serde_json::Value {
30        json!({
31            "type": "object",
32            "required": ["language", "code"],
33            "properties": {
34                "language": {
35                    "type": "string",
36                    "enum": ["python", "node"],
37                    "description": "Interpreter to use"
38                },
39                "code": {
40                    "type": "string",
41                    "description": "Code to execute"
42                },
43                "timeout": {
44                    "type": "integer",
45                    "description": "Timeout in milliseconds (default 30000)",
46                    "default": 30000
47                }
48            }
49        })
50    }
51
52    fn is_read_only(&self) -> bool {
53        false
54    }
55
56    fn is_concurrency_safe(&self) -> bool {
57        false
58    }
59
60    async fn call(
61        &self,
62        input: serde_json::Value,
63        ctx: &ToolContext,
64    ) -> Result<ToolResult, ToolError> {
65        let language = input
66            .get("language")
67            .and_then(|v| v.as_str())
68            .ok_or_else(|| ToolError::InvalidInput("'language' is required".into()))?;
69
70        let code = input
71            .get("code")
72            .and_then(|v| v.as_str())
73            .ok_or_else(|| ToolError::InvalidInput("'code' is required".into()))?;
74
75        let timeout_ms = input
76            .get("timeout")
77            .and_then(|v| v.as_u64())
78            .unwrap_or(30_000)
79            .min(120_000);
80
81        let (cmd, flag) = match language {
82            "python" => ("python3", "-c"),
83            "node" => ("node", "-e"),
84            other => {
85                return Err(ToolError::InvalidInput(format!(
86                    "Unsupported language '{other}'. Use 'python' or 'node'."
87                )));
88            }
89        };
90
91        let mut child = Command::new(cmd)
92            .arg(flag)
93            .arg(code)
94            .current_dir(&ctx.cwd)
95            .stdout(Stdio::piped())
96            .stderr(Stdio::piped())
97            .spawn()
98            .map_err(|e| {
99                ToolError::ExecutionFailed(format!(
100                    "Failed to start {language} interpreter: {e}. \
101                     Make sure '{cmd}' is installed and in PATH."
102                ))
103            })?;
104
105        let mut stdout_handle = child.stdout.take().unwrap();
106        let mut stderr_handle = child.stderr.take().unwrap();
107        let mut stdout_buf = Vec::new();
108        let mut stderr_buf = Vec::new();
109
110        let timeout = Duration::from_millis(timeout_ms);
111
112        let result = tokio::select! {
113            r = async {
114                let (so, se) = tokio::join!(
115                    async { stdout_handle.read_to_end(&mut stdout_buf).await },
116                    async { stderr_handle.read_to_end(&mut stderr_buf).await },
117                );
118                so?;
119                se?;
120                child.wait().await
121            } => {
122                match r {
123                    Ok(status) => {
124                        let stdout = String::from_utf8_lossy(&stdout_buf).to_string();
125                        let stderr = String::from_utf8_lossy(&stderr_buf).to_string();
126                        let exit_code = status.code().unwrap_or(-1);
127
128                        let mut content = String::new();
129                        if !stdout.is_empty() {
130                            content.push_str(&stdout);
131                        }
132                        if !stderr.is_empty() {
133                            if !content.is_empty() {
134                                content.push('\n');
135                            }
136                            content.push_str(&stderr);
137                        }
138                        if content.is_empty() {
139                            content = "(no output)".to_string();
140                        }
141
142                        Ok(ToolResult {
143                            content,
144                            is_error: exit_code != 0,
145                        })
146                    }
147                    Err(e) => Err(ToolError::ExecutionFailed(e.to_string())),
148                }
149            }
150            _ = tokio::time::sleep(timeout) => {
151                let _ = child.kill().await;
152                Err(ToolError::Timeout(timeout_ms))
153            }
154            _ = ctx.cancel.cancelled() => {
155                let _ = child.kill().await;
156                Err(ToolError::Cancelled)
157            }
158        };
159
160        result
161    }
162}