| """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( |
| 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( |
| compile(ast.Module(body, type_ignores=[]), "<tool>", "exec"), |
| namespace, |
| namespace, |
| ) |
| value = eval( |
| 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) |
| 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() |
|
|