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.
- 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 - 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
The eval tool executes one or more Python cells inside a long-lived python3 subprocess that speaks NDJSON over stdin/stdout. No Jupyter, no kernel gateway, no extra pip dependencies — a vanilla Python 3.8+ interpreter is enough. Rich display() output (PIL, pandas, plotly, matplotlib figures) keeps working because the wrapper reimplements the MIME-bundle dispatch that IPython previously provided.
Tool params:
{
cells: Array<{ code: string; title?: string }>;
timeout?: number; // seconds, clamped to 1..600, default 30
reset?: boolean; // reset selected runtime before the first cell only
}The tool is concurrency = "exclusive" for a session, so calls do not overlap.
Each kernel is a single Python subprocess: python -u <runner.py>. The bundled runner is materialized once per GJC process in a process-private temporary directory and file, then reused only by subsequent spawns within that process.
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.
One JSON object per line, UTF-8, \n terminated.
Host → runner:
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-gjc-status so the existing TUI status renderer keeps working.
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.
python.kernelMode controls retained kernel reuse:
session(default)- Reuses kernel sessions keyed by session file plus cwd when a session file exists; otherwise by cwd.
- Execution is serialized per session via a queue.
- Idle sessions are evicted after 5 minutes.
- At most 4 sessions; oldest is evicted on overflow.
- Heartbeat checks detect dead kernels.
- Auto-restart allowed once; repeated crash ⇒ hard failure.
per-call- Spawns a fresh subprocess for each request.
- Shuts the subprocess down after the request.
- No cross-call state persistence.
Cells run sequentially in the same 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 only applies to the first cell execution in that call.
Environment is filtered before launching the runner:
- Allowlist includes core vars like
PATH,HOME, locale vars,VIRTUAL_ENV,PYTHONPATH, etc. - Allow-prefixes:
LC_,XDG_,GJC_ - Denylist strips common API keys (OpenAI/Anthropic/Gemini/etc.)
Runtime selection order:
- Active/located venv (
VIRTUAL_ENV, then<cwd>/.venv,<cwd>/venv) - Managed venv at
~/.gjc/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.
eval.py / eval.js (both default true) plus optional GJC_PY override controls eval backend exposure:
- Python backend only (
eval.py=true,eval.js=false) - JavaScript backend only (
eval.py=false,eval.js=true) - both backends
GJC_PY accepted values:
0/bash→ JavaScript backend only1/py→ Python backend onlymix/both→ both backends
If Python preflight fails and eval.js is enabled, eval remains available and dispatches to JavaScript unless language: "python" is explicitly requested.
eval timeout is in seconds, default 30, clamped to 1..600. The tool combines caller abort signal and timeout signal with AbortSignal.any(...).
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; timeout path annotates output asCommand timed out after <n> seconds. - Between requests the runner installs
SIG_IGNfor SIGINT so a stray cancel does not tear down the kernel.
If a second cancel is required (runner stuck in C code), the host escalates to SIGTERM and the session restarts on the next call.
Interactive stdin is not supported. The runner does not forward input() prompts; user code that calls input() blocks until cancellation.
From runner frames:
stdout/stderr→ plain text chunksdisplay/result→ rich display handling (MIME bundle)error→ traceback textapplication/x-gjc-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-gjc-status→ status events
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.
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.
- Tool renderer (
eval.ts):- shows code-cell blocks with per-cell status
- collapsed preview defaults to 10 lines
- supports expanded mode for full output and richer status detail
- 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
- Python backend not available — Check
eval.py,GJC_PY, and thatpython/python3is on PATH. If preflight fails andeval.jsis enabled, omitlanguageor passlanguage: "js"to use JavaScript. - No Python on PATH — Install a system Python 3.8+ or place a venv at
~/.gjc/python-env.gjc setup python --checkreports the resolved interpreter. - Execution hangs then times out — Increase tool
timeout(max 600s) 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.
GJC_PY— tool exposure overrideGJC_PYTHON_SKIP_CHECK=1— bypass Python preflight/warm checksGJC_PYTHON_INTEGRATION=1— enable gated integration tests that spawn a real PythonGJC_PYTHON_IPC_TRACE=1— log NDJSON frames exchanged with the runner subprocess
{"id": "<reqId>", "code": "<source>", "silent": false, "storeHistory": true} {"type": "exit"}