feat(dspy): add interpreter-level callbacks (on_interpreter_*) - #72
feat(dspy): add interpreter-level callbacks (on_interpreter_*)#72dbreunig wants to merge 5 commits into
Conversation
Introduce eight no-op BaseCallback handlers for the CodeInterpreter layer: execute, sandbox->host tool dispatch, and startup/shutdown lifecycle. Route them in _get_on_start_handler/_get_on_end_handler via a structural isinstance check on the runtime_checkable CodeInterpreter Protocol, dispatching by method name (following the Adapter precedent). The branch sits before the module fallback so interpreter events never masquerade as module events. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HKrQ1MkNA3Ftt3XYCPm2YL
Decorate PythonInterpreter.execute/start/shutdown with @with_callbacks and add an optional callbacks parameter for instance-level parity with LM/Module. Extract the core tool invocation into a decoratable _invoke_tool seam so the tool-call end handler observes the real exception before _handle_tool_call converts it into a JSON-RPC error response (byte-for-byte unchanged sandbox behavior). Document that CodeInterpreter implementations should decorate these methods to participate, since the Protocol cannot enforce it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HKrQ1MkNA3Ftt3XYCPm2YL
Decorate the MockInterpreter test double so it participates in the callback system like a real interpreter, then cover the new hooks: execute start/end with code and outputs, the SUBMIT FinalOutput path, error surfacing, tool dispatch for plain closures with JSON-RPC error preservation, call_id nesting, lifecycle with idempotent shutdown, dispatch-routing integrity (no misrouting to on_module_*), the zero-callback fast path, and a full dspy.RLM run emitting one execute event per iteration. Deno-gated tests exercise the real interpreter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HKrQ1MkNA3Ftt3XYCPm2YL
Add the on_interpreter_* handlers to the BaseCallback reference table and an RLM-flavored example that logs per-cell execution time and sub-tool calls, noting the large-variables caveat and the custom-interpreter decoration guidance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HKrQ1MkNA3Ftt3XYCPm2YL
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 81e21f6f28
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| @with_callbacks | ||
| def start(self) -> None: |
There was a problem hiding this comment.
Route lazy process creation through startup callbacks
When PythonInterpreter is used normally by RLM, start() is never called: execute() creates the Deno process directly through _ensure_deno_process(), and the default RLM context only calls shutdown(). Consequently, on_interpreter_startup_* is not emitted for the actual lazy process spawn, so these new lifecycle hooks only work for users who explicitly pre-warm the interpreter. Route lazy initialization through the decorated startup path or instrument the process-creation seam itself.
Useful? React with 👍 / 👎.
| _INTERPRETER_START_HANDLERS = { | ||
| "execute": "on_interpreter_execute_start", | ||
| "start": "on_interpreter_startup_start", | ||
| "shutdown": "on_interpreter_shutdown_start", | ||
| "_invoke_tool": "on_interpreter_tool_call_start", | ||
| } |
There was a problem hiding this comment.
Avoid hard-coding the custom tool-dispatch seam name
For a custom CodeInterpreter that follows the new documentation and decorates a tool-dispatch seam named something conventional such as dispatch_tool, handler selection raises an unsupported-method ValueError, which with_callbacks catches and logs, so no tool-call events are emitted. Only the private PythonInterpreter-specific name _invoke_tool is recognized even though custom implementations are told they can decorate their own seam; provide a way to declare the event type or require and document a protocol-level method name.
Useful? React with 👍 / 👎.
Greptile SummaryThis PR adds interpreter-level callbacks for code execution observability. The main changes are:
Confidence Score: 5/5This PR is safe to merge with low risk. The changes are additive and preserve the zero-callback fast path. Callback dispatch order keeps interpreter events from falling through to module handlers. No blocking correctness or security issues were found in the changed paths. No files require special attention.
What T-Rex did
Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant User
participant RLM as dspy.RLM / caller
participant PI as PythonInterpreter
participant CB as BaseCallback handlers
participant Sandbox as Deno/Pyodide sandbox
participant Tool as Host-side tool
User->>RLM: forward(...)
RLM->>PI: execute(code, variables)
PI->>CB: on_interpreter_execute_start
PI->>PI: _ensure_deno_process()
PI->>CB: on_interpreter_startup_start/end (on spawn)
PI->>Sandbox: JSON-RPC execute
Sandbox-->>PI: tool_call(name, kwargs)
PI->>CB: on_interpreter_tool_call_start
PI->>Tool: invoke_tool(name, kwargs)
Tool-->>PI: result or exception
PI->>CB: on_interpreter_tool_call_end
PI-->>Sandbox: JSON-RPC result/error
Sandbox-->>PI: output / final / error
PI->>CB: on_interpreter_execute_end
PI-->>RLM: output or FinalOutput
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant User
participant RLM as dspy.RLM / caller
participant PI as PythonInterpreter
participant CB as BaseCallback handlers
participant Sandbox as Deno/Pyodide sandbox
participant Tool as Host-side tool
User->>RLM: forward(...)
RLM->>PI: execute(code, variables)
PI->>CB: on_interpreter_execute_start
PI->>PI: _ensure_deno_process()
PI->>CB: on_interpreter_startup_start/end (on spawn)
PI->>Sandbox: JSON-RPC execute
Sandbox-->>PI: tool_call(name, kwargs)
PI->>CB: on_interpreter_tool_call_start
PI->>Tool: invoke_tool(name, kwargs)
Tool-->>PI: result or exception
PI->>CB: on_interpreter_tool_call_end
PI-->>Sandbox: JSON-RPC result/error
Sandbox-->>PI: output / final / error
PI->>CB: on_interpreter_execute_end
PI-->>RLM: output or FinalOutput
Reviews (2): Last reviewed commit: "Address review: cover lazy startup and m..." | Re-trigger Greptile |
Two follow-ups from automated review of the interpreter callbacks: - Startup callbacks now fire from the actual process-spawn seam. execute() spawns Deno lazily via _ensure_deno_process(), so decorating the public start() missed the common path (the default RLM flow never calls start()). Move startup emission to a decorated _spawn_process() seam and leave start() undecorated, so on_interpreter_startup_* fires exactly once per real spawn on both the lazy and explicit paths, and not when the process is already running. - Rename the tool-dispatch seam _invoke_tool -> invoke_tool (public). Callback routing dispatches by method name, so custom CodeInterpreter implementations must decorate an identically named method to emit tool-call events; a public name is a reasonable contract to document, whereas an underscore-private one is not. Tighten the CodeInterpreter docstring to spell out the recognized seam names (execute/start/shutdown/invoke_tool). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HKrQ1MkNA3Ftt3XYCPm2YL
|
Addressed both automated-review findings in 1. Lazy startup was invisible (Greptile P1 / Codex). Startup callbacks now fire from the actual process-spawn seam instead of the public 2. Renamed the tool-dispatch seam CI was green before these changes; re-running now. Generated by Claude Code |
📝 Changes Description
Adds first-class
BaseCallbackhooks for theCodeInterpreterlayer so thatdspy.RLM— and any other code-executing module — is observable the same way Module, LM, Tool, Adapter, and Evaluate already are.Motivation. The callback system had zero hooks for the interpreter layer, leaving gaps for RLM: a cell's output was only visible one turn late (embedded in the next iteration's
repl_history); terminal states (FinalOutput,CodeInterpreterError,SyntaxError) were flattened into"[Error] ..."strings, losing the real exception class and the SUBMIT payload; RLM's injectedllm_query/llm_query_batchedare plain closures, so the sandbox→host dispatch itself was invisible; and interpreter startup/shutdown (Deno process spawn) was unobservable.Changes.
dspy/utils/callback.py— Eight new no-opBaseCallbackhandlers:on_interpreter_execute_{start,end},on_interpreter_tool_call_{start,end},on_interpreter_startup_{start,end},on_interpreter_shutdown_{start,end}. Routed via a structuralisinstance(instance, dspy.CodeInterpreter)branch that dispatches by method name (execute/start/shutdown/_invoke_tool), following theAdapterprecedent. The branch sits before theon_module_*fallback so interpreter events never masquerade as module events.dspy/primitives/python_interpreter.py— Decorateexecute/start/shutdownwith@with_callbacks; add an optionalcallbacksparam for instance-level parity with LM/Module. Extract the core tool invocation into a new_invoke_toolseam and decorate that —_handle_tool_callkeeps itstry/exceptaround it, so the real exception reacheson_interpreter_tool_call_endand is then converted to the same JSON-RPC error as before. Sandbox behavior is byte-for-byte unchanged.dspy/primitives/code_interpreter.py— Document that implementations should decorate these methods to participate (the Protocol can't enforce it).tests/mock_interpreter.py— Decorate the test double so it participates like a real interpreter.BaseCallbacktable plus an RLM-flavored example (per-cell timing + sub-tool logging).Test coverage. New
tests/callback/test_interpreter_callback.py(recording-callback pattern) covers: execute start/end withcodein inputs and result in outputs; the SUBMITFinalOutputpath; error surfacing (CodeInterpreterErrorinexception, still propagates); plain-closure tool dispatch with correcttool_name/kwargsand JSON-RPC error preservation for raising/unknown tools;call_idnesting of tool calls under the enclosing execute; lifecycle with idempotent shutdown; dispatch-routing integrity (no misrouting toon_module_*); the zero-callback fast path; and a fulldspy.RLMrun emitting one execute event per iteration. Deno-gated tests (@pytest.mark.deno, existing skip convention) exercise the realPythonInterpreter.Local runs of
tests/callback,tests/primitives, andtests/predict/test_rlm.pypass (Deno tests skip without--deno).No behavior change without registered callbacks.
with_callbacksreturns immediately when the combined callback list is empty, so the empty-callback fast path is preserved and there is no behavior change when no callbacks are registered.✅ Contributor Checklist
ruff checkclean on all changed files){label}(dspy): {message}format{label}(dspy): {message}format (commits use plain imperative summaries; can be reworded/squashed before any upstream submission)stanfordnlp/dspy, note their CONTRIBUTING policy: open an issue first for non-trivial features and disclose AI assistance (draftISSUE.md/PR_DESCRIPTION.mdprepared separately). The upstream issue/PR should be opened by a human in their own words.@pytest.mark.denoskip convention and were not executed here (Deno unavailable).🤖 Generated with Claude Code
Generated by Claude Code