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