Skip to content

Latest commit

 

History

History
261 lines (180 loc) · 12.4 KB

File metadata and controls

261 lines (180 loc) · 12.4 KB

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
  • 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 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<{
    language: "py" | "js";
    code: string;
    title?: string;
    timeout?: number; // per-cell seconds, 1..600, default 30
    reset?: boolean; // wipe this cell's language kernel before running it
  }>;
}

The tool is concurrency = "exclusive" for a session, so calls do not overlap.

Kernel lifecycle

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:

  1. Availability check (checkPythonKernelAvailability) — verifies that a Python interpreter resolves and runs.
  2. Spawn python -u runner.py with filtered env and cwd.
  3. Send an init request that runs os.chdir(cwd), injects env entries, and adds cwd to sys.path.
  4. Execute PYTHON_PRELUDE (idempotent — only initializes once per process).

Kernel shutdown:

  • Send {"type": "exit"} over stdin.
  • Wait for process exit with SHUTDOWN_GRACE_MS budget.
  • Escalate to SIGTERM and finally SIGKILL if 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}
{"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-gjc-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 session file plus cwd when a session file exists; otherwise by cwd.
    • Execution is serialized per session via a queue.
    • There is no idle eviction, session cap, or heartbeat; retained kernels live until reset, owner cleanup, or process shutdown.
    • A kernel found dead (isAlive() false) before a cell runs is replaced. If a cell fails for a reason other than cancellation and leaves the kernel dead, the kernel 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.

Kernel ownership

Retained kernels are keyed by an owner id, and there are two kinds of owner:

  • Session-owned (the eval tool) — derived from the session file plus cwd as described above. Reaped when the session disposes.
  • Explicitly-owned (the python tool) — the caller supplies kernelOwnerId, which is python:<session-id>. This is deliberately distinct from the session's eval owner id so the two never alias and a Python REPL kernel is never reaped as collateral of eval cleanup.

disposeKernelSessionsByOwner(ownerId) disposes every retained kernel for one owner and is idempotent, so disposing an owner twice is not a double free.

Owner-scoped kernels are reaped on all three exits:

  • the owning tool's own teardown action (for python, action: "clear"),
  • session cleanup, which also handles session identity transitions,
  • signal exit (Ctrl-C), via disposeChildSubprocesses, which also drains both registries inside a single bounded budget.

gjc autoresearch clear clears autoresearch state only; it does not dispose a Python kernel.

A tool registering through the SDK's registerSessionCleanup lands in the transition cleanup registry, not the session one. Draining only the session registry on signal exit left explicitly-owned kernels running after Ctrl-C while graceful dispose looked correct.

Multi-cell behavior in a single tool call

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 is per cell: a Python cell with reset: true disposes that session's kernel before it runs, and later Python cells in the same call reuse the fresh kernel.

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_, GJC_
  • Denylist strips common API keys (OpenAI/Anthropic/Gemini/etc.)

Runtime selection order:

  1. Active/located venv (VIRTUAL_ENV, then <cwd>/.venv, <cwd>/venv)
  2. Managed venv at ~/.gjc/python-env
  3. python or python3 on 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 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 only
  • 1 / py → Python backend only
  • mix / both → both backends

If Python preflight fails and eval.js is enabled, eval remains available for JavaScript cells. A cell with language: "py" throws a ToolError; there is no automatic fallback to JavaScript.

Execution flow and cancellation/timeout

Tool-level timeout

eval timeout is in seconds, default 30, clamped to 1..600. The tool combines caller abort signal and timeout signal with AbortSignal.any(...).

Kernel execution cancellation

On abort/timeout:

  • The host sends SIGINT to the runner's process group (the runner is spawned detached as a group leader).
  • The runner's exec-time signal handler raises KeyboardInterrupt inside the user code.
  • Result includes cancelled=true. A timeout annotates the output with eval cell timed out after <n>s; kernel interrupted but remains running. ..., or with a kernel-killed notice when escalation was needed (formatKernelTimeoutAnnotation()). A deadline that expires before the kernel returns a result (for example while waiting for the session queue) yields a Command timed out notice instead.
  • Between requests the runner installs SIG_IGN for SIGINT so a stray cancel does not tear down the kernel.

If the runner has not finished 5s after SIGINT (for example, stuck in C code), the host shuts the kernel down (exit request, then SIGTERM, then SIGKILL) and marks the cell as kernel-killed; the next call starts a new kernel.

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 chunks
  • display / result → rich display handling (MIME bundle)
  • error → traceback text
  • application/x-gjc-status MIME inside display → structured status events

Display MIME precedence:

  1. text/markdown
  2. text/plain
  3. text/html (converted to basic markdown)

Additionally captured as structured outputs:

  • application/json → JSON tree data
  • image/png / image/jpeg → image payloads
  • application/x-gjc-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.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

Operational troubleshooting

  • Python backend not available — Check eval.py, GJC_PY, and that python/python3 is on PATH. If preflight fails and eval.js is enabled, pass language: "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 --check reports the resolved interpreter.
  • Execution hangs then times out — Increase tool timeout (max 600s) if workload is legitimate. For stuck native code, cancellation triggers SIGINT first 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 cwd exists and is a directory before execution.

Relevant environment variables

  • GJC_PY — tool exposure override
  • GJC_PYTHON_SKIP_CHECK=1 — bypass Python preflight/warm checks
  • GJC_PYTHON_INTEGRATION=1 — enable gated integration tests that spawn a real Python
  • GJC_PYTHON_IPC_TRACE=1 — log NDJSON frames exchanged with the runner subprocess