Skip to content

Commit 557c31d

Browse files
ampagentIsaac Miller
andcommitted
Add invocation-scoped interpreter binding
Co-authored-by: Isaac Miller <isaac@cmpnd.ai> Amp-Thread-ID: https://ampcode.com/threads/T-019fcce1-bc8b-753e-886f-9b77c0ccb376
1 parent e3a4f4d commit 557c31d

8 files changed

Lines changed: 69 additions & 28 deletions

File tree

‎docs/docs/api/modules/RLM.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ rlm = dspy.RLM(
199199
)
200200
```
201201

202-
RLM creates and shuts down one interpreter from this factory per invocation. It adds invocation-scoped tools to the returned interpreter's mutable `tools` dictionary, so remote sandboxes need a `CodeInterpreter` adapter that supports that protocol. To reuse a caller-owned interpreter, pass it as the first positional argument when calling the module: `rlm(interpreter, context=data, query=query)`. RLM updates its tools and output metadata but does not shut down or restore it. Reuse is supported only for sequential calls to the same RLM instance; use the factory path for concurrency.
202+
RLM creates and shuts down one interpreter from this factory per invocation. It adds invocation-scoped tools to the returned interpreter's mutable `tools` dictionary, so remote sandboxes need a `CodeInterpreter` adapter that supports that protocol. To reuse a caller-owned interpreter, pass it as the first positional argument when calling the module: `rlm(interpreter, context=data, query=query)`. RLM temporarily replaces its tools and output metadata, restores them afterward, and does not shut it down. Interpreter namespace state created during the invocation persists. Reuse is supported only for sequential calls to the same RLM instance; use the factory path for concurrency.
203203

204204
If the factory exposes an `execution_instructions` string, RLM adds it to the action predictor's task instructions,
205205
which DSPy adapters place in the system prompt. Optimizers such as GEPA may therefore adapt the execution guidance

‎docs/docs/diving-deeper/rlm.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ A list of plain functions or `dspy.Tool` objects. RLM normalizes each to a `Tool
9393
A zero-argument callable that returns a fresh `CodeInterpreter` for one invocation. RLM may call the factory concurrently, and it always shuts down the returned interpreter. A class such as `PythonInterpreter` is already a factory; use `functools.partial` or a callable provider object when construction needs configuration. RLM adds invocation-scoped tools to the returned interpreter's mutable `tools` dictionary, so remote sandboxes need a `CodeInterpreter` adapter that supports that protocol.
9494

9595
**`__call__(interpreter, **inputs)` / `acall(interpreter, **inputs)`**
96-
An escape hatch for a caller-owned interpreter, supplied as the first positional argument. RLM mutates its `tools` dictionary and, when supported, its output-field metadata, but does not shut down or restore the instance. Reuse is supported only for sequential calls to the same RLM instance, so retained variables and tool registrations stay within one program and trust boundary. Use `interpreter_factory` for concurrent invocations. A `PythonInterpreter` override must also stay on the thread where it was first used.
96+
An escape hatch for a caller-owned interpreter, supplied as the first positional argument. RLM temporarily replaces its `tools` dictionary and, when supported, its output-field metadata, then restores both after the invocation without shutting down the instance. Interpreter namespace state created during the invocation persists. Reuse is supported only for sequential calls to the same RLM instance, so retained variables stay within one program and trust boundary. Use `interpreter_factory` for concurrent invocations. A `PythonInterpreter` override must also stay on the thread where it was first used.
9797

9898
**`dspy.SandboxSerializable`**
9999
The base class for inputs that need custom loading. Implement `sandbox_setup`, `to_sandbox`, `sandbox_assignment`, and `rlm_preview`. It also defines a Pydantic schema hook, so a subclass can be a typed field in a signature, as in `data: DataFrame = dspy.InputField()`.

‎dspy/predict/flex/bridge.py‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
from dspy import CodeInterpreterError
4343
from dspy.adapters.types.base_type import Type as _CustomType
4444
from dspy.adapters.utils import annotation_allows_none, parse_value
45-
from dspy.primitives.code_interpreter import _create_interpreter
45+
from dspy.primitives.code_interpreter import _bind_interpreter, _create_interpreter
4646
from dspy.signatures.signature import make_signature
4747
from dspy.utils.exceptions import LMError
4848

@@ -256,11 +256,9 @@ def forward(self, inputs: dict[str, Any]) -> Any:
256256
invocation = _Invocation(self, originals)
257257
interp = _create_interpreter(self._factory)
258258
try:
259-
interp.tools.update({CONSTRUCT_TOOL: invocation.construct, CALL_TOOL: invocation.call})
260-
# User tools callable by name in the sandbox.
261-
interp.tools.update(
262-
{name: _restoring_entrypoint(fn, originals) for name, fn in self._tool_callables().items()}
263-
)
259+
tools = {CONSTRUCT_TOOL: invocation.construct, CALL_TOOL: invocation.call}
260+
tools.update({name: _restoring_entrypoint(fn, originals) for name, fn in self._tool_callables().items()})
261+
_bind_interpreter(interp, {**interp.tools, **tools})
264262
interp.execute(SHIM_SETUP)
265263
interp.execute(self._module_src) # defines the class in the sandbox
266264
code = (

‎dspy/predict/rlm.py‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
CodeExecutionError,
3232
CodeInterpreter,
3333
FinalOutput,
34+
_bind_interpreter,
3435
_create_interpreter,
3536
_validate_interpreter,
3637
_validate_interpreter_factory,
@@ -510,12 +511,7 @@ def _inject_execution_context(self, interpreter: CodeInterpreter, execution_tool
510511
even for user-provided interpreters. Each forward() call gets fresh tools with a
511512
fresh call counter, so we must inject on every execution.
512513
"""
513-
interpreter.tools.update(execution_tools)
514-
if hasattr(interpreter, "output_fields"):
515-
interpreter.output_fields = self._get_output_fields_info()
516-
# Reset registration flag to force re-registration with fresh tools
517-
if hasattr(interpreter, "_tools_registered"):
518-
interpreter._tools_registered = False
514+
_bind_interpreter(interpreter, {**interpreter.tools, **execution_tools}, self._get_output_fields_info())
519515

520516
@contextmanager
521517
def _interpreter_context(
@@ -526,8 +522,12 @@ def _interpreter_context(
526522
"""Yield a caller-owned interpreter or manage a factory-created one."""
527523
if interpreter is not None:
528524
_validate_interpreter(interpreter)
529-
self._inject_execution_context(interpreter, execution_tools)
530-
yield interpreter
525+
original = (dict(interpreter.tools), getattr(interpreter, "output_fields", None))
526+
try:
527+
self._inject_execution_context(interpreter, execution_tools)
528+
yield interpreter
529+
finally:
530+
_bind_interpreter(interpreter, *original)
531531
return
532532

533533
interpreter = _create_interpreter(self._interpreter_factory)

‎dspy/primitives/code_interpreter.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,14 @@ def _validate_interpreter(interpreter: Any) -> None:
177177
"""Validate a caller-owned interpreter."""
178178
if not isinstance(interpreter, CodeInterpreter):
179179
raise TypeError(f"interpreter must implement CodeInterpreter, not {type(interpreter).__name__}.")
180+
181+
182+
def _bind_interpreter(interpreter, tools, output_fields=None) -> None:
183+
if callable(bind := getattr(interpreter, "bind", None)):
184+
bind(tools=tools, output_fields=output_fields)
185+
else:
186+
interpreter.tools.clear()
187+
interpreter.tools.update(tools)
188+
for name, value in (("output_fields", output_fields), ("_tools_registered", False)):
189+
if hasattr(interpreter, name):
190+
setattr(interpreter, name, value)

‎dspy/primitives/python_interpreter.py‎

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ def __init__(
287287
self.tools = dict(tools) if tools else {}
288288
self.output_fields = output_fields
289289
self.callbacks = list(callbacks or [])
290-
self._tools_registered = False
290+
self._tools_registered = not bool(self.tools or self.output_fields)
291291
# TODO later on add enable_run (--allow-run) by proxying subprocess.run through Deno.run() to fix 'emscripten does not support processes' error
292292

293293
self._uses_default_deno_command = not deno_command
@@ -348,6 +348,11 @@ def __init__(
348348
"extensions are unavailable. Host filesystem, environment, and network access require explicit permission."
349349
)
350350

351+
def bind(self, *, tools: dict[str, Callable], output_fields: list[dict] | None = None) -> None:
352+
fields = None if output_fields is None else [dict(field) for field in output_fields]
353+
self.tools, self.output_fields = dict(tools), fields
354+
self._tools_registered = False
355+
351356
def _check_session_active(self) -> None:
352357
if self._session_ended:
353358
raise CodeInterpreterError(
@@ -479,7 +484,7 @@ def _register_tools(self) -> None:
479484
return
480485

481486
# Build registration params with typed tool signatures
482-
params = {}
487+
params = {"outputs": self.output_fields or []}
483488

484489
if self.tools:
485490
tools_info = []
@@ -490,14 +495,6 @@ def _register_tools(self) -> None:
490495
})
491496
params["tools"] = tools_info
492497

493-
if self.output_fields:
494-
params["outputs"] = self.output_fields
495-
496-
# Skip if nothing to register
497-
if not params:
498-
self._tools_registered = True
499-
return
500-
501498
self._send_request("register", params, "registering tools/outputs")
502499
self._tools_registered = True
503500

‎tests/predict/test_rlm.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,11 +502,16 @@ async def test_factory_creates_and_shuts_down_one_interpreter_per_call(self):
502502
@pytest.mark.asyncio
503503
async def test_caller_owned_interpreter_can_be_reused_across_sequential_calls(self):
504504
factory = MockInterpreterFactory()
505+
506+
def backend_tool():
507+
return "backend"
508+
505509
interpreter = MockInterpreter(
506510
responses=[
507511
FinalOutput({"answer": "first"}),
508512
FinalOutput({"answer": "second"}),
509-
]
513+
],
514+
tools={"backend_tool": backend_tool},
510515
)
511516
rlm = RLM("query -> answer", max_iters=1, interpreter_factory=factory)
512517
rlm.generate_action = make_mock_predictor([
@@ -520,6 +525,7 @@ async def test_caller_owned_interpreter_can_be_reused_across_sequential_calls(se
520525
assert sync_result.answer == "first"
521526
assert async_result.answer == "second"
522527
assert factory.instances == []
528+
assert interpreter.tools == {"backend_tool": backend_tool}
523529
assert interpreter.execute("print('still open')") == ""
524530
finally:
525531
interpreter.shutdown()

‎tests/primitives/test_python_interpreter.py‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from pydantic import BaseModel, ConfigDict
2020

2121
import dspy.primitives.python_interpreter as python_interpreter
22-
from dspy.primitives.code_interpreter import CodeExecutionError, CodeInterpreterError, FinalOutput
22+
from dspy.primitives.code_interpreter import CodeExecutionError, CodeInterpreterError, FinalOutput, _bind_interpreter
2323
from dspy.primitives.python_interpreter import (
2424
LARGE_VAR_THRESHOLD,
2525
PythonInterpreter,
@@ -740,6 +740,35 @@ def test_submit_valid_runtime_edge_cases(field, code, expected):
740740
assert interpreter.execute(code) == FinalOutput(expected)
741741

742742

743+
def test_bind_replaces_tools_and_submit_shape_without_resetting_state(pooled_interpreter):
744+
tools = {"tool": lambda: "ok"}
745+
fields = [{"name": "answer", "type": "str"}]
746+
pooled_interpreter.execute("state = 41")
747+
pooled_interpreter.bind(tools=tools, output_fields=fields)
748+
tools.clear()
749+
fields[0]["name"] = "changed"
750+
751+
assert pooled_interpreter.execute("tool()") == "ok"
752+
assert pooled_interpreter.execute("state + 1") == 42
753+
assert pooled_interpreter.execute("SUBMIT(answer='done')") == FinalOutput({"answer": "done"})
754+
with pytest.raises(TypeError):
755+
pooled_interpreter.bind(tools={}, output_fields=[None])
756+
assert pooled_interpreter.execute("tool()") == "ok"
757+
758+
pooled_interpreter.bind(tools={})
759+
assert pooled_interpreter.execute("SUBMIT('default')") == FinalOutput({"output": "default"})
760+
761+
762+
def test_bind_fallback_invalidates_legacy_registration():
763+
class LegacyPythonInterpreter(PythonInterpreter):
764+
bind = None
765+
766+
with LegacyPythonInterpreter(tools={"old": lambda: "old"}) as interpreter:
767+
assert interpreter.execute("old()") == "old"
768+
_bind_interpreter(interpreter, {"new": lambda: "new"})
769+
assert interpreter.execute("new()") == "new"
770+
771+
743772
def test_tool_positional_args(configure_pooled_interpreter):
744773
"""Test that tools work with positional arguments."""
745774

0 commit comments

Comments
 (0)