Spaces:
Running
Running
| """Tool: run_python_code β execute Python code in an isolated sandbox.""" | |
| from __future__ import annotations | |
| import ast | |
| import asyncio | |
| import logging | |
| import re | |
| import uuid | |
| from pathlib import Path | |
| from frontier_agent.core.tool import tool | |
| from frontier_agent.infra.usage_meter import record_api_request | |
| from plugins.tools._code_sanitize import sanitize_code | |
| from plugins.tools._net_guard import ensure_guard_file, guard_env_prefix | |
| from plugins.tools._overflow import maybe_overflow | |
| from plugins.tools._sandbox import ( | |
| aget_sandbox, | |
| arun_sandbox_cmd, | |
| is_e2b_sandbox, | |
| remote_exec_prefix, | |
| ) | |
| from plugins.tools.bash import BASH_STDERR_SEPARATOR | |
| logger = logging.getLogger(__name__) | |
| _MAX_OUTPUT = 10_000 | |
| # Tail of captured stdout surfaced when a run is killed at the timeout, so a | |
| # batched crawl that printed progress leaves salvageable output instead of a | |
| # total loss (observed: 2Γ600s OpenAlex pagination crawls, zero output kept). | |
| _PARTIAL_TAIL = 3_000 | |
| # Detect-only (2026-06-04): log when agent code does raw HTTP so we can size | |
| # how often crawls bypass the governed web tools (proxy cache, retries, | |
| # metering) before deciding whether to clamp their timeout. See the network | |
| # discipline section of the sub-agent research prompt. | |
| _NET_LIB_RE = re.compile( | |
| r"^\s*(?:import|from)\s+(requests|httpx|aiohttp|urllib3|urllib|socket)\b", | |
| re.MULTILINE, | |
| ) | |
| _ML_MODULES = frozenset({ | |
| "torch", | |
| "torchvision", | |
| "torchaudio", | |
| "transformers", | |
| "datasets", | |
| "huggingface_hub", | |
| "sentence_transformers", | |
| "diffusers", | |
| "accelerate", | |
| "tensorflow", | |
| "keras", | |
| }) | |
| _ML_INSTALL_PACKAGES = _ML_MODULES | { | |
| "huggingface-hub", | |
| "sentence-transformers", | |
| } | |
| _ML_DOWNLOAD_ATTRS = frozenset({ | |
| "from_pretrained", | |
| "snapshot_download", | |
| "hf_hub_download", | |
| "load_state_dict_from_url", | |
| }) | |
| _SUBPROCESS_CALLS = frozenset({ | |
| "subprocess.run", | |
| "subprocess.call", | |
| "subprocess.check_call", | |
| "subprocess.check_output", | |
| "subprocess.Popen", | |
| }) | |
| # Hard-deny heavyweight ML/model-download paths in the generic research | |
| # sandbox. These libraries are not part of run_python_code's advertised | |
| # package set, and their convenience APIs can pull multi-GB weights into RAM or | |
| # disk cache before the Python process sees a clean MemoryError. | |
| _ML_IMPORT_RE = re.compile( | |
| r"^\s*(?:import|from)\s+(" | |
| r"torch|torchvision|torchaudio|transformers|datasets|huggingface_hub|" | |
| r"sentence_transformers|diffusers|accelerate|tensorflow|keras" | |
| r")(?:\b|\.)", | |
| re.MULTILINE, | |
| ) | |
| _ML_DOWNLOAD_API_RE = re.compile( | |
| r"\b(" | |
| r"torch\.hub\.(?:load|download_url_to_file)|" | |
| r"load_state_dict_from_url|" | |
| r"(?:from_pretrained|snapshot_download|hf_hub_download)\s*\(" | |
| r")" | |
| ) | |
| _ML_INSTALL_RE = re.compile( | |
| r"\b(?:pip|python\s+-m\s+pip|uv\s+pip)\s+install\b[^\n;]*\b(" | |
| r"torch|torchvision|torchaudio|transformers|datasets|huggingface-hub|" | |
| r"sentence-transformers|diffusers|accelerate|tensorflow|keras" | |
| r")\b", | |
| re.IGNORECASE, | |
| ) | |
| _ML_BLOCK_MESSAGE = ( | |
| "Error: run_python_code blocks PyTorch/HuggingFace/transformers-style " | |
| "model loading and downloads in the generic research sandbox. These " | |
| "paths can fetch multi-GB weights or metadata caches and OOM the worker. " | |
| "Use lightweight structured APIs, aggregate/count endpoints, or the " | |
| "governed web_search/web_fetch tools instead." | |
| ) | |
| _OFFLINE_DOWNLOAD_ENV = ( | |
| "HF_HUB_OFFLINE=1 " | |
| "TRANSFORMERS_OFFLINE=1 " | |
| "HF_DATASETS_OFFLINE=1 " | |
| "HF_HUB_DISABLE_TELEMETRY=1 " | |
| "TORCH_HOME=/tmp/frontier_agent_no_torch_cache " | |
| "HF_HOME=/tmp/frontier_agent_no_hf_cache " | |
| "TRANSFORMERS_CACHE=/tmp/frontier_agent_no_hf_cache " | |
| ) | |
| # Streaming recipe shared by the OOM (-1) and MemoryError messages. Precise on | |
| # purpose: a vague "use chunked processing" hint led models to chunk only the | |
| # pandas parse while still buffering the whole download via | |
| # ``requests.get(url).content`` β the retry then OOM'd identically. | |
| _MEM_RECIPE = ( | |
| "- pd.read_csv(url, chunksize=10000) streams the download AND the parse; " | |
| "filter each chunk, keep only needed columns/rows.\n" | |
| "- Or requests.get(url, stream=True) + iterate lines; never touch " | |
| "response.content / .text on a large body (it buffers everything, and " | |
| ".decode() doubles it).\n" | |
| "- Write the filtered subset to a file in the current working directory " | |
| "first, then analyze that small file in a second run." | |
| ) | |
| def _default_timeout() -> int: | |
| """Per-exec wall-clock default from config (run_python_timeout_s).""" | |
| try: | |
| from frontier_agent.infra.config import get_config | |
| return int(get_config().run_python_timeout_s) | |
| except Exception: | |
| return 300 | |
| def _max_timeout() -> int: | |
| """Hard ceiling on an agent-supplied timeout (run_python_max_timeout_s).""" | |
| try: | |
| from frontier_agent.infra.config import get_config | |
| return int(get_config().run_python_max_timeout_s) | |
| except Exception: | |
| return 300 | |
| def _root_module(name: str) -> str: | |
| return name.split(".", 1)[0] | |
| def _literal_str(node: ast.AST) -> str | None: | |
| if isinstance(node, ast.Constant) and isinstance(node.value, str): | |
| return node.value | |
| return None | |
| def _call_name(node: ast.AST) -> str: | |
| parts: list[str] = [] | |
| cur = node | |
| while isinstance(cur, ast.Attribute): | |
| parts.append(cur.attr) | |
| cur = cur.value | |
| if isinstance(cur, ast.Name): | |
| parts.append(cur.id) | |
| return ".".join(reversed(parts)) | |
| def _iter_literal_strings(node: ast.AST) -> list[str]: | |
| if not isinstance(node, (ast.List, ast.Tuple)): | |
| return [] | |
| out: list[str] = [] | |
| for elt in node.elts: | |
| text = _literal_str(elt) | |
| if text is not None: | |
| out.append(text) | |
| return out | |
| def _is_blocked_pip_install_args(args: list[str]) -> bool: | |
| lowered = [arg.lower() for arg in args] | |
| if "install" not in lowered: | |
| return False | |
| installer = lowered[:3] | |
| if not ( | |
| any(arg.endswith("pip") or arg in {"pip", "pip3"} for arg in installer) | |
| or (len(installer) >= 3 and installer[1:3] == ["-m", "pip"]) | |
| or installer[:2] == ["uv", "pip"] | |
| ): | |
| return False | |
| install_at = lowered.index("install") | |
| packages = { | |
| arg.split("==", 1)[0].split(">=", 1)[0].split("<=", 1)[0] | |
| for arg in lowered[install_at + 1 :] | |
| if arg and not arg.startswith("-") | |
| } | |
| return any(pkg in _ML_INSTALL_PACKAGES for pkg in packages) | |
| class _MLDownloadBlockVisitor(ast.NodeVisitor): | |
| def __init__(self) -> None: | |
| self.blocked = False | |
| def visit_Import(self, node: ast.Import) -> None: | |
| if any(_root_module(alias.name) in _ML_MODULES for alias in node.names): | |
| self.blocked = True | |
| return | |
| self.generic_visit(node) | |
| def visit_ImportFrom(self, node: ast.ImportFrom) -> None: | |
| if node.module and _root_module(node.module) in _ML_MODULES: | |
| self.blocked = True | |
| return | |
| self.generic_visit(node) | |
| def visit_Call(self, node: ast.Call) -> None: | |
| name = _call_name(node.func) | |
| if name == "__import__" and node.args: | |
| mod = _literal_str(node.args[0]) | |
| if mod and _root_module(mod) in _ML_MODULES: | |
| self.blocked = True | |
| return | |
| if name == "importlib.import_module" and node.args: | |
| mod = _literal_str(node.args[0]) | |
| if mod and _root_module(mod) in _ML_MODULES: | |
| self.blocked = True | |
| return | |
| if ( | |
| name in {"torch.hub.load", "torch.hub.download_url_to_file"} | |
| or name.rsplit(".", 1)[-1] in _ML_DOWNLOAD_ATTRS | |
| ): | |
| self.blocked = True | |
| return | |
| if name == "getattr" and len(node.args) >= 2: | |
| attr = _literal_str(node.args[1]) | |
| if attr in _ML_DOWNLOAD_ATTRS: | |
| self.blocked = True | |
| return | |
| if ( | |
| name in _SUBPROCESS_CALLS | |
| and node.args | |
| and _is_blocked_pip_install_args(_iter_literal_strings(node.args[0])) | |
| ): | |
| self.blocked = True | |
| return | |
| self.generic_visit(node) | |
| def _blocked_ml_download_ast(code: str) -> bool: | |
| try: | |
| tree = ast.parse(code) | |
| except SyntaxError: | |
| return False | |
| visitor = _MLDownloadBlockVisitor() | |
| visitor.visit(tree) | |
| return visitor.blocked | |
| def _blocked_ml_download_reason(code: str) -> str | None: | |
| """Return a user-facing block reason for heavyweight ML download paths.""" | |
| if _ML_IMPORT_RE.search(code): | |
| return _ML_BLOCK_MESSAGE | |
| if _ML_DOWNLOAD_API_RE.search(code): | |
| return _ML_BLOCK_MESSAGE | |
| if _ML_INSTALL_RE.search(code): | |
| return _ML_BLOCK_MESSAGE | |
| if _blocked_ml_download_ast(code): | |
| return _ML_BLOCK_MESSAGE | |
| return None | |
| async def run_python_code(code: str, timeout: int = 0) -> str: | |
| """Execute Python code in an isolated sandbox environment. | |
| Pre-installed packages: numpy, pandas, scipy, sympy, mpmath, networkx, | |
| plotly, Pillow, openpyxl, tabulate. | |
| The code MUST terminate on its own within seconds. NEVER submit code that | |
| runs forever: no unbounded ``while True:`` refresh loops, no live curses / | |
| TUI dashboards, no servers, daemons, or ``input()`` waits. If you are | |
| testing a long-running program (e.g. a monitoring dashboard that samples | |
| repeatedly), drive it with a BOUNDED test harness instead β call its | |
| collect/render function 2-3 times with a short (<=1s) sleep, or run it with | |
| an explicit ``--iterations N`` / ``--once`` flag β so it exits quickly. A | |
| non-terminating program is killed only at the timeout, wasting the whole | |
| budget; repeated kills can stall the entire task. | |
| Network rules: to read the CONTENT of a web page or PDF use ``web_fetch``, | |
| not requests/httpx. Calling a structured API (OpenAlex, Crossref, EDGAR, | |
| ...) from code is allowed ONLY for aggregate/count queries (e.g. OpenAlex | |
| ``group_by``, ``meta.count``) β a handful of requests that each return a | |
| small statistical answer. Bulk-paginating a full result set (downloading | |
| every record's metadata page by page) is FORBIDDEN: a corpus-scale crawl | |
| cannot be verified item-by-item anyway, so sample instead β get the total | |
| via an aggregate endpoint, pull β€2 pages as a representative sample, and | |
| reason from count + sample. Set a short per-request timeout and write any | |
| intermediate data to files in the current working directory, not stdout. | |
| If the plan seems to need more than ~10 requests, narrow the query | |
| server-side. | |
| Scratch files: use RELATIVE paths. The working directory is private to | |
| this agent and persists across calls, so a follow-up run sees what this | |
| one wrote. Absolute ``/tmp`` and ``/workspace`` paths are shared with | |
| every other agent on this task β a fixed absolute name can be overwritten | |
| by a concurrent agent, and you would read back their data as if it were | |
| yours. | |
| Output rules: NEVER print a full dataset or raw API responses to stdout β | |
| print counts, aggregates, and at most ~20 sample rows. Anything larger | |
| belongs in a file in the working directory (analyze it in a follow-up | |
| run). Stdout is capped; a full-corpus dump gets truncated AND bloats every | |
| downstream consumer of this conversation. | |
| Memory rules: the sandbox has LIMITED RAM (~512MB) and an out-of-memory | |
| kill loses the whole run. Any file/dataset over ~20MB MUST be streamed, | |
| never buffered: use ``pd.read_csv(url, chunksize=10000)`` (streams both | |
| download and parse; filter each chunk) or ``requests.get(url, | |
| stream=True)`` + line iteration. NEVER call ``response.content`` / | |
| ``response.text`` on a large body β it buffers the full payload and | |
| ``.decode()`` doubles it; loading the result into a DataFrame multiplies | |
| it again. Filter early, keep only needed columns, write the reduced | |
| subset to a file in the working directory and analyze that instead. | |
| Args: | |
| code: Python source code to execute. Must self-terminate. | |
| timeout: Maximum execution time in seconds. 0 (default) uses the | |
| server-configured default (run_python_timeout_s). Hard-capped at | |
| run_python_max_timeout_s (300s) β a larger request is clamped, so | |
| a single exec can't pin a scarce sandbox slot for many minutes. | |
| Returns: | |
| stdout + stderr from the execution, or an error message. | |
| """ | |
| if not code or not code.strip(): | |
| return "Error: empty code provided." | |
| if timeout <= 0: | |
| timeout = _default_timeout() | |
| # Clamp an agent-supplied timeout to the hard ceiling regardless of what | |
| # the model asked for (it can ask for less, never more). Stops a runaway | |
| # data-collection exec from burning many minutes on one sandbox slot. | |
| max_timeout = _max_timeout() | |
| if timeout > max_timeout: | |
| timeout = max_timeout | |
| # Normalise Unicode math symbols copied from problem statements | |
| # (``β« Ξ£ Ο β€`` β ``integral sum pi <=``) before executing β LLMs | |
| # frequently echo these and Python rejects them with SyntaxError. | |
| code = sanitize_code(code) | |
| blocked = _blocked_ml_download_reason(code) | |
| if blocked: | |
| return blocked | |
| net_libs = sorted(set(_NET_LIB_RE.findall(code))) | |
| if net_libs: | |
| logger.info( | |
| "run_python_code: raw HTTP libs in agent code: %s " | |
| "(timeout=%ss, code_len=%d)", | |
| ",".join(net_libs), timeout, len(code), | |
| ) | |
| try: | |
| sandbox = await aget_sandbox() | |
| except RuntimeError as exc: | |
| return f"Error: sandbox unavailable β {exc}" | |
| filename = f"/tmp/exec_{uuid.uuid4().hex[:8]}.py" | |
| # E2B / Docker ``commands.run(timeout=...)`` only times out the SDK client | |
| # wait β it does NOT kill the in-container process, so a brute-force | |
| # enumeration keeps burning a scarce sandbox slot well past ``timeout`` | |
| # (issue #221: observed up to 600s on a 120s budget). Wrap remote execs in | |
| # a coreutils ``timeout -s KILL`` for an OS-level hard kill that even a | |
| # numpy/C loop can't ignore. | |
| # Socket-level download cap (sitecustomize injection): bounds how many | |
| # bytes any python process in this exec tree β including pip children β | |
| # can receive per connection, whether buffered or streamed to disk. See | |
| # ``plugins/tools/_net_guard.py`` (smoke-memwt-003: GB-scale dataset | |
| # downloads inside the sandbox). | |
| await ensure_guard_file(sandbox) | |
| net_guard_env = guard_env_prefix() | |
| base_exec_cmd = f"python3 {filename}" | |
| # Add the per-exec memory cap + single-thread math-lib env so a buffered | |
| # parse fails inside the sandbox instead of OOM-killing its environment. | |
| exec_cmd = ( | |
| f"{remote_exec_prefix()}{net_guard_env}{_OFFLINE_DOWNLOAD_ENV}" | |
| f"timeout -s KILL {timeout}s {base_exec_cmd}" | |
| ) | |
| cmd_timeout = timeout + 30 # let the inner OS timeout fire first | |
| try: | |
| if hasattr(sandbox, "files"): | |
| await asyncio.to_thread(sandbox.files.write, filename, code) | |
| else: | |
| await asyncio.to_thread( | |
| Path(filename).write_text, code, encoding="utf-8", | |
| ) | |
| # Count one E2B execution (lifetime is metered at the | |
| # sandbox create/kill sites in ``_sandbox.py``). Bwrap/Current/Docker | |
| # facades bill nobody β they all carry a ``sandbox_id`` | |
| # too, so discriminate by implementing module instead. | |
| if is_e2b_sandbox(sandbox): | |
| record_api_request("e2b") | |
| result = await arun_sandbox_cmd( | |
| sandbox, exec_cmd, timeout=cmd_timeout, | |
| # Match ``bash``: both run model-authored code, so denying the | |
| # network here while allowing it there only means the same snippet | |
| # succeeds under ``bash -c 'python3 β¦'`` and fails via this tool β | |
| # an asymmetry with no security value and a hard-to-place error. | |
| # The bound is the socket cap injected above, not the namespace; | |
| # on E2B/CurrentSandbox this path already had network anyway, so | |
| # only the bwrap backend changes. | |
| allow_net=True, | |
| ) | |
| except TimeoutError: | |
| return f"Error: execution timed out after {timeout}s." | |
| except Exception as exc: | |
| return f"Error: {type(exc).__name__}: {exc}" | |
| # coreutils ``timeout`` exit codes: 124 = TERM expired, 137 = 128+SIGKILL. | |
| # Surface a consistent timeout message so the LLM | |
| # gets a consistent signal instead of a bare "[exit code 137]". Append the | |
| # tail of whatever stdout the process streamed before the kill β a batched | |
| # crawl that printed progress / checkpointed partial results can be resumed | |
| # or narrowed instead of being a total loss. | |
| if result.exit_code in (124, 137): | |
| msg = f"Error: execution timed out after {timeout}s." | |
| partial = (result.stdout or "").strip() | |
| if partial: | |
| msg += ( | |
| "\nPartial stdout before the kill (salvage it: resume from the " | |
| "last checkpoint, or narrow the query / use an aggregate " | |
| f"endpoint instead of re-running as-is):\n{partial[-_PARTIAL_TAIL:]}" | |
| ) | |
| return msg | |
| # E2B reports a process that died without a normal exit status (OOM-killed, | |
| # envd-side failure) as exit code -1, usually with an empty stderr | |
| # (observed: swarm_gv 2026-06-03, repeated -1 bursts on one sandbox). Give | |
| # the model an actionable signal instead of a bare "[exit code -1]". | |
| if result.exit_code == -1: | |
| detail = (result.stderr or result.stdout or "").strip() | |
| suffix = f"\n{detail}" if detail else "" | |
| return ( | |
| "Error: sandbox process died unexpectedly (exit code -1, likely " | |
| "out-of-memory or a sandbox-side failure). The sandbox has very " | |
| "limited RAM β do NOT retry the same code. To process a large " | |
| "file/dataset, stream it end-to-end instead of buffering it:\n" | |
| f"{_MEM_RECIPE}{suffix}" | |
| ) | |
| output = result.stdout or "" | |
| if result.stderr: | |
| output += f"{BASH_STDERR_SEPARATOR}{result.stderr}" | |
| if result.exit_code != 0: | |
| output = f"[exit code {result.exit_code}]\n{output}" | |
| # The per-exec ``ulimit -v`` cap converts a would-be VM OOM kill into | |
| # a clean MemoryError traceback β steer the retry toward streaming | |
| # instead of letting the model shrink the workload and re-buffer. | |
| if "MemoryError" in (result.stderr or ""): | |
| output += ( | |
| "\n[hint] The process hit the per-exec memory cap. Do NOT " | |
| "retry the same approach with a smaller slice β stream " | |
| f"end-to-end instead of buffering:\n{_MEM_RECIPE}" | |
| ) | |
| return maybe_overflow("run_python_code", output) | |