Skip to main content

vtcode_core/exec/
code_executor.rs

1//! Code execution environment for agents using MCP tools programmatically.
2//!
3//! This module allows agents to write and execute code snippets that interact with
4//! MCP tools as library functions, rather than making individual tool calls. This
5//! improves efficiency through:
6//!
7//! - Control flow: loops, conditionals, error handling without repeated model calls
8//! - Data filtering: process results before returning to model
9//! - Latency: code runs locally with direct process execution
10//! - Context: intermediate results stay local unless explicitly logged
11//!
12//! # Example
13//!
14//! ```ignore
15//! let executor = CodeExecutor::new(
16//!     Language::Python3,
17//!     Arc::new(mcp_client),
18//!     PathBuf::from("/workspace"),
19//! );
20//!
21//! // Agent writes Python code
22//! let code = r#"
23//! files = list_files(path="/workspace", recursive=True)
24//! filtered = [f for f in files if "test" in f]
25//! result = {"count": len(filtered), "files": filtered[:10]}
26//! "#;
27//!
28//! let result = executor.execute(code).await?;
29//! ```
30
31use crate::exec::async_command::{AsyncProcessRunner, ProcessOptions, StreamCaptureConfig};
32use crate::exec::sdk_ipc::{ToolIpcHandler, ToolResponse};
33use crate::mcp::McpToolExecutor;
34use crate::utils::async_utils;
35use crate::utils::file_utils::{ensure_dir_exists, write_file_with_context};
36use anyhow::{Context, Result};
37use hashbrown::HashMap;
38use serde_json::Value;
39use std::ffi::OsString;
40use std::path::PathBuf;
41use std::sync::Arc;
42use std::time::{Duration, Instant};
43use tokio::task::JoinHandle;
44use tracing::{debug, info};
45
46/// Supported languages for code execution.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum Language {
49    Python3,
50    JavaScript,
51}
52
53impl Language {
54    pub fn as_str(&self) -> &'static str {
55        match self {
56            Self::Python3 => "python3",
57            Self::JavaScript => "javascript",
58        }
59    }
60
61    pub fn interpreter(&self) -> &'static str {
62        match self {
63            Self::Python3 => "python3",
64            Self::JavaScript => "node",
65        }
66    }
67
68    /// Get the best available Python interpreter (venv > uv > system)
69    pub fn detect_python_interpreter(workspace_root: &std::path::Path) -> String {
70        // 1. Check for activated venv
71        if let Ok(venv_python) = std::env::var("VIRTUAL_ENV") {
72            let venv_bin = PathBuf::from(venv_python).join("bin").join("python");
73            if venv_bin.exists() {
74                debug!("Using venv Python: {:?}", venv_bin);
75                return venv_bin.to_string_lossy().into_owned();
76            }
77        }
78
79        // 2. Check for .venv in workspace
80        let workspace_venv = workspace_root.join(".venv").join("bin").join("python");
81        if workspace_venv.exists() {
82            debug!("Using workspace .venv Python: {:?}", workspace_venv);
83            return workspace_venv.to_string_lossy().into_owned();
84        }
85
86        // 3. Prefer a direct system python3 before shelling through uv.
87        if let Ok(system_python) = which::which("python3") {
88            debug!("Using system python3: {:?}", system_python);
89            return system_python.to_string_lossy().into_owned();
90        }
91
92        // 4. Check for uv in PATH
93        if which::which("uv").is_ok() {
94            debug!("Using uv for Python execution");
95            return "uv".to_string();
96        }
97
98        // 5. Fall back to the plain python3 name for environments where
99        // path lookup is restricted but the interpreter is still invocable.
100        debug!("Using system python3");
101        "python3".to_string()
102    }
103}
104
105/// Result of code execution.
106#[derive(Debug, Clone, serde::Serialize)]
107pub struct ExecutionResult {
108    /// Exit code from the process
109    pub exit_code: i32,
110    /// Standard output from the code
111    pub stdout: String,
112    /// Standard error output
113    pub stderr: String,
114    /// Parsed JSON result if available (from `result = {...}` in code)
115    pub json_result: Option<Value>,
116    /// Total execution time in milliseconds
117    pub duration_ms: u128,
118}
119
120/// Configuration for code execution.
121#[derive(Debug, Clone)]
122pub struct ExecutionConfig {
123    /// Maximum execution time in seconds (soft limit, code can exceed)
124    pub timeout_secs: u64,
125    /// Maximum output size in bytes before truncation
126    pub max_output_bytes: usize,
127}
128
129impl Default for ExecutionConfig {
130    fn default() -> Self {
131        Self {
132            timeout_secs: 30,
133            max_output_bytes: 10 * 1024 * 1024, // 10 MB
134        }
135    }
136}
137
138/// Code executor for running agent code.
139pub struct CodeExecutor {
140    language: Language,
141    mcp_client: Arc<dyn McpToolExecutor>,
142    config: ExecutionConfig,
143    workspace_root: PathBuf,
144    enable_pii_protection: bool,
145}
146
147impl CodeExecutor {
148    /// Create a new code executor.
149    pub fn new(
150        language: Language,
151        mcp_client: Arc<dyn McpToolExecutor>,
152        workspace_root: PathBuf,
153    ) -> Self {
154        Self {
155            language,
156            mcp_client,
157            config: ExecutionConfig::default(),
158            workspace_root,
159            enable_pii_protection: false,
160        }
161    }
162
163    /// Set custom execution configuration.
164    pub fn with_config(mut self, config: ExecutionConfig) -> Self {
165        self.config = config;
166        self
167    }
168
169    /// Enable PII (Personally Identifiable Information) protection.
170    ///
171    /// When enabled, the executor will automatically tokenize sensitive data
172    /// in MCP tool calls to prevent accidental exposure.
173    pub fn with_pii_protection(mut self, enabled: bool) -> Self {
174        self.enable_pii_protection = enabled;
175        self
176    }
177
178    /// Execute code snippet and return result.
179    ///
180    /// # Arguments
181    /// * `code` - Code snippet to execute (Python 3 or JavaScript)
182    ///
183    /// # Returns
184    /// Execution result with output, exit code, and optional JSON result
185    ///
186    /// The code can access MCP tools as library functions. Any `result = {...}`
187    /// assignment at the module level will be captured as JSON output.
188    pub async fn execute(&self, code: &str) -> Result<ExecutionResult> {
189        info!(
190            language = self.language.as_str(),
191            timeout_secs = self.config.timeout_secs,
192            "Executing code snippet"
193        );
194
195        let start = Instant::now();
196
197        // Set up IPC directory for tool invocation
198        let ipc_dir = self.workspace_root.join(".vtcode").join("ipc");
199        ensure_dir_exists(&ipc_dir).await?;
200
201        // Generate the SDK wrapper
202        let sdk = self
203            .generate_sdk()
204            .await
205            .context("failed to generate SDK")?;
206
207        // Prepare the complete code with SDK
208        let complete_code = match self.language {
209            Language::Python3 => self.prepare_python_code(&sdk, code)?,
210            Language::JavaScript => self.prepare_javascript_code(&sdk, code)?,
211        };
212
213        // Write code to temporary file in workspace
214        // Use code_temp as a directory, not a file
215        let code_temp_dir = self.workspace_root.join(".vtcode").join("code_temp");
216        ensure_dir_exists(&code_temp_dir).await?;
217
218        // Use a unique temp file name based on timestamp
219        let timestamp = std::time::SystemTime::now()
220            .duration_since(std::time::UNIX_EPOCH)
221            .unwrap_or_default()
222            .as_micros();
223        let ext = match self.language {
224            Language::Python3 => "py",
225            Language::JavaScript => "js",
226        };
227        let code_file = code_temp_dir.join(format!("exec_{}.{}", timestamp, ext));
228
229        write_file_with_context(&code_file, &complete_code, "temporary code file").await?;
230
231        debug!(
232            language = self.language.as_str(),
233            code_file = ?code_file,
234            "Wrote code to temporary file"
235        );
236
237        // Execute code via ProcessRunner with timeout
238        let mut env = HashMap::new();
239
240        // Set IPC directory for tool invocation
241        env.insert(
242            OsString::from("VTCODE_IPC_DIR"),
243            OsString::from(&*ipc_dir.to_string_lossy()),
244        );
245
246        // Spawn IPC handler task that will process tool requests from code
247        let mut ipc_handler = ToolIpcHandler::new(ipc_dir.clone());
248        if self.enable_pii_protection {
249            ipc_handler.enable_pii_protection()?;
250        }
251        let mcp_client = self.mcp_client.clone();
252        let execution_timeout = Duration::from_secs(self.config.timeout_secs);
253
254        let ipc_task: JoinHandle<Result<()>> = tokio::spawn(async move {
255            let ipc_start = Instant::now();
256
257            loop {
258                let Some(remaining_timeout) = execution_timeout.checked_sub(ipc_start.elapsed())
259                else {
260                    break;
261                };
262
263                let Some(mut request) = ipc_handler.wait_for_request(remaining_timeout).await?
264                else {
265                    break;
266                };
267
268                debug!(
269                    tool_name = %request.tool_name,
270                    request_id = %request.id,
271                    "Processing tool request from code"
272                );
273
274                // Process request for PII protection (tokenize if enabled)
275                if let Err(e) = ipc_handler.process_request_for_pii(&mut request) {
276                    debug!(error = %e, "PII tokenization failed");
277                    let response = ToolResponse {
278                        id: request.id,
279                        success: false,
280                        result: None,
281                        error: Some(format!("PII processing error: {}", e)),
282                        duration_ms: None,
283                        cache_hit: None,
284                    };
285                    ipc_handler.write_response(response).await?;
286                    continue;
287                }
288
289                // Execute the tool
290                let result = match mcp_client
291                    .execute_mcp_tool(&request.tool_name, &request.args)
292                    .await
293                {
294                    Ok(result) => {
295                        debug!(tool_name = %request.tool_name, "Tool executed successfully");
296                        ToolResponse {
297                            id: request.id,
298                            success: true,
299                            result: Some(result),
300                            error: None,
301                            duration_ms: None,
302                            cache_hit: None,
303                        }
304                    }
305                    Err(e) => {
306                        debug!(
307                            tool_name = %request.tool_name,
308                            error = %e,
309                            "Tool execution failed"
310                        );
311                        ToolResponse {
312                            id: request.id,
313                            success: false,
314                            result: None,
315                            error: Some(e.to_string()),
316                            duration_ms: None,
317                            cache_hit: None,
318                        }
319                    }
320                };
321
322                // Write response (de-tokenizes if enabled)
323                ipc_handler.write_response(result).await?;
324            }
325
326            Ok(())
327        });
328
329        // Detect best Python interpreter for safe execution
330        let (program, args) = match self.language {
331            Language::Python3 => {
332                let interpreter = Language::detect_python_interpreter(&self.workspace_root);
333                if interpreter == "uv" {
334                    // uv run python script.py
335                    (
336                        "uv".to_string(),
337                        vec![
338                            "run".to_string(),
339                            "python".to_string(),
340                            code_file.to_string_lossy().into_owned(),
341                        ],
342                    )
343                } else {
344                    (interpreter, vec![code_file.to_string_lossy().into_owned()])
345                }
346            }
347            Language::JavaScript => (
348                self.language.interpreter().to_string(),
349                vec![code_file.to_string_lossy().into_owned()],
350            ),
351        };
352
353        let options = ProcessOptions {
354            program,
355            args,
356            env,
357            current_dir: Some(self.workspace_root.clone()),
358            timeout: Some(Duration::from_secs(self.config.timeout_secs)),
359            cancellation_token: None,
360            stdout: StreamCaptureConfig {
361                capture: true,
362                max_bytes: self.config.max_output_bytes,
363            },
364            stderr: StreamCaptureConfig {
365                capture: true,
366                max_bytes: self.config.max_output_bytes,
367            },
368        };
369
370        let process_output = AsyncProcessRunner::run(options)
371            .await
372            .context("failed to execute code")?;
373
374        let duration_ms = start.elapsed().as_millis();
375
376        // Parse output - only allocate if needed
377        let stdout = String::from_utf8_lossy(&process_output.stdout).into_owned();
378        let stderr = String::from_utf8_lossy(&process_output.stderr).into_owned();
379
380        // Extract JSON result if present
381        let json_result = self.extract_json_result(&stdout, self.language)?;
382
383        // Clean up temp files
384        let _ = tokio::fs::remove_file(&code_file).await;
385        let _ = tokio::fs::remove_dir_all(&ipc_dir).await;
386
387        // Wait for IPC task to complete (with timeout)
388        let ipc_result =
389            async_utils::with_timeout(ipc_task, Duration::from_secs(1), "IPC handler task").await;
390
391        if let Err(e) = ipc_result {
392            debug!(error = %e, "IPC handler did not complete in time");
393        }
394
395        debug!(
396            exit_code = process_output.exit_status.code().unwrap_or(-1),
397            duration_ms,
398            has_json_result = json_result.is_some(),
399            "Code execution completed"
400        );
401
402        Ok(ExecutionResult {
403            exit_code: process_output.exit_status.code().unwrap_or(-1),
404            stdout,
405            stderr,
406            json_result,
407            duration_ms,
408        })
409    }
410
411    /// Prepare Python code with SDK and user code.
412    fn prepare_python_code(&self, sdk: &str, user_code: &str) -> Result<String> {
413        Ok(format!(
414            "{}\n\n# User code\n{}\n\n# Capture result\nimport json\nif 'result' in dir():\n    print('__JSON_RESULT__')\n    print(json.dumps(result, default=str))\n    print('__END_JSON__')",
415            sdk, user_code
416        ))
417    }
418
419    /// Prepare JavaScript code with SDK and user code.
420    fn prepare_javascript_code(&self, sdk: &str, user_code: &str) -> Result<String> {
421        Ok(format!(
422            "{}\n\n// User code\n(async () => {{\n{}\n\n// Capture result\nif (typeof result !== 'undefined') {{\n  console.log('__JSON_RESULT__');\n  console.log(JSON.stringify(result, null, 2));\n  console.log('__END_JSON__');\n}}\n}})();\n",
423            sdk, user_code
424        ))
425    }
426
427    /// Extract JSON result from stdout between markers.
428    fn extract_json_result(&self, stdout: &str, _language: Language) -> Result<Option<Value>> {
429        if !stdout.contains("__JSON_RESULT__") {
430            return Ok(None);
431        }
432
433        let start_marker = "__JSON_RESULT__";
434        let end_marker = "__END_JSON__";
435
436        let start = match stdout.find(start_marker) {
437            Some(pos) => pos + start_marker.len(),
438            None => return Ok(None),
439        };
440
441        let end = match stdout[start..].find(end_marker) {
442            Some(pos) => start + pos,
443            None => return Ok(None),
444        };
445
446        let json_str = stdout[start..end].trim();
447
448        match serde_json::from_str::<Value>(json_str) {
449            Ok(value) => {
450                debug!("Extracted JSON result from code output");
451                Ok(Some(value))
452            }
453            Err(e) => {
454                debug!(error = %e, "Failed to parse JSON result");
455                Ok(None)
456            }
457        }
458    }
459
460    /// Generate SDK module imports for the target language.
461    pub async fn generate_sdk(&self) -> Result<String> {
462        match self.language {
463            Language::Python3 => self.generate_python_sdk().await,
464            Language::JavaScript => self.generate_javascript_sdk().await,
465        }
466    }
467
468    /// Generate Python SDK with MCP tool wrappers.
469    async fn generate_python_sdk(&self) -> Result<String> {
470        debug!("Generating Python SDK for MCP tools");
471
472        let tools = self
473            .mcp_client
474            .list_mcp_tools()
475            .await
476            .context("failed to list MCP tools")?;
477
478        let mut sdk = String::from(
479            r#"# MCP Tools SDK - Auto-generated
480import json
481import sys
482import os
483import time
484from typing import Any, Dict, Optional
485from uuid import uuid4
486
487class MCPTools:
488    """Interface to MCP tools from agent code via file-based IPC."""
489
490    IPC_DIR = os.environ.get("VTCODE_IPC_DIR", "/tmp/vtcode_ipc")
491
492    def __init__(self):
493        self._call_count = 0
494        self._results = []
495        os.makedirs(self.IPC_DIR, exist_ok=True)
496
497    def _call_tool(self, name: str, args: Dict[str, Any]) -> Any:
498        """Call an MCP tool via file-based IPC."""
499        request_id = str(uuid4())
500
501        # Write request
502        request = {
503            "id": request_id,
504            "tool_name": name,
505            "args": args
506        }
507        request_file = os.path.join(self.IPC_DIR, "request.json")
508        with open(request_file, 'w') as f:
509            json.dump(request, f)
510
511        # Wait for response
512        response_file = os.path.join(self.IPC_DIR, "response.json")
513        timeout = 30
514        start = time.time()
515        while time.time() - start < timeout:
516            if os.path.exists(response_file):
517                with open(response_file, 'r') as f:
518                    response = json.load(f)
519
520                if response.get("id") == request_id:
521                    # Clean up response
522                    try:
523                        os.remove(response_file)
524                    except:
525                        pass
526
527                    if response.get("success"):
528                        return response.get("result")
529                    else:
530                        raise RuntimeError(f"Tool error: {response.get('error', 'unknown error')}")
531
532            time.sleep(0.1)
533
534        raise TimeoutError(f"Tool '{name}' timed out after {timeout}s")
535
536    def log(self, message: str) -> None:
537        """Log a message that will be captured."""
538        print(f"[LOG] {message}")
539
540# Initialize tools interface
541mcp = MCPTools()
542"#,
543        );
544
545        // Generate wrapper methods for each tool
546        for tool in tools {
547            sdk.push_str(&format!(
548                "\ndef {}(**kwargs):\n    \"\"\"{}.\"\"\"\n    return mcp._call_tool('{}', kwargs)\n\n",
549                sanitize_function_name(&tool.name), tool.description, tool.name
550            ));
551        }
552
553        Ok(sdk)
554    }
555
556    /// Generate JavaScript SDK with MCP tool wrappers.
557    async fn generate_javascript_sdk(&self) -> Result<String> {
558        debug!("Generating JavaScript SDK for MCP tools");
559
560        let tools = self
561            .mcp_client
562            .list_mcp_tools()
563            .await
564            .context("failed to list MCP tools")?;
565
566        let mut sdk = String::from(
567            r#"// MCP Tools SDK - Auto-generated
568const fs = require('fs');
569const path = require('path');
570const { v4: uuid4 } = require('uuid');
571
572class MCPTools {
573  constructor() {
574    this.callCount = 0;
575    this.results = [];
576    this.ipcDir = process.env.VTCODE_IPC_DIR || '/tmp/vtcode_ipc';
577    if (!fs.existsSync(this.ipcDir)) {
578      fs.mkdirSync(this.ipcDir, { recursive: true });
579    }
580  }
581
582  async callTool(name, args = {}) {
583    const requestId = uuid4();
584    const request = {
585      id: requestId,
586      tool_name: name,
587      args: args
588    };
589
590    const requestFile = path.join(this.ipcDir, 'request.json');
591    fs.writeFileSync(requestFile, JSON.stringify(request, null, 2));
592
593    // Wait for response
594    const responseFile = path.join(this.ipcDir, 'response.json');
595    const timeout = 30000; // 30s
596    const start = Date.now();
597
598    while (Date.now() - start < timeout) {
599      try {
600        if (fs.existsSync(responseFile)) {
601          const response = JSON.parse(fs.readFileSync(responseFile, 'utf-8'));
602
603          if (response.id === requestId) {
604            // Clean up response
605            try {
606              fs.unlinkSync(responseFile);
607            } catch (e) {}
608
609            if (response.success) {
610              return response.result;
611            } else {
612              throw new Error(`Tool error: ${response.error || 'unknown error'}`);
613            }
614          }
615        }
616      } catch (e) {
617        if (e.code !== 'ENOENT') throw e;
618      }
619
620      await new Promise(r => setTimeout(r, 100));
621    }
622
623    throw new Error(`Tool '${name}' timed out after ${timeout}ms`);
624  }
625
626  log(message) {
627    console.log(`[LOG] ${message}`);
628  }
629}
630
631const mcp = new MCPTools();
632
633"#,
634        );
635
636        // Generate wrapper functions for each tool
637        for tool in tools {
638            sdk.push_str(&format!(
639                "async function {}(args = {{}}) {{\n  // {}\n  return await mcp.callTool('{}', args);\n}}\n\n",
640                sanitize_function_name(&tool.name), tool.description, tool.name
641            ));
642        }
643
644        Ok(sdk)
645    }
646
647    /// Get the workspace root path.
648    pub fn workspace_root(&self) -> &PathBuf {
649        &self.workspace_root
650    }
651
652    /// Borrow the MCP client without exposing shared ownership.
653    pub fn mcp_client_ref(&self) -> &dyn McpToolExecutor {
654        self.mcp_client.as_ref()
655    }
656
657    /// Get the shared MCP client handle for callers that need to clone it.
658    pub fn mcp_client(&self) -> &Arc<dyn McpToolExecutor> {
659        &self.mcp_client
660    }
661}
662
663/// Sanitize tool name to valid function name.
664fn sanitize_function_name(name: &str) -> String {
665    name.chars()
666        .map(|c| {
667            if c.is_ascii_alphanumeric() || c == '_' {
668                c
669            } else {
670                '_'
671            }
672        })
673        .collect()
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679    use crate::mcp::{McpClientStatus, McpToolExecutor, McpToolInfo};
680    use async_trait::async_trait;
681    use serde_json::{Value, json};
682    use std::path::PathBuf;
683    use std::sync::Arc;
684
685    struct MockMcpToolExecutor;
686
687    #[async_trait]
688    impl McpToolExecutor for MockMcpToolExecutor {
689        async fn execute_mcp_tool(&self, _tool_name: &str, _args: &Value) -> Result<Value> {
690            Ok(json!({}))
691        }
692
693        async fn list_mcp_tools(&self) -> Result<Vec<McpToolInfo>> {
694            Ok(vec![McpToolInfo {
695                name: "read_file".to_string(),
696                description: "Read a file".to_string(),
697                provider: "test".to_string(),
698                input_schema: json!({}),
699            }])
700        }
701
702        async fn has_mcp_tool(&self, _tool_name: &str) -> Result<bool> {
703            Ok(true)
704        }
705
706        fn get_status(&self) -> McpClientStatus {
707            McpClientStatus {
708                enabled: true,
709                provider_count: 1,
710                active_connections: 1,
711                configured_providers: vec!["test".to_string()],
712            }
713        }
714    }
715
716    fn test_executor(language: Language) -> CodeExecutor {
717        CodeExecutor::new(
718            language,
719            Arc::new(MockMcpToolExecutor),
720            PathBuf::from("/workspace"),
721        )
722    }
723
724    #[test]
725    fn sanitize_function_name_handles_special_chars() {
726        assert_eq!(sanitize_function_name("read_file"), "read_file");
727        assert_eq!(sanitize_function_name("read-file"), "read_file");
728        assert_eq!(sanitize_function_name("read.file"), "read_file");
729        assert_eq!(sanitize_function_name("readFile123"), "readFile123");
730    }
731
732    #[test]
733    fn language_as_str() {
734        assert_eq!(Language::Python3.as_str(), "python3");
735        assert_eq!(Language::JavaScript.as_str(), "javascript");
736    }
737
738    #[test]
739    fn language_interpreter() {
740        assert_eq!(Language::Python3.interpreter(), "python3");
741        assert_eq!(Language::JavaScript.interpreter(), "node");
742    }
743
744    #[tokio::test]
745    async fn python_sdk_uses_ipc_dir_only() {
746        let sdk = test_executor(Language::Python3)
747            .generate_sdk()
748            .await
749            .expect("python sdk should generate");
750
751        assert!(sdk.contains("VTCODE_IPC_DIR"));
752        assert!(!sdk.contains("VTCODE_WORKSPACE"));
753    }
754
755    #[tokio::test]
756    async fn javascript_sdk_uses_ipc_dir_only() {
757        let sdk = test_executor(Language::JavaScript)
758            .generate_sdk()
759            .await
760            .expect("javascript sdk should generate");
761
762        assert!(sdk.contains("VTCODE_IPC_DIR"));
763        assert!(!sdk.contains("VTCODE_WORKSPACE"));
764    }
765}