Skip to content

Commit 22ada11

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 3154767 commit 22ada11

6 files changed

Lines changed: 67 additions & 26 deletions

File tree

‎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,
@@ -512,12 +513,7 @@ def _inject_execution_context(self, interpreter: CodeInterpreter, execution_tool
512513
even for user-provided interpreters. Each forward() call gets fresh tools with a
513514
fresh call counter, so we must inject on every execution.
514515
"""
515-
interpreter.tools.update(execution_tools)
516-
if hasattr(interpreter, "output_fields"):
517-
interpreter.output_fields = self._get_output_fields_info()
518-
# Reset registration flag to force re-registration with fresh tools
519-
if hasattr(interpreter, "_tools_registered"):
520-
interpreter._tools_registered = False
516+
_bind_interpreter(interpreter, {**interpreter.tools, **execution_tools}, self._get_output_fields_info())
521517

522518
@contextmanager
523519
def _interpreter_context(
@@ -528,8 +524,12 @@ def _interpreter_context(
528524
"""Yield a caller-owned interpreter or manage a factory-created one."""
529525
if interpreter is not None:
530526
_validate_interpreter(interpreter)
531-
self._inject_execution_context(interpreter, execution_tools)
532-
yield interpreter
527+
original = (dict(interpreter.tools), getattr(interpreter, "output_fields", None))
528+
try:
529+
self._inject_execution_context(interpreter, execution_tools)
530+
yield interpreter
531+
finally:
532+
_bind_interpreter(interpreter, *original)
533533
return
534534

535535
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. 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
@@ -471,11 +471,16 @@ async def test_factory_creates_and_shuts_down_one_interpreter_per_call(self):
471471
@pytest.mark.asyncio
472472
async def test_caller_owned_interpreter_can_be_reused_across_sequential_calls(self):
473473
factory = MockInterpreterFactory()
474+
475+
def backend_tool():
476+
return "backend"
477+
474478
interpreter = MockInterpreter(
475479
responses=[
476480
FinalOutput({"answer": "first"}),
477481
FinalOutput({"answer": "second"}),
478-
]
482+
],
483+
tools={"backend_tool": backend_tool},
479484
)
480485
rlm = RLM("query -> answer", max_iters=1, interpreter_factory=factory)
481486
rlm.generate_action = make_mock_predictor([
@@ -489,6 +494,7 @@ async def test_caller_owned_interpreter_can_be_reused_across_sequential_calls(se
489494
assert sync_result.answer == "first"
490495
assert async_result.answer == "second"
491496
assert factory.instances == []
497+
assert interpreter.tools == {"backend_tool": backend_tool}
492498
assert interpreter.execute("print('still open')") == ""
493499
finally:
494500
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)