feat(coding-agent): implemented eval framework and replaced python tool

- Added a unified eval framework with parser grammar, backend interfaces, and JS/Python execution result types.
- Added eval tool docs and updated prompts for fenced cells, `eval.py`/`eval.js`, and fallback behavior.
- Replaced the built-in `python` tool with `eval` across registry, rendering, interactive modes, and tool settings.
- Migrated Python execution runtime from `src/ipy` to `src/eval/py`, renamed state fields, and removed legacy introspection.
- Refactored browser tooling from in-process VM helpers to worker-managed tab supervisors and protocol transport.
- Added eval parser fallback and JS tool-bridge tests, updated imports, and removed obsolete python-mode suites.
This commit is contained in:
can1357
2026-04-30 17:58:19 +02:00
parent d2c4958296
commit cf60e6df51
104 changed files with 4937 additions and 3306 deletions
+2 -2
View File
@@ -248,7 +248,7 @@ Related vars:
| Variable | Default / behavior |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `PI_PY` | Python tool mode override: `0`/`bash`=`bash-only`, `1`/`py`=`ipy-only`, `mix`/`both`=`both`; invalid values ignored |
| `PI_PY` | Eval backend override: `0`/`bash`=JavaScript only, `1`/`py`=Python only, `mix`/`both`=both; invalid values ignored |
| `PI_PYTHON_SKIP_CHECK` | If `1`, skips Python kernel availability checks/warm checks |
| `PI_PYTHON_GATEWAY_URL` | If set, uses external kernel gateway instead of local shared gateway |
| `PI_PYTHON_GATEWAY_TOKEN` | Optional auth token for external gateway (`Authorization: token <value>`) |
@@ -378,4 +378,4 @@ Treat these as secrets; do not log or commit them:
- Search/provider auth vars (`EXA_API_KEY`, `BRAVE_API_KEY`, `PERPLEXITY_API_KEY`, Anthropic search keys)
- Foundry mTLS material (`CLAUDE_CODE_CLIENT_CERT`, `CLAUDE_CODE_CLIENT_KEY`, `NODE_EXTRA_CA_CERTS` when it points to private CA bundles)
Python runtime also explicitly strips many common key vars before spawning kernel subprocesses (`packages/coding-agent/src/ipy/runtime.ts`).
Python runtime also explicitly strips many common key vars before spawning kernel subprocesses (`packages/coding-agent/src/eval/py/runtime.ts`).
+12 -12
View File
@@ -7,10 +7,10 @@ The critical distinction: **`notebook` is a JSON notebook editor, not a notebook
## Implementation files
- [`src/tools/notebook.ts`](../packages/coding-agent/src/tools/notebook.ts)
- [`src/ipy/executor.ts`](../packages/coding-agent/src/ipy/executor.ts)
- [`src/ipy/kernel.ts`](../packages/coding-agent/src/ipy/kernel.ts)
- [`src/eval/py/executor.ts`](../packages/coding-agent/src/eval/py/executor.ts)
- [`src/eval/py/kernel.ts`](../packages/coding-agent/src/eval/py/kernel.ts)
- [`src/session/streaming-output.ts`](../packages/coding-agent/src/session/streaming-output.ts)
- [`src/tools/python.ts`](../packages/coding-agent/src/tools/python.ts)
- [`src/tools/eval.ts`](../packages/coding-agent/src/tools/eval.ts)
## 1) Runtime boundary: editing vs executing
@@ -30,9 +30,9 @@ No kernel lifecycle exists in this tool:
- no stream chunks from kernel channels
- no rich display capture (`image/png`, JSON display, status MIME)
## Notebook-like execution path (`src/tools/python.ts` + `src/ipy/*`)
## Notebook-like execution path (`src/tools/eval.ts` + `src/eval/py/*`)
When the agent needs to run cell-style Python code (sequential cells, persistent state, rich displays), that goes through the **`python` tool**, not `notebook`.
When the agent needs to run cell-style Python code (sequential cells, persistent state, rich displays), that goes through the **`eval` tool** with `language: "python"`, not `notebook`.
That path is where kernel modes, restart/cancel behavior, chunk streaming, and output artifact truncation live.
@@ -75,7 +75,7 @@ These become `Error:` tool responses upstream; renderer uses notebook path + for
## 3) Kernel session semantics (where they actually exist)
Kernel semantics are implemented in `executePython` / `PythonKernel` and apply to the `python` tool.
Kernel semantics are implemented in `executePython` / `PythonKernel` and apply to the Python backend of the `eval` tool.
## Modes
@@ -93,7 +93,7 @@ Kernel semantics are implemented in `executePython` / `PythonKernel` and apply t
## Reset behavior
`python` tool passes `reset` only for the first cell in a multi-cell call; later cells always run with `reset: false`.
`eval` passes `reset` only for the first cell in a multi-cell Python call; later cells always run with `reset: false`.
## Kernel death / restart / retry
@@ -170,7 +170,7 @@ Cancellation/timeout:
- truncation flag + counts
- artifact ID (for `artifact://<id>` references)
`python` tool converts this metadata into result truncation notices and TUI warnings.
`eval` converts this metadata into result truncation notices and TUI warnings.
`notebook` tool does **not** use `OutputSink`; it has no stream/artifact truncation pipeline because it does not execute code.
@@ -202,16 +202,16 @@ Kernel-backed execution rendering expects:
This renderer behavior is unrelated to `notebook` JSON editing results except that both reuse shared TUI primitives.
## 8) Divergence from plain Python tool behavior
## 8) Divergence from eval Python backend behavior
If "plain Python tool" means `python` execution path:
If "plain Python execution" means the `eval` tool with `language: "python"`:
- `python` executes code in a kernel, persists state by mode, streams chunks, captures rich displays, handles interrupts/timeouts, and supports output truncation/artifacts.
- `eval` executes code in a kernel, persists state by mode, streams chunks, captures rich displays, handles interrupts/timeouts, and supports output truncation/artifacts.
- `notebook` performs deterministic notebook JSON mutations only; no execution, no kernel state, no chunk stream, no display outputs, no artifact pipeline.
If a workflow needs both:
1. edit notebook source with `notebook`
2. execute code cells via `python` (manually passing code), not through `notebook`
2. execute code cells via `eval` with `language: "python"` (manually passing code), not through `notebook`
Current implementation does not provide a single tool that both mutates `.ipynb` and executes notebook cells through kernel context.
+24 -24
View File
@@ -1,20 +1,20 @@
# Python Tool and IPython Runtime
# Eval Tool Python Backend and IPython Runtime
This document describes the current Python execution stack in `packages/coding-agent`.
It covers tool behavior, kernel/gateway lifecycle, environment handling, execution semantics, output rendering, and operational failure modes.
## Scope and Key Files
- Tool surface: `src/tools/python.ts`
- Session/per-call kernel orchestration: `src/ipy/executor.ts`
- Kernel protocol + gateway integration: `src/ipy/kernel.ts`
- Shared local gateway coordinator: `src/ipy/gateway-coordinator.ts`
- Interactive-mode renderer for user-triggered Python runs: `src/modes/components/python-execution.ts`
- Runtime/env filtering and Python resolution: `src/ipy/runtime.ts`
- Tool surface: `src/tools/eval.ts`
- Session/per-call kernel orchestration: `src/eval/py/executor.ts`
- Kernel protocol + gateway integration: `src/eval/py/kernel.ts`
- Shared local gateway coordinator: `src/eval/py/gateway-coordinator.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 the Python tool is
## What eval's Python backend is
The `python` tool executes one or more Python cells through a Jupyter Kernel Gateway-backed kernel (not by spawning `python -c` directly per cell).
The `eval` tool executes one or more Python cells through a Jupyter Kernel Gateway-backed kernel when `language: "python"` is selected or inferred (not by spawning `python -c` directly per cell).
Tool params:
@@ -22,7 +22,7 @@ Tool params:
{
cells: Array<{ code: string; title?: string }>;
timeout?: number; // seconds, clamped to 1..600, default 30
reset?: boolean; // reset kernel before first cell only
reset?: boolean; // reset selected runtime before the first cell only
}
```
@@ -146,25 +146,25 @@ Kernel startup receives the optional session file path from the executor:
## Tool availability and mode selection
`python.toolMode` (default `both`) + optional `PI_PY` override controls exposure:
`eval.py` / `eval.js` (both default `true`) plus optional `PI_PY` override controls eval backend exposure:
- `ipy-only`
- `bash-only`
- `both`
- Python backend only (`eval.py=true`, `eval.js=false`)
- JavaScript backend only (`eval.py=false`, `eval.js=true`)
- both backends
`PI_PY` accepted values:
- `0` / `bash` -> `bash-only`
- `1` / `py` -> `ipy-only`
- `mix` / `both` -> `both`
- `0` / `bash` -> JavaScript backend only
- `1` / `py` -> Python backend only
- `mix` / `both` -> both backends
If Python preflight fails, tool creation degrades to bash-only for that session.
If Python preflight fails and `eval.js` is enabled, `eval` remains available and dispatches to JavaScript unless `language: "python"` is explicitly requested.
## Execution flow and cancellation/timeout
### Tool-level timeout
`python` tool timeout is in seconds, default 30, clamped to `1..600`.
`eval` timeout is in seconds, default 30, clamped to `1..600`.
The tool combines:
@@ -224,11 +224,11 @@ Tool results can include truncation metadata and `artifact://<id>` for full outp
### Renderer behavior
- Tool renderer (`python.ts`):
- 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 (`python-execution.ts`):
- 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
@@ -253,9 +253,9 @@ Behavior differences from local shared gateway:
## Operational troubleshooting (current failure modes)
- **Python tool not available**
- Check `python.toolMode` / `PI_PY`.
- If preflight fails, runtime falls back to bash-only.
- **Python backend not available**
- Check `eval.py`, Python kernel dependencies, and `PI_PY`.
- If preflight fails and `eval.js` is enabled, omit `language` or pass `language: "js"` to use JavaScript.
- **Kernel availability errors**
- Local mode requires both `kernel_gateway` and `ipykernel` importable in resolved Python runtime.
+10
View File
@@ -1,9 +1,17 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Removed the built-in `python` tool in favor of `eval`, so tool allowlists and tool-call handlers referencing `python` need to migrate
- Removed the `python.toolMode` setting and replaced mode control with separate `eval.py` and `eval.js` toggles
- Changed the tool runtime config surface by migrating `python` execution timeout/export behavior to `eval` and replacing `./ipy/*` internal exports with `./eval/*` paths
- Changed the `eval` tool wire format to a single `input` string with `=== CELL ===` sections, fenced language selection, per-cell header timeouts, and `=== RESET ===` directives instead of top-level `cells`, `language`, `timeout`, and `reset` fields
### Added
- Added a JavaScript backend to the `eval` tool with an in-process VM runtime and JS helper bridge (`read`, `write`, `glob`, etc.)
- Added `eval.py` and `eval.js` settings so Python and JavaScript `eval` backends can be enabled or disabled independently
- Added `rename_file` action to the Lsp tool to rename files and directories with LSP `workspace/willRenameFiles` and `workspace/didRenameFiles` flow, applying returned workspace edits before moving files
- Added `apply: false` preview mode for `rename_file` so users can see planned LSP edits without performing filesystem changes
- Added `request` action to invoke arbitrary LSP methods, with automatic `textDocument`/`position` parameter construction from `file`/`line`/`symbol` and support for explicit JSON `payload`
@@ -11,6 +19,8 @@
### Changed
- Changed execution/tool discovery flow so `exec` maps to `eval` when any `eval` backend is enabled, while `bash` stays independently available
- Changed `eval` dispatch to automatically fall back to JavaScript when Python is unavailable and JavaScript backend is enabled
- Parallelized plugin root preloading with other startup initialization in `runRootCommand` to reduce startup latency
- Parallelized session bootstrap work in `createAgentSession`, including AGENTS.md scanning, context discovery, prompt template loading, slash command loading, and skill discovery, to reduce time to first available session
+14 -14
View File
@@ -17,7 +17,7 @@ src/
├── mcp/ # MCP transport/manager/loader/tool bridge
├── lsp/ # language server client/runtime integration
├── internal-urls/ # protocol router + handlers (agent://, docs://, rule://, ...)
├── exec/ ipy/ ssh/ # execution backends (shell, python, ssh)
├── exec/ eval/ ssh/ # execution backends (shell, eval runtimes, ssh)
├── web/ # search providers + domain scrapers
├── patch/ # edit/patch parser + applicator + diff utilities
└── config/ utils/ tui/ # settings, helpers, low-level TUI primitives
@@ -99,7 +99,7 @@ createAgentSession(...)
- **Core runtime/session APIs**: `createAgentSession`, `AgentSession`, `SessionManager`, prompt/compaction/session types.
- **Mode APIs**: `InteractiveMode`, `runPrintMode`, `runRpcMode`, `RpcClient` and RPC event/types.
- **Discovery + tool constructors**: `discoverAuthStorage`, `discoverExtensions`, `discoverMCPServers`, `createTools`, built-in tool classes (`ReadTool`, `WriteTool`, `BashTool`, `PythonTool`, `FindTool`, `GrepTool`, `EditTool`).
- **Discovery + tool constructors**: `discoverAuthStorage`, `discoverExtensions`, `discoverMCPServers`, `createTools`, built-in tool classes (`ReadTool`, `WriteTool`, `BashTool`, `EvalTool`, `FindTool`, `GrepTool`, `EditTool`).
- **Extensibility interfaces**: extension/custom-command/custom-tool/skill/slash-command types and loaders.
- **UI/theming helpers**: TUI components plus `initTheme`, `Theme`, and code-highlighting/theme utilities.
- **CLI callable export**: `main` is re-exported for embedding/integration contexts that invoke root behavior explicitly.
@@ -390,7 +390,7 @@ A `ToolFactory` is `(session: ToolSession) => Tool | null | Promise<Tool | null>
`createTools(session, toolNames?)` is the entry point. It:
1. Normalizes requested tool names (`toolNames`) and always injects `exit_plan_mode`.
2. Resolves Python mode via `PI_PY` override (`getPythonModeFromEnv()`) or `session.settings.get("python.toolMode")`.
2. Resolves eval backend allowance via `PI_PY` override (`getEvalBackendsFromEnv()`) or `eval.py` / `eval.js` settings.
3. Performs Python kernel preflight/warmup when applicable (`checkPythonKernelAvailability`, `warmPythonEnvironment`).
4. Computes effective gating (`isToolAllowed`) from settings and runtime state:
- feature toggles (`find.enabled`, `grep.enabled`, etc.)
@@ -741,10 +741,10 @@ This keeps URL protocol resolution centralized and pluggable while keeping proto
### ASCII overview
```text
Tool adapters (tools/bash.ts, tools/python.ts, tools/ssh.ts)
Tool adapters (tools/bash.ts, tools/eval.ts, tools/ssh.ts)
│ schema + validation + onUpdate + error policy
▼
Executors (exec/bash-executor.ts, ipy/executor.ts, ssh/ssh-executor.ts)
Executors (exec/bash-executor.ts, eval/py/executor.ts, ssh/ssh-executor.ts)
│ process/kernel/session lifecycle
▼
OutputSink
@@ -755,8 +755,8 @@ Executors (exec/bash-executor.ts, ipy/executor.ts, ssh/ssh-executor.ts)
This subsystem is split into two layers:
- **Core executors** (`src/exec/bash-executor.ts`, `src/ipy/executor.ts`, `src/ssh/ssh-executor.ts`) own process/kernel lifecycle and raw output capture.
- **Tool adapters** (`src/tools/bash.ts`, `src/tools/python.ts`) own tool schemas, argument normalization, UX-facing updates, and error policy for agent tool calls.
- **Core executors** (`src/exec/bash-executor.ts`, `src/eval/py/executor.ts`, `src/ssh/ssh-executor.ts`) own process/kernel lifecycle and raw output capture.
- **Tool adapters** (`src/tools/bash.ts`, `src/tools/eval.ts`) own tool schemas, argument normalization, UX-facing updates, and error policy for agent tool calls.
### Core executor responsibilities
@@ -772,7 +772,7 @@ This subsystem is split into two layers:
- `output`, `truncated`, `totalLines/totalBytes`, `outputLines/outputBytes`, optional `artifactId`
- `exitCode` / `cancelled` states for normal completion, timeout, and abort.
#### Python executor (`src/ipy/executor.ts`)
#### Python executor (`src/eval/py/executor.ts`)
- Entry points:
- `executePython(code, options)`
@@ -819,11 +819,11 @@ This subsystem is split into two layers:
- converts backend cancellation/timeout/exit status into `ToolAbortError` / `ToolError`
- returns `toolResult(...).text(...).truncationFromSummary(...)`.
#### Python tool (`src/tools/python.ts`)
#### Eval tool (`src/tools/eval.ts`)
- Adapter class: `PythonTool implements AgentTool<typeof pythonSchema>`.
- Defines schema (`cells[]`, `timeout`, `cwd`, `reset`) and dynamic description from prelude docs (`getPythonToolDescription`, `getPreludeDocs`).
- Supports proxy mode via `PythonProxyExecutor`; otherwise executes locally.
- Adapter class: `EvalTool implements AgentTool<typeof evalSchema>`.
- Defines schema (`cells[]`, `language`, `timeout`, `reset`) and a static description from `prompts/tools/eval.md` (`getEvalToolDescription`).
- Supports proxy mode via `EvalProxyExecutor`; otherwise executes locally.
- Per-call adaptation:
- validates and resolves working dir
- clamps timeout and combines abort signals (`AbortSignal.any`)
@@ -923,7 +923,7 @@ What _is_ isolated is execution context and artifacts, not process memory:
- Adds `task` tool automatically when `agent.spawns` is set and recursion depth permits.
- Removes `task` when max recursion depth is reached (`task.maxRecursionDepth`).
- Expands legacy `exec` alias into `python` and/or `bash` based on `python.toolMode`.
- Expands legacy `exec` alias into `eval` when any eval backend is enabled, and always includes `bash`.
- Forces `requireYieldTool: true` in `createAgentSession(...)`.
- Filters parent-owned tools out of child tools (`todo_write` is removed).
@@ -1142,7 +1142,7 @@ Notes from current behavior:
- `createTools()` always injects `exit_plan_mode` when `toolNames` are specified.
- `resolve` is included only when at least one active tool is marked `deferrable: true` (built-in or extension/custom).
- `yield` is force-added when `session.requireYieldTool === true`.
- Python/Bash availability is mode-driven (`PI_PY`, `python.toolMode`) and can auto-fallback to bash.
- Eval availability is mode-driven (`PI_PY`, `eval.py`, `eval.js`); eval falls back to JavaScript when Python is unavailable and JavaScript is enabled. The standalone `bash` tool is always available.
### Playbook: add an RPC command
+11 -3
View File
@@ -319,9 +319,17 @@
"types": "./src/internal-urls/*.ts",
"import": "./src/internal-urls/*.ts"
},
"./ipy/*": {
"types": "./src/ipy/*.ts",
"import": "./src/ipy/*.ts"
"./eval": {
"types": "./src/eval/index.ts",
"import": "./src/eval/index.ts"
},
"./eval/js/*": {
"types": "./src/eval/js/*.ts",
"import": "./src/eval/js/*.ts"
},
"./eval/py/*": {
"types": "./src/eval/py/*.ts",
"import": "./src/eval/py/*.ts"
},
"./lsp": {
"types": "./src/lsp/index.ts",
+1 -1
View File
@@ -6,7 +6,7 @@
import { APP_NAME } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { getGatewayStatus, shutdownSharedGateway } from "../ipy/gateway-coordinator";
import { getGatewayStatus, shutdownSharedGateway } from "../eval/py/gateway-coordinator";
export type JupyterAction = "kill" | "status";
@@ -1137,14 +1137,28 @@ export const SETTINGS_SCHEMA = {
},
},
// Python
"python.toolMode": {
type: "enum",
values: ["ipy-only", "bash-only", "both"] as const,
default: "both",
ui: { tab: "editing", label: "Python Tool Mode", description: "How Python code is executed" },
// Eval (per-backend toggles; add more as new backends ship, e.g. eval.ts)
"eval.py": {
type: "boolean",
default: true,
ui: {
tab: "editing",
label: "Eval: Python backend",
description: "Allow the eval tool to dispatch to the IPython kernel",
},
},
"eval.js": {
type: "boolean",
default: true,
ui: {
tab: "editing",
label: "Eval: JavaScript backend",
description: "Allow the eval tool to dispatch to the in-process JavaScript runtime",
},
},
// Python kernel knobs (consumed by the eval py backend and the /python slash command)
"python.kernelMode": {
type: "enum",
values: ["session", "per-call"] as const,
+49
View File
@@ -0,0 +1,49 @@
import type { ToolSession } from "../tools";
import type { EvalDisplayOutput, EvalLanguage } from "./types";
/** Per-call options shared across warm and execute. */
export interface ExecutorBackendCallOptions {
cwd: string;
sessionId: string;
sessionFile: string | undefined;
kernelOwnerId: string | undefined;
signal?: AbortSignal;
session: ToolSession;
}
/** Per-cell execute() options. */
export interface ExecutorBackendExecOptions extends ExecutorBackendCallOptions {
deadlineMs: number;
reset: boolean;
artifactPath: string | undefined;
artifactId: string | undefined;
onChunk: (chunk: string) => void;
}
/** Result returned by a backend's execute(). */
export interface ExecutorBackendResult {
output: string;
exitCode: number | undefined;
cancelled: boolean;
truncated: boolean;
artifactId: string | undefined;
totalLines: number;
totalBytes: number;
outputLines: number;
outputBytes: number;
displayOutputs: EvalDisplayOutput[];
}
/** Pluggable language backend for the eval tool. */
export interface ExecutorBackend {
readonly id: EvalLanguage;
readonly label: string;
/** Source language identifier passed to the syntax highlighter (e.g. "python", "javascript"). */
readonly highlightLang: string;
/** Cheap availability check (no full warmup). Used by fallback resolution. */
isAvailable(session: ToolSession): Promise<boolean>;
/** Optional pre-warm performed once per session (no-op on backends that don't need it). */
warm?(opts: ExecutorBackendCallOptions): Promise<{ ok: boolean; reason?: string }>;
/** Execute one cell. Caller invokes once per cell and aggregates results. */
execute(code: string, opts: ExecutorBackendExecOptions): Promise<ExecutorBackendResult>;
}
+43
View File
@@ -0,0 +1,43 @@
%import common.LF
%import common.WS_INLINE
// Strict canonical surface for the eval tool. Callers MUST emit exactly this
// form. The runtime parser accepts additional lenient shapes (positional
// title/duration, alias keys, long-form lang tokens, mixed casing, fence
// runs of any length ≥ 3, etc.) but those are fallback only and MUST NOT
// be relied on.
//
// Each cell is a fenced code block opened and closed by exactly three
// (or exactly five) backticks or tildes — five lets callers nest a 3-char
// fence inside a cell verbatim. The opening fence carries an optional info
// string with up to four parts, IN THIS ORDER:
//
// lang? id_attr? t_attr? rst_attr?
//
// where:
// lang = "py" | "js" | "ts"
// id_attr = id="..." (double-quoted cell id)
// t_attr = t=<duration> (bare integer with optional ms/s/m unit)
// rst_attr= rst=0|1 (per-language kernel reset for this cell)
start: cell+
cell: backtick_cell | tilde_cell
backtick_cell: BACKTICKS info? LF code_line* BACKTICKS LF
tilde_cell: TILDES info? LF code_line* TILDES LF
info: lang (WS_INLINE id_attr)? (WS_INLINE t_attr)? (WS_INLINE rst_attr)?
| id_attr (WS_INLINE t_attr)? (WS_INLINE rst_attr)?
| t_attr (WS_INLINE rst_attr)?
| rst_attr
lang: "py" | "js" | "ts"
id_attr: "id=" /"[^"\r\n]*"/
t_attr: "t=" /\d+(ms|s|m)?/
rst_attr: "rst=" /[01]/
code_line: /[^\r\n]*/ LF
BACKTICKS: "```" | "`````"
TILDES: "~~~" | "~~~~~"
+5
View File
@@ -0,0 +1,5 @@
export * from "./backend";
export { default as jsBackend } from "./js";
export * from "./parse";
export { default as pythonBackend } from "./py";
export * from "./types";
@@ -0,0 +1,717 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as util from "node:util";
import * as vm from "node:vm";
import * as Diff from "diff";
import type { ToolSession } from "../../tools";
import { ToolError } from "../../tools/tool-errors";
import { JAVASCRIPT_PRELUDE_SOURCE } from "./prelude";
import { callSessionTool, type JsStatusEvent } from "./tool-bridge";
export type JsDisplayOutput =
| { type: "json"; data: unknown }
| { type: "image"; data: string; mimeType: string }
| { type: "status"; event: JsStatusEvent };
export interface VmRunState {
signal?: AbortSignal;
onText?: (chunk: string) => void;
onDisplay?: (output: JsDisplayOutput) => void;
}
interface VmHelperOptions {
path?: string;
hidden?: boolean;
maxDepth?: number;
ignoreCase?: boolean;
literal?: boolean;
limit?: number;
offset?: number;
globPattern?: string;
flags?: string;
reverse?: boolean;
unique?: boolean;
count?: boolean;
cwd?: string;
timeoutMs?: number;
timeout?: number;
}
interface VmContextState {
sessionKey: string;
cwd: string;
sessionId: string;
session: ToolSession;
context: vm.Context;
env: Map<string, string>;
timers: Set<NodeJS.Timeout>;
intervals: Set<NodeJS.Timeout>;
currentRun?: VmRunState;
queue: Promise<void>;
}
const vmContexts = new Map<string, VmContextState>();
const utf8Encoder = new TextEncoder();
function getMergedEnv(state: VmContextState): Record<string, string> {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(Bun.env)) {
if (typeof value === "string") {
env[key] = value;
}
}
for (const [key, value] of state.env) {
env[key] = value;
}
return env;
}
function resolvePath(state: VmContextState, value: string): string {
if (value.includes("://")) {
throw new ToolError(`Protocol paths are not supported by this helper: ${value}`);
}
return path.isAbsolute(value) ? path.normalize(value) : path.join(state.cwd, value);
}
async function resolveRegularFile(
state: VmContextState,
rawPath: string,
): Promise<{ filePath: string; file: Bun.BunFile; size: number }> {
const filePath = resolvePath(state, rawPath);
const file = Bun.file(filePath);
const info = await file.stat().catch(() => undefined);
if (!info) {
throw new ToolError(`File not found: ${filePath}`);
}
if (info.isDirectory()) {
throw new ToolError(`Directory paths are not supported by this helper: ${filePath}`);
}
return { filePath, file, size: info.size };
}
function getDataSize(data: string | Blob | ArrayBuffer | ArrayBufferView): number {
if (typeof data === "string") {
return utf8Encoder.encode(data).byteLength;
}
if (data instanceof Blob) {
return data.size;
}
if (data instanceof ArrayBuffer) {
return data.byteLength;
}
return data.byteLength;
}
function isWriteData(value: unknown): value is string | Blob | ArrayBuffer | ArrayBufferView {
return (
typeof value === "string" || value instanceof Blob || value instanceof ArrayBuffer || ArrayBuffer.isView(value)
);
}
function emitText(state: VmContextState, text: string): void {
if (!text) return;
state.currentRun?.onText?.(text.endsWith("\n") ? text : `${text}\n`);
}
function emitStatus(state: VmContextState, event: JsStatusEvent): void {
state.currentRun?.onDisplay?.({ type: "status", event });
}
function displayValue(state: VmContextState, value: unknown): void {
if (value === undefined) return;
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
if (record.type === "image" && typeof record.data === "string" && typeof record.mimeType === "string") {
state.currentRun?.onDisplay?.({
type: "image",
data: record.data,
mimeType: record.mimeType,
});
return;
}
state.currentRun?.onDisplay?.({
type: "json",
data: structuredClone(value),
});
return;
}
emitText(state, String(value));
}
function formatConsoleArgs(args: unknown[]): string {
return args
.map(arg => (typeof arg === "string" ? arg : util.inspect(arg, { depth: 6, colors: false, breakLength: 120 })))
.join(" ");
}
function createTrackedTimeout(state: VmContextState, repeat: boolean) {
return (callback: (...args: unknown[]) => void, delay?: number, ...args: unknown[]) => {
const fn = () => callback(...args);
const timer = repeat ? setInterval(fn, delay) : setTimeout(fn, delay);
if (repeat) {
state.intervals.add(timer);
} else {
state.timers.add(timer);
}
return timer;
};
}
function clearTrackedTimeout(state: VmContextState, repeat: boolean, timer: NodeJS.Timeout | undefined): void {
if (!timer) return;
if (repeat) {
clearInterval(timer);
state.intervals.delete(timer);
return;
}
clearTimeout(timer);
state.timers.delete(timer);
}
async function listFiles(
state: VmContextState,
pattern: string,
searchPath: string,
options: VmHelperOptions,
): Promise<string[]> {
const resolved = resolvePath(state, searchPath);
const hasRecursivePattern = pattern.includes("**");
const normalizedPattern = hasRecursivePattern ? pattern : `**/${pattern}`;
const matches = await Array.fromAsync(
new Bun.Glob(normalizedPattern).scan({
cwd: resolved,
dot: options.hidden ?? false,
absolute: true,
onlyFiles: false,
}),
);
const limited = matches.slice(0, options.limit ?? 1000).map(match => path.normalize(match));
return limited.sort();
}
async function grepFile(
filePath: string,
pattern: string,
options: VmHelperOptions,
): Promise<Array<{ line: number; text: string }>> {
const content = await Bun.file(filePath).text();
const lines = content.split(/\r?\n/);
const matcher = options.literal
? (line: string) =>
options.ignoreCase ? line.toLowerCase().includes(pattern.toLowerCase()) : line.includes(pattern)
: (line: string) => new RegExp(pattern, options.flags ?? (options.ignoreCase ? "i" : "")).test(line);
const hits: Array<{ line: number; text: string }> = [];
for (let index = 0; index < lines.length; index++) {
if (matcher(lines[index])) {
hits.push({ line: index + 1, text: lines[index] });
if (hits.length >= (options.limit ?? 200)) {
break;
}
}
}
return hits;
}
async function createHelpers(state: VmContextState) {
return {
read: async (rawPath: string, options: VmHelperOptions = {}): Promise<string> => {
const { filePath, file, size } = await resolveRegularFile(state, rawPath);
let text = await file.text();
const offset = typeof options.offset === "number" ? options.offset : 1;
const limit = typeof options.limit === "number" ? options.limit : undefined;
if (offset > 1 || limit !== undefined) {
const lines = text.split(/\r?\n/);
const start = Math.max(0, offset - 1);
const end = limit !== undefined ? start + limit : lines.length;
text = lines.slice(start, end).join("\n");
}
emitStatus(state, { op: "read", path: filePath, bytes: size, chars: text.length });
return text;
},
writeFile: async (rawPath: string, data: unknown): Promise<string> => {
if (!isWriteData(data)) {
throw new ToolError("write() expects string, Blob, ArrayBuffer, or TypedArray data");
}
const filePath = resolvePath(state, rawPath);
if (typeof data === "string" || data instanceof Blob || data instanceof ArrayBuffer) {
await Bun.write(filePath, data);
} else {
await Bun.write(filePath, new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
}
emitStatus(state, { op: "write", path: filePath, bytes: getDataSize(data) });
return filePath;
},
append: async (rawPath: string, content: string): Promise<string> => {
const target = resolvePath(state, rawPath);
await Bun.write(
target,
`${await Bun.file(target)
.text()
.catch(() => "")}${content}`,
);
emitStatus(state, {
op: "append",
path: target,
chars: content.length,
bytes: utf8Encoder.encode(content).byteLength,
});
return target;
},
stat: async (
rawPath: string,
): Promise<{ path: string; size: number; is_file: boolean; is_dir: boolean; mtime: string }> => {
const target = resolvePath(state, rawPath);
const info = await Bun.file(target).stat();
const result = {
path: target,
size: info.size,
is_file: info.isFile(),
is_dir: info.isDirectory(),
mtime: new Date(info.mtimeMs).toISOString(),
};
emitStatus(state, { op: "stat", path: target, size: result.size, is_dir: result.is_dir, mtime: result.mtime });
return result;
},
find: async (pattern: string, searchPath = ".", options: VmHelperOptions = {}): Promise<string[]> => {
const matches = await listFiles(state, pattern, searchPath, options);
emitStatus(state, {
op: "find",
pattern,
path: resolvePath(state, searchPath),
count: matches.length,
matches: matches.slice(0, 20),
});
return matches;
},
glob: async (pattern: string, searchPath = ".", options: VmHelperOptions = {}): Promise<string[]> => {
const resolved = resolvePath(state, searchPath);
const matches = await Array.fromAsync(
new Bun.Glob(pattern).scan({
cwd: resolved,
dot: options.hidden ?? false,
absolute: true,
onlyFiles: false,
}),
);
const limited = matches
.slice(0, options.limit ?? 1000)
.map(match => path.normalize(match))
.sort();
emitStatus(state, {
op: "glob",
pattern,
path: resolved,
count: limited.length,
matches: limited.slice(0, 20),
});
return limited;
},
grep: async (
pattern: string,
rawPath: string,
options: VmHelperOptions = {},
): Promise<Array<{ line: number; text: string }>> => {
const filePath = resolvePath(state, rawPath);
const hits = await grepFile(filePath, pattern, options);
emitStatus(state, { op: "grep", pattern, path: filePath, count: hits.length, hits: hits.slice(0, 10) });
return hits;
},
rgrep: async (
pattern: string,
searchPath = ".",
options: VmHelperOptions = {},
): Promise<Array<{ file: string; line: number; text: string }>> => {
const files = await listFiles(state, options.globPattern ?? "*", searchPath, {
...options,
limit: options.limit ?? 100,
});
const hits: Array<{ file: string; line: number; text: string }> = [];
for (const file of files) {
const fileStat = await Bun.file(file)
.stat()
.catch(() => undefined);
if (!fileStat || fileStat.isDirectory()) continue;
for (const hit of await grepFile(file, pattern, options)) {
hits.push({ file, line: hit.line, text: hit.text });
if (hits.length >= (options.limit ?? 100)) {
emitStatus(state, {
op: "rgrep",
pattern,
path: resolvePath(state, searchPath),
count: hits.length,
hits: hits.slice(0, 10),
});
return hits;
}
}
}
emitStatus(state, {
op: "rgrep",
pattern,
path: resolvePath(state, searchPath),
count: hits.length,
hits: hits.slice(0, 10),
});
return hits;
},
sortText: (text: string, options: VmHelperOptions = {}): string => {
const lines = String(text).split(/\r?\n/);
const deduped = options.unique ? Array.from(new Set(lines)) : lines;
const sorted = deduped.sort((a, b) => a.localeCompare(b));
if (options.reverse) {
sorted.reverse();
}
const result = sorted.join("\n");
emitStatus(state, {
op: "sort",
lines: sorted.length,
reverse: options.reverse === true,
unique: options.unique === true,
});
return result;
},
uniqText: (text: string, options: VmHelperOptions = {}): string | Array<[number, string]> => {
const lines = String(text)
.split(/\r?\n/)
.filter(line => line.length > 0);
const groups: Array<[number, string]> = [];
for (const line of lines) {
const last = groups.at(-1);
if (last && last[1] === line) {
last[0] += 1;
continue;
}
groups.push([1, line]);
}
emitStatus(state, { op: "uniq", groups: groups.length, count_mode: options.count === true });
if (options.count) {
return groups;
}
return groups.map(([, line]) => line).join("\n");
},
counter: (items: string | string[], options: VmHelperOptions = {}): Array<[number, string]> => {
const values = Array.isArray(items) ? items : String(items).split(/\r?\n/).filter(Boolean);
const counts = new Map<string, number>();
for (const item of values) {
counts.set(item, (counts.get(item) ?? 0) + 1);
}
const entries = Array.from(counts.entries())
.map(([item, count]) => [count, item] as [number, string])
.sort((a, b) => (options.reverse === false ? a[0] - b[0] : b[0] - a[0]) || a[1].localeCompare(b[1]));
const limited = entries.slice(0, options.limit ?? entries.length);
emitStatus(state, { op: "counter", unique: counts.size, total: values.length, top: limited.slice(0, 10) });
return limited;
},
sed: async (
rawPath: string,
pattern: string,
replacement: string,
options: VmHelperOptions = {},
): Promise<number> => {
const filePath = resolvePath(state, rawPath);
const content = await Bun.file(filePath).text();
const regex = new RegExp(pattern, options.flags ?? "g");
const matches = content.match(regex);
const updated = content.replace(regex, replacement);
await Bun.write(filePath, updated);
const count = matches?.length ?? 0;
emitStatus(state, { op: "sed", path: filePath, count });
return count;
},
diff: async (rawA: string, rawB: string): Promise<string> => {
const fileA = resolvePath(state, rawA);
const fileB = resolvePath(state, rawB);
const [a, b] = await Promise.all([Bun.file(fileA).text(), Bun.file(fileB).text()]);
const result = Diff.createTwoFilesPatch(fileA, fileB, a, b, "", "", { context: 3 });
emitStatus(state, {
op: "diff",
file_a: fileA,
file_b: fileB,
identical: a === b,
preview: result.slice(0, 500),
});
return result;
},
tree: async (searchPath = ".", options: VmHelperOptions = {}): Promise<string> => {
const root = resolvePath(state, searchPath);
const maxDepth = options.maxDepth ?? 3;
const showHidden = options.hidden ?? false;
const lines: string[] = [`${root}/`];
let entryCount = 0;
const walk = async (dir: string, prefix: string, depth: number): Promise<void> => {
if (depth > maxDepth) return;
const entries = (await fs.promises.readdir(dir, { withFileTypes: true }))
.filter(entry => showHidden || !entry.name.startsWith("."))
.sort((a, b) => a.name.localeCompare(b.name));
for (let index = 0; index < entries.length; index++) {
const entry = entries[index];
const isLast = index === entries.length - 1;
const connector = isLast ? "└── " : "├── ";
const suffix = entry.isDirectory() ? "/" : "";
lines.push(`${prefix}${connector}${entry.name}${suffix}`);
entryCount += 1;
if (entry.isDirectory()) {
await walk(path.join(dir, entry.name), `${prefix}${isLast ? " " : "│ "}`, depth + 1);
}
}
};
await walk(root, "", 1);
const result = lines.join("\n");
emitStatus(state, { op: "tree", path: root, entries: entryCount, preview: result.slice(0, 1000) });
return result;
},
run: async (
command: string,
options: VmHelperOptions = {},
): Promise<{ stdout: string; stderr: string; exit_code: number }> => {
const cwd = options.cwd ? resolvePath(state, options.cwd) : state.cwd;
const timeoutMs =
typeof options.timeoutMs === "number"
? options.timeoutMs
: typeof options.timeout === "number"
? options.timeout * 1000
: undefined;
const timeoutSignal =
typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
? AbortSignal.timeout(timeoutMs)
: undefined;
const signal =
state.currentRun?.signal && timeoutSignal
? AbortSignal.any([state.currentRun.signal, timeoutSignal])
: (state.currentRun?.signal ?? timeoutSignal);
const child = Bun.spawn(["bash", "-lc", command], {
cwd,
env: getMergedEnv(state),
stdout: "pipe",
stderr: "pipe",
signal,
});
const [stdout, stderr, exit_code] = await Promise.all([
new Response(child.stdout as ReadableStream<Uint8Array>).text(),
new Response(child.stderr as ReadableStream<Uint8Array>).text(),
child.exited,
]);
const output = `${stdout}${stderr}`.slice(0, 500);
emitStatus(state, { op: "run", cmd: command.slice(0, 120), code: exit_code, output });
return { stdout, stderr, exit_code };
},
env: (key?: string, value?: string): string | Record<string, string> | undefined => {
if (!key) {
const env = Object.fromEntries(Object.entries(getMergedEnv(state)).sort(([a], [b]) => a.localeCompare(b)));
emitStatus(state, { op: "env", count: Object.keys(env).length, keys: Object.keys(env).slice(0, 20) });
return env;
}
if (value !== undefined) {
state.env.set(key, value);
emitStatus(state, { op: "env", key, value, action: "set" });
return value;
}
const result = state.env.get(key) ?? Bun.env[key];
emitStatus(state, { op: "env", key, value: result, action: "get" });
return result;
},
};
}
function createProcessSubset(cwd: string): Record<string, unknown> {
return Object.freeze({
arch: process.arch,
cwd: () => cwd,
platform: process.platform,
release: Object.freeze({ ...process.release }),
version: process.version,
versions: Object.freeze({ ...process.versions }),
});
}
async function createVmState(
sessionKey: string,
sessionId: string,
cwd: string,
session: ToolSession,
): Promise<VmContextState> {
const state: VmContextState = {
sessionKey,
cwd,
sessionId,
session,
context: {} as vm.Context,
env: new Map(),
timers: new Set(),
intervals: new Set(),
queue: Promise.resolve(),
};
const helpers = await createHelpers(state);
const contextGlobals: Record<string, unknown> = {
__omp_session__: { cwd, sessionId },
__omp_helpers__: helpers,
__omp_call_tool__: async (name: string, args: unknown) =>
callSessionTool(name, args, {
session: state.session,
signal: state.currentRun?.signal,
emitStatus: event => emitStatus(state, event),
}),
__omp_emit_status__: (op: string, data: Record<string, unknown> = {}) => emitStatus(state, { op, ...data }),
__omp_log__: (level: string, ...args: unknown[]) => {
const prefix = level === "error" ? "[error] " : level === "warn" ? "[warn] " : "";
emitText(state, `${prefix}${formatConsoleArgs(args)}`);
},
__omp_display__: (value: unknown) => displayValue(state, value),
setTimeout: createTrackedTimeout(state, false),
setInterval: createTrackedTimeout(state, true),
clearTimeout: (timer?: NodeJS.Timeout) => clearTrackedTimeout(state, false, timer),
clearInterval: (timer?: NodeJS.Timeout) => clearTrackedTimeout(state, true, timer),
queueMicrotask,
URL,
URLSearchParams,
TextEncoder,
TextDecoder,
AbortController,
AbortSignal,
structuredClone,
crypto,
webcrypto: crypto,
performance,
atob,
btoa,
Buffer,
process: createProcessSubset(cwd),
fs,
fetch,
Blob,
File,
Headers,
Request,
Response,
globalThis: undefined,
};
const context = vm.createContext(contextGlobals);
context.globalThis = context;
state.context = context;
vm.runInContext(JAVASCRIPT_PRELUDE_SOURCE, context, { filename: "js-prelude.js" });
return state;
}
async function getOrCreateVmState(
sessionKey: string,
sessionId: string,
cwd: string,
session: ToolSession,
): Promise<VmContextState> {
const existing = vmContexts.get(sessionKey);
if (existing) {
existing.cwd = cwd;
existing.sessionId = sessionId;
existing.session = session;
return existing;
}
const created = await createVmState(sessionKey, sessionId, cwd, session);
vmContexts.set(sessionKey, created);
return created;
}
async function disposeState(state: VmContextState): Promise<void> {
for (const timer of state.timers) {
clearTimeout(timer);
}
state.timers.clear();
for (const timer of state.intervals) {
clearInterval(timer);
}
state.intervals.clear();
state.currentRun = undefined;
}
async function runQueued<T>(state: VmContextState, work: () => Promise<T>): Promise<T> {
const previous = state.queue;
const { promise, resolve } = Promise.withResolvers<void>();
state.queue = promise;
await previous;
try {
return await work();
} finally {
resolve();
}
}
function wrapCode(code: string): { source: string; asyncWrapped: boolean } {
const needsAsyncWrapper = /\bawait\b|\breturn\b/.test(code);
if (!needsAsyncWrapper) {
return { source: code, asyncWrapped: false };
}
return {
source: `(async () => {\n${code}\n})()`,
asyncWrapped: true,
};
}
async function awaitMaybePromise<T>(value: T | Promise<T>, signal?: AbortSignal): Promise<T> {
if (!value || typeof value !== "object" || typeof (value as { then?: unknown }).then !== "function") {
return value;
}
const promised = value as Promise<T>;
if (!signal) {
return promised;
}
const { promise, resolve, reject } = Promise.withResolvers<T>();
if (signal.aborted) {
reject(signal.reason ?? new Error("Execution aborted"));
return promise;
}
const onAbort = () => reject(signal.reason ?? new Error("Execution aborted"));
signal.addEventListener("abort", onAbort, { once: true });
promised.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
return promise;
}
export async function executeInVmContext(options: {
sessionKey: string;
sessionId: string;
cwd: string;
session: ToolSession;
reset?: boolean;
code: string;
filename: string;
timeoutMs?: number;
runState: VmRunState;
}): Promise<{ value: unknown }> {
if (options.reset) {
await resetVmContext(options.sessionKey);
}
const state = await getOrCreateVmState(options.sessionKey, options.sessionId, options.cwd, options.session);
return runQueued(state, async () => {
state.currentRun = options.runState;
try {
if (options.runState.signal?.aborted) {
throw options.runState.signal.reason ?? new Error("Execution aborted");
}
const wrapped = wrapCode(options.code);
const value = vm.runInContext(wrapped.source, state.context, {
filename: options.filename,
timeout: options.timeoutMs,
});
const awaited = await awaitMaybePromise(value, options.runState.signal);
displayValue(state, awaited);
return { value: awaited };
} finally {
state.currentRun = undefined;
}
});
}
export async function resetVmContext(sessionKey: string): Promise<void> {
const existing = vmContexts.get(sessionKey);
if (!existing) return;
vmContexts.delete(sessionKey);
await disposeState(existing);
}
export async function disposeAllVmContexts(): Promise<void> {
const states = Array.from(vmContexts.values());
vmContexts.clear();
for (const state of states) {
await disposeState(state);
}
}
@@ -0,0 +1,131 @@
import { DEFAULT_MAX_BYTES, OutputSink } from "../../session/streaming-output";
import type { ToolSession } from "../../tools";
import { executeInVmContext, type JsDisplayOutput } from "./context-manager";
export interface JsExecutorOptions {
cwd?: string;
timeoutMs?: number;
deadlineMs?: number;
onChunk?: (chunk: string) => Promise<void> | void;
signal?: AbortSignal;
sessionId: string;
reset?: boolean;
sessionFile?: string;
artifactPath?: string;
artifactId?: string;
session: ToolSession;
}
export interface JsResult {
output: string;
exitCode: number | undefined;
cancelled: boolean;
truncated: boolean;
artifactId?: string;
totalLines: number;
totalBytes: number;
outputLines: number;
outputBytes: number;
displayOutputs: JsDisplayOutput[];
}
function getExecutionTimeoutMs(options: Pick<JsExecutorOptions, "deadlineMs" | "timeoutMs">): number | undefined {
if (options.deadlineMs !== undefined) {
return Math.max(1, options.deadlineMs - Date.now());
}
return options.timeoutMs;
}
function isAbortError(error: unknown): boolean {
return (
(error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) ||
(error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError"))
);
}
export async function executeJs(code: string, options: JsExecutorOptions): Promise<JsResult> {
const displayOutputs: JsDisplayOutput[] = [];
const outputSink = new OutputSink({
artifactPath: options.artifactPath,
artifactId: options.artifactId,
spillThreshold: DEFAULT_MAX_BYTES,
onChunk: chunk => options.onChunk?.(chunk),
});
const timeoutMs = getExecutionTimeoutMs(options);
const timeoutSignal =
typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
? AbortSignal.timeout(timeoutMs)
: undefined;
const signal =
options.signal && timeoutSignal
? AbortSignal.any([options.signal, timeoutSignal])
: (options.signal ?? timeoutSignal);
try {
await executeInVmContext({
sessionKey: options.sessionId,
sessionId: options.sessionId,
cwd: options.cwd ?? options.session.cwd,
session: options.session,
reset: options.reset,
code,
filename: `js-cell-${crypto.randomUUID()}.js`,
timeoutMs,
runState: {
signal,
onText: chunk => outputSink.push(chunk),
onDisplay: output => {
displayOutputs.push(output);
},
},
});
const summary = await outputSink.dump();
return {
output: summary.output,
exitCode: 0,
cancelled: false,
truncated: summary.truncated,
artifactId: summary.artifactId,
totalLines: summary.totalLines,
totalBytes: summary.totalBytes,
outputLines: summary.outputLines,
outputBytes: summary.outputBytes,
displayOutputs,
};
} catch (error) {
if (signal?.aborted || isAbortError(error)) {
const timeoutReason = timeoutSignal?.aborted ? "Command timed out" : "";
if (timeoutReason) {
outputSink.push(timeoutReason);
}
const summary = await outputSink.dump();
return {
output: summary.output,
exitCode: undefined,
cancelled: true,
truncated: summary.truncated,
artifactId: summary.artifactId,
totalLines: summary.totalLines,
totalBytes: summary.totalBytes,
outputLines: summary.outputLines,
outputBytes: summary.outputBytes,
displayOutputs,
};
}
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
outputSink.push(message);
const summary = await outputSink.dump();
return {
output: summary.output,
exitCode: 1,
cancelled: false,
truncated: summary.truncated,
artifactId: summary.artifactId,
totalLines: summary.totalLines,
totalBytes: summary.totalBytes,
outputLines: summary.outputLines,
outputBytes: summary.outputBytes,
displayOutputs,
};
}
}
@@ -0,0 +1,46 @@
import type { ToolSession } from "../../tools";
import type { ExecutorBackend, ExecutorBackendExecOptions, ExecutorBackendResult } from "../backend";
import { executeJs } from "./executor";
const JS_SESSION_PREFIX = "js:";
function namespaceSessionId(sessionId: string): string {
return sessionId.startsWith(JS_SESSION_PREFIX) ? sessionId : `${JS_SESSION_PREFIX}${sessionId}`;
}
export default {
id: "js",
label: "JavaScript",
highlightLang: "javascript",
async isAvailable(_session: ToolSession): Promise<boolean> {
return true;
},
async execute(code: string, opts: ExecutorBackendExecOptions): Promise<ExecutorBackendResult> {
const result = await executeJs(code, {
cwd: opts.cwd,
deadlineMs: opts.deadlineMs,
signal: opts.signal,
sessionId: namespaceSessionId(opts.sessionId),
sessionFile: opts.sessionFile,
reset: opts.reset,
artifactPath: opts.artifactPath,
artifactId: opts.artifactId,
onChunk: opts.onChunk,
session: opts.session,
});
return {
output: result.output,
exitCode: result.exitCode,
cancelled: result.cancelled,
truncated: result.truncated,
artifactId: result.artifactId,
totalLines: result.totalLines,
totalBytes: result.totalBytes,
outputLines: result.outputLines,
outputBytes: result.outputBytes,
displayOutputs: result.displayOutputs,
};
},
} satisfies ExecutorBackend;
@@ -0,0 +1,2 @@
import javascriptPrelude from "./prelude.txt" with { type: "text" };
export const JAVASCRIPT_PRELUDE_SOURCE = javascriptPrelude;
@@ -0,0 +1,84 @@
if (!globalThis.__omp_js_prelude_loaded__) {
globalThis.__omp_js_prelude_loaded__ = true;
const toOptions = value => (value && typeof value === "object" && !Array.isArray(value) ? value : {});
const callHelper = (name, ...args) => globalThis.__omp_helpers__[name](...args);
const read = (path, opts = {}) => callHelper("read", path, toOptions(opts));
const write = async (path, data) => callHelper("writeFile", path, data);
const append = (path, content) => callHelper("append", path, content);
const stat = path => callHelper("stat", path);
const grep = (pattern, path, opts = {}) => callHelper("grep", pattern, path, toOptions(opts));
const rgrep = (pattern, path = ".", opts = {}) => callHelper("rgrep", pattern, path, toOptions(opts));
const find = (pattern, path = ".", opts = {}) => callHelper("find", pattern, path, toOptions(opts));
const glob = (pattern, path = ".", opts = {}) => callHelper("glob", pattern, path, toOptions(opts));
const sort = (text, opts = {}) => callHelper("sortText", text, toOptions(opts));
const uniq = (text, opts = {}) => callHelper("uniqText", text, toOptions(opts));
const counter = (items, opts = {}) => callHelper("counter", items, toOptions(opts));
const sed = (path, pattern, repl, opts = {}) => callHelper("sed", path, pattern, repl, toOptions(opts));
const diff = (a, b) => callHelper("diff", a, b);
const tree = (path = ".", opts = {}) => callHelper("tree", path, toOptions(opts));
const run = (cmd, opts = {}) => callHelper("run", cmd, toOptions(opts));
const env = (key, value) => callHelper("env", key, value);
const tool = new Proxy(
{},
{
get(_target, prop) {
if (typeof prop !== "string") return undefined;
return async args => globalThis.__omp_call_tool__(prop, args ?? {});
},
},
);
const output = async (...args) => {
let opts = {};
let ids = args;
if (args.length > 0) {
const last = args.at(-1);
if (last && typeof last === "object" && !Array.isArray(last)) {
opts = last;
ids = args.slice(0, -1);
}
}
const reads = ids.map(id => tool.read({ path: `agent://${id}`, ...opts }));
const values = await Promise.all(reads);
return values.length === 1 ? values[0] : values;
};
const display = value => {
globalThis.__omp_display__(value);
};
const formatArgs = args => args.map(arg => (typeof arg === "string" ? arg : arg));
const consoleBridge = {
log: (...args) => globalThis.__omp_log__("log", ...formatArgs(args)),
info: (...args) => globalThis.__omp_log__("info", ...formatArgs(args)),
warn: (...args) => globalThis.__omp_log__("warn", ...formatArgs(args)),
error: (...args) => globalThis.__omp_log__("error", ...formatArgs(args)),
debug: (...args) => globalThis.__omp_log__("debug", ...formatArgs(args)),
};
globalThis.console = consoleBridge;
globalThis.print = consoleBridge.log;
globalThis.display = display;
globalThis.tool = tool;
globalThis.output = output;
globalThis.read = read;
globalThis.write = write;
globalThis.append = append;
globalThis.stat = stat;
globalThis.grep = grep;
globalThis.rgrep = rgrep;
globalThis.find = find;
globalThis.glob = glob;
globalThis.sort = sort;
globalThis.uniq = uniq;
globalThis.counter = counter;
globalThis.sed = sed;
globalThis.diff = diff;
globalThis.tree = tree;
globalThis.run = run;
globalThis.env = env;
}
@@ -0,0 +1,124 @@
import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
import type { ToolSession } from "../../tools";
import { ToolError } from "../../tools/tool-errors";
export interface JsStatusEvent {
op: string;
[key: string]: unknown;
}
interface ToolBridgeOptions {
session: ToolSession;
signal?: AbortSignal;
emitStatus?: (event: JsStatusEvent) => void;
}
type ToolValue =
| string
| {
text: string;
details?: unknown;
images?: Array<{ mimeType: string; data: string }>;
};
function getTool(session: ToolSession, name: string): AgentTool {
const tool = session.getToolByName?.(name);
if (!tool) {
throw new ToolError(`Unknown tool from js runtime: ${name}`);
}
return tool;
}
function normalizeArgs(args: unknown): unknown {
if (!args || typeof args !== "object" || Array.isArray(args)) {
return args;
}
const record = { ...(args as Record<string, unknown>) };
if (record._i === undefined) {
record._i = "js prelude";
}
return record;
}
function summarizeToolResult(name: string, args: unknown, result: AgentToolResult, text: string): JsStatusEvent {
const record = (args && typeof args === "object" ? (args as Record<string, unknown>) : {}) as Record<
string,
unknown
>;
const details = (
result.details && typeof result.details === "object" ? (result.details as Record<string, unknown>) : {}
) as Record<string, unknown>;
switch (name) {
case "read":
return { op: "read", path: record.path, chars: text.length, preview: text.slice(0, 500) };
case "write":
return {
op: "write",
path: record.path,
chars: typeof record.content === "string" ? record.content.length : 0,
};
case "grep":
return {
op: "grep",
pattern: record.pattern,
path: record.path,
count: details.matchCount ?? undefined,
};
case "find":
return {
op: "find",
pattern: record.pattern,
count: details.fileCount ?? undefined,
matches: Array.isArray(details.files) ? details.files.slice(0, 20) : undefined,
};
case "bash":
return {
op: "run",
cmd: record.command,
code: typeof details.exitCode === "number" ? details.exitCode : undefined,
output: text.slice(0, 500),
};
default:
return { op: name, chars: text.length };
}
}
export async function callSessionTool(name: string, args: unknown, options: ToolBridgeOptions): Promise<ToolValue> {
const tool = getTool(options.session, name);
const normalizedArgs = normalizeArgs(args);
const toolCallId = `js-${name}-${crypto.randomUUID()}`;
try {
const result = await tool.execute(toolCallId, normalizedArgs, options.signal);
const textBlocks = result.content.filter(
(content): content is { type: "text"; text: string } =>
content.type === "text" && typeof content.text === "string",
);
const imageBlocks = result.content.filter(
(content): content is { type: "image"; mimeType: string; data: string } =>
content.type === "image" && typeof content.mimeType === "string" && typeof content.data === "string",
);
const text = textBlocks.map(block => block.text).join("");
options.emitStatus?.(summarizeToolResult(name, normalizedArgs, result, text));
if (result.details === undefined && imageBlocks.length === 0) {
return text;
}
return {
text,
details: result.details,
images:
imageBlocks.length > 0
? imageBlocks.map(block => ({
mimeType: block.mimeType,
data: block.data,
}))
: undefined,
};
} catch (error) {
options.emitStatus?.({
op: name,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
+337
View File
@@ -0,0 +1,337 @@
import type { EvalLanguage } from "./types";
export type EvalLanguageOrigin = "default" | "fence";
export interface ParsedEvalCell {
index: number;
title?: string;
code: string;
language: EvalLanguage;
languageOrigin: EvalLanguageOrigin;
timeoutMs: number;
reset: boolean;
}
export interface ParsedEvalInput {
cells: ParsedEvalCell[];
}
const DEFAULT_TIMEOUT_MS = 30_000;
/**
* Canonical fenced-language tokens we map onto our two backends. Matched
* case-insensitively. Anything else found in a fence info string is treated as
* a title fragment rather than a language; this is intentional fallback
* behaviour and MUST NOT be advertised in the tool's prompt — the lark grammar
* describes the canonical surface we encourage callers to emit.
*/
const LANGUAGE_ALIASES: Record<string, EvalLanguage> = {
py: "python",
python: "python",
ipy: "python",
ipython: "python",
js: "js",
javascript: "js",
ts: "js",
typescript: "js",
};
function resolveLanguageAlias(token: string): EvalLanguage | undefined {
return LANGUAGE_ALIASES[token.toLowerCase()];
}
/**
* Map an attribute key (from `key=value` in a fence info string) to one of
* the three canonical roles. Canonical keys: `id`, `t`, `rst`. Fallback
* aliases — accepted but not advertised in the prompt — cover common
* synonyms the LLM is likely to reach for instead of the short canonical.
*/
const ID_KEYS = new Set(["id", "title", "name", "cell", "file", "label"]);
const T_KEYS = new Set(["t", "timeout", "duration", "time"]);
const RST_KEYS = new Set(["rst", "reset"]);
function classifyAttrKey(key: string): "id" | "t" | "rst" | null {
if (ID_KEYS.has(key)) return "id";
if (T_KEYS.has(key)) return "t";
if (RST_KEYS.has(key)) return "rst";
return null;
}
interface RawBlock {
type: "raw";
lines: string[];
startLine: number;
}
interface FencedBlock {
type: "fenced";
info: string;
codeLines: string[];
startLine: number;
}
type Block = RawBlock | FencedBlock;
interface FenceInfo {
language?: EvalLanguage;
title?: string;
timeoutMs?: number;
reset?: boolean;
}
const ATTR_TOKEN_RE = /^([a-zA-Z][\w-]*)=(?:"([^"]*)"|'([^']*)'|(.*))$/;
const DURATION_TOKEN_RE = /^\d+(?:ms|s|m)?$/;
function parseDurationMs(raw: string, lineNumber: number): number {
const match = /^(\d+)(ms|s|m)?$/.exec(raw.trim());
if (!match) {
throw new Error(
`Eval line ${lineNumber}: invalid duration \`${raw}\`; use a number with optional ms, s, or m units.`,
);
}
const value = Number.parseInt(match[1], 10);
const unit = match[2] ?? "s";
if (unit === "ms") return value;
if (unit === "s") return value * 1000;
return value * 60_000;
}
function parseBoolean(value: string): boolean | undefined {
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") return true;
if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") return false;
return undefined;
}
function trimOuterBlankLines(lines: string[]): string[] {
let start = 0;
let end = lines.length;
while (start < end && lines[start].trim() === "") start++;
while (end > start && lines[end - 1].trim() === "") end--;
return lines.slice(start, end);
}
function parseFenceOpener(line: string): { char: "`" | "~"; count: number; info: string } | null {
const opener = /^(`{3,}|~{3,})(.*)$/.exec(line);
if (!opener) return null;
const run = opener[1];
return { char: run[0] as "`" | "~", count: run.length, info: opener[2].trim() };
}
function isFenceCloser(line: string, char: "`" | "~", minCount: number): boolean {
let count = 0;
while (count < line.length && line[count] === char) count++;
if (count < minCount) return false;
return line.slice(count).trim() === "";
}
/**
* Tokenize a fence info string while preserving content inside matching
* single or double quotes as a single token. The opening and closing quote
* characters are kept verbatim so attribute parsing can strip them later.
*/
function tokenizeInfoString(info: string): string[] {
const tokens: string[] = [];
let i = 0;
while (i < info.length) {
while (i < info.length && /\s/.test(info[i])) i++;
if (i >= info.length) break;
let token = "";
while (i < info.length && !/\s/.test(info[i])) {
const ch = info[i];
if (ch === '"' || ch === "'") {
token += ch;
i++;
while (i < info.length && info[i] !== ch) {
token += info[i];
i++;
}
if (i < info.length) {
token += info[i];
i++;
}
} else {
token += ch;
i++;
}
}
tokens.push(token);
}
return tokens;
}
/**
* Decode a fence info string into language, title, timeout, and reset flag.
*
* Layout (positional → kv, all optional):
* `<lang>? <duration>? <(title-fragment | key=value)>*`
*
* Canonical attribute keys (the only ones surfaced in the lark grammar):
* - `id` → cell title
* - `t` → per-cell timeout
* - `rst` → boolean reset for this cell's kernel
*
* Lenient fallback aliases (NOT advertised in the prompt; we silently accept
* them when the LLM reaches for a more familiar key):
* - id: title, name, cell, file, label
* - t: timeout, duration, time
* - rst: reset
*
* Truly unknown keys are silently dropped. First occurrence wins when a key
* is repeated (canonical or alias).
*
* - First token is consumed as a language alias when it matches one; otherwise
* it falls through to the title-fragment branch and the cell inherits the
* surrounding language.
* - The first remaining duration-shaped token (e.g. `15s`, `500ms`, `2m`,
* `30`) becomes the positional timeout. The `t=` attribute always wins.
* - Anything else accumulates as positional title fragments joined by spaces.
*/
function parseFenceInfo(info: string, lineNumber: number): FenceInfo {
const tokens = tokenizeInfoString(info.trim());
if (tokens.length === 0) return {};
let language: EvalLanguage | undefined;
let positionalDurationMs: number | undefined;
const titleParts: string[] = [];
let idAttr: string | undefined;
let tAttr: string | undefined;
let rstAttr: string | undefined;
for (let idx = 0; idx < tokens.length; idx++) {
const token = tokens[idx];
const attrMatch = ATTR_TOKEN_RE.exec(token);
if (attrMatch) {
const key = attrMatch[1].toLowerCase();
const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? "";
const role = classifyAttrKey(key);
if (role === "id" && idAttr === undefined) idAttr = value;
else if (role === "t" && tAttr === undefined) tAttr = value;
else if (role === "rst" && rstAttr === undefined) rstAttr = value;
// unknown / repeated keys silently dropped
continue;
}
if (idx === 0) {
const lang = resolveLanguageAlias(token);
if (lang) {
language = lang;
continue;
}
}
if (positionalDurationMs === undefined && DURATION_TOKEN_RE.test(token)) {
positionalDurationMs = parseDurationMs(token, lineNumber);
continue;
}
titleParts.push(token);
}
const explicitTitle = (idAttr ?? "").trim();
const positionalTitle = titleParts.join(" ").trim();
const title = explicitTitle.length > 0 ? explicitTitle : positionalTitle.length > 0 ? positionalTitle : undefined;
let timeoutMs: number | undefined;
if (tAttr !== undefined) {
timeoutMs = parseDurationMs(tAttr, lineNumber);
} else if (positionalDurationMs !== undefined) {
timeoutMs = positionalDurationMs;
}
let reset: boolean | undefined;
if (rstAttr !== undefined) {
const parsed = parseBoolean(rstAttr);
if (parsed === undefined) {
throw new Error(`Eval line ${lineNumber}: invalid rst value \`${rstAttr}\`; use true or false.`);
}
reset = parsed;
}
return { language, title, timeoutMs, reset };
}
/**
* Walk normalized lines and split into top-level fenced blocks and raw
* (between/around fences) blocks. Unclosed fences are leniently closed at
* end-of-input. Raw blocks with only blank lines are dropped.
*/
function splitIntoBlocks(lines: string[]): Block[] {
const blocks: Block[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const opener = parseFenceOpener(line);
if (opener) {
const fenceStart = i + 1; // 1-indexed line number of opener
const codeLines: string[] = [];
let j = i + 1;
let closed = false;
while (j < lines.length) {
if (isFenceCloser(lines[j], opener.char, opener.count)) {
closed = true;
break;
}
codeLines.push(lines[j]);
j++;
}
blocks.push({ type: "fenced", info: opener.info, codeLines, startLine: fenceStart });
i = closed ? j + 1 : j;
} else {
const rawStart = i + 1;
const rawLines: string[] = [line];
let j = i + 1;
while (j < lines.length && !parseFenceOpener(lines[j])) {
rawLines.push(lines[j]);
j++;
}
const trimmed = trimOuterBlankLines(rawLines);
if (trimmed.length > 0) {
blocks.push({ type: "raw", lines: trimmed, startLine: rawStart });
}
i = j;
}
}
return blocks;
}
interface ExpansionState {
language: EvalLanguage;
languageOrigin: EvalLanguageOrigin;
}
export function parseEvalInput(input: string): ParsedEvalInput {
const normalized = input.replace(/\r\n?/g, "\n");
const lines = normalized.split("\n");
const blocks = splitIntoBlocks(lines);
const state: ExpansionState = { language: "python", languageOrigin: "default" };
const cells: ParsedEvalCell[] = [];
for (const block of blocks) {
if (block.type === "raw") {
cells.push({
index: cells.length,
title: undefined,
code: block.lines.join("\n"),
language: state.language,
languageOrigin: state.languageOrigin,
timeoutMs: DEFAULT_TIMEOUT_MS,
reset: false,
});
continue;
}
const fence = parseFenceInfo(block.info, block.startLine);
const language = fence.language ?? state.language;
const languageOrigin: EvalLanguageOrigin = fence.language ? "fence" : state.languageOrigin;
cells.push({
index: cells.length,
title: fence.title,
code: block.codeLines.join("\n"),
language,
languageOrigin,
timeoutMs: fence.timeoutMs ?? DEFAULT_TIMEOUT_MS,
reset: fence.reset ?? false,
});
state.language = language;
state.languageOrigin = languageOrigin;
}
return { cells };
}
@@ -1,17 +1,13 @@
import * as path from "node:path";
import { getAgentDir, getProjectDir, isBunTestRuntime, isEnoent, logger } from "@oh-my-pi/pi-utils";
import { OutputSink } from "../session/streaming-output";
import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
import { OutputSink } from "../../session/streaming-output";
import { shutdownSharedGateway } from "./gateway-coordinator";
import {
checkPythonKernelAvailability,
type KernelDisplayOutput,
type KernelExecuteOptions,
type KernelExecuteResult,
type PreludeHelper,
PythonKernel,
} from "./kernel";
import { discoverPythonModules } from "./modules";
import { PYTHON_PRELUDE } from "./prelude";
const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const MAX_KERNEL_SESSIONS = 4;
@@ -101,7 +97,6 @@ interface KernelSession {
const kernelSessions = new Map<string, KernelSession>();
const disposingKernelSessions = new Set<KernelSession>();
let cachedPreludeDocs: PreludeHelper[] | null = null;
let cleanupTimer: NodeJS.Timeout | null = null;
interface KernelSessionExecutionOptions {
@@ -256,110 +251,6 @@ function buildKernelStartOptions(
};
}
interface PreludeCacheSource {
path: string;
hash: string;
}
interface PreludeCachePayload {
helpers: PreludeHelper[];
sources: PreludeCacheSource[];
}
interface PreludeCacheState {
cacheKey: string;
cachePath: string;
sources: PreludeCacheSource[];
}
const PRELUDE_CACHE_DIR = "pycache";
function hashPreludeContent(content: string): string {
return Bun.hash(content).toString(16);
}
async function buildPreludeCacheState(cwd: string): Promise<PreludeCacheState> {
const modules = await discoverPythonModules({ cwd });
const moduleSources = modules
.map(module => ({ path: module.path, hash: hashPreludeContent(module.content) }))
.sort((a, b) => a.path.localeCompare(b.path));
const sources: PreludeCacheSource[] = [
{ path: "omp:prelude", hash: hashPreludeContent(PYTHON_PRELUDE) },
...moduleSources,
];
const composite = sources.map(source => `${source.path}:${source.hash}`).join("|");
const cacheKey = Bun.hash(composite).toString(16);
const cachePath = path.join(getAgentDir(), PRELUDE_CACHE_DIR, `${cacheKey}.json`);
return { cacheKey, cachePath, sources };
}
async function readPreludeCache(state: PreludeCacheState): Promise<PreludeHelper[] | null> {
let raw: string;
try {
raw = await Bun.file(state.cachePath).text();
} catch (err) {
if (isEnoent(err)) return null;
logger.warn("Failed to read Python prelude cache", { path: state.cachePath, error: String(err) });
return null;
}
try {
const parsed = JSON.parse(raw) as PreludeCachePayload | PreludeHelper[];
const helpers = Array.isArray(parsed) ? parsed : parsed.helpers;
if (!Array.isArray(helpers) || helpers.length === 0) return null;
return helpers;
} catch (err) {
logger.warn("Failed to parse Python prelude cache", { path: state.cachePath, error: String(err) });
return null;
}
}
async function writePreludeCache(state: PreludeCacheState, helpers: PreludeHelper[]): Promise<void> {
const payload: PreludeCachePayload = { helpers, sources: state.sources };
try {
await Bun.write(state.cachePath, JSON.stringify(payload));
} catch (err) {
logger.warn("Failed to write Python prelude cache", { path: state.cachePath, error: String(err) });
}
}
function getPreludeIntrospectionOptions(
options: KernelSessionExecutionOptions = {},
): Pick<KernelExecuteOptions, "signal" | "timeoutMs"> {
return {
signal: options.signal,
timeoutMs: requireRemainingTimeoutMs(options.deadlineMs),
};
}
async function cachePreludeDocs(
cwd: string,
docs: PreludeHelper[],
cacheState?: PreludeCacheState | null,
): Promise<PreludeHelper[]> {
cachedPreludeDocs = docs;
if (!isBunTestRuntime() && docs.length > 0) {
const state = cacheState ?? (await buildPreludeCacheState(cwd));
await writePreludeCache(state, docs);
}
return docs;
}
async function ensurePreludeDocsLoaded(
kernel: PythonKernel,
cwd: string,
options: KernelSessionExecutionOptions = {},
cacheState?: PreludeCacheState | null,
): Promise<PreludeHelper[]> {
if (cachedPreludeDocs && cachedPreludeDocs.length > 0) {
return cachedPreludeDocs;
}
const docs = await kernel.introspectPrelude(getPreludeIntrospectionOptions(options));
if (docs.length === 0) {
throw new Error("Python prelude helpers unavailable");
}
return cachePreludeDocs(cwd, docs, cacheState);
}
function startCleanupTimer(): void {
if (cleanupTimer) return;
cleanupTimer = setInterval(() => {
@@ -565,70 +456,20 @@ async function ensureKernelAvailable(
export async function warmPythonEnvironment(
cwd: string,
sessionId?: string,
useSharedGateway?: boolean,
sessionFile?: string,
_useSharedGateway?: boolean,
_sessionFile?: string,
kernelOwnerId?: string,
signal?: AbortSignal,
): Promise<{ ok: boolean; reason?: string; docs: PreludeHelper[] }> {
let cacheState: PreludeCacheState | null = null;
): Promise<{ ok: boolean; reason?: string }> {
const resolvedSessionId = sessionId ?? `session:${cwd}`;
try {
await logger.time("warmPython:ensureKernelAvailable", ensureKernelAvailable, cwd, { signal });
} catch (err: unknown) {
const reason = err instanceof Error ? err.message : String(err);
cachedPreludeDocs = [];
return { ok: false, reason, docs: [] };
return { ok: false, reason };
}
if (!isBunTestRuntime()) {
try {
cacheState = await buildPreludeCacheState(cwd);
const cached = await readPreludeCache(cacheState);
if (cached) {
cachedPreludeDocs = cached;
attachKernelOwner(resolvedSessionId, kernelOwnerId);
return { ok: true, docs: cached };
}
} catch (err) {
logger.warn("Failed to resolve Python prelude cache", { error: String(err) });
cacheState = null;
}
}
if (cachedPreludeDocs && cachedPreludeDocs.length > 0) {
attachKernelOwner(resolvedSessionId, kernelOwnerId);
return { ok: true, docs: cachedPreludeDocs };
}
try {
const docs = await logger.time(
"warmPython:withKernelSession",
withKernelSession,
resolvedSessionId,
cwd,
kernel => ensurePreludeDocsLoaded(kernel, cwd, { useSharedGateway, sessionFile, signal }, cacheState),
{
useSharedGateway,
sessionFile,
kernelOwnerId,
signal,
},
);
return { ok: true, docs };
} catch (err: unknown) {
const reason = err instanceof Error ? err.message : String(err);
cachedPreludeDocs = [];
return { ok: false, reason, docs: [] };
}
}
export function getPreludeDocs(): PreludeHelper[] {
return cachedPreludeDocs ?? [];
}
export function setPreludeDocsCache(docs: PreludeHelper[]): void {
cachedPreludeDocs = docs;
}
export function resetPreludeDocsCache(): void {
cachedPreludeDocs = null;
attachKernelOwner(resolvedSessionId, kernelOwnerId);
return { ok: true };
}
function isResourceExhaustionError(error: unknown): boolean {
@@ -4,8 +4,8 @@ import * as path from "node:path";
import { Process } from "@oh-my-pi/pi-natives";
import { getAgentDir, isEnoent, logger, procmgr } from "@oh-my-pi/pi-utils";
import type { Subprocess } from "bun";
import { Settings } from "../config/settings";
import { getOrCreateSnapshot } from "../utils/shell-snapshot";
import { Settings } from "../../config/settings";
import { getOrCreateSnapshot } from "../../utils/shell-snapshot";
import { filterEnv, resolvePythonRuntime } from "./runtime";
const GATEWAY_DIR_NAME = "python-gateway";
@@ -0,0 +1,76 @@
import type { ToolSession } from "../../tools";
import type {
ExecutorBackend,
ExecutorBackendCallOptions,
ExecutorBackendExecOptions,
ExecutorBackendResult,
} from "../backend";
import { executePython, type PythonExecutorOptions, warmPythonEnvironment } from "./executor";
import { checkPythonKernelAvailability } from "./kernel";
const PYTHON_SESSION_PREFIX = "python:";
function namespaceSessionId(sessionId: string): string {
return sessionId.startsWith(PYTHON_SESSION_PREFIX) ? sessionId : `${PYTHON_SESSION_PREFIX}${sessionId}`;
}
function readSetting<T>(session: ToolSession, key: string): T | undefined {
const settings = session.settings as { get?: (key: string) => T | undefined } | undefined;
return settings?.get?.(key);
}
export default {
id: "python",
label: "Python",
highlightLang: "python",
async isAvailable(session: ToolSession): Promise<boolean> {
const availability = await checkPythonKernelAvailability(session.cwd);
return availability.ok;
},
async warm(opts: ExecutorBackendCallOptions): Promise<{ ok: boolean; reason?: string }> {
const useSharedGateway = readSetting<boolean>(opts.session, "python.sharedGateway");
const result = await warmPythonEnvironment(
opts.cwd,
namespaceSessionId(opts.sessionId),
useSharedGateway,
opts.sessionFile,
opts.kernelOwnerId,
opts.signal,
);
return { ok: result.ok, reason: result.reason };
},
async execute(code: string, opts: ExecutorBackendExecOptions): Promise<ExecutorBackendResult> {
const useSharedGateway = readSetting<boolean>(opts.session, "python.sharedGateway");
const kernelMode = readSetting<PythonExecutorOptions["kernelMode"]>(opts.session, "python.kernelMode");
const executorOptions: PythonExecutorOptions = {
cwd: opts.cwd,
deadlineMs: opts.deadlineMs,
signal: opts.signal,
sessionId: namespaceSessionId(opts.sessionId),
kernelMode,
useSharedGateway,
sessionFile: opts.sessionFile,
kernelOwnerId: opts.kernelOwnerId,
reset: opts.reset,
artifactPath: opts.artifactPath,
artifactId: opts.artifactId,
onChunk: opts.onChunk,
};
const result = await executePython(code, executorOptions);
return {
output: result.output,
exitCode: result.exitCode,
cancelled: result.cancelled,
truncated: result.truncated,
artifactId: result.artifactId,
totalLines: result.totalLines,
totalBytes: result.totalBytes,
outputLines: result.outputLines,
outputBytes: result.outputBytes,
displayOutputs: result.displayOutputs,
};
},
} satisfies ExecutorBackend;
@@ -1,17 +1,16 @@
import { $env, $flag, isBunTestRuntime, logger, Snowflake } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
import { Settings } from "../config/settings";
import { htmlToBasicMarkdown } from "../web/scrapers/types";
import { Settings } from "../../config/settings";
import { htmlToBasicMarkdown } from "../../web/scrapers/types";
import { createCancellationError, getAbortReason, getExecutionCancellationError } from "./cancellation";
import { acquireSharedGateway, releaseSharedGateway, shutdownSharedGateway } from "./gateway-coordinator";
import { loadPythonModules } from "./modules";
import { PYTHON_PRELUDE } from "./prelude";
import { filterEnv, resolvePythonRuntime } from "./runtime";
const TEXT_ENCODER = new TextEncoder();
const TEXT_DECODER = new TextDecoder();
const TRACE_IPC = $flag("PI_PYTHON_IPC_TRACE");
const PRELUDE_INTROSPECTION_SNIPPET = "import json\nprint(json.dumps(__omp_prelude_docs__()))";
class SharedGatewayCreateError extends Error {
constructor(
@@ -179,13 +178,6 @@ export interface KernelExecuteResult {
stdinRequested: boolean;
}
export interface PreludeHelper {
name: string;
signature: string;
docstring: string;
category: string;
}
interface KernelStartOptions extends KernelLifecycleOptions {
cwd: string;
env?: Record<string, string | undefined>;
@@ -536,7 +528,7 @@ export class PythonKernel {
preludeOptions.signal,
"Failed to initialize Python kernel prelude",
);
await loadPythonModules(kernel, { cwd, signal: startup.signal, deadlineMs: startup.deadlineMs });
return kernel;
} catch (err: unknown) {
await kernel.shutdown({ timeoutMs: getStartupCleanupTimeoutMs(startup.deadlineMs) });
@@ -607,11 +599,7 @@ export class PythonKernel {
preludeOptions.signal,
"Failed to initialize Python kernel prelude",
);
await logger.time("startWithSharedGateway:loadModules", loadPythonModules, kernel, {
cwd,
signal: startup.signal,
deadlineMs: startup.deadlineMs,
});
return kernel;
} catch (err: unknown) {
await kernel.shutdown({ timeoutMs: getStartupCleanupTimeoutMs(startup.deadlineMs) });
@@ -953,30 +941,6 @@ export class PythonKernel {
return promise;
}
async introspectPrelude(options: Pick<KernelExecuteOptions, "signal" | "timeoutMs"> = {}): Promise<PreludeHelper[]> {
let output = "";
const result = await this.execute(PRELUDE_INTROSPECTION_SNIPPET, {
silent: false,
storeHistory: false,
signal: options.signal,
timeoutMs: options.timeoutMs,
onChunk: text => {
output += text;
},
});
if (result.cancelled || result.status === "error") {
throw new Error("Failed to introspect Python prelude");
}
const trimmed = output.trim();
if (!trimmed) return [];
try {
return JSON.parse(trimmed) as PreludeHelper[];
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to parse Python prelude docs: ${message}`);
}
}
async interrupt(): Promise<void> {
try {
await fetch(`${this.gatewayUrl}/api/kernels/${this.kernelId}/interrupt`, {
@@ -3,22 +3,39 @@ from __future__ import annotations
if "__omp_prelude_loaded__" not in globals():
__omp_prelude_loaded__ = True
from pathlib import Path
import os, re, json, shutil, subprocess, inspect
import os, re, json, shutil, subprocess
from datetime import datetime
from IPython.display import display
from IPython.display import display as _ipy_display, JSON
_PRESENTABLE_REPRS = (
"_repr_mimebundle_",
"_repr_html_",
"_repr_json_",
"_repr_markdown_",
"_repr_png_",
"_repr_jpeg_",
"_repr_svg_",
"_repr_latex_",
)
def display(value):
"""Render a value. Wraps plain dict/list values as interactive JSON."""
if any(hasattr(value, attr) for attr in _PRESENTABLE_REPRS):
_ipy_display(value)
return
if isinstance(value, (dict, list, tuple)):
try:
_ipy_display(JSON(value))
return
except Exception:
pass
_ipy_display(value)
def _emit_status(op: str, **data):
"""Emit structured status event for TUI rendering."""
display({"application/x-omp-status": {"op": op, **data}}, raw=True)
_ipy_display({"application/x-omp-status": {"op": op, **data}}, raw=True)
def _category(cat: str):
"""Decorator to tag a prelude function with its category."""
def decorator(fn):
fn._omp_category = cat
return fn
return decorator
@_category("Shell")
def env(key: str | None = None, value: str | None = None):
"""Get/set environment variables."""
if key is None:
@@ -33,7 +50,6 @@ if "__omp_prelude_loaded__" not in globals():
_emit_status("env", key=key, value=val, action="get")
return val
@_category("File I/O")
def read(path: str | Path, *, offset: int = 1, limit: int | None = None) -> str:
"""Read file contents. offset/limit are 1-indexed line numbers."""
p = Path(path)
@@ -48,7 +64,6 @@ if "__omp_prelude_loaded__" not in globals():
_emit_status("read", path=str(p), chars=len(data), preview=preview)
return data
@_category("File I/O")
def write(path: str | Path, content: str) -> Path:
"""Write file contents (create parents)."""
p = Path(path)
@@ -57,7 +72,6 @@ if "__omp_prelude_loaded__" not in globals():
_emit_status("write", path=str(p), chars=len(content))
return p
@_category("File I/O")
def append(path: str | Path, content: str) -> Path:
"""Append to file."""
p = Path(path)
@@ -66,47 +80,6 @@ if "__omp_prelude_loaded__" not in globals():
f.write(content)
_emit_status("append", path=str(p), chars=len(content))
return p
@_category("File ops")
def rm(path: str | Path, *, recursive: bool = False) -> None:
"""Delete file or directory (recursive optional)."""
p = Path(path)
if p.is_dir():
if recursive:
shutil.rmtree(p)
_emit_status("rm", path=str(p), recursive=True)
return
_emit_status("rm", path=str(p), error="directory, use recursive=True")
return
if p.exists():
p.unlink()
_emit_status("rm", path=str(p))
else:
_emit_status("rm", path=str(p), error="missing")
@_category("File ops")
def mv(src: str | Path, dst: str | Path) -> Path:
"""Move or rename a file/directory."""
src_p = Path(src)
dst_p = Path(dst)
dst_p.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src_p), str(dst_p))
_emit_status("mv", src=str(src_p), dst=str(dst_p))
return dst_p
@_category("File ops")
def cp(src: str | Path, dst: str | Path) -> Path:
"""Copy a file or directory."""
src_p = Path(src)
dst_p = Path(dst)
dst_p.parent.mkdir(parents=True, exist_ok=True)
if src_p.is_dir():
shutil.copytree(src_p, dst_p, dirs_exist_ok=True)
else:
shutil.copy2(src_p, dst_p)
_emit_status("cp", src=str(src_p), dst=str(dst_p))
return dst_p
def _load_gitignore_patterns(base: Path) -> list[str]:
"""Load .gitignore patterns from base directory and parents."""
patterns: list[str] = []
@@ -152,7 +125,6 @@ if "__omp_prelude_loaded__" not in globals():
return True
return False
@_category("Search")
def find(
pattern: str,
path: str | Path = ".",
@@ -200,7 +172,6 @@ if "__omp_prelude_loaded__" not in globals():
_emit_status("find", pattern=pattern, path=str(p), count=len(matches), matches=[str(m) for m in matches[:20]])
return matches
@_category("Search")
def grep(
pattern: str,
path: str | Path,
@@ -208,8 +179,8 @@ if "__omp_prelude_loaded__" not in globals():
ignore_case: bool = False,
literal: bool = False,
context: int = 0,
) -> list[tuple[int, str]]:
"""Grep a single file. Returns (line_number, text) tuples."""
) -> list[dict]:
"""Grep a single file. Returns dicts with line/text fields."""
p = Path(path)
lines = p.read_text(encoding="utf-8").splitlines()
if literal:
@@ -237,11 +208,10 @@ if "__omp_prelude_loaded__" not in globals():
else:
output_lines = sorted(match_lines)
hits = [(ln, lines[ln - 1]) for ln in output_lines]
_emit_status("grep", pattern=pattern, path=str(p), count=len(match_lines), hits=[{"line": h[0], "text": h[1][:100]} for h in hits[:10]])
hits = [{"line": ln, "text": lines[ln - 1]} for ln in output_lines]
_emit_status("grep", pattern=pattern, path=str(p), count=len(match_lines), hits=hits[:10])
return hits
@_category("Search")
def rgrep(
pattern: str,
path: str | Path = ".",
@@ -251,8 +221,8 @@ if "__omp_prelude_loaded__" not in globals():
literal: bool = False,
limit: int = 100,
hidden: bool = False,
) -> list[tuple[Path, int, str]]:
"""Recursive grep across files matching glob_pattern. Respects .gitignore."""
) -> list[dict]:
"""Recursive grep across files matching glob_pattern. Returns dicts with file/line/text fields. Respects .gitignore."""
if literal:
if ignore_case:
match_fn = lambda line: pattern.lower() in line.lower()
@@ -265,7 +235,7 @@ if "__omp_prelude_loaded__" not in globals():
base = Path(path)
ignore_patterns = _load_gitignore_patterns(base)
hits: list[tuple[Path, int, str]] = []
hits: list[dict] = []
for file_path in base.rglob(glob_pattern):
if len(hits) >= limit:
break
@@ -285,24 +255,9 @@ if "__omp_prelude_loaded__" not in globals():
if len(hits) >= limit:
break
if match_fn(line):
hits.append((file_path, i, line))
_emit_status("rgrep", pattern=pattern, path=str(base), count=len(hits), hits=[{"file": str(h[0]), "line": h[1], "text": h[2][:80]} for h in hits[:10]])
hits.append({"file": str(file_path), "line": i, "text": line})
_emit_status("rgrep", pattern=pattern, path=str(base), count=len(hits), hits=hits[:10])
return hits
@_category("Find/Replace")
def replace(path: str | Path, pattern: str, repl: str, *, regex: bool = False) -> int:
"""Replace text in a file (regex optional)."""
p = Path(path)
data = p.read_text(encoding="utf-8")
if regex:
new, count = re.subn(pattern, repl, data)
else:
new = data.replace(pattern, repl)
count = data.count(pattern)
p.write_text(new, encoding="utf-8")
_emit_status("replace", path=str(p), count=count)
return count
class ShellResult:
"""Result from shell command execution."""
__slots__ = ("args", "stdout", "stderr", "returncode")
@@ -371,25 +326,22 @@ if "__omp_prelude_loaded__" not in globals():
result = subprocess.CompletedProcess(args, proc.returncode, stdout, stderr)
return _make_shell_result(result, cmd)
@_category("Shell")
def run(cmd: str, *, cwd: str | Path | None = None, timeout: int | None = None) -> ShellResult:
"""Run a shell command. Returns ShellResult with stdout/stderr and returncode/exit_code fields."""
shell_path = shutil.which("bash") or shutil.which("sh") or "/bin/sh"
args = [shell_path, "-c", cmd]
return _run_with_interrupt(args, str(cwd) if cwd else None, timeout, cmd)
@_category("Text")
def sort_lines(text: str, *, reverse: bool = False, unique: bool = False) -> str:
def sort(text: str, *, reverse: bool = False, unique: bool = False) -> str:
"""Sort lines of text."""
lines = text.splitlines()
if unique:
lines = list(dict.fromkeys(lines))
lines = sorted(lines, reverse=reverse)
out = "\n".join(lines)
_emit_status("sort_lines", lines=len(lines), unique=unique, reverse=reverse)
_emit_status("sort", lines=len(lines), unique=unique, reverse=reverse)
return out
@_category("Text")
def uniq(text: str, *, count: bool = False) -> str | list[tuple[int, str]]:
"""Remove duplicate adjacent lines (like uniq)."""
lines = text.splitlines()
@@ -412,7 +364,6 @@ if "__omp_prelude_loaded__" not in globals():
return groups
return "\n".join(line for _, line in groups)
@_category("Text")
def counter(
items: str | list,
*,
@@ -435,20 +386,6 @@ if "__omp_prelude_loaded__" not in globals():
result = [(count, item) for item, count in sorted_items]
_emit_status("counter", unique=len(counts), total=sum(counts.values()), top=result[:10])
return result
@_category("Text")
def cols(text: str, *indices: int, sep: str | None = None) -> str:
"""Extract columns from text (0-indexed). Like cut."""
result_lines = []
for line in text.splitlines():
parts = line.split(sep) if sep else line.split()
selected = [parts[i] for i in indices if i < len(parts)]
result_lines.append(" ".join(selected))
out = "\n".join(result_lines)
_emit_status("cols", lines=len(result_lines), columns=list(indices))
return out
@_category("Navigation")
def tree(path: str | Path = ".", *, max_depth: int = 3, show_hidden: bool = False) -> str:
"""Return directory tree."""
base = Path(path)
@@ -472,7 +409,6 @@ if "__omp_prelude_loaded__" not in globals():
_emit_status("tree", path=str(base), entries=len(lines) - 1, preview=out[:1000])
return out
@_category("Navigation")
def stat(path: str | Path) -> dict:
"""Get file/directory info."""
p = Path(path)
@@ -483,12 +419,10 @@ if "__omp_prelude_loaded__" not in globals():
"is_file": p.is_file(),
"is_dir": p.is_dir(),
"mtime": datetime.fromtimestamp(s.st_mtime).isoformat(),
"mode": oct(s.st_mode),
}
_emit_status("stat", path=str(p), size=s.st_size, is_dir=p.is_dir(), mtime=info["mtime"])
return info
@_category("Batch")
def diff(a: str | Path, b: str | Path) -> str:
"""Compare two files, return unified diff."""
import difflib
@@ -500,8 +434,7 @@ if "__omp_prelude_loaded__" not in globals():
_emit_status("diff", file_a=str(path_a), file_b=str(path_b), identical=not out, preview=out[:500])
return out
@_category("Search")
def glob_files(pattern: str, path: str | Path = ".", *, hidden: bool = False) -> list[Path]:
def glob(pattern: str, path: str | Path = ".", *, hidden: bool = False) -> list[str]:
"""Non-recursive glob (use find() for recursive). Respects .gitignore."""
p = Path(path)
ignore_patterns = _load_gitignore_patterns(p)
@@ -518,7 +451,6 @@ if "__omp_prelude_loaded__" not in globals():
_emit_status("glob", pattern=pattern, path=str(p), count=len(matches), matches=[str(m) for m in matches[:20]])
return matches
@_category("Find/Replace")
def sed(path: str | Path, pattern: str, repl: str, *, flags: int = 0) -> int:
"""Regex replace in file (like sed -i). Returns count."""
p = Path(path)
@@ -527,110 +459,6 @@ if "__omp_prelude_loaded__" not in globals():
p.write_text(new, encoding="utf-8")
_emit_status("sed", path=str(p), count=count)
return count
@_category("Find/Replace")
def rsed(
pattern: str,
repl: str,
path: str | Path = ".",
*,
glob_pattern: str = "*",
flags: int = 0,
hidden: bool = False,
) -> int:
"""Recursive sed across files matching glob_pattern. Respects .gitignore."""
base = Path(path)
ignore_patterns = _load_gitignore_patterns(base)
total = 0
files_changed = 0
changed_files = []
for file_path in base.rglob(glob_pattern):
if file_path.is_dir():
continue
# Skip hidden files unless requested
if not hidden and any(part.startswith(".") for part in file_path.parts):
continue
# Skip gitignored paths
if _match_gitignore(file_path, ignore_patterns, base):
continue
try:
data = file_path.read_text(encoding="utf-8")
new, count = re.subn(pattern, repl, data, flags=flags)
if count > 0:
file_path.write_text(new, encoding="utf-8")
total += count
files_changed += 1
if len(changed_files) < 10:
changed_files.append({"file": str(file_path), "count": count})
except Exception:
continue
_emit_status("rsed", path=str(base), count=total, files=files_changed, changed=changed_files)
return total
@_category("Line ops")
def lines(path: str | Path, start: int = 1, end: int | None = None) -> str:
"""Extract line range from file (1-indexed, inclusive). Like sed -n 'N,Mp'."""
p = Path(path)
all_lines = p.read_text(encoding="utf-8").splitlines()
if end is None:
end = len(all_lines)
start = max(1, start)
end = min(len(all_lines), end)
selected = all_lines[start - 1 : end]
out = "\n".join(selected)
_emit_status("lines", path=str(p), start=start, end=end, count=len(selected), preview=out[:500])
return out
@_category("Line ops")
def delete_lines(path: str | Path, start: int, end: int | None = None) -> int:
"""Delete line range from file (1-indexed, inclusive). Like sed -i 'N,Md'."""
p = Path(path)
all_lines = p.read_text(encoding="utf-8").splitlines()
if end is None:
end = start
start = max(1, start)
end = min(len(all_lines), end)
count = end - start + 1
new_lines = all_lines[: start - 1] + all_lines[end:]
p.write_text("\n".join(new_lines) + ("\n" if all_lines else ""), encoding="utf-8")
_emit_status("delete_lines", path=str(p), start=start, end=end, count=count)
return count
@_category("Line ops")
def delete_matching(path: str | Path, pattern: str, *, regex: bool = True) -> int:
"""Delete lines matching pattern. Like sed -i '/pattern/d'."""
p = Path(path)
all_lines = p.read_text(encoding="utf-8").splitlines()
if regex:
rx = re.compile(pattern)
new_lines = [l for l in all_lines if not rx.search(l)]
else:
new_lines = [l for l in all_lines if pattern not in l]
count = len(all_lines) - len(new_lines)
p.write_text("\n".join(new_lines) + ("\n" if all_lines else ""), encoding="utf-8")
_emit_status("delete_matching", path=str(p), pattern=pattern, count=count)
return count
@_category("Line ops")
def insert_at(path: str | Path, line_num: int, text: str, *, after: bool = True) -> Path:
"""Insert text at line. after=True (sed 'Na\\'), after=False (sed 'Ni\\')."""
p = Path(path)
all_lines = p.read_text(encoding="utf-8").splitlines()
new_lines = text.splitlines()
line_num = max(1, min(len(all_lines) + 1, line_num))
if after:
idx = min(line_num, len(all_lines))
all_lines = all_lines[:idx] + new_lines + all_lines[idx:]
pos = "after"
else:
idx = line_num - 1
all_lines = all_lines[:idx] + new_lines + all_lines[idx:]
pos = "before"
p.write_text("\n".join(all_lines) + "\n", encoding="utf-8")
_emit_status("insert_at", path=str(p), line=line_num, lines_inserted=len(new_lines), position=pos)
return p
@_category("Agent")
def output(
*ids: str,
format: str = "raw",
@@ -831,19 +659,3 @@ if "__omp_prelude_loaded__" not in globals():
return current
def __omp_prelude_docs__() -> list[dict[str, str]]:
"""Return prelude helper docs for templating. Discovers functions by _omp_category attribute."""
helpers: list[dict[str, str]] = []
for name, obj in globals().items():
if not callable(obj) or not hasattr(obj, "_omp_category"):
continue
signature = str(inspect.signature(obj))
doc = inspect.getdoc(obj) or ""
docline = doc.splitlines()[0] if doc else ""
helpers.append({
"name": name,
"signature": signature,
"docstring": docline,
"category": obj._omp_category,
})
return sorted(helpers, key=lambda h: (h["category"], h["name"]))
+48
View File
@@ -0,0 +1,48 @@
/** Runtime backend that an eval cell dispatches to. */
export type EvalLanguage = "python" | "js";
import type { ImageContent } from "@oh-my-pi/pi-ai";
import type { OutputMeta } from "../tools/output-meta";
/** Status event emitted by prelude helpers (python or js) for TUI rendering. */
export interface EvalStatusEvent {
op: string;
[key: string]: unknown;
}
/** Display output captured during eval execution. Union of python and js shapes. */
export type EvalDisplayOutput =
| { type: "json"; data: unknown }
| { type: "image"; data: string; mimeType: string }
| { type: "markdown"; text?: string }
| { type: "status"; event: EvalStatusEvent };
/** Per-cell execution result for transcript rendering. */
export interface EvalCellResult {
index: number;
title?: string;
code: string;
language?: EvalLanguage;
output: string;
status: "pending" | "running" | "complete" | "error";
durationMs?: number;
exitCode?: number;
statusEvents?: EvalStatusEvent[];
hasMarkdown?: boolean;
}
/** Tool result detail object surfaced to the UI/transcript. */
export interface EvalToolDetails {
cells?: EvalCellResult[];
jsonOutputs?: unknown[];
images?: ImageContent[];
statusEvents?: EvalStatusEvent[];
isError?: boolean;
meta?: OutputMeta;
/** First backend that produced cells. Kept for transcript compatibility. */
language?: EvalLanguage;
/** Backends that produced cells in this call, in first-use order. */
languages?: EvalLanguage[];
/** Optional human-readable notice (e.g. fallback explanation). */
notice?: string;
}
File diff suppressed because one or more lines are too long
@@ -779,23 +779,21 @@
}
function renderJsLike(name, args, result, ctx) {
const lang = name === 'python' ? 'python' : 'javascript';
const badges = [];
if (args.cwd) badges.push('cwd=' + shortenPath(String(args.cwd)));
if (args.timeout) badges.push('timeout=' + args.timeout + 's');
if (args.reset) badges.push('reset');
let html = toolHead(name, '', badges);
const cells = Array.isArray(args.cells) ? args.cells : null;
if (!cells) {
html += '<div class="tool-error">[missing cells]</div>';
} else {
let html = toolHead(name, '');
const cells = result && result.details && Array.isArray(result.details.cells) ? result.details.cells : null;
if (cells) {
for (const cell of cells) {
html += '<div class="tool-cell">';
if (cell && cell.title) html += '<div class="tool-cell-title">' + escapeHtml(String(cell.title)) + '</div>';
const code = cell && typeof cell.code === 'string' ? cell.code : '';
const lang = cell && cell.language === 'js' ? 'javascript' : 'python';
html += codeBlock(code, lang);
html += '</div>';
}
} else if (typeof args.input === 'string') {
html += codeBlock(args.input, null);
} else {
html += '<div class="tool-error">[missing input]</div>';
}
if (result) {
html += ctx.renderResultImages();
@@ -28,9 +28,9 @@ import type { Rule } from "../../capability/rule";
import type { KeybindingsManager } from "../../config/keybindings";
import type { ModelRegistry } from "../../config/model-registry";
import type { EditToolDetails } from "../../edit";
import type { PythonResult } from "../../eval/py/executor";
import type { BashResult } from "../../exec/bash-executor";
import type { ExecOptions, ExecResult } from "../../exec/exec";
import type { PythonResult } from "../../ipy/executor";
import type { Theme } from "../../modes/theme/theme";
import type { CompactionPreparation, CompactionResult } from "../../session/compaction";
import type { CustomMessage } from "../../session/messages";
-144
View File
@@ -1,144 +0,0 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { getAgentModulesDir, getProjectDir, getProjectModulesDir } from "@oh-my-pi/pi-utils";
import { getExecutionCancellationError } from "./cancellation";
export type PythonModuleSource = "user" | "project";
export interface PythonModuleEntry {
path: string;
content: string;
source: PythonModuleSource;
}
export interface PythonModuleExecuteResult {
status: "ok" | "error";
cancelled: boolean;
timedOut?: boolean;
error?: { name: string; value: string; traceback: string[] };
}
export interface PythonModuleExecutor {
execute: (
code: string,
options?: { signal?: AbortSignal; timeoutMs?: number; silent?: boolean; storeHistory?: boolean },
) => Promise<PythonModuleExecuteResult>;
}
export interface DiscoverPythonModulesOptions {
/** Working directory for project-level modules. Default: getProjectDir() */
cwd?: string;
/** Agent directory for user-level modules. Default: from getAgentDir() */
agentDir?: string;
}
export interface LoadPythonModulesOptions extends DiscoverPythonModulesOptions {
signal?: AbortSignal;
timeoutMs?: number;
deadlineMs?: number;
}
interface ModuleCandidate {
name: string;
path: string;
source: PythonModuleSource;
}
async function listModuleCandidates(dir: string, source: PythonModuleSource): Promise<ModuleCandidate[]> {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
return entries
.filter(entry => entry.isFile() && entry.name.endsWith(".py"))
.map(entry => ({
name: entry.name,
path: path.resolve(dir, entry.name),
source,
}));
} catch {
return [];
}
}
async function readModuleContent(candidate: ModuleCandidate): Promise<PythonModuleEntry> {
try {
const content = await Bun.file(candidate.path).text();
return { path: candidate.path, content, source: candidate.source };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to read Python module ${candidate.path}: ${message}`);
}
}
function createTimeoutError(message: string): Error {
const error = new Error(message);
error.name = "TimeoutError";
return error;
}
function requireModuleExecutionTimeoutMs(options: LoadPythonModulesOptions): number | undefined {
if (options.deadlineMs === undefined) {
return options.timeoutMs;
}
const remainingMs = options.deadlineMs - Date.now();
if (remainingMs <= 0) {
throw createTimeoutError("Python module loading timed out");
}
return remainingMs;
}
/**
* Discover Python prelude extension modules from user and project directories.
*/
export async function discoverPythonModules(options: DiscoverPythonModulesOptions = {}): Promise<PythonModuleEntry[]> {
const cwd = options.cwd ?? getProjectDir();
const userDir = getAgentModulesDir(options.agentDir);
const projectDir = getProjectModulesDir(cwd);
const userCandidates = await listModuleCandidates(userDir, "user");
const projectCandidates = await listModuleCandidates(projectDir, "project");
const byName = new Map<string, ModuleCandidate>();
for (const candidate of userCandidates) {
if (!byName.has(candidate.name)) {
byName.set(candidate.name, candidate);
}
}
for (const candidate of projectCandidates) {
const existing = byName.get(candidate.name);
if (!existing || existing.source === "user") {
byName.set(candidate.name, candidate);
}
}
const sorted = Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
return Promise.all(sorted.map(candidate => readModuleContent(candidate)));
}
/**
* Load Python prelude extension modules into an active kernel.
*/
export async function loadPythonModules(
executor: PythonModuleExecutor,
options: LoadPythonModulesOptions = {},
): Promise<PythonModuleEntry[]> {
const modules = await discoverPythonModules(options);
for (const module of modules) {
const result = await executor.execute(module.content, {
signal: options.signal,
timeoutMs: requireModuleExecutionTimeoutMs(options),
silent: true,
storeHistory: false,
});
if (result.cancelled) {
throw getExecutionCancellationError(result, options.signal, `Failed to load Python module ${module.path}`);
}
if (result.status === "error") {
const details = result.error ? `${result.error.name}: ${result.error.value}` : "unknown error";
throw new Error(`Failed to load Python module ${module.path}: ${details}`);
}
}
return modules;
}
+1 -1
View File
@@ -544,7 +544,7 @@ function shouldPersistResponseItemForMemories(message: AgentMessage): boolean {
}
if (role !== "toolResult") return false;
const toolName = (message as { toolName?: string }).toolName;
if (toolName === "bash" || toolName === "python" || toolName === "read" || toolName === "search") {
if (toolName === "bash" || toolName === "eval" || toolName === "read" || toolName === "search") {
const text = extractMessageText(message);
return text.length > 0 && text.length <= 32_000;
}
@@ -104,7 +104,7 @@ export function mapToolKind(toolName: string): ToolKind {
case "move":
return "move";
case "bash":
case "python":
case "eval":
return "execute";
case "search":
case "find":
@@ -1,6 +1,6 @@
/**
* Component for displaying user-initiated Python execution with streaming output.
* Shares the same kernel session as the agent's Python tool.
* Component for displaying user-initiated eval execution with streaming output.
* Shares the same kernel session as the agent's eval tool.
*/
import { sanitizeText } from "@oh-my-pi/pi-natives";
@@ -13,7 +13,9 @@ import { truncateToVisualLines } from "./visual-truncate";
const PREVIEW_LINES = 20;
const MAX_DISPLAY_LINE_CHARS = 4000;
export class PythonExecutionComponent extends Container {
export type EvalExecutionLanguage = "python" | "js";
export class EvalExecutionComponent extends Container {
#outputLines: string[] = [];
#status: "running" | "complete" | "cancelled" | "error" = "running";
#exitCode: number | undefined = undefined;
@@ -22,10 +24,14 @@ export class PythonExecutionComponent extends Container {
#expanded = false;
#contentContainer: Container;
#highlightLang(): "python" | "javascript" {
return this.language === "js" ? "javascript" : "python";
}
#formatHeader(colorKey: "dim" | "pythonMode"): Text {
const prompt = theme.fg(colorKey, theme.bold(">>>"));
const continuation = theme.fg(colorKey, " ");
const codeLines = highlightCode(this.code, "python");
const codeLines = highlightCode(this.code, this.#highlightLang());
const headerLines = codeLines.map((line, index) =>
index === 0 ? `${prompt} ${line}` : `${continuation}${line}`,
);
@@ -36,6 +42,7 @@ export class PythonExecutionComponent extends Container {
private readonly code: string,
ui: TUI,
private readonly excludeFromContext = false,
private readonly language: EvalExecutionLanguage = "python",
) {
super();
@@ -18,6 +18,7 @@ import { EDIT_MODE_STRATEGIES, type EditMode, type PerFileDiffPreview } from "..
import type { Theme } from "../../modes/theme/theme";
import { theme } from "../../modes/theme/theme";
import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash";
import { EVAL_DEFAULT_PREVIEW_LINES } from "../../tools/eval";
import {
formatArgsInline,
JSON_TREE_MAX_DEPTH_COLLAPSED,
@@ -28,7 +29,6 @@ import {
JSON_TREE_SCALAR_LEN_EXPANDED,
renderJsonTreeLines,
} from "../../tools/json-tree";
import { PYTHON_DEFAULT_PREVIEW_LINES } from "../../tools/python";
import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
import { toolRenderers } from "../../tools/renderers";
import { renderStatusLine } from "../../tui";
@@ -668,12 +668,11 @@ export class ToolExecutionComponent extends Container {
context.expanded = this.#expanded;
context.previewLines = BASH_DEFAULT_PREVIEW_LINES;
context.timeout = normalizeTimeoutSeconds(this.#args?.timeout, 3600);
} else if (this.#toolName === "python" && this.#result) {
} else if (this.#toolName === "eval" && this.#result) {
const output = this.#getTextOutput().trimEnd();
context.output = output;
context.expanded = this.#expanded;
context.previewLines = PYTHON_DEFAULT_PREVIEW_LINES;
context.timeout = normalizeTimeoutSeconds(this.#args?.timeout, 600);
context.previewLines = EVAL_DEFAULT_PREVIEW_LINES;
} else if (isEditLikeToolName(this.#toolName)) {
context.editMode = this.#editMode;
const previews = this.#editDiffPreview;
@@ -14,14 +14,14 @@ import { formatDuration, Snowflake, setProjectDir } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
import { reset as resetCapabilities } from "../../capability";
import { clearClaudePluginRootsCache } from "../../discovery/helpers";
import { getGatewayStatus } from "../../eval/py/gateway-coordinator";
import { loadCustomShare } from "../../export/custom-share";
import type { CompactOptions } from "../../extensibility/extensions/types";
import { getGatewayStatus } from "../../ipy/gateway-coordinator";
import { buildMemoryToolDeveloperInstructions, clearMemoryData, enqueueMemoryConsolidation } from "../../memories";
import { BashExecutionComponent } from "../../modes/components/bash-execution";
import { BorderedLoader } from "../../modes/components/bordered-loader";
import { DynamicBorder } from "../../modes/components/dynamic-border";
import { PythonExecutionComponent } from "../../modes/components/python-execution";
import { EvalExecutionComponent } from "../../modes/components/eval-execution";
import { getMarkdownTheme, getSymbolTheme, theme } from "../../modes/theme/theme";
import type { InteractiveModeContext } from "../../modes/types";
import { computeContextBreakdown, renderContextUsage } from "../../modes/utils/context-usage";
@@ -285,9 +285,26 @@ export class CommandController {
this.#doCopy(combined, `Copied ${matches.length} code block${matches.length > 1 ? "s" : ""} to clipboard`);
}
#extractEvalCode(args: unknown): string | undefined {
if (!args || typeof args !== "object") return undefined;
const cells = (args as { cells?: unknown }).cells;
if (!Array.isArray(cells)) return undefined;
const codeBlocks: string[] = [];
for (const cell of cells) {
if (!cell || typeof cell !== "object") continue;
const code = (cell as { code?: unknown }).code;
if (typeof code === "string" && code.length > 0) {
codeBlocks.push(code);
}
}
return codeBlocks.length > 0 ? codeBlocks.join("\n\n") : undefined;
}
#copyLastCommand() {
const messages = this.ctx.session.messages;
// Walk backwards to find the last bash/python tool call
// Walk backwards to find the last bash/eval tool call
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role !== "assistant") continue;
@@ -298,13 +315,16 @@ export class CommandController {
this.#doCopy(tc.arguments.command, "Copied last bash command to clipboard");
return;
}
if (tc.name === "python" && typeof tc.arguments.code === "string") {
this.#doCopy(tc.arguments.code, "Copied last python code to clipboard");
return;
if (tc.name === "eval") {
const code = this.#extractEvalCode(tc.arguments);
if (code) {
this.#doCopy(code, "Copied last eval code to clipboard");
return;
}
}
}
}
this.ctx.showWarning("No bash or python command found in the conversation.");
this.ctx.showWarning("No bash or eval command found in the conversation.");
}
#doCopy(content: string, label: string) {
@@ -779,7 +799,7 @@ export class CommandController {
async handlePythonCommand(code: string, excludeFromContext = false): Promise<void> {
const isDeferred = this.ctx.session.isStreaming;
this.ctx.pythonComponent = new PythonExecutionComponent(code, this.ctx.ui, excludeFromContext);
this.ctx.pythonComponent = new EvalExecutionComponent(code, this.ctx.ui, excludeFromContext);
if (isDeferred) {
this.ctx.pendingMessagesContainer.addChild(this.ctx.pythonComponent);
@@ -37,7 +37,7 @@ export class InputController {
this.ctx.session.isCompacting ||
this.ctx.session.isGeneratingHandoff ||
this.ctx.session.isBashRunning ||
this.ctx.session.isPythonRunning ||
this.ctx.session.isEvalRunning ||
this.ctx.autoCompactionLoader ||
this.ctx.retryLoader ||
this.ctx.autoCompactionEscapeHandler ||
@@ -67,8 +67,8 @@ export class InputController {
this.ctx.editor.setText("");
this.ctx.isBashMode = false;
this.ctx.updateEditorBorderColor();
} else if (this.ctx.session.isPythonRunning) {
this.ctx.session.abortPython();
} else if (this.ctx.session.isEvalRunning) {
this.ctx.session.abortEval();
} else if (this.ctx.isPythonMode) {
this.ctx.editor.setText("");
this.ctx.isPythonMode = false;
@@ -304,7 +304,7 @@ export class InputController {
const isExcluded = text.startsWith("$$");
const code = isExcluded ? text.slice(2).trim() : text.slice(1).trim();
if (code) {
if (this.ctx.session.isPythonRunning) {
if (this.ctx.session.isEvalRunning) {
this.ctx.showWarning("A Python execution is already running. Press Esc to cancel it first.");
this.ctx.editor.setText(text);
return;
@@ -57,10 +57,10 @@ import type { AssistantMessageComponent } from "./components/assistant-message";
import type { BashExecutionComponent } from "./components/bash-execution";
import { CustomEditor } from "./components/custom-editor";
import { DynamicBorder } from "./components/dynamic-border";
import type { EvalExecutionComponent } from "./components/eval-execution";
import type { HookEditorComponent } from "./components/hook-editor";
import type { HookInputComponent } from "./components/hook-input";
import type { HookSelectorComponent } from "./components/hook-selector";
import type { PythonExecutionComponent } from "./components/python-execution";
import { StatusLineComponent } from "./components/status-line";
import type { ToolExecutionHandle } from "./components/tool-execution";
import { WelcomeComponent, type LspServerInfo as WelcomeLspServerInfo } from "./components/welcome";
@@ -166,8 +166,8 @@ export class InteractiveMode implements InteractiveModeContext {
pendingTools = new Map<string, ToolExecutionHandle>();
pendingBashComponents: BashExecutionComponent[] = [];
bashComponent: BashExecutionComponent | undefined = undefined;
pendingPythonComponents: PythonExecutionComponent[] = [];
pythonComponent: PythonExecutionComponent | undefined = undefined;
pendingPythonComponents: EvalExecutionComponent[] = [];
pythonComponent: EvalExecutionComponent | undefined = undefined;
isPythonMode = false;
streamingComponent: AssistantMessageComponent | undefined = undefined;
streamingMessage: AssistantMessage | undefined = undefined;
+3 -3
View File
@@ -18,10 +18,10 @@ import type { ExitPlanModeDetails, LspStartupServerInfo } from "../tools";
import type { AssistantMessageComponent } from "./components/assistant-message";
import type { BashExecutionComponent } from "./components/bash-execution";
import type { CustomEditor } from "./components/custom-editor";
import type { EvalExecutionComponent } from "./components/eval-execution";
import type { HookEditorComponent } from "./components/hook-editor";
import type { HookInputComponent } from "./components/hook-input";
import type { HookSelectorComponent } from "./components/hook-selector";
import type { PythonExecutionComponent } from "./components/python-execution";
import type { StatusLineComponent } from "./components/status-line";
import type { ToolExecutionHandle } from "./components/tool-execution";
import type { OAuthManualInputManager } from "./oauth-manual-input";
@@ -93,8 +93,8 @@ export interface InteractiveModeContext {
pendingTools: Map<string, ToolExecutionHandle>;
pendingBashComponents: BashExecutionComponent[];
bashComponent: BashExecutionComponent | undefined;
pendingPythonComponents: PythonExecutionComponent[];
pythonComponent: PythonExecutionComponent | undefined;
pendingPythonComponents: EvalExecutionComponent[];
pythonComponent: EvalExecutionComponent | undefined;
isPythonMode: boolean;
streamingComponent: AssistantMessageComponent | undefined;
streamingMessage: AssistantMessage | undefined;
@@ -8,7 +8,7 @@ import { BranchSummaryMessageComponent } from "../../modes/components/branch-sum
import { CompactionSummaryMessageComponent } from "../../modes/components/compaction-summary-message";
import { CustomMessageComponent } from "../../modes/components/custom-message";
import { DynamicBorder } from "../../modes/components/dynamic-border";
import { PythonExecutionComponent } from "../../modes/components/python-execution";
import { EvalExecutionComponent } from "../../modes/components/eval-execution";
import { ReadToolGroupComponent } from "../../modes/components/read-tool-group";
import { SkillMessageComponent } from "../../modes/components/skill-message";
import { ToolExecutionComponent } from "../../modes/components/tool-execution";
@@ -84,7 +84,7 @@ export class UiHelpers {
break;
}
case "pythonExecution": {
const component = new PythonExecutionComponent(message.code, this.ctx.ui, message.excludeFromContext);
const component = new EvalExecutionComponent(message.code, this.ctx.ui, message.excludeFromContext);
if (message.output) {
component.appendOutput(message.output);
}
@@ -216,12 +216,12 @@ Most tools have a `{{intentField}}` parameter. Fill it with a concise intent in
If the task may involve external systems, SaaS APIs, chat, tickets, databases, deployments, or other non-local integrations, you **SHOULD** call `{{toolRefs.search_tool_bm25}}` before concluding no such tool exists.
{{/if}}
{{#ifAny (includes tools "python") (includes tools "bash")}}
{{#ifAny (includes tools "eval") (includes tools "bash")}}
### Tool priority
1. Use specialized tools first{{#ifAny (includes tools "read") (includes tools "search") (includes tools "find") (includes tools "edit") (includes tools "lsp")}}: {{#has tools "read"}}`{{toolRefs.read}}`, {{/has}}{{#has tools "search"}}`{{toolRefs.search}}`, {{/has}}{{#has tools "find"}}`{{toolRefs.find}}`, {{/has}}{{#has tools "edit"}}`{{toolRefs.edit}}`, {{/has}}{{#has tools "lsp"}}`{{toolRefs.lsp}}`{{/has}}{{/ifAny}}
2. Python: logic, loops, processing, display
2. Eval: logic, loops, processing, display (default python; pass `language: "js"` for in-process JavaScript)
3. Bash: simple one-liners only
You **MUST NOT** use Python or Bash when a specialized tool exists.
You **MUST NOT** use Eval or Bash when a specialized tool exists.
{{/ifAny}}
{{#ifAny (includes tools "read") (includes tools "write") (includes tools "search") (includes tools "find") (includes tools "edit")}}
@@ -0,0 +1,92 @@
Run code in a persistent kernel, using a series of codeblocks acting as cells.
<instruction>
Each cell is a markdown fenced code block. The opening fence's info string carries metadata:
```
<lang>? <duration>? (title-fragment | key=value)*
```
- **Language**: {{#if py}}`py`/`python` for Python{{/if}}{{#ifAll py js}}, {{/ifAll}}{{#if js}}`js`/`javascript`/`ts`/`typescript` for JavaScript{{/if}}.{{#ifAll py js}} Omitted → inherit the previous cell's language (the first cell defaults to Python, falling back to JavaScript when Python is unavailable).{{else}} Omitted → inherit the previous cell's language.{{/ifAll}}
- **Positional duration**: `15s`, `500ms`, `2m`, or a bare integer (seconds). Default 30s.
- **Attributes**:
- `id="…"` — cell id (shown as the title in the transcript).
- `t=<duration>` — overrides the positional duration.
- `rst=true` — wipe **this cell's own language kernel** before running.{{#ifAll py js}} Other languages are untouched.{{/ifAll}}
**Work incrementally:** one logical step per cell (imports, define, test, use). Pass multiple small cells in one call. Define small reusable functions you can debug individually. You **MUST** put workflow explanations in the assistant message or cell title — never inside cell code.
**On failure:** errors identify the failing cell (e.g., "Cell 3 failed"). Resubmit only the fixed cell (or fixed cell + remaining cells).
</instruction>
<prelude>
{{#ifAll py js}}The same helpers are available in both runtimes with the same positional argument order. Python takes the trailing options as keyword args; JavaScript takes the same options as a trailing object literal. JavaScript helpers are async and `await`able; Python helpers run synchronously.{{else}}{{#if py}}Helpers run synchronously. Trailing options are passed as keyword arguments.{{/if}}{{#if js}}Helpers are async and `await`able. Trailing options are passed as a final object literal.{{/if}}{{/ifAll}}
```
display(value) → None
Render a value in the current cell output.
print(value, ...) → None
Print to the cell's text output.
read(path, offset?=1, limit?=None) → str
Read file contents as text. offset/limit are 1-indexed line bounds.
write(path, content) → str
Write content to a file (creates parent directories). Returns the resolved path.
append(path, content) → str
Append content to a file. Returns the resolved path.
stat(path) → {path, size, is_file, is_dir, mtime}
File or directory metadata. mtime is an ISO-8601 string.
find(pattern, path?=".", type?="file", limit?=1000, hidden?=False, sort_by_mtime?=False, maxdepth?=None, mindepth?=None) → list[path]
Recursive glob find. Respects .gitignore.
glob(pattern, path?=".", hidden?=False) → list[path]
Non-recursive glob. Use find() for recursive walks. Respects .gitignore.
grep(pattern, path, ignore_case?=False, literal?=False, context?=0) → list[{line, text}]
Search a single file.
rgrep(pattern, path?=".", glob_pattern?="*", ignore_case?=False, literal?=False, limit?=100, hidden?=False) → list[{file, line, text}]
Search recursively across files. Respects .gitignore.
sed(path, pattern, repl, flags?=0) → int
Regex replace in a file (like sed -i). Returns replacement count.
tree(path?=".", max_depth?=3, show_hidden?=False) → str
Render a directory tree.
diff(a, b) → str
Unified diff between two files.
run(cmd, cwd?=None, timeout?=None) → {stdout, stderr, exit_code}
Run a shell command.
env(key?=None, value?=None) → str | None | dict
No args → full environment as dict. One arg → value of `key`. Two args → set `key=value` and return value.
output(*ids, format?="raw", query?=None, offset?=None, limit?=None) → str | dict | list[dict]
Read task/agent output by ID. Single id returns text/dict; multiple ids return a list.
```
{{#if js}}**JavaScript only:** `tool.<name>(args)` invokes any session tool directly (e.g. `await tool.read({ path: "src/foo.ts" })`).
{{/if}}</prelude>
<output>
Cells render like a Jupyter notebook. Pass any value to `display(value)`; non-presentable data is rendered as an interactive JSON tree, and presentable values (figures, images, dataframes, etc.) render with their native representation.
</output>
<caution>
- In session mode, use `rst=true` on a cell to wipe its language's kernel before running.{{#ifAll py js}} Reset is per-language: a python cell's `rst=true` does not touch the JavaScript kernel and vice versa.{{/ifAll}}
{{#if js}}- **js**: the VM exposes a selective `process` subset, Web APIs, `Buffer`, `fs/promises`.
{{/if}}</caution>
<example>
{{#if py}}```py id="imports" t="10s"
import json
from pathlib import Path
```
```py id="load config"
data = json.loads(read('package.json'))
display(data)
```
{{/if}}{{#ifAll py js}}
{{/ifAll}}{{#if js}}```js id="js summary" rst=true
const data = JSON.parse(await read('package.json'));
display(data);
return data.name;
```
```
return 'still JavaScript';
```
{{/if}}
</example>
@@ -9,8 +9,11 @@ Interacts with Language Server Protocol servers for code intelligence.
- `hover`: Get type info and documentation → type signature + docs
- `symbols`: List symbols in a file, or search workspace with `file: "*"` and a `query`
- `rename`: Rename symbol across codebase → preview or apply edits
- `rename_file`: Rename or move a file/directory; sends `workspace/willRenameFiles` so LSP servers update import paths and other references → preview or apply edits + filesystem rename
- `code_actions`: List available quick-fixes/refactors/import actions; apply one when `apply: true` and `query` matches title or index
- `status`: Show active language servers
- `capabilities`: Dump per-server capabilities (standard + experimental + executeCommand list) for discovery — file scopes to one server, omitted/`"*"` lists every active server
- `request`: Send a raw LSP request to a server — `query` is the method name (e.g., `rust-analyzer/expandMacro`, `typescript/goToSourceDefinition`, `workspace/executeCommand`); use `payload` for arbitrary JSON params or let the tool auto-build them from `file`/`line`/`symbol`
- `reload`: Restart a specific server (via `file`) or all servers with `file: "*"`
</operations>
@@ -18,9 +21,10 @@ Interacts with Language Server Protocol servers for code intelligence.
- `file`: File path, glob pattern (e.g. `src/**/*.ts`), or `"*"` for workspace scope. Globs are expanded locally before dispatch. `"*"` routes `diagnostics`/`symbols`/`reload` to their workspace-wide form.
- `line`: 1-indexed line number for position-based actions
- `symbol`: Substring on the target line used to resolve column automatically. Append `#N` to pick the Nth occurrence on that line (1-indexed; default 1) — e.g. `foo#2` selects the second `foo`.
- `query`: Symbol search query, code-action kind filter (list mode), or code-action selector (apply mode)
- `new_name`: Required for rename
- `apply`: Apply edits for rename/code_actions (default true for rename, list mode for code_actions unless explicitly true)
- `query`: Symbol search query, code-action kind filter / selector (list/apply mode), or LSP method name when `action: request`
- `new_name`: Required for `rename` (new symbol identifier) and `rename_file` (destination path)
- `apply`: Apply edits for rename/rename_file/code_actions (default true for rename and rename_file; list mode for code_actions unless explicitly true)
- `payload`: JSON-encoded params for `action: request`. Overrides the auto-built `{ textDocument, position }` shape when present.
- `timeout`: Request timeout in seconds (clamped to 5-60, default 20)
</parameters>
@@ -1,57 +0,0 @@
Runs Python cells sequentially in persistent IPython kernel.
<instruction>
Kernel persists across calls and cells; **imports, variables, and functions survive — use this.**
**Work incrementally:** one logical step per cell (imports, define, test, use). Pass multiple small cells in one call. Define small reusable functions you can debug individually. You **MUST** put workflow explanations in the assistant message or cell title — never inside cell code.
**On failure:** errors identify the failing cell (e.g., "Cell 3 failed"). Resubmit only the fixed cell (or fixed cell + remaining cells).
</instruction>
{{#if categories.length}}
<prelude>
All helpers auto-print results and return values for chaining.
{{#each categories}}
### {{name}}
```
{{#each functions}}
{{name}}{{signature}}
{{docstring}}
{{/each}}
```
{{/each}}
</prelude>
{{/if}}
<output>
User sees output like Jupyter notebook; rich displays render fully:
- `display(JSON(data))` → interactive JSON tree
- `display(HTML(…))` → rendered HTML
- `display(Markdown(…))` → formatted markdown
- `plt.show()` → inline figures
**You will see object repr** (e.g., `<IPython.core.display.JSON object>`). Trust `display()`; you **MUST NOT** assume the user sees only the repr.
</output>
<caution>
- Per-call mode uses a fresh kernel each call
- You **MUST** use `reset: true` to clear state when session mode is active
</caution>
<critical>
- You **MUST** use `run()` for shell commands; you **MUST NOT** use raw `subprocess`
</critical>
<examples>
# Multiple small cells
```python
cells: [
{"title": "imports", "code": "import json\nfrom pathlib import Path"},
{"title": "parse helper", "code": "def parse_config(path):\n return json.loads(Path(path).read_text())"},
{"title": "test helper", "code": "parse_config('config.json')"},
{"title": "use helper", "code": "configs = [parse_config(p) for p in Path('.').glob('*.json')]"}
]
```
</examples>
+11 -10
View File
@@ -36,6 +36,7 @@ import { CursorExecHandlers } from "./cursor";
import "./discovery";
import { resolveConfigValue } from "./config/resolve-config-value";
import { initializeWithSettings } from "./discovery";
import { disposeAllKernelSessions, disposeKernelSessionsByOwner } from "./eval/py/executor";
import { TtsrManager } from "./export/ttsr";
import {
type CustomCommandsLoadResult,
@@ -73,7 +74,6 @@ import {
RuleProtocolHandler,
SkillProtocolHandler,
} from "./internal-urls";
import { disposeAllKernelSessions, disposeKernelSessionsByOwner } from "./ipy/executor";
import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "./lsp/startup-events";
import { discoverAndLoadMCPTools, type MCPManager, type MCPToolsLoadResult } from "./mcp";
import {
@@ -113,13 +113,13 @@ import {
createTools,
discoverStartupLspServers,
EditTool,
EvalTool,
FindTool,
getSearchTools,
HIDDEN_TOOLS,
isSearchProviderPreference,
type LspStartupServerInfo,
loadSshTool,
PythonTool,
ReadTool,
ResolveTool,
renderSearchToolBm25Description,
@@ -277,10 +277,10 @@ export {
BUILTIN_TOOLS,
createTools,
EditTool,
EvalTool,
FindTool,
HIDDEN_TOOLS,
loadSshTool,
PythonTool,
ReadTool,
ResolveTool,
SearchTool,
@@ -936,7 +936,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const resolvedAgentId = options.agentId ?? options.parentTaskPrefix ?? MAIN_AGENT_ID;
const resolvedAgentDisplayName =
options.agentDisplayName ?? ((options.taskDepth ?? 0) > 0 || options.parentTaskPrefix ? "sub" : "main");
const pythonKernelOwnerId = `agent-session:${Snowflake.next()}`;
const evalKernelOwnerId = `agent-session:${Snowflake.next()}`;
try {
const getActiveModelString = (): string | undefined => {
@@ -964,12 +964,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
requireYieldTool: options.requireYieldTool,
taskDepth: options.taskDepth ?? 0,
getSessionFile: () => sessionManager.getSessionFile() ?? null,
getPythonKernelOwnerId: () => pythonKernelOwnerId,
assertPythonExecutionAllowed: () => session?.assertPythonExecutionAllowed(),
trackPythonExecution: (execution, abortController) =>
session ? session.trackPythonExecution(execution, abortController) : execution,
getEvalKernelOwnerId: () => evalKernelOwnerId,
assertEvalExecutionAllowed: () => session?.assertEvalExecutionAllowed(),
trackEvalExecution: (execution, abortController) =>
session ? session.trackEvalExecution(execution, abortController) : execution,
getSessionId: () => sessionManager.getSessionId?.() ?? null,
getAgentId: () => resolvedAgentId,
getToolByName: name => session?.getToolByName(name),
agentRegistry,
getSessionSpawns: () => options.spawns ?? "*",
getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined),
@@ -1614,7 +1615,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
thinkingLevel,
sessionManager,
settings,
pythonKernelOwnerId,
evalKernelOwnerId,
scopedModels: options.scopedModels,
promptTemplates,
slashCommands,
@@ -1797,7 +1798,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
if (hasSession) {
await session.dispose();
} else {
await disposeKernelSessionsByOwner(pythonKernelOwnerId);
await disposeKernelSessionsByOwner(evalKernelOwnerId);
}
} catch (cleanupError) {
logger.warn("Failed to clean up createAgentSession resources after startup error", {
@@ -68,6 +68,11 @@ import {
import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
import type { Settings, SkillsSettings } from "../config/settings";
import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
import {
disposeKernelSessionsByOwner,
executePython as executePythonCommand,
type PythonResult,
} from "../eval/py/executor";
import { type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor";
import { exportSessionToHtml } from "../export/html";
import type { TtsrManager, TtsrMatchContext } from "../export/ttsr";
@@ -98,11 +103,6 @@ import type { HookCommandContext } from "../extensibility/hooks/types";
import type { Skill, SkillWarning } from "../extensibility/skills";
import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
import {
disposeKernelSessionsByOwner,
executePython as executePythonCommand,
type PythonResult,
} from "../ipy/executor";
import {
buildDiscoverableMCPSearchIndex,
collectDiscoverableMCPTools,
@@ -259,7 +259,7 @@ export interface AgentSessionConfig {
/** Secret obfuscator for deobfuscating streaming edit content */
obfuscator?: SecretObfuscator;
/** Logical owner for retained Python kernels created by this session. */
pythonKernelOwnerId?: string;
evalKernelOwnerId?: string;
/** Agent identity (registry id like "0-Main" or "3-Alice") used for IRC routing. */
agentId?: string;
/** Shared agent registry (for forwarding IRC observations to the main session UI). */
@@ -474,11 +474,11 @@ export class AgentSession {
#pendingBashMessages: BashExecutionMessage[] = [];
// Python execution state
#pythonAbortControllers = new Set<AbortController>();
#pythonKernelOwnerId: string;
#evalAbortControllers = new Set<AbortController>();
#evalKernelOwnerId: string;
#pendingPythonMessages: PythonExecutionMessage[] = [];
#activePythonExecutions = new Set<Promise<unknown>>();
#pythonExecutionDisposing = false;
#activeEvalExecutions = new Set<Promise<unknown>>();
#evalExecutionDisposing = false;
// Background-channel IRC exchanges queued while the recipient was streaming.
// Drained into history (via emitExternalEvent) once the recipient becomes idle.
@@ -577,7 +577,7 @@ export class AgentSession {
this.settings = config.settings;
this.#startPowerAssertion();
this.#asyncJobManager = config.asyncJobManager;
this.#pythonKernelOwnerId = config.pythonKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
this.#evalKernelOwnerId = config.evalKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
this.#scopedModels = config.scopedModels ?? [];
this.#thinkingLevel = config.thinkingLevel;
this.#promptTemplates = config.promptTemplates ?? [];
@@ -1938,7 +1938,7 @@ export class AgentSession {
* Call this when completely done with the session.
*/
async dispose(): Promise<void> {
this.#pythonExecutionDisposing = true;
this.#evalExecutionDisposing = true;
try {
if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
await this.#extensionRunner.emit({ type: "session_shutdown" });
@@ -1953,13 +1953,13 @@ export class AgentSession {
if (drained === false && deliveryState) {
logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState });
}
const pythonExecutionsSettled = await this.#preparePythonExecutionsForDispose();
const pythonExecutionsSettled = await this.#prepareEvalExecutionsForDispose();
if (!pythonExecutionsSettled) {
logger.warn(
"Detaching retained Python kernel ownership during dispose while Python execution is still active",
);
}
await disposeKernelSessionsByOwner(this.#pythonKernelOwnerId);
await disposeKernelSessionsByOwner(this.#evalKernelOwnerId);
this.#stopPowerAssertion();
await this.sessionManager.close();
this.#closeAllProviderSessions("dispose");
@@ -3423,7 +3423,7 @@ export class AgentSession {
this.abortCompaction();
this.abortHandoff();
this.abortBash();
this.abortPython();
this.abortEval();
const postPromptDrain = this.#cancelPostPromptTasks();
this.agent.abort();
await postPromptDrain;
@@ -5895,7 +5895,7 @@ export class AgentSession {
/**
* Execute Python code in the shared kernel.
* Uses the same kernel session as the agent's Python tool, allowing collaborative editing.
* Uses the same kernel session as eval's Python backend, allowing collaborative editing.
* @param code The Python code to execute
* @param onChunk Optional streaming callback for output
* @param options.excludeFromContext If true, execution won't be sent to LLM ($$ prefix)
@@ -5907,7 +5907,7 @@ export class AgentSession {
): Promise<PythonResult> {
const excludeFromContext = options?.excludeFromContext === true;
const cwd = this.sessionManager.getCwd();
this.assertPythonExecutionAllowed();
this.assertEvalExecutionAllowed();
const abortController = new AbortController();
const execution = (async (): Promise<PythonResult> => {
@@ -5918,20 +5918,20 @@ export class AgentSession {
excludeFromContext,
cwd,
});
this.assertPythonExecutionAllowed();
this.assertEvalExecutionAllowed();
if (hookResult?.result) {
this.recordPythonResult(code, hookResult.result, options);
return hookResult.result;
}
}
// Use the same session ID as the Python tool for kernel sharing
// Use the same session ID as eval's Python backend for kernel sharing
const sessionFile = this.sessionManager.getSessionFile();
const sessionId = sessionFile ? `session:${sessionFile}:cwd:${cwd}` : `cwd:${cwd}`;
const result = await executePythonCommand(code, {
cwd,
sessionId,
kernelOwnerId: this.#pythonKernelOwnerId,
kernelOwnerId: this.#evalKernelOwnerId,
kernelMode: this.settings.get("python.kernelMode"),
useSharedGateway: this.settings.get("python.sharedGateway"),
onChunk,
@@ -5940,11 +5940,11 @@ export class AgentSession {
this.recordPythonResult(code, result, options);
return result;
})();
return await this.trackPythonExecution(execution, abortController);
return await this.trackEvalExecution(execution, abortController);
}
assertPythonExecutionAllowed(): void {
if (this.#pythonExecutionDisposing) {
assertEvalExecutionAllowed(): void {
if (this.#evalExecutionDisposing) {
throw new Error("Python execution is unavailable while session disposal is in progress");
}
}
@@ -5952,17 +5952,17 @@ export class AgentSession {
/**
* Track Python work started outside AgentSession.executePython so dispose can await and abort it too.
*/
trackPythonExecution<T>(execution: Promise<T>, abortController: AbortController): Promise<T> {
this.#pythonAbortControllers.add(abortController);
this.#activePythonExecutions.add(execution);
trackEvalExecution<T>(execution: Promise<T>, abortController: AbortController): Promise<T> {
this.#evalAbortControllers.add(abortController);
this.#activeEvalExecutions.add(execution);
void execution.then(
() => {
this.#pythonAbortControllers.delete(abortController);
this.#activePythonExecutions.delete(execution);
this.#evalAbortControllers.delete(abortController);
this.#activeEvalExecutions.delete(execution);
},
() => {
this.#pythonAbortControllers.delete(abortController);
this.#activePythonExecutions.delete(execution);
this.#evalAbortControllers.delete(abortController);
this.#activeEvalExecutions.delete(execution);
},
);
return execution;
@@ -5997,35 +5997,35 @@ export class AgentSession {
/**
* Cancel running Python execution.
*/
abortPython(): void {
for (const abortController of this.#pythonAbortControllers) {
abortEval(): void {
for (const abortController of this.#evalAbortControllers) {
abortController.abort();
}
}
async #waitForPythonExecutionsToSettle(timeoutMs: number): Promise<boolean> {
async #waitForEvalExecutionsToSettle(timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (this.#activePythonExecutions.size > 0) {
while (this.#activeEvalExecutions.size > 0) {
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
return false;
}
const settled = await Promise.race([
Promise.allSettled(Array.from(this.#activePythonExecutions)).then(() => true),
Promise.allSettled(Array.from(this.#activeEvalExecutions)).then(() => true),
Bun.sleep(remainingMs).then(() => false),
]);
if (!settled && this.#activePythonExecutions.size > 0) {
if (!settled && this.#activeEvalExecutions.size > 0) {
return false;
}
}
return true;
}
async #preparePythonExecutionsForDispose(): Promise<boolean> {
if (!(await this.#waitForPythonExecutionsToSettle(3_000))) {
async #prepareEvalExecutionsForDispose(): Promise<boolean> {
if (!(await this.#waitForEvalExecutionsToSettle(3_000))) {
logger.warn("Aborting active Python execution during dispose before retained kernel cleanup");
this.abortPython();
if (!(await this.#waitForPythonExecutionsToSettle(1_000))) {
this.abortEval();
if (!(await this.#waitForEvalExecutionsToSettle(1_000))) {
logger.warn(
"Python execution is still active after dispose aborted all active runs; retained kernel ownership will still be detached",
);
@@ -6036,8 +6036,8 @@ export class AgentSession {
}
/** Whether a Python execution is currently running */
get isPythonRunning(): boolean {
return this.#pythonAbortControllers.size > 0;
get isEvalRunning(): boolean {
return this.#evalAbortControllers.size > 0;
}
/** Whether there are pending Python messages waiting to be flushed */
@@ -59,7 +59,7 @@ export interface BashExecutionMessage {
/**
* Message type for user-initiated Python executions via the $ command.
* Shares the same kernel session as the agent's Python tool.
* Shares the same kernel session as eval's Python backend.
*/
export interface PythonExecutionMessage {
role: "pythonExecution";
+1 -1
View File
@@ -578,7 +578,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
toolNames = Array.from(tools.keys());
} else {
// Use defaults
toolNames = ["read", "bash", "python", "edit", "write"]; // TODO: Why?
toolNames = ["read", "bash", "eval", "edit", "write"]; // TODO: Why?
}
}
+5 -9
View File
@@ -532,16 +532,12 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
if (atMaxDepth && toolNames?.includes("task")) {
toolNames = toolNames.filter(name => name !== "task");
}
const pythonToolMode = settings.get("python.toolMode") ?? "both";
if (toolNames?.includes("exec")) {
const allowEvalPy = settings.get("eval.py") ?? true;
const allowEvalJs = settings.get("eval.js") ?? true;
const expanded = toolNames.filter(name => name !== "exec");
if (pythonToolMode === "bash-only") {
expanded.push("bash");
} else if (pythonToolMode === "ipy-only") {
expanded.push("python");
} else {
expanded.push("python", "bash");
}
if (allowEvalPy || allowEvalJs) expanded.push("eval");
expanded.push("bash");
toolNames = Array.from(new Set(expanded));
}
@@ -557,7 +553,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
const lspEnabled = enableLsp ?? true;
const ircEnabled = subagentSettings.get("irc.enabled") === true;
const skipPythonPreflight = Array.isArray(toolNames) && !toolNames.includes("python");
const skipPythonPreflight = Array.isArray(toolNames) && !toolNames.includes("eval");
const outputChunks: string[] = [];
const finalOutputChunks: string[] = [];
+12 -29
View File
@@ -4,18 +4,9 @@ import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import browserDescription from "../prompts/tools/browser.md" with { type: "text" };
import type { ToolSession } from "../sdk";
import {
acquireBrowser,
acquireTab,
type BrowserHandle,
type BrowserKind,
type BrowserKindTag,
dropHeadlessBrowsers,
getTab,
releaseAllTabs,
releaseTab,
} from "./browser/registry";
import { collectObservation, formatObservation, type Observation, runInTab, type ScreenshotResult } from "./browser/vm";
import { acquireBrowser, type BrowserHandle, type BrowserKind, type BrowserKindTag } from "./browser/registry";
import type { Observation, ScreenshotResult } from "./browser/tab-protocol";
import { acquireTab, dropHeadlessTabs, getTab, releaseAllTabs, releaseTab, runInTab } from "./browser/tab-supervisor";
import type { OutputMeta } from "./output-meta";
import { resolveToCwd } from "./path-utils";
import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
@@ -23,7 +14,7 @@ import { toolResult } from "./tool-result";
import { clampTimeout } from "./tool-timeouts";
export { extractReadableFromHtml, type ReadableFormat, type ReadableResult } from "./browser/readable";
export type { Observation, ObservationEntry } from "./browser/vm";
export type { Observation, ObservationEntry } from "./browser/tab-protocol";
const DEFAULT_TAB_NAME = "main";
@@ -129,7 +120,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
/** Restart browser to apply mode changes (e.g. headless toggle). Drops only headless browsers. */
async restartForModeChange(): Promise<void> {
await dropHeadlessBrowsers();
await dropHeadlessTabs();
}
async execute(
@@ -216,10 +207,10 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
}),
);
const tab = result.tab;
const url = tab.page.url();
const title = (await untilAborted(signal, () => tab.page.title())) as string;
const url = tab.info.url;
const title = tab.info.title ?? "";
details.url = url;
details.viewport = tab.page.viewport() ?? undefined;
details.viewport = tab.info.viewport;
const verb = result.created ? "Opened" : "Reused";
const lines = [
`${verb} tab ${JSON.stringify(name)} on ${describeBrowser(browser)}`,
@@ -258,16 +249,12 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
throw new ToolError("Missing required parameter 'code' for action 'run'.");
}
const tab = getTab(name);
if (!tab) {
throw new ToolError(
`No tab named ${JSON.stringify(name)}. Call open first (e.g. action: 'open', name: '${name}').`,
);
if (tab) {
details.browser = tab.browser.kind.kind;
details.url = tab.info.url;
}
details.browser = tab.browser.kind.kind;
details.url = tab.page.url();
const { displays, returnValue, screenshots } = await runInTab({
tab,
const { displays, returnValue, screenshots } = await runInTab(name, {
code: params.code,
timeoutMs,
signal,
@@ -330,7 +317,3 @@ function stringifyReturnValue(value: unknown): string {
return String(value);
}
}
// Re-export collectObservation so external callers (e.g. tests) can use it without
// reaching into the browser/ subdirectory.
export { collectObservation, formatObservation };
@@ -63,6 +63,19 @@ export async function loadPuppeteer(): Promise<typeof Puppeteer> {
}
}
let puppeteerModuleWorker: typeof Puppeteer | undefined;
export async function loadPuppeteerInWorker(safeDir: string): Promise<typeof Puppeteer> {
if (puppeteerModuleWorker) return puppeteerModuleWorker;
const orig = process.cwd;
Object.defineProperty(process, "cwd", { value: () => safeDir, configurable: true });
try {
puppeteerModuleWorker = (await import("puppeteer-core")).default;
return puppeteerModuleWorker;
} finally {
Object.defineProperty(process, "cwd", { value: orig, configurable: true });
}
}
/**
* Lazily download Chromium on first browser launch via @puppeteer/browsers.
* Skipped when a system Chromium (NixOS) or PUPPETEER_EXECUTABLE_PATH is set.
@@ -1,24 +1,10 @@
import * as path from "node:path";
import { logger } from "@oh-my-pi/pi-utils";
import type { Subprocess } from "bun";
import type { Browser, CDPSession, ElementHandle, Page } from "puppeteer-core";
import type { Browser, CDPSession } from "puppeteer-core";
import { ToolAbortError, ToolError } from "../tool-errors";
import {
findFreeCdpPort,
findReusableCdp,
gracefulKillTreeOnce,
killExistingByPath,
pickElectronTarget,
waitForCdp,
} from "./attach";
import {
applyStealthPatches,
applyViewport,
BROWSER_PROTOCOL_TIMEOUT_MS,
launchHeadlessBrowser,
loadPuppeteer,
type UserAgentOverride,
} from "./launch";
import { findFreeCdpPort, findReusableCdp, gracefulKillTreeOnce, killExistingByPath, waitForCdp } from "./attach";
import { BROWSER_PROTOCOL_TIMEOUT_MS, launchHeadlessBrowser, loadPuppeteer, type UserAgentOverride } from "./launch";
export type BrowserKind =
| { kind: "headless"; headless: boolean }
@@ -38,28 +24,7 @@ export interface BrowserHandle {
stealth: { browserSession: CDPSession | null; override: UserAgentOverride | null };
}
export type DialogPolicy = "accept" | "dismiss";
export interface TabHandle {
name: string;
browser: BrowserHandle;
page: Page;
elementCache: Map<number, ElementHandle>;
elementCounter: number;
dialogPolicy?: DialogPolicy;
dialogHandler?: (dialog: { accept: () => Promise<void>; dismiss: () => Promise<void> }) => void;
}
const browsers = new Map<string, BrowserHandle>();
const tabs = new Map<string, TabHandle>();
export function getTab(name: string): TabHandle | undefined {
return tabs.get(name);
}
export function listTabs(): TabHandle[] {
return [...tabs.values()];
}
export function listBrowsers(): BrowserHandle[] {
return [...browsers.values()];
@@ -87,9 +52,7 @@ export async function acquireBrowser(kind: BrowserKind, opts: AcquireBrowserOpti
const key = browserKey(kind);
const existing = browsers.get(key);
if (existing) {
// Headless: connection check; spawned/connected: connection check.
if (existing.browser.connected) return existing;
// Stale handle — purge and rebuild.
browsers.delete(key);
await disposeBrowserHandle(existing, { kill: false });
}
@@ -128,7 +91,7 @@ async function openBrowserHandle(kind: BrowserKind, opts: AcquireBrowserOptions)
stealth: { browserSession: null, override: null },
};
}
// spawned
const exe = kind.path;
if (!path.isAbsolute(exe)) {
throw new ToolError(
@@ -140,18 +103,12 @@ async function openBrowserHandle(kind: BrowserKind, opts: AcquireBrowserOptions)
let pid: number;
let subprocess: Subprocess | undefined;
if (reused) {
logger.debug("Reusing existing CDP endpoint for attach", {
exe,
pid: reused.pid,
cdpUrl: reused.cdpUrl,
});
logger.debug("Reusing existing CDP endpoint for attach", { exe, pid: reused.pid, cdpUrl: reused.cdpUrl });
cdpUrl = reused.cdpUrl;
pid = reused.pid;
} else {
const killed = await killExistingByPath(exe, opts.signal);
if (killed > 0) {
logger.debug("Killed existing instances before attach", { exe, killed });
}
if (killed > 0) logger.debug("Killed existing instances before attach", { exe, killed });
const port = await findFreeCdpPort();
const launchArgs = [...(opts.appArgs ?? []), `--remote-debugging-port=${port}`];
const child = Bun.spawn([exe, ...launchArgs], {
@@ -197,167 +154,19 @@ async function openBrowserHandle(kind: BrowserKind, opts: AcquireBrowserOptions)
};
}
export interface AcquireTabOptions {
url?: string;
waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2";
viewport?: { width: number; height: number; deviceScaleFactor?: number };
target?: string;
signal?: AbortSignal;
timeoutMs: number;
dialogs?: DialogPolicy;
export function holdBrowser(handle: BrowserHandle): void {
handle.refCount++;
}
export interface AcquireTabResult {
tab: TabHandle;
created: boolean;
}
export async function acquireTab(
name: string,
browser: BrowserHandle,
opts: AcquireTabOptions,
): Promise<AcquireTabResult> {
const existing = tabs.get(name);
if (existing) {
if (existing.browser !== browser) {
throw new ToolError(
`Tab ${JSON.stringify(name)} already exists on a different browser (${existing.browser.kind.kind}). Close it first.`,
);
}
if (!existing.page.isClosed()) {
if (opts.dialogs !== undefined) applyDialogPolicy(existing, opts.dialogs);
if (opts.url) {
clearElementCache(existing);
await existing.page.goto(opts.url, {
waitUntil: opts.waitUntil ?? "networkidle2",
timeout: opts.timeoutMs,
});
}
return { tab: existing, created: false };
}
// Stale tab — purge and recreate.
tabs.delete(name);
browser.refCount = Math.max(0, browser.refCount - 1);
}
let page: Page;
if (browser.kind.kind === "headless") {
page = await browser.browser.newPage();
await applyStealthPatches(browser.browser, page, browser.stealth);
if (browser.kind.headless || opts.viewport) {
await applyViewport(page, opts.viewport);
}
} else {
// spawned/connected — don't open a new tab in the user's app; pick an existing target.
page = await pickElectronTarget(browser.browser, opts.target);
}
const tab: TabHandle = {
name,
browser,
page,
elementCache: new Map(),
elementCounter: 0,
};
tabs.set(name, tab);
browser.refCount++;
if (opts.dialogs !== undefined) applyDialogPolicy(tab, opts.dialogs);
if (opts.url) {
await page.goto(opts.url, {
waitUntil: opts.waitUntil ?? "networkidle2",
timeout: opts.timeoutMs,
});
}
return { tab, created: true };
}
export interface ReleaseTabOptions {
kill?: boolean;
}
export async function releaseTab(name: string, opts: ReleaseTabOptions = {}): Promise<boolean> {
const tab = tabs.get(name);
if (!tab) {
logger.debug("releaseTab: unknown tab", { name });
return false;
}
tabs.delete(name);
await disposeTab(tab);
tab.browser.refCount = Math.max(0, tab.browser.refCount - 1);
if (tab.browser.refCount === 0) {
browsers.delete(tab.browser.key);
await disposeBrowserHandle(tab.browser, { kill: opts.kill ?? false });
}
return true;
}
export async function releaseAllTabs(opts: ReleaseTabOptions = {}): Promise<number> {
const names = [...tabs.keys()];
let count = 0;
for (const name of names) {
if (await releaseTab(name, opts)) count++;
}
return count;
}
/** Drop only headless browsers and their tabs. Used by the headless-toggle slash command. */
export async function dropHeadlessBrowsers(): Promise<void> {
const targets = [...tabs.values()].filter(t => t.browser.kind.kind === "headless");
for (const tab of targets) {
await releaseTab(tab.name);
}
// Drop any zero-refcount headless browsers that survived (shouldn't happen, defensive).
for (const [key, browser] of browsers) {
if (browser.kind.kind === "headless" && browser.refCount === 0) {
browsers.delete(key);
await disposeBrowserHandle(browser, { kill: false });
}
export async function releaseBrowser(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
handle.refCount = Math.max(0, handle.refCount - 1);
if (handle.refCount === 0) {
browsers.delete(handle.key);
await disposeBrowserHandle(handle, opts);
}
}
function applyDialogPolicy(tab: TabHandle, policy: DialogPolicy): void {
if (tab.dialogPolicy === policy && tab.dialogHandler) return;
if (tab.dialogHandler) {
try {
tab.page.off("dialog", tab.dialogHandler);
} catch {}
}
const handler = (dialog: { accept: () => Promise<void>; dismiss: () => Promise<void> }): void => {
const action = policy === "accept" ? dialog.accept() : dialog.dismiss();
void action.catch(err => {
logger.debug("Dialog auto-handler failed", { policy, error: (err as Error).message });
});
};
tab.page.on("dialog", handler);
tab.dialogPolicy = policy;
tab.dialogHandler = handler;
}
async function disposeTab(tab: TabHandle): Promise<void> {
clearElementCache(tab);
if (tab.dialogHandler && !tab.page.isClosed()) {
try {
tab.page.off("dialog", tab.dialogHandler);
} catch {}
tab.dialogHandler = undefined;
tab.dialogPolicy = undefined;
}
if (tab.browser.kind.kind === "headless") {
// Owned tab — close it.
if (!tab.page.isClosed()) {
try {
await tab.page.close();
} catch (err) {
logger.debug("Failed to close page", { error: (err as Error).message });
}
}
}
// spawned/connected: page belongs to user's app — never close.
}
async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
export async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
if (handle.kind.kind === "headless") {
if (handle.browser.connected) {
try {
@@ -369,7 +178,6 @@ async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean
return;
}
if (handle.kind.kind === "connected") {
// Never close a remote app — only disconnect.
if (handle.browser.connected) {
try {
handle.browser.disconnect();
@@ -379,7 +187,6 @@ async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean
}
return;
}
// spawned
if (handle.browser.connected) {
try {
handle.browser.disconnect();
@@ -387,40 +194,5 @@ async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean
logger.debug("Failed to disconnect from spawned browser", { error: (err as Error).message });
}
}
if (opts.kill && handle.pid !== undefined) {
await gracefulKillTreeOnce(handle.pid);
}
}
export function clearElementCache(tab: TabHandle): void {
if (tab.elementCache.size === 0) {
tab.elementCounter = 0;
return;
}
const handles = [...tab.elementCache.values()];
tab.elementCache.clear();
tab.elementCounter = 0;
for (const handle of handles) {
// Fire and forget; disposal failures don't affect correctness.
void handle.dispose().catch(() => undefined);
}
}
export async function resolveCachedHandle(tab: TabHandle, id: number): Promise<ElementHandle> {
const handle = tab.elementCache.get(id);
if (!handle) {
throw new ToolError(`Unknown element id ${id}. Run tab.observe() to refresh the element list.`);
}
try {
const isConnected = (await handle.evaluate(el => el.isConnected)) as boolean;
if (!isConnected) {
clearElementCache(tab);
throw new ToolError(`Element id ${id} is stale. Run tab.observe() again.`);
}
} catch (err) {
if (err instanceof ToolError) throw err;
clearElementCache(tab);
throw new ToolError(`Element id ${id} is stale. Run tab.observe() again.`);
}
return handle;
if (opts.kill && handle.pid !== undefined) await gracefulKillTreeOnce(handle.pid);
}
@@ -1,7 +1,7 @@
/**
* TUI renderer for the browser tool.
*
* Mirrors the `python` tool look: each `run` invocation is shown as a JS code
* Mirrors the `eval` tool look: each `run` invocation is shown as a JS code
* cell with status icon, optional output, and expand/collapse handling. `open`
* and `close` actions render as compact status lines.
*/
@@ -0,0 +1,101 @@
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
export type Transferable = Bun.Transferable;
export interface ObservationEntry {
id: number;
role: string;
name?: string;
value?: string | number;
description?: string;
keyshortcuts?: string;
states: string[];
}
export interface Observation {
url: string;
title?: string;
viewport: { width: number; height: number; deviceScaleFactor?: number };
scroll: {
x: number;
y: number;
width: number;
height: number;
scrollWidth: number;
scrollHeight: number;
};
elements: ObservationEntry[];
}
export interface ScreenshotResult {
dest: string;
mimeType: string;
bytes: number;
width: number;
height: number;
}
export interface SessionSnapshot {
cwd: string;
browserScreenshotDir?: string;
}
export type WorkerInitPayload =
| {
mode: "headless";
browserWSEndpoint: string;
safeDir: string;
viewport?: { width: number; height: number; deviceScaleFactor?: number };
dialogs?: "accept" | "dismiss";
url?: string;
waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2";
timeoutMs: number;
}
| {
mode: "attach";
browserWSEndpoint: string;
safeDir: string;
targetId: string;
dialogs?: "accept" | "dismiss";
};
export type WorkerInbound =
| { type: "init"; payload: WorkerInitPayload }
| { type: "run"; id: string; name: string; code: string; timeoutMs: number; session: SessionSnapshot }
| { type: "abort"; id: string }
| { type: "close" };
export interface ReadyInfo {
url: string;
title?: string;
viewport: { width: number; height: number; deviceScaleFactor?: number };
targetId: string;
}
export interface RunResultOk {
displays: Array<TextContent | ImageContent>;
returnValue: unknown;
screenshots: ScreenshotResult[];
}
export interface RunErrorPayload {
name: string;
message: string;
stack?: string;
isToolError: boolean;
isAbort: boolean;
}
export type WorkerOutbound =
| { type: "ready"; info: ReadyInfo }
| { type: "init-failed"; error: RunErrorPayload }
| { type: "result"; id: string; ok: true; payload: RunResultOk }
| { type: "result"; id: string; ok: false; error: RunErrorPayload }
| { type: "log"; level: "debug" | "warn" | "error"; msg: string; meta?: Record<string, unknown> }
| { type: "closed" };
export interface Transport {
send(msg: WorkerOutbound | WorkerInbound, transferList?: Transferable[]): void;
onMessage(handler: (msg: WorkerOutbound | WorkerInbound) => void): () => void;
close(): void;
}
@@ -0,0 +1,429 @@
import { getPuppeteerDir, logger, Snowflake } from "@oh-my-pi/pi-utils";
import type { Page, Target } from "puppeteer-core";
import type { ToolSession } from "../../sdk";
import { expandPath } from "../path-utils";
import { ToolAbortError, ToolError } from "../tool-errors";
import { pickElectronTarget } from "./attach";
import { type BrowserHandle, type BrowserKindTag, holdBrowser, releaseBrowser } from "./registry";
import type {
ReadyInfo,
RunErrorPayload,
RunResultOk,
SessionSnapshot,
Transferable,
Transport,
WorkerInbound,
WorkerInitPayload,
WorkerOutbound,
} from "./tab-protocol";
import { WorkerCore } from "./tab-worker";
interface WorkerHandle {
send(msg: WorkerInbound, transferList?: Transferable[]): void;
onMessage(handler: (msg: WorkerOutbound) => void): () => void;
terminate(): Promise<void>;
readonly mode: "worker" | "inline";
}
export type DialogPolicy = "accept" | "dismiss";
export interface TabSession {
name: string;
browser: BrowserHandle;
targetId: string;
worker: WorkerHandle;
state: "alive" | "dead";
info: ReadyInfo;
pending: Map<string, { resolve: (result: RunResultOk) => void; reject: (error: unknown) => void }>;
dialogPolicy?: DialogPolicy;
kindTag: BrowserKindTag;
}
export interface AcquireTabOptions {
url?: string;
waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2";
viewport?: { width: number; height: number; deviceScaleFactor?: number };
target?: string;
signal?: AbortSignal;
timeoutMs: number;
dialogs?: DialogPolicy;
}
export interface AcquireTabResult {
tab: TabSession;
created: boolean;
}
export interface RunInTabOptions {
code: string;
timeoutMs: number;
signal?: AbortSignal;
session: ToolSession;
}
export interface ReleaseTabOptions {
kill?: boolean;
}
const tabs = new Map<string, TabSession>();
const GRACE_MS = 750;
export function getTab(name: string): TabSession | undefined {
return tabs.get(name);
}
export function listTabs(): TabSession[] {
return [...tabs.values()];
}
export async function acquireTab(
name: string,
browser: BrowserHandle,
opts: AcquireTabOptions,
): Promise<AcquireTabResult> {
const existing = tabs.get(name);
if (existing) {
if (existing.browser === browser && existing.state === "alive") {
if (opts.dialogs !== undefined && opts.dialogs !== existing.dialogPolicy) {
await releaseTab(name, { kill: false });
} else {
if (opts.url) {
await runInTabWithSnapshot(
name,
{
code: `await tab.goto(${JSON.stringify(opts.url)}, { waitUntil: ${JSON.stringify(opts.waitUntil ?? "networkidle2")} });`,
timeoutMs: opts.timeoutMs,
signal: opts.signal,
},
{ cwd: process.cwd() },
);
}
return { tab: tabs.get(name)!, created: false };
}
} else {
await releaseTab(name, { kill: false });
}
}
const initPayload = await buildInitPayload(browser, opts);
const worker = await spawnTabWorker();
const { promise, resolve, reject } = Promise.withResolvers<ReadyInfo>();
const unlisten = worker.onMessage(msg => {
if (msg.type === "ready") resolve(msg.info);
else if (msg.type === "init-failed") reject(errorFromPayload(msg.error));
else if (msg.type === "log") logWorkerMessage(msg);
});
let info: ReadyInfo;
try {
worker.send({ type: "init", payload: initPayload });
info = await raceWithTimeout(promise, opts.timeoutMs + GRACE_MS, "Timed out initializing browser tab worker");
} catch (error) {
unlisten();
await worker.terminate().catch(() => undefined);
if (browser.refCount === 0) await releaseBrowser(browser, { kill: false });
throw error;
}
unlisten();
holdBrowser(browser);
const tab: TabSession = {
name,
browser,
targetId: info.targetId,
worker,
state: "alive",
info,
pending: new Map(),
dialogPolicy: opts.dialogs,
kindTag: browser.kind.kind,
};
worker.onMessage(msg => handleTabMessage(tab, msg));
tabs.set(name, tab);
return { tab, created: true };
}
export async function runInTab(name: string, opts: RunInTabOptions): Promise<RunResultOk> {
return await runInTabWithSnapshot(
name,
{ code: opts.code, timeoutMs: opts.timeoutMs, signal: opts.signal },
{ cwd: opts.session.cwd, browserScreenshotDir: expandBrowserScreenshotDir(opts.session) },
);
}
async function runInTabWithSnapshot(
name: string,
opts: { code: string; timeoutMs: number; signal?: AbortSignal },
snapshot: SessionSnapshot,
): Promise<RunResultOk> {
const tab = tabs.get(name);
if (!tab || tab.state === "dead") throw new ToolError(`Tab ${JSON.stringify(name)} is not alive. Reopen it.`);
if (tab.pending.size > 0) throw new ToolError(`Tab ${JSON.stringify(name)} is busy`);
const id = Snowflake.next();
const { promise, resolve, reject } = Promise.withResolvers<RunResultOk>();
tab.pending.set(id, { resolve, reject });
const abort = (): void => tab.worker.send({ type: "abort", id });
if (opts.signal?.aborted) abort();
else opts.signal?.addEventListener("abort", abort, { once: true });
try {
tab.worker.send({ type: "run", id, name, code: opts.code, timeoutMs: opts.timeoutMs, session: snapshot });
return await raceWithTimeout(
promise,
opts.timeoutMs + GRACE_MS,
"Browser code execution hung past grace; tab killed",
async reason => await forceKillTab(name, reason),
);
} finally {
opts.signal?.removeEventListener("abort", abort);
tab.pending.delete(id);
}
}
export async function releaseTab(name: string, opts: ReleaseTabOptions = {}): Promise<boolean> {
const tab = tabs.get(name);
if (!tab) {
logger.debug("releaseTab: unknown tab", { name });
return false;
}
const wasAlive = tab.state === "alive";
tab.state = "dead";
const closeError = new ToolError(`Tab ${JSON.stringify(name)} was closed`);
for (const [id, pending] of tab.pending) {
try {
tab.worker.send({ type: "abort", id });
} catch {}
pending.reject(closeError);
}
tab.pending.clear();
let forced = false;
if (wasAlive) {
try {
tab.worker.send({ type: "close" });
await waitForClosed(tab);
} catch {
forced = true;
}
}
await tab.worker.terminate().catch(() => undefined);
if (forced && tab.kindTag === "headless") await closeOrphanTarget(tab);
await releaseBrowser(tab.browser, { kill: opts.kill ?? false });
tabs.delete(name);
return true;
}
export async function releaseAllTabs(opts: ReleaseTabOptions = {}): Promise<number> {
const names = [...tabs.keys()];
let count = 0;
for (const name of names) {
if (await releaseTab(name, opts)) count++;
}
return count;
}
export async function dropHeadlessTabs(): Promise<void> {
const names = [...tabs.values()].filter(tab => tab.kindTag === "headless").map(tab => tab.name);
for (const name of names) await releaseTab(name);
}
async function buildInitPayload(browser: BrowserHandle, opts: AcquireTabOptions): Promise<WorkerInitPayload> {
const safeDir = getPuppeteerDir();
const browserWSEndpoint = browser.browser.wsEndpoint();
if (!browserWSEndpoint) throw new ToolError("Browser websocket endpoint is unavailable");
if (browser.kind.kind === "headless") {
return {
mode: "headless",
browserWSEndpoint,
safeDir,
viewport: opts.viewport,
dialogs: opts.dialogs,
url: opts.url,
waitUntil: opts.waitUntil,
timeoutMs: opts.timeoutMs,
};
}
const page = await pickElectronTarget(browser.browser, opts.target);
const targetId = await targetIdForPage(page);
return {
mode: "attach",
browserWSEndpoint,
safeDir,
targetId,
dialogs: opts.dialogs,
};
}
function handleTabMessage(tab: TabSession, msg: WorkerOutbound): void {
if (msg.type === "result") {
const pending = tab.pending.get(msg.id);
if (!pending) return;
tab.pending.delete(msg.id);
if (msg.ok) {
pending.resolve(msg.payload);
return;
}
pending.reject(errorFromPayload(msg.error));
return;
}
if (msg.type === "ready") {
tab.info = msg.info;
return;
}
if (msg.type === "log") logWorkerMessage(msg);
}
async function forceKillTab(name: string, reason: string): Promise<void> {
const tab = tabs.get(name);
if (!tab) return;
tab.state = "dead";
const error = new ToolError(reason);
for (const pending of tab.pending.values()) pending.reject(error);
tab.pending.clear();
await tab.worker.terminate().catch(() => undefined);
if (tab.kindTag === "headless") await closeOrphanTarget(tab);
await releaseBrowser(tab.browser, { kill: false });
tabs.delete(name);
}
async function closeOrphanTarget(tab: TabSession): Promise<void> {
for (const target of tab.browser.browser.targets()) {
if ((await targetIdForTarget(target).catch(() => "")) !== tab.targetId) continue;
const page = await target.page().catch(() => null);
await page?.close().catch(() => undefined);
return;
}
}
async function waitForClosed(tab: TabSession): Promise<void> {
const { promise, resolve } = Promise.withResolvers<void>();
const unsubscribe = tab.worker.onMessage(msg => {
if (msg.type === "closed") resolve();
});
try {
await raceWithTimeout(promise, GRACE_MS, "Timed out closing browser tab worker");
} finally {
unsubscribe();
}
}
function expandBrowserScreenshotDir(session: ToolSession): string | undefined {
const value = session.settings.get("browser.screenshotDir") as string | undefined;
return value ? expandPath(value) : undefined;
}
async function targetIdForPage(page: Page): Promise<string> {
return await targetIdForTarget(page.target());
}
async function targetIdForTarget(target: Target): Promise<string> {
const raw = target as unknown as { _targetId?: unknown };
if (typeof raw._targetId === "string") return raw._targetId;
const session = await target.createCDPSession();
try {
const info = (await session.send("Target.getTargetInfo")) as { targetInfo?: { targetId?: string } };
if (info.targetInfo?.targetId) return info.targetInfo.targetId;
throw new ToolError("Target id unavailable from CDP target info");
} finally {
await session.detach().catch(() => undefined);
}
}
function errorFromPayload(payload: RunErrorPayload): Error {
const error = payload.isAbort
? new ToolAbortError()
: payload.isToolError
? new ToolError(payload.message)
: new Error(payload.message);
error.name = payload.name;
if (payload.stack) error.stack = payload.stack;
return error;
}
function logWorkerMessage(msg: Extract<WorkerOutbound, { type: "log" }>): void {
if (msg.level === "debug") logger.debug(msg.msg, msg.meta);
else if (msg.level === "warn") logger.warn(msg.msg, msg.meta);
else logger.error(msg.msg, msg.meta);
}
async function raceWithTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
reason: string,
onTimeout?: (reason: string) => Promise<void>,
): Promise<T> {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
const { promise: timeoutPromise, reject } = Promise.withResolvers<never>();
const onAbort = (): void => reject(new ToolError(reason));
timeoutSignal.addEventListener("abort", onAbort, { once: true });
try {
return await Promise.race([promise, timeoutPromise]);
} catch (error) {
if (error instanceof ToolError && error.message === reason) await onTimeout?.(reason);
throw error;
} finally {
timeoutSignal.removeEventListener("abort", onAbort);
}
}
async function spawnTabWorker(): Promise<WorkerHandle> {
try {
const url = new URL("./tab-worker-entry.ts", import.meta.url);
const worker = new Worker(url.href, { type: "module" });
return wrapBunWorker(worker);
} catch (err) {
logger.warn("Bun Worker spawn failed; using inline tab worker (no sync-loop guard)", {
error: err instanceof Error ? err.message : String(err),
});
return spawnInlineWorker();
}
}
function wrapBunWorker(worker: Worker): WorkerHandle {
return {
mode: "worker",
send(msg, transferList) {
worker.postMessage(msg, { transfer: transferList ?? [] });
},
onMessage(handler) {
const wrap = (event: MessageEvent): void => handler(event.data as WorkerOutbound);
worker.addEventListener("message", wrap);
return () => worker.removeEventListener("message", wrap);
},
async terminate() {
worker.terminate();
},
};
}
/**
* Inline fallback for environments where Bun cannot compile or spawn the worker
* entry. This preserves normal browser behavior but cannot interrupt synchronous
* infinite loops because user code runs on the main thread.
*/
function spawnInlineWorker(): WorkerHandle {
const hostListeners = new Set<(message: WorkerOutbound) => void>();
const workerListeners = new Set<(message: WorkerInbound) => void>();
const workerTransport: Transport = {
send: msg =>
queueMicrotask(() => {
for (const listener of hostListeners) listener(msg as WorkerOutbound);
}),
onMessage: handler => {
const typed = handler as (message: WorkerInbound) => void;
workerListeners.add(typed);
return () => workerListeners.delete(typed);
},
close: () => {},
};
new WorkerCore(workerTransport);
return {
mode: "inline",
send: msg =>
queueMicrotask(() => {
for (const listener of workerListeners) listener(msg);
}),
onMessage: handler => {
hostListeners.add(handler);
return () => hostListeners.delete(handler);
},
async terminate() {},
};
}
@@ -0,0 +1,21 @@
import { parentPort } from "node:worker_threads";
import type { Transport, WorkerInbound, WorkerOutbound } from "./tab-protocol";
import { WorkerCore } from "./tab-worker";
if (!parentPort) throw new Error("tab-worker-entry: missing parentPort");
const transport: Transport = {
send(msg, transferList) {
parentPort!.postMessage(msg, transferList ?? []);
},
onMessage(handler) {
const wrap = (message: unknown): void => handler(message as WorkerOutbound | WorkerInbound);
parentPort!.on("message", wrap);
return () => parentPort!.off("message", wrap);
},
close() {
parentPort!.close();
},
};
new WorkerCore(transport);
File diff suppressed because it is too large Load Diff
@@ -1,826 +0,0 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
import { Snowflake, untilAborted } from "@oh-my-pi/pi-utils";
import type { HTMLElement } from "linkedom";
import type { ElementHandle, HTTPResponse, KeyInput, Page, SerializedAXNode } from "puppeteer-core";
import type { ToolSession } from "../../sdk";
import { resizeImage } from "../../utils/image-resize";
import { expandPath, resolveToCwd } from "../path-utils";
import { formatScreenshot } from "../render-utils";
import { ToolAbortError, ToolError, throwIfAborted } from "../tool-errors";
import { DEFAULT_VIEWPORT } from "./launch";
import { extractReadableFromHtml, type ReadableFormat, type ReadableResult } from "./readable";
import { clearElementCache, resolveCachedHandle, type TabHandle } from "./registry";
declare global {
interface Element extends HTMLElement {}
function getComputedStyle(element: Element): Record<string, unknown>;
var innerWidth: number;
var innerHeight: number;
var document: {
elementFromPoint(x: number, y: number): Element | null;
};
}
export interface ObservationEntry {
id: number;
role: string;
name?: string;
value?: string | number;
description?: string;
keyshortcuts?: string;
states: string[];
}
export interface Observation {
url: string;
title?: string;
viewport: { width: number; height: number; deviceScaleFactor?: number };
scroll: {
x: number;
y: number;
width: number;
height: number;
scrollWidth: number;
scrollHeight: number;
};
elements: ObservationEntry[];
}
const INTERACTIVE_AX_ROLES = new Set([
"button",
"link",
"textbox",
"combobox",
"listbox",
"option",
"checkbox",
"radio",
"switch",
"tab",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"slider",
"spinbutton",
"searchbox",
"treeitem",
]);
const LEGACY_SELECTOR_PREFIXES = ["p-aria/", "p-text/", "p-xpath/", "p-pierce/"] as const;
function normalizeSelector(selector: string): string {
if (!selector) return selector;
if (selector.startsWith("p-") && !LEGACY_SELECTOR_PREFIXES.some(prefix => selector.startsWith(prefix))) {
throw new ToolError(
`Unsupported selector prefix. Use CSS or puppeteer query handlers (aria/, text/, xpath/, pierce/). Got: ${selector}`,
);
}
if (selector.startsWith("p-text/")) {
return `text/${selector.slice("p-text/".length)}`;
}
if (selector.startsWith("p-xpath/")) {
return `xpath/${selector.slice("p-xpath/".length)}`;
}
if (selector.startsWith("p-pierce/")) {
return `pierce/${selector.slice("p-pierce/".length)}`;
}
if (selector.startsWith("p-aria/")) {
const rest = selector.slice("p-aria/".length);
const nameMatch = rest.match(/\[\s*name\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\]]+))\s*\]/);
const name = nameMatch?.[1] ?? nameMatch?.[2] ?? nameMatch?.[3];
if (name) return `aria/${name.trim()}`;
return `aria/${rest}`;
}
return selector;
}
function isInteractiveNode(node: SerializedAXNode): boolean {
if (INTERACTIVE_AX_ROLES.has(node.role)) return true;
return (
node.checked !== undefined ||
node.pressed !== undefined ||
node.selected !== undefined ||
node.expanded !== undefined ||
node.focused === true
);
}
async function collectObservationEntries(
tab: TabHandle,
node: SerializedAXNode,
entries: ObservationEntry[],
options: { viewportOnly: boolean; includeAll: boolean },
): Promise<void> {
if (options.includeAll || isInteractiveNode(node)) {
const handle = await node.elementHandle();
if (handle) {
let inViewport = true;
if (options.viewportOnly) {
try {
inViewport = await handle.isIntersectingViewport();
} catch {
inViewport = false;
}
}
if (inViewport) {
const id = ++tab.elementCounter;
const states: string[] = [];
if (node.disabled) states.push("disabled");
if (node.checked !== undefined) states.push(`checked=${String(node.checked)}`);
if (node.pressed !== undefined) states.push(`pressed=${String(node.pressed)}`);
if (node.selected !== undefined) states.push(`selected=${String(node.selected)}`);
if (node.expanded !== undefined) states.push(`expanded=${String(node.expanded)}`);
if (node.required) states.push("required");
if (node.readonly) states.push("readonly");
if (node.multiselectable) states.push("multiselectable");
if (node.multiline) states.push("multiline");
if (node.modal) states.push("modal");
if (node.focused) states.push("focused");
tab.elementCache.set(id, handle);
entries.push({
id,
role: node.role,
name: node.name,
value: node.value,
description: node.description,
keyshortcuts: node.keyshortcuts,
states,
});
} else {
await handle.dispose();
}
}
}
for (const child of node.children ?? []) {
await collectObservationEntries(tab, child, entries, options);
}
}
export async function collectObservation(
tab: TabHandle,
options: { includeAll?: boolean; viewportOnly?: boolean; signal?: AbortSignal },
): Promise<Observation> {
clearElementCache(tab);
const includeAll = options.includeAll ?? false;
const viewportOnly = options.viewportOnly ?? false;
const snapshot = (await untilAborted(options.signal, () =>
tab.page.accessibility.snapshot({ interestingOnly: !includeAll }),
)) as SerializedAXNode | null;
if (!snapshot) {
throw new ToolError("Accessibility snapshot unavailable");
}
const entries: ObservationEntry[] = [];
await collectObservationEntries(tab, snapshot, entries, { includeAll, viewportOnly });
const scroll = (await untilAborted(options.signal, () =>
tab.page.evaluate(() => {
const win = globalThis as unknown as {
scrollX: number;
scrollY: number;
innerWidth: number;
innerHeight: number;
document: { documentElement: { scrollWidth: number; scrollHeight: number } };
};
const doc = win.document.documentElement;
return {
x: win.scrollX,
y: win.scrollY,
width: win.innerWidth,
height: win.innerHeight,
scrollWidth: doc.scrollWidth,
scrollHeight: doc.scrollHeight,
};
}),
)) as Observation["scroll"];
const url = tab.page.url();
const title = (await untilAborted(options.signal, () => tab.page.title())) as string;
const viewport = tab.page.viewport() ?? DEFAULT_VIEWPORT;
return { url, title, viewport, scroll, elements: entries };
}
export function formatObservation(observation: Observation): string {
const viewport = `${observation.viewport.width}x${observation.viewport.height}`;
const scroll = `x=${observation.scroll.x} y=${observation.scroll.y} viewport=${observation.scroll.width}x${observation.scroll.height} doc=${observation.scroll.scrollWidth}x${observation.scroll.scrollHeight}`;
const lines = [
`URL: ${observation.url}`,
observation.title ? `Title: ${observation.title}` : "Title:",
`Viewport: ${viewport}`,
`Scroll: ${scroll}`,
"Elements:",
];
for (const entry of observation.elements) {
const name = entry.name ? ` "${entry.name}"` : "";
const value = entry.value !== undefined ? ` value=${JSON.stringify(entry.value)}` : "";
const description = entry.description ? ` desc=${JSON.stringify(entry.description)}` : "";
const shortcuts = entry.keyshortcuts ? ` shortcuts=${JSON.stringify(entry.keyshortcuts)}` : "";
const state = entry.states.length ? ` (${entry.states.join(", ")})` : "";
lines.push(`${entry.id}. ${entry.role}${name}${value}${description}${shortcuts}${state}`);
}
return lines.join("\n");
}
// =====================================================================
// Click resolution helpers (text/aria selectors with visibility filtering)
// =====================================================================
type ActionabilityResult = { ok: true; x: number; y: number } | { ok: false; reason: string };
async function resolveActionableQueryHandlerClickTarget(handles: ElementHandle[]): Promise<ElementHandle | null> {
const candidates: Array<{
handle: ElementHandle;
rect: { x: number; y: number; w: number; h: number };
ownedProxy?: ElementHandle;
}> = [];
for (const handle of handles) {
let clickable: ElementHandle = handle;
let clickableProxy: ElementHandle | null = null;
try {
const proxy = await handle.evaluateHandle(el => {
const target =
(el as Element).closest(
'a,button,[role="button"],[role="link"],input[type="button"],input[type="submit"]',
) ?? el;
return target;
});
const nodeHandle = proxy.asElement();
clickableProxy = nodeHandle ? (nodeHandle as unknown as ElementHandle) : null;
if (clickableProxy) {
clickable = clickableProxy;
}
} catch {
// ignore
}
try {
const intersecting = await clickable.isIntersectingViewport();
if (!intersecting) continue;
const rect = (await clickable.evaluate(el => {
const r = (el as Element).getBoundingClientRect();
return { x: r.left, y: r.top, w: r.width, h: r.height };
})) as { x: number; y: number; w: number; h: number };
if (rect.w < 1 || rect.h < 1) continue;
candidates.push({ handle: clickable, rect, ownedProxy: clickableProxy ?? undefined });
} catch {
// ignore
} finally {
if (clickableProxy && clickableProxy !== handle && clickable !== clickableProxy) {
try {
await clickableProxy.dispose();
} catch {}
}
}
}
if (!candidates.length) return null;
candidates.sort((a, b) => a.rect.y - b.rect.y || a.rect.x - b.rect.x);
const winner = candidates[0]?.handle ?? null;
for (let i = 1; i < candidates.length; i++) {
const c = candidates[i]!;
if (c.ownedProxy) {
try {
await c.ownedProxy.dispose();
} catch {}
}
}
return winner;
}
async function isClickActionable(handle: ElementHandle): Promise<ActionabilityResult> {
return (await handle.evaluate(el => {
const element = el as HTMLElement;
const style = globalThis.getComputedStyle(element);
if (style.display === "none") return { ok: false as const, reason: "display:none" };
if (style.visibility === "hidden") return { ok: false as const, reason: "visibility:hidden" };
if (style.pointerEvents === "none") return { ok: false as const, reason: "pointer-events:none" };
if (Number(style.opacity) === 0) return { ok: false as const, reason: "opacity:0" };
const r = element.getBoundingClientRect();
if (r.width < 1 || r.height < 1) return { ok: false as const, reason: "zero-size" };
const vw = globalThis.innerWidth;
const vh = globalThis.innerHeight;
const left = Math.max(0, Math.min(vw, r.left));
const right = Math.max(0, Math.min(vw, r.right));
const top = Math.max(0, Math.min(vh, r.top));
const bottom = Math.max(0, Math.min(vh, r.bottom));
if (right - left < 1 || bottom - top < 1) return { ok: false as const, reason: "off-viewport" };
const x = Math.floor((left + right) / 2);
const y = Math.floor((top + bottom) / 2);
const topEl = globalThis.document.elementFromPoint(x, y);
if (!topEl) return { ok: false as const, reason: "elementFromPoint-null" };
if (topEl === element || element.contains(topEl) || (topEl as Element).contains(element)) {
return { ok: true as const, x, y };
}
return { ok: false as const, reason: "obscured" };
})) as ActionabilityResult;
}
async function clickQueryHandlerText(
page: Page,
selector: string,
timeoutMs: number,
signal?: AbortSignal,
): Promise<void> {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
const clickSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
const start = Date.now();
let lastSeen = 0;
let lastReason: string | null = null;
while (Date.now() - start < timeoutMs) {
throwIfAborted(clickSignal);
const handles = (await untilAborted(clickSignal, () => page.$$(selector))) as ElementHandle[];
try {
lastSeen = handles.length;
const target = await resolveActionableQueryHandlerClickTarget(handles);
if (!target) {
lastReason = handles.length ? "no-visible-candidate" : "no-matches";
await Bun.sleep(100);
continue;
}
const actionability = await isClickActionable(target);
if (!actionability.ok) {
lastReason = actionability.reason;
await Bun.sleep(100);
continue;
}
try {
await untilAborted(clickSignal, () => target.click());
return;
} catch (err) {
lastReason = err instanceof Error ? err.message : String(err);
await Bun.sleep(100);
}
} finally {
await Promise.all(
handles.map(async h => {
try {
await h.dispose();
} catch {}
}),
);
}
}
throw new ToolError(
`Timed out clicking ${selector} (seen ${lastSeen} matches; last reason: ${lastReason ?? "unknown"}). ` +
"If there are multiple matching elements, use observe + tab.id() or a more specific selector.",
);
}
// =====================================================================
// Tab API surface (visible to user code as `tab`)
// =====================================================================
export interface ScreenshotOptions {
selector?: string;
fullPage?: boolean;
save?: string;
silent?: boolean;
}
export interface ScreenshotResult {
dest: string;
mimeType: string;
bytes: number;
width: number;
height: number;
}
export type DragTarget = string | { readonly x: number; readonly y: number };
export interface TabApi {
readonly name: string;
readonly page: Page;
readonly signal?: AbortSignal;
url(): string;
title(): Promise<string>;
goto(
url: string,
opts?: { waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2" },
): Promise<void>;
observe(opts?: { includeAll?: boolean; viewportOnly?: boolean }): Promise<Observation>;
screenshot(opts?: ScreenshotOptions): Promise<ScreenshotResult>;
extract(format?: ReadableFormat): Promise<ReadableResult | null>;
click(selector: string): Promise<void>;
type(selector: string, text: string): Promise<void>;
fill(selector: string, value: string): Promise<void>;
press(key: KeyInput, opts?: { selector?: string }): Promise<void>;
scroll(deltaX: number, deltaY: number): Promise<void>;
drag(from: DragTarget, to: DragTarget): Promise<void>;
waitFor(selector: string): Promise<ElementHandle>;
evaluate<TResult, TArgs extends unknown[]>(
fn: string | ((...args: TArgs) => TResult | Promise<TResult>),
...args: TArgs
): Promise<TResult>;
scrollIntoView(selector: string): Promise<void>;
select(selector: string, ...values: string[]): Promise<string[]>;
uploadFile(selector: string, ...filePaths: string[]): Promise<void>;
waitForUrl(pattern: string | RegExp, opts?: { timeout?: number }): Promise<string>;
waitForResponse(
pattern: string | RegExp | ((response: HTTPResponse) => boolean | Promise<boolean>),
opts?: { timeout?: number },
): Promise<HTTPResponse>;
id(n: number): Promise<ElementHandle>;
}
export interface RunInTabOptions {
tab: TabHandle;
code: string;
timeoutMs: number;
signal?: AbortSignal;
session: ToolSession;
}
export interface RunInTabResult {
displays: Array<TextContent | ImageContent>;
returnValue: unknown;
screenshots: ScreenshotResult[];
}
const AsyncFunctionCtor = Object.getPrototypeOf(async () => {}).constructor as new (
...args: string[]
) => (...args: unknown[]) => Promise<unknown>;
export async function runInTab(opts: RunInTabOptions): Promise<RunInTabResult> {
const { tab, code, timeoutMs, signal: outerSignal, session } = opts;
// Compose a combined signal that aborts when either the caller cancels OR the
// per-execution wall-clock timeout fires. This guards against user code that
// awaits a never-resolving promise; synchronous infinite loops still block the
// event loop until they yield.
const timeoutSignal = AbortSignal.timeout(timeoutMs);
const signal = outerSignal ? AbortSignal.any([outerSignal, timeoutSignal]) : timeoutSignal;
throwIfAborted(outerSignal);
const displays: Array<TextContent | ImageContent> = [];
const screenshots: ScreenshotResult[] = [];
const display = (value: unknown): void => {
if (value === undefined || value === null) return;
if (
typeof value === "object" &&
value !== null &&
"type" in (value as Record<string, unknown>) &&
(value as { type?: unknown }).type === "image"
) {
const img = value as { data?: unknown; mimeType?: unknown };
if (typeof img.data === "string" && typeof img.mimeType === "string") {
displays.push({ type: "image", data: img.data, mimeType: img.mimeType });
return;
}
}
if (typeof value === "string") {
displays.push({ type: "text", text: value });
return;
}
try {
displays.push({ type: "text", text: JSON.stringify(value, null, 2) });
} catch {
displays.push({ type: "text", text: String(value) });
}
};
const assertFn = (cond: unknown, msg?: string): void => {
if (!cond) throw new ToolError(msg ?? "Assertion failed");
};
const wait = (ms: number): Promise<void> => Bun.sleep(ms);
const tabApi: TabApi = {
name: tab.name,
page: tab.page,
signal,
url: () => tab.page.url(),
title: () => tab.page.title(),
goto: async (url, gOpts) => {
clearElementCache(tab);
await untilAborted(signal, () =>
tab.page.goto(url, {
waitUntil: gOpts?.waitUntil ?? "networkidle2",
timeout: timeoutMs,
}),
);
},
observe: opts2 => collectObservation(tab, { ...opts2, signal }),
screenshot: async opts2 => {
const result = await captureScreenshot(tab, session, displays, screenshots, signal, opts2);
return result;
},
extract: async (format = "markdown") => {
const html = (await untilAborted(signal, () => tab.page.content())) as string;
return extractReadableFromHtml(html, tab.page.url(), format);
},
click: async selector => {
const resolved = normalizeSelector(selector);
if (resolved.startsWith("text/")) {
await clickQueryHandlerText(tab.page, resolved, timeoutMs, signal);
} else {
const locator = tab.page.locator(resolved).setTimeout(timeoutMs);
await untilAborted(signal, () => locator.click());
}
},
type: async (selector, text) => {
const resolved = normalizeSelector(selector);
const locator = tab.page.locator(resolved).setTimeout(timeoutMs);
const handle = (await untilAborted(signal, () => locator.waitHandle())) as ElementHandle;
try {
await untilAborted(signal, () => handle.type(text, { delay: 0 }));
} finally {
await handle.dispose();
}
},
fill: async (selector, value) => {
const resolved = normalizeSelector(selector);
const locator = tab.page.locator(resolved).setTimeout(timeoutMs);
await untilAborted(signal, () => locator.fill(value));
},
press: async (key, opts2) => {
if (opts2?.selector) {
const resolved = normalizeSelector(opts2.selector);
await untilAborted(signal, () => tab.page.focus(resolved));
}
await untilAborted(signal, () => tab.page.keyboard.press(key));
},
scroll: async (deltaX, deltaY) => {
await untilAborted(signal, () => tab.page.mouse.wheel({ deltaX, deltaY }));
},
drag: async (from, to) => {
const resolveDragPoint = async (
target: DragTarget,
role: "from" | "to",
): Promise<{ x: number; y: number; handle?: ElementHandle }> => {
if (typeof target === "string") {
const resolved = normalizeSelector(target);
const handle = (await untilAborted(signal, () => tab.page.$(resolved))) as ElementHandle | null;
if (!handle) throw new ToolError(`Drag ${role} selector did not resolve: ${target}`);
const box = (await untilAborted(signal, () => handle.boundingBox())) as {
x: number;
y: number;
width: number;
height: number;
} | null;
if (!box) {
await handle.dispose().catch(() => undefined);
throw new ToolError(`Drag ${role} element has no bounding box (likely not visible): ${target}`);
}
return { x: box.x + box.width / 2, y: box.y + box.height / 2, handle };
}
if (
target !== null &&
typeof target === "object" &&
typeof (target as { x: unknown }).x === "number" &&
typeof (target as { y: unknown }).y === "number"
) {
return { x: (target as { x: number }).x, y: (target as { y: number }).y };
}
throw new ToolError(
`Drag ${role} must be a selector string or { x: number, y: number } point. Got: ${typeof target}`,
);
};
const start = await resolveDragPoint(from, "from");
let end: { x: number; y: number; handle?: ElementHandle } | undefined;
try {
end = await resolveDragPoint(to, "to");
await untilAborted(signal, () => tab.page.mouse.move(start.x, start.y));
await untilAborted(signal, () => tab.page.mouse.down());
await untilAborted(signal, () => tab.page.mouse.move(end!.x, end!.y, { steps: 12 }));
await untilAborted(signal, () => tab.page.mouse.up());
} finally {
if (start.handle) await start.handle.dispose().catch(() => undefined);
if (end?.handle) await end.handle.dispose().catch(() => undefined);
}
},
waitFor: async selector => {
const resolved = normalizeSelector(selector);
const locator = tab.page.locator(resolved).setTimeout(timeoutMs);
return (await untilAborted(signal, () => locator.waitHandle())) as ElementHandle;
},
evaluate: async (fn, ...args) => {
return (await untilAborted(signal, () =>
typeof fn === "string"
? tab.page.evaluate(fn)
: tab.page.evaluate(fn as (...a: unknown[]) => unknown, ...args),
)) as never;
},
scrollIntoView: async selector => {
const resolved = normalizeSelector(selector);
const locator = tab.page.locator(resolved).setTimeout(timeoutMs);
const handle = (await untilAborted(signal, () => locator.waitHandle())) as ElementHandle;
try {
await untilAborted(signal, () =>
handle.evaluate(el => {
const target = el as unknown as {
scrollIntoView: (opts: { behavior: string; block: string; inline: string }) => void;
};
target.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
}),
);
} finally {
await handle.dispose().catch(() => undefined);
}
},
select: async (selector, ...values) => {
const resolved = normalizeSelector(selector);
const locator = tab.page.locator(resolved).setTimeout(timeoutMs);
const handle = (await untilAborted(signal, () => locator.waitHandle())) as ElementHandle;
try {
return (await untilAborted(signal, () =>
handle.evaluate((el, vals) => {
interface SelectOption {
value: string;
selected: boolean;
}
interface SelectLike {
tagName: string;
options: ArrayLike<SelectOption>;
dispatchEvent: (event: unknown) => boolean;
}
const select = el as unknown as SelectLike;
if (!select || select.tagName !== "SELECT") {
throw new Error("tab.select() requires a <select> element");
}
const EventCtor = (
globalThis as unknown as { Event: new (type: string, init?: { bubbles: boolean }) => unknown }
).Event;
const wanted = new Set(vals as string[]);
const selected: string[] = [];
for (let i = 0; i < select.options.length; i++) {
const opt = select.options[i] as SelectOption;
opt.selected = wanted.has(opt.value);
if (opt.selected) selected.push(opt.value);
}
select.dispatchEvent(new EventCtor("input", { bubbles: true }));
select.dispatchEvent(new EventCtor("change", { bubbles: true }));
return selected;
}, values),
)) as string[];
} finally {
await handle.dispose().catch(() => undefined);
}
},
uploadFile: async (selector, ...filePaths) => {
if (!filePaths.length) {
throw new ToolError("tab.uploadFile() requires at least one file path");
}
const resolved = normalizeSelector(selector);
const locator = tab.page.locator(resolved).setTimeout(timeoutMs);
const handle = (await untilAborted(signal, () => locator.waitHandle())) as ElementHandle;
try {
const absolute = filePaths.map(p => resolveToCwd(p, session.cwd));
const upload = handle as unknown as { uploadFile: (...paths: string[]) => Promise<void> };
const tagName = (await untilAborted(signal, () =>
handle.evaluate(el => (el as unknown as { tagName: string }).tagName),
)) as string;
if (tagName !== "INPUT") {
throw new ToolError(
`tab.uploadFile() requires an <input type="file"> element (got <${tagName.toLowerCase()}>)`,
);
}
await untilAborted(signal, () => upload.uploadFile(...absolute));
} finally {
await handle.dispose().catch(() => undefined);
}
},
waitForUrl: async (pattern, wOpts) => {
const timeout = wOpts?.timeout ?? timeoutMs;
const isRegex = pattern instanceof RegExp;
const matcher = isRegex ? pattern.source : pattern;
const flags = isRegex ? pattern.flags : "";
await untilAborted(signal, () =>
tab.page.waitForFunction(
(m: string, isRe: boolean, fl: string) => {
const url = (globalThis as unknown as { location: { href: string } }).location.href;
return isRe ? new RegExp(m, fl).test(url) : url.includes(m);
},
{ timeout, polling: 200 },
matcher,
isRegex,
flags,
),
);
return tab.page.url();
},
waitForResponse: async (pattern, wOpts) => {
const timeout = wOpts?.timeout ?? timeoutMs;
const predicate: (response: HTTPResponse) => boolean | Promise<boolean> =
typeof pattern === "function"
? pattern
: pattern instanceof RegExp
? response => pattern.test(response.url())
: response => response.url().includes(pattern);
return (await untilAborted(signal, () => tab.page.waitForResponse(predicate, { timeout }))) as HTTPResponse;
},
id: async n => resolveCachedHandle(tab, n),
};
const fn = new AsyncFunctionCtor("page", "browser", "tab", "display", "assert", "wait", code);
// Race the user's async function against the combined signal so cancellation
// (caller abort or wall-clock timeout) escapes promptly even when `code` is
// blocked in a raw puppeteer call (e.g. `handle.click()`) that does not
// consult the signal. The orphaned `fn()` may keep running until its CDP
// request settles; we accept that to avoid closing the user's tab on every
// cancellation.
const { promise: cancelRejection, reject: rejectCancel } = Promise.withResolvers<never>();
const onCancel = (): void => {
if (timeoutSignal.aborted) {
rejectCancel(new ToolError(`Browser code execution timed out after ${timeoutMs}ms`));
} else {
rejectCancel(new ToolAbortError());
}
};
if (signal.aborted) {
onCancel();
} else {
signal.addEventListener("abort", onCancel, { once: true });
}
try {
const returnValue = await Promise.race([
fn(tab.page, tab.browser.browser, tabApi, display, assertFn, wait),
cancelRejection,
]);
return { displays, returnValue, screenshots };
} finally {
signal.removeEventListener("abort", onCancel);
}
}
async function captureScreenshot(
tab: TabHandle,
session: ToolSession,
displays: Array<TextContent | ImageContent>,
screenshots: ScreenshotResult[],
signal: AbortSignal | undefined,
opts: ScreenshotOptions = {},
): Promise<ScreenshotResult> {
const fullPage = opts.selector ? false : (opts.fullPage ?? false);
let buffer: Buffer;
if (opts.selector) {
const resolved = normalizeSelector(opts.selector);
const handle = (await untilAborted(signal, () => tab.page.$(resolved))) as ElementHandle | null;
if (!handle) {
throw new ToolError("Screenshot selector did not resolve to an element");
}
try {
buffer = (await untilAborted(signal, () => handle.screenshot({ type: "png" }))) as Buffer;
} finally {
await handle.dispose().catch(() => undefined);
}
} else {
buffer = (await untilAborted(signal, () => tab.page.screenshot({ type: "png", fullPage }))) as Buffer;
}
// Compress aggressively for the model copy.
const resized = await resizeImage(
{ type: "image", data: buffer.toBase64(), mimeType: "image/png" },
{ maxWidth: 1024, maxHeight: 1024, maxBytes: 150 * 1024, jpegQuality: 70 },
);
const screenshotDir = (() => {
const v = session.settings.get("browser.screenshotDir") as string | undefined;
return v ? expandPath(v) : undefined;
})();
const explicitPath = opts.save ? resolveToCwd(opts.save, session.cwd) : undefined;
let dest: string;
if (explicitPath) {
dest = explicitPath;
} else if (screenshotDir) {
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1);
dest = path.join(screenshotDir, `screenshot-${ts}.png`);
} else {
dest = path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.png`);
}
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
const saveFullRes = !!(explicitPath || screenshotDir);
const savedBuffer = saveFullRes ? buffer : resized.buffer;
const savedMimeType = saveFullRes ? "image/png" : resized.mimeType;
await Bun.write(dest, savedBuffer);
const info: ScreenshotResult = {
dest,
mimeType: savedMimeType,
bytes: savedBuffer.length,
width: resized.width,
height: resized.height,
};
screenshots.push(info);
if (!opts.silent) {
const lines = formatScreenshot({
saveFullRes,
savedMimeType,
savedByteLength: savedBuffer.length,
dest,
resized,
});
displays.push({ type: "text", text: lines.join("\n") });
displays.push({ type: "image", data: resized.data, mimeType: resized.mimeType });
}
return info;
}
File diff suppressed because it is too large Load Diff
+61 -65
View File
@@ -5,10 +5,10 @@ import type { AsyncJobManager } from "../async";
import type { PromptTemplate } from "../config/prompt-templates";
import type { Settings } from "../config/settings";
import { EditTool } from "../edit";
import { warmPythonEnvironment } from "../eval/py/executor";
import { checkPythonKernelAvailability } from "../eval/py/kernel";
import type { Skill } from "../extensibility/skills";
import type { InternalUrlRouter } from "../internal-urls";
import { getPreludeDocs, resetPreludeDocsCache, warmPythonEnvironment } from "../ipy/executor";
import { checkPythonKernelAvailability } from "../ipy/kernel";
import { LspTool } from "../lsp";
import type { DiscoverableMCPSearchIndex, DiscoverableMCPTool } from "../mcp/discoverable-tool-metadata";
import type { PlanModeState } from "../plan-mode/state";
@@ -27,6 +27,7 @@ import { BrowserTool } from "./browser";
import { CalculatorTool } from "./calculator";
import { type CheckpointState, CheckpointTool, RewindTool } from "./checkpoint";
import { DebugTool } from "./debug";
import { EvalTool } from "./eval";
import { ExitPlanModeTool } from "./exit-plan-mode";
import { FindTool } from "./find";
import { GithubTool } from "./gh";
@@ -35,7 +36,6 @@ import { IrcTool } from "./irc";
import { JobTool } from "./job";
import { NotebookTool } from "./notebook";
import { wrapToolWithMetaNotice } from "./output-meta";
import { PythonTool } from "./python";
import { ReadTool } from "./read";
import { RecipeTool } from "./recipe";
import { RenderMermaidTool } from "./render-mermaid";
@@ -66,6 +66,7 @@ export * from "./browser";
export * from "./calculator";
export * from "./checkpoint";
export * from "./debug";
export * from "./eval";
export * from "./exit-plan-mode";
export * from "./find";
export * from "./gh";
@@ -74,7 +75,6 @@ export * from "./inspect-image";
export * from "./irc";
export * from "./job";
export * from "./notebook";
export * from "./python";
export * from "./read";
export * from "./recipe";
export * from "./render-mermaid";
@@ -130,16 +130,18 @@ export interface ToolSession {
taskDepth?: number;
/** Get session file */
getSessionFile: () => string | null;
/** Get Python kernel owner ID for session-scoped retained-kernel cleanup */
getPythonKernelOwnerId?: () => string | null;
/** Reject new Python work once session disposal has started. */
assertPythonExecutionAllowed?: () => void;
/** Track tool-owned Python work so session disposal can await/abort it like direct session Python runs. */
trackPythonExecution?<T>(execution: Promise<T>, abortController: AbortController): Promise<T>;
/** Get eval kernel owner ID for session-scoped retained-kernel cleanup. */
getEvalKernelOwnerId?: () => string | null;
/** Reject new eval (python or js) work once session disposal has started. */
assertEvalExecutionAllowed?: () => void;
/** Track tool-owned eval work so session disposal can await/abort it like direct session eval runs. */
trackEvalExecution?<T>(execution: Promise<T>, abortController: AbortController): Promise<T>;
/** Get session ID */
getSessionId?: () => string | null;
/** Agent identity used for IRC routing. Returns the registry id (e.g. "0-Main", "0-AuthLoader"). */
getAgentId?: () => string | null;
/** Look up a registered tool by name (used by the eval js backend's tool bridge). */
getToolByName?: (name: string) => AgentTool | undefined;
/** Agent registry for IRC routing across live sessions. */
agentRegistry?: AgentRegistry;
/** Get artifacts directory for artifact:// URLs */
@@ -210,7 +212,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
ask: AskTool.createIf,
bash: s => new BashTool(s),
debug: DebugTool.createIf,
python: s => new PythonTool(s),
eval: s => new EvalTool(s),
calc: s => new CalculatorTool(s),
ssh: loadSshTool,
edit: s => new EditTool(s),
@@ -244,34 +246,40 @@ export const HIDDEN_TOOLS: Record<string, ToolFactory> = {
export type ToolName = keyof typeof BUILTIN_TOOLS;
export type PythonToolMode = "ipy-only" | "bash-only" | "both";
export interface EvalBackendsAllowance {
python: boolean;
js: boolean;
}
/**
* Parse PI_PY environment variable to determine Python tool mode.
* Returns null if not set or invalid.
*
* Values:
* - "0" or "bash" → bash-only
* - "1" or "py" → ipy-only
* - "mix" or "both" → both
* Parse PI_PY / PI_JS environment variables. Each is a boolean flag; unset
* means "not specified, defer to settings". Returns null when neither is set
* so the caller can fall through to `readEvalBackendsAllowance` per key.
*/
function getPythonModeFromEnv(): PythonToolMode | null {
const value = $env.PI_PY?.toLowerCase();
if (!value) return null;
function getEvalBackendsFromEnv(): EvalBackendsAllowance | null {
const pyEnv = $env.PI_PY;
const jsEnv = $env.PI_JS;
if (pyEnv === undefined && jsEnv === undefined) return null;
return {
python: pyEnv === undefined ? true : $flag("PI_PY"),
js: jsEnv === undefined ? true : $flag("PI_JS"),
};
}
switch (value) {
case "0":
case "bash":
return "bash-only";
case "1":
case "py":
return "ipy-only";
case "mix":
case "both":
return "both";
default:
return null;
}
/** Read per-backend allowance from settings (defaults true). */
export function readEvalBackendsAllowance(session: ToolSession): EvalBackendsAllowance {
return {
python: session.settings.get("eval.py") ?? true,
js: session.settings.get("eval.js") ?? true,
};
}
/**
* Materialize the active eval backend allowance: PI_PY / PI_JS env flags
* override the per-key settings; otherwise settings (defaults true) win.
*/
export function resolveEvalBackends(session: ToolSession): EvalBackendsAllowance {
return getEvalBackendsFromEnv() ?? readEvalBackendsAllowance(session);
}
/**
@@ -285,36 +293,32 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
if (requestedTools && !requestedTools.includes("exit_plan_mode")) {
requestedTools.push("exit_plan_mode");
}
const pythonMode = getPythonModeFromEnv() ?? session.settings.get("python.toolMode");
const backends = resolveEvalBackends(session);
const allowPython = backends.python;
const allowJs = backends.js;
const skipPythonPreflight = session.skipPythonPreflight === true;
let pythonAvailable = true;
const shouldCheckPython =
!skipPythonPreflight &&
pythonMode !== "bash-only" &&
(requestedTools === undefined || requestedTools.includes("python"));
!skipPythonPreflight && allowPython && (requestedTools === undefined || requestedTools.includes("eval"));
const isTestEnv = isBunTestRuntime();
const forcePythonWarmup = session.forcePythonWarmup === true;
const skipPythonWarm = (isTestEnv && !forcePythonWarmup) || $flag("PI_PYTHON_SKIP_CHECK");
const cachedPreludeDocs = getPreludeDocs();
const shouldWarmPython = !skipPythonWarm && (forcePythonWarmup || cachedPreludeDocs.length === 0);
if (shouldCheckPython) {
const availability = await logger.time("createTools:pythonCheck", checkPythonKernelAvailability, session.cwd);
pythonAvailable = availability.ok;
if (!availability.ok) {
logger.warn("Python kernel unavailable, falling back to bash", {
logger.warn("Python kernel unavailable; eval will dispatch to JavaScript backend", {
reason: availability.reason,
});
} else if (shouldWarmPython) {
} else if (!skipPythonWarm) {
const sessionFile = session.getSessionFile?.() ?? undefined;
const kernelOwnerId = session.getPythonKernelOwnerId?.() ?? undefined;
const kernelOwnerId = session.getEvalKernelOwnerId?.() ?? undefined;
const warmSessionId = sessionFile ? `session:${sessionFile}:cwd:${session.cwd}` : `cwd:${session.cwd}`;
const warmupAbortController = new AbortController();
try {
session.assertPythonExecutionAllowed?.();
if (forcePythonWarmup && cachedPreludeDocs.length > 0) {
resetPreludeDocsCache();
}
const warmupExecution = session.trackPythonExecution
session.assertEvalExecutionAllowed?.();
const warmupExecution = session.trackEvalExecution
? logger.time(
"createTools:warmPython",
warmPythonEnvironment,
@@ -334,8 +338,8 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
sessionFile,
kernelOwnerId,
);
await (session.trackPythonExecution?.(warmupExecution, warmupAbortController) ?? warmupExecution);
session.assertPythonExecutionAllowed?.();
await (session.trackEvalExecution?.(warmupExecution, warmupAbortController) ?? warmupExecution);
session.assertEvalExecutionAllowed?.();
} catch (err) {
logger.warn("Failed to warm Python environment", {
error: err instanceof Error ? err.message : String(err),
@@ -344,18 +348,10 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
}
}
const effectiveMode = pythonAvailable ? pythonMode : "bash-only";
const allowBash = effectiveMode !== "ipy-only";
const allowPython = effectiveMode !== "bash-only";
if (
requestedTools &&
allowBash &&
!allowPython &&
requestedTools.includes("python") &&
!requestedTools.includes("bash")
) {
requestedTools.push("bash");
}
const effectivePythonAllowed = allowPython && pythonAvailable;
// Eval is exposed whenever any backend is reachable. The python backend may
// be unreachable, in which case eval dispatches exclusively to js.
const allowEval = effectivePythonAllowed || allowJs;
// Auto-include AST counterparts when their text-based sibling is present
if (requestedTools) {
@@ -384,8 +380,8 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
const allTools: Record<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };
const isToolAllowed = (name: string) => {
if (name === "lsp") return enableLsp && session.settings.get("lsp.enabled");
if (name === "bash") return allowBash;
if (name === "python") return allowPython;
if (name === "bash") return true;
if (name === "eval") return allowEval;
if (name === "debug") return session.settings.get("debug.enabled");
if (name === "todo_write") return !includeYield && session.settings.get("todo.enabled");
if (name === "find") return session.settings.get("find.enabled");
+2 -2
View File
@@ -17,12 +17,12 @@ import { bashToolRenderer } from "./bash";
import { browserToolRenderer } from "./browser/render";
import { calculatorToolRenderer } from "./calculator";
import { debugToolRenderer } from "./debug";
import { evalToolRenderer } from "./eval";
import { findToolRenderer } from "./find";
import { githubToolRenderer } from "./gh-renderer";
import { inspectImageToolRenderer } from "./inspect-image-renderer";
import { jobToolRenderer } from "./job";
import { notebookToolRenderer } from "./notebook";
import { pythonToolRenderer } from "./python";
import { readToolRenderer } from "./read";
import { recipeToolRenderer } from "./recipe/render";
import { resolveToolRenderer } from "./resolve";
@@ -53,7 +53,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
browser: browserToolRenderer as ToolRenderer,
recipe: recipeToolRenderer as ToolRenderer,
debug: debugToolRenderer as ToolRenderer,
python: pythonToolRenderer as ToolRenderer,
eval: evalToolRenderer as ToolRenderer,
calc: calculatorToolRenderer as ToolRenderer,
edit: editToolRenderer as ToolRenderer,
apply_patch: editToolRenderer as ToolRenderer,
@@ -9,7 +9,7 @@ export interface ToolTimeoutConfig {
export const TOOL_TIMEOUTS = {
bash: { default: 300, min: 1, max: 3600 },
python: { default: 30, min: 1, max: 600 },
eval: { default: 30, min: 1, max: 600 },
browser: { default: 30, min: 1, max: 30 },
ssh: { default: 60, min: 1, max: 3600 },
fetch: { default: 20, min: 1, max: 45 },
@@ -74,14 +74,14 @@ const TOOLS: Record<string, ToolConfig> = {
},
};
// Python packages installed via uv/pip
interface PythonToolConfig {
// CLI packages installed via uv/pip
interface PythonPackageToolConfig {
name: string;
package: string; // PyPI package name
binaryName: string; // CLI command name after install
}
const PYTHON_TOOLS: Record<string, PythonToolConfig> = {
const PYTHON_TOOLS: Record<string, PythonPackageToolConfig> = {
trafilatura: {
name: "trafilatura",
package: "trafilatura",
@@ -93,7 +93,7 @@ export type ToolName = "sd" | "sg" | "yt-dlp" | "trafilatura";
// Get the path to a tool (system-wide or in our tools dir)
export function getToolPath(tool: ToolName): string | null {
// Check Python tools first
// Check uv/pip-installed CLI packages first
const pythonConfig = PYTHON_TOOLS[tool];
if (pythonConfig) {
return $which(pythonConfig.binaryName);
@@ -306,7 +306,7 @@ export async function ensureTool(tool: ToolName, silentOrOptions?: EnsureToolOpt
return undefined;
}
// Handle Python tools
// Handle uv/pip-installed CLI packages
const pythonConfig = PYTHON_TOOLS[tool];
if (pythonConfig) {
if (!silent) {
@@ -4,9 +4,9 @@ import * as os from "node:os";
import * as path from "node:path";
import { getBundledModel } from "@oh-my-pi/pi-ai";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import * as pythonExecutor from "@oh-my-pi/pi-coding-agent/ipy/executor";
import type { PreludeHelper, PythonKernel as PythonKernelInstance } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
import * as pythonKernel from "@oh-my-pi/pi-coding-agent/ipy/kernel";
import * as pythonExecutor from "@oh-my-pi/pi-coding-agent/eval/py/executor";
import type { PythonKernel as PythonKernelInstance } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
import * as pythonKernel from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
import * as memories from "@oh-my-pi/pi-coding-agent/memories";
import { createAgentSession, type ExtensionFactory } from "@oh-my-pi/pi-coding-agent/sdk";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
@@ -96,17 +96,15 @@ const createSession = async (
enableMCP: false,
enableLsp: false,
forcePythonWarmup: true,
toolNames: ["python"],
toolNames: ["eval"],
})
).session;
const stubPythonWarmup = () =>
vi.spyOn(pythonExecutor, "warmPythonEnvironment").mockResolvedValue({ ok: true, docs: [] });
const stubPythonWarmup = () => vi.spyOn(pythonExecutor, "warmPythonEnvironment").mockResolvedValue({ ok: true });
const createWarmupKernel = (docs: PreludeHelper[] = []) => {
const createWarmupKernel = () => {
let alive = true;
return {
introspectPrelude: vi.fn().mockResolvedValue(docs),
execute: vi.fn(async () => {
if (!alive) throw new Error("Expected warmup kernel to be restarted after shutdown");
return OK_EXECUTION;
@@ -124,7 +122,6 @@ describe("AgentSession python cleanup", () => {
const tempDirs: string[] = [];
afterEach(async () => {
pythonExecutor.resetPreludeDocsCache();
vi.restoreAllMocks();
await pythonExecutor.disposeAllKernelSessions();
for (const tempDir of tempDirs.splice(0)) {
@@ -132,15 +129,10 @@ describe("AgentSession python cleanup", () => {
}
});
it("cleans up warmed Python owners when createAgentSession fails before session construction", async () => {
it("does not dispose unrelated Python owners when createAgentSession fails before session construction", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
pythonExecutor.resetPreludeDocsCache();
const unrelatedKernel = createWarmupKernel();
const warmedKernel = createWarmupKernel([
{ name: "helper", signature: "helper()", docstring: "doc", category: "general" },
]);
const unrelatedCwd = path.join(tempDir, "unrelated-before");
const throwingExtension: ExtensionFactory = () => {
throw new Error("Extension init failed");
@@ -148,8 +140,7 @@ describe("AgentSession python cleanup", () => {
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
const startSpy = vi
.spyOn(pythonKernel.PythonKernel, "start")
.mockResolvedValueOnce(unrelatedKernel as unknown as PythonKernelInstance)
.mockResolvedValueOnce(warmedKernel as unknown as PythonKernelInstance);
.mockResolvedValueOnce(unrelatedKernel as unknown as PythonKernelInstance);
await pythonExecutor.executePython("print('unrelated before')", {
cwd: unrelatedCwd,
@@ -174,13 +165,11 @@ describe("AgentSession python cleanup", () => {
enableMCP: false,
enableLsp: false,
forcePythonWarmup: true,
toolNames: ["python"],
toolNames: ["eval"],
}),
).rejects.toThrow("Extension init failed");
expect(startSpy).toHaveBeenCalledTimes(2);
expect(warmedKernel.introspectPrelude).toHaveBeenCalledTimes(1);
expect(warmedKernel.shutdown).toHaveBeenCalledTimes(1);
expect(startSpy).toHaveBeenCalledTimes(1);
expect(unrelatedKernel.shutdown).not.toHaveBeenCalled();
const replacementKernel = createWarmupKernel();
@@ -191,10 +180,9 @@ describe("AgentSession python cleanup", () => {
kernelMode: "session",
kernelOwnerId: "fresh-owner-before",
});
expect(startSpy).toHaveBeenCalledTimes(3);
expect(startSpy).toHaveBeenCalledTimes(2);
expect(replacementKernel.execute).toHaveBeenCalledTimes(1);
expect(replacementKernel.execute).toHaveBeenCalledTimes(1);
expect(warmedKernel.shutdown).toHaveBeenCalledTimes(1);
expect(warmedKernel.execute).not.toHaveBeenCalled();
await pythonExecutor.executePython("print('still alive before')", {
cwd: unrelatedCwd,
@@ -203,25 +191,19 @@ describe("AgentSession python cleanup", () => {
kernelOwnerId: "other-owner",
});
expect(startSpy).toHaveBeenCalledTimes(3);
expect(startSpy).toHaveBeenCalledTimes(2);
expect(unrelatedKernel.execute).toHaveBeenCalledTimes(2);
});
it("cleans up warmed Python owners when createAgentSession fails after session construction", async () => {
it("does not dispose unrelated Python owners when createAgentSession fails after session construction", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
pythonExecutor.resetPreludeDocsCache();
const unrelatedKernel = createWarmupKernel();
const warmedKernel = createWarmupKernel([
{ name: "helper", signature: "helper()", docstring: "doc", category: "general" },
]);
const unrelatedCwd = path.join(tempDir, "unrelated-after");
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
const startSpy = vi
.spyOn(pythonKernel.PythonKernel, "start")
.mockResolvedValueOnce(unrelatedKernel as unknown as PythonKernelInstance)
.mockResolvedValueOnce(warmedKernel as unknown as PythonKernelInstance);
.mockResolvedValueOnce(unrelatedKernel as unknown as PythonKernelInstance);
vi.spyOn(memories, "startMemoryStartupTask").mockImplementation(() => {
throw new Error("Memory startup failed");
});
@@ -248,13 +230,11 @@ describe("AgentSession python cleanup", () => {
enableMCP: false,
enableLsp: false,
forcePythonWarmup: true,
toolNames: ["python"],
toolNames: ["eval"],
}),
).rejects.toThrow("Memory startup failed");
expect(startSpy).toHaveBeenCalledTimes(2);
expect(warmedKernel.introspectPrelude).toHaveBeenCalledTimes(1);
expect(warmedKernel.shutdown).toHaveBeenCalledTimes(1);
expect(startSpy).toHaveBeenCalledTimes(1);
expect(unrelatedKernel.shutdown).not.toHaveBeenCalled();
const replacementKernel = createWarmupKernel();
@@ -265,10 +245,9 @@ describe("AgentSession python cleanup", () => {
kernelMode: "session",
kernelOwnerId: "fresh-owner-after",
});
expect(startSpy).toHaveBeenCalledTimes(3);
expect(startSpy).toHaveBeenCalledTimes(2);
expect(replacementKernel.execute).toHaveBeenCalledTimes(1);
expect(replacementKernel.execute).toHaveBeenCalledTimes(1);
expect(warmedKernel.shutdown).toHaveBeenCalledTimes(1);
expect(warmedKernel.execute).not.toHaveBeenCalled();
await pythonExecutor.executePython("print('still alive after')", {
cwd: unrelatedCwd,
@@ -277,14 +256,13 @@ describe("AgentSession python cleanup", () => {
kernelOwnerId: "other-owner",
});
expect(startSpy).toHaveBeenCalledTimes(3);
expect(startSpy).toHaveBeenCalledTimes(2);
expect(unrelatedKernel.execute).toHaveBeenCalledTimes(2);
});
it("waits for active SDK session Python work before releasing a shared retained kernel", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
pythonExecutor.resetPreludeDocsCache();
stubPythonWarmup();
const kernel = new FakeKernel();
@@ -359,11 +337,9 @@ describe("AgentSession python cleanup", () => {
expect(kernel.shutdownCalls).toBe(1);
});
it("aborts tracked Python tool warmup during session dispose before executePython starts", async () => {
it("aborts tracked eval warmup during session dispose before executePython starts", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
pythonExecutor.resetPreludeDocsCache();
const blockedWarmupStarted = Promise.withResolvers<void>();
const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockResolvedValue({
output: "tool ok",
@@ -383,11 +359,11 @@ describe("AgentSession python cleanup", () => {
.mockImplementation(async (_cwd, _sessionId, _useSharedGateway, _sessionFile, _kernelOwnerId, signal) => {
warmupCallCount += 1;
if (warmupCallCount === 1) {
return { ok: true, docs: [] };
return { ok: true };
}
blockedWarmupStarted.resolve();
return await new Promise<{ ok: boolean; reason?: string; docs: [] }>(resolve => {
const onAbort = () => resolve({ ok: false, reason: "Warmup aborted", docs: [] });
return await new Promise<{ ok: boolean; reason?: string }>(resolve => {
const onAbort = () => resolve({ ok: false, reason: "Warmup aborted" });
if (signal?.aborted) {
onAbort();
return;
@@ -398,11 +374,11 @@ describe("AgentSession python cleanup", () => {
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
const session = await createSession(tempDir, cwd);
const pythonTool = session.getToolByName("python");
expect(pythonTool).toBeDefined();
const EvalTool = session.getToolByName("eval");
expect(EvalTool).toBeDefined();
let toolExecutionSettled = false;
const toolExecution = pythonTool!
.execute("call-id", { cells: [{ code: "print('tool')" }] }, undefined, undefined, undefined)
const toolExecution = EvalTool!
.execute("call-id", { input: "=== CELL ===\n```py\nprint('tool')\n```" }, undefined, undefined, undefined)
.finally(() => {
toolExecutionSettled = true;
});
@@ -427,11 +403,9 @@ describe("AgentSession python cleanup", () => {
expect(executeSpy).not.toHaveBeenCalled();
});
it("rejects Python tool starts when warmup finishes after dispose begins", async () => {
it("rejects eval starts when warmup finishes after dispose begins", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
pythonExecutor.resetPreludeDocsCache();
const blockedWarmupStarted = Promise.withResolvers<void>();
const releaseWarmup = Promise.withResolvers<void>();
const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockResolvedValue({
@@ -450,20 +424,20 @@ describe("AgentSession python cleanup", () => {
const warmupSpy = vi.spyOn(pythonExecutor, "warmPythonEnvironment").mockImplementation(async () => {
warmupCallCount += 1;
if (warmupCallCount === 1) {
return { ok: true, docs: [] };
return { ok: true };
}
blockedWarmupStarted.resolve();
await releaseWarmup.promise;
return { ok: true, docs: [] };
return { ok: true };
});
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
const session = await createSession(tempDir, cwd);
const pythonTool = session.getToolByName("python");
expect(pythonTool).toBeDefined();
const toolExecution = pythonTool!.execute(
const EvalTool = session.getToolByName("eval");
expect(EvalTool).toBeDefined();
const toolExecution = EvalTool!.execute(
"call-id",
{ cells: [{ code: "print('tool after warmup')" }] },
{ input: "=== CELL ===\n```py\nprint('tool after warmup')\n```" },
undefined,
undefined,
undefined,
@@ -478,22 +452,11 @@ describe("AgentSession python cleanup", () => {
expect(warmupSpy).toHaveBeenCalledTimes(2);
expect(executeSpy).not.toHaveBeenCalled();
});
it("aborts tracked Python tool execution during session dispose after warmup completes", async () => {
it("aborts tracked eval execution during session dispose after warmup completes", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
pythonExecutor.resetPreludeDocsCache();
const helper: PreludeHelper = {
name: "helper",
signature: "helper()",
docstring: "doc",
category: "general",
};
let preludeDocs: PreludeHelper[] = [];
vi.spyOn(pythonExecutor, "getPreludeDocs").mockImplementation(() => preludeDocs);
const warmupSpy = vi.spyOn(pythonExecutor, "warmPythonEnvironment").mockImplementation(async () => {
preludeDocs = [helper];
return { ok: true, docs: preludeDocs };
return { ok: true };
});
const blockedExecuteStarted = Promise.withResolvers<void>();
const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockImplementation(async (_code, options) => {
@@ -526,11 +489,11 @@ describe("AgentSession python cleanup", () => {
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
const session = await createSession(tempDir, cwd);
const pythonTool = session.getToolByName("python");
expect(pythonTool).toBeDefined();
const EvalTool = session.getToolByName("eval");
expect(EvalTool).toBeDefined();
let toolExecutionSettled = false;
const toolExecution = pythonTool!
.execute("call-id", { cells: [{ code: "print('tool')" }] }, undefined, undefined, undefined)
const toolExecution = EvalTool!
.execute("call-id", { input: "=== CELL ===\n```py\nprint('tool')\n```" }, undefined, undefined, undefined)
.finally(() => {
toolExecutionSettled = true;
});
@@ -562,7 +525,6 @@ describe("AgentSession python cleanup", () => {
it("detaches retained kernel ownership even when dispose times out waiting for Python work", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
pythonExecutor.resetPreludeDocsCache();
stubPythonWarmup();
const kernel = new FakeKernel();
@@ -621,7 +583,6 @@ describe("AgentSession python cleanup", () => {
it("rejects direct session Python starts once dispose begins", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
pythonExecutor.resetPreludeDocsCache();
stubPythonWarmup();
const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockResolvedValue({
output: "late",
@@ -648,7 +609,6 @@ describe("AgentSession python cleanup", () => {
it("rejects direct session Python starts after an async user_python hook yields during dispose", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
pythonExecutor.resetPreludeDocsCache();
const hookStarted = Promise.withResolvers<void>();
const releaseHook = Promise.withResolvers<void>();
const hookExtension: ExtensionFactory = api => {
@@ -690,7 +650,6 @@ describe("AgentSession python cleanup", () => {
it("rejects async user_python hook results after dispose begins", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
pythonExecutor.resetPreludeDocsCache();
const hookStarted = Promise.withResolvers<void>();
const releaseHook = Promise.withResolvers<void>();
const hookExtension: ExtensionFactory = api => {
@@ -743,10 +702,9 @@ describe("AgentSession python cleanup", () => {
expect(session.messages.some(message => message.role === "pythonExecution")).toBe(false);
}, 10000);
it("rejects Python tool starts once dispose begins", async () => {
it("rejects eval starts once dispose begins", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
pythonExecutor.resetPreludeDocsCache();
const warmupSpy = stubPythonWarmup();
const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockResolvedValue({
output: "late",
@@ -762,21 +720,26 @@ describe("AgentSession python cleanup", () => {
});
const session = await createSession(tempDir, cwd);
const pythonTool = session.getToolByName("python");
expect(pythonTool).toBeDefined();
const EvalTool = session.getToolByName("eval");
expect(EvalTool).toBeDefined();
const disposeSession = session.dispose();
await expect(
pythonTool!.execute("call-id", { cells: [{ code: "print('late')" }] }, undefined, undefined, undefined),
EvalTool!.execute(
"call-id",
{ input: "=== CELL ===\n```py\nprint('late')\n```" },
undefined,
undefined,
undefined,
),
).rejects.toThrow("Python execution is unavailable while session disposal is in progress");
await disposeSession;
expect(warmupSpy).toHaveBeenCalledTimes(1);
expect(executeSpy).not.toHaveBeenCalled();
});
it("rejects Python tool starts that reach async preflight after dispose begins", async () => {
it("rejects eval starts that reach async preflight after dispose begins", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
pythonExecutor.resetPreludeDocsCache();
const warmupSpy = stubPythonWarmup();
const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockResolvedValue({
output: "late",
@@ -800,11 +763,11 @@ describe("AgentSession python cleanup", () => {
});
const session = await createSession(tempDir, cwd, { sessionManager });
const pythonTool = session.getToolByName("python");
expect(pythonTool).toBeDefined();
const execution = pythonTool!.execute(
const EvalTool = session.getToolByName("eval");
expect(EvalTool).toBeDefined();
const execution = EvalTool!.execute(
"call-id",
{ cells: [{ code: "print('late after artifact')" }] },
{ input: "=== CELL ===\n```py\nprint('late after artifact')\n```" },
undefined,
undefined,
undefined,
@@ -821,7 +784,6 @@ describe("AgentSession python cleanup", () => {
it("aborts every active Python execution owned by the session during dispose", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
pythonExecutor.resetPreludeDocsCache();
stubPythonWarmup();
const kernel = new FakeKernel();
@@ -4,9 +4,9 @@ import { Agent } from "@oh-my-pi/pi-agent-core";
import { getBundledModel } from "@oh-my-pi/pi-ai";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import * as pythonExecutor from "@oh-my-pi/pi-coding-agent/eval/py/executor";
import * as bashExecutor from "@oh-my-pi/pi-coding-agent/exec/bash-executor";
import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions";
import * as pythonExecutor from "@oh-my-pi/pi-coding-agent/ipy/executor";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
+2 -2
View File
@@ -1,5 +1,5 @@
import type { PythonKernelExecutor } from "@oh-my-pi/pi-coding-agent/ipy/executor";
import type { KernelExecuteOptions, KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
import type { PythonKernelExecutor } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
import type { KernelExecuteOptions, KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
export class FakeKernel implements PythonKernelExecutor {
private result: KernelExecuteResult;
@@ -0,0 +1,336 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { TempDir } from "@oh-my-pi/pi-utils";
import { Type } from "@sinclair/typebox";
import { disposeAllVmContexts } from "../../src/eval/js/context-manager";
import { executeJs, type JsResult } from "../../src/eval/js/executor";
function createTool(
name: string,
execute: (toolCallId: string, args: unknown, signal?: AbortSignal) => Promise<AgentToolResult>,
): AgentTool {
return {
name,
label: name,
description: `${name} tool`,
parameters: Type.Object({}),
concurrency: "parallel",
execute,
} as unknown as AgentTool;
}
function getJsonData(result: JsResult): unknown {
const jsonOutputs = result.displayOutputs.filter(
(output): output is Extract<JsResult["displayOutputs"][number], { type: "json" }> => output.type === "json",
);
expect(jsonOutputs).toHaveLength(1);
return jsonOutputs[0].data;
}
function getStatusEvents(result: JsResult) {
return result.displayOutputs.filter(
(output): output is Extract<JsResult["displayOutputs"][number], { type: "status" }> => output.type === "status",
);
}
describe("executeJs", () => {
let tempDir: TempDir;
let session: ToolSession;
let sessionFile: string;
let sessionId: string;
beforeEach(async () => {
tempDir = TempDir.createSync("@js-executor-");
sessionFile = path.join(tempDir.path(), "session.jsonl");
sessionId = `session:${sessionFile}:cwd:${tempDir.path()}`;
session = {
cwd: tempDir.path(),
hasUI: false,
getSessionFile: () => sessionFile,
getSessionSpawns: () => null,
settings: Settings.isolated(),
};
await Bun.write(
path.join(tempDir.path(), "config.json"),
JSON.stringify({ name: "demo", enabled: true }, null, 2),
);
await Bun.write(path.join(tempDir.path(), "config.yaml"), "name: demo\nenabled: true\n");
});
afterEach(async () => {
await disposeAllVmContexts();
tempDir.removeSync();
vi.restoreAllMocks();
});
it("persists bindings across calls and reset clears them", async () => {
await executeJs("const answer = 2;", { sessionId, session, sessionFile });
const persisted = await executeJs("return answer + 2;", { sessionId, session, sessionFile });
expect(persisted.exitCode).toBe(0);
expect(persisted.output.trim()).toBe("4");
const resetResult = await executeJs("return typeof answer;", {
sessionId,
session,
sessionFile,
reset: true,
});
expect(resetResult.exitCode).toBe(0);
expect(resetResult.output.trim()).toBe("undefined");
});
it("exposes a read-only safe process subset", async () => {
const result = await executeJs(
[
"return {",
" version: process.version,",
" versionsNode: typeof process.versions.node,",
" platform: process.platform,",
" arch: process.arch,",
" cwd: process.cwd(),",
" frozen: Object.isFrozen(process) && Object.isFrozen(process.versions),",
" hasEnv: 'env' in process,",
" hasExit: 'exit' in process,",
"};",
].join("\n"),
{ sessionId, session, sessionFile },
);
expect(result.exitCode).toBe(0);
expect(getJsonData(result)).toEqual({
version: process.version,
versionsNode: "string",
platform: process.platform,
arch: process.arch,
cwd: tempDir.path(),
frozen: true,
hasEnv: false,
hasExit: false,
});
});
it("exposes common web globals and the raw Node fs module", async () => {
const result = await executeJs(
[
"const uuid = crypto.randomUUID();",
"const digest = await webcrypto.subtle.digest('SHA-256', new TextEncoder().encode('ok'));",
"const base = process.cwd();",
"fs.mkdirSync(`${base}/nested`, { recursive: true });",
"fs.writeFileSync(`${base}/nested/value.txt`, 'hello');",
"await fs.promises.copyFile(`${base}/nested/value.txt`, `${base}/nested/copy.txt`);",
"const text = fs.readFileSync(`${base}/nested/copy.txt`, 'utf8');",
"const bytes = await fs.promises.readFile(`${base}/nested/copy.txt`);",
"const stat = fs.statSync(`${base}/nested/copy.txt`);",
"const entries = fs.readdirSync(`${base}/nested`);",
"const start = performance.now();",
"return {",
" uuid: typeof uuid,",
" digestBytes: digest.byteLength,",
" text,",
" byteLength: bytes.byteLength,",
" isFile: stat.isFile(),",
" entries: entries.sort(),",
" hasConstants: typeof fs.constants.R_OK,",
" buffer: Buffer.from('ok').toString('hex'),",
" performance: typeof start,",
"};",
].join("\n"),
{ sessionId, session, sessionFile },
);
expect(result.exitCode).toBe(0);
expect(getJsonData(result)).toEqual({
uuid: "string",
digestBytes: 32,
text: "hello",
byteLength: 5,
isFile: true,
entries: ["copy.txt", "value.txt"],
hasConstants: "number",
buffer: "6f6b",
performance: "number",
});
});
it("reads files as text and supports offset/limit slicing", async () => {
const result = await executeJs(
[
"const full = await read('config.json');",
"const sliced = await read('config.json', { offset: 2, limit: 1 });",
"return { isString: typeof full === 'string', full, sliced };",
].join("\n"),
{
sessionId,
session,
sessionFile,
},
);
expect(result.exitCode).toBe(0);
expect(getStatusEvents(result)).toHaveLength(2);
expect(getJsonData(result)).toEqual({
isString: true,
full: '{\n "name": "demo",\n "enabled": true\n}',
sliced: ' "name": "demo",',
});
});
it("rejects protocol paths and directory reads from native read()", async () => {
const protocolResult = await executeJs("await read('agent://demo');", {
sessionId,
session,
sessionFile,
});
expect(protocolResult.exitCode).toBe(1);
expect(protocolResult.output).toContain("Protocol paths are not supported");
const directoryResult = await executeJs("await read('.');", {
sessionId,
session,
sessionFile,
});
expect(directoryResult.exitCode).toBe(1);
expect(directoryResult.output).toContain("Directory paths are not supported");
});
it("supports parse/stringify helpers and row utilities", async () => {
const result = await executeJs(
[
'const csv = \'name,role,note\\nalice,admin,"hello, world"\\nbob,user,"line1\\nline2"\';',
"const parsed = await parse.csv(csv);",
"const tsv = stringify.tsv(rows.pick(parsed, ['name', 'role']));",
"const roundtrip = await parse.tsv(tsv);",
"const grouped = rows.groupBy(parsed, 'role');",
"const counted = rows.countBy(parsed, 'role');",
"const sorted = rows.sortBy(parsed, 'name', { reverse: true });",
"return { parsed, roundtrip, groupedSizes: Object.fromEntries(Object.entries(grouped).map(([key, value]) => [key, value.length])), counted, sorted: rows.column(sorted, 'name') };",
].join("\n"),
{
sessionId,
session,
sessionFile,
},
);
expect(result.exitCode).toBe(0);
expect(result.displayOutputs).toEqual([
{
type: "json",
data: {
parsed: [
{ name: "alice", role: "admin", note: "hello, world" },
{ name: "bob", role: "user", note: "line1\nline2" },
],
roundtrip: [
{ name: "alice", role: "admin" },
{ name: "bob", role: "user" },
],
groupedSizes: { admin: 1, user: 1 },
counted: { admin: 1, user: 1 },
sorted: ["bob", "alice"],
},
},
]);
});
it("round-trips text and binary writes", async () => {
const result = await executeJs(
[
"await write('plain.txt', 'hello');",
"await write('bin.dat', new Uint8Array([0, 1, 2, 255]));",
"await write.json('state.json', { ok: true });",
"await write.csv('table.csv', [{ name: 'alice', role: 'admin' }]);",
"return {",
" text: await read.text('plain.txt'),",
" bytes: Array.from(await read.bytes('bin.dat')),",
" json: await read.json('state.json'),",
" csv: await read.csv('table.csv'),",
"};",
].join("\n"),
{
sessionId,
session,
sessionFile,
},
);
expect(result.exitCode).toBe(0);
expect(getStatusEvents(result)).toHaveLength(8);
expect(getJsonData(result)).toEqual({
text: "hello",
bytes: [0, 1, 2, 255],
json: { ok: true },
csv: [{ name: "alice", role: "admin" }],
});
});
it("routes output() through tool.read and keeps tool.* results normalized", async () => {
const execute = vi.fn(async (_toolCallId: string, args: unknown): Promise<AgentToolResult> => {
const record = args as { path: string };
if (record.path.startsWith("agent://")) {
return { content: [{ type: "text", text: "from-agent" }] };
}
return {
content: [{ type: "text", text: "annotated" }],
details: { path: record.path, kind: "tool-result" },
};
});
const toolSession: ToolSession = {
...session,
getToolByName: name => (name === "read" ? createTool("read", execute) : undefined),
};
const result = await executeJs(
"return { toolResult: await tool.read({ path: 'package.json' }), agentOutput: await output('agent-42') };",
{
sessionId,
session: toolSession,
sessionFile,
},
);
expect(result.exitCode).toBe(0);
expect(getStatusEvents(result)).toHaveLength(2);
expect(getJsonData(result)).toEqual({
toolResult: {
text: "annotated",
details: { path: "package.json", kind: "tool-result" },
images: undefined,
},
agentOutput: "from-agent",
});
expect(execute).toHaveBeenCalledTimes(2);
expect(execute.mock.calls[0]?.[1]).toEqual({ path: "package.json", _i: "js prelude" });
expect(execute.mock.calls[1]?.[1]).toEqual({ path: "agent://agent-42", _i: "js prelude" });
});
it("auto-displays returned objects as structured output", async () => {
const result = await executeJs("return { answer: 42, nested: { ok: true } };", {
sessionId,
session,
sessionFile,
});
expect(result.exitCode).toBe(0);
expect(result.output).toBe("");
expect(result.displayOutputs).toEqual([{ type: "json", data: { answer: 42, nested: { ok: true } } }]);
});
it("cancels execution when the timeout expires", async () => {
const result = await executeJs("await new Promise(() => {})", {
sessionId,
session,
sessionFile,
timeoutMs: 20,
});
expect(result.cancelled).toBe(true);
expect(result.exitCode).toBeUndefined();
expect(result.output).toContain("Command timed out");
});
});
@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from "bun:test";
import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { Type } from "@sinclair/typebox";
import { callSessionTool } from "../../src/eval/js/tool-bridge";
function createTool(
name: string,
execute: (toolCallId: string, args: unknown, signal?: AbortSignal) => Promise<AgentToolResult>,
): AgentTool {
return {
name,
label: name,
description: `${name} tool`,
parameters: Type.Object({}),
concurrency: "parallel",
execute,
} as unknown as AgentTool;
}
function createSession(tools: AgentTool[]): ToolSession {
const registry = new Map(tools.map(tool => [tool.name, tool]));
return {
cwd: "/tmp/test",
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => null,
settings: Settings.isolated(),
getToolByName: name => registry.get(name),
};
}
describe("callSessionTool", () => {
it("injects js intent and summarizes text results", async () => {
const execute = vi.fn().mockResolvedValue({
content: [{ type: "text", text: "hello" }],
});
const session = createSession([createTool("read", execute)]);
const statuses: Array<Record<string, unknown>> = [];
const result = await callSessionTool(
"read",
{ path: "/tmp/demo.txt" },
{
session,
emitStatus: event => {
statuses.push(event);
},
},
);
expect(result).toBe("hello");
expect(execute).toHaveBeenCalledWith(
expect.stringMatching(/^js-read-/),
{ path: "/tmp/demo.txt", _i: "js prelude" },
undefined,
);
expect(statuses).toEqual([expect.objectContaining({ op: "read", path: "/tmp/demo.txt", chars: 5 })]);
});
it("returns structured tool results when details or images are present", async () => {
const session = createSession([
createTool("custom", async () => ({
content: [
{ type: "text", text: "done" },
{ type: "image", mimeType: "image/png", data: "abc123" },
],
details: { ok: true },
})),
]);
const result = await callSessionTool("custom", {}, { session });
expect(result).toEqual({
text: "done",
details: { ok: true },
images: [{ mimeType: "image/png", data: "abc123" }],
});
});
it("throws when the requested tool is not available in the session registry", async () => {
const session = createSession([]);
await expect(callSessionTool("missing", {}, { session })).rejects.toThrow("Unknown tool from js runtime");
});
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test";
import { executePythonWithKernel } from "@oh-my-pi/pi-coding-agent/ipy/executor";
import type { KernelDisplayOutput } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
import { executePythonWithKernel } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
import type { KernelDisplayOutput } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
import { FakeKernel } from "./helpers";
describe("executePythonWithKernel display outputs", () => {
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { disposeAllKernelSessions, executePython } from "@oh-my-pi/pi-coding-agent/ipy/executor";
import type { KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
import * as pythonKernel from "@oh-my-pi/pi-coding-agent/ipy/kernel";
import { disposeAllKernelSessions, executePython } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
import type { KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
import * as pythonKernel from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
import { getProjectDir } from "@oh-my-pi/pi-utils";
class FakeKernel {
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test";
import { executePythonWithKernel, type PythonKernelExecutor } from "@oh-my-pi/pi-coding-agent/ipy/executor";
import type { KernelExecuteOptions, KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
import { executePythonWithKernel, type PythonKernelExecutor } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
import type { KernelExecuteOptions, KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
class FakeKernel implements PythonKernelExecutor {
constructor(
@@ -3,18 +3,16 @@ import {
disposeAllKernelSessions,
disposeKernelSessionsByOwner,
executePython,
resetPreludeDocsCache,
warmPythonEnvironment,
} from "@oh-my-pi/pi-coding-agent/ipy/executor";
import * as gatewayCoordinator from "@oh-my-pi/pi-coding-agent/ipy/gateway-coordinator";
} from "@oh-my-pi/pi-coding-agent/eval/py/executor";
import * as gatewayCoordinator from "@oh-my-pi/pi-coding-agent/eval/py/gateway-coordinator";
import type {
KernelExecuteResult,
KernelShutdownResult,
PreludeHelper,
PythonKernel as PythonKernelInstance,
} from "@oh-my-pi/pi-coding-agent/ipy/kernel";
import * as pythonKernel from "@oh-my-pi/pi-coding-agent/ipy/kernel";
import { PythonKernel } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
} from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
import * as pythonKernel from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
import { PythonKernel } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
import { TempDir } from "@oh-my-pi/pi-utils";
const OK_RESULT: KernelExecuteResult = {
@@ -47,7 +45,6 @@ async function flushMicrotasks(turns = 6): Promise<void> {
afterEach(async () => {
await disposeAllKernelSessions();
resetPreludeDocsCache();
vi.restoreAllMocks();
});
@@ -781,16 +778,7 @@ describe("python executor owner cleanup", () => {
it("attaches cached warmup sessions to newly provided owners", async () => {
using tempDir = TempDir.createSync("@python-owner-warmup-");
const docs: PreludeHelper[] = [
{
name: "read",
signature: "(path)",
docstring: "Read file contents.",
category: "File I/O",
},
];
const kernel = {
introspectPrelude: vi.fn().mockResolvedValue(docs),
execute: vi.fn(async () => OK_RESULT),
ping: vi.fn(async () => true),
isAlive: () => true,
@@ -801,11 +789,9 @@ describe("python executor owner cleanup", () => {
const firstWarmup = await warmPythonEnvironment(tempDir.path(), "warm-session", true, undefined, "owner-a");
expect(firstWarmup.ok).toBe(true);
expect(kernel.introspectPrelude).toHaveBeenCalledTimes(1);
const cachedWarmup = await warmPythonEnvironment(tempDir.path(), "warm-session", true, undefined, "owner-b");
expect(cachedWarmup.ok).toBe(true);
expect(kernel.introspectPrelude).toHaveBeenCalledTimes(1);
expect(startSpy).toHaveBeenCalledTimes(1);
await disposeKernelSessionsByOwner("owner-a");
@@ -827,16 +813,7 @@ describe("python executor owner cleanup", () => {
it("keeps cache-hit ownerless warmups provisional until an explicit owner takes over", async () => {
using tempDir = TempDir.createSync("@python-owner-fallback-");
const docs: PreludeHelper[] = [
{
name: "read",
signature: "(path)",
docstring: "Read file contents.",
category: "File I/O",
},
];
const kernel = {
introspectPrelude: vi.fn().mockResolvedValue(docs),
execute: vi.fn(async () => OK_RESULT),
ping: vi.fn(async () => true),
isAlive: () => true,
@@ -847,11 +824,9 @@ describe("python executor owner cleanup", () => {
const firstWarmup = await warmPythonEnvironment(tempDir.path(), "warm-fallback-session", true);
expect(firstWarmup.ok).toBe(true);
expect(kernel.introspectPrelude).toHaveBeenCalledTimes(1);
const cachedWarmup = await warmPythonEnvironment(tempDir.path(), "warm-fallback-session", true);
expect(cachedWarmup.ok).toBe(true);
expect(kernel.introspectPrelude).toHaveBeenCalledTimes(1);
expect(startSpy).toHaveBeenCalledTimes(1);
await executePython("1 + 1", {
@@ -865,7 +840,6 @@ describe("python executor owner cleanup", () => {
const postTakeoverWarmup = await warmPythonEnvironment(tempDir.path(), "warm-fallback-session", true);
expect(postTakeoverWarmup.ok).toBe(true);
expect(kernel.introspectPrelude).toHaveBeenCalledTimes(1);
expect(startSpy).toHaveBeenCalledTimes(1);
await disposeKernelSessionsByOwner("owner-a");
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it } from "bun:test";
import { executePython } from "@oh-my-pi/pi-coding-agent/ipy/executor";
import type { KernelExecuteOptions, KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
import { PythonKernel } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
import { executePython } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
import type { KernelExecuteOptions, KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
import { PythonKernel } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
import { TempDir } from "@oh-my-pi/pi-utils";
interface KernelStub {
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { disposeAllKernelSessions, executePython } from "@oh-my-pi/pi-coding-agent/ipy/executor";
import * as pythonKernel from "@oh-my-pi/pi-coding-agent/ipy/kernel";
import { disposeAllKernelSessions, executePython } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
import * as pythonKernel from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
class FakeKernel {
executeCalls = 0;
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import { executePythonWithKernel } from "@oh-my-pi/pi-coding-agent/ipy/executor";
import { executePythonWithKernel } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
import { DEFAULT_MAX_BYTES } from "@oh-my-pi/pi-coding-agent/session/streaming-output";
import { FakeKernel } from "./helpers";
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test";
import { executePythonWithKernel, type PythonKernelExecutor } from "@oh-my-pi/pi-coding-agent/ipy/executor";
import type { KernelExecuteOptions, KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
import { executePythonWithKernel, type PythonKernelExecutor } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
import type { KernelExecuteOptions, KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
class FakeKernel implements PythonKernelExecutor {
private result: KernelExecuteResult;
@@ -1,11 +1,11 @@
import { afterEach, describe, expect, it } from "bun:test";
import { disposeAllKernelSessions, executePython } from "@oh-my-pi/pi-coding-agent/ipy/executor";
import { disposeAllKernelSessions, executePython } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
import {
type KernelExecuteOptions,
type KernelExecuteResult,
type KernelShutdownResult,
PythonKernel,
} from "@oh-my-pi/pi-coding-agent/ipy/kernel";
} from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
Bun.env.PI_PYTHON_SKIP_CHECK = "1";
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test";
import { executePythonWithKernel, type PythonKernelExecutor } from "@oh-my-pi/pi-coding-agent/ipy/executor";
import type { KernelExecuteOptions, KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
import { executePythonWithKernel, type PythonKernelExecutor } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
import type { KernelExecuteOptions, KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
class FakeKernel implements PythonKernelExecutor {
private result: KernelExecuteResult;
@@ -1,13 +1,6 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import {
disposeAllKernelSessions,
executePythonWithKernel,
getPreludeDocs,
resetPreludeDocsCache,
warmPythonEnvironment,
} from "@oh-my-pi/pi-coding-agent/ipy/executor";
import { type KernelShutdownResult, type PreludeHelper, PythonKernel } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
import { disposeAllKernelSessions, executePythonWithKernel } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
import { DEFAULT_MAX_BYTES } from "@oh-my-pi/pi-coding-agent/session/streaming-output";
import { TempDir } from "@oh-my-pi/pi-utils";
import { FakeKernel } from "./helpers";
@@ -134,43 +127,5 @@ describe("executePythonWithKernel", () => {
afterEach(async () => {
await disposeAllKernelSessions();
resetPreludeDocsCache();
vi.restoreAllMocks();
});
describe("warmPythonEnvironment", () => {
it("caches prelude docs on warmup", async () => {
const previousSkip = Bun.env.PI_PYTHON_SKIP_CHECK;
Bun.env.PI_PYTHON_SKIP_CHECK = "1";
using tempDir = TempDir.createSync("@python-executor-");
const docs: PreludeHelper[] = [
{
name: "read",
signature: "(path)",
docstring: "Read file contents.",
category: "File I/O",
},
];
const kernel = {
introspectPrelude: vi.fn().mockResolvedValue(docs),
ping: vi.fn().mockResolvedValue(true),
isAlive: () => true,
shutdown: vi.fn(async (): Promise<KernelShutdownResult> => ({ confirmed: true })),
};
const startSpy = vi.spyOn(PythonKernel, "start").mockResolvedValue(kernel as unknown as PythonKernel);
const result = await warmPythonEnvironment(tempDir.path(), "session-1");
expect(result.ok).toBe(true);
expect(result.docs).toEqual(docs);
expect(getPreludeDocs()).toEqual(docs);
expect(kernel.introspectPrelude).toHaveBeenCalledTimes(1);
startSpy.mockRestore();
if (previousSkip === undefined) {
delete Bun.env.PI_PYTHON_SKIP_CHECK;
} else {
Bun.env.PI_PYTHON_SKIP_CHECK = previousSkip;
}
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import { renderKernelDisplay } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
import { renderKernelDisplay } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
describe("PythonKernel display rendering", () => {
it("normalizes text/plain output and returns no display outputs", () => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import { filterEnv } from "@oh-my-pi/pi-coding-agent/ipy/runtime";
import { filterEnv } from "@oh-my-pi/pi-coding-agent/eval/py/runtime";
describe("Python gateway environment filtering", () => {
it("filters sensitive and unknown variables from shell env", () => {
@@ -1,11 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { disposeAllKernelSessions, executePython } from "@oh-my-pi/pi-coding-agent/ipy/executor";
import { disposeAllKernelSessions, executePython } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
import type {
KernelExecuteOptions,
KernelExecuteResult,
KernelShutdownResult,
} from "@oh-my-pi/pi-coding-agent/ipy/kernel";
import { PythonKernel } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
} from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
import { PythonKernel } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
import { TempDir } from "@oh-my-pi/pi-utils";
class FakeKernel {
@@ -3,7 +3,7 @@ import {
deserializeWebSocketMessage,
type JupyterMessage,
serializeWebSocketMessage,
} from "@oh-my-pi/pi-coding-agent/ipy/kernel";
} from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
const encoder = new TextEncoder();
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as gatewayCoordinator from "@oh-my-pi/pi-coding-agent/ipy/gateway-coordinator";
import { PythonKernel } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
import * as gatewayCoordinator from "@oh-my-pi/pi-coding-agent/eval/py/gateway-coordinator";
import { PythonKernel } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
import { hookFetch, TempDir } from "@oh-my-pi/pi-utils";
import type { Subprocess } from "bun";
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { type KernelDisplayOutput, PythonKernel } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
import { PYTHON_PRELUDE } from "@oh-my-pi/pi-coding-agent/ipy/prelude";
import { type KernelDisplayOutput, PythonKernel } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
import { PYTHON_PRELUDE } from "@oh-my-pi/pi-coding-agent/eval/py/prelude";
import { hookFetch } from "@oh-my-pi/pi-utils";
type JupyterMessage = {
@@ -372,105 +372,6 @@ describe("PythonKernel (external gateway)", () => {
expect(kernel.isAlive()).toBe(true);
await kernel.shutdown();
});
it("introspects prelude helpers", async () => {
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
if (url.endsWith("/api/kernels") && init?.method === "POST") {
return new Response(JSON.stringify({ id: "kernel-4" }), { status: 201 });
}
return new Response("", { status: 200 });
});
using _hook = hookFetch((input, init) => fetchMock(String(input), init));
const docs = [
{
name: "read",
signature: "(path, limit=None)",
docstring: "Read file contents.",
category: "File I/O",
},
];
const payload = JSON.stringify(docs);
let initSeen = false;
let preludeSeen = false;
const kernelPromise = PythonKernel.start({ cwd: "/" });
await Bun.sleep(10);
const ws = FakeWebSocket.lastInstance;
if (!ws) throw new Error("WebSocket not initialized");
ws.setSendHandler(data => {
const msg = typeof data === "string" ? (JSON.parse(data) as JupyterMessage) : decodeMessage(data);
const code = String(msg.content.code ?? "");
if (!initSeen) {
initSeen = true;
sendOkExecution(ws, msg.header.msg_id);
return;
}
if (!preludeSeen) {
expect(code).toBe(PYTHON_PRELUDE);
preludeSeen = true;
sendOkExecution(ws, msg.header.msg_id);
return;
}
if (code.includes("__omp_prelude_docs__")) {
const stream: JupyterMessage = {
channel: "iopub",
header: {
msg_id: "stream-docs",
session: "session",
username: "omp",
date: new Date().toISOString(),
msg_type: "stream",
version: "5.5",
},
parent_header: { msg_id: msg.header.msg_id },
metadata: {},
content: { text: `${payload}\n` },
};
const reply: JupyterMessage = {
channel: "shell",
header: {
msg_id: "reply-docs",
session: "session",
username: "omp",
date: new Date().toISOString(),
msg_type: "execute_reply",
version: "5.5",
},
parent_header: { msg_id: msg.header.msg_id },
metadata: {},
content: { status: "ok", execution_count: 2 },
};
const status: JupyterMessage = {
channel: "iopub",
header: {
msg_id: "status-docs",
session: "session",
username: "omp",
date: new Date().toISOString(),
msg_type: "status",
version: "5.5",
},
parent_header: { msg_id: msg.header.msg_id },
metadata: {},
content: { execution_state: "idle" },
};
ws.onmessage?.({ data: encodeMessage(stream) });
ws.onmessage?.({ data: encodeMessage(reply) });
ws.onmessage?.({ data: encodeMessage(status) });
return;
}
sendOkExecution(ws, msg.header.msg_id);
});
const kernel = await kernelPromise;
const result = await kernel.introspectPrelude();
expect(result).toEqual(docs);
await kernel.shutdown();
});
});
// TODO: add coverage for gateway process exit handling once PythonKernel exposes a test hook.
@@ -1,154 +0,0 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { loadPythonModules, type PythonModuleExecutor } from "@oh-my-pi/pi-coding-agent/ipy/modules";
import { getAgentModulesDir, getProjectModulesDir, TempDir } from "@oh-my-pi/pi-utils";
const fixturesDir = path.resolve(import.meta.dir, "../../test/fixtures/python-modules");
const readFixture = (name: string): Promise<string> => Bun.file(path.join(fixturesDir, name)).text();
const writeModule = async (dir: string, name: string, tag: string) => {
await fs.mkdir(dir, { recursive: true });
const base = await readFixture(name);
await Bun.write(path.join(dir, name), `${base}\n# ${tag}`);
};
describe("python modules", () => {
let tempRoot: TempDir | null = null;
afterEach(() => {
if (tempRoot) {
tempRoot.removeSync();
}
tempRoot = null;
vi.restoreAllMocks();
});
it("loads modules in sorted order and forwards startup execute options", async () => {
tempRoot = TempDir.createSync("@omp-python-modules-");
const agentDir = path.join(tempRoot.path(), "agent");
const cwd = path.join(tempRoot.path(), "project");
const signal = new AbortController().signal;
await writeModule(getAgentModulesDir(agentDir), "beta.py", "user-omp");
await writeModule(getAgentModulesDir(agentDir), "alpha.py", "user-omp");
const calls: Array<{
name: string;
options?: { signal?: AbortSignal; timeoutMs?: number; silent?: boolean; storeHistory?: boolean };
}> = [];
const executor: PythonModuleExecutor = {
execute: async (code: string, options) => {
const name = code.includes("def alpha") ? "alpha" : "beta";
calls.push({ name, options });
return { status: "ok", cancelled: false };
},
};
await loadPythonModules(executor, { cwd, agentDir, signal, timeoutMs: 987 });
expect(calls.map(call => call.name)).toEqual(["alpha", "beta"]);
for (const call of calls) {
expect(call.options).toEqual({ signal, timeoutMs: 987, silent: true, storeHistory: false });
}
});
it("derives module execution timeout from the remaining deadline", async () => {
tempRoot = TempDir.createSync("@omp-python-modules-");
const agentDir = path.join(tempRoot.path(), "agent");
const cwd = path.join(tempRoot.path(), "project");
const signal = new AbortController().signal;
await writeModule(getProjectModulesDir(cwd), "alpha.py", "project-omp");
const execute = vi.fn(
async (
_code: string,
_options?: {
signal?: AbortSignal;
timeoutMs?: number;
silent?: boolean;
storeHistory?: boolean;
},
) => ({ status: "ok" as const, cancelled: false }),
);
const executor: PythonModuleExecutor = { execute };
vi.spyOn(Date, "now").mockReturnValue(10_000);
await loadPythonModules(executor, { cwd, agentDir, signal, deadlineMs: 10_250 });
expect(execute).toHaveBeenCalledTimes(1);
expect(execute).toHaveBeenCalledWith(expect.any(String), {
signal,
timeoutMs: 250,
silent: true,
storeHistory: false,
});
});
it("fails fast when the module deadline expires before the next execution starts", async () => {
tempRoot = TempDir.createSync("@omp-python-modules-");
const agentDir = path.join(tempRoot.path(), "agent");
const cwd = path.join(tempRoot.path(), "project");
const signal = new AbortController().signal;
await writeModule(getProjectModulesDir(cwd), "alpha.py", "project-omp");
await writeModule(getProjectModulesDir(cwd), "beta.py", "project-omp");
const execute = vi.fn(async () => ({ status: "ok" as const, cancelled: false }));
const executor: PythonModuleExecutor = { execute };
vi.spyOn(Date, "now").mockReturnValueOnce(10_000).mockReturnValueOnce(10_300);
await expect(loadPythonModules(executor, { cwd, agentDir, signal, deadlineMs: 10_250 })).rejects.toMatchObject({
name: "TimeoutError",
message: "Python module loading timed out",
});
expect(execute).toHaveBeenCalledTimes(1);
expect(execute).toHaveBeenCalledWith(expect.any(String), {
signal,
timeoutMs: 250,
silent: true,
storeHistory: false,
});
});
it("preserves timeout classification when module execution is cancelled", async () => {
tempRoot = TempDir.createSync("@omp-python-modules-");
const agentDir = path.join(tempRoot.path(), "agent");
const cwd = path.join(tempRoot.path(), "project");
await writeModule(getProjectModulesDir(cwd), "alpha.py", "project-omp");
const execute = vi.fn(async () => ({ status: "ok" as const, cancelled: true, timedOut: true }));
const executor: PythonModuleExecutor = { execute };
await expect(loadPythonModules(executor, { cwd, agentDir })).rejects.toMatchObject({
name: "TimeoutError",
message: expect.stringContaining("Failed to load Python module"),
});
expect(execute).toHaveBeenCalledTimes(1);
});
it("fails fast when a module fails to execute", async () => {
tempRoot = TempDir.createSync("@omp-python-modules-");
const agentDir = path.join(tempRoot.path(), "agent");
const cwd = path.join(tempRoot.path(), "project");
await writeModule(getAgentModulesDir(agentDir), "alpha.py", "user-omp");
await writeModule(getProjectModulesDir(cwd), "beta.py", "project-omp");
const executor: PythonModuleExecutor = {
execute: async (code: string) => {
if (code.includes("def beta")) {
return {
status: "error",
cancelled: false,
error: { name: "Error", value: "boom", traceback: [] },
};
}
return { status: "ok", cancelled: false };
},
};
await expect(loadPythonModules(executor, { cwd, agentDir })).rejects.toThrow("Failed to load Python module");
});
});
@@ -2,8 +2,8 @@ import { describe, expect, it } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { resetPreludeDocsCache, warmPythonEnvironment } from "@oh-my-pi/pi-coding-agent/ipy/executor";
import { getPythonToolDescription, PythonTool } from "@oh-my-pi/pi-coding-agent/tools/python";
import { warmPythonEnvironment } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
import { EvalTool, getEvalToolDescription } from "@oh-my-pi/pi-coding-agent/tools/eval";
import { $which, getProjectDir } from "@oh-my-pi/pi-utils";
const resolvePythonPath = (): string | null => {
@@ -39,7 +39,7 @@ const hasKernelDeps = (() => {
const shouldRun = Boolean(pythonPath) && hasKernelDeps;
describe.skipIf(!shouldRun)("PYTHON_PRELUDE integration", () => {
it("exposes prelude helpers via python tool", async () => {
it("exposes prelude helpers via eval python backend", async () => {
const helpers = ["env", "read", "write", "append", "rm", "mv", "cp", "find", "grep"];
const session = {
@@ -49,47 +49,37 @@ describe.skipIf(!shouldRun)("PYTHON_PRELUDE integration", () => {
getSessionSpawns: () => null,
settings: Settings.isolated({
"lsp.diagnosticsOnWrite": false,
"python.toolMode": "ipy-only",
"eval.py": true,
"python.kernelMode": "per-call",
"python.sharedGateway": true,
}),
};
resetPreludeDocsCache();
const tool = new PythonTool(session);
const tool = new EvalTool(session);
const code = `
helpers = ${JSON.stringify(helpers)}
missing = [name for name in helpers if name not in globals() or not callable(globals()[name])]
docs = __omp_prelude_docs__()
doc_names = [d.get("name") for d in docs]
doc_categories = [d.get("category") for d in docs]
print("HELPERS_OK=" + ("1" if not missing else "0"))
print("DOCS_OK=" + ("1" if "read" in doc_names and "File I/O" in doc_categories else "0"))
if missing:
print("MISSING=" + ",".join(missing))
`;
const result = await tool.execute("tool-call-1", { cells: [{ title: "prelude helpers", code }] });
const result = await tool.execute("tool-call-1", {
input: `=== CELL prelude helpers ===
\`\`\`py
${code}
\`\`\`
`,
});
const output = result.content.find(item => item.type === "text")?.text ?? "";
expect(output).toContain("HELPERS_OK=1");
expect(output).toContain("DOCS_OK=1");
expect(tool.description).toContain("read");
expect(tool.description).not.toContain("Documentation unavailable");
});
it("exposes prelude docs via warmup", async () => {
resetPreludeDocsCache();
it("renders prelude docs in eval tool description", async () => {
const result = await warmPythonEnvironment(getProjectDir());
expect(result.ok).toBe(true);
const names = result.docs.map(doc => doc.name);
expect(names).toContain("read");
});
it("renders prelude docs in python tool description", async () => {
resetPreludeDocsCache();
const result = await warmPythonEnvironment(getProjectDir());
expect(result.ok).toBe(true);
const description = getPythonToolDescription();
const description = getEvalToolDescription();
expect(description).toContain("read");
expect(description).not.toContain("Documentation unavailable");
});
@@ -1,11 +0,0 @@
import { describe, expect, it } from "bun:test";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
describe("Settings python settings", () => {
it("defaults to both and session", () => {
const settings = Settings.isolated({});
expect(settings.get("python.toolMode")).toBe("both");
expect(settings.get("python.kernelMode")).toBe("session");
});
});
@@ -0,0 +1,251 @@
import { describe, expect, it } from "bun:test";
import { parseEvalInput } from "../../src/eval/parse";
describe("parseEvalInput", () => {
it("parses a single fenced cell with positional title and timeout", () => {
const result = parseEvalInput(`\`\`\`py setup 15s
print("hi")
\`\`\`
`);
expect(result.cells).toEqual([
{
index: 0,
title: "setup",
code: 'print("hi")',
language: "python",
languageOrigin: "fence",
timeoutMs: 15_000,
reset: false,
},
]);
});
it("treats rst=true as a per-language kernel wipe for that cell", () => {
const result = parseEvalInput(`\`\`\`py rst=true id="bootstrap"
import json
\`\`\`
\`\`\`js rst=true
const x = 1;
\`\`\`
`);
expect(result.cells.map(cell => [cell.language, cell.reset, cell.title])).toEqual([
["python", true, "bootstrap"],
["js", true, undefined],
]);
});
it("inherits language and runs without reset for empty fence info", () => {
const result = parseEvalInput(`\`\`\`js
const a = 1;
\`\`\`
\`\`\`
const b = a + 1;
\`\`\`
`);
expect(result.cells.map(cell => [cell.language, cell.languageOrigin, cell.code, cell.reset])).toEqual([
["js", "fence", "const a = 1;", false],
["js", "fence", "const b = a + 1;", false],
]);
});
it("supports tilde fences and case-insensitive language tokens including ipython aliases", () => {
const result = parseEvalInput(`~~~TypeScript
const a = 1;
~~~
\`\`\`IPython
print("ipy")
\`\`\`
`);
expect(result.cells.map(cell => [cell.language, cell.languageOrigin])).toEqual([
["js", "fence"],
["python", "fence"],
]);
});
it("uses canonical id and t attributes, with explicit attrs winning over positional", () => {
const result = parseEvalInput(`\`\`\`py 5s some words t=2m id="explicit win"
print(1)
\`\`\`
`);
expect(result.cells[0]).toMatchObject({
title: "explicit win",
timeoutMs: 120_000,
language: "python",
});
});
it("accepts fallback aliases for id, t, and rst keys", () => {
const cases = [
{ key: "title", expectTitle: "alpha" },
{ key: "name", expectTitle: "alpha" },
{ key: "cell", expectTitle: "alpha" },
{ key: "file", expectTitle: "alpha" },
{ key: "label", expectTitle: "alpha" },
];
for (const { key, expectTitle } of cases) {
const result = parseEvalInput(`\`\`\`py ${key}="alpha"\nprint(1)\n\`\`\`\n`);
expect(result.cells[0].title).toBe(expectTitle);
}
const timeoutAliases = ["timeout", "duration", "time"];
for (const key of timeoutAliases) {
const result = parseEvalInput(`\`\`\`py ${key}=2m\nprint(1)\n\`\`\`\n`);
expect(result.cells[0].timeoutMs).toBe(120_000);
}
const resetAliases = ["reset"];
for (const key of resetAliases) {
const result = parseEvalInput(`\`\`\`py ${key}=true\nprint(1)\n\`\`\`\n`);
expect(result.cells[0].reset).toBe(true);
}
});
it("first occurrence wins when canonical and alias collide", () => {
const canonicalFirst = parseEvalInput(`\`\`\`py id="canon" title="alias"
print(1)
\`\`\`
`);
const aliasFirst = parseEvalInput(`\`\`\`py title="alias" id="canon"
print(1)
\`\`\`
`);
expect(canonicalFirst.cells[0].title).toBe("canon");
expect(aliasFirst.cells[0].title).toBe("alias");
});
it("parses millisecond, second, and minute durations", () => {
const result = parseEvalInput(`\`\`\`py 500ms
a = 1
\`\`\`
\`\`\`py 5
a = 2
\`\`\`
\`\`\`py 2m
a = 3
\`\`\`
`);
expect(result.cells.map(cell => cell.timeoutMs)).toEqual([500, 5_000, 120_000]);
});
it("treats unrecognized fence info as title and inherits the language", () => {
const result = parseEvalInput(`\`\`\`ruby
puts "no"
\`\`\`
`);
expect(result.cells[0]).toMatchObject({
title: "ruby",
code: 'puts "no"',
language: "python",
languageOrigin: "default",
});
});
it("joins multiple positional title fragments with spaces", () => {
const result = parseEvalInput(`\`\`\`py compute totals
print(1)
\`\`\`
`);
expect(result.cells[0].title).toBe("compute totals");
});
it("accepts back-to-back fenced cells without blank separators", () => {
const result = parseEvalInput(`\`\`\`py id=a
print("a")
\`\`\`
\`\`\`py id=b
print("b")
\`\`\`
`);
expect(result.cells.map(cell => [cell.title, cell.code])).toEqual([
["a", 'print("a")'],
["b", 'print("b")'],
]);
});
it("wraps bare code with no fences in a single implicit cell", () => {
const result = parseEvalInput(`print("hello")
print("world")
`);
expect(result.cells).toEqual([
{
index: 0,
title: undefined,
code: 'print("hello")\nprint("world")',
language: "python",
languageOrigin: "default",
timeoutMs: 30_000,
reset: false,
},
]);
});
it("surfaces raw inter-fence content as its own implicit cell that inherits language", () => {
const result = parseEvalInput(`\`\`\`js
const x = 1;
\`\`\`
inherited tail
`);
expect(result.cells.map(cell => [cell.language, cell.languageOrigin, cell.code])).toEqual([
["js", "fence", "const x = 1;"],
["js", "fence", "inherited tail"],
]);
});
it("treats unclosed fences leniently and closes them at end of input", () => {
const result = parseEvalInput(`\`\`\`py
print("still typing")`);
expect(result.cells).toHaveLength(1);
expect(result.cells[0]).toMatchObject({
code: 'print("still typing")',
language: "python",
languageOrigin: "fence",
reset: false,
});
});
it("ignores unknown attribute keys without erroring", () => {
const result = parseEvalInput(`\`\`\`py mystery=123 id=ok
print(1)
\`\`\`
`);
expect(result.cells[0]).toMatchObject({ title: "ok", language: "python" });
});
it("rejects an invalid rst value", () => {
expect(() =>
parseEvalInput(`\`\`\`py rst=maybe
print(1)
\`\`\`
`),
).toThrow("invalid rst value");
});
it("rejects an invalid t value", () => {
expect(() =>
parseEvalInput(`\`\`\`py t=forever
print(1)
\`\`\`
`),
).toThrow("invalid duration");
});
});
@@ -1,2 +0,0 @@
def alpha():
return "alpha"
@@ -1,2 +0,0 @@
def beta():
return "beta"
@@ -1,2 +0,0 @@
def delta():
return "delta"
@@ -1,2 +0,0 @@
def gamma():
return "gamma"
@@ -49,7 +49,7 @@ function createContext(): {
spies: {
abort: ReturnType<typeof vi.fn>;
abortBash: ReturnType<typeof vi.fn>;
abortPython: ReturnType<typeof vi.fn>;
abortEval: ReturnType<typeof vi.fn>;
addMessageToChat: ReturnType<typeof vi.fn>;
cancelPendingSubmission: ReturnType<typeof vi.fn>;
clearQueue: ReturnType<typeof vi.fn>;
@@ -66,7 +66,7 @@ function createContext(): {
let editorText = "";
const abort = vi.fn();
const abortBash = vi.fn();
const abortPython = vi.fn();
const abortEval = vi.fn();
const addMessageToChat = vi.fn();
const cancelPendingSubmission = vi.fn(() => false);
const clearQueue = vi.fn(() => ({ steering: [], followUp: [] }));
@@ -111,13 +111,13 @@ function createContext(): {
isCompacting: false,
isGeneratingHandoff: false,
isBashRunning: false,
isPythonRunning: false,
isEvalRunning: false,
queuedMessageCount: 0,
messages: [],
extensionRunner: undefined,
abort,
abortBash,
abortPython,
abortEval,
clearQueue,
prompt,
} as unknown as InteractiveModeContext["session"],
@@ -160,7 +160,7 @@ function createContext(): {
spies: {
abort,
abortBash,
abortPython,
abortEval,
addMessageToChat,
cancelPendingSubmission,
clearQueue,
@@ -242,14 +242,14 @@ describe("InputController escape behavior", () => {
it("prefers aborting python before aborting an overlapping stream", () => {
const { ctx, editor, spies } = createContext();
(ctx.session as { isStreaming: boolean; isPythonRunning: boolean }).isStreaming = true;
(ctx.session as { isStreaming: boolean; isPythonRunning: boolean }).isPythonRunning = true;
(ctx.session as { isStreaming: boolean; isEvalRunning: boolean }).isStreaming = true;
(ctx.session as { isStreaming: boolean; isEvalRunning: boolean }).isEvalRunning = true;
const controller = new InputController(ctx);
controller.setupKeyHandlers();
editor.onEscape?.();
expect(spies.abortPython).toHaveBeenCalledTimes(1);
expect(spies.abortEval).toHaveBeenCalledTimes(1);
expect(spies.abort).not.toHaveBeenCalled();
});
@@ -63,7 +63,7 @@ async function createContext() {
isCompacting: false,
isGeneratingHandoff: false,
isBashRunning: false,
isPythonRunning: false,
isEvalRunning: false,
extensionRunner: undefined,
} as InteractiveModeContext["session"],
keybindings: {
@@ -1,132 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { type SettingPath, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import * as pythonExecutor from "@oh-my-pi/pi-coding-agent/ipy/executor";
import * as pythonKernel from "@oh-my-pi/pi-coding-agent/ipy/kernel";
import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { PythonTool } from "@oh-my-pi/pi-coding-agent/tools/python";
import { Snowflake } from "@oh-my-pi/pi-utils";
function createSession(
cwd: string,
sessionFile: string,
overrides?: Partial<Record<SettingPath, unknown>>,
kernelOwnerId?: string,
forcePythonWarmup = false,
): ToolSession {
return {
cwd,
hasUI: false,
getSessionFile: () => sessionFile,
getSessionSpawns: () => null,
getPythonKernelOwnerId: () => kernelOwnerId ?? null,
forcePythonWarmup,
settings: Settings.isolated({ "python.toolMode": "ipy-only", ...overrides }),
};
}
describe("python tool settings", () => {
let testDir: string;
beforeEach(() => {
testDir = path.join(os.tmpdir(), `python-tool-settings-${Snowflake.next()}`);
fs.mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
pythonExecutor.resetPreludeDocsCache();
vi.restoreAllMocks();
fs.rmSync(testDir, { recursive: true, force: true });
});
it("exposes python tool when kernel is available", async () => {
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
const sessionFile = path.join(testDir, "session.jsonl");
const tools = await createTools(createSession(testDir, sessionFile), ["python"]);
expect(tools.map(tool => tool.name).sort()).toEqual(["exit_plan_mode", "python"]);
});
it("falls back to bash when python is unavailable", async () => {
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({
ok: false,
reason: "missing",
});
const sessionFile = path.join(testDir, "session.jsonl");
const tools = await createTools(createSession(testDir, sessionFile), ["python"]);
expect(tools.map(tool => tool.name).sort()).toEqual(["bash", "exit_plan_mode"]);
});
it("passes kernel owner and kernel mode from settings to executor", async () => {
vi.spyOn(pythonExecutor, "getPreludeDocs").mockReturnValue([]);
const warmupSpy = vi.spyOn(pythonExecutor, "warmPythonEnvironment").mockResolvedValue({ ok: true, docs: [] });
const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockResolvedValue({
output: "ok",
exitCode: 0,
cancelled: false,
truncated: false,
totalLines: 1,
totalBytes: 2,
outputLines: 1,
outputBytes: 2,
displayOutputs: [],
stdinRequested: false,
});
const sessionFile = path.join(testDir, "session.jsonl");
const kernelOwnerId = "owner-456";
const session = createSession(testDir, sessionFile, { "python.kernelMode": "per-call" }, kernelOwnerId);
const pythonTool = new PythonTool(session);
await pythonTool.execute("tool-call", { cells: [{ title: "print-single-cell", code: "print(1)" }] });
expect(warmupSpy).toHaveBeenCalledWith(
testDir,
`session:${sessionFile}:cwd:${testDir}`,
true,
sessionFile,
kernelOwnerId,
expect.any(AbortSignal),
);
expect(executeSpy).toHaveBeenCalledWith(
"print(1)",
expect.objectContaining({
kernelMode: "per-call",
sessionId: `session:${sessionFile}:cwd:${testDir}`,
kernelOwnerId,
}),
);
});
it("passes kernel owner into createTools warmup without changing session ids", async () => {
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
vi.spyOn(pythonExecutor, "getPreludeDocs").mockReturnValue([]);
const warmupSpy = vi.spyOn(pythonExecutor, "warmPythonEnvironment").mockResolvedValue({ ok: true, docs: [] });
const sessionFile = path.join(testDir, "session-create-tools.jsonl");
const kernelOwnerId = "owner-create-tools";
const previousSkipCheck = Bun.env.PI_PYTHON_SKIP_CHECK;
delete Bun.env.PI_PYTHON_SKIP_CHECK;
try {
await createTools(createSession(testDir, sessionFile, undefined, kernelOwnerId, true), ["python"]);
expect(warmupSpy).toHaveBeenCalledWith(
testDir,
`session:${sessionFile}:cwd:${testDir}`,
true,
sessionFile,
kernelOwnerId,
);
} finally {
if (previousSkipCheck === undefined) {
delete Bun.env.PI_PYTHON_SKIP_CHECK;
} else {
Bun.env.PI_PYTHON_SKIP_CHECK = previousSkipCheck;
}
}
});
});
@@ -277,7 +277,7 @@ describe("system Handlebars prompt templates", () => {
contextFiles: [],
skills: [],
rules: [],
toolNames: ["read", "search", "find", "edit", "lsp", "bash", "python"],
toolNames: ["read", "search", "find", "edit", "lsp", "bash", "eval"],
tools: new Map([
["read", { label: "Read", description: "Reads files" }],
["search", { label: "Search", description: "Searches files" }],
@@ -285,7 +285,7 @@ describe("system Handlebars prompt templates", () => {
["edit", { label: "Edit", description: "Edits files", wireName: "apply_patch" }],
["lsp", { label: "LSP", description: "Queries language servers" }],
["bash", { label: "Bash", description: "Runs shell commands" }],
["python", { label: "Python", description: "Runs Python cells" }],
["eval", { label: "Eval", description: "Runs eval cells" }],
]),
});
@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import * as evalIndex from "@oh-my-pi/pi-coding-agent/eval";
import * as pyKernel from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { EvalTool } from "@oh-my-pi/pi-coding-agent/tools/eval";
function makeSession(): ToolSession {
return {
cwd: "/tmp/eval-test",
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => null,
settings: Settings.isolated(),
};
}
const mockResult = {
output: "ok",
exitCode: 0,
cancelled: false,
truncated: false,
artifactId: undefined,
totalLines: 1,
totalBytes: 2,
outputLines: 1,
outputBytes: 2,
displayOutputs: [],
};
describe("EvalTool language resolution", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("dispatches to js when fenced code declares ```js", async () => {
vi.spyOn(pyKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
const jsExecuteSpy = vi.spyOn(evalIndex.jsBackend, "execute").mockResolvedValue(mockResult);
const pythonExecuteSpy = vi.spyOn(evalIndex.pythonBackend, "execute");
const tool = new EvalTool(makeSession());
await tool.execute("call-1", {
input: "=== CELL one ===\n```js\nconst x = 1;\n```\n",
});
expect(jsExecuteSpy).toHaveBeenCalledTimes(1);
expect(pythonExecuteSpy).not.toHaveBeenCalled();
});
it("dispatches to python when fenced code declares ```python", async () => {
const pythonExecuteSpy = vi.spyOn(evalIndex.pythonBackend, "execute").mockResolvedValue(mockResult);
vi.spyOn(evalIndex.pythonBackend, "isAvailable").mockResolvedValue(true);
const jsExecuteSpy = vi.spyOn(evalIndex.jsBackend, "execute");
const tool = new EvalTool(makeSession());
await tool.execute("call-2", {
input: "=== CELL one ===\n```python\nprint('hi')\n```\n",
});
expect(pythonExecuteSpy).toHaveBeenCalledTimes(1);
expect(jsExecuteSpy).not.toHaveBeenCalled();
});
it("auto-detects python via syntactic markers when fence is bare", async () => {
const pythonExecuteSpy = vi.spyOn(evalIndex.pythonBackend, "execute").mockResolvedValue(mockResult);
vi.spyOn(evalIndex.pythonBackend, "isAvailable").mockResolvedValue(true);
const jsExecuteSpy = vi.spyOn(evalIndex.jsBackend, "execute");
const tool = new EvalTool(makeSession());
await tool.execute("call-3", {
input: "=== CELL one ===\ndef greet():\n print('hi')\ngreet()\n",
});
expect(pythonExecuteSpy).toHaveBeenCalledTimes(1);
expect(jsExecuteSpy).not.toHaveBeenCalled();
});
});
+19 -17
View File
@@ -53,7 +53,7 @@ describe("createTools", () => {
const names = tools.map(t => t.name);
// Core tools should always be present
expect(names).toContain("python");
expect(names).toContain("eval");
expect(names).toContain("bash");
expect(names).toContain("read");
expect(names).toContain("edit");
@@ -83,46 +83,48 @@ describe("createTools", () => {
expect(names).not.toContain("vim");
});
it("includes bash and python when python mode is both", async () => {
it("includes bash and eval when both eval backends are allowed", async () => {
const session = createTestSession({
settings: createSettingsWithOverrides({
"python.toolMode": "both",
"eval.py": true,
"eval.js": true,
}),
});
const tools = await createTools(session);
const names = tools.map(t => t.name);
expect(names).toContain("python");
expect(names).toContain("eval");
expect(names).toContain("bash");
});
it("includes bash when python mode is bash-only", async () => {
it("still exposes eval when only the js backend is allowed", async () => {
const session = createTestSession({
settings: createSettingsWithOverrides({
"python.toolMode": "bash-only",
"eval.py": false,
"eval.js": true,
}),
});
const tools = await createTools(session);
const names = tools.map(t => t.name);
expect(names).toContain("bash");
expect(names).not.toContain("python");
expect(names).toContain("eval");
});
it("includes bash when python unavailable and python requested", async () => {
it("still exposes eval when python kernel is unavailable (dispatches to js)", async () => {
const session = createTestSession();
vi.spyOn(await import("@oh-my-pi/pi-coding-agent/ipy/kernel"), "checkPythonKernelAvailability").mockResolvedValue(
{
ok: false,
reason: "missing python",
},
);
const tools = await createTools(session, ["python"]);
vi.spyOn(
await import("@oh-my-pi/pi-coding-agent/eval/py/kernel"),
"checkPythonKernelAvailability",
).mockResolvedValue({
ok: false,
reason: "missing python",
});
const tools = await createTools(session, ["eval"]);
const names = tools.map(t => t.name);
expect(names).toContain("bash");
expect(names).toContain("eval");
expect(names).toContain("exit_plan_mode");
expect(names).not.toContain("python");
});
it("excludes lsp tool when session disables LSP", async () => {
@@ -1,84 +0,0 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import * as pythonExecutor from "@oh-my-pi/pi-coding-agent/ipy/executor";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { PythonTool } from "@oh-my-pi/pi-coding-agent/tools/python";
import { TempDir } from "@oh-my-pi/pi-utils";
function createSession(cwd: string, kernelOwnerId?: string): ToolSession {
return {
cwd,
hasUI: false,
getSessionFile: () => `${cwd}/session-file.jsonl`,
getSessionSpawns: () => "*",
getPythonKernelOwnerId: () => kernelOwnerId ?? null,
settings: Settings.isolated({
"lsp.formatOnWrite": true,
"bashInterceptor.enabled": true,
"python.toolMode": "ipy-only",
"python.kernelMode": "per-call",
}),
};
}
describe("python tool execution", () => {
afterEach(() => {
pythonExecutor.resetPreludeDocsCache();
vi.restoreAllMocks();
});
it("passes kernel owner and kernel options from settings and args", async () => {
const tempDir = TempDir.createSync("@python-tool-");
vi.spyOn(pythonExecutor, "getPreludeDocs").mockReturnValue([]);
const warmupSpy = vi.spyOn(pythonExecutor, "warmPythonEnvironment").mockResolvedValue({ ok: true, docs: [] });
const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockResolvedValue({
output: "ok",
exitCode: 0,
cancelled: false,
truncated: false,
totalLines: 1,
totalBytes: 2,
outputLines: 1,
outputBytes: 2,
displayOutputs: [],
stdinRequested: false,
});
const kernelOwnerId = "owner-123";
const tool = new PythonTool(createSession(tempDir.path(), kernelOwnerId));
const result = await tool.execute(
"call-id",
{ cells: [{ title: "hello-world", code: "print('hi')" }], timeout: 5, reset: true },
undefined,
undefined,
undefined,
);
expect(warmupSpy).toHaveBeenCalledWith(
tempDir.path(),
`session:${tempDir.path()}/session-file.jsonl:cwd:${tempDir.path()}`,
true,
`${tempDir.path()}/session-file.jsonl`,
kernelOwnerId,
expect.any(AbortSignal),
);
expect(executeSpy).toHaveBeenCalledWith(
"print('hi')",
expect.objectContaining({
cwd: tempDir.path(),
deadlineMs: expect.any(Number),
signal: expect.any(AbortSignal),
sessionFile: `${tempDir.path()}/session-file.jsonl`,
sessionId: `session:${tempDir.path()}/session-file.jsonl:cwd:${tempDir.path()}`,
kernelOwnerId,
kernelMode: "per-call",
useSharedGateway: true,
reset: true,
}),
);
const text = result.content.find(item => item.type === "text")?.text;
expect(text).toBe("ok");
tempDir.removeSync();
});
});

Some files were not shown because too many files have changed in this diff Show More