Skip to content

Commit 18196aa

Browse files
Enhance Local Python Executor with better error handling
Enhanced the Local Python Executor with improved exception handling, structured logging, and additional helper tools for better debugging. Key improvements include robust extraction of output and error information, and detailed error reporting.
1 parent db31b64 commit 18196aa

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)