Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/docs/api/modules/RLM.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ rlm = dspy.RLM(
)
```

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.
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.

If the factory exposes an `execution_instructions` string, RLM adds it to the action predictor's task instructions,
which DSPy adapters place in the system prompt. Optimizers such as GEPA may therefore adapt the execution guidance
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/diving-deeper/rlm.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ A list of plain functions or `dspy.Tool` objects. RLM normalizes each to a `Tool
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.

**`__call__(interpreter, **inputs)` / `acall(interpreter, **inputs)`**
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.
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.

**`dspy.SandboxSerializable`**
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()`.
Expand Down
10 changes: 4 additions & 6 deletions dspy/predict/flex/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
from dspy import CodeInterpreterError
from dspy.adapters.types.base_type import Type as _CustomType
from dspy.adapters.utils import annotation_allows_none, parse_value
from dspy.primitives.code_interpreter import _create_interpreter
from dspy.primitives.code_interpreter import _bind_interpreter, _create_interpreter
from dspy.signatures.signature import make_signature
from dspy.utils.exceptions import LMError

Expand Down Expand Up @@ -256,11 +256,9 @@ def forward(self, inputs: dict[str, Any]) -> Any:
invocation = _Invocation(self, originals)
interp = _create_interpreter(self._factory)
try:
interp.tools.update({CONSTRUCT_TOOL: invocation.construct, CALL_TOOL: invocation.call})
# User tools callable by name in the sandbox.
interp.tools.update(
{name: _restoring_entrypoint(fn, originals) for name, fn in self._tool_callables().items()}
)
tools = {CONSTRUCT_TOOL: invocation.construct, CALL_TOOL: invocation.call}
tools.update({name: _restoring_entrypoint(fn, originals) for name, fn in self._tool_callables().items()})
_bind_interpreter(interp, {**interp.tools, **tools})
interp.execute(SHIM_SETUP)
interp.execute(self._module_src) # defines the class in the sandbox
code = (
Expand Down
16 changes: 8 additions & 8 deletions dspy/predict/rlm.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
CodeExecutionError,
CodeInterpreter,
FinalOutput,
_bind_interpreter,
_create_interpreter,
_validate_interpreter,
_validate_interpreter_factory,
Expand Down Expand Up @@ -510,12 +511,7 @@ def _inject_execution_context(self, interpreter: CodeInterpreter, execution_tool
even for user-provided interpreters. Each forward() call gets fresh tools with a
fresh call counter, so we must inject on every execution.
"""
interpreter.tools.update(execution_tools)
if hasattr(interpreter, "output_fields"):
interpreter.output_fields = self._get_output_fields_info()
# Reset registration flag to force re-registration with fresh tools
if hasattr(interpreter, "_tools_registered"):
interpreter._tools_registered = False
_bind_interpreter(interpreter, {**interpreter.tools, **execution_tools}, self._get_output_fields_info())

@contextmanager
def _interpreter_context(
Expand All @@ -526,8 +522,12 @@ def _interpreter_context(
"""Yield a caller-owned interpreter or manage a factory-created one."""
if interpreter is not None:
_validate_interpreter(interpreter)
self._inject_execution_context(interpreter, execution_tools)
yield interpreter
original = (dict(interpreter.tools), getattr(interpreter, "output_fields", None))
try:
self._inject_execution_context(interpreter, execution_tools)
yield interpreter
finally:
_bind_interpreter(interpreter, *original)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment on lines +525 to +530

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Caller-owned interpreter bindings race across concurrent RLM calls

Two overlapping RLM calls can mutate the same caller-owned interpreter without exclusive ownership of its bindings. Each call independently snapshots tools, injects invocation-specific tools, executes, and restores its snapshot. Concurrent execution causes one invocation to observe the other invocation's llm_query closure and can leave invocation tools registered after both calls complete. This breaks invocation-scoped tool isolation and can expose a tool from one request to another. Serialize the complete snapshot/bind/execute/restore lifecycle per caller-owned interpreter, or explicitly reject overlapping use.

Artifacts

Focused caller-owned lifecycle pytest source

  • Authored pytest source creates protocol-conforming interpreters and executes the exact incompatible-bind, sequential-reuse, and concurrent-overlap paths; it is the runnable proof harness.

Focused caller-owned lifecycle validation output

  • Captured output of `.venv/bin/python -m pytest -q trex-artifacts/caller-owned-lifecycle-validation.py` from `/home/user/repo`, showing all three focused assertions passed and confirming the observed current behaviors.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +525 to +530

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Caller-owned interpreter bindings race across concurrent RLM calls

The caller-owned path snapshots mutable bindings, installs invocation tools, yields for execution, and restores its snapshot without exclusive ownership of the interpreter. Two overlapping calls can therefore observe each other's tools, restore over each other's active bindings, and leave invocation tools behind after both calls finish. The deterministic concurrent run showed one invocation receiving the other invocation's tool, the other losing its own tool after restoration, and a stale invocation tool remaining afterward. Serialize the complete snapshot/bind/execute/restore lifecycle per caller-owned interpreter, or reject overlapping use.

Artifacts

Deterministic shared-interpreter race reproducer source

  • Authored focused Python reproducer creates two overlapping RLM caller-owned interpreter scopes and forces the leak-and-restore interleaving, proving the concurrency invariant being tested.

Before capture of legacy snapshot update restore race

  • Executed parent-behavior model shows invocation A seeing B's tool, B losing its tool after A restores, and the final caller binding retaining A's tool, proving the original race.

After capture of current RLM binding race

  • Executed current `RLM._interpreter_context()` path produces the same leaked tools and incorrect final binding as the before capture, proving the issue remains.

Focused upstream test collection attempt

  • Executed focused pytest command could not collect because optional `gepa` is absent from the project virtual environment, documenting the upstream-suite blocker.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines 524 to +530

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Caller-owned interpreter bindings race across concurrent RLM calls

Overlapping calls using the same caller-owned interpreter independently snapshot, bind, execute, and restore mutable tool bindings. One invocation can therefore observe another invocation's tools, lose its own binding while still active, and leave stale invocation tools after cleanup. This breaks request-scoped capability isolation and can expose tenant-specific or privileged tools across requests. Serialize the full lifecycle per caller-owned interpreter, or explicitly reject concurrent use.

Artifacts

Deterministic caller-owned interpreter lifecycle reproduction source

  • Authored script loads the checked-out RLM lifecycle and coordinates sequential and overlapping caller-owned interpreter contexts, ending with a deterministic race assertion.

Sequential caller-owned interpreter lifecycle output

  • Executed sequential control run restores the interpreter to only its baseline tool, establishing the non-overlapping comparison.

Overlapping caller-owned interpreter lifecycle output

  • Executed overlapping run shows invocation A leaking into B, invocation B being lost while active, and A leaking after cleanup, reproducing the defect.

Focused RLM pytest collection attempt

  • Executed focused pytest command could not collect because the environment lacks `litellm`, so package-level regression collection was blocked.

Worktree integrity check after validation

  • Executed diff and status check confirms no tracked repository source modifications were made during validation.

Validation environment record

  • Captured repository path, commit identifier, and Python runtime used for the reproduction.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +525 to +530

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Concurrent caller-owned RLM calls race on tool bindings

Caller-owned interpreters are snapshotted, rebound, executed, and restored without exclusive ownership of their mutable bindings. Overlapping RLM calls can therefore execute with another invocation's tools, lose their own active tools during restoration, and retain stale invocation tools afterward. Serialize the complete snapshot/bind/execute/restore lifecycle per interpreter, or explicitly reject concurrent use.

Artifacts

Deterministic RLM caller-owned interpreter lifecycle reproduction source

  • Authored executable source that creates sequential and forced-overlap caller-owned interpreter scenarios, showing the lifecycle behavior under test.

Runtime output for caller-owned interpreter sequential and concurrent lifecycle reproduction

  • Captured command output from running the authored reproduction with the project virtual environment, showing sequential success and the concurrent binding leak.

Focused RLM interpreter lifecycle test output

  • Captured command output from the existing focused interpreter-lifecycle pytest class, confirming its lifecycle coverage passes while it does not exercise overlapping ownership.

View artifacts

T-Rex Ran code and verified through T-Rex

return
Comment on lines 523 to 531

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Shared interpreter calls leak invocation tools

Caller-owned interpreters are snapshotted, rebound, executed, and restored without exclusive ownership. Overlapping calls can therefore execute with another invocation's tools and leave invocation-scoped tools installed after both calls finish. The reproduced overlap made invocation A receive B's tool result and retained A's owner, llm_query, and llm_query_batched bindings afterward. Serialize the complete lifecycle per interpreter or reject concurrent use.

Artifacts

Deterministic shared-interpreter RLM concurrency reproduction source

  • Executable source runs real RLM forward and aforward lifecycle paths against a barrier-controlled caller-owned interpreter; it proves the race is reproducible.

Captured deterministic reproduction source with checksum

  • Command-captured SHA-256 and full source of the executed reproduction; it makes the supplied test artifact traceable.

Sequential caller-owned interpreter control output

  • Sequential executions return A then B and restore only the original base tool state; it establishes the non-overlapping baseline.

Overlapping caller-owned interpreter reproduction output

  • Barrier-controlled overlapping forward and aforward calls return B's tool result to A and leave A's stale context installed; it reproduces the defect.

Current RLM interpreter lifecycle lines

  • Captured current source lines 507-539, 702-737, and 791-826 showing unsynchronized context mutation and both public call paths; it identifies the affected code.

Focused RLM regression suite output

  • Executed `tests/predict/test_rlm.py` with 106 passing and 39 skipped tests; existing focused coverage remains green despite the concurrency defect.

View artifacts

T-Rex Ran code and verified through T-Rex


interpreter = _create_interpreter(self._interpreter_factory)
Expand Down
11 changes: 11 additions & 0 deletions dspy/primitives/code_interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,14 @@ def _validate_interpreter(interpreter: Any) -> None:
"""Validate a caller-owned interpreter."""
if not isinstance(interpreter, CodeInterpreter):
raise TypeError(f"interpreter must implement CodeInterpreter, not {type(interpreter).__name__}.")


def _bind_interpreter(interpreter, tools, output_fields=None) -> None:
if callable(bind := getattr(interpreter, "bind", None)):
bind(tools=tools, output_fields=output_fields)
Comment on lines +183 to +184

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unrelated bind methods break caller-owned interpreters

_bind_interpreter() treats any callable attribute named bind as DSPy's optional binding API and unconditionally passes tools= and output_fields=. A caller-owned object can satisfy CodeInterpreter while using an unrelated bind(connection) method; RLM setup then raises TypeError before execution. Invoke an explicitly compatible binding interface, or use the protocol-owned state-update fallback when that signature is unsupported.

Artifacts

Focused reproduction source for a protocol-valid interpreter with bind(connection)

  • The authored runtime script defines a structurally valid caller-owned interpreter with an unrelated one-argument bind method and invokes validation plus binding; it isolates the failing path.

Runtime output showing bind(connection) rejects RLM keyword arguments

  • Running the reproduction with the repository virtual environment accepted the interpreter as CodeInterpreter, then captured the unexpected-tools-keyword TypeError and unchanged tools; the claim is verified.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +183 to +184

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unrelated bind methods are invoked as DSPy bindings

A caller-owned object can satisfy CodeInterpreter while exposing an unrelated bind(connection) method. _bind_interpreter() treats any callable attribute named bind as DSPy's binding API and calls it with tools= and output_fields=, raising TypeError before RLM execution. Restrict this path to an explicit compatible binding interface, or fall back to updating the protocol-owned interpreter state when that signature is unsupported.

Artifacts

Focused caller-owned lifecycle pytest source

  • Authored pytest source creates protocol-conforming interpreters and executes the exact incompatible-bind, sequential-reuse, and concurrent-overlap paths; it is the runnable proof harness.

Focused caller-owned lifecycle validation output

  • Captured output of `.venv/bin/python -m pytest -q trex-artifacts/caller-owned-lifecycle-validation.py` from `/home/user/repo`, showing all three focused assertions passed and confirming the observed current behaviors.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +183 to +184

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unrelated bind methods are invoked as DSPy bindings

CodeInterpreter does not define bind, so a caller-owned interpreter can validly satisfy the protocol while using bind for an unrelated API. _bind_interpreter() treats any callable attribute with that name as DSPy's optional binding interface and calls it with tools= and output_fields=. A structurally compliant interpreter exposing bind(connection) therefore raises TypeError before RLM execution. Use an explicit, documented execution-binding capability, or fall back to updating the protocol-owned state when that capability is unavailable.

Artifacts

Focused RLM caller-owned bind collision reproducer source

  • Authored minimal Python fixture that satisfies CodeInterpreter structurally while defining an unrelated bind(connection) method, then invokes RLM. It isolates the incompatible binding call.

Baseline run for structurally compliant caller-owned interpreter

  • Executed baseline command from /home/user/repo shows the fixture passes CodeInterpreter validation and its bind(connection) API succeeds, establishing the pre-RLM condition.

RLM setup failure with unrelated caller-owned bind method

  • Executed current-code RLM command exits 1 and traces _bind_interpreter line 184 calling bind with tools=, which raises the reproduced TypeError.

Existing interpreter bind test selection result

  • Executed existing bind-focused pytest selection; both selected tests were skipped due to unavailable interpreter fixtures, so the focused runtime reproducer is the direct proof.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +183 to +184

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unrelated bind methods break caller-owned interpreters

CodeInterpreter does not require a bind method, so a caller-owned interpreter may validly use bind(connection) for its own API. _bind_interpreter() calls every callable attribute with that name as though it accepted DSPy's tools= and output_fields= keywords. RLM setup then raises TypeError before the interpreter can start or execute. Restrict this to an explicit compatible binding capability, or fall back to updating the protocol-owned interpreter state when the binding signature is unsupported.

Artifacts

Minimal reproduction source

  • Authored Python reproduction defining the no-bind baseline and structurally valid interpreter with unrelated bind(connection), then invoking caller-owned RLM setup.

Baseline RLM setup output

  • Executed no-bind control showing the structural CodeInterpreter passes RLM setup, starts, executes once, and returns answer ok.

Unrelated bind failure output

  • Executed structurally valid bind(connection) variant showing RLM setup raises TypeError for the unsupported tools keyword before start or execution.

Current binding source excerpt

  • Executed numbered source capture showing CodeInterpreter requirements, unconditional bind invocation, and its caller-owned RLM setup path.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +182 to +184

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unrelated bind methods break caller-owned interpreters

CodeInterpreter does not define bind, but _bind_interpreter() treats any callable attribute with that name as DSPy's binding API and invokes it with tools= and output_fields=. A structurally valid caller-owned interpreter may use bind(connection) for an unrelated API; RLM setup then raises TypeError before execute() runs. Use an explicit compatible binding capability, validate the binding signature, or fall back to updating the protocol-owned state.

Artifacts

Focused reproduction source with an unrelated bind(connection) method

  • Defines a structurally valid caller-owned interpreter with unrelated bind(connection) and invokes RLM, showing the exact setup path that fails before execution.

RLM setup failure with unrelated bind(connection)

  • Runs the focused RLM reproduction and captures the TypeError plus execute_called=False, proving the unguarded bind call blocks execution.

Matched control source with DSPy-compatible keyword bind method

  • Defines the same minimal interpreter shape with bind(*, tools, output_fields=None) so the expected RLM binding contract can be exercised.

Matched RLM control succeeds with keyword-compatible bind

  • Runs the matched control and captures result_answer=setup completed plus execute_called=True, showing RLM proceeds when bind supports the assumed keywords.

Focused existing RLM interpreter lifecycle tests passing

  • Runs the current RLM interpreter lifecycle regression suite, which passed 6 tests after the reproduction runs.

View artifacts

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Incidental bind method breaks RLM

CodeInterpreter does not define a bind method, but _bind_interpreter() invokes any callable attribute with that name using DSPy's tools= and output_fields= keywords. A caller-owned interpreter can otherwise conform to the protocol while using bind(connection) for an unrelated API; RLM setup then raises TypeError before execute() runs. Restrict this path to an explicit compatible binding capability, validate the supported signature, or use the protocol-owned mutable-state fallback when it is unavailable.

Artifacts

Focused RLM caller-owned interpreter binding reproducer source

  • The authored executable defines protocol-conforming control and unrelated-bind caller-owned interpreters and invokes the RLM setup path; it is the exact script used for the comparison.

Control interpreter reaches execute during RLM caller-owned setup

  • Running the focused script with a conforming interpreter that has no bind method returned `reached-execute` with `execute_calls=1`; ordinary caller-owned setup reaches execution.

Unrelated bind method raises before RLM executes code

  • Running the same script with an unrelated `bind(connection)` method raised the unexpected `tools` keyword TypeError with `execute_calls=0`; current RLM setup is incompatible with this conforming caller-owned interpreter.

Current RLM regression suite passes

  • The current RLM test suite completed with 106 passed and 39 skipped; existing coverage does not exercise an unrelated callable bind method.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +182 to +184

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unrelated bind methods are invoked as RLM bindings

CodeInterpreter does not define bind, so a caller-owned interpreter may validly use bind(connection) for another backend operation. _bind_interpreter() treats every callable attribute with that name as DSPy's binding API and invokes it with tools= and output_fields=. A protocol-compliant interpreter with an unrelated bind(connection) therefore raises TypeError before it executes any RLM code. Use an explicit compatible binding capability or fall back to updating the protocol-owned interpreter state when that capability is unavailable.

Artifacts

Reproduction source for caller-owned interpreter with unrelated bind method

  • This exact authored script defines a structurally valid caller-owned interpreter with `bind(connection)` and drives the real RLM forward path, showing the failing condition.

RLM output before safe binding behavior

  • The executed command output shows protocol validation succeeds but RLM setup raises the unexpected `tools` keyword TypeError before interpreter execution, confirming the defect.

In-process safe-binding control source

  • This exact authored control script replaces only the test process binding helper with the protocol fallback and drives the same RLM flow, demonstrating the expected usable behavior without altering repository source.

RLM output with in-process safe binding control

  • The executed control output shows successful RLM completion, one interpreter execution, restored caller tools, and no caller-owned shutdown, demonstrating that safe binding resolves the failure.

View artifacts

T-Rex Ran code and verified through T-Rex

else:
interpreter.tools.clear()
interpreter.tools.update(tools)
for name, value in (("output_fields", output_fields), ("_tools_registered", False)):
if hasattr(interpreter, name):
setattr(interpreter, name, value)
17 changes: 7 additions & 10 deletions dspy/primitives/python_interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ def __init__(
self.tools = dict(tools) if tools else {}
self.output_fields = output_fields
self.callbacks = list(callbacks or [])
self._tools_registered = False
self._tools_registered = not bool(self.tools or self.output_fields)
# TODO later on add enable_run (--allow-run) by proxying subprocess.run through Deno.run() to fix 'emscripten does not support processes' error

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

def bind(self, *, tools: dict[str, Callable], output_fields: list[dict] | None = None) -> None:
fields = None if output_fields is None else [dict(field) for field in output_fields]
self.tools, self.output_fields = dict(tools), fields
self._tools_registered = False

def _check_session_active(self) -> None:
if self._session_ended:
raise CodeInterpreterError(
Expand Down Expand Up @@ -479,7 +484,7 @@ def _register_tools(self) -> None:
return

# Build registration params with typed tool signatures
params = {}
params = {"outputs": self.output_fields or []}

if self.tools:
tools_info = []
Expand All @@ -490,14 +495,6 @@ def _register_tools(self) -> None:
})
params["tools"] = tools_info

if self.output_fields:
params["outputs"] = self.output_fields

# Skip if nothing to register
if not params:
self._tools_registered = True
return

self._send_request("register", params, "registering tools/outputs")
self._tools_registered = True

Expand Down
8 changes: 7 additions & 1 deletion tests/predict/test_rlm.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,11 +502,16 @@ async def test_factory_creates_and_shuts_down_one_interpreter_per_call(self):
@pytest.mark.asyncio
async def test_caller_owned_interpreter_can_be_reused_across_sequential_calls(self):
factory = MockInterpreterFactory()

def backend_tool():
return "backend"

interpreter = MockInterpreter(
responses=[
FinalOutput({"answer": "first"}),
FinalOutput({"answer": "second"}),
]
],
tools={"backend_tool": backend_tool},
)
rlm = RLM("query -> answer", max_iters=1, interpreter_factory=factory)
rlm.generate_action = make_mock_predictor([
Expand All @@ -520,6 +525,7 @@ async def test_caller_owned_interpreter_can_be_reused_across_sequential_calls(se
assert sync_result.answer == "first"
assert async_result.answer == "second"
assert factory.instances == []
assert interpreter.tools == {"backend_tool": backend_tool}
assert interpreter.execute("print('still open')") == ""
finally:
interpreter.shutdown()
Expand Down
31 changes: 30 additions & 1 deletion tests/primitives/test_python_interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from pydantic import BaseModel, ConfigDict

import dspy.primitives.python_interpreter as python_interpreter
from dspy.primitives.code_interpreter import CodeExecutionError, CodeInterpreterError, FinalOutput
from dspy.primitives.code_interpreter import CodeExecutionError, CodeInterpreterError, FinalOutput, _bind_interpreter
from dspy.primitives.python_interpreter import (
LARGE_VAR_THRESHOLD,
PythonInterpreter,
Expand Down Expand Up @@ -740,6 +740,35 @@ def test_submit_valid_runtime_edge_cases(field, code, expected):
assert interpreter.execute(code) == FinalOutput(expected)


def test_bind_replaces_tools_and_submit_shape_without_resetting_state(pooled_interpreter):
tools = {"tool": lambda: "ok"}
fields = [{"name": "answer", "type": "str"}]
pooled_interpreter.execute("state = 41")
pooled_interpreter.bind(tools=tools, output_fields=fields)
tools.clear()
fields[0]["name"] = "changed"

assert pooled_interpreter.execute("tool()") == "ok"
assert pooled_interpreter.execute("state + 1") == 42
assert pooled_interpreter.execute("SUBMIT(answer='done')") == FinalOutput({"answer": "done"})
with pytest.raises(TypeError):
pooled_interpreter.bind(tools={}, output_fields=[None])
assert pooled_interpreter.execute("tool()") == "ok"

pooled_interpreter.bind(tools={})
assert pooled_interpreter.execute("SUBMIT('default')") == FinalOutput({"output": "default"})


def test_bind_fallback_invalidates_legacy_registration():
class LegacyPythonInterpreter(PythonInterpreter):
bind = None

with LegacyPythonInterpreter(tools={"old": lambda: "old"}) as interpreter:
assert interpreter.execute("old()") == "old"
_bind_interpreter(interpreter, {"new": lambda: "new"})
assert interpreter.execute("new()") == "new"


def test_tool_positional_args(configure_pooled_interpreter):
"""Test that tools work with positional arguments."""

Expand Down