Skip to content

Commit 797afdc

Browse files
ampagentIsaac Miller
andcommitted
Harden interpreter rebinding validation
Co-authored-by: Isaac Miller <isaac@cmpnd.ai> Amp-Thread-ID: https://ampcode.com/threads/T-019fcce1-bc8b-753e-886f-9b77c0ccb376
1 parent a17d7fd commit 797afdc

5 files changed

Lines changed: 213 additions & 27 deletions

File tree

‎dspy/primitives/python_interpreter.py‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
import threading
2323
from collections.abc import Mapping, Sequence
2424
from os import PathLike
25-
from typing import Any, Callable, NoReturn
25+
from typing import Any, Callable, ClassVar, NoReturn
2626

2727
from pydantic import BaseModel
2828
from pydantic_core import PydanticSerializationError, to_jsonable_python
@@ -241,6 +241,22 @@ def my_tool(question: str) -> str:
241241
```
242242
"""
243243

244+
_RESERVED_TOOL_NAMES: ClassVar[set[str]] = {
245+
"SUBMIT",
246+
"FinalOutput",
247+
"JsProxy",
248+
"_js_tool_call",
249+
"buf_stderr",
250+
"buf_stdout",
251+
"io",
252+
"json",
253+
"last_exception_args",
254+
"old_stderr",
255+
"old_stdout",
256+
"run_sync",
257+
"sys",
258+
}
259+
244260
def __init__(
245261
self,
246262
deno_command: list[str] | None = None,
@@ -286,6 +302,7 @@ def __init__(
286302
self.enable_network_access = enable_network_access or []
287303
self.sync_files = sync_files
288304
self.tools = {}
305+
self._tool_parameters = {}
289306
self.output_fields = None
290307
self.callbacks = list(callbacks or [])
291308
self._tools_registered = True
@@ -302,8 +319,8 @@ def __init__(
302319
self._session_ended = False
303320
self._state_lock = threading.Lock()
304321
self._execution_active = False
305-
if tools or output_fields:
306-
self.bind(tools=tools or {}, output_fields=output_fields)
322+
if tools is not None or output_fields is not None:
323+
self.bind(tools=tools if tools is not None else {}, output_fields=output_fields)
307324

308325
@property
309326
def execution_instructions(self) -> str:
@@ -433,14 +450,25 @@ def bind(
433450
raise TypeError(f"tools must be a mapping, not {type(tools).__name__}")
434451

435452
copied_tools: dict[str, Callable[..., Any]] = {}
453+
copied_tool_parameters = {}
436454
for name, tool in tools.items():
437455
if not isinstance(name, str) or not name.isidentifier() or keyword.iskeyword(name):
438456
raise CodeInterpreterError(f"Invalid tool name: {name!r}")
439-
if name in {"SUBMIT", "FinalOutput"}:
457+
if name in self._RESERVED_TOOL_NAMES:
440458
raise CodeInterpreterError(f"Tool name {name!r} is reserved by the interpreter")
441459
if not callable(tool):
442460
raise CodeInterpreterError(f"Tool {name!r} must be callable, not {type(tool).__name__}")
461+
previous = self._tool_parameters.get(name)
462+
if previous is not None and previous[0] is tool:
463+
parameters = previous[1]
464+
else:
465+
try:
466+
parameters = self._extract_parameters(tool)
467+
parameters = json.loads(json.dumps(parameters, allow_nan=False))
468+
except (TypeError, ValueError) as e:
469+
raise CodeInterpreterError(f"Tool {name!r} has an unsupported signature: {e}") from e
443470
copied_tools[name] = tool
471+
copied_tool_parameters[name] = (tool, parameters)
444472

445473
copied_output_fields = None
446474
if output_fields is not None:
@@ -471,6 +499,7 @@ def bind(
471499
if self._execution_active:
472500
raise RuntimeError("Cannot bind tools while PythonInterpreter execution is active.")
473501
self.tools = copied_tools
502+
self._tool_parameters = copied_tool_parameters
474503
self.output_fields = copied_output_fields
475504
self._tools_registered = False
476505

@@ -565,9 +594,10 @@ def _register_tools(self) -> None:
565594
# after a previous typed binding.
566595
tools_info = []
567596
for name, fn in self.tools.items():
597+
cached = self._tool_parameters.get(name)
568598
tools_info.append({
569599
"name": name,
570-
"parameters": self._extract_parameters(fn)
600+
"parameters": cached[1] if cached is not None and cached[0] is fn else self._extract_parameters(fn)
571601
})
572602
params = {"tools": tools_info, "outputs": self.output_fields or []}
573603

‎dspy/primitives/runner.js‎

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,8 @@ if 'SUBMIT' not in dir():
3535
// Parameters is an array of {name, type?, default?} objects.
3636
// Convert a JavaScript/JSON value to Python literal syntax
3737
const toPythonLiteral = (value) => {
38-
if (value === null) return 'None';
39-
if (value === true) return 'True';
40-
if (value === false) return 'False';
41-
return JSON.stringify(value); // Works for strings, numbers, arrays, objects
38+
const serialized = JSON.stringify(value);
39+
return `__import__("json").loads(${JSON.stringify(serialized)})`;
4240
};
4341

4442
const toPythonType = (type) => type === "NoneType" ? "type(None)" : type;
@@ -67,6 +65,30 @@ def ${toolName}(${signature}):
6765
`;
6866
};
6967

68+
const registeredToolNames = new Set(), shadowedGlobals = new Map();
69+
70+
const replaceToolWrappers = (tools) => {
71+
const nextNames = new Set(tools.map(tool => typeof tool === 'string' ? tool : tool.name));
72+
for (const name of registeredToolNames) {
73+
if (!nextNames.has(name)) {
74+
const shadowed = shadowedGlobals.get(name);
75+
if (shadowed.exists) pyodide.globals.set(name, shadowed.value);
76+
else pyodide.globals.delete(name);
77+
shadowedGlobals.delete(name);
78+
}
79+
}
80+
for (const tool of tools) {
81+
const name = typeof tool === 'string' ? tool : tool.name;
82+
if (!registeredToolNames.has(name)) {
83+
const exists = pyodide.globals.has(name);
84+
shadowedGlobals.set(name, { exists, value: exists ? pyodide.globals.get(name) : undefined });
85+
}
86+
pyodide.runPython(makeToolWrapper(name, typeof tool === 'string' ? [] : tool.parameters || []));
87+
}
88+
registeredToolNames.clear();
89+
for (const name of nextNames) registeredToolNames.add(name);
90+
};
91+
7092
// Generate SUBMIT function with output field signature.
7193
// Outputs is an array of {name, type?} objects.
7294
const makeSubmitWrapper = (outputs) => {
@@ -290,21 +312,9 @@ while (true) {
290312
}
291313

292314
if (method === "register") {
293-
const toolNames = [];
294-
295-
// Register tools with typed signatures
296-
if (params.tools) {
297-
for (const tool of params.tools) {
298-
// Support both old format (string) and new format (object with parameters)
299-
if (typeof tool === 'string') {
300-
pyodide.runPython(makeToolWrapper(tool, []));
301-
toolNames.push(tool);
302-
} else {
303-
pyodide.runPython(makeToolWrapper(tool.name, tool.parameters || []));
304-
toolNames.push(tool.name);
305-
}
306-
}
307-
}
315+
const tools = params.tools || [];
316+
replaceToolWrappers(tools);
317+
const toolNames = tools.map(tool => typeof tool === 'string' ? tool : tool.name);
308318

309319
// Register SUBMIT with output signature
310320
if (params.outputs) {

‎tests/predict/test_rlm.py‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88

99
import base64
10+
import inspect
1011
from contextlib import contextmanager
1112

1213
import pytest
@@ -1492,18 +1493,53 @@ def accepts_none(value: type(None)) -> str:
14921493
assert pooled_interpreter.execute("state + 1") == 42
14931494
assert pooled_interpreter.deno_process is process
14941495

1496+
def test_restores_caller_namespace_shadowed_by_invocation_tools(self, pooled_interpreter):
1497+
pooled_interpreter.execute("llm_query = 'original query'\nllm_query_batched = 'original batch'")
1498+
process = pooled_interpreter.deno_process
1499+
rlm = RLM("query -> answer", max_iters=1)
1500+
rlm.generate_action = make_mock_predictor([
1501+
{"reasoning": "Submit", "code": "SUBMIT(answer='done')"},
1502+
])
1503+
1504+
assert rlm(pooled_interpreter, query="test").answer == "done"
1505+
assert pooled_interpreter.execute("(llm_query, llm_query_batched)") == ["original query", "original batch"]
1506+
assert pooled_interpreter.deno_process is process
1507+
1508+
def test_reuses_validated_metadata_when_restoring_caller_tools(self, pooled_interpreter):
1509+
class StatefulTool:
1510+
reads = 0
1511+
1512+
@property
1513+
def __signature__(self):
1514+
self.reads += 1
1515+
return inspect.Signature() if self.reads == 1 else "malformed"
1516+
1517+
def __call__(self):
1518+
return "baseline"
1519+
1520+
baseline = StatefulTool()
1521+
pooled_interpreter.bind(tools={"baseline": baseline})
1522+
rlm = RLM("query -> answer", max_iters=1)
1523+
rlm.generate_action = make_mock_predictor([
1524+
{"reasoning": "Submit", "code": "SUBMIT(answer='done')"},
1525+
])
1526+
1527+
assert rlm(pooled_interpreter, query="test").answer == "done"
1528+
assert pooled_interpreter.execute("baseline()") == "baseline"
1529+
assert baseline.reads == 1
1530+
14951531
def test_reserved_tool_rejection_preserves_caller_owned_interpreter(self, pooled_interpreter):
14961532
def baseline():
14971533
return "baseline"
14981534

1499-
def FinalOutput():
1535+
def reserved_tool():
15001536
return "unreachable"
15011537

15021538
previous_outputs = [{"name": "previous", "type": "str"}]
15031539
pooled_interpreter.bind(tools={"baseline": baseline}, output_fields=previous_outputs)
15041540
pooled_interpreter.execute("state = 41")
15051541
process = pooled_interpreter.deno_process
1506-
rlm = RLM("query -> answer", tools=[FinalOutput], max_iters=1)
1542+
rlm = RLM("query -> answer", tools=[Tool(reserved_tool, name="FinalOutput")], max_iters=1)
15071543

15081544
with pytest.raises(CodeInterpreterError, match="'FinalOutput' is reserved"):
15091545
rlm(pooled_interpreter, query="test")

‎tests/primitives/test_python_interpreter.py‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
import dataclasses
3+
import inspect
34
import io
45
import json
56
import os
@@ -736,6 +737,72 @@ def accepts_none(value: type(None)) -> str:
736737
assert pooled_interpreter.deno_process is process
737738

738739

740+
def test_tool_with_json_default_preserves_session(pooled_interpreter):
741+
def tool(value: list = [None]): # noqa: B006 - exercises JSON-compatible mutable defaults
742+
return value
743+
744+
pooled_interpreter.execute("state = 41")
745+
process = pooled_interpreter.deno_process
746+
pooled_interpreter.bind(tools={"tool": tool})
747+
748+
assert pooled_interpreter.execute("tool()") == [None]
749+
assert pooled_interpreter.execute("state + 1") == 42
750+
assert pooled_interpreter.deno_process is process
751+
752+
753+
def test_unsupported_tool_signature_is_rejected_without_changing_session(pooled_interpreter):
754+
unsupported_default = object()
755+
756+
def baseline():
757+
return "baseline"
758+
759+
def unsupported(value=unsupported_default):
760+
return value
761+
762+
pooled_interpreter.bind(tools={"baseline": baseline})
763+
pooled_interpreter.execute("state = 41")
764+
process = pooled_interpreter.deno_process
765+
766+
with pytest.raises(CodeInterpreterError, match="unsupported signature"):
767+
pooled_interpreter.bind(tools={"unsupported": unsupported})
768+
769+
assert pooled_interpreter.tools == {"baseline": baseline}
770+
assert pooled_interpreter.execute("baseline()") == "baseline"
771+
assert pooled_interpreter.execute("state + 1") == 42
772+
assert pooled_interpreter.deno_process is process
773+
774+
775+
def test_bind_snapshots_validated_tool_defaults(pooled_interpreter):
776+
default = [None]
777+
778+
def tool(value=default):
779+
return value
780+
781+
pooled_interpreter.bind(tools={"tool": tool})
782+
default.append(object())
783+
784+
assert pooled_interpreter.execute("tool()") == [None]
785+
786+
787+
def test_bind_does_not_reinspect_tool_signature(pooled_interpreter):
788+
class StatefulTool:
789+
reads = 0
790+
791+
@property
792+
def __signature__(self):
793+
self.reads += 1
794+
return inspect.Signature() if self.reads == 1 else "malformed"
795+
796+
def __call__(self):
797+
return "ok"
798+
799+
tool = StatefulTool()
800+
pooled_interpreter.bind(tools={"tool": tool})
801+
802+
assert pooled_interpreter.execute("tool()") == "ok"
803+
assert tool.reads == 1
804+
805+
739806
def test_bind_after_start_replaces_tools_and_preserves_namespace(pooled_interpreter):
740807
old_calls = []
741808

@@ -755,6 +822,18 @@ def old_tool():
755822
assert old_calls == [True]
756823

757824

825+
def test_bind_restores_shadowed_globals_and_removes_stale_wrappers(pooled_interpreter):
826+
pooled_interpreter.execute("tool = 'original'")
827+
pooled_interpreter.bind(tools={"tool": lambda: "host", "ephemeral": lambda: "temporary"})
828+
assert pooled_interpreter.execute("tool()") == "host"
829+
assert pooled_interpreter.execute("ephemeral()") == "temporary"
830+
831+
pooled_interpreter.bind(tools={})
832+
833+
assert pooled_interpreter.execute("tool") == "original"
834+
assert pooled_interpreter.execute("'ephemeral' in globals()") is False
835+
836+
758837
def test_bind_none_output_fields_restores_default_submit(pooled_interpreter):
759838
pooled_interpreter.bind(tools={}, output_fields=[{"name": "answer", "type": "str"}])
760839
assert pooled_interpreter.execute("SUBMIT(answer='typed')") == FinalOutput({"answer": "typed"})

‎tests/primitives/test_python_interpreter_config.py‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ def second():
7272
assert interpreter.output_fields is None
7373

7474

75-
@pytest.mark.parametrize("name", ["not valid", "class", "SUBMIT", "FinalOutput"])
75+
@pytest.mark.parametrize("name", ["not valid", "class", "SUBMIT", "FinalOutput", "json", "run_sync"])
7676
def test_bind_rejects_invalid_tool_names_without_changing_binding(name):
7777
def original():
7878
return "original"
@@ -85,6 +85,23 @@ def original():
8585
assert interpreter.tools == {"original": original}
8686

8787

88+
def test_bind_rejects_unsupported_tool_signature_without_changing_binding():
89+
unsupported_default = object()
90+
91+
def original():
92+
return "original"
93+
94+
def unsupported(value=unsupported_default):
95+
return value
96+
97+
interpreter = PythonInterpreter(deno_command=["deno", "run"], tools={"original": original})
98+
99+
with pytest.raises(CodeInterpreterError, match="unsupported signature"):
100+
interpreter.bind(tools={"unsupported": unsupported})
101+
102+
assert interpreter.tools == {"original": original}
103+
104+
88105
def test_bind_rejects_invalid_output_fields_without_changing_binding():
89106
original_fields = [{"name": "answer", "type": "str"}]
90107
interpreter = PythonInterpreter(deno_command=["deno", "run"], output_fields=original_fields)
@@ -110,6 +127,20 @@ def test_constructor_rejects_reserved_tool_name():
110127
PythonInterpreter(deno_command=["deno", "run"], tools={"FinalOutput": lambda: None})
111128

112129

130+
@pytest.mark.parametrize(
131+
("kwargs", "message"),
132+
[
133+
({"tools": []}, "tools must be a mapping"),
134+
({"tools": ""}, "tools must be a mapping"),
135+
({"output_fields": ""}, "output_fields must be a sequence"),
136+
({"output_fields": b""}, "output_fields must be a sequence"),
137+
],
138+
)
139+
def test_constructor_validates_falsy_bindings(kwargs, message):
140+
with pytest.raises(TypeError, match=message):
141+
PythonInterpreter(deno_command=["deno", "run"], **kwargs)
142+
143+
113144
def test_bind_after_shutdown_fails():
114145
interpreter = PythonInterpreter(deno_command=["deno", "run"])
115146
interpreter.shutdown()

0 commit comments

Comments
 (0)