How the Eval Tool Runs Persistent Python and JavaScript Kernels in Oh-My-Pi
The eval tool maintains persistent execution contexts by running JavaScript in a Node.js worker thread and Python in a long-lived subprocess, enabling stateful multi-cell notebooks with shared variables and imports across calls.
The eval tool in the can1357/oh-my-pi coding agent provides a unified interface for executing code cells in both Python and JavaScript. Unlike ephemeral execution environments that reset after each call, this implementation maintains persistent kernels that preserve state, imports, and variables across multiple evaluations. By leveraging a worker thread for JavaScript and a subprocess for Python, the tool delivers a Jupyter-like notebook experience within the agent's architecture.
Architecture Overview
The eval tool implements two distinct backend strategies behind a common interface defined in src/eval/backend.ts:
| Language | Kernel Implementation | Process Model | Communication Protocol |
|---|---|---|---|
| JavaScript | JsRuntime |
In-process worker thread (worker-entry.ts) |
worker-protocol.ts (postMessage) |
| Python | PythonKernel |
Separate OS subprocess (runner.py + kernel.ts) |
NDJSON over stdin/stdout |
The EvalTool class defined in src/tools/eval.ts consumes these backends through a unified ExecutorBackend interface, selecting the appropriate kernel based on the language property of the execution cell.
JavaScript Kernel: In-Process Worker Thread
The JavaScript backend executes code within a sandboxed vm context running inside a dedicated Node.js worker thread, ensuring isolation while maintaining access to the host's tool ecosystem.
Worker Entry and Core Components
The kernel lifecycle begins in src/eval/js/worker-entry.ts, which instantiates a WorkerCore class from src/eval/js/worker-core.ts. The WorkerCore orchestrates a single-threaded JsRuntime and manages the run queue. The actual execution environment lives in src/eval/js/shared/runtime.ts, which implements the sandboxed vm-based runtime respecting the session's current working directory, settings, and abort signals.
Communication between the host and worker uses strict message types defined in src/eval/js/worker-protocol.ts:
export type WorkerInbound = { type: "init" | "run" | "tool-reply" | "close"; … };
export type WorkerOutbound = { type: "ready" | "text" | "display" | "result" | "closed"; … };
Execution Flow and State Persistence
When EvalTool.run() invokes jsBackend.execute() (from src/eval/js/index.ts), the flow proceeds through executeJs() in src/eval/js/executor.ts, which creates an OutputSink for streaming results and calls executeInVmContext().
Because the worker thread persists for the entire session, variables and imports remain available across successive calls. The session can be explicitly reset by passing reset: true in the ExecOpts, which causes WorkerCore to tear down and recreate the JavaScript runtime.
Python Kernel: Persistent Subprocess
The Python backend maintains a long-running subprocess that communicates via newline-delimited JSON (NDJSON), enabling robust handling of long-running computations and clean cancellation.
Subprocess Lifecycle and NDJSON Protocol
The PythonKernel class in src/eval/py/kernel.ts manages the subprocess lifecycle. When pythonBackend.execute() is called (from src/eval/py/index.ts), it invokes executePython() in src/eval/py/executor.ts, which retrieves or creates a kernel instance from a global sessions map.
If no kernel exists for the session, PythonKernel.start() spawns a new process:
- The
runner.pyscript (imported viaimport … with { type: "text" }) is written toRUNNER_CACHE_DIR - The detected Python interpreter launches the script with a filtered environment (
filterEnvfromsrc/eval/py/runtime.ts) - Parent and child communicate via NDJSON frames over stdin/stdout with types:
"stdout","stderr","display","result", and"error"
Each executePython call sends a {"type":"run", "code":…} frame and awaits the corresponding result, allowing the subprocess to maintain Jupyter-like state between executions.
Robust Cancellation Handling
The Python kernel implements graceful degradation for cancellation. When an AbortSignal triggers:
- The kernel sends
SIGINTto the subprocess, triggeringKeyboardInterruptinside Python - If the process does not exit within
INTERRUPT_ESCALATION_MS, the kernel escalates toSIGTERMand finallySIGKILL
This ensures that runaway Python code cannot deadlock the agent while preserving the ability to catch interrupts within the Python runtime.
Unified Backend Interface
Both kernels expose a common interface through src/eval/index.ts:
export * from "./backend";
export { default as jsBackend } from "./js";
export { default as pythonBackend } from "./py";
The EvalTool consumes these backends polymorphically:
// src/tools/eval.ts (simplified)
export class EvalTool implements Tool {
async run(cell: EvalCell, opts: ExecOpts) {
const backend = cell.language === "python"
? evalIndex.pythonBackend
: evalIndex.jsBackend;
return backend.execute(cell.code, execOptions);
}
}
This abstraction allows client code to switch languages without changing the execution pattern, while both backends respect identical options for deadlineMs, signal, artifactPath, and streaming callbacks.
Cross-Language Tool Bridge
Python cells can invoke host-side tools (such as search, read, or bash) through a bridge mechanism implemented in src/eval/py/tool-bridge.ts. The system injects a small Python helper via src/eval/py/prelude.ts that creates a proxy object named tool.
When Python code calls tool.<name>(args), the bridge marshals the request over the NDJSON channel back to the host. The PythonKernel dispatcher forwards the call to the corresponding Tool implementation and returns the result to the Python subprocess. This mechanism ensures feature parity between JavaScript and Python cells regarding tool access.
Practical Code Examples
Executing Python with Tool Access
import { createToolSession } from "@oh-my-pi/pi-coding-agent";
import { EvalTool } from "@oh-my-pi/pi-coding-agent/tools/eval";
async function runPython() {
const session = await createToolSession({ cwd: "/tmp/omp" });
const evalTool = new EvalTool(session);
const result = await evalTool.run({
language: "python",
code: `
# The prelude automatically makes 'tool' available
search_result = await tool.search({ query: "oh-my-pi" })
print(f"Found {len(search_result.hits)} results")
`,
});
console.log("Output:", result.output);
}
runPython();
Executing JavaScript with State Persistence
import { EvalTool } from "@oh-my-pi/pi-coding-agent/tools/eval";
async function runJs() {
const evalTool = new EvalTool(await createToolSession());
// First cell: define a variable
await evalTool.run({
language: "javascript",
code: `const sharedData = { count: 42 };`,
});
// Second cell: access the persisted variable
const { output } = await evalTool.run({
language: "javascript",
code: `console.log("Count is:", sharedData.count);`,
});
console.log(output); // "Count is: 42"
}
runJs();
Summary
- Dual Runtime Strategy: JavaScript executes in an in-process worker thread (
worker-entry.ts), while Python runs in a separate OS subprocess (kernel.ts) communicating via NDJSON. - Stateful Execution: Both kernels persist across
evalcalls, maintaining variables, imports, and file handles unless explicitly reset viaopts.reset. - Unified Interface: The
EvalToolclass insrc/tools/eval.tsabstracts backend differences, routing tojsBackendorpythonBackendbased on thelanguageflag. - Robust Cancellation: Python implements a signal escalation strategy (
SIGINT→SIGTERM→SIGKILL), while JavaScript relies on worker termination viaAbortSignal. - Tool Ecosystem Access: Python cells access host tools through a lightweight bridge (
tool-bridge.tsandprelude.ts) that marshals calls over the NDJSON protocol.
Frequently Asked Questions
How does the eval tool maintain state between code cells?
The eval tool maintains state by keeping kernels alive across executions. For JavaScript, the WorkerCore in src/eval/js/worker-core.ts preserves the vm context between calls. For Python, the PythonKernel in src/eval/py/kernel.ts keeps the subprocess running, maintaining the interpreter's global namespace. Variables and imports defined in one cell remain accessible in subsequent cells until the session ends or reset: true is passed.
What happens when I cancel a running Python cell?
When cancellation is requested via an AbortSignal, the PythonKernel first sends SIGINT to the subprocess, allowing Python code to catch KeyboardInterrupt and clean up. If the process does not terminate within INTERRUPT_ESCALATION_MS, the kernel escalates to SIGTERM and finally SIGKILL to ensure the agent remains responsive regardless of the subprocess state.
Can Python code access the same tools as JavaScript?
Yes. Through the tool bridge implemented in src/eval/py/tool-bridge.ts, Python cells can access the complete host tool ecosystem. The bridge injects a tool proxy object via prelude.ts that marshals function calls over the NDJSON channel to the host, executes the corresponding Tool implementation, and returns the result to the Python runtime.
How do I force a kernel reset to clear all variables?
Pass reset: true in the ExecOpts when calling EvalTool.run(). For JavaScript, this triggers the recreation of the WorkerCore and JsRuntime. For Python, this causes the PythonKernel to terminate the existing subprocess and spawn a fresh one on the next execution, clearing all accumulated state.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →