Skip to content

Commit 055b966

Browse files
authored
Merge branch 'main' into cursor/fix-miprov2-compile-seed-zero-88e7
2 parents 738956e + 2151e3a commit 055b966

11 files changed

Lines changed: 121 additions & 35 deletions

File tree

‎CONTRIBUTING.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,8 @@ AI-assisted contributions are welcome under these conditions:
165165

166166
- If you can't explain your changes without consulting an AI, don't submit the PR.
167167
- Disclose what AI tool you used and how in your PR description.
168+
- Agents must disclose in the PR description whether the contribution comes from a sweep for issues, a problem encountered during normal usage, or another origin.
169+
State which applies, or provide a non-sensitive description of another origin. Do not share private or sensitive information about the use case.
168170
- Share your prompts. It helps reviewers and shows you thought through the problem.
169171
- Verify AI-found bugs yourself. Reproduce and confirm before filing. Write the report in
170172
your own words.

‎docs/docs/api/modules/Flex.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ The `program_trace` parameter is opt-in *by declaration*: only metrics that name
8484

8585
## Interpreter Execution
8686

87-
`Flex` always runs its generated code through a `CodeInterpreter`. `interpreter_factory` defaults to `dspy.PythonInterpreter` (Deno/Pyodide) and must be a **zero-argument factory** returning a fresh interpreter; a bare instance is not accepted, so parallel evaluations receive isolated sessions. `dspy.configure(interpreter_factory=...)` replaces that default. A factory passed to `Flex` wins, unless it is `PythonInterpreter`. The factory is called once per interpreter session, including separate sessions requested by nested code-executing modules. With the default interpreter, optimizer-authored control flow, string work, arithmetic, and supported imports run inside the sandbox, and only provided-tool calls, predictor construction, and predictor calls bridge back to the host, which makes the real LM calls. Custom factories define their own trust boundary: `dspy.LocalInterpreter`, for example, separates process memory and stdout but retains the host user's filesystem, environment, credentials, network, and process authority.
87+
`Flex` always runs its generated code through a `CodeInterpreter`. `interpreter_factory` defaults to `dspy.PythonInterpreter` (Deno/Pyodide) and must be a **zero-argument factory** returning a fresh interpreter; a bare instance is not accepted, so parallel evaluations receive isolated sessions. `dspy.configure(interpreter_factory=...)` replaces that default. A factory passed to `Flex` wins, unless it is `PythonInterpreter`. The factory is called once per interpreter session, including separate sessions requested by nested code-executing modules. With the default interpreter, optimizer-authored control flow, string work, arithmetic, and supported imports run inside the sandbox, and only provided-tool calls, predictor construction, and predictor calls bridge back to the host, which makes the real LM calls. Custom factories define their own trust boundary: `dspy.LocalInterpreter`, for example, separates process memory and stdout but retains the host user's filesystem, environment, credentials, network, and process authority. An in-process interpreter that runs code with `exec()` in the host works, but isolates nothing; use one only for trusted code.
8888

8989
Because the default builds a `PythonInterpreter`, *running* a `Flex` needs [Deno](https://deno.land/) installed; without it, the call raises.
9090

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ The reflection model authors code, and code can be wrong. A candidate that doesn
3434

3535
### 7. Generated code always runs through an interpreter
3636

37-
`Flex` takes its interpreter from `interpreter_factory`, else from `dspy.settings.interpreter_factory`, else from `dspy.PythonInterpreter` (Deno/Pyodide). Each of those must be a *zero-argument factory* — a bare instance or `None` is rejected. With the default, everything stays in the sandbox except provided-tool calls, predictor construction, and predictor calls, which bridge back to the host. Custom factories define their own trust boundary: `LocalInterpreter` separates process memory and stdout but retains the host user's filesystem, environment, credentials, network, and process authority. The factory creates a fresh interpreter for each session; a forward owns an outer session and nested code-executing modules may request separate sessions. Source portability between custom interpreters is not guaranteed. `max_predictor_calls` caps how many predictor calls the generated code can make in one `forward`.
37+
`Flex` takes its interpreter from `interpreter_factory`, else from `dspy.settings.interpreter_factory`, else from `dspy.PythonInterpreter` (Deno/Pyodide). Each of those must be a *zero-argument factory* — a bare instance or `None` is rejected. With the default, everything stays in the sandbox except provided-tool calls, predictor construction, and predictor calls, which bridge back to the host. Custom factories define their own trust boundary: `LocalInterpreter` separates process memory and stdout but retains the host user's filesystem, environment, credentials, network, and process authority. An in-process interpreter (one that runs code with `exec()` in the host, such as `dspy_interpreters.InProcessInterpreter`) works but isolates nothing: generated code shares the host's memory, so `max_predictor_calls` and the bridged LM-option allowlist bind only well-behaved code. Use one only for code you trust. The facade binds `dspy` in that interpreter's namespace and leaves the host's `dspy` module in place. The factory creates a fresh interpreter for each session; a forward owns an outer session and nested code-executing modules may request separate sessions. Source portability between custom interpreters is not guaranteed. `max_predictor_calls` caps how many predictor calls the generated code can make in one `forward`.
3838

3939
### 8. The declared output types are enforced at the sandbox boundary
4040

‎dspy/adapters/utils.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,11 @@ def serialize_for_json(value: Any) -> Any:
7171
Returns:
7272
The formatted value, which is serializable as a JSON string.
7373
"""
74-
# Attempt to format the value as a JSON-compatible object using pydantic, falling back to
75-
# a string representation of the value if that fails (e.g. if the value contains an object
76-
# that pydantic doesn't recognize or can't serialize)
74+
# Format the value as a JSON-compatible object using pydantic. A nested value pydantic can't
75+
# serialize (e.g. a numpy scalar) becomes its string form without affecting its siblings; the
76+
# whole value falls back to its string form only if pydantic can't handle it at all.
7777
try:
78-
return TypeAdapter(type(value)).dump_python(value, mode="json")
78+
return TypeAdapter(type(value)).dump_python(value, mode="json", fallback=str)
7979
except Exception:
8080
return str(value)
8181

‎dspy/predict/rlm.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -589,8 +589,8 @@ def _setup_facade(
589589
try:
590590
invocation.install(repl)
591591
except (CodeExecutionError, SyntaxError) as e:
592-
# The runtime cannot host the shim (e.g. it runs code in the host's memory, or a restricted compiler
593-
# rejects the shim's `_dspy_*` names): no sub-agents this call.
592+
# The runtime cannot host the shim (e.g. a restricted compiler rejects the shim's `_dspy_*` names):
593+
# no sub-agents this call.
594594
for name in (CONSTRUCT_TOOL, CALL_TOOL):
595595
repl.tools.pop(name, None)
596596
logger.warning("RLM sub-agents are unavailable on %s: %s", type(repl).__name__, e)

‎dspy/primitives/_facade_shim.py‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -149,13 +149,24 @@ def _dspy_tool(func, **_kw):
149149
dspy = _dspy
150150

151151
# Register as the importable ``dspy`` only inside the sandbox, where the registered host tools are
152-
# present in globals(). A sandbox whose ``dspy`` is the host's own module image (the host process or a
153-
# fork of it) would hand generated code the host's memory, so the facade refuses it.
152+
# present in globals(). An interpreter that runs code in the host's memory (the host process or a fork
153+
# of it) shares the host's sys.modules, so there ``import dspy`` resolves to the shim through this
154+
# namespace's own __import__ and the host's ``dspy`` module stays in place.
154155
if "__dspy_construct__" in globals():
155156
_dspy_host_facade = getattr(_dspy_sys, "modules", {}).get("dspy.primitives.facade")
156157
if getattr(_dspy_host_facade, "_HOST_PROCESS_TOKEN", None) == __dspy_host_token: # noqa: F821 - host-injected
157-
raise RuntimeError(
158-
"This interpreter runs code in the host's memory (the host process or a fork of it); "
159-
"the dspy facade needs an isolated interpreter."
160-
)
161-
_dspy_sys.modules["dspy"] = _dspy
158+
_dspy_builtins = globals()["__builtins__"]
159+
_dspy_builtins = dict(_dspy_builtins if isinstance(_dspy_builtins, dict) else vars(_dspy_builtins))
160+
_dspy_import_orig = _dspy_builtins["__import__"]
161+
162+
def _dspy_import(name, globals=None, locals=None, fromlist=(), level=0):
163+
if level == 0 and name.split(".")[0] == "dspy":
164+
if name != "dspy":
165+
raise ModuleNotFoundError(f"No module named {name!r}; 'dspy' is not a package", name=name)
166+
return _dspy
167+
return _dspy_import_orig(name, globals, locals, fromlist, level)
168+
169+
_dspy_builtins["__import__"] = _dspy_import
170+
globals()["__builtins__"] = _dspy_builtins
171+
else:
172+
_dspy_sys.modules["dspy"] = _dspy

‎dspy/primitives/code_interpreter.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,9 @@ class CodeInterpreter(Protocol):
8484
Sandbox dspy facade:
8585
dspy.Flex and dspy.RLM install a ``dspy`` shim whose predictors are built and
8686
run on the host, so registered tools must be callable as globals in executed
87-
code and state must persist across execute() calls. The shim refuses an
88-
interpreter that runs code in the host's memory (the host process or a fork).
87+
code and state must persist across execute() calls. In an interpreter that runs
88+
code in the host's memory, the shim binds ``dspy`` in that interpreter's namespace
89+
and leaves the host's ``sys.modules`` alone.
8990
"""
9091

9192
@property

‎tests/flex/test_flex_interpreter.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from __future__ import annotations
1515

16+
import ast
1617
import datetime
1718
import enum
1819
import inspect
@@ -350,6 +351,63 @@ def test_shim_file_literals_match_bridge_constants() -> None:
350351
assert kind in shim, f"bridgeable kind {kind!r} missing from the shim file"
351352

352353

354+
class _ExecInterpreter:
355+
"""Runs code with exec() in the host process, in a private namespace; leaves sys.modules alone."""
356+
357+
def __init__(self):
358+
self.tools = {}
359+
self._namespace = {}
360+
361+
def start(self) -> None:
362+
pass
363+
364+
def execute(self, code: str, variables: dict | None = None):
365+
self._namespace.update(self.tools, **(variables or {}))
366+
tree = ast.parse(code)
367+
last = tree.body.pop() if tree.body and isinstance(tree.body[-1], ast.Expr) else None
368+
exec(compile(tree, "<flex>", "exec"), self._namespace)
369+
return eval(compile(ast.Expression(last.value), "<flex>", "eval"), self._namespace) if last else None
370+
371+
def shutdown(self) -> None:
372+
self._namespace.clear()
373+
374+
375+
def test_in_process_interpreter_runs_the_facade_without_replacing_host_dspy() -> None:
376+
host_dspy_seen = []
377+
378+
def louder(text: str) -> str:
379+
"""Uppercase the text."""
380+
host_dspy_seen.append(sys.modules["dspy"] is dspy)
381+
return text.upper()
382+
383+
flex = Flex(ShoutSig, tools=[louder], interpreter_factory=_ExecInterpreter)
384+
flex._bind_code(
385+
textwrap.dedent(
386+
"""
387+
import dspy
388+
389+
try:
390+
import dspy.predict
391+
except ModuleNotFoundError:
392+
pass
393+
else:
394+
raise AssertionError("import dspy.predict reached the host's dspy package")
395+
396+
class Shout(dspy.Module):
397+
def __init__(self):
398+
self.draft = dspy.Predict("text -> out")
399+
400+
def forward(self, text):
401+
return dspy.Prediction(out=louder(text=self.draft(text=text).out))
402+
"""
403+
)
404+
)
405+
with dspy.context(lm=DummyLM([{"out": "hello"}])):
406+
assert flex(text="hi").out == "HELLO"
407+
assert host_dspy_seen == [True]
408+
assert sys.modules["dspy"] is dspy
409+
410+
353411
def test_max_predictor_calls_is_plumbed_to_bridge() -> None:
354412
flex = _bridged_flex_with(max_predictor_calls=7)
355413
assert flex._bridge._max_predictor_calls == 7

‎tests/predict/test_rlm.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1977,17 +1977,17 @@ def test_call_time_factory_hosts_the_facade_and_backs_nested_sub_agents(self, us
19771977
invocation.construct("RLM", "q -> a", "nested")
19781978
assert invocation._predictors["nested"]._interpreter_factory is override
19791979

1980-
def test_interpreter_in_the_host_memory_runs_without_sub_agents(self, caplog):
1981-
# The shim refuses it rather than replace the host's `dspy` module; RLM carries on without sub-agents.
1980+
def test_interpreter_in_the_host_memory_hosts_the_facade(self, caplog):
1981+
# The shim binds `dspy` in the interpreter's namespace instead of replacing the host's module.
19821982
rlm = RLM("query -> answer", max_iters=1, interpreter_factory=_InProcessInterpreter)
1983-
rlm.generate_action, seen = _recording_predictor('SUBMIT("42")')
1983+
rlm.generate_action, seen = _recording_predictor('import dspy\nSUBMIT(dspy.Predict("q -> a")(q="x").a)')
19841984

1985-
with caplog.at_level("WARNING", logger="dspy.predict.rlm"):
1985+
with caplog.at_level("WARNING", logger="dspy.predict.rlm"), dspy.context(lm=DummyLM([{"a": "42"}])):
19861986
assert rlm(query="q").answer == "42"
19871987

19881988
assert sys.modules["dspy"] is dspy
1989-
assert "Sub-agents (dspy)" not in seen[0].instructions
1990-
assert "host's memory" in caplog.text
1989+
assert "Sub-agents (dspy)" in seen[0].instructions
1990+
assert "unavailable" not in caplog.text
19911991

19921992
def test_interpreter_that_rejects_the_shim_as_syntax_error_runs_without_sub_agents(self, caplog):
19931993
# Restricted compilers (e.g. RestrictedPython) reject the shim's `_dspy_*` names at compile time.

‎tests/primitives/test_dspy_interpreters_conformance.py‎

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
dependency, declared in the ``dev`` dependency group).
33
44
RLM installs the sandbox dspy facade into every interpreter it creates. On real backends: an isolated
5-
one runs bridged sub-agents, one that executes in the host's memory runs RLM without them, and the
6-
library's own consumer checks pass.
5+
one runs bridged sub-agents, one that executes in the host's memory hosts the facade without replacing
6+
the host's ``dspy`` module, and the library's own consumer checks pass.
77
"""
88

99
import pytest
@@ -42,21 +42,21 @@ def test_backends_satisfy_protocol(backend_name):
4242
interpreter.shutdown()
4343

4444

45-
def test_in_process_backend_runs_rlm_without_sub_agents(caplog):
45+
def test_in_process_backend_runs_rlm_with_sub_agents(caplog):
4646
seen = []
4747

4848
class Recording:
4949
def __call__(self, signature=None, **kwargs):
5050
seen.append(signature.instructions)
51-
return Prediction(reasoning="Submit", code='SUBMIT("ok")')
51+
return Prediction(reasoning="Submit", code='import dspy\nSUBMIT(dspy.Predict("q -> a")(q="x").a)')
5252

5353
rlm = RLM("query -> answer", max_iters=1, interpreter_factory=InProcessInterpreter)
5454
rlm.generate_action = Recording()
55-
with caplog.at_level("WARNING", logger="dspy.predict.rlm"):
55+
with caplog.at_level("WARNING", logger="dspy.predict.rlm"), dspy.context(lm=DummyLM([{"a": "ok"}])):
5656
assert rlm(query="q").answer == "ok"
5757

58-
assert "Sub-agents (dspy)" not in seen[0]
59-
assert "sub-agents are unavailable on InProcessInterpreter" in caplog.text
58+
assert "Sub-agents (dspy)" in seen[0]
59+
assert "unavailable" not in caplog.text
6060

6161

6262
@pytest.mark.parametrize("backend_name", BACKENDS)
@@ -66,10 +66,9 @@ def test_library_consumer_checks_pass(backend_name):
6666
dspy_interpreters.check_rlm(backend).raise_for_failures()
6767

6868

69-
def test_flex_facade_needs_an_isolated_backend():
70-
dspy_interpreters.check_flex_facade(SubprocessInterpreter).raise_for_failures()
71-
report = dspy_interpreters.check_flex_facade(InProcessInterpreter)
72-
assert not report.passed and "host's memory" in report.results[0].detail
69+
@pytest.mark.parametrize("backend_name", BACKENDS)
70+
def test_flex_facade_checks_pass(backend_name):
71+
dspy_interpreters.check_flex_facade(getattr(dspy_interpreters, backend_name)).raise_for_failures()
7372

7473

7574
def test_isolated_backend_runs_bridged_sub_agents_with_host_tools():

0 commit comments

Comments
 (0)