Skip to content

Commit 78cdf89

Browse files
authored
Merge pull request #136 from Jatkingmodern/patch-1
Enhance Local Python Executor with better error handling
2 parents d31994b + 18196aa commit 78cdf89

File tree

1 file changed

+121
-74
lines changed

1 file changed

+121
-74
lines changed

src/core/tools/local_python_executor.py

Lines changed: 121 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -4,102 +4,149 @@
44
# This source code is licensed under the BSD-style license found in the
55
# LICENSE file in the root directory of this source tree.
66

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.
720
"""
8-
Local Python Executor.
921

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
1328

1429
from smolagents import LocalPythonExecutor
1530

1631
from core.env_server.types import CodeExecResult
1732

33+
logger = logging.getLogger(__name__)
34+
logger.addHandler(logging.NullHandler())
35+
1836

1937
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.
4244
"""
4345

4446
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-
"""
5247
if additional_imports is None:
5348
additional_imports = []
49+
5450
self._executor = LocalPythonExecutor(
5551
additional_authorized_imports=additional_imports
5652
)
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)
5973

6074
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.
8081
"""
8182
try:
82-
# Execute the code using LocalPythonExecutor
83-
# LocalPythonExecutor returns a CodeOutput object with output, logs, is_final_answer
8483
exec_result = self._executor(code)
8584

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)
97146

98147
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

Comments
 (0)