diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ee70e90fe..f50d9ffa9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -119,6 +119,10 @@ - Removed the `clearOnShrink` setting and its `PI_CLEAR_ON_SHRINK` environment variable: the rewritten renderer always clears shrunken rows exactly, so the flicker/perf tradeoff the setting controlled no longer exists. Existing config entries are ignored. - Removed the prompt-submit native-scrollback reconciliation checkpoint and the eager streaming render mode from the interactive controllers — the renderer's append-only contract made both obsolete. +### Added + +- Added `python.interpreter` to pin eval's Python backend to an explicit interpreter and skip automatic runtime discovery ([#1802](https://github.com/can1357/oh-my-pi/issues/1802)). + ## [15.10.9] - 2026-06-09 ### Fixed diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 7e7621a65..fa61ce50f 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -2100,6 +2100,16 @@ export const SETTINGS_SCHEMA = { description: "Whether to keep IPython kernel alive across calls", }, }, + "python.interpreter": { + type: "string", + default: "", + ui: { + tab: "editing", + label: "Python Interpreter", + description: + "Optional path to an exact Python executable. When set, automatic Python runtime discovery is skipped.", + }, + }, // ──────────────────────────────────────────────────────────────────────── // Tools diff --git a/packages/coding-agent/src/eval/py/kernel.ts b/packages/coding-agent/src/eval/py/kernel.ts index 3848bd8cc..6e7fbfc80 100644 --- a/packages/coding-agent/src/eval/py/kernel.ts +++ b/packages/coding-agent/src/eval/py/kernel.ts @@ -17,7 +17,13 @@ import { Settings } from "../../config/settings"; import { type KernelDisplayOutput, renderKernelDisplay } from "./display"; import { PYTHON_PRELUDE } from "./prelude"; import RUNNER_SCRIPT from "./runner.py" with { type: "text" }; -import { enumeratePythonRuntimes, filterEnv, type PythonRuntime, resolvePythonRuntime } from "./runtime"; +import { + enumeratePythonRuntimes, + filterEnv, + type PythonRuntime, + resolveExplicitPythonRuntime, + resolvePythonRuntime, +} from "./runtime"; import { hostHasInheritableConsole, shouldHideKernelWindow } from "./spawn-options"; export type { KernelDisplayOutput, PythonStatusEvent } from "./display"; @@ -156,7 +162,10 @@ async function probePythonKernelAvailability(cwd: string): Promise = {}; for (const [key, value] of Object.entries(runtime.env)) { diff --git a/packages/coding-agent/src/eval/py/runtime.ts b/packages/coding-agent/src/eval/py/runtime.ts index acc41d075..e5e89d0e0 100644 --- a/packages/coding-agent/src/eval/py/runtime.ts +++ b/packages/coding-agent/src/eval/py/runtime.ts @@ -182,6 +182,27 @@ function venvBinDir(venvPath: string): string { return process.platform === "win32" ? path.join(venvPath, "Scripts") : path.join(venvPath, "bin"); } +function detectExplicitVenv(pythonPath: string): { venvPath: string; binDir: string } | undefined { + const binDir = path.dirname(pythonPath); + const venvPath = path.dirname(binDir); + if (fs.existsSync(path.join(venvPath, "pyvenv.cfg"))) { + return { venvPath, binDir }; + } + return undefined; +} + +export function resolveExplicitPythonRuntime( + interpreter: string, + cwd: string, + baseEnv: Record, +): PythonRuntime { + const pythonPath = path.isAbsolute(interpreter) ? interpreter : path.resolve(cwd, interpreter); + const venv = detectExplicitVenv(pythonPath); + if (venv) { + return { pythonPath, env: applyVenvEnv(baseEnv, venv.venvPath, venv.binDir), venvPath: venv.venvPath }; + } + return { pythonPath, env: { ...baseEnv } }; +} /** * Enumerate candidate Python runtimes in priority order: an active/project venv, * the managed `~/.omp/python-env`, then the system interpreter on PATH. Every diff --git a/packages/coding-agent/test/core/python-kernel-env.test.ts b/packages/coding-agent/test/core/python-kernel-env.test.ts index 67ef5f162..cc257bb4f 100644 --- a/packages/coding-agent/test/core/python-kernel-env.test.ts +++ b/packages/coding-agent/test/core/python-kernel-env.test.ts @@ -1,7 +1,12 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as path from "node:path"; -import { enumeratePythonRuntimes, filterEnv, resolvePythonRuntime } from "@oh-my-pi/pi-coding-agent/eval/py/runtime"; +import { + enumeratePythonRuntimes, + filterEnv, + resolveExplicitPythonRuntime, + resolvePythonRuntime, +} from "@oh-my-pi/pi-coding-agent/eval/py/runtime"; import * as piUtils from "@oh-my-pi/pi-utils"; describe("Python gateway environment filtering", () => { @@ -92,6 +97,35 @@ describe("enumeratePythonRuntimes", () => { expect(resolvePythonRuntime(path.join(path.sep, "work"), {}).pythonPath).toBe(systemPy); }); + it("resolves an explicit interpreter without falling through to discovery", () => { + vi.spyOn(piUtils, "getPythonEnvDir").mockReturnValue(managedDir); + vi.spyOn(piUtils, "$which").mockImplementation(bin => (bin === "python" ? systemPy : null)); + vi.spyOn(fs, "existsSync").mockReturnValue(false); + const explicitPy = path.join(path.sep, "custom", "python3.13"); + + const runtime = resolveExplicitPythonRuntime(explicitPy, path.join(path.sep, "work"), { + PATH: path.join(path.sep, "usr", "bin"), + }); + + expect(runtime.pythonPath).toBe(explicitPy); + expect(runtime.venvPath).toBeUndefined(); + expect(runtime.env.PATH).toBe(path.join(path.sep, "usr", "bin")); + }); + + it("sets venv env vars for an explicit interpreter inside a virtualenv", () => { + const venvDir = path.join(path.sep, "work", ".venv"); + const binDir = path.join(venvDir, process.platform === "win32" ? "Scripts" : "bin"); + const explicitPy = path.join(binDir, process.platform === "win32" ? "python.exe" : "python"); + vi.spyOn(fs, "existsSync").mockImplementation(candidate => candidate === path.join(venvDir, "pyvenv.cfg")); + + const runtime = resolveExplicitPythonRuntime(explicitPy, path.join(path.sep, "work"), { + PATH: path.join(path.sep, "usr", "bin"), + }); + + expect(runtime.venvPath).toBe(venvDir); + expect(runtime.env.VIRTUAL_ENV).toBe(venvDir); + expect(runtime.env.PATH).toBe(`${binDir}${path.delimiter}${path.join(path.sep, "usr", "bin")}`); + }); it("throws from resolvePythonRuntime when no interpreter can be found", () => { vi.spyOn(piUtils, "getPythonEnvDir").mockReturnValue(managedDir); vi.spyOn(piUtils, "$which").mockReturnValue(null);