(no commit message)
This commit is contained in:
145
host_interpreter.py
Normal file
145
host_interpreter.py
Normal file
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import builtins
|
||||
import io
|
||||
import sys
|
||||
import traceback
|
||||
from dataclasses import dataclass, field
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Callable
|
||||
|
||||
from dspy.primitives.code_interpreter import CodeInterpreterError, FinalOutput
|
||||
|
||||
|
||||
@dataclass
|
||||
class UnsafeHostInterpreter:
|
||||
"""
|
||||
A minimal CodeInterpreter implementation that executes code in the host Python process.
|
||||
|
||||
Why this exists:
|
||||
- DSPy's default RLM interpreter (Deno/Pyodide) currently relies on pyodide.ffi.run_sync
|
||||
to bridge async tool calls, which fails on runtimes without WASM stack switching support.
|
||||
|
||||
Tradeoff:
|
||||
- This is NOT a security sandbox. It will execute arbitrary Python code produced by the LLM.
|
||||
Use only in trusted/local environments.
|
||||
"""
|
||||
|
||||
tools: dict[str, Callable[..., str]] = field(default_factory=dict)
|
||||
# If RLM injects this attribute, we can map SUBMIT() to output fields.
|
||||
output_fields: list[dict] | None = None
|
||||
_started: bool = False
|
||||
_globals: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def start(self) -> None:
|
||||
if self._started:
|
||||
return
|
||||
# Start with a constrained global namespace. This is not a real sandbox.
|
||||
self._globals = {
|
||||
"__name__": "__rlm_host__",
|
||||
"__builtins__": MappingProxyType(
|
||||
{
|
||||
# Allow common harmless builtins needed for analysis.
|
||||
"print": builtins.print,
|
||||
"len": builtins.len,
|
||||
"type": builtins.type,
|
||||
"range": builtins.range,
|
||||
"reversed": builtins.reversed,
|
||||
"min": builtins.min,
|
||||
"max": builtins.max,
|
||||
"sum": builtins.sum,
|
||||
"sorted": builtins.sorted,
|
||||
"enumerate": builtins.enumerate,
|
||||
"str": builtins.str,
|
||||
"int": builtins.int,
|
||||
"float": builtins.float,
|
||||
"bool": builtins.bool,
|
||||
"dict": builtins.dict,
|
||||
"list": builtins.list,
|
||||
"set": builtins.set,
|
||||
"tuple": builtins.tuple,
|
||||
"abs": builtins.abs,
|
||||
"all": builtins.all,
|
||||
"any": builtins.any,
|
||||
"zip": builtins.zip,
|
||||
}
|
||||
),
|
||||
}
|
||||
# Provide a few commonly-used stdlib modules without enabling arbitrary imports.
|
||||
# (The host interpreter is already unsafe, but keeping imports closed reduces footguns.)
|
||||
import json as _json
|
||||
import math as _math
|
||||
import re as _re
|
||||
|
||||
self._globals.update({"re": _re, "json": _json, "math": _math})
|
||||
self._started = True
|
||||
|
||||
def execute(self, code: str, variables: dict[str, Any] | None = None) -> Any:
|
||||
if not self._started:
|
||||
self.start()
|
||||
|
||||
# Inject variables and tools into the exec namespace.
|
||||
if variables:
|
||||
self._globals.update(variables)
|
||||
self._globals.update(self.tools)
|
||||
|
||||
# Provide SUBMIT for early termination.
|
||||
class _SubmitSignal(BaseException):
|
||||
def __init__(self, payload: dict[str, Any]):
|
||||
super().__init__()
|
||||
self.payload = payload
|
||||
|
||||
def SUBMIT(*args: Any, **kwargs: Any) -> None: # noqa: N802 - matches DSPy contract
|
||||
# RLM expects interpreter.execute() to RETURN a FinalOutput instance,
|
||||
# not raise it as an exception. We raise a private control-flow signal
|
||||
# and convert it into FinalOutput below.
|
||||
if not kwargs:
|
||||
# Support SUBMIT("...") for single-output signatures.
|
||||
if (
|
||||
len(args) == 1
|
||||
and self.output_fields
|
||||
and len(self.output_fields) == 1
|
||||
):
|
||||
name = self.output_fields[0]["name"]
|
||||
kwargs = {name: args[0]}
|
||||
# Support SUBMIT() if user assigned output variables in globals.
|
||||
elif len(args) == 0 and self.output_fields:
|
||||
payload: dict[str, Any] = {}
|
||||
for f in self.output_fields:
|
||||
fname = f["name"]
|
||||
if fname in self._globals:
|
||||
payload[fname] = self._globals[fname]
|
||||
if payload:
|
||||
kwargs = payload
|
||||
else:
|
||||
raise _SubmitSignal(
|
||||
{
|
||||
"error": "SUBMIT called without outputs; provide kwargs or set output variables."
|
||||
}
|
||||
)
|
||||
|
||||
raise _SubmitSignal(kwargs)
|
||||
|
||||
self._globals["SUBMIT"] = SUBMIT
|
||||
|
||||
buf = io.StringIO()
|
||||
old_stdout, old_stderr = sys.stdout, sys.stderr
|
||||
sys.stdout, sys.stderr = buf, buf
|
||||
try:
|
||||
exec(code, self._globals, self._globals)
|
||||
except _SubmitSignal as sig:
|
||||
return FinalOutput(sig.payload)
|
||||
except SyntaxError:
|
||||
raise
|
||||
except Exception as e:
|
||||
tb = traceback.format_exc()
|
||||
raise CodeInterpreterError(f"{e}\n\n{tb}")
|
||||
finally:
|
||||
sys.stdout, sys.stderr = old_stdout, old_stderr
|
||||
|
||||
out = buf.getvalue()
|
||||
return out.strip() if out.strip() else None
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._globals.clear()
|
||||
self._started = False
|
||||
Reference in New Issue
Block a user