File size: 18,990 Bytes
9d0fd45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
"""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


@tool
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)