Eval Tool Python Backend
This document describes the Python execution stack in packages/coding-agent.
It covers tool behavior, runner lifecycle, environment handling, execution semantics, output rendering, supported magics, and operational failure modes.
Scope and Key Files
- Tool surface:
src/tools/eval.ts - Session/per-call kernel orchestration:
src/eval/py/executor.ts - Subprocess kernel client:
src/eval/py/kernel.ts - Python wrapper / NDJSON server:
src/eval/py/runner.py - Prelude helpers loaded into every kernel:
src/eval/py/prelude.py - Host-side subagent helper bridge:
src/eval/agent-bridge.ts - MIME bundle renderer (text + structured outputs):
src/eval/py/display.ts - Interactive-mode renderer for user-triggered Python runs:
src/modes/components/eval-execution.ts - Runtime/env filtering and Python resolution:
src/eval/py/runtime.ts
What eval’s Python backend is
The eval tool executes one or more Python cells inside a retained python subprocess that speaks NDJSON over stdin/stdout. No Jupyter gateway and no extra pip dependencies are required — a vanilla Python 3.8+ interpreter is enough. Rich display() output (PIL, pandas, plotly, matplotlib figures) keeps working because the wrapper implements MIME-bundle dispatch.
Tool params:
{
cells: Array<{
language: "py" | "js";
code: string;
title?: string;
timeout?: number; // seconds, clamped to 1..3600, default 30. Inactivity budget — see "Cell timeout".
reset?: boolean; // reset this cell's selected runtime before execution
}>;
}
The tool is concurrency = "exclusive" for a session, so calls do not overlap.
Kernel lifecycle
Each Python kernel is a single subprocess: <resolved-python> -u <runner.py>. The runner is bundled with the host binary (Bun text import), written to an omp-python-runner cache under the OS temp directory once per script hash, and reused by subsequent spawns.
Kernel startup sequence:
- Availability check (
checkPythonKernelAvailability) — verifies that a Python interpreter resolves and runs. - Spawn
python -u runner.pywith filtered env andcwd. - Send an init request that runs
os.chdir(cwd), injects env entries, and addscwdtosys.path. - Execute
PYTHON_PRELUDE(idempotent — only initializes once per process).
Kernel shutdown:
- Send
{"type": "exit"}over stdin. - Wait for process exit with
SHUTDOWN_GRACE_MSbudget. - Escalate to
SIGTERMand finallySIGKILLif the process does not exit in time.
Wire protocol (NDJSON, host ↔ runner)
One JSON object per line, UTF-8, \n terminated.
Host → runner:
{"id": "<reqId>", "code": "<source>", "silent": false, "storeHistory": true, "cwd": "<optional>", "env": {"KEY": "VAL"}}
{"type": "exit"}
Runner → host:
{"type": "started", "id": "<reqId>"}
{"type": "stdout", "id": "<reqId>", "data": "..."}
{"type": "stderr", "id": "<reqId>", "data": "..."}
{"type": "display", "id": "<reqId>", "bundle": {<mime>: <value>}}
{"type": "result", "id": "<reqId>", "bundle": {<mime>: <value>}}
{"type": "error", "id": "<reqId>", "ename": "...", "evalue": "...", "traceback": ["..."]}
{"type": "done", "id": "<reqId>", "status": "ok"|"error", "executionCount": N, "cancelled": false}
Status events the prelude emits (e.g. _emit_status("find", count=…)) ship inside display bundles under application/x-omp-status so the existing TUI status renderer keeps working.
Magics
The runner’s source transformer rewrites IPython-style magics to plain Python calls before parsing. Supported set:
| Magic | Effect |
|---|---|
%pip <args> |
python -m pip <args> with live streaming output. Newly installed packages are evicted from sys.modules so the next import picks up the fresh install. |
%cd <path> |
os.chdir(path) (with ~ expansion); emits status event. |
%pwd |
Returns os.getcwd(). |
%ls [path] |
Returns sorted(os.listdir(path)). |
%env [KEY[=VAL]] |
List, read, or set env vars (matches prelude env() semantics). |
%set_env KEY VALUE |
Set os.environ[KEY]. |
%time <expr> / %timeit <expr> |
Time the expression; emits status event with elapsed ms. |
%who / %whos |
List user-namespace names. |
%reset |
Clear user globals and re-inject prelude. |
%load <path> |
Read a file into a fresh cell and execute. |
%run <path> |
runpy.run_path and merge globals back. |
%%bash / %%sh |
Run the cell body via bash/sh. |
%%capture [name] |
Run body with stdout/stderr captured into name. |
%%timeit |
Time the cell body. |
%%writefile <path> |
Write body to file. |
!cmd / var = !cmd |
Run command via subprocess shell; returns an SList-style result with .n / .s helpers. |
var = %name args |
Assignment forms work for line magics and !cmd. |
Unknown magic names raise NameError: UsageError: ... inside the cell.
Session persistence semantics
python.kernelMode controls retained kernel reuse:
session(default)- Reuses kernel sessions keyed by namespaced eval session id plus normalized cwd and interpreter.
- Multiple owners can share the same retained kernel for that key.
- Calls through the tool are exclusive, so tool invocations do not overlap.
- A dead retained subprocess is replaced before execution.
- If the subprocess dies during execution, it is replaced and the cell is retried once.
per-call- Spawns a fresh subprocess for each request.
- Shuts the subprocess down after the request.
- No cross-call state persistence.
Multi-cell behavior in a single tool call
Python cells run sequentially in the same selected Python kernel instance for that tool call.
If an intermediate cell fails:
- Earlier cell state remains in memory.
- Tool returns a targeted error indicating which cell failed.
- Later cells are not executed.
reset=true is per cell and resets that language runtime before the cell executes.
Environment filtering and runtime resolution
Environment is filtered before launching the runner:
- Allowlist includes core vars like
PATH,HOME, locale vars,VIRTUAL_ENV,PYTHONPATH, etc. - Allow-prefixes:
LC_,XDG_,PI_ - Denylist strips common API keys (OpenAI/Anthropic/Gemini/etc.)
Runtime selection order (skipped entirely when the python.interpreter setting names an explicit executable):
- Active/located venv (
VIRTUAL_ENV, thenCONDA_PREFIX, then<cwd>/.venv,<cwd>/venv) - Managed venv at
~/.musepi/python-env pythonorpython3on PATH
When a venv is selected, its bin/Scripts path is prepended to PATH.
The runner additionally receives PYTHONUNBUFFERED=1 and PYTHONIOENCODING=utf-8 so streamed output reaches the host promptly.
Tool availability and mode selection
eval.py / eval.js (both default true) plus optional boolean env flags PI_PY / PI_JS control eval backend exposure:
- Python backend only (
eval.py=true,eval.js=false, orPI_PY=1 PI_JS=0) - JavaScript backend only (
eval.py=false,eval.js=true, orPI_PY=0 PI_JS=1) - both backends (
eval.py=true,eval.js=true, orPI_PY=1 PI_JS=1)
PI_PY and PI_JS use normal boolean flag parsing. Each flag, when set, overrides only its own setting; an unset flag falls back to its setting (eval.py / eval.js, both default true).
If Python preflight fails and eval.js is enabled, eval remains available for js cells; py cells fail with a Python-backend availability error.
Python prelude helpers include agent(prompt, *, agent="task", label=None, schema=None, schema_mode=None, isolated=None, apply=None, merge=None, handle=False). It synchronously calls the host bridge and returns final text, or parsed data when schema is supplied. schema_mode selects permissive or strict structured-output handling; the isolation/apply/merge flags control task worktree behavior. With handle=True, it returns a DAG node dict ({"text", "output", "handle", "id", "agent"}) whose handle is the recoverable agent://<id> URI; parsed output is also stored under "data" when available.
Execution flow and cancellation/timeout
Cell timeout
Each eval cell timeout is in seconds, defaults to 30, and is clamped to 1..3600. It is a wall-clock budget on the cell’s own work that the watchdog (IdleTimeout, src/eval/idle-timeout.ts) enforces, but it is suspended while a host-side agent()/parallel()/completion() bridge call is in flight: those calls emit synthetic pause/resume timeout-control status events (withBridgeTimeoutPause, src/eval/bridge-timeout.ts) that pause the watchdog entirely and start a fresh timeout window when control returns to the runtime, so a long fanout or a slow completion runs to completion instead of being killed mid-stream. Pause is reference-counted because parallel() can have multiple bridge calls in flight at once.
The pause/resume events are the sole mechanism that suspends the budget. Everything else the cell does — compute, stdout/stderr, log()/phase(), and ordinary (non-agent) tool calls — counts against timeout, so a cell that is not delegating to an agent/completion is bounded by a plain wall-clock timeout. The tool combines the caller abort signal, the session abort signal, and the watchdog’s signal with AbortSignal.any(...); no wall-clock deadline is passed to the backend, so neither runtime arms a competing fixed timer.
Kernel execution cancellation
On abort/timeout:
- The host sends
kill("SIGINT")to the runner subprocess. - The runner’s exec-time signal handler raises
KeyboardInterruptinside the user code. - Result includes
cancelled=true; a kernel timeout is annotated aseval cell timed out after <n>s; kernel interrupted but remains running. Reset the kernel via { reset: true } if state appears corrupted. - Between requests the runner installs
SIG_IGNfor SIGINT so a stray cancel does not tear down the kernel.
If the runner does not emit done within 5s of the interrupt (INTERRUPT_ESCALATION_MS — e.g. stuck in C code holding the GIL), the host shuts the subprocess down (escalating exit → SIGTERM → SIGKILL), the cell is annotated as kernel-killed, and the kernel is recreated on the next call.
stdin behavior
Interactive stdin is not supported. The runner does not forward input() prompts; user code that calls input() blocks until cancellation.
Output capture and rendering
Captured output classes
From runner frames:
stdout/stderr→ plain text chunksdisplay/result→ rich display handling (MIME bundle)error→ traceback textapplication/x-omp-statusMIME insidedisplay→ structured status events
Display MIME precedence:
text/markdowntext/plaintext/html(converted to basic markdown)
Additionally captured as structured outputs:
application/json→ JSON tree dataimage/png/image/jpeg→ image payloadsapplication/x-omp-status→ status events
Matplotlib
The runner sets MPLBACKEND=Agg as an environ default so figures render off-screen. After every cell, pyplot.get_fignums() is iterated; each figure is saved to PNG, emitted as an image/png display, and closed.
Storage and truncation
Output is streamed through OutputSink and may be persisted to artifact storage. Tool results can include truncation metadata and artifact://<id> for full output recovery.
Renderer behavior
- Tool renderer (
eval-render.ts, re-exported fromeval.ts):- shows code-cell blocks with per-cell status
- collapsed preview defaults to 10 lines
- supports expanded mode for all output retained in the tool result
- Interactive renderer (
eval-execution.ts):- used for user-triggered Python execution in TUI
- collapsed preview defaults to 20 lines
- clamps very long individual lines to 4000 chars for display safety
- shows cancellation/error/truncation notices
Operational troubleshooting
- Python backend not available — Check
eval.py,PI_PY, and thatpython/python3is on PATH. If preflight fails andeval.jsis enabled, use ajscell. - No Python on PATH — Install a system Python 3.8+ or place a venv at
~/.musepi/python-env.musepi setup python --checkreports the resolved interpreter. - Execution hangs then times out — Increase tool
timeout(max 3600s) if workload is legitimate. For stuck native code, cancellation triggersSIGINTfirst then escalates; the session restarts on the next request. - stdin/input prompts in Python code —
input()is not supported; pass data programmatically. - Working directory errors — Tool validates
cwdexists and is a directory before execution.
Relevant environment variables
PI_PY/PI_JS— eval backend exposure overridesPI_PYTHON_SKIP_CHECK=1— bypass Python preflight/warm checksPI_PYTHON_INTEGRATION=1— enable gated integration tests that spawn a real PythonPI_PYTHON_IPC_TRACE=1— log NDJSON frames exchanged with the runner subprocess