Skip to content

Commit 41e56c7

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 a0bda0f commit 41e56c7

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,
@@ -504,12 +505,7 @@ def _inject_execution_context(self, interpreter: CodeInterpreter, execution_tool
504505
even for user-provided interpreters. Each forward() call gets fresh tools with a
505506
fresh call counter, so we must inject on every execution.
506507
"""
507-
interpreter.tools.update(execution_tools)
508-
if hasattr(interpreter, "output_fields"):
509-
interpreter.output_fields = self._get_output_fields_info()
510-
# Reset registration flag to force re-registration with fresh tools
511-
if hasattr(interpreter, "_tools_registered"):
512-
interpreter._tools_registered = False
508+
_bind_interpreter(interpreter, {**interpreter.tools, **execution_tools}, self._get_output_fields_info())
513509

514510
@contextmanager
515511
def _interpreter_context(
@@ -520,8 +516,12 @@ def _interpreter_context(
520516
"""Yield a caller-owned interpreter or manage a factory-created one."""
521517
if interpreter is not None:
522518
_validate_interpreter(interpreter)
523-
self._inject_execution_context(interpreter, execution_tools)
524-
yield interpreter
519+
original = (dict(interpreter.tools), getattr(interpreter, "output_fields", None))
520+
try:
521+
self._inject_execution_context(interpreter, execution_tools)
522+
yield interpreter
523+
finally:
524+
_bind_interpreter(interpreter, *original)
525525
return
526526

527527
interpreter = _create_interpreter(self._interpreter_factory)

‎dspy/primitives/code_interpreter.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,13 @@ 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): setattr(interpreter, name, value)

‎dspy/primitives/python_interpreter.py‎

Lines changed: 8 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
@@ -351,6 +351,12 @@ def execution_instructions(self) -> str:
351351
"extensions are unavailable. Filesystem, environment, and network access require explicit permission."
352352
)
353353

354+
def bind(self, *, tools: dict[str, Callable], output_fields: list[dict] | None = None) -> None:
355+
"""Replace tools and the SUBMIT output shape without resetting the session."""
356+
fields = None if output_fields is None else [dict(field) for field in output_fields]
357+
self.tools, self.output_fields = dict(tools), fields
358+
self._tools_registered = False
359+
354360
def _check_session_active(self) -> None:
355361
if self._session_ended:
356362
raise CodeInterpreterError(
@@ -482,7 +488,7 @@ def _register_tools(self) -> None:
482488
return
483489

484490
# Build registration params with typed tool signatures
485-
params = {}
491+
params = {"outputs": self.output_fields or []}
486492

487493
if self.tools:
488494
tools_info = []
@@ -493,14 +499,6 @@ def _register_tools(self) -> None:
493499
})
494500
params["tools"] = tools_info
495501

496-
if self.output_fields:
497-
params["outputs"] = self.output_fields
498-
499-
# Skip if nothing to register
500-
if not params:
501-
self._tools_registered = True
502-
return
503-
504502
self._send_request("register", params, "registering tools/outputs")
505503
self._tools_registered = True
506504

‎tests/predict/test_rlm.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -494,11 +494,16 @@ async def test_factory_creates_and_shuts_down_one_interpreter_per_call(self):
494494
@pytest.mark.asyncio
495495
async def test_caller_owned_interpreter_can_be_reused_across_sequential_calls(self):
496496
factory = MockInterpreterFactory()
497+
498+
def backend_tool():
499+
return "backend"
500+
497501
interpreter = MockInterpreter(
498502
responses=[
499503
FinalOutput({"answer": "first"}),
500504
FinalOutput({"answer": "second"}),
501-
]
505+
],
506+
tools={"backend_tool": backend_tool},
502507
)
503508
rlm = RLM("query -> answer", max_iters=1, interpreter_factory=factory)
504509
rlm.generate_action = make_mock_predictor([
@@ -512,6 +517,7 @@ async def test_caller_owned_interpreter_can_be_reused_across_sequential_calls(se
512517
assert sync_result.answer == "first"
513518
assert async_result.answer == "second"
514519
assert factory.instances == []
520+
assert interpreter.tools == {"backend_tool": backend_tool}
515521
assert interpreter.execute("print('still open')") == ""
516522
finally:
517523
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)