asta-code-execution 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.3
2
+ Name: asta-code-execution
3
+ Version: 0.1.2
4
+ Summary: Add your description here
5
+ Author: Allen Institute for Artificial Intelligence
6
+ Requires-Dist: ipython>=9.9.0
7
+ Requires-Python: >=3.13
8
+ Description-Content-Type: text/markdown
9
+
@@ -0,0 +1,8 @@
1
+ code_execution/__init__.py,sha256=IeWaNDWSA0QNQDqsek2xXowglHp7NTVRoM4qzJXCCHk,371
2
+ code_execution/executor.py,sha256=xKf2fp6RJx-gMrc0UIZKgLxywCdiaXwrMOlZ_n007b0,3394
3
+ code_execution/ipython_session.py,sha256=Yd-KYheOzJ0BNErEPqJHmFf-C0iplmZ6gHnoXdeH65c,8453
4
+ code_execution/process_backend.py,sha256=xFtiQMIXwJN4Iqas6gCza1UlxNqMzSyCWs0gtz67nEE,9270
5
+ code_execution/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ asta_code_execution-0.1.2.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
7
+ asta_code_execution-0.1.2.dist-info/METADATA,sha256=3weUIeEeNGsJhQXiyvEJUJ_vnHsS_0ieN1xyx2Nbxlo,245
8
+ asta_code_execution-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.8.24
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,14 @@
1
+ """Code Execution."""
2
+
3
+ from .executor import IPythonBackend, IPythonExecutor, LocalIPythonBackend
4
+ from .ipython_session import ExecutionConfig, IPythonSession
5
+ from .process_backend import ProcessIPythonBackend
6
+
7
+ __all__ = [
8
+ "ExecutionConfig",
9
+ "IPythonBackend",
10
+ "IPythonExecutor",
11
+ "IPythonSession",
12
+ "LocalIPythonBackend",
13
+ "ProcessIPythonBackend",
14
+ ]
@@ -0,0 +1,105 @@
1
+ """Execution backends for running IPython cells."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Iterable
7
+ from contextlib import contextmanager
8
+ from typing import Any, Protocol
9
+
10
+ from .ipython_session import ExecutionConfig, IPythonSession
11
+
12
+
13
+ class IPythonBackend(Protocol):
14
+ """Protocol for backends that execute IPython cells."""
15
+
16
+ def run_cell(
17
+ self,
18
+ code_str: str,
19
+ *,
20
+ use_subprocess: bool = False,
21
+ timeout_s: float | None = None,
22
+ allow_mime: Iterable[str] | None = None,
23
+ matplotlib_backend: str | None = ExecutionConfig.matplotlib_backend,
24
+ ) -> dict[str, Any]:
25
+ """Execute a code cell and return normalized outputs."""
26
+ ...
27
+
28
+
29
+ @contextmanager
30
+ def _execution_context(cwd: str | None, env: dict[str, str] | None) -> Iterable[None]:
31
+ # Temporarily apply environment and working directory changes for a run.
32
+ previous_cwd = os.getcwd()
33
+ previous_env: dict[str, str | None] = {}
34
+ try:
35
+ if env:
36
+ for key, value in env.items():
37
+ previous_env[key] = os.environ.get(key)
38
+ os.environ[key] = value
39
+ if cwd:
40
+ os.chdir(cwd)
41
+ yield
42
+ finally:
43
+ if cwd:
44
+ os.chdir(previous_cwd)
45
+ if env:
46
+ for key in env:
47
+ previous_value = previous_env.get(key)
48
+ if previous_value is None:
49
+ os.environ.pop(key, None)
50
+ else:
51
+ os.environ[key] = previous_value
52
+
53
+
54
+ class LocalIPythonBackend:
55
+ """Local backend that executes code via IPythonSession."""
56
+
57
+ def __init__(self, *, cwd: str | None = None, env: dict[str, str] | None = None) -> None:
58
+ """Initialize the backend with optional working directory and env vars."""
59
+ self._cwd = cwd
60
+ self._env = env
61
+
62
+ def run_cell(
63
+ self,
64
+ code_str: str,
65
+ *,
66
+ use_subprocess: bool = False,
67
+ timeout_s: float | None = None,
68
+ allow_mime: Iterable[str] | None = None,
69
+ matplotlib_backend: str | None = ExecutionConfig.matplotlib_backend,
70
+ ) -> dict[str, Any]:
71
+ """Execute a code cell in a local IPython session."""
72
+ with _execution_context(self._cwd, self._env):
73
+ session = IPythonSession(
74
+ use_subprocess=use_subprocess,
75
+ timeout_s=timeout_s,
76
+ allow_mime=allow_mime,
77
+ matplotlib_backend=matplotlib_backend,
78
+ )
79
+ return session.run_cell(code_str)
80
+
81
+
82
+ class IPythonExecutor:
83
+ """Facade that executes IPython cells via a configurable backend."""
84
+
85
+ def __init__(self, backend: IPythonBackend) -> None:
86
+ """Initialize the executor with the provided backend."""
87
+ self._backend = backend
88
+
89
+ def run_cell(
90
+ self,
91
+ code_str: str,
92
+ *,
93
+ use_subprocess: bool = False,
94
+ timeout_s: float | None = None,
95
+ allow_mime: Iterable[str] | None = None,
96
+ matplotlib_backend: str | None = ExecutionConfig.matplotlib_backend,
97
+ ) -> dict[str, Any]:
98
+ """Execute a code cell using the configured backend."""
99
+ return self._backend.run_cell(
100
+ code_str,
101
+ use_subprocess=use_subprocess,
102
+ timeout_s=timeout_s,
103
+ allow_mime=allow_mime,
104
+ matplotlib_backend=matplotlib_backend,
105
+ )
@@ -0,0 +1,266 @@
1
+ """IPython-backed execution helpers with optional subprocess isolation."""
2
+
3
+ import base64
4
+ import traceback
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass
7
+ from multiprocessing import get_context
8
+ from typing import Any, cast
9
+
10
+ from IPython.core.formatters import DisplayFormatter
11
+ from IPython.core.interactiveshell import InteractiveShell
12
+ from IPython.utils.capture import capture_output
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ExecutionConfig:
17
+ """Configuration for controlling how an IPython session executes cells."""
18
+
19
+ use_subprocess: bool = False
20
+ timeout_s: float | None = None
21
+ allow_mime: frozenset[str] = frozenset(
22
+ {
23
+ "text/plain",
24
+ "text/html",
25
+ "text/markdown",
26
+ "text/latex",
27
+ "image/png",
28
+ "image/svg+xml",
29
+ "image/jpeg",
30
+ "application/json",
31
+ "application/javascript",
32
+ "application/pdf",
33
+ }
34
+ )
35
+ matplotlib_backend: str | None = "module://matplotlib_inline.backend_inline"
36
+
37
+
38
+ def _normalize_value(value: Any) -> Any:
39
+ # Ensure outputs are JSON-safe for downstream serialization.
40
+ if isinstance(value, bytes):
41
+ return base64.b64encode(value).decode("ascii")
42
+ if isinstance(value, (str, int, float, bool)) or value is None:
43
+ return value
44
+ if isinstance(value, (list, tuple)):
45
+ return [_normalize_value(item) for item in value]
46
+ if isinstance(value, dict):
47
+ return {str(key): _normalize_value(item) for key, item in value.items()}
48
+ return repr(value)
49
+
50
+
51
+ def _normalize_mime_bundle(data: dict[str, Any], allow_mime: frozenset[str]) -> dict[str, Any]:
52
+ # Filter to a predictable MIME allowlist to avoid leaking unexpected payload types.
53
+ normalized: dict[str, Any] = {}
54
+ for mime_type, payload in data.items():
55
+ if mime_type not in allow_mime:
56
+ continue
57
+ normalized[mime_type] = _normalize_value(payload)
58
+ return normalized
59
+
60
+
61
+ def _format_error(exc: BaseException | None) -> dict[str, str] | None:
62
+ # Preserve traceback details so callers can render useful diagnostics.
63
+ if exc is None:
64
+ return None
65
+ return {
66
+ "type": type(exc).__name__,
67
+ "message": str(exc),
68
+ "traceback": "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)),
69
+ }
70
+
71
+
72
+ def _run_cell_with_shell(
73
+ shell: InteractiveShell,
74
+ code_str: str,
75
+ allow_mime: frozenset[str],
76
+ ) -> dict[str, Any]:
77
+ # Execute in-process to preserve state between calls when isolation isn't needed.
78
+ with capture_output() as captured:
79
+ result = shell.run_cell(code_str)
80
+
81
+ error = result.error_before_exec or result.error_in_exec
82
+ outputs = {
83
+ "stdout": captured.stdout,
84
+ "stderr": captured.stderr,
85
+ "rich_outputs": [],
86
+ "success": result.success,
87
+ "error": _format_error(error),
88
+ }
89
+
90
+ for display_obj in captured.outputs:
91
+ outputs["rich_outputs"].append(_normalize_mime_bundle(display_obj.data, allow_mime))
92
+
93
+ return outputs
94
+
95
+
96
+ def _configure_display_formatters(
97
+ shell: InteractiveShell,
98
+ allow_mime: frozenset[str],
99
+ ) -> None:
100
+ formatter = shell.display_formatter
101
+ if formatter is None:
102
+ return
103
+ display_formatter = cast(DisplayFormatter, formatter)
104
+ available = [mime for mime in allow_mime if mime in display_formatter.formatters]
105
+ if not available:
106
+ return
107
+ for mime in available:
108
+ display_formatter.formatters[mime].enabled = True
109
+ display_formatter.active_types = available
110
+
111
+
112
+ def _configure_matplotlib_backend(
113
+ backend: str | None,
114
+ allow_mime: frozenset[str],
115
+ ) -> None:
116
+ if not backend:
117
+ return
118
+ try:
119
+ import matplotlib
120
+ import matplotlib_inline.backend_inline as backend_inline
121
+ except Exception:
122
+ return
123
+
124
+ try:
125
+ current = matplotlib.get_backend()
126
+ except Exception:
127
+ current = None
128
+
129
+ if current == backend:
130
+ return
131
+
132
+ try:
133
+ # Ensure rich display payloads are emitted in non-interactive contexts.
134
+ matplotlib.use(backend, force=True)
135
+ if "matplotlib_inline.backend_inline" in backend:
136
+ formats: list[str] = []
137
+ if "image/png" in allow_mime:
138
+ formats.append("png")
139
+ if "image/svg+xml" in allow_mime:
140
+ formats.append("svg")
141
+ if "image/jpeg" in allow_mime:
142
+ formats.append("jpeg")
143
+ if formats:
144
+ backend_inline.set_matplotlib_formats(*formats)
145
+ except Exception:
146
+ # If pyplot or another backend is already loaded, keep running.
147
+ return
148
+
149
+
150
+ def _configure_shell(
151
+ shell: InteractiveShell,
152
+ allow_mime: frozenset[str],
153
+ matplotlib_backend: str | None,
154
+ ) -> None:
155
+ _configure_display_formatters(shell, allow_mime)
156
+ _configure_matplotlib_backend(matplotlib_backend, allow_mime)
157
+
158
+
159
+ def _ensure_pip_available() -> None:
160
+ # Bootstrap pip so %pip magic can install packages on demand.
161
+ try:
162
+ import pip # noqa: F401
163
+
164
+ return
165
+ except Exception:
166
+ pass
167
+
168
+ try:
169
+ import ensurepip
170
+ except Exception:
171
+ return
172
+
173
+ try:
174
+ ensurepip.bootstrap()
175
+ except Exception:
176
+ return
177
+
178
+
179
+ def _run_cell_in_subprocess(
180
+ code_str: str,
181
+ allow_mime: frozenset[str],
182
+ matplotlib_backend: str | None,
183
+ connection,
184
+ ) -> None:
185
+ # Run in a child process so a hard timeout can be enforced without blocking.
186
+ shell = InteractiveShell.instance()
187
+ _configure_shell(shell, allow_mime, matplotlib_backend)
188
+ outputs = _run_cell_with_shell(shell, code_str, allow_mime)
189
+ connection.send(outputs)
190
+ connection.close()
191
+
192
+
193
+ class IPythonSession:
194
+ """Run code in an IPython shell with optional subprocess isolation."""
195
+
196
+ def __init__(
197
+ self,
198
+ *,
199
+ use_subprocess: bool = False,
200
+ timeout_s: float | None = None,
201
+ allow_mime: Iterable[str] | None = None,
202
+ matplotlib_backend: str | None = ExecutionConfig.matplotlib_backend,
203
+ ) -> None:
204
+ """Initialize an IPython execution session with optional isolation settings."""
205
+ _ensure_pip_available()
206
+ self._config = ExecutionConfig(
207
+ use_subprocess=use_subprocess,
208
+ timeout_s=timeout_s,
209
+ allow_mime=frozenset(allow_mime or ExecutionConfig.allow_mime),
210
+ matplotlib_backend=matplotlib_backend,
211
+ )
212
+ # Create a shell instance that persists variables between calls.
213
+ self.shell = InteractiveShell.instance()
214
+ _configure_shell(self.shell, self._config.allow_mime, self._config.matplotlib_backend)
215
+
216
+ def run_cell(self, code_str: str) -> dict[str, Any]:
217
+ """Run a code cell and return captured outputs, errors, and success state."""
218
+ if self._config.timeout_s is not None and not self._config.use_subprocess:
219
+ raise ValueError("timeout_s requires use_subprocess=True to enforce a hard timeout")
220
+ if not self._config.use_subprocess:
221
+ return _run_cell_with_shell(self.shell, code_str, self._config.allow_mime)
222
+
223
+ ctx = get_context("spawn")
224
+ parent_conn, child_conn = ctx.Pipe(duplex=False)
225
+ process = ctx.Process(
226
+ target=_run_cell_in_subprocess,
227
+ args=(code_str, self._config.allow_mime, self._config.matplotlib_backend, child_conn),
228
+ )
229
+ process.start()
230
+ child_conn.close()
231
+ process.join(timeout=self._config.timeout_s)
232
+
233
+ if process.is_alive():
234
+ process.terminate()
235
+ process.join()
236
+ parent_conn.close()
237
+ return {
238
+ "stdout": "",
239
+ "stderr": "",
240
+ "rich_outputs": [],
241
+ "success": False,
242
+ "error": {
243
+ "type": "TimeoutError",
244
+ "message": f"Execution exceeded {self._config.timeout_s} seconds",
245
+ "traceback": "",
246
+ },
247
+ }
248
+
249
+ if parent_conn.poll():
250
+ result = parent_conn.recv()
251
+ parent_conn.close()
252
+ return result
253
+
254
+ parent_conn.close()
255
+
256
+ return {
257
+ "stdout": "",
258
+ "stderr": "",
259
+ "rich_outputs": [],
260
+ "success": False,
261
+ "error": {
262
+ "type": "RuntimeError",
263
+ "message": "Subprocess exited without returning a result",
264
+ "traceback": "",
265
+ },
266
+ }
@@ -0,0 +1,272 @@
1
+ """Subprocess-based IPython execution backend for local isolated runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ from collections.abc import Iterable
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .ipython_session import ExecutionConfig
16
+
17
+ _DEFAULT_SANDBOX_PACKAGES = [
18
+ "ipython",
19
+ "numpy",
20
+ "pandas",
21
+ "matplotlib",
22
+ "matplotlib-inline",
23
+ "seaborn",
24
+ "scikit-learn",
25
+ "scipy",
26
+ "statsmodels",
27
+ ]
28
+
29
+ _SANDBOX_RUNNER = """\
30
+ import json
31
+ import sys
32
+ import traceback
33
+
34
+ from code_execution.ipython_session import ExecutionConfig, IPythonSession
35
+
36
+
37
+ def _format_error(exc: BaseException) -> dict[str, str]:
38
+ return {
39
+ "type": type(exc).__name__,
40
+ "message": str(exc),
41
+ "traceback": "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)),
42
+ }
43
+
44
+
45
+ def main() -> None:
46
+ try:
47
+ payload = json.load(sys.stdin)
48
+ allow_mime = payload.get("allow_mime")
49
+ session = IPythonSession(
50
+ use_subprocess=payload.get("use_subprocess", False),
51
+ timeout_s=payload.get("timeout_s"),
52
+ allow_mime=allow_mime,
53
+ matplotlib_backend=payload.get(
54
+ "matplotlib_backend",
55
+ ExecutionConfig.matplotlib_backend,
56
+ ),
57
+ )
58
+ result = session.run_cell(payload["code_str"])
59
+ except BaseException as exc:
60
+ result = {
61
+ "stdout": "",
62
+ "stderr": "",
63
+ "rich_outputs": [],
64
+ "success": False,
65
+ "error": _format_error(exc),
66
+ }
67
+ json.dump(result, sys.stdout)
68
+
69
+
70
+ if __name__ == "__main__":
71
+ main()
72
+ """
73
+
74
+
75
+ def _find_uv() -> str:
76
+ """Return path to the ``uv`` binary, or raise if not found."""
77
+ uv = shutil.which("uv")
78
+ if uv is None:
79
+ raise RuntimeError(
80
+ "uv is required for ProcessIPythonBackend but was not found on PATH"
81
+ )
82
+ return uv
83
+
84
+
85
+ # TODO: Transplant this implementation onto asta_sandbox.SandboxBase (making run_cell
86
+ # async via asyncio.to_thread) and move it into the asta-sandbox library as a first-class
87
+ # local process backend alongside InProcessExecutor and ModalEphemeralExecutor. Once done,
88
+ # _ProcessBackendAdapter in agents.py can be removed and ProcessIPythonBackend can be used
89
+ # directly wherever a SandboxBase is expected.
90
+ class ProcessIPythonBackend:
91
+ """Backend that executes IPython cells in isolated subprocesses.
92
+
93
+ Each backend instance lazily creates a *base sandbox venv* with a curated
94
+ set of packages (mirroring the Modal sandbox image). Individual
95
+ ``run_cell`` invocations get a per-process temporary directory so that any
96
+ ``uv pip install`` executed by experiment code does **not** mutate the
97
+ base venv.
98
+ """
99
+
100
+ def __init__(
101
+ self,
102
+ *,
103
+ cwd: str | None = None,
104
+ env: dict[str, str] | None = None,
105
+ packages: list[str] | None = None,
106
+ sandbox_venv_path: str | None = None,
107
+ ) -> None:
108
+ """Initialize the backend with optional working directory and env vars."""
109
+ self._cwd = cwd
110
+ self._env = env
111
+ self._packages = packages if packages is not None else list(_DEFAULT_SANDBOX_PACKAGES)
112
+ self._sandbox_venv_path: Path | None = (
113
+ Path(sandbox_venv_path) if sandbox_venv_path else None
114
+ )
115
+ self._sandbox_ready = False
116
+ self._owned_venv_dir: tempfile.TemporaryDirectory[str] | None = None
117
+
118
+ # -- lazy venv setup -----------------------------------------------------
119
+
120
+ def _ensure_sandbox(self) -> Path:
121
+ """Create the base sandbox venv if it doesn't already exist."""
122
+ if self._sandbox_ready and self._sandbox_venv_path is not None:
123
+ return self._sandbox_venv_path
124
+
125
+ uv = _find_uv()
126
+
127
+ if self._sandbox_venv_path is None:
128
+ self._owned_venv_dir = tempfile.TemporaryDirectory(prefix="process_sandbox_")
129
+ self._sandbox_venv_path = Path(self._owned_venv_dir.name)
130
+
131
+ venv_path = self._sandbox_venv_path
132
+ python_bin = venv_path / "bin" / "python"
133
+
134
+ if not python_bin.exists():
135
+ subprocess.run(
136
+ [uv, "venv", str(venv_path), "--seed", "--python", sys.executable],
137
+ check=True,
138
+ capture_output=True,
139
+ text=True,
140
+ )
141
+
142
+ # Install curated packages
143
+ if self._packages:
144
+ subprocess.run(
145
+ [uv, "pip", "install", "--python", str(python_bin), *self._packages],
146
+ check=True,
147
+ capture_output=True,
148
+ text=True,
149
+ )
150
+
151
+ # Install code_execution package (editable) so the sandbox runner works
152
+ code_exec_pkg = Path(__file__).resolve().parent.parent.parent # packages/code_execution
153
+ subprocess.run(
154
+ [uv, "pip", "install", "--python", str(python_bin), "-e", str(code_exec_pkg)],
155
+ check=True,
156
+ capture_output=True,
157
+ text=True,
158
+ )
159
+
160
+ self._sandbox_ready = True
161
+ return venv_path
162
+
163
+ @property
164
+ def sandbox_python(self) -> str:
165
+ """Return the path to the sandbox venv's Python interpreter."""
166
+ venv = self._ensure_sandbox()
167
+ return str(venv / "bin" / "python")
168
+
169
+ # -- cell execution ------------------------------------------------------
170
+
171
+ def run_cell(
172
+ self,
173
+ code_str: str,
174
+ *,
175
+ use_subprocess: bool = False,
176
+ timeout_s: float | None = None,
177
+ allow_mime: Iterable[str] | None = None,
178
+ matplotlib_backend: str | None = ExecutionConfig.matplotlib_backend,
179
+ ) -> dict[str, Any]:
180
+ """Execute a code cell in an isolated subprocess and return normalized outputs."""
181
+ venv_path = self._ensure_sandbox()
182
+ python_bin = str(venv_path / "bin" / "python")
183
+
184
+ payload = {
185
+ "code_str": code_str,
186
+ "use_subprocess": use_subprocess,
187
+ "timeout_s": None,
188
+ "allow_mime": list(allow_mime) if allow_mime else None,
189
+ "matplotlib_backend": matplotlib_backend,
190
+ }
191
+
192
+ child_env = os.environ.copy()
193
+ if self._env:
194
+ child_env.update(self._env)
195
+
196
+ # Per-cell temp directory for any packages installed by experiment code.
197
+ # An `install-package` script on PATH installs into this directory
198
+ # via `uv pip install --target`, keeping the base sandbox venv clean.
199
+ cell_tmp = tempfile.mkdtemp(prefix="cell_pkgs_")
200
+ try:
201
+ cell_install_dir = os.path.join(cell_tmp, "lib")
202
+ os.makedirs(cell_install_dir, exist_ok=True)
203
+
204
+ existing_pp = child_env.get("PYTHONPATH", "")
205
+ child_env["PYTHONPATH"] = (
206
+ f"{cell_install_dir}:{existing_pp}" if existing_pp else cell_install_dir
207
+ )
208
+
209
+ # Create an install-package script that experiment code calls
210
+ # to install packages into the per-process temp directory.
211
+ script_dir = os.path.join(cell_tmp, "bin")
212
+ os.makedirs(script_dir, exist_ok=True)
213
+ real_uv = _find_uv()
214
+ script_path = os.path.join(script_dir, "install-package")
215
+ with open(script_path, "w") as f:
216
+ f.write(f"""#!/bin/sh
217
+ # Install a Python package into the per-process temp directory.
218
+ exec "{real_uv}" pip install --target "{cell_install_dir}" --quiet "$@"
219
+ """)
220
+ os.chmod(script_path, 0o755)
221
+
222
+ venv_bin = str(venv_path / "bin")
223
+ child_env["PATH"] = f"{script_dir}:{venv_bin}:{child_env.get('PATH', '')}"
224
+ child_env["VIRTUAL_ENV"] = str(venv_path)
225
+
226
+ try:
227
+ proc = subprocess.run(
228
+ [python_bin, "-c", _SANDBOX_RUNNER],
229
+ input=json.dumps(payload),
230
+ capture_output=True,
231
+ text=True,
232
+ timeout=timeout_s,
233
+ cwd=self._cwd,
234
+ env=child_env,
235
+ )
236
+ except subprocess.TimeoutExpired:
237
+ return {
238
+ "stdout": "",
239
+ "stderr": "",
240
+ "rich_outputs": [],
241
+ "success": False,
242
+ "error": {
243
+ "type": "TimeoutError",
244
+ "message": f"Process execution timed out after {timeout_s}s",
245
+ "traceback": "",
246
+ },
247
+ }
248
+ finally:
249
+ shutil.rmtree(cell_tmp, ignore_errors=True)
250
+
251
+ stdout = proc.stdout or ""
252
+ stderr = proc.stderr or ""
253
+
254
+ try:
255
+ result = json.loads(stdout) if stdout.strip() else {}
256
+ except json.JSONDecodeError:
257
+ return {
258
+ "stdout": "",
259
+ "stderr": stderr,
260
+ "rich_outputs": [],
261
+ "success": False,
262
+ "error": {
263
+ "type": "RuntimeError",
264
+ "message": "Subprocess output was not valid JSON.",
265
+ "traceback": stdout,
266
+ },
267
+ }
268
+
269
+ if stderr:
270
+ result.setdefault("stderr", "")
271
+ result["stderr"] = f"{result['stderr']}{stderr}"
272
+ return result
File without changes