feat(coding-agent): implemented eval framework and replaced python tool
- Added a unified eval framework with parser grammar, backend interfaces, and JS/Python execution result types. - Added eval tool docs and updated prompts for fenced cells, `eval.py`/`eval.js`, and fallback behavior. - Replaced the built-in `python` tool with `eval` across registry, rendering, interactive modes, and tool settings. - Migrated Python execution runtime from `src/ipy` to `src/eval/py`, renamed state fields, and removed legacy introspection. - Refactored browser tooling from in-process VM helpers to worker-managed tab supervisors and protocol transport. - Added eval parser fallback and JS tool-bridge tests, updated imports, and removed obsolete python-mode suites.
This commit is contained in:
@@ -248,7 +248,7 @@ Related vars:
|
||||
|
||||
| Variable | Default / behavior |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PI_PY` | Python tool mode override: `0`/`bash`=`bash-only`, `1`/`py`=`ipy-only`, `mix`/`both`=`both`; invalid values ignored |
|
||||
| `PI_PY` | Eval backend override: `0`/`bash`=JavaScript only, `1`/`py`=Python only, `mix`/`both`=both; invalid values ignored |
|
||||
| `PI_PYTHON_SKIP_CHECK` | If `1`, skips Python kernel availability checks/warm checks |
|
||||
| `PI_PYTHON_GATEWAY_URL` | If set, uses external kernel gateway instead of local shared gateway |
|
||||
| `PI_PYTHON_GATEWAY_TOKEN` | Optional auth token for external gateway (`Authorization: token <value>`) |
|
||||
@@ -378,4 +378,4 @@ Treat these as secrets; do not log or commit them:
|
||||
- Search/provider auth vars (`EXA_API_KEY`, `BRAVE_API_KEY`, `PERPLEXITY_API_KEY`, Anthropic search keys)
|
||||
- Foundry mTLS material (`CLAUDE_CODE_CLIENT_CERT`, `CLAUDE_CODE_CLIENT_KEY`, `NODE_EXTRA_CA_CERTS` when it points to private CA bundles)
|
||||
|
||||
Python runtime also explicitly strips many common key vars before spawning kernel subprocesses (`packages/coding-agent/src/ipy/runtime.ts`).
|
||||
Python runtime also explicitly strips many common key vars before spawning kernel subprocesses (`packages/coding-agent/src/eval/py/runtime.ts`).
|
||||
|
||||
@@ -7,10 +7,10 @@ The critical distinction: **`notebook` is a JSON notebook editor, not a notebook
|
||||
## Implementation files
|
||||
|
||||
- [`src/tools/notebook.ts`](../packages/coding-agent/src/tools/notebook.ts)
|
||||
- [`src/ipy/executor.ts`](../packages/coding-agent/src/ipy/executor.ts)
|
||||
- [`src/ipy/kernel.ts`](../packages/coding-agent/src/ipy/kernel.ts)
|
||||
- [`src/eval/py/executor.ts`](../packages/coding-agent/src/eval/py/executor.ts)
|
||||
- [`src/eval/py/kernel.ts`](../packages/coding-agent/src/eval/py/kernel.ts)
|
||||
- [`src/session/streaming-output.ts`](../packages/coding-agent/src/session/streaming-output.ts)
|
||||
- [`src/tools/python.ts`](../packages/coding-agent/src/tools/python.ts)
|
||||
- [`src/tools/eval.ts`](../packages/coding-agent/src/tools/eval.ts)
|
||||
|
||||
## 1) Runtime boundary: editing vs executing
|
||||
|
||||
@@ -30,9 +30,9 @@ No kernel lifecycle exists in this tool:
|
||||
- no stream chunks from kernel channels
|
||||
- no rich display capture (`image/png`, JSON display, status MIME)
|
||||
|
||||
## Notebook-like execution path (`src/tools/python.ts` + `src/ipy/*`)
|
||||
## Notebook-like execution path (`src/tools/eval.ts` + `src/eval/py/*`)
|
||||
|
||||
When the agent needs to run cell-style Python code (sequential cells, persistent state, rich displays), that goes through the **`python` tool**, not `notebook`.
|
||||
When the agent needs to run cell-style Python code (sequential cells, persistent state, rich displays), that goes through the **`eval` tool** with `language: "python"`, not `notebook`.
|
||||
|
||||
That path is where kernel modes, restart/cancel behavior, chunk streaming, and output artifact truncation live.
|
||||
|
||||
@@ -75,7 +75,7 @@ These become `Error:` tool responses upstream; renderer uses notebook path + for
|
||||
|
||||
## 3) Kernel session semantics (where they actually exist)
|
||||
|
||||
Kernel semantics are implemented in `executePython` / `PythonKernel` and apply to the `python` tool.
|
||||
Kernel semantics are implemented in `executePython` / `PythonKernel` and apply to the Python backend of the `eval` tool.
|
||||
|
||||
## Modes
|
||||
|
||||
@@ -93,7 +93,7 @@ Kernel semantics are implemented in `executePython` / `PythonKernel` and apply t
|
||||
|
||||
## Reset behavior
|
||||
|
||||
`python` tool passes `reset` only for the first cell in a multi-cell call; later cells always run with `reset: false`.
|
||||
`eval` passes `reset` only for the first cell in a multi-cell Python call; later cells always run with `reset: false`.
|
||||
|
||||
## Kernel death / restart / retry
|
||||
|
||||
@@ -170,7 +170,7 @@ Cancellation/timeout:
|
||||
- truncation flag + counts
|
||||
- artifact ID (for `artifact://<id>` references)
|
||||
|
||||
`python` tool converts this metadata into result truncation notices and TUI warnings.
|
||||
`eval` converts this metadata into result truncation notices and TUI warnings.
|
||||
|
||||
`notebook` tool does **not** use `OutputSink`; it has no stream/artifact truncation pipeline because it does not execute code.
|
||||
|
||||
@@ -202,16 +202,16 @@ Kernel-backed execution rendering expects:
|
||||
|
||||
This renderer behavior is unrelated to `notebook` JSON editing results except that both reuse shared TUI primitives.
|
||||
|
||||
## 8) Divergence from plain Python tool behavior
|
||||
## 8) Divergence from eval Python backend behavior
|
||||
|
||||
If "plain Python tool" means `python` execution path:
|
||||
If "plain Python execution" means the `eval` tool with `language: "python"`:
|
||||
|
||||
- `python` executes code in a kernel, persists state by mode, streams chunks, captures rich displays, handles interrupts/timeouts, and supports output truncation/artifacts.
|
||||
- `eval` executes code in a kernel, persists state by mode, streams chunks, captures rich displays, handles interrupts/timeouts, and supports output truncation/artifacts.
|
||||
- `notebook` performs deterministic notebook JSON mutations only; no execution, no kernel state, no chunk stream, no display outputs, no artifact pipeline.
|
||||
|
||||
If a workflow needs both:
|
||||
|
||||
1. edit notebook source with `notebook`
|
||||
2. execute code cells via `python` (manually passing code), not through `notebook`
|
||||
2. execute code cells via `eval` with `language: "python"` (manually passing code), not through `notebook`
|
||||
|
||||
Current implementation does not provide a single tool that both mutates `.ipynb` and executes notebook cells through kernel context.
|
||||
|
||||
+24
-24
@@ -1,20 +1,20 @@
|
||||
# Python Tool and IPython Runtime
|
||||
# Eval Tool Python Backend and IPython Runtime
|
||||
|
||||
This document describes the current Python execution stack in `packages/coding-agent`.
|
||||
It covers tool behavior, kernel/gateway lifecycle, environment handling, execution semantics, output rendering, and operational failure modes.
|
||||
|
||||
## Scope and Key Files
|
||||
|
||||
- Tool surface: `src/tools/python.ts`
|
||||
- Session/per-call kernel orchestration: `src/ipy/executor.ts`
|
||||
- Kernel protocol + gateway integration: `src/ipy/kernel.ts`
|
||||
- Shared local gateway coordinator: `src/ipy/gateway-coordinator.ts`
|
||||
- Interactive-mode renderer for user-triggered Python runs: `src/modes/components/python-execution.ts`
|
||||
- Runtime/env filtering and Python resolution: `src/ipy/runtime.ts`
|
||||
- Tool surface: `src/tools/eval.ts`
|
||||
- Session/per-call kernel orchestration: `src/eval/py/executor.ts`
|
||||
- Kernel protocol + gateway integration: `src/eval/py/kernel.ts`
|
||||
- Shared local gateway coordinator: `src/eval/py/gateway-coordinator.ts`
|
||||
- Interactive-mode renderer for user-triggered Python runs: `src/modes/components/eval-execution.ts`
|
||||
- Runtime/env filtering and Python resolution: `src/eval/py/runtime.ts`
|
||||
|
||||
## What the Python tool is
|
||||
## What eval's Python backend is
|
||||
|
||||
The `python` tool executes one or more Python cells through a Jupyter Kernel Gateway-backed kernel (not by spawning `python -c` directly per cell).
|
||||
The `eval` tool executes one or more Python cells through a Jupyter Kernel Gateway-backed kernel when `language: "python"` is selected or inferred (not by spawning `python -c` directly per cell).
|
||||
|
||||
Tool params:
|
||||
|
||||
@@ -22,7 +22,7 @@ Tool params:
|
||||
{
|
||||
cells: Array<{ code: string; title?: string }>;
|
||||
timeout?: number; // seconds, clamped to 1..600, default 30
|
||||
reset?: boolean; // reset kernel before first cell only
|
||||
reset?: boolean; // reset selected runtime before the first cell only
|
||||
}
|
||||
```
|
||||
|
||||
@@ -146,25 +146,25 @@ Kernel startup receives the optional session file path from the executor:
|
||||
|
||||
## Tool availability and mode selection
|
||||
|
||||
`python.toolMode` (default `both`) + optional `PI_PY` override controls exposure:
|
||||
`eval.py` / `eval.js` (both default `true`) plus optional `PI_PY` override controls eval backend exposure:
|
||||
|
||||
- `ipy-only`
|
||||
- `bash-only`
|
||||
- `both`
|
||||
- Python backend only (`eval.py=true`, `eval.js=false`)
|
||||
- JavaScript backend only (`eval.py=false`, `eval.js=true`)
|
||||
- both backends
|
||||
|
||||
`PI_PY` accepted values:
|
||||
|
||||
- `0` / `bash` -> `bash-only`
|
||||
- `1` / `py` -> `ipy-only`
|
||||
- `mix` / `both` -> `both`
|
||||
- `0` / `bash` -> JavaScript backend only
|
||||
- `1` / `py` -> Python backend only
|
||||
- `mix` / `both` -> both backends
|
||||
|
||||
If Python preflight fails, tool creation degrades to bash-only for that session.
|
||||
If Python preflight fails and `eval.js` is enabled, `eval` remains available and dispatches to JavaScript unless `language: "python"` is explicitly requested.
|
||||
|
||||
## Execution flow and cancellation/timeout
|
||||
|
||||
### Tool-level timeout
|
||||
|
||||
`python` tool timeout is in seconds, default 30, clamped to `1..600`.
|
||||
`eval` timeout is in seconds, default 30, clamped to `1..600`.
|
||||
|
||||
The tool combines:
|
||||
|
||||
@@ -224,11 +224,11 @@ Tool results can include truncation metadata and `artifact://<id>` for full outp
|
||||
|
||||
### Renderer behavior
|
||||
|
||||
- Tool renderer (`python.ts`):
|
||||
- Tool renderer (`eval.ts`):
|
||||
- shows code-cell blocks with per-cell status
|
||||
- collapsed preview defaults to 10 lines
|
||||
- supports expanded mode for full output and richer status detail
|
||||
- Interactive renderer (`python-execution.ts`):
|
||||
- Interactive renderer (`eval-execution.ts`):
|
||||
- used for user-triggered Python execution in TUI
|
||||
- collapsed preview defaults to 20 lines
|
||||
- clamps very long individual lines to 4000 chars for display safety
|
||||
@@ -253,9 +253,9 @@ Behavior differences from local shared gateway:
|
||||
|
||||
## Operational troubleshooting (current failure modes)
|
||||
|
||||
- **Python tool not available**
|
||||
- Check `python.toolMode` / `PI_PY`.
|
||||
- If preflight fails, runtime falls back to bash-only.
|
||||
- **Python backend not available**
|
||||
- Check `eval.py`, Python kernel dependencies, and `PI_PY`.
|
||||
- If preflight fails and `eval.js` is enabled, omit `language` or pass `language: "js"` to use JavaScript.
|
||||
|
||||
- **Kernel availability errors**
|
||||
- Local mode requires both `kernel_gateway` and `ipykernel` importable in resolved Python runtime.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ src/
|
||||
├── mcp/ # MCP transport/manager/loader/tool bridge
|
||||
├── lsp/ # language server client/runtime integration
|
||||
├── internal-urls/ # protocol router + handlers (agent://, docs://, rule://, ...)
|
||||
├── exec/ ipy/ ssh/ # execution backends (shell, python, ssh)
|
||||
├── exec/ eval/ ssh/ # execution backends (shell, eval runtimes, ssh)
|
||||
├── web/ # search providers + domain scrapers
|
||||
├── patch/ # edit/patch parser + applicator + diff utilities
|
||||
└── config/ utils/ tui/ # settings, helpers, low-level TUI primitives
|
||||
@@ -99,7 +99,7 @@ createAgentSession(...)
|
||||
|
||||
- **Core runtime/session APIs**: `createAgentSession`, `AgentSession`, `SessionManager`, prompt/compaction/session types.
|
||||
- **Mode APIs**: `InteractiveMode`, `runPrintMode`, `runRpcMode`, `RpcClient` and RPC event/types.
|
||||
- **Discovery + tool constructors**: `discoverAuthStorage`, `discoverExtensions`, `discoverMCPServers`, `createTools`, built-in tool classes (`ReadTool`, `WriteTool`, `BashTool`, `PythonTool`, `FindTool`, `GrepTool`, `EditTool`).
|
||||
- **Discovery + tool constructors**: `discoverAuthStorage`, `discoverExtensions`, `discoverMCPServers`, `createTools`, built-in tool classes (`ReadTool`, `WriteTool`, `BashTool`, `EvalTool`, `FindTool`, `GrepTool`, `EditTool`).
|
||||
- **Extensibility interfaces**: extension/custom-command/custom-tool/skill/slash-command types and loaders.
|
||||
- **UI/theming helpers**: TUI components plus `initTheme`, `Theme`, and code-highlighting/theme utilities.
|
||||
- **CLI callable export**: `main` is re-exported for embedding/integration contexts that invoke root behavior explicitly.
|
||||
@@ -390,7 +390,7 @@ A `ToolFactory` is `(session: ToolSession) => Tool | null | Promise<Tool | null>
|
||||
`createTools(session, toolNames?)` is the entry point. It:
|
||||
|
||||
1. Normalizes requested tool names (`toolNames`) and always injects `exit_plan_mode`.
|
||||
2. Resolves Python mode via `PI_PY` override (`getPythonModeFromEnv()`) or `session.settings.get("python.toolMode")`.
|
||||
2. Resolves eval backend allowance via `PI_PY` override (`getEvalBackendsFromEnv()`) or `eval.py` / `eval.js` settings.
|
||||
3. Performs Python kernel preflight/warmup when applicable (`checkPythonKernelAvailability`, `warmPythonEnvironment`).
|
||||
4. Computes effective gating (`isToolAllowed`) from settings and runtime state:
|
||||
- feature toggles (`find.enabled`, `grep.enabled`, etc.)
|
||||
@@ -741,10 +741,10 @@ This keeps URL protocol resolution centralized and pluggable while keeping proto
|
||||
### ASCII overview
|
||||
|
||||
```text
|
||||
Tool adapters (tools/bash.ts, tools/python.ts, tools/ssh.ts)
|
||||
Tool adapters (tools/bash.ts, tools/eval.ts, tools/ssh.ts)
|
||||
│ schema + validation + onUpdate + error policy
|
||||
▼
|
||||
Executors (exec/bash-executor.ts, ipy/executor.ts, ssh/ssh-executor.ts)
|
||||
Executors (exec/bash-executor.ts, eval/py/executor.ts, ssh/ssh-executor.ts)
|
||||
│ process/kernel/session lifecycle
|
||||
▼
|
||||
OutputSink
|
||||
@@ -755,8 +755,8 @@ Executors (exec/bash-executor.ts, ipy/executor.ts, ssh/ssh-executor.ts)
|
||||
|
||||
This subsystem is split into two layers:
|
||||
|
||||
- **Core executors** (`src/exec/bash-executor.ts`, `src/ipy/executor.ts`, `src/ssh/ssh-executor.ts`) own process/kernel lifecycle and raw output capture.
|
||||
- **Tool adapters** (`src/tools/bash.ts`, `src/tools/python.ts`) own tool schemas, argument normalization, UX-facing updates, and error policy for agent tool calls.
|
||||
- **Core executors** (`src/exec/bash-executor.ts`, `src/eval/py/executor.ts`, `src/ssh/ssh-executor.ts`) own process/kernel lifecycle and raw output capture.
|
||||
- **Tool adapters** (`src/tools/bash.ts`, `src/tools/eval.ts`) own tool schemas, argument normalization, UX-facing updates, and error policy for agent tool calls.
|
||||
|
||||
### Core executor responsibilities
|
||||
|
||||
@@ -772,7 +772,7 @@ This subsystem is split into two layers:
|
||||
- `output`, `truncated`, `totalLines/totalBytes`, `outputLines/outputBytes`, optional `artifactId`
|
||||
- `exitCode` / `cancelled` states for normal completion, timeout, and abort.
|
||||
|
||||
#### Python executor (`src/ipy/executor.ts`)
|
||||
#### Python executor (`src/eval/py/executor.ts`)
|
||||
|
||||
- Entry points:
|
||||
- `executePython(code, options)`
|
||||
@@ -819,11 +819,11 @@ This subsystem is split into two layers:
|
||||
- converts backend cancellation/timeout/exit status into `ToolAbortError` / `ToolError`
|
||||
- returns `toolResult(...).text(...).truncationFromSummary(...)`.
|
||||
|
||||
#### Python tool (`src/tools/python.ts`)
|
||||
#### Eval tool (`src/tools/eval.ts`)
|
||||
|
||||
- Adapter class: `PythonTool implements AgentTool<typeof pythonSchema>`.
|
||||
- Defines schema (`cells[]`, `timeout`, `cwd`, `reset`) and dynamic description from prelude docs (`getPythonToolDescription`, `getPreludeDocs`).
|
||||
- Supports proxy mode via `PythonProxyExecutor`; otherwise executes locally.
|
||||
- Adapter class: `EvalTool implements AgentTool<typeof evalSchema>`.
|
||||
- Defines schema (`cells[]`, `language`, `timeout`, `reset`) and a static description from `prompts/tools/eval.md` (`getEvalToolDescription`).
|
||||
- Supports proxy mode via `EvalProxyExecutor`; otherwise executes locally.
|
||||
- Per-call adaptation:
|
||||
- validates and resolves working dir
|
||||
- clamps timeout and combines abort signals (`AbortSignal.any`)
|
||||
@@ -923,7 +923,7 @@ What _is_ isolated is execution context and artifacts, not process memory:
|
||||
|
||||
- Adds `task` tool automatically when `agent.spawns` is set and recursion depth permits.
|
||||
- Removes `task` when max recursion depth is reached (`task.maxRecursionDepth`).
|
||||
- Expands legacy `exec` alias into `python` and/or `bash` based on `python.toolMode`.
|
||||
- Expands legacy `exec` alias into `eval` when any eval backend is enabled, and always includes `bash`.
|
||||
- Forces `requireYieldTool: true` in `createAgentSession(...)`.
|
||||
- Filters parent-owned tools out of child tools (`todo_write` is removed).
|
||||
|
||||
@@ -1142,7 +1142,7 @@ Notes from current behavior:
|
||||
- `createTools()` always injects `exit_plan_mode` when `toolNames` are specified.
|
||||
- `resolve` is included only when at least one active tool is marked `deferrable: true` (built-in or extension/custom).
|
||||
- `yield` is force-added when `session.requireYieldTool === true`.
|
||||
- Python/Bash availability is mode-driven (`PI_PY`, `python.toolMode`) and can auto-fallback to bash.
|
||||
- Eval availability is mode-driven (`PI_PY`, `eval.py`, `eval.js`); eval falls back to JavaScript when Python is unavailable and JavaScript is enabled. The standalone `bash` tool is always available.
|
||||
|
||||
### Playbook: add an RPC command
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { APP_NAME } from "@oh-my-pi/pi-utils";
|
||||
import chalk from "chalk";
|
||||
import { getGatewayStatus, shutdownSharedGateway } from "../ipy/gateway-coordinator";
|
||||
import { getGatewayStatus, shutdownSharedGateway } from "../eval/py/gateway-coordinator";
|
||||
|
||||
export type JupyterAction = "kill" | "status";
|
||||
|
||||
|
||||
@@ -1137,14 +1137,28 @@ export const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
|
||||
// Python
|
||||
"python.toolMode": {
|
||||
type: "enum",
|
||||
values: ["ipy-only", "bash-only", "both"] as const,
|
||||
default: "both",
|
||||
ui: { tab: "editing", label: "Python Tool Mode", description: "How Python code is executed" },
|
||||
// Eval (per-backend toggles; add more as new backends ship, e.g. eval.ts)
|
||||
"eval.py": {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
ui: {
|
||||
tab: "editing",
|
||||
label: "Eval: Python backend",
|
||||
description: "Allow the eval tool to dispatch to the IPython kernel",
|
||||
},
|
||||
},
|
||||
|
||||
"eval.js": {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
ui: {
|
||||
tab: "editing",
|
||||
label: "Eval: JavaScript backend",
|
||||
description: "Allow the eval tool to dispatch to the in-process JavaScript runtime",
|
||||
},
|
||||
},
|
||||
|
||||
// Python kernel knobs (consumed by the eval py backend and the /python slash command)
|
||||
"python.kernelMode": {
|
||||
type: "enum",
|
||||
values: ["session", "per-call"] as const,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ToolSession } from "../tools";
|
||||
import type { EvalDisplayOutput, EvalLanguage } from "./types";
|
||||
|
||||
/** Per-call options shared across warm and execute. */
|
||||
export interface ExecutorBackendCallOptions {
|
||||
cwd: string;
|
||||
sessionId: string;
|
||||
sessionFile: string | undefined;
|
||||
kernelOwnerId: string | undefined;
|
||||
signal?: AbortSignal;
|
||||
session: ToolSession;
|
||||
}
|
||||
|
||||
/** Per-cell execute() options. */
|
||||
export interface ExecutorBackendExecOptions extends ExecutorBackendCallOptions {
|
||||
deadlineMs: number;
|
||||
reset: boolean;
|
||||
artifactPath: string | undefined;
|
||||
artifactId: string | undefined;
|
||||
onChunk: (chunk: string) => void;
|
||||
}
|
||||
|
||||
/** Result returned by a backend's execute(). */
|
||||
export interface ExecutorBackendResult {
|
||||
output: string;
|
||||
exitCode: number | undefined;
|
||||
cancelled: boolean;
|
||||
truncated: boolean;
|
||||
artifactId: string | undefined;
|
||||
totalLines: number;
|
||||
totalBytes: number;
|
||||
outputLines: number;
|
||||
outputBytes: number;
|
||||
displayOutputs: EvalDisplayOutput[];
|
||||
}
|
||||
|
||||
/** Pluggable language backend for the eval tool. */
|
||||
export interface ExecutorBackend {
|
||||
readonly id: EvalLanguage;
|
||||
readonly label: string;
|
||||
/** Source language identifier passed to the syntax highlighter (e.g. "python", "javascript"). */
|
||||
readonly highlightLang: string;
|
||||
/** Cheap availability check (no full warmup). Used by fallback resolution. */
|
||||
isAvailable(session: ToolSession): Promise<boolean>;
|
||||
/** Optional pre-warm performed once per session (no-op on backends that don't need it). */
|
||||
warm?(opts: ExecutorBackendCallOptions): Promise<{ ok: boolean; reason?: string }>;
|
||||
/** Execute one cell. Caller invokes once per cell and aggregates results. */
|
||||
execute(code: string, opts: ExecutorBackendExecOptions): Promise<ExecutorBackendResult>;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
%import common.LF
|
||||
%import common.WS_INLINE
|
||||
|
||||
// Strict canonical surface for the eval tool. Callers MUST emit exactly this
|
||||
// form. The runtime parser accepts additional lenient shapes (positional
|
||||
// title/duration, alias keys, long-form lang tokens, mixed casing, fence
|
||||
// runs of any length ≥ 3, etc.) but those are fallback only and MUST NOT
|
||||
// be relied on.
|
||||
//
|
||||
// Each cell is a fenced code block opened and closed by exactly three
|
||||
// (or exactly five) backticks or tildes — five lets callers nest a 3-char
|
||||
// fence inside a cell verbatim. The opening fence carries an optional info
|
||||
// string with up to four parts, IN THIS ORDER:
|
||||
//
|
||||
// lang? id_attr? t_attr? rst_attr?
|
||||
//
|
||||
// where:
|
||||
// lang = "py" | "js" | "ts"
|
||||
// id_attr = id="..." (double-quoted cell id)
|
||||
// t_attr = t=<duration> (bare integer with optional ms/s/m unit)
|
||||
// rst_attr= rst=0|1 (per-language kernel reset for this cell)
|
||||
|
||||
start: cell+
|
||||
|
||||
cell: backtick_cell | tilde_cell
|
||||
|
||||
backtick_cell: BACKTICKS info? LF code_line* BACKTICKS LF
|
||||
tilde_cell: TILDES info? LF code_line* TILDES LF
|
||||
|
||||
info: lang (WS_INLINE id_attr)? (WS_INLINE t_attr)? (WS_INLINE rst_attr)?
|
||||
| id_attr (WS_INLINE t_attr)? (WS_INLINE rst_attr)?
|
||||
| t_attr (WS_INLINE rst_attr)?
|
||||
| rst_attr
|
||||
|
||||
lang: "py" | "js" | "ts"
|
||||
id_attr: "id=" /"[^"\r\n]*"/
|
||||
t_attr: "t=" /\d+(ms|s|m)?/
|
||||
rst_attr: "rst=" /[01]/
|
||||
|
||||
code_line: /[^\r\n]*/ LF
|
||||
|
||||
BACKTICKS: "```" | "`````"
|
||||
TILDES: "~~~" | "~~~~~"
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./backend";
|
||||
export { default as jsBackend } from "./js";
|
||||
export * from "./parse";
|
||||
export { default as pythonBackend } from "./py";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,717 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as util from "node:util";
|
||||
import * as vm from "node:vm";
|
||||
|
||||
import * as Diff from "diff";
|
||||
import type { ToolSession } from "../../tools";
|
||||
import { ToolError } from "../../tools/tool-errors";
|
||||
import { JAVASCRIPT_PRELUDE_SOURCE } from "./prelude";
|
||||
import { callSessionTool, type JsStatusEvent } from "./tool-bridge";
|
||||
|
||||
export type JsDisplayOutput =
|
||||
| { type: "json"; data: unknown }
|
||||
| { type: "image"; data: string; mimeType: string }
|
||||
| { type: "status"; event: JsStatusEvent };
|
||||
|
||||
export interface VmRunState {
|
||||
signal?: AbortSignal;
|
||||
onText?: (chunk: string) => void;
|
||||
onDisplay?: (output: JsDisplayOutput) => void;
|
||||
}
|
||||
|
||||
interface VmHelperOptions {
|
||||
path?: string;
|
||||
hidden?: boolean;
|
||||
maxDepth?: number;
|
||||
ignoreCase?: boolean;
|
||||
literal?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
globPattern?: string;
|
||||
flags?: string;
|
||||
reverse?: boolean;
|
||||
unique?: boolean;
|
||||
count?: boolean;
|
||||
cwd?: string;
|
||||
timeoutMs?: number;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
interface VmContextState {
|
||||
sessionKey: string;
|
||||
cwd: string;
|
||||
sessionId: string;
|
||||
session: ToolSession;
|
||||
context: vm.Context;
|
||||
env: Map<string, string>;
|
||||
timers: Set<NodeJS.Timeout>;
|
||||
intervals: Set<NodeJS.Timeout>;
|
||||
currentRun?: VmRunState;
|
||||
queue: Promise<void>;
|
||||
}
|
||||
|
||||
const vmContexts = new Map<string, VmContextState>();
|
||||
const utf8Encoder = new TextEncoder();
|
||||
|
||||
function getMergedEnv(state: VmContextState): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(Bun.env)) {
|
||||
if (typeof value === "string") {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
for (const [key, value] of state.env) {
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function resolvePath(state: VmContextState, value: string): string {
|
||||
if (value.includes("://")) {
|
||||
throw new ToolError(`Protocol paths are not supported by this helper: ${value}`);
|
||||
}
|
||||
return path.isAbsolute(value) ? path.normalize(value) : path.join(state.cwd, value);
|
||||
}
|
||||
|
||||
async function resolveRegularFile(
|
||||
state: VmContextState,
|
||||
rawPath: string,
|
||||
): Promise<{ filePath: string; file: Bun.BunFile; size: number }> {
|
||||
const filePath = resolvePath(state, rawPath);
|
||||
const file = Bun.file(filePath);
|
||||
const info = await file.stat().catch(() => undefined);
|
||||
if (!info) {
|
||||
throw new ToolError(`File not found: ${filePath}`);
|
||||
}
|
||||
if (info.isDirectory()) {
|
||||
throw new ToolError(`Directory paths are not supported by this helper: ${filePath}`);
|
||||
}
|
||||
return { filePath, file, size: info.size };
|
||||
}
|
||||
|
||||
function getDataSize(data: string | Blob | ArrayBuffer | ArrayBufferView): number {
|
||||
if (typeof data === "string") {
|
||||
return utf8Encoder.encode(data).byteLength;
|
||||
}
|
||||
if (data instanceof Blob) {
|
||||
return data.size;
|
||||
}
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return data.byteLength;
|
||||
}
|
||||
return data.byteLength;
|
||||
}
|
||||
|
||||
function isWriteData(value: unknown): value is string | Blob | ArrayBuffer | ArrayBufferView {
|
||||
return (
|
||||
typeof value === "string" || value instanceof Blob || value instanceof ArrayBuffer || ArrayBuffer.isView(value)
|
||||
);
|
||||
}
|
||||
|
||||
function emitText(state: VmContextState, text: string): void {
|
||||
if (!text) return;
|
||||
state.currentRun?.onText?.(text.endsWith("\n") ? text : `${text}\n`);
|
||||
}
|
||||
|
||||
function emitStatus(state: VmContextState, event: JsStatusEvent): void {
|
||||
state.currentRun?.onDisplay?.({ type: "status", event });
|
||||
}
|
||||
|
||||
function displayValue(state: VmContextState, value: unknown): void {
|
||||
if (value === undefined) return;
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.type === "image" && typeof record.data === "string" && typeof record.mimeType === "string") {
|
||||
state.currentRun?.onDisplay?.({
|
||||
type: "image",
|
||||
data: record.data,
|
||||
mimeType: record.mimeType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
state.currentRun?.onDisplay?.({
|
||||
type: "json",
|
||||
data: structuredClone(value),
|
||||
});
|
||||
return;
|
||||
}
|
||||
emitText(state, String(value));
|
||||
}
|
||||
|
||||
function formatConsoleArgs(args: unknown[]): string {
|
||||
return args
|
||||
.map(arg => (typeof arg === "string" ? arg : util.inspect(arg, { depth: 6, colors: false, breakLength: 120 })))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function createTrackedTimeout(state: VmContextState, repeat: boolean) {
|
||||
return (callback: (...args: unknown[]) => void, delay?: number, ...args: unknown[]) => {
|
||||
const fn = () => callback(...args);
|
||||
const timer = repeat ? setInterval(fn, delay) : setTimeout(fn, delay);
|
||||
if (repeat) {
|
||||
state.intervals.add(timer);
|
||||
} else {
|
||||
state.timers.add(timer);
|
||||
}
|
||||
return timer;
|
||||
};
|
||||
}
|
||||
|
||||
function clearTrackedTimeout(state: VmContextState, repeat: boolean, timer: NodeJS.Timeout | undefined): void {
|
||||
if (!timer) return;
|
||||
if (repeat) {
|
||||
clearInterval(timer);
|
||||
state.intervals.delete(timer);
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
state.timers.delete(timer);
|
||||
}
|
||||
|
||||
async function listFiles(
|
||||
state: VmContextState,
|
||||
pattern: string,
|
||||
searchPath: string,
|
||||
options: VmHelperOptions,
|
||||
): Promise<string[]> {
|
||||
const resolved = resolvePath(state, searchPath);
|
||||
const hasRecursivePattern = pattern.includes("**");
|
||||
const normalizedPattern = hasRecursivePattern ? pattern : `**/${pattern}`;
|
||||
const matches = await Array.fromAsync(
|
||||
new Bun.Glob(normalizedPattern).scan({
|
||||
cwd: resolved,
|
||||
dot: options.hidden ?? false,
|
||||
absolute: true,
|
||||
onlyFiles: false,
|
||||
}),
|
||||
);
|
||||
const limited = matches.slice(0, options.limit ?? 1000).map(match => path.normalize(match));
|
||||
return limited.sort();
|
||||
}
|
||||
|
||||
async function grepFile(
|
||||
filePath: string,
|
||||
pattern: string,
|
||||
options: VmHelperOptions,
|
||||
): Promise<Array<{ line: number; text: string }>> {
|
||||
const content = await Bun.file(filePath).text();
|
||||
const lines = content.split(/\r?\n/);
|
||||
const matcher = options.literal
|
||||
? (line: string) =>
|
||||
options.ignoreCase ? line.toLowerCase().includes(pattern.toLowerCase()) : line.includes(pattern)
|
||||
: (line: string) => new RegExp(pattern, options.flags ?? (options.ignoreCase ? "i" : "")).test(line);
|
||||
const hits: Array<{ line: number; text: string }> = [];
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
if (matcher(lines[index])) {
|
||||
hits.push({ line: index + 1, text: lines[index] });
|
||||
if (hits.length >= (options.limit ?? 200)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
async function createHelpers(state: VmContextState) {
|
||||
return {
|
||||
read: async (rawPath: string, options: VmHelperOptions = {}): Promise<string> => {
|
||||
const { filePath, file, size } = await resolveRegularFile(state, rawPath);
|
||||
let text = await file.text();
|
||||
const offset = typeof options.offset === "number" ? options.offset : 1;
|
||||
const limit = typeof options.limit === "number" ? options.limit : undefined;
|
||||
if (offset > 1 || limit !== undefined) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const start = Math.max(0, offset - 1);
|
||||
const end = limit !== undefined ? start + limit : lines.length;
|
||||
text = lines.slice(start, end).join("\n");
|
||||
}
|
||||
emitStatus(state, { op: "read", path: filePath, bytes: size, chars: text.length });
|
||||
return text;
|
||||
},
|
||||
writeFile: async (rawPath: string, data: unknown): Promise<string> => {
|
||||
if (!isWriteData(data)) {
|
||||
throw new ToolError("write() expects string, Blob, ArrayBuffer, or TypedArray data");
|
||||
}
|
||||
const filePath = resolvePath(state, rawPath);
|
||||
if (typeof data === "string" || data instanceof Blob || data instanceof ArrayBuffer) {
|
||||
await Bun.write(filePath, data);
|
||||
} else {
|
||||
await Bun.write(filePath, new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
||||
}
|
||||
emitStatus(state, { op: "write", path: filePath, bytes: getDataSize(data) });
|
||||
return filePath;
|
||||
},
|
||||
append: async (rawPath: string, content: string): Promise<string> => {
|
||||
const target = resolvePath(state, rawPath);
|
||||
await Bun.write(
|
||||
target,
|
||||
`${await Bun.file(target)
|
||||
.text()
|
||||
.catch(() => "")}${content}`,
|
||||
);
|
||||
emitStatus(state, {
|
||||
op: "append",
|
||||
path: target,
|
||||
chars: content.length,
|
||||
bytes: utf8Encoder.encode(content).byteLength,
|
||||
});
|
||||
return target;
|
||||
},
|
||||
stat: async (
|
||||
rawPath: string,
|
||||
): Promise<{ path: string; size: number; is_file: boolean; is_dir: boolean; mtime: string }> => {
|
||||
const target = resolvePath(state, rawPath);
|
||||
const info = await Bun.file(target).stat();
|
||||
const result = {
|
||||
path: target,
|
||||
size: info.size,
|
||||
is_file: info.isFile(),
|
||||
is_dir: info.isDirectory(),
|
||||
mtime: new Date(info.mtimeMs).toISOString(),
|
||||
};
|
||||
emitStatus(state, { op: "stat", path: target, size: result.size, is_dir: result.is_dir, mtime: result.mtime });
|
||||
return result;
|
||||
},
|
||||
find: async (pattern: string, searchPath = ".", options: VmHelperOptions = {}): Promise<string[]> => {
|
||||
const matches = await listFiles(state, pattern, searchPath, options);
|
||||
emitStatus(state, {
|
||||
op: "find",
|
||||
pattern,
|
||||
path: resolvePath(state, searchPath),
|
||||
count: matches.length,
|
||||
matches: matches.slice(0, 20),
|
||||
});
|
||||
return matches;
|
||||
},
|
||||
glob: async (pattern: string, searchPath = ".", options: VmHelperOptions = {}): Promise<string[]> => {
|
||||
const resolved = resolvePath(state, searchPath);
|
||||
const matches = await Array.fromAsync(
|
||||
new Bun.Glob(pattern).scan({
|
||||
cwd: resolved,
|
||||
dot: options.hidden ?? false,
|
||||
absolute: true,
|
||||
onlyFiles: false,
|
||||
}),
|
||||
);
|
||||
const limited = matches
|
||||
.slice(0, options.limit ?? 1000)
|
||||
.map(match => path.normalize(match))
|
||||
.sort();
|
||||
emitStatus(state, {
|
||||
op: "glob",
|
||||
pattern,
|
||||
path: resolved,
|
||||
count: limited.length,
|
||||
matches: limited.slice(0, 20),
|
||||
});
|
||||
return limited;
|
||||
},
|
||||
grep: async (
|
||||
pattern: string,
|
||||
rawPath: string,
|
||||
options: VmHelperOptions = {},
|
||||
): Promise<Array<{ line: number; text: string }>> => {
|
||||
const filePath = resolvePath(state, rawPath);
|
||||
const hits = await grepFile(filePath, pattern, options);
|
||||
emitStatus(state, { op: "grep", pattern, path: filePath, count: hits.length, hits: hits.slice(0, 10) });
|
||||
return hits;
|
||||
},
|
||||
rgrep: async (
|
||||
pattern: string,
|
||||
searchPath = ".",
|
||||
options: VmHelperOptions = {},
|
||||
): Promise<Array<{ file: string; line: number; text: string }>> => {
|
||||
const files = await listFiles(state, options.globPattern ?? "*", searchPath, {
|
||||
...options,
|
||||
limit: options.limit ?? 100,
|
||||
});
|
||||
const hits: Array<{ file: string; line: number; text: string }> = [];
|
||||
for (const file of files) {
|
||||
const fileStat = await Bun.file(file)
|
||||
.stat()
|
||||
.catch(() => undefined);
|
||||
if (!fileStat || fileStat.isDirectory()) continue;
|
||||
for (const hit of await grepFile(file, pattern, options)) {
|
||||
hits.push({ file, line: hit.line, text: hit.text });
|
||||
if (hits.length >= (options.limit ?? 100)) {
|
||||
emitStatus(state, {
|
||||
op: "rgrep",
|
||||
pattern,
|
||||
path: resolvePath(state, searchPath),
|
||||
count: hits.length,
|
||||
hits: hits.slice(0, 10),
|
||||
});
|
||||
return hits;
|
||||
}
|
||||
}
|
||||
}
|
||||
emitStatus(state, {
|
||||
op: "rgrep",
|
||||
pattern,
|
||||
path: resolvePath(state, searchPath),
|
||||
count: hits.length,
|
||||
hits: hits.slice(0, 10),
|
||||
});
|
||||
return hits;
|
||||
},
|
||||
sortText: (text: string, options: VmHelperOptions = {}): string => {
|
||||
const lines = String(text).split(/\r?\n/);
|
||||
const deduped = options.unique ? Array.from(new Set(lines)) : lines;
|
||||
const sorted = deduped.sort((a, b) => a.localeCompare(b));
|
||||
if (options.reverse) {
|
||||
sorted.reverse();
|
||||
}
|
||||
const result = sorted.join("\n");
|
||||
emitStatus(state, {
|
||||
op: "sort",
|
||||
lines: sorted.length,
|
||||
reverse: options.reverse === true,
|
||||
unique: options.unique === true,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
uniqText: (text: string, options: VmHelperOptions = {}): string | Array<[number, string]> => {
|
||||
const lines = String(text)
|
||||
.split(/\r?\n/)
|
||||
.filter(line => line.length > 0);
|
||||
const groups: Array<[number, string]> = [];
|
||||
for (const line of lines) {
|
||||
const last = groups.at(-1);
|
||||
if (last && last[1] === line) {
|
||||
last[0] += 1;
|
||||
continue;
|
||||
}
|
||||
groups.push([1, line]);
|
||||
}
|
||||
emitStatus(state, { op: "uniq", groups: groups.length, count_mode: options.count === true });
|
||||
if (options.count) {
|
||||
return groups;
|
||||
}
|
||||
return groups.map(([, line]) => line).join("\n");
|
||||
},
|
||||
counter: (items: string | string[], options: VmHelperOptions = {}): Array<[number, string]> => {
|
||||
const values = Array.isArray(items) ? items : String(items).split(/\r?\n/).filter(Boolean);
|
||||
const counts = new Map<string, number>();
|
||||
for (const item of values) {
|
||||
counts.set(item, (counts.get(item) ?? 0) + 1);
|
||||
}
|
||||
const entries = Array.from(counts.entries())
|
||||
.map(([item, count]) => [count, item] as [number, string])
|
||||
.sort((a, b) => (options.reverse === false ? a[0] - b[0] : b[0] - a[0]) || a[1].localeCompare(b[1]));
|
||||
const limited = entries.slice(0, options.limit ?? entries.length);
|
||||
emitStatus(state, { op: "counter", unique: counts.size, total: values.length, top: limited.slice(0, 10) });
|
||||
return limited;
|
||||
},
|
||||
sed: async (
|
||||
rawPath: string,
|
||||
pattern: string,
|
||||
replacement: string,
|
||||
options: VmHelperOptions = {},
|
||||
): Promise<number> => {
|
||||
const filePath = resolvePath(state, rawPath);
|
||||
const content = await Bun.file(filePath).text();
|
||||
const regex = new RegExp(pattern, options.flags ?? "g");
|
||||
const matches = content.match(regex);
|
||||
const updated = content.replace(regex, replacement);
|
||||
await Bun.write(filePath, updated);
|
||||
const count = matches?.length ?? 0;
|
||||
emitStatus(state, { op: "sed", path: filePath, count });
|
||||
return count;
|
||||
},
|
||||
diff: async (rawA: string, rawB: string): Promise<string> => {
|
||||
const fileA = resolvePath(state, rawA);
|
||||
const fileB = resolvePath(state, rawB);
|
||||
const [a, b] = await Promise.all([Bun.file(fileA).text(), Bun.file(fileB).text()]);
|
||||
const result = Diff.createTwoFilesPatch(fileA, fileB, a, b, "", "", { context: 3 });
|
||||
emitStatus(state, {
|
||||
op: "diff",
|
||||
file_a: fileA,
|
||||
file_b: fileB,
|
||||
identical: a === b,
|
||||
preview: result.slice(0, 500),
|
||||
});
|
||||
return result;
|
||||
},
|
||||
tree: async (searchPath = ".", options: VmHelperOptions = {}): Promise<string> => {
|
||||
const root = resolvePath(state, searchPath);
|
||||
const maxDepth = options.maxDepth ?? 3;
|
||||
const showHidden = options.hidden ?? false;
|
||||
const lines: string[] = [`${root}/`];
|
||||
let entryCount = 0;
|
||||
const walk = async (dir: string, prefix: string, depth: number): Promise<void> => {
|
||||
if (depth > maxDepth) return;
|
||||
const entries = (await fs.promises.readdir(dir, { withFileTypes: true }))
|
||||
.filter(entry => showHidden || !entry.name.startsWith("."))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (let index = 0; index < entries.length; index++) {
|
||||
const entry = entries[index];
|
||||
const isLast = index === entries.length - 1;
|
||||
const connector = isLast ? "└── " : "├── ";
|
||||
const suffix = entry.isDirectory() ? "/" : "";
|
||||
lines.push(`${prefix}${connector}${entry.name}${suffix}`);
|
||||
entryCount += 1;
|
||||
if (entry.isDirectory()) {
|
||||
await walk(path.join(dir, entry.name), `${prefix}${isLast ? " " : "│ "}`, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
await walk(root, "", 1);
|
||||
const result = lines.join("\n");
|
||||
emitStatus(state, { op: "tree", path: root, entries: entryCount, preview: result.slice(0, 1000) });
|
||||
return result;
|
||||
},
|
||||
run: async (
|
||||
command: string,
|
||||
options: VmHelperOptions = {},
|
||||
): Promise<{ stdout: string; stderr: string; exit_code: number }> => {
|
||||
const cwd = options.cwd ? resolvePath(state, options.cwd) : state.cwd;
|
||||
const timeoutMs =
|
||||
typeof options.timeoutMs === "number"
|
||||
? options.timeoutMs
|
||||
: typeof options.timeout === "number"
|
||||
? options.timeout * 1000
|
||||
: undefined;
|
||||
const timeoutSignal =
|
||||
typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
|
||||
? AbortSignal.timeout(timeoutMs)
|
||||
: undefined;
|
||||
const signal =
|
||||
state.currentRun?.signal && timeoutSignal
|
||||
? AbortSignal.any([state.currentRun.signal, timeoutSignal])
|
||||
: (state.currentRun?.signal ?? timeoutSignal);
|
||||
const child = Bun.spawn(["bash", "-lc", command], {
|
||||
cwd,
|
||||
env: getMergedEnv(state),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
signal,
|
||||
});
|
||||
const [stdout, stderr, exit_code] = await Promise.all([
|
||||
new Response(child.stdout as ReadableStream<Uint8Array>).text(),
|
||||
new Response(child.stderr as ReadableStream<Uint8Array>).text(),
|
||||
child.exited,
|
||||
]);
|
||||
const output = `${stdout}${stderr}`.slice(0, 500);
|
||||
emitStatus(state, { op: "run", cmd: command.slice(0, 120), code: exit_code, output });
|
||||
return { stdout, stderr, exit_code };
|
||||
},
|
||||
env: (key?: string, value?: string): string | Record<string, string> | undefined => {
|
||||
if (!key) {
|
||||
const env = Object.fromEntries(Object.entries(getMergedEnv(state)).sort(([a], [b]) => a.localeCompare(b)));
|
||||
emitStatus(state, { op: "env", count: Object.keys(env).length, keys: Object.keys(env).slice(0, 20) });
|
||||
return env;
|
||||
}
|
||||
if (value !== undefined) {
|
||||
state.env.set(key, value);
|
||||
emitStatus(state, { op: "env", key, value, action: "set" });
|
||||
return value;
|
||||
}
|
||||
const result = state.env.get(key) ?? Bun.env[key];
|
||||
emitStatus(state, { op: "env", key, value: result, action: "get" });
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createProcessSubset(cwd: string): Record<string, unknown> {
|
||||
return Object.freeze({
|
||||
arch: process.arch,
|
||||
cwd: () => cwd,
|
||||
platform: process.platform,
|
||||
release: Object.freeze({ ...process.release }),
|
||||
version: process.version,
|
||||
versions: Object.freeze({ ...process.versions }),
|
||||
});
|
||||
}
|
||||
|
||||
async function createVmState(
|
||||
sessionKey: string,
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
session: ToolSession,
|
||||
): Promise<VmContextState> {
|
||||
const state: VmContextState = {
|
||||
sessionKey,
|
||||
cwd,
|
||||
sessionId,
|
||||
session,
|
||||
context: {} as vm.Context,
|
||||
env: new Map(),
|
||||
timers: new Set(),
|
||||
intervals: new Set(),
|
||||
queue: Promise.resolve(),
|
||||
};
|
||||
|
||||
const helpers = await createHelpers(state);
|
||||
const contextGlobals: Record<string, unknown> = {
|
||||
__omp_session__: { cwd, sessionId },
|
||||
__omp_helpers__: helpers,
|
||||
__omp_call_tool__: async (name: string, args: unknown) =>
|
||||
callSessionTool(name, args, {
|
||||
session: state.session,
|
||||
signal: state.currentRun?.signal,
|
||||
emitStatus: event => emitStatus(state, event),
|
||||
}),
|
||||
__omp_emit_status__: (op: string, data: Record<string, unknown> = {}) => emitStatus(state, { op, ...data }),
|
||||
__omp_log__: (level: string, ...args: unknown[]) => {
|
||||
const prefix = level === "error" ? "[error] " : level === "warn" ? "[warn] " : "";
|
||||
emitText(state, `${prefix}${formatConsoleArgs(args)}`);
|
||||
},
|
||||
__omp_display__: (value: unknown) => displayValue(state, value),
|
||||
setTimeout: createTrackedTimeout(state, false),
|
||||
setInterval: createTrackedTimeout(state, true),
|
||||
clearTimeout: (timer?: NodeJS.Timeout) => clearTrackedTimeout(state, false, timer),
|
||||
clearInterval: (timer?: NodeJS.Timeout) => clearTrackedTimeout(state, true, timer),
|
||||
queueMicrotask,
|
||||
URL,
|
||||
URLSearchParams,
|
||||
TextEncoder,
|
||||
TextDecoder,
|
||||
AbortController,
|
||||
AbortSignal,
|
||||
structuredClone,
|
||||
crypto,
|
||||
webcrypto: crypto,
|
||||
performance,
|
||||
atob,
|
||||
btoa,
|
||||
Buffer,
|
||||
process: createProcessSubset(cwd),
|
||||
fs,
|
||||
fetch,
|
||||
Blob,
|
||||
File,
|
||||
Headers,
|
||||
Request,
|
||||
Response,
|
||||
globalThis: undefined,
|
||||
};
|
||||
const context = vm.createContext(contextGlobals);
|
||||
context.globalThis = context;
|
||||
state.context = context;
|
||||
vm.runInContext(JAVASCRIPT_PRELUDE_SOURCE, context, { filename: "js-prelude.js" });
|
||||
return state;
|
||||
}
|
||||
|
||||
async function getOrCreateVmState(
|
||||
sessionKey: string,
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
session: ToolSession,
|
||||
): Promise<VmContextState> {
|
||||
const existing = vmContexts.get(sessionKey);
|
||||
if (existing) {
|
||||
existing.cwd = cwd;
|
||||
existing.sessionId = sessionId;
|
||||
existing.session = session;
|
||||
return existing;
|
||||
}
|
||||
const created = await createVmState(sessionKey, sessionId, cwd, session);
|
||||
vmContexts.set(sessionKey, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
async function disposeState(state: VmContextState): Promise<void> {
|
||||
for (const timer of state.timers) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
state.timers.clear();
|
||||
for (const timer of state.intervals) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
state.intervals.clear();
|
||||
state.currentRun = undefined;
|
||||
}
|
||||
|
||||
async function runQueued<T>(state: VmContextState, work: () => Promise<T>): Promise<T> {
|
||||
const previous = state.queue;
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
state.queue = promise;
|
||||
await previous;
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function wrapCode(code: string): { source: string; asyncWrapped: boolean } {
|
||||
const needsAsyncWrapper = /\bawait\b|\breturn\b/.test(code);
|
||||
if (!needsAsyncWrapper) {
|
||||
return { source: code, asyncWrapped: false };
|
||||
}
|
||||
return {
|
||||
source: `(async () => {\n${code}\n})()`,
|
||||
asyncWrapped: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function awaitMaybePromise<T>(value: T | Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (!value || typeof value !== "object" || typeof (value as { then?: unknown }).then !== "function") {
|
||||
return value;
|
||||
}
|
||||
const promised = value as Promise<T>;
|
||||
if (!signal) {
|
||||
return promised;
|
||||
}
|
||||
const { promise, resolve, reject } = Promise.withResolvers<T>();
|
||||
if (signal.aborted) {
|
||||
reject(signal.reason ?? new Error("Execution aborted"));
|
||||
return promise;
|
||||
}
|
||||
const onAbort = () => reject(signal.reason ?? new Error("Execution aborted"));
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
promised.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
||||
return promise;
|
||||
}
|
||||
|
||||
export async function executeInVmContext(options: {
|
||||
sessionKey: string;
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
session: ToolSession;
|
||||
reset?: boolean;
|
||||
code: string;
|
||||
filename: string;
|
||||
timeoutMs?: number;
|
||||
runState: VmRunState;
|
||||
}): Promise<{ value: unknown }> {
|
||||
if (options.reset) {
|
||||
await resetVmContext(options.sessionKey);
|
||||
}
|
||||
const state = await getOrCreateVmState(options.sessionKey, options.sessionId, options.cwd, options.session);
|
||||
return runQueued(state, async () => {
|
||||
state.currentRun = options.runState;
|
||||
try {
|
||||
if (options.runState.signal?.aborted) {
|
||||
throw options.runState.signal.reason ?? new Error("Execution aborted");
|
||||
}
|
||||
const wrapped = wrapCode(options.code);
|
||||
const value = vm.runInContext(wrapped.source, state.context, {
|
||||
filename: options.filename,
|
||||
timeout: options.timeoutMs,
|
||||
});
|
||||
const awaited = await awaitMaybePromise(value, options.runState.signal);
|
||||
displayValue(state, awaited);
|
||||
return { value: awaited };
|
||||
} finally {
|
||||
state.currentRun = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetVmContext(sessionKey: string): Promise<void> {
|
||||
const existing = vmContexts.get(sessionKey);
|
||||
if (!existing) return;
|
||||
vmContexts.delete(sessionKey);
|
||||
await disposeState(existing);
|
||||
}
|
||||
|
||||
export async function disposeAllVmContexts(): Promise<void> {
|
||||
const states = Array.from(vmContexts.values());
|
||||
vmContexts.clear();
|
||||
for (const state of states) {
|
||||
await disposeState(state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { DEFAULT_MAX_BYTES, OutputSink } from "../../session/streaming-output";
|
||||
import type { ToolSession } from "../../tools";
|
||||
import { executeInVmContext, type JsDisplayOutput } from "./context-manager";
|
||||
|
||||
export interface JsExecutorOptions {
|
||||
cwd?: string;
|
||||
timeoutMs?: number;
|
||||
deadlineMs?: number;
|
||||
onChunk?: (chunk: string) => Promise<void> | void;
|
||||
signal?: AbortSignal;
|
||||
sessionId: string;
|
||||
reset?: boolean;
|
||||
sessionFile?: string;
|
||||
artifactPath?: string;
|
||||
artifactId?: string;
|
||||
session: ToolSession;
|
||||
}
|
||||
|
||||
export interface JsResult {
|
||||
output: string;
|
||||
exitCode: number | undefined;
|
||||
cancelled: boolean;
|
||||
truncated: boolean;
|
||||
artifactId?: string;
|
||||
totalLines: number;
|
||||
totalBytes: number;
|
||||
outputLines: number;
|
||||
outputBytes: number;
|
||||
displayOutputs: JsDisplayOutput[];
|
||||
}
|
||||
|
||||
function getExecutionTimeoutMs(options: Pick<JsExecutorOptions, "deadlineMs" | "timeoutMs">): number | undefined {
|
||||
if (options.deadlineMs !== undefined) {
|
||||
return Math.max(1, options.deadlineMs - Date.now());
|
||||
}
|
||||
return options.timeoutMs;
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return (
|
||||
(error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) ||
|
||||
(error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError"))
|
||||
);
|
||||
}
|
||||
|
||||
export async function executeJs(code: string, options: JsExecutorOptions): Promise<JsResult> {
|
||||
const displayOutputs: JsDisplayOutput[] = [];
|
||||
const outputSink = new OutputSink({
|
||||
artifactPath: options.artifactPath,
|
||||
artifactId: options.artifactId,
|
||||
spillThreshold: DEFAULT_MAX_BYTES,
|
||||
onChunk: chunk => options.onChunk?.(chunk),
|
||||
});
|
||||
const timeoutMs = getExecutionTimeoutMs(options);
|
||||
const timeoutSignal =
|
||||
typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
|
||||
? AbortSignal.timeout(timeoutMs)
|
||||
: undefined;
|
||||
const signal =
|
||||
options.signal && timeoutSignal
|
||||
? AbortSignal.any([options.signal, timeoutSignal])
|
||||
: (options.signal ?? timeoutSignal);
|
||||
|
||||
try {
|
||||
await executeInVmContext({
|
||||
sessionKey: options.sessionId,
|
||||
sessionId: options.sessionId,
|
||||
cwd: options.cwd ?? options.session.cwd,
|
||||
session: options.session,
|
||||
reset: options.reset,
|
||||
code,
|
||||
filename: `js-cell-${crypto.randomUUID()}.js`,
|
||||
timeoutMs,
|
||||
runState: {
|
||||
signal,
|
||||
onText: chunk => outputSink.push(chunk),
|
||||
onDisplay: output => {
|
||||
displayOutputs.push(output);
|
||||
},
|
||||
},
|
||||
});
|
||||
const summary = await outputSink.dump();
|
||||
return {
|
||||
output: summary.output,
|
||||
exitCode: 0,
|
||||
cancelled: false,
|
||||
truncated: summary.truncated,
|
||||
artifactId: summary.artifactId,
|
||||
totalLines: summary.totalLines,
|
||||
totalBytes: summary.totalBytes,
|
||||
outputLines: summary.outputLines,
|
||||
outputBytes: summary.outputBytes,
|
||||
displayOutputs,
|
||||
};
|
||||
} catch (error) {
|
||||
if (signal?.aborted || isAbortError(error)) {
|
||||
const timeoutReason = timeoutSignal?.aborted ? "Command timed out" : "";
|
||||
if (timeoutReason) {
|
||||
outputSink.push(timeoutReason);
|
||||
}
|
||||
const summary = await outputSink.dump();
|
||||
return {
|
||||
output: summary.output,
|
||||
exitCode: undefined,
|
||||
cancelled: true,
|
||||
truncated: summary.truncated,
|
||||
artifactId: summary.artifactId,
|
||||
totalLines: summary.totalLines,
|
||||
totalBytes: summary.totalBytes,
|
||||
outputLines: summary.outputLines,
|
||||
outputBytes: summary.outputBytes,
|
||||
displayOutputs,
|
||||
};
|
||||
}
|
||||
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
outputSink.push(message);
|
||||
const summary = await outputSink.dump();
|
||||
return {
|
||||
output: summary.output,
|
||||
exitCode: 1,
|
||||
cancelled: false,
|
||||
truncated: summary.truncated,
|
||||
artifactId: summary.artifactId,
|
||||
totalLines: summary.totalLines,
|
||||
totalBytes: summary.totalBytes,
|
||||
outputLines: summary.outputLines,
|
||||
outputBytes: summary.outputBytes,
|
||||
displayOutputs,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ToolSession } from "../../tools";
|
||||
import type { ExecutorBackend, ExecutorBackendExecOptions, ExecutorBackendResult } from "../backend";
|
||||
import { executeJs } from "./executor";
|
||||
|
||||
const JS_SESSION_PREFIX = "js:";
|
||||
|
||||
function namespaceSessionId(sessionId: string): string {
|
||||
return sessionId.startsWith(JS_SESSION_PREFIX) ? sessionId : `${JS_SESSION_PREFIX}${sessionId}`;
|
||||
}
|
||||
|
||||
export default {
|
||||
id: "js",
|
||||
label: "JavaScript",
|
||||
highlightLang: "javascript",
|
||||
|
||||
async isAvailable(_session: ToolSession): Promise<boolean> {
|
||||
return true;
|
||||
},
|
||||
|
||||
async execute(code: string, opts: ExecutorBackendExecOptions): Promise<ExecutorBackendResult> {
|
||||
const result = await executeJs(code, {
|
||||
cwd: opts.cwd,
|
||||
deadlineMs: opts.deadlineMs,
|
||||
signal: opts.signal,
|
||||
sessionId: namespaceSessionId(opts.sessionId),
|
||||
sessionFile: opts.sessionFile,
|
||||
reset: opts.reset,
|
||||
artifactPath: opts.artifactPath,
|
||||
artifactId: opts.artifactId,
|
||||
onChunk: opts.onChunk,
|
||||
session: opts.session,
|
||||
});
|
||||
return {
|
||||
output: result.output,
|
||||
exitCode: result.exitCode,
|
||||
cancelled: result.cancelled,
|
||||
truncated: result.truncated,
|
||||
artifactId: result.artifactId,
|
||||
totalLines: result.totalLines,
|
||||
totalBytes: result.totalBytes,
|
||||
outputLines: result.outputLines,
|
||||
outputBytes: result.outputBytes,
|
||||
displayOutputs: result.displayOutputs,
|
||||
};
|
||||
},
|
||||
} satisfies ExecutorBackend;
|
||||
@@ -0,0 +1,2 @@
|
||||
import javascriptPrelude from "./prelude.txt" with { type: "text" };
|
||||
export const JAVASCRIPT_PRELUDE_SOURCE = javascriptPrelude;
|
||||
@@ -0,0 +1,84 @@
|
||||
if (!globalThis.__omp_js_prelude_loaded__) {
|
||||
globalThis.__omp_js_prelude_loaded__ = true;
|
||||
|
||||
const toOptions = value => (value && typeof value === "object" && !Array.isArray(value) ? value : {});
|
||||
const callHelper = (name, ...args) => globalThis.__omp_helpers__[name](...args);
|
||||
|
||||
const read = (path, opts = {}) => callHelper("read", path, toOptions(opts));
|
||||
const write = async (path, data) => callHelper("writeFile", path, data);
|
||||
const append = (path, content) => callHelper("append", path, content);
|
||||
const stat = path => callHelper("stat", path);
|
||||
const grep = (pattern, path, opts = {}) => callHelper("grep", pattern, path, toOptions(opts));
|
||||
const rgrep = (pattern, path = ".", opts = {}) => callHelper("rgrep", pattern, path, toOptions(opts));
|
||||
const find = (pattern, path = ".", opts = {}) => callHelper("find", pattern, path, toOptions(opts));
|
||||
const glob = (pattern, path = ".", opts = {}) => callHelper("glob", pattern, path, toOptions(opts));
|
||||
const sort = (text, opts = {}) => callHelper("sortText", text, toOptions(opts));
|
||||
const uniq = (text, opts = {}) => callHelper("uniqText", text, toOptions(opts));
|
||||
const counter = (items, opts = {}) => callHelper("counter", items, toOptions(opts));
|
||||
const sed = (path, pattern, repl, opts = {}) => callHelper("sed", path, pattern, repl, toOptions(opts));
|
||||
const diff = (a, b) => callHelper("diff", a, b);
|
||||
const tree = (path = ".", opts = {}) => callHelper("tree", path, toOptions(opts));
|
||||
const run = (cmd, opts = {}) => callHelper("run", cmd, toOptions(opts));
|
||||
const env = (key, value) => callHelper("env", key, value);
|
||||
|
||||
const tool = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, prop) {
|
||||
if (typeof prop !== "string") return undefined;
|
||||
return async args => globalThis.__omp_call_tool__(prop, args ?? {});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const output = async (...args) => {
|
||||
let opts = {};
|
||||
let ids = args;
|
||||
if (args.length > 0) {
|
||||
const last = args.at(-1);
|
||||
if (last && typeof last === "object" && !Array.isArray(last)) {
|
||||
opts = last;
|
||||
ids = args.slice(0, -1);
|
||||
}
|
||||
}
|
||||
const reads = ids.map(id => tool.read({ path: `agent://${id}`, ...opts }));
|
||||
const values = await Promise.all(reads);
|
||||
return values.length === 1 ? values[0] : values;
|
||||
};
|
||||
|
||||
const display = value => {
|
||||
globalThis.__omp_display__(value);
|
||||
};
|
||||
|
||||
const formatArgs = args => args.map(arg => (typeof arg === "string" ? arg : arg));
|
||||
|
||||
const consoleBridge = {
|
||||
log: (...args) => globalThis.__omp_log__("log", ...formatArgs(args)),
|
||||
info: (...args) => globalThis.__omp_log__("info", ...formatArgs(args)),
|
||||
warn: (...args) => globalThis.__omp_log__("warn", ...formatArgs(args)),
|
||||
error: (...args) => globalThis.__omp_log__("error", ...formatArgs(args)),
|
||||
debug: (...args) => globalThis.__omp_log__("debug", ...formatArgs(args)),
|
||||
};
|
||||
|
||||
globalThis.console = consoleBridge;
|
||||
globalThis.print = consoleBridge.log;
|
||||
globalThis.display = display;
|
||||
globalThis.tool = tool;
|
||||
globalThis.output = output;
|
||||
globalThis.read = read;
|
||||
globalThis.write = write;
|
||||
globalThis.append = append;
|
||||
globalThis.stat = stat;
|
||||
globalThis.grep = grep;
|
||||
globalThis.rgrep = rgrep;
|
||||
globalThis.find = find;
|
||||
globalThis.glob = glob;
|
||||
globalThis.sort = sort;
|
||||
globalThis.uniq = uniq;
|
||||
globalThis.counter = counter;
|
||||
globalThis.sed = sed;
|
||||
globalThis.diff = diff;
|
||||
globalThis.tree = tree;
|
||||
globalThis.run = run;
|
||||
globalThis.env = env;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
||||
import type { ToolSession } from "../../tools";
|
||||
import { ToolError } from "../../tools/tool-errors";
|
||||
|
||||
export interface JsStatusEvent {
|
||||
op: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ToolBridgeOptions {
|
||||
session: ToolSession;
|
||||
signal?: AbortSignal;
|
||||
emitStatus?: (event: JsStatusEvent) => void;
|
||||
}
|
||||
|
||||
type ToolValue =
|
||||
| string
|
||||
| {
|
||||
text: string;
|
||||
details?: unknown;
|
||||
images?: Array<{ mimeType: string; data: string }>;
|
||||
};
|
||||
|
||||
function getTool(session: ToolSession, name: string): AgentTool {
|
||||
const tool = session.getToolByName?.(name);
|
||||
if (!tool) {
|
||||
throw new ToolError(`Unknown tool from js runtime: ${name}`);
|
||||
}
|
||||
return tool;
|
||||
}
|
||||
|
||||
function normalizeArgs(args: unknown): unknown {
|
||||
if (!args || typeof args !== "object" || Array.isArray(args)) {
|
||||
return args;
|
||||
}
|
||||
const record = { ...(args as Record<string, unknown>) };
|
||||
if (record._i === undefined) {
|
||||
record._i = "js prelude";
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function summarizeToolResult(name: string, args: unknown, result: AgentToolResult, text: string): JsStatusEvent {
|
||||
const record = (args && typeof args === "object" ? (args as Record<string, unknown>) : {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const details = (
|
||||
result.details && typeof result.details === "object" ? (result.details as Record<string, unknown>) : {}
|
||||
) as Record<string, unknown>;
|
||||
|
||||
switch (name) {
|
||||
case "read":
|
||||
return { op: "read", path: record.path, chars: text.length, preview: text.slice(0, 500) };
|
||||
case "write":
|
||||
return {
|
||||
op: "write",
|
||||
path: record.path,
|
||||
chars: typeof record.content === "string" ? record.content.length : 0,
|
||||
};
|
||||
case "grep":
|
||||
return {
|
||||
op: "grep",
|
||||
pattern: record.pattern,
|
||||
path: record.path,
|
||||
count: details.matchCount ?? undefined,
|
||||
};
|
||||
case "find":
|
||||
return {
|
||||
op: "find",
|
||||
pattern: record.pattern,
|
||||
count: details.fileCount ?? undefined,
|
||||
matches: Array.isArray(details.files) ? details.files.slice(0, 20) : undefined,
|
||||
};
|
||||
case "bash":
|
||||
return {
|
||||
op: "run",
|
||||
cmd: record.command,
|
||||
code: typeof details.exitCode === "number" ? details.exitCode : undefined,
|
||||
output: text.slice(0, 500),
|
||||
};
|
||||
default:
|
||||
return { op: name, chars: text.length };
|
||||
}
|
||||
}
|
||||
|
||||
export async function callSessionTool(name: string, args: unknown, options: ToolBridgeOptions): Promise<ToolValue> {
|
||||
const tool = getTool(options.session, name);
|
||||
const normalizedArgs = normalizeArgs(args);
|
||||
const toolCallId = `js-${name}-${crypto.randomUUID()}`;
|
||||
try {
|
||||
const result = await tool.execute(toolCallId, normalizedArgs, options.signal);
|
||||
const textBlocks = result.content.filter(
|
||||
(content): content is { type: "text"; text: string } =>
|
||||
content.type === "text" && typeof content.text === "string",
|
||||
);
|
||||
const imageBlocks = result.content.filter(
|
||||
(content): content is { type: "image"; mimeType: string; data: string } =>
|
||||
content.type === "image" && typeof content.mimeType === "string" && typeof content.data === "string",
|
||||
);
|
||||
const text = textBlocks.map(block => block.text).join("");
|
||||
options.emitStatus?.(summarizeToolResult(name, normalizedArgs, result, text));
|
||||
if (result.details === undefined && imageBlocks.length === 0) {
|
||||
return text;
|
||||
}
|
||||
return {
|
||||
text,
|
||||
details: result.details,
|
||||
images:
|
||||
imageBlocks.length > 0
|
||||
? imageBlocks.map(block => ({
|
||||
mimeType: block.mimeType,
|
||||
data: block.data,
|
||||
}))
|
||||
: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
options.emitStatus?.({
|
||||
op: name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import type { EvalLanguage } from "./types";
|
||||
|
||||
export type EvalLanguageOrigin = "default" | "fence";
|
||||
|
||||
export interface ParsedEvalCell {
|
||||
index: number;
|
||||
title?: string;
|
||||
code: string;
|
||||
language: EvalLanguage;
|
||||
languageOrigin: EvalLanguageOrigin;
|
||||
timeoutMs: number;
|
||||
reset: boolean;
|
||||
}
|
||||
|
||||
export interface ParsedEvalInput {
|
||||
cells: ParsedEvalCell[];
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Canonical fenced-language tokens we map onto our two backends. Matched
|
||||
* case-insensitively. Anything else found in a fence info string is treated as
|
||||
* a title fragment rather than a language; this is intentional fallback
|
||||
* behaviour and MUST NOT be advertised in the tool's prompt — the lark grammar
|
||||
* describes the canonical surface we encourage callers to emit.
|
||||
*/
|
||||
const LANGUAGE_ALIASES: Record<string, EvalLanguage> = {
|
||||
py: "python",
|
||||
python: "python",
|
||||
ipy: "python",
|
||||
ipython: "python",
|
||||
js: "js",
|
||||
javascript: "js",
|
||||
ts: "js",
|
||||
typescript: "js",
|
||||
};
|
||||
|
||||
function resolveLanguageAlias(token: string): EvalLanguage | undefined {
|
||||
return LANGUAGE_ALIASES[token.toLowerCase()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an attribute key (from `key=value` in a fence info string) to one of
|
||||
* the three canonical roles. Canonical keys: `id`, `t`, `rst`. Fallback
|
||||
* aliases — accepted but not advertised in the prompt — cover common
|
||||
* synonyms the LLM is likely to reach for instead of the short canonical.
|
||||
*/
|
||||
const ID_KEYS = new Set(["id", "title", "name", "cell", "file", "label"]);
|
||||
const T_KEYS = new Set(["t", "timeout", "duration", "time"]);
|
||||
const RST_KEYS = new Set(["rst", "reset"]);
|
||||
|
||||
function classifyAttrKey(key: string): "id" | "t" | "rst" | null {
|
||||
if (ID_KEYS.has(key)) return "id";
|
||||
if (T_KEYS.has(key)) return "t";
|
||||
if (RST_KEYS.has(key)) return "rst";
|
||||
return null;
|
||||
}
|
||||
|
||||
interface RawBlock {
|
||||
type: "raw";
|
||||
lines: string[];
|
||||
startLine: number;
|
||||
}
|
||||
|
||||
interface FencedBlock {
|
||||
type: "fenced";
|
||||
info: string;
|
||||
codeLines: string[];
|
||||
startLine: number;
|
||||
}
|
||||
|
||||
type Block = RawBlock | FencedBlock;
|
||||
|
||||
interface FenceInfo {
|
||||
language?: EvalLanguage;
|
||||
title?: string;
|
||||
timeoutMs?: number;
|
||||
reset?: boolean;
|
||||
}
|
||||
|
||||
const ATTR_TOKEN_RE = /^([a-zA-Z][\w-]*)=(?:"([^"]*)"|'([^']*)'|(.*))$/;
|
||||
const DURATION_TOKEN_RE = /^\d+(?:ms|s|m)?$/;
|
||||
|
||||
function parseDurationMs(raw: string, lineNumber: number): number {
|
||||
const match = /^(\d+)(ms|s|m)?$/.exec(raw.trim());
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Eval line ${lineNumber}: invalid duration \`${raw}\`; use a number with optional ms, s, or m units.`,
|
||||
);
|
||||
}
|
||||
const value = Number.parseInt(match[1], 10);
|
||||
const unit = match[2] ?? "s";
|
||||
if (unit === "ms") return value;
|
||||
if (unit === "s") return value * 1000;
|
||||
return value * 60_000;
|
||||
}
|
||||
|
||||
function parseBoolean(value: string): boolean | undefined {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") return true;
|
||||
if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function trimOuterBlankLines(lines: string[]): string[] {
|
||||
let start = 0;
|
||||
let end = lines.length;
|
||||
while (start < end && lines[start].trim() === "") start++;
|
||||
while (end > start && lines[end - 1].trim() === "") end--;
|
||||
return lines.slice(start, end);
|
||||
}
|
||||
|
||||
function parseFenceOpener(line: string): { char: "`" | "~"; count: number; info: string } | null {
|
||||
const opener = /^(`{3,}|~{3,})(.*)$/.exec(line);
|
||||
if (!opener) return null;
|
||||
const run = opener[1];
|
||||
return { char: run[0] as "`" | "~", count: run.length, info: opener[2].trim() };
|
||||
}
|
||||
|
||||
function isFenceCloser(line: string, char: "`" | "~", minCount: number): boolean {
|
||||
let count = 0;
|
||||
while (count < line.length && line[count] === char) count++;
|
||||
if (count < minCount) return false;
|
||||
return line.slice(count).trim() === "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize a fence info string while preserving content inside matching
|
||||
* single or double quotes as a single token. The opening and closing quote
|
||||
* characters are kept verbatim so attribute parsing can strip them later.
|
||||
*/
|
||||
function tokenizeInfoString(info: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let i = 0;
|
||||
while (i < info.length) {
|
||||
while (i < info.length && /\s/.test(info[i])) i++;
|
||||
if (i >= info.length) break;
|
||||
let token = "";
|
||||
while (i < info.length && !/\s/.test(info[i])) {
|
||||
const ch = info[i];
|
||||
if (ch === '"' || ch === "'") {
|
||||
token += ch;
|
||||
i++;
|
||||
while (i < info.length && info[i] !== ch) {
|
||||
token += info[i];
|
||||
i++;
|
||||
}
|
||||
if (i < info.length) {
|
||||
token += info[i];
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
token += ch;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
tokens.push(token);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a fence info string into language, title, timeout, and reset flag.
|
||||
*
|
||||
* Layout (positional → kv, all optional):
|
||||
* `<lang>? <duration>? <(title-fragment | key=value)>*`
|
||||
*
|
||||
* Canonical attribute keys (the only ones surfaced in the lark grammar):
|
||||
* - `id` → cell title
|
||||
* - `t` → per-cell timeout
|
||||
* - `rst` → boolean reset for this cell's kernel
|
||||
*
|
||||
* Lenient fallback aliases (NOT advertised in the prompt; we silently accept
|
||||
* them when the LLM reaches for a more familiar key):
|
||||
* - id: title, name, cell, file, label
|
||||
* - t: timeout, duration, time
|
||||
* - rst: reset
|
||||
*
|
||||
* Truly unknown keys are silently dropped. First occurrence wins when a key
|
||||
* is repeated (canonical or alias).
|
||||
*
|
||||
* - First token is consumed as a language alias when it matches one; otherwise
|
||||
* it falls through to the title-fragment branch and the cell inherits the
|
||||
* surrounding language.
|
||||
* - The first remaining duration-shaped token (e.g. `15s`, `500ms`, `2m`,
|
||||
* `30`) becomes the positional timeout. The `t=` attribute always wins.
|
||||
* - Anything else accumulates as positional title fragments joined by spaces.
|
||||
*/
|
||||
function parseFenceInfo(info: string, lineNumber: number): FenceInfo {
|
||||
const tokens = tokenizeInfoString(info.trim());
|
||||
if (tokens.length === 0) return {};
|
||||
|
||||
let language: EvalLanguage | undefined;
|
||||
let positionalDurationMs: number | undefined;
|
||||
const titleParts: string[] = [];
|
||||
let idAttr: string | undefined;
|
||||
let tAttr: string | undefined;
|
||||
let rstAttr: string | undefined;
|
||||
|
||||
for (let idx = 0; idx < tokens.length; idx++) {
|
||||
const token = tokens[idx];
|
||||
const attrMatch = ATTR_TOKEN_RE.exec(token);
|
||||
if (attrMatch) {
|
||||
const key = attrMatch[1].toLowerCase();
|
||||
const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? "";
|
||||
const role = classifyAttrKey(key);
|
||||
if (role === "id" && idAttr === undefined) idAttr = value;
|
||||
else if (role === "t" && tAttr === undefined) tAttr = value;
|
||||
else if (role === "rst" && rstAttr === undefined) rstAttr = value;
|
||||
// unknown / repeated keys silently dropped
|
||||
continue;
|
||||
}
|
||||
if (idx === 0) {
|
||||
const lang = resolveLanguageAlias(token);
|
||||
if (lang) {
|
||||
language = lang;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (positionalDurationMs === undefined && DURATION_TOKEN_RE.test(token)) {
|
||||
positionalDurationMs = parseDurationMs(token, lineNumber);
|
||||
continue;
|
||||
}
|
||||
titleParts.push(token);
|
||||
}
|
||||
|
||||
const explicitTitle = (idAttr ?? "").trim();
|
||||
const positionalTitle = titleParts.join(" ").trim();
|
||||
const title = explicitTitle.length > 0 ? explicitTitle : positionalTitle.length > 0 ? positionalTitle : undefined;
|
||||
|
||||
let timeoutMs: number | undefined;
|
||||
if (tAttr !== undefined) {
|
||||
timeoutMs = parseDurationMs(tAttr, lineNumber);
|
||||
} else if (positionalDurationMs !== undefined) {
|
||||
timeoutMs = positionalDurationMs;
|
||||
}
|
||||
|
||||
let reset: boolean | undefined;
|
||||
if (rstAttr !== undefined) {
|
||||
const parsed = parseBoolean(rstAttr);
|
||||
if (parsed === undefined) {
|
||||
throw new Error(`Eval line ${lineNumber}: invalid rst value \`${rstAttr}\`; use true or false.`);
|
||||
}
|
||||
reset = parsed;
|
||||
}
|
||||
|
||||
return { language, title, timeoutMs, reset };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk normalized lines and split into top-level fenced blocks and raw
|
||||
* (between/around fences) blocks. Unclosed fences are leniently closed at
|
||||
* end-of-input. Raw blocks with only blank lines are dropped.
|
||||
*/
|
||||
function splitIntoBlocks(lines: string[]): Block[] {
|
||||
const blocks: Block[] = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const opener = parseFenceOpener(line);
|
||||
if (opener) {
|
||||
const fenceStart = i + 1; // 1-indexed line number of opener
|
||||
const codeLines: string[] = [];
|
||||
let j = i + 1;
|
||||
let closed = false;
|
||||
while (j < lines.length) {
|
||||
if (isFenceCloser(lines[j], opener.char, opener.count)) {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
codeLines.push(lines[j]);
|
||||
j++;
|
||||
}
|
||||
blocks.push({ type: "fenced", info: opener.info, codeLines, startLine: fenceStart });
|
||||
i = closed ? j + 1 : j;
|
||||
} else {
|
||||
const rawStart = i + 1;
|
||||
const rawLines: string[] = [line];
|
||||
let j = i + 1;
|
||||
while (j < lines.length && !parseFenceOpener(lines[j])) {
|
||||
rawLines.push(lines[j]);
|
||||
j++;
|
||||
}
|
||||
const trimmed = trimOuterBlankLines(rawLines);
|
||||
if (trimmed.length > 0) {
|
||||
blocks.push({ type: "raw", lines: trimmed, startLine: rawStart });
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
interface ExpansionState {
|
||||
language: EvalLanguage;
|
||||
languageOrigin: EvalLanguageOrigin;
|
||||
}
|
||||
|
||||
export function parseEvalInput(input: string): ParsedEvalInput {
|
||||
const normalized = input.replace(/\r\n?/g, "\n");
|
||||
const lines = normalized.split("\n");
|
||||
const blocks = splitIntoBlocks(lines);
|
||||
|
||||
const state: ExpansionState = { language: "python", languageOrigin: "default" };
|
||||
const cells: ParsedEvalCell[] = [];
|
||||
for (const block of blocks) {
|
||||
if (block.type === "raw") {
|
||||
cells.push({
|
||||
index: cells.length,
|
||||
title: undefined,
|
||||
code: block.lines.join("\n"),
|
||||
language: state.language,
|
||||
languageOrigin: state.languageOrigin,
|
||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
reset: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const fence = parseFenceInfo(block.info, block.startLine);
|
||||
const language = fence.language ?? state.language;
|
||||
const languageOrigin: EvalLanguageOrigin = fence.language ? "fence" : state.languageOrigin;
|
||||
cells.push({
|
||||
index: cells.length,
|
||||
title: fence.title,
|
||||
code: block.codeLines.join("\n"),
|
||||
language,
|
||||
languageOrigin,
|
||||
timeoutMs: fence.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
reset: fence.reset ?? false,
|
||||
});
|
||||
state.language = language;
|
||||
state.languageOrigin = languageOrigin;
|
||||
}
|
||||
|
||||
return { cells };
|
||||
}
|
||||
+8
-167
@@ -1,17 +1,13 @@
|
||||
import * as path from "node:path";
|
||||
import { getAgentDir, getProjectDir, isBunTestRuntime, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
||||
import { OutputSink } from "../session/streaming-output";
|
||||
import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
|
||||
import { OutputSink } from "../../session/streaming-output";
|
||||
import { shutdownSharedGateway } from "./gateway-coordinator";
|
||||
import {
|
||||
checkPythonKernelAvailability,
|
||||
type KernelDisplayOutput,
|
||||
type KernelExecuteOptions,
|
||||
type KernelExecuteResult,
|
||||
type PreludeHelper,
|
||||
PythonKernel,
|
||||
} from "./kernel";
|
||||
import { discoverPythonModules } from "./modules";
|
||||
import { PYTHON_PRELUDE } from "./prelude";
|
||||
|
||||
const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const MAX_KERNEL_SESSIONS = 4;
|
||||
@@ -101,7 +97,6 @@ interface KernelSession {
|
||||
|
||||
const kernelSessions = new Map<string, KernelSession>();
|
||||
const disposingKernelSessions = new Set<KernelSession>();
|
||||
let cachedPreludeDocs: PreludeHelper[] | null = null;
|
||||
let cleanupTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
interface KernelSessionExecutionOptions {
|
||||
@@ -256,110 +251,6 @@ function buildKernelStartOptions(
|
||||
};
|
||||
}
|
||||
|
||||
interface PreludeCacheSource {
|
||||
path: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
interface PreludeCachePayload {
|
||||
helpers: PreludeHelper[];
|
||||
sources: PreludeCacheSource[];
|
||||
}
|
||||
|
||||
interface PreludeCacheState {
|
||||
cacheKey: string;
|
||||
cachePath: string;
|
||||
sources: PreludeCacheSource[];
|
||||
}
|
||||
|
||||
const PRELUDE_CACHE_DIR = "pycache";
|
||||
|
||||
function hashPreludeContent(content: string): string {
|
||||
return Bun.hash(content).toString(16);
|
||||
}
|
||||
|
||||
async function buildPreludeCacheState(cwd: string): Promise<PreludeCacheState> {
|
||||
const modules = await discoverPythonModules({ cwd });
|
||||
const moduleSources = modules
|
||||
.map(module => ({ path: module.path, hash: hashPreludeContent(module.content) }))
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
const sources: PreludeCacheSource[] = [
|
||||
{ path: "omp:prelude", hash: hashPreludeContent(PYTHON_PRELUDE) },
|
||||
...moduleSources,
|
||||
];
|
||||
const composite = sources.map(source => `${source.path}:${source.hash}`).join("|");
|
||||
const cacheKey = Bun.hash(composite).toString(16);
|
||||
const cachePath = path.join(getAgentDir(), PRELUDE_CACHE_DIR, `${cacheKey}.json`);
|
||||
return { cacheKey, cachePath, sources };
|
||||
}
|
||||
|
||||
async function readPreludeCache(state: PreludeCacheState): Promise<PreludeHelper[] | null> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await Bun.file(state.cachePath).text();
|
||||
} catch (err) {
|
||||
if (isEnoent(err)) return null;
|
||||
logger.warn("Failed to read Python prelude cache", { path: state.cachePath, error: String(err) });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as PreludeCachePayload | PreludeHelper[];
|
||||
const helpers = Array.isArray(parsed) ? parsed : parsed.helpers;
|
||||
if (!Array.isArray(helpers) || helpers.length === 0) return null;
|
||||
return helpers;
|
||||
} catch (err) {
|
||||
logger.warn("Failed to parse Python prelude cache", { path: state.cachePath, error: String(err) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writePreludeCache(state: PreludeCacheState, helpers: PreludeHelper[]): Promise<void> {
|
||||
const payload: PreludeCachePayload = { helpers, sources: state.sources };
|
||||
try {
|
||||
await Bun.write(state.cachePath, JSON.stringify(payload));
|
||||
} catch (err) {
|
||||
logger.warn("Failed to write Python prelude cache", { path: state.cachePath, error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
function getPreludeIntrospectionOptions(
|
||||
options: KernelSessionExecutionOptions = {},
|
||||
): Pick<KernelExecuteOptions, "signal" | "timeoutMs"> {
|
||||
return {
|
||||
signal: options.signal,
|
||||
timeoutMs: requireRemainingTimeoutMs(options.deadlineMs),
|
||||
};
|
||||
}
|
||||
|
||||
async function cachePreludeDocs(
|
||||
cwd: string,
|
||||
docs: PreludeHelper[],
|
||||
cacheState?: PreludeCacheState | null,
|
||||
): Promise<PreludeHelper[]> {
|
||||
cachedPreludeDocs = docs;
|
||||
if (!isBunTestRuntime() && docs.length > 0) {
|
||||
const state = cacheState ?? (await buildPreludeCacheState(cwd));
|
||||
await writePreludeCache(state, docs);
|
||||
}
|
||||
return docs;
|
||||
}
|
||||
|
||||
async function ensurePreludeDocsLoaded(
|
||||
kernel: PythonKernel,
|
||||
cwd: string,
|
||||
options: KernelSessionExecutionOptions = {},
|
||||
cacheState?: PreludeCacheState | null,
|
||||
): Promise<PreludeHelper[]> {
|
||||
if (cachedPreludeDocs && cachedPreludeDocs.length > 0) {
|
||||
return cachedPreludeDocs;
|
||||
}
|
||||
const docs = await kernel.introspectPrelude(getPreludeIntrospectionOptions(options));
|
||||
if (docs.length === 0) {
|
||||
throw new Error("Python prelude helpers unavailable");
|
||||
}
|
||||
return cachePreludeDocs(cwd, docs, cacheState);
|
||||
}
|
||||
|
||||
function startCleanupTimer(): void {
|
||||
if (cleanupTimer) return;
|
||||
cleanupTimer = setInterval(() => {
|
||||
@@ -565,70 +456,20 @@ async function ensureKernelAvailable(
|
||||
export async function warmPythonEnvironment(
|
||||
cwd: string,
|
||||
sessionId?: string,
|
||||
useSharedGateway?: boolean,
|
||||
sessionFile?: string,
|
||||
_useSharedGateway?: boolean,
|
||||
_sessionFile?: string,
|
||||
kernelOwnerId?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ ok: boolean; reason?: string; docs: PreludeHelper[] }> {
|
||||
let cacheState: PreludeCacheState | null = null;
|
||||
): Promise<{ ok: boolean; reason?: string }> {
|
||||
const resolvedSessionId = sessionId ?? `session:${cwd}`;
|
||||
try {
|
||||
await logger.time("warmPython:ensureKernelAvailable", ensureKernelAvailable, cwd, { signal });
|
||||
} catch (err: unknown) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
cachedPreludeDocs = [];
|
||||
return { ok: false, reason, docs: [] };
|
||||
return { ok: false, reason };
|
||||
}
|
||||
if (!isBunTestRuntime()) {
|
||||
try {
|
||||
cacheState = await buildPreludeCacheState(cwd);
|
||||
const cached = await readPreludeCache(cacheState);
|
||||
if (cached) {
|
||||
cachedPreludeDocs = cached;
|
||||
attachKernelOwner(resolvedSessionId, kernelOwnerId);
|
||||
return { ok: true, docs: cached };
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("Failed to resolve Python prelude cache", { error: String(err) });
|
||||
cacheState = null;
|
||||
}
|
||||
}
|
||||
if (cachedPreludeDocs && cachedPreludeDocs.length > 0) {
|
||||
attachKernelOwner(resolvedSessionId, kernelOwnerId);
|
||||
return { ok: true, docs: cachedPreludeDocs };
|
||||
}
|
||||
try {
|
||||
const docs = await logger.time(
|
||||
"warmPython:withKernelSession",
|
||||
withKernelSession,
|
||||
resolvedSessionId,
|
||||
cwd,
|
||||
kernel => ensurePreludeDocsLoaded(kernel, cwd, { useSharedGateway, sessionFile, signal }, cacheState),
|
||||
{
|
||||
useSharedGateway,
|
||||
sessionFile,
|
||||
kernelOwnerId,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
return { ok: true, docs };
|
||||
} catch (err: unknown) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
cachedPreludeDocs = [];
|
||||
return { ok: false, reason, docs: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function getPreludeDocs(): PreludeHelper[] {
|
||||
return cachedPreludeDocs ?? [];
|
||||
}
|
||||
|
||||
export function setPreludeDocsCache(docs: PreludeHelper[]): void {
|
||||
cachedPreludeDocs = docs;
|
||||
}
|
||||
|
||||
export function resetPreludeDocsCache(): void {
|
||||
cachedPreludeDocs = null;
|
||||
attachKernelOwner(resolvedSessionId, kernelOwnerId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function isResourceExhaustionError(error: unknown): boolean {
|
||||
+2
-2
@@ -4,8 +4,8 @@ import * as path from "node:path";
|
||||
import { Process } from "@oh-my-pi/pi-natives";
|
||||
import { getAgentDir, isEnoent, logger, procmgr } from "@oh-my-pi/pi-utils";
|
||||
import type { Subprocess } from "bun";
|
||||
import { Settings } from "../config/settings";
|
||||
import { getOrCreateSnapshot } from "../utils/shell-snapshot";
|
||||
import { Settings } from "../../config/settings";
|
||||
import { getOrCreateSnapshot } from "../../utils/shell-snapshot";
|
||||
import { filterEnv, resolvePythonRuntime } from "./runtime";
|
||||
|
||||
const GATEWAY_DIR_NAME = "python-gateway";
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ToolSession } from "../../tools";
|
||||
import type {
|
||||
ExecutorBackend,
|
||||
ExecutorBackendCallOptions,
|
||||
ExecutorBackendExecOptions,
|
||||
ExecutorBackendResult,
|
||||
} from "../backend";
|
||||
import { executePython, type PythonExecutorOptions, warmPythonEnvironment } from "./executor";
|
||||
import { checkPythonKernelAvailability } from "./kernel";
|
||||
|
||||
const PYTHON_SESSION_PREFIX = "python:";
|
||||
|
||||
function namespaceSessionId(sessionId: string): string {
|
||||
return sessionId.startsWith(PYTHON_SESSION_PREFIX) ? sessionId : `${PYTHON_SESSION_PREFIX}${sessionId}`;
|
||||
}
|
||||
|
||||
function readSetting<T>(session: ToolSession, key: string): T | undefined {
|
||||
const settings = session.settings as { get?: (key: string) => T | undefined } | undefined;
|
||||
return settings?.get?.(key);
|
||||
}
|
||||
|
||||
export default {
|
||||
id: "python",
|
||||
label: "Python",
|
||||
highlightLang: "python",
|
||||
|
||||
async isAvailable(session: ToolSession): Promise<boolean> {
|
||||
const availability = await checkPythonKernelAvailability(session.cwd);
|
||||
return availability.ok;
|
||||
},
|
||||
|
||||
async warm(opts: ExecutorBackendCallOptions): Promise<{ ok: boolean; reason?: string }> {
|
||||
const useSharedGateway = readSetting<boolean>(opts.session, "python.sharedGateway");
|
||||
const result = await warmPythonEnvironment(
|
||||
opts.cwd,
|
||||
namespaceSessionId(opts.sessionId),
|
||||
useSharedGateway,
|
||||
opts.sessionFile,
|
||||
opts.kernelOwnerId,
|
||||
opts.signal,
|
||||
);
|
||||
return { ok: result.ok, reason: result.reason };
|
||||
},
|
||||
|
||||
async execute(code: string, opts: ExecutorBackendExecOptions): Promise<ExecutorBackendResult> {
|
||||
const useSharedGateway = readSetting<boolean>(opts.session, "python.sharedGateway");
|
||||
const kernelMode = readSetting<PythonExecutorOptions["kernelMode"]>(opts.session, "python.kernelMode");
|
||||
const executorOptions: PythonExecutorOptions = {
|
||||
cwd: opts.cwd,
|
||||
deadlineMs: opts.deadlineMs,
|
||||
signal: opts.signal,
|
||||
sessionId: namespaceSessionId(opts.sessionId),
|
||||
kernelMode,
|
||||
useSharedGateway,
|
||||
sessionFile: opts.sessionFile,
|
||||
kernelOwnerId: opts.kernelOwnerId,
|
||||
reset: opts.reset,
|
||||
artifactPath: opts.artifactPath,
|
||||
artifactId: opts.artifactId,
|
||||
onChunk: opts.onChunk,
|
||||
};
|
||||
const result = await executePython(code, executorOptions);
|
||||
return {
|
||||
output: result.output,
|
||||
exitCode: result.exitCode,
|
||||
cancelled: result.cancelled,
|
||||
truncated: result.truncated,
|
||||
artifactId: result.artifactId,
|
||||
totalLines: result.totalLines,
|
||||
totalBytes: result.totalBytes,
|
||||
outputLines: result.outputLines,
|
||||
outputBytes: result.outputBytes,
|
||||
displayOutputs: result.displayOutputs,
|
||||
};
|
||||
},
|
||||
} satisfies ExecutorBackend;
|
||||
+5
-41
@@ -1,17 +1,16 @@
|
||||
import { $env, $flag, isBunTestRuntime, logger, Snowflake } from "@oh-my-pi/pi-utils";
|
||||
import { $ } from "bun";
|
||||
import { Settings } from "../config/settings";
|
||||
import { htmlToBasicMarkdown } from "../web/scrapers/types";
|
||||
import { Settings } from "../../config/settings";
|
||||
import { htmlToBasicMarkdown } from "../../web/scrapers/types";
|
||||
import { createCancellationError, getAbortReason, getExecutionCancellationError } from "./cancellation";
|
||||
import { acquireSharedGateway, releaseSharedGateway, shutdownSharedGateway } from "./gateway-coordinator";
|
||||
import { loadPythonModules } from "./modules";
|
||||
|
||||
import { PYTHON_PRELUDE } from "./prelude";
|
||||
import { filterEnv, resolvePythonRuntime } from "./runtime";
|
||||
|
||||
const TEXT_ENCODER = new TextEncoder();
|
||||
const TEXT_DECODER = new TextDecoder();
|
||||
const TRACE_IPC = $flag("PI_PYTHON_IPC_TRACE");
|
||||
const PRELUDE_INTROSPECTION_SNIPPET = "import json\nprint(json.dumps(__omp_prelude_docs__()))";
|
||||
|
||||
class SharedGatewayCreateError extends Error {
|
||||
constructor(
|
||||
@@ -179,13 +178,6 @@ export interface KernelExecuteResult {
|
||||
stdinRequested: boolean;
|
||||
}
|
||||
|
||||
export interface PreludeHelper {
|
||||
name: string;
|
||||
signature: string;
|
||||
docstring: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface KernelStartOptions extends KernelLifecycleOptions {
|
||||
cwd: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
@@ -536,7 +528,7 @@ export class PythonKernel {
|
||||
preludeOptions.signal,
|
||||
"Failed to initialize Python kernel prelude",
|
||||
);
|
||||
await loadPythonModules(kernel, { cwd, signal: startup.signal, deadlineMs: startup.deadlineMs });
|
||||
|
||||
return kernel;
|
||||
} catch (err: unknown) {
|
||||
await kernel.shutdown({ timeoutMs: getStartupCleanupTimeoutMs(startup.deadlineMs) });
|
||||
@@ -607,11 +599,7 @@ export class PythonKernel {
|
||||
preludeOptions.signal,
|
||||
"Failed to initialize Python kernel prelude",
|
||||
);
|
||||
await logger.time("startWithSharedGateway:loadModules", loadPythonModules, kernel, {
|
||||
cwd,
|
||||
signal: startup.signal,
|
||||
deadlineMs: startup.deadlineMs,
|
||||
});
|
||||
|
||||
return kernel;
|
||||
} catch (err: unknown) {
|
||||
await kernel.shutdown({ timeoutMs: getStartupCleanupTimeoutMs(startup.deadlineMs) });
|
||||
@@ -953,30 +941,6 @@ export class PythonKernel {
|
||||
return promise;
|
||||
}
|
||||
|
||||
async introspectPrelude(options: Pick<KernelExecuteOptions, "signal" | "timeoutMs"> = {}): Promise<PreludeHelper[]> {
|
||||
let output = "";
|
||||
const result = await this.execute(PRELUDE_INTROSPECTION_SNIPPET, {
|
||||
silent: false,
|
||||
storeHistory: false,
|
||||
signal: options.signal,
|
||||
timeoutMs: options.timeoutMs,
|
||||
onChunk: text => {
|
||||
output += text;
|
||||
},
|
||||
});
|
||||
if (result.cancelled || result.status === "error") {
|
||||
throw new Error("Failed to introspect Python prelude");
|
||||
}
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) return [];
|
||||
try {
|
||||
return JSON.parse(trimmed) as PreludeHelper[];
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to parse Python prelude docs: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {
|
||||
try {
|
||||
await fetch(`${this.gatewayUrl}/api/kernels/${this.kernelId}/interrupt`, {
|
||||
+39
-227
@@ -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"]))
|
||||
@@ -0,0 +1,48 @@
|
||||
/** Runtime backend that an eval cell dispatches to. */
|
||||
export type EvalLanguage = "python" | "js";
|
||||
|
||||
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
||||
import type { OutputMeta } from "../tools/output-meta";
|
||||
|
||||
/** Status event emitted by prelude helpers (python or js) for TUI rendering. */
|
||||
export interface EvalStatusEvent {
|
||||
op: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Display output captured during eval execution. Union of python and js shapes. */
|
||||
export type EvalDisplayOutput =
|
||||
| { type: "json"; data: unknown }
|
||||
| { type: "image"; data: string; mimeType: string }
|
||||
| { type: "markdown"; text?: string }
|
||||
| { type: "status"; event: EvalStatusEvent };
|
||||
|
||||
/** Per-cell execution result for transcript rendering. */
|
||||
export interface EvalCellResult {
|
||||
index: number;
|
||||
title?: string;
|
||||
code: string;
|
||||
language?: EvalLanguage;
|
||||
output: string;
|
||||
status: "pending" | "running" | "complete" | "error";
|
||||
durationMs?: number;
|
||||
exitCode?: number;
|
||||
statusEvents?: EvalStatusEvent[];
|
||||
hasMarkdown?: boolean;
|
||||
}
|
||||
|
||||
/** Tool result detail object surfaced to the UI/transcript. */
|
||||
export interface EvalToolDetails {
|
||||
cells?: EvalCellResult[];
|
||||
jsonOutputs?: unknown[];
|
||||
images?: ImageContent[];
|
||||
statusEvents?: EvalStatusEvent[];
|
||||
isError?: boolean;
|
||||
meta?: OutputMeta;
|
||||
/** First backend that produced cells. Kept for transcript compatibility. */
|
||||
language?: EvalLanguage;
|
||||
/** Backends that produced cells in this call, in first-use order. */
|
||||
languages?: EvalLanguage[];
|
||||
/** Optional human-readable notice (e.g. fallback explanation). */
|
||||
notice?: string;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -779,23 +779,21 @@
|
||||
}
|
||||
|
||||
function renderJsLike(name, args, result, ctx) {
|
||||
const lang = name === 'python' ? 'python' : 'javascript';
|
||||
const badges = [];
|
||||
if (args.cwd) badges.push('cwd=' + shortenPath(String(args.cwd)));
|
||||
if (args.timeout) badges.push('timeout=' + args.timeout + 's');
|
||||
if (args.reset) badges.push('reset');
|
||||
let html = toolHead(name, '', badges);
|
||||
const cells = Array.isArray(args.cells) ? args.cells : null;
|
||||
if (!cells) {
|
||||
html += '<div class="tool-error">[missing cells]</div>';
|
||||
} else {
|
||||
let html = toolHead(name, '');
|
||||
const cells = result && result.details && Array.isArray(result.details.cells) ? result.details.cells : null;
|
||||
if (cells) {
|
||||
for (const cell of cells) {
|
||||
html += '<div class="tool-cell">';
|
||||
if (cell && cell.title) html += '<div class="tool-cell-title">' + escapeHtml(String(cell.title)) + '</div>';
|
||||
const code = cell && typeof cell.code === 'string' ? cell.code : '';
|
||||
const lang = cell && cell.language === 'js' ? 'javascript' : 'python';
|
||||
html += codeBlock(code, lang);
|
||||
html += '</div>';
|
||||
}
|
||||
} else if (typeof args.input === 'string') {
|
||||
html += codeBlock(args.input, null);
|
||||
} else {
|
||||
html += '<div class="tool-error">[missing input]</div>';
|
||||
}
|
||||
if (result) {
|
||||
html += ctx.renderResultImages();
|
||||
|
||||
@@ -28,9 +28,9 @@ import type { Rule } from "../../capability/rule";
|
||||
import type { KeybindingsManager } from "../../config/keybindings";
|
||||
import type { ModelRegistry } from "../../config/model-registry";
|
||||
import type { EditToolDetails } from "../../edit";
|
||||
import type { PythonResult } from "../../eval/py/executor";
|
||||
import type { BashResult } from "../../exec/bash-executor";
|
||||
import type { ExecOptions, ExecResult } from "../../exec/exec";
|
||||
import type { PythonResult } from "../../ipy/executor";
|
||||
import type { Theme } from "../../modes/theme/theme";
|
||||
import type { CompactionPreparation, CompactionResult } from "../../session/compaction";
|
||||
import type { CustomMessage } from "../../session/messages";
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { getAgentModulesDir, getProjectDir, getProjectModulesDir } from "@oh-my-pi/pi-utils";
|
||||
import { getExecutionCancellationError } from "./cancellation";
|
||||
|
||||
export type PythonModuleSource = "user" | "project";
|
||||
|
||||
export interface PythonModuleEntry {
|
||||
path: string;
|
||||
content: string;
|
||||
source: PythonModuleSource;
|
||||
}
|
||||
|
||||
export interface PythonModuleExecuteResult {
|
||||
status: "ok" | "error";
|
||||
cancelled: boolean;
|
||||
timedOut?: boolean;
|
||||
error?: { name: string; value: string; traceback: string[] };
|
||||
}
|
||||
|
||||
export interface PythonModuleExecutor {
|
||||
execute: (
|
||||
code: string,
|
||||
options?: { signal?: AbortSignal; timeoutMs?: number; silent?: boolean; storeHistory?: boolean },
|
||||
) => Promise<PythonModuleExecuteResult>;
|
||||
}
|
||||
|
||||
export interface DiscoverPythonModulesOptions {
|
||||
/** Working directory for project-level modules. Default: getProjectDir() */
|
||||
cwd?: string;
|
||||
/** Agent directory for user-level modules. Default: from getAgentDir() */
|
||||
agentDir?: string;
|
||||
}
|
||||
|
||||
export interface LoadPythonModulesOptions extends DiscoverPythonModulesOptions {
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
deadlineMs?: number;
|
||||
}
|
||||
|
||||
interface ModuleCandidate {
|
||||
name: string;
|
||||
path: string;
|
||||
source: PythonModuleSource;
|
||||
}
|
||||
|
||||
async function listModuleCandidates(dir: string, source: PythonModuleSource): Promise<ModuleCandidate[]> {
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
return entries
|
||||
.filter(entry => entry.isFile() && entry.name.endsWith(".py"))
|
||||
.map(entry => ({
|
||||
name: entry.name,
|
||||
path: path.resolve(dir, entry.name),
|
||||
source,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function readModuleContent(candidate: ModuleCandidate): Promise<PythonModuleEntry> {
|
||||
try {
|
||||
const content = await Bun.file(candidate.path).text();
|
||||
return { path: candidate.path, content, source: candidate.source };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to read Python module ${candidate.path}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function createTimeoutError(message: string): Error {
|
||||
const error = new Error(message);
|
||||
error.name = "TimeoutError";
|
||||
return error;
|
||||
}
|
||||
|
||||
function requireModuleExecutionTimeoutMs(options: LoadPythonModulesOptions): number | undefined {
|
||||
if (options.deadlineMs === undefined) {
|
||||
return options.timeoutMs;
|
||||
}
|
||||
|
||||
const remainingMs = options.deadlineMs - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
throw createTimeoutError("Python module loading timed out");
|
||||
}
|
||||
|
||||
return remainingMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover Python prelude extension modules from user and project directories.
|
||||
*/
|
||||
export async function discoverPythonModules(options: DiscoverPythonModulesOptions = {}): Promise<PythonModuleEntry[]> {
|
||||
const cwd = options.cwd ?? getProjectDir();
|
||||
|
||||
const userDir = getAgentModulesDir(options.agentDir);
|
||||
const projectDir = getProjectModulesDir(cwd);
|
||||
|
||||
const userCandidates = await listModuleCandidates(userDir, "user");
|
||||
const projectCandidates = await listModuleCandidates(projectDir, "project");
|
||||
|
||||
const byName = new Map<string, ModuleCandidate>();
|
||||
for (const candidate of userCandidates) {
|
||||
if (!byName.has(candidate.name)) {
|
||||
byName.set(candidate.name, candidate);
|
||||
}
|
||||
}
|
||||
for (const candidate of projectCandidates) {
|
||||
const existing = byName.get(candidate.name);
|
||||
if (!existing || existing.source === "user") {
|
||||
byName.set(candidate.name, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
return Promise.all(sorted.map(candidate => readModuleContent(candidate)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Python prelude extension modules into an active kernel.
|
||||
*/
|
||||
export async function loadPythonModules(
|
||||
executor: PythonModuleExecutor,
|
||||
options: LoadPythonModulesOptions = {},
|
||||
): Promise<PythonModuleEntry[]> {
|
||||
const modules = await discoverPythonModules(options);
|
||||
for (const module of modules) {
|
||||
const result = await executor.execute(module.content, {
|
||||
signal: options.signal,
|
||||
timeoutMs: requireModuleExecutionTimeoutMs(options),
|
||||
silent: true,
|
||||
storeHistory: false,
|
||||
});
|
||||
if (result.cancelled) {
|
||||
throw getExecutionCancellationError(result, options.signal, `Failed to load Python module ${module.path}`);
|
||||
}
|
||||
if (result.status === "error") {
|
||||
const details = result.error ? `${result.error.name}: ${result.error.value}` : "unknown error";
|
||||
throw new Error(`Failed to load Python module ${module.path}: ${details}`);
|
||||
}
|
||||
}
|
||||
return modules;
|
||||
}
|
||||
@@ -544,7 +544,7 @@ function shouldPersistResponseItemForMemories(message: AgentMessage): boolean {
|
||||
}
|
||||
if (role !== "toolResult") return false;
|
||||
const toolName = (message as { toolName?: string }).toolName;
|
||||
if (toolName === "bash" || toolName === "python" || toolName === "read" || toolName === "search") {
|
||||
if (toolName === "bash" || toolName === "eval" || toolName === "read" || toolName === "search") {
|
||||
const text = extractMessageText(message);
|
||||
return text.length > 0 && text.length <= 32_000;
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export function mapToolKind(toolName: string): ToolKind {
|
||||
case "move":
|
||||
return "move";
|
||||
case "bash":
|
||||
case "python":
|
||||
case "eval":
|
||||
return "execute";
|
||||
case "search":
|
||||
case "find":
|
||||
|
||||
+11
-4
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Component for displaying user-initiated Python execution with streaming output.
|
||||
* Shares the same kernel session as the agent's Python tool.
|
||||
* Component for displaying user-initiated eval execution with streaming output.
|
||||
* Shares the same kernel session as the agent's eval tool.
|
||||
*/
|
||||
|
||||
import { sanitizeText } from "@oh-my-pi/pi-natives";
|
||||
@@ -13,7 +13,9 @@ import { truncateToVisualLines } from "./visual-truncate";
|
||||
const PREVIEW_LINES = 20;
|
||||
const MAX_DISPLAY_LINE_CHARS = 4000;
|
||||
|
||||
export class PythonExecutionComponent extends Container {
|
||||
export type EvalExecutionLanguage = "python" | "js";
|
||||
|
||||
export class EvalExecutionComponent extends Container {
|
||||
#outputLines: string[] = [];
|
||||
#status: "running" | "complete" | "cancelled" | "error" = "running";
|
||||
#exitCode: number | undefined = undefined;
|
||||
@@ -22,10 +24,14 @@ export class PythonExecutionComponent extends Container {
|
||||
#expanded = false;
|
||||
#contentContainer: Container;
|
||||
|
||||
#highlightLang(): "python" | "javascript" {
|
||||
return this.language === "js" ? "javascript" : "python";
|
||||
}
|
||||
|
||||
#formatHeader(colorKey: "dim" | "pythonMode"): Text {
|
||||
const prompt = theme.fg(colorKey, theme.bold(">>>"));
|
||||
const continuation = theme.fg(colorKey, " ");
|
||||
const codeLines = highlightCode(this.code, "python");
|
||||
const codeLines = highlightCode(this.code, this.#highlightLang());
|
||||
const headerLines = codeLines.map((line, index) =>
|
||||
index === 0 ? `${prompt} ${line}` : `${continuation}${line}`,
|
||||
);
|
||||
@@ -36,6 +42,7 @@ export class PythonExecutionComponent extends Container {
|
||||
private readonly code: string,
|
||||
ui: TUI,
|
||||
private readonly excludeFromContext = false,
|
||||
private readonly language: EvalExecutionLanguage = "python",
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -18,6 +18,7 @@ import { EDIT_MODE_STRATEGIES, type EditMode, type PerFileDiffPreview } from "..
|
||||
import type { Theme } from "../../modes/theme/theme";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash";
|
||||
import { EVAL_DEFAULT_PREVIEW_LINES } from "../../tools/eval";
|
||||
import {
|
||||
formatArgsInline,
|
||||
JSON_TREE_MAX_DEPTH_COLLAPSED,
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
JSON_TREE_SCALAR_LEN_EXPANDED,
|
||||
renderJsonTreeLines,
|
||||
} from "../../tools/json-tree";
|
||||
import { PYTHON_DEFAULT_PREVIEW_LINES } from "../../tools/python";
|
||||
import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
|
||||
import { toolRenderers } from "../../tools/renderers";
|
||||
import { renderStatusLine } from "../../tui";
|
||||
@@ -668,12 +668,11 @@ export class ToolExecutionComponent extends Container {
|
||||
context.expanded = this.#expanded;
|
||||
context.previewLines = BASH_DEFAULT_PREVIEW_LINES;
|
||||
context.timeout = normalizeTimeoutSeconds(this.#args?.timeout, 3600);
|
||||
} else if (this.#toolName === "python" && this.#result) {
|
||||
} else if (this.#toolName === "eval" && this.#result) {
|
||||
const output = this.#getTextOutput().trimEnd();
|
||||
context.output = output;
|
||||
context.expanded = this.#expanded;
|
||||
context.previewLines = PYTHON_DEFAULT_PREVIEW_LINES;
|
||||
context.timeout = normalizeTimeoutSeconds(this.#args?.timeout, 600);
|
||||
context.previewLines = EVAL_DEFAULT_PREVIEW_LINES;
|
||||
} else if (isEditLikeToolName(this.#toolName)) {
|
||||
context.editMode = this.#editMode;
|
||||
const previews = this.#editDiffPreview;
|
||||
|
||||
@@ -14,14 +14,14 @@ import { formatDuration, Snowflake, setProjectDir } from "@oh-my-pi/pi-utils";
|
||||
import { $ } from "bun";
|
||||
import { reset as resetCapabilities } from "../../capability";
|
||||
import { clearClaudePluginRootsCache } from "../../discovery/helpers";
|
||||
import { getGatewayStatus } from "../../eval/py/gateway-coordinator";
|
||||
import { loadCustomShare } from "../../export/custom-share";
|
||||
import type { CompactOptions } from "../../extensibility/extensions/types";
|
||||
import { getGatewayStatus } from "../../ipy/gateway-coordinator";
|
||||
import { buildMemoryToolDeveloperInstructions, clearMemoryData, enqueueMemoryConsolidation } from "../../memories";
|
||||
import { BashExecutionComponent } from "../../modes/components/bash-execution";
|
||||
import { BorderedLoader } from "../../modes/components/bordered-loader";
|
||||
import { DynamicBorder } from "../../modes/components/dynamic-border";
|
||||
import { PythonExecutionComponent } from "../../modes/components/python-execution";
|
||||
import { EvalExecutionComponent } from "../../modes/components/eval-execution";
|
||||
import { getMarkdownTheme, getSymbolTheme, theme } from "../../modes/theme/theme";
|
||||
import type { InteractiveModeContext } from "../../modes/types";
|
||||
import { computeContextBreakdown, renderContextUsage } from "../../modes/utils/context-usage";
|
||||
@@ -285,9 +285,26 @@ export class CommandController {
|
||||
this.#doCopy(combined, `Copied ${matches.length} code block${matches.length > 1 ? "s" : ""} to clipboard`);
|
||||
}
|
||||
|
||||
#extractEvalCode(args: unknown): string | undefined {
|
||||
if (!args || typeof args !== "object") return undefined;
|
||||
const cells = (args as { cells?: unknown }).cells;
|
||||
if (!Array.isArray(cells)) return undefined;
|
||||
|
||||
const codeBlocks: string[] = [];
|
||||
for (const cell of cells) {
|
||||
if (!cell || typeof cell !== "object") continue;
|
||||
const code = (cell as { code?: unknown }).code;
|
||||
if (typeof code === "string" && code.length > 0) {
|
||||
codeBlocks.push(code);
|
||||
}
|
||||
}
|
||||
|
||||
return codeBlocks.length > 0 ? codeBlocks.join("\n\n") : undefined;
|
||||
}
|
||||
|
||||
#copyLastCommand() {
|
||||
const messages = this.ctx.session.messages;
|
||||
// Walk backwards to find the last bash/python tool call
|
||||
// Walk backwards to find the last bash/eval tool call
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.role !== "assistant") continue;
|
||||
@@ -298,13 +315,16 @@ export class CommandController {
|
||||
this.#doCopy(tc.arguments.command, "Copied last bash command to clipboard");
|
||||
return;
|
||||
}
|
||||
if (tc.name === "python" && typeof tc.arguments.code === "string") {
|
||||
this.#doCopy(tc.arguments.code, "Copied last python code to clipboard");
|
||||
return;
|
||||
if (tc.name === "eval") {
|
||||
const code = this.#extractEvalCode(tc.arguments);
|
||||
if (code) {
|
||||
this.#doCopy(code, "Copied last eval code to clipboard");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.ctx.showWarning("No bash or python command found in the conversation.");
|
||||
this.ctx.showWarning("No bash or eval command found in the conversation.");
|
||||
}
|
||||
|
||||
#doCopy(content: string, label: string) {
|
||||
@@ -779,7 +799,7 @@ export class CommandController {
|
||||
|
||||
async handlePythonCommand(code: string, excludeFromContext = false): Promise<void> {
|
||||
const isDeferred = this.ctx.session.isStreaming;
|
||||
this.ctx.pythonComponent = new PythonExecutionComponent(code, this.ctx.ui, excludeFromContext);
|
||||
this.ctx.pythonComponent = new EvalExecutionComponent(code, this.ctx.ui, excludeFromContext);
|
||||
|
||||
if (isDeferred) {
|
||||
this.ctx.pendingMessagesContainer.addChild(this.ctx.pythonComponent);
|
||||
|
||||
@@ -37,7 +37,7 @@ export class InputController {
|
||||
this.ctx.session.isCompacting ||
|
||||
this.ctx.session.isGeneratingHandoff ||
|
||||
this.ctx.session.isBashRunning ||
|
||||
this.ctx.session.isPythonRunning ||
|
||||
this.ctx.session.isEvalRunning ||
|
||||
this.ctx.autoCompactionLoader ||
|
||||
this.ctx.retryLoader ||
|
||||
this.ctx.autoCompactionEscapeHandler ||
|
||||
@@ -67,8 +67,8 @@ export class InputController {
|
||||
this.ctx.editor.setText("");
|
||||
this.ctx.isBashMode = false;
|
||||
this.ctx.updateEditorBorderColor();
|
||||
} else if (this.ctx.session.isPythonRunning) {
|
||||
this.ctx.session.abortPython();
|
||||
} else if (this.ctx.session.isEvalRunning) {
|
||||
this.ctx.session.abortEval();
|
||||
} else if (this.ctx.isPythonMode) {
|
||||
this.ctx.editor.setText("");
|
||||
this.ctx.isPythonMode = false;
|
||||
@@ -304,7 +304,7 @@ export class InputController {
|
||||
const isExcluded = text.startsWith("$$");
|
||||
const code = isExcluded ? text.slice(2).trim() : text.slice(1).trim();
|
||||
if (code) {
|
||||
if (this.ctx.session.isPythonRunning) {
|
||||
if (this.ctx.session.isEvalRunning) {
|
||||
this.ctx.showWarning("A Python execution is already running. Press Esc to cancel it first.");
|
||||
this.ctx.editor.setText(text);
|
||||
return;
|
||||
|
||||
@@ -57,10 +57,10 @@ import type { AssistantMessageComponent } from "./components/assistant-message";
|
||||
import type { BashExecutionComponent } from "./components/bash-execution";
|
||||
import { CustomEditor } from "./components/custom-editor";
|
||||
import { DynamicBorder } from "./components/dynamic-border";
|
||||
import type { EvalExecutionComponent } from "./components/eval-execution";
|
||||
import type { HookEditorComponent } from "./components/hook-editor";
|
||||
import type { HookInputComponent } from "./components/hook-input";
|
||||
import type { HookSelectorComponent } from "./components/hook-selector";
|
||||
import type { PythonExecutionComponent } from "./components/python-execution";
|
||||
import { StatusLineComponent } from "./components/status-line";
|
||||
import type { ToolExecutionHandle } from "./components/tool-execution";
|
||||
import { WelcomeComponent, type LspServerInfo as WelcomeLspServerInfo } from "./components/welcome";
|
||||
@@ -166,8 +166,8 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
pendingTools = new Map<string, ToolExecutionHandle>();
|
||||
pendingBashComponents: BashExecutionComponent[] = [];
|
||||
bashComponent: BashExecutionComponent | undefined = undefined;
|
||||
pendingPythonComponents: PythonExecutionComponent[] = [];
|
||||
pythonComponent: PythonExecutionComponent | undefined = undefined;
|
||||
pendingPythonComponents: EvalExecutionComponent[] = [];
|
||||
pythonComponent: EvalExecutionComponent | undefined = undefined;
|
||||
isPythonMode = false;
|
||||
streamingComponent: AssistantMessageComponent | undefined = undefined;
|
||||
streamingMessage: AssistantMessage | undefined = undefined;
|
||||
|
||||
@@ -18,10 +18,10 @@ import type { ExitPlanModeDetails, LspStartupServerInfo } from "../tools";
|
||||
import type { AssistantMessageComponent } from "./components/assistant-message";
|
||||
import type { BashExecutionComponent } from "./components/bash-execution";
|
||||
import type { CustomEditor } from "./components/custom-editor";
|
||||
import type { EvalExecutionComponent } from "./components/eval-execution";
|
||||
import type { HookEditorComponent } from "./components/hook-editor";
|
||||
import type { HookInputComponent } from "./components/hook-input";
|
||||
import type { HookSelectorComponent } from "./components/hook-selector";
|
||||
import type { PythonExecutionComponent } from "./components/python-execution";
|
||||
import type { StatusLineComponent } from "./components/status-line";
|
||||
import type { ToolExecutionHandle } from "./components/tool-execution";
|
||||
import type { OAuthManualInputManager } from "./oauth-manual-input";
|
||||
@@ -93,8 +93,8 @@ export interface InteractiveModeContext {
|
||||
pendingTools: Map<string, ToolExecutionHandle>;
|
||||
pendingBashComponents: BashExecutionComponent[];
|
||||
bashComponent: BashExecutionComponent | undefined;
|
||||
pendingPythonComponents: PythonExecutionComponent[];
|
||||
pythonComponent: PythonExecutionComponent | undefined;
|
||||
pendingPythonComponents: EvalExecutionComponent[];
|
||||
pythonComponent: EvalExecutionComponent | undefined;
|
||||
isPythonMode: boolean;
|
||||
streamingComponent: AssistantMessageComponent | undefined;
|
||||
streamingMessage: AssistantMessage | undefined;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { BranchSummaryMessageComponent } from "../../modes/components/branch-sum
|
||||
import { CompactionSummaryMessageComponent } from "../../modes/components/compaction-summary-message";
|
||||
import { CustomMessageComponent } from "../../modes/components/custom-message";
|
||||
import { DynamicBorder } from "../../modes/components/dynamic-border";
|
||||
import { PythonExecutionComponent } from "../../modes/components/python-execution";
|
||||
import { EvalExecutionComponent } from "../../modes/components/eval-execution";
|
||||
import { ReadToolGroupComponent } from "../../modes/components/read-tool-group";
|
||||
import { SkillMessageComponent } from "../../modes/components/skill-message";
|
||||
import { ToolExecutionComponent } from "../../modes/components/tool-execution";
|
||||
@@ -84,7 +84,7 @@ export class UiHelpers {
|
||||
break;
|
||||
}
|
||||
case "pythonExecution": {
|
||||
const component = new PythonExecutionComponent(message.code, this.ctx.ui, message.excludeFromContext);
|
||||
const component = new EvalExecutionComponent(message.code, this.ctx.ui, message.excludeFromContext);
|
||||
if (message.output) {
|
||||
component.appendOutput(message.output);
|
||||
}
|
||||
|
||||
@@ -216,12 +216,12 @@ Most tools have a `{{intentField}}` parameter. Fill it with a concise intent in
|
||||
If the task may involve external systems, SaaS APIs, chat, tickets, databases, deployments, or other non-local integrations, you **SHOULD** call `{{toolRefs.search_tool_bm25}}` before concluding no such tool exists.
|
||||
{{/if}}
|
||||
|
||||
{{#ifAny (includes tools "python") (includes tools "bash")}}
|
||||
{{#ifAny (includes tools "eval") (includes tools "bash")}}
|
||||
### Tool priority
|
||||
1. Use specialized tools first{{#ifAny (includes tools "read") (includes tools "search") (includes tools "find") (includes tools "edit") (includes tools "lsp")}}: {{#has tools "read"}}`{{toolRefs.read}}`, {{/has}}{{#has tools "search"}}`{{toolRefs.search}}`, {{/has}}{{#has tools "find"}}`{{toolRefs.find}}`, {{/has}}{{#has tools "edit"}}`{{toolRefs.edit}}`, {{/has}}{{#has tools "lsp"}}`{{toolRefs.lsp}}`{{/has}}{{/ifAny}}
|
||||
2. Python: logic, loops, processing, display
|
||||
2. Eval: logic, loops, processing, display (default python; pass `language: "js"` for in-process JavaScript)
|
||||
3. Bash: simple one-liners only
|
||||
You **MUST NOT** use Python or Bash when a specialized tool exists.
|
||||
You **MUST NOT** use Eval or Bash when a specialized tool exists.
|
||||
{{/ifAny}}
|
||||
|
||||
{{#ifAny (includes tools "read") (includes tools "write") (includes tools "search") (includes tools "find") (includes tools "edit")}}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
Run code in a persistent kernel, using a series of codeblocks acting as cells.
|
||||
|
||||
<instruction>
|
||||
Each cell is a markdown fenced code block. The opening fence's info string carries metadata:
|
||||
|
||||
```
|
||||
<lang>? <duration>? (title-fragment | key=value)*
|
||||
```
|
||||
- **Language**: {{#if py}}`py`/`python` for Python{{/if}}{{#ifAll py js}}, {{/ifAll}}{{#if js}}`js`/`javascript`/`ts`/`typescript` for JavaScript{{/if}}.{{#ifAll py js}} Omitted → inherit the previous cell's language (the first cell defaults to Python, falling back to JavaScript when Python is unavailable).{{else}} Omitted → inherit the previous cell's language.{{/ifAll}}
|
||||
- **Positional duration**: `15s`, `500ms`, `2m`, or a bare integer (seconds). Default 30s.
|
||||
- **Attributes**:
|
||||
- `id="…"` — cell id (shown as the title in the transcript).
|
||||
- `t=<duration>` — overrides the positional duration.
|
||||
- `rst=true` — wipe **this cell's own language kernel** before running.{{#ifAll py js}} Other languages are untouched.{{/ifAll}}
|
||||
|
||||
**Work incrementally:** one logical step per cell (imports, define, test, use). Pass multiple small cells in one call. Define small reusable functions you can debug individually. You **MUST** put workflow explanations in the assistant message or cell title — never inside cell code.
|
||||
|
||||
**On failure:** errors identify the failing cell (e.g., "Cell 3 failed"). Resubmit only the fixed cell (or fixed cell + remaining cells).
|
||||
</instruction>
|
||||
|
||||
<prelude>
|
||||
{{#ifAll py js}}The same helpers are available in both runtimes with the same positional argument order. Python takes the trailing options as keyword args; JavaScript takes the same options as a trailing object literal. JavaScript helpers are async and `await`able; Python helpers run synchronously.{{else}}{{#if py}}Helpers run synchronously. Trailing options are passed as keyword arguments.{{/if}}{{#if js}}Helpers are async and `await`able. Trailing options are passed as a final object literal.{{/if}}{{/ifAll}}
|
||||
```
|
||||
display(value) → None
|
||||
Render a value in the current cell output.
|
||||
print(value, ...) → None
|
||||
Print to the cell's text output.
|
||||
read(path, offset?=1, limit?=None) → str
|
||||
Read file contents as text. offset/limit are 1-indexed line bounds.
|
||||
write(path, content) → str
|
||||
Write content to a file (creates parent directories). Returns the resolved path.
|
||||
append(path, content) → str
|
||||
Append content to a file. Returns the resolved path.
|
||||
stat(path) → {path, size, is_file, is_dir, mtime}
|
||||
File or directory metadata. mtime is an ISO-8601 string.
|
||||
find(pattern, path?=".", type?="file", limit?=1000, hidden?=False, sort_by_mtime?=False, maxdepth?=None, mindepth?=None) → list[path]
|
||||
Recursive glob find. Respects .gitignore.
|
||||
glob(pattern, path?=".", hidden?=False) → list[path]
|
||||
Non-recursive glob. Use find() for recursive walks. Respects .gitignore.
|
||||
grep(pattern, path, ignore_case?=False, literal?=False, context?=0) → list[{line, text}]
|
||||
Search a single file.
|
||||
rgrep(pattern, path?=".", glob_pattern?="*", ignore_case?=False, literal?=False, limit?=100, hidden?=False) → list[{file, line, text}]
|
||||
Search recursively across files. Respects .gitignore.
|
||||
sed(path, pattern, repl, flags?=0) → int
|
||||
Regex replace in a file (like sed -i). Returns replacement count.
|
||||
tree(path?=".", max_depth?=3, show_hidden?=False) → str
|
||||
Render a directory tree.
|
||||
diff(a, b) → str
|
||||
Unified diff between two files.
|
||||
run(cmd, cwd?=None, timeout?=None) → {stdout, stderr, exit_code}
|
||||
Run a shell command.
|
||||
env(key?=None, value?=None) → str | None | dict
|
||||
No args → full environment as dict. One arg → value of `key`. Two args → set `key=value` and return value.
|
||||
output(*ids, format?="raw", query?=None, offset?=None, limit?=None) → str | dict | list[dict]
|
||||
Read task/agent output by ID. Single id returns text/dict; multiple ids return a list.
|
||||
```
|
||||
|
||||
{{#if js}}**JavaScript only:** `tool.<name>(args)` invokes any session tool directly (e.g. `await tool.read({ path: "src/foo.ts" })`).
|
||||
{{/if}}</prelude>
|
||||
|
||||
<output>
|
||||
Cells render like a Jupyter notebook. Pass any value to `display(value)`; non-presentable data is rendered as an interactive JSON tree, and presentable values (figures, images, dataframes, etc.) render with their native representation.
|
||||
</output>
|
||||
|
||||
<caution>
|
||||
- In session mode, use `rst=true` on a cell to wipe its language's kernel before running.{{#ifAll py js}} Reset is per-language: a python cell's `rst=true` does not touch the JavaScript kernel and vice versa.{{/ifAll}}
|
||||
{{#if js}}- **js**: the VM exposes a selective `process` subset, Web APIs, `Buffer`, `fs/promises`.
|
||||
{{/if}}</caution>
|
||||
|
||||
<example>
|
||||
{{#if py}}```py id="imports" t="10s"
|
||||
import json
|
||||
from pathlib import Path
|
||||
```
|
||||
|
||||
```py id="load config"
|
||||
data = json.loads(read('package.json'))
|
||||
display(data)
|
||||
```
|
||||
{{/if}}{{#ifAll py js}}
|
||||
|
||||
{{/ifAll}}{{#if js}}```js id="js summary" rst=true
|
||||
const data = JSON.parse(await read('package.json'));
|
||||
display(data);
|
||||
return data.name;
|
||||
```
|
||||
|
||||
```
|
||||
return 'still JavaScript';
|
||||
```
|
||||
{{/if}}
|
||||
</example>
|
||||
@@ -9,8 +9,11 @@ Interacts with Language Server Protocol servers for code intelligence.
|
||||
- `hover`: Get type info and documentation → type signature + docs
|
||||
- `symbols`: List symbols in a file, or search workspace with `file: "*"` and a `query`
|
||||
- `rename`: Rename symbol across codebase → preview or apply edits
|
||||
- `rename_file`: Rename or move a file/directory; sends `workspace/willRenameFiles` so LSP servers update import paths and other references → preview or apply edits + filesystem rename
|
||||
- `code_actions`: List available quick-fixes/refactors/import actions; apply one when `apply: true` and `query` matches title or index
|
||||
- `status`: Show active language servers
|
||||
- `capabilities`: Dump per-server capabilities (standard + experimental + executeCommand list) for discovery — file scopes to one server, omitted/`"*"` lists every active server
|
||||
- `request`: Send a raw LSP request to a server — `query` is the method name (e.g., `rust-analyzer/expandMacro`, `typescript/goToSourceDefinition`, `workspace/executeCommand`); use `payload` for arbitrary JSON params or let the tool auto-build them from `file`/`line`/`symbol`
|
||||
- `reload`: Restart a specific server (via `file`) or all servers with `file: "*"`
|
||||
</operations>
|
||||
|
||||
@@ -18,9 +21,10 @@ Interacts with Language Server Protocol servers for code intelligence.
|
||||
- `file`: File path, glob pattern (e.g. `src/**/*.ts`), or `"*"` for workspace scope. Globs are expanded locally before dispatch. `"*"` routes `diagnostics`/`symbols`/`reload` to their workspace-wide form.
|
||||
- `line`: 1-indexed line number for position-based actions
|
||||
- `symbol`: Substring on the target line used to resolve column automatically. Append `#N` to pick the Nth occurrence on that line (1-indexed; default 1) — e.g. `foo#2` selects the second `foo`.
|
||||
- `query`: Symbol search query, code-action kind filter (list mode), or code-action selector (apply mode)
|
||||
- `new_name`: Required for rename
|
||||
- `apply`: Apply edits for rename/code_actions (default true for rename, list mode for code_actions unless explicitly true)
|
||||
- `query`: Symbol search query, code-action kind filter / selector (list/apply mode), or LSP method name when `action: request`
|
||||
- `new_name`: Required for `rename` (new symbol identifier) and `rename_file` (destination path)
|
||||
- `apply`: Apply edits for rename/rename_file/code_actions (default true for rename and rename_file; list mode for code_actions unless explicitly true)
|
||||
- `payload`: JSON-encoded params for `action: request`. Overrides the auto-built `{ textDocument, position }` shape when present.
|
||||
- `timeout`: Request timeout in seconds (clamped to 5-60, default 20)
|
||||
</parameters>
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
Runs Python cells sequentially in persistent IPython kernel.
|
||||
|
||||
<instruction>
|
||||
Kernel persists across calls and cells; **imports, variables, and functions survive — use this.**
|
||||
|
||||
**Work incrementally:** one logical step per cell (imports, define, test, use). Pass multiple small cells in one call. Define small reusable functions you can debug individually. You **MUST** put workflow explanations in the assistant message or cell title — never inside cell code.
|
||||
|
||||
**On failure:** errors identify the failing cell (e.g., "Cell 3 failed"). Resubmit only the fixed cell (or fixed cell + remaining cells).
|
||||
</instruction>
|
||||
|
||||
{{#if categories.length}}
|
||||
<prelude>
|
||||
All helpers auto-print results and return values for chaining.
|
||||
|
||||
{{#each categories}}
|
||||
### {{name}}
|
||||
|
||||
```
|
||||
{{#each functions}}
|
||||
{{name}}{{signature}}
|
||||
{{docstring}}
|
||||
{{/each}}
|
||||
```
|
||||
{{/each}}
|
||||
</prelude>
|
||||
{{/if}}
|
||||
|
||||
<output>
|
||||
User sees output like Jupyter notebook; rich displays render fully:
|
||||
- `display(JSON(data))` → interactive JSON tree
|
||||
- `display(HTML(…))` → rendered HTML
|
||||
- `display(Markdown(…))` → formatted markdown
|
||||
- `plt.show()` → inline figures
|
||||
|
||||
**You will see object repr** (e.g., `<IPython.core.display.JSON object>`). Trust `display()`; you **MUST NOT** assume the user sees only the repr.
|
||||
</output>
|
||||
|
||||
<caution>
|
||||
- Per-call mode uses a fresh kernel each call
|
||||
- You **MUST** use `reset: true` to clear state when session mode is active
|
||||
</caution>
|
||||
|
||||
<critical>
|
||||
- You **MUST** use `run()` for shell commands; you **MUST NOT** use raw `subprocess`
|
||||
</critical>
|
||||
|
||||
<examples>
|
||||
# Multiple small cells
|
||||
```python
|
||||
cells: [
|
||||
{"title": "imports", "code": "import json\nfrom pathlib import Path"},
|
||||
{"title": "parse helper", "code": "def parse_config(path):\n return json.loads(Path(path).read_text())"},
|
||||
{"title": "test helper", "code": "parse_config('config.json')"},
|
||||
{"title": "use helper", "code": "configs = [parse_config(p) for p in Path('.').glob('*.json')]"}
|
||||
]
|
||||
```
|
||||
</examples>
|
||||
@@ -36,6 +36,7 @@ import { CursorExecHandlers } from "./cursor";
|
||||
import "./discovery";
|
||||
import { resolveConfigValue } from "./config/resolve-config-value";
|
||||
import { initializeWithSettings } from "./discovery";
|
||||
import { disposeAllKernelSessions, disposeKernelSessionsByOwner } from "./eval/py/executor";
|
||||
import { TtsrManager } from "./export/ttsr";
|
||||
import {
|
||||
type CustomCommandsLoadResult,
|
||||
@@ -73,7 +74,6 @@ import {
|
||||
RuleProtocolHandler,
|
||||
SkillProtocolHandler,
|
||||
} from "./internal-urls";
|
||||
import { disposeAllKernelSessions, disposeKernelSessionsByOwner } from "./ipy/executor";
|
||||
import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "./lsp/startup-events";
|
||||
import { discoverAndLoadMCPTools, type MCPManager, type MCPToolsLoadResult } from "./mcp";
|
||||
import {
|
||||
@@ -113,13 +113,13 @@ import {
|
||||
createTools,
|
||||
discoverStartupLspServers,
|
||||
EditTool,
|
||||
EvalTool,
|
||||
FindTool,
|
||||
getSearchTools,
|
||||
HIDDEN_TOOLS,
|
||||
isSearchProviderPreference,
|
||||
type LspStartupServerInfo,
|
||||
loadSshTool,
|
||||
PythonTool,
|
||||
ReadTool,
|
||||
ResolveTool,
|
||||
renderSearchToolBm25Description,
|
||||
@@ -277,10 +277,10 @@ export {
|
||||
BUILTIN_TOOLS,
|
||||
createTools,
|
||||
EditTool,
|
||||
EvalTool,
|
||||
FindTool,
|
||||
HIDDEN_TOOLS,
|
||||
loadSshTool,
|
||||
PythonTool,
|
||||
ReadTool,
|
||||
ResolveTool,
|
||||
SearchTool,
|
||||
@@ -936,7 +936,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
const resolvedAgentId = options.agentId ?? options.parentTaskPrefix ?? MAIN_AGENT_ID;
|
||||
const resolvedAgentDisplayName =
|
||||
options.agentDisplayName ?? ((options.taskDepth ?? 0) > 0 || options.parentTaskPrefix ? "sub" : "main");
|
||||
const pythonKernelOwnerId = `agent-session:${Snowflake.next()}`;
|
||||
const evalKernelOwnerId = `agent-session:${Snowflake.next()}`;
|
||||
|
||||
try {
|
||||
const getActiveModelString = (): string | undefined => {
|
||||
@@ -964,12 +964,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
requireYieldTool: options.requireYieldTool,
|
||||
taskDepth: options.taskDepth ?? 0,
|
||||
getSessionFile: () => sessionManager.getSessionFile() ?? null,
|
||||
getPythonKernelOwnerId: () => pythonKernelOwnerId,
|
||||
assertPythonExecutionAllowed: () => session?.assertPythonExecutionAllowed(),
|
||||
trackPythonExecution: (execution, abortController) =>
|
||||
session ? session.trackPythonExecution(execution, abortController) : execution,
|
||||
getEvalKernelOwnerId: () => evalKernelOwnerId,
|
||||
assertEvalExecutionAllowed: () => session?.assertEvalExecutionAllowed(),
|
||||
trackEvalExecution: (execution, abortController) =>
|
||||
session ? session.trackEvalExecution(execution, abortController) : execution,
|
||||
getSessionId: () => sessionManager.getSessionId?.() ?? null,
|
||||
getAgentId: () => resolvedAgentId,
|
||||
getToolByName: name => session?.getToolByName(name),
|
||||
agentRegistry,
|
||||
getSessionSpawns: () => options.spawns ?? "*",
|
||||
getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined),
|
||||
@@ -1614,7 +1615,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
thinkingLevel,
|
||||
sessionManager,
|
||||
settings,
|
||||
pythonKernelOwnerId,
|
||||
evalKernelOwnerId,
|
||||
scopedModels: options.scopedModels,
|
||||
promptTemplates,
|
||||
slashCommands,
|
||||
@@ -1797,7 +1798,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
if (hasSession) {
|
||||
await session.dispose();
|
||||
} else {
|
||||
await disposeKernelSessionsByOwner(pythonKernelOwnerId);
|
||||
await disposeKernelSessionsByOwner(evalKernelOwnerId);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
logger.warn("Failed to clean up createAgentSession resources after startup error", {
|
||||
|
||||
@@ -68,6 +68,11 @@ import {
|
||||
import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
|
||||
import type { Settings, SkillsSettings } from "../config/settings";
|
||||
import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
|
||||
import {
|
||||
disposeKernelSessionsByOwner,
|
||||
executePython as executePythonCommand,
|
||||
type PythonResult,
|
||||
} from "../eval/py/executor";
|
||||
import { type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor";
|
||||
import { exportSessionToHtml } from "../export/html";
|
||||
import type { TtsrManager, TtsrMatchContext } from "../export/ttsr";
|
||||
@@ -98,11 +103,6 @@ import type { HookCommandContext } from "../extensibility/hooks/types";
|
||||
import type { Skill, SkillWarning } from "../extensibility/skills";
|
||||
import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
|
||||
import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
|
||||
import {
|
||||
disposeKernelSessionsByOwner,
|
||||
executePython as executePythonCommand,
|
||||
type PythonResult,
|
||||
} from "../ipy/executor";
|
||||
import {
|
||||
buildDiscoverableMCPSearchIndex,
|
||||
collectDiscoverableMCPTools,
|
||||
@@ -259,7 +259,7 @@ export interface AgentSessionConfig {
|
||||
/** Secret obfuscator for deobfuscating streaming edit content */
|
||||
obfuscator?: SecretObfuscator;
|
||||
/** Logical owner for retained Python kernels created by this session. */
|
||||
pythonKernelOwnerId?: string;
|
||||
evalKernelOwnerId?: string;
|
||||
/** Agent identity (registry id like "0-Main" or "3-Alice") used for IRC routing. */
|
||||
agentId?: string;
|
||||
/** Shared agent registry (for forwarding IRC observations to the main session UI). */
|
||||
@@ -474,11 +474,11 @@ export class AgentSession {
|
||||
#pendingBashMessages: BashExecutionMessage[] = [];
|
||||
|
||||
// Python execution state
|
||||
#pythonAbortControllers = new Set<AbortController>();
|
||||
#pythonKernelOwnerId: string;
|
||||
#evalAbortControllers = new Set<AbortController>();
|
||||
#evalKernelOwnerId: string;
|
||||
#pendingPythonMessages: PythonExecutionMessage[] = [];
|
||||
#activePythonExecutions = new Set<Promise<unknown>>();
|
||||
#pythonExecutionDisposing = false;
|
||||
#activeEvalExecutions = new Set<Promise<unknown>>();
|
||||
#evalExecutionDisposing = false;
|
||||
|
||||
// Background-channel IRC exchanges queued while the recipient was streaming.
|
||||
// Drained into history (via emitExternalEvent) once the recipient becomes idle.
|
||||
@@ -577,7 +577,7 @@ export class AgentSession {
|
||||
this.settings = config.settings;
|
||||
this.#startPowerAssertion();
|
||||
this.#asyncJobManager = config.asyncJobManager;
|
||||
this.#pythonKernelOwnerId = config.pythonKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
|
||||
this.#evalKernelOwnerId = config.evalKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
|
||||
this.#scopedModels = config.scopedModels ?? [];
|
||||
this.#thinkingLevel = config.thinkingLevel;
|
||||
this.#promptTemplates = config.promptTemplates ?? [];
|
||||
@@ -1938,7 +1938,7 @@ export class AgentSession {
|
||||
* Call this when completely done with the session.
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
this.#pythonExecutionDisposing = true;
|
||||
this.#evalExecutionDisposing = true;
|
||||
try {
|
||||
if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
|
||||
await this.#extensionRunner.emit({ type: "session_shutdown" });
|
||||
@@ -1953,13 +1953,13 @@ export class AgentSession {
|
||||
if (drained === false && deliveryState) {
|
||||
logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState });
|
||||
}
|
||||
const pythonExecutionsSettled = await this.#preparePythonExecutionsForDispose();
|
||||
const pythonExecutionsSettled = await this.#prepareEvalExecutionsForDispose();
|
||||
if (!pythonExecutionsSettled) {
|
||||
logger.warn(
|
||||
"Detaching retained Python kernel ownership during dispose while Python execution is still active",
|
||||
);
|
||||
}
|
||||
await disposeKernelSessionsByOwner(this.#pythonKernelOwnerId);
|
||||
await disposeKernelSessionsByOwner(this.#evalKernelOwnerId);
|
||||
this.#stopPowerAssertion();
|
||||
await this.sessionManager.close();
|
||||
this.#closeAllProviderSessions("dispose");
|
||||
@@ -3423,7 +3423,7 @@ export class AgentSession {
|
||||
this.abortCompaction();
|
||||
this.abortHandoff();
|
||||
this.abortBash();
|
||||
this.abortPython();
|
||||
this.abortEval();
|
||||
const postPromptDrain = this.#cancelPostPromptTasks();
|
||||
this.agent.abort();
|
||||
await postPromptDrain;
|
||||
@@ -5895,7 +5895,7 @@ export class AgentSession {
|
||||
|
||||
/**
|
||||
* Execute Python code in the shared kernel.
|
||||
* Uses the same kernel session as the agent's Python tool, allowing collaborative editing.
|
||||
* Uses the same kernel session as eval's Python backend, allowing collaborative editing.
|
||||
* @param code The Python code to execute
|
||||
* @param onChunk Optional streaming callback for output
|
||||
* @param options.excludeFromContext If true, execution won't be sent to LLM ($$ prefix)
|
||||
@@ -5907,7 +5907,7 @@ export class AgentSession {
|
||||
): Promise<PythonResult> {
|
||||
const excludeFromContext = options?.excludeFromContext === true;
|
||||
const cwd = this.sessionManager.getCwd();
|
||||
this.assertPythonExecutionAllowed();
|
||||
this.assertEvalExecutionAllowed();
|
||||
|
||||
const abortController = new AbortController();
|
||||
const execution = (async (): Promise<PythonResult> => {
|
||||
@@ -5918,20 +5918,20 @@ export class AgentSession {
|
||||
excludeFromContext,
|
||||
cwd,
|
||||
});
|
||||
this.assertPythonExecutionAllowed();
|
||||
this.assertEvalExecutionAllowed();
|
||||
if (hookResult?.result) {
|
||||
this.recordPythonResult(code, hookResult.result, options);
|
||||
return hookResult.result;
|
||||
}
|
||||
}
|
||||
|
||||
// Use the same session ID as the Python tool for kernel sharing
|
||||
// Use the same session ID as eval's Python backend for kernel sharing
|
||||
const sessionFile = this.sessionManager.getSessionFile();
|
||||
const sessionId = sessionFile ? `session:${sessionFile}:cwd:${cwd}` : `cwd:${cwd}`;
|
||||
const result = await executePythonCommand(code, {
|
||||
cwd,
|
||||
sessionId,
|
||||
kernelOwnerId: this.#pythonKernelOwnerId,
|
||||
kernelOwnerId: this.#evalKernelOwnerId,
|
||||
kernelMode: this.settings.get("python.kernelMode"),
|
||||
useSharedGateway: this.settings.get("python.sharedGateway"),
|
||||
onChunk,
|
||||
@@ -5940,11 +5940,11 @@ export class AgentSession {
|
||||
this.recordPythonResult(code, result, options);
|
||||
return result;
|
||||
})();
|
||||
return await this.trackPythonExecution(execution, abortController);
|
||||
return await this.trackEvalExecution(execution, abortController);
|
||||
}
|
||||
|
||||
assertPythonExecutionAllowed(): void {
|
||||
if (this.#pythonExecutionDisposing) {
|
||||
assertEvalExecutionAllowed(): void {
|
||||
if (this.#evalExecutionDisposing) {
|
||||
throw new Error("Python execution is unavailable while session disposal is in progress");
|
||||
}
|
||||
}
|
||||
@@ -5952,17 +5952,17 @@ export class AgentSession {
|
||||
/**
|
||||
* Track Python work started outside AgentSession.executePython so dispose can await and abort it too.
|
||||
*/
|
||||
trackPythonExecution<T>(execution: Promise<T>, abortController: AbortController): Promise<T> {
|
||||
this.#pythonAbortControllers.add(abortController);
|
||||
this.#activePythonExecutions.add(execution);
|
||||
trackEvalExecution<T>(execution: Promise<T>, abortController: AbortController): Promise<T> {
|
||||
this.#evalAbortControllers.add(abortController);
|
||||
this.#activeEvalExecutions.add(execution);
|
||||
void execution.then(
|
||||
() => {
|
||||
this.#pythonAbortControllers.delete(abortController);
|
||||
this.#activePythonExecutions.delete(execution);
|
||||
this.#evalAbortControllers.delete(abortController);
|
||||
this.#activeEvalExecutions.delete(execution);
|
||||
},
|
||||
() => {
|
||||
this.#pythonAbortControllers.delete(abortController);
|
||||
this.#activePythonExecutions.delete(execution);
|
||||
this.#evalAbortControllers.delete(abortController);
|
||||
this.#activeEvalExecutions.delete(execution);
|
||||
},
|
||||
);
|
||||
return execution;
|
||||
@@ -5997,35 +5997,35 @@ export class AgentSession {
|
||||
/**
|
||||
* Cancel running Python execution.
|
||||
*/
|
||||
abortPython(): void {
|
||||
for (const abortController of this.#pythonAbortControllers) {
|
||||
abortEval(): void {
|
||||
for (const abortController of this.#evalAbortControllers) {
|
||||
abortController.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async #waitForPythonExecutionsToSettle(timeoutMs: number): Promise<boolean> {
|
||||
async #waitForEvalExecutionsToSettle(timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (this.#activePythonExecutions.size > 0) {
|
||||
while (this.#activeEvalExecutions.size > 0) {
|
||||
const remainingMs = deadline - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
return false;
|
||||
}
|
||||
const settled = await Promise.race([
|
||||
Promise.allSettled(Array.from(this.#activePythonExecutions)).then(() => true),
|
||||
Promise.allSettled(Array.from(this.#activeEvalExecutions)).then(() => true),
|
||||
Bun.sleep(remainingMs).then(() => false),
|
||||
]);
|
||||
if (!settled && this.#activePythonExecutions.size > 0) {
|
||||
if (!settled && this.#activeEvalExecutions.size > 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async #preparePythonExecutionsForDispose(): Promise<boolean> {
|
||||
if (!(await this.#waitForPythonExecutionsToSettle(3_000))) {
|
||||
async #prepareEvalExecutionsForDispose(): Promise<boolean> {
|
||||
if (!(await this.#waitForEvalExecutionsToSettle(3_000))) {
|
||||
logger.warn("Aborting active Python execution during dispose before retained kernel cleanup");
|
||||
this.abortPython();
|
||||
if (!(await this.#waitForPythonExecutionsToSettle(1_000))) {
|
||||
this.abortEval();
|
||||
if (!(await this.#waitForEvalExecutionsToSettle(1_000))) {
|
||||
logger.warn(
|
||||
"Python execution is still active after dispose aborted all active runs; retained kernel ownership will still be detached",
|
||||
);
|
||||
@@ -6036,8 +6036,8 @@ export class AgentSession {
|
||||
}
|
||||
|
||||
/** Whether a Python execution is currently running */
|
||||
get isPythonRunning(): boolean {
|
||||
return this.#pythonAbortControllers.size > 0;
|
||||
get isEvalRunning(): boolean {
|
||||
return this.#evalAbortControllers.size > 0;
|
||||
}
|
||||
|
||||
/** Whether there are pending Python messages waiting to be flushed */
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface BashExecutionMessage {
|
||||
|
||||
/**
|
||||
* Message type for user-initiated Python executions via the $ command.
|
||||
* Shares the same kernel session as the agent's Python tool.
|
||||
* Shares the same kernel session as eval's Python backend.
|
||||
*/
|
||||
export interface PythonExecutionMessage {
|
||||
role: "pythonExecution";
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -532,16 +532,12 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
||||
if (atMaxDepth && toolNames?.includes("task")) {
|
||||
toolNames = toolNames.filter(name => name !== "task");
|
||||
}
|
||||
const pythonToolMode = settings.get("python.toolMode") ?? "both";
|
||||
if (toolNames?.includes("exec")) {
|
||||
const allowEvalPy = settings.get("eval.py") ?? true;
|
||||
const allowEvalJs = settings.get("eval.js") ?? true;
|
||||
const expanded = toolNames.filter(name => name !== "exec");
|
||||
if (pythonToolMode === "bash-only") {
|
||||
expanded.push("bash");
|
||||
} else if (pythonToolMode === "ipy-only") {
|
||||
expanded.push("python");
|
||||
} else {
|
||||
expanded.push("python", "bash");
|
||||
}
|
||||
if (allowEvalPy || allowEvalJs) expanded.push("eval");
|
||||
expanded.push("bash");
|
||||
toolNames = Array.from(new Set(expanded));
|
||||
}
|
||||
|
||||
@@ -557,7 +553,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
||||
|
||||
const lspEnabled = enableLsp ?? true;
|
||||
const ircEnabled = subagentSettings.get("irc.enabled") === true;
|
||||
const skipPythonPreflight = Array.isArray(toolNames) && !toolNames.includes("python");
|
||||
const skipPythonPreflight = Array.isArray(toolNames) && !toolNames.includes("eval");
|
||||
|
||||
const outputChunks: string[] = [];
|
||||
const finalOutputChunks: string[] = [];
|
||||
|
||||
@@ -4,18 +4,9 @@ import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import browserDescription from "../prompts/tools/browser.md" with { type: "text" };
|
||||
import type { ToolSession } from "../sdk";
|
||||
import {
|
||||
acquireBrowser,
|
||||
acquireTab,
|
||||
type BrowserHandle,
|
||||
type BrowserKind,
|
||||
type BrowserKindTag,
|
||||
dropHeadlessBrowsers,
|
||||
getTab,
|
||||
releaseAllTabs,
|
||||
releaseTab,
|
||||
} from "./browser/registry";
|
||||
import { collectObservation, formatObservation, type Observation, runInTab, type ScreenshotResult } from "./browser/vm";
|
||||
import { acquireBrowser, type BrowserHandle, type BrowserKind, type BrowserKindTag } from "./browser/registry";
|
||||
import type { Observation, ScreenshotResult } from "./browser/tab-protocol";
|
||||
import { acquireTab, dropHeadlessTabs, getTab, releaseAllTabs, releaseTab, runInTab } from "./browser/tab-supervisor";
|
||||
import type { OutputMeta } from "./output-meta";
|
||||
import { resolveToCwd } from "./path-utils";
|
||||
import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
|
||||
@@ -23,7 +14,7 @@ import { toolResult } from "./tool-result";
|
||||
import { clampTimeout } from "./tool-timeouts";
|
||||
|
||||
export { extractReadableFromHtml, type ReadableFormat, type ReadableResult } from "./browser/readable";
|
||||
export type { Observation, ObservationEntry } from "./browser/vm";
|
||||
export type { Observation, ObservationEntry } from "./browser/tab-protocol";
|
||||
|
||||
const DEFAULT_TAB_NAME = "main";
|
||||
|
||||
@@ -129,7 +120,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
|
||||
|
||||
/** Restart browser to apply mode changes (e.g. headless toggle). Drops only headless browsers. */
|
||||
async restartForModeChange(): Promise<void> {
|
||||
await dropHeadlessBrowsers();
|
||||
await dropHeadlessTabs();
|
||||
}
|
||||
|
||||
async execute(
|
||||
@@ -216,10 +207,10 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
|
||||
}),
|
||||
);
|
||||
const tab = result.tab;
|
||||
const url = tab.page.url();
|
||||
const title = (await untilAborted(signal, () => tab.page.title())) as string;
|
||||
const url = tab.info.url;
|
||||
const title = tab.info.title ?? "";
|
||||
details.url = url;
|
||||
details.viewport = tab.page.viewport() ?? undefined;
|
||||
details.viewport = tab.info.viewport;
|
||||
const verb = result.created ? "Opened" : "Reused";
|
||||
const lines = [
|
||||
`${verb} tab ${JSON.stringify(name)} on ${describeBrowser(browser)}`,
|
||||
@@ -258,16 +249,12 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
|
||||
throw new ToolError("Missing required parameter 'code' for action 'run'.");
|
||||
}
|
||||
const tab = getTab(name);
|
||||
if (!tab) {
|
||||
throw new ToolError(
|
||||
`No tab named ${JSON.stringify(name)}. Call open first (e.g. action: 'open', name: '${name}').`,
|
||||
);
|
||||
if (tab) {
|
||||
details.browser = tab.browser.kind.kind;
|
||||
details.url = tab.info.url;
|
||||
}
|
||||
details.browser = tab.browser.kind.kind;
|
||||
details.url = tab.page.url();
|
||||
|
||||
const { displays, returnValue, screenshots } = await runInTab({
|
||||
tab,
|
||||
const { displays, returnValue, screenshots } = await runInTab(name, {
|
||||
code: params.code,
|
||||
timeoutMs,
|
||||
signal,
|
||||
@@ -330,7 +317,3 @@ function stringifyReturnValue(value: unknown): string {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export collectObservation so external callers (e.g. tests) can use it without
|
||||
// reaching into the browser/ subdirectory.
|
||||
export { collectObservation, formatObservation };
|
||||
|
||||
@@ -63,6 +63,19 @@ export async function loadPuppeteer(): Promise<typeof Puppeteer> {
|
||||
}
|
||||
}
|
||||
|
||||
let puppeteerModuleWorker: typeof Puppeteer | undefined;
|
||||
export async function loadPuppeteerInWorker(safeDir: string): Promise<typeof Puppeteer> {
|
||||
if (puppeteerModuleWorker) return puppeteerModuleWorker;
|
||||
const orig = process.cwd;
|
||||
Object.defineProperty(process, "cwd", { value: () => safeDir, configurable: true });
|
||||
try {
|
||||
puppeteerModuleWorker = (await import("puppeteer-core")).default;
|
||||
return puppeteerModuleWorker;
|
||||
} finally {
|
||||
Object.defineProperty(process, "cwd", { value: orig, configurable: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily download Chromium on first browser launch via @puppeteer/browsers.
|
||||
* Skipped when a system Chromium (NixOS) or PUPPETEER_EXECUTABLE_PATH is set.
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
import * as path from "node:path";
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
import type { Subprocess } from "bun";
|
||||
import type { Browser, CDPSession, ElementHandle, Page } from "puppeteer-core";
|
||||
import type { Browser, CDPSession } from "puppeteer-core";
|
||||
import { ToolAbortError, ToolError } from "../tool-errors";
|
||||
import {
|
||||
findFreeCdpPort,
|
||||
findReusableCdp,
|
||||
gracefulKillTreeOnce,
|
||||
killExistingByPath,
|
||||
pickElectronTarget,
|
||||
waitForCdp,
|
||||
} from "./attach";
|
||||
import {
|
||||
applyStealthPatches,
|
||||
applyViewport,
|
||||
BROWSER_PROTOCOL_TIMEOUT_MS,
|
||||
launchHeadlessBrowser,
|
||||
loadPuppeteer,
|
||||
type UserAgentOverride,
|
||||
} from "./launch";
|
||||
import { findFreeCdpPort, findReusableCdp, gracefulKillTreeOnce, killExistingByPath, waitForCdp } from "./attach";
|
||||
import { BROWSER_PROTOCOL_TIMEOUT_MS, launchHeadlessBrowser, loadPuppeteer, type UserAgentOverride } from "./launch";
|
||||
|
||||
export type BrowserKind =
|
||||
| { kind: "headless"; headless: boolean }
|
||||
@@ -38,28 +24,7 @@ export interface BrowserHandle {
|
||||
stealth: { browserSession: CDPSession | null; override: UserAgentOverride | null };
|
||||
}
|
||||
|
||||
export type DialogPolicy = "accept" | "dismiss";
|
||||
|
||||
export interface TabHandle {
|
||||
name: string;
|
||||
browser: BrowserHandle;
|
||||
page: Page;
|
||||
elementCache: Map<number, ElementHandle>;
|
||||
elementCounter: number;
|
||||
dialogPolicy?: DialogPolicy;
|
||||
dialogHandler?: (dialog: { accept: () => Promise<void>; dismiss: () => Promise<void> }) => void;
|
||||
}
|
||||
|
||||
const browsers = new Map<string, BrowserHandle>();
|
||||
const tabs = new Map<string, TabHandle>();
|
||||
|
||||
export function getTab(name: string): TabHandle | undefined {
|
||||
return tabs.get(name);
|
||||
}
|
||||
|
||||
export function listTabs(): TabHandle[] {
|
||||
return [...tabs.values()];
|
||||
}
|
||||
|
||||
export function listBrowsers(): BrowserHandle[] {
|
||||
return [...browsers.values()];
|
||||
@@ -87,9 +52,7 @@ export async function acquireBrowser(kind: BrowserKind, opts: AcquireBrowserOpti
|
||||
const key = browserKey(kind);
|
||||
const existing = browsers.get(key);
|
||||
if (existing) {
|
||||
// Headless: connection check; spawned/connected: connection check.
|
||||
if (existing.browser.connected) return existing;
|
||||
// Stale handle — purge and rebuild.
|
||||
browsers.delete(key);
|
||||
await disposeBrowserHandle(existing, { kill: false });
|
||||
}
|
||||
@@ -128,7 +91,7 @@ async function openBrowserHandle(kind: BrowserKind, opts: AcquireBrowserOptions)
|
||||
stealth: { browserSession: null, override: null },
|
||||
};
|
||||
}
|
||||
// spawned
|
||||
|
||||
const exe = kind.path;
|
||||
if (!path.isAbsolute(exe)) {
|
||||
throw new ToolError(
|
||||
@@ -140,18 +103,12 @@ async function openBrowserHandle(kind: BrowserKind, opts: AcquireBrowserOptions)
|
||||
let pid: number;
|
||||
let subprocess: Subprocess | undefined;
|
||||
if (reused) {
|
||||
logger.debug("Reusing existing CDP endpoint for attach", {
|
||||
exe,
|
||||
pid: reused.pid,
|
||||
cdpUrl: reused.cdpUrl,
|
||||
});
|
||||
logger.debug("Reusing existing CDP endpoint for attach", { exe, pid: reused.pid, cdpUrl: reused.cdpUrl });
|
||||
cdpUrl = reused.cdpUrl;
|
||||
pid = reused.pid;
|
||||
} else {
|
||||
const killed = await killExistingByPath(exe, opts.signal);
|
||||
if (killed > 0) {
|
||||
logger.debug("Killed existing instances before attach", { exe, killed });
|
||||
}
|
||||
if (killed > 0) logger.debug("Killed existing instances before attach", { exe, killed });
|
||||
const port = await findFreeCdpPort();
|
||||
const launchArgs = [...(opts.appArgs ?? []), `--remote-debugging-port=${port}`];
|
||||
const child = Bun.spawn([exe, ...launchArgs], {
|
||||
@@ -197,167 +154,19 @@ async function openBrowserHandle(kind: BrowserKind, opts: AcquireBrowserOptions)
|
||||
};
|
||||
}
|
||||
|
||||
export interface AcquireTabOptions {
|
||||
url?: string;
|
||||
waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2";
|
||||
viewport?: { width: number; height: number; deviceScaleFactor?: number };
|
||||
target?: string;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs: number;
|
||||
dialogs?: DialogPolicy;
|
||||
export function holdBrowser(handle: BrowserHandle): void {
|
||||
handle.refCount++;
|
||||
}
|
||||
|
||||
export interface AcquireTabResult {
|
||||
tab: TabHandle;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
export async function acquireTab(
|
||||
name: string,
|
||||
browser: BrowserHandle,
|
||||
opts: AcquireTabOptions,
|
||||
): Promise<AcquireTabResult> {
|
||||
const existing = tabs.get(name);
|
||||
if (existing) {
|
||||
if (existing.browser !== browser) {
|
||||
throw new ToolError(
|
||||
`Tab ${JSON.stringify(name)} already exists on a different browser (${existing.browser.kind.kind}). Close it first.`,
|
||||
);
|
||||
}
|
||||
if (!existing.page.isClosed()) {
|
||||
if (opts.dialogs !== undefined) applyDialogPolicy(existing, opts.dialogs);
|
||||
if (opts.url) {
|
||||
clearElementCache(existing);
|
||||
await existing.page.goto(opts.url, {
|
||||
waitUntil: opts.waitUntil ?? "networkidle2",
|
||||
timeout: opts.timeoutMs,
|
||||
});
|
||||
}
|
||||
return { tab: existing, created: false };
|
||||
}
|
||||
// Stale tab — purge and recreate.
|
||||
tabs.delete(name);
|
||||
browser.refCount = Math.max(0, browser.refCount - 1);
|
||||
}
|
||||
|
||||
let page: Page;
|
||||
if (browser.kind.kind === "headless") {
|
||||
page = await browser.browser.newPage();
|
||||
await applyStealthPatches(browser.browser, page, browser.stealth);
|
||||
if (browser.kind.headless || opts.viewport) {
|
||||
await applyViewport(page, opts.viewport);
|
||||
}
|
||||
} else {
|
||||
// spawned/connected — don't open a new tab in the user's app; pick an existing target.
|
||||
page = await pickElectronTarget(browser.browser, opts.target);
|
||||
}
|
||||
|
||||
const tab: TabHandle = {
|
||||
name,
|
||||
browser,
|
||||
page,
|
||||
elementCache: new Map(),
|
||||
elementCounter: 0,
|
||||
};
|
||||
tabs.set(name, tab);
|
||||
browser.refCount++;
|
||||
if (opts.dialogs !== undefined) applyDialogPolicy(tab, opts.dialogs);
|
||||
|
||||
if (opts.url) {
|
||||
await page.goto(opts.url, {
|
||||
waitUntil: opts.waitUntil ?? "networkidle2",
|
||||
timeout: opts.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
return { tab, created: true };
|
||||
}
|
||||
|
||||
export interface ReleaseTabOptions {
|
||||
kill?: boolean;
|
||||
}
|
||||
|
||||
export async function releaseTab(name: string, opts: ReleaseTabOptions = {}): Promise<boolean> {
|
||||
const tab = tabs.get(name);
|
||||
if (!tab) {
|
||||
logger.debug("releaseTab: unknown tab", { name });
|
||||
return false;
|
||||
}
|
||||
tabs.delete(name);
|
||||
await disposeTab(tab);
|
||||
tab.browser.refCount = Math.max(0, tab.browser.refCount - 1);
|
||||
if (tab.browser.refCount === 0) {
|
||||
browsers.delete(tab.browser.key);
|
||||
await disposeBrowserHandle(tab.browser, { kill: opts.kill ?? false });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function releaseAllTabs(opts: ReleaseTabOptions = {}): Promise<number> {
|
||||
const names = [...tabs.keys()];
|
||||
let count = 0;
|
||||
for (const name of names) {
|
||||
if (await releaseTab(name, opts)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Drop only headless browsers and their tabs. Used by the headless-toggle slash command. */
|
||||
export async function dropHeadlessBrowsers(): Promise<void> {
|
||||
const targets = [...tabs.values()].filter(t => t.browser.kind.kind === "headless");
|
||||
for (const tab of targets) {
|
||||
await releaseTab(tab.name);
|
||||
}
|
||||
// Drop any zero-refcount headless browsers that survived (shouldn't happen, defensive).
|
||||
for (const [key, browser] of browsers) {
|
||||
if (browser.kind.kind === "headless" && browser.refCount === 0) {
|
||||
browsers.delete(key);
|
||||
await disposeBrowserHandle(browser, { kill: false });
|
||||
}
|
||||
export async function releaseBrowser(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
|
||||
handle.refCount = Math.max(0, handle.refCount - 1);
|
||||
if (handle.refCount === 0) {
|
||||
browsers.delete(handle.key);
|
||||
await disposeBrowserHandle(handle, opts);
|
||||
}
|
||||
}
|
||||
|
||||
function applyDialogPolicy(tab: TabHandle, policy: DialogPolicy): void {
|
||||
if (tab.dialogPolicy === policy && tab.dialogHandler) return;
|
||||
if (tab.dialogHandler) {
|
||||
try {
|
||||
tab.page.off("dialog", tab.dialogHandler);
|
||||
} catch {}
|
||||
}
|
||||
const handler = (dialog: { accept: () => Promise<void>; dismiss: () => Promise<void> }): void => {
|
||||
const action = policy === "accept" ? dialog.accept() : dialog.dismiss();
|
||||
void action.catch(err => {
|
||||
logger.debug("Dialog auto-handler failed", { policy, error: (err as Error).message });
|
||||
});
|
||||
};
|
||||
tab.page.on("dialog", handler);
|
||||
tab.dialogPolicy = policy;
|
||||
tab.dialogHandler = handler;
|
||||
}
|
||||
|
||||
async function disposeTab(tab: TabHandle): Promise<void> {
|
||||
clearElementCache(tab);
|
||||
if (tab.dialogHandler && !tab.page.isClosed()) {
|
||||
try {
|
||||
tab.page.off("dialog", tab.dialogHandler);
|
||||
} catch {}
|
||||
tab.dialogHandler = undefined;
|
||||
tab.dialogPolicy = undefined;
|
||||
}
|
||||
if (tab.browser.kind.kind === "headless") {
|
||||
// Owned tab — close it.
|
||||
if (!tab.page.isClosed()) {
|
||||
try {
|
||||
await tab.page.close();
|
||||
} catch (err) {
|
||||
logger.debug("Failed to close page", { error: (err as Error).message });
|
||||
}
|
||||
}
|
||||
}
|
||||
// spawned/connected: page belongs to user's app — never close.
|
||||
}
|
||||
|
||||
async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
|
||||
export async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
|
||||
if (handle.kind.kind === "headless") {
|
||||
if (handle.browser.connected) {
|
||||
try {
|
||||
@@ -369,7 +178,6 @@ async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean
|
||||
return;
|
||||
}
|
||||
if (handle.kind.kind === "connected") {
|
||||
// Never close a remote app — only disconnect.
|
||||
if (handle.browser.connected) {
|
||||
try {
|
||||
handle.browser.disconnect();
|
||||
@@ -379,7 +187,6 @@ async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean
|
||||
}
|
||||
return;
|
||||
}
|
||||
// spawned
|
||||
if (handle.browser.connected) {
|
||||
try {
|
||||
handle.browser.disconnect();
|
||||
@@ -387,40 +194,5 @@ async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean
|
||||
logger.debug("Failed to disconnect from spawned browser", { error: (err as Error).message });
|
||||
}
|
||||
}
|
||||
if (opts.kill && handle.pid !== undefined) {
|
||||
await gracefulKillTreeOnce(handle.pid);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearElementCache(tab: TabHandle): void {
|
||||
if (tab.elementCache.size === 0) {
|
||||
tab.elementCounter = 0;
|
||||
return;
|
||||
}
|
||||
const handles = [...tab.elementCache.values()];
|
||||
tab.elementCache.clear();
|
||||
tab.elementCounter = 0;
|
||||
for (const handle of handles) {
|
||||
// Fire and forget; disposal failures don't affect correctness.
|
||||
void handle.dispose().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveCachedHandle(tab: TabHandle, id: number): Promise<ElementHandle> {
|
||||
const handle = tab.elementCache.get(id);
|
||||
if (!handle) {
|
||||
throw new ToolError(`Unknown element id ${id}. Run tab.observe() to refresh the element list.`);
|
||||
}
|
||||
try {
|
||||
const isConnected = (await handle.evaluate(el => el.isConnected)) as boolean;
|
||||
if (!isConnected) {
|
||||
clearElementCache(tab);
|
||||
throw new ToolError(`Element id ${id} is stale. Run tab.observe() again.`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ToolError) throw err;
|
||||
clearElementCache(tab);
|
||||
throw new ToolError(`Element id ${id} is stale. Run tab.observe() again.`);
|
||||
}
|
||||
return handle;
|
||||
if (opts.kill && handle.pid !== undefined) await gracefulKillTreeOnce(handle.pid);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* TUI renderer for the browser tool.
|
||||
*
|
||||
* Mirrors the `python` tool look: each `run` invocation is shown as a JS code
|
||||
* Mirrors the `eval` tool look: each `run` invocation is shown as a JS code
|
||||
* cell with status icon, optional output, and expand/collapse handling. `open`
|
||||
* and `close` actions render as compact status lines.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
|
||||
|
||||
export type Transferable = Bun.Transferable;
|
||||
|
||||
export interface ObservationEntry {
|
||||
id: number;
|
||||
role: string;
|
||||
name?: string;
|
||||
value?: string | number;
|
||||
description?: string;
|
||||
keyshortcuts?: string;
|
||||
states: string[];
|
||||
}
|
||||
|
||||
export interface Observation {
|
||||
url: string;
|
||||
title?: string;
|
||||
viewport: { width: number; height: number; deviceScaleFactor?: number };
|
||||
scroll: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
scrollWidth: number;
|
||||
scrollHeight: number;
|
||||
};
|
||||
elements: ObservationEntry[];
|
||||
}
|
||||
|
||||
export interface ScreenshotResult {
|
||||
dest: string;
|
||||
mimeType: string;
|
||||
bytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface SessionSnapshot {
|
||||
cwd: string;
|
||||
browserScreenshotDir?: string;
|
||||
}
|
||||
|
||||
export type WorkerInitPayload =
|
||||
| {
|
||||
mode: "headless";
|
||||
browserWSEndpoint: string;
|
||||
safeDir: string;
|
||||
viewport?: { width: number; height: number; deviceScaleFactor?: number };
|
||||
dialogs?: "accept" | "dismiss";
|
||||
url?: string;
|
||||
waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2";
|
||||
timeoutMs: number;
|
||||
}
|
||||
| {
|
||||
mode: "attach";
|
||||
browserWSEndpoint: string;
|
||||
safeDir: string;
|
||||
targetId: string;
|
||||
dialogs?: "accept" | "dismiss";
|
||||
};
|
||||
|
||||
export type WorkerInbound =
|
||||
| { type: "init"; payload: WorkerInitPayload }
|
||||
| { type: "run"; id: string; name: string; code: string; timeoutMs: number; session: SessionSnapshot }
|
||||
| { type: "abort"; id: string }
|
||||
| { type: "close" };
|
||||
|
||||
export interface ReadyInfo {
|
||||
url: string;
|
||||
title?: string;
|
||||
viewport: { width: number; height: number; deviceScaleFactor?: number };
|
||||
targetId: string;
|
||||
}
|
||||
|
||||
export interface RunResultOk {
|
||||
displays: Array<TextContent | ImageContent>;
|
||||
returnValue: unknown;
|
||||
screenshots: ScreenshotResult[];
|
||||
}
|
||||
|
||||
export interface RunErrorPayload {
|
||||
name: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
isToolError: boolean;
|
||||
isAbort: boolean;
|
||||
}
|
||||
|
||||
export type WorkerOutbound =
|
||||
| { type: "ready"; info: ReadyInfo }
|
||||
| { type: "init-failed"; error: RunErrorPayload }
|
||||
| { type: "result"; id: string; ok: true; payload: RunResultOk }
|
||||
| { type: "result"; id: string; ok: false; error: RunErrorPayload }
|
||||
| { type: "log"; level: "debug" | "warn" | "error"; msg: string; meta?: Record<string, unknown> }
|
||||
| { type: "closed" };
|
||||
|
||||
export interface Transport {
|
||||
send(msg: WorkerOutbound | WorkerInbound, transferList?: Transferable[]): void;
|
||||
onMessage(handler: (msg: WorkerOutbound | WorkerInbound) => void): () => void;
|
||||
close(): void;
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import { getPuppeteerDir, logger, Snowflake } from "@oh-my-pi/pi-utils";
|
||||
import type { Page, Target } from "puppeteer-core";
|
||||
import type { ToolSession } from "../../sdk";
|
||||
import { expandPath } from "../path-utils";
|
||||
import { ToolAbortError, ToolError } from "../tool-errors";
|
||||
import { pickElectronTarget } from "./attach";
|
||||
import { type BrowserHandle, type BrowserKindTag, holdBrowser, releaseBrowser } from "./registry";
|
||||
import type {
|
||||
ReadyInfo,
|
||||
RunErrorPayload,
|
||||
RunResultOk,
|
||||
SessionSnapshot,
|
||||
Transferable,
|
||||
Transport,
|
||||
WorkerInbound,
|
||||
WorkerInitPayload,
|
||||
WorkerOutbound,
|
||||
} from "./tab-protocol";
|
||||
import { WorkerCore } from "./tab-worker";
|
||||
|
||||
interface WorkerHandle {
|
||||
send(msg: WorkerInbound, transferList?: Transferable[]): void;
|
||||
onMessage(handler: (msg: WorkerOutbound) => void): () => void;
|
||||
terminate(): Promise<void>;
|
||||
readonly mode: "worker" | "inline";
|
||||
}
|
||||
|
||||
export type DialogPolicy = "accept" | "dismiss";
|
||||
|
||||
export interface TabSession {
|
||||
name: string;
|
||||
browser: BrowserHandle;
|
||||
targetId: string;
|
||||
worker: WorkerHandle;
|
||||
state: "alive" | "dead";
|
||||
info: ReadyInfo;
|
||||
pending: Map<string, { resolve: (result: RunResultOk) => void; reject: (error: unknown) => void }>;
|
||||
dialogPolicy?: DialogPolicy;
|
||||
kindTag: BrowserKindTag;
|
||||
}
|
||||
|
||||
export interface AcquireTabOptions {
|
||||
url?: string;
|
||||
waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2";
|
||||
viewport?: { width: number; height: number; deviceScaleFactor?: number };
|
||||
target?: string;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs: number;
|
||||
dialogs?: DialogPolicy;
|
||||
}
|
||||
|
||||
export interface AcquireTabResult {
|
||||
tab: TabSession;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
export interface RunInTabOptions {
|
||||
code: string;
|
||||
timeoutMs: number;
|
||||
signal?: AbortSignal;
|
||||
session: ToolSession;
|
||||
}
|
||||
|
||||
export interface ReleaseTabOptions {
|
||||
kill?: boolean;
|
||||
}
|
||||
|
||||
const tabs = new Map<string, TabSession>();
|
||||
const GRACE_MS = 750;
|
||||
|
||||
export function getTab(name: string): TabSession | undefined {
|
||||
return tabs.get(name);
|
||||
}
|
||||
|
||||
export function listTabs(): TabSession[] {
|
||||
return [...tabs.values()];
|
||||
}
|
||||
|
||||
export async function acquireTab(
|
||||
name: string,
|
||||
browser: BrowserHandle,
|
||||
opts: AcquireTabOptions,
|
||||
): Promise<AcquireTabResult> {
|
||||
const existing = tabs.get(name);
|
||||
if (existing) {
|
||||
if (existing.browser === browser && existing.state === "alive") {
|
||||
if (opts.dialogs !== undefined && opts.dialogs !== existing.dialogPolicy) {
|
||||
await releaseTab(name, { kill: false });
|
||||
} else {
|
||||
if (opts.url) {
|
||||
await runInTabWithSnapshot(
|
||||
name,
|
||||
{
|
||||
code: `await tab.goto(${JSON.stringify(opts.url)}, { waitUntil: ${JSON.stringify(opts.waitUntil ?? "networkidle2")} });`,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
signal: opts.signal,
|
||||
},
|
||||
{ cwd: process.cwd() },
|
||||
);
|
||||
}
|
||||
return { tab: tabs.get(name)!, created: false };
|
||||
}
|
||||
} else {
|
||||
await releaseTab(name, { kill: false });
|
||||
}
|
||||
}
|
||||
|
||||
const initPayload = await buildInitPayload(browser, opts);
|
||||
const worker = await spawnTabWorker();
|
||||
const { promise, resolve, reject } = Promise.withResolvers<ReadyInfo>();
|
||||
const unlisten = worker.onMessage(msg => {
|
||||
if (msg.type === "ready") resolve(msg.info);
|
||||
else if (msg.type === "init-failed") reject(errorFromPayload(msg.error));
|
||||
else if (msg.type === "log") logWorkerMessage(msg);
|
||||
});
|
||||
let info: ReadyInfo;
|
||||
try {
|
||||
worker.send({ type: "init", payload: initPayload });
|
||||
info = await raceWithTimeout(promise, opts.timeoutMs + GRACE_MS, "Timed out initializing browser tab worker");
|
||||
} catch (error) {
|
||||
unlisten();
|
||||
await worker.terminate().catch(() => undefined);
|
||||
if (browser.refCount === 0) await releaseBrowser(browser, { kill: false });
|
||||
throw error;
|
||||
}
|
||||
unlisten();
|
||||
|
||||
holdBrowser(browser);
|
||||
const tab: TabSession = {
|
||||
name,
|
||||
browser,
|
||||
targetId: info.targetId,
|
||||
worker,
|
||||
state: "alive",
|
||||
info,
|
||||
pending: new Map(),
|
||||
dialogPolicy: opts.dialogs,
|
||||
kindTag: browser.kind.kind,
|
||||
};
|
||||
worker.onMessage(msg => handleTabMessage(tab, msg));
|
||||
tabs.set(name, tab);
|
||||
return { tab, created: true };
|
||||
}
|
||||
|
||||
export async function runInTab(name: string, opts: RunInTabOptions): Promise<RunResultOk> {
|
||||
return await runInTabWithSnapshot(
|
||||
name,
|
||||
{ code: opts.code, timeoutMs: opts.timeoutMs, signal: opts.signal },
|
||||
{ cwd: opts.session.cwd, browserScreenshotDir: expandBrowserScreenshotDir(opts.session) },
|
||||
);
|
||||
}
|
||||
|
||||
async function runInTabWithSnapshot(
|
||||
name: string,
|
||||
opts: { code: string; timeoutMs: number; signal?: AbortSignal },
|
||||
snapshot: SessionSnapshot,
|
||||
): Promise<RunResultOk> {
|
||||
const tab = tabs.get(name);
|
||||
if (!tab || tab.state === "dead") throw new ToolError(`Tab ${JSON.stringify(name)} is not alive. Reopen it.`);
|
||||
if (tab.pending.size > 0) throw new ToolError(`Tab ${JSON.stringify(name)} is busy`);
|
||||
const id = Snowflake.next();
|
||||
const { promise, resolve, reject } = Promise.withResolvers<RunResultOk>();
|
||||
tab.pending.set(id, { resolve, reject });
|
||||
const abort = (): void => tab.worker.send({ type: "abort", id });
|
||||
if (opts.signal?.aborted) abort();
|
||||
else opts.signal?.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
tab.worker.send({ type: "run", id, name, code: opts.code, timeoutMs: opts.timeoutMs, session: snapshot });
|
||||
return await raceWithTimeout(
|
||||
promise,
|
||||
opts.timeoutMs + GRACE_MS,
|
||||
"Browser code execution hung past grace; tab killed",
|
||||
async reason => await forceKillTab(name, reason),
|
||||
);
|
||||
} finally {
|
||||
opts.signal?.removeEventListener("abort", abort);
|
||||
tab.pending.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function releaseTab(name: string, opts: ReleaseTabOptions = {}): Promise<boolean> {
|
||||
const tab = tabs.get(name);
|
||||
if (!tab) {
|
||||
logger.debug("releaseTab: unknown tab", { name });
|
||||
return false;
|
||||
}
|
||||
const wasAlive = tab.state === "alive";
|
||||
tab.state = "dead";
|
||||
const closeError = new ToolError(`Tab ${JSON.stringify(name)} was closed`);
|
||||
for (const [id, pending] of tab.pending) {
|
||||
try {
|
||||
tab.worker.send({ type: "abort", id });
|
||||
} catch {}
|
||||
pending.reject(closeError);
|
||||
}
|
||||
tab.pending.clear();
|
||||
let forced = false;
|
||||
if (wasAlive) {
|
||||
try {
|
||||
tab.worker.send({ type: "close" });
|
||||
await waitForClosed(tab);
|
||||
} catch {
|
||||
forced = true;
|
||||
}
|
||||
}
|
||||
await tab.worker.terminate().catch(() => undefined);
|
||||
if (forced && tab.kindTag === "headless") await closeOrphanTarget(tab);
|
||||
await releaseBrowser(tab.browser, { kill: opts.kill ?? false });
|
||||
tabs.delete(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function releaseAllTabs(opts: ReleaseTabOptions = {}): Promise<number> {
|
||||
const names = [...tabs.keys()];
|
||||
let count = 0;
|
||||
for (const name of names) {
|
||||
if (await releaseTab(name, opts)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export async function dropHeadlessTabs(): Promise<void> {
|
||||
const names = [...tabs.values()].filter(tab => tab.kindTag === "headless").map(tab => tab.name);
|
||||
for (const name of names) await releaseTab(name);
|
||||
}
|
||||
|
||||
async function buildInitPayload(browser: BrowserHandle, opts: AcquireTabOptions): Promise<WorkerInitPayload> {
|
||||
const safeDir = getPuppeteerDir();
|
||||
const browserWSEndpoint = browser.browser.wsEndpoint();
|
||||
if (!browserWSEndpoint) throw new ToolError("Browser websocket endpoint is unavailable");
|
||||
if (browser.kind.kind === "headless") {
|
||||
return {
|
||||
mode: "headless",
|
||||
browserWSEndpoint,
|
||||
safeDir,
|
||||
viewport: opts.viewport,
|
||||
dialogs: opts.dialogs,
|
||||
url: opts.url,
|
||||
waitUntil: opts.waitUntil,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
};
|
||||
}
|
||||
const page = await pickElectronTarget(browser.browser, opts.target);
|
||||
const targetId = await targetIdForPage(page);
|
||||
return {
|
||||
mode: "attach",
|
||||
browserWSEndpoint,
|
||||
safeDir,
|
||||
targetId,
|
||||
dialogs: opts.dialogs,
|
||||
};
|
||||
}
|
||||
|
||||
function handleTabMessage(tab: TabSession, msg: WorkerOutbound): void {
|
||||
if (msg.type === "result") {
|
||||
const pending = tab.pending.get(msg.id);
|
||||
if (!pending) return;
|
||||
tab.pending.delete(msg.id);
|
||||
if (msg.ok) {
|
||||
pending.resolve(msg.payload);
|
||||
return;
|
||||
}
|
||||
pending.reject(errorFromPayload(msg.error));
|
||||
return;
|
||||
}
|
||||
if (msg.type === "ready") {
|
||||
tab.info = msg.info;
|
||||
return;
|
||||
}
|
||||
if (msg.type === "log") logWorkerMessage(msg);
|
||||
}
|
||||
|
||||
async function forceKillTab(name: string, reason: string): Promise<void> {
|
||||
const tab = tabs.get(name);
|
||||
if (!tab) return;
|
||||
tab.state = "dead";
|
||||
const error = new ToolError(reason);
|
||||
for (const pending of tab.pending.values()) pending.reject(error);
|
||||
tab.pending.clear();
|
||||
await tab.worker.terminate().catch(() => undefined);
|
||||
if (tab.kindTag === "headless") await closeOrphanTarget(tab);
|
||||
await releaseBrowser(tab.browser, { kill: false });
|
||||
tabs.delete(name);
|
||||
}
|
||||
|
||||
async function closeOrphanTarget(tab: TabSession): Promise<void> {
|
||||
for (const target of tab.browser.browser.targets()) {
|
||||
if ((await targetIdForTarget(target).catch(() => "")) !== tab.targetId) continue;
|
||||
const page = await target.page().catch(() => null);
|
||||
await page?.close().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForClosed(tab: TabSession): Promise<void> {
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
const unsubscribe = tab.worker.onMessage(msg => {
|
||||
if (msg.type === "closed") resolve();
|
||||
});
|
||||
try {
|
||||
await raceWithTimeout(promise, GRACE_MS, "Timed out closing browser tab worker");
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
function expandBrowserScreenshotDir(session: ToolSession): string | undefined {
|
||||
const value = session.settings.get("browser.screenshotDir") as string | undefined;
|
||||
return value ? expandPath(value) : undefined;
|
||||
}
|
||||
|
||||
async function targetIdForPage(page: Page): Promise<string> {
|
||||
return await targetIdForTarget(page.target());
|
||||
}
|
||||
|
||||
async function targetIdForTarget(target: Target): Promise<string> {
|
||||
const raw = target as unknown as { _targetId?: unknown };
|
||||
if (typeof raw._targetId === "string") return raw._targetId;
|
||||
const session = await target.createCDPSession();
|
||||
try {
|
||||
const info = (await session.send("Target.getTargetInfo")) as { targetInfo?: { targetId?: string } };
|
||||
if (info.targetInfo?.targetId) return info.targetInfo.targetId;
|
||||
throw new ToolError("Target id unavailable from CDP target info");
|
||||
} finally {
|
||||
await session.detach().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function errorFromPayload(payload: RunErrorPayload): Error {
|
||||
const error = payload.isAbort
|
||||
? new ToolAbortError()
|
||||
: payload.isToolError
|
||||
? new ToolError(payload.message)
|
||||
: new Error(payload.message);
|
||||
error.name = payload.name;
|
||||
if (payload.stack) error.stack = payload.stack;
|
||||
return error;
|
||||
}
|
||||
|
||||
function logWorkerMessage(msg: Extract<WorkerOutbound, { type: "log" }>): void {
|
||||
if (msg.level === "debug") logger.debug(msg.msg, msg.meta);
|
||||
else if (msg.level === "warn") logger.warn(msg.msg, msg.meta);
|
||||
else logger.error(msg.msg, msg.meta);
|
||||
}
|
||||
|
||||
async function raceWithTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
reason: string,
|
||||
onTimeout?: (reason: string) => Promise<void>,
|
||||
): Promise<T> {
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
const { promise: timeoutPromise, reject } = Promise.withResolvers<never>();
|
||||
const onAbort = (): void => reject(new ToolError(reason));
|
||||
timeoutSignal.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise]);
|
||||
} catch (error) {
|
||||
if (error instanceof ToolError && error.message === reason) await onTimeout?.(reason);
|
||||
throw error;
|
||||
} finally {
|
||||
timeoutSignal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnTabWorker(): Promise<WorkerHandle> {
|
||||
try {
|
||||
const url = new URL("./tab-worker-entry.ts", import.meta.url);
|
||||
const worker = new Worker(url.href, { type: "module" });
|
||||
return wrapBunWorker(worker);
|
||||
} catch (err) {
|
||||
logger.warn("Bun Worker spawn failed; using inline tab worker (no sync-loop guard)", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return spawnInlineWorker();
|
||||
}
|
||||
}
|
||||
|
||||
function wrapBunWorker(worker: Worker): WorkerHandle {
|
||||
return {
|
||||
mode: "worker",
|
||||
send(msg, transferList) {
|
||||
worker.postMessage(msg, { transfer: transferList ?? [] });
|
||||
},
|
||||
onMessage(handler) {
|
||||
const wrap = (event: MessageEvent): void => handler(event.data as WorkerOutbound);
|
||||
worker.addEventListener("message", wrap);
|
||||
return () => worker.removeEventListener("message", wrap);
|
||||
},
|
||||
async terminate() {
|
||||
worker.terminate();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline fallback for environments where Bun cannot compile or spawn the worker
|
||||
* entry. This preserves normal browser behavior but cannot interrupt synchronous
|
||||
* infinite loops because user code runs on the main thread.
|
||||
*/
|
||||
function spawnInlineWorker(): WorkerHandle {
|
||||
const hostListeners = new Set<(message: WorkerOutbound) => void>();
|
||||
const workerListeners = new Set<(message: WorkerInbound) => void>();
|
||||
const workerTransport: Transport = {
|
||||
send: msg =>
|
||||
queueMicrotask(() => {
|
||||
for (const listener of hostListeners) listener(msg as WorkerOutbound);
|
||||
}),
|
||||
onMessage: handler => {
|
||||
const typed = handler as (message: WorkerInbound) => void;
|
||||
workerListeners.add(typed);
|
||||
return () => workerListeners.delete(typed);
|
||||
},
|
||||
close: () => {},
|
||||
};
|
||||
new WorkerCore(workerTransport);
|
||||
return {
|
||||
mode: "inline",
|
||||
send: msg =>
|
||||
queueMicrotask(() => {
|
||||
for (const listener of workerListeners) listener(msg);
|
||||
}),
|
||||
onMessage: handler => {
|
||||
hostListeners.add(handler);
|
||||
return () => hostListeners.delete(handler);
|
||||
},
|
||||
async terminate() {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { parentPort } from "node:worker_threads";
|
||||
import type { Transport, WorkerInbound, WorkerOutbound } from "./tab-protocol";
|
||||
import { WorkerCore } from "./tab-worker";
|
||||
|
||||
if (!parentPort) throw new Error("tab-worker-entry: missing parentPort");
|
||||
|
||||
const transport: Transport = {
|
||||
send(msg, transferList) {
|
||||
parentPort!.postMessage(msg, transferList ?? []);
|
||||
},
|
||||
onMessage(handler) {
|
||||
const wrap = (message: unknown): void => handler(message as WorkerOutbound | WorkerInbound);
|
||||
parentPort!.on("message", wrap);
|
||||
return () => parentPort!.off("message", wrap);
|
||||
},
|
||||
close() {
|
||||
parentPort!.close();
|
||||
},
|
||||
};
|
||||
|
||||
new WorkerCore(transport);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,826 +0,0 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
|
||||
import { Snowflake, untilAborted } from "@oh-my-pi/pi-utils";
|
||||
import type { HTMLElement } from "linkedom";
|
||||
import type { ElementHandle, HTTPResponse, KeyInput, Page, SerializedAXNode } from "puppeteer-core";
|
||||
import type { ToolSession } from "../../sdk";
|
||||
import { resizeImage } from "../../utils/image-resize";
|
||||
import { expandPath, resolveToCwd } from "../path-utils";
|
||||
import { formatScreenshot } from "../render-utils";
|
||||
import { ToolAbortError, ToolError, throwIfAborted } from "../tool-errors";
|
||||
import { DEFAULT_VIEWPORT } from "./launch";
|
||||
import { extractReadableFromHtml, type ReadableFormat, type ReadableResult } from "./readable";
|
||||
import { clearElementCache, resolveCachedHandle, type TabHandle } from "./registry";
|
||||
|
||||
declare global {
|
||||
interface Element extends HTMLElement {}
|
||||
function getComputedStyle(element: Element): Record<string, unknown>;
|
||||
var innerWidth: number;
|
||||
var innerHeight: number;
|
||||
var document: {
|
||||
elementFromPoint(x: number, y: number): Element | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ObservationEntry {
|
||||
id: number;
|
||||
role: string;
|
||||
name?: string;
|
||||
value?: string | number;
|
||||
description?: string;
|
||||
keyshortcuts?: string;
|
||||
states: string[];
|
||||
}
|
||||
|
||||
export interface Observation {
|
||||
url: string;
|
||||
title?: string;
|
||||
viewport: { width: number; height: number; deviceScaleFactor?: number };
|
||||
scroll: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
scrollWidth: number;
|
||||
scrollHeight: number;
|
||||
};
|
||||
elements: ObservationEntry[];
|
||||
}
|
||||
|
||||
const INTERACTIVE_AX_ROLES = new Set([
|
||||
"button",
|
||||
"link",
|
||||
"textbox",
|
||||
"combobox",
|
||||
"listbox",
|
||||
"option",
|
||||
"checkbox",
|
||||
"radio",
|
||||
"switch",
|
||||
"tab",
|
||||
"menuitem",
|
||||
"menuitemcheckbox",
|
||||
"menuitemradio",
|
||||
"slider",
|
||||
"spinbutton",
|
||||
"searchbox",
|
||||
"treeitem",
|
||||
]);
|
||||
|
||||
const LEGACY_SELECTOR_PREFIXES = ["p-aria/", "p-text/", "p-xpath/", "p-pierce/"] as const;
|
||||
|
||||
function normalizeSelector(selector: string): string {
|
||||
if (!selector) return selector;
|
||||
if (selector.startsWith("p-") && !LEGACY_SELECTOR_PREFIXES.some(prefix => selector.startsWith(prefix))) {
|
||||
throw new ToolError(
|
||||
`Unsupported selector prefix. Use CSS or puppeteer query handlers (aria/, text/, xpath/, pierce/). Got: ${selector}`,
|
||||
);
|
||||
}
|
||||
if (selector.startsWith("p-text/")) {
|
||||
return `text/${selector.slice("p-text/".length)}`;
|
||||
}
|
||||
if (selector.startsWith("p-xpath/")) {
|
||||
return `xpath/${selector.slice("p-xpath/".length)}`;
|
||||
}
|
||||
if (selector.startsWith("p-pierce/")) {
|
||||
return `pierce/${selector.slice("p-pierce/".length)}`;
|
||||
}
|
||||
if (selector.startsWith("p-aria/")) {
|
||||
const rest = selector.slice("p-aria/".length);
|
||||
const nameMatch = rest.match(/\[\s*name\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\]]+))\s*\]/);
|
||||
const name = nameMatch?.[1] ?? nameMatch?.[2] ?? nameMatch?.[3];
|
||||
if (name) return `aria/${name.trim()}`;
|
||||
return `aria/${rest}`;
|
||||
}
|
||||
return selector;
|
||||
}
|
||||
|
||||
function isInteractiveNode(node: SerializedAXNode): boolean {
|
||||
if (INTERACTIVE_AX_ROLES.has(node.role)) return true;
|
||||
return (
|
||||
node.checked !== undefined ||
|
||||
node.pressed !== undefined ||
|
||||
node.selected !== undefined ||
|
||||
node.expanded !== undefined ||
|
||||
node.focused === true
|
||||
);
|
||||
}
|
||||
|
||||
async function collectObservationEntries(
|
||||
tab: TabHandle,
|
||||
node: SerializedAXNode,
|
||||
entries: ObservationEntry[],
|
||||
options: { viewportOnly: boolean; includeAll: boolean },
|
||||
): Promise<void> {
|
||||
if (options.includeAll || isInteractiveNode(node)) {
|
||||
const handle = await node.elementHandle();
|
||||
if (handle) {
|
||||
let inViewport = true;
|
||||
if (options.viewportOnly) {
|
||||
try {
|
||||
inViewport = await handle.isIntersectingViewport();
|
||||
} catch {
|
||||
inViewport = false;
|
||||
}
|
||||
}
|
||||
if (inViewport) {
|
||||
const id = ++tab.elementCounter;
|
||||
const states: string[] = [];
|
||||
if (node.disabled) states.push("disabled");
|
||||
if (node.checked !== undefined) states.push(`checked=${String(node.checked)}`);
|
||||
if (node.pressed !== undefined) states.push(`pressed=${String(node.pressed)}`);
|
||||
if (node.selected !== undefined) states.push(`selected=${String(node.selected)}`);
|
||||
if (node.expanded !== undefined) states.push(`expanded=${String(node.expanded)}`);
|
||||
if (node.required) states.push("required");
|
||||
if (node.readonly) states.push("readonly");
|
||||
if (node.multiselectable) states.push("multiselectable");
|
||||
if (node.multiline) states.push("multiline");
|
||||
if (node.modal) states.push("modal");
|
||||
if (node.focused) states.push("focused");
|
||||
tab.elementCache.set(id, handle);
|
||||
entries.push({
|
||||
id,
|
||||
role: node.role,
|
||||
name: node.name,
|
||||
value: node.value,
|
||||
description: node.description,
|
||||
keyshortcuts: node.keyshortcuts,
|
||||
states,
|
||||
});
|
||||
} else {
|
||||
await handle.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const child of node.children ?? []) {
|
||||
await collectObservationEntries(tab, child, entries, options);
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectObservation(
|
||||
tab: TabHandle,
|
||||
options: { includeAll?: boolean; viewportOnly?: boolean; signal?: AbortSignal },
|
||||
): Promise<Observation> {
|
||||
clearElementCache(tab);
|
||||
const includeAll = options.includeAll ?? false;
|
||||
const viewportOnly = options.viewportOnly ?? false;
|
||||
const snapshot = (await untilAborted(options.signal, () =>
|
||||
tab.page.accessibility.snapshot({ interestingOnly: !includeAll }),
|
||||
)) as SerializedAXNode | null;
|
||||
if (!snapshot) {
|
||||
throw new ToolError("Accessibility snapshot unavailable");
|
||||
}
|
||||
const entries: ObservationEntry[] = [];
|
||||
await collectObservationEntries(tab, snapshot, entries, { includeAll, viewportOnly });
|
||||
const scroll = (await untilAborted(options.signal, () =>
|
||||
tab.page.evaluate(() => {
|
||||
const win = globalThis as unknown as {
|
||||
scrollX: number;
|
||||
scrollY: number;
|
||||
innerWidth: number;
|
||||
innerHeight: number;
|
||||
document: { documentElement: { scrollWidth: number; scrollHeight: number } };
|
||||
};
|
||||
const doc = win.document.documentElement;
|
||||
return {
|
||||
x: win.scrollX,
|
||||
y: win.scrollY,
|
||||
width: win.innerWidth,
|
||||
height: win.innerHeight,
|
||||
scrollWidth: doc.scrollWidth,
|
||||
scrollHeight: doc.scrollHeight,
|
||||
};
|
||||
}),
|
||||
)) as Observation["scroll"];
|
||||
const url = tab.page.url();
|
||||
const title = (await untilAborted(options.signal, () => tab.page.title())) as string;
|
||||
const viewport = tab.page.viewport() ?? DEFAULT_VIEWPORT;
|
||||
return { url, title, viewport, scroll, elements: entries };
|
||||
}
|
||||
|
||||
export function formatObservation(observation: Observation): string {
|
||||
const viewport = `${observation.viewport.width}x${observation.viewport.height}`;
|
||||
const scroll = `x=${observation.scroll.x} y=${observation.scroll.y} viewport=${observation.scroll.width}x${observation.scroll.height} doc=${observation.scroll.scrollWidth}x${observation.scroll.scrollHeight}`;
|
||||
const lines = [
|
||||
`URL: ${observation.url}`,
|
||||
observation.title ? `Title: ${observation.title}` : "Title:",
|
||||
`Viewport: ${viewport}`,
|
||||
`Scroll: ${scroll}`,
|
||||
"Elements:",
|
||||
];
|
||||
for (const entry of observation.elements) {
|
||||
const name = entry.name ? ` "${entry.name}"` : "";
|
||||
const value = entry.value !== undefined ? ` value=${JSON.stringify(entry.value)}` : "";
|
||||
const description = entry.description ? ` desc=${JSON.stringify(entry.description)}` : "";
|
||||
const shortcuts = entry.keyshortcuts ? ` shortcuts=${JSON.stringify(entry.keyshortcuts)}` : "";
|
||||
const state = entry.states.length ? ` (${entry.states.join(", ")})` : "";
|
||||
lines.push(`${entry.id}. ${entry.role}${name}${value}${description}${shortcuts}${state}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Click resolution helpers (text/aria selectors with visibility filtering)
|
||||
// =====================================================================
|
||||
|
||||
type ActionabilityResult = { ok: true; x: number; y: number } | { ok: false; reason: string };
|
||||
|
||||
async function resolveActionableQueryHandlerClickTarget(handles: ElementHandle[]): Promise<ElementHandle | null> {
|
||||
const candidates: Array<{
|
||||
handle: ElementHandle;
|
||||
rect: { x: number; y: number; w: number; h: number };
|
||||
ownedProxy?: ElementHandle;
|
||||
}> = [];
|
||||
|
||||
for (const handle of handles) {
|
||||
let clickable: ElementHandle = handle;
|
||||
let clickableProxy: ElementHandle | null = null;
|
||||
try {
|
||||
const proxy = await handle.evaluateHandle(el => {
|
||||
const target =
|
||||
(el as Element).closest(
|
||||
'a,button,[role="button"],[role="link"],input[type="button"],input[type="submit"]',
|
||||
) ?? el;
|
||||
return target;
|
||||
});
|
||||
const nodeHandle = proxy.asElement();
|
||||
clickableProxy = nodeHandle ? (nodeHandle as unknown as ElementHandle) : null;
|
||||
if (clickableProxy) {
|
||||
clickable = clickableProxy;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const intersecting = await clickable.isIntersectingViewport();
|
||||
if (!intersecting) continue;
|
||||
const rect = (await clickable.evaluate(el => {
|
||||
const r = (el as Element).getBoundingClientRect();
|
||||
return { x: r.left, y: r.top, w: r.width, h: r.height };
|
||||
})) as { x: number; y: number; w: number; h: number };
|
||||
if (rect.w < 1 || rect.h < 1) continue;
|
||||
candidates.push({ handle: clickable, rect, ownedProxy: clickableProxy ?? undefined });
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
if (clickableProxy && clickableProxy !== handle && clickable !== clickableProxy) {
|
||||
try {
|
||||
await clickableProxy.dispose();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidates.length) return null;
|
||||
|
||||
candidates.sort((a, b) => a.rect.y - b.rect.y || a.rect.x - b.rect.x);
|
||||
const winner = candidates[0]?.handle ?? null;
|
||||
for (let i = 1; i < candidates.length; i++) {
|
||||
const c = candidates[i]!;
|
||||
if (c.ownedProxy) {
|
||||
try {
|
||||
await c.ownedProxy.dispose();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return winner;
|
||||
}
|
||||
|
||||
async function isClickActionable(handle: ElementHandle): Promise<ActionabilityResult> {
|
||||
return (await handle.evaluate(el => {
|
||||
const element = el as HTMLElement;
|
||||
const style = globalThis.getComputedStyle(element);
|
||||
if (style.display === "none") return { ok: false as const, reason: "display:none" };
|
||||
if (style.visibility === "hidden") return { ok: false as const, reason: "visibility:hidden" };
|
||||
if (style.pointerEvents === "none") return { ok: false as const, reason: "pointer-events:none" };
|
||||
if (Number(style.opacity) === 0) return { ok: false as const, reason: "opacity:0" };
|
||||
|
||||
const r = element.getBoundingClientRect();
|
||||
if (r.width < 1 || r.height < 1) return { ok: false as const, reason: "zero-size" };
|
||||
|
||||
const vw = globalThis.innerWidth;
|
||||
const vh = globalThis.innerHeight;
|
||||
const left = Math.max(0, Math.min(vw, r.left));
|
||||
const right = Math.max(0, Math.min(vw, r.right));
|
||||
const top = Math.max(0, Math.min(vh, r.top));
|
||||
const bottom = Math.max(0, Math.min(vh, r.bottom));
|
||||
if (right - left < 1 || bottom - top < 1) return { ok: false as const, reason: "off-viewport" };
|
||||
|
||||
const x = Math.floor((left + right) / 2);
|
||||
const y = Math.floor((top + bottom) / 2);
|
||||
const topEl = globalThis.document.elementFromPoint(x, y);
|
||||
if (!topEl) return { ok: false as const, reason: "elementFromPoint-null" };
|
||||
if (topEl === element || element.contains(topEl) || (topEl as Element).contains(element)) {
|
||||
return { ok: true as const, x, y };
|
||||
}
|
||||
return { ok: false as const, reason: "obscured" };
|
||||
})) as ActionabilityResult;
|
||||
}
|
||||
|
||||
async function clickQueryHandlerText(
|
||||
page: Page,
|
||||
selector: string,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
const clickSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
||||
const start = Date.now();
|
||||
let lastSeen = 0;
|
||||
let lastReason: string | null = null;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
throwIfAborted(clickSignal);
|
||||
const handles = (await untilAborted(clickSignal, () => page.$$(selector))) as ElementHandle[];
|
||||
try {
|
||||
lastSeen = handles.length;
|
||||
const target = await resolveActionableQueryHandlerClickTarget(handles);
|
||||
if (!target) {
|
||||
lastReason = handles.length ? "no-visible-candidate" : "no-matches";
|
||||
await Bun.sleep(100);
|
||||
continue;
|
||||
}
|
||||
const actionability = await isClickActionable(target);
|
||||
if (!actionability.ok) {
|
||||
lastReason = actionability.reason;
|
||||
await Bun.sleep(100);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await untilAborted(clickSignal, () => target.click());
|
||||
return;
|
||||
} catch (err) {
|
||||
lastReason = err instanceof Error ? err.message : String(err);
|
||||
await Bun.sleep(100);
|
||||
}
|
||||
} finally {
|
||||
await Promise.all(
|
||||
handles.map(async h => {
|
||||
try {
|
||||
await h.dispose();
|
||||
} catch {}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new ToolError(
|
||||
`Timed out clicking ${selector} (seen ${lastSeen} matches; last reason: ${lastReason ?? "unknown"}). ` +
|
||||
"If there are multiple matching elements, use observe + tab.id() or a more specific selector.",
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Tab API surface (visible to user code as `tab`)
|
||||
// =====================================================================
|
||||
|
||||
export interface ScreenshotOptions {
|
||||
selector?: string;
|
||||
fullPage?: boolean;
|
||||
save?: string;
|
||||
silent?: boolean;
|
||||
}
|
||||
|
||||
export interface ScreenshotResult {
|
||||
dest: string;
|
||||
mimeType: string;
|
||||
bytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type DragTarget = string | { readonly x: number; readonly y: number };
|
||||
|
||||
export interface TabApi {
|
||||
readonly name: string;
|
||||
readonly page: Page;
|
||||
readonly signal?: AbortSignal;
|
||||
url(): string;
|
||||
title(): Promise<string>;
|
||||
goto(
|
||||
url: string,
|
||||
opts?: { waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2" },
|
||||
): Promise<void>;
|
||||
observe(opts?: { includeAll?: boolean; viewportOnly?: boolean }): Promise<Observation>;
|
||||
screenshot(opts?: ScreenshotOptions): Promise<ScreenshotResult>;
|
||||
extract(format?: ReadableFormat): Promise<ReadableResult | null>;
|
||||
click(selector: string): Promise<void>;
|
||||
type(selector: string, text: string): Promise<void>;
|
||||
fill(selector: string, value: string): Promise<void>;
|
||||
press(key: KeyInput, opts?: { selector?: string }): Promise<void>;
|
||||
scroll(deltaX: number, deltaY: number): Promise<void>;
|
||||
drag(from: DragTarget, to: DragTarget): Promise<void>;
|
||||
waitFor(selector: string): Promise<ElementHandle>;
|
||||
evaluate<TResult, TArgs extends unknown[]>(
|
||||
fn: string | ((...args: TArgs) => TResult | Promise<TResult>),
|
||||
...args: TArgs
|
||||
): Promise<TResult>;
|
||||
scrollIntoView(selector: string): Promise<void>;
|
||||
select(selector: string, ...values: string[]): Promise<string[]>;
|
||||
uploadFile(selector: string, ...filePaths: string[]): Promise<void>;
|
||||
waitForUrl(pattern: string | RegExp, opts?: { timeout?: number }): Promise<string>;
|
||||
waitForResponse(
|
||||
pattern: string | RegExp | ((response: HTTPResponse) => boolean | Promise<boolean>),
|
||||
opts?: { timeout?: number },
|
||||
): Promise<HTTPResponse>;
|
||||
id(n: number): Promise<ElementHandle>;
|
||||
}
|
||||
|
||||
export interface RunInTabOptions {
|
||||
tab: TabHandle;
|
||||
code: string;
|
||||
timeoutMs: number;
|
||||
signal?: AbortSignal;
|
||||
session: ToolSession;
|
||||
}
|
||||
|
||||
export interface RunInTabResult {
|
||||
displays: Array<TextContent | ImageContent>;
|
||||
returnValue: unknown;
|
||||
screenshots: ScreenshotResult[];
|
||||
}
|
||||
|
||||
const AsyncFunctionCtor = Object.getPrototypeOf(async () => {}).constructor as new (
|
||||
...args: string[]
|
||||
) => (...args: unknown[]) => Promise<unknown>;
|
||||
|
||||
export async function runInTab(opts: RunInTabOptions): Promise<RunInTabResult> {
|
||||
const { tab, code, timeoutMs, signal: outerSignal, session } = opts;
|
||||
// Compose a combined signal that aborts when either the caller cancels OR the
|
||||
// per-execution wall-clock timeout fires. This guards against user code that
|
||||
// awaits a never-resolving promise; synchronous infinite loops still block the
|
||||
// event loop until they yield.
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
const signal = outerSignal ? AbortSignal.any([outerSignal, timeoutSignal]) : timeoutSignal;
|
||||
throwIfAborted(outerSignal);
|
||||
const displays: Array<TextContent | ImageContent> = [];
|
||||
const screenshots: ScreenshotResult[] = [];
|
||||
|
||||
const display = (value: unknown): void => {
|
||||
if (value === undefined || value === null) return;
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"type" in (value as Record<string, unknown>) &&
|
||||
(value as { type?: unknown }).type === "image"
|
||||
) {
|
||||
const img = value as { data?: unknown; mimeType?: unknown };
|
||||
if (typeof img.data === "string" && typeof img.mimeType === "string") {
|
||||
displays.push({ type: "image", data: img.data, mimeType: img.mimeType });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
displays.push({ type: "text", text: value });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
displays.push({ type: "text", text: JSON.stringify(value, null, 2) });
|
||||
} catch {
|
||||
displays.push({ type: "text", text: String(value) });
|
||||
}
|
||||
};
|
||||
|
||||
const assertFn = (cond: unknown, msg?: string): void => {
|
||||
if (!cond) throw new ToolError(msg ?? "Assertion failed");
|
||||
};
|
||||
|
||||
const wait = (ms: number): Promise<void> => Bun.sleep(ms);
|
||||
|
||||
const tabApi: TabApi = {
|
||||
name: tab.name,
|
||||
page: tab.page,
|
||||
signal,
|
||||
url: () => tab.page.url(),
|
||||
title: () => tab.page.title(),
|
||||
goto: async (url, gOpts) => {
|
||||
clearElementCache(tab);
|
||||
await untilAborted(signal, () =>
|
||||
tab.page.goto(url, {
|
||||
waitUntil: gOpts?.waitUntil ?? "networkidle2",
|
||||
timeout: timeoutMs,
|
||||
}),
|
||||
);
|
||||
},
|
||||
observe: opts2 => collectObservation(tab, { ...opts2, signal }),
|
||||
screenshot: async opts2 => {
|
||||
const result = await captureScreenshot(tab, session, displays, screenshots, signal, opts2);
|
||||
return result;
|
||||
},
|
||||
extract: async (format = "markdown") => {
|
||||
const html = (await untilAborted(signal, () => tab.page.content())) as string;
|
||||
return extractReadableFromHtml(html, tab.page.url(), format);
|
||||
},
|
||||
click: async selector => {
|
||||
const resolved = normalizeSelector(selector);
|
||||
if (resolved.startsWith("text/")) {
|
||||
await clickQueryHandlerText(tab.page, resolved, timeoutMs, signal);
|
||||
} else {
|
||||
const locator = tab.page.locator(resolved).setTimeout(timeoutMs);
|
||||
await untilAborted(signal, () => locator.click());
|
||||
}
|
||||
},
|
||||
type: async (selector, text) => {
|
||||
const resolved = normalizeSelector(selector);
|
||||
const locator = tab.page.locator(resolved).setTimeout(timeoutMs);
|
||||
const handle = (await untilAborted(signal, () => locator.waitHandle())) as ElementHandle;
|
||||
try {
|
||||
await untilAborted(signal, () => handle.type(text, { delay: 0 }));
|
||||
} finally {
|
||||
await handle.dispose();
|
||||
}
|
||||
},
|
||||
fill: async (selector, value) => {
|
||||
const resolved = normalizeSelector(selector);
|
||||
const locator = tab.page.locator(resolved).setTimeout(timeoutMs);
|
||||
await untilAborted(signal, () => locator.fill(value));
|
||||
},
|
||||
press: async (key, opts2) => {
|
||||
if (opts2?.selector) {
|
||||
const resolved = normalizeSelector(opts2.selector);
|
||||
await untilAborted(signal, () => tab.page.focus(resolved));
|
||||
}
|
||||
await untilAborted(signal, () => tab.page.keyboard.press(key));
|
||||
},
|
||||
scroll: async (deltaX, deltaY) => {
|
||||
await untilAborted(signal, () => tab.page.mouse.wheel({ deltaX, deltaY }));
|
||||
},
|
||||
drag: async (from, to) => {
|
||||
const resolveDragPoint = async (
|
||||
target: DragTarget,
|
||||
role: "from" | "to",
|
||||
): Promise<{ x: number; y: number; handle?: ElementHandle }> => {
|
||||
if (typeof target === "string") {
|
||||
const resolved = normalizeSelector(target);
|
||||
const handle = (await untilAborted(signal, () => tab.page.$(resolved))) as ElementHandle | null;
|
||||
if (!handle) throw new ToolError(`Drag ${role} selector did not resolve: ${target}`);
|
||||
const box = (await untilAborted(signal, () => handle.boundingBox())) as {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null;
|
||||
if (!box) {
|
||||
await handle.dispose().catch(() => undefined);
|
||||
throw new ToolError(`Drag ${role} element has no bounding box (likely not visible): ${target}`);
|
||||
}
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2, handle };
|
||||
}
|
||||
if (
|
||||
target !== null &&
|
||||
typeof target === "object" &&
|
||||
typeof (target as { x: unknown }).x === "number" &&
|
||||
typeof (target as { y: unknown }).y === "number"
|
||||
) {
|
||||
return { x: (target as { x: number }).x, y: (target as { y: number }).y };
|
||||
}
|
||||
throw new ToolError(
|
||||
`Drag ${role} must be a selector string or { x: number, y: number } point. Got: ${typeof target}`,
|
||||
);
|
||||
};
|
||||
const start = await resolveDragPoint(from, "from");
|
||||
let end: { x: number; y: number; handle?: ElementHandle } | undefined;
|
||||
try {
|
||||
end = await resolveDragPoint(to, "to");
|
||||
await untilAborted(signal, () => tab.page.mouse.move(start.x, start.y));
|
||||
await untilAborted(signal, () => tab.page.mouse.down());
|
||||
await untilAborted(signal, () => tab.page.mouse.move(end!.x, end!.y, { steps: 12 }));
|
||||
await untilAborted(signal, () => tab.page.mouse.up());
|
||||
} finally {
|
||||
if (start.handle) await start.handle.dispose().catch(() => undefined);
|
||||
if (end?.handle) await end.handle.dispose().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
waitFor: async selector => {
|
||||
const resolved = normalizeSelector(selector);
|
||||
const locator = tab.page.locator(resolved).setTimeout(timeoutMs);
|
||||
return (await untilAborted(signal, () => locator.waitHandle())) as ElementHandle;
|
||||
},
|
||||
evaluate: async (fn, ...args) => {
|
||||
return (await untilAborted(signal, () =>
|
||||
typeof fn === "string"
|
||||
? tab.page.evaluate(fn)
|
||||
: tab.page.evaluate(fn as (...a: unknown[]) => unknown, ...args),
|
||||
)) as never;
|
||||
},
|
||||
scrollIntoView: async selector => {
|
||||
const resolved = normalizeSelector(selector);
|
||||
const locator = tab.page.locator(resolved).setTimeout(timeoutMs);
|
||||
const handle = (await untilAborted(signal, () => locator.waitHandle())) as ElementHandle;
|
||||
try {
|
||||
await untilAborted(signal, () =>
|
||||
handle.evaluate(el => {
|
||||
const target = el as unknown as {
|
||||
scrollIntoView: (opts: { behavior: string; block: string; inline: string }) => void;
|
||||
};
|
||||
target.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await handle.dispose().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
select: async (selector, ...values) => {
|
||||
const resolved = normalizeSelector(selector);
|
||||
const locator = tab.page.locator(resolved).setTimeout(timeoutMs);
|
||||
const handle = (await untilAborted(signal, () => locator.waitHandle())) as ElementHandle;
|
||||
try {
|
||||
return (await untilAborted(signal, () =>
|
||||
handle.evaluate((el, vals) => {
|
||||
interface SelectOption {
|
||||
value: string;
|
||||
selected: boolean;
|
||||
}
|
||||
interface SelectLike {
|
||||
tagName: string;
|
||||
options: ArrayLike<SelectOption>;
|
||||
dispatchEvent: (event: unknown) => boolean;
|
||||
}
|
||||
const select = el as unknown as SelectLike;
|
||||
if (!select || select.tagName !== "SELECT") {
|
||||
throw new Error("tab.select() requires a <select> element");
|
||||
}
|
||||
const EventCtor = (
|
||||
globalThis as unknown as { Event: new (type: string, init?: { bubbles: boolean }) => unknown }
|
||||
).Event;
|
||||
const wanted = new Set(vals as string[]);
|
||||
const selected: string[] = [];
|
||||
for (let i = 0; i < select.options.length; i++) {
|
||||
const opt = select.options[i] as SelectOption;
|
||||
opt.selected = wanted.has(opt.value);
|
||||
if (opt.selected) selected.push(opt.value);
|
||||
}
|
||||
select.dispatchEvent(new EventCtor("input", { bubbles: true }));
|
||||
select.dispatchEvent(new EventCtor("change", { bubbles: true }));
|
||||
return selected;
|
||||
}, values),
|
||||
)) as string[];
|
||||
} finally {
|
||||
await handle.dispose().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
uploadFile: async (selector, ...filePaths) => {
|
||||
if (!filePaths.length) {
|
||||
throw new ToolError("tab.uploadFile() requires at least one file path");
|
||||
}
|
||||
const resolved = normalizeSelector(selector);
|
||||
const locator = tab.page.locator(resolved).setTimeout(timeoutMs);
|
||||
const handle = (await untilAborted(signal, () => locator.waitHandle())) as ElementHandle;
|
||||
try {
|
||||
const absolute = filePaths.map(p => resolveToCwd(p, session.cwd));
|
||||
const upload = handle as unknown as { uploadFile: (...paths: string[]) => Promise<void> };
|
||||
const tagName = (await untilAborted(signal, () =>
|
||||
handle.evaluate(el => (el as unknown as { tagName: string }).tagName),
|
||||
)) as string;
|
||||
if (tagName !== "INPUT") {
|
||||
throw new ToolError(
|
||||
`tab.uploadFile() requires an <input type="file"> element (got <${tagName.toLowerCase()}>)`,
|
||||
);
|
||||
}
|
||||
await untilAborted(signal, () => upload.uploadFile(...absolute));
|
||||
} finally {
|
||||
await handle.dispose().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
waitForUrl: async (pattern, wOpts) => {
|
||||
const timeout = wOpts?.timeout ?? timeoutMs;
|
||||
const isRegex = pattern instanceof RegExp;
|
||||
const matcher = isRegex ? pattern.source : pattern;
|
||||
const flags = isRegex ? pattern.flags : "";
|
||||
await untilAborted(signal, () =>
|
||||
tab.page.waitForFunction(
|
||||
(m: string, isRe: boolean, fl: string) => {
|
||||
const url = (globalThis as unknown as { location: { href: string } }).location.href;
|
||||
return isRe ? new RegExp(m, fl).test(url) : url.includes(m);
|
||||
},
|
||||
{ timeout, polling: 200 },
|
||||
matcher,
|
||||
isRegex,
|
||||
flags,
|
||||
),
|
||||
);
|
||||
return tab.page.url();
|
||||
},
|
||||
waitForResponse: async (pattern, wOpts) => {
|
||||
const timeout = wOpts?.timeout ?? timeoutMs;
|
||||
const predicate: (response: HTTPResponse) => boolean | Promise<boolean> =
|
||||
typeof pattern === "function"
|
||||
? pattern
|
||||
: pattern instanceof RegExp
|
||||
? response => pattern.test(response.url())
|
||||
: response => response.url().includes(pattern);
|
||||
return (await untilAborted(signal, () => tab.page.waitForResponse(predicate, { timeout }))) as HTTPResponse;
|
||||
},
|
||||
id: async n => resolveCachedHandle(tab, n),
|
||||
};
|
||||
|
||||
const fn = new AsyncFunctionCtor("page", "browser", "tab", "display", "assert", "wait", code);
|
||||
|
||||
// Race the user's async function against the combined signal so cancellation
|
||||
// (caller abort or wall-clock timeout) escapes promptly even when `code` is
|
||||
// blocked in a raw puppeteer call (e.g. `handle.click()`) that does not
|
||||
// consult the signal. The orphaned `fn()` may keep running until its CDP
|
||||
// request settles; we accept that to avoid closing the user's tab on every
|
||||
// cancellation.
|
||||
const { promise: cancelRejection, reject: rejectCancel } = Promise.withResolvers<never>();
|
||||
const onCancel = (): void => {
|
||||
if (timeoutSignal.aborted) {
|
||||
rejectCancel(new ToolError(`Browser code execution timed out after ${timeoutMs}ms`));
|
||||
} else {
|
||||
rejectCancel(new ToolAbortError());
|
||||
}
|
||||
};
|
||||
if (signal.aborted) {
|
||||
onCancel();
|
||||
} else {
|
||||
signal.addEventListener("abort", onCancel, { once: true });
|
||||
}
|
||||
try {
|
||||
const returnValue = await Promise.race([
|
||||
fn(tab.page, tab.browser.browser, tabApi, display, assertFn, wait),
|
||||
cancelRejection,
|
||||
]);
|
||||
return { displays, returnValue, screenshots };
|
||||
} finally {
|
||||
signal.removeEventListener("abort", onCancel);
|
||||
}
|
||||
}
|
||||
|
||||
async function captureScreenshot(
|
||||
tab: TabHandle,
|
||||
session: ToolSession,
|
||||
displays: Array<TextContent | ImageContent>,
|
||||
screenshots: ScreenshotResult[],
|
||||
signal: AbortSignal | undefined,
|
||||
opts: ScreenshotOptions = {},
|
||||
): Promise<ScreenshotResult> {
|
||||
const fullPage = opts.selector ? false : (opts.fullPage ?? false);
|
||||
let buffer: Buffer;
|
||||
if (opts.selector) {
|
||||
const resolved = normalizeSelector(opts.selector);
|
||||
const handle = (await untilAborted(signal, () => tab.page.$(resolved))) as ElementHandle | null;
|
||||
if (!handle) {
|
||||
throw new ToolError("Screenshot selector did not resolve to an element");
|
||||
}
|
||||
try {
|
||||
buffer = (await untilAborted(signal, () => handle.screenshot({ type: "png" }))) as Buffer;
|
||||
} finally {
|
||||
await handle.dispose().catch(() => undefined);
|
||||
}
|
||||
} else {
|
||||
buffer = (await untilAborted(signal, () => tab.page.screenshot({ type: "png", fullPage }))) as Buffer;
|
||||
}
|
||||
|
||||
// Compress aggressively for the model copy.
|
||||
const resized = await resizeImage(
|
||||
{ type: "image", data: buffer.toBase64(), mimeType: "image/png" },
|
||||
{ maxWidth: 1024, maxHeight: 1024, maxBytes: 150 * 1024, jpegQuality: 70 },
|
||||
);
|
||||
|
||||
const screenshotDir = (() => {
|
||||
const v = session.settings.get("browser.screenshotDir") as string | undefined;
|
||||
return v ? expandPath(v) : undefined;
|
||||
})();
|
||||
const explicitPath = opts.save ? resolveToCwd(opts.save, session.cwd) : undefined;
|
||||
let dest: string;
|
||||
if (explicitPath) {
|
||||
dest = explicitPath;
|
||||
} else if (screenshotDir) {
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1);
|
||||
dest = path.join(screenshotDir, `screenshot-${ts}.png`);
|
||||
} else {
|
||||
dest = path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.png`);
|
||||
}
|
||||
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
|
||||
const saveFullRes = !!(explicitPath || screenshotDir);
|
||||
const savedBuffer = saveFullRes ? buffer : resized.buffer;
|
||||
const savedMimeType = saveFullRes ? "image/png" : resized.mimeType;
|
||||
await Bun.write(dest, savedBuffer);
|
||||
|
||||
const info: ScreenshotResult = {
|
||||
dest,
|
||||
mimeType: savedMimeType,
|
||||
bytes: savedBuffer.length,
|
||||
width: resized.width,
|
||||
height: resized.height,
|
||||
};
|
||||
screenshots.push(info);
|
||||
|
||||
if (!opts.silent) {
|
||||
const lines = formatScreenshot({
|
||||
saveFullRes,
|
||||
savedMimeType,
|
||||
savedByteLength: savedBuffer.length,
|
||||
dest,
|
||||
resized,
|
||||
});
|
||||
displays.push({ type: "text", text: lines.join("\n") });
|
||||
displays.push({ type: "image", data: resized.data, mimeType: resized.mimeType });
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
+343
-315
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,10 @@ import type { AsyncJobManager } from "../async";
|
||||
import type { PromptTemplate } from "../config/prompt-templates";
|
||||
import type { Settings } from "../config/settings";
|
||||
import { EditTool } from "../edit";
|
||||
import { warmPythonEnvironment } from "../eval/py/executor";
|
||||
import { checkPythonKernelAvailability } from "../eval/py/kernel";
|
||||
import type { Skill } from "../extensibility/skills";
|
||||
import type { InternalUrlRouter } from "../internal-urls";
|
||||
import { getPreludeDocs, resetPreludeDocsCache, warmPythonEnvironment } from "../ipy/executor";
|
||||
import { checkPythonKernelAvailability } from "../ipy/kernel";
|
||||
import { LspTool } from "../lsp";
|
||||
import type { DiscoverableMCPSearchIndex, DiscoverableMCPTool } from "../mcp/discoverable-tool-metadata";
|
||||
import type { PlanModeState } from "../plan-mode/state";
|
||||
@@ -27,6 +27,7 @@ import { BrowserTool } from "./browser";
|
||||
import { CalculatorTool } from "./calculator";
|
||||
import { type CheckpointState, CheckpointTool, RewindTool } from "./checkpoint";
|
||||
import { DebugTool } from "./debug";
|
||||
import { EvalTool } from "./eval";
|
||||
import { ExitPlanModeTool } from "./exit-plan-mode";
|
||||
import { FindTool } from "./find";
|
||||
import { GithubTool } from "./gh";
|
||||
@@ -35,7 +36,6 @@ import { IrcTool } from "./irc";
|
||||
import { JobTool } from "./job";
|
||||
import { NotebookTool } from "./notebook";
|
||||
import { wrapToolWithMetaNotice } from "./output-meta";
|
||||
import { PythonTool } from "./python";
|
||||
import { ReadTool } from "./read";
|
||||
import { RecipeTool } from "./recipe";
|
||||
import { RenderMermaidTool } from "./render-mermaid";
|
||||
@@ -66,6 +66,7 @@ export * from "./browser";
|
||||
export * from "./calculator";
|
||||
export * from "./checkpoint";
|
||||
export * from "./debug";
|
||||
export * from "./eval";
|
||||
export * from "./exit-plan-mode";
|
||||
export * from "./find";
|
||||
export * from "./gh";
|
||||
@@ -74,7 +75,6 @@ export * from "./inspect-image";
|
||||
export * from "./irc";
|
||||
export * from "./job";
|
||||
export * from "./notebook";
|
||||
export * from "./python";
|
||||
export * from "./read";
|
||||
export * from "./recipe";
|
||||
export * from "./render-mermaid";
|
||||
@@ -130,16 +130,18 @@ export interface ToolSession {
|
||||
taskDepth?: number;
|
||||
/** Get session file */
|
||||
getSessionFile: () => string | null;
|
||||
/** Get Python kernel owner ID for session-scoped retained-kernel cleanup */
|
||||
getPythonKernelOwnerId?: () => string | null;
|
||||
/** Reject new Python work once session disposal has started. */
|
||||
assertPythonExecutionAllowed?: () => void;
|
||||
/** Track tool-owned Python work so session disposal can await/abort it like direct session Python runs. */
|
||||
trackPythonExecution?<T>(execution: Promise<T>, abortController: AbortController): Promise<T>;
|
||||
/** Get eval kernel owner ID for session-scoped retained-kernel cleanup. */
|
||||
getEvalKernelOwnerId?: () => string | null;
|
||||
/** Reject new eval (python or js) work once session disposal has started. */
|
||||
assertEvalExecutionAllowed?: () => void;
|
||||
/** Track tool-owned eval work so session disposal can await/abort it like direct session eval runs. */
|
||||
trackEvalExecution?<T>(execution: Promise<T>, abortController: AbortController): Promise<T>;
|
||||
/** Get session ID */
|
||||
getSessionId?: () => string | null;
|
||||
/** Agent identity used for IRC routing. Returns the registry id (e.g. "0-Main", "0-AuthLoader"). */
|
||||
getAgentId?: () => string | null;
|
||||
/** Look up a registered tool by name (used by the eval js backend's tool bridge). */
|
||||
getToolByName?: (name: string) => AgentTool | undefined;
|
||||
/** Agent registry for IRC routing across live sessions. */
|
||||
agentRegistry?: AgentRegistry;
|
||||
/** Get artifacts directory for artifact:// URLs */
|
||||
@@ -210,7 +212,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
|
||||
ask: AskTool.createIf,
|
||||
bash: s => new BashTool(s),
|
||||
debug: DebugTool.createIf,
|
||||
python: s => new PythonTool(s),
|
||||
eval: s => new EvalTool(s),
|
||||
calc: s => new CalculatorTool(s),
|
||||
ssh: loadSshTool,
|
||||
edit: s => new EditTool(s),
|
||||
@@ -244,34 +246,40 @@ export const HIDDEN_TOOLS: Record<string, ToolFactory> = {
|
||||
|
||||
export type ToolName = keyof typeof BUILTIN_TOOLS;
|
||||
|
||||
export type PythonToolMode = "ipy-only" | "bash-only" | "both";
|
||||
export interface EvalBackendsAllowance {
|
||||
python: boolean;
|
||||
js: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse PI_PY environment variable to determine Python tool mode.
|
||||
* Returns null if not set or invalid.
|
||||
*
|
||||
* Values:
|
||||
* - "0" or "bash" → bash-only
|
||||
* - "1" or "py" → ipy-only
|
||||
* - "mix" or "both" → both
|
||||
* Parse PI_PY / PI_JS environment variables. Each is a boolean flag; unset
|
||||
* means "not specified, defer to settings". Returns null when neither is set
|
||||
* so the caller can fall through to `readEvalBackendsAllowance` per key.
|
||||
*/
|
||||
function getPythonModeFromEnv(): PythonToolMode | null {
|
||||
const value = $env.PI_PY?.toLowerCase();
|
||||
if (!value) return null;
|
||||
function getEvalBackendsFromEnv(): EvalBackendsAllowance | null {
|
||||
const pyEnv = $env.PI_PY;
|
||||
const jsEnv = $env.PI_JS;
|
||||
if (pyEnv === undefined && jsEnv === undefined) return null;
|
||||
return {
|
||||
python: pyEnv === undefined ? true : $flag("PI_PY"),
|
||||
js: jsEnv === undefined ? true : $flag("PI_JS"),
|
||||
};
|
||||
}
|
||||
|
||||
switch (value) {
|
||||
case "0":
|
||||
case "bash":
|
||||
return "bash-only";
|
||||
case "1":
|
||||
case "py":
|
||||
return "ipy-only";
|
||||
case "mix":
|
||||
case "both":
|
||||
return "both";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
/** Read per-backend allowance from settings (defaults true). */
|
||||
export function readEvalBackendsAllowance(session: ToolSession): EvalBackendsAllowance {
|
||||
return {
|
||||
python: session.settings.get("eval.py") ?? true,
|
||||
js: session.settings.get("eval.js") ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize the active eval backend allowance: PI_PY / PI_JS env flags
|
||||
* override the per-key settings; otherwise settings (defaults true) win.
|
||||
*/
|
||||
export function resolveEvalBackends(session: ToolSession): EvalBackendsAllowance {
|
||||
return getEvalBackendsFromEnv() ?? readEvalBackendsAllowance(session);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -285,36 +293,32 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
||||
if (requestedTools && !requestedTools.includes("exit_plan_mode")) {
|
||||
requestedTools.push("exit_plan_mode");
|
||||
}
|
||||
const pythonMode = getPythonModeFromEnv() ?? session.settings.get("python.toolMode");
|
||||
const backends = resolveEvalBackends(session);
|
||||
const allowPython = backends.python;
|
||||
const allowJs = backends.js;
|
||||
const skipPythonPreflight = session.skipPythonPreflight === true;
|
||||
let pythonAvailable = true;
|
||||
const shouldCheckPython =
|
||||
!skipPythonPreflight &&
|
||||
pythonMode !== "bash-only" &&
|
||||
(requestedTools === undefined || requestedTools.includes("python"));
|
||||
!skipPythonPreflight && allowPython && (requestedTools === undefined || requestedTools.includes("eval"));
|
||||
const isTestEnv = isBunTestRuntime();
|
||||
const forcePythonWarmup = session.forcePythonWarmup === true;
|
||||
const skipPythonWarm = (isTestEnv && !forcePythonWarmup) || $flag("PI_PYTHON_SKIP_CHECK");
|
||||
const cachedPreludeDocs = getPreludeDocs();
|
||||
const shouldWarmPython = !skipPythonWarm && (forcePythonWarmup || cachedPreludeDocs.length === 0);
|
||||
if (shouldCheckPython) {
|
||||
const availability = await logger.time("createTools:pythonCheck", checkPythonKernelAvailability, session.cwd);
|
||||
pythonAvailable = availability.ok;
|
||||
if (!availability.ok) {
|
||||
logger.warn("Python kernel unavailable, falling back to bash", {
|
||||
logger.warn("Python kernel unavailable; eval will dispatch to JavaScript backend", {
|
||||
reason: availability.reason,
|
||||
});
|
||||
} else if (shouldWarmPython) {
|
||||
} else if (!skipPythonWarm) {
|
||||
const sessionFile = session.getSessionFile?.() ?? undefined;
|
||||
const kernelOwnerId = session.getPythonKernelOwnerId?.() ?? undefined;
|
||||
const kernelOwnerId = session.getEvalKernelOwnerId?.() ?? undefined;
|
||||
const warmSessionId = sessionFile ? `session:${sessionFile}:cwd:${session.cwd}` : `cwd:${session.cwd}`;
|
||||
const warmupAbortController = new AbortController();
|
||||
try {
|
||||
session.assertPythonExecutionAllowed?.();
|
||||
if (forcePythonWarmup && cachedPreludeDocs.length > 0) {
|
||||
resetPreludeDocsCache();
|
||||
}
|
||||
const warmupExecution = session.trackPythonExecution
|
||||
session.assertEvalExecutionAllowed?.();
|
||||
|
||||
const warmupExecution = session.trackEvalExecution
|
||||
? logger.time(
|
||||
"createTools:warmPython",
|
||||
warmPythonEnvironment,
|
||||
@@ -334,8 +338,8 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
||||
sessionFile,
|
||||
kernelOwnerId,
|
||||
);
|
||||
await (session.trackPythonExecution?.(warmupExecution, warmupAbortController) ?? warmupExecution);
|
||||
session.assertPythonExecutionAllowed?.();
|
||||
await (session.trackEvalExecution?.(warmupExecution, warmupAbortController) ?? warmupExecution);
|
||||
session.assertEvalExecutionAllowed?.();
|
||||
} catch (err) {
|
||||
logger.warn("Failed to warm Python environment", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
@@ -344,18 +348,10 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveMode = pythonAvailable ? pythonMode : "bash-only";
|
||||
const allowBash = effectiveMode !== "ipy-only";
|
||||
const allowPython = effectiveMode !== "bash-only";
|
||||
if (
|
||||
requestedTools &&
|
||||
allowBash &&
|
||||
!allowPython &&
|
||||
requestedTools.includes("python") &&
|
||||
!requestedTools.includes("bash")
|
||||
) {
|
||||
requestedTools.push("bash");
|
||||
}
|
||||
const effectivePythonAllowed = allowPython && pythonAvailable;
|
||||
// Eval is exposed whenever any backend is reachable. The python backend may
|
||||
// be unreachable, in which case eval dispatches exclusively to js.
|
||||
const allowEval = effectivePythonAllowed || allowJs;
|
||||
|
||||
// Auto-include AST counterparts when their text-based sibling is present
|
||||
if (requestedTools) {
|
||||
@@ -384,8 +380,8 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
||||
const allTools: Record<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };
|
||||
const isToolAllowed = (name: string) => {
|
||||
if (name === "lsp") return enableLsp && session.settings.get("lsp.enabled");
|
||||
if (name === "bash") return allowBash;
|
||||
if (name === "python") return allowPython;
|
||||
if (name === "bash") return true;
|
||||
if (name === "eval") return allowEval;
|
||||
if (name === "debug") return session.settings.get("debug.enabled");
|
||||
if (name === "todo_write") return !includeYield && session.settings.get("todo.enabled");
|
||||
if (name === "find") return session.settings.get("find.enabled");
|
||||
|
||||
@@ -17,12 +17,12 @@ import { bashToolRenderer } from "./bash";
|
||||
import { browserToolRenderer } from "./browser/render";
|
||||
import { calculatorToolRenderer } from "./calculator";
|
||||
import { debugToolRenderer } from "./debug";
|
||||
import { evalToolRenderer } from "./eval";
|
||||
import { findToolRenderer } from "./find";
|
||||
import { githubToolRenderer } from "./gh-renderer";
|
||||
import { inspectImageToolRenderer } from "./inspect-image-renderer";
|
||||
import { jobToolRenderer } from "./job";
|
||||
import { notebookToolRenderer } from "./notebook";
|
||||
import { pythonToolRenderer } from "./python";
|
||||
import { readToolRenderer } from "./read";
|
||||
import { recipeToolRenderer } from "./recipe/render";
|
||||
import { resolveToolRenderer } from "./resolve";
|
||||
@@ -53,7 +53,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
|
||||
browser: browserToolRenderer as ToolRenderer,
|
||||
recipe: recipeToolRenderer as ToolRenderer,
|
||||
debug: debugToolRenderer as ToolRenderer,
|
||||
python: pythonToolRenderer as ToolRenderer,
|
||||
eval: evalToolRenderer as ToolRenderer,
|
||||
calc: calculatorToolRenderer as ToolRenderer,
|
||||
edit: editToolRenderer as ToolRenderer,
|
||||
apply_patch: editToolRenderer as ToolRenderer,
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface ToolTimeoutConfig {
|
||||
|
||||
export const TOOL_TIMEOUTS = {
|
||||
bash: { default: 300, min: 1, max: 3600 },
|
||||
python: { default: 30, min: 1, max: 600 },
|
||||
eval: { default: 30, min: 1, max: 600 },
|
||||
browser: { default: 30, min: 1, max: 30 },
|
||||
ssh: { default: 60, min: 1, max: 3600 },
|
||||
fetch: { default: 20, min: 1, max: 45 },
|
||||
|
||||
@@ -74,14 +74,14 @@ const TOOLS: Record<string, ToolConfig> = {
|
||||
},
|
||||
};
|
||||
|
||||
// Python packages installed via uv/pip
|
||||
interface PythonToolConfig {
|
||||
// CLI packages installed via uv/pip
|
||||
interface PythonPackageToolConfig {
|
||||
name: string;
|
||||
package: string; // PyPI package name
|
||||
binaryName: string; // CLI command name after install
|
||||
}
|
||||
|
||||
const PYTHON_TOOLS: Record<string, PythonToolConfig> = {
|
||||
const PYTHON_TOOLS: Record<string, PythonPackageToolConfig> = {
|
||||
trafilatura: {
|
||||
name: "trafilatura",
|
||||
package: "trafilatura",
|
||||
@@ -93,7 +93,7 @@ export type ToolName = "sd" | "sg" | "yt-dlp" | "trafilatura";
|
||||
|
||||
// Get the path to a tool (system-wide or in our tools dir)
|
||||
export function getToolPath(tool: ToolName): string | null {
|
||||
// Check Python tools first
|
||||
// Check uv/pip-installed CLI packages first
|
||||
const pythonConfig = PYTHON_TOOLS[tool];
|
||||
if (pythonConfig) {
|
||||
return $which(pythonConfig.binaryName);
|
||||
@@ -306,7 +306,7 @@ export async function ensureTool(tool: ToolName, silentOrOptions?: EnsureToolOpt
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Handle Python tools
|
||||
// Handle uv/pip-installed CLI packages
|
||||
const pythonConfig = PYTHON_TOOLS[tool];
|
||||
if (pythonConfig) {
|
||||
if (!silent) {
|
||||
|
||||
@@ -4,9 +4,9 @@ import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { getBundledModel } from "@oh-my-pi/pi-ai";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import * as pythonExecutor from "@oh-my-pi/pi-coding-agent/ipy/executor";
|
||||
import type { PreludeHelper, PythonKernel as PythonKernelInstance } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
import * as pythonKernel from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
import * as pythonExecutor from "@oh-my-pi/pi-coding-agent/eval/py/executor";
|
||||
import type { PythonKernel as PythonKernelInstance } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
import * as pythonKernel from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
import * as memories from "@oh-my-pi/pi-coding-agent/memories";
|
||||
import { createAgentSession, type ExtensionFactory } from "@oh-my-pi/pi-coding-agent/sdk";
|
||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
@@ -96,17 +96,15 @@ const createSession = async (
|
||||
enableMCP: false,
|
||||
enableLsp: false,
|
||||
forcePythonWarmup: true,
|
||||
toolNames: ["python"],
|
||||
toolNames: ["eval"],
|
||||
})
|
||||
).session;
|
||||
|
||||
const stubPythonWarmup = () =>
|
||||
vi.spyOn(pythonExecutor, "warmPythonEnvironment").mockResolvedValue({ ok: true, docs: [] });
|
||||
const stubPythonWarmup = () => vi.spyOn(pythonExecutor, "warmPythonEnvironment").mockResolvedValue({ ok: true });
|
||||
|
||||
const createWarmupKernel = (docs: PreludeHelper[] = []) => {
|
||||
const createWarmupKernel = () => {
|
||||
let alive = true;
|
||||
return {
|
||||
introspectPrelude: vi.fn().mockResolvedValue(docs),
|
||||
execute: vi.fn(async () => {
|
||||
if (!alive) throw new Error("Expected warmup kernel to be restarted after shutdown");
|
||||
return OK_EXECUTION;
|
||||
@@ -124,7 +122,6 @@ describe("AgentSession python cleanup", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
pythonExecutor.resetPreludeDocsCache();
|
||||
vi.restoreAllMocks();
|
||||
await pythonExecutor.disposeAllKernelSessions();
|
||||
for (const tempDir of tempDirs.splice(0)) {
|
||||
@@ -132,15 +129,10 @@ describe("AgentSession python cleanup", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("cleans up warmed Python owners when createAgentSession fails before session construction", async () => {
|
||||
it("does not dispose unrelated Python owners when createAgentSession fails before session construction", async () => {
|
||||
const { tempDir, cwd } = createTempProject();
|
||||
tempDirs.push(tempDir);
|
||||
pythonExecutor.resetPreludeDocsCache();
|
||||
|
||||
const unrelatedKernel = createWarmupKernel();
|
||||
const warmedKernel = createWarmupKernel([
|
||||
{ name: "helper", signature: "helper()", docstring: "doc", category: "general" },
|
||||
]);
|
||||
const unrelatedCwd = path.join(tempDir, "unrelated-before");
|
||||
const throwingExtension: ExtensionFactory = () => {
|
||||
throw new Error("Extension init failed");
|
||||
@@ -148,8 +140,7 @@ describe("AgentSession python cleanup", () => {
|
||||
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
|
||||
const startSpy = vi
|
||||
.spyOn(pythonKernel.PythonKernel, "start")
|
||||
.mockResolvedValueOnce(unrelatedKernel as unknown as PythonKernelInstance)
|
||||
.mockResolvedValueOnce(warmedKernel as unknown as PythonKernelInstance);
|
||||
.mockResolvedValueOnce(unrelatedKernel as unknown as PythonKernelInstance);
|
||||
|
||||
await pythonExecutor.executePython("print('unrelated before')", {
|
||||
cwd: unrelatedCwd,
|
||||
@@ -174,13 +165,11 @@ describe("AgentSession python cleanup", () => {
|
||||
enableMCP: false,
|
||||
enableLsp: false,
|
||||
forcePythonWarmup: true,
|
||||
toolNames: ["python"],
|
||||
toolNames: ["eval"],
|
||||
}),
|
||||
).rejects.toThrow("Extension init failed");
|
||||
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
expect(warmedKernel.introspectPrelude).toHaveBeenCalledTimes(1);
|
||||
expect(warmedKernel.shutdown).toHaveBeenCalledTimes(1);
|
||||
expect(startSpy).toHaveBeenCalledTimes(1);
|
||||
expect(unrelatedKernel.shutdown).not.toHaveBeenCalled();
|
||||
|
||||
const replacementKernel = createWarmupKernel();
|
||||
@@ -191,10 +180,9 @@ describe("AgentSession python cleanup", () => {
|
||||
kernelMode: "session",
|
||||
kernelOwnerId: "fresh-owner-before",
|
||||
});
|
||||
expect(startSpy).toHaveBeenCalledTimes(3);
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
expect(replacementKernel.execute).toHaveBeenCalledTimes(1);
|
||||
expect(replacementKernel.execute).toHaveBeenCalledTimes(1);
|
||||
expect(warmedKernel.shutdown).toHaveBeenCalledTimes(1);
|
||||
expect(warmedKernel.execute).not.toHaveBeenCalled();
|
||||
|
||||
await pythonExecutor.executePython("print('still alive before')", {
|
||||
cwd: unrelatedCwd,
|
||||
@@ -203,25 +191,19 @@ describe("AgentSession python cleanup", () => {
|
||||
kernelOwnerId: "other-owner",
|
||||
});
|
||||
|
||||
expect(startSpy).toHaveBeenCalledTimes(3);
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
expect(unrelatedKernel.execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("cleans up warmed Python owners when createAgentSession fails after session construction", async () => {
|
||||
it("does not dispose unrelated Python owners when createAgentSession fails after session construction", async () => {
|
||||
const { tempDir, cwd } = createTempProject();
|
||||
tempDirs.push(tempDir);
|
||||
pythonExecutor.resetPreludeDocsCache();
|
||||
|
||||
const unrelatedKernel = createWarmupKernel();
|
||||
const warmedKernel = createWarmupKernel([
|
||||
{ name: "helper", signature: "helper()", docstring: "doc", category: "general" },
|
||||
]);
|
||||
const unrelatedCwd = path.join(tempDir, "unrelated-after");
|
||||
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
|
||||
const startSpy = vi
|
||||
.spyOn(pythonKernel.PythonKernel, "start")
|
||||
.mockResolvedValueOnce(unrelatedKernel as unknown as PythonKernelInstance)
|
||||
.mockResolvedValueOnce(warmedKernel as unknown as PythonKernelInstance);
|
||||
.mockResolvedValueOnce(unrelatedKernel as unknown as PythonKernelInstance);
|
||||
vi.spyOn(memories, "startMemoryStartupTask").mockImplementation(() => {
|
||||
throw new Error("Memory startup failed");
|
||||
});
|
||||
@@ -248,13 +230,11 @@ describe("AgentSession python cleanup", () => {
|
||||
enableMCP: false,
|
||||
enableLsp: false,
|
||||
forcePythonWarmup: true,
|
||||
toolNames: ["python"],
|
||||
toolNames: ["eval"],
|
||||
}),
|
||||
).rejects.toThrow("Memory startup failed");
|
||||
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
expect(warmedKernel.introspectPrelude).toHaveBeenCalledTimes(1);
|
||||
expect(warmedKernel.shutdown).toHaveBeenCalledTimes(1);
|
||||
expect(startSpy).toHaveBeenCalledTimes(1);
|
||||
expect(unrelatedKernel.shutdown).not.toHaveBeenCalled();
|
||||
|
||||
const replacementKernel = createWarmupKernel();
|
||||
@@ -265,10 +245,9 @@ describe("AgentSession python cleanup", () => {
|
||||
kernelMode: "session",
|
||||
kernelOwnerId: "fresh-owner-after",
|
||||
});
|
||||
expect(startSpy).toHaveBeenCalledTimes(3);
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
expect(replacementKernel.execute).toHaveBeenCalledTimes(1);
|
||||
expect(replacementKernel.execute).toHaveBeenCalledTimes(1);
|
||||
expect(warmedKernel.shutdown).toHaveBeenCalledTimes(1);
|
||||
expect(warmedKernel.execute).not.toHaveBeenCalled();
|
||||
|
||||
await pythonExecutor.executePython("print('still alive after')", {
|
||||
cwd: unrelatedCwd,
|
||||
@@ -277,14 +256,13 @@ describe("AgentSession python cleanup", () => {
|
||||
kernelOwnerId: "other-owner",
|
||||
});
|
||||
|
||||
expect(startSpy).toHaveBeenCalledTimes(3);
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
expect(unrelatedKernel.execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("waits for active SDK session Python work before releasing a shared retained kernel", async () => {
|
||||
const { tempDir, cwd } = createTempProject();
|
||||
tempDirs.push(tempDir);
|
||||
pythonExecutor.resetPreludeDocsCache();
|
||||
stubPythonWarmup();
|
||||
|
||||
const kernel = new FakeKernel();
|
||||
@@ -359,11 +337,9 @@ describe("AgentSession python cleanup", () => {
|
||||
expect(kernel.shutdownCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("aborts tracked Python tool warmup during session dispose before executePython starts", async () => {
|
||||
it("aborts tracked eval warmup during session dispose before executePython starts", async () => {
|
||||
const { tempDir, cwd } = createTempProject();
|
||||
tempDirs.push(tempDir);
|
||||
pythonExecutor.resetPreludeDocsCache();
|
||||
|
||||
const blockedWarmupStarted = Promise.withResolvers<void>();
|
||||
const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockResolvedValue({
|
||||
output: "tool ok",
|
||||
@@ -383,11 +359,11 @@ describe("AgentSession python cleanup", () => {
|
||||
.mockImplementation(async (_cwd, _sessionId, _useSharedGateway, _sessionFile, _kernelOwnerId, signal) => {
|
||||
warmupCallCount += 1;
|
||||
if (warmupCallCount === 1) {
|
||||
return { ok: true, docs: [] };
|
||||
return { ok: true };
|
||||
}
|
||||
blockedWarmupStarted.resolve();
|
||||
return await new Promise<{ ok: boolean; reason?: string; docs: [] }>(resolve => {
|
||||
const onAbort = () => resolve({ ok: false, reason: "Warmup aborted", docs: [] });
|
||||
return await new Promise<{ ok: boolean; reason?: string }>(resolve => {
|
||||
const onAbort = () => resolve({ ok: false, reason: "Warmup aborted" });
|
||||
if (signal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
@@ -398,11 +374,11 @@ describe("AgentSession python cleanup", () => {
|
||||
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
|
||||
|
||||
const session = await createSession(tempDir, cwd);
|
||||
const pythonTool = session.getToolByName("python");
|
||||
expect(pythonTool).toBeDefined();
|
||||
const EvalTool = session.getToolByName("eval");
|
||||
expect(EvalTool).toBeDefined();
|
||||
let toolExecutionSettled = false;
|
||||
const toolExecution = pythonTool!
|
||||
.execute("call-id", { cells: [{ code: "print('tool')" }] }, undefined, undefined, undefined)
|
||||
const toolExecution = EvalTool!
|
||||
.execute("call-id", { input: "=== CELL ===\n```py\nprint('tool')\n```" }, undefined, undefined, undefined)
|
||||
.finally(() => {
|
||||
toolExecutionSettled = true;
|
||||
});
|
||||
@@ -427,11 +403,9 @@ describe("AgentSession python cleanup", () => {
|
||||
expect(executeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects Python tool starts when warmup finishes after dispose begins", async () => {
|
||||
it("rejects eval starts when warmup finishes after dispose begins", async () => {
|
||||
const { tempDir, cwd } = createTempProject();
|
||||
tempDirs.push(tempDir);
|
||||
pythonExecutor.resetPreludeDocsCache();
|
||||
|
||||
const blockedWarmupStarted = Promise.withResolvers<void>();
|
||||
const releaseWarmup = Promise.withResolvers<void>();
|
||||
const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockResolvedValue({
|
||||
@@ -450,20 +424,20 @@ describe("AgentSession python cleanup", () => {
|
||||
const warmupSpy = vi.spyOn(pythonExecutor, "warmPythonEnvironment").mockImplementation(async () => {
|
||||
warmupCallCount += 1;
|
||||
if (warmupCallCount === 1) {
|
||||
return { ok: true, docs: [] };
|
||||
return { ok: true };
|
||||
}
|
||||
blockedWarmupStarted.resolve();
|
||||
await releaseWarmup.promise;
|
||||
return { ok: true, docs: [] };
|
||||
return { ok: true };
|
||||
});
|
||||
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
|
||||
|
||||
const session = await createSession(tempDir, cwd);
|
||||
const pythonTool = session.getToolByName("python");
|
||||
expect(pythonTool).toBeDefined();
|
||||
const toolExecution = pythonTool!.execute(
|
||||
const EvalTool = session.getToolByName("eval");
|
||||
expect(EvalTool).toBeDefined();
|
||||
const toolExecution = EvalTool!.execute(
|
||||
"call-id",
|
||||
{ cells: [{ code: "print('tool after warmup')" }] },
|
||||
{ input: "=== CELL ===\n```py\nprint('tool after warmup')\n```" },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -478,22 +452,11 @@ describe("AgentSession python cleanup", () => {
|
||||
expect(warmupSpy).toHaveBeenCalledTimes(2);
|
||||
expect(executeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
it("aborts tracked Python tool execution during session dispose after warmup completes", async () => {
|
||||
it("aborts tracked eval execution during session dispose after warmup completes", async () => {
|
||||
const { tempDir, cwd } = createTempProject();
|
||||
tempDirs.push(tempDir);
|
||||
pythonExecutor.resetPreludeDocsCache();
|
||||
|
||||
const helper: PreludeHelper = {
|
||||
name: "helper",
|
||||
signature: "helper()",
|
||||
docstring: "doc",
|
||||
category: "general",
|
||||
};
|
||||
let preludeDocs: PreludeHelper[] = [];
|
||||
vi.spyOn(pythonExecutor, "getPreludeDocs").mockImplementation(() => preludeDocs);
|
||||
const warmupSpy = vi.spyOn(pythonExecutor, "warmPythonEnvironment").mockImplementation(async () => {
|
||||
preludeDocs = [helper];
|
||||
return { ok: true, docs: preludeDocs };
|
||||
return { ok: true };
|
||||
});
|
||||
const blockedExecuteStarted = Promise.withResolvers<void>();
|
||||
const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockImplementation(async (_code, options) => {
|
||||
@@ -526,11 +489,11 @@ describe("AgentSession python cleanup", () => {
|
||||
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
|
||||
|
||||
const session = await createSession(tempDir, cwd);
|
||||
const pythonTool = session.getToolByName("python");
|
||||
expect(pythonTool).toBeDefined();
|
||||
const EvalTool = session.getToolByName("eval");
|
||||
expect(EvalTool).toBeDefined();
|
||||
let toolExecutionSettled = false;
|
||||
const toolExecution = pythonTool!
|
||||
.execute("call-id", { cells: [{ code: "print('tool')" }] }, undefined, undefined, undefined)
|
||||
const toolExecution = EvalTool!
|
||||
.execute("call-id", { input: "=== CELL ===\n```py\nprint('tool')\n```" }, undefined, undefined, undefined)
|
||||
.finally(() => {
|
||||
toolExecutionSettled = true;
|
||||
});
|
||||
@@ -562,7 +525,6 @@ describe("AgentSession python cleanup", () => {
|
||||
it("detaches retained kernel ownership even when dispose times out waiting for Python work", async () => {
|
||||
const { tempDir, cwd } = createTempProject();
|
||||
tempDirs.push(tempDir);
|
||||
pythonExecutor.resetPreludeDocsCache();
|
||||
stubPythonWarmup();
|
||||
|
||||
const kernel = new FakeKernel();
|
||||
@@ -621,7 +583,6 @@ describe("AgentSession python cleanup", () => {
|
||||
it("rejects direct session Python starts once dispose begins", async () => {
|
||||
const { tempDir, cwd } = createTempProject();
|
||||
tempDirs.push(tempDir);
|
||||
pythonExecutor.resetPreludeDocsCache();
|
||||
stubPythonWarmup();
|
||||
const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockResolvedValue({
|
||||
output: "late",
|
||||
@@ -648,7 +609,6 @@ describe("AgentSession python cleanup", () => {
|
||||
it("rejects direct session Python starts after an async user_python hook yields during dispose", async () => {
|
||||
const { tempDir, cwd } = createTempProject();
|
||||
tempDirs.push(tempDir);
|
||||
pythonExecutor.resetPreludeDocsCache();
|
||||
const hookStarted = Promise.withResolvers<void>();
|
||||
const releaseHook = Promise.withResolvers<void>();
|
||||
const hookExtension: ExtensionFactory = api => {
|
||||
@@ -690,7 +650,6 @@ describe("AgentSession python cleanup", () => {
|
||||
it("rejects async user_python hook results after dispose begins", async () => {
|
||||
const { tempDir, cwd } = createTempProject();
|
||||
tempDirs.push(tempDir);
|
||||
pythonExecutor.resetPreludeDocsCache();
|
||||
const hookStarted = Promise.withResolvers<void>();
|
||||
const releaseHook = Promise.withResolvers<void>();
|
||||
const hookExtension: ExtensionFactory = api => {
|
||||
@@ -743,10 +702,9 @@ describe("AgentSession python cleanup", () => {
|
||||
expect(session.messages.some(message => message.role === "pythonExecution")).toBe(false);
|
||||
}, 10000);
|
||||
|
||||
it("rejects Python tool starts once dispose begins", async () => {
|
||||
it("rejects eval starts once dispose begins", async () => {
|
||||
const { tempDir, cwd } = createTempProject();
|
||||
tempDirs.push(tempDir);
|
||||
pythonExecutor.resetPreludeDocsCache();
|
||||
const warmupSpy = stubPythonWarmup();
|
||||
const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockResolvedValue({
|
||||
output: "late",
|
||||
@@ -762,21 +720,26 @@ describe("AgentSession python cleanup", () => {
|
||||
});
|
||||
|
||||
const session = await createSession(tempDir, cwd);
|
||||
const pythonTool = session.getToolByName("python");
|
||||
expect(pythonTool).toBeDefined();
|
||||
const EvalTool = session.getToolByName("eval");
|
||||
expect(EvalTool).toBeDefined();
|
||||
const disposeSession = session.dispose();
|
||||
await expect(
|
||||
pythonTool!.execute("call-id", { cells: [{ code: "print('late')" }] }, undefined, undefined, undefined),
|
||||
EvalTool!.execute(
|
||||
"call-id",
|
||||
{ input: "=== CELL ===\n```py\nprint('late')\n```" },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
),
|
||||
).rejects.toThrow("Python execution is unavailable while session disposal is in progress");
|
||||
await disposeSession;
|
||||
expect(warmupSpy).toHaveBeenCalledTimes(1);
|
||||
expect(executeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects Python tool starts that reach async preflight after dispose begins", async () => {
|
||||
it("rejects eval starts that reach async preflight after dispose begins", async () => {
|
||||
const { tempDir, cwd } = createTempProject();
|
||||
tempDirs.push(tempDir);
|
||||
pythonExecutor.resetPreludeDocsCache();
|
||||
const warmupSpy = stubPythonWarmup();
|
||||
const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockResolvedValue({
|
||||
output: "late",
|
||||
@@ -800,11 +763,11 @@ describe("AgentSession python cleanup", () => {
|
||||
});
|
||||
|
||||
const session = await createSession(tempDir, cwd, { sessionManager });
|
||||
const pythonTool = session.getToolByName("python");
|
||||
expect(pythonTool).toBeDefined();
|
||||
const execution = pythonTool!.execute(
|
||||
const EvalTool = session.getToolByName("eval");
|
||||
expect(EvalTool).toBeDefined();
|
||||
const execution = EvalTool!.execute(
|
||||
"call-id",
|
||||
{ cells: [{ code: "print('late after artifact')" }] },
|
||||
{ input: "=== CELL ===\n```py\nprint('late after artifact')\n```" },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -821,7 +784,6 @@ describe("AgentSession python cleanup", () => {
|
||||
it("aborts every active Python execution owned by the session during dispose", async () => {
|
||||
const { tempDir, cwd } = createTempProject();
|
||||
tempDirs.push(tempDir);
|
||||
pythonExecutor.resetPreludeDocsCache();
|
||||
stubPythonWarmup();
|
||||
|
||||
const kernel = new FakeKernel();
|
||||
|
||||
@@ -4,9 +4,9 @@ import { Agent } from "@oh-my-pi/pi-agent-core";
|
||||
import { getBundledModel } from "@oh-my-pi/pi-ai";
|
||||
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import * as pythonExecutor from "@oh-my-pi/pi-coding-agent/eval/py/executor";
|
||||
import * as bashExecutor from "@oh-my-pi/pi-coding-agent/exec/bash-executor";
|
||||
import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions";
|
||||
import * as pythonExecutor from "@oh-my-pi/pi-coding-agent/ipy/executor";
|
||||
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PythonKernelExecutor } from "@oh-my-pi/pi-coding-agent/ipy/executor";
|
||||
import type { KernelExecuteOptions, KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
import type { PythonKernelExecutor } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
|
||||
import type { KernelExecuteOptions, KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
|
||||
export class FakeKernel implements PythonKernelExecutor {
|
||||
private result: KernelExecuteResult;
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as path from "node:path";
|
||||
import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { disposeAllVmContexts } from "../../src/eval/js/context-manager";
|
||||
import { executeJs, type JsResult } from "../../src/eval/js/executor";
|
||||
|
||||
function createTool(
|
||||
name: string,
|
||||
execute: (toolCallId: string, args: unknown, signal?: AbortSignal) => Promise<AgentToolResult>,
|
||||
): AgentTool {
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: `${name} tool`,
|
||||
parameters: Type.Object({}),
|
||||
concurrency: "parallel",
|
||||
execute,
|
||||
} as unknown as AgentTool;
|
||||
}
|
||||
|
||||
function getJsonData(result: JsResult): unknown {
|
||||
const jsonOutputs = result.displayOutputs.filter(
|
||||
(output): output is Extract<JsResult["displayOutputs"][number], { type: "json" }> => output.type === "json",
|
||||
);
|
||||
expect(jsonOutputs).toHaveLength(1);
|
||||
return jsonOutputs[0].data;
|
||||
}
|
||||
|
||||
function getStatusEvents(result: JsResult) {
|
||||
return result.displayOutputs.filter(
|
||||
(output): output is Extract<JsResult["displayOutputs"][number], { type: "status" }> => output.type === "status",
|
||||
);
|
||||
}
|
||||
|
||||
describe("executeJs", () => {
|
||||
let tempDir: TempDir;
|
||||
let session: ToolSession;
|
||||
let sessionFile: string;
|
||||
let sessionId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = TempDir.createSync("@js-executor-");
|
||||
sessionFile = path.join(tempDir.path(), "session.jsonl");
|
||||
sessionId = `session:${sessionFile}:cwd:${tempDir.path()}`;
|
||||
session = {
|
||||
cwd: tempDir.path(),
|
||||
hasUI: false,
|
||||
getSessionFile: () => sessionFile,
|
||||
getSessionSpawns: () => null,
|
||||
settings: Settings.isolated(),
|
||||
};
|
||||
|
||||
await Bun.write(
|
||||
path.join(tempDir.path(), "config.json"),
|
||||
JSON.stringify({ name: "demo", enabled: true }, null, 2),
|
||||
);
|
||||
await Bun.write(path.join(tempDir.path(), "config.yaml"), "name: demo\nenabled: true\n");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllVmContexts();
|
||||
tempDir.removeSync();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("persists bindings across calls and reset clears them", async () => {
|
||||
await executeJs("const answer = 2;", { sessionId, session, sessionFile });
|
||||
|
||||
const persisted = await executeJs("return answer + 2;", { sessionId, session, sessionFile });
|
||||
expect(persisted.exitCode).toBe(0);
|
||||
expect(persisted.output.trim()).toBe("4");
|
||||
|
||||
const resetResult = await executeJs("return typeof answer;", {
|
||||
sessionId,
|
||||
session,
|
||||
sessionFile,
|
||||
reset: true,
|
||||
});
|
||||
expect(resetResult.exitCode).toBe(0);
|
||||
expect(resetResult.output.trim()).toBe("undefined");
|
||||
});
|
||||
|
||||
it("exposes a read-only safe process subset", async () => {
|
||||
const result = await executeJs(
|
||||
[
|
||||
"return {",
|
||||
" version: process.version,",
|
||||
" versionsNode: typeof process.versions.node,",
|
||||
" platform: process.platform,",
|
||||
" arch: process.arch,",
|
||||
" cwd: process.cwd(),",
|
||||
" frozen: Object.isFrozen(process) && Object.isFrozen(process.versions),",
|
||||
" hasEnv: 'env' in process,",
|
||||
" hasExit: 'exit' in process,",
|
||||
"};",
|
||||
].join("\n"),
|
||||
{ sessionId, session, sessionFile },
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(getJsonData(result)).toEqual({
|
||||
version: process.version,
|
||||
versionsNode: "string",
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
cwd: tempDir.path(),
|
||||
frozen: true,
|
||||
hasEnv: false,
|
||||
hasExit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes common web globals and the raw Node fs module", async () => {
|
||||
const result = await executeJs(
|
||||
[
|
||||
"const uuid = crypto.randomUUID();",
|
||||
"const digest = await webcrypto.subtle.digest('SHA-256', new TextEncoder().encode('ok'));",
|
||||
"const base = process.cwd();",
|
||||
"fs.mkdirSync(`${base}/nested`, { recursive: true });",
|
||||
"fs.writeFileSync(`${base}/nested/value.txt`, 'hello');",
|
||||
"await fs.promises.copyFile(`${base}/nested/value.txt`, `${base}/nested/copy.txt`);",
|
||||
"const text = fs.readFileSync(`${base}/nested/copy.txt`, 'utf8');",
|
||||
"const bytes = await fs.promises.readFile(`${base}/nested/copy.txt`);",
|
||||
"const stat = fs.statSync(`${base}/nested/copy.txt`);",
|
||||
"const entries = fs.readdirSync(`${base}/nested`);",
|
||||
"const start = performance.now();",
|
||||
"return {",
|
||||
" uuid: typeof uuid,",
|
||||
" digestBytes: digest.byteLength,",
|
||||
" text,",
|
||||
" byteLength: bytes.byteLength,",
|
||||
" isFile: stat.isFile(),",
|
||||
" entries: entries.sort(),",
|
||||
" hasConstants: typeof fs.constants.R_OK,",
|
||||
" buffer: Buffer.from('ok').toString('hex'),",
|
||||
" performance: typeof start,",
|
||||
"};",
|
||||
].join("\n"),
|
||||
{ sessionId, session, sessionFile },
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(getJsonData(result)).toEqual({
|
||||
uuid: "string",
|
||||
digestBytes: 32,
|
||||
text: "hello",
|
||||
byteLength: 5,
|
||||
isFile: true,
|
||||
entries: ["copy.txt", "value.txt"],
|
||||
hasConstants: "number",
|
||||
buffer: "6f6b",
|
||||
performance: "number",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads files as text and supports offset/limit slicing", async () => {
|
||||
const result = await executeJs(
|
||||
[
|
||||
"const full = await read('config.json');",
|
||||
"const sliced = await read('config.json', { offset: 2, limit: 1 });",
|
||||
"return { isString: typeof full === 'string', full, sliced };",
|
||||
].join("\n"),
|
||||
{
|
||||
sessionId,
|
||||
session,
|
||||
sessionFile,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(getStatusEvents(result)).toHaveLength(2);
|
||||
expect(getJsonData(result)).toEqual({
|
||||
isString: true,
|
||||
full: '{\n "name": "demo",\n "enabled": true\n}',
|
||||
sliced: ' "name": "demo",',
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects protocol paths and directory reads from native read()", async () => {
|
||||
const protocolResult = await executeJs("await read('agent://demo');", {
|
||||
sessionId,
|
||||
session,
|
||||
sessionFile,
|
||||
});
|
||||
expect(protocolResult.exitCode).toBe(1);
|
||||
expect(protocolResult.output).toContain("Protocol paths are not supported");
|
||||
|
||||
const directoryResult = await executeJs("await read('.');", {
|
||||
sessionId,
|
||||
session,
|
||||
sessionFile,
|
||||
});
|
||||
expect(directoryResult.exitCode).toBe(1);
|
||||
expect(directoryResult.output).toContain("Directory paths are not supported");
|
||||
});
|
||||
|
||||
it("supports parse/stringify helpers and row utilities", async () => {
|
||||
const result = await executeJs(
|
||||
[
|
||||
'const csv = \'name,role,note\\nalice,admin,"hello, world"\\nbob,user,"line1\\nline2"\';',
|
||||
"const parsed = await parse.csv(csv);",
|
||||
"const tsv = stringify.tsv(rows.pick(parsed, ['name', 'role']));",
|
||||
"const roundtrip = await parse.tsv(tsv);",
|
||||
"const grouped = rows.groupBy(parsed, 'role');",
|
||||
"const counted = rows.countBy(parsed, 'role');",
|
||||
"const sorted = rows.sortBy(parsed, 'name', { reverse: true });",
|
||||
"return { parsed, roundtrip, groupedSizes: Object.fromEntries(Object.entries(grouped).map(([key, value]) => [key, value.length])), counted, sorted: rows.column(sorted, 'name') };",
|
||||
].join("\n"),
|
||||
{
|
||||
sessionId,
|
||||
session,
|
||||
sessionFile,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.displayOutputs).toEqual([
|
||||
{
|
||||
type: "json",
|
||||
data: {
|
||||
parsed: [
|
||||
{ name: "alice", role: "admin", note: "hello, world" },
|
||||
{ name: "bob", role: "user", note: "line1\nline2" },
|
||||
],
|
||||
roundtrip: [
|
||||
{ name: "alice", role: "admin" },
|
||||
{ name: "bob", role: "user" },
|
||||
],
|
||||
groupedSizes: { admin: 1, user: 1 },
|
||||
counted: { admin: 1, user: 1 },
|
||||
sorted: ["bob", "alice"],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("round-trips text and binary writes", async () => {
|
||||
const result = await executeJs(
|
||||
[
|
||||
"await write('plain.txt', 'hello');",
|
||||
"await write('bin.dat', new Uint8Array([0, 1, 2, 255]));",
|
||||
"await write.json('state.json', { ok: true });",
|
||||
"await write.csv('table.csv', [{ name: 'alice', role: 'admin' }]);",
|
||||
"return {",
|
||||
" text: await read.text('plain.txt'),",
|
||||
" bytes: Array.from(await read.bytes('bin.dat')),",
|
||||
" json: await read.json('state.json'),",
|
||||
" csv: await read.csv('table.csv'),",
|
||||
"};",
|
||||
].join("\n"),
|
||||
{
|
||||
sessionId,
|
||||
session,
|
||||
sessionFile,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(getStatusEvents(result)).toHaveLength(8);
|
||||
expect(getJsonData(result)).toEqual({
|
||||
text: "hello",
|
||||
bytes: [0, 1, 2, 255],
|
||||
json: { ok: true },
|
||||
csv: [{ name: "alice", role: "admin" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("routes output() through tool.read and keeps tool.* results normalized", async () => {
|
||||
const execute = vi.fn(async (_toolCallId: string, args: unknown): Promise<AgentToolResult> => {
|
||||
const record = args as { path: string };
|
||||
if (record.path.startsWith("agent://")) {
|
||||
return { content: [{ type: "text", text: "from-agent" }] };
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: "annotated" }],
|
||||
details: { path: record.path, kind: "tool-result" },
|
||||
};
|
||||
});
|
||||
const toolSession: ToolSession = {
|
||||
...session,
|
||||
getToolByName: name => (name === "read" ? createTool("read", execute) : undefined),
|
||||
};
|
||||
|
||||
const result = await executeJs(
|
||||
"return { toolResult: await tool.read({ path: 'package.json' }), agentOutput: await output('agent-42') };",
|
||||
{
|
||||
sessionId,
|
||||
session: toolSession,
|
||||
sessionFile,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(getStatusEvents(result)).toHaveLength(2);
|
||||
expect(getJsonData(result)).toEqual({
|
||||
toolResult: {
|
||||
text: "annotated",
|
||||
details: { path: "package.json", kind: "tool-result" },
|
||||
images: undefined,
|
||||
},
|
||||
agentOutput: "from-agent",
|
||||
});
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
expect(execute.mock.calls[0]?.[1]).toEqual({ path: "package.json", _i: "js prelude" });
|
||||
expect(execute.mock.calls[1]?.[1]).toEqual({ path: "agent://agent-42", _i: "js prelude" });
|
||||
});
|
||||
|
||||
it("auto-displays returned objects as structured output", async () => {
|
||||
const result = await executeJs("return { answer: 42, nested: { ok: true } };", {
|
||||
sessionId,
|
||||
session,
|
||||
sessionFile,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output).toBe("");
|
||||
expect(result.displayOutputs).toEqual([{ type: "json", data: { answer: 42, nested: { ok: true } } }]);
|
||||
});
|
||||
|
||||
it("cancels execution when the timeout expires", async () => {
|
||||
const result = await executeJs("await new Promise(() => {})", {
|
||||
sessionId,
|
||||
session,
|
||||
sessionFile,
|
||||
timeoutMs: 20,
|
||||
});
|
||||
|
||||
expect(result.cancelled).toBe(true);
|
||||
expect(result.exitCode).toBeUndefined();
|
||||
expect(result.output).toContain("Command timed out");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it, vi } from "bun:test";
|
||||
import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { callSessionTool } from "../../src/eval/js/tool-bridge";
|
||||
|
||||
function createTool(
|
||||
name: string,
|
||||
execute: (toolCallId: string, args: unknown, signal?: AbortSignal) => Promise<AgentToolResult>,
|
||||
): AgentTool {
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: `${name} tool`,
|
||||
parameters: Type.Object({}),
|
||||
concurrency: "parallel",
|
||||
execute,
|
||||
} as unknown as AgentTool;
|
||||
}
|
||||
|
||||
function createSession(tools: AgentTool[]): ToolSession {
|
||||
const registry = new Map(tools.map(tool => [tool.name, tool]));
|
||||
return {
|
||||
cwd: "/tmp/test",
|
||||
hasUI: false,
|
||||
getSessionFile: () => null,
|
||||
getSessionSpawns: () => null,
|
||||
settings: Settings.isolated(),
|
||||
getToolByName: name => registry.get(name),
|
||||
};
|
||||
}
|
||||
|
||||
describe("callSessionTool", () => {
|
||||
it("injects js intent and summarizes text results", async () => {
|
||||
const execute = vi.fn().mockResolvedValue({
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
});
|
||||
const session = createSession([createTool("read", execute)]);
|
||||
const statuses: Array<Record<string, unknown>> = [];
|
||||
|
||||
const result = await callSessionTool(
|
||||
"read",
|
||||
{ path: "/tmp/demo.txt" },
|
||||
{
|
||||
session,
|
||||
emitStatus: event => {
|
||||
statuses.push(event);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe("hello");
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^js-read-/),
|
||||
{ path: "/tmp/demo.txt", _i: "js prelude" },
|
||||
undefined,
|
||||
);
|
||||
expect(statuses).toEqual([expect.objectContaining({ op: "read", path: "/tmp/demo.txt", chars: 5 })]);
|
||||
});
|
||||
|
||||
it("returns structured tool results when details or images are present", async () => {
|
||||
const session = createSession([
|
||||
createTool("custom", async () => ({
|
||||
content: [
|
||||
{ type: "text", text: "done" },
|
||||
{ type: "image", mimeType: "image/png", data: "abc123" },
|
||||
],
|
||||
details: { ok: true },
|
||||
})),
|
||||
]);
|
||||
|
||||
const result = await callSessionTool("custom", {}, { session });
|
||||
|
||||
expect(result).toEqual({
|
||||
text: "done",
|
||||
details: { ok: true },
|
||||
images: [{ mimeType: "image/png", data: "abc123" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when the requested tool is not available in the session registry", async () => {
|
||||
const session = createSession([]);
|
||||
|
||||
await expect(callSessionTool("missing", {}, { session })).rejects.toThrow("Unknown tool from js runtime");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { executePythonWithKernel } from "@oh-my-pi/pi-coding-agent/ipy/executor";
|
||||
import type { KernelDisplayOutput } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
import { executePythonWithKernel } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
|
||||
import type { KernelDisplayOutput } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
import { FakeKernel } from "./helpers";
|
||||
|
||||
describe("executePythonWithKernel display outputs", () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import { disposeAllKernelSessions, executePython } from "@oh-my-pi/pi-coding-agent/ipy/executor";
|
||||
import type { KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
import * as pythonKernel from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
import { disposeAllKernelSessions, executePython } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
|
||||
import type { KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
import * as pythonKernel from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
import { getProjectDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
class FakeKernel {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { executePythonWithKernel, type PythonKernelExecutor } from "@oh-my-pi/pi-coding-agent/ipy/executor";
|
||||
import type { KernelExecuteOptions, KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
import { executePythonWithKernel, type PythonKernelExecutor } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
|
||||
import type { KernelExecuteOptions, KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
|
||||
class FakeKernel implements PythonKernelExecutor {
|
||||
constructor(
|
||||
|
||||
@@ -3,18 +3,16 @@ import {
|
||||
disposeAllKernelSessions,
|
||||
disposeKernelSessionsByOwner,
|
||||
executePython,
|
||||
resetPreludeDocsCache,
|
||||
warmPythonEnvironment,
|
||||
} from "@oh-my-pi/pi-coding-agent/ipy/executor";
|
||||
import * as gatewayCoordinator from "@oh-my-pi/pi-coding-agent/ipy/gateway-coordinator";
|
||||
} from "@oh-my-pi/pi-coding-agent/eval/py/executor";
|
||||
import * as gatewayCoordinator from "@oh-my-pi/pi-coding-agent/eval/py/gateway-coordinator";
|
||||
import type {
|
||||
KernelExecuteResult,
|
||||
KernelShutdownResult,
|
||||
PreludeHelper,
|
||||
PythonKernel as PythonKernelInstance,
|
||||
} from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
import * as pythonKernel from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
import { PythonKernel } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
} from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
import * as pythonKernel from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
import { PythonKernel } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
const OK_RESULT: KernelExecuteResult = {
|
||||
@@ -47,7 +45,6 @@ async function flushMicrotasks(turns = 6): Promise<void> {
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllKernelSessions();
|
||||
resetPreludeDocsCache();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -781,16 +778,7 @@ describe("python executor owner cleanup", () => {
|
||||
|
||||
it("attaches cached warmup sessions to newly provided owners", async () => {
|
||||
using tempDir = TempDir.createSync("@python-owner-warmup-");
|
||||
const docs: PreludeHelper[] = [
|
||||
{
|
||||
name: "read",
|
||||
signature: "(path)",
|
||||
docstring: "Read file contents.",
|
||||
category: "File I/O",
|
||||
},
|
||||
];
|
||||
const kernel = {
|
||||
introspectPrelude: vi.fn().mockResolvedValue(docs),
|
||||
execute: vi.fn(async () => OK_RESULT),
|
||||
ping: vi.fn(async () => true),
|
||||
isAlive: () => true,
|
||||
@@ -801,11 +789,9 @@ describe("python executor owner cleanup", () => {
|
||||
|
||||
const firstWarmup = await warmPythonEnvironment(tempDir.path(), "warm-session", true, undefined, "owner-a");
|
||||
expect(firstWarmup.ok).toBe(true);
|
||||
expect(kernel.introspectPrelude).toHaveBeenCalledTimes(1);
|
||||
|
||||
const cachedWarmup = await warmPythonEnvironment(tempDir.path(), "warm-session", true, undefined, "owner-b");
|
||||
expect(cachedWarmup.ok).toBe(true);
|
||||
expect(kernel.introspectPrelude).toHaveBeenCalledTimes(1);
|
||||
expect(startSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
await disposeKernelSessionsByOwner("owner-a");
|
||||
@@ -827,16 +813,7 @@ describe("python executor owner cleanup", () => {
|
||||
|
||||
it("keeps cache-hit ownerless warmups provisional until an explicit owner takes over", async () => {
|
||||
using tempDir = TempDir.createSync("@python-owner-fallback-");
|
||||
const docs: PreludeHelper[] = [
|
||||
{
|
||||
name: "read",
|
||||
signature: "(path)",
|
||||
docstring: "Read file contents.",
|
||||
category: "File I/O",
|
||||
},
|
||||
];
|
||||
const kernel = {
|
||||
introspectPrelude: vi.fn().mockResolvedValue(docs),
|
||||
execute: vi.fn(async () => OK_RESULT),
|
||||
ping: vi.fn(async () => true),
|
||||
isAlive: () => true,
|
||||
@@ -847,11 +824,9 @@ describe("python executor owner cleanup", () => {
|
||||
|
||||
const firstWarmup = await warmPythonEnvironment(tempDir.path(), "warm-fallback-session", true);
|
||||
expect(firstWarmup.ok).toBe(true);
|
||||
expect(kernel.introspectPrelude).toHaveBeenCalledTimes(1);
|
||||
|
||||
const cachedWarmup = await warmPythonEnvironment(tempDir.path(), "warm-fallback-session", true);
|
||||
expect(cachedWarmup.ok).toBe(true);
|
||||
expect(kernel.introspectPrelude).toHaveBeenCalledTimes(1);
|
||||
expect(startSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
await executePython("1 + 1", {
|
||||
@@ -865,7 +840,6 @@ describe("python executor owner cleanup", () => {
|
||||
|
||||
const postTakeoverWarmup = await warmPythonEnvironment(tempDir.path(), "warm-fallback-session", true);
|
||||
expect(postTakeoverWarmup.ok).toBe(true);
|
||||
expect(kernel.introspectPrelude).toHaveBeenCalledTimes(1);
|
||||
expect(startSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
await disposeKernelSessionsByOwner("owner-a");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { executePython } from "@oh-my-pi/pi-coding-agent/ipy/executor";
|
||||
import type { KernelExecuteOptions, KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
import { PythonKernel } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
import { executePython } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
|
||||
import type { KernelExecuteOptions, KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
import { PythonKernel } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
interface KernelStub {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import { disposeAllKernelSessions, executePython } from "@oh-my-pi/pi-coding-agent/ipy/executor";
|
||||
import * as pythonKernel from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
import { disposeAllKernelSessions, executePython } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
|
||||
import * as pythonKernel from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
|
||||
class FakeKernel {
|
||||
executeCalls = 0;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { executePythonWithKernel } from "@oh-my-pi/pi-coding-agent/ipy/executor";
|
||||
import { executePythonWithKernel } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
|
||||
import { DEFAULT_MAX_BYTES } from "@oh-my-pi/pi-coding-agent/session/streaming-output";
|
||||
import { FakeKernel } from "./helpers";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { executePythonWithKernel, type PythonKernelExecutor } from "@oh-my-pi/pi-coding-agent/ipy/executor";
|
||||
import type { KernelExecuteOptions, KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
import { executePythonWithKernel, type PythonKernelExecutor } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
|
||||
import type { KernelExecuteOptions, KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
|
||||
class FakeKernel implements PythonKernelExecutor {
|
||||
private result: KernelExecuteResult;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { disposeAllKernelSessions, executePython } from "@oh-my-pi/pi-coding-agent/ipy/executor";
|
||||
import { disposeAllKernelSessions, executePython } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
|
||||
import {
|
||||
type KernelExecuteOptions,
|
||||
type KernelExecuteResult,
|
||||
type KernelShutdownResult,
|
||||
PythonKernel,
|
||||
} from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
} from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
|
||||
Bun.env.PI_PYTHON_SKIP_CHECK = "1";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { executePythonWithKernel, type PythonKernelExecutor } from "@oh-my-pi/pi-coding-agent/ipy/executor";
|
||||
import type { KernelExecuteOptions, KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
import { executePythonWithKernel, type PythonKernelExecutor } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
|
||||
import type { KernelExecuteOptions, KernelExecuteResult } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
|
||||
class FakeKernel implements PythonKernelExecutor {
|
||||
private result: KernelExecuteResult;
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
disposeAllKernelSessions,
|
||||
executePythonWithKernel,
|
||||
getPreludeDocs,
|
||||
resetPreludeDocsCache,
|
||||
warmPythonEnvironment,
|
||||
} from "@oh-my-pi/pi-coding-agent/ipy/executor";
|
||||
import { type KernelShutdownResult, type PreludeHelper, PythonKernel } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
import { disposeAllKernelSessions, executePythonWithKernel } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
|
||||
import { DEFAULT_MAX_BYTES } from "@oh-my-pi/pi-coding-agent/session/streaming-output";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
import { FakeKernel } from "./helpers";
|
||||
@@ -134,43 +127,5 @@ describe("executePythonWithKernel", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllKernelSessions();
|
||||
resetPreludeDocsCache();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("warmPythonEnvironment", () => {
|
||||
it("caches prelude docs on warmup", async () => {
|
||||
const previousSkip = Bun.env.PI_PYTHON_SKIP_CHECK;
|
||||
Bun.env.PI_PYTHON_SKIP_CHECK = "1";
|
||||
using tempDir = TempDir.createSync("@python-executor-");
|
||||
const docs: PreludeHelper[] = [
|
||||
{
|
||||
name: "read",
|
||||
signature: "(path)",
|
||||
docstring: "Read file contents.",
|
||||
category: "File I/O",
|
||||
},
|
||||
];
|
||||
const kernel = {
|
||||
introspectPrelude: vi.fn().mockResolvedValue(docs),
|
||||
ping: vi.fn().mockResolvedValue(true),
|
||||
isAlive: () => true,
|
||||
shutdown: vi.fn(async (): Promise<KernelShutdownResult> => ({ confirmed: true })),
|
||||
};
|
||||
const startSpy = vi.spyOn(PythonKernel, "start").mockResolvedValue(kernel as unknown as PythonKernel);
|
||||
|
||||
const result = await warmPythonEnvironment(tempDir.path(), "session-1");
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.docs).toEqual(docs);
|
||||
expect(getPreludeDocs()).toEqual(docs);
|
||||
expect(kernel.introspectPrelude).toHaveBeenCalledTimes(1);
|
||||
|
||||
startSpy.mockRestore();
|
||||
if (previousSkip === undefined) {
|
||||
delete Bun.env.PI_PYTHON_SKIP_CHECK;
|
||||
} else {
|
||||
Bun.env.PI_PYTHON_SKIP_CHECK = previousSkip;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { renderKernelDisplay } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
import { renderKernelDisplay } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
|
||||
describe("PythonKernel display rendering", () => {
|
||||
it("normalizes text/plain output and returns no display outputs", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { filterEnv } from "@oh-my-pi/pi-coding-agent/ipy/runtime";
|
||||
import { filterEnv } from "@oh-my-pi/pi-coding-agent/eval/py/runtime";
|
||||
|
||||
describe("Python gateway environment filtering", () => {
|
||||
it("filters sensitive and unknown variables from shell env", () => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { disposeAllKernelSessions, executePython } from "@oh-my-pi/pi-coding-agent/ipy/executor";
|
||||
import { disposeAllKernelSessions, executePython } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
|
||||
import type {
|
||||
KernelExecuteOptions,
|
||||
KernelExecuteResult,
|
||||
KernelShutdownResult,
|
||||
} from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
import { PythonKernel } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
} from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
import { PythonKernel } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
class FakeKernel {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
deserializeWebSocketMessage,
|
||||
type JupyterMessage,
|
||||
serializeWebSocketMessage,
|
||||
} from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
} from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as gatewayCoordinator from "@oh-my-pi/pi-coding-agent/ipy/gateway-coordinator";
|
||||
import { PythonKernel } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
import * as gatewayCoordinator from "@oh-my-pi/pi-coding-agent/eval/py/gateway-coordinator";
|
||||
import { PythonKernel } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
import { hookFetch, TempDir } from "@oh-my-pi/pi-utils";
|
||||
import type { Subprocess } from "bun";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import { type KernelDisplayOutput, PythonKernel } from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
import { PYTHON_PRELUDE } from "@oh-my-pi/pi-coding-agent/ipy/prelude";
|
||||
import { type KernelDisplayOutput, PythonKernel } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
import { PYTHON_PRELUDE } from "@oh-my-pi/pi-coding-agent/eval/py/prelude";
|
||||
import { hookFetch } from "@oh-my-pi/pi-utils";
|
||||
|
||||
type JupyterMessage = {
|
||||
@@ -372,105 +372,6 @@ describe("PythonKernel (external gateway)", () => {
|
||||
expect(kernel.isAlive()).toBe(true);
|
||||
await kernel.shutdown();
|
||||
});
|
||||
|
||||
it("introspects prelude helpers", async () => {
|
||||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (url.endsWith("/api/kernels") && init?.method === "POST") {
|
||||
return new Response(JSON.stringify({ id: "kernel-4" }), { status: 201 });
|
||||
}
|
||||
return new Response("", { status: 200 });
|
||||
});
|
||||
using _hook = hookFetch((input, init) => fetchMock(String(input), init));
|
||||
|
||||
const docs = [
|
||||
{
|
||||
name: "read",
|
||||
signature: "(path, limit=None)",
|
||||
docstring: "Read file contents.",
|
||||
category: "File I/O",
|
||||
},
|
||||
];
|
||||
const payload = JSON.stringify(docs);
|
||||
|
||||
let initSeen = false;
|
||||
let preludeSeen = false;
|
||||
|
||||
const kernelPromise = PythonKernel.start({ cwd: "/" });
|
||||
await Bun.sleep(10);
|
||||
const ws = FakeWebSocket.lastInstance;
|
||||
if (!ws) throw new Error("WebSocket not initialized");
|
||||
ws.setSendHandler(data => {
|
||||
const msg = typeof data === "string" ? (JSON.parse(data) as JupyterMessage) : decodeMessage(data);
|
||||
const code = String(msg.content.code ?? "");
|
||||
if (!initSeen) {
|
||||
initSeen = true;
|
||||
sendOkExecution(ws, msg.header.msg_id);
|
||||
return;
|
||||
}
|
||||
if (!preludeSeen) {
|
||||
expect(code).toBe(PYTHON_PRELUDE);
|
||||
preludeSeen = true;
|
||||
sendOkExecution(ws, msg.header.msg_id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (code.includes("__omp_prelude_docs__")) {
|
||||
const stream: JupyterMessage = {
|
||||
channel: "iopub",
|
||||
header: {
|
||||
msg_id: "stream-docs",
|
||||
session: "session",
|
||||
username: "omp",
|
||||
date: new Date().toISOString(),
|
||||
msg_type: "stream",
|
||||
version: "5.5",
|
||||
},
|
||||
parent_header: { msg_id: msg.header.msg_id },
|
||||
metadata: {},
|
||||
content: { text: `${payload}\n` },
|
||||
};
|
||||
const reply: JupyterMessage = {
|
||||
channel: "shell",
|
||||
header: {
|
||||
msg_id: "reply-docs",
|
||||
session: "session",
|
||||
username: "omp",
|
||||
date: new Date().toISOString(),
|
||||
msg_type: "execute_reply",
|
||||
version: "5.5",
|
||||
},
|
||||
parent_header: { msg_id: msg.header.msg_id },
|
||||
metadata: {},
|
||||
content: { status: "ok", execution_count: 2 },
|
||||
};
|
||||
const status: JupyterMessage = {
|
||||
channel: "iopub",
|
||||
header: {
|
||||
msg_id: "status-docs",
|
||||
session: "session",
|
||||
username: "omp",
|
||||
date: new Date().toISOString(),
|
||||
msg_type: "status",
|
||||
version: "5.5",
|
||||
},
|
||||
parent_header: { msg_id: msg.header.msg_id },
|
||||
metadata: {},
|
||||
content: { execution_state: "idle" },
|
||||
};
|
||||
ws.onmessage?.({ data: encodeMessage(stream) });
|
||||
ws.onmessage?.({ data: encodeMessage(reply) });
|
||||
ws.onmessage?.({ data: encodeMessage(status) });
|
||||
return;
|
||||
}
|
||||
|
||||
sendOkExecution(ws, msg.header.msg_id);
|
||||
});
|
||||
|
||||
const kernel = await kernelPromise;
|
||||
const result = await kernel.introspectPrelude();
|
||||
expect(result).toEqual(docs);
|
||||
await kernel.shutdown();
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: add coverage for gateway process exit handling once PythonKernel exposes a test hook.
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { loadPythonModules, type PythonModuleExecutor } from "@oh-my-pi/pi-coding-agent/ipy/modules";
|
||||
import { getAgentModulesDir, getProjectModulesDir, TempDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
const fixturesDir = path.resolve(import.meta.dir, "../../test/fixtures/python-modules");
|
||||
|
||||
const readFixture = (name: string): Promise<string> => Bun.file(path.join(fixturesDir, name)).text();
|
||||
|
||||
const writeModule = async (dir: string, name: string, tag: string) => {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const base = await readFixture(name);
|
||||
await Bun.write(path.join(dir, name), `${base}\n# ${tag}`);
|
||||
};
|
||||
|
||||
describe("python modules", () => {
|
||||
let tempRoot: TempDir | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (tempRoot) {
|
||||
tempRoot.removeSync();
|
||||
}
|
||||
tempRoot = null;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("loads modules in sorted order and forwards startup execute options", async () => {
|
||||
tempRoot = TempDir.createSync("@omp-python-modules-");
|
||||
const agentDir = path.join(tempRoot.path(), "agent");
|
||||
const cwd = path.join(tempRoot.path(), "project");
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
await writeModule(getAgentModulesDir(agentDir), "beta.py", "user-omp");
|
||||
await writeModule(getAgentModulesDir(agentDir), "alpha.py", "user-omp");
|
||||
|
||||
const calls: Array<{
|
||||
name: string;
|
||||
options?: { signal?: AbortSignal; timeoutMs?: number; silent?: boolean; storeHistory?: boolean };
|
||||
}> = [];
|
||||
const executor: PythonModuleExecutor = {
|
||||
execute: async (code: string, options) => {
|
||||
const name = code.includes("def alpha") ? "alpha" : "beta";
|
||||
calls.push({ name, options });
|
||||
return { status: "ok", cancelled: false };
|
||||
},
|
||||
};
|
||||
|
||||
await loadPythonModules(executor, { cwd, agentDir, signal, timeoutMs: 987 });
|
||||
expect(calls.map(call => call.name)).toEqual(["alpha", "beta"]);
|
||||
for (const call of calls) {
|
||||
expect(call.options).toEqual({ signal, timeoutMs: 987, silent: true, storeHistory: false });
|
||||
}
|
||||
});
|
||||
|
||||
it("derives module execution timeout from the remaining deadline", async () => {
|
||||
tempRoot = TempDir.createSync("@omp-python-modules-");
|
||||
const agentDir = path.join(tempRoot.path(), "agent");
|
||||
const cwd = path.join(tempRoot.path(), "project");
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
await writeModule(getProjectModulesDir(cwd), "alpha.py", "project-omp");
|
||||
|
||||
const execute = vi.fn(
|
||||
async (
|
||||
_code: string,
|
||||
_options?: {
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
silent?: boolean;
|
||||
storeHistory?: boolean;
|
||||
},
|
||||
) => ({ status: "ok" as const, cancelled: false }),
|
||||
);
|
||||
const executor: PythonModuleExecutor = { execute };
|
||||
vi.spyOn(Date, "now").mockReturnValue(10_000);
|
||||
|
||||
await loadPythonModules(executor, { cwd, agentDir, signal, deadlineMs: 10_250 });
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
expect(execute).toHaveBeenCalledWith(expect.any(String), {
|
||||
signal,
|
||||
timeoutMs: 250,
|
||||
silent: true,
|
||||
storeHistory: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails fast when the module deadline expires before the next execution starts", async () => {
|
||||
tempRoot = TempDir.createSync("@omp-python-modules-");
|
||||
const agentDir = path.join(tempRoot.path(), "agent");
|
||||
const cwd = path.join(tempRoot.path(), "project");
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
await writeModule(getProjectModulesDir(cwd), "alpha.py", "project-omp");
|
||||
await writeModule(getProjectModulesDir(cwd), "beta.py", "project-omp");
|
||||
|
||||
const execute = vi.fn(async () => ({ status: "ok" as const, cancelled: false }));
|
||||
const executor: PythonModuleExecutor = { execute };
|
||||
vi.spyOn(Date, "now").mockReturnValueOnce(10_000).mockReturnValueOnce(10_300);
|
||||
|
||||
await expect(loadPythonModules(executor, { cwd, agentDir, signal, deadlineMs: 10_250 })).rejects.toMatchObject({
|
||||
name: "TimeoutError",
|
||||
message: "Python module loading timed out",
|
||||
});
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
expect(execute).toHaveBeenCalledWith(expect.any(String), {
|
||||
signal,
|
||||
timeoutMs: 250,
|
||||
silent: true,
|
||||
storeHistory: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves timeout classification when module execution is cancelled", async () => {
|
||||
tempRoot = TempDir.createSync("@omp-python-modules-");
|
||||
const agentDir = path.join(tempRoot.path(), "agent");
|
||||
const cwd = path.join(tempRoot.path(), "project");
|
||||
|
||||
await writeModule(getProjectModulesDir(cwd), "alpha.py", "project-omp");
|
||||
|
||||
const execute = vi.fn(async () => ({ status: "ok" as const, cancelled: true, timedOut: true }));
|
||||
const executor: PythonModuleExecutor = { execute };
|
||||
|
||||
await expect(loadPythonModules(executor, { cwd, agentDir })).rejects.toMatchObject({
|
||||
name: "TimeoutError",
|
||||
message: expect.stringContaining("Failed to load Python module"),
|
||||
});
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails fast when a module fails to execute", async () => {
|
||||
tempRoot = TempDir.createSync("@omp-python-modules-");
|
||||
const agentDir = path.join(tempRoot.path(), "agent");
|
||||
const cwd = path.join(tempRoot.path(), "project");
|
||||
|
||||
await writeModule(getAgentModulesDir(agentDir), "alpha.py", "user-omp");
|
||||
await writeModule(getProjectModulesDir(cwd), "beta.py", "project-omp");
|
||||
|
||||
const executor: PythonModuleExecutor = {
|
||||
execute: async (code: string) => {
|
||||
if (code.includes("def beta")) {
|
||||
return {
|
||||
status: "error",
|
||||
cancelled: false,
|
||||
error: { name: "Error", value: "boom", traceback: [] },
|
||||
};
|
||||
}
|
||||
return { status: "ok", cancelled: false };
|
||||
},
|
||||
};
|
||||
|
||||
await expect(loadPythonModules(executor, { cwd, agentDir })).rejects.toThrow("Failed to load Python module");
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,8 @@ import { describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { resetPreludeDocsCache, warmPythonEnvironment } from "@oh-my-pi/pi-coding-agent/ipy/executor";
|
||||
import { getPythonToolDescription, PythonTool } from "@oh-my-pi/pi-coding-agent/tools/python";
|
||||
import { warmPythonEnvironment } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
|
||||
import { EvalTool, getEvalToolDescription } from "@oh-my-pi/pi-coding-agent/tools/eval";
|
||||
import { $which, getProjectDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
const resolvePythonPath = (): string | null => {
|
||||
@@ -39,7 +39,7 @@ const hasKernelDeps = (() => {
|
||||
const shouldRun = Boolean(pythonPath) && hasKernelDeps;
|
||||
|
||||
describe.skipIf(!shouldRun)("PYTHON_PRELUDE integration", () => {
|
||||
it("exposes prelude helpers via python tool", async () => {
|
||||
it("exposes prelude helpers via eval python backend", async () => {
|
||||
const helpers = ["env", "read", "write", "append", "rm", "mv", "cp", "find", "grep"];
|
||||
|
||||
const session = {
|
||||
@@ -49,47 +49,37 @@ describe.skipIf(!shouldRun)("PYTHON_PRELUDE integration", () => {
|
||||
getSessionSpawns: () => null,
|
||||
settings: Settings.isolated({
|
||||
"lsp.diagnosticsOnWrite": false,
|
||||
"python.toolMode": "ipy-only",
|
||||
"eval.py": true,
|
||||
"python.kernelMode": "per-call",
|
||||
"python.sharedGateway": true,
|
||||
}),
|
||||
};
|
||||
|
||||
resetPreludeDocsCache();
|
||||
const tool = new PythonTool(session);
|
||||
const tool = new EvalTool(session);
|
||||
const code = `
|
||||
helpers = ${JSON.stringify(helpers)}
|
||||
missing = [name for name in helpers if name not in globals() or not callable(globals()[name])]
|
||||
docs = __omp_prelude_docs__()
|
||||
doc_names = [d.get("name") for d in docs]
|
||||
doc_categories = [d.get("category") for d in docs]
|
||||
print("HELPERS_OK=" + ("1" if not missing else "0"))
|
||||
print("DOCS_OK=" + ("1" if "read" in doc_names and "File I/O" in doc_categories else "0"))
|
||||
if missing:
|
||||
print("MISSING=" + ",".join(missing))
|
||||
`;
|
||||
|
||||
const result = await tool.execute("tool-call-1", { cells: [{ title: "prelude helpers", code }] });
|
||||
const result = await tool.execute("tool-call-1", {
|
||||
input: `=== CELL prelude helpers ===
|
||||
\`\`\`py
|
||||
${code}
|
||||
\`\`\`
|
||||
`,
|
||||
});
|
||||
const output = result.content.find(item => item.type === "text")?.text ?? "";
|
||||
expect(output).toContain("HELPERS_OK=1");
|
||||
expect(output).toContain("DOCS_OK=1");
|
||||
expect(tool.description).toContain("read");
|
||||
expect(tool.description).not.toContain("Documentation unavailable");
|
||||
});
|
||||
|
||||
it("exposes prelude docs via warmup", async () => {
|
||||
resetPreludeDocsCache();
|
||||
it("renders prelude docs in eval tool description", async () => {
|
||||
const result = await warmPythonEnvironment(getProjectDir());
|
||||
expect(result.ok).toBe(true);
|
||||
const names = result.docs.map(doc => doc.name);
|
||||
expect(names).toContain("read");
|
||||
});
|
||||
|
||||
it("renders prelude docs in python tool description", async () => {
|
||||
resetPreludeDocsCache();
|
||||
const result = await warmPythonEnvironment(getProjectDir());
|
||||
expect(result.ok).toBe(true);
|
||||
const description = getPythonToolDescription();
|
||||
const description = getEvalToolDescription();
|
||||
expect(description).toContain("read");
|
||||
expect(description).not.toContain("Documentation unavailable");
|
||||
});
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
|
||||
describe("Settings python settings", () => {
|
||||
it("defaults to both and session", () => {
|
||||
const settings = Settings.isolated({});
|
||||
|
||||
expect(settings.get("python.toolMode")).toBe("both");
|
||||
expect(settings.get("python.kernelMode")).toBe("session");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { parseEvalInput } from "../../src/eval/parse";
|
||||
|
||||
describe("parseEvalInput", () => {
|
||||
it("parses a single fenced cell with positional title and timeout", () => {
|
||||
const result = parseEvalInput(`\`\`\`py setup 15s
|
||||
print("hi")
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
expect(result.cells).toEqual([
|
||||
{
|
||||
index: 0,
|
||||
title: "setup",
|
||||
code: 'print("hi")',
|
||||
language: "python",
|
||||
languageOrigin: "fence",
|
||||
timeoutMs: 15_000,
|
||||
reset: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats rst=true as a per-language kernel wipe for that cell", () => {
|
||||
const result = parseEvalInput(`\`\`\`py rst=true id="bootstrap"
|
||||
import json
|
||||
\`\`\`
|
||||
|
||||
\`\`\`js rst=true
|
||||
const x = 1;
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
expect(result.cells.map(cell => [cell.language, cell.reset, cell.title])).toEqual([
|
||||
["python", true, "bootstrap"],
|
||||
["js", true, undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
it("inherits language and runs without reset for empty fence info", () => {
|
||||
const result = parseEvalInput(`\`\`\`js
|
||||
const a = 1;
|
||||
\`\`\`
|
||||
|
||||
\`\`\`
|
||||
const b = a + 1;
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
expect(result.cells.map(cell => [cell.language, cell.languageOrigin, cell.code, cell.reset])).toEqual([
|
||||
["js", "fence", "const a = 1;", false],
|
||||
["js", "fence", "const b = a + 1;", false],
|
||||
]);
|
||||
});
|
||||
|
||||
it("supports tilde fences and case-insensitive language tokens including ipython aliases", () => {
|
||||
const result = parseEvalInput(`~~~TypeScript
|
||||
const a = 1;
|
||||
~~~
|
||||
|
||||
\`\`\`IPython
|
||||
print("ipy")
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
expect(result.cells.map(cell => [cell.language, cell.languageOrigin])).toEqual([
|
||||
["js", "fence"],
|
||||
["python", "fence"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses canonical id and t attributes, with explicit attrs winning over positional", () => {
|
||||
const result = parseEvalInput(`\`\`\`py 5s some words t=2m id="explicit win"
|
||||
print(1)
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
expect(result.cells[0]).toMatchObject({
|
||||
title: "explicit win",
|
||||
timeoutMs: 120_000,
|
||||
language: "python",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts fallback aliases for id, t, and rst keys", () => {
|
||||
const cases = [
|
||||
{ key: "title", expectTitle: "alpha" },
|
||||
{ key: "name", expectTitle: "alpha" },
|
||||
{ key: "cell", expectTitle: "alpha" },
|
||||
{ key: "file", expectTitle: "alpha" },
|
||||
{ key: "label", expectTitle: "alpha" },
|
||||
];
|
||||
for (const { key, expectTitle } of cases) {
|
||||
const result = parseEvalInput(`\`\`\`py ${key}="alpha"\nprint(1)\n\`\`\`\n`);
|
||||
expect(result.cells[0].title).toBe(expectTitle);
|
||||
}
|
||||
|
||||
const timeoutAliases = ["timeout", "duration", "time"];
|
||||
for (const key of timeoutAliases) {
|
||||
const result = parseEvalInput(`\`\`\`py ${key}=2m\nprint(1)\n\`\`\`\n`);
|
||||
expect(result.cells[0].timeoutMs).toBe(120_000);
|
||||
}
|
||||
|
||||
const resetAliases = ["reset"];
|
||||
for (const key of resetAliases) {
|
||||
const result = parseEvalInput(`\`\`\`py ${key}=true\nprint(1)\n\`\`\`\n`);
|
||||
expect(result.cells[0].reset).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("first occurrence wins when canonical and alias collide", () => {
|
||||
const canonicalFirst = parseEvalInput(`\`\`\`py id="canon" title="alias"
|
||||
print(1)
|
||||
\`\`\`
|
||||
`);
|
||||
const aliasFirst = parseEvalInput(`\`\`\`py title="alias" id="canon"
|
||||
print(1)
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
expect(canonicalFirst.cells[0].title).toBe("canon");
|
||||
expect(aliasFirst.cells[0].title).toBe("alias");
|
||||
});
|
||||
|
||||
it("parses millisecond, second, and minute durations", () => {
|
||||
const result = parseEvalInput(`\`\`\`py 500ms
|
||||
a = 1
|
||||
\`\`\`
|
||||
|
||||
\`\`\`py 5
|
||||
a = 2
|
||||
\`\`\`
|
||||
|
||||
\`\`\`py 2m
|
||||
a = 3
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
expect(result.cells.map(cell => cell.timeoutMs)).toEqual([500, 5_000, 120_000]);
|
||||
});
|
||||
|
||||
it("treats unrecognized fence info as title and inherits the language", () => {
|
||||
const result = parseEvalInput(`\`\`\`ruby
|
||||
puts "no"
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
expect(result.cells[0]).toMatchObject({
|
||||
title: "ruby",
|
||||
code: 'puts "no"',
|
||||
language: "python",
|
||||
languageOrigin: "default",
|
||||
});
|
||||
});
|
||||
|
||||
it("joins multiple positional title fragments with spaces", () => {
|
||||
const result = parseEvalInput(`\`\`\`py compute totals
|
||||
print(1)
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
expect(result.cells[0].title).toBe("compute totals");
|
||||
});
|
||||
|
||||
it("accepts back-to-back fenced cells without blank separators", () => {
|
||||
const result = parseEvalInput(`\`\`\`py id=a
|
||||
print("a")
|
||||
\`\`\`
|
||||
\`\`\`py id=b
|
||||
print("b")
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
expect(result.cells.map(cell => [cell.title, cell.code])).toEqual([
|
||||
["a", 'print("a")'],
|
||||
["b", 'print("b")'],
|
||||
]);
|
||||
});
|
||||
|
||||
it("wraps bare code with no fences in a single implicit cell", () => {
|
||||
const result = parseEvalInput(`print("hello")
|
||||
print("world")
|
||||
`);
|
||||
|
||||
expect(result.cells).toEqual([
|
||||
{
|
||||
index: 0,
|
||||
title: undefined,
|
||||
code: 'print("hello")\nprint("world")',
|
||||
language: "python",
|
||||
languageOrigin: "default",
|
||||
timeoutMs: 30_000,
|
||||
reset: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces raw inter-fence content as its own implicit cell that inherits language", () => {
|
||||
const result = parseEvalInput(`\`\`\`js
|
||||
const x = 1;
|
||||
\`\`\`
|
||||
|
||||
inherited tail
|
||||
`);
|
||||
|
||||
expect(result.cells.map(cell => [cell.language, cell.languageOrigin, cell.code])).toEqual([
|
||||
["js", "fence", "const x = 1;"],
|
||||
["js", "fence", "inherited tail"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats unclosed fences leniently and closes them at end of input", () => {
|
||||
const result = parseEvalInput(`\`\`\`py
|
||||
print("still typing")`);
|
||||
|
||||
expect(result.cells).toHaveLength(1);
|
||||
expect(result.cells[0]).toMatchObject({
|
||||
code: 'print("still typing")',
|
||||
language: "python",
|
||||
languageOrigin: "fence",
|
||||
reset: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores unknown attribute keys without erroring", () => {
|
||||
const result = parseEvalInput(`\`\`\`py mystery=123 id=ok
|
||||
print(1)
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
expect(result.cells[0]).toMatchObject({ title: "ok", language: "python" });
|
||||
});
|
||||
|
||||
it("rejects an invalid rst value", () => {
|
||||
expect(() =>
|
||||
parseEvalInput(`\`\`\`py rst=maybe
|
||||
print(1)
|
||||
\`\`\`
|
||||
`),
|
||||
).toThrow("invalid rst value");
|
||||
});
|
||||
|
||||
it("rejects an invalid t value", () => {
|
||||
expect(() =>
|
||||
parseEvalInput(`\`\`\`py t=forever
|
||||
print(1)
|
||||
\`\`\`
|
||||
`),
|
||||
).toThrow("invalid duration");
|
||||
});
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
def alpha():
|
||||
return "alpha"
|
||||
@@ -1,2 +0,0 @@
|
||||
def beta():
|
||||
return "beta"
|
||||
@@ -1,2 +0,0 @@
|
||||
def delta():
|
||||
return "delta"
|
||||
@@ -1,2 +0,0 @@
|
||||
def gamma():
|
||||
return "gamma"
|
||||
@@ -49,7 +49,7 @@ function createContext(): {
|
||||
spies: {
|
||||
abort: ReturnType<typeof vi.fn>;
|
||||
abortBash: ReturnType<typeof vi.fn>;
|
||||
abortPython: ReturnType<typeof vi.fn>;
|
||||
abortEval: ReturnType<typeof vi.fn>;
|
||||
addMessageToChat: ReturnType<typeof vi.fn>;
|
||||
cancelPendingSubmission: ReturnType<typeof vi.fn>;
|
||||
clearQueue: ReturnType<typeof vi.fn>;
|
||||
@@ -66,7 +66,7 @@ function createContext(): {
|
||||
let editorText = "";
|
||||
const abort = vi.fn();
|
||||
const abortBash = vi.fn();
|
||||
const abortPython = vi.fn();
|
||||
const abortEval = vi.fn();
|
||||
const addMessageToChat = vi.fn();
|
||||
const cancelPendingSubmission = vi.fn(() => false);
|
||||
const clearQueue = vi.fn(() => ({ steering: [], followUp: [] }));
|
||||
@@ -111,13 +111,13 @@ function createContext(): {
|
||||
isCompacting: false,
|
||||
isGeneratingHandoff: false,
|
||||
isBashRunning: false,
|
||||
isPythonRunning: false,
|
||||
isEvalRunning: false,
|
||||
queuedMessageCount: 0,
|
||||
messages: [],
|
||||
extensionRunner: undefined,
|
||||
abort,
|
||||
abortBash,
|
||||
abortPython,
|
||||
abortEval,
|
||||
clearQueue,
|
||||
prompt,
|
||||
} as unknown as InteractiveModeContext["session"],
|
||||
@@ -160,7 +160,7 @@ function createContext(): {
|
||||
spies: {
|
||||
abort,
|
||||
abortBash,
|
||||
abortPython,
|
||||
abortEval,
|
||||
addMessageToChat,
|
||||
cancelPendingSubmission,
|
||||
clearQueue,
|
||||
@@ -242,14 +242,14 @@ describe("InputController escape behavior", () => {
|
||||
|
||||
it("prefers aborting python before aborting an overlapping stream", () => {
|
||||
const { ctx, editor, spies } = createContext();
|
||||
(ctx.session as { isStreaming: boolean; isPythonRunning: boolean }).isStreaming = true;
|
||||
(ctx.session as { isStreaming: boolean; isPythonRunning: boolean }).isPythonRunning = true;
|
||||
(ctx.session as { isStreaming: boolean; isEvalRunning: boolean }).isStreaming = true;
|
||||
(ctx.session as { isStreaming: boolean; isEvalRunning: boolean }).isEvalRunning = true;
|
||||
const controller = new InputController(ctx);
|
||||
|
||||
controller.setupKeyHandlers();
|
||||
editor.onEscape?.();
|
||||
|
||||
expect(spies.abortPython).toHaveBeenCalledTimes(1);
|
||||
expect(spies.abortEval).toHaveBeenCalledTimes(1);
|
||||
expect(spies.abort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ async function createContext() {
|
||||
isCompacting: false,
|
||||
isGeneratingHandoff: false,
|
||||
isBashRunning: false,
|
||||
isPythonRunning: false,
|
||||
isEvalRunning: false,
|
||||
extensionRunner: undefined,
|
||||
} as InteractiveModeContext["session"],
|
||||
keybindings: {
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { type SettingPath, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import * as pythonExecutor from "@oh-my-pi/pi-coding-agent/ipy/executor";
|
||||
import * as pythonKernel from "@oh-my-pi/pi-coding-agent/ipy/kernel";
|
||||
import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
import { PythonTool } from "@oh-my-pi/pi-coding-agent/tools/python";
|
||||
import { Snowflake } from "@oh-my-pi/pi-utils";
|
||||
|
||||
function createSession(
|
||||
cwd: string,
|
||||
sessionFile: string,
|
||||
overrides?: Partial<Record<SettingPath, unknown>>,
|
||||
kernelOwnerId?: string,
|
||||
forcePythonWarmup = false,
|
||||
): ToolSession {
|
||||
return {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
getSessionFile: () => sessionFile,
|
||||
getSessionSpawns: () => null,
|
||||
getPythonKernelOwnerId: () => kernelOwnerId ?? null,
|
||||
forcePythonWarmup,
|
||||
settings: Settings.isolated({ "python.toolMode": "ipy-only", ...overrides }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("python tool settings", () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = path.join(os.tmpdir(), `python-tool-settings-${Snowflake.next()}`);
|
||||
fs.mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
pythonExecutor.resetPreludeDocsCache();
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("exposes python tool when kernel is available", async () => {
|
||||
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
|
||||
const sessionFile = path.join(testDir, "session.jsonl");
|
||||
const tools = await createTools(createSession(testDir, sessionFile), ["python"]);
|
||||
|
||||
expect(tools.map(tool => tool.name).sort()).toEqual(["exit_plan_mode", "python"]);
|
||||
});
|
||||
|
||||
it("falls back to bash when python is unavailable", async () => {
|
||||
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({
|
||||
ok: false,
|
||||
reason: "missing",
|
||||
});
|
||||
const sessionFile = path.join(testDir, "session.jsonl");
|
||||
const tools = await createTools(createSession(testDir, sessionFile), ["python"]);
|
||||
|
||||
expect(tools.map(tool => tool.name).sort()).toEqual(["bash", "exit_plan_mode"]);
|
||||
});
|
||||
|
||||
it("passes kernel owner and kernel mode from settings to executor", async () => {
|
||||
vi.spyOn(pythonExecutor, "getPreludeDocs").mockReturnValue([]);
|
||||
const warmupSpy = vi.spyOn(pythonExecutor, "warmPythonEnvironment").mockResolvedValue({ ok: true, docs: [] });
|
||||
const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockResolvedValue({
|
||||
output: "ok",
|
||||
exitCode: 0,
|
||||
cancelled: false,
|
||||
truncated: false,
|
||||
totalLines: 1,
|
||||
totalBytes: 2,
|
||||
outputLines: 1,
|
||||
outputBytes: 2,
|
||||
displayOutputs: [],
|
||||
stdinRequested: false,
|
||||
});
|
||||
|
||||
const sessionFile = path.join(testDir, "session.jsonl");
|
||||
const kernelOwnerId = "owner-456";
|
||||
const session = createSession(testDir, sessionFile, { "python.kernelMode": "per-call" }, kernelOwnerId);
|
||||
const pythonTool = new PythonTool(session);
|
||||
|
||||
await pythonTool.execute("tool-call", { cells: [{ title: "print-single-cell", code: "print(1)" }] });
|
||||
|
||||
expect(warmupSpy).toHaveBeenCalledWith(
|
||||
testDir,
|
||||
`session:${sessionFile}:cwd:${testDir}`,
|
||||
true,
|
||||
sessionFile,
|
||||
kernelOwnerId,
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(executeSpy).toHaveBeenCalledWith(
|
||||
"print(1)",
|
||||
expect.objectContaining({
|
||||
kernelMode: "per-call",
|
||||
sessionId: `session:${sessionFile}:cwd:${testDir}`,
|
||||
kernelOwnerId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes kernel owner into createTools warmup without changing session ids", async () => {
|
||||
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
|
||||
vi.spyOn(pythonExecutor, "getPreludeDocs").mockReturnValue([]);
|
||||
const warmupSpy = vi.spyOn(pythonExecutor, "warmPythonEnvironment").mockResolvedValue({ ok: true, docs: [] });
|
||||
|
||||
const sessionFile = path.join(testDir, "session-create-tools.jsonl");
|
||||
const kernelOwnerId = "owner-create-tools";
|
||||
const previousSkipCheck = Bun.env.PI_PYTHON_SKIP_CHECK;
|
||||
|
||||
delete Bun.env.PI_PYTHON_SKIP_CHECK;
|
||||
try {
|
||||
await createTools(createSession(testDir, sessionFile, undefined, kernelOwnerId, true), ["python"]);
|
||||
|
||||
expect(warmupSpy).toHaveBeenCalledWith(
|
||||
testDir,
|
||||
`session:${sessionFile}:cwd:${testDir}`,
|
||||
true,
|
||||
sessionFile,
|
||||
kernelOwnerId,
|
||||
);
|
||||
} finally {
|
||||
if (previousSkipCheck === undefined) {
|
||||
delete Bun.env.PI_PYTHON_SKIP_CHECK;
|
||||
} else {
|
||||
Bun.env.PI_PYTHON_SKIP_CHECK = previousSkipCheck;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -277,7 +277,7 @@ describe("system Handlebars prompt templates", () => {
|
||||
contextFiles: [],
|
||||
skills: [],
|
||||
rules: [],
|
||||
toolNames: ["read", "search", "find", "edit", "lsp", "bash", "python"],
|
||||
toolNames: ["read", "search", "find", "edit", "lsp", "bash", "eval"],
|
||||
tools: new Map([
|
||||
["read", { label: "Read", description: "Reads files" }],
|
||||
["search", { label: "Search", description: "Searches files" }],
|
||||
@@ -285,7 +285,7 @@ describe("system Handlebars prompt templates", () => {
|
||||
["edit", { label: "Edit", description: "Edits files", wireName: "apply_patch" }],
|
||||
["lsp", { label: "LSP", description: "Queries language servers" }],
|
||||
["bash", { label: "Bash", description: "Runs shell commands" }],
|
||||
["python", { label: "Python", description: "Runs Python cells" }],
|
||||
["eval", { label: "Eval", description: "Runs eval cells" }],
|
||||
]),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import * as evalIndex from "@oh-my-pi/pi-coding-agent/eval";
|
||||
import * as pyKernel from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
import { EvalTool } from "@oh-my-pi/pi-coding-agent/tools/eval";
|
||||
|
||||
function makeSession(): ToolSession {
|
||||
return {
|
||||
cwd: "/tmp/eval-test",
|
||||
hasUI: false,
|
||||
getSessionFile: () => null,
|
||||
getSessionSpawns: () => null,
|
||||
settings: Settings.isolated(),
|
||||
};
|
||||
}
|
||||
|
||||
const mockResult = {
|
||||
output: "ok",
|
||||
exitCode: 0,
|
||||
cancelled: false,
|
||||
truncated: false,
|
||||
artifactId: undefined,
|
||||
totalLines: 1,
|
||||
totalBytes: 2,
|
||||
outputLines: 1,
|
||||
outputBytes: 2,
|
||||
displayOutputs: [],
|
||||
};
|
||||
|
||||
describe("EvalTool language resolution", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("dispatches to js when fenced code declares ```js", async () => {
|
||||
vi.spyOn(pyKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
|
||||
const jsExecuteSpy = vi.spyOn(evalIndex.jsBackend, "execute").mockResolvedValue(mockResult);
|
||||
const pythonExecuteSpy = vi.spyOn(evalIndex.pythonBackend, "execute");
|
||||
|
||||
const tool = new EvalTool(makeSession());
|
||||
await tool.execute("call-1", {
|
||||
input: "=== CELL one ===\n```js\nconst x = 1;\n```\n",
|
||||
});
|
||||
|
||||
expect(jsExecuteSpy).toHaveBeenCalledTimes(1);
|
||||
expect(pythonExecuteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches to python when fenced code declares ```python", async () => {
|
||||
const pythonExecuteSpy = vi.spyOn(evalIndex.pythonBackend, "execute").mockResolvedValue(mockResult);
|
||||
vi.spyOn(evalIndex.pythonBackend, "isAvailable").mockResolvedValue(true);
|
||||
const jsExecuteSpy = vi.spyOn(evalIndex.jsBackend, "execute");
|
||||
|
||||
const tool = new EvalTool(makeSession());
|
||||
await tool.execute("call-2", {
|
||||
input: "=== CELL one ===\n```python\nprint('hi')\n```\n",
|
||||
});
|
||||
|
||||
expect(pythonExecuteSpy).toHaveBeenCalledTimes(1);
|
||||
expect(jsExecuteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-detects python via syntactic markers when fence is bare", async () => {
|
||||
const pythonExecuteSpy = vi.spyOn(evalIndex.pythonBackend, "execute").mockResolvedValue(mockResult);
|
||||
vi.spyOn(evalIndex.pythonBackend, "isAvailable").mockResolvedValue(true);
|
||||
const jsExecuteSpy = vi.spyOn(evalIndex.jsBackend, "execute");
|
||||
|
||||
const tool = new EvalTool(makeSession());
|
||||
await tool.execute("call-3", {
|
||||
input: "=== CELL one ===\ndef greet():\n print('hi')\ngreet()\n",
|
||||
});
|
||||
|
||||
expect(pythonExecuteSpy).toHaveBeenCalledTimes(1);
|
||||
expect(jsExecuteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,7 @@ describe("createTools", () => {
|
||||
const names = tools.map(t => t.name);
|
||||
|
||||
// Core tools should always be present
|
||||
expect(names).toContain("python");
|
||||
expect(names).toContain("eval");
|
||||
expect(names).toContain("bash");
|
||||
expect(names).toContain("read");
|
||||
expect(names).toContain("edit");
|
||||
@@ -83,46 +83,48 @@ describe("createTools", () => {
|
||||
expect(names).not.toContain("vim");
|
||||
});
|
||||
|
||||
it("includes bash and python when python mode is both", async () => {
|
||||
it("includes bash and eval when both eval backends are allowed", async () => {
|
||||
const session = createTestSession({
|
||||
settings: createSettingsWithOverrides({
|
||||
"python.toolMode": "both",
|
||||
"eval.py": true,
|
||||
"eval.js": true,
|
||||
}),
|
||||
});
|
||||
const tools = await createTools(session);
|
||||
const names = tools.map(t => t.name);
|
||||
|
||||
expect(names).toContain("python");
|
||||
expect(names).toContain("eval");
|
||||
expect(names).toContain("bash");
|
||||
});
|
||||
|
||||
it("includes bash when python mode is bash-only", async () => {
|
||||
it("still exposes eval when only the js backend is allowed", async () => {
|
||||
const session = createTestSession({
|
||||
settings: createSettingsWithOverrides({
|
||||
"python.toolMode": "bash-only",
|
||||
"eval.py": false,
|
||||
"eval.js": true,
|
||||
}),
|
||||
});
|
||||
const tools = await createTools(session);
|
||||
const names = tools.map(t => t.name);
|
||||
|
||||
expect(names).toContain("bash");
|
||||
expect(names).not.toContain("python");
|
||||
expect(names).toContain("eval");
|
||||
});
|
||||
|
||||
it("includes bash when python unavailable and python requested", async () => {
|
||||
it("still exposes eval when python kernel is unavailable (dispatches to js)", async () => {
|
||||
const session = createTestSession();
|
||||
vi.spyOn(await import("@oh-my-pi/pi-coding-agent/ipy/kernel"), "checkPythonKernelAvailability").mockResolvedValue(
|
||||
{
|
||||
ok: false,
|
||||
reason: "missing python",
|
||||
},
|
||||
);
|
||||
const tools = await createTools(session, ["python"]);
|
||||
vi.spyOn(
|
||||
await import("@oh-my-pi/pi-coding-agent/eval/py/kernel"),
|
||||
"checkPythonKernelAvailability",
|
||||
).mockResolvedValue({
|
||||
ok: false,
|
||||
reason: "missing python",
|
||||
});
|
||||
const tools = await createTools(session, ["eval"]);
|
||||
const names = tools.map(t => t.name);
|
||||
|
||||
expect(names).toContain("bash");
|
||||
expect(names).toContain("eval");
|
||||
expect(names).toContain("exit_plan_mode");
|
||||
expect(names).not.toContain("python");
|
||||
});
|
||||
|
||||
it("excludes lsp tool when session disables LSP", async () => {
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import * as pythonExecutor from "@oh-my-pi/pi-coding-agent/ipy/executor";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
import { PythonTool } from "@oh-my-pi/pi-coding-agent/tools/python";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
function createSession(cwd: string, kernelOwnerId?: string): ToolSession {
|
||||
return {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
getSessionFile: () => `${cwd}/session-file.jsonl`,
|
||||
getSessionSpawns: () => "*",
|
||||
getPythonKernelOwnerId: () => kernelOwnerId ?? null,
|
||||
settings: Settings.isolated({
|
||||
"lsp.formatOnWrite": true,
|
||||
"bashInterceptor.enabled": true,
|
||||
"python.toolMode": "ipy-only",
|
||||
"python.kernelMode": "per-call",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("python tool execution", () => {
|
||||
afterEach(() => {
|
||||
pythonExecutor.resetPreludeDocsCache();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("passes kernel owner and kernel options from settings and args", async () => {
|
||||
const tempDir = TempDir.createSync("@python-tool-");
|
||||
vi.spyOn(pythonExecutor, "getPreludeDocs").mockReturnValue([]);
|
||||
const warmupSpy = vi.spyOn(pythonExecutor, "warmPythonEnvironment").mockResolvedValue({ ok: true, docs: [] });
|
||||
const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockResolvedValue({
|
||||
output: "ok",
|
||||
exitCode: 0,
|
||||
cancelled: false,
|
||||
truncated: false,
|
||||
totalLines: 1,
|
||||
totalBytes: 2,
|
||||
outputLines: 1,
|
||||
outputBytes: 2,
|
||||
displayOutputs: [],
|
||||
stdinRequested: false,
|
||||
});
|
||||
|
||||
const kernelOwnerId = "owner-123";
|
||||
const tool = new PythonTool(createSession(tempDir.path(), kernelOwnerId));
|
||||
const result = await tool.execute(
|
||||
"call-id",
|
||||
{ cells: [{ title: "hello-world", code: "print('hi')" }], timeout: 5, reset: true },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(warmupSpy).toHaveBeenCalledWith(
|
||||
tempDir.path(),
|
||||
`session:${tempDir.path()}/session-file.jsonl:cwd:${tempDir.path()}`,
|
||||
true,
|
||||
`${tempDir.path()}/session-file.jsonl`,
|
||||
kernelOwnerId,
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(executeSpy).toHaveBeenCalledWith(
|
||||
"print('hi')",
|
||||
expect.objectContaining({
|
||||
cwd: tempDir.path(),
|
||||
deadlineMs: expect.any(Number),
|
||||
signal: expect.any(AbortSignal),
|
||||
sessionFile: `${tempDir.path()}/session-file.jsonl`,
|
||||
sessionId: `session:${tempDir.path()}/session-file.jsonl:cwd:${tempDir.path()}`,
|
||||
kernelOwnerId,
|
||||
kernelMode: "per-call",
|
||||
useSharedGateway: true,
|
||||
reset: true,
|
||||
}),
|
||||
);
|
||||
const text = result.content.find(item => item.type === "text")?.text;
|
||||
expect(text).toBe("ok");
|
||||
|
||||
tempDir.removeSync();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user