1mod local;
17
18pub use local::LocalSandbox;
19
20use async_trait::async_trait;
21use serde::{Deserialize, Serialize};
22
23use lc_core::tools::{BaseTool, ToolError};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum Language {
29 Python,
31 JavaScript,
33 Rust,
35}
36
37impl std::fmt::Display for Language {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 match self {
40 Language::Python => write!(f, "python"),
41 Language::JavaScript => write!(f, "javascript"),
42 Language::Rust => write!(f, "rust"),
43 }
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct RunResult {
50 pub stdout: String,
52 pub stderr: String,
54 pub exit_code: i32,
56 pub execution_time_ms: u64,
58}
59
60#[derive(Debug, thiserror::Error)]
62#[non_exhaustive]
63pub enum SandboxError {
64 #[error("execution timeout after {0}ms")]
66 Timeout(u64),
67
68 #[error("sandbox error: {0}")]
70 Runtime(String),
71
72 #[error("language not supported: {0}")]
74 UnsupportedLanguage(String),
75}
76
77#[async_trait]
79pub trait CodeSandbox: Send + Sync {
80 async fn run(
82 &self,
83 code: &str,
84 language: Language,
85 timeout_ms: u64,
86 ) -> Result<RunResult, SandboxError>;
87}
88
89#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
91struct SandboxInput {
92 code: String,
94}
95
96pub struct SandboxTool<S: CodeSandbox> {
98 sandbox: S,
99 language: Language,
100 timeout_ms: u64,
101}
102
103impl<S: CodeSandbox> SandboxTool<S> {
104 pub fn new(sandbox: S, language: Language) -> Self {
106 Self {
107 sandbox,
108 language,
109 timeout_ms: 30_000,
110 }
111 }
112
113 pub fn with_timeout(mut self, ms: u64) -> Self {
115 self.timeout_ms = ms;
116 self
117 }
118}
119
120#[async_trait]
121impl<S: CodeSandbox + 'static> BaseTool for SandboxTool<S> {
122 fn name(&self) -> &str {
123 "code_interpreter"
124 }
125
126 fn description(&self) -> &str {
127 "Execute code in a sandboxed environment. \
128 Input JSON: {\"code\": \"...\"}. \
129 Returns stdout, stderr, exit_code, and execution_time_ms. \
130 Supported languages depend on the sandbox backend."
131 }
132
133 async fn run(&self, input: String) -> Result<String, ToolError> {
134 let parsed: SandboxInput = serde_json::from_str(&input)
135 .map_err(|e| ToolError::InvalidInput(format!("JSON parse error: {}", e)))?;
136
137 if parsed.code.trim().is_empty() {
138 return Err(ToolError::InvalidInput(
139 "code must not be empty".to_string(),
140 ));
141 }
142
143 let result = self
144 .sandbox
145 .run(&parsed.code, self.language, self.timeout_ms)
146 .await
147 .map_err(|e| match e {
148 SandboxError::Timeout(ms) => ToolError::Timeout(ms / 1000),
149 SandboxError::Runtime(msg) => ToolError::ExecutionFailed(msg),
150 SandboxError::UnsupportedLanguage(lang) => {
151 ToolError::InvalidInput(format!("unsupported language: {}", lang))
152 }
153 })?;
154
155 serde_json::to_string_pretty(&result)
156 .map_err(|e| ToolError::ExecutionFailed(format!("failed to serialize result: {}", e)))
157 }
158
159 fn args_schema(&self) -> Option<serde_json::Value> {
160 use schemars::schema_for;
161 serde_json::to_value(schema_for!(SandboxInput)).ok()
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn test_language_display() {
171 assert_eq!(Language::Python.to_string(), "python");
172 assert_eq!(Language::JavaScript.to_string(), "javascript");
173 assert_eq!(Language::Rust.to_string(), "rust");
174 }
175
176 #[test]
177 fn test_language_serde() {
178 let json = serde_json::to_string(&Language::Python).unwrap();
179 assert_eq!(json, "\"python\"");
180
181 let lang: Language = serde_json::from_str("\"javascript\"").unwrap();
182 assert_eq!(lang, Language::JavaScript);
183 }
184
185 #[test]
186 fn test_run_result_serialization() {
187 let result = RunResult {
188 stdout: "hello\n".to_string(),
189 stderr: String::new(),
190 exit_code: 0,
191 execution_time_ms: 42,
192 };
193 let json = serde_json::to_string(&result).unwrap();
194 let parsed: RunResult = serde_json::from_str(&json).unwrap();
195 assert_eq!(parsed.stdout, "hello\n");
196 assert_eq!(parsed.exit_code, 0);
197 assert_eq!(parsed.execution_time_ms, 42);
198 }
199
200 #[test]
201 fn test_sandbox_error_display() {
202 let err = SandboxError::Timeout(5000);
203 assert!(err.to_string().contains("5000ms"));
204
205 let err = SandboxError::Runtime("crashed".to_string());
206 assert!(err.to_string().contains("crashed"));
207
208 let err = SandboxError::UnsupportedLanguage("brainfuck".to_string());
209 assert!(err.to_string().contains("brainfuck"));
210 }
211
212 struct MockSandbox;
213
214 #[async_trait]
215 impl CodeSandbox for MockSandbox {
216 async fn run(
217 &self,
218 code: &str,
219 _language: Language,
220 _timeout_ms: u64,
221 ) -> Result<RunResult, SandboxError> {
222 Ok(RunResult {
223 stdout: format!("executed: {}", code),
224 stderr: String::new(),
225 exit_code: 0,
226 execution_time_ms: 1,
227 })
228 }
229 }
230
231 #[tokio::test]
232 async fn test_sandbox_tool_name_and_description() {
233 let tool = SandboxTool::new(MockSandbox, Language::Python);
234 assert_eq!(tool.name(), "code_interpreter");
235 assert!(tool.description().contains("sandbox"));
236 }
237
238 #[tokio::test]
239 async fn test_sandbox_tool_args_schema() {
240 let tool = SandboxTool::new(MockSandbox, Language::Python);
241 let schema = tool.args_schema();
242 assert!(schema.is_some());
243 let schema = schema.unwrap();
244 assert!(schema["properties"]["code"].is_object());
245 }
246
247 #[tokio::test]
248 async fn test_sandbox_tool_run_success() {
249 let tool = SandboxTool::new(MockSandbox, Language::Python);
250 let result = tool.run(r#"{"code": "print(1+1)"}"#.to_string()).await;
251 assert!(result.is_ok());
252 let output: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
253 assert_eq!(output["exit_code"], 0);
254 assert!(output["stdout"].as_str().unwrap().contains("executed"));
255 }
256
257 #[tokio::test]
258 async fn test_sandbox_tool_run_empty_code() {
259 let tool = SandboxTool::new(MockSandbox, Language::Python);
260 let result = tool.run(r#"{"code": " "}"#.to_string()).await;
261 assert!(result.is_err());
262 let err = result.unwrap_err().to_string();
263 assert!(err.contains("empty"));
264 }
265
266 #[tokio::test]
267 async fn test_sandbox_tool_run_invalid_json() {
268 let tool = SandboxTool::new(MockSandbox, Language::Python);
269 let result = tool.run("not json".to_string()).await;
270 assert!(result.is_err());
271 }
272
273 struct TimeoutSandbox;
274
275 #[async_trait]
276 impl CodeSandbox for TimeoutSandbox {
277 async fn run(
278 &self,
279 _code: &str,
280 _language: Language,
281 timeout_ms: u64,
282 ) -> Result<RunResult, SandboxError> {
283 Err(SandboxError::Timeout(timeout_ms))
284 }
285 }
286
287 #[tokio::test]
288 async fn test_sandbox_tool_timeout_maps_to_tool_error() {
289 let tool = SandboxTool::new(TimeoutSandbox, Language::Python).with_timeout(5000);
290 let result = tool
291 .run(r#"{"code": "while True: pass"}"#.to_string())
292 .await;
293 assert!(result.is_err());
294 let err = result.unwrap_err();
295 match err {
296 ToolError::Timeout(secs) => assert_eq!(secs, 5),
297 other => panic!("expected Timeout error, got: {:?}", other),
298 }
299 }
300
301 struct UnsupportedSandbox;
302
303 #[async_trait]
304 impl CodeSandbox for UnsupportedSandbox {
305 async fn run(
306 &self,
307 _code: &str,
308 language: Language,
309 _timeout_ms: u64,
310 ) -> Result<RunResult, SandboxError> {
311 Err(SandboxError::UnsupportedLanguage(language.to_string()))
312 }
313 }
314
315 #[tokio::test]
316 async fn test_sandbox_tool_unsupported_language() {
317 let tool = SandboxTool::new(UnsupportedSandbox, Language::Rust);
318 let result = tool.run(r#"{"code": "fn main(){}"}"#.to_string()).await;
319 assert!(result.is_err());
320 }
321}