feat(coding-agent): removed unused eval helper functions

- Removed deprecated eval prelude helpers `append`, `tree`, `diff`, `sort`, `uniq`, and `counter` from all supported runtimes.
- Cleaned up runtime implementations, protocol definitions, and UI rendering logic associated with the removed helpers.
- Updated project documentation, prompts, and test suites to reflect the reduced helper API surface.
- Recorded functional changes in the package changelog.
This commit is contained in:
can1357
2026-06-23 01:39:24 +02:00
parent ec16b09c76
commit 1dd78b207e
18 changed files with 23 additions and 711 deletions
+4 -6
View File
@@ -129,16 +129,14 @@ Implemented in `packages/coding-agent/src/eval/js/worker-core.ts`, `packages/cod
- Module cache is busted for **local** imports between cells so edits to source files are picked up without restarting the runtime. `__omp_import__` deletes `require.cache[absPath]` before re-importing whenever the original specifier is a filesystem path: relative (`./x`, `../x`, `.`, `..`), POSIX-absolute (`/...`), home-prefixed (`~/...`), or Windows drive-letter (`C:\...` / `C:/...`). Bare specifiers (`react`, `lodash/x`) and URL/scheme specifiers (`node:fs`, `file://...`, `https://...`) are left in cache so package identity stays stable across cells. The cache-bust only fires when the resolved target is an absolute path — unresolved bare-package fallbacks (`resolveImportSpecifier()` returning the original specifier) skip it.
- The prelude installs globals:
- `display`, `print`, and a `console` bridge
- `read`, `write`, `append`, `sort`, `uniq`, `counter`, `diff`, `tree`, `env`, `output`
- `read`, `write`, `env`, `output`
- `tool.<name>(args)` proxy for arbitrary session tool calls
- `completion(prompt, opts?)` for oneshot, stateless model calls (see _Oneshot completion helper_ below)
- `agent(prompt, opts?)` for a single subagent call, plus `parallel()` / `pipeline()` bounded-pool helpers (see _Subagent helper_ below)
- `log(message)`, `phase(title)`, and `budget` (live token-budget view via async `budget.total()` / `budget.spent()` / `budget.remaining()` / `budget.hard()`)
- JS helpers that touch the host/runtime boundary are async and `await`able; pure text helpers (`sort`, `uniq`, `counter`) return synchronously but may still be safely awaited.
- JS host/runtime helpers (`read`, `write`, `output`) are async and `await`able; `env` returns synchronously.
- JS helper options may be passed either positionally in the Python order or as a trailing options object. `null` and `undefined` skip positional slots:
- `await read(path, offset?, limit?)` or `await read(path, { offset?, limit? })`
- `await tree(path = ".", maxDepth?, showHidden?)` or `await tree(path, { maxDepth?, showHidden? })`
- `sort(text, reverse?, unique?)`, `uniq(text, count?)`, `counter(items, limit?, reverse?)`
- `await agent(prompt, agent?, model?, label?, schema?)` or `await agent(prompt, { agent?, model?, label?, schema?, handle? })`
- `await parallel([() => agent("a"), () => agent("b")])`
- `await pipeline(items, stage1, stage2)`
@@ -215,7 +213,7 @@ A single tool call can mix Python and JS cells. Persistence is per language runt
## Side Effects
- Filesystem
- JS/Python prelude helpers can read, write, append, diff, and traverse filesystem paths under the session cwd or absolute paths.
- JS/Python prelude helpers can read and write filesystem paths under the session cwd or absolute paths.
- JS helper `read()` auto-delegates any non-`local://` scheme URI (`agent://`, `artifact://`, `https://`, ...) to `tool.read(...)` (honoring an `offset`/`limit` line selector), resolves `local://` under its mapped root, reads plain/absolute filesystem paths directly, and rejects directory paths.
- Output may spill to an artifact file via `OutputSink`.
- Network
@@ -280,7 +278,7 @@ A single tool call can mix Python and JS cells. Persistence is per language runt
- Backend selection is strictly explicit per cell: `language` must be `"py"` or `"js"`. The previous `*** Cell` header parser, the `eval.lark` constrained grammar, and the sniffer-based fallback have all been removed.
- `EvalTool.customFormat` no longer exists. Tool calls flow through the standard JSON schema; there is no Lark-constrained sampling path.
- `tool.<name>()` exists in both JS and Python. Python calls route through a per-run loopback bridge keyed by the current cell id.
- `read()` delegates non-`local://` scheme URIs to `tool.read`, resolves `local://` under its injected root, and resolves plain paths against the session cwd or an absolute filesystem path; `resolveRegularFile()` rejects directory paths. `write()`/`append()` accept `local://` and plain paths but reject any other `scheme://` via `resolveHelperPath()` (`Protocol paths are not supported by write()`).
- `read()` delegates non-`local://` scheme URIs to `tool.read`, resolves `local://` under its injected root, and resolves plain paths against the session cwd or an absolute filesystem path; `resolveRegularFile()` rejects directory paths. `write()` accepts `local://` and plain paths but rejects any other `scheme://` via `resolveHelperPath()` (`Protocol paths are not supported by write()`).
- Python helper `output(...)` depends on `PI_ARTIFACTS_DIR` or `PI_SESSION_FILE`; it fails outside a session-backed run.
- `display()` can produce text and structured outputs from the same value; the renderer prefers markdown over `text/plain` when both exist.
- JS static imports are rewritten only at top level. Nested imports stay invalid and surface normal JS syntax/runtime errors.
+7 -1
View File
@@ -1,7 +1,6 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Renamed the eval `agent()` helper parameters `agent_type` → `agent` and `return_handle` → `handle` across every workflow runtime (Python, JavaScript, Ruby, Julia), so the names are identical in every language (no camelCase/snake_case split) and the agent-selection parameter matches the `task` tool's `agent`. The `__agent__` eval bridge wire protocol was renamed to match.
@@ -20,6 +19,13 @@
- Reinforced routing of fragile, multi-step shell logic to the `eval` tool over `bash`. The system-prompt tool policy, `bash.md`, and `eval.md` now treat loops, conditionals, heredocs, inline `-e`/`-c` scripts, multi-stage pipelines, and quote/JSON escaping as the signal to write an `eval` cell; bash's "compute a fact" carveout is narrowed to single short pipelines, and `eval.md` now actively claims that territory with runtime-templated examples (only enabled backends are advertised).
- Made `eval` an essential built-in tool (`loadMode: "essential"`, added to the default essential tool set) so it stays active under `tools.discoveryMode: "all"` instead of being hidden behind `search_tool_bm25`.
### Removed
- Removed `append`, `tree`, and `diff` eval helper functions from Python, JavaScript, and Ruby
- Removed `sort`, `uniq`, and `counter` text processing eval helpers from Python, JavaScript, and Ruby
- Removed the `append(path, content)`, `tree(path, max_depth?, show_hidden?)`, and `diff(a, b)` eval prelude helpers from every workflow runtime (Python, JavaScript, Ruby, Julia), along with their status renderers, icon entries, and tool/`docs` references. Use `write`/`read` for file mutation and `tool.<name>(...)` for richer filesystem operations.
- Removed the `sort(text, reverse?, unique?)`, `uniq(text, count?)`, and `counter(items, limit?, reverse?)` eval text helpers from the Python, JavaScript, and Ruby prelude surfaces (Julia never defined them), along with the JS `HelperBundle`/`HelperOptions` members and `docs` references. Sort/dedupe/count inline in cell code instead.
### Fixed
- Fixed Escape doing nothing in the Settings text-input fields (e.g. "Python Interpreter") on terminals with the kitty keyboard protocol active (ghostty/kitty). Inside the fullscreen settings overlay the protocol reports Escape as the CSI-u sequence `\x1b[27u`, which the text-input submenu's raw `\x1b` compare missed; `handleInputOrEscape` now decodes Escape via `matchesKey`, matching every other Escape-to-cancel path.
@@ -4,7 +4,7 @@ import { TempDir } from "@oh-my-pi/pi-utils/temp";
import { createHelpers, type HelperContext } from "../js/shared/helpers";
/**
* The eval helpers (`read`/`write`/`append`) must substitute injected on-disk
* The eval helpers (`read`/`write`) must substitute injected on-disk
* roots for internal-URL schemes. Without it, `write("local://x.md")` hits a
* stdlib `path.resolve` that collapses `local://` to `local:/`, creating a junk
* `local:` directory under the cwd instead of landing where `read local://x.md`
@@ -20,7 +20,7 @@ function makeCtx(cwd: string, roots: Record<string, string>): HelperContext {
}
describe("eval js helpers internal-url resolution", () => {
it("writes, reads, and appends local:// under the injected root", async () => {
it("writes and reads local:// under the injected root", async () => {
using tmp = TempDir.createSync("@eval-helpers-local-");
const root = path.join(tmp.path(), "local");
const helpers = createHelpers(makeCtx(tmp.path(), { local: root }));
@@ -30,9 +30,6 @@ describe("eval js helpers internal-url resolution", () => {
expect(await Bun.file(written).text()).toBe("hello");
expect(await helpers.read("local://notes/merge-map.md")).toBe("hello");
await helpers.append("local://notes/merge-map.md", " world");
expect(await helpers.read("local://notes/merge-map.md")).toBe("hello world");
// Regression: no literal `local:` directory created under the cwd.
expect(await Bun.file(path.join(tmp.path(), "local:")).exists()).toBe(false);
expect(await Bun.file(path.join(tmp.path(), "local:", "notes", "merge-map.md")).exists()).toBe(false);
@@ -11,35 +11,6 @@ describe.skipIf(!HAS_JULIA)("eval Julia prelude helpers", () => {
await disposeJuliaKernelSessionsByOwner(OWNER_ID);
});
it("supports tree keyword options and unified diff", async () => {
using tempDir = TempDir.createSync("@omp-eval-julia-helpers-");
await Bun.write(path.join(tempDir.path(), "a.txt"), "same\nold\n");
await Bun.write(path.join(tempDir.path(), "b.txt"), "same\nnew\n");
await Bun.write(path.join(tempDir.path(), "dir", "child.txt"), "child");
const result = await executeJulia(
`
d = diff("a.txt", "b.txt")
println("DIFF_DELETE=", occursin("-old", d))
println("DIFF_ADD=", occursin("+new", d))
t = tree(".", max_depth=2)
println("TREE_CHILD=", occursin("child.txt", t))
nothing
`,
{
cwd: tempDir.path(),
sessionId: `julia-prelude-diff:${crypto.randomUUID()}`,
kernelOwnerId: OWNER_ID,
reset: true,
},
);
expect(result.exitCode).toBe(0);
expect(result.output).toContain("DIFF_DELETE=true");
expect(result.output).toContain("DIFF_ADD=true");
expect(result.output).toContain("TREE_CHILD=true");
}, 30_000);
it("supports output ranges, JSON queries, metadata, and ANSI stripping", async () => {
using tempDir = TempDir.createSync("@omp-eval-julia-output-");
const artifactsDir = path.join(tempDir.path(), "session-artifacts");
@@ -146,221 +146,6 @@ function Base.write(path::AbstractString, content::Any)
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=".", positional_max_depth=3, positional_show_hidden=false; max_depth=positional_max_depth, show_hidden=positional_show_hidden)
base = string(path)
resolved = __omp_resolve_path(base)
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(resolved, "", 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" => resolved,
"lines" => length(lines)
)
)
))
return out
end
function __omp_lines_keepends(content::String)
parts = split(content, '\n'; keepempty=true)
if length(parts) == 1 && isempty(parts[1])
return String[]
end
lines = String[]
for i in eachindex(parts)
if i < length(parts)
push!(lines, string(parts[i], "\n"))
elseif !isempty(parts[i])
push!(lines, string(parts[i]))
end
end
return lines
end
function __omp_diff_ops(a::Vector{String}, b::Vector{String})
n = length(a)
m = length(b)
ops = Vector{Tuple{Symbol, Int, Int}}()
if n * m > 4_000_000
for i in 1:n
push!(ops, (:delete, i, 1))
end
for j in 1:m
push!(ops, (:insert, n + 1, j))
end
return ops
end
dp = [zeros(Int, m + 1) for _ in 1:(n + 1)]
for i in n:-1:1
for j in m:-1:1
dp[i][j] = a[i] == b[j] ? dp[i + 1][j + 1] + 1 : max(dp[i + 1][j], dp[i][j + 1])
end
end
i = 1
j = 1
while i <= n && j <= m
if a[i] == b[j]
push!(ops, (:equal, i, j))
i += 1
j += 1
elseif dp[i + 1][j] >= dp[i][j + 1]
push!(ops, (:delete, i, j))
i += 1
else
push!(ops, (:insert, i, j))
j += 1
end
end
while i <= n
push!(ops, (:delete, i, j))
i += 1
end
while j <= m
push!(ops, (:insert, i, j))
j += 1
end
return ops
end
function __omp_unified_diff(a::Vector{String}, b::Vector{String}, from_file::String, to_file::String, context::Int=3)
ops = __omp_diff_ops(a, b)
if !any(op -> op[1] != :equal, ops)
return ""
end
entries = [Dict{Symbol, Any}(:tag => tag, :ai => ai, :bi => bi, :text => tag == :insert ? b[bi] : a[ai]) for (tag, ai, bi) in ops]
changed = [i for i in eachindex(entries) if entries[i][:tag] != :equal]
groups = Vector{Tuple{Int, Int}}()
start = nothing
prev = nothing
for idx in changed
if start === nothing
start = idx
prev = idx
elseif idx - prev <= (2 * context) + 1
prev = idx
else
push!(groups, (start, prev))
start = idx
prev = idx
end
end
if start !== nothing
push!(groups, (start, prev))
end
out = IOBuffer()
write(out, "--- $from_file\n")
write(out, "+++ $to_file\n")
for (group_start, group_end) in groups
lo = max(group_start - context, 1)
hi = min(group_end + context, length(entries))
slice = entries[lo:hi]
a_start = nothing
a_count = 0
b_start = nothing
b_count = 0
for entry in slice
if entry[:tag] != :insert
if a_start === nothing
a_start = entry[:ai]
end
a_count += 1
end
if entry[:tag] != :delete
if b_start === nothing
b_start = entry[:bi]
end
b_count += 1
end
end
write(out, "@@ -$(a_start === nothing ? 1 : a_start),$a_count +$(b_start === nothing ? 1 : b_start),$b_count @@\n")
for entry in slice
prefix = entry[:tag] == :equal ? " " : (entry[:tag] == :delete ? "-" : "+")
text = string(entry[:text])
if !endswith(text, "\n")
text *= "\n"
end
write(out, prefix * text)
end
end
return String(take!(out))
end
function Base.diff(a::AbstractString, b::AbstractString)
path_a = __omp_resolve_path(string(a))
path_b = __omp_resolve_path(string(b))
lines_a = __omp_lines_keepends(open(path_a, "r") do io
Base.read(io, String)
end)
lines_b = __omp_lines_keepends(open(path_b, "r") do io
Base.read(io, String)
end)
out = __omp_unified_diff(lines_a, lines_b, path_a, path_b)
__omp_emit_status("diff", Dict{String, Any}(
"file_a" => path_a,
"file_b" => path_b,
"identical" => isempty(out),
"preview" => first(out, min(500, length(out)))
))
return out
end
function __omp_apply_query(data, query)
if query === nothing || isempty(string(query))
return data
@@ -1,19 +1,11 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as Diff from "diff";
import { ToolError } from "../../../tools/tool-errors";
import type { JsStatusEvent } from "./types";
export interface HelperOptions {
path?: string;
hidden?: boolean;
maxDepth?: number;
limit?: number;
offset?: number;
reverse?: boolean;
unique?: boolean;
count?: boolean;
}
/**
@@ -35,18 +27,12 @@ export interface HelperContext {
/**
* The set of functions exposed to user code via `globalThis.__omp_helpers__`. The JS
* prelude reads from this bag and attaches short aliases (`read`, `write`, `tree`, ...)
* prelude reads from this bag and attaches short aliases (`read`, `write`, `env`, ...)
* onto the global scope.
*/
export interface HelperBundle {
read(rawPath: string, options?: HelperOptions): Promise<string>;
writeFile(rawPath: string, data: unknown): Promise<string>;
append(rawPath: string, content: string): Promise<string>;
sortText(text: string, options?: HelperOptions): string;
uniqText(text: string, options?: HelperOptions): string | Array<[number, string]>;
counter(items: string | string[], options?: HelperOptions): Array<[number, string]>;
diff(rawA: string, rawB: string): Promise<string>;
tree(searchPath?: string, options?: HelperOptions): Promise<string>;
env(key?: string, value?: string): string | Record<string, string> | undefined;
}
@@ -81,105 +67,6 @@ export function createHelpers(ctx: HelperContext): HelperBundle {
ctx.emitStatus({ op: "write", path: filePath, bytes: getDataSize(data) });
return filePath;
},
append: async (rawPath, content) => {
const target = resolveHelperPath(ctx, rawPath, "write");
// O(1) append; read-all+rewrite both raced concurrent writers and went
// quadratic when called in a loop. Bun.write creates parent dirs, so
// keep that behavior for the append path too.
await fs.promises.mkdir(path.dirname(target), { recursive: true });
await fs.promises.appendFile(target, content, "utf-8");
ctx.emitStatus({
op: "append",
path: target,
chars: content.length,
bytes: utf8Encoder.encode(content).byteLength,
});
return target;
},
sortText: (text, options = {}) => {
const lines = String(text).split(/\r?\n/);
const deduped = options.unique ? Array.from(new Set(lines)) : lines;
const sorted = deduped.sort((a, b) => a.localeCompare(b));
if (options.reverse) sorted.reverse();
const result = sorted.join("\n");
ctx.emitStatus({
op: "sort",
lines: sorted.length,
reverse: options.reverse === true,
unique: options.unique === true,
});
return result;
},
uniqText: (text, options = {}) => {
const lines = String(text)
.split(/\r?\n/)
.filter(line => line.length > 0);
const groups: Array<[number, string]> = [];
for (const line of lines) {
const last = groups.at(-1);
if (last && last[1] === line) {
last[0] += 1;
continue;
}
groups.push([1, line]);
}
ctx.emitStatus({ op: "uniq", groups: groups.length, count_mode: options.count === true });
if (options.count) return groups;
return groups.map(([, line]) => line).join("\n");
},
counter: (items, options = {}) => {
const values = Array.isArray(items) ? items : String(items).split(/\r?\n/).filter(Boolean);
const counts = new Map<string, number>();
for (const item of values) counts.set(item, (counts.get(item) ?? 0) + 1);
const entries = Array.from(counts.entries())
.map(([item, count]) => [count, item] as [number, string])
.sort((a, b) => (options.reverse === false ? a[0] - b[0] : b[0] - a[0]) || a[1].localeCompare(b[1]));
const limited = entries.slice(0, options.limit ?? entries.length);
ctx.emitStatus({ op: "counter", unique: counts.size, total: values.length, top: limited.slice(0, 10) });
return limited;
},
diff: async (rawA, rawB) => {
const fileA = resolvePath(ctx, rawA);
const fileB = resolvePath(ctx, rawB);
const [a, b] = await Promise.all([Bun.file(fileA).text(), Bun.file(fileB).text()]);
const result = Diff.createTwoFilesPatch(fileA, fileB, a, b, "", "", { context: 3 });
ctx.emitStatus({
op: "diff",
file_a: fileA,
file_b: fileB,
identical: a === b,
preview: result.slice(0, 500),
});
return result;
},
tree: async (searchPath = ".", options = {}) => {
const root = resolvePath(ctx, searchPath);
const maxDepth = options.maxDepth ?? 3;
const showHidden = options.hidden ?? false;
const lines: string[] = [`${root}/`];
let entryCount = 0;
const walk = async (dir: string, prefix: string, depth: number): Promise<void> => {
if (depth > maxDepth) return;
const entries = (await fs.promises.readdir(dir, { withFileTypes: true }))
.filter(entry => showHidden || !entry.name.startsWith("."))
.sort((a, b) => a.name.localeCompare(b.name));
for (let index = 0; index < entries.length; index++) {
const entry = entries[index];
const isLast = index === entries.length - 1;
const connector = isLast ? "└── " : "├── ";
const suffix = entry.isDirectory() ? "/" : "";
lines.push(`${prefix}${connector}${entry.name}${suffix}`);
entryCount += 1;
if (entry.isDirectory()) {
await walk(path.join(dir, entry.name), `${prefix}${isLast ? " " : "│ "}`, depth + 1);
}
}
};
await walk(root, "", 1);
const result = lines.join("\n");
ctx.emitStatus({ op: "tree", path: root, entries: entryCount, preview: result.slice(0, 1000) });
return result;
},
env: (key, value) => {
if (!key) {
const merged = Object.fromEntries(Object.entries(getMergedEnv(ctx)).sort(([a], [b]) => a.localeCompare(b)));
@@ -63,23 +63,6 @@ if (!globalThis.__omp_js_prelude_loaded__) {
return callHelper("read", path, options);
};
const write = async (path, data) => callHelper("writeFile", path, data);
const append = (path, content) => callHelper("append", path, content);
const sort = (text, opts, ...rest) =>
callHelper("sortText", text, optionsArg("sort", opts, rest, ["reverse", "unique"], "{ reverse, unique }"));
const uniq = (text, opts, ...rest) => callHelper("uniqText", text, optionsArg("uniq", opts, rest, ["count"], "{ count }"));
const counter = (items, opts, ...rest) =>
callHelper("counter", items, optionsArg("counter", opts, rest, ["limit", "reverse"], "{ limit, reverse }"));
const diff = (a, b) => callHelper("diff", a, b);
const tree = (path = ".", opts, ...rest) => {
if (isPlainObject(path) && opts === undefined && rest.length === 0) {
return callHelper("tree", ".", path);
}
return callHelper(
"tree",
isNil(path) ? "." : path,
optionsArg("tree", opts, rest, ["maxDepth", "showHidden"], "{ maxDepth, showHidden }"),
);
};
const env = (key, value) => callHelper("env", key, value);
const tool = new Proxy(
@@ -306,11 +289,5 @@ if (!globalThis.__omp_js_prelude_loaded__) {
globalThis.__pool = __pool;
globalThis.read = read;
globalThis.write = write;
globalThis.append = append;
globalThis.sort = sort;
globalThis.uniq = uniq;
globalThis.counter = counter;
globalThis.diff = diff;
globalThis.tree = tree;
globalThis.env = env;
}
@@ -72,12 +72,6 @@ const PRELUDE_GLOBAL_KEYS = [
"__pool",
"read",
"write",
"append",
"sort",
"uniq",
"counter",
"diff",
"tree",
"env",
];
@@ -1,5 +1,5 @@
/**
* Structured status payload emitted by helpers (`read`, `write`, `tree`, etc.) and the
* Structured status payload emitted by helpers (`read`, `write`, `env`, etc.) and the
* tool-call bridge. Surfaces to the model as part of `displays` so it has machine-readable
* context about what side effects happened.
*/
@@ -7,7 +7,7 @@ export interface SessionSnapshot {
sessionId: string;
/**
* On-disk roots the helpers substitute for internal-URL schemes
* (e.g. `{ local: "/…/artifacts/local" }`). Lets `read`/`write`/`append`
* (e.g. `{ local: "/…/artifacts/local" }`). Lets `read`/`write`
* accept `local://…` paths instead of writing a literal `local:/` directory.
*/
localRoots?: Record<string, string>;
@@ -71,7 +71,7 @@ export interface PythonExecutorOptions {
artifactPath?: string;
artifactId?: string;
/**
* On-disk roots the prelude helpers (`read`/`write`/`append`) substitute for
* On-disk roots the prelude helpers (`read`/`write`) substitute for
* internal-URL schemes (e.g. `{ local: "/…/artifacts/local" }`). Exported to
* the kernel as `PI_EVAL_LOCAL_ROOTS` (JSON) so `write("local://x")` lands
* where `read local://x` resolves instead of a literal `local:/` directory.
@@ -115,103 +115,6 @@ if "__omp_prelude_loaded__" not in globals():
_emit_status("write", path=str(p), chars=len(content))
return p
def append(path: str | Path, content: str) -> Path:
"""Append to file."""
p = _resolve_omp_path(path)
p.parent.mkdir(parents=True, exist_ok=True)
with p.open("a", encoding="utf-8") as f:
f.write(content)
_emit_status("append", path=str(p), chars=len(content))
return p
def sort(text: str, *, reverse: bool = False, unique: bool = False) -> str:
"""Sort lines of text."""
lines = text.splitlines()
if unique:
lines = list(dict.fromkeys(lines))
lines = sorted(lines, reverse=reverse)
out = "\n".join(lines)
_emit_status("sort", lines=len(lines), unique=unique, reverse=reverse)
return out
def uniq(text: str, *, count: bool = False) -> str | list[tuple[int, str]]:
"""Remove duplicate adjacent lines (like uniq)."""
lines = text.splitlines()
if not lines:
_emit_status("uniq", groups=0)
return [] if count else ""
groups: list[tuple[int, str]] = []
current = lines[0]
current_count = 1
for line in lines[1:]:
if line == current:
current_count += 1
continue
groups.append((current_count, current))
current = line
current_count = 1
groups.append((current_count, current))
_emit_status("uniq", groups=len(groups), count_mode=count)
if count:
return groups
return "\n".join(line for _, line in groups)
def counter(
items: str | list,
*,
limit: int | None = None,
reverse: bool = True,
) -> list[tuple[int, str]]:
"""Count occurrences and sort by frequency. Like sort | uniq -c | sort -rn.
items: text (splits into lines) or list of strings
reverse: True for descending (most common first), False for ascending
Returns: [(count, item), ...] sorted by count
"""
from collections import Counter
if isinstance(items, str):
items = items.splitlines()
counts = Counter(items)
sorted_items = sorted(counts.items(), key=lambda x: (x[1], x[0]), reverse=reverse)
if limit is not None:
sorted_items = sorted_items[:limit]
result = [(count, item) for item, count in sorted_items]
_emit_status("counter", unique=len(counts), total=sum(counts.values()), top=result[:10])
return result
def tree(path: str | Path = ".", *, max_depth: int = 3, show_hidden: bool = False) -> str:
"""Return directory tree."""
base = Path(path)
lines = []
def walk(p: Path, prefix: str, depth: int):
if depth > max_depth:
return
items = sorted(p.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
items = [i for i in items if show_hidden or not i.name.startswith(".")]
for i, item in enumerate(items):
is_last = i == len(items) - 1
connector = "└── " if is_last else "├── "
suffix = "/" if item.is_dir() else ""
lines.append(f"{prefix}{connector}{item.name}{suffix}")
if item.is_dir():
ext = " " if is_last else "│ "
walk(item, prefix + ext, depth + 1)
lines.append(str(base) + "/")
walk(base, "", 1)
out = "\n".join(lines)
_emit_status("tree", path=str(base), entries=len(lines) - 1, preview=out[:1000])
return out
def diff(a: str | Path, b: str | Path) -> str:
"""Compare two files, return unified diff."""
import difflib
path_a, path_b = Path(a), Path(b)
lines_a = path_a.read_text(encoding="utf-8").splitlines(keepends=True)
lines_b = path_b.read_text(encoding="utf-8").splitlines(keepends=True)
result = difflib.unified_diff(lines_a, lines_b, fromfile=str(path_a), tofile=str(path_b))
out = "".join(result)
_emit_status("diff", file_a=str(path_a), file_b=str(path_b), identical=not out, preview=out[:500])
return out
def output(
*ids: str,
format: str = "raw",
+1 -186
View File
@@ -2,7 +2,7 @@
# 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,
# (display/read/write/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
@@ -112,191 +112,6 @@ unless defined?($__omp_prelude_loaded) && $__omp_prelude_loaded
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
# -------------------------------------------------------------------------
@@ -157,7 +157,7 @@ export function resolveLocalUrlToPath(
}
/**
* On-disk roots the eval helpers (`read`/`write`/`append`) substitute for
* On-disk roots the eval helpers (`read`/`write`) substitute for
* internal-URL schemes so e.g. `write("local://x.md")` lands where a later
* `read local://x.md` resolves — instead of a literal `local:/` directory under
* the cwd (a stdlib `pathlib.Path`/`path.resolve` collapses `local://` to
@@ -15,8 +15,8 @@ Fields:
{{#if py}}Live event loop: use top-level `await` directly; `asyncio.run(…)` raises "cannot be called from a running event loop".{{/if}}
{{#if js}}JS runs under **Bun**: Bun globals/APIs are available (`Bun.file`, `Bun.write`, `Bun.$`, `fetch`, `Buffer`); top-level `await`/`return` work directly.{{/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}}
{{#if rb}}Ruby: synchronous; helper options are keyword args (e.g. `output("id", limit: 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. `output("id", limit=2)`); the last expression auto-displays unless it is an assignment or a definition (like the Julia REPL).{{/if}}
On error, fix and re-run only the failing step — prior calls' state survives.
</instruction>
@@ -31,12 +31,6 @@ read(path, offset?=1, limit?=None) → str
File as text; offset/limit 1-indexed lines. Accepts `local://…`.
write(path, content) → str
Write file (creates parents) → resolved path. `local://…` persists across turns/subagents.
append(path, content) → str
Append → resolved path. Accepts `local://…`.
tree(path?=".", max_depth?=3, show_hidden?=False) → str
Directory tree.
diff(a, b) → str
Unified diff of two files.
env(key?=None, value?=None) → str | None | dict
No args → full env dict; one → value of `key`; two → set `key=value`, return value.
output(*ids, format?="raw", query?=None, offset?=None, limit?=None) → str | dict | list[dict]
@@ -800,7 +800,7 @@ export class WorkerCore {
displays.push({ type: "text", text: safeJsonStringify(output.data) });
return;
}
// status — surface as compact JSON so helper side effects (read/write/tree) appear in
// status — surface as compact JSON so helper side effects (read/write/env) appear in
// the cell result alongside explicit display() output.
displays.push({ type: "text", text: safeJsonStringify(output.event) });
}
@@ -241,14 +241,12 @@ function formatStatusEvent(event: EvalStatusEvent, theme: Theme): string {
const opIcons: Record<string, AvailableIcon> = {
read: "icon.file",
write: "icon.file",
append: "icon.file",
cat: "icon.file",
touch: "icon.file",
ls: "icon.folder",
cd: "icon.folder",
pwd: "icon.folder",
mkdir: "icon.folder",
tree: "icon.folder",
git_status: "icon.git",
git_diff: "icon.git",
git_log: "icon.git",
@@ -280,7 +278,6 @@ function formatStatusEvent(event: EvalStatusEvent, theme: Theme): string {
if (data.path) parts.push(`from ${shortenPath(String(data.path))}`);
break;
case "write":
case "append":
parts.push(`${data.chars ?? data.bytes ?? 0} chars`);
if (data.path) parts.push(`to ${shortenPath(String(data.path))}`);
break;
@@ -319,13 +316,6 @@ function formatStatusEvent(event: EvalStatusEvent, theme: Theme): string {
parts.push(`${data.lines} line${(data.lines as number) !== 1 ? "s" : ""}`);
if (data.staged) parts.push("(staged)");
break;
case "diff":
if (data.identical) {
parts.push("files identical");
} else {
parts.push("files differ");
}
break;
case "batch":
parts.push(`${data.files} file${(data.files as number) !== 1 ? "s" : ""} processed`);
break;
@@ -415,8 +405,6 @@ function formatStatusEventExpanded(event: EvalStatusEvent, theme: Theme): string
case "cat":
case "head":
case "tail":
case "tree":
case "diff":
case "git_diff":
case "sh":
if (data.preview) addPreview(String(data.preview));
@@ -144,13 +144,10 @@ describe.skipIf(!SHOULD_RUN)("ruby runner subprocess", () => {
}
});
it("exposes prelude file + text helpers", async () => {
it("exposes prelude file 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 {