File size: 14,950 Bytes
90e4c64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
"""Restricted stateful Python for Nemotron Cascade-2 math tool calls.

Tercet-R saw `stateful_python_code_exec` on `math/math_tool.jsonl` during
the Cascade-2 SFT stage. Observations are the Jupyter-style stdout / last
expression value, not a JSON envelope.

The worker process only allows a math-oriented import whitelist and rejects
dunder access, so a tool call cannot read the filesystem or start a shell.
"""

from __future__ import annotations

import ast
import io
import json
import math
import os
import subprocess
import sys
import threading
from collections.abc import Mapping
from pathlib import Path
from queue import Empty, Queue
from typing import Any


NEMOTRON_PYTHON_EXEC_TOOL: dict[str, Any] = {
    "type": "function",
    "function": {
        "name": "stateful_python_code_exec",
        "description": (
            "Call this function to execute Python code in a stateful Jupyter "
            "notebook environment. Python will respond with the output of the "
            "execution or time out after 120.0 seconds."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "code": {
                    "type": "string",
                    "description": "Code to execute",
                }
            },
            "required": ["code"],
        },
    },
}

CALCULATOR_TOOL: dict[str, Any] = {
    "type": "function",
    "function": {
        "name": "calculator",
        "description": "Evaluate a math expression and return the numeric result.",
        "parameters": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "Math expression, for example 12.5 * (3 + 4)",
                }
            },
            "required": ["expression"],
        },
    },
}

PYTHON_EXEC_TOOL_NAMES = frozenset(
    {
        "stateful_python_code_exec",
        "python_code_exec",
        "code_interpreter",
        "python_exec",
        "python",
    }
)
CALCULATOR_TOOL_NAMES = frozenset({"calculator", "calc"})
DEFAULT_EXEC_TIMEOUT_SECONDS = 30.0
_SRC_ROOT = Path(__file__).resolve().parents[1]

_session_lock = threading.Lock()
_default_session: StatefulPythonSession | None = None


def _normalize_tool_name(name: str) -> str:
    return name.strip().lower().replace("-", "_")


def is_python_exec_tool_name(name: str) -> bool:
    return _normalize_tool_name(name) in PYTHON_EXEC_TOOL_NAMES


def is_calculator_tool_name(name: str) -> bool:
    return _normalize_tool_name(name) in CALCULATOR_TOOL_NAMES


def is_auto_math_tool_name(name: str) -> bool:
    return is_python_exec_tool_name(name) or is_calculator_tool_name(name)


def code_from_arguments(arguments: Mapping[str, Any] | None) -> str:
    if not arguments:
        return ""
    for key in ("code", "expression", "expr", "source"):
        raw = arguments.get(key)
        if isinstance(raw, str) and raw.strip():
            return raw
        if raw is not None and key != "code":
            return str(raw)
    return ""


class CodeExecError(ValueError):
    """Rejected or failed tool code."""


class StatefulPythonSession:
    """One long-lived restricted interpreter, matching the training tool."""

    def __init__(self, *, timeout_seconds: float = DEFAULT_EXEC_TIMEOUT_SECONDS) -> None:
        self.timeout_seconds = timeout_seconds
        self._lock = threading.Lock()
        self._process: subprocess.Popen[str] | None = None

    def close(self) -> None:
        with self._lock:
            self._kill_locked()

    def reset(self) -> None:
        with self._lock:
            self._kill_locked()

    def run(self, code: str) -> str:
        text = code.strip()
        if not text:
            raise CodeExecError("code is empty")
        with self._lock:
            return self._run_locked(text)

    def _run_locked(self, code: str) -> str:
        process = self._ensure_process_locked()
        try:
            process.stdin.write(json.dumps({"code": code}, ensure_ascii=False) + "\n")
            process.stdin.flush()
            line = self._readline_locked(process, self.timeout_seconds)
        except (BrokenPipeError, OSError) as error:
            self._kill_locked()
            raise CodeExecError(f"python worker died: {error}") from error
        if not line:
            stderr = ""
            if process.stderr is not None:
                try:
                    stderr = process.stderr.read()
                except OSError:
                    stderr = ""
            self._kill_locked()
            detail = stderr.strip() or "python worker closed stdout"
            raise CodeExecError(detail)
        try:
            payload = json.loads(line)
        except json.JSONDecodeError as error:
            self._kill_locked()
            raise CodeExecError(f"python worker returned invalid JSON: {line!r}") from error
        if not isinstance(payload, dict):
            raise CodeExecError("python worker returned a non-object")
        output = str(payload.get("output") or "")
        if payload.get("ok") is True:
            return output if output else "None"
        raise CodeExecError(output or "execution failed")

    def _readline_locked(self, process: subprocess.Popen[str], timeout: float) -> str:
        if process.stdout is None:
            raise CodeExecError("python worker has no stdout")
        lines: Queue[str] = Queue()

        def _read() -> None:
            lines.put(process.stdout.readline() if process.stdout is not None else "")

        reader = threading.Thread(target=_read, daemon=True)
        reader.start()
        reader.join(timeout)
        if reader.is_alive():
            self._kill_locked()
            raise CodeExecError(f"timed out after {timeout:g}s")
        try:
            return lines.get_nowait()
        except Empty:
            return ""

    def _ensure_process_locked(self) -> subprocess.Popen[str]:
        process = self._process
        if process is not None and process.poll() is None:
            return process
        env = os.environ.copy()
        pythonpath = env.get("PYTHONPATH", "")
        env["PYTHONPATH"] = (
            str(_SRC_ROOT) if not pythonpath else f"{_SRC_ROOT}{os.pathsep}{pythonpath}"
        )
        self._process = subprocess.Popen(
            [sys.executable, "-m", "tiny_gdn.code_exec"],
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            bufsize=1,
            env=env,
        )
        if self._process.stdin is None or self._process.stdout is None:
            self._kill_locked()
            raise CodeExecError("failed to start python worker")
        return self._process

    def _kill_locked(self) -> None:
        process = self._process
        self._process = None
        if process is None:
            return
        try:
            process.kill()
            process.wait(timeout=2)
        except (OSError, subprocess.TimeoutExpired):
            pass


def default_python_session() -> StatefulPythonSession:
    global _default_session
    with _session_lock:
        if _default_session is None:
            _default_session = StatefulPythonSession()
        return _default_session


def reset_default_python_session() -> None:
    global _default_session
    with _session_lock:
        session = _default_session
        _default_session = None
    if session is not None:
        session.close()


def execute_calculator(expression: str) -> str:
    tree = ast.parse(expression, mode="eval")
    _reject_unsafe_ast(tree)
    value = eval(  # noqa: S307 — AST already rejected unsafe nodes
        compile(tree, "<calculator>", "eval"),
        {"__builtins__": {}},
        _calculator_namespace(),
    )
    return _format_result(value)


def execute_math_tool(name: str, arguments: Mapping[str, Any] | None) -> str:
    source = code_from_arguments(arguments)
    if is_calculator_tool_name(name) and "\n" not in source and ";" not in source:
        try:
            return execute_calculator(source)
        except (SyntaxError, CodeExecError, TypeError, ValueError, ZeroDivisionError) as error:
            raise CodeExecError(str(error)) from error
    return default_python_session().run(source)


def _calculator_namespace() -> dict[str, Any]:
    names = {
        key: getattr(math, key)
        for key in dir(math)
        if not key.startswith("_")
    }
    names.update(
        {
            "abs": abs,
            "min": min,
            "max": max,
            "round": round,
            "pow": pow,
            "pi": math.pi,
            "e": math.e,
            "tau": math.tau,
            "inf": math.inf,
        }
    )
    return names


def _reject_unsafe_ast(tree: ast.AST) -> None:
    for node in ast.walk(tree):
        if isinstance(node, (ast.ClassDef, ast.AsyncFunctionDef)):
            raise CodeExecError("class definitions are blocked")
        if isinstance(node, ast.Attribute) and node.attr.startswith("_"):
            raise CodeExecError("dunder attribute access is blocked")
        if isinstance(node, ast.Name) and node.id.startswith("_"):
            raise CodeExecError("dunder names are blocked")
        if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
            if node.func.id in _BANNED_CALLS:
                raise CodeExecError(f"{node.func.id}() is blocked")


_BANNED_CALLS = frozenset(
    {
        "eval",
        "exec",
        "compile",
        "open",
        "input",
        "breakpoint",
        "getattr",
        "setattr",
        "delattr",
        "globals",
        "locals",
        "vars",
        "dir",
        "help",
        "__import__",
        "memoryview",
        "exit",
        "quit",
    }
)


def _format_result(value: Any) -> str:
    if value is None:
        return "None"
    if isinstance(value, bool):
        return str(value)
    if isinstance(value, float):
        if value.is_integer() and abs(value) < 1e15:
            return str(int(value))
        return format(value, ".12g")
    return str(value)


_ALLOWED_IMPORT_ROOTS = frozenset(
    {
        "cmath",
        "collections",
        "copy",
        "decimal",
        "fractions",
        "functools",
        "itertools",
        "json",
        "math",
        "mpmath",
        "numbers",
        "numpy",
        "operator",
        "re",
        "statistics",
        "string",
        "sympy",
        "textwrap",
        "unicodedata",
    }
)

_WORKER_NAMESPACE: dict[str, Any] | None = None


def _allowed_import(
    name: str,
    globals: dict[str, Any] | None = None,
    locals: dict[str, Any] | None = None,
    fromlist: tuple[str, ...] = (),
    level: int = 0,
) -> Any:
    root = name.split(".")[0]
    if root not in _ALLOWED_IMPORT_ROOTS:
        raise ImportError(f"import of {name!r} is blocked")
    return __import__(name, globals, locals, fromlist, level)


def _worker_namespace() -> dict[str, Any]:
    global _WORKER_NAMESPACE
    if _WORKER_NAMESPACE is None:
        builtins = {
            "abs": abs,
            "all": all,
            "any": any,
            "bin": bin,
            "bool": bool,
            "bytes": bytes,
            "chr": chr,
            "complex": complex,
            "dict": dict,
            "divmod": divmod,
            "enumerate": enumerate,
            "filter": filter,
            "float": float,
            "format": format,
            "frozenset": frozenset,
            "hex": hex,
            "int": int,
            "isinstance": isinstance,
            "issubclass": issubclass,
            "iter": iter,
            "len": len,
            "list": list,
            "map": map,
            "max": max,
            "min": min,
            "next": next,
            "oct": oct,
            "ord": ord,
            "pow": pow,
            "print": print,
            "range": range,
            "repr": repr,
            "reversed": reversed,
            "round": round,
            "set": set,
            "slice": slice,
            "sorted": sorted,
            "str": str,
            "sum": sum,
            "tuple": tuple,
            "zip": zip,
            "True": True,
            "False": False,
            "None": None,
            "__import__": _allowed_import,
        }
        _WORKER_NAMESPACE = {
            "__builtins__": builtins,
            "__name__": "__tool__",
            "math": math,
        }
    return _WORKER_NAMESPACE


def run_cell(code: str) -> str:
    tree = ast.parse(code)
    _reject_unsafe_import_roots(tree)
    _reject_unsafe_ast(tree)
    namespace = _worker_namespace()
    buffer = io.StringIO()
    previous = sys.stdout
    sys.stdout = buffer
    try:
        if tree.body and isinstance(tree.body[-1], ast.Expr):
            body = tree.body[:-1]
            last = tree.body[-1]
            if body:
                exec(  # noqa: S102
                    compile(ast.Module(body, type_ignores=[]), "<tool>", "exec"),
                    namespace,
                    namespace,
                )
            value = eval(  # noqa: S307
                compile(ast.Expression(last.value), "<tool>", "eval"),
                namespace,
                namespace,
            )
            printed = buffer.getvalue()
            if value is None:
                return printed if printed else "None"
            rendered = _format_result(value)
            return f"{printed}{rendered}" if printed else rendered
        exec(compile(tree, "<tool>", "exec"), namespace, namespace)  # noqa: S102
        printed = buffer.getvalue()
        return printed if printed else "None"
    finally:
        sys.stdout = previous


def _reject_unsafe_import_roots(tree: ast.AST) -> None:
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            for alias in node.names:
                root = alias.name.split(".")[0]
                if root not in _ALLOWED_IMPORT_ROOTS:
                    raise CodeExecError(f"import of {alias.name!r} is blocked")
        elif isinstance(node, ast.ImportFrom):
            root = (node.module or "").split(".")[0]
            if root not in _ALLOWED_IMPORT_ROOTS:
                raise CodeExecError(f"import of {node.module!r} is blocked")


def _worker_loop() -> None:
    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue
        try:
            request = json.loads(line)
            code = str(request.get("code") or "")
            output = run_cell(code)
            sys.stdout.write(json.dumps({"ok": True, "output": output}, ensure_ascii=False) + "\n")
        except Exception as error:
            sys.stdout.write(
                json.dumps({"ok": False, "output": str(error)}, ensure_ascii=False) + "\n"
            )
        sys.stdout.flush()


if __name__ == "__main__":
    _worker_loop()