diff --git a/agent_core/core/impl/action/executor.py b/agent_core/core/impl/action/executor.py index 23706385..1498413e 100644 --- a/agent_core/core/impl/action/executor.py +++ b/agent_core/core/impl/action/executor.py @@ -39,6 +39,70 @@ # Default timeout for action execution (100 minutes, GUI mode might need more time) DEFAULT_ACTION_TIMEOUT = 6000 +# Persistent venv for sandboxed actions (reused across calls) +_PERSISTENT_VENV_DIR: Optional[Path] = None +_PERSISTENT_VENV_LOCK = None # Will be initialized lazily to avoid issues with ProcessPoolExecutor + +# Base packages that must be installed in the sandbox venv (empty - venv isolation is the sandbox) +_SANDBOX_BASE_PACKAGES = [] + + +def _get_persistent_venv_dir() -> Path: + """Get the persistent venv directory path.""" + # Store venv in user's home directory under .craftbot + return Path.home() / ".craftbot" / "sandbox_venv" + + +def _ensure_persistent_venv() -> Path: + """ + Ensure the persistent venv exists and return the path to its Python binary. + Creates the venv lazily on first use. Subsequent calls reuse the existing venv. + Ensures base packages (like RestrictedPython) are installed. + """ + global _PERSISTENT_VENV_DIR + + venv_dir = _get_persistent_venv_dir() + python_bin = ( + venv_dir / "Scripts" / "python.exe" + if os.name == "nt" + else venv_dir / "bin" / "python" + ) + + venv_existed = venv_dir.exists() and python_bin.exists() + + if not venv_existed: + # Create parent directory if needed + venv_dir.parent.mkdir(parents=True, exist_ok=True) + + # Create the venv (only happens once) + logger.info(f"[VENV] Creating persistent sandbox venv at {venv_dir}") + venv.EnvBuilder(with_pip=True).create(venv_dir) + logger.info(f"[VENV] Persistent sandbox venv created successfully") + + _PERSISTENT_VENV_DIR = venv_dir + + # Ensure base packages are installed (check even for existing venvs) + # Use a marker file to avoid checking pip on every call + marker_file = venv_dir / ".base_packages_installed" + if not marker_file.exists() and _SANDBOX_BASE_PACKAGES: + logger.info(f"[VENV] Installing base packages: {_SANDBOX_BASE_PACKAGES}") + try: + result = subprocess.run( + [str(python_bin), "-m", "pip", "install", "--quiet"] + _SANDBOX_BASE_PACKAGES, + capture_output=True, + timeout=120 + ) + if result.returncode == 0: + # Create marker file to skip this check on future calls + marker_file.write_text("installed") + logger.info(f"[VENV] Base packages installed successfully") + else: + logger.warning(f"[VENV] pip install returned non-zero: {result.stderr}") + except Exception as e: + logger.warning(f"[VENV] Failed to install base packages: {e}") + + return python_bin + # Optional GUI handler hook - set by agent at startup if GUI mode is needed _gui_execute_hook: Optional[Callable[[str, str, Dict, str], Dict]] = None @@ -248,9 +312,12 @@ def _atomic_action_venv_process( requirements: Optional[List[str]] = None, ) -> dict: """ - Executes an action inside an ephemeral virtual environment. + Executes an action inside a persistent virtual environment. Runs in a SEPARATE PROCESS via ProcessPoolExecutor. + The venv is created once and reused across all calls. Packages installed + via pip persist in the venv, eliminating redundant installations. + stdout/stderr are suppressed at the OS level so that venv creation and other subprocess calls do not corrupt the parent's TUI. """ @@ -261,41 +328,44 @@ def _atomic_action_venv_process( # Suppress worker stdout/stderr to prevent TUI corruption saved_stdout, saved_stderr = _suppress_worker_stdio() - # Sandboxed mode - NOT in a Docker container try: - with tempfile.TemporaryDirectory(prefix="action_venv_") as tmpdir: + # Get or create persistent venv (reused across calls) + python_bin = _ensure_persistent_venv() + + # Install requirements only if not already installed + if requirements: + for pkg in requirements: + pkg = pkg.strip() + if not pkg: + continue + # Check if package is already installed before attempting install + check_result = subprocess.run( + [str(python_bin), "-m", "pip", "show", "--quiet", pkg], + capture_output=True, + timeout=15 + ) + if check_result.returncode == 0: + continue # Already installed, skip + + try: + pip_result = subprocess.run( + [str(python_bin), "-m", "pip", "install", "--quiet", pkg], + capture_output=True, + text=True, + timeout=120 + ) + if pip_result.returncode != 0: + stderr_lower = pip_result.stderr.lower() + if "no matching distribution" not in stderr_lower and "could not find" not in stderr_lower: + print(f"Warning: Could not install '{pkg}': {pip_result.stderr.strip()[:100]}", file=sys.stderr) + except subprocess.TimeoutExpired: + print(f"Warning: Installation timed out for '{pkg}'", file=sys.stderr) + except Exception as e: + print(f"Warning: Error installing '{pkg}': {e}", file=sys.stderr) + + # Write action script to temp file (only the script is temporary, not the venv) + with tempfile.TemporaryDirectory(prefix="action_script_") as tmpdir: tmp = Path(tmpdir) - - # Create virtual environment - venv_dir = tmp / "venv" - venv.EnvBuilder(with_pip=True).create(venv_dir) - - python_bin = ( - venv_dir / "Scripts" / "python.exe" - if os.name == "nt" - else venv_dir / "bin" / "python" - ) - - # Install requirements in the venv - if requirements: - for pkg in requirements: - try: - pip_result = subprocess.run( - [str(python_bin), "-m", "pip", "install", "--quiet", pkg], - capture_output=True, - text=True, - timeout=120 - ) - if pip_result.returncode != 0: - stderr_lower = pip_result.stderr.lower() - if "no matching distribution" not in stderr_lower and "could not find" not in stderr_lower: - print(f"Warning: Could not install '{pkg}': {pip_result.stderr.strip()[:100]}", file=sys.stderr) - except subprocess.TimeoutExpired: - print(f"Warning: Installation timed out for '{pkg}'", file=sys.stderr) - except Exception as e: - print(f"Warning: Error installing '{pkg}': {e}", file=sys.stderr) - - # Write action script action_file = tmp / "action.py" action_file.write_text( f""" diff --git a/app/config/settings.json b/app/config/settings.json index 403e9084..8b4bb82c 100644 --- a/app/config/settings.json +++ b/app/config/settings.json @@ -1,24 +1,25 @@ { "general": { - "agent_name": "CraftBot" + "agent_name": "CraftBot", + "os_language": "en" }, "proactive": { - "enabled": false + "enabled": true }, "memory": { "enabled": true }, "model": { - "llm_provider": "gemini", - "vlm_provider": "gemini", - "llm_model": null, - "vlm_model": null + "llm_provider": "byteplus", + "vlm_provider": "byteplus", + "llm_model": "kimi-k2-250905", + "vlm_model": "seed-1-6-250915" }, "api_keys": { "openai": "", "anthropic": "", "google": "", - "byteplus": "" + "byteplus": "6aa60576-c6ef-4835-a77a-f7e51d0637ef" }, "endpoints": { "remote_model_url": "", diff --git a/app/data/action/run_python.py b/app/data/action/run_python.py index e9aba0f2..b37fa591 100644 --- a/app/data/action/run_python.py +++ b/app/data/action/run_python.py @@ -2,7 +2,7 @@ @action( name="run_python", - description="This action takes a single Python code snippet as input and executes it in a fresh environment. Missing packages are automatically detected and installed when ImportError occurs. This action is intended for cases when the AI agent needs to create a one-off solution dynamically.", + description="Execute a Python code snippet in an isolated environment. Missing packages are auto-installed. Use print() to return results.", execution_mode="sandboxed", mode="CLI", default=True, @@ -10,165 +10,86 @@ input_schema={ "code": { "type": "string", - "example": "import requests\nprint(requests.get('https://example.com').text)", - "description": "The Python code snippet to execute. Missing packages will be automatically installed on ImportError. The input code MUST NOT have any malicious code, the code MUST BE SANDBOXED. The code must be production code with the highest level of quality. DO NOT give any placeholder code or fabricated data. You MUST NOT handle exception with system exit. The result of the code return to the agent can only be returned with 'print'." + "example": "print('Hello World')", + "description": "Python code to execute. Use print() to output results." } }, output_schema={ "status": { "type": "string", - "example": "success", - "description": "'success' if the script ran without errors; otherwise 'error'." + "description": "'success' or 'error'" }, "stdout": { "type": "string", - "example": "Hello, World!", - "description": "Captured standard output from the script execution." + "description": "Output from print() statements" }, "stderr": { "type": "string", - "example": "Traceback (most recent call last): ...", - "description": "Captured standard error from the script execution (empty if no error)." + "description": "Error output (if any)" }, "message": { "type": "string", - "example": "Script executed successfully.", - "description": "A short message indicating the result of the script execution. Only present if status is 'error'." + "description": "Error message (only if status is 'error')" } }, - requirement=["traceback"], - test_payload={ - "code": "import subprocess, sys\nsubprocess.check_call([sys.executable, '-m', 'pip', 'install', '--quiet', 'requests'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\nimport requests\nprint(requests.get('https://example.com').text)", - "simulated_mode": True - } + requirement=[], + test_payload={"code": "print('test')", "simulated_mode": True} ) def create_and_run_python_script(input_data: dict) -> dict: - import json import sys - import subprocess import io import traceback + import subprocess import re - import importlib - code_snippet = input_data.get("code", "") - - def _ensure_utf8_stdio() -> None: - """Force stdout/stderr to UTF-8 so Unicode output doesn't break on Windows consoles.""" - for stream_name in ("stdout", "stderr"): - stream = getattr(sys, stream_name, None) - if hasattr(stream, "reconfigure"): - try: - stream.reconfigure(encoding="utf-8", errors="replace") - except Exception: - return { - "status": "error", - "stdout": "", - "stderr": "", - "message": "The 'utf-8' not supported." - } + code = input_data.get("code", "").strip() - _ensure_utf8_stdio() + if not code: + return {"status": "error", "stdout": "", "stderr": "", "message": "No code provided"} - if not code_snippet.strip(): - return { - "status": "error", - "stdout": "", - "stderr": "", - "message": "The 'code' field is required." - } - - stdout_capture = io.StringIO() - stderr_capture = io.StringIO() + # Capture stdout/stderr + stdout_buf = io.StringIO() + stderr_buf = io.StringIO() + old_stdout, old_stderr = sys.stdout, sys.stderr - def _install_package(pkg_name: str) -> bool: + def install_package(pkg): try: subprocess.check_call( - [sys.executable, '-m', 'pip', 'install', '--quiet', pkg_name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=60 + [sys.executable, '-m', 'pip', 'install', '--quiet', pkg], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=60 ) return True - except Exception: + except: return False - def _extract_imports(code: str) -> set: - imports = set() - # Match: import module, import module as alias, from module import ... - patterns = [ - r'^import\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)', - r'^from\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\s+import', - ] - for line in code.split('\n'): - line = line.strip() - if line.startswith('#') or not line: - continue - for pattern in patterns: - match = re.match(pattern, line) - if match: - module = match.group(1).split('.')[0] # Get top-level module - # Skip stdlib modules - if module not in ['json', 'sys', 'os', 'io', 'subprocess', 'traceback', 're', 'importlib', - 'urllib', 'collections', 'datetime', 'time', 'pathlib', 'tempfile']: - imports.add(module) - return imports - try: - original_stdout = sys.stdout - original_stderr = sys.stderr - sys.stdout = stdout_capture - sys.stderr = stderr_capture + sys.stdout, sys.stderr = stdout_buf, stderr_buf - # Pre-install packages detected from imports (optional optimization) - # This helps but we'll also handle ImportError at runtime - detected_imports = _extract_imports(code_snippet) - for pkg in detected_imports: + # Simple exec with retry for missing modules + for attempt in range(3): try: - importlib.import_module(pkg) - except ImportError: - _install_package(pkg) - - exec_globals = {} - max_retries = 3 - retry_count = 0 - - while retry_count < max_retries: - try: - exec(code_snippet, exec_globals) - break # Success, exit retry loop + exec(code, {"__builtins__": __builtins__}) + break except ModuleNotFoundError as e: - # Extract module name from error message - module_match = re.search(r"No module named ['\"]([^'\"]+)['\"]", str(e)) - if module_match: - missing_module = module_match.group(1).split('.')[0] # Get top-level module - if retry_count < max_retries - 1: - # Try to install the missing module - if _install_package(missing_module): - retry_count += 1 - continue # Retry execution - # If we can't install or max retries reached, raise the original error - raise - except Exception: - # For non-ImportError exceptions, don't retry + match = re.search(r"No module named ['\"]([^'\"]+)['\"]", str(e)) + if match and attempt < 2: + pkg = match.group(1).split('.')[0] + if install_package(pkg): + continue raise - sys.stdout = original_stdout - sys.stderr = original_stderr - + sys.stdout, sys.stderr = old_stdout, old_stderr return { "status": "success", - "stdout": stdout_capture.getvalue().strip(), - "stderr": stderr_capture.getvalue().strip() + "stdout": stdout_buf.getvalue().strip(), + "stderr": stderr_buf.getvalue().strip() } except Exception: - sys.stdout = original_stdout - sys.stderr = original_stderr - + sys.stdout, sys.stderr = old_stdout, old_stderr return { "status": "error", - "stdout": stdout_capture.getvalue().strip(), - "stderr": stderr_capture.getvalue().strip(), + "stdout": stdout_buf.getvalue().strip(), + "stderr": stderr_buf.getvalue().strip(), "message": traceback.format_exc() - } \ No newline at end of file + }