Skip to main content

synaptic_e2b/
lib.rs

1//! E2B code execution sandbox integration for Synaptic.
2//!
3//! Provides [`E2BSandboxTool`] which executes code in isolated E2B cloud environments.
4//! Each tool call creates a fresh sandbox, runs the code, and destroys the environment.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use synaptic_e2b::{E2BConfig, E2BSandboxTool};
10//! use synaptic_core::Tool;
11//!
12//! let config = E2BConfig::new("your-api-key")
13//!     .with_template("python")
14//!     .with_timeout(30);
15//! let tool = E2BSandboxTool::new(config);
16//!
17//! let result = tool.call(serde_json::json!({
18//!     "code": "print(sum(range(1, 101)))",
19//!     "language": "python"
20//! })).await?;
21//! // {"stdout": "5050\n", "stderr": "", "exit_code": 0}
22//! ```
23
24use async_trait::async_trait;
25use serde_json::{json, Value};
26use synaptic_core::{SynapticError, Tool};
27
28/// Configuration for the E2B sandbox.
29#[derive(Debug, Clone)]
30pub struct E2BConfig {
31    /// E2B API key.
32    pub api_key: String,
33    /// Sandbox template (e.g. `"base"`, `"python"`, `"nodejs"`).
34    pub template: String,
35    /// Execution timeout in seconds.
36    pub timeout_secs: u64,
37}
38
39impl E2BConfig {
40    /// Create a new config with the given API key and default settings.
41    pub fn new(api_key: impl Into<String>) -> Self {
42        Self {
43            api_key: api_key.into(),
44            template: "base".to_string(),
45            timeout_secs: 30,
46        }
47    }
48
49    /// Set the sandbox template.
50    pub fn with_template(mut self, template: impl Into<String>) -> Self {
51        self.template = template.into();
52        self
53    }
54
55    /// Set the execution timeout in seconds.
56    pub fn with_timeout(mut self, secs: u64) -> Self {
57        self.timeout_secs = secs;
58        self
59    }
60}
61
62/// E2B sandbox tool for executing code in isolated cloud environments.
63///
64/// Creates an ephemeral E2B sandbox per invocation, executes the provided code,
65/// then destroys the sandbox. Supports Python, JavaScript, and Bash.
66pub struct E2BSandboxTool {
67    config: E2BConfig,
68    client: reqwest::Client,
69}
70
71impl E2BSandboxTool {
72    /// Create a new `E2BSandboxTool` with the given configuration.
73    pub fn new(config: E2BConfig) -> Self {
74        Self {
75            config,
76            client: reqwest::Client::new(),
77        }
78    }
79}
80
81#[async_trait]
82impl Tool for E2BSandboxTool {
83    fn name(&self) -> &'static str {
84        "e2b_code_executor"
85    }
86
87    fn description(&self) -> &'static str {
88        "Execute code in an isolated E2B cloud sandbox. Supports Python, JavaScript, and other \
89         languages. Returns stdout, stderr, and exit code."
90    }
91
92    fn parameters(&self) -> Option<Value> {
93        Some(json!({
94            "type": "object",
95            "properties": {
96                "code": {
97                    "type": "string",
98                    "description": "The code to execute"
99                },
100                "language": {
101                    "type": "string",
102                    "enum": ["python", "javascript", "bash"],
103                    "description": "The programming language of the code"
104                }
105            },
106            "required": ["code", "language"]
107        }))
108    }
109
110    async fn call(&self, args: Value) -> Result<Value, SynapticError> {
111        let code = args["code"]
112            .as_str()
113            .ok_or_else(|| SynapticError::Tool("missing 'code' parameter".to_string()))?;
114        let language = args["language"].as_str().unwrap_or("python");
115
116        // Step 1: Create sandbox
117        let create_resp = self
118            .client
119            .post("https://api.e2b.dev/sandboxes")
120            .header("X-API-Key", &self.config.api_key)
121            .header("Content-Type", "application/json")
122            .json(&json!({
123                "template": self.config.template,
124                "timeout": self.config.timeout_secs,
125            }))
126            .send()
127            .await
128            .map_err(|e| SynapticError::Tool(format!("E2B create sandbox: {e}")))?;
129
130        let create_status = create_resp.status().as_u16();
131        let create_body: Value = create_resp
132            .json()
133            .await
134            .map_err(|e| SynapticError::Tool(format!("E2B create parse: {e}")))?;
135
136        if create_status != 200 && create_status != 201 {
137            return Err(SynapticError::Tool(format!(
138                "E2B create sandbox error ({}): {}",
139                create_status, create_body
140            )));
141        }
142
143        let sandbox_id = create_body["sandboxId"]
144            .as_str()
145            .or_else(|| create_body["sandbox_id"].as_str())
146            .ok_or_else(|| SynapticError::Tool("E2B: missing sandbox ID in response".to_string()))?
147            .to_string();
148
149        // Step 2: Execute code
150        let exec_resp = self
151            .client
152            .post(format!(
153                "https://api.e2b.dev/sandboxes/{}/process",
154                sandbox_id
155            ))
156            .header("X-API-Key", &self.config.api_key)
157            .header("Content-Type", "application/json")
158            .json(&json!({
159                "cmd": get_cmd(language, code),
160                "timeout": self.config.timeout_secs,
161            }))
162            .send()
163            .await;
164
165        // Step 3: Always destroy sandbox (best-effort)
166        let _ = self
167            .client
168            .delete(format!("https://api.e2b.dev/sandboxes/{}", sandbox_id))
169            .header("X-API-Key", &self.config.api_key)
170            .send()
171            .await;
172
173        let exec_resp = exec_resp.map_err(|e| SynapticError::Tool(format!("E2B execute: {e}")))?;
174        let exec_body: Value = exec_resp
175            .json()
176            .await
177            .map_err(|e| SynapticError::Tool(format!("E2B execute parse: {e}")))?;
178
179        Ok(json!({
180            "stdout": exec_body["stdout"],
181            "stderr": exec_body["stderr"],
182            "exit_code": exec_body["exitCode"]
183                .as_i64()
184                .unwrap_or_else(|| exec_body["exit_code"].as_i64().unwrap_or(0)),
185        }))
186    }
187}
188
189fn get_cmd(language: &str, code: &str) -> Vec<String> {
190    match language {
191        "python" => vec!["python3".to_string(), "-c".to_string(), code.to_string()],
192        "javascript" | "js" => vec!["node".to_string(), "-e".to_string(), code.to_string()],
193        _ => vec!["bash".to_string(), "-c".to_string(), code.to_string()],
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn config_defaults() {
203        let config = E2BConfig::new("test-key");
204        assert_eq!(config.api_key, "test-key");
205        assert_eq!(config.template, "base");
206        assert_eq!(config.timeout_secs, 30);
207    }
208
209    #[test]
210    fn config_builder() {
211        let config = E2BConfig::new("key")
212            .with_template("python")
213            .with_timeout(60);
214        assert_eq!(config.template, "python");
215        assert_eq!(config.timeout_secs, 60);
216    }
217
218    #[test]
219    fn tool_name() {
220        let tool = E2BSandboxTool::new(E2BConfig::new("key"));
221        assert_eq!(tool.name(), "e2b_code_executor");
222    }
223
224    #[test]
225    fn tool_description_contains_sandbox() {
226        let tool = E2BSandboxTool::new(E2BConfig::new("key"));
227        assert!(tool.description().contains("sandbox") || tool.description().contains("E2B"));
228    }
229
230    #[test]
231    fn tool_parameters() {
232        let tool = E2BSandboxTool::new(E2BConfig::new("key"));
233        let params = tool.parameters().unwrap();
234        assert_eq!(params["type"], "object");
235        assert!(params["properties"]["code"].is_object());
236        assert!(params["properties"]["language"].is_object());
237    }
238
239    #[test]
240    fn get_cmd_python() {
241        let cmd = get_cmd("python", "print('hi')");
242        assert_eq!(cmd[0], "python3");
243        assert_eq!(cmd[1], "-c");
244        assert_eq!(cmd[2], "print('hi')");
245    }
246
247    #[test]
248    fn get_cmd_javascript() {
249        let cmd = get_cmd("javascript", "console.log('hi')");
250        assert_eq!(cmd[0], "node");
251        assert_eq!(cmd[1], "-e");
252    }
253
254    #[test]
255    fn get_cmd_bash() {
256        let cmd = get_cmd("bash", "echo hi");
257        assert_eq!(cmd[0], "bash");
258        assert_eq!(cmd[1], "-c");
259    }
260
261    #[tokio::test]
262    async fn missing_code_returns_error() {
263        let tool = E2BSandboxTool::new(E2BConfig::new("key"));
264        let result = tool.call(json!({"language": "python"})).await;
265        assert!(result.is_err());
266        assert!(result.unwrap_err().to_string().contains("code"));
267    }
268}