diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index deaaf9d38..feeee6d42 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog ## [Unreleased] +### Added + +- Added a Ruby eval backend (`language: "rb"`): a persistent subprocess Ruby kernel that shares the standard agent tool bridge and evaluation model, supporting IRB-style auto-display of results in a persistent session +- Added a Julia eval backend (`language: "jl"`): a persistent subprocess Julia kernel that shares the standard agent tool bridge and evaluation model, supporting Julia REPL-style auto-display of results in a persistent session +- Added configuration options `eval.rb` and `eval.jl` to enable or disable the Ruby and Julia backends, as well as `ruby.interpreter` and `julia.interpreter` paths for explicit runtime control +- Added a Ruby eval backend (`language: "rb"`): a persistent subprocess Ruby kernel modeled on the Python kernel, speaking the same NDJSON protocol with isolated frame/stdout channels, clean SIGINT cancellation that preserves kernel state, and per-owner kernel cleanup. Ships the full prelude helper surface (`display`/`read`/`write`/`append`/`tree`/`diff`/`env`/`output`/`sort`/`uniq`/`counter`, the `tool.` bridge proxy, and `completion`/`agent`/`parallel`/`pipeline`/`log`/`phase`/`budget`) over the shared loopback tool bridge, honors `local://` roots, and auto-displays the last expression IRB-style (suppressing assignments/definitions). Gated by the `eval.rb` setting and `PI_RB` env flag, with an optional `ruby.interpreter` override. +- Added a Julia eval backend (`language: "jl"`): a persistent subprocess Julia kernel with the same NDJSON/result-display/tool-bridge model as the Python and Ruby eval runtimes, including `eval.jl` / `PI_JL` gating, `julia.interpreter` override support, persistent per-session state, clean cancellation, owner-based kernel cleanup, `local://` path roots, and the shared prelude helper surface for file I/O, tool calls, subagents, concurrency helpers, and budget access. ### Changed diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index c1ef39d33..628b6a1a1 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -2986,6 +2986,28 @@ export const SETTINGS_SCHEMA = { }, }, + "eval.rb": { + type: "boolean", + default: true, + ui: { + tab: "shell", + group: "Eval & Python", + label: "Ruby Eval Backend", + description: "Allow the eval tool to dispatch Ruby cells to the persistent Ruby kernel", + }, + }, + + "eval.jl": { + type: "boolean", + default: true, + ui: { + tab: "shell", + group: "Eval & Python", + label: "Julia Eval Backend", + description: "Allow the eval tool to dispatch Julia cells to the persistent Julia kernel", + }, + }, + // Python kernel knobs (consumed by the eval py backend and the /python slash command) "python.kernelMode": { type: "enum", @@ -3009,6 +3031,28 @@ export const SETTINGS_SCHEMA = { "Optional path to an exact Python executable. When set, automatic Python runtime discovery is skipped.", }, }, + "ruby.interpreter": { + type: "string", + default: "", + ui: { + tab: "shell", + group: "Eval & Python", + label: "Ruby Interpreter", + description: + "Optional path to an exact Ruby executable. When set, automatic Ruby runtime discovery is skipped.", + }, + }, + "julia.interpreter": { + type: "string", + default: "", + ui: { + tab: "shell", + group: "Eval & Python", + label: "Julia Interpreter", + description: + "Optional path to an exact Julia executable. When set, automatic Julia runtime discovery is skipped.", + }, + }, // ──────────────────────────────────────────────────────────────────────── // Tools diff --git a/packages/coding-agent/src/eval/index.ts b/packages/coding-agent/src/eval/index.ts index 5986aa401..5c1c34081 100644 --- a/packages/coding-agent/src/eval/index.ts +++ b/packages/coding-agent/src/eval/index.ts @@ -1,4 +1,6 @@ export * from "./backend"; +export { default as juliaBackend } from "./jl"; export { default as jsBackend } from "./js"; export { default as pythonBackend } from "./py"; +export { default as rubyBackend } from "./rb"; export * from "./types"; diff --git a/packages/coding-agent/src/eval/jl/executor.ts b/packages/coding-agent/src/eval/jl/executor.ts new file mode 100644 index 000000000..db72a7e6e --- /dev/null +++ b/packages/coding-agent/src/eval/jl/executor.ts @@ -0,0 +1,583 @@ +import * as path from "node:path"; +import { getProjectDir, logger } from "@oh-my-pi/pi-utils"; +import { Settings } from "../../config/settings"; +import { OutputSink } from "../../session/streaming-output"; +import type { ToolSession } from "../../tools"; +import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta"; +import { ensurePyToolBridge, type PyToolBridgeInfo, registerPyToolBridge } from "../py/tool-bridge"; +import type { EvalDisplayOutput, EvalStatusEvent } from "../types"; +import { + checkJuliaKernelAvailability, + JuliaKernel, + type KernelDisplayOutput, + type KernelExecuteOptions, + type KernelExecuteResult, +} from "./kernel"; +import { resolveExplicitJuliaRuntime } from "./runtime"; + +const SHUTDOWN_GRACE_MS = 1_000; + +export interface JuliaExecutorOptions { + cwd?: string; + sessionId?: string; + sessionFile?: string; + artifactsDir?: string; + localRoots?: Record; + interpreter?: string; + onChunk?: (text: string) => void | Promise; + onStatus?: (event: EvalStatusEvent) => void; + signal?: AbortSignal; + timeoutMs?: number; + deadlineMs?: number; + idleTimeoutMs?: number; + kernelOwnerId?: string; + reset?: boolean; + toolSession?: ToolSession; + bridge?: PyToolBridgeInfo; + bridgeSessionId?: string; + artifactId?: string; +} + +export interface JuliaKernelExecutor { + execute: (code: string, options?: KernelExecuteOptions) => Promise; +} + +export interface JuliaResult { + output: string; + exitCode: number | undefined; + cancelled: boolean; + truncated: boolean; + artifactId: string | undefined; + totalLines: number; + totalBytes: number; + outputLines: number; + outputBytes: number; + displayOutputs: EvalDisplayOutput[]; + stdinRequested: boolean; +} + +interface JuliaSession { + sessionKey: string; + sessionId: string; + kernel: JuliaKernel; + owners: Set; +} + +class JuliaExecutionCancelledError extends Error { + constructor(readonly timedOut: boolean) { + super(timedOut ? "Julia execution timed out" : "Julia execution cancelled"); + this.name = "JuliaExecutionCancelledError"; + } +} + +const sessions = new Map(); +const startingSessions = new Map>(); +const resettingSessions = new Map>(); + +function normalizeSessionCwd(cwd: string): string { + return path.resolve(cwd); +} + +function normalizeExplicitInterpreter(cwd: string, interpreter: string | undefined): string { + if (interpreter === undefined) return ""; + const resolved = resolveExplicitJuliaRuntime(interpreter, cwd, {}).juliaPath; + try { + return path.resolve(resolved); + } catch { + return resolved; + } +} + +function buildSessionKey(sessionId: string, cwd: string, interpreter: string | undefined): string { + const normalizedCwd = normalizeSessionCwd(cwd); + const normalizedInterpreter = normalizeExplicitInterpreter(normalizedCwd, interpreter); + return `${sessionId}::${normalizedCwd}::${normalizedInterpreter}`; +} + +function isCancellationError(error: unknown): boolean { + if (error instanceof JuliaExecutionCancelledError) return true; + if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) return true; + if ( + error && + typeof error === "object" && + "name" in error && + (error.name === "AbortError" || error.name === "TimeoutError") + ) + return true; + return false; +} + +function isTimedOutCancellation(error: unknown, signal?: AbortSignal): boolean { + if (error instanceof JuliaExecutionCancelledError) return error.timedOut; + if (error instanceof Error && error.name === "TimeoutError") return true; + if (error && typeof error === "object" && "name" in error && error.name === "TimeoutError") return true; + if (signal?.reason instanceof Error && signal.reason.name === "TimeoutError") return true; + return false; +} + +function getExecutionDeadlineMs(options?: Pick): number | undefined { + if (options?.deadlineMs !== undefined) return options.deadlineMs; + if (options?.timeoutMs !== undefined && options.timeoutMs > 0) return Date.now() + options.timeoutMs; + return undefined; +} + +function getRemainingTimeoutMs(deadlineMs?: number): number | undefined { + if (deadlineMs === undefined) return undefined; + return Math.max(0, deadlineMs - Date.now()); +} + +function requireRemainingTimeoutMs(deadlineMs?: number): number | undefined { + if (deadlineMs === undefined) return undefined; + const remaining = getRemainingTimeoutMs(deadlineMs); + if (remaining !== undefined && remaining <= 0) { + throw new JuliaExecutionCancelledError(true); + } + return remaining; +} + +async function waitForPromiseWithCancellation( + promise: Promise, + options: Pick, +): Promise { + if (options.signal?.aborted) { + throw new JuliaExecutionCancelledError(isTimedOutCancellation(options.signal.reason, options.signal)); + } + const cleanups: Array<() => void> = []; + const { promise: cancelPromise, reject } = Promise.withResolvers(); + + if (options.signal) { + const onAbort = () => { + reject(new JuliaExecutionCancelledError(isTimedOutCancellation(options.signal?.reason, options.signal))); + }; + options.signal.addEventListener("abort", onAbort, { once: true }); + cleanups.push(() => options.signal?.removeEventListener("abort", onAbort)); + } + + const deadlineMs = options.deadlineMs; + if (typeof deadlineMs === "number" && deadlineMs > Date.now()) { + const timeout = setTimeout(() => { + reject(new JuliaExecutionCancelledError(true)); + }, deadlineMs - Date.now()); + timeout.unref?.(); + cleanups.push(() => clearTimeout(timeout)); + } + + try { + return await Promise.race([promise, cancelPromise]); + } finally { + for (const cleanup of cleanups) cleanup(); + } +} + +function formatTimeoutAnnotation(timeoutMs?: number): string | undefined { + if (timeoutMs === undefined) return undefined; + const rounded = (timeoutMs / 1000).toFixed(0); + return `[cell timed out after ${rounded}s]`; +} + +function formatKernelTimeoutAnnotation(timeoutMs: number | undefined, kernelKilled: boolean): string { + const explanation = kernelKilled ? "; active subprocess terminated to recover" : "; kernel is still running"; + if (timeoutMs === undefined) return `[execution timed out${explanation}]`; + const rounded = (timeoutMs / 1000).toFixed(0); + return `[execution timed out after ${rounded}s${explanation}]`; +} + +function createCancelledJuliaResult(_timedOut: boolean, timeoutMs?: number): JuliaResult { + const output = formatTimeoutAnnotation(timeoutMs) ?? "[execution cancelled]\n"; + return { + output, + exitCode: undefined, + cancelled: true, + truncated: false, + artifactId: undefined, + totalLines: 1, + totalBytes: Buffer.byteLength(output), + outputLines: 1, + outputBytes: Buffer.byteLength(output), + displayOutputs: [], + stdinRequested: false, + }; +} + +function buildKernelEnvPatch(options: { + sessionFile?: string; + artifactsDir?: string; + bridge?: PyToolBridgeInfo; + bridgeSessionId?: string; + localRoots?: Record; +}): Record { + const patch: Record = {}; + if (options.sessionFile) patch.PI_SESSION_FILE = options.sessionFile; + if (options.artifactsDir) patch.PI_ARTIFACTS_DIR = options.artifactsDir; + if (options.bridge) { + patch.PI_TOOL_BRIDGE_URL = options.bridge.url; + patch.PI_TOOL_BRIDGE_TOKEN = options.bridge.token; + patch.PI_TOOL_BRIDGE_SESSION = options.bridgeSessionId ?? ""; + } + if (options.localRoots) { + patch.PI_EVAL_LOCAL_ROOTS = JSON.stringify(options.localRoots); + } + return patch; +} + +function buildKernelEnv(options: { + sessionFile?: string; + artifactsDir?: string; + bridge?: PyToolBridgeInfo; + bridgeSessionId?: string; + localRoots?: Record; +}): Record | undefined { + const patch = buildKernelEnvPatch(options); + const keys = Object.keys(patch); + if (keys.length === 0) return undefined; + const realEnv: Record = {}; + for (const key in patch) { + const val = patch[key]; + if (typeof val === "string") realEnv[key] = val; + } + return realEnv; +} + +async function startKernel(cwd: string, options: JuliaExecutorOptions): Promise { + const env: Record = {}; + const patch = buildKernelEnv(options); + if (patch) { + for (const key in patch) { + const value = patch[key]; + if (typeof value === "string") env[key] = value; + } + } + return await JuliaKernel.start({ + cwd, + interpreter: options.interpreter, + env, + signal: options.signal, + deadlineMs: options.deadlineMs, + }); +} + +function attachOwner(session: JuliaSession, sessionId: string, ownerId: string | undefined): void { + if (ownerId) { + session.owners.add(ownerId); + } else { + session.owners.add(`unmanaged:${sessionId}`); + } +} + +async function acquireSession( + sessionKey: string, + sessionId: string, + cwd: string, + options: JuliaExecutorOptions, +): Promise { + const existing = sessions.get(sessionKey); + if (existing) { + attachOwner(existing, sessionId, options.kernelOwnerId); + return existing; + } + + const inFlight = startingSessions.get(sessionKey); + if (inFlight) { + const session = await waitForPromiseWithCancellation(inFlight, options); + attachOwner(session, sessionId, options.kernelOwnerId); + return session; + } + + const startPromise = (async () => { + try { + const kernel = await startKernel(cwd, options); + const session: JuliaSession = { + sessionKey, + sessionId, + kernel, + owners: new Set(), + }; + sessions.set(sessionKey, session); + return session; + } finally { + startingSessions.delete(sessionKey); + } + })(); + + startingSessions.set(sessionKey, startPromise); + const session = await waitForPromiseWithCancellation(startPromise, options); + attachOwner(session, sessionId, options.kernelOwnerId); + return session; +} + +async function replaceSessionKernel(session: JuliaSession, cwd: string, options: JuliaExecutorOptions): Promise { + logger.warn("Julia subprocess died or is unresponsive; spawning fresh process", { + sessionKey: session.sessionKey, + }); + const oldKernel = session.kernel; + void oldKernel.shutdown({ timeoutMs: SHUTDOWN_GRACE_MS }).catch(() => {}); + + const kernelPromise = startKernel(cwd, options); + const kernel = await waitForPromiseWithCancellation(kernelPromise, options); + session.kernel = kernel; +} + +async function resetSession(sessionKey: string): Promise { + const session = sessions.get(sessionKey); + if (!session) return; + sessions.delete(sessionKey); + await session.kernel.shutdown({ timeoutMs: SHUTDOWN_GRACE_MS }).catch(() => {}); +} + +export async function disposeAllJuliaKernelSessions(): Promise { + const active = Array.from(sessions.values()); + sessions.clear(); + startingSessions.clear(); + resettingSessions.clear(); + await Promise.all(active.map(s => s.kernel.shutdown({ timeoutMs: SHUTDOWN_GRACE_MS }).catch(() => {}))); +} + +export async function disposeJuliaKernelSessionsByOwner(ownerId: string): Promise { + const victims: JuliaSession[] = []; + for (const [key, session] of sessions) { + session.owners.delete(ownerId); + if (session.owners.size === 0) { + sessions.delete(key); + victims.push(session); + } + } + await Promise.all(victims.map(s => s.kernel.shutdown({ timeoutMs: SHUTDOWN_GRACE_MS }).catch(() => {}))); +} + +async function executeWithKernel( + kernel: JuliaKernel, + code: string, + options: JuliaExecutorOptions | undefined, +): Promise { + const displayOutputs: EvalDisplayOutput[] = []; + const collectDisplay = (output: KernelDisplayOutput) => { + if (output.type === "status") { + options?.onStatus?.(output.event); + } + displayOutputs.push(output); + }; + + const settings = await Settings.init(); + const sink = new OutputSink({ + onChunk: options?.onChunk, + headBytes: resolveOutputSinkHeadBytes(settings), + maxColumns: resolveOutputMaxColumns(settings), + }); + + const deadlineMs = options?.deadlineMs; + let executionTimeoutMs: number | undefined; + const runId = `jl-${crypto.randomUUID()}`; + + const emitStatus = (event: EvalStatusEvent) => collectDisplay({ type: "status", event }); + const unregisterBridge = + options?.toolSession && options?.bridgeSessionId + ? registerPyToolBridge(options.bridgeSessionId, runId, { + toolSession: options.toolSession, + signal: options.signal, + emitStatus, + }) + : null; + + try { + executionTimeoutMs = requireRemainingTimeoutMs(deadlineMs); + const result = await kernel.execute(code, { + cwd: options?.cwd, + env: buildKernelEnvPatch(options ?? {}), + id: runId, + signal: options?.signal, + timeoutMs: executionTimeoutMs, + onChunk: text => sink.push(text), + onDisplay: output => collectDisplay(output), + }); + + if (result.cancelled) { + const annotation = result.timedOut + ? formatKernelTimeoutAnnotation(executionTimeoutMs ?? options?.idleTimeoutMs, result.kernelKilled ?? false) + : undefined; + const dumped = await sink.dump(annotation); + return { + exitCode: undefined, + cancelled: true, + truncated: dumped.truncated, + output: dumped.output, + artifactId: dumped.artifactId ?? undefined, + totalLines: dumped.totalLines, + totalBytes: dumped.totalBytes, + outputLines: dumped.outputLines, + outputBytes: dumped.outputBytes, + displayOutputs, + stdinRequested: result.stdinRequested, + }; + } + + if (result.stdinRequested) { + const dumped = await sink.dump("Kernel requested stdin; interactive input is not supported."); + return { + exitCode: 1, + cancelled: false, + truncated: dumped.truncated, + output: dumped.output, + artifactId: dumped.artifactId ?? undefined, + totalLines: dumped.totalLines, + totalBytes: dumped.totalBytes, + outputLines: dumped.outputLines, + outputBytes: dumped.outputBytes, + displayOutputs, + stdinRequested: true, + }; + } + + const exitCode = result.status === "ok" ? 0 : 1; + const dumped = await sink.dump(); + return { + exitCode, + cancelled: false, + truncated: dumped.truncated, + output: dumped.output, + artifactId: dumped.artifactId ?? undefined, + totalLines: dumped.totalLines, + totalBytes: dumped.totalBytes, + outputLines: dumped.outputLines, + outputBytes: dumped.outputBytes, + displayOutputs, + stdinRequested: false, + }; + } catch (err) { + if (isCancellationError(err) || options?.signal?.aborted) { + const timedOut = isTimedOutCancellation(err, options?.signal); + const annotation = timedOut + ? formatTimeoutAnnotation(executionTimeoutMs ?? options?.idleTimeoutMs) + : undefined; + const dumped = await sink.dump(annotation); + return { + exitCode: undefined, + cancelled: true, + truncated: dumped.truncated, + output: dumped.output, + artifactId: dumped.artifactId ?? undefined, + totalLines: dumped.totalLines, + totalBytes: dumped.totalBytes, + outputLines: dumped.outputLines, + outputBytes: dumped.outputBytes, + displayOutputs, + stdinRequested: false, + }; + } + const error = err instanceof Error ? err : new Error(String(err)); + logger.error("Julia execution failed", { error: error.message }); + throw error; + } finally { + unregisterBridge?.(); + } +} + +async function ensureKernelAvailable(cwd: string, options: JuliaExecutorOptions): Promise { + const availability = await waitForPromiseWithCancellation( + checkJuliaKernelAvailability(cwd, options.interpreter), + options, + ); + if (!availability.ok) { + throw new Error(availability.reason ?? "Julia kernel unavailable"); + } +} + +async function ensureToolBridge(options: JuliaExecutorOptions): Promise { + if (!options.toolSession || options.bridge) return; + try { + options.bridge = await ensurePyToolBridge(); + } catch (err) { + logger.warn("Failed to start Julia tool bridge", { + error: err instanceof Error ? err.message : String(err), + }); + } +} + +async function executeOnSession(code: string, cwd: string, options: JuliaExecutorOptions): Promise { + const sessionId = options.sessionId ?? `session:${cwd}`; + const sessionKey = buildSessionKey(sessionId, cwd, options.interpreter); + if (options.bridge && !options.bridgeSessionId) { + options.bridgeSessionId = sessionId; + } + if (options.reset) { + const inFlight = resettingSessions.get(sessionKey); + if (inFlight) await inFlight.catch(() => undefined); + else { + const resetPromise = resetSession(sessionKey); + resettingSessions.set( + sessionKey, + resetPromise.then(() => undefined), + ); + try { + await resetPromise; + } finally { + resettingSessions.delete(sessionKey); + } + } + } else { + const inFlight = resettingSessions.get(sessionKey); + if (inFlight) await inFlight.catch(() => undefined); + } + const session = await acquireSession(sessionKey, sessionId, cwd, options); + if (options.signal?.aborted) { + throw new JuliaExecutionCancelledError(isTimedOutCancellation(options.signal.reason, options.signal)); + } + if (sessions.get(session.sessionKey) !== session) { + throw new JuliaExecutionCancelledError(false); + } + if (!session.kernel.isAlive()) { + await replaceSessionKernel(session, cwd, options); + if (sessions.get(session.sessionKey) !== session) { + throw new JuliaExecutionCancelledError(false); + } + } + const runOptions = { ...options, cwd }; + try { + return await executeWithKernel(session.kernel, code, runOptions); + } catch (err) { + if (isCancellationError(err) || options.signal?.aborted) throw err; + if (session.kernel.isAlive()) throw err; + if (sessions.get(session.sessionKey) !== session) { + throw new JuliaExecutionCancelledError(false); + } + await replaceSessionKernel(session, cwd, options); + if (sessions.get(session.sessionKey) !== session) { + throw new JuliaExecutionCancelledError(false); + } + return await executeWithKernel(session.kernel, code, runOptions); + } +} + +export async function executeJuliaWithKernel( + kernel: JuliaKernel, + code: string, + options?: JuliaExecutorOptions, +): Promise { + return await executeWithKernel(kernel, code, options); +} + +export async function executeJulia(code: string, options?: JuliaExecutorOptions): Promise { + const cwd = normalizeSessionCwd(options?.cwd ?? getProjectDir()); + const deadlineMs = getExecutionDeadlineMs(options); + const executionOptions: JuliaExecutorOptions = { + ...(options ?? {}), + cwd, + deadlineMs, + }; + + try { + requireRemainingTimeoutMs(deadlineMs); + if (executionOptions.signal?.aborted) { + throw new JuliaExecutionCancelledError( + isTimedOutCancellation(executionOptions.signal.reason, executionOptions.signal), + ); + } + await ensureKernelAvailable(cwd, executionOptions); + await ensureToolBridge(executionOptions); + return await executeOnSession(code, cwd, executionOptions); + } catch (err) { + if (isCancellationError(err) || executionOptions.signal?.aborted) { + return createCancelledJuliaResult(isTimedOutCancellation(err, executionOptions.signal)); + } + throw err; + } +} diff --git a/packages/coding-agent/src/eval/jl/index.ts b/packages/coding-agent/src/eval/jl/index.ts new file mode 100644 index 000000000..acb95beb2 --- /dev/null +++ b/packages/coding-agent/src/eval/jl/index.ts @@ -0,0 +1,62 @@ +import type { ToolSession } from "../../tools"; +import { + type ExecutorBackend, + type ExecutorBackendExecOptions, + type ExecutorBackendResult, + resolveEvalUrlRoots, +} from "../backend"; +import { executeJulia } from "./executor"; +import { checkJuliaKernelAvailability } from "./kernel"; + +const JULIA_SESSION_PREFIX = "julia:"; + +export function namespaceSessionId(sessionId: string): string { + return sessionId.startsWith(JULIA_SESSION_PREFIX) ? sessionId : `${JULIA_SESSION_PREFIX}${sessionId}`; +} + +function readInterpreterSetting(session: ToolSession): string | undefined { + const settings = session.settings as { get?: (key: string) => unknown } | undefined; + const value = settings?.get?.("julia.interpreter"); + return typeof value === "string" ? value.trim() || undefined : undefined; +} + +export default { + id: "julia", + label: "Julia", + highlightLang: "julia", + + async isAvailable(session: ToolSession): Promise { + const availability = await checkJuliaKernelAvailability(session.cwd, readInterpreterSetting(session)); + return availability.ok; + }, + + async execute(code: string, opts: ExecutorBackendExecOptions): Promise { + const result = await executeJulia(code, { + cwd: opts.cwd, + idleTimeoutMs: opts.idleTimeoutMs, + signal: opts.signal, + sessionId: namespaceSessionId(opts.sessionId), + interpreter: readInterpreterSetting(opts.session), + sessionFile: opts.sessionFile, + artifactsDir: opts.session.getArtifactsDir?.() ?? undefined, + localRoots: resolveEvalUrlRoots(opts.session), + kernelOwnerId: opts.kernelOwnerId, + reset: opts.reset, + onChunk: opts.onChunk, + onStatus: opts.onStatus, + toolSession: opts.session, + }); + return { + output: result.output, + exitCode: result.exitCode, + cancelled: result.cancelled, + truncated: result.truncated, + artifactId: result.artifactId, + totalLines: result.totalLines, + totalBytes: result.totalBytes, + outputLines: result.outputLines, + outputBytes: result.outputBytes, + displayOutputs: result.displayOutputs, + }; + }, +} satisfies ExecutorBackend; diff --git a/packages/coding-agent/src/eval/jl/kernel.ts b/packages/coding-agent/src/eval/jl/kernel.ts new file mode 100644 index 000000000..f1df8ab3f --- /dev/null +++ b/packages/coding-agent/src/eval/jl/kernel.ts @@ -0,0 +1,709 @@ +/** + * Subprocess-backed Julia runner. + */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { $flag, logger, Snowflake } from "@oh-my-pi/pi-utils"; +import { $, type Subprocess } from "bun"; +import { Settings } from "../../config/settings"; +import { type KernelDisplayOutput, renderKernelDisplay } from "../py/display"; +import { hostHasInheritableConsole, shouldHideKernelWindow } from "../py/spawn-options"; +import { JULIA_PRELUDE } from "./prelude"; +import RUNNER_SCRIPT from "./runner.jl" with { type: "text" }; +import { + enumerateJuliaRuntimes, + filterEnv, + type JuliaRuntime, + resolveExplicitJuliaRuntime, + resolveJuliaRuntime, +} from "./runtime"; + +export type { KernelDisplayOutput }; +export { renderKernelDisplay }; + +const TRACE_IPC = $flag("PI_JULIA_IPC_TRACE"); + +// Cache the runner script on disk so the subprocess loads it normally. Cached +// per script hash so installs don't race across versions. +const RUNNER_CACHE_DIR = path.join(os.tmpdir(), "omp-julia-runner"); +let RUNNER_SCRIPT_PATH: string | null = null; + +async function ensureRunnerScript(): Promise { + if (RUNNER_SCRIPT_PATH) return RUNNER_SCRIPT_PATH; + await fs.promises.mkdir(RUNNER_CACHE_DIR, { recursive: true }); + const hash = Bun.hash(RUNNER_SCRIPT).toString(36); + const target = path.join(RUNNER_CACHE_DIR, `runner-${hash}.jl`); + if (!fs.existsSync(target)) { + await Bun.write(target, RUNNER_SCRIPT); + } + RUNNER_SCRIPT_PATH = target; + return target; +} + +const SHUTDOWN_GRACE_MS = 1_000; +const STARTUP_TIMEOUT_MS = 15_000; // Julia compile/warmup can be slightly slower +const INTERRUPT_ESCALATION_MS = 5_000; + +export type KernelRuntimeEnv = Record; + +export interface KernelExecuteOptions { + id?: string; + cwd?: string; + env?: Record; + silent?: boolean; + storeHistory?: boolean; + timeoutMs?: number; + signal?: AbortSignal; + onChunk?: (text: string) => void | Promise; + onDisplay?: (output: KernelDisplayOutput) => void | Promise; +} + +export interface KernelExecuteResult { + status: "ok" | "error"; + executionCount?: number; + error?: { + name: string; + value: string; + traceback: string[]; + }; + cancelled: boolean; + timedOut: boolean; + stdinRequested: boolean; + kernelKilled: boolean; +} + +interface KernelLifecycleOptions { + cwd: string; + signal?: AbortSignal; + deadlineMs?: number; +} + +interface KernelStartOptions extends KernelLifecycleOptions { + interpreter?: string; + env?: Record; +} + +interface KernelShutdownOptions { + timeoutMs?: number; +} + +export interface JuliaKernelAvailability { + ok: boolean; + juliaPath?: string; + runtime?: JuliaRuntime; + reason?: string; +} + +function getRemainingTimeMs(deadlineMs?: number): number | undefined { + if (deadlineMs === undefined) return undefined; + return Math.max(0, deadlineMs - Date.now()); +} + +function createAbortError(name: "AbortError" | "TimeoutError", message: string): Error { + const err = new Error(message); + err.name = name; + return err; +} + +function throwIfAborted(signal: AbortSignal | undefined, fallbackReason: string): void { + if (signal?.aborted) { + throw signal.reason ?? createAbortError("AbortError", fallbackReason); + } +} + +// Cache successful probes per resolved cwd + explicit interpreter. Failures are +// not cached so installing Julia mid-session is picked up on the next attempt. +const availabilityCache = new Map>(); + +export async function checkJuliaKernelAvailability( + cwd: string, + interpreter?: string, +): Promise { + const cacheKey = `${path.resolve(cwd)}::${interpreter ?? ""}`; + let cached = availabilityCache.get(cacheKey); + if (!cached) { + cached = probeJuliaKernelAvailability(cwd, interpreter); + availabilityCache.set(cacheKey, cached); + } + const result = await cached; + if (!result.ok) { + availabilityCache.delete(cacheKey); + } + return result; +} + +async function probeJuliaKernelAvailability(cwd: string, interpreter?: string): Promise { + const { env: shellEnv } = (await Settings.init()).getShellConfig(); + const baseEnv = filterEnv(shellEnv); + const runtimes = enumerateJuliaRuntimes(cwd, baseEnv, interpreter); + + if (runtimes.length === 0) { + return { + ok: false, + reason: "Julia executable not found on PATH. Please install Julia (https://julialang.org/).", + }; + } + + const failures: string[] = []; + for (const runtime of runtimes) { + try { + const probe = await $`${runtime.juliaPath} -e "exit(0)"`.quiet().nothrow().cwd(cwd).env(runtime.env); + if (probe.exitCode === 0) { + return { ok: true, juliaPath: runtime.juliaPath, runtime }; + } + failures.push(`${runtime.juliaPath} (exit code ${probe.exitCode})`); + } catch (err) { + failures.push(`${runtime.juliaPath} (${err instanceof Error ? err.message : String(err)})`); + } + } + + return { + ok: false, + juliaPath: runtimes[0].juliaPath, + reason: `No working Julia interpreter found. Tried: ${failures.join("; ")}`, + }; +} + +type FrameType = "started" | "stdout" | "stderr" | "display" | "result" | "error" | "done"; + +interface Frame { + type: FrameType; + id?: string; + data?: string; + bundle?: Record; + ename?: string; + evalue?: string; + traceback?: string[]; + status?: "ok" | "error"; + executionCount?: number; + cancelled?: boolean; +} + +interface PendingExecution { + resolve: (value: KernelExecuteResult) => void; + options?: KernelExecuteOptions; + status: "ok" | "error"; + cancelled: boolean; + timedOut: boolean; + stdinRequested: boolean; + settled: boolean; + kernelKilled: boolean; + executionCount?: number; + error?: { + name: string; + value: string; + traceback: string[]; + }; + escalationTimer?: NodeJS.Timeout; +} + +export class JuliaKernel { + readonly id: string; + #proc: Subprocess | null = null; + #stdin: Bun.FileSink | null = null; + #alive = true; + #disposed = false; + #shutdownConfirmed = false; + #exitedPromise: Promise | null = null; + #pending = new Map(); + #readBuffer = ""; + + private constructor(id: string) { + this.id = id; + } + + static async start(options: KernelStartOptions): Promise { + const availability = await checkJuliaKernelAvailability(options.cwd, options.interpreter); + if (!availability.ok) { + throw new Error(availability.reason ?? "Julia kernel unavailable"); + } + + let runtime = availability.runtime; + if (!runtime) { + const { env: shellEnv } = (await Settings.init()).getShellConfig(); + runtime = options.interpreter + ? resolveExplicitJuliaRuntime(options.interpreter, options.cwd, filterEnv(shellEnv)) + : resolveJuliaRuntime(options.cwd, filterEnv(shellEnv)); + } + const spawnEnv: Record = {}; + for (const key in runtime.env) { + const value = runtime.env[key]; + if (typeof value === "string") spawnEnv[key] = value; + } + for (const key in options.env) { + const value = options.env[key]; + if (typeof value === "string") spawnEnv[key] = value; + } + + const scriptPath = await ensureRunnerScript(); + const kernel = new JuliaKernel(Snowflake.next()); + + const proc = Bun.spawn( + [runtime.juliaPath, "--startup-file=no", "--history-file=no", "--color=no", "--project=@.", scriptPath], + { + cwd: options.cwd, + env: spawnEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + windowsHide: shouldHideKernelWindow({ + platform: process.platform, + hostHasInheritableConsole: hostHasInheritableConsole(), + }), + }, + ); + kernel.#proc = proc; + kernel.#stdin = proc.stdin; + kernel.#exitedPromise = proc.exited; + void kernel.#exitedPromise.then(code => { + kernel.#alive = false; + kernel.#abortPendingExecutions(`Julia kernel exited with code ${code}`, { kernelKilled: true }); + }); + + kernel.#startReader(proc.stdout as ReadableStream); + kernel.#startStderrDrain(proc.stderr as ReadableStream); + + const startup = { signal: options.signal, deadlineMs: options.deadlineMs }; + const startupBudget = Math.min(getRemainingTimeMs(startup.deadlineMs) ?? STARTUP_TIMEOUT_MS, STARTUP_TIMEOUT_MS); + + try { + const initScript = buildInitScript(options.cwd, options.env); + await kernel.#executeWithBudget(initScript, startup.signal, startupBudget, "Julia kernel init"); + await kernel.#executeWithBudget(JULIA_PRELUDE, startup.signal, startupBudget, "Julia kernel prelude"); + return kernel; + } catch (err) { + await kernel.shutdown({ timeoutMs: SHUTDOWN_GRACE_MS }).catch(() => {}); + throw err; + } + } + + isAlive(): boolean { + return this.#alive && !this.#disposed; + } + + async execute(code: string, options?: KernelExecuteOptions): Promise { + if (!this.isAlive()) { + throw new Error("Julia kernel is not running"); + } + + const msgId = options?.id ?? Snowflake.next(); + const { promise, resolve } = Promise.withResolvers(); + const pending: PendingExecution = { + resolve, + options, + status: "ok", + cancelled: false, + timedOut: false, + stdinRequested: false, + settled: false, + kernelKilled: false, + }; + this.#pending.set(msgId, pending); + + const finalize = () => { + if (pending.settled) return; + pending.settled = true; + this.#pending.delete(msgId); + cleanup(); + resolve({ + status: pending.status, + executionCount: pending.executionCount, + error: pending.error, + cancelled: pending.cancelled, + timedOut: pending.timedOut, + stdinRequested: pending.stdinRequested, + kernelKilled: pending.kernelKilled, + }); + }; + + const requestCancel = () => { + if (pending.settled || pending.escalationTimer) return; + void this.interrupt(); + const escalation = setTimeout(() => { + if (pending.settled) return; + logger.warn("Julia runner did not respond to SIGINT; terminating subprocess", { + kernelId: this.id, + }); + pending.kernelKilled = true; + void this.shutdown(); + }, INTERRUPT_ESCALATION_MS); + escalation.unref?.(); + pending.escalationTimer = escalation; + }; + + const onAbort = () => { + pending.cancelled = true; + pending.timedOut = pending.timedOut || isTimeoutReason(options?.signal?.reason); + requestCancel(); + }; + const timeoutId = + typeof options?.timeoutMs === "number" && options.timeoutMs > 0 + ? setTimeout(() => { + pending.timedOut = true; + pending.cancelled = true; + requestCancel(); + }, options.timeoutMs) + : undefined; + + const cleanup = () => { + clearTimeout(timeoutId); + clearTimeout(pending.escalationTimer); + pending.escalationTimer = undefined; + options?.signal?.removeEventListener("abort", onAbort); + }; + + if (options?.signal) { + if (options.signal.aborted) { + onAbort(); + } else { + options.signal.addEventListener("abort", onAbort, { once: true }); + } + } + + (pending as PendingExecution & { finalize: () => void }).finalize = finalize; + + // Convert arguments into TSV / Base64 payload + const cwdB64 = Buffer.from(options?.cwd ?? "").toString("base64"); + const silentVal = options?.silent ? "1" : "0"; + const storeHistVal = options?.storeHistory !== false && !options?.silent ? "1" : "0"; + + // Format environment variables as key1_b64=val1_b64 key2_b64=val2_b64 + const envPairs: string[] = []; + if (options?.env) { + for (const key in options.env) { + const val = options.env[key]; + if (val !== undefined) { + const k_b64 = Buffer.from(key).toString("base64"); + const v_b64 = Buffer.from(val).toString("base64"); + envPairs.push(`${k_b64}:${v_b64}`); + } + } + } + const envPairsStr = envPairs.join(" "); + const codeB64 = Buffer.from(code).toString("base64"); + + const payload = `run\t${msgId}\t${cwdB64}\t${silentVal}\t${storeHistVal}\t${envPairsStr}\t${codeB64}`; + + try { + await this.#writeLine(payload); + } catch (err) { + pending.cancelled = true; + pending.error = { + name: "TransportError", + value: err instanceof Error ? err.message : String(err), + traceback: [], + }; + finalize(); + } + + return promise; + } + + async interrupt(): Promise { + if (!this.#proc || this.#disposed) return; + try { + this.#proc.kill("SIGINT"); + } catch (err) { + logger.warn("Failed to interrupt Julia runner", { error: err instanceof Error ? err.message : String(err) }); + } + } + + async shutdown(options?: KernelShutdownOptions): Promise<{ confirmed: boolean }> { + if (this.#shutdownConfirmed) return { confirmed: true }; + + this.#alive = false; + this.#abortPendingExecutions("Julia kernel shutdown", { kernelKilled: true }); + + const timeoutMs = options?.timeoutMs ?? SHUTDOWN_GRACE_MS; + const proc = this.#proc; + if (!proc) { + this.#shutdownConfirmed = true; + this.#disposed = true; + return { confirmed: true }; + } + + try { + await this.#writeLine("exit").catch(() => {}); + } catch { + /* writer may already be closed */ + } + + try { + this.#stdin?.end(); + } catch { + /* ignore */ + } + + const exited = this.#waitForExitWithTimeout(timeoutMs); + let result = await exited; + if (!result) { + try { + proc.kill("SIGTERM"); + } catch { + /* ignore */ + } + result = await this.#waitForExitWithTimeout(timeoutMs); + } + if (!result) { + try { + proc.kill("SIGKILL"); + } catch { + /* ignore */ + } + result = await this.#waitForExitWithTimeout(timeoutMs); + } + + const confirmed = !!result; + this.#shutdownConfirmed = confirmed; + this.#disposed = true; + return { confirmed }; + } + + #abortPendingExecutions(reason: string, options?: { kernelKilled?: boolean }): void { + if (this.#pending.size === 0) return; + const pending = Array.from(this.#pending.values()); + this.#pending.clear(); + const kernelKilledDefault = options?.kernelKilled ?? false; + for (const entry of pending) { + if (entry.settled) continue; + entry.settled = true; + void entry.options?.onChunk?.(`[kernel] ${reason}\n`); + entry.resolve({ + status: "error", + cancelled: true, + timedOut: entry.timedOut, + stdinRequested: entry.stdinRequested, + executionCount: entry.executionCount, + error: entry.error, + kernelKilled: entry.kernelKilled || kernelKilledDefault, + }); + } + } + + async #writeLine(line: string): Promise { + if (!this.#stdin) { + throw new Error("Julia kernel stdin is not open"); + } + if (TRACE_IPC) { + logger.debug("JuliaKernel send", { preview: line.slice(0, 120) }); + } + this.#stdin.write(`${line}\n`); + this.#stdin.flush(); + } + + #startReader(stream: ReadableStream): void { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + const loop = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + this.#readBuffer += decoder.decode(value, { stream: true }); + await this.#flushFrames(); + } + this.#readBuffer += decoder.decode(); + await this.#flushFrames(); + } catch (err) { + logger.warn("Julia kernel reader failed", { error: err instanceof Error ? err.message : String(err) }); + } finally { + try { + reader.releaseLock(); + } catch { + /* ignore */ + } + } + }; + void loop(); + } + + #startStderrDrain(stream: ReadableStream): void { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + const loop = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value); + if (text.trim()) { + logger.warn("Julia runner stderr", { text }); + } + } + } catch { + /* ignore */ + } finally { + try { + reader.releaseLock(); + } catch { + /* ignore */ + } + } + }; + void loop(); + } + + async #flushFrames(): Promise { + while (true) { + const nl = this.#readBuffer.indexOf("\n"); + if (nl < 0) return; + const line = this.#readBuffer.slice(0, nl); + this.#readBuffer = this.#readBuffer.slice(nl + 1); + if (!line.trim()) continue; + let frame: Frame; + try { + frame = JSON.parse(line) as Frame; + } catch (err) { + logger.warn("Julia runner emitted invalid JSON", { + line: line.slice(0, 200), + error: err instanceof Error ? err.message : String(err), + }); + continue; + } + if (TRACE_IPC) { + logger.debug("JuliaKernel recv", { type: frame.type, id: frame.id }); + } + await this.#handleFrame(frame); + } + } + + async #handleFrame(frame: Frame): Promise { + const rid = frame.id; + if (!rid) return; + const pending = this.#pending.get(rid) as (PendingExecution & { finalize?: () => void }) | undefined; + if (!pending) return; + + switch (frame.type) { + case "started": + return; + case "stdout": + case "stderr": { + const text = frame.data ?? ""; + if (text && pending.options?.onChunk) { + await pending.options.onChunk(text); + } + return; + } + case "display": + case "result": { + const bundle = frame.bundle ?? {}; + const { text, outputs } = await renderKernelDisplay(bundle); + if (text && pending.options?.onChunk) { + await pending.options.onChunk(text); + } + if (outputs.length > 0 && pending.options?.onDisplay) { + for (const output of outputs) { + await pending.options.onDisplay(output); + } + } + return; + } + case "error": { + const traceback = Array.isArray(frame.traceback) ? frame.traceback.map(String) : []; + pending.status = "error"; + pending.error = { + name: String(frame.ename ?? "Error"), + value: String(frame.evalue ?? ""), + traceback, + }; + const message = + traceback.length > 0 ? `${traceback.join("\n")}\n` : `${pending.error.name}: ${pending.error.value}\n`; + if (pending.options?.onChunk) { + await pending.options.onChunk(message); + } + return; + } + case "done": { + if (typeof frame.executionCount === "number") { + pending.executionCount = frame.executionCount; + } + if (frame.status === "error" && pending.status === "ok") { + pending.status = "error"; + } + if (frame.cancelled) { + pending.cancelled = true; + } + pending.finalize?.(); + return; + } + } + } + + async #executeWithBudget( + code: string, + signal: AbortSignal | undefined, + timeoutMs: number, + label: string, + ): Promise { + const controller = new AbortController(); + const cleanups: Array<() => void> = []; + if (signal) { + if (signal.aborted) { + controller.abort(signal.reason); + } else { + const onAbort = () => controller.abort(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + cleanups.push(() => signal.removeEventListener("abort", onAbort)); + } + } + const timer = + timeoutMs > 0 + ? setTimeout(() => controller.abort(createAbortError("TimeoutError", `${label} timed out`)), timeoutMs) + : undefined; + if (timer) cleanups.push(() => clearTimeout(timer)); + try { + throwIfAborted(controller.signal, label); + const result = await this.execute(code, { + signal: controller.signal, + silent: true, + storeHistory: false, + }); + if (result.cancelled) { + throw createAbortError(result.timedOut ? "TimeoutError" : "AbortError", `${label} cancelled`); + } + if (result.status === "error") { + const reason = result.error?.value ?? "Julia kernel init failed"; + throw new Error(`${label} failed: ${reason}`); + } + } finally { + for (const cleanup of cleanups) cleanup(); + } + } + + #waitForExitWithTimeout(timeoutMs: number): Promise { + if (!this.#exitedPromise) return Promise.resolve(0); + const exitedPromise = this.#exitedPromise; + const timeout = new Promise(resolve => { + const timer = setTimeout(() => resolve(null), Math.max(0, timeoutMs)); + timer.unref?.(); + }); + return Promise.race([exitedPromise.then(code => code as number | null), timeout]); + } +} + +function isTimeoutReason(reason: unknown): boolean { + if (reason instanceof DOMException) return reason.name === "TimeoutError"; + if (reason instanceof Error) return reason.name === "TimeoutError"; + return false; +} + +function buildInitScript(cwd: string, env?: Record): string { + const envPayload: Record = {}; + for (const key in env) { + const value = env[key]; + if (value !== undefined) envPayload[key] = value; + } + const lines = [ + `__omp_init_cwd = String(Base64.base64decode("${Buffer.from(cwd).toString("base64")}"))`, + "try cd(__omp_init_cwd) catch; end", + ]; + for (const key in envPayload) { + const k_b64 = Buffer.from(key).toString("base64"); + const v_b64 = Buffer.from(envPayload[key]).toString("base64"); + lines.push(`ENV[String(Base64.base64decode("${k_b64}"))] = String(Base64.base64decode("${v_b64}"))`); + } + // Avoid modifying LOAD_PATH if not necessary, but if needed, prepend cwd + lines.push("if !(__omp_init_cwd in LOAD_PATH); pushfirst!(LOAD_PATH, __omp_init_cwd); end"); + return lines.join("\n"); +} diff --git a/packages/coding-agent/src/eval/jl/prelude.jl b/packages/coding-agent/src/eval/jl/prelude.jl new file mode 100644 index 000000000..698ba3b99 --- /dev/null +++ b/packages/coding-agent/src/eval/jl/prelude.jl @@ -0,0 +1,489 @@ +# OMP Julia prelude helpers (loaded once into the runner's top-level scope). + +if !isdefined(Main, :__omp_prelude_loaded) + global __omp_prelude_loaded = true +end + +# ------------------------------------------------------------------------- +# Internal-URL path resolution +# ------------------------------------------------------------------------- + +function __omp_url_decode(s::String) + res = IOBuffer() + i = 1 + len = ncodeunits(s) + while i <= len + c = Char(codeunit(s, i)) + if c == '%' && i + 2 <= len + h_str = s[i+1:i+2] + try + b = parse(UInt8, h_str, base=16) + write(res, b) + i += 3 + continue + catch + # ignore format error + end + end + write(res, c) + i += 1 + end + return String(take!(res)) +end + +function __omp_resolve_path(p::AbstractString) + m = match(r"^([a-z][a-z0-9+.\-]*)://(.*)$"i, p) + if m === nothing + return abspath(p) + end + scheme = lowercase(string(m.captures[1])) + roots_env = get(ENV, "PI_EVAL_LOCAL_ROOTS", "{}") + roots = try + Main.json_parse(roots_env) + catch + Dict{String, Any}() + end + root = get(roots, scheme, nothing) + if root === nothing || isempty(root) + error("Protocol paths are not supported by this helper: $p") + end + + relative = __omp_url_decode(replace(string(m.captures[2]), '\\' => '/')) + root_path = abspath(string(root)) + if isempty(relative) + return root_path + end + + if startswith(relative, '/') || ".." in split(relative, '/') + error("Unsafe $scheme:// path (absolute or traversal): $p") + end + + resolved = abspath(joinpath(root_path, relative)) + if resolved != root_path && !startswith(resolved, root_path * Base.Filesystem.path_separator) + error("$scheme:// path escapes its root: $p") + end + return resolved +end + +# ------------------------------------------------------------------------- +# Display + status +# ------------------------------------------------------------------------- + + +function display_image(base64_str::String, mime_type::String = "image/png") + bundle = Dict(mime_type => base64_str) + Main.emit_frame(Dict("type" => "display", "id" => Main.current_rid, "bundle" => bundle)) + return nothing +end + +# ------------------------------------------------------------------------- +# File helpers +# ------------------------------------------------------------------------- + +function Base.read(path::AbstractString, offset::Integer=1, limit::Union{Integer, Nothing}=nothing) + resolved = __omp_resolve_path(string(path)) + content = open(resolved, "r") do io + Base.read(io, String) + end + lines = split(content, '\n') + if offset > 1 || limit !== nothing + st = max(1, offset) + en = limit !== nothing ? min(length(lines), st + limit - 1) : length(lines) + if st <= length(lines) + content = join(lines[st:en], '\n') + else + content = "" + end + end + + preview = length(content) > 500 ? content[1:500] : content + Main.emit_frame(Dict( + "type" => "display", + "id" => Main.current_rid, + "bundle" => Dict( + "application/x-omp-status" => Dict( + "op" => "read", + "path" => resolved, + "chars" => length(content), + "preview" => preview + ) + ) + )) + return content +end + +function Base.write(path::AbstractString, content::Any) + resolved = __omp_resolve_path(string(path)) + mkpath(dirname(resolved)) + open(resolved, "w") do io + Base.write(io, string(content)) + end + + Main.emit_frame(Dict( + "type" => "display", + "id" => Main.current_rid, + "bundle" => Dict( + "application/x-omp-status" => Dict( + "op" => "write", + "path" => resolved, + "chars" => length(string(content)) + ) + ) + )) + return resolved +end + +function append(path, content) + resolved = __omp_resolve_path(string(path)) + mkpath(dirname(resolved)) + open(resolved, "a") do f + Base.write(f, string(content)) + end + + Main.emit_frame(Dict( + "type" => "display", + "id" => Main.current_rid, + "bundle" => Dict( + "application/x-omp-status" => Dict( + "op" => "append", + "path" => resolved, + "chars" => length(string(content)) + ) + ) + )) + return resolved +end + +function tree(path=".", max_depth=3, show_hidden=false) + base = string(path) + lines = String[] + + function walk(dir, prefix, depth) + if depth > max_depth + return + end + entries = try + readdir(dir) + catch + String[] + end + if !show_hidden + entries = filter(e -> !startswith(e, '.'), entries) + end + sort!(entries, by = e -> (ispath(joinpath(dir, e)) && isdir(joinpath(dir, e)) ? 0 : 1, lowercase(e))) + + for (i, name) in enumerate(entries) + full = joinpath(dir, name) + is_last = i == length(entries) + is_dir = isdir(full) + push!(lines, "$(prefix)$(is_last ? "└── " : "├── ")$(name)$(is_dir ? "/" : "")") + if is_dir + walk(full, prefix * (is_last ? " " : "│ "), depth + 1) + end + end + end + + walk(base, "", 1) + out = join(lines, '\n') + + Main.emit_frame(Dict( + "type" => "display", + "id" => Main.current_rid, + "bundle" => Dict( + "application/x-omp-status" => Dict( + "op" => "tree", + "path" => base, + "lines" => length(lines) + ) + ) + )) + return out +end + +function env(key=nothing, value=nothing) + if key === nothing + items = Dict{String, String}() + for (k, v) in ENV + items[k] = v + end + keys_list = sort(collect(keys(items))) + Main.emit_frame(Dict( + "type" => "display", + "id" => Main.current_rid, + "bundle" => Dict( + "application/x-omp-status" => Dict( + "op" => "env", + "count" => length(items), + "keys" => keys_list[1:min(20, length(keys_list))] + ) + ) + )) + return items + end + + k = string(key) + if value !== nothing + v = string(value) + ENV[k] = v + Main.emit_frame(Dict( + "type" => "display", + "id" => Main.current_rid, + "bundle" => Dict( + "application/x-omp-status" => Dict( + "op" => "env", + "key" => k, + "value" => v, + "action" => "set" + ) + ) + )) + return v + end + + v = get(ENV, k, nothing) + Main.emit_frame(Dict( + "type" => "display", + "id" => Main.current_rid, + "bundle" => Dict( + "application/x-omp-status" => Dict( + "op" => "env", + "key" => k, + "value" => v, + "action" => "get" + ) + ) + )) + return v +end + +# ------------------------------------------------------------------------- +# Dynamic bridge proxy +# ------------------------------------------------------------------------- + +using Downloads + +function __omp_call_bridge(name::String, args::Dict{String, Any}) + base_url = get(ENV, "PI_TOOL_BRIDGE_URL", nothing) + token = get(ENV, "PI_TOOL_BRIDGE_TOKEN", nothing) + session = get(ENV, "PI_TOOL_BRIDGE_SESSION", nothing) + + if base_url === nothing || token === nothing || session === nothing + error("Tool bridge is not available in this cell.") + end + + url = base_url + if !endswith(url, "/v1/tool") + url = endswith(url, "/") ? (url * "v1/tool") : (url * "/v1/tool") + end + + payload_dict = Dict( + "session" => session, + "run" => Main.current_rid, + "name" => name, + "args" => args + ) + payload_json = Main.json_serialize(payload_dict) + + headers = [ + "Authorization" => "Bearer $token", + "Content-Type" => "application/json" + ] + + io_out = IOBuffer() + response = Downloads.request( + url, + method="POST", + headers=headers, + input=IOBuffer(payload_json), + output=io_out + ) + + resp_str = String(take!(io_out)) + if response.status != 200 + error("Tool bridge call failed with status $(response.status): $resp_str") + end + + parsed_resp = Main.json_parse(resp_str) + + ok = get(parsed_resp, "ok", false) + if !ok + err_msg = get(parsed_resp, "error", "Unknown error") + error("Tool bridge error: $err_msg") + end + + return get(parsed_resp, "value", nothing) +end + +struct OmpToolProxy end + +struct OmpToolCallable + name::String +end + +function (tc::OmpToolCallable)(args...; kwargs...) + args_dict = Dict{String, Any}() + if length(args) == 1 && args[1] isa AbstractDict + for (k, v) in args[1] + args_dict[string(k)] = v + end + end + for (k, v) in kwargs + args_dict[string(k)] = v + end + + return __omp_call_bridge("tool:" * tc.name, args_dict) +end + +function Base.getproperty(::OmpToolProxy, sym::Symbol) + return OmpToolCallable(string(sym)) +end + +const tool = OmpToolProxy() + +# ------------------------------------------------------------------------- +# Agent calls +# ------------------------------------------------------------------------- + +function completion(prompt::String; kwargs...) + args_dict = Dict{String, Any}("prompt" => prompt) + for (k, v) in kwargs + args_dict[string(k)] = v + end + return __omp_call_bridge("completion", args_dict) +end + +function agent(prompt::String; kwargs...) + args_dict = Dict{String, Any}("prompt" => prompt) + for (k, v) in kwargs + args_dict[string(k)] = v + end + return __omp_call_bridge("agent", args_dict) +end + +function Base.log(message::AbstractString) + Main.emit_frame(Dict( + "type" => "display", + "id" => Main.current_rid, + "bundle" => Dict( + "application/x-omp-status" => Dict( + "op" => "log", + "message" => message + ) + ) + )) + return nothing +end + +function phase(title::String) + Main.emit_frame(Dict( + "type" => "display", + "id" => Main.current_rid, + "bundle" => Dict( + "application/x-omp-status" => Dict( + "op" => "phase", + "title" => title + ) + ) + )) + return nothing +end + +# ------------------------------------------------------------------------- +# Concurrency +# ------------------------------------------------------------------------- + +function _concurrency_limit() + try + limit_val = __omp_call_bridge("concurrency-bridge", Dict{String, Any}()) + return limit_val isa Number ? Int(limit_val) : 0 + catch + return 0 + end +end + +function _pool_map(items, fn) + if isempty(items) + return [] + end + limit = _concurrency_limit() + + n = length(items) + results = Vector{Any}(undef, n) + errors = Dict{Int, Any}() + + sem = limit > 0 ? Channel{Nothing}(limit) : nothing + + @sync for i in 1:n + if sem !== nothing + put!(sem, nothing) + end + item = items[i] + idx = i + @async begin + try + res = fn(item) + results[idx] = res + catch err + errors[idx] = err + finally + if sem !== nothing + take!(sem) + end + end + end + end + + if !isempty(errors) + min_idx = minimum(keys(errors)) + throw(errors[min_idx]) + end + return results +end + +function parallel(thunks) + return _pool_map(thunks, t -> t()) +end + +function pipeline(items, stages...) + curr = collect(items) + for stage in stages + curr = _pool_map(curr, stage) + end + return curr +end + +# ------------------------------------------------------------------------- +# Budget +# ------------------------------------------------------------------------- + +struct OmpBudgetHardProxy end + +function Base.getproperty(::OmpBudgetHardProxy, sym::Symbol) + if sym === :total + return __omp_call_bridge("budget:total", Dict{String, Any}()) + elseif sym === :spent + return () -> __omp_call_bridge("budget:spent", Dict{String, Any}()) + elseif sym === :remaining + return () -> __omp_call_bridge("budget:remaining", Dict{String, Any}()) + end + error("Unknown budget hard metric: $sym") +end + +struct OmpBudgetProxy + hard::OmpBudgetHardProxy +end + +function Base.getproperty(bp::OmpBudgetProxy, sym::Symbol) + if sym === :hard + return bp.hard + elseif sym === :total + return __omp_call_bridge("budget:total", Dict{String, Any}()) + elseif sym === :spent + return () -> __omp_call_bridge("budget:spent", Dict{String, Any}()) + elseif sym === :remaining + return () -> __omp_call_bridge("budget:remaining", Dict{String, Any}()) + end + error("Unknown budget metric: $sym") +end + +const budget = OmpBudgetProxy(OmpBudgetHardProxy()) diff --git a/packages/coding-agent/src/eval/jl/prelude.ts b/packages/coding-agent/src/eval/jl/prelude.ts new file mode 100644 index 000000000..bb2065d9c --- /dev/null +++ b/packages/coding-agent/src/eval/jl/prelude.ts @@ -0,0 +1,3 @@ +import juliaPrelude from "./prelude.jl" with { type: "text" }; + +export const JULIA_PRELUDE = juliaPrelude; diff --git a/packages/coding-agent/src/eval/jl/runner.jl b/packages/coding-agent/src/eval/jl/runner.jl new file mode 100644 index 000000000..d63c4949d --- /dev/null +++ b/packages/coding-agent/src/eval/jl/runner.jl @@ -0,0 +1,490 @@ +# OMP Julia runner — subprocess wrapper used by the coding-agent host. +# Persistent Julia process that speaks NDJSON over stdout and a custom TSV protocol on stdin. + +using Base64 + +const ORIGINAL_STDOUT = stdout +const ORIGINAL_STDERR = stderr +const ORIGINAL_STDIN = stdin + +# Redirect stdin/stdout/stderr to prevent cell prints from corrupting NDJSON +out_rd, out_wr = redirect_stdout() +err_rd, err_wr = redirect_stderr() +redirect_stdin(devnull) + +global current_rid = nothing +const write_lock = ReentrantLock() + +function json_parse(s::String) + chars = collect(s) + pos = 1 + len = length(chars) + + function skip_whitespace() + while pos <= len && isspace(chars[pos]) + pos += 1 + end + end + + function parse_value() + skip_whitespace() + if pos > len + error("Unexpected EOF") + end + c = chars[pos] + if c == '"' + return parse_string() + elseif c == '{' + return parse_object() + elseif c == '[' + return parse_array() + elseif (c == 't' || c == 'f') + return parse_boolean() + elseif c == 'n' + return parse_null() + elseif c == '-' || isdigit(c) + return parse_number() + else + error("Unexpected character at $pos: $c") + end + end + + function parse_string() + pos += 1 # skip '"' + res = IOBuffer() + while pos <= len + c = chars[pos] + if c == '"' + pos += 1 # skip '"' + return String(take!(res)) + elseif c == '\\' + pos += 1 + if pos > len; error("Unexpected EOF in string escape"); end + esc = chars[pos] + if esc == '"' + write(res, '"') + elseif esc == '\\' + write(res, '\\') + elseif esc == '/' + write(res, '/') + elseif esc == 'b' + write(res, '\b') + elseif esc == 'f' + write(res, '\f') + elseif esc == 'n' + write(res, '\n') + elseif esc == 'r' + write(res, '\r') + elseif esc == 't' + write(res, '\t') + elseif esc == 'u' + # parse 4 hex digits + hex = "" + for i in 1:4 + pos += 1 + hex *= chars[pos] + end + write(res, Char(parse(Int, hex, base=16))) + else + write(res, esc) + end + else + write(res, c) + end + pos += 1 + end + error("Unterminated string") + end + + function parse_object() + pos += 1 # skip '{' + obj = Dict{String, Any}() + skip_whitespace() + if pos <= len && chars[pos] == '}' + pos += 1 + return obj + end + while true + skip_whitespace() + if pos > len || chars[pos] != '"' + error("Expected string key in object at $pos") + end + key = parse_string() + skip_whitespace() + if pos > len || chars[pos] != ':' + error("Expected ':' at $pos") + end + pos += 1 # skip ':' + val = parse_value() + obj[key] = val + skip_whitespace() + if pos > len + error("Expected ',' or '}' in object at $pos") + end + c = chars[pos] + if c == '}' + pos += 1 + return obj + elseif c == ',' + pos += 1 + else + error("Expected ',' or '}' in object at $pos, got '$c'") + end + end + end + + function parse_array() + pos += 1 # skip '[' + arr = Any[] + skip_whitespace() + if pos <= len && chars[pos] == ']' + pos += 1 + return arr + end + while true + push!(arr, parse_value()) + skip_whitespace() + if pos > len + error("Expected ',' or ']' in array") + end + c = chars[pos] + if c == ']' + pos += 1 + return arr + elseif c == ',' + pos += 1 + else + error("Expected ',' or ']' in array at $pos, got '$c'") + end + end + end + + function parse_boolean() + s_slice = String(chars[pos:min(len, pos+4)]) + if startswith(s_slice, "true") + pos += 4 + return true + elseif startswith(s_slice, "false") + pos += 5 + return false + else + error("Expected boolean at $pos") + end + end + + function parse_null() + s_slice = String(chars[pos:min(len, pos+3)]) + if startswith(s_slice, "null") + pos += 4 + return nothing + else + error("Expected null at $pos") + end + end + + function parse_number() + start_pos = pos + while pos <= len + c = chars[pos] + if isdigit(c) || c in ['.', '-', '+', 'e', 'E'] + pos += 1 + else + break + end + end + num_str = String(chars[start_pos:pos-1]) + val = tryparse(Int, num_str) + if val !== nothing + return val + end + val_f = tryparse(Float64, num_str) + if val_f !== nothing + return val_f + end + error("Invalid number format: $num_str") + end + + val = parse_value() + skip_whitespace() + if pos <= len + error("Extra data after JSON value at $pos") + end + return val +end + +function json_serialize_string(s::AbstractString) + res = IOBuffer() + write(res, '"') + for c in s + if c == '"' + write(res, "\\\"") + elseif c == '\\' + write(res, "\\\\") + elseif c == '\n' + write(res, "\\n") + elseif c == '\r' + write(res, "\\r") + elseif c == '\t' + write(res, "\\t") + elseif c == '\f' + write(res, "\\f") + elseif c == '\b' + write(res, "\\b") + elseif UInt32(c) < 32 + d1 = div(UInt32(c), 16) + d2 = rem(UInt32(c), 16) + hex_chars = "0123456789abcdef" + write(res, "\\u00" * hex_chars[d1 + 1] * hex_chars[d2 + 1]) + else + write(res, c) + end + end + write(res, '"') + return String(take!(res)) +end + +function json_serialize(v) + if v === nothing + return "null" + elseif v isa Bool + return v ? "true" : "false" + elseif v isa Number + return string(v) + elseif v isa AbstractString + return json_serialize_string(v) + elseif v isa Symbol + return json_serialize_string(string(v)) + elseif v isa AbstractVector + return "[" * join([json_serialize(x) for x in v], ",") * "]" + elseif v isa AbstractDict + parts = String[] + for (k, val) in v + push!(parts, json_serialize_string(string(k)) * ":" * json_serialize(val)) + end + return "{" * join(parts, ",") * "}" + else + return json_serialize_string(repr(v)) + end +end + +function emit_frame(frame) + lock(write_lock) do + println(ORIGINAL_STDOUT, json_serialize(frame)) + flush(ORIGINAL_STDOUT) + end +end + +function drain_stream(rd, kind) + try + while !eof(rd) + line = readline(rd, keep=true) + rid = current_rid + if rid !== nothing && !isempty(line) + emit_frame(Dict("type" => kind, "id" => rid, "data" => line)) + end + end + catch + # ignore + end +end + +@async drain_stream(out_rd, "stdout") +@async drain_stream(err_rd, "stderr") + +function build_mime_bundle(value) + bundle = Dict{String, Any}() + + # text/plain + io_plain = IOBuffer() + show(io_plain, MIME"text/plain"(), value) + bundle["text/plain"] = String(take!(io_plain)) + + # rich mime types + for mime_str in ["text/html", "text/markdown", "image/png", "image/jpeg"] + m = MIME(Symbol(mime_str)) + if showable(m, value) + try + io = IOBuffer() + if mime_str in ["image/png", "image/jpeg"] + b64_io = Base64EncodePipe(io) + show(b64_io, m, value) + close(b64_io) + else + show(io, m, value) + end + bundle[mime_str] = String(take!(io)) + catch + # ignore + end + end + end + + if value isa AbstractDict || value isa AbstractVector + try + bundle["application/json"] = value + catch + # ignore + end + end + + return bundle +end + +struct OmpDisplay <: AbstractDisplay end + +function Base.display(d::OmpDisplay, value) + rid = current_rid + if rid !== nothing + bundle = build_mime_bundle(value) + emit_frame(Dict("type" => "display", "id" => rid, "bundle" => bundle)) + end + return nothing +end + +pushdisplay(OmpDisplay()) + +function emit_error(rid, err, bt) + io = IOBuffer() + showerror(io, err) + err_str = String(take!(io)) + + tb = String[] + for frame in stacktrace(bt) + file = string(frame.file) + line = frame.line + func = string(frame.func) + push!(tb, " at $func ($file:$line)") + end + + emit_frame(Dict( + "type" => "error", + "id" => rid, + "ename" => string(typeof(err)), + "evalue" => err_str, + "traceback" => tb + )) +end + +function should_display_result(parsed_expr) + if parsed_expr isa Expr && parsed_expr.head === :block + args = parsed_expr.args + if !isempty(args) + last_arg = args[end] + if last_arg isa Expr + if last_arg.head in [Symbol("="), :function, :struct, :using, :import, :const, :global, :local, :macro] + return false + end + end + end + end + return true +end + +function apply_request_runtime(cwd, env_pairs) + try + if !isempty(cwd) + cd(cwd) + end + catch + # ignore + end + + managed_env_keys = [ + "PI_SESSION_FILE", + "PI_ARTIFACTS_DIR", + "PI_TOOL_BRIDGE_URL", + "PI_TOOL_BRIDGE_TOKEN", + "PI_TOOL_BRIDGE_SESSION", + "PI_EVAL_LOCAL_ROOTS" + ] + for k in managed_env_keys + delete!(ENV, k) + end + + if !isempty(env_pairs) + for pair in split(env_pairs, ' ') + if !isempty(pair) + try + k_b64, v_b64 = split(pair, ':', limit=2) + k = String(base64decode(string(k_b64))) + v = String(base64decode(string(v_b64))) + ENV[k] = v + catch + # ignore + end + end + end + end +end + +# Main loop +function main() + while !eof(ORIGINAL_STDIN) + line = readline(ORIGINAL_STDIN) + if isempty(line) + continue + end + parts = split(line, '\t') + cmd = string(parts[1]) + if cmd == "exit" + break + elseif cmd == "run" + if length(parts) < 7 + continue + end + rid = string(parts[2]) + cwd = String(base64decode(string(parts[3]))) + silent = string(parts[4]) == "1" + store_history = string(parts[5]) == "1" + env_pairs = string(parts[6]) + code = String(base64decode(string(parts[7]))) + + global current_rid = rid + emit_frame(Dict("type" => "started", "id" => rid)) + + apply_request_runtime(cwd, env_pairs) + + exec_status = "ok" + try + parsed = Meta.parse("begin\n" * code * "\nend") + if parsed isa Expr && parsed.head === :error + # Syntax error from parser + exec_status = "error" + emit_frame(Dict( + "type" => "error", + "id" => rid, + "ename" => "ParseError", + "evalue" => string(parsed.args[1]), + "traceback" => String[] + )) + else + ans = Core.eval(Main, parsed) + if ans !== nothing && !silent && should_display_result(parsed) + bundle = build_mime_bundle(ans) + emit_frame(Dict("type" => "result", "id" => rid, "bundle" => bundle)) + end + end + catch err + exec_status = "error" + emit_error(rid, err, catch_backtrace()) + end + + # Flush stdout and stderr writes before sending done frame + flush(stdout) + flush(stderr) + # Yield to make sure async drain processes all writes + sleep(0.01) + + emit_frame(Dict( + "type" => "done", + "id" => rid, + "status" => exec_status, + "executionCount" => 1, + "cancelled" => false + )) + global current_rid = nothing + end + end +end + +main() diff --git a/packages/coding-agent/src/eval/jl/runtime.ts b/packages/coding-agent/src/eval/jl/runtime.ts new file mode 100644 index 000000000..3721398e9 --- /dev/null +++ b/packages/coding-agent/src/eval/jl/runtime.ts @@ -0,0 +1,164 @@ +/** + * Julia runtime resolution utilities. + */ +import * as os from "node:os"; +import * as path from "node:path"; +import { $which } from "@oh-my-pi/pi-utils"; + +const DEFAULT_ENV_ALLOWLIST = [ + "PATH", + "HOME", + "USER", + "USERNAME", + "LOGNAME", + "SHELL", + "TERM", + "LANG", + "SYSTEMROOT", + "WINDIR", + "COMSPEC", + "PATHEXT", + "TEMP", + "TMP", + "TERM_PROGRAM", + "TERM_PROGRAM_VERSION", + "SSH_AUTH_SOCK", + "SSH_AGENT_PID", + "SSH_CONNECTION", + "SSH_CLIENT", + "SSH_TTY", + "DISPLAY", + "XAUTHORITY", + "TZ", + "LD_LIBRARY_PATH", + "DYLD_LIBRARY_PATH", +]; + +const WINDOWS_ENV_ALLOWLIST = [ + "ALLUSERSPROFILE", + "APPDATA", + "COMMONPROGRAMFILES", + "COMMONPROGRAMFILES(X86)", + "COMMONPROGRAMW6432", + "COMPUTERNAME", + "HOMEDRIVE", + "HOMEPATH", + "LOCALAPPDATA", + "NUMBER_OF_PROCESSORS", + "OS", + "PROCESSOR_ARCHITECTURE", + "PROCESSOR_IDENTIFIER", + "PROCESSOR_LEVEL", + "PROCESSOR_REVISION", + "PROGRAMDATA", + "PROGRAMFILES", + "PROGRAMFILES(X86)", + "PROGRAMW6432", + "PUBLIC", + "SESSIONNAME", + "SYSTEMDRIVE", + "USERDOMAIN", + "USERDOMAIN_ROAMING_PC", + "USERPROFILE", +]; + +const DEFAULT_ENV_DENYLIST = ["PI_API_KEY", "PI_TOKEN", "PI_PASSWORD", "PI_SESSION", "PI_TOOL_BRIDGE_TOKEN"]; + +// Julia version managers and package layout live behind these prefixes; passing them +// through lets Julia discover packages and configure its runtime consistently. +const DEFAULT_ENV_ALLOW_PREFIXES = ["LC_", "XDG_", "PI_", "JULIA_", "OPENBLAS_", "MKL_"]; + +const CASE_INSENSITIVE_ENV = process.platform === "win32"; + +const NORMALIZED_ALLOWLIST = new Set( + [...DEFAULT_ENV_ALLOWLIST, ...WINDOWS_ENV_ALLOWLIST].map(key => (CASE_INSENSITIVE_ENV ? key.toUpperCase() : key)), +); +const NORMALIZED_DENYLIST = new Set(DEFAULT_ENV_DENYLIST.map(key => (CASE_INSENSITIVE_ENV ? key.toUpperCase() : key))); +const NORMALIZED_ALLOW_PREFIXES = CASE_INSENSITIVE_ENV + ? DEFAULT_ENV_ALLOW_PREFIXES.map(prefix => prefix.toUpperCase()) + : DEFAULT_ENV_ALLOW_PREFIXES; + +// Secret-shaped names that must never leak into eval cells even when they fall +// under a broad allow-prefix. +const SECRET_KEY_PATTERN = /API[_-]?KEY|APIKEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|ACCESS[_-]?KEY|PRIVATE[_-]?KEY/i; + +export interface JuliaRuntime { + /** Path to the julia executable. */ + juliaPath: string; + /** Filtered environment variables. */ + env: Record; +} + +/** + * Filter environment variables to a safe allowlist for Julia subprocesses. + * Removes sensitive API keys and limits to known-safe variables. + */ +export function filterEnv(env: Record): Record { + const filtered: Record = {}; + for (const key in env) { + const value = env[key]; + if (value === undefined) continue; + const normalizedKey = CASE_INSENSITIVE_ENV ? key.toUpperCase() : key; + if (NORMALIZED_DENYLIST.has(normalizedKey)) continue; + if (NORMALIZED_ALLOWLIST.has(normalizedKey)) { + filtered[normalizedKey === "PATH" ? "PATH" : key] = value; + continue; + } + if (SECRET_KEY_PATTERN.test(normalizedKey)) continue; + if (NORMALIZED_ALLOW_PREFIXES.some(prefix => normalizedKey.startsWith(prefix))) { + filtered[key] = value; + } + } + return filtered; +} + +/** + * Resolve an explicitly configured interpreter (`julia.interpreter`) into a + * runtime, bypassing discovery. Does not probe the executable. + * `~` expands to the home directory and relative paths resolve against `cwd`. + */ +export function resolveExplicitJuliaRuntime( + interpreter: string, + cwd: string, + baseEnv: Record, +): JuliaRuntime { + const expanded = + interpreter === "~" + ? os.homedir() + : interpreter.startsWith("~/") + ? path.join(os.homedir(), interpreter.slice(2)) + : interpreter; + const juliaPath = path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded); + return { juliaPath, env: { ...baseEnv } }; +} + +/** + * Enumerate candidate Julia runtimes in priority order. With an explicit + * interpreter that is the only candidate; otherwise the first `julia` on PATH. + */ +export function enumerateJuliaRuntimes( + cwd: string, + baseEnv: Record, + interpreter?: string, +): JuliaRuntime[] { + if (interpreter) { + return [resolveExplicitJuliaRuntime(interpreter, cwd, baseEnv)]; + } + const systemPath = $which("julia"); + return systemPath ? [{ juliaPath: systemPath, env: { ...baseEnv } }] : []; +} + +/** + * Resolve the highest-priority Julia runtime. Throws when none exists. + */ +export function resolveJuliaRuntime( + cwd: string, + baseEnv: Record, + interpreter?: string, +): JuliaRuntime { + const [runtime] = enumerateJuliaRuntimes(cwd, baseEnv, interpreter); + if (!runtime) { + throw new Error("Julia executable not found on PATH"); + } + return runtime; +} diff --git a/packages/coding-agent/src/eval/rb/executor.ts b/packages/coding-agent/src/eval/rb/executor.ts new file mode 100644 index 000000000..aeedbf966 --- /dev/null +++ b/packages/coding-agent/src/eval/rb/executor.ts @@ -0,0 +1,678 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { getProjectDir, logger } from "@oh-my-pi/pi-utils"; +import { Settings } from "../../config/settings"; +import { OutputSink } from "../../session/streaming-output"; +import type { ToolSession } from "../../tools"; +import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta"; +import { isEvalTimeoutControlEvent } from "../bridge-timeout"; +import type { JsStatusEvent } from "../js/shared/types"; +import { ensurePyToolBridge, registerPyToolBridge } from "../py/tool-bridge"; +import { + checkRubyKernelAvailability, + type KernelDisplayOutput, + type KernelExecuteOptions, + type KernelExecuteResult, + type KernelRuntimeEnv, + RubyKernel, +} from "./kernel"; +import { resolveExplicitRubyRuntime } from "./runtime"; + +export interface RubyExecutorOptions { + /** Working directory for command execution */ + cwd?: string; + /** Timeout in milliseconds */ + timeoutMs?: number; + /** Absolute wall-clock deadline in milliseconds since epoch */ + deadlineMs?: number; + /** + * Runtime-work budget (ms). Used only for timeout-annotation text when the + * caller drives cancellation via the eval watchdog `signal`. Does not arm a timer. + */ + idleTimeoutMs?: number; + /** Callback for streaming output chunks (already sanitized) */ + onChunk?: (chunk: string) => Promise | void; + /** AbortSignal for cancellation */ + signal?: AbortSignal; + /** Session identifier for kernel reuse */ + sessionId?: string; + /** Logical owner identifier for retained kernel cleanup */ + kernelOwnerId?: string; + /** Explicit interpreter path (`ruby.interpreter`). Skips discovery when set. */ + interpreter?: string; + /** Restart the kernel before executing */ + reset?: boolean; + /** Session file path for accessing task outputs */ + sessionFile?: string; + /** Effective artifacts directory for the current session. */ + artifactsDir?: string; + /** Artifact path/id for full output storage */ + artifactPath?: string; + artifactId?: string; + /** + * On-disk roots the prelude helpers substitute for internal-URL schemes + * (e.g. `{ local: "/…/artifacts/local" }`). Exported to the kernel as + * `PI_EVAL_LOCAL_ROOTS` (JSON). + */ + localRoots?: Record; + /** + * ToolSession used to resolve host-side `tool.(args)` calls. When + * omitted, the bridge env vars are not injected and `tool.foo(...)` raises. + */ + toolSession?: ToolSession; + /** Callback for status events emitted by tool bridge invocations. */ + emitStatus?: (event: JsStatusEvent) => void; + /** Live status events streamed as they are emitted. */ + onStatus?: (event: JsStatusEvent) => void; + /** @internal Bridge session id, set by `executeRuby` before delegating. */ + bridgeSessionId?: string; + /** @internal Bridge endpoint info, set by `executeRuby` before delegating. */ + bridge?: { url: string; token: string }; +} + +export interface RubyKernelExecutor { + execute: (code: string, options?: KernelExecuteOptions) => Promise; +} + +export interface RubyResult { + output: string; + exitCode: number | undefined; + cancelled: boolean; + truncated: boolean; + artifactId?: string; + totalLines: number; + totalBytes: number; + outputLines: number; + outputBytes: number; + displayOutputs: KernelDisplayOutput[]; + stdinRequested: boolean; +} + +// --------------------------------------------------------------------------- +// Session bookkeeping +// +// One RubyKernel subprocess per (session id, cwd, interpreter) tuple. The +// runner mutates process-global cwd/$LOAD_PATH/ENV during execution, so +// cross-directory work must never share a live kernel. Multiple agent owners can +// register against the same tuple; the kernel stays alive until the last owner detaches. +// --------------------------------------------------------------------------- + +interface RubySession { + sessionKey: string; + sessionId: string; + cwd: string; + kernel: RubyKernel; + ownerIds: Set; + hasFallbackOwner: boolean; +} + +const sessions = new Map(); +const startingSessions = new Map>(); +const resettingSessions = new Map>(); + +function normalizeSessionCwd(cwd: string): string { + return path.resolve(cwd); +} + +function normalizeExplicitInterpreter(cwd: string, interpreter: string | undefined): string { + if (interpreter === undefined) return ""; + const resolved = resolveExplicitRubyRuntime(interpreter, cwd, {}).rubyPath; + try { + return fs.realpathSync.native(resolved); + } catch { + return resolved; + } +} + +function buildSessionKey(sessionId: string, cwd: string, interpreter: string | undefined): string { + const normalizedCwd = normalizeSessionCwd(cwd); + return `${sessionId}\0${normalizedCwd}\0${normalizeExplicitInterpreter(normalizedCwd, interpreter)}`; +} + +// --------------------------------------------------------------------------- +// Cancellation plumbing +// --------------------------------------------------------------------------- + +class RubyExecutionCancelledError extends Error { + readonly timedOut: boolean; + + constructor(timedOut: boolean) { + super(timedOut ? "Command timed out" : "Command aborted"); + this.name = timedOut ? "TimeoutError" : "AbortError"; + this.timedOut = timedOut; + } +} + +function getExecutionDeadlineMs(options?: Pick): number | undefined { + if (options?.deadlineMs !== undefined) return options.deadlineMs; + if (options?.timeoutMs === undefined) return undefined; + return Date.now() + options.timeoutMs; +} + +function getRemainingTimeoutMs(deadlineMs?: number): number | undefined { + if (deadlineMs === undefined) return undefined; + return deadlineMs - Date.now(); +} + +function requireRemainingTimeoutMs(deadlineMs?: number): number | undefined { + const remainingMs = getRemainingTimeoutMs(deadlineMs); + if (remainingMs === undefined) return undefined; + if (remainingMs <= 0) { + throw new RubyExecutionCancelledError(true); + } + return remainingMs; +} + +function isCancellationError(error: unknown): boolean { + return ( + error instanceof RubyExecutionCancelledError || + (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) || + (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) + ); +} + +function isTimedOutCancellation(error: unknown, signal?: AbortSignal): boolean { + if (error instanceof RubyExecutionCancelledError) return error.timedOut; + if (error instanceof DOMException) return error.name === "TimeoutError"; + if (error instanceof Error && error.name === "TimeoutError") return true; + const reason = signal?.reason; + if (reason instanceof DOMException) return reason.name === "TimeoutError"; + return reason instanceof Error ? reason.name === "TimeoutError" : false; +} + +async function waitForPromiseWithCancellation( + promise: Promise, + options: Pick, +): Promise { + if (options.signal?.aborted) { + throw new RubyExecutionCancelledError(isTimedOutCancellation(options.signal.reason, options.signal)); + } + const remainingMs = getRemainingTimeoutMs(options.deadlineMs); + if (remainingMs !== undefined && remainingMs <= 0) { + throw new RubyExecutionCancelledError(true); + } + if (!options.signal && remainingMs === undefined) { + return await promise; + } + + const { promise: resultPromise, resolve, reject } = Promise.withResolvers(); + const cleanups: Array<() => void> = []; + const finish = (cb: () => void): void => { + while (cleanups.length > 0) cleanups.pop()?.(); + cb(); + }; + if (options.signal) { + const onAbort = (): void => + finish(() => + reject(new RubyExecutionCancelledError(isTimedOutCancellation(options.signal?.reason, options.signal))), + ); + options.signal.addEventListener("abort", onAbort, { once: true }); + cleanups.push(() => options.signal?.removeEventListener("abort", onAbort)); + } + if (remainingMs !== undefined) { + const timer = setTimeout(() => finish(() => reject(new RubyExecutionCancelledError(true))), remainingMs); + timer.unref(); + cleanups.push(() => clearTimeout(timer)); + } + promise.then( + value => finish(() => resolve(value)), + err => finish(() => reject(err)), + ); + return await resultPromise; +} + +// --------------------------------------------------------------------------- +// Result formatting +// --------------------------------------------------------------------------- + +function formatTimeoutAnnotation(timeoutMs?: number): string | undefined { + if (timeoutMs === undefined) return "Command timed out"; + const secs = Math.max(1, Math.round(timeoutMs / 1000)); + return `Command timed out after ${secs} seconds`; +} + +function formatKernelTimeoutAnnotation(timeoutMs: number | undefined, kernelKilled: boolean): string { + const secs = timeoutMs === undefined ? undefined : Math.max(1, Math.round(timeoutMs / 1000)); + if (kernelKilled) { + return "eval cell timed out and the kernel was unresponsive to interrupt; the kernel has been killed and will be recreated on the next call."; + } + const duration = secs === undefined ? "the configured timeout" : `${secs}s`; + return `eval cell timed out after ${duration}; kernel interrupted but remains running. Reset the kernel via { reset: true } if state appears corrupted.`; +} + +function createCancelledRubyResult(timedOut: boolean, timeoutMs?: number): RubyResult { + const output = timedOut ? (formatTimeoutAnnotation(timeoutMs) ?? "Command timed out") : ""; + const outputBytes = Buffer.byteLength(output, "utf-8"); + const outputLines = output.length > 0 ? 1 : 0; + return { + output, + exitCode: undefined, + cancelled: true, + truncated: false, + totalLines: outputLines, + totalBytes: outputBytes, + outputLines, + outputBytes, + displayOutputs: [], + stdinRequested: false, + }; +} + +// --------------------------------------------------------------------------- +// Kernel start helpers +// --------------------------------------------------------------------------- + +const MANAGED_KERNEL_ENV_KEYS = [ + "PI_SESSION_FILE", + "PI_ARTIFACTS_DIR", + "PI_TOOL_BRIDGE_URL", + "PI_TOOL_BRIDGE_TOKEN", + "PI_TOOL_BRIDGE_SESSION", + "PI_EVAL_LOCAL_ROOTS", +] as const; + +function buildKernelEnvPatch(options: { + sessionFile?: string; + artifactsDir?: string; + bridgeSessionId?: string; + bridge?: { url: string; token: string }; + localRoots?: Record; +}): KernelRuntimeEnv { + const localRoots = options.localRoots; + return { + PI_SESSION_FILE: options.sessionFile ?? null, + PI_ARTIFACTS_DIR: options.artifactsDir ?? null, + PI_TOOL_BRIDGE_URL: options.bridge?.url ?? null, + PI_TOOL_BRIDGE_TOKEN: options.bridge?.token ?? null, + PI_TOOL_BRIDGE_SESSION: options.bridge && options.bridgeSessionId ? options.bridgeSessionId : null, + PI_EVAL_LOCAL_ROOTS: localRoots && Object.keys(localRoots).length > 0 ? JSON.stringify(localRoots) : null, + }; +} + +function buildKernelEnv(options: { + sessionFile?: string; + artifactsDir?: string; + bridgeSessionId?: string; + bridge?: { url: string; token: string }; + localRoots?: Record; +}): Record | undefined { + const patch = buildKernelEnvPatch(options); + const env: Record = {}; + for (const key of MANAGED_KERNEL_ENV_KEYS) { + const value = patch[key]; + if (value !== null) env[key] = value; + } + return Object.keys(env).length > 0 ? env : undefined; +} + +async function startKernel(cwd: string, options: RubyExecutorOptions): Promise { + requireRemainingTimeoutMs(options.deadlineMs); + return await RubyKernel.start({ + cwd, + env: buildKernelEnv(options), + signal: options.signal, + deadlineMs: options.deadlineMs, + interpreter: options.interpreter, + }); +} + +function attachOwner(session: RubySession, sessionId: string, ownerId: string | undefined): void { + if (ownerId !== undefined) { + if (session.hasFallbackOwner) { + session.ownerIds.delete(sessionId); + session.hasFallbackOwner = false; + } + session.ownerIds.add(ownerId); + return; + } + if (session.hasFallbackOwner || session.ownerIds.size === 0) { + session.ownerIds.add(sessionId); + session.hasFallbackOwner = true; + } +} + +async function acquireSession( + sessionKey: string, + sessionId: string, + cwd: string, + options: RubyExecutorOptions, +): Promise { + const existing = sessions.get(sessionKey); + if (existing) { + attachOwner(existing, sessionId, options.kernelOwnerId); + return existing; + } + const starting = startingSessions.get(sessionKey); + if (starting) { + const session = await starting; + attachOwner(session, sessionId, options.kernelOwnerId); + return session; + } + const startup = (async () => { + const kernel = await startKernel(cwd, options); + const session: RubySession = { + sessionKey, + sessionId, + cwd, + kernel, + ownerIds: new Set(), + hasFallbackOwner: false, + }; + sessions.set(sessionKey, session); + return session; + })(); + startingSessions.set(sessionKey, startup); + try { + const session = await startup; + attachOwner(session, sessionId, options.kernelOwnerId); + return session; + } finally { + if (startingSessions.get(sessionKey) === startup) startingSessions.delete(sessionKey); + } +} + +async function replaceSessionKernel(session: RubySession, cwd: string, options: RubyExecutorOptions): Promise { + const old = session.kernel; + const remaining = getRemainingTimeoutMs(options.deadlineMs); + await old + .shutdown(remaining !== undefined ? { timeoutMs: Math.max(0, remaining) } : undefined) + .catch(() => undefined); + if (sessions.get(session.sessionKey) !== session) { + throw new RubyExecutionCancelledError(false); + } + requireRemainingTimeoutMs(options.deadlineMs); + const next = await startKernel(cwd, options); + if (sessions.get(session.sessionKey) !== session) { + await next.shutdown().catch(() => undefined); + throw new RubyExecutionCancelledError(false); + } + session.kernel = next; +} + +async function resetSession(sessionKey: string): Promise { + const existing = sessions.get(sessionKey) ?? (await startingSessions.get(sessionKey)?.catch(() => undefined)); + if (!existing) return; + sessions.delete(sessionKey); + await existing.kernel.shutdown().catch(() => undefined); +} + +// --------------------------------------------------------------------------- +// Public dispose entry points +// --------------------------------------------------------------------------- + +export async function disposeAllRubyKernelSessions(): Promise { + const pending = [...startingSessions.values()]; + startingSessions.clear(); + const started = await Promise.allSettled(pending); + const all = [...sessions.entries()]; + for (const result of started) { + if (result.status !== "fulfilled") continue; + if (!all.some(([, session]) => session === result.value)) { + all.push([result.value.sessionKey, result.value]); + } + } + for (const [id, session] of all) { + if (sessions.get(id) === session) sessions.delete(id); + } + const results = await Promise.allSettled(all.map(([, session]) => session.kernel.shutdown())); + for (let i = 0; i < all.length; i += 1) { + const [id, session] = all[i]; + const result = results[i]; + if (result.status === "fulfilled" && result.value?.confirmed !== false) continue; + const reason = result.status === "rejected" ? result.reason : "not confirmed"; + logger.warn("Ruby kernel shutdown not confirmed", { + sessionId: session.sessionId, + sessionKey: id, + cwd: session.cwd, + reason, + }); + if (!sessions.has(id)) sessions.set(id, session); + } +} + +export async function disposeRubyKernelSessionsByOwner(ownerId: string): Promise { + const toShutdown: RubySession[] = []; + for (const session of [...sessions.values()]) { + if (!session.ownerIds.has(ownerId)) continue; + if (session.ownerIds.size === 1) { + toShutdown.push(session); + continue; + } + session.ownerIds.delete(ownerId); + } + for (const session of toShutdown) { + if (sessions.get(session.sessionKey) === session) sessions.delete(session.sessionKey); + } + const results = await Promise.allSettled(toShutdown.map(session => session.kernel.shutdown())); + for (let i = 0; i < toShutdown.length; i += 1) { + const session = toShutdown[i]; + const result = results[i]; + if (result.status === "fulfilled" && result.value?.confirmed !== false) { + session.ownerIds.delete(ownerId); + continue; + } + const reason = result.status === "rejected" ? result.reason : "not confirmed"; + logger.warn("Ruby kernel shutdown not confirmed", { + sessionId: session.sessionId, + sessionKey: session.sessionKey, + cwd: session.cwd, + reason, + }); + if (!sessions.has(session.sessionKey)) sessions.set(session.sessionKey, session); + } +} + +// --------------------------------------------------------------------------- +// Execution +// --------------------------------------------------------------------------- + +async function executeWithKernel( + kernel: RubyKernelExecutor, + code: string, + options: RubyExecutorOptions | undefined, +): Promise { + const settings = await Settings.init(); + const sink = new OutputSink({ + onChunk: options?.onChunk, + artifactPath: options?.artifactPath, + artifactId: options?.artifactId, + headBytes: resolveOutputSinkHeadBytes(settings), + maxColumns: resolveOutputMaxColumns(settings), + }); + const displayOutputs: KernelDisplayOutput[] = []; + const deadlineMs = getExecutionDeadlineMs(options); + let executionTimeoutMs: number | undefined; + + const collectDisplay = (output: KernelDisplayOutput) => { + if (output.type === "status") { + options?.onStatus?.(output.event); + if (isEvalTimeoutControlEvent(output.event)) return; + } + displayOutputs.push(output); + }; + const emitStatus = options?.emitStatus ?? ((event: JsStatusEvent) => collectDisplay({ type: "status", event })); + const runId = `rb-${crypto.randomUUID()}`; + const unregisterBridge = + options?.toolSession && options?.bridgeSessionId + ? registerPyToolBridge(options.bridgeSessionId, runId, { + toolSession: options.toolSession, + signal: options.signal, + emitStatus, + }) + : null; + + try { + executionTimeoutMs = requireRemainingTimeoutMs(deadlineMs); + const result = await kernel.execute(code, { + cwd: options?.cwd, + env: buildKernelEnvPatch(options ?? {}), + id: runId, + signal: options?.signal, + timeoutMs: executionTimeoutMs, + onChunk: text => sink.push(text), + onDisplay: output => collectDisplay(output), + }); + + if (result.cancelled) { + const annotation = result.timedOut + ? formatKernelTimeoutAnnotation(executionTimeoutMs ?? options?.idleTimeoutMs, result.kernelKilled ?? false) + : undefined; + return { + exitCode: undefined, + cancelled: true, + displayOutputs, + stdinRequested: result.stdinRequested, + ...(await sink.dump(annotation)), + }; + } + + if (result.stdinRequested) { + return { + exitCode: 1, + cancelled: false, + displayOutputs, + stdinRequested: true, + ...(await sink.dump("Kernel requested stdin; interactive input is not supported.")), + }; + } + + const exitCode = result.status === "ok" ? 0 : 1; + return { + exitCode, + cancelled: false, + displayOutputs, + stdinRequested: false, + ...(await sink.dump()), + }; + } catch (err) { + if (isCancellationError(err) || options?.signal?.aborted) { + const timedOut = isTimedOutCancellation(err, options?.signal); + return { + exitCode: undefined, + cancelled: true, + displayOutputs, + stdinRequested: false, + ...(await sink.dump( + timedOut ? formatTimeoutAnnotation(executionTimeoutMs ?? options?.idleTimeoutMs) : undefined, + )), + }; + } + const error = err instanceof Error ? err : new Error(String(err)); + logger.error("Ruby execution failed", { error: error.message }); + throw error; + } finally { + unregisterBridge?.(); + } +} + +async function ensureKernelAvailable(cwd: string, options: RubyExecutorOptions): Promise { + const availability = await waitForPromiseWithCancellation( + checkRubyKernelAvailability(cwd, options.interpreter), + options, + ); + if (!availability.ok) { + throw new Error(availability.reason ?? "Ruby kernel unavailable"); + } +} + +async function ensureToolBridge(options: RubyExecutorOptions): Promise { + if (!options.toolSession || options.bridge) return; + try { + options.bridge = await ensurePyToolBridge(); + } catch (err) { + logger.warn("Failed to start Ruby tool bridge", { + error: err instanceof Error ? err.message : String(err), + }); + } +} + +async function executeOnSession(code: string, cwd: string, options: RubyExecutorOptions): Promise { + const sessionId = options.sessionId ?? `session:${cwd}`; + const sessionKey = buildSessionKey(sessionId, cwd, options.interpreter); + if (options.bridge && !options.bridgeSessionId) { + options.bridgeSessionId = sessionId; + } + if (options.reset) { + const inFlight = resettingSessions.get(sessionKey); + if (inFlight) await inFlight.catch(() => undefined); + else { + const resetPromise = resetSession(sessionKey); + resettingSessions.set( + sessionKey, + resetPromise.then(() => undefined), + ); + try { + await resetPromise; + } finally { + resettingSessions.delete(sessionKey); + } + } + } else { + const inFlight = resettingSessions.get(sessionKey); + if (inFlight) await inFlight.catch(() => undefined); + } + const session = await acquireSession(sessionKey, sessionId, cwd, options); + if (options.signal?.aborted) { + throw new RubyExecutionCancelledError(isTimedOutCancellation(options.signal.reason, options.signal)); + } + if (sessions.get(session.sessionKey) !== session) { + throw new RubyExecutionCancelledError(false); + } + if (!session.kernel.isAlive()) { + await replaceSessionKernel(session, cwd, options); + if (sessions.get(session.sessionKey) !== session) { + throw new RubyExecutionCancelledError(false); + } + } + const runOptions = { ...options, cwd }; + try { + return await executeWithKernel(session.kernel, code, runOptions); + } catch (err) { + if (isCancellationError(err) || options.signal?.aborted) throw err; + if (session.kernel.isAlive()) throw err; + if (sessions.get(session.sessionKey) !== session) { + throw new RubyExecutionCancelledError(false); + } + await replaceSessionKernel(session, cwd, options); + if (sessions.get(session.sessionKey) !== session) { + throw new RubyExecutionCancelledError(false); + } + return await executeWithKernel(session.kernel, code, runOptions); + } +} + +export async function executeRubyWithKernel( + kernel: RubyKernelExecutor, + code: string, + options?: RubyExecutorOptions, +): Promise { + return await executeWithKernel(kernel, code, options); +} + +export async function executeRuby(code: string, options?: RubyExecutorOptions): Promise { + const cwd = normalizeSessionCwd(options?.cwd ?? getProjectDir()); + const deadlineMs = getExecutionDeadlineMs(options); + const executionOptions: RubyExecutorOptions = { + ...(options ?? {}), + cwd, + deadlineMs, + }; + + try { + requireRemainingTimeoutMs(deadlineMs); + if (executionOptions.signal?.aborted) { + throw new RubyExecutionCancelledError( + isTimedOutCancellation(executionOptions.signal.reason, executionOptions.signal), + ); + } + await ensureKernelAvailable(cwd, executionOptions); + await ensureToolBridge(executionOptions); + return await executeOnSession(code, cwd, executionOptions); + } catch (err) { + if (isCancellationError(err) || executionOptions.signal?.aborted) { + return createCancelledRubyResult(isTimedOutCancellation(err, executionOptions.signal)); + } + throw err; + } +} diff --git a/packages/coding-agent/src/eval/rb/index.ts b/packages/coding-agent/src/eval/rb/index.ts new file mode 100644 index 000000000..5f670b47a --- /dev/null +++ b/packages/coding-agent/src/eval/rb/index.ts @@ -0,0 +1,62 @@ +import type { ToolSession } from "../../tools"; +import { + type ExecutorBackend, + type ExecutorBackendExecOptions, + type ExecutorBackendResult, + resolveEvalUrlRoots, +} from "../backend"; +import { executeRuby } from "./executor"; +import { checkRubyKernelAvailability } from "./kernel"; + +const RUBY_SESSION_PREFIX = "ruby:"; + +export function namespaceSessionId(sessionId: string): string { + return sessionId.startsWith(RUBY_SESSION_PREFIX) ? sessionId : `${RUBY_SESSION_PREFIX}${sessionId}`; +} + +function readInterpreterSetting(session: ToolSession): string | undefined { + const settings = session.settings as { get?: (key: string) => unknown } | undefined; + const value = settings?.get?.("ruby.interpreter"); + return typeof value === "string" ? value.trim() || undefined : undefined; +} + +export default { + id: "ruby", + label: "Ruby", + highlightLang: "ruby", + + async isAvailable(session: ToolSession): Promise { + const availability = await checkRubyKernelAvailability(session.cwd, readInterpreterSetting(session)); + return availability.ok; + }, + + async execute(code: string, opts: ExecutorBackendExecOptions): Promise { + const result = await executeRuby(code, { + cwd: opts.cwd, + idleTimeoutMs: opts.idleTimeoutMs, + signal: opts.signal, + sessionId: namespaceSessionId(opts.sessionId), + interpreter: readInterpreterSetting(opts.session), + sessionFile: opts.sessionFile, + artifactsDir: opts.session.getArtifactsDir?.() ?? undefined, + localRoots: resolveEvalUrlRoots(opts.session), + kernelOwnerId: opts.kernelOwnerId, + reset: opts.reset, + onChunk: opts.onChunk, + onStatus: opts.onStatus, + toolSession: opts.session, + }); + return { + output: result.output, + exitCode: result.exitCode, + cancelled: result.cancelled, + truncated: result.truncated, + artifactId: result.artifactId, + totalLines: result.totalLines, + totalBytes: result.totalBytes, + outputLines: result.outputLines, + outputBytes: result.outputBytes, + displayOutputs: result.displayOutputs, + }; + }, +} satisfies ExecutorBackend; diff --git a/packages/coding-agent/src/eval/rb/kernel.ts b/packages/coding-agent/src/eval/rb/kernel.ts new file mode 100644 index 000000000..b00b0a472 --- /dev/null +++ b/packages/coding-agent/src/eval/rb/kernel.ts @@ -0,0 +1,710 @@ +/** + * Subprocess-backed Ruby runner. + * + * Speaks NDJSON with `runner.rb` over stdin/stdout. One subprocess per kernel + * instance; sessions reuse a single subprocess across executions. Cancellation + * is delivered as SIGINT (clean interrupt, kernel state preserved) and escalates + * to a full shutdown only when the runner ignores it. Mirrors the Python kernel + * (eval/py/kernel.ts); display rendering and Windows console spawn handling are + * shared with it. + */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { $flag, isBunTestRuntime, logger, Snowflake } from "@oh-my-pi/pi-utils"; +import type { Subprocess } from "bun"; +import { $ } from "bun"; +import { Settings } from "../../config/settings"; +import { type KernelDisplayOutput, renderKernelDisplay } from "../py/display"; +import { hostHasInheritableConsole, shouldHideKernelWindow } from "../py/spawn-options"; +import { RUBY_PRELUDE } from "./prelude"; +import RUNNER_SCRIPT from "./runner.rb" with { type: "text" }; +import { + enumerateRubyRuntimes, + filterEnv, + type RubyRuntime, + resolveExplicitRubyRuntime, + resolveRubyRuntime, +} from "./runtime"; + +export type { KernelDisplayOutput, PythonStatusEvent } from "../py/display"; +export { renderKernelDisplay } from "../py/display"; + +const TRACE_IPC = $flag("PI_RUBY_IPC_TRACE"); + +// Cache the runner script on disk so the subprocess loads it normally. Cached +// per script hash so installs don't race across versions. +const RUNNER_CACHE_DIR = path.join(os.tmpdir(), "omp-ruby-runner"); +let RUNNER_SCRIPT_PATH: string | null = null; + +async function ensureRunnerScript(): Promise { + if (RUNNER_SCRIPT_PATH) return RUNNER_SCRIPT_PATH; + await fs.promises.mkdir(RUNNER_CACHE_DIR, { recursive: true }); + const hash = Bun.hash(RUNNER_SCRIPT).toString(36); + const target = path.join(RUNNER_CACHE_DIR, `runner-${hash}.rb`); + if (!fs.existsSync(target)) { + await Bun.write(target, RUNNER_SCRIPT); + } + RUNNER_SCRIPT_PATH = target; + return target; +} + +const SHUTDOWN_GRACE_MS = 1_000; +const STARTUP_TIMEOUT_MS = 10_000; +// How long to wait after SIGINT for the runner to emit `done` before escalating +// to a full subprocess shutdown so the host queue unblocks instead of hanging. +const INTERRUPT_ESCALATION_MS = 5_000; + +export type KernelRuntimeEnv = Record; + +export interface KernelExecuteOptions { + id?: string; + /** Runtime working directory applied immediately before this request executes. */ + cwd?: string; + /** Managed runtime environment variables applied immediately before this request executes. */ + env?: KernelRuntimeEnv; + signal?: AbortSignal; + onChunk?: (text: string) => Promise | void; + onDisplay?: (output: KernelDisplayOutput) => Promise | void; + timeoutMs?: number; + silent?: boolean; + storeHistory?: boolean; +} + +export interface KernelExecuteResult { + status: "ok" | "error"; + executionCount?: number; + error?: { name: string; value: string; traceback: string[] }; + cancelled: boolean; + timedOut: boolean; + stdinRequested: boolean; + /** True when the kernel subprocess was killed while settling this execution. */ + kernelKilled?: boolean; +} + +export interface KernelShutdownResult { + confirmed: boolean; +} + +interface KernelLifecycleOptions { + signal?: AbortSignal; + deadlineMs?: number; +} + +interface KernelStartOptions extends KernelLifecycleOptions { + cwd: string; + env?: Record; + /** Explicit interpreter path (`ruby.interpreter`); skips discovery when set. */ + interpreter?: string; +} + +interface KernelShutdownOptions { + signal?: AbortSignal; + timeoutMs?: number; +} + +export interface RubyKernelAvailability { + ok: boolean; + rubyPath?: string; + reason?: string; + /** The probed-working runtime, when one was found. */ + runtime?: RubyRuntime; +} + +function getRemainingTimeMs(deadlineMs?: number): number | undefined { + if (deadlineMs === undefined) return undefined; + return Math.max(0, deadlineMs - Date.now()); +} + +function createAbortError(name: "AbortError" | "TimeoutError", message: string): Error { + const err = new Error(message); + err.name = name; + return err; +} + +function throwIfAborted(signal: AbortSignal | undefined, fallbackReason: string): void { + if (!signal?.aborted) return; + const reason = signal.reason; + if (reason instanceof Error) throw reason; + throw createAbortError("AbortError", typeof reason === "string" ? reason : fallbackReason); +} + +// Cache successful probes per resolved cwd + explicit interpreter. Failures are +// not cached so installing Ruby mid-session is picked up on the next attempt. +const availabilityCache = new Map>(); + +export async function checkRubyKernelAvailability(cwd: string, interpreter?: string): Promise { + if (isBunTestRuntime() || $flag("PI_RUBY_SKIP_CHECK")) { + return { ok: true }; + } + const resolvedCwd = path.resolve(cwd); + const key = `${resolvedCwd}\0${interpreter ?? ""}`; + const cached = availabilityCache.get(key); + if (cached) return await cached; + const probe = probeRubyKernelAvailability(resolvedCwd, interpreter); + availabilityCache.set(key, probe); + const result = await probe; + if (!result.ok && availabilityCache.get(key) === probe) { + availabilityCache.delete(key); + } + return result; +} + +async function probeRubyKernelAvailability(cwd: string, interpreter?: string): Promise { + try { + const settings = await Settings.init(); + const { env } = settings.getShellConfig(); + const baseEnv = filterEnv(env); + const runtimes = enumerateRubyRuntimes(cwd, baseEnv, interpreter); + if (runtimes.length === 0) { + return { ok: false, reason: "Ruby executable not found on PATH" }; + } + const failures: string[] = []; + for (const runtime of runtimes) { + try { + const probe = await $`${runtime.rubyPath} -e ${"exit 0"}`.quiet().nothrow().cwd(cwd).env(runtime.env); + if (probe.exitCode === 0) { + return { ok: true, rubyPath: runtime.rubyPath, runtime }; + } + failures.push(`${runtime.rubyPath} (exit code ${probe.exitCode})`); + } catch (err) { + failures.push(`${runtime.rubyPath} (${err instanceof Error ? err.message : String(err)})`); + } + } + return { + ok: false, + rubyPath: runtimes[0].rubyPath, + reason: `No working Ruby interpreter found. Tried: ${failures.join("; ")}`, + }; + } catch (err) { + return { ok: false, reason: err instanceof Error ? err.message : String(err) }; + } +} + +type FrameType = "started" | "stdout" | "stderr" | "display" | "result" | "error" | "done"; + +interface Frame { + type: FrameType; + id?: string; + data?: string; + bundle?: Record; + ename?: string; + evalue?: string; + traceback?: string[]; + status?: "ok" | "error"; + executionCount?: number; + cancelled?: boolean; +} + +interface PendingExecution { + resolve: (result: KernelExecuteResult) => void; + options?: KernelExecuteOptions; + status: "ok" | "error"; + executionCount?: number; + error?: { name: string; value: string; traceback: string[] }; + cancelled: boolean; + timedOut: boolean; + stdinRequested: boolean; + kernelKilled: boolean; + settled: boolean; + escalationTimer?: NodeJS.Timeout; +} + +export class RubyKernel { + readonly id: string; + #proc: Subprocess | null = null; + #stdin: Bun.FileSink | null = null; + #alive = true; + #disposed = false; + #shutdownConfirmed = false; + #exitedPromise: Promise | null = null; + #pending = new Map(); + #readBuffer = ""; + + private constructor(id: string) { + this.id = id; + } + + static async start(options: KernelStartOptions): Promise { + const availability = await logger.time( + "RubyKernel.start:availabilityCheck", + checkRubyKernelAvailability, + options.cwd, + options.interpreter, + ); + if (!availability.ok) { + throw new Error(availability.reason ?? "Ruby kernel unavailable"); + } + + // Reuse the interpreter the availability probe selected. The fallback + // computes a runtime only for the skip-check fast path (test runtime / + // PI_RUBY_SKIP_CHECK), where no candidate was probed. + let runtime = availability.runtime; + if (!runtime) { + const { env: shellEnv } = (await Settings.init()).getShellConfig(); + runtime = options.interpreter + ? resolveExplicitRubyRuntime(options.interpreter, options.cwd, filterEnv(shellEnv)) + : resolveRubyRuntime(options.cwd, filterEnv(shellEnv)); + } + const spawnEnv: Record = {}; + for (const key in runtime.env) { + const value = runtime.env[key]; + if (typeof value === "string") spawnEnv[key] = value; + } + for (const key in options.env) { + const value = options.env[key]; + if (typeof value === "string") spawnEnv[key] = value; + } + + const scriptPath = await ensureRunnerScript(); + const kernel = new RubyKernel(Snowflake.next()); + + const proc = Bun.spawn([runtime.rubyPath, scriptPath], { + cwd: options.cwd, + env: spawnEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + windowsHide: shouldHideKernelWindow({ + platform: process.platform, + hostHasInheritableConsole: hostHasInheritableConsole(), + }), + }); + kernel.#proc = proc; + kernel.#stdin = proc.stdin; + kernel.#exitedPromise = proc.exited; + void kernel.#exitedPromise.then(code => { + kernel.#alive = false; + kernel.#abortPendingExecutions(`Ruby kernel exited with code ${code}`, { kernelKilled: true }); + }); + + kernel.#startReader(proc.stdout as ReadableStream); + kernel.#startStderrDrain(proc.stderr as ReadableStream); + + const startup = { signal: options.signal, deadlineMs: options.deadlineMs }; + const startupBudget = Math.min(getRemainingTimeMs(startup.deadlineMs) ?? STARTUP_TIMEOUT_MS, STARTUP_TIMEOUT_MS); + + try { + const initScript = buildInitScript(options.cwd, options.env); + await kernel.#executeWithBudget(initScript, startup.signal, startupBudget, "Ruby kernel init"); + await kernel.#executeWithBudget(RUBY_PRELUDE, startup.signal, startupBudget, "Ruby kernel prelude"); + return kernel; + } catch (err) { + await kernel.shutdown({ timeoutMs: SHUTDOWN_GRACE_MS }).catch(() => {}); + throw err; + } + } + + isAlive(): boolean { + return this.#alive && !this.#disposed; + } + + async execute(code: string, options?: KernelExecuteOptions): Promise { + if (!this.isAlive()) { + throw new Error("Ruby kernel is not running"); + } + + const msgId = options?.id ?? Snowflake.next(); + const { promise, resolve } = Promise.withResolvers(); + const pending: PendingExecution = { + resolve, + options, + status: "ok", + cancelled: false, + timedOut: false, + stdinRequested: false, + settled: false, + kernelKilled: false, + }; + this.#pending.set(msgId, pending); + + const finalize = () => { + if (pending.settled) return; + pending.settled = true; + this.#pending.delete(msgId); + cleanup(); + resolve({ + status: pending.status, + executionCount: pending.executionCount, + error: pending.error, + cancelled: pending.cancelled, + timedOut: pending.timedOut, + stdinRequested: pending.stdinRequested, + kernelKilled: pending.kernelKilled, + }); + }; + + const requestCancel = () => { + if (pending.settled || pending.escalationTimer) return; + void this.interrupt(); + const escalation = setTimeout(() => { + if (pending.settled) return; + logger.warn("Ruby runner did not respond to SIGINT; terminating subprocess", { + kernelId: this.id, + }); + pending.kernelKilled = true; + void this.shutdown(); + }, INTERRUPT_ESCALATION_MS); + escalation.unref?.(); + pending.escalationTimer = escalation; + }; + + const onAbort = () => { + pending.cancelled = true; + pending.timedOut = pending.timedOut || isTimeoutReason(options?.signal?.reason); + requestCancel(); + }; + const timeoutId = + typeof options?.timeoutMs === "number" && options.timeoutMs > 0 + ? setTimeout(() => { + pending.timedOut = true; + pending.cancelled = true; + requestCancel(); + }, options.timeoutMs) + : undefined; + + const cleanup = () => { + clearTimeout(timeoutId); + clearTimeout(pending.escalationTimer); + pending.escalationTimer = undefined; + options?.signal?.removeEventListener("abort", onAbort); + }; + + if (options?.signal) { + if (options.signal.aborted) { + onAbort(); + } else { + options.signal.addEventListener("abort", onAbort, { once: true }); + } + } + + (pending as PendingExecution & { finalize: () => void }).finalize = finalize; + + const payload = JSON.stringify({ + id: msgId, + code, + cwd: options?.cwd, + env: options?.env, + silent: options?.silent ?? false, + storeHistory: options?.storeHistory ?? !(options?.silent ?? false), + }); + + try { + await this.#writeLine(payload); + } catch (err) { + pending.cancelled = true; + pending.error = { + name: "TransportError", + value: err instanceof Error ? err.message : String(err), + traceback: [], + }; + finalize(); + } + + return promise; + } + + async interrupt(): Promise { + if (!this.#proc || this.#disposed) return; + try { + this.#proc.kill("SIGINT"); + } catch (err) { + logger.warn("Failed to interrupt ruby runner", { error: err instanceof Error ? err.message : String(err) }); + } + } + + async shutdown(options?: KernelShutdownOptions): Promise { + if (this.#shutdownConfirmed) return { confirmed: true }; + + this.#alive = false; + this.#abortPendingExecutions("Ruby kernel shutdown", { kernelKilled: true }); + + const timeoutMs = options?.timeoutMs ?? SHUTDOWN_GRACE_MS; + const proc = this.#proc; + if (!proc) { + this.#shutdownConfirmed = true; + this.#disposed = true; + return { confirmed: true }; + } + + try { + await this.#writeLine(JSON.stringify({ type: "exit" })).catch(() => {}); + } catch { + /* writer may already be closed */ + } + + try { + this.#stdin?.end(); + } catch { + /* ignore */ + } + + const exited = this.#waitForExitWithTimeout(timeoutMs); + let result = await exited; + if (!result) { + try { + proc.kill("SIGTERM"); + } catch { + /* ignore */ + } + result = await this.#waitForExitWithTimeout(timeoutMs); + } + if (!result) { + try { + proc.kill("SIGKILL"); + } catch { + /* ignore */ + } + result = await this.#waitForExitWithTimeout(timeoutMs); + } + + const confirmed = !!result; + this.#shutdownConfirmed = confirmed; + this.#disposed = true; + return { confirmed }; + } + + #abortPendingExecutions(reason: string, options?: { kernelKilled?: boolean }): void { + if (this.#pending.size === 0) return; + const pending = Array.from(this.#pending.values()); + this.#pending.clear(); + const kernelKilledDefault = options?.kernelKilled ?? false; + for (const entry of pending) { + if (entry.settled) continue; + entry.settled = true; + void entry.options?.onChunk?.(`[kernel] ${reason}\n`); + entry.resolve({ + status: "error", + cancelled: true, + timedOut: entry.timedOut, + stdinRequested: entry.stdinRequested, + executionCount: entry.executionCount, + error: entry.error, + kernelKilled: entry.kernelKilled || kernelKilledDefault, + }); + } + } + + async #writeLine(line: string): Promise { + if (!this.#stdin) { + throw new Error("Ruby kernel stdin is not open"); + } + if (TRACE_IPC) { + logger.debug("RubyKernel send", { preview: line.slice(0, 120) }); + } + this.#stdin.write(`${line}\n`); + this.#stdin.flush(); + } + + #startReader(stream: ReadableStream): void { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + const loop = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + this.#readBuffer += decoder.decode(value, { stream: true }); + await this.#flushFrames(); + } + this.#readBuffer += decoder.decode(); + await this.#flushFrames(); + } catch (err) { + logger.warn("Ruby kernel reader failed", { error: err instanceof Error ? err.message : String(err) }); + } finally { + try { + reader.releaseLock(); + } catch { + /* ignore */ + } + } + }; + void loop(); + } + + #startStderrDrain(stream: ReadableStream): void { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + const loop = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value); + if (text.trim()) { + logger.warn("Ruby runner stderr", { text }); + } + } + } catch { + /* ignore */ + } finally { + try { + reader.releaseLock(); + } catch { + /* ignore */ + } + } + }; + void loop(); + } + + async #flushFrames(): Promise { + while (true) { + const nl = this.#readBuffer.indexOf("\n"); + if (nl < 0) return; + const line = this.#readBuffer.slice(0, nl); + this.#readBuffer = this.#readBuffer.slice(nl + 1); + if (!line.trim()) continue; + let frame: Frame; + try { + frame = JSON.parse(line) as Frame; + } catch (err) { + logger.warn("Ruby runner emitted invalid JSON", { + line: line.slice(0, 200), + error: err instanceof Error ? err.message : String(err), + }); + continue; + } + if (TRACE_IPC) { + logger.debug("RubyKernel recv", { type: frame.type, id: frame.id }); + } + await this.#handleFrame(frame); + } + } + + async #handleFrame(frame: Frame): Promise { + const rid = frame.id; + if (!rid) return; + const pending = this.#pending.get(rid) as (PendingExecution & { finalize?: () => void }) | undefined; + if (!pending) return; + + switch (frame.type) { + case "started": + return; + case "stdout": + case "stderr": { + const text = frame.data ?? ""; + if (text && pending.options?.onChunk) { + await pending.options.onChunk(text); + } + return; + } + case "display": + case "result": { + const bundle = frame.bundle ?? {}; + const { text, outputs } = await renderKernelDisplay(bundle); + if (text && pending.options?.onChunk) { + await pending.options.onChunk(text); + } + if (outputs.length > 0 && pending.options?.onDisplay) { + for (const output of outputs) { + await pending.options.onDisplay(output); + } + } + return; + } + case "error": { + const traceback = Array.isArray(frame.traceback) ? frame.traceback.map(String) : []; + pending.status = "error"; + pending.error = { + name: String(frame.ename ?? "Error"), + value: String(frame.evalue ?? ""), + traceback, + }; + const message = + traceback.length > 0 ? `${traceback.join("\n")}\n` : `${pending.error.name}: ${pending.error.value}\n`; + if (pending.options?.onChunk) { + await pending.options.onChunk(message); + } + return; + } + case "done": { + if (typeof frame.executionCount === "number") { + pending.executionCount = frame.executionCount; + } + if (frame.status === "error" && pending.status === "ok") { + pending.status = "error"; + } + if (frame.cancelled) { + pending.cancelled = true; + } + pending.finalize?.(); + return; + } + } + } + + async #executeWithBudget( + code: string, + signal: AbortSignal | undefined, + timeoutMs: number, + label: string, + ): Promise { + const controller = new AbortController(); + const cleanups: Array<() => void> = []; + if (signal) { + if (signal.aborted) { + controller.abort(signal.reason); + } else { + const onAbort = () => controller.abort(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + cleanups.push(() => signal.removeEventListener("abort", onAbort)); + } + } + const timer = + timeoutMs > 0 + ? setTimeout(() => controller.abort(createAbortError("TimeoutError", `${label} timed out`)), timeoutMs) + : undefined; + if (timer) cleanups.push(() => clearTimeout(timer)); + try { + throwIfAborted(controller.signal, label); + const result = await this.execute(code, { + signal: controller.signal, + silent: true, + storeHistory: false, + }); + if (result.cancelled) { + throw createAbortError(result.timedOut ? "TimeoutError" : "AbortError", `${label} cancelled`); + } + if (result.status === "error") { + const reason = result.error?.value ?? "Ruby kernel init failed"; + throw new Error(`${label} failed: ${reason}`); + } + } finally { + for (const cleanup of cleanups) cleanup(); + } + } + + #waitForExitWithTimeout(timeoutMs: number): Promise { + if (!this.#exitedPromise) return Promise.resolve(0); + const exitedPromise = this.#exitedPromise; + const timeout = new Promise(resolve => { + const timer = setTimeout(() => resolve(null), Math.max(0, timeoutMs)); + timer.unref?.(); + }); + return Promise.race([exitedPromise.then(code => code as number | null), timeout]); + } +} + +function isTimeoutReason(reason: unknown): boolean { + if (reason instanceof DOMException) return reason.name === "TimeoutError"; + if (reason instanceof Error) return reason.name === "TimeoutError"; + return false; +} + +function buildInitScript(cwd: string, env?: Record): string { + const envPayload: Record = {}; + for (const key in env) { + const value = env[key]; + if (value !== undefined) envPayload[key] = value; + } + // JSON string literals are valid Ruby string literals. Emit one + // `ENV["k"] = "v"` per key — a `{"k":"v"}` object literal would parse as a + // SYMBOL-keyed hash in Ruby (`:"k" => "v"`), which `ENV[]=` rejects. + const lines = [`__omp_init_cwd = ${JSON.stringify(cwd)}`, "Dir.chdir(__omp_init_cwd) rescue nil"]; + for (const key in envPayload) { + lines.push(`ENV[${JSON.stringify(key)}] = ${JSON.stringify(envPayload[key])}`); + } + lines.push("$LOAD_PATH.delete(__omp_init_cwd)", "$LOAD_PATH.unshift(__omp_init_cwd)"); + return lines.join("\n"); +} diff --git a/packages/coding-agent/src/eval/rb/prelude.rb b/packages/coding-agent/src/eval/rb/prelude.rb new file mode 100644 index 000000000..42eace372 --- /dev/null +++ b/packages/coding-agent/src/eval/rb/prelude.rb @@ -0,0 +1,721 @@ +# frozen_string_literal: false +# OMP Ruby prelude helpers (loaded once into the runner's TOPLEVEL_BINDING). +# +# Mirrors eval/py/prelude.py: defines the cross-runtime helper surface +# (display/read/write/append/tree/diff/env/output, the `tool` bridge proxy, +# completion/agent/parallel/pipeline/log/phase/budget). Host-side helpers reach +# the coding-agent over the same loopback HTTP tool bridge the Python prelude +# uses (PI_TOOL_BRIDGE_URL/TOKEN/SESSION). Path helpers honor PI_EVAL_LOCAL_ROOTS +# so `write("local://x")` lands where `read local://x` resolves. +# +# `__omp_*` primitives (emit/present/status/scrub/run_id) are provided by +# runner.rb; this file only consumes them. + +unless defined?($__omp_prelude_loaded) && $__omp_prelude_loaded + $__omp_prelude_loaded = true + + require "json" + + # ------------------------------------------------------------------------- + # Internal-URL path resolution + # ------------------------------------------------------------------------- + + def __omp_url_decode(str) + str.gsub(/%([0-9A-Fa-f]{2})/) { [Regexp.last_match(1)].pack("H2") }.force_encoding(Encoding::UTF_8) + end + + # Map a helper path to a real filesystem path. A `scheme://…` whose scheme has + # an injected on-disk root (PI_EVAL_LOCAL_ROOTS, e.g. `local://`) is rewritten + # under that root; plain paths pass through; any other `scheme://` is rejected. + def __omp_resolve_path(path) + return path unless path.is_a?(String) + m = path.match(%r{\A([a-z][a-z0-9+.\-]*)://(.*)\z}i) + return path unless m + scheme = m[1].downcase + roots = + begin + raw = ENV["PI_EVAL_LOCAL_ROOTS"] + raw && !raw.empty? ? JSON.parse(raw) : {} + rescue StandardError + {} + end + root = roots.is_a?(Hash) ? roots[scheme] : nil + raise "Protocol paths are not supported by this helper: #{path}" if root.nil? || root.to_s.empty? + relative = __omp_url_decode(m[2].tr("\\", "/")) + root_path = File.absolute_path(root.to_s) + return root_path if relative.empty? + if relative.start_with?("/") || relative.split("/").include?("..") + raise "Unsafe #{scheme}:// path (absolute or traversal): #{path}" + end + resolved = File.absolute_path(File.join(root_path, relative)) + unless resolved == root_path || resolved.start_with?(root_path + File::SEPARATOR) + raise "#{scheme}:// path escapes its root: #{path}" + end + resolved + end + + # ------------------------------------------------------------------------- + # Display + status + # ------------------------------------------------------------------------- + + def display(value) + __omp_present(value, "display") + nil + end + + # Emit a base64 image as a display output. `mime_type` is "image/png" (default) + # or "image/jpeg"; the host surfaces it as an inspectable image block. + def display_image(base64, mime_type: "image/png") + __omp_emit_display({ mime_type.to_s => base64.to_s }) + nil + end + + def env(key = nil, value = nil) + if key.nil? + items = ENV.to_h.sort.to_h + __omp_emit_status("env", "count" => items.size, "keys" => items.keys.first(20)) + return items + end + unless value.nil? + ENV[key.to_s] = value.to_s + __omp_emit_status("env", "key" => key.to_s, "value" => value.to_s, "action" => "set") + return value + end + val = ENV[key.to_s] + __omp_emit_status("env", "key" => key.to_s, "value" => val, "action" => "get") + val + end + + # ------------------------------------------------------------------------- + # File helpers + # ------------------------------------------------------------------------- + + def read(path, offset = 1, limit = nil) + resolved = __omp_resolve_path(path) + data = File.read(resolved.to_s, encoding: Encoding::UTF_8) + if offset > 1 || !limit.nil? + lines = data.lines + start = [offset - 1, 0].max + finish = limit ? start + limit : lines.length + data = lines[start...finish].to_a.join + end + __omp_emit_status("read", "path" => resolved.to_s, "chars" => data.length, "preview" => __omp_scrub(data[0, 500].to_s)) + data + end + + def write(path, content) + resolved = __omp_resolve_path(path) + require "fileutils" + FileUtils.mkdir_p(File.dirname(resolved.to_s)) + File.write(resolved.to_s, content.to_s) + __omp_emit_status("write", "path" => resolved.to_s, "chars" => content.to_s.length) + resolved.to_s + end + + def append(path, content) + resolved = __omp_resolve_path(path) + require "fileutils" + FileUtils.mkdir_p(File.dirname(resolved.to_s)) + File.open(resolved.to_s, "a") { |f| f.write(content.to_s) } + __omp_emit_status("append", "path" => resolved.to_s, "chars" => content.to_s.length) + resolved.to_s + end + + def tree(path = ".", max_depth: 3, show_hidden: false) + base = path.to_s + lines = [] + walk = lambda do |dir, prefix, depth| + return if depth > max_depth + entries = (Dir.children(dir) rescue []) + entries = entries.reject { |e| e.start_with?(".") } unless show_hidden + entries = entries.sort_by { |e| [File.directory?(File.join(dir, e)) ? 0 : 1, e.downcase] } + entries.each_with_index do |name, i| + full = File.join(dir, name) + is_last = i == entries.length - 1 + is_dir = File.directory?(full) + lines << "#{prefix}#{is_last ? "└── " : "├── "}#{name}#{is_dir ? "/" : ""}" + walk.call(full, prefix + (is_last ? " " : "│ "), depth + 1) if is_dir + end + end + lines << "#{base}/" + walk.call(base, "", 1) + out = lines.join("\n") + __omp_emit_status("tree", "path" => base, "entries" => lines.length - 1, "preview" => __omp_scrub(out[0, 1000].to_s)) + out + end + + def diff(a, b) + pa = a.to_s + pb = b.to_s + lines_a = File.read(pa, encoding: Encoding::UTF_8).lines + lines_b = File.read(pb, encoding: Encoding::UTF_8).lines + out = __omp_unified_diff(lines_a, lines_b, pa, pb) + __omp_emit_status("diff", "file_a" => pa, "file_b" => pb, "identical" => out.empty?, "preview" => __omp_scrub(out[0, 500].to_s)) + out + end + + # LCS-based op list ([:equal/:delete/:insert, aIndex, bIndex]) per consumed line. + def __omp_diff_ops(a, b) + n = a.length + m = b.length + if n * m > 4_000_000 + # Too large for the DP table — fall back to a coarse all-delete/all-insert. + ops = [] + n.times { |i| ops << [:delete, i, 0] } + m.times { |j| ops << [:insert, n, j] } + return ops + end + dp = Array.new(n + 1) { Array.new(m + 1, 0) } + (n - 1).downto(0) do |i| + row = dp[i] + nrow = dp[i + 1] + (m - 1).downto(0) do |j| + row[j] = a[i] == b[j] ? nrow[j + 1] + 1 : (nrow[j] >= row[j + 1] ? nrow[j] : row[j + 1]) + end + end + ops = [] + i = 0 + j = 0 + while i < n && j < m + if a[i] == b[j] + ops << [:equal, i, j]; i += 1; j += 1 + elsif dp[i + 1][j] >= dp[i][j + 1] + ops << [:delete, i, j]; i += 1 + else + ops << [:insert, i, j]; j += 1 + end + end + ops << [:delete, i, j].tap { i += 1 } while i < n + ops << [:insert, i, j].tap { j += 1 } while j < m + ops + end + + def __omp_unified_diff(a, b, from_file, to_file, context = 3) + ops = __omp_diff_ops(a, b) + return "" unless ops.any? { |tag, _, _| tag != :equal } + + entries = ops.map do |tag, ai, bi| + { tag: tag, ai: ai, bi: bi, text: (tag == :insert ? b[bi] : a[ai]) } + end + changed = entries.each_index.select { |k| entries[k][:tag] != :equal } + + groups = [] + start = nil + prev = nil + changed.each do |k| + if start.nil? + start = k + prev = k + elsif k - prev <= (2 * context) + 1 + prev = k + else + groups << [start, prev] + start = k + prev = k + end + end + groups << [start, prev] unless start.nil? + + out = +"" + out << "--- #{from_file}\n" + out << "+++ #{to_file}\n" + groups.each do |gs, ge| + lo = [gs - context, 0].max + hi = [ge + context, entries.length - 1].min + slice = entries[lo..hi] + a_start = nil + a_count = 0 + b_start = nil + b_count = 0 + slice.each do |e| + if e[:tag] != :insert + a_start ||= e[:ai] + a_count += 1 + end + if e[:tag] != :delete + b_start ||= e[:bi] + b_count += 1 + end + end + out << "@@ -#{(a_start || 0) + 1},#{a_count} +#{(b_start || 0) + 1},#{b_count} @@\n" + slice.each do |e| + prefix = e[:tag] == :equal ? " " : (e[:tag] == :delete ? "-" : "+") + text = e[:text].to_s + text = "#{text}\n" unless text.end_with?("\n") + out << "#{prefix}#{text}" + end + end + out + end + + # ------------------------------------------------------------------------- + # Text helpers (sort / uniq / counter) + # ------------------------------------------------------------------------- + + def sort(text, reverse: false, unique: false) + lines = text.to_s.lines.map(&:chomp) + lines = lines.uniq if unique + lines = lines.sort + lines = lines.reverse if reverse + out = lines.join("\n") + __omp_emit_status("sort", "lines" => lines.length, "unique" => unique, "reverse" => reverse) + out + end + + def uniq(text, count: false) + lines = text.to_s.lines.map(&:chomp) + if lines.empty? + __omp_emit_status("uniq", "groups" => 0) + return count ? [] : "" + end + groups = [] + current = lines[0] + run = 1 + lines[1..].each do |line| + if line == current + run += 1 + else + groups << [run, current] + current = line + run = 1 + end + end + groups << [run, current] + __omp_emit_status("uniq", "groups" => groups.length, "count_mode" => count) + count ? groups : groups.map { |_, l| l }.join("\n") + end + + def counter(items, limit: nil, reverse: true) + arr = items.is_a?(String) ? items.lines.map(&:chomp) : items.to_a + counts = Hash.new(0) + arr.each { |i| counts[i] += 1 } + sorted = counts.sort_by { |item, c| [c, item] } + sorted = sorted.reverse if reverse + sorted = sorted.first(limit) if limit + result = sorted.map { |item, c| [c, item] } + __omp_emit_status("counter", "unique" => counts.size, "total" => arr.length, "top" => result.first(10)) + result + end + + # ------------------------------------------------------------------------- + # Task/agent output reader + # ------------------------------------------------------------------------- + + def __omp_apply_query(data, query) + return data if query.nil? || query.empty? + q = query.strip + q = q[1..] if q.start_with?(".") + return data if q.empty? + tokens = [] + buf = +"" + i = 0 + while i < q.length + ch = q[i] + if ch == "." + unless buf.empty? + tokens << [:key, buf] + buf = +"" + end + elsif ch == "[" + unless buf.empty? + tokens << [:key, buf] + buf = +"" + end + j = i + 1 + j += 1 while j < q.length && q[j] != "]" + inner = q[(i + 1)...j] + if inner.start_with?('"') && inner.end_with?('"') + tokens << [:key, inner[1..-2]] + else + tokens << [:index, inner.to_i] + end + i = j + else + buf << ch + end + i += 1 + end + tokens << [:key, buf] unless buf.empty? + + current = data + tokens.each do |kind, value| + if kind == :index + return nil unless current.is_a?(Array) && value < current.length + current = current[value] + else + return nil unless current.is_a?(Hash) && current.key?(value) + current = current[value] + end + end + current + end + + def output(*ids, format: "raw", query: nil, offset: nil, limit: nil) + artifacts_dir = ENV["PI_ARTIFACTS_DIR"] + if artifacts_dir.nil? || artifacts_dir.empty? + session_file = ENV["PI_SESSION_FILE"] + if session_file.nil? || session_file.empty? + __omp_emit_status("output", "error" => "No session file available") + raise "No session - output artifacts unavailable" + end + artifacts_dir = session_file.sub(/\.[^.]*\z/, "") + end + unless File.directory?(artifacts_dir) + __omp_emit_status("output", "error" => "Artifacts directory not found", "path" => artifacts_dir) + raise "No artifacts directory found: #{artifacts_dir}" + end + raise ArgumentError, "At least one output ID is required" if ids.empty? + if query && (!offset.nil? || !limit.nil?) + __omp_emit_status("output", "error" => "query cannot be combined with offset/limit") + raise ArgumentError, "query cannot be combined with offset/limit" + end + + results = [] + not_found = [] + ids.each do |output_id| + path = File.join(artifacts_dir, "#{output_id}.md") + unless File.exist?(path) + not_found << output_id + next + end + raw = File.read(path, encoding: Encoding::UTF_8) + raw_lines = raw.split("\n", -1) + total_lines = raw_lines.length + selected = raw + range_info = nil + + if query + json_value = + begin + JSON.parse(raw) + rescue JSON::ParserError => e + __omp_emit_status("output", "id" => output_id, "error" => "Not valid JSON: #{e.message}") + raise "Output #{output_id} is not valid JSON: #{e.message}" + end + result_value = __omp_apply_query(json_value, query) + selected = + begin + result_value.nil? ? "null" : JSON.pretty_generate(result_value) + rescue StandardError + result_value.to_s + end + elsif !offset.nil? || !limit.nil? + start_line = [offset || 1, 1].max + if start_line > total_lines + __omp_emit_status("output", "id" => output_id, "error" => "Offset #{start_line} beyond end (#{total_lines} lines)") + raise "Offset #{start_line} is beyond end of output (#{total_lines} lines) for #{output_id}" + end + effective_limit = limit || (total_lines - start_line + 1) + end_line = [total_lines, start_line + effective_limit - 1].min + selected = raw_lines[(start_line - 1)...end_line].join("\n") + range_info = { "start_line" => start_line, "end_line" => end_line, "total_lines" => total_lines } + end + + selected = selected.gsub(/\e\[[0-9;]*m/, "") if format == "stripped" + + if format == "json" + entry = { + "id" => output_id, + "path" => path, + "line_count" => query ? selected.split("\n").length : total_lines, + "char_count" => query ? selected.length : raw.length, + "content" => selected, + } + entry["range"] = range_info if range_info + entry["query"] = query if query + results << entry + else + results << { "id" => output_id, "content" => selected } + end + end + + unless not_found.empty? + available = Dir.glob(File.join(artifacts_dir, "*.md")).map { |f| File.basename(f, ".md") }.sort + msg = "Output not found: #{not_found.join(", ")}" + unless available.empty? + msg += "\n\nAvailable outputs: #{available.first(20).join(", ")}" + msg += " (and #{available.length - 20} more)" if available.length > 20 + end + __omp_emit_status("output", "not_found" => not_found, "available_count" => available.length) + raise msg + end + + if ids.length == 1 + if format == "json" + __omp_emit_status("output", "id" => ids[0], "chars" => results[0]["char_count"]) + return results[0] + end + __omp_emit_status("output", "id" => ids[0], "chars" => results[0]["content"].length) + return results[0]["content"] + end + + if format == "json" + __omp_emit_status("output", "count" => results.length, "total_chars" => results.sum { |r| r["char_count"] }) + return results + end + combined = results.map { |r| { "id" => r["id"], "content" => r["content"] } } + __omp_emit_status("output", "count" => combined.length, "total_chars" => combined.sum { |r| r["content"].length }) + combined + end + + # ------------------------------------------------------------------------- + # Host tool bridge (loopback HTTP) — `tool.(args)`, completion, agent. + # ------------------------------------------------------------------------- + + module OmpBridge + INTENT_FIELD = "i" + + module_function + + def proxy_env + base = ENV["PI_TOOL_BRIDGE_URL"] + token = ENV["PI_TOOL_BRIDGE_TOKEN"] + session = ENV["PI_TOOL_BRIDGE_SESSION"] + if base.nil? || base.empty? || token.nil? || token.empty? || session.nil? || session.empty? + raise "tool bridge is unavailable in this kernel" + end + [base.sub(%r{/+\z}, ""), token, session] + end + + def call(name, args) + require "net/http" + require "uri" + base, token, session = proxy_env + uri = URI("#{base}/v1/tool") + payload = JSON.generate("session" => session, "run" => $__omp_current_rid, "name" => name, "args" => args) + http = Net::HTTP.new(uri.hostname, uri.port) + http.open_timeout = 10 + http.read_timeout = 7 * 24 * 3600 + req = Net::HTTP::Post.new(uri) + req["Content-Type"] = "application/json" + req["Authorization"] = "Bearer #{token}" + req.body = payload + resp = http.request(req) + data = + begin + JSON.parse(resp.body.to_s) + rescue JSON::ParserError + raise "bridge call #{name.inspect}: non-JSON response: #{resp.body.to_s[0, 200].inspect}" + end + unless data.is_a?(Hash) && data["ok"] + raise((data.is_a?(Hash) ? data["error"] : nil) || "bridge call #{name.inspect} failed") + end + data["value"] + end + + def stringify_keys(hash) + out = {} + hash.each { |k, v| out[k.to_s] = v } + out + end + + def tool_call(name, positional, kwargs) + merged = + if positional.nil? + {} + elsif positional.is_a?(Hash) + stringify_keys(positional) + else + raise ArgumentError, "tool.#{name}(...) expects a Hash of arguments (got #{positional.class})" + end + merged.merge!(stringify_keys(kwargs)) if kwargs && !kwargs.empty? + merged[INTENT_FIELD] = "rb prelude" unless merged.key?(INTENT_FIELD) + call(name, merged) + end + end + + # `tool[:name]` form — a reusable one-tool callable. + class OmpToolCallable + def initialize(name) + @name = name + end + + def call(args = nil, **kwargs) + OmpBridge.tool_call(@name, args, kwargs) + end + + def to_proc + method(:call).to_proc + end + + def inspect + "#" + end + end + + # `tool.(args)` proxy. BasicObject so helper methods defined on Object + # (read/write/…) never shadow a tool name — every call routes to the bridge. + class OmpToolProxy < BasicObject + def method_missing(name, args = nil, **kwargs) + ::OmpBridge.tool_call(name.to_s, args, kwargs) + end + + def [](name) + ::OmpToolCallable.new(name.to_s) + end + + def respond_to_missing?(_name, _include_private = false) + true + end + + def inspect + session = ::ENV["PI_TOOL_BRIDGE_SESSION"] + session ? "#" : "#" + end + end + + def tool + $__omp_tool_proxy ||= OmpToolProxy.new + end + + def completion(prompt, model: "default", system: nil, schema: nil) + args = { "prompt" => prompt, "model" => model } + args["system"] = system unless system.nil? + args["schema"] = schema unless schema.nil? + res = OmpBridge.call("__completion__", args) + text = res.is_a?(Hash) ? res["text"] : res + schema.nil? ? text : JSON.parse(text) + end + + def agent(prompt, agent_type: "task", model: nil, label: nil, schema: nil, return_handle: false) + args = { "prompt" => prompt } + args["agentType"] = agent_type unless agent_type.nil? + args["model"] = model unless model.nil? + args["label"] = label unless label.nil? + args["schema"] = schema unless schema.nil? + res = OmpBridge.call("__agent__", args) + text = res.is_a?(Hash) ? res["text"] : res + parsed = schema.nil? ? text : JSON.parse(text) + return parsed unless return_handle + details = res.is_a?(Hash) ? res["details"] : nil + if !details.is_a?(Hash) || details["id"].nil? + return { "text" => text, "output" => text, "handle" => nil, "id" => nil, "agent" => nil } + end + node = { + "text" => text, + "output" => text, + "handle" => "agent://#{details["id"]}", + "id" => details["id"], + "agent" => details["agent"], + } + node["data"] = parsed unless schema.nil? + node + end + + # ------------------------------------------------------------------------- + # Concurrency: parallel / pipeline over a bounded pool (task.maxConcurrency). + # ------------------------------------------------------------------------- + + def __omp_concurrency_limit + snap = (OmpBridge.call("__concurrency__", {}) rescue nil) || {} + n = (snap["limit"] || 0).to_i + n > 0 ? n : 0 + rescue StandardError + 0 + end + + def __omp_pool_map(items) + arr = items.to_a + return [] if arr.empty? + limit = __omp_concurrency_limit + workers = limit > 0 ? [limit, arr.length].min : arr.length + results = Array.new(arr.length) + errors = {} + emut = Mutex.new + queue = Queue.new + arr.each_index { |i| queue << i } + threads = workers.times.map do + Thread.new do + loop do + idx = + begin + queue.pop(true) + rescue ThreadError + break + end + begin + results[idx] = yield(arr[idx]) + rescue Exception => e # rubocop:disable Lint/RescueException + emut.synchronize { errors[idx] = e } + end + end + end + end + begin + threads.each(&:join) + rescue Exception # rubocop:disable Lint/RescueException + threads.each { |t| (t.kill rescue nil) } + raise + end + raise errors[errors.keys.min] unless errors.empty? + results + end + + def parallel(thunks) + list = thunks.to_a + list.each do |t| + raise TypeError, "parallel() expects an iterable of zero-arg callables" unless t.respond_to?(:call) + end + __omp_pool_map(list) { |t| t.call } + end + + def pipeline(items, *stages) + current = items.to_a + stages.each do |stage| + raise TypeError, "pipeline() stages must be callables" unless stage.respond_to?(:call) + current = __omp_pool_map(current) { |item| stage.call(item) } + end + current + end + + # ------------------------------------------------------------------------- + # Progress + budget + # ------------------------------------------------------------------------- + + def log(message) + __omp_emit_status("log", "message" => message.to_s) + nil + end + + def phase(title) + $__omp_current_phase = title.to_s + __omp_emit_status("phase", "title" => title.to_s) + nil + end + + # Live view of the host Goal Mode token budget via the host bridge. + class OmpBudget + def total + snap = (OmpBridge.call("__budget__", {}) || {}) + snap["total"] + end + + def hard + snap = (OmpBridge.call("__budget__", {}) || {}) + snap["hard"] ? true : false + end + + def spent + snap = (OmpBridge.call("__budget__", {}) || {}) + (snap["spent"] || 0).to_i + end + + def remaining + snap = (OmpBridge.call("__budget__", {}) || {}) + total = snap["total"] + return Float::INFINITY if total.nil? + [0, total - (snap["spent"] || 0).to_i].max + end + + def inspect + snap = ((OmpBridge.call("__budget__", {}) rescue nil) || {}) + "#" + rescue StandardError + "#" + end + end + + def budget + $__omp_budget ||= OmpBudget.new + end +end diff --git a/packages/coding-agent/src/eval/rb/prelude.ts b/packages/coding-agent/src/eval/rb/prelude.ts new file mode 100644 index 000000000..4788278a8 --- /dev/null +++ b/packages/coding-agent/src/eval/rb/prelude.ts @@ -0,0 +1,3 @@ +import rubyPrelude from "./prelude.rb" with { type: "text" }; + +export const RUBY_PRELUDE = rubyPrelude; diff --git a/packages/coding-agent/src/eval/rb/runner.rb b/packages/coding-agent/src/eval/rb/runner.rb new file mode 100644 index 000000000..9fa80d616 --- /dev/null +++ b/packages/coding-agent/src/eval/rb/runner.rb @@ -0,0 +1,460 @@ +# frozen_string_literal: false +# OMP Ruby runner — subprocess wrapper used by the coding-agent host. +# +# Mirrors the Python runner (eval/py/runner.py): a persistent Ruby process that +# speaks NDJSON over stdin/stdout. The host writes one JSON request per line +# ({id, code, cwd?, env?, silent?}) and the runner replies with frames: +# {type:"started", id} +# {type:"stdout"|"stderr", id, data} +# {type:"display"|"result", id, bundle} # bundle = Jupyter-style MIME hash +# {type:"error", id, ename, evalue, traceback:[...]} +# {type:"done", id, status, executionCount, cancelled} +# A {type:"exit"} request (or stdin EOF) shuts the runner down. +# +# Each cell is evaluated in the persistent TOPLEVEL_BINDING so local variables, +# methods, and constants survive across cells. The last expression's value is +# auto-displayed (like IRB) unless it is nil, an assignment, or a definition. +# +# Frame channel isolation: the original stdout is dup'd onto a private IO for +# protocol frames, then fd 1 is repointed at an internal pipe. Child processes +# that inherit fd 1 (any `system`/backtick call) land in that pipe and a drain +# thread re-emits their bytes as stdout frames instead of corrupting the NDJSON +# channel. Ruby-level writes go through $stdout/$stderr proxies that emit frames +# synchronously so they order correctly with display output. + +require "json" + +# --------------------------------------------------------------------------- +# Frame channel + fd capture setup +# --------------------------------------------------------------------------- + +$__omp_out_mutex = Mutex.new +$__omp_raw_stderr = (STDERR.dup rescue STDERR) +$__omp_current_rid = nil +$__omp_capture_rid = nil +$__omp_exec_count = 0 +$__omp_active_exec = 0 +$__omp_silent = false + +begin + $__omp_frame_io = STDOUT.dup + $__omp_frame_io.sync = true + __omp_cap_r, __omp_cap_w = IO.pipe + STDOUT.reopen(__omp_cap_w) + STDOUT.sync = true + __omp_cap_w.close + $__omp_capture_read = __omp_cap_r +rescue StandardError + $__omp_frame_io = STDOUT + ($__omp_frame_io.sync = true) rescue nil + $__omp_capture_read = nil +end + +# Protect the protocol channel from user code: read requests on a private dup of +# the original stdin, then repoint fd 0 at /dev/null so a user `gets`/`STDIN.gets` +# inside a cell sees EOF instead of consuming the next JSON request. +begin + $__omp_proto_stdin = STDIN.dup + STDIN.reopen(File.open(File::NULL, "r")) +rescue StandardError + $__omp_proto_stdin = STDIN +end + +# --------------------------------------------------------------------------- +# Frame writer + helpers (top-level private methods, available to user code) +# --------------------------------------------------------------------------- + +def __omp_scrub(str) + s = str.to_s + begin + s = s.encoding == Encoding::UTF_8 ? s : s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) + rescue StandardError + s = s.dup.force_encoding(Encoding::UTF_8) + end + s.valid_encoding? ? s : s.scrub("\uFFFD") +end + +def __omp_emit(frame) + line = + begin + JSON.generate(frame) + rescue StandardError + JSON.generate( + "type" => (frame["type"] || "stdout"), + "id" => frame["id"], + "data" => "\n", + ) + end + $__omp_out_mutex.synchronize do + $__omp_frame_io.write(line) + $__omp_frame_io.write("\n") + $__omp_frame_io.flush + end +rescue StandardError + nil +end + +def __omp_run_id + $__omp_current_rid +end + +def __omp_emit_stream(kind, text) + rid = $__omp_current_rid + if rid.nil? + ($__omp_raw_stderr.write(text) rescue nil) + return + end + __omp_emit("type" => kind, "id" => rid, "data" => __omp_scrub(text)) +end + +def __omp_emit_display(bundle, kind = "display") + rid = $__omp_current_rid + return if rid.nil? + __omp_emit("type" => kind, "id" => rid, "bundle" => bundle) +end + +def __omp_emit_status(op, data = {}) + status = { "op" => op.to_s } + data.each { |k, v| status[k.to_s] = v } + __omp_emit_display({ "application/x-omp-status" => status }, "display") +end + +# Build a Jupyter-style MIME bundle for a value. Strings render as plain text, +# Hash/Array render as JSON (plus a text/plain repr) so the model sees structure, +# and anything else falls back to its inspect string. Objects may opt into a +# richer bundle by defining `to_omp_mime` returning a Hash of mime => value. +def __omp_mime_bundle(value) + case value + when String + { "text/plain" => __omp_scrub(value) } + when Hash, Array + safe = begin + JSON.parse(JSON.generate(value)) + rescue StandardError + nil + end + if safe.nil? + { "text/plain" => __omp_scrub(value.inspect) } + else + { "application/json" => safe, "text/plain" => __omp_scrub(value.inspect) } + end + when nil + { "text/plain" => "nil" } + else + if value.respond_to?(:to_omp_mime) + mime = (value.to_omp_mime rescue nil) + mime.is_a?(Hash) ? mime : { "text/plain" => __omp_scrub(value.inspect) } + else + { "text/plain" => __omp_scrub(value.inspect) } + end + end +end + +def __omp_present(value, kind = "display") + __omp_emit_display(__omp_mime_bundle(value), kind) +end + +# --------------------------------------------------------------------------- +# User stdout/stderr proxies — emit typed frames for the current request. +# --------------------------------------------------------------------------- + +class OmpStreamProxy + def initialize(kind) + @kind = kind + end + + def write(*args) + total = 0 + args.each do |arg| + s = arg.to_s + next if s.empty? + total += s.bytesize + __omp_emit_stream(@kind, s) + end + total + end + + def print(*args) + args.each { |a| write(a) } + nil + end + + def <<(obj) + write(obj) + self + end + + def puts(*args) + if args.empty? + write("\n") + else + args.each do |arg| + if arg.is_a?(Array) + arg.empty? ? write("\n") : puts(*arg) + else + s = arg.to_s + write(s.end_with?("\n") ? s : "#{s}\n") + end + end + end + nil + end + + def printf(fmt, *args) + write(format(fmt, *args)) + nil + end + + def write_nonblock(s, *) + write(s) + end + + def flush; self; end + def sync; true; end + def sync=(value); value; end + def tty?; false; end + def isatty; false; end + def fileno; 1; end + def to_io; STDOUT; end + def closed?; false; end + def fsync; 0; end + def external_encoding; Encoding::UTF_8; end +end + +# --------------------------------------------------------------------------- +# fd-1 capture drain (child-process stdout) + parent watchdog +# --------------------------------------------------------------------------- + +def __omp_start_capture_drain + io = $__omp_capture_read + return if io.nil? + Thread.new do + loop do + chunk = + begin + io.readpartial(65_536) + rescue EOFError, IOError, Errno::EBADF + break + rescue StandardError + break + end + next if chunk.nil? || chunk.empty? + rid = $__omp_capture_rid + if rid.nil? + ($__omp_raw_stderr.write(chunk) rescue nil) + else + __omp_emit("type" => "stdout", "id" => rid, "data" => __omp_scrub(chunk)) + end + end + end +end + +def __omp_start_parent_watchdog + return unless RUBY_PLATFORM !~ /mswin|mingw|cygwin/ + return unless Process.respond_to?(:ppid) + original = (Process.ppid rescue 0) + return if original <= 1 + Thread.new do + loop do + begin + Process.exit!(0) if Process.ppid != original + rescue StandardError + break + end + sleep 10 + end + end +end + +# --------------------------------------------------------------------------- +# Signal handling — SIGINT raises Interrupt only while a cell is executing. +# --------------------------------------------------------------------------- + +def __omp_install_idle_sigint + Signal.trap("INT", "IGNORE") rescue nil +end + +def __omp_install_exec_sigint + Signal.trap("INT", "DEFAULT") rescue nil +end + +def __omp_begin_exec + $__omp_active_exec += 1 + __omp_install_exec_sigint +end + +def __omp_end_exec + $__omp_active_exec -= 1 if $__omp_active_exec > 0 + __omp_install_idle_sigint if $__omp_active_exec.zero? +end + +# --------------------------------------------------------------------------- +# Per-request runtime (cwd + managed env) + auto-result suppression +# --------------------------------------------------------------------------- + +OMP_MANAGED_ENV_KEYS = %w[ + PI_SESSION_FILE + PI_ARTIFACTS_DIR + PI_TOOL_BRIDGE_URL + PI_TOOL_BRIDGE_TOKEN + PI_TOOL_BRIDGE_SESSION + PI_EVAL_LOCAL_ROOTS +].freeze + +def __omp_apply_request_runtime(req) + cwd = req["cwd"] + if cwd.is_a?(String) && !cwd.empty? + (Dir.chdir(cwd) rescue nil) + $LOAD_PATH.delete(cwd) + $LOAD_PATH.unshift(cwd) + end + env = req["env"] + if env.is_a?(Hash) + OMP_MANAGED_ENV_KEYS.each do |key| + next unless env.key?(key) + value = env[key] + if value.is_a?(String) + ENV[key] = value + elsif value.nil? + ENV.delete(key) + end + end + end +end + +# Last value-bearing AST node types we should NOT auto-display (statements / +# definitions, mirroring IPython's "only display a trailing expression"). Falls +# back to displaying any non-nil value when the AST is unavailable. +OMP_NON_DISPLAY_NODES = %i[ + LASGN IASGN GASGN CVASGN DASGN OP_ASGN OP_CDECL CDECL MASGN CASGN + DEFN DEFS CLASS MODULE SCLASS ALIAS UNDEF +].freeze + +def __omp_ast_last(node) + return nil unless node.is_a?(RubyVM::AbstractSyntaxTree::Node) + case node.type + when :SCOPE + __omp_ast_last(node.children[2]) + when :BLOCK + kids = node.children.compact + kids.empty? ? nil : __omp_ast_last(kids.last) + else + node + end +end + +def __omp_should_display_result?(src) + return true unless defined?(RubyVM::AbstractSyntaxTree) + node = + begin + RubyVM::AbstractSyntaxTree.parse(src) + rescue StandardError, SyntaxError + return true + end + last = __omp_ast_last(node) + return true if last.nil? + !OMP_NON_DISPLAY_NODES.include?(last.type) +end + +# --------------------------------------------------------------------------- +# Request dispatch +# --------------------------------------------------------------------------- + +def __omp_emit_error(rid, exc, name_override = nil) + ename = name_override || exc.class.name + evalue = (exc.message.to_s rescue "") + backtrace = (exc.backtrace || []) + user_tb = backtrace.select { |l| l.include?("(eval)") } + user_tb = backtrace.first(20) if user_tb.empty? + traceback = ["#{ename}: #{evalue}"] + user_tb.each { |line| traceback << " #{line}" } + __omp_emit( + "type" => "error", + "id" => rid, + "ename" => ename, + "evalue" => __omp_scrub(evalue), + "traceback" => traceback.map { |l| __omp_scrub(l) }, + ) +end + +def __omp_handle_request(req) + rid = req["id"].to_s + $__omp_current_rid = rid + $__omp_capture_rid = rid + $__omp_silent = req["silent"] == true + $__omp_exec_count += 1 + count = $__omp_exec_count + __omp_emit("type" => "started", "id" => rid) + + status = "ok" + cancelled = false + begin + begin + __omp_apply_request_runtime(req) + src = req["code"].to_s + rescue Exception => e # rubocop:disable Lint/RescueException + __omp_emit_error(rid, e) + __omp_emit("type" => "done", "id" => rid, "status" => "error", "executionCount" => count, "cancelled" => false) + return + end + + __omp_begin_exec + begin + value = TOPLEVEL_BINDING.eval(src, "(eval)") + unless $__omp_silent || value.nil? || !__omp_should_display_result?(src) + __omp_present(value, "result") + end + rescue Interrupt => e + cancelled = true + status = "error" + __omp_emit_error(rid, e, "Interrupt") + rescue SystemExit => e + status = "error" + __omp_emit_error(rid, e) + rescue Exception => e # rubocop:disable Lint/RescueException + status = "error" + __omp_emit_error(rid, e) + ensure + __omp_end_exec + end + + __omp_emit("type" => "done", "id" => rid, "status" => status, "executionCount" => count, "cancelled" => cancelled) + ensure + $__omp_capture_rid = nil if $__omp_capture_rid == rid + $__omp_current_rid = nil + end +end + +# --------------------------------------------------------------------------- +# Main loop +# --------------------------------------------------------------------------- + +def __omp_main + $stdout = OmpStreamProxy.new("stdout") + $stderr = OmpStreamProxy.new("stderr") + __omp_install_idle_sigint + __omp_start_parent_watchdog + __omp_start_capture_drain + + $__omp_proto_stdin.each_line do |raw| + line = raw.strip + next if line.empty? + req = + begin + JSON.parse(line) + rescue JSON::ParserError => e + __omp_emit( + "type" => "error", + "id" => "", + "ename" => "ProtocolError", + "evalue" => "Invalid JSON request: #{e.message}", + "traceback" => [], + ) + next + end + break if req.is_a?(Hash) && req["type"] == "exit" + __omp_handle_request(req) if req.is_a?(Hash) + end +end + +__omp_main diff --git a/packages/coding-agent/src/eval/rb/runtime.ts b/packages/coding-agent/src/eval/rb/runtime.ts new file mode 100644 index 000000000..c589e1f7a --- /dev/null +++ b/packages/coding-agent/src/eval/rb/runtime.ts @@ -0,0 +1,181 @@ +/** + * Ruby runtime resolution utilities. + * + * Resolves the Ruby interpreter for the local kernel and filters the + * environment to a safe allowlist before exposing it to user cell code. Much + * simpler than the Python sibling — Ruby has no venv layout to detect — but it + * mirrors the same allowlist/denylist + explicit-interpreter shape. + */ +import * as os from "node:os"; +import * as path from "node:path"; +import { $which } from "@oh-my-pi/pi-utils"; + +const DEFAULT_ENV_ALLOWLIST = [ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "LANG", + "LC_ALL", + "LC_CTYPE", + "LC_MESSAGES", + "TERM", + "TERM_PROGRAM", + "TERM_PROGRAM_VERSION", + "TMPDIR", + "TEMP", + "TMP", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "SSH_AUTH_SOCK", + "SSH_AGENT_PID", + "LD_LIBRARY_PATH", + "DYLD_LIBRARY_PATH", +]; + +const WINDOWS_ENV_ALLOWLIST = [ + "APPDATA", + "COMPUTERNAME", + "COMSPEC", + "HOMEDRIVE", + "HOMEPATH", + "LOCALAPPDATA", + "NUMBER_OF_PROCESSORS", + "OS", + "PATH", + "PATHEXT", + "PROCESSOR_ARCHITECTURE", + "PROCESSOR_IDENTIFIER", + "PROGRAMDATA", + "PROGRAMFILES", + "PROGRAMFILES(X86)", + "PROGRAMW6432", + "SESSIONNAME", + "SYSTEMDRIVE", + "SYSTEMROOT", + "TEMP", + "TMP", + "USERDOMAIN", + "USERPROFILE", + "USERNAME", + "WINDIR", +]; + +const DEFAULT_ENV_DENYLIST = [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GOOGLE_API_KEY", + "GEMINI_API_KEY", + "OPENROUTER_API_KEY", + "PERPLEXITY_API_KEY", + "PERPLEXITY_COOKIES", + "EXA_API_KEY", + "AZURE_OPENAI_API_KEY", + "MISTRAL_API_KEY", +]; + +// Ruby version managers and gem layout live behind these prefixes; passing them +// through lets `bundle`/`gem`/rbenv/asdf-shimmed code resolve consistently. +const DEFAULT_ENV_ALLOW_PREFIXES = ["LC_", "XDG_", "PI_", "GEM_", "BUNDLE", "RBENV_", "RUBY", "CHRUBY_", "ASDF_"]; + +const CASE_INSENSITIVE_ENV = process.platform === "win32"; + +// Dynamic-membership lookups: built once by normalizing the static lists, then +// queried with `.has()` against runtime env keys. +const NORMALIZED_ALLOWLIST = new Set( + [...DEFAULT_ENV_ALLOWLIST, ...WINDOWS_ENV_ALLOWLIST].map(key => (CASE_INSENSITIVE_ENV ? key.toUpperCase() : key)), +); +const NORMALIZED_DENYLIST = new Set(DEFAULT_ENV_DENYLIST.map(key => (CASE_INSENSITIVE_ENV ? key.toUpperCase() : key))); +const NORMALIZED_ALLOW_PREFIXES = CASE_INSENSITIVE_ENV + ? DEFAULT_ENV_ALLOW_PREFIXES.map(prefix => prefix.toUpperCase()) + : DEFAULT_ENV_ALLOW_PREFIXES; + +// Secret-shaped names that must never leak into eval cells even when they fall +// under a broad allow-prefix (e.g. `RUBYGEMS_API_KEY` under `RUBY`). Checked +// after the explicit allowlist so intentional entries (SSH_AUTH_SOCK) survive. +const SECRET_KEY_PATTERN = /API[_-]?KEY|APIKEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|ACCESS[_-]?KEY|PRIVATE[_-]?KEY/i; + +export interface RubyRuntime { + /** Path to the ruby executable. */ + rubyPath: string; + /** Filtered environment variables. */ + env: Record; +} + +/** + * Filter environment variables to a safe allowlist for Ruby subprocesses. + * Removes sensitive API keys and limits to known-safe variables. + */ +export function filterEnv(env: Record): Record { + const filtered: Record = {}; + for (const key in env) { + const value = env[key]; + if (value === undefined) continue; + const normalizedKey = CASE_INSENSITIVE_ENV ? key.toUpperCase() : key; + if (NORMALIZED_DENYLIST.has(normalizedKey)) continue; + if (NORMALIZED_ALLOWLIST.has(normalizedKey)) { + filtered[normalizedKey === "PATH" ? "PATH" : key] = value; + continue; + } + if (SECRET_KEY_PATTERN.test(normalizedKey)) continue; + if (NORMALIZED_ALLOW_PREFIXES.some(prefix => normalizedKey.startsWith(prefix))) { + filtered[key] = value; + } + } + return filtered; +} + +/** + * Resolve an explicitly configured interpreter (`ruby.interpreter`) into a + * runtime, bypassing discovery. Does not probe the executable — callers must + * check it actually runs. `~` expands to the home directory and relative paths + * resolve against `cwd`. + */ +export function resolveExplicitRubyRuntime( + interpreter: string, + cwd: string, + baseEnv: Record, +): RubyRuntime { + const expanded = + interpreter === "~" + ? os.homedir() + : interpreter.startsWith("~/") + ? path.join(os.homedir(), interpreter.slice(2)) + : interpreter; + const rubyPath = path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded); + return { rubyPath, env: { ...baseEnv } }; +} + +/** + * Enumerate candidate Ruby runtimes in priority order. With an explicit + * interpreter that is the only candidate; otherwise the first `ruby` on PATH. + */ +export function enumerateRubyRuntimes( + cwd: string, + baseEnv: Record, + interpreter?: string, +): RubyRuntime[] { + if (interpreter) { + return [resolveExplicitRubyRuntime(interpreter, cwd, baseEnv)]; + } + const systemPath = $which("ruby"); + return systemPath ? [{ rubyPath: systemPath, env: { ...baseEnv } }] : []; +} + +/** + * Resolve the highest-priority Ruby runtime. Throws when none exists. + */ +export function resolveRubyRuntime( + cwd: string, + baseEnv: Record, + interpreter?: string, +): RubyRuntime { + const [runtime] = enumerateRubyRuntimes(cwd, baseEnv, interpreter); + if (!runtime) { + throw new Error("Ruby executable not found on PATH"); + } + return runtime; +} diff --git a/packages/coding-agent/src/eval/types.ts b/packages/coding-agent/src/eval/types.ts index 53de14456..df22ccf0b 100644 --- a/packages/coding-agent/src/eval/types.ts +++ b/packages/coding-agent/src/eval/types.ts @@ -1,5 +1,5 @@ /** Runtime backend that an eval cell dispatches to. */ -export type EvalLanguage = "python" | "js"; +export type EvalLanguage = "python" | "js" | "ruby" | "julia"; import type { ImageContent } from "@oh-my-pi/pi-ai"; import type { OutputMeta } from "../tools/output-meta"; diff --git a/packages/coding-agent/src/modes/utils/copy-targets.ts b/packages/coding-agent/src/modes/utils/copy-targets.ts index 6ded2aba8..b93c9129f 100644 --- a/packages/coding-agent/src/modes/utils/copy-targets.ts +++ b/packages/coding-agent/src/modes/utils/copy-targets.ts @@ -139,7 +139,8 @@ function extractEvalCode(args: unknown): { code: string; language: string } | un if (typeof code !== "string" || code.length === 0) continue; codeBlocks.push(code); if (!languageResolved) { - language = (cell as { language?: unknown }).language === "js" ? "javascript" : "python"; + const lang = (cell as { language?: unknown }).language; + language = lang === "js" ? "javascript" : lang === "rb" ? "ruby" : lang === "jl" ? "julia" : "python"; languageResolved = true; } } diff --git a/packages/coding-agent/src/prompts/tools/eval.md b/packages/coding-agent/src/prompts/tools/eval.md index 5095869ba..2ff30cdb6 100644 --- a/packages/coding-agent/src/prompts/tools/eval.md +++ b/packages/coding-agent/src/prompts/tools/eval.md @@ -5,7 +5,7 @@ Cells run in array order. State persists per language across cells, tool calls, Cell fields: -- `language` — {{#if py}}`"py"` IPython kernel{{/if}}{{#ifAll py js}}, {{/ifAll}}{{#if js}}`"js"` persistent JavaScript VM{{/if}}. +- `language` — {{#if py}}`"py"` IPython kernel{{/if}}{{#ifAll py js}}, {{/ifAll}}{{#if js}}`"js"` persistent JavaScript VM{{/if}}{{#if rb}}{{#ifAny py js}}, {{/ifAny}}`"rb"` persistent Ruby kernel{{/if}}{{#if jl}}{{#ifAny py js rb}}, {{/ifAny}}`"jl"` persistent Julia kernel{{/if}}. - `code` — cell body, verbatim. Newlines/quotes JSON-encoded; no fences, no headers. - `title` (optional) — short transcript label (e.g. `"imports"`). - `timeout` (optional) — per-cell seconds. Raise only for heavy compute or long non-agent tool calls. @@ -13,11 +13,13 @@ Cell fields: Work incrementally — one logical step per cell (imports, define, test, use), many small cells per call; workflow notes in the assistant message or `title`, never in cell code. {{#if py}}Live event loop: use top-level `await` directly; `asyncio.run(…)` raises "cannot be called from a running event loop".{{/if}} +{{#if rb}}Ruby: synchronous; helper options are keyword args (e.g. `tree(".", max_depth: 2)`); the last expression auto-displays unless it is `nil`, an assignment, or a definition (like IRB).{{/if}} +{{#if jl}}Julia: synchronous; helper options are standard keyword args (e.g. `tree(max_depth=2)`); the last expression auto-displays unless it is an assignment or a definition (like the Julia REPL).{{/if}} Errors name the failing cell ("Cell 3 failed") — resubmit the fixed cell + any remaining. -{{#ifAll py js}}Same helpers + arg order, both runtimes. Python: sync, options = trailing kwargs. JS: async/`await`able, options = ONE trailing object literal, never positional (extras throw).{{else}}{{#if py}}Sync; options = trailing kwargs.{{/if}}{{#if js}}Async/`await`able; options = ONE trailing object literal, never positional (extras throw).{{/if}}{{/ifAll}} +{{#ifAll py js}}Same helpers + arg order, both runtimes. Python: sync, options = trailing kwargs. JS: async/`await`able, options = ONE trailing object literal, never positional (extras throw).{{else}}{{#if py}}Sync; options = trailing kwargs.{{/if}}{{#if js}}Async/`await`able; options = ONE trailing object literal, never positional (extras throw).{{/if}}{{/ifAll}}{{#if rb}} Ruby: sync, options = trailing keyword args.{{/if}}{{#if jl}} Julia: sync, options = trailing keyword args.{{/if}} ``` display(value) → None Cell output; figures/images/dataframes shown natively. @@ -55,13 +57,13 @@ log(message) → None phase(title) → None Phase grouping subsequent status lines. budget → per-turn token budget - {{#if py}}`budget.total` (ceiling or None), `budget.spent()`, `budget.remaining()` (math.inf when no ceiling), `budget.hard`.{{/if}}{{#if js}}`await budget.total()` (ceiling or null), `await budget.spent()`, `await budget.remaining()` (Infinity when no ceiling), `await budget.hard()`.{{/if}} Ceiling: `+Nk` (advisory) or `+Nk!`/Goal Mode (hard — `agent()` won't spawn past it); spend still tracked. + {{#if py}}`budget.total` (ceiling or None), `budget.spent()`, `budget.remaining()` (math.inf when no ceiling), `budget.hard`.{{/if}}{{#if js}}`await budget.total()` (ceiling or null), `await budget.spent()`, `await budget.remaining()` (Infinity when no ceiling), `await budget.hard()`.{{/if}}{{#if rb}} Ruby: `budget.total` (ceiling or nil), `budget.spent`, `budget.remaining` (Float::INFINITY when no ceiling), `budget.hard`.{{/if}}{{#if jl}} Julia: `budget.total` (ceiling or nothing), `budget.spent()`, `budget.remaining()` (Inf when no ceiling), `budget.hard`.{{/if}} Ceiling: `+Nk` (advisory) or `+Nk!`/Goal Mode (hard — `agent()` won't spawn past it); spend still tracked. ``` {{#if spawns}} Pipe handles through stage helpers to build a dependency graph — acyclic waves: -- **Name nodes.** Capture each `agent(…, {{#if py}}return_handle=True{{/if}}{{#if js}}{ returnHandle: true }{{/if}})` result; carries `handle` (`agent://`) + `output`. +- **Name nodes.** Capture each `agent(…, {{#if py}}return_handle=True{{/if}}{{#if js}}{ returnHandle: true }{{/if}}{{#if jl}}return_handle=true{{/if}})` result; carries `handle` (`agent://`) + `output`. - **Wire edges by reference.** Put an upstream node's `handle`/`output` in the dependent stage's prompt — large transcript never re-inlined. Bulk: `write("local://.md", …)`, pass the URI. - **`pipeline(items, *stages)` = staged waves**, barrier between stages (every item clears stage N before any enters N+1). **`parallel(thunks)` = one wave** of independent nodes. - **Isolate failure.** A raising node re-raises the lowest-index error, aborts its wave; wrap risky nodes in try/except so a failure degrades only its dependent subtree, independent branches finish. diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index a89b020eb..acc71956f 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -46,7 +46,9 @@ import { Settings, type SkillsSettings } from "./config/settings"; import { CursorExecHandlers } from "./cursor"; import "./discovery"; import { initializeWithSettings } from "./discovery"; +import { disposeAllJuliaKernelSessions, disposeJuliaKernelSessionsByOwner } from "./eval/jl/executor"; import { disposeAllKernelSessions, disposeKernelSessionsByOwner } from "./eval/py/executor"; +import { disposeAllRubyKernelSessions, disposeRubyKernelSessionsByOwner } from "./eval/rb/executor"; import { defaultEvalSessionId } from "./eval/session-id"; import { type CustomCommandsLoadResult, @@ -872,6 +874,8 @@ function registerPythonCleanup(): void { if (pythonCleanupRegistered) return; pythonCleanupRegistered = true; postmortem.register("python-cleanup", disposeAllKernelSessions); + postmortem.register("ruby-cleanup", disposeAllRubyKernelSessions); + postmortem.register("julia-cleanup", disposeAllJuliaKernelSessions); } function customToolToDefinition(tool: CustomTool): ToolDefinition { @@ -2913,6 +2917,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} await asyncJobManager.dispose({ timeoutMs: 3_000 }); } await disposeKernelSessionsByOwner(evalKernelOwnerId); + await disposeRubyKernelSessionsByOwner(evalKernelOwnerId); + await disposeJuliaKernelSessionsByOwner(evalKernelOwnerId); if (ownsAuthStorage) authStorage.close(); } } catch (cleanupError) { diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 85c1c7e30..a2061f4b8 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -164,12 +164,14 @@ import { RawSseDebugBuffer } from "../debug/raw-sse-buffer"; import { loadCapability } from "../discovery"; import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit"; import { getFileSnapshotStore } from "../edit/file-snapshot-store"; +import { disposeJuliaKernelSessionsByOwner } from "../eval/jl/executor"; import { namespaceSessionId as namespacePythonSessionId } from "../eval/py"; import { disposeKernelSessionsByOwner, executePython as executePythonCommand, type PythonResult, } from "../eval/py/executor"; +import { disposeRubyKernelSessionsByOwner } from "../eval/rb/executor"; import { defaultEvalSessionId } from "../eval/session-id"; import { type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor"; import type { TtsrManager, TtsrMatchContext } from "../export/ttsr"; @@ -4242,6 +4244,8 @@ export class AgentSession { ); } await disposeKernelSessionsByOwner(this.#evalKernelOwnerId); + await disposeRubyKernelSessionsByOwner(this.#evalKernelOwnerId); + await disposeJuliaKernelSessionsByOwner(this.#evalKernelOwnerId); await shutdownTinyTitleClient(); this.#releasePowerAssertion(); await this.sessionManager.close(); diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 8dc2eb1ae..cd10f7433 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -1792,8 +1792,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise name !== "exec"); - if (allowEvalPy || allowEvalJs) expanded.push("eval"); + if (allowEvalPy || allowEvalJs || allowEvalRb || allowEvalJl) expanded.push("eval"); expanded.push("bash"); toolNames = Array.from(new Set(expanded)); } diff --git a/packages/coding-agent/src/tools/eval-backends.ts b/packages/coding-agent/src/tools/eval-backends.ts index 7839387d9..81f808a2f 100644 --- a/packages/coding-agent/src/tools/eval-backends.ts +++ b/packages/coding-agent/src/tools/eval-backends.ts @@ -4,6 +4,8 @@ import type { ToolSession } from "."; export interface EvalBackendsAllowance { python: boolean; js: boolean; + ruby: boolean; + julia: boolean; } /** Read per-backend allowance from settings (defaults true). */ @@ -11,11 +13,13 @@ export function readEvalBackendsAllowance(session: ToolSession): EvalBackendsAll return { python: session.settings.get("eval.py") ?? true, js: session.settings.get("eval.js") ?? true, + ruby: session.settings.get("eval.rb") ?? true, + julia: session.settings.get("eval.jl") ?? true, }; } /** - * Materialize the active eval backend allowance: PI_PY / PI_JS env flags + * Materialize the active eval backend allowance: PI_PY / PI_JS / PI_RB env flags * override the per-key settings; otherwise settings (defaults true) win. */ export function resolveEvalBackends(session: ToolSession): EvalBackendsAllowance { @@ -23,5 +27,7 @@ export function resolveEvalBackends(session: ToolSession): EvalBackendsAllowance return { python: $flag("PI_PY", settings.python), js: $flag("PI_JS", settings.js), + ruby: $flag("PI_RB", settings.ruby), + julia: $flag("PI_JL", settings.julia), }; } diff --git a/packages/coding-agent/src/tools/eval-render.ts b/packages/coding-agent/src/tools/eval-render.ts index a82a79529..98221e503 100644 --- a/packages/coding-agent/src/tools/eval-render.ts +++ b/packages/coding-agent/src/tools/eval-render.ts @@ -41,8 +41,11 @@ import { } from "./render-utils"; export const EVAL_DEFAULT_PREVIEW_LINES = 10; -function languageForHighlighter(language: EvalLanguage | undefined): "python" | "javascript" { - return language === "js" ? "javascript" : "python"; +function languageForHighlighter(language: EvalLanguage | undefined): "python" | "javascript" | "ruby" | "julia" { + if (language === "js") return "javascript"; + if (language === "ruby") return "ruby"; + if (language === "julia") return "julia"; + return "python"; } interface EvalRenderCellArg { @@ -70,7 +73,10 @@ interface EvalRenderCell { } function normalizeRenderLanguage(value: string | undefined): EvalLanguage { - return value === "js" ? "js" : "python"; + if (value === "js") return "js"; + if (value === "rb" || value === "ruby") return "ruby"; + if (value === "jl" || value === "julia") return "julia"; + return "python"; } function getRenderCells(args: EvalRenderArgs | undefined): EvalRenderCell[] { diff --git a/packages/coding-agent/src/tools/eval.ts b/packages/coding-agent/src/tools/eval.ts index 631faf1a2..75acb19af 100644 --- a/packages/coding-agent/src/tools/eval.ts +++ b/packages/coding-agent/src/tools/eval.ts @@ -2,7 +2,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb import type { ImageContent, ToolExample } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; import { type } from "arktype"; -import { jsBackend, pythonBackend } from "../eval"; +import { jsBackend, juliaBackend, pythonBackend, rubyBackend } from "../eval"; import type { ExecutorBackend, ExecutorBackendResult } from "../eval/backend"; import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP } from "../eval/bridge-timeout"; import { IdleTimeout } from "../eval/idle-timeout"; @@ -28,8 +28,10 @@ export { EVAL_DEFAULT_PREVIEW_LINES, evalToolRenderer } from "./eval-render"; * across cells and across tool calls. */ const evalCellSchema = type({ - language: type("'py' | 'js'").describe('runtime: "py" for the IPython kernel, "js" for the persistent JS VM'), - code: type("string").describe("cell body, verbatim. Use top-level await freely."), + language: type("'py' | 'js' | 'rb' | 'jl'").describe( + 'runtime: "py" for the IPython kernel, "js" for the persistent JS VM, "rb" for the persistent Ruby kernel, "jl" for the persistent Julia kernel', + ), + code: type("string").describe("cell body, verbatim. Top-level `await` is available in py/js."), "title?": type("string").describe('short label shown in transcript (e.g. "imports", "load config")'), "timeout?": type("number").describe("per-cell timeout in seconds"), "reset?": type("boolean").describe( @@ -88,6 +90,8 @@ function formatDisplayOutputsForText(outputs: EvalDisplayOutput[]): string { export interface EvalToolDescriptionOptions { py?: boolean; js?: boolean; + rb?: boolean; + jl?: boolean; /** * Whether `agent()` is allowed in this session. Driven by the parent's * spawn policy (`getSessionSpawns`). Defaults to `true` for backward @@ -101,8 +105,10 @@ export interface EvalToolDescriptionOptions { export function getEvalToolDescription(options: EvalToolDescriptionOptions = {}): string { const py = options.py ?? true; const js = options.js ?? true; + const rb = options.rb ?? true; + const jl = options.jl ?? true; const spawns = options.spawns ?? true; - return prompt.render(evalDescription, { py, js, spawns }); + return prompt.render(evalDescription, { py, js, rb, jl, spawns }); } export interface EvalToolOptions { @@ -142,6 +148,7 @@ async function resolveBackend(session: ToolSession, language: EvalLanguage): Pro const backends = resolveEvalBackends(session); const allowPy = backends.python; const allowJs = backends.js; + const allowRb = backends.ruby; if (language === "python") { if (!allowPy) throw new ToolError("Python backend is disabled (PI_PY=0 or eval.py = false)."); @@ -152,6 +159,20 @@ async function resolveBackend(session: ToolSession, language: EvalLanguage): Pro } return { backend: pythonBackend }; } + if (language === "ruby") { + if (!allowRb) throw new ToolError("Ruby backend is disabled (PI_RB=0 or eval.rb = false)."); + if (!(await rubyBackend.isAvailable(session))) { + throw new ToolError('Ruby backend is unavailable in this session. Pass language: "js" or install Ruby.'); + } + return { backend: rubyBackend }; + } + if (language === "julia") { + if (!backends.julia) throw new ToolError("Julia backend is disabled (PI_JL=0 or eval.jl = false)."); + if (!(await juliaBackend.isAvailable(session))) { + throw new ToolError('Julia backend is unavailable in this session. Pass language: "js" or install Julia.'); + } + return { backend: juliaBackend }; + } if (!allowJs) throw new ToolError("JavaScript backend is disabled (PI_JS=0 or eval.js = false)."); return { backend: jsBackend }; } @@ -172,7 +193,7 @@ export class EvalTool implements AgentTool { } return lines; }; - readonly summary = "Execute Python or JavaScript code in an in-process eval backend"; + readonly summary = "Execute Python, JavaScript, Ruby, or Julia code in a persistent eval backend"; readonly loadMode = "discoverable"; readonly label = "Eval"; get description(): string { @@ -180,7 +201,13 @@ export class EvalTool implements AgentTool { const backends = resolveEvalBackends(this.session); const sessionSpawns = this.session.getSessionSpawns?.() ?? "*"; const spawnsAllowed = sessionSpawns !== "" && sessionSpawns !== null; - return getEvalToolDescription({ py: backends.python, js: backends.js, spawns: spawnsAllowed }); + return getEvalToolDescription({ + py: backends.python, + js: backends.js, + rb: backends.ruby, + jl: backends.julia, + spawns: spawnsAllowed, + }); } readonly examples: readonly ToolExample[] = [ { @@ -243,7 +270,14 @@ export class EvalTool implements AgentTool { const cells: ResolvedEvalCell[] = []; for (let i = 0; i < params.cells.length; i++) { const cell = params.cells[i]; - const language: EvalLanguage = cell.language === "py" ? "python" : "js"; + const language: EvalLanguage = + cell.language === "py" + ? "python" + : cell.language === "rb" + ? "ruby" + : cell.language === "jl" + ? "julia" + : "js"; const resolved = await resolveBackend(session, language); cells.push({ index: i, diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 35c7f83c0..9f83f9ae5 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -7,7 +7,9 @@ import type { Rule } from "../capability/rule"; import type { PromptTemplate } from "../config/prompt-templates"; import type { Settings } from "../config/settings"; import { EditTool } from "../edit"; +import { checkJuliaKernelAvailability } from "../eval/jl/kernel"; import { checkPythonKernelAvailability } from "../eval/py/kernel"; +import { checkRubyKernelAvailability } from "../eval/rb/kernel"; import type { ToolPathWithSource } from "../extensibility/custom-tools"; import type { Skill } from "../extensibility/skills"; import type { GoalModeState, GoalRuntime } from "../goals"; @@ -486,36 +488,57 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P const backends = resolveEvalBackends(session); const allowPython = backends.python; const allowJs = backends.js; - const skipPythonPreflight = session.skipPythonPreflight === true; - // Eval tool is enabled if EITHER backend is reachable. We only need to know - // whether python is reachable when JS is disabled — otherwise allowEval is - // already true and the python-availability check can be deferred to first - // invocation of the python backend (already handled inside the executor). + const allowRuby = backends.ruby; + const allowJulia = backends.julia; + const skipEvalPreflight = session.skipPythonPreflight === true; + // Eval tool is enabled if ANY backend is reachable. JS needs no preflight, so + // we only probe Python/Ruby/Julia when JS is disabled — otherwise allowEval is + // already true and per-backend availability is checked at first invocation. let pythonAvailable = true; - if ( - !skipPythonPreflight && - allowPython && - !allowJs && - (requestedTools === undefined || requestedTools.includes("eval")) - ) { - const availability = await logger.time( - "createTools:pythonCheck", - checkPythonKernelAvailability, - session.cwd, - session.settings.get("python.interpreter")?.trim() || undefined, - ); - pythonAvailable = availability.ok; - if (!availability.ok) { - logger.warn("Python kernel unavailable and JS backend disabled; eval will be unavailable", { - reason: availability.reason, - }); + let rubyAvailable = true; + let juliaAvailable = true; + const evalRequested = requestedTools === undefined || requestedTools.includes("eval"); + if (!skipEvalPreflight && !allowJs && evalRequested) { + if (allowPython) { + const availability = await logger.time( + "createTools:pythonCheck", + checkPythonKernelAvailability, + session.cwd, + session.settings.get("python.interpreter")?.trim() || undefined, + ); + pythonAvailable = availability.ok; + if (!availability.ok) { + logger.warn("Python kernel unavailable and JS backend disabled", { reason: availability.reason }); + } + } + if (allowRuby) { + const availability = await checkRubyKernelAvailability( + session.cwd, + session.settings.get("ruby.interpreter")?.trim() || undefined, + ); + rubyAvailable = availability.ok; + if (!availability.ok) { + logger.warn("Ruby kernel unavailable and JS backend disabled", { reason: availability.reason }); + } + } + if (allowJulia) { + const availability = await checkJuliaKernelAvailability( + session.cwd, + session.settings.get("julia.interpreter")?.trim() || undefined, + ); + juliaAvailable = availability.ok; + if (!availability.ok) { + logger.warn("Julia kernel unavailable and JS backend disabled", { reason: availability.reason }); + } } } 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; + const effectiveRubyAllowed = allowRuby && rubyAvailable; + const effectiveJuliaAllowed = allowJulia && juliaAvailable; + // Eval is exposed whenever any backend is reachable. A backend may be + // unreachable, in which case eval dispatches exclusively to the others. + const allowEval = effectivePythonAllowed || allowJs || effectiveRubyAllowed || effectiveJuliaAllowed; // Auto-include AST counterparts when their text-based sibling is present if (requestedTools) { diff --git a/packages/coding-agent/test/core/ruby-runner.integration.test.ts b/packages/coding-agent/test/core/ruby-runner.integration.test.ts new file mode 100644 index 000000000..ea6b29741 --- /dev/null +++ b/packages/coding-agent/test/core/ruby-runner.integration.test.ts @@ -0,0 +1,113 @@ +/** + * End-to-end exercise of the subprocess-backed Ruby runner. + * + * Gated by `PI_RUBY_INTEGRATION=1` so CI without a real Ruby interpreter + * (or sandboxes where subprocess spawning is restricted) does not fail. + */ +import { afterEach, describe, expect, it } from "bun:test"; +import { disposeAllRubyKernelSessions, executeRubyWithKernel } from "@oh-my-pi/pi-coding-agent/eval/rb/executor"; +import { RubyKernel } from "@oh-my-pi/pi-coding-agent/eval/rb/kernel"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +const SHOULD_RUN = Bun.env.PI_RUBY_INTEGRATION === "1"; + +describe.skipIf(!SHOULD_RUN)("ruby runner subprocess", () => { + afterEach(async () => { + await disposeAllRubyKernelSessions(); + }); + + it("streams stdout chunks as they are produced", async () => { + using tempDir = TempDir.createSync("@ruby-runner-stream-"); + const kernel = await RubyKernel.start({ cwd: tempDir.path() }); + try { + const chunks: string[] = []; + const result = await executeRubyWithKernel(kernel, "5.times { |i| puts i }", { + onChunk: chunk => { + chunks.push(chunk); + }, + }); + expect(result.exitCode).toBe(0); + expect(chunks.join("")).toContain("0\n"); + expect(chunks.join("")).toContain("4\n"); + } finally { + await kernel.shutdown(); + } + }); + + it("keeps local variables across cells on one kernel", async () => { + using tempDir = TempDir.createSync("@ruby-runner-state-"); + const kernel = await RubyKernel.start({ cwd: tempDir.path() }); + try { + const first = await executeRubyWithKernel(kernel, "x = 41", {}); + expect(first.exitCode).toBe(0); + const second = await executeRubyWithKernel(kernel, "x + 1", {}); + expect(second.exitCode).toBe(0); + expect(second.output).toContain("42"); + } finally { + await kernel.shutdown(); + } + }); + + it("auto-displays the last expression but suppresses assignments", async () => { + using tempDir = TempDir.createSync("@ruby-runner-display-"); + const kernel = await RubyKernel.start({ cwd: tempDir.path() }); + try { + const assigned = await executeRubyWithKernel(kernel, "y = { a: 1, b: [2, 3] }", {}); + expect(assigned.displayOutputs).toHaveLength(0); + + const expr = await executeRubyWithKernel(kernel, "y", {}); + const json = expr.displayOutputs.find(o => o.type === "json"); + expect(json).toBeDefined(); + if (json?.type === "json") { + expect(json.data).toEqual({ a: 1, b: [2, 3] }); + } + } finally { + await kernel.shutdown(); + } + }); + + it("cancels a running cell via signal and keeps the kernel usable", async () => { + using tempDir = TempDir.createSync("@ruby-runner-cancel-"); + const kernel = await RubyKernel.start({ cwd: tempDir.path() }); + try { + const controller = new AbortController(); + // Real delay: this interrupts a live OS subprocess mid-`sleep`, which runs on + // the platform clock — fake timers cannot drive it. Abort once the cell is in flight. + setTimeout(() => controller.abort(), 300); + const cancelled = await executeRubyWithKernel(kernel, "sleep 30", { signal: controller.signal }); + expect(cancelled.cancelled).toBe(true); + + const after = await executeRubyWithKernel(kernel, "20 + 22", {}); + expect(after.exitCode).toBe(0); + expect(after.output).toContain("42"); + } finally { + await kernel.shutdown(); + } + }); + + it("surfaces Ruby errors with a non-zero exit and the message", async () => { + using tempDir = TempDir.createSync("@ruby-runner-error-"); + const kernel = await RubyKernel.start({ cwd: tempDir.path() }); + try { + const result = await executeRubyWithKernel(kernel, "raise 'boom'", {}); + expect(result.exitCode).toBe(1); + expect(result.output).toContain("boom"); + } finally { + await kernel.shutdown(); + } + }); + + it("exposes prelude file + text helpers", async () => { + using tempDir = TempDir.createSync("@ruby-runner-prelude-"); + const kernel = await RubyKernel.start({ cwd: tempDir.path() }); + try { + const sorted = await executeRubyWithKernel(kernel, 'sort("b\\na\\nb", unique: true)', {}); + expect(sorted.output).toContain("a\nb"); + + const written = await executeRubyWithKernel(kernel, 'write("note.txt", "hello"); read("note.txt")', {}); + expect(written.output).toContain("hello"); + } finally { + await kernel.shutdown(); + } + }); +}); diff --git a/packages/coding-agent/test/tools/eval-fallback.test.ts b/packages/coding-agent/test/tools/eval-fallback.test.ts index 083b5faa6..f88a04231 100644 --- a/packages/coding-agent/test/tools/eval-fallback.test.ts +++ b/packages/coding-agent/test/tools/eval-fallback.test.ts @@ -127,7 +127,7 @@ describe("EvalTool language dispatch", () => { settings.set("eval.py", false); settings.set("eval.js", false); - expect(resolveEvalBackends(makeSession(settings))).toEqual({ python: true, js: false }); + expect(resolveEvalBackends(makeSession(settings))).toEqual({ python: true, js: false, ruby: true, julia: true }); }); it("lets PI_JS disable js execution even when eval.js is enabled", async () => { diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 6f4073710..a02ede9c1 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -8,6 +8,10 @@ - Added Julia syntax highlighting to `highlightCode`/`supportsLanguage` via a vendored `Julia.sublime-syntax` folded into syntect's default set (`jl`/`julia` aliases); syntect ships no Julia grammar. +### Fixed + +- Enabled full Julia syntax highlighting support in highlightCode + ## [16.1.8] - 2026-06-20 ### Breaking Changes @@ -847,4 +851,4 @@ ### Fixed -- Fixed potential crashes when updating native binaries by using safe copy strategy that avoids overwriting in-memory binaries +- Fixed potential crashes when updating native binaries by using safe copy strategy that avoids overwriting in-memory binaries \ No newline at end of file diff --git a/packages/natives/test/native.test.ts b/packages/natives/test/native.test.ts index 989f610e3..6c9562793 100644 --- a/packages/natives/test/native.test.ts +++ b/packages/natives/test/native.test.ts @@ -12,8 +12,10 @@ import { fuzzyFind, type GlobMatch, GrepOutputMode, + getSupportedLanguages, glob, grep, + highlightCode, htmlToMarkdown, invalidateFsScanCache, listWorkspace, @@ -197,6 +199,33 @@ describe("pi-natives", () => { expect(supportsLanguage("emacs-lisp")).toBe(true); expect(supportsLanguage("elisp")).toBe(true); }); + + it("highlights Julia via the vendored syntax", () => { + // Julia is not in syntect's defaults; its syntax is vendored and folded + // into the set. Assert it is actually present, not merely aliased — an + // alias alone would let supportsLanguage report true while highlightCode + // returns the source unchanged. + expect(getSupportedLanguages()).toContain("Julia"); + expect(supportsLanguage("julia")).toBe(true); + expect(supportsLanguage("jl")).toBe(true); + + const colors = { + comment: "", + keyword: "", + function: "", + variable: "", + string: "", + number: "", + type: "", + operator: "", + punctuation: "

", + }; + const out = highlightCode("function f(x)\n return x + 1 # add\nend\n", "julia", colors); + // Real highlighting wraps tokens in the supplied color sentinels. + expect(out).toContain("function"); + expect(out).toContain("1"); + expect(out).toContain(" add"); + }); }); describe("keys", () => { diff --git a/types/assets/index.d.ts b/types/assets/index.d.ts index e90c04e95..75b162801 100644 --- a/types/assets/index.d.ts +++ b/types/assets/index.d.ts @@ -13,6 +13,16 @@ declare module "*.py" { export default content; } +declare module "*.rb" { + const content: string; + export default content; +} + +declare module "*.jl" { + const content: string; + export default content; +} + declare module "*.lark" { const content: string; export default content;