diff --git a/dspy/primitives/python_interpreter.py b/dspy/primitives/python_interpreter.py index 9d420090ec..7aca51b074 100644 --- a/dspy/primitives/python_interpreter.py +++ b/dspy/primitives/python_interpreter.py @@ -5,7 +5,7 @@ import os -class InterpreterError(ValueError): +class InterpreterError(RuntimeError): pass @@ -119,8 +119,10 @@ def execute( error_type = result.get("errorType", "Sandbox Error") if error_type == "SyntaxError": raise SyntaxError(f"Invalid Python syntax. message: {error_msg}") + elif error_type == "FinalAnswer": + return result.get("errorArgs") else: - raise InterpreterError(f"{error_type}: {error_msg}") + raise InterpreterError(f"{error_type}: {result.get('errorArgs') or error_msg}") # If there's no error, return the "output" field return result.get("output", None) diff --git a/dspy/primitives/runner.js b/dspy/primitives/runner.js index 644c6820c0..18660efc0b 100644 --- a/dspy/primitives/runner.js +++ b/dspy/primitives/runner.js @@ -32,7 +32,7 @@ for await (const line of readLines(Deno.stdin)) { } const code = input.code || ""; - + // Wrap execution in a try/catch so we can handle syntax errors, etc. try { await pyodide.loadPackagesFromImports(code); @@ -53,20 +53,35 @@ sys.stdout = buf_stdout sys.stderr = buf_stderr `); - // 2. Run the user's code asynchronously + // 2. Setup proper exception arguments extractor and FinalAnswer bridge + // The idea is borrowed from `smolagents` that uses the exception to simulate non-local exit + pyodide.runPython(` +import json + +def last_exception_args(): + return json.dumps(sys.last_exc.args) if sys.last_exc else None + +class FinalAnswer(Exception): + pass + +def final_answer(*args): + raise FinalAnswer(*args) + `); + + // 3. Run the user's code asynchronously const result = await pyodide.runPythonAsync(code); - // 3. Retrieve captured stdout/stderr + // 4. Retrieve captured stdout/stderr const capturedStdout = pyodide.runPython("buf_stdout.getvalue()"); const capturedStderr = pyodide.runPython("buf_stderr.getvalue()"); - // 4. Restore original stdout/stderr + // 5. Restore original stdout/stderr pyodide.runPython(` sys.stdout = old_stdout sys.stderr = old_stderr `); - // 5. Build our output object according to the rules: + // 6. Build our output object according to the rules: // - If result is None (or Python "None" => JS null), output all prints // - Else output the result only // Note: `None` in Python becomes `null` in JS. @@ -75,7 +90,7 @@ sys.stderr = old_stderr // The final statement was None or no return => deliver printed output // If you want to combine capturedStderr as well, you can append it // But here we'll just do stdout for clarity - output = capturedStdout; + output = capturedStdout; // If there's something in stderr, you might want to include that or log it // output += capturedStderr; } else { @@ -90,9 +105,23 @@ sys.stderr = old_stderr const errorType = error.type || "Error"; // error.message is mostly blank. const errorMessage = (error.message || "").trim(); + // The arguments of the exception are stored in sys.last_exc.args, + // which is always helpful but pyodide don't extract them for us. + // Use a bridge function to get them. + let errorArgs = []; + if (errorType !== "SyntaxError") { + // Only python exceptions have args. + const last_exception_args = pyodide.globals.get("last_exception_args"); + // Regarding https://pyodide.org/en/stable/usage/type-conversions.html#type-translations-errors, + // we do a addtional `json.dumps` and `JSON.parse` on the values, to avoid the possible memory leak. + errorArgs = JSON.parse(last_exception_args()) || []; + } + + console.log(JSON.stringify({ error: errorMessage, + errorArgs: errorArgs, errorType: errorType })); } -} +} \ No newline at end of file diff --git a/tests/primitives/test_python_interpreter.py b/tests/primitives/test_python_interpreter.py index ec20c48ea7..8ad606d25e 100644 --- a/tests/primitives/test_python_interpreter.py +++ b/tests/primitives/test_python_interpreter.py @@ -1,6 +1,8 @@ import shutil import pytest +import random + from dspy.primitives.python_interpreter import InterpreterError, PythonInterpreter @@ -42,3 +44,21 @@ def test_failure_zero_division(): code = "1+0/0" with pytest.raises(InterpreterError, match="ZeroDivisionError"): interpreter.execute(code) + + +def test_exception_args(): + with PythonInterpreter() as interpreter: + token = random.randint(1, 10**9) + code = f"raise ValueError({token})" + with pytest.raises(InterpreterError, match=rf"ValueError: \[{token}\]"): + interpreter.execute(code) + + +def test_final_answer_trick(): + with PythonInterpreter() as interpreter: + token = random.randint(1, 10**9) + code = f"final_answer('The result is', {token})" + result = interpreter(code) + + # They should matain the same order + assert result == ["The result is", token], "The returned results are differ, `final_answer` trick doesn't work"