|
4 | 4 | # This source code is licensed under the BSD-style license found in the |
5 | 5 | # LICENSE file in the root directory of this source tree. |
6 | 6 |
|
| 7 | +"""Local Python Executor (enhanced). |
| 8 | +
|
| 9 | +This module provides a safer wrapper around smolagents.LocalPythonExecutor |
| 10 | +with improved exception handling and a few helpful tools registered with |
| 11 | +the executor to make debugging executed code easier. |
| 12 | +
|
| 13 | +Key improvements: |
| 14 | +- Register a few helper utilities via send_tools so user code can use |
| 15 | + them for reporting (e.g. `format_exc`). |
| 16 | +- More robust extraction of stdout/stderr/exit codes from the executor |
| 17 | + result object, tolerant to different versions of smolagents. |
| 18 | +- Detailed stderr on unexpected exceptions including full traceback. |
| 19 | +- Structured logging for operational visibility. |
7 | 20 | """ |
8 | | -Local Python Executor. |
9 | 21 |
|
10 | | -This module provides functionality for executing Python code locally by wrapping |
11 | | -the smolagents LocalPythonExecutor. |
12 | | -""" |
| 22 | +from __future__ import annotations |
| 23 | + |
| 24 | +import json |
| 25 | +import logging |
| 26 | +import traceback |
| 27 | +from typing import Any |
13 | 28 |
|
14 | 29 | from smolagents import LocalPythonExecutor |
15 | 30 |
|
16 | 31 | from core.env_server.types import CodeExecResult |
17 | 32 |
|
| 33 | +logger = logging.getLogger(__name__) |
| 34 | +logger.addHandler(logging.NullHandler()) |
| 35 | + |
18 | 36 |
|
19 | 37 | class PyExecutor: |
20 | | - """ |
21 | | - Wrapper around smolagents LocalPythonExecutor for executing Python code. |
22 | | -
|
23 | | - This class provides a simple interface to execute Python code in a subprocess |
24 | | - and capture the results including stdout, stderr, and exit code. |
25 | | -
|
26 | | - Args: |
27 | | - additional_imports: List of additional module imports to authorize. |
28 | | - For example: ["numpy", "pandas", "matplotlib"] |
29 | | - These will be added to the base authorized imports. |
30 | | -
|
31 | | - Example: |
32 | | - >>> # Basic usage with default imports |
33 | | - >>> executor = PyExecutor() |
34 | | - >>> result = executor.run("print('Hello, World!')") |
35 | | - >>> print(result.stdout) # "Hello, World!\n" |
36 | | - >>> print(result.exit_code) # 0 |
37 | | - >>> |
38 | | - >>> # Usage with additional imports |
39 | | - >>> executor = PyExecutor(additional_imports=["numpy", "pandas"]) |
40 | | - >>> result = executor.run("import numpy as np\\nprint(np.array([1, 2, 3]))") |
41 | | - >>> print(result.stdout) # "[1 2 3]\n" |
| 38 | + """Wrapper around smolagents LocalPythonExecutor. |
| 39 | +
|
| 40 | + The wrapper registers a few non-privileged helper tools to the |
| 41 | + LocalPythonExecutor that can be used by the executed code to |
| 42 | + format exceptions and to safely stringify results for improved |
| 43 | + error reporting. |
42 | 44 | """ |
43 | 45 |
|
44 | 46 | def __init__(self, additional_imports: list[str] | None = None): |
45 | | - """ |
46 | | - Initialize the PyExecutor with a LocalPythonExecutor instance. |
47 | | -
|
48 | | - Args: |
49 | | - additional_imports: List of additional module names to authorize for import. |
50 | | - Defaults to an empty list if not provided. |
51 | | - """ |
52 | 47 | if additional_imports is None: |
53 | 48 | additional_imports = [] |
| 49 | + |
54 | 50 | self._executor = LocalPythonExecutor( |
55 | 51 | additional_authorized_imports=additional_imports |
56 | 52 | ) |
57 | | - # Initialize tools to make BASE_PYTHON_TOOLS available (including print) |
58 | | - self._executor.send_tools({}) |
| 53 | + |
| 54 | + # Register helpful utilities exposed to the execution environment. |
| 55 | + # These are intentionally small, read-only helpers. |
| 56 | + tools = { |
| 57 | + # Provide a small helper to format the current exception in the |
| 58 | + # executed context. This is a *string formatting* helper only. |
| 59 | + "format_exc": traceback.format_exc, |
| 60 | + # Safe JSON dumps with a fallback for non-serializable objects. |
| 61 | + "safe_json_dumps": lambda obj: json.dumps(obj, default=lambda o: repr(o)), |
| 62 | + } |
| 63 | + |
| 64 | + # `send_tools` is the public API on LocalPythonExecutor to make |
| 65 | + # helper callables available to the sandboxed runtime. We don't |
| 66 | + # provide any builtins that could change the environment. |
| 67 | + try: |
| 68 | + self._executor.send_tools(tools) |
| 69 | + except Exception: |
| 70 | + # If the LocalPythonExecutor implementation doesn't support |
| 71 | + # send_tools or fails, log and continue — the executor is still usable. |
| 72 | + logger.debug("LocalPythonExecutor.send_tools failed; continuing without extra tools", exc_info=True) |
59 | 73 |
|
60 | 74 | def run(self, code: str) -> CodeExecResult: |
61 | | - """ |
62 | | - Execute Python code and return the result. |
63 | | -
|
64 | | - Args: |
65 | | - code: Python code string to execute |
66 | | -
|
67 | | - Returns: |
68 | | - CodeExecResult containing stdout, stderr, and exit_code |
69 | | -
|
70 | | - Example: |
71 | | - >>> executor = PyExecutor() |
72 | | - >>> result = executor.run("x = 5 + 3\\nprint(x)") |
73 | | - >>> print(result.stdout) # "8\n" |
74 | | - >>> print(result.exit_code) # 0 |
75 | | - >>> |
76 | | - >>> # Error handling |
77 | | - >>> result = executor.run("1 / 0") |
78 | | - >>> print(result.exit_code) # 1 |
79 | | - >>> print(result.stderr) # Contains error message |
| 75 | + """Execute Python code and return a CodeExecResult. |
| 76 | +
|
| 77 | + This method is intentionally defensive: it attempts to extract |
| 78 | + meaningful stdout/stderr/exit_code information from a variety of |
| 79 | + possible return shapes that different versions of smolagents |
| 80 | + may provide. |
80 | 81 | """ |
81 | 82 | try: |
82 | | - # Execute the code using LocalPythonExecutor |
83 | | - # LocalPythonExecutor returns a CodeOutput object with output, logs, is_final_answer |
84 | 83 | exec_result = self._executor(code) |
85 | 84 |
|
86 | | - # Extract the logs (which contain print outputs) as stdout |
87 | | - # The output field contains the return value of the code |
88 | | - stdout = exec_result.logs |
89 | | - stderr = "" |
90 | | - exit_code = 0 # Success |
91 | | - |
92 | | - return CodeExecResult( |
93 | | - stdout=stdout, |
94 | | - stderr=stderr, |
95 | | - exit_code=exit_code, |
96 | | - ) |
| 85 | + # Default values |
| 86 | + stdout_parts: list[str] = [] |
| 87 | + stderr_parts: list[str] = [] |
| 88 | + exit_code = 0 |
| 89 | + |
| 90 | + # Extract logs/prints |
| 91 | + try: |
| 92 | + logs = getattr(exec_result, "logs", None) |
| 93 | + if logs: |
| 94 | + stdout_parts.append(str(logs)) |
| 95 | + except Exception: |
| 96 | + logger.debug("Failed to read exec_result.logs", exc_info=True) |
| 97 | + |
| 98 | + # Extract the result / output value |
| 99 | + try: |
| 100 | + if hasattr(exec_result, "output"): |
| 101 | + out_val = exec_result.output |
| 102 | + # If the output is not None, stringify it in a safe way |
| 103 | + if out_val is not None: |
| 104 | + # Prefer JSON if possible, otherwise repr |
| 105 | + try: |
| 106 | + stdout_parts.append(json.dumps(out_val)) |
| 107 | + except Exception: |
| 108 | + stdout_parts.append(repr(out_val)) |
| 109 | + except Exception: |
| 110 | + logger.debug("Failed to read exec_result.output", exc_info=True) |
| 111 | + |
| 112 | + # Some runtime implementations may put errors on `error` or `exception` |
| 113 | + try: |
| 114 | + err = getattr(exec_result, "error", None) |
| 115 | + if err: |
| 116 | + stderr_parts.append(str(err)) |
| 117 | + except Exception: |
| 118 | + logger.debug("Failed to read exec_result.error", exc_info=True) |
| 119 | + |
| 120 | + try: |
| 121 | + ex = getattr(exec_result, "exception", None) |
| 122 | + if ex: |
| 123 | + stderr_parts.append(str(ex)) |
| 124 | + except Exception: |
| 125 | + logger.debug("Failed to read exec_result.exception", exc_info=True) |
| 126 | + |
| 127 | + # Determine exit code if provided |
| 128 | + try: |
| 129 | + if hasattr(exec_result, "exit_code"): |
| 130 | + exit_code = int(exec_result.exit_code) if exec_result.exit_code is not None else 0 |
| 131 | + elif hasattr(exec_result, "success"): |
| 132 | + # Some versions use `success` boolean |
| 133 | + exit_code = 0 if exec_result.success else 1 |
| 134 | + else: |
| 135 | + # Fallback: if there were any stderr parts, treat as non-zero |
| 136 | + exit_code = 1 if stderr_parts else 0 |
| 137 | + except Exception: |
| 138 | + logger.debug("Failed to determine exec_result exit code", exc_info=True) |
| 139 | + exit_code = 1 if stderr_parts else 0 |
| 140 | + |
| 141 | + # Compose the final stdout/stderr strings |
| 142 | + stdout = "\n".join(part for part in stdout_parts if part is not None) |
| 143 | + stderr = "\n".join(part for part in stderr_parts if part is not None) |
| 144 | + |
| 145 | + return CodeExecResult(stdout=stdout, stderr=stderr, exit_code=exit_code) |
97 | 146 |
|
98 | 147 | except Exception as e: |
99 | | - # LocalPythonExecutor raises InterpreterError for various issues |
100 | | - # (syntax errors, forbidden operations, runtime errors, etc.) |
101 | | - return CodeExecResult( |
102 | | - stdout="", |
103 | | - stderr=str(e), |
104 | | - exit_code=1, # Non-zero indicates error |
105 | | - ) |
| 148 | + # Any unexpected exception from the LocalPythonExecutor is |
| 149 | + # returned with a full traceback to make debugging easier. |
| 150 | + tb = traceback.format_exc() |
| 151 | + logger.exception("LocalPythonExecutor raised an exception during run") |
| 152 | + return CodeExecResult(stdout="", stderr=tb, exit_code=1) |
0 commit comments