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