Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/computer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ export {
type ReplEvalOptions,
type ReplSessionOptions,
} from "./repl/session.js";
export {
createJsToolDefinition,
renderJsResultText,
type JsToolDefinition,
type JsToolInput,
type JsToolOptions,
type JsToolSessionLike,
type JsToolWorkspaceLike,
} from "./repl/tool.js";
Comment thread
ben-reitz marked this conversation as resolved.
export type {
ReplEffect,
ReplErrorKind,
Expand Down
133 changes: 132 additions & 1 deletion packages/computer/src/repl/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ export function replCellsModule(count: number): string {

const MAX_LOG_ENTRIES = 1_000;
const MAX_LOG_ENTRY_CHARS = 8_192;
// emit() is load-bearing output (unlike logs), so past the per-cell entry
// cap it throws instead of dropping — deterministic across replay, and the
// error names the fix. The value ceiling is display transport, not replay
// input, so oversized values degrade to their rendering (omit-plus-render).
const MAX_EMIT_ENTRIES = 1_000;
const MAX_EMIT_VALUE_BYTES = 262_144;

// The runner source. A template-built string (matching how the
// worker-javascript backend ships its runtime module) so the package build
Expand Down Expand Up @@ -119,6 +125,8 @@ function effect(kind, make) {
let BRIDGE;
const PROXY_META = new WeakMap();
const INJECTED = new Map();
// Grant shapes injected for the cell currently running — help() reads them.
let CURRENT_SHAPES = null;

function joinPath(path, prop) {
return path === "" ? prop : path + "." + prop;
Expand Down Expand Up @@ -207,6 +215,7 @@ async function doCall(id, path, args, recipe) {
// cell start in both modes, so grants win over leftover same-name bindings
// identically live and on replay.
function injectGrants(shapes) {
CURRENT_SHAPES = shapes || null;
// Restore whatever a grant name shadowed (e.g. the ambient-fetch error
// when a capability was granted as \`fetch\`), then remove our proxies.
for (const [name, prior] of INJECTED) {
Expand Down Expand Up @@ -330,6 +339,121 @@ function inspect(value, depth) {
return name + "{ " + entries.join(", ") + " }";
}

// --- in-session built-ins: help() and emit() ------------------------------
//
// Neither is a capability and neither is ever recorded. help() is a pure
// function of the cell's injected grant shapes — replayed cells see the
// snapshot they were recorded under, so a committed help() call reproduces
// exactly even after grants change. emit() fills a per-cell display buffer;
// replayed cells' emits are discarded like their console output, so only
// the new cell's emits ride back on the result.

const emits = { entries: [] };

function emitValue(value) {
if (emits.entries.length >= ${MAX_EMIT_ENTRIES}) {
throw new Error(
"emit(): this cell already emitted ${MAX_EMIT_ENTRIES} results. " +
"Emit fewer, larger entries — or write bulk data to a workspace file " +
"via a granted capability and emit the path."
);
}
let text = inspect(value, 0);
if (text.length > ${MAX_LOG_ENTRY_CHARS}) text = text.slice(0, ${MAX_LOG_ENTRY_CHARS}) + "…";
const entry = { text };
attachEmitValue(entry, value);
emits.entries.push(entry);
return value;
}

// Attach the structured value when it can cross the boundary and fits the
// per-entry ceiling; otherwise the rendering alone ships. Cloned at emit
// time, so later cell code can't mutate what was already published.
function attachEmitValue(entry, value) {
if (PROXY_META.has(value)) return; // capability handles are not values
let clone;
try {
clone = structuredClone(value);
} catch {
return; // unclonable: rendering only
}
try {
const estimate = JSON.stringify(value);
if (estimate !== undefined && estimate.length > ${MAX_EMIT_VALUE_BYTES}) {
entry.text += " (structured value omitted: about " + estimate.length +
" bytes exceeds the ${MAX_EMIT_VALUE_BYTES}-byte emit ceiling — " +
"write large data to a workspace file and emit the path)";
return;
}
} catch {
// Clonable but not JSON-estimable (bigint, cycles): ship it.
}
Comment thread
ben-reitz marked this conversation as resolved.
entry.value = clone;
}

function helpText(name) {
const shapes = CURRENT_SHAPES || {};
const names = Object.keys(shapes);
if (name === undefined) {
const lines = [
"Persistent JavaScript session: top-level await works, and bindings survive across cells — including restarts.",
"Built-ins:",
" help(\\"name\\") — full docs for one granted capability",
" emit(value) — publish an extra structured result alongside the cell value (returns the value)",
];
if (names.length === 0) {
lines.push("No capabilities are granted in this session — pure JavaScript with durable state.");
} else {
lines.push("Capabilities:");
for (const n of names) {
const d = shapes[n] && shapes[n].description;
lines.push(" " + n + (d ? " — " + d : ""));
}
lines.push("Capability calls are asynchronous — always await them.");
lines.push("help(\\"name\\") shows a capability's methods and docs.");
}
return lines.join("\\n");
}
const key = String(name);
if (!Object.prototype.hasOwnProperty.call(shapes, key)) {
return "No capability named " + JSON.stringify(key) + " in this session." +
(names.length > 0 ? " Granted: " + names.join(", ") + "." : " No capabilities are granted.") +
" help() lists everything.";
}
const shape = shapes[key];
const lines = [key + (shape.description ? " — " + shape.description : "")];
renderShapeHelp(lines, key, "", shape, shape.docs || {});
return lines.join("\\n");
}

// Walk a grant shape: methods (with grantor docs, dotted keys for nested
// surfaces), data snapshots with their current values, and children.
function renderShapeHelp(lines, path, docPath, shape, docs) {
if (shape.opaque) {
lines.push(" await " + path + ".<method>(…) — surface unknown (opaque remote stub): call any method it supports");
return;
}
if (shape.callable) {
lines.push(" await " + path + "(…) — callable directly" + (docs[docPath] ? ": " + docs[docPath] : ""));
}
for (const m of shape.methods || []) {
const dk = docPath === "" ? m : docPath + "." + m;
lines.push(" await " + path + "." + m + "(…)" + (docs[dk] ? " — " + docs[dk] : ""));
}
for (const k of Object.keys(shape.data || {})) {
let rendered;
try { rendered = inspect(decodeReplValue(shape.data[k]), 1); } catch { rendered = "…"; }
if (rendered.length > 200) rendered = rendered.slice(0, 200) + "…";
lines.push(" " + path + "." + k + " = " + rendered);
}
for (const c of Object.keys(shape.children || {})) {
renderShapeHelp(lines, path + "." + c, docPath === "" ? c : docPath + "." + c, shape.children[c], docs);
}
}

globalThis.help = helpText;
globalThis.emit = emitValue;

function describeError(error, kind) {
const isError = error instanceof Error;
return {
Expand All @@ -354,6 +478,7 @@ export default class ReplRunner extends WorkerEntrypoint {
for (let i = 0; i < cells.length - 1; i++) {
fx.mode = "serve";
fx.queue = (effectLog[i] || []).slice();
emits.entries = [];
injectGrants(perCell[i]);
try {
await cells[i]();
Expand All @@ -377,6 +502,7 @@ export default class ReplRunner extends WorkerEntrypoint {
fx.recorded = [];
logs.entries = [];
logs.dropped = 0;
emits.entries = [];
injectGrants(current);
let value;
try {
Expand All @@ -387,14 +513,19 @@ export default class ReplRunner extends WorkerEntrypoint {
phase: "cell",
error: describeError(error, undefined),
logs: snapshotLogs(),
// A failing cell's emits still ship (they narrate the failure);
// the cell itself is never committed.
results: emits.entries.slice(),
};
}

const outcome = {
ok: true,
effects: fx.recorded,
logs: snapshotLogs(),
results: [],
// Emitted entries first; an unclonable completion value's rendering
// appends after them (Jupyter display order).
results: emits.entries.slice(),
};
try {
// A capability handle is not a value — it structured-clones as an
Expand Down
4 changes: 2 additions & 2 deletions packages/computer/src/repl/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ interface RunOutcome {
error?: ReplExecutionError;
effects?: ReplEffect[];
logs?: { entries: ReplLogEntry[]; dropped?: number };
results?: Array<{ text: string }>;
results?: Array<{ text: string; value?: unknown }>;
hasValue?: boolean;
value?: unknown;
}
Expand Down Expand Up @@ -191,7 +191,7 @@ export class ReplSession {
return {
code,
logs: outcome.logs ?? EMPTY_LOGS(),
results: [],
results: outcome.results ?? [],
error: outcome.error ?? { name: "Error", message: "REPL evaluation failed." },
executionCount,
};
Expand Down
170 changes: 170 additions & 0 deletions packages/computer/src/repl/tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// Unit tests for the framework-agnostic `js` tool definition: generated
// description, JSON Schema shape, session routing, input validation, and
// the shared text renderer. Real eval behavior is covered by the workerd
// integration suites; here the workspace is a recording fake.

import { describe, expect, it } from "vitest";

import { capability, fetchCapability } from "./capability.js";
import { createJsToolDefinition, type JsToolOptions, renderJsResultText } from "./tool.js";
import type { ReplExecutionResult } from "./types.js";

interface ReplCall {
name: string;
options: Record<string, unknown>;
code?: string;
}

function fakeWorkspace(calls: ReplCall[]) {
return {
repl(name: string, options: Record<string, unknown>) {
const call: ReplCall = { name, options };
calls.push(call);
return {
eval(code: string): Promise<ReplExecutionResult> {
call.code = code;
return Promise.resolve({
code,
value: `ran in ${name}`,
logs: { entries: [] },
results: [],
executionCount: 1,
});
},
};
},
};
}

const LOADER = {} as JsToolOptions["loader"];

function makeTool(overrides: Partial<JsToolOptions> = {}) {
const calls: ReplCall[] = [];
const definition = createJsToolDefinition({
workspace: fakeWorkspace(calls),
loader: LOADER,
...overrides,
});
return { definition, calls };
}

describe("createJsToolDefinition", () => {
it("describes persistence, built-ins, and each grant with its description", () => {
const { definition } = makeTool({
capabilities: {
weather: capability({ get: () => 1 }, { description: "Weather lookups" }),
fetch: fetchCapability({ allow: ["api.example.com"] }),
bare: capability(() => 0),
},
});
expect(definition.name).toBe("js");
expect(definition.description).toContain("persistent");
expect(definition.description).toContain("emit(value)");
expect(definition.description).toContain('session "main"');
expect(definition.description).toContain("weather (Weather lookups)");
expect(definition.description).toContain("fetch (HTTP fetch restricted to: api.example.com)");
expect(definition.description).toContain("bare");
expect(definition.description).toContain("async — always `await`");
expect(definition.description).toContain('help("name")');
});

it("says so when nothing is granted", () => {
const { definition } = makeTool();
expect(definition.description).toContain("No capabilities are granted");
expect(definition.description).toContain("help()");
});

it("names the configured default session in description and schema", () => {
const { definition } = makeTool({
defaultSession: "scratch",
capabilities: { weather: capability({ get: () => 1 }) },
});
expect(definition.description).toContain('session "scratch"');
expect(JSON.stringify(definition.inputSchema)).toContain('\\"scratch\\"');
});

it("exposes a plain JSON Schema requiring only code", () => {
const { definition } = makeTool();
expect(definition.inputSchema.type).toBe("object");
expect(definition.inputSchema.required).toEqual(["code"]);
expect(definition.inputSchema.additionalProperties).toBe(false);
const code = definition.inputSchema.properties.code as { type: string };
const sessionName = definition.inputSchema.properties.sessionName as { type: string };
expect(code.type).toBe("string");
expect(sessionName.type).toBe("string");
});

it("routes to the default session and forwards grants and limits", async () => {
const weather = capability({ get: () => 1 });
const { definition, calls } = makeTool({
capabilities: { weather },
timeoutMs: 5_000,
maxEffectBytes: 1_024,
});
const result = await definition.run({ code: "1 + 1" });
expect(result.value).toBe("ran in main");
expect(calls).toHaveLength(1);
expect(calls[0].name).toBe("main");
expect(calls[0].code).toBe("1 + 1");
expect(calls[0].options).toEqual({
loader: LOADER,
capabilities: { weather },
timeoutMs: 5_000,
maxEffectBytes: 1_024,
});
});

it("routes sessionName overrides and omits unset limits", async () => {
const { definition, calls } = makeTool();
const result = await definition.run({ code: "2", sessionName: "notes" });
expect(result.value).toBe("ran in notes");
expect(calls[0].name).toBe("notes");
expect(calls[0].options).toEqual({ loader: LOADER, capabilities: {} });
});

it("rejects caller mistakes with TypeErrors", async () => {
const { definition } = makeTool();
await expect(definition.run({ code: 1 as unknown as string })).rejects.toThrow(TypeError);
await expect(definition.run({ code: "1", sessionName: "" })).rejects.toThrow(TypeError);
expect(() => createJsToolDefinition({
workspace: fakeWorkspace([]),
loader: LOADER,
defaultSession: "",
})).toThrow(TypeError);
});
});

describe("renderJsResultText", () => {
const base = { code: "", logs: { entries: [] }, results: [], executionCount: 1 };

it("renders logs, emits, and value in execution order", () => {
const text = renderJsResultText({
...base,
value: { total: 3 },
logs: { entries: [{ level: "warn" as const, text: "careful" }], dropped: 2 },
results: [{ text: "emitted-one", value: 1 }],
});
expect(text).toBe(
'[warn] careful\n(2 more log entries dropped)\nemitted-one\nvalue: {"total":3}',
);
});

it("renders structured errors with kind and traceback", () => {
const text = renderJsResultText({
...base,
error: {
name: "StaleLeaseError",
message: "handle died",
kind: "stale-lease",
traceback: "at cell:1",
},
});
expect(text).toBe("StaleLeaseError [stale-lease]: handle died\nat cell:1");
});

it("renders undefined and unstringifiable values honestly", () => {
expect(renderJsResultText({ ...base, value: undefined })).toBe("value: undefined");
expect(renderJsResultText({ ...base, value: 10n })).toBe('value: "10n"');
expect(renderJsResultText(base)).toBe("value: undefined");
});
});
Loading
Loading