openenv/coding_env
21
1# Copyright (c) Meta Platforms, Inc. and affiliates.2# All rights reserved.3#4# This source code is licensed under the BSD-style license found in the5# LICENSE file in the root directory of this source tree.6 7"""Local Python Executor (enhanced).8 9This module provides a safer wrapper around smolagents.LocalPythonExecutor10with improved exception handling and a few helpful tools registered with11the executor to make debugging executed code easier.12 13Key improvements:14- Register a few helper utilities via send_tools so user code can use15 them for reporting (e.g. `format_exc`).16- More robust extraction of stdout/stderr/exit codes from the executor17 result object, tolerant to different versions of smolagents.18- Detailed stderr on unexpected exceptions including full traceback.19- Structured logging for operational visibility.20"""21 22from __future__ import annotations23 24import json25import logging26import traceback27 28from openenv.core.env_server.types import CodeExecResult29from smolagents import LocalPythonExecutor30 31logger = logging.getLogger(__name__)32logger.addHandler(logging.NullHandler())33 34 35class PyExecutor:36 """Wrapper around smolagents LocalPythonExecutor.37 38 The wrapper registers a few non-privileged helper tools to the39 LocalPythonExecutor that can be used by the executed code to40 format exceptions and to safely stringify results for improved41 error reporting.42 """43 44 def __init__(self, additional_imports: list[str] | None = None):45 if additional_imports is None:46 additional_imports = []47 48 self._executor = LocalPythonExecutor(49 additional_authorized_imports=additional_imports50 )51 52 # Register helpful utilities exposed to the execution environment.53 # These are intentionally small, read-only helpers.54 tools = {55 # Provide a small helper to format the current exception in the56 # executed context. This is a *string formatting* helper only.57 "format_exc": traceback.format_exc,58 # Safe JSON dumps with a fallback for non-serializable objects.59 "safe_json_dumps": lambda obj: json.dumps(obj, default=lambda o: repr(o)),60 }61 62 # `send_tools` is the public API on LocalPythonExecutor to make63 # helper callables available to the sandboxed runtime. We don't64 # provide any builtins that could change the environment.65 try:66 self._executor.send_tools(tools)67 except Exception:68 # If the LocalPythonExecutor implementation doesn't support69 # send_tools or fails, log and continue — the executor is still usable.70 logger.debug(71 "LocalPythonExecutor.send_tools failed; continuing without extra tools",72 exc_info=True,73 )74 75 def run(self, code: str) -> CodeExecResult:76 """Execute Python code and return a CodeExecResult.77 78 This method is intentionally defensive: it attempts to extract79 meaningful stdout/stderr/exit_code information from a variety of80 possible return shapes that different versions of smolagents81 may provide.82 """83 try:84 exec_result = self._executor(code)85 86 # Default values87 stdout_parts: list[str] = []88 stderr_parts: list[str] = []89 exit_code = 090 91 # Extract logs/prints92 try:93 logs = getattr(exec_result, "logs", None)94 if logs:95 stdout_parts.append(str(logs))96 except Exception:97 logger.debug("Failed to read exec_result.logs", exc_info=True)98 99 # Extract the result / output value100 try:101 if hasattr(exec_result, "output"):102 out_val = exec_result.output103 # If the output is not None, stringify it in a safe way104 if out_val is not None:105 # Prefer JSON if possible, otherwise repr106 try:107 stdout_parts.append(json.dumps(out_val))108 except Exception:109 stdout_parts.append(repr(out_val))110 except Exception:111 logger.debug("Failed to read exec_result.output", exc_info=True)112 113 # Some runtime implementations may put errors on `error` or `exception`114 try:115 err = getattr(exec_result, "error", None)116 if err:117 stderr_parts.append(str(err))118 except Exception:119 logger.debug("Failed to read exec_result.error", exc_info=True)120 121 try:122 ex = getattr(exec_result, "exception", None)123 if ex:124 stderr_parts.append(str(ex))125 except Exception:126 logger.debug("Failed to read exec_result.exception", exc_info=True)127 128 # Determine exit code if provided129 try:130 if hasattr(exec_result, "exit_code"):131 exit_code = (132 int(exec_result.exit_code)133 if exec_result.exit_code is not None134 else 0135 )136 elif hasattr(exec_result, "success"):137 # Some versions use `success` boolean138 exit_code = 0 if exec_result.success else 1139 else:140 # Fallback: if there were any stderr parts, treat as non-zero141 exit_code = 1 if stderr_parts else 0142 except Exception:143 logger.debug("Failed to determine exec_result exit code", exc_info=True)144 exit_code = 1 if stderr_parts else 0145 146 # Compose the final stdout/stderr strings147 stdout = "\n".join(part for part in stdout_parts if part is not None)148 stderr = "\n".join(part for part in stderr_parts if part is not None)149 150 return CodeExecResult(stdout=stdout, stderr=stderr, exit_code=exit_code)151 152 except Exception:153 # Any unexpected exception from the LocalPythonExecutor is154 # returned with a full traceback to make debugging easier.155 tb = traceback.format_exc()156 logger.exception("LocalPythonExecutor raised an exception during run")157 return CodeExecResult(stdout="", stderr=tb, exit_code=1)158 