From cf60e6df5151e9a90bb9a58ae9a158e59993e588 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 30 Apr 2026 17:58:19 +0200 Subject: [PATCH] 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. --- docs/environment-variables.md | 4 +- docs/notebook-tool-runtime.md | 24 +- docs/python-repl.md | 48 +- packages/coding-agent/CHANGELOG.md | 10 + packages/coding-agent/DEVELOPMENT.md | 28 +- packages/coding-agent/package.json | 14 +- packages/coding-agent/src/cli/jupyter-cli.ts | 2 +- .../src/config/settings-schema.ts | 26 +- packages/coding-agent/src/eval/backend.ts | 49 + packages/coding-agent/src/eval/eval.lark | 43 + packages/coding-agent/src/eval/index.ts | 5 + .../src/eval/js/context-manager.ts | 717 ++++++++++++ packages/coding-agent/src/eval/js/executor.ts | 131 +++ packages/coding-agent/src/eval/js/index.ts | 46 + packages/coding-agent/src/eval/js/prelude.ts | 2 + packages/coding-agent/src/eval/js/prelude.txt | 84 ++ .../coding-agent/src/eval/js/tool-bridge.ts | 124 ++ packages/coding-agent/src/eval/parse.ts | 337 ++++++ .../src/{ipy => eval/py}/cancellation.ts | 0 .../src/{ipy => eval/py}/executor.ts | 175 +-- .../{ipy => eval/py}/gateway-coordinator.ts | 4 +- packages/coding-agent/src/eval/py/index.ts | 76 ++ .../src/{ipy => eval/py}/kernel.ts | 46 +- .../src/{ipy => eval/py}/prelude.py | 266 +---- .../src/{ipy => eval/py}/prelude.ts | 0 .../src/{ipy => eval/py}/runtime.ts | 0 packages/coding-agent/src/eval/types.ts | 48 + .../src/export/html/template.generated.ts | 2 +- .../coding-agent/src/export/html/template.js | 18 +- .../src/extensibility/extensions/types.ts | 2 +- packages/coding-agent/src/ipy/modules.ts | 144 --- packages/coding-agent/src/memories/index.ts | 2 +- .../src/modes/acp/acp-event-mapper.ts | 2 +- ...{python-execution.ts => eval-execution.ts} | 15 +- .../src/modes/components/tool-execution.ts | 7 +- .../modes/controllers/command-controller.ts | 36 +- .../src/modes/controllers/input-controller.ts | 8 +- .../src/modes/interactive-mode.ts | 6 +- packages/coding-agent/src/modes/types.ts | 6 +- .../src/modes/utils/ui-helpers.ts | 4 +- .../src/prompts/system/system-prompt.md | 6 +- .../coding-agent/src/prompts/tools/eval.md | 92 ++ .../coding-agent/src/prompts/tools/lsp.md | 10 +- .../coding-agent/src/prompts/tools/python.md | 57 - packages/coding-agent/src/sdk.ts | 21 +- .../coding-agent/src/session/agent-session.ts | 84 +- packages/coding-agent/src/session/messages.ts | 2 +- packages/coding-agent/src/system-prompt.ts | 2 +- packages/coding-agent/src/task/executor.ts | 14 +- packages/coding-agent/src/tools/browser.ts | 41 +- .../coding-agent/src/tools/browser/launch.ts | 13 + .../src/tools/browser/registry.ts | 258 +---- .../coding-agent/src/tools/browser/render.ts | 2 +- .../src/tools/browser/tab-protocol.ts | 101 ++ .../src/tools/browser/tab-supervisor.ts | 429 +++++++ .../src/tools/browser/tab-worker-entry.ts | 21 + .../src/tools/browser/tab-worker.ts | 1006 +++++++++++++++++ packages/coding-agent/src/tools/browser/vm.ts | 826 -------------- .../src/tools/{python.ts => eval.ts} | 658 +++++------ packages/coding-agent/src/tools/index.ts | 126 +-- packages/coding-agent/src/tools/renderers.ts | 4 +- .../coding-agent/src/tools/tool-timeouts.ts | 2 +- .../coding-agent/src/utils/tools-manager.ts | 10 +- .../test/agent-session-python-cleanup.test.ts | 150 +-- .../agent-session-user-shortcut-hooks.test.ts | 2 +- packages/coding-agent/test/core/helpers.ts | 4 +- .../test/core/js-executor.test.ts | 336 ++++++ .../test/core/js-tool-bridge.test.ts | 87 ++ .../test/core/python-executor-display.test.ts | 4 +- .../core/python-executor-lifecycle.test.ts | 6 +- .../test/core/python-executor-mapping.test.ts | 4 +- .../python-executor-owner-cleanup.test.ts | 36 +- .../core/python-executor-per-call.test.ts | 6 +- .../test/core/python-executor-session.test.ts | 4 +- .../core/python-executor-streaming.test.ts | 2 +- .../test/core/python-executor-timeout.test.ts | 4 +- .../core/python-executor.lifecycle.test.ts | 4 +- .../test/core/python-executor.result.test.ts | 4 +- .../test/core/python-executor.test.ts | 47 +- .../test/core/python-kernel-display.test.ts | 2 +- .../test/core/python-kernel-env.test.ts | 2 +- .../test/core/python-kernel-session.test.ts | 6 +- .../test/core/python-kernel-ws.test.ts | 2 +- .../test/core/python-kernel.lifecycle.test.ts | 4 +- .../test/core/python-kernel.test.ts | 103 +- .../test/core/python-modules.test.ts | 154 --- .../test/core/python-prelude.test.ts | 38 +- .../test/core/settings-manager-python.test.ts | 11 - packages/coding-agent/test/eval/parse.test.ts | 251 ++++ .../test/fixtures/python-modules/alpha.py | 2 - .../test/fixtures/python-modules/beta.py | 2 - .../test/fixtures/python-modules/delta.py | 2 - .../test/fixtures/python-modules/gamma.py | 2 - .../test/input-controller-escape.test.ts | 16 +- .../test/input-controller-keybindings.test.ts | 2 +- .../test/python-tool-settings.test.ts | 132 --- .../test/system-prompt-templates.test.ts | 4 +- .../test/tools/eval-fallback.test.ts | 77 ++ .../coding-agent/test/tools/index.test.ts | 36 +- .../test/tools/python-execution.test.ts | 84 -- .../test/tools/python-fallback.test.ts | 66 -- .../test/tools/python-renderer.test.ts | 46 - .../test/tools/python-tool-mode.test.ts | 36 - .../coding-agent/test/tools/python.test.ts | 117 -- 104 files changed, 4937 insertions(+), 3306 deletions(-) create mode 100644 packages/coding-agent/src/eval/backend.ts create mode 100644 packages/coding-agent/src/eval/eval.lark create mode 100644 packages/coding-agent/src/eval/index.ts create mode 100644 packages/coding-agent/src/eval/js/context-manager.ts create mode 100644 packages/coding-agent/src/eval/js/executor.ts create mode 100644 packages/coding-agent/src/eval/js/index.ts create mode 100644 packages/coding-agent/src/eval/js/prelude.ts create mode 100644 packages/coding-agent/src/eval/js/prelude.txt create mode 100644 packages/coding-agent/src/eval/js/tool-bridge.ts create mode 100644 packages/coding-agent/src/eval/parse.ts rename packages/coding-agent/src/{ipy => eval/py}/cancellation.ts (100%) rename packages/coding-agent/src/{ipy => eval/py}/executor.ts (85%) rename packages/coding-agent/src/{ipy => eval/py}/gateway-coordinator.ts (99%) create mode 100644 packages/coding-agent/src/eval/py/index.ts rename packages/coding-agent/src/{ipy => eval/py}/kernel.ts (95%) rename packages/coding-agent/src/{ipy => eval/py}/prelude.py (73%) rename packages/coding-agent/src/{ipy => eval/py}/prelude.ts (100%) rename packages/coding-agent/src/{ipy => eval/py}/runtime.ts (100%) create mode 100644 packages/coding-agent/src/eval/types.ts delete mode 100644 packages/coding-agent/src/ipy/modules.ts rename packages/coding-agent/src/modes/components/{python-execution.ts => eval-execution.ts} (91%) create mode 100644 packages/coding-agent/src/prompts/tools/eval.md delete mode 100644 packages/coding-agent/src/prompts/tools/python.md create mode 100644 packages/coding-agent/src/tools/browser/tab-protocol.ts create mode 100644 packages/coding-agent/src/tools/browser/tab-supervisor.ts create mode 100644 packages/coding-agent/src/tools/browser/tab-worker-entry.ts create mode 100644 packages/coding-agent/src/tools/browser/tab-worker.ts delete mode 100644 packages/coding-agent/src/tools/browser/vm.ts rename packages/coding-agent/src/tools/{python.ts => eval.ts} (65%) create mode 100644 packages/coding-agent/test/core/js-executor.test.ts create mode 100644 packages/coding-agent/test/core/js-tool-bridge.test.ts delete mode 100644 packages/coding-agent/test/core/python-modules.test.ts delete mode 100644 packages/coding-agent/test/core/settings-manager-python.test.ts create mode 100644 packages/coding-agent/test/eval/parse.test.ts delete mode 100644 packages/coding-agent/test/fixtures/python-modules/alpha.py delete mode 100644 packages/coding-agent/test/fixtures/python-modules/beta.py delete mode 100644 packages/coding-agent/test/fixtures/python-modules/delta.py delete mode 100644 packages/coding-agent/test/fixtures/python-modules/gamma.py delete mode 100644 packages/coding-agent/test/python-tool-settings.test.ts create mode 100644 packages/coding-agent/test/tools/eval-fallback.test.ts delete mode 100644 packages/coding-agent/test/tools/python-execution.test.ts delete mode 100644 packages/coding-agent/test/tools/python-fallback.test.ts delete mode 100644 packages/coding-agent/test/tools/python-renderer.test.ts delete mode 100644 packages/coding-agent/test/tools/python-tool-mode.test.ts delete mode 100644 packages/coding-agent/test/tools/python.test.ts diff --git a/docs/environment-variables.md b/docs/environment-variables.md index d022ccaa8..c7892bd05 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -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 `) | @@ -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`). diff --git a/docs/notebook-tool-runtime.md b/docs/notebook-tool-runtime.md index e7b748c0a..ad65d7976 100644 --- a/docs/notebook-tool-runtime.md +++ b/docs/notebook-tool-runtime.md @@ -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://` 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. diff --git a/docs/python-repl.md b/docs/python-repl.md index 0a1dbd8f6..cf910a34a 100644 --- a/docs/python-repl.md +++ b/docs/python-repl.md @@ -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://` 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. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1f02f32f9..23a6e8443 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/DEVELOPMENT.md b/packages/coding-agent/DEVELOPMENT.md index 2c11934c0..9c54725ca 100644 --- a/packages/coding-agent/DEVELOPMENT.md +++ b/packages/coding-agent/DEVELOPMENT.md @@ -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 `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`. -- 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`. +- 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 diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 945630a94..d5d70c556 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -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", diff --git a/packages/coding-agent/src/cli/jupyter-cli.ts b/packages/coding-agent/src/cli/jupyter-cli.ts index ff129488e..04acb18fd 100644 --- a/packages/coding-agent/src/cli/jupyter-cli.ts +++ b/packages/coding-agent/src/cli/jupyter-cli.ts @@ -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"; diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 402003ff6..7a3dbe026 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -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, diff --git a/packages/coding-agent/src/eval/backend.ts b/packages/coding-agent/src/eval/backend.ts new file mode 100644 index 000000000..badacbd31 --- /dev/null +++ b/packages/coding-agent/src/eval/backend.ts @@ -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; + /** 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; +} diff --git a/packages/coding-agent/src/eval/eval.lark b/packages/coding-agent/src/eval/eval.lark new file mode 100644 index 000000000..340079d24 --- /dev/null +++ b/packages/coding-agent/src/eval/eval.lark @@ -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= (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: "~~~" | "~~~~~" diff --git a/packages/coding-agent/src/eval/index.ts b/packages/coding-agent/src/eval/index.ts new file mode 100644 index 000000000..72c73ae49 --- /dev/null +++ b/packages/coding-agent/src/eval/index.ts @@ -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"; diff --git a/packages/coding-agent/src/eval/js/context-manager.ts b/packages/coding-agent/src/eval/js/context-manager.ts new file mode 100644 index 000000000..135ed51d6 --- /dev/null +++ b/packages/coding-agent/src/eval/js/context-manager.ts @@ -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; + timers: Set; + intervals: Set; + currentRun?: VmRunState; + queue: Promise; +} + +const vmContexts = new Map(); +const utf8Encoder = new TextEncoder(); + +function getMergedEnv(state: VmContextState): Record { + const env: Record = {}; + 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; + 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 { + 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> { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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> => { + 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> => { + 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(); + 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 => { + 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 => { + 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 => { + 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 => { + 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).text(), + new Response(child.stderr as ReadableStream).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 | 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 { + 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 { + 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 = { + __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 = {}) => 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 { + 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 { + 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(state: VmContextState, work: () => Promise): Promise { + const previous = state.queue; + const { promise, resolve } = Promise.withResolvers(); + 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(value: T | Promise, signal?: AbortSignal): Promise { + if (!value || typeof value !== "object" || typeof (value as { then?: unknown }).then !== "function") { + return value; + } + const promised = value as Promise; + if (!signal) { + return promised; + } + const { promise, resolve, reject } = Promise.withResolvers(); + 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 { + const existing = vmContexts.get(sessionKey); + if (!existing) return; + vmContexts.delete(sessionKey); + await disposeState(existing); +} + +export async function disposeAllVmContexts(): Promise { + const states = Array.from(vmContexts.values()); + vmContexts.clear(); + for (const state of states) { + await disposeState(state); + } +} diff --git a/packages/coding-agent/src/eval/js/executor.ts b/packages/coding-agent/src/eval/js/executor.ts new file mode 100644 index 000000000..76fb6fc02 --- /dev/null +++ b/packages/coding-agent/src/eval/js/executor.ts @@ -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; + 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): 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 { + 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, + }; + } +} diff --git a/packages/coding-agent/src/eval/js/index.ts b/packages/coding-agent/src/eval/js/index.ts new file mode 100644 index 000000000..ace3d70a9 --- /dev/null +++ b/packages/coding-agent/src/eval/js/index.ts @@ -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 { + return true; + }, + + async execute(code: string, opts: ExecutorBackendExecOptions): Promise { + 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; diff --git a/packages/coding-agent/src/eval/js/prelude.ts b/packages/coding-agent/src/eval/js/prelude.ts new file mode 100644 index 000000000..a11b5d719 --- /dev/null +++ b/packages/coding-agent/src/eval/js/prelude.ts @@ -0,0 +1,2 @@ +import javascriptPrelude from "./prelude.txt" with { type: "text" }; +export const JAVASCRIPT_PRELUDE_SOURCE = javascriptPrelude; diff --git a/packages/coding-agent/src/eval/js/prelude.txt b/packages/coding-agent/src/eval/js/prelude.txt new file mode 100644 index 000000000..6b17207be --- /dev/null +++ b/packages/coding-agent/src/eval/js/prelude.txt @@ -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; +} diff --git a/packages/coding-agent/src/eval/js/tool-bridge.ts b/packages/coding-agent/src/eval/js/tool-bridge.ts new file mode 100644 index 000000000..27d5f8865 --- /dev/null +++ b/packages/coding-agent/src/eval/js/tool-bridge.ts @@ -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) }; + 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) : {}) as Record< + string, + unknown + >; + const details = ( + result.details && typeof result.details === "object" ? (result.details as Record) : {} + ) as Record; + + 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 { + 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; + } +} diff --git a/packages/coding-agent/src/eval/parse.ts b/packages/coding-agent/src/eval/parse.ts new file mode 100644 index 000000000..8235f0993 --- /dev/null +++ b/packages/coding-agent/src/eval/parse.ts @@ -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 = { + 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): + * `? ? <(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 }; +} diff --git a/packages/coding-agent/src/ipy/cancellation.ts b/packages/coding-agent/src/eval/py/cancellation.ts similarity index 100% rename from packages/coding-agent/src/ipy/cancellation.ts rename to packages/coding-agent/src/eval/py/cancellation.ts diff --git a/packages/coding-agent/src/ipy/executor.ts b/packages/coding-agent/src/eval/py/executor.ts similarity index 85% rename from packages/coding-agent/src/ipy/executor.ts rename to packages/coding-agent/src/eval/py/executor.ts index cd019eca3..fda829b50 100644 --- a/packages/coding-agent/src/ipy/executor.ts +++ b/packages/coding-agent/src/eval/py/executor.ts @@ -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(); const disposingKernelSessions = new Set(); -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 { - 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 { - 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 { - 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 { - return { - signal: options.signal, - timeoutMs: requireRemainingTimeoutMs(options.deadlineMs), - }; -} - -async function cachePreludeDocs( - cwd: string, - docs: PreludeHelper[], - cacheState?: PreludeCacheState | null, -): Promise { - 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 { - 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 { diff --git a/packages/coding-agent/src/ipy/gateway-coordinator.ts b/packages/coding-agent/src/eval/py/gateway-coordinator.ts similarity index 99% rename from packages/coding-agent/src/ipy/gateway-coordinator.ts rename to packages/coding-agent/src/eval/py/gateway-coordinator.ts index 211726e51..e568f6e33 100644 --- a/packages/coding-agent/src/ipy/gateway-coordinator.ts +++ b/packages/coding-agent/src/eval/py/gateway-coordinator.ts @@ -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"; diff --git a/packages/coding-agent/src/eval/py/index.ts b/packages/coding-agent/src/eval/py/index.ts new file mode 100644 index 000000000..9e2caff46 --- /dev/null +++ b/packages/coding-agent/src/eval/py/index.ts @@ -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(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 { + const availability = await checkPythonKernelAvailability(session.cwd); + return availability.ok; + }, + + async warm(opts: ExecutorBackendCallOptions): Promise<{ ok: boolean; reason?: string }> { + const useSharedGateway = readSetting(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 { + const useSharedGateway = readSetting(opts.session, "python.sharedGateway"); + const kernelMode = readSetting(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; diff --git a/packages/coding-agent/src/ipy/kernel.ts b/packages/coding-agent/src/eval/py/kernel.ts similarity index 95% rename from packages/coding-agent/src/ipy/kernel.ts rename to packages/coding-agent/src/eval/py/kernel.ts index a1945ba41..7455e0ed5 100644 --- a/packages/coding-agent/src/ipy/kernel.ts +++ b/packages/coding-agent/src/eval/py/kernel.ts @@ -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; @@ -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 = {}): Promise { - 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 { try { await fetch(`${this.gatewayUrl}/api/kernels/${this.kernelId}/interrupt`, { diff --git a/packages/coding-agent/src/ipy/prelude.py b/packages/coding-agent/src/eval/py/prelude.py similarity index 73% rename from packages/coding-agent/src/ipy/prelude.py rename to packages/coding-agent/src/eval/py/prelude.py index e6f3a7cdf..0039996af 100644 --- a/packages/coding-agent/src/ipy/prelude.py +++ b/packages/coding-agent/src/eval/py/prelude.py @@ -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"])) diff --git a/packages/coding-agent/src/ipy/prelude.ts b/packages/coding-agent/src/eval/py/prelude.ts similarity index 100% rename from packages/coding-agent/src/ipy/prelude.ts rename to packages/coding-agent/src/eval/py/prelude.ts diff --git a/packages/coding-agent/src/ipy/runtime.ts b/packages/coding-agent/src/eval/py/runtime.ts similarity index 100% rename from packages/coding-agent/src/ipy/runtime.ts rename to packages/coding-agent/src/eval/py/runtime.ts diff --git a/packages/coding-agent/src/eval/types.ts b/packages/coding-agent/src/eval/types.ts new file mode 100644 index 000000000..53de14456 --- /dev/null +++ b/packages/coding-agent/src/eval/types.ts @@ -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; +} diff --git a/packages/coding-agent/src/export/html/template.generated.ts b/packages/coding-agent/src/export/html/template.generated.ts index f645f02ec..aafed6ec9 100644 --- a/packages/coding-agent/src/export/html/template.generated.ts +++ b/packages/coding-agent/src/export/html/template.generated.ts @@ -1,2 +1,2 @@ // Auto-generated by scripts/generate-template.ts - DO NOT EDIT -export const TEMPLATE = "\n\n\n \n \n Session Export\n \n \n\n\n \n
\n
\n \n
\n
\n
\n
\n
\n
\n \"\"\n
\n
\n\n \n \n \n \n\n\n"; +export const TEMPLATE = "\n\n\n \n \n Session Export\n \n \n\n\n \n
\n
\n \n
\n
\n
\n
\n
\n
\n \"\"\n
\n
\n\n \n \n \n \n\n\n"; diff --git a/packages/coding-agent/src/export/html/template.js b/packages/coding-agent/src/export/html/template.js index 1492c3f0d..395948ad9 100644 --- a/packages/coding-agent/src/export/html/template.js +++ b/packages/coding-agent/src/export/html/template.js @@ -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 += '
[missing cells]
'; - } 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 += '
'; if (cell && cell.title) html += '
' + escapeHtml(String(cell.title)) + '
'; const code = cell && typeof cell.code === 'string' ? cell.code : ''; + const lang = cell && cell.language === 'js' ? 'javascript' : 'python'; html += codeBlock(code, lang); html += '
'; } + } else if (typeof args.input === 'string') { + html += codeBlock(args.input, null); + } else { + html += '
[missing input]
'; } if (result) { html += ctx.renderResultImages(); diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 434033a63..cb1a93c64 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -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"; diff --git a/packages/coding-agent/src/ipy/modules.ts b/packages/coding-agent/src/ipy/modules.ts deleted file mode 100644 index 062cb6aa9..000000000 --- a/packages/coding-agent/src/ipy/modules.ts +++ /dev/null @@ -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; -} - -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 { - 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 { - 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 { - 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(); - 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 { - 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; -} diff --git a/packages/coding-agent/src/memories/index.ts b/packages/coding-agent/src/memories/index.ts index 3e38a470c..9de84a260 100644 --- a/packages/coding-agent/src/memories/index.ts +++ b/packages/coding-agent/src/memories/index.ts @@ -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; } diff --git a/packages/coding-agent/src/modes/acp/acp-event-mapper.ts b/packages/coding-agent/src/modes/acp/acp-event-mapper.ts index ab8b919be..f67a1c550 100644 --- a/packages/coding-agent/src/modes/acp/acp-event-mapper.ts +++ b/packages/coding-agent/src/modes/acp/acp-event-mapper.ts @@ -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": diff --git a/packages/coding-agent/src/modes/components/python-execution.ts b/packages/coding-agent/src/modes/components/eval-execution.ts similarity index 91% rename from packages/coding-agent/src/modes/components/python-execution.ts rename to packages/coding-agent/src/modes/components/eval-execution.ts index 936803257..a42021d11 100644 --- a/packages/coding-agent/src/modes/components/python-execution.ts +++ b/packages/coding-agent/src/modes/components/eval-execution.ts @@ -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(); diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index 7fbfb3456..d387dd607 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -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; diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 17dc001e8..2c88701d1 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -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 { 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); diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 7ee97d63b..f9fe62580 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -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; diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 0da683689..76a4d22d8 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -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(); 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; diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 6e33879be..6c25fb039 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -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; pendingBashComponents: BashExecutionComponent[]; bashComponent: BashExecutionComponent | undefined; - pendingPythonComponents: PythonExecutionComponent[]; - pythonComponent: PythonExecutionComponent | undefined; + pendingPythonComponents: EvalExecutionComponent[]; + pythonComponent: EvalExecutionComponent | undefined; isPythonMode: boolean; streamingComponent: AssistantMessageComponent | undefined; streamingMessage: AssistantMessage | undefined; diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index afe80117e..9a09dc440 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -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); } diff --git a/packages/coding-agent/src/prompts/system/system-prompt.md b/packages/coding-agent/src/prompts/system/system-prompt.md index 79f27f570..d575ae1d3 100644 --- a/packages/coding-agent/src/prompts/system/system-prompt.md +++ b/packages/coding-agent/src/prompts/system/system-prompt.md @@ -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")}} diff --git a/packages/coding-agent/src/prompts/tools/eval.md b/packages/coding-agent/src/prompts/tools/eval.md new file mode 100644 index 000000000..2c53cf870 --- /dev/null +++ b/packages/coding-agent/src/prompts/tools/eval.md @@ -0,0 +1,92 @@ +Run code in a persistent kernel, using a series of codeblocks acting as cells. + + +Each cell is a markdown fenced code block. The opening fence's info string carries metadata: + +``` +? ? (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=` — 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). + + + +{{#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.(args)` invokes any session tool directly (e.g. `await tool.read({ path: "src/foo.ts" })`). +{{/if}} + + +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. + + + +- 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}} + + +{{#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}} + diff --git a/packages/coding-agent/src/prompts/tools/lsp.md b/packages/coding-agent/src/prompts/tools/lsp.md index 0c6b7d7a7..ec65b5d20 100644 --- a/packages/coding-agent/src/prompts/tools/lsp.md +++ b/packages/coding-agent/src/prompts/tools/lsp.md @@ -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: "*"` @@ -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) diff --git a/packages/coding-agent/src/prompts/tools/python.md b/packages/coding-agent/src/prompts/tools/python.md deleted file mode 100644 index 0146086c4..000000000 --- a/packages/coding-agent/src/prompts/tools/python.md +++ /dev/null @@ -1,57 +0,0 @@ -Runs Python cells sequentially in persistent IPython kernel. - - -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). - - -{{#if categories.length}} - -All helpers auto-print results and return values for chaining. - -{{#each categories}} -### {{name}} - -``` -{{#each functions}} -{{name}}{{signature}} - {{docstring}} -{{/each}} -``` -{{/each}} - -{{/if}} - - -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., ``). Trust `display()`; you **MUST NOT** assume the user sees only the repr. - - - -- Per-call mode uses a fresh kernel each call -- You **MUST** use `reset: true` to clear state when session mode is active - - - -- You **MUST** use `run()` for shell commands; you **MUST NOT** use raw `subprocess` - - - -# 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')]"} -] -``` - diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 2f2ecdcfe..5a852e517 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -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", { diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index c2f9d1d8d..93461f8ec 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -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(); - #pythonKernelOwnerId: string; + #evalAbortControllers = new Set(); + #evalKernelOwnerId: string; #pendingPythonMessages: PythonExecutionMessage[] = []; - #activePythonExecutions = new Set>(); - #pythonExecutionDisposing = false; + #activeEvalExecutions = new Set>(); + #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 { - 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 { const excludeFromContext = options?.excludeFromContext === true; const cwd = this.sessionManager.getCwd(); - this.assertPythonExecutionAllowed(); + this.assertEvalExecutionAllowed(); const abortController = new AbortController(); const execution = (async (): Promise => { @@ -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(execution: Promise, abortController: AbortController): Promise { - this.#pythonAbortControllers.add(abortController); - this.#activePythonExecutions.add(execution); + trackEvalExecution(execution: Promise, abortController: AbortController): Promise { + 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 { + async #waitForEvalExecutionsToSettle(timeoutMs: number): Promise { 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 { - if (!(await this.#waitForPythonExecutionsToSettle(3_000))) { + async #prepareEvalExecutionsForDispose(): Promise { + 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 */ diff --git a/packages/coding-agent/src/session/messages.ts b/packages/coding-agent/src/session/messages.ts index 9f48e4d2a..061ef5d42 100644 --- a/packages/coding-agent/src/session/messages.ts +++ b/packages/coding-agent/src/session/messages.ts @@ -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"; diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index befd91f7a..792b9f287 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -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? } } diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index dbc0b42dc..8bdccb7f9 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -532,16 +532,12 @@ export async function runSubprocess(options: ExecutorOptions): Promise 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 { - await dropHeadlessBrowsers(); + await dropHeadlessTabs(); } async execute( @@ -216,10 +207,10 @@ export class BrowserTool implements AgentTool 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 { } } +let puppeteerModuleWorker: typeof Puppeteer | undefined; +export async function loadPuppeteerInWorker(safeDir: string): Promise { + 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. diff --git a/packages/coding-agent/src/tools/browser/registry.ts b/packages/coding-agent/src/tools/browser/registry.ts index 12794eaca..78aa7ccb6 100644 --- a/packages/coding-agent/src/tools/browser/registry.ts +++ b/packages/coding-agent/src/tools/browser/registry.ts @@ -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; - elementCounter: number; - dialogPolicy?: DialogPolicy; - dialogHandler?: (dialog: { accept: () => Promise; dismiss: () => Promise }) => void; -} - const browsers = new Map(); -const tabs = new Map(); - -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 { - 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 { - 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 { - 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 { - 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 { + 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; dismiss: () => Promise }): 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 { - 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 { +export async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise { 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 { - 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); } diff --git a/packages/coding-agent/src/tools/browser/render.ts b/packages/coding-agent/src/tools/browser/render.ts index 26a8925a8..8eda81b4f 100644 --- a/packages/coding-agent/src/tools/browser/render.ts +++ b/packages/coding-agent/src/tools/browser/render.ts @@ -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. */ diff --git a/packages/coding-agent/src/tools/browser/tab-protocol.ts b/packages/coding-agent/src/tools/browser/tab-protocol.ts new file mode 100644 index 000000000..2a431d8ec --- /dev/null +++ b/packages/coding-agent/src/tools/browser/tab-protocol.ts @@ -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; + 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 } + | { type: "closed" }; + +export interface Transport { + send(msg: WorkerOutbound | WorkerInbound, transferList?: Transferable[]): void; + onMessage(handler: (msg: WorkerOutbound | WorkerInbound) => void): () => void; + close(): void; +} diff --git a/packages/coding-agent/src/tools/browser/tab-supervisor.ts b/packages/coding-agent/src/tools/browser/tab-supervisor.ts new file mode 100644 index 000000000..7c2b67bf6 --- /dev/null +++ b/packages/coding-agent/src/tools/browser/tab-supervisor.ts @@ -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; + 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 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(); +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 { + 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(); + 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 { + 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 { + 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(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const { promise, resolve } = Promise.withResolvers(); + 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 { + return await targetIdForTarget(page.target()); +} + +async function targetIdForTarget(target: Target): Promise { + 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): 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( + promise: Promise, + timeoutMs: number, + reason: string, + onTimeout?: (reason: string) => Promise, +): Promise { + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const { promise: timeoutPromise, reject } = Promise.withResolvers(); + 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 { + 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() {}, + }; +} diff --git a/packages/coding-agent/src/tools/browser/tab-worker-entry.ts b/packages/coding-agent/src/tools/browser/tab-worker-entry.ts new file mode 100644 index 000000000..b8d2ee303 --- /dev/null +++ b/packages/coding-agent/src/tools/browser/tab-worker-entry.ts @@ -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); diff --git a/packages/coding-agent/src/tools/browser/tab-worker.ts b/packages/coding-agent/src/tools/browser/tab-worker.ts new file mode 100644 index 000000000..c359b8163 --- /dev/null +++ b/packages/coding-agent/src/tools/browser/tab-worker.ts @@ -0,0 +1,1006 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as vm from "node:vm"; +import { Snowflake, untilAborted } from "@oh-my-pi/pi-utils"; +import type { HTMLElement } from "linkedom"; +import type { + Browser, + Dialog, + ElementHandle, + HTTPResponse, + KeyInput, + Page, + SerializedAXNode, + Target, +} from "puppeteer-core"; +import { resizeImage } from "../../utils/image-resize"; +import { resolveToCwd } from "../path-utils"; +import { formatScreenshot } from "../render-utils"; +import { ToolAbortError, ToolError, throwIfAborted } from "../tool-errors"; +import { + applyStealthPatches, + applyViewport, + BROWSER_PROTOCOL_TIMEOUT_MS, + DEFAULT_VIEWPORT, + loadPuppeteerInWorker, +} from "./launch"; +import { extractReadableFromHtml, type ReadableFormat, type ReadableResult } from "./readable"; +import type { + Observation, + ObservationEntry, + ReadyInfo, + RunErrorPayload, + RunResultOk, + ScreenshotResult, + SessionSnapshot, + Transport, + WorkerInbound, + WorkerInitPayload, +} from "./tab-protocol"; + +declare global { + interface Element extends HTMLElement {} + function getComputedStyle(element: Element): Record; + var innerWidth: number; + var innerHeight: number; + var document: { + elementFromPoint(x: number, y: number): Element | null; + }; +} + +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; + +type DialogPolicy = "accept" | "dismiss"; +type DragTarget = string | { readonly x: number; readonly y: number }; +type ActionabilityResult = { ok: true; x: number; y: number } | { ok: false; reason: string }; + +interface ScreenshotOptions { + selector?: string; + fullPage?: boolean; + save?: string; + silent?: boolean; +} + +interface TabApi { + readonly name: string; + readonly page: Page; + readonly signal?: AbortSignal; + url(): string; + title(): Promise; + goto( + url: string, + opts?: { waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2" }, + ): Promise; + observe(opts?: { includeAll?: boolean; viewportOnly?: boolean }): Promise; + screenshot(opts?: ScreenshotOptions): Promise; + extract(format?: ReadableFormat): Promise; + click(selector: string): Promise; + type(selector: string, text: string): Promise; + fill(selector: string, value: string): Promise; + press(key: KeyInput, opts?: { selector?: string }): Promise; + scroll(deltaX: number, deltaY: number): Promise; + drag(from: DragTarget, to: DragTarget): Promise; + waitFor(selector: string): Promise; + evaluate( + fn: string | ((...args: TArgs) => TResult | Promise), + ...args: TArgs + ): Promise; + scrollIntoView(selector: string): Promise; + select(selector: string, ...values: string[]): Promise; + uploadFile(selector: string, ...filePaths: string[]): Promise; + waitForUrl(pattern: string | RegExp, opts?: { timeout?: number }): Promise; + waitForResponse( + pattern: string | RegExp | ((response: HTTPResponse) => boolean | Promise), + opts?: { timeout?: number }, + ): Promise; + id(n: number): Promise; +} + +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 + ); +} + +function asElementHandle(handle: unknown): ElementHandle | null { + return handle ? (handle as ElementHandle) : null; +} + +function cloneSafe(value: unknown): unknown { + if (value === undefined) return undefined; + try { + structuredClone(value); + return value; + } catch {} + try { + return JSON.parse(JSON.stringify(value)) as unknown; + } catch {} + return String(value); +} + +function errorPayload(error: unknown): RunErrorPayload { + if (error instanceof ToolAbortError) { + return { name: error.name, message: error.message, stack: error.stack, isToolError: false, isAbort: true }; + } + if (error instanceof ToolError) { + return { name: error.name, message: error.message, stack: error.stack, isToolError: true, isAbort: false }; + } + if (error instanceof Error) { + return { name: error.name, message: error.message, stack: error.stack, isToolError: false, isAbort: false }; + } + return { name: "Error", message: String(error), isToolError: false, isAbort: false }; +} + +async function targetIdForTarget(target: Target): Promise { + 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); + } +} + +async function targetIdForPage(page: Page): Promise { + return await targetIdForTarget(page.target()); +} + +async function collectObservationEntries( + core: WorkerCore, + node: SerializedAXNode, + entries: ObservationEntry[], + options: { viewportOnly: boolean; includeAll: boolean }, +): Promise { + 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 = core.nextElementId(); + 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"); + core.cacheElement(id, handle as ElementHandle); + 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(core, child, entries, options); + } +} + +async function resolveActionableQueryHandlerClickTarget(handles: ElementHandle[]): Promise { + 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; + }); + clickableProxy = asElementHandle(proxy.asElement()); + if (clickableProxy) clickable = clickableProxy; + } catch {} + 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 { + } finally { + if (clickableProxy && clickableProxy !== handle && clickable !== clickableProxy) { + await clickableProxy.dispose().catch(() => undefined); + } + } + } + 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 candidate = candidates[i]!; + if (candidate.ownedProxy) await candidate.ownedProxy.dispose().catch(() => undefined); + } + return winner; +} + +async function isClickActionable(handle: ElementHandle): Promise { + 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 left = Math.max(0, Math.min(globalThis.innerWidth, r.left)); + const right = Math.max(0, Math.min(globalThis.innerWidth, r.right)); + const top = Math.max(0, Math.min(globalThis.innerHeight, r.top)); + const bottom = Math.max(0, Math.min(globalThis.innerHeight, 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 { + 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 handle => handle.dispose().catch(() => undefined))); + } + } + 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.", + ); +} + +export class WorkerCore { + #transport: Transport; + #browser?: Browser; + #page?: Page; + #targetId?: string; + #elementCache = new Map(); + #elementCounter = 0; + #active?: { id: string; ac: AbortController }; + #unsub: () => void; + #mode?: WorkerInitPayload["mode"]; + #dialogPolicy?: DialogPolicy; + #dialogHandler?: (dialog: Dialog) => void; + + constructor(transport: Transport) { + this.#transport = transport; + this.#unsub = this.#transport.onMessage(msg => { + void this.#handleMessage(msg as WorkerInbound); + }); + } + + nextElementId(): number { + this.#elementCounter += 1; + return this.#elementCounter; + } + + cacheElement(id: number, handle: ElementHandle): void { + this.#elementCache.set(id, handle); + } + + async #handleMessage(msg: WorkerInbound): Promise { + switch (msg.type) { + case "init": + await this.#init(msg.payload); + return; + case "run": + await this.#run(msg); + return; + case "abort": + if (this.#active?.id === msg.id) this.#active.ac.abort(new ToolAbortError()); + return; + case "close": + await this.#close(); + return; + } + } + + async #init(payload: WorkerInitPayload): Promise { + try { + this.#mode = payload.mode; + const puppeteer = await loadPuppeteerInWorker(payload.safeDir); + this.#browser = await puppeteer.connect({ + browserWSEndpoint: payload.browserWSEndpoint, + defaultViewport: null, + protocolTimeout: BROWSER_PROTOCOL_TIMEOUT_MS, + }); + if (payload.mode === "headless") { + this.#page = await this.#browser.newPage(); + await applyStealthPatches(this.#browser, this.#page, { browserSession: null, override: null }); + await applyViewport(this.#page, payload.viewport); + if (payload.dialogs) this.#applyDialogPolicy(payload.dialogs); + if (payload.url) { + await this.#page.goto(payload.url, { + waitUntil: payload.waitUntil ?? "networkidle2", + timeout: payload.timeoutMs, + }); + } + } else { + this.#page = await this.#findAttachedPage(payload.targetId); + if (payload.dialogs) this.#applyDialogPolicy(payload.dialogs); + } + this.#targetId = await targetIdForPage(this.#page); + this.#transport.send({ type: "ready", info: await this.#currentReadyInfo() }); + } catch (error) { + this.#transport.send({ type: "init-failed", error: errorPayload(error) }); + } + } + + async #findAttachedPage(targetId: string): Promise { + if (!this.#browser) throw new ToolError("Browser is not connected"); + for (const target of this.#browser.targets()) { + if ((await targetIdForTarget(target).catch(() => "")) !== targetId) continue; + const page = await target.page(); + if (!page) break; + return page; + } + throw new ToolError(`Target ${targetId} is no longer available on the attached browser`); + } + + async #currentReadyInfo(): Promise { + const page = this.#requirePage(); + const targetId = this.#targetId ?? (await targetIdForPage(page)); + this.#targetId = targetId; + return { + url: page.url(), + title: await page.title().catch(() => undefined), + viewport: page.viewport() ?? DEFAULT_VIEWPORT, + targetId, + }; + } + + #applyDialogPolicy(policy: DialogPolicy): void { + const page = this.#requirePage(); + if (this.#dialogPolicy === policy && this.#dialogHandler) return; + if (this.#dialogHandler) page.off("dialog", this.#dialogHandler); + const handler = (dialog: Dialog): void => { + const action = policy === "accept" ? dialog.accept() : dialog.dismiss(); + void action.catch(err => + this.#log("debug", "Dialog auto-handler failed", { + policy, + error: err instanceof Error ? err.message : String(err), + }), + ); + }; + page.on("dialog", handler); + this.#dialogPolicy = policy; + this.#dialogHandler = handler; + } + + async #postReadyInfo(): Promise { + try { + this.#transport.send({ type: "ready", info: await this.#currentReadyInfo() }); + } catch (error) { + this.#log("debug", "Failed to refresh tab info", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + async #run(msg: Extract): Promise { + if (this.#active) { + this.#transport.send({ + type: "result", + id: msg.id, + ok: false, + error: errorPayload(new ToolError("Tab worker is busy")), + }); + return; + } + const timeoutSignal = AbortSignal.timeout(msg.timeoutMs); + const ac = new AbortController(); + const signal = AbortSignal.any([timeoutSignal, ac.signal]); + this.#active = { id: msg.id, ac }; + const displays: RunResultOk["displays"] = []; + const screenshots: ScreenshotResult[] = []; + try { + throwIfAborted(signal); + const page = this.#requirePage(); + const browser = this.#requireBrowser(); + const tabApi = this.#createTabApi(msg.name, msg.timeoutMs, signal, msg.session, displays, screenshots); + const ctx = vm.createContext({ + page, + browser, + tab: tabApi, + display: (value: unknown): void => this.#display(displays, value), + assert: (cond: unknown, text?: string): void => { + if (!cond) throw new ToolError(text ?? "Assertion failed"); + }, + wait: (ms: number): Promise => Bun.sleep(ms), + console: this.#console(), + setTimeout, + clearTimeout, + setInterval, + clearInterval, + queueMicrotask, + Promise, + URL, + URLSearchParams, + TextEncoder, + TextDecoder, + Buffer, + }); + const wrapped = `(async () => {\n${msg.code}\n})()`; + const { promise: cancelRejection, reject: rejectCancel } = Promise.withResolvers(); + const onCancel = (): void => { + rejectCancel( + timeoutSignal.aborted + ? new ToolError(`Browser code execution timed out after ${msg.timeoutMs}ms`) + : new ToolAbortError(), + ); + }; + if (signal.aborted) onCancel(); + else signal.addEventListener("abort", onCancel, { once: true }); + try { + const returnValue = await Promise.race([ + vm.runInContext(wrapped, ctx, { + filename: `browser-run-${msg.id}.js`, + lineOffset: -1, + }) as Promise, + cancelRejection, + ]); + await this.#postReadyInfo(); + this.#transport.send({ + type: "result", + id: msg.id, + ok: true, + payload: { displays, returnValue: cloneSafe(returnValue), screenshots }, + }); + } finally { + signal.removeEventListener("abort", onCancel); + } + } catch (error) { + this.#transport.send({ type: "result", id: msg.id, ok: false, error: errorPayload(error) }); + } finally { + if (this.#active?.id === msg.id) this.#active = undefined; + } + } + + #createTabApi( + name: string, + timeoutMs: number, + signal: AbortSignal, + session: SessionSnapshot, + displays: RunResultOk["displays"], + screenshots: ScreenshotResult[], + ): TabApi { + const page = this.#requirePage(); + return { + name, + page, + signal, + url: () => page.url(), + title: () => page.title(), + goto: async (url, opts) => { + this.#clearElementCache(); + await untilAborted(signal, () => + page.goto(url, { waitUntil: opts?.waitUntil ?? "networkidle2", timeout: timeoutMs }), + ); + }, + observe: opts => this.#collectObservation({ ...opts, signal }), + screenshot: async opts => await this.#captureScreenshot(session, displays, screenshots, signal, opts), + extract: async (format = "markdown") => { + const html = (await untilAborted(signal, () => page.content())) as string; + return extractReadableFromHtml(html, page.url(), format); + }, + click: async selector => { + const resolved = normalizeSelector(selector); + if (resolved.startsWith("text/")) await clickQueryHandlerText(page, resolved, timeoutMs, signal); + else await untilAborted(signal, () => page.locator(resolved).setTimeout(timeoutMs).click()); + }, + type: async (selector, text) => { + const handle = (await untilAborted(signal, () => + page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(), + )) as ElementHandle; + try { + await untilAborted(signal, () => handle.type(text, { delay: 0 })); + } finally { + await handle.dispose(); + } + }, + fill: async (selector, value) => { + await untilAborted(signal, () => + page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).fill(value), + ); + }, + press: async (key, opts) => { + const selector = opts?.selector; + if (selector) await untilAborted(signal, () => page.focus(normalizeSelector(selector))); + await untilAborted(signal, () => page.keyboard.press(key)); + }, + scroll: async (deltaX, deltaY) => { + await untilAborted(signal, () => page.mouse.wheel({ deltaX, deltaY })); + }, + drag: async (from, to) => await this.#drag(from, to, signal), + waitFor: async selector => + (await untilAborted(signal, () => + page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(), + )) as ElementHandle, + evaluate: async (fn, ...args) => + (await untilAborted(signal, () => + typeof fn === "string" ? page.evaluate(fn) : page.evaluate(fn as (...a: unknown[]) => unknown, ...args), + )) as never, + scrollIntoView: async selector => { + const handle = (await untilAborted(signal, () => + page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).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) => await this.#select(selector, values, timeoutMs, signal), + uploadFile: async (selector, ...filePaths) => + await this.#uploadFile(selector, filePaths, timeoutMs, signal, session), + waitForUrl: async (pattern, opts) => await this.#waitForUrl(pattern, opts?.timeout ?? timeoutMs, signal), + waitForResponse: async (pattern, opts) => + await this.#waitForResponse(pattern, opts?.timeout ?? timeoutMs, signal), + id: async id => await this.#resolveCachedHandle(id), + }; + } + + async #collectObservation(options: { + includeAll?: boolean; + viewportOnly?: boolean; + signal?: AbortSignal; + }): Promise { + const page = this.#requirePage(); + this.#clearElementCache(); + const includeAll = options.includeAll ?? false; + const viewportOnly = options.viewportOnly ?? false; + const snapshot = (await untilAborted(options.signal, () => + page.accessibility.snapshot({ interestingOnly: !includeAll }), + )) as SerializedAXNode | null; + if (!snapshot) throw new ToolError("Accessibility snapshot unavailable"); + const entries: ObservationEntry[] = []; + await collectObservationEntries(this, snapshot, entries, { includeAll, viewportOnly }); + const scroll = (await untilAborted(options.signal, () => + 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"]; + return { + url: page.url(), + title: (await untilAborted(options.signal, () => page.title())) as string, + viewport: page.viewport() ?? DEFAULT_VIEWPORT, + scroll, + elements: entries, + }; + } + + async #captureScreenshot( + session: SessionSnapshot, + displays: RunResultOk["displays"], + screenshots: ScreenshotResult[], + signal: AbortSignal | undefined, + opts: ScreenshotOptions = {}, + ): Promise { + const page = this.#requirePage(); + const fullPage = opts.selector ? false : (opts.fullPage ?? false); + let buffer: Buffer; + if (opts.selector) { + const handle = (await untilAborted(signal, () => + page.$(normalizeSelector(opts.selector!)), + )) 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, () => page.screenshot({ type: "png", fullPage }))) as Buffer; + } + const resized = await resizeImage( + { type: "image", data: buffer.toBase64(), mimeType: "image/png" }, + { maxWidth: 1024, maxHeight: 1024, maxBytes: 150 * 1024, jpegQuality: 70 }, + ); + const explicitPath = opts.save ? resolveToCwd(opts.save, session.cwd) : undefined; + const dest = + explicitPath ?? + (session.browserScreenshotDir + ? path.join( + session.browserScreenshotDir, + `screenshot-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1)}.png`, + ) + : path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.png`)); + await fs.promises.mkdir(path.dirname(dest), { recursive: true }); + const saveFullRes = !!(explicitPath || session.browserScreenshotDir); + 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; + } + + async #drag(from: DragTarget, to: DragTarget, signal: AbortSignal): Promise { + const page = this.#requirePage(); + const resolveDragPoint = async ( + target: DragTarget, + role: "from" | "to", + ): Promise<{ x: number; y: number; handle?: ElementHandle }> => { + if (typeof target === "string") { + const handle = (await untilAborted(signal, () => + page.$(normalizeSelector(target)), + )) 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, () => page.mouse.move(start.x, start.y)); + await untilAborted(signal, () => page.mouse.down()); + await untilAborted(signal, () => page.mouse.move(end!.x, end!.y, { steps: 12 })); + await untilAborted(signal, () => page.mouse.up()); + } finally { + if (start.handle) await start.handle.dispose().catch(() => undefined); + if (end?.handle) await end.handle.dispose().catch(() => undefined); + } + } + + async #select(selector: string, values: string[], timeoutMs: number, signal: AbortSignal): Promise { + const page = this.#requirePage(); + const handle = (await untilAborted(signal, () => + page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(), + )) as ElementHandle; + try { + return (await untilAborted(signal, () => + handle.evaluate((el, vals) => { + interface SelectOption { + value: string; + selected: boolean; + } + interface SelectLike { + tagName: string; + options: ArrayLike; + dispatchEvent: (event: unknown) => boolean; + } + const select = el as unknown as SelectLike; + if (!select || select.tagName !== "SELECT") throw new Error("tab.select() requires a element (got <${tagName.toLowerCase()}>)`, + ); + await untilAborted(signal, () => upload.uploadFile(...absolute)); + } finally { + await handle.dispose().catch(() => undefined); + } + } + + async #waitForUrl(pattern: string | RegExp, timeout: number, signal: AbortSignal): Promise { + const page = this.#requirePage(); + const isRegex = pattern instanceof RegExp; + const matcher = isRegex ? pattern.source : pattern; + const flags = isRegex ? pattern.flags : ""; + await untilAborted(signal, () => + 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 page.url(); + } + + async #waitForResponse( + pattern: string | RegExp | ((response: HTTPResponse) => boolean | Promise), + timeout: number, + signal: AbortSignal, + ): Promise { + const page = this.#requirePage(); + const predicate: (response: HTTPResponse) => boolean | Promise = + typeof pattern === "function" + ? pattern + : pattern instanceof RegExp + ? response => pattern.test(response.url()) + : response => response.url().includes(pattern); + return (await untilAborted(signal, () => page.waitForResponse(predicate, { timeout }))) as HTTPResponse; + } + + async #resolveCachedHandle(id: number): Promise { + const handle = this.#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) { + this.#clearElementCache(); + throw new ToolError(`Element id ${id} is stale. Run tab.observe() again.`); + } + } catch (err) { + if (err instanceof ToolError) throw err; + this.#clearElementCache(); + throw new ToolError(`Element id ${id} is stale. Run tab.observe() again.`); + } + return handle; + } + + #display(displays: RunResultOk["displays"], value: unknown): void { + if (value === undefined || value === null) return; + if ( + typeof value === "object" && + value !== null && + "type" in (value as Record) && + (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) }); + } + } + + #console(): Pick { + return { + log: (...args: unknown[]) => this.#log("debug", args.map(String).join(" ")), + debug: (...args: unknown[]) => this.#log("debug", args.map(String).join(" ")), + warn: (...args: unknown[]) => this.#log("warn", args.map(String).join(" ")), + error: (...args: unknown[]) => this.#log("error", args.map(String).join(" ")), + }; + } + + #clearElementCache(): void { + if (this.#elementCache.size === 0) { + this.#elementCounter = 0; + return; + } + const handles = [...this.#elementCache.values()]; + this.#elementCache.clear(); + this.#elementCounter = 0; + for (const handle of handles) void handle.dispose().catch(() => undefined); + } + + async #close(): Promise { + this.#unsub(); + this.#clearElementCache(); + const page = this.#page; + if (this.#dialogHandler && page && !page.isClosed()) page.off("dialog", this.#dialogHandler); + if (this.#mode === "headless" && page && !page.isClosed()) await page.close().catch(() => undefined); + if (this.#browser?.connected) this.#browser.disconnect(); + this.#transport.send({ type: "closed" }); + this.#transport.close(); + } + + #requirePage(): Page { + if (!this.#page) throw new ToolError("Tab worker is not initialized"); + return this.#page; + } + + #requireBrowser(): Browser { + if (!this.#browser) throw new ToolError("Tab worker is not initialized"); + return this.#browser; + } + + #log(level: "debug" | "warn" | "error", msg: string, meta?: Record): void { + this.#transport.send({ type: "log", level, msg, meta }); + } +} diff --git a/packages/coding-agent/src/tools/browser/vm.ts b/packages/coding-agent/src/tools/browser/vm.ts deleted file mode 100644 index 5f1edfa92..000000000 --- a/packages/coding-agent/src/tools/browser/vm.ts +++ /dev/null @@ -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; - 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 { - 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 { - 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 { - 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 { - 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 { - 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; - goto( - url: string, - opts?: { waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2" }, - ): Promise; - observe(opts?: { includeAll?: boolean; viewportOnly?: boolean }): Promise; - screenshot(opts?: ScreenshotOptions): Promise; - extract(format?: ReadableFormat): Promise; - click(selector: string): Promise; - type(selector: string, text: string): Promise; - fill(selector: string, value: string): Promise; - press(key: KeyInput, opts?: { selector?: string }): Promise; - scroll(deltaX: number, deltaY: number): Promise; - drag(from: DragTarget, to: DragTarget): Promise; - waitFor(selector: string): Promise; - evaluate( - fn: string | ((...args: TArgs) => TResult | Promise), - ...args: TArgs - ): Promise; - scrollIntoView(selector: string): Promise; - select(selector: string, ...values: string[]): Promise; - uploadFile(selector: string, ...filePaths: string[]): Promise; - waitForUrl(pattern: string | RegExp, opts?: { timeout?: number }): Promise; - waitForResponse( - pattern: string | RegExp | ((response: HTTPResponse) => boolean | Promise), - opts?: { timeout?: number }, - ): Promise; - id(n: number): Promise; -} - -export interface RunInTabOptions { - tab: TabHandle; - code: string; - timeoutMs: number; - signal?: AbortSignal; - session: ToolSession; -} - -export interface RunInTabResult { - displays: Array; - returnValue: unknown; - screenshots: ScreenshotResult[]; -} - -const AsyncFunctionCtor = Object.getPrototypeOf(async () => {}).constructor as new ( - ...args: string[] -) => (...args: unknown[]) => Promise; - -export async function runInTab(opts: RunInTabOptions): Promise { - 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 = []; - 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) && - (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 => 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; - dispatchEvent: (event: unknown) => boolean; - } - const select = el as unknown as SelectLike; - if (!select || select.tagName !== "SELECT") { - throw new Error("tab.select() requires a 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 = - 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(); - 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, - screenshots: ScreenshotResult[], - signal: AbortSignal | undefined, - opts: ScreenshotOptions = {}, -): Promise { - 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; -} diff --git a/packages/coding-agent/src/tools/python.ts b/packages/coding-agent/src/tools/eval.ts similarity index 65% rename from packages/coding-agent/src/tools/python.ts rename to packages/coding-agent/src/tools/eval.ts index a4cdc8bed..0c842e07f 100644 --- a/packages/coding-agent/src/tools/python.ts +++ b/packages/coding-agent/src/tools/eval.ts @@ -1,89 +1,42 @@ -import * as path from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { ImageContent } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; import { Markdown, Text } from "@oh-my-pi/pi-tui"; -import { getProjectDir, prompt } from "@oh-my-pi/pi-utils"; +import { prompt } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; +import { jsBackend, parseEvalInput, pythonBackend } from "../eval"; +import type { ExecutorBackend } from "../eval/backend"; +import evalGrammar from "../eval/eval.lark" with { type: "text" }; +import type { ParsedEvalCell } from "../eval/parse"; +import type { EvalCellResult, EvalLanguage, EvalStatusEvent, EvalToolDetails } from "../eval/types"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; -import { executePython, getPreludeDocs, type PythonExecutorOptions, warmPythonEnvironment } from "../ipy/executor"; -import type { PreludeHelper, PythonStatusEvent } from "../ipy/kernel"; import { truncateToVisualLines } from "../modes/components/visual-truncate"; import { getMarkdownTheme, type Theme } from "../modes/theme/theme"; -import pythonDescription from "../prompts/tools/python.md" with { type: "text" }; +import evalDescription from "../prompts/tools/eval.md" with { type: "text" }; import { DEFAULT_MAX_BYTES, OutputSink, type OutputSummary, TailBuffer } from "../session/streaming-output"; import { getTreeBranch, getTreeContinuePrefix, renderCodeCell } from "../tui"; -import type { ToolSession } from "."; -import { formatStyledTruncationWarning, type OutputMeta } from "./output-meta"; +import { resolveEvalBackends, type ToolSession } from "."; +import { formatStyledTruncationWarning } from "./output-meta"; import { formatTitle, replaceTabs, shortenPath, truncateToWidth, wrapBrackets } from "./render-utils"; import { ToolAbortError, ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; import { clampTimeout } from "./tool-timeouts"; -export const PYTHON_DEFAULT_PREVIEW_LINES = 10; +export const EVAL_DEFAULT_PREVIEW_LINES = 10; -type PreludeCategory = { - name: string; - functions: PreludeHelper[]; -}; - -function groupPreludeHelpers(helpers: PreludeHelper[]): PreludeCategory[] { - const categories: PreludeCategory[] = []; - const byName = new Map(); - for (const helper of helpers) { - let bucket = byName.get(helper.category); - if (!bucket) { - bucket = []; - byName.set(helper.category, bucket); - categories.push({ name: helper.category, functions: bucket }); - } - bucket.push(helper); - } - return categories; -} - -export const pythonSchema = Type.Object({ - cells: Type.Array( - Type.Object({ - code: Type.String({ description: "python code", examples: ["print('hello')", "import json"] }), - title: Type.String({ description: "cell label", examples: ["imports", "helper"] }), - }), - { description: "cells to execute" }, - ), - timeout: Type.Optional(Type.Number({ description: "timeout in seconds", default: 30 })), - reset: Type.Optional(Type.Boolean({ description: "restart kernel" })), +export const evalSchema = Type.Object({ + input: Type.String({ + description: "atom-style eval input containing CELL sections, fenced code, and optional RESET directive", + }), }); -export type PythonToolParams = Static; +export type EvalToolParams = Static; -export type PythonToolResult = { +export type EvalToolResult = { content: Array<{ type: "text"; text: string }>; - details: PythonToolDetails | undefined; + details: EvalToolDetails | undefined; }; -export type PythonProxyExecutor = (params: PythonToolParams, signal?: AbortSignal) => Promise; - -export interface PythonCellResult { - index: number; - title?: string; - code: string; - output: string; - status: "pending" | "running" | "complete" | "error"; - durationMs?: number; - exitCode?: number; - statusEvents?: PythonStatusEvent[]; - hasMarkdown?: boolean; -} - -export interface PythonToolDetails { - cells?: PythonCellResult[]; - jsonOutputs?: unknown[]; - images?: ImageContent[]; - /** Structured status events from prelude helpers */ - statusEvents?: PythonStatusEvent[]; - isError?: boolean; - /** Structured output metadata for notices */ - meta?: OutputMeta; -} +export type EvalProxyExecutor = (params: EvalToolParams, signal?: AbortSignal) => Promise; function formatJsonScalar(value: unknown): string { if (value === null) return "null"; @@ -129,61 +82,189 @@ function renderJsonTree(value: unknown, theme: Theme, expanded: boolean, maxDept return renderNode(value, "", 0, true); } -export function getPythonToolDescription(): string { - const helpers = getPreludeDocs(); - const categories = groupPreludeHelpers(helpers); - return prompt.render(pythonDescription, { categories }); +export interface EvalToolDescriptionOptions { + py?: boolean; + js?: boolean; } -export interface PythonToolOptions { - proxyExecutor?: PythonProxyExecutor; +export function getEvalToolDescription(options: EvalToolDescriptionOptions = {}): string { + const py = options.py ?? true; + const js = options.js ?? true; + return prompt.render(evalDescription, { py, js }); } -export class PythonTool implements AgentTool { - readonly name = "python"; - readonly label = "Python"; - get description(): string { - return getPythonToolDescription(); +export interface EvalToolOptions { + proxyExecutor?: EvalProxyExecutor; +} + +interface ResolvedBackend { + backend: ExecutorBackend; + fallback: boolean; + notice?: string; +} + +interface ResolvedEvalCell { + index: number; + title?: string; + code: string; + timeoutMs: number; + reset: boolean; + resolved: ResolvedBackend; +} + +function uniqueEvalLanguages(cells: ResolvedEvalCell[]): EvalLanguage[] { + return [...new Set(cells.map(cell => cell.resolved.backend.id))]; +} + +function detailsNotice(cells: ResolvedEvalCell[]): string | undefined { + const notices = [ + ...new Set(cells.map(cell => cell.resolved.notice).filter((notice): notice is string => Boolean(notice))), + ]; + return notices.length > 0 ? notices.join(" ") : undefined; +} + +function languageForHighlighter(language: EvalLanguage | undefined): "python" | "javascript" { + return language === "js" ? "javascript" : "python"; +} + +function timeoutSecondsFromMs(timeoutMs: number): number { + return clampTimeout("eval", timeoutMs / 1000); +} + +/** + * Best-effort language sniff for cells with no explicit `language`. + * + * Order: + * 1. Shebang on first line (`#!/usr/bin/env python`, `#!/usr/bin/env node`, etc.) + * 2. Strong syntactic markers unique to one language. We bias false negatives over + * false positives — anything ambiguous returns `undefined` and the caller falls + * back to the default-backend rules. + */ +function sniffLanguage(code: string): EvalLanguage | undefined { + const stripped = code.replace(/^\s+/, ""); + if (stripped.startsWith("#!")) { + const firstLine = stripped.split("\n", 1)[0]!.toLowerCase(); + if (/(\bpython\d?\b|\bipython\b)/.test(firstLine)) return "python"; + if (/(\bnode\b|\bbun\b|\bdeno\b|\bjavascript\b|\bjs\b)/.test(firstLine)) return "js"; } - readonly parameters = pythonSchema; + const jsMarkers = + /(^|\n)\s*(const|let|var|async\s+function|function\s*\*?\s*[\w$]*\s*\(|import\s+[^\n]+\sfrom\s|export\s+(default|const|let|function|class|async)|require\s*\(|console\.\w+\s*\(|=>|;\s*$)/m; + const pyMarkers = + /(^|\n)\s*(def\s+\w+\s*\(|from\s+[\w.]+\s+import|import\s+\w+(\s+as\s+\w+)?\s*$|class\s+\w+\s*[(:]|print\s*\(|elif\s+[^\n]*:|with\s+[^\n]+:\s*$|@[\w.]+\s*$)/m; + const hasJs = jsMarkers.test(code); + const hasPy = pyMarkers.test(code); + if (hasJs && !hasPy) return "js"; + if (hasPy && !hasJs) return "python"; + return undefined; +} + +async function resolveBackend( + session: ToolSession, + requested: EvalLanguage | undefined, + code: string, +): Promise { + const allowPy = (session.settings.get("eval.py") as boolean | undefined) ?? true; + const allowJs = (session.settings.get("eval.js") as boolean | undefined) ?? true; + + if (requested === "python") { + if (!allowPy) throw new ToolError("Python backend is disabled (eval.py = false)."); + if (!(await pythonBackend.isAvailable(session))) { + throw new ToolError( + 'Python backend is unavailable in this session. Pass language: "js" or install the python kernel.', + ); + } + return { backend: pythonBackend, fallback: false }; + } + if (requested === "js") { + if (!allowJs) throw new ToolError("JavaScript backend is disabled (eval.js = false)."); + return { backend: jsBackend, fallback: false }; + } + // Auto-detect. + const sniffed = sniffLanguage(code); + if (sniffed === "python" && allowPy && (await pythonBackend.isAvailable(session))) { + return { backend: pythonBackend, fallback: false }; + } + if (sniffed === "js" && allowJs) { + return { backend: jsBackend, fallback: false }; + } + + // Sniffer returned undefined or the preferred backend was disabled. Prefer + // python when its kernel is up, else fall back to js. + if (allowPy && (await pythonBackend.isAvailable(session))) { + const notice = + sniffed === "js" ? "JavaScript markers detected but eval.js is disabled; using Python." : undefined; + return { backend: pythonBackend, fallback: false, notice }; + } + if (allowJs) { + const notice = + sniffed === "python" + ? "Python markers detected but the python kernel is unavailable; using JavaScript." + : undefined; + return { backend: jsBackend, fallback: true, notice }; + } + throw new ToolError("No eval backend is available; enable eval.py or eval.js."); +} + +export class EvalTool implements AgentTool { + readonly name = "eval"; + readonly label = "Eval"; + get description(): string { + if (!this.session) return getEvalToolDescription(); + const backends = resolveEvalBackends(this.session); + return getEvalToolDescription({ py: backends.python, js: backends.js }); + } + readonly parameters = evalSchema; readonly concurrency = "exclusive"; readonly strict = true; - readonly #proxyExecutor?: PythonProxyExecutor; + get customFormat(): { syntax: "lark"; definition: string } { + return { syntax: "lark", definition: evalGrammar }; + } + + readonly #proxyExecutor?: EvalProxyExecutor; constructor( private readonly session: ToolSession | null, - options?: PythonToolOptions, + options?: EvalToolOptions, ) { this.#proxyExecutor = options?.proxyExecutor; } async execute( _toolCallId: string, - params: Static, + params: Static, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback, _ctx?: AgentToolContext, - ): Promise> { + ): Promise> { if (this.#proxyExecutor) { return this.#proxyExecutor(params, signal); } if (!this.session) { - throw new ToolError("Python tool requires a session when not using proxy executor"); + throw new ToolError("Eval tool requires a session when not using proxy executor"); } const session = this.session; - const { cells, timeout: rawTimeout = 30, reset } = params; - // Clamp to reasonable range: 1s - 600s (10 min) - const timeoutSec = clampTimeout("python", rawTimeout); - const timeoutMs = timeoutSec * 1000; - const deadlineMs = Date.now() + timeoutMs; - const timeoutSignal = AbortSignal.timeout(Math.max(0, deadlineMs - Date.now())); + const parsedInput = parseEvalInput(params.input); + let previousRuntimeLanguage: EvalLanguage | undefined; + const cells: ResolvedEvalCell[] = []; + for (const cell of parsedInput.cells) { + const requested = cell.languageOrigin === "fence" ? cell.language : (previousRuntimeLanguage ?? undefined); + const resolved = await resolveBackend(session, requested, cell.code); + previousRuntimeLanguage = resolved.backend.id; + cells.push({ + index: cell.index, + title: cell.title, + code: cell.code, + timeoutMs: cell.timeoutMs, + reset: cell.reset, + resolved, + }); + } + const languages = uniqueEvalLanguages(cells); + const notice = detailsNotice(cells); const sessionAbortController = new AbortController(); - const combinedSignal = signal - ? AbortSignal.any([signal, timeoutSignal, sessionAbortController.signal]) - : AbortSignal.any([timeoutSignal, sessionAbortController.signal]); let outputSink: OutputSink | undefined; let outputSummary: OutputSummary | undefined; let outputDumped = false; @@ -194,22 +275,23 @@ export class PythonTool implements AgentTool { return outputSummary; }; - const execution = (async (): Promise> => { + const execution = (async (): Promise> => { try { if (signal?.aborted) { throw new ToolAbortError(); } - session.assertPythonExecutionAllowed?.(); + session.assertEvalExecutionAllowed?.(); const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES * 2); const jsonOutputs: unknown[] = []; const images: ImageContent[] = []; - const statusEvents: PythonStatusEvent[] = []; + const statusEvents: EvalStatusEvent[] = []; - const cellResults: PythonCellResult[] = cells.map((cell, index) => ({ - index, + const cellResults: EvalCellResult[] = cells.map(cell => ({ + index: cell.index, title: cell.title, code: cell.code, + language: cell.resolved.backend.id, output: "", status: "pending", })); @@ -219,8 +301,10 @@ export class PythonTool implements AgentTool { tailBuffer.append(text); }; - const buildUpdateDetails = (): PythonToolDetails => { - const details: PythonToolDetails = { + const buildUpdateDetails = (): EvalToolDetails => { + const details: EvalToolDetails = { + language: languages[0], + languages, cells: cellResults.map(cell => ({ ...cell, statusEvents: cell.statusEvents ? [...cell.statusEvents] : undefined, @@ -235,6 +319,9 @@ export class PythonTool implements AgentTool { if (statusEvents.length > 0) { details.statusEvents = statusEvents; } + if (notice) { + details.notice = notice; + } return details; }; @@ -248,9 +335,9 @@ export class PythonTool implements AgentTool { }; const sessionFile = session.getSessionFile?.() ?? undefined; - const kernelOwnerId = session.getPythonKernelOwnerId?.() ?? undefined; - const { path: artifactPath, id: artifactId } = (await session.allocateOutputArtifact?.("python")) ?? {}; - session.assertPythonExecutionAllowed?.(); + const kernelOwnerId = session.getEvalKernelOwnerId?.() ?? undefined; + const { path: artifactPath, id: artifactId } = (await session.allocateOutputArtifact?.("eval")) ?? {}; + session.assertEvalExecutionAllowed?.(); outputSink = new OutputSink({ artifactPath, artifactId, @@ -261,36 +348,35 @@ export class PythonTool implements AgentTool { }); const sessionId = sessionFile ? `session:${sessionFile}:cwd:${session.cwd}` : `cwd:${session.cwd}`; - if (getPreludeDocs().length === 0) { - const warmup = await warmPythonEnvironment( - session.cwd, - sessionId, - session.settings.get("python.sharedGateway"), - sessionFile ?? undefined, - kernelOwnerId, - combinedSignal, - ); - if (!warmup.ok) { - if (combinedSignal.aborted) throw new ToolAbortError(); - throw new ToolError(warmup.reason ?? "Python prelude helpers unavailable"); - } - session.assertPythonExecutionAllowed?.(); - } - - const baseExecutorOptions = { - cwd: session.cwd, - deadlineMs, - signal: combinedSignal, - sessionId, - kernelMode: session.settings.get("python.kernelMode"), - useSharedGateway: session.settings.get("python.sharedGateway"), - sessionFile: sessionFile ?? undefined, - kernelOwnerId, - }; + const warmedBackends = new Set(); for (let i = 0; i < cells.length; i++) { const cell = cells[i]; - const isFirstCell = i === 0; + const backend = cell.resolved.backend; + const timeoutSec = timeoutSecondsFromMs(cell.timeoutMs); + const deadlineMs = Date.now() + timeoutSec * 1000; + const timeoutSignal = AbortSignal.timeout(Math.max(0, deadlineMs - Date.now())); + const combinedSignal = signal + ? AbortSignal.any([signal, timeoutSignal, sessionAbortController.signal]) + : AbortSignal.any([timeoutSignal, sessionAbortController.signal]); + + if (!warmedBackends.has(backend.id) && backend.warm) { + const warmup = await backend.warm({ + cwd: session.cwd, + sessionId, + sessionFile: sessionFile ?? undefined, + kernelOwnerId, + signal: combinedSignal, + session, + }); + if (!warmup.ok) { + if (combinedSignal.aborted) throw new ToolAbortError(); + throw new ToolError(warmup.reason ?? `${backend.label} prelude helpers unavailable`); + } + session.assertEvalExecutionAllowed?.(); + } + warmedBackends.add(backend.id); + const cellResult = cellResults[i]; cellResult.status = "running"; cellResult.output = ""; @@ -299,19 +385,25 @@ export class PythonTool implements AgentTool { cellResult.durationMs = undefined; pushUpdate(); - const executorOptions: PythonExecutorOptions = { - ...baseExecutorOptions, - reset: isFirstCell ? reset : false, + const startTime = Date.now(); + const result = await backend.execute(cell.code, { + cwd: session.cwd, + sessionId, + sessionFile: sessionFile ?? undefined, + kernelOwnerId, + signal: combinedSignal, + session, + deadlineMs, + reset: cell.reset, + artifactPath, + artifactId, onChunk: chunk => { outputSink!.push(chunk); }, - }; - - const startTime = Date.now(); - const result = await executePython(cell.code, executorOptions); + }); const durationMs = Date.now() - startTime; - const cellStatusEvents: PythonStatusEvent[] = []; + const cellStatusEvents: EvalStatusEvent[] = []; let cellHasMarkdown = false; for (const output of result.displayOutputs) { if (output.type === "json") { @@ -366,35 +458,17 @@ export class PythonTool implements AgentTool { ? `${combinedOutput}\n\nCell ${i + 1} aborted: ${errorMsg}` : combinedOutput || errorMsg; - const rawSummary = (await finalizeOutput()) ?? { - output: "", - truncated: false, - totalLines: 0, - totalBytes: 0, - outputLines: 0, - outputBytes: 0, - }; - const outputLines = combinedOutput.length > 0 ? combinedOutput.split("\n").length : 0; - const outputBytes = Buffer.byteLength(combinedOutput, "utf-8"); - const missingLines = Math.max(0, rawSummary.totalLines - rawSummary.outputLines); - const missingBytes = Math.max(0, rawSummary.totalBytes - rawSummary.outputBytes); - const summaryForMeta: OutputSummary = { - output: combinedOutput, - truncated: rawSummary.truncated, - totalLines: outputLines + missingLines, - totalBytes: outputBytes + missingBytes, - outputLines, - outputBytes, - artifactId: rawSummary.artifactId, - }; - - const details: PythonToolDetails = { + const summaryForMeta = await summarizeFinal(combinedOutput, finalizeOutput); + const details: EvalToolDetails = { + language: languages[0], + languages, cells: cellResults, jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined, images: images.length > 0 ? images : undefined, statusEvents: statusEvents.length > 0 ? statusEvents : undefined, isError: true, }; + if (notice) details.notice = notice; return toolResult(details) .text(outputText) @@ -413,35 +487,17 @@ export class PythonTool implements AgentTool { ? `${combinedOutput}\n\nCommand exited with code ${result.exitCode}` : `Command exited with code ${result.exitCode}`; - const rawSummary = (await finalizeOutput()) ?? { - output: "", - truncated: false, - totalLines: 0, - totalBytes: 0, - outputLines: 0, - outputBytes: 0, - }; - const outputLines = combinedOutput.length > 0 ? combinedOutput.split("\n").length : 0; - const outputBytes = Buffer.byteLength(combinedOutput, "utf-8"); - const missingLines = Math.max(0, rawSummary.totalLines - rawSummary.outputLines); - const missingBytes = Math.max(0, rawSummary.totalBytes - rawSummary.outputBytes); - const summaryForMeta: OutputSummary = { - output: combinedOutput, - truncated: rawSummary.truncated, - totalLines: outputLines + missingLines, - totalBytes: outputBytes + missingBytes, - outputLines, - outputBytes, - artifactId: rawSummary.artifactId, - }; - - const details: PythonToolDetails = { + const summaryForMeta = await summarizeFinal(combinedOutput, finalizeOutput); + const details: EvalToolDetails = { + language: languages[0], + languages, cells: cellResults, jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined, images: images.length > 0 ? images : undefined, statusEvents: statusEvents.length > 0 ? statusEvents : undefined, isError: true, }; + if (notice) details.notice = notice; return toolResult(details) .text(outputText) @@ -456,40 +512,22 @@ export class PythonTool implements AgentTool { const combinedOutput = cellOutputs.join("\n\n"); const outputText = combinedOutput || (jsonOutputs.length > 0 || images.length > 0 ? "(no text output)" : "(no output)"); - const rawSummary = (await finalizeOutput()) ?? { - output: "", - truncated: false, - totalLines: 0, - totalBytes: 0, - outputLines: 0, - outputBytes: 0, - }; - const outputLines = combinedOutput.length > 0 ? combinedOutput.split("\n").length : 0; - const outputBytes = Buffer.byteLength(combinedOutput, "utf-8"); - const missingLines = Math.max(0, rawSummary.totalLines - rawSummary.outputLines); - const missingBytes = Math.max(0, rawSummary.totalBytes - rawSummary.outputBytes); - const summaryForMeta: OutputSummary = { - output: combinedOutput, - truncated: rawSummary.truncated, - totalLines: outputLines + missingLines, - totalBytes: outputBytes + missingBytes, - outputLines, - outputBytes, - artifactId: rawSummary.artifactId, - }; + const summaryForMeta = await summarizeFinal(combinedOutput, finalizeOutput); - const details: PythonToolDetails = { + const details: EvalToolDetails = { + language: languages[0], + languages, cells: cellResults, jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined, images: images.length > 0 ? images : undefined, statusEvents: statusEvents.length > 0 ? statusEvents : undefined, }; + if (notice) details.notice = notice; - const resultBuilder = toolResult(details) + return toolResult(details) .text(outputText) - .truncationFromSummary(summaryForMeta, { direction: "tail" }); - - return resultBuilder.done(); + .truncationFromSummary(summaryForMeta, { direction: "tail" }) + .done(); } finally { if (!outputDumped) { try { @@ -498,57 +536,95 @@ export class PythonTool implements AgentTool { } } })(); - return await (session.trackPythonExecution?.(execution, sessionAbortController) ?? execution); + + return await (session.trackEvalExecution?.(execution, sessionAbortController) ?? execution); } } -interface PythonRenderArgs { - cells?: Array<{ code: string; title?: string }>; - timeout?: number; - cwd?: string; +async function summarizeFinal( + combinedOutput: string, + finalizeOutput: () => Promise, +): Promise { + const rawSummary = (await finalizeOutput()) ?? { + output: "", + truncated: false, + totalLines: 0, + totalBytes: 0, + outputLines: 0, + outputBytes: 0, + }; + const outputLines = combinedOutput.length > 0 ? combinedOutput.split("\n").length : 0; + const outputBytes = Buffer.byteLength(combinedOutput, "utf-8"); + const missingLines = Math.max(0, rawSummary.totalLines - rawSummary.outputLines); + const missingBytes = Math.max(0, rawSummary.totalBytes - rawSummary.outputBytes); + return { + output: combinedOutput, + truncated: rawSummary.truncated, + totalLines: outputLines + missingLines, + totalBytes: outputBytes + missingBytes, + outputLines, + outputBytes, + artifactId: rawSummary.artifactId, + }; } -interface PythonRenderContext { +interface EvalRenderArgs { + input?: string; + __partialJson?: string; +} + +interface EvalRenderContext { output?: string; expanded?: boolean; previewLines?: number; timeout?: number; } +function decodePartialJsonStringFragment(fragment: string): string { + let text = fragment.replace(/\\u[0-9a-fA-F]{0,3}$/, ""); + const trailingBackslashes = text.match(/\\+$/)?.[0].length ?? 0; + if (trailingBackslashes % 2 === 1) text = text.slice(0, -1); + try { + return JSON.parse(`"${text}"`) as string; + } catch { + return text; + } +} + +function extractPartialJsonString(partialJson: string | undefined, key: string): string | undefined { + if (!partialJson) return undefined; + const pattern = new RegExp(`"${key}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)`, "u"); + const match = pattern.exec(partialJson); + if (!match) return undefined; + return decodePartialJsonStringFragment(match[1]); +} + +function getRenderInput(args: EvalRenderArgs | undefined): string | undefined { + return args?.input ?? extractPartialJsonString(args?.__partialJson, "input"); +} + /** Format a status event as a single line for display. */ -function formatStatusEvent(event: PythonStatusEvent, theme: Theme): string { +function formatStatusEvent(event: EvalStatusEvent, theme: Theme): string { const { op, ...data } = event; - // Map operations to available theme icons type AvailableIcon = "icon.file" | "icon.folder" | "icon.git" | "icon.package"; const opIcons: Record = { - // File I/O read: "icon.file", write: "icon.file", append: "icon.file", cat: "icon.file", touch: "icon.file", - lines: "icon.file", - // Navigation/Directory ls: "icon.folder", cd: "icon.folder", pwd: "icon.folder", mkdir: "icon.folder", tree: "icon.folder", stat: "icon.folder", - // Search (use file icon since no search icon) find: "icon.file", grep: "icon.file", rgrep: "icon.file", glob: "icon.file", - // Edit operations (use file icon) - replace: "icon.file", sed: "icon.file", - rsed: "icon.file", - delete_lines: "icon.file", - delete_matching: "icon.file", - insert_at: "icon.file", - // Git git_status: "icon.git", git_diff: "icon.git", git_log: "icon.git", @@ -556,7 +632,6 @@ function formatStatusEvent(event: PythonStatusEvent, theme: Theme): string { git_branch: "icon.git", git_file_at: "icon.git", git_has_changes: "icon.git", - // Shell/batch (use package icon) run: "icon.package", sh: "icon.package", env: "icon.package", @@ -566,23 +641,20 @@ function formatStatusEvent(event: PythonStatusEvent, theme: Theme): string { const iconKey = opIcons[op] ?? "icon.file"; const icon = theme.styledSymbol(iconKey, "muted"); - // Format the status message based on operation type const parts: string[] = []; - // Error handling if (data.error) { return `${icon} ${theme.fg("warning", op)}: ${theme.fg("dim", String(data.error))}`; } - // Build description based on common fields switch (op) { case "read": - parts.push(`${data.chars} chars`); + parts.push(`${data.chars ?? data.bytes ?? 0} chars`); if (data.path) parts.push(`from ${shortenPath(String(data.path))}`); break; case "write": case "append": - parts.push(`${data.chars} chars`); + parts.push(`${data.chars ?? data.bytes ?? 0} chars`); if (data.path) parts.push(`to ${shortenPath(String(data.path))}`); break; case "cat": @@ -622,15 +694,10 @@ function formatStatusEvent(event: PythonStatusEvent, theme: Theme): string { } if (data.path) parts.push(shortenPath(String(data.path))); break; - case "replace": case "sed": parts.push(`${data.count} replacement${(data.count as number) !== 1 ? "s" : ""}`); if (data.path) parts.push(`in ${shortenPath(String(data.path))}`); break; - case "rsed": - parts.push(`${data.count} replacement${(data.count as number) !== 1 ? "s" : ""}`); - if (data.files) parts.push(`in ${data.files} file${(data.files as number) !== 1 ? "s" : ""}`); - break; case "git_status": if (data.clean) { parts.push("clean"); @@ -663,31 +730,13 @@ function formatStatusEvent(event: PythonStatusEvent, theme: Theme): string { case "wc": parts.push(`${data.lines}L ${data.words}W ${data.chars}C`); break; - case "lines": - parts.push(`${data.count} line${(data.count as number) !== 1 ? "s" : ""}`); - if (data.start && data.end) parts.push(`(${data.start}-${data.end})`); - break; - case "delete_lines": - case "delete_matching": - parts.push(`${data.count} line${(data.count as number) !== 1 ? "s" : ""} deleted`); - break; - case "insert_at": - parts.push(`${data.lines_inserted} line${(data.lines_inserted as number) !== 1 ? "s" : ""} inserted`); - break; case "cd": case "pwd": case "mkdir": case "touch": if (data.path) parts.push(shortenPath(String(data.path))); break; - case "rm": - case "mv": - case "cp": - if (data.src) parts.push(`${shortenPath(String(data.src))} → ${shortenPath(String(data.dst))}`); - else if (data.path) parts.push(shortenPath(String(data.path))); - break; default: - // Generic formatting for other operations if (data.count !== undefined) { parts.push(String(data.count)); } @@ -701,14 +750,12 @@ function formatStatusEvent(event: PythonStatusEvent, theme: Theme): string { } /** Format status event with expanded detail lines. */ -function formatStatusEventExpanded(event: PythonStatusEvent, theme: Theme): string[] { +function formatStatusEventExpanded(event: EvalStatusEvent, theme: Theme): string[] { const lines: string[] = []; const { op, ...data } = event; - // Main status line lines.push(formatStatusEvent(event, theme)); - // Add detail lines for operations with list data const addItems = (items: unknown[], formatter: (item: unknown) => string, max = 5) => { const arr = Array.isArray(items) ? items : []; for (let i = 0; i < Math.min(arr.length, max); i++) { @@ -719,7 +766,6 @@ function formatStatusEventExpanded(event: PythonStatusEvent, theme: Theme): stri } }; - // Add preview lines (truncated content) const addPreview = (preview: string, maxLines = 3) => { const previewLines = String(preview).split("\n").slice(0, maxLines); for (const line of previewLines) { @@ -755,14 +801,6 @@ function formatStatusEventExpanded(event: PythonStatusEvent, theme: Theme): stri }); } break; - case "rsed": - if (data.changed) { - addItems(data.changed as unknown[], c => { - const change = c as { file: string; count: number }; - return `${shortenPath(change.file)}: ${change.count} replacement${change.count !== 1 ? "s" : ""}`; - }); - } - break; case "env": if (data.keys) addItems(data.keys as unknown[], k => String(k), 10); break; @@ -786,7 +824,6 @@ function formatStatusEventExpanded(event: PythonStatusEvent, theme: Theme): stri case "tail": case "tree": case "diff": - case "lines": case "git_diff": case "sh": if (data.preview) addPreview(String(data.preview)); @@ -797,7 +834,7 @@ function formatStatusEventExpanded(event: PythonStatusEvent, theme: Theme): stri } /** Render status events as tree lines. */ -function renderStatusEvents(events: PythonStatusEvent[], theme: Theme, expanded: boolean): string[] { +function renderStatusEvents(events: EvalStatusEvent[], theme: Theme, expanded: boolean): string[] { if (events.length === 0) return []; const maxCollapsed = 3; @@ -810,7 +847,6 @@ function renderStatusEvents(events: PythonStatusEvent[], theme: Theme, expanded: const branch = isLast ? theme.tree.last : theme.tree.branch; if (expanded) { - // Show expanded details for each event const eventLines = formatStatusEventExpanded(events[i], theme); lines.push(`${theme.fg("dim", branch)} ${eventLines[0]}`); const continueBranch = isLast ? " " : `${theme.tree.vertical} `; @@ -832,7 +868,7 @@ function renderStatusEvents(events: PythonStatusEvent[], theme: Theme, expanded: } function formatCellOutputLines( - cell: PythonCellResult, + cell: EvalCellResult, expanded: boolean, previewLines: number, theme: Theme, @@ -861,60 +897,46 @@ function formatCellOutputLines( return { lines: outputLines, hiddenCount }; } -export const pythonToolRenderer = { - renderCall(args: PythonRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component { - const cells = args.cells ?? []; - const cwd = getProjectDir(); - let displayWorkdir = args.cwd; - - if (displayWorkdir) { - const resolvedCwd = path.resolve(cwd); - const resolvedWorkdir = path.resolve(displayWorkdir); - if (resolvedWorkdir === resolvedCwd) { - displayWorkdir = undefined; - } else { - const relativePath = path.relative(resolvedCwd, resolvedWorkdir); - const isWithinCwd = - relativePath && !relativePath.startsWith("..") && !relativePath.startsWith(`..${path.sep}`); - if (isWithinCwd) { - displayWorkdir = relativePath; - } +export const evalToolRenderer = { + renderCall(args: EvalRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component { + const input = getRenderInput(args); + let cells: ParsedEvalCell[] = []; + if (input) { + try { + cells = parseEvalInput(input).cells; + } catch { + cells = []; } } - const workdirLabel = displayWorkdir ? `cd ${displayWorkdir}` : undefined; if (cells.length === 0) { - const prompt = uiTheme.fg("accent", ">>>"); - const prefix = workdirLabel ? `${uiTheme.fg("dim", `${workdirLabel} && `)}` : ""; - const text = formatTitle(`${prompt} ${prefix}…`, uiTheme); + const promptSym = uiTheme.fg("accent", ">>>"); + const text = formatTitle(`${promptSym} …`, uiTheme); return new Text(text, 0, 0); } - // Cache state - cells don't change, only width varies - let cached: { width: number; result: string[] } | undefined; + let cached: { key: string; width: number; result: string[] } | undefined; return { render: (width: number): string[] => { - if (cached && cached.width === width) { + const key = `${input?.length ?? 0}`; + if (cached && cached.key === key && cached.width === width) { return cached.result; } const lines: string[] = []; for (let i = 0; i < cells.length; i++) { const cell = cells[i]; - const cellTitle = cell.title; - const combinedTitle = - cellTitle && workdirLabel ? `${workdirLabel} · ${cellTitle}` : (cellTitle ?? workdirLabel); const cellLines = renderCodeCell( { code: cell.code, - language: "python", + language: languageForHighlighter(cell.language), index: i, total: cells.length, - title: combinedTitle, + title: cell.title, status: "pending", width, - codeMaxLines: PYTHON_DEFAULT_PREVIEW_LINES, + codeMaxLines: EVAL_DEFAULT_PREVIEW_LINES, expanded: true, }, uiTheme, @@ -924,7 +946,7 @@ export const pythonToolRenderer = { lines.push(""); } } - cached = { width, result: lines }; + cached = { key, width, result: lines }; return lines; }, invalidate: () => { @@ -934,9 +956,10 @@ export const pythonToolRenderer = { }, renderResult( - result: { content: Array<{ type: string; text?: string }>; details?: PythonToolDetails }, - options: RenderResultOptions & { renderContext?: PythonRenderContext }, + result: { content: Array<{ type: string; text?: string }>; details?: EvalToolDetails }, + options: RenderResultOptions & { renderContext?: EvalRenderContext }, uiTheme: Theme, + _args?: EvalRenderArgs, ): Component { const details = result.details; @@ -959,17 +982,16 @@ export const pythonToolRenderer = { if (details?.meta?.truncation) { warningLine = formatStyledTruncationWarning(details.meta, uiTheme) ?? undefined; } + const noticeLine = details?.notice ? uiTheme.fg("dim", wrapBrackets(details.notice, uiTheme)) : undefined; const cellResults = details?.cells; if (cellResults && cellResults.length > 0) { - // Cache state following Box pattern let cached: { key: string; width: number; result: string[] } | undefined; return { render: (width: number): string[] => { - // Read mutable state at render time const expanded = options.renderContext?.expanded ?? options.expanded; - const previewLines = options.renderContext?.previewLines ?? PYTHON_DEFAULT_PREVIEW_LINES; + const previewLines = options.renderContext?.previewLines ?? EVAL_DEFAULT_PREVIEW_LINES; const key = `${expanded}|${previewLines}|${options.spinnerFrame}`; if (cached && cached.key === key && cached.width === width) { return cached.result; @@ -995,7 +1017,7 @@ export const pythonToolRenderer = { const cellLines = renderCodeCell( { code: cell.code, - language: "python", + language: languageForHighlighter(cell.language ?? details?.language), index: i, total: cellResults.length, title: cell.title, @@ -1004,7 +1026,7 @@ export const pythonToolRenderer = { duration: cell.durationMs, output: outputLines.length > 0 ? outputLines.join("\n") : undefined, outputMaxLines: outputLines.length, - codeMaxLines: expanded ? Number.POSITIVE_INFINITY : PYTHON_DEFAULT_PREVIEW_LINES, + codeMaxLines: expanded ? Number.POSITIVE_INFINITY : EVAL_DEFAULT_PREVIEW_LINES, expanded, width, }, @@ -1024,6 +1046,9 @@ export const pythonToolRenderer = { if (timeoutLine) { lines.push(timeoutLine); } + if (noticeLine) { + lines.push(noticeLine); + } if (warningLine) { lines.push(warningLine); } @@ -1047,12 +1072,12 @@ export const pythonToolRenderer = { ); if (!combinedOutput && statusLines.length === 0) { - const lines = [timeoutLine, warningLine].filter(Boolean) as string[]; + const lines = [timeoutLine, noticeLine, warningLine].filter(Boolean) as string[]; return new Text(lines.join("\n"), 0, 0); } if (!combinedOutput && statusLines.length > 0) { - const lines = [uiTheme.fg("dim", "Status"), ...statusLines, timeoutLine, warningLine].filter( + const lines = [uiTheme.fg("dim", "Status"), ...statusLines, timeoutLine, noticeLine, warningLine].filter( Boolean, ) as string[]; return new Text(lines.join("\n"), 0, 0); @@ -1067,6 +1092,7 @@ export const pythonToolRenderer = { styledOutput, ...(statusLines.length > 0 ? [uiTheme.fg("dim", "Status"), ...statusLines] : []), timeoutLine, + noticeLine, warningLine, ].filter(Boolean) as string[]; return new Text(lines.join("\n"), 0, 0); @@ -1085,8 +1111,7 @@ export const pythonToolRenderer = { return { render: (width: number): string[] => { - // Read mutable state at render time - const previewLines = options.renderContext?.previewLines ?? PYTHON_DEFAULT_PREVIEW_LINES; + const previewLines = options.renderContext?.previewLines ?? EVAL_DEFAULT_PREVIEW_LINES; if (cachedLines === undefined || cachedWidth !== width || cachedPreviewLines !== previewLines) { const result = truncateToVisualLines(textContent, previewLines, width); cachedLines = result.visualLines; @@ -1113,6 +1138,9 @@ export const pythonToolRenderer = { if (timeoutLine) { outputLines.push(truncateToWidth(timeoutLine, width)); } + if (noticeLine) { + outputLines.push(truncateToWidth(noticeLine, width)); + } if (warningLine) { outputLines.push(truncateToWidth(warningLine, width)); } diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 8863230df..1a0b3a441 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -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?(execution: Promise, abortController: AbortController): Promise; + /** 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?(execution: Promise, abortController: AbortController): Promise; /** 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 = { 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 = { 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 = { ...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"); diff --git a/packages/coding-agent/src/tools/renderers.ts b/packages/coding-agent/src/tools/renderers.ts index 4c29be058..22cd8543a 100644 --- a/packages/coding-agent/src/tools/renderers.ts +++ b/packages/coding-agent/src/tools/renderers.ts @@ -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 = { 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, diff --git a/packages/coding-agent/src/tools/tool-timeouts.ts b/packages/coding-agent/src/tools/tool-timeouts.ts index a3788b519..0445268ab 100644 --- a/packages/coding-agent/src/tools/tool-timeouts.ts +++ b/packages/coding-agent/src/tools/tool-timeouts.ts @@ -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 }, diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index e91708e1a..9e4a5babd 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -74,14 +74,14 @@ const TOOLS: Record = { }, }; -// 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 = { +const PYTHON_TOOLS: Record = { 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) { diff --git a/packages/coding-agent/test/agent-session-python-cleanup.test.ts b/packages/coding-agent/test/agent-session-python-cleanup.test.ts index dd358b38f..1397f402a 100644 --- a/packages/coding-agent/test/agent-session-python-cleanup.test.ts +++ b/packages/coding-agent/test/agent-session-python-cleanup.test.ts @@ -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(); 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(); const releaseWarmup = Promise.withResolvers(); 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(); 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(); const releaseHook = Promise.withResolvers(); 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(); const releaseHook = Promise.withResolvers(); 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(); diff --git a/packages/coding-agent/test/agent-session-user-shortcut-hooks.test.ts b/packages/coding-agent/test/agent-session-user-shortcut-hooks.test.ts index 91452e10d..120e27a60 100644 --- a/packages/coding-agent/test/agent-session-user-shortcut-hooks.test.ts +++ b/packages/coding-agent/test/agent-session-user-shortcut-hooks.test.ts @@ -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"; diff --git a/packages/coding-agent/test/core/helpers.ts b/packages/coding-agent/test/core/helpers.ts index 1bbd7831b..6e2244492 100644 --- a/packages/coding-agent/test/core/helpers.ts +++ b/packages/coding-agent/test/core/helpers.ts @@ -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; diff --git a/packages/coding-agent/test/core/js-executor.test.ts b/packages/coding-agent/test/core/js-executor.test.ts new file mode 100644 index 000000000..566eb7deb --- /dev/null +++ b/packages/coding-agent/test/core/js-executor.test.ts @@ -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, +): 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 => output.type === "json", + ); + expect(jsonOutputs).toHaveLength(1); + return jsonOutputs[0].data; +} + +function getStatusEvents(result: JsResult) { + return result.displayOutputs.filter( + (output): output is Extract => 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 => { + 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"); + }); +}); diff --git a/packages/coding-agent/test/core/js-tool-bridge.test.ts b/packages/coding-agent/test/core/js-tool-bridge.test.ts new file mode 100644 index 000000000..a8245225e --- /dev/null +++ b/packages/coding-agent/test/core/js-tool-bridge.test.ts @@ -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, +): 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> = []; + + 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"); + }); +}); diff --git a/packages/coding-agent/test/core/python-executor-display.test.ts b/packages/coding-agent/test/core/python-executor-display.test.ts index 35f59c763..339e32461 100644 --- a/packages/coding-agent/test/core/python-executor-display.test.ts +++ b/packages/coding-agent/test/core/python-executor-display.test.ts @@ -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", () => { diff --git a/packages/coding-agent/test/core/python-executor-lifecycle.test.ts b/packages/coding-agent/test/core/python-executor-lifecycle.test.ts index 146880d78..c8fc90762 100644 --- a/packages/coding-agent/test/core/python-executor-lifecycle.test.ts +++ b/packages/coding-agent/test/core/python-executor-lifecycle.test.ts @@ -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 { diff --git a/packages/coding-agent/test/core/python-executor-mapping.test.ts b/packages/coding-agent/test/core/python-executor-mapping.test.ts index 2327b19c7..cc04c4f26 100644 --- a/packages/coding-agent/test/core/python-executor-mapping.test.ts +++ b/packages/coding-agent/test/core/python-executor-mapping.test.ts @@ -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( diff --git a/packages/coding-agent/test/core/python-executor-owner-cleanup.test.ts b/packages/coding-agent/test/core/python-executor-owner-cleanup.test.ts index 20994d7f7..963757468 100644 --- a/packages/coding-agent/test/core/python-executor-owner-cleanup.test.ts +++ b/packages/coding-agent/test/core/python-executor-owner-cleanup.test.ts @@ -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 { 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"); diff --git a/packages/coding-agent/test/core/python-executor-per-call.test.ts b/packages/coding-agent/test/core/python-executor-per-call.test.ts index 879704b84..1ea3ee5cc 100644 --- a/packages/coding-agent/test/core/python-executor-per-call.test.ts +++ b/packages/coding-agent/test/core/python-executor-per-call.test.ts @@ -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 { diff --git a/packages/coding-agent/test/core/python-executor-session.test.ts b/packages/coding-agent/test/core/python-executor-session.test.ts index d8a45eb4b..845a74ba1 100644 --- a/packages/coding-agent/test/core/python-executor-session.test.ts +++ b/packages/coding-agent/test/core/python-executor-session.test.ts @@ -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; diff --git a/packages/coding-agent/test/core/python-executor-streaming.test.ts b/packages/coding-agent/test/core/python-executor-streaming.test.ts index a4bf992df..2a64c3e19 100644 --- a/packages/coding-agent/test/core/python-executor-streaming.test.ts +++ b/packages/coding-agent/test/core/python-executor-streaming.test.ts @@ -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"; diff --git a/packages/coding-agent/test/core/python-executor-timeout.test.ts b/packages/coding-agent/test/core/python-executor-timeout.test.ts index 75095d6fc..358d2f8cc 100644 --- a/packages/coding-agent/test/core/python-executor-timeout.test.ts +++ b/packages/coding-agent/test/core/python-executor-timeout.test.ts @@ -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; diff --git a/packages/coding-agent/test/core/python-executor.lifecycle.test.ts b/packages/coding-agent/test/core/python-executor.lifecycle.test.ts index c4db1d55e..849d793cf 100644 --- a/packages/coding-agent/test/core/python-executor.lifecycle.test.ts +++ b/packages/coding-agent/test/core/python-executor.lifecycle.test.ts @@ -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"; diff --git a/packages/coding-agent/test/core/python-executor.result.test.ts b/packages/coding-agent/test/core/python-executor.result.test.ts index d77ef58d2..c8d263315 100644 --- a/packages/coding-agent/test/core/python-executor.result.test.ts +++ b/packages/coding-agent/test/core/python-executor.result.test.ts @@ -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; diff --git a/packages/coding-agent/test/core/python-executor.test.ts b/packages/coding-agent/test/core/python-executor.test.ts index d3290c84d..76d4463a8 100644 --- a/packages/coding-agent/test/core/python-executor.test.ts +++ b/packages/coding-agent/test/core/python-executor.test.ts @@ -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 => ({ 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; - } - }); -}); diff --git a/packages/coding-agent/test/core/python-kernel-display.test.ts b/packages/coding-agent/test/core/python-kernel-display.test.ts index 0863ee180..307e523e1 100644 --- a/packages/coding-agent/test/core/python-kernel-display.test.ts +++ b/packages/coding-agent/test/core/python-kernel-display.test.ts @@ -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", () => { diff --git a/packages/coding-agent/test/core/python-kernel-env.test.ts b/packages/coding-agent/test/core/python-kernel-env.test.ts index 1097ed9e2..bc7dc0931 100644 --- a/packages/coding-agent/test/core/python-kernel-env.test.ts +++ b/packages/coding-agent/test/core/python-kernel-env.test.ts @@ -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", () => { diff --git a/packages/coding-agent/test/core/python-kernel-session.test.ts b/packages/coding-agent/test/core/python-kernel-session.test.ts index 5d80e7007..831d35438 100644 --- a/packages/coding-agent/test/core/python-kernel-session.test.ts +++ b/packages/coding-agent/test/core/python-kernel-session.test.ts @@ -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 { diff --git a/packages/coding-agent/test/core/python-kernel-ws.test.ts b/packages/coding-agent/test/core/python-kernel-ws.test.ts index 2ea67776f..a20c7eedd 100644 --- a/packages/coding-agent/test/core/python-kernel-ws.test.ts +++ b/packages/coding-agent/test/core/python-kernel-ws.test.ts @@ -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(); diff --git a/packages/coding-agent/test/core/python-kernel.lifecycle.test.ts b/packages/coding-agent/test/core/python-kernel.lifecycle.test.ts index 59cdb0054..1f7d591de 100644 --- a/packages/coding-agent/test/core/python-kernel.lifecycle.test.ts +++ b/packages/coding-agent/test/core/python-kernel.lifecycle.test.ts @@ -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"; diff --git a/packages/coding-agent/test/core/python-kernel.test.ts b/packages/coding-agent/test/core/python-kernel.test.ts index a51195b11..b66caa8dc 100644 --- a/packages/coding-agent/test/core/python-kernel.test.ts +++ b/packages/coding-agent/test/core/python-kernel.test.ts @@ -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. diff --git a/packages/coding-agent/test/core/python-modules.test.ts b/packages/coding-agent/test/core/python-modules.test.ts deleted file mode 100644 index 1c20b1d7f..000000000 --- a/packages/coding-agent/test/core/python-modules.test.ts +++ /dev/null @@ -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 => 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"); - }); -}); diff --git a/packages/coding-agent/test/core/python-prelude.test.ts b/packages/coding-agent/test/core/python-prelude.test.ts index db3dd502e..22bd62b10 100644 --- a/packages/coding-agent/test/core/python-prelude.test.ts +++ b/packages/coding-agent/test/core/python-prelude.test.ts @@ -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"); }); diff --git a/packages/coding-agent/test/core/settings-manager-python.test.ts b/packages/coding-agent/test/core/settings-manager-python.test.ts deleted file mode 100644 index d0f13d87a..000000000 --- a/packages/coding-agent/test/core/settings-manager-python.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/packages/coding-agent/test/eval/parse.test.ts b/packages/coding-agent/test/eval/parse.test.ts new file mode 100644 index 000000000..6c49f0e1a --- /dev/null +++ b/packages/coding-agent/test/eval/parse.test.ts @@ -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"); + }); +}); diff --git a/packages/coding-agent/test/fixtures/python-modules/alpha.py b/packages/coding-agent/test/fixtures/python-modules/alpha.py deleted file mode 100644 index 88f238870..000000000 --- a/packages/coding-agent/test/fixtures/python-modules/alpha.py +++ /dev/null @@ -1,2 +0,0 @@ -def alpha(): - return "alpha" diff --git a/packages/coding-agent/test/fixtures/python-modules/beta.py b/packages/coding-agent/test/fixtures/python-modules/beta.py deleted file mode 100644 index 8571dc97f..000000000 --- a/packages/coding-agent/test/fixtures/python-modules/beta.py +++ /dev/null @@ -1,2 +0,0 @@ -def beta(): - return "beta" diff --git a/packages/coding-agent/test/fixtures/python-modules/delta.py b/packages/coding-agent/test/fixtures/python-modules/delta.py deleted file mode 100644 index a6d0265e2..000000000 --- a/packages/coding-agent/test/fixtures/python-modules/delta.py +++ /dev/null @@ -1,2 +0,0 @@ -def delta(): - return "delta" diff --git a/packages/coding-agent/test/fixtures/python-modules/gamma.py b/packages/coding-agent/test/fixtures/python-modules/gamma.py deleted file mode 100644 index fd25b457a..000000000 --- a/packages/coding-agent/test/fixtures/python-modules/gamma.py +++ /dev/null @@ -1,2 +0,0 @@ -def gamma(): - return "gamma" diff --git a/packages/coding-agent/test/input-controller-escape.test.ts b/packages/coding-agent/test/input-controller-escape.test.ts index 25ac40f1f..279c0be7a 100644 --- a/packages/coding-agent/test/input-controller-escape.test.ts +++ b/packages/coding-agent/test/input-controller-escape.test.ts @@ -49,7 +49,7 @@ function createContext(): { spies: { abort: ReturnType; abortBash: ReturnType; - abortPython: ReturnType; + abortEval: ReturnType; addMessageToChat: ReturnType; cancelPendingSubmission: ReturnType; clearQueue: ReturnType; @@ -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(); }); diff --git a/packages/coding-agent/test/input-controller-keybindings.test.ts b/packages/coding-agent/test/input-controller-keybindings.test.ts index 783ccc533..7a085e1e6 100644 --- a/packages/coding-agent/test/input-controller-keybindings.test.ts +++ b/packages/coding-agent/test/input-controller-keybindings.test.ts @@ -63,7 +63,7 @@ async function createContext() { isCompacting: false, isGeneratingHandoff: false, isBashRunning: false, - isPythonRunning: false, + isEvalRunning: false, extensionRunner: undefined, } as InteractiveModeContext["session"], keybindings: { diff --git a/packages/coding-agent/test/python-tool-settings.test.ts b/packages/coding-agent/test/python-tool-settings.test.ts deleted file mode 100644 index cd9ef35b7..000000000 --- a/packages/coding-agent/test/python-tool-settings.test.ts +++ /dev/null @@ -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>, - 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; - } - } - }); -}); diff --git a/packages/coding-agent/test/system-prompt-templates.test.ts b/packages/coding-agent/test/system-prompt-templates.test.ts index 402f482f5..957a06250 100644 --- a/packages/coding-agent/test/system-prompt-templates.test.ts +++ b/packages/coding-agent/test/system-prompt-templates.test.ts @@ -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" }], ]), }); diff --git a/packages/coding-agent/test/tools/eval-fallback.test.ts b/packages/coding-agent/test/tools/eval-fallback.test.ts new file mode 100644 index 000000000..e9392e4d8 --- /dev/null +++ b/packages/coding-agent/test/tools/eval-fallback.test.ts @@ -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(); + }); +}); diff --git a/packages/coding-agent/test/tools/index.test.ts b/packages/coding-agent/test/tools/index.test.ts index 8fb2331db..8f25192a9 100644 --- a/packages/coding-agent/test/tools/index.test.ts +++ b/packages/coding-agent/test/tools/index.test.ts @@ -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 () => { diff --git a/packages/coding-agent/test/tools/python-execution.test.ts b/packages/coding-agent/test/tools/python-execution.test.ts deleted file mode 100644 index b164d6569..000000000 --- a/packages/coding-agent/test/tools/python-execution.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/packages/coding-agent/test/tools/python-fallback.test.ts b/packages/coding-agent/test/tools/python-fallback.test.ts deleted file mode 100644 index 5858b0490..000000000 --- a/packages/coding-agent/test/tools/python-fallback.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it, vi } from "bun:test"; -import { type SettingPath, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import * as pythonKernelModule from "@oh-my-pi/pi-coding-agent/ipy/kernel"; -import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; - -function createTestSession(overrides: Partial = {}): ToolSession { - return { - cwd: "/tmp/test", - hasUI: false, - getSessionFile: () => null, - getSessionSpawns: () => "*", - settings: Settings.isolated(), - ...overrides, - }; -} - -function createSettingsWithOverrides(overrides: Partial> = {}): Settings { - return Settings.isolated({ - "lsp.formatOnWrite": true, - "bashInterceptor.enabled": true, - ...overrides, - }); -} - -describe("createTools python fallback", () => { - it("falls back to bash-only when kernel unavailable", async () => { - const availabilitySpy = vi - .spyOn(pythonKernelModule, "checkPythonKernelAvailability") - .mockResolvedValue({ ok: false, reason: "unavailable" }); - - const session = createTestSession({ - settings: createSettingsWithOverrides({ - "python.toolMode": "ipy-only", - "python.kernelMode": "session", - }), - }); - - const tools = await createTools(session, ["python"]); - const names = tools.map(tool => tool.name).sort(); - - expect(names).toEqual(["bash", "exit_plan_mode"]); - - availabilitySpy.mockRestore(); - }); - - it("keeps bash when python mode is both but unavailable", async () => { - const availabilitySpy = vi - .spyOn(pythonKernelModule, "checkPythonKernelAvailability") - .mockResolvedValue({ ok: false, reason: "unavailable" }); - - const session = createTestSession({ - settings: createSettingsWithOverrides({ - "python.toolMode": "both", - "python.kernelMode": "session", - }), - }); - - const tools = await createTools(session); - const names = tools.map(tool => tool.name); - - expect(names).toContain("bash"); - expect(names).not.toContain("python"); - - availabilitySpy.mockRestore(); - }); -}); diff --git a/packages/coding-agent/test/tools/python-renderer.test.ts b/packages/coding-agent/test/tools/python-renderer.test.ts deleted file mode 100644 index 2a3ddbbba..000000000 --- a/packages/coding-agent/test/tools/python-renderer.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { getThemeByName } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; -import { pythonToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/python"; -import { sanitizeText } from "@oh-my-pi/pi-natives"; - -describe("pythonToolRenderer", () => { - it("renders truncated output when collapsed and full output when expanded", async () => { - const theme = await getThemeByName("dark"); - expect(theme).toBeDefined(); - const uiTheme = theme!; - - const fullOutput = ["line 1", "line 2", "line 3", "line 4"].join("\n"); - - const result = { - content: [{ type: "text", text: fullOutput }], - details: { - cells: [ - { - index: 0, - title: "run", - code: "print('hello')", - output: fullOutput, - status: "complete" as const, - durationMs: 12, - }, - ], - }, - }; - - const collapsed = pythonToolRenderer.renderResult( - result, - { expanded: false, isPartial: false, renderContext: { previewLines: 2 } }, - uiTheme, - ); - const collapsedLines = sanitizeText(collapsed.render(80).join("\n")); - expect(collapsedLines).toContain("line 4"); - expect(collapsedLines).not.toContain("line 1"); - expect(collapsedLines).toContain("more lines"); - - const expanded = pythonToolRenderer.renderResult(result, { expanded: true, isPartial: false }, uiTheme); - const expandedLines = sanitizeText(expanded.render(80).join("\n")); - expect(expandedLines).toContain("line 1"); - expect(expandedLines).toContain("line 4"); - expect(expandedLines).not.toContain("more lines"); - }); -}); diff --git a/packages/coding-agent/test/tools/python-tool-mode.test.ts b/packages/coding-agent/test/tools/python-tool-mode.test.ts deleted file mode 100644 index f931ff510..000000000 --- a/packages/coding-agent/test/tools/python-tool-mode.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; - -function createSession(overrides: Partial = {}): ToolSession { - return { - cwd: "/tmp/test", - hasUI: false, - getSessionFile: () => null, - getSessionSpawns: () => "*", - settings: Settings.isolated({ - "lsp.formatOnWrite": true, - "bashInterceptor.enabled": true, - "python.toolMode": "bash-only", - }), - ...overrides, - }; -} - -describe("createTools python fallback", () => { - it("falls back to bash when python is requested but disabled", async () => { - const previous = Bun.env.PI_PYTHON_SKIP_CHECK; - Bun.env.PI_PYTHON_SKIP_CHECK = "1"; - const session = createSession(); - const tools = await createTools(session, ["python"]); - const names = tools.map(tool => tool.name).sort(); - - expect(names).toEqual(["bash", "exit_plan_mode"]); - - if (previous === undefined) { - delete Bun.env.PI_PYTHON_SKIP_CHECK; - } else { - Bun.env.PI_PYTHON_SKIP_CHECK = previous; - } - }); -}); diff --git a/packages/coding-agent/test/tools/python.test.ts b/packages/coding-agent/test/tools/python.test.ts deleted file mode 100644 index 03e58153b..000000000 --- a/packages/coding-agent/test/tools/python.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { afterAll, beforeAll, 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 { createTools, 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"; - -let previousSkipCheck: string | undefined; -let tempDir: TempDir; -beforeAll(() => { - tempDir = TempDir.createSync("@omp-python-test-"); - previousSkipCheck = Bun.env.PI_PYTHON_SKIP_CHECK; - Bun.env.PI_PYTHON_SKIP_CHECK = "1"; -}); - -afterAll(() => { - if (previousSkipCheck === undefined) { - delete Bun.env.PI_PYTHON_SKIP_CHECK; - return; - } - Bun.env.PI_PYTHON_SKIP_CHECK = previousSkipCheck; - tempDir.removeSync(); -}); - -function createSession(overrides: Partial = {}): ToolSession { - return { - cwd: tempDir.path(), - hasUI: false, - getSessionFile: () => null, - getSessionSpawns: () => "*", - settings: Settings.isolated(), - ...overrides, - }; -} - -function createSettings(toolMode: "ipy-only" | "bash-only" | "both"): Settings { - return Settings.isolated({ - "lsp.formatOnWrite": true, - "bashInterceptor.enabled": true, - "python.toolMode": toolMode, - }); -} - -describe("python tool schema", () => { - it("exposes expected parameters", () => { - const tool = new PythonTool(createSession()); - const schema = tool.parameters as { - type: string; - properties: Record; - required?: string[]; - }; - - expect(schema.type).toBe("object"); - expect(schema.properties.cells.type).toBe("array"); - expect(schema.properties.timeout.type).toBe("number"); - expect(schema.properties.reset.type).toBe("boolean"); - expect(schema.required).toEqual(["cells"]); - }); -}); - -describe("python tool docs template", () => { - it("renders dynamic helper docs", () => { - const docs = [ - { - name: "read", - signature: "(path)", - docstring: "Read file contents.", - category: "File I/O", - }, - ]; - const spy = vi.spyOn(pythonExecutor, "getPreludeDocs").mockReturnValue(docs); - - const tool = new PythonTool(createSession()); - - expect(tool.description).toContain("### File I/O"); - expect(tool.description).toContain("read(path)"); - expect(tool.description).toContain("Read file contents."); - - spy.mockRestore(); - }); - - it("renders fallback when docs are unavailable", () => { - const spy = vi.spyOn(pythonExecutor, "getPreludeDocs").mockReturnValue([]); - - const tool = new PythonTool(createSession()); - - expect(tool.description).not.toContain(""); - - spy.mockRestore(); - }); -}); - -describe("python tool exposure", () => { - it("includes python only in ipy-only mode", async () => { - const session = createSession({ settings: createSettings("ipy-only") }); - const tools = await createTools(session); - const names = tools.map(tool => tool.name); - expect(names).toContain("python"); - expect(names).not.toContain("bash"); - }); - - it("includes bash only in bash-only mode", async () => { - const session = createSession({ settings: createSettings("bash-only") }); - const tools = await createTools(session); - const names = tools.map(tool => tool.name); - expect(names).toContain("bash"); - expect(names).not.toContain("python"); - }); - - it("includes bash and python in both mode", async () => { - const session = createSession({ settings: createSettings("both") }); - const tools = await createTools(session); - const names = tools.map(tool => tool.name); - expect(names).toContain("bash"); - expect(names).toContain("python"); - }); -});