Skip to content

Commit 33f4e4a

Browse files
author
Marco Del Pin
committed
merge: stanfordnlp/dspy main -> fork main (mini-sync 2026-08-15b, +2: PythonInterpreter request-handling hardening stanfordnlp#10208, Dataset.reset_seeds zero-values stanfordnlp#9906)
2 parents c9be7ee + 15c62d4 commit 33f4e4a

5 files changed

Lines changed: 125 additions & 26 deletions

File tree

‎dspy/datasets/dataset.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,12 @@ def reset_seeds(
3939
dev_size: int | None = None,
4040
test_size: int | None = None,
4141
) -> None:
42-
self.train_size = train_size or self.train_size
43-
self.train_seed = train_seed or self.train_seed
44-
self.dev_size = dev_size or self.dev_size
45-
self.dev_seed = eval_seed or self.dev_seed
46-
self.test_size = test_size or self.test_size
47-
self.test_seed = eval_seed or self.test_seed
42+
self.train_size = train_size if train_size is not None else self.train_size
43+
self.train_seed = train_seed if train_seed is not None else self.train_seed
44+
self.dev_size = dev_size if dev_size is not None else self.dev_size
45+
self.dev_seed = eval_seed if eval_seed is not None else self.dev_seed
46+
self.test_size = test_size if test_size is not None else self.test_size
47+
self.test_seed = eval_seed if eval_seed is not None else self.test_seed
4848

4949
if hasattr(self, "_train_"):
5050
del self._train_

‎dspy/primitives/python_interpreter.py‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import math
1717
import os
1818
import re
19+
import secrets
1920
import shutil
2021
import subprocess
2122
import threading
@@ -336,9 +337,9 @@ def __init__(
336337

337338
self.deno_process = None
338339
self._mounted_files = False
339-
self._request_id = 0
340340
self._last_diagnostic: str | None = None
341341
self._owner_thread: int | None = None
342+
self._handling_tool_call = False
342343
self._pending_large_vars = {}
343344
self._session_ended = False
344345

@@ -503,7 +504,11 @@ def _handle_tool_call(self, request: dict) -> None:
503504
kwargs = params.get("kwargs", {})
504505

505506
try:
506-
result = self.invoke_tool(tool_name, kwargs)
507+
self._handling_tool_call = True
508+
try:
509+
result = self.invoke_tool(tool_name, kwargs)
510+
finally:
511+
self._handling_tool_call = False
507512
result = _make_jsonable(result)
508513
if result is None or isinstance(result, str):
509514
response = _jsonrpc_result({"value": str(result) if result is not None else "", "type": "string"}, request_id)
@@ -594,10 +599,9 @@ def _parse_response_line(self, response_line: str, context: str) -> dict | None:
594599
logger.debug("Skipping malformed JSON during %s: %s", context, response_line[:100])
595600
return None
596601

597-
def _next_request_id(self) -> int:
598-
self._request_id += 1
602+
def _next_request_id(self) -> str:
599603
self._last_diagnostic = None
600-
return self._request_id
604+
return secrets.token_hex(16)
601605

602606
def _handle_out_of_band_message(self, msg: dict, context: str) -> bool:
603607
"""Consume a message that is not a response to any request (a notification
@@ -760,6 +764,8 @@ def execute(
760764
code: str,
761765
variables: dict[str, Any] | None = None,
762766
) -> Any:
767+
if self._handling_tool_call:
768+
raise CodeInterpreterError("PythonInterpreter cannot execute recursively from one of its tools.")
763769
self._check_session_active()
764770
self._check_thread_ownership()
765771
variables = variables or {}

‎dspy/primitives/runner.js‎

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pyodideModule from "npm:pyodide@0.29.4/pyodide.js";
44
import { readLines } from "https://deno.land/std@0.186.0/io/mod.ts";
55

6+
const JSON = Object.freeze(globalThis.JSON), console = Object.freeze(globalThis.console);
67
// =============================================================================
78
// Python Code Templates
89
// =============================================================================
@@ -19,32 +20,32 @@ sys.stdout, sys.stderr = buf_stdout, buf_stderr
1920
def last_exception_args():
2021
return json.dumps(sys.last_exc.args) if sys.last_exc else None
2122
22-
class FinalOutput(BaseException):
23+
class _DSPyFinalOutput(BaseException):
2324
# Control-flow exception to signal completion (like StopIteration)
2425
pass
2526
2627
# Default SUBMIT for single-output signatures (e.g., Program of Thought).
2728
# Only define if not already registered with typed signatures.
2829
if 'SUBMIT' not in dir():
2930
def SUBMIT(output):
30-
raise FinalOutput({"output": output})
31+
raise _DSPyFinalOutput({"output": output})
3132
`;
3233

3334
// Generate a tool wrapper function with typed signature.
3435
// Parameters is an array of {name, type?, default?} objects.
3536
// Convert a JavaScript/JSON value to Python literal syntax
3637
const toPythonLiteral = (value) => {
37-
if (value === null) return 'None';
38-
if (value === true) return 'True';
39-
if (value === false) return 'False';
40-
return JSON.stringify(value); // Works for strings, numbers, arrays, objects
38+
const serialized = JSON.stringify(value);
39+
return `__import__("json").loads(${JSON.stringify(serialized)})`;
4140
};
4241

42+
const toPythonType = (type) => type === "NoneType" ? "type(None)" : type;
43+
4344
const makeToolWrapper = (toolName, parameters = []) => {
4445
// Build signature parts: "query: str, limit: int = 10"
4546
const sigParts = parameters.map(p => {
4647
let part = p.name;
47-
if (p.type) part += `: ${p.type}`;
48+
if (p.type) part += `: ${toPythonType(p.type)}`;
4849
if (p.default !== undefined) part += ` = ${toPythonLiteral(p.default)}`;
4950
return part;
5051
});
@@ -71,20 +72,20 @@ const makeSubmitWrapper = (outputs) => {
7172
// Fallback to single-arg SUBMIT if no outputs defined
7273
return `
7374
def SUBMIT(output):
74-
raise FinalOutput({"output": output})
75+
raise _DSPyFinalOutput({"output": output})
7576
`;
7677
}
7778

7879
const sigParts = outputs.map(o => {
7980
let part = o.name;
80-
if (o.type) part += `: ${o.type}`;
81+
if (o.type) part += `: ${toPythonType(o.type)}`;
8182
return part;
8283
});
8384
const dictParts = outputs.map(o => `"${o.name}": ${o.name}`);
8485

8586
return `
8687
def SUBMIT(${sigParts.join(', ')}):
87-
raise FinalOutput({${dictParts.join(', ')}})
88+
raise _DSPyFinalOutput({${dictParts.join(', ')}})
8889
`;
8990
};
9091

@@ -114,7 +115,7 @@ const JSONRPC_APP_ERRORS = {
114115
};
115116

116117
const jsonrpcRequest = (method, params, id) =>
117-
JSON.stringify({ jsonrpc: "2.0", method, params, id });
118+
JSON.stringify({ __proto__: null, jsonrpc: "2.0", method, params: { __proto__: null, ...params }, id });
118119

119120
const jsonrpcNotification = (method, params = null) => {
120121
const msg = { jsonrpc: "2.0", method };
@@ -351,8 +352,8 @@ while (true) {
351352
// error.message is mostly blank.
352353
const errorMessage = (error.message || "").trim();
353354

354-
// Handle FinalOutput as a success result, not an error
355-
if (errorType === "FinalOutput") {
355+
// Handle SUBMIT's control-flow exception as a success result, not an error
356+
if (errorType === "_DSPyFinalOutput") {
356357
const last_exception_args = pyodide.globals.get("last_exception_args");
357358
const errorArgs = JSON.parse(last_exception_args()) || [];
358359
const answer = errorArgs[0] || null;

‎tests/datasets/test_dataset.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,31 @@ def csv_file():
3636
yield tmp_file.name
3737

3838

39+
def test_reset_seeds_accepts_zero():
40+
dataset = Dataset(train_seed=5, train_size=10, eval_seed=5, dev_size=10, test_size=10)
41+
dataset.reset_seeds(train_seed=0, train_size=0, eval_seed=0, dev_size=0, test_size=0)
42+
43+
assert dataset.train_seed == 0
44+
assert dataset.train_size == 0
45+
assert dataset.dev_seed == 0
46+
assert dataset.dev_size == 0
47+
assert dataset.test_seed == 0
48+
assert dataset.test_size == 0
49+
50+
51+
def test_reset_seeds_keeps_existing_values_when_omitted():
52+
dataset = Dataset(train_seed=5, train_size=10, eval_seed=7, dev_size=20, test_size=30)
53+
dataset.reset_seeds(train_seed=1)
54+
55+
assert dataset.train_seed == 1
56+
# Unspecified values should be left untouched.
57+
assert dataset.train_size == 10
58+
assert dataset.dev_seed == 7
59+
assert dataset.dev_size == 20
60+
assert dataset.test_seed == 7
61+
assert dataset.test_size == 30
62+
63+
3964
@pytest.mark.extra
4065
def test_input_keys(csv_file):
4166
dataset = CSVDataset(csv_file, input_keys=["content", "question"])

‎tests/primitives/test_python_interpreter.py‎

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,26 @@ def my_tool(query: str, limit: int = 10) -> str:
723723
assert result == "searched 'test' with limit 5"
724724

725725

726+
def test_tool_none_type_and_nested_json_default():
727+
def tool(value: type(None) = None, items: list = [None]): # noqa: B006
728+
return [value, items]
729+
730+
with PythonInterpreter(tools={"tool": tool}) as interpreter:
731+
assert interpreter.execute("tool()") == [None, [None]]
732+
733+
734+
@pytest.mark.parametrize(
735+
("field", "code", "expected"),
736+
[
737+
({"name": "nothing", "type": "NoneType"}, "SUBMIT(nothing=None)", {"nothing": None}),
738+
({"name": "FinalOutput", "type": "str"}, "SUBMIT(FinalOutput='ok')", {"FinalOutput": "ok"}),
739+
],
740+
)
741+
def test_submit_valid_runtime_edge_cases(field, code, expected):
742+
with PythonInterpreter(output_fields=[field]) as interpreter:
743+
assert interpreter.execute(code) == FinalOutput(expected)
744+
745+
726746
def test_tool_positional_args(configure_pooled_interpreter):
727747
"""Test that tools work with positional arguments."""
728748

@@ -801,6 +821,51 @@ def test_protocol_failure_ends_session(monkeypatch):
801821
interpreter.execute("2 + 2")
802822

803823

824+
def test_request_ids_are_128_bit_random_values():
825+
interpreter = PythonInterpreter(deno_command=["deno"])
826+
request_ids = {interpreter._next_request_id() for _ in range(100)}
827+
828+
assert len(request_ids) == 100
829+
assert all(len(request_id) == 32 and int(request_id, 16) >= 0 for request_id in request_ids)
830+
831+
832+
def test_guest_prototype_hook_cannot_redirect_tool_wrapper():
833+
calls = []
834+
835+
def benign():
836+
calls.append("benign")
837+
return "safe"
838+
839+
def danger():
840+
calls.append("danger")
841+
return "unsafe"
842+
843+
with PythonInterpreter(tools={"benign": benign, "danger": danger}) as interpreter:
844+
result = interpreter.execute(
845+
"import js\n"
846+
"js.eval('Object.prototype.toJSON = function() { "
847+
'if (Object.hasOwn(this, "name")) return {name: "danger", kwargs: {}}; '
848+
"return this; }')\n"
849+
"benign()"
850+
)
851+
852+
assert result == "safe"
853+
assert calls == ["benign"]
854+
855+
856+
def test_tool_cannot_reenter_same_interpreter():
857+
holder = {}
858+
859+
def nested_execute():
860+
return holder["interpreter"].execute("'nested'")
861+
862+
with PythonInterpreter(tools={"nested_execute": nested_execute}) as interpreter:
863+
holder["interpreter"] = interpreter
864+
with pytest.raises(CodeExecutionError, match="cannot execute recursively"):
865+
interpreter.execute("nested_execute()")
866+
assert interpreter.execute("40 + 2") == 42
867+
868+
804869
def test_failed_health_check_ends_session(monkeypatch):
805870
interpreter = PythonInterpreter()
806871
monkeypatch.setattr(
@@ -1545,9 +1610,11 @@ def test_id_less_protocol_errors_are_terminal():
15451610
interpreter._handle_out_of_band_message(parse_error, "during test")
15461611

15471612

1548-
def test_unsolicited_error_line_is_not_consumed_as_the_response():
1613+
def test_unsolicited_error_line_is_not_consumed_as_the_response(monkeypatch):
15491614
"""#10165: an id-less async error arriving ahead of the real response (the
15501615
wire trace an unhandled rejection produces) must not be mistaken for it."""
1616+
request_id = "a" * 32
1617+
monkeypatch.setattr(python_interpreter.secrets, "token_hex", lambda _: request_id)
15511618

15521619
class FakeDeno:
15531620
def __init__(self, lines):
@@ -1561,7 +1628,7 @@ def poll(self):
15611628
interpreter = PythonInterpreter()
15621629
interpreter.deno_process = FakeDeno([
15631630
json.dumps({"jsonrpc": "2.0", "error": {"code": -32007, "message": "Unhandled async error: PythonError"}, "id": None}),
1564-
json.dumps({"jsonrpc": "2.0", "result": {"output": "ok\n"}, "id": 1}),
1631+
json.dumps({"jsonrpc": "2.0", "result": {"output": "ok\n"}, "id": request_id}),
15651632
])
15661633
assert interpreter.execute("print('ok')") == "ok\n"
15671634

0 commit comments

Comments
 (0)