@earendil-works/pi-coding-agent
Version:
Coding agent CLI with read, bash, edit, write tools and session management
740 lines • 34.7 kB
JavaScript
/**
* Runs one codemode script in the sandbox. Split from tool.ts and loaded through
* execute.lazy.ts so the sandbox runtime only loads when a script runs.
*/
import { CodemodeSandbox, loadQuickJSWasm, parseCodemodeSource, renderToolSample, toCodemodeIdentifier, } from "@earendil-works/pi-codemode";
import { getCodemodeWorkerSpecifier, getQuickJSWasmPath } from "../../config.js";
import { formatSize, sliceHead, sliceTail } from "../../core/tools/truncate.js";
import { combineUsage } from "../../core/usage-totals.js";
import { writeOutputFile } from "../../utils/output-files.js";
import { Bm25Ranker, createToolSearchDocument, DEFAULT_TOOL_SEARCH_LIMIT } from "../tool-search/tool.js";
import { CODEMODE_DOCS_PATH, CODEMODE_STORE_ENTRY_TYPE, getCodemodeCallableTools, toCodemodeDeclaration, } from "./tool.js";
const ARGS_PREVIEW_CHARS = 200;
const ERROR_PREVIEW_CHARS = 500;
/** `models.classify()` and `models.generateImages()` calls one script may have in flight; `Promise.all` over many items queues the rest. */
const MAX_CONCURRENT_MODEL_CALLS = 4;
/**
* Heap limit for the QuickJS VM. The worker shares pi's process, so without a limit a runaway
* script can grow to wasm32's 4 GiB and take the session down. Overruns throw
* `InternalError: out of memory` inside the script.
*/
const CODEMODE_MEMORY_LIMIT_BYTES = 256 * 1024 * 1024;
// Shared across calls and sessions. Allow one replacement while a cancelled worker is still exiting.
const DEFAULT_MAX_CODEMODE_WORKERS = 2;
/** `PI_CODEMODE_MAX_WORKERS` if it is a positive integer. Read per call so SDK hosts can set it at runtime. */
function maxCodemodeWorkers() {
const value = Number(process.env.PI_CODEMODE_MAX_WORKERS);
return Number.isInteger(value) && value > 0 ? value : DEFAULT_MAX_CODEMODE_WORKERS;
}
/**
* How long a script waits for a free worker before failing. Bounds the wait when every worker is
* held by a script that waits on another script, for example an in-process subagent that uses codemode.
*/
const CODEMODE_WORKER_WAIT_MS = 60_000;
let activeCodemodeWorkers = 0;
/** Scripts waiting for a worker, in arrival order. */
const codemodeWorkerWaiters = [];
/**
* Reserve one worker slot, waiting in arrival order while all are taken. Resolves with why it could
* not reserve one: the signal aborted, or `timeoutMs` passed.
*/
function reserveCodemodeWorker(signal, timeoutMs) {
if (signal?.aborted)
return Promise.resolve("aborted");
return new Promise((resolve) => {
const settle = (outcome) => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
const index = codemodeWorkerWaiters.indexOf(grant);
if (index >= 0)
codemodeWorkerWaiters.splice(index, 1);
resolve(outcome);
};
const grant = () => settle("reserved");
const onAbort = () => settle("aborted");
const timer = setTimeout(() => settle("timeout"), timeoutMs);
codemodeWorkerWaiters.push(grant);
signal?.addEventListener("abort", onAbort, { once: true });
admitCodemodeWorkers();
});
}
/** Give free slots to waiting scripts in arrival order. */
function admitCodemodeWorkers() {
while (codemodeWorkerWaiters.length > 0 && activeCodemodeWorkers < maxCodemodeWorkers()) {
activeCodemodeWorkers++;
codemodeWorkerWaiters.shift()?.();
}
}
function releaseCodemodeWorker() {
activeCodemodeWorkers--;
admitCodemodeWorkers();
}
const MODEL_TYPES = new Set(["chat", "image", "classifier"]);
function truncateText(text, maxChars) {
return text.length > maxChars ? `${sliceHead(text, maxChars - 3)}...` : text;
}
function previewArgs(args) {
if (args === undefined)
return "";
try {
return truncateText(JSON.stringify(args) ?? "", ARGS_PREVIEW_CHARS);
}
catch {
return "";
}
}
function textOf(result) {
return (result.content ?? [])
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("\n");
}
function toModelType(value) {
if (typeof value === "string" && MODEL_TYPES.has(value))
return value;
throw new Error(`Unknown model type ${JSON.stringify(value)}. Use "chat", "image", or "classifier".`);
}
function toProvider(value) {
if (value === undefined || value === null)
return undefined;
if (typeof value !== "string")
throw new Error("provider must be a string");
return value;
}
/** Catalog entry for scripts. `headers` is dropped because models.json headers can carry credentials. */
function toModelInfo(model) {
const info = { ...model };
delete info.headers;
return info;
}
/** `an image`, `a classifier`. */
function withArticle(word) {
return `${/^[aeiou]/.test(word) ? "an" : "a"} ${word}`;
}
/** A script value in an error message: `undefined`, `a string`, `an array`, or its keys (`{ prompt }`). */
function describeValue(value) {
if (value === undefined || value === null)
return String(value);
if (Array.isArray(value))
return value.length === 0 ? "an empty array" : "an array";
if (typeof value === "object") {
const keys = Object.keys(value);
if (keys.length === 0)
return "{}";
return `{ ${keys.slice(0, 6).join(", ")}${keys.length > 6 ? ", ..." : ""} }`;
}
return typeof value === "string" ? "a string" : `a ${typeof value}`;
}
const CLASSIFIER_CONTEXT_SHAPE = '{ state: { ... }, images?: [{ type: "image", data: <base64>, mimeType }], questions: { <id>: { type: "choice", instructions, criteria: { <label>: <meaning> } } | { type: "score", instructions, criteria: [<lowest level>, ..., <highest level>] } | { type: "bool", instructions, criteria: { true: <meaning>, false: <meaning> } } } }';
/** Check a script's classifier context, so mistakes fail with the expected shape instead of a provider error. */
function checkClassifierContext(context) {
const fail = (problem) => new Error(`models.classify() ${problem}. Expected context: ${CLASSIFIER_CONTEXT_SHAPE}. See "Classify" in ${CODEMODE_DOCS_PATH}.`);
if (typeof context !== "object" || context === null || Array.isArray(context)) {
throw fail(`expects a context object as its second argument, got ${describeValue(context)}`);
}
const { state, images, questions } = context;
if (typeof state !== "object" || state === null || Array.isArray(state)) {
throw fail(`context.state must be an object, got ${describeValue(state)}`);
}
if (images !== undefined) {
if (!Array.isArray(images))
throw fail(`context.images must be an array, got ${describeValue(images)}`);
images.forEach((image, index) => {
if (image?.type !== "image" || typeof image.data !== "string" || typeof image.mimeType !== "string") {
throw fail(`context.images[${index}] must be an image block, got ${describeValue(image)}`);
}
});
}
if (typeof questions !== "object" ||
questions === null ||
Array.isArray(questions) ||
Object.keys(questions).length === 0) {
throw fail(`context.questions must map question IDs to questions, got ${describeValue(questions)}`);
}
const isStrings = (values) => values.length > 0 && values.every((value) => typeof value === "string");
for (const [id, question] of Object.entries(questions)) {
const at = `context.questions.${id}`;
if (typeof question !== "object" || question === null || Array.isArray(question)) {
throw fail(`${at} must be a question object, got ${describeValue(question)}`);
}
const { type, instructions, criteria } = question;
if (typeof instructions !== "string")
throw fail(`${at}.instructions must be a string`);
if (type === "choice") {
if (typeof criteria !== "object" ||
criteria === null ||
Array.isArray(criteria) ||
!isStrings(Object.values(criteria))) {
throw fail(`${at} is a "choice" question, so criteria must map each label to its meaning`);
}
}
else if (type === "score") {
if (!Array.isArray(criteria) || !isStrings(criteria)) {
throw fail(`${at} is a "score" question, so criteria must list the levels as strings, lowest first`);
}
}
else if (type === "bool") {
const labels = criteria;
if (typeof labels?.true !== "string" || typeof labels.false !== "string") {
throw fail(`${at} is a "bool" question, so criteria must be { true: string, false: string }`);
}
}
else {
throw fail(`${at}.type must be "choice", "score", or "bool", got ${JSON.stringify(type)}`);
}
}
return context;
}
/** Check a script's image context, so mistakes such as `{ prompt }` fail with the expected shape. */
function checkImagesContext(context) {
const fail = (problem) => new Error(`models.generateImages() ${problem}. Expected context: { input: [{ type: "text", text: <prompt> }, ...optional { type: "image", data: <base64>, mimeType } references] }. See "Generate images" in ${CODEMODE_DOCS_PATH}.`);
if (typeof context !== "object" || context === null || Array.isArray(context)) {
throw fail(`expects a context object as its second argument, got ${describeValue(context)}`);
}
const { input } = context;
if (!Array.isArray(input) || input.length === 0) {
throw fail(`context.input must be a non-empty array of blocks, got ${describeValue(input)}`);
}
input.forEach((block, index) => {
if (block?.type === "text" && typeof block.text === "string")
return;
if (block?.type === "image" && typeof block.data === "string" && typeof block.mimeType === "string")
return;
throw fail(`context.input[${index}] must be a text or image block, got ${describeValue(block)}`);
});
return context;
}
/** Runs at most `limit` calls at once, in call order. */
function createLimiter(limit) {
let active = 0;
const waiting = [];
return async (run) => {
if (active >= limit)
await new Promise((resolve) => waiting.push(resolve));
active++;
try {
return await run();
}
finally {
active--;
waiting.shift()?.();
}
};
}
function isStoreEntryData(data) {
if (typeof data !== "object" || data === null)
return false;
const { set, delete: deleted } = data;
return (typeof set === "object" &&
set !== null &&
Array.isArray(deleted) &&
deleted.every((key) => typeof key === "string"));
}
/** Values of `load()`: the `codemode-store` entries on the branch, applied from the root. */
export function readCodemodeStore(branch) {
const store = new Map();
for (const entry of branch) {
if (entry.type !== "custom" || entry.customType !== CODEMODE_STORE_ENTRY_TYPE || !isStoreEntryData(entry.data)) {
continue;
}
for (const key of entry.data.delete)
store.delete(key);
for (const [key, value] of Object.entries(entry.data.set))
store.set(key, value);
}
return Object.fromEntries(store);
}
/** Default token budget for script output. */
const DEFAULT_MAX_OUTPUT_TOKENS = 10_000;
/** Characters per token when estimating. */
const CHARS_PER_TOKEN = 4;
/** Like the script's `text()`: strings as is, other values as compact JSON. */
function valueText(value) {
if (typeof value === "string")
return value;
return JSON.stringify(value) ?? String(value);
}
/**
* Lay out the script's output so the model can tell items apart: providers join adjacent text
* blocks with a newline or with nothing. With more than one text item (`text()` or the returned
* value), each starts with a `==> text N/M <==` line. `console.*` lines follow all other output in
* one `<console_output>` block.
*/
function formatOutput(output) {
const total = output.filter((item) => item.type === "text" && !item.console).length;
const items = [];
const consoleLines = [];
let index = 0;
for (const item of output) {
if (item.type === "image") {
items.push(item);
}
else if (item.console) {
consoleLines.push(item.text);
}
else {
index++;
items.push({ type: "text", text: total > 1 ? `==> text ${index}/${total} <==\n${item.text}` : item.text });
}
}
if (consoleLines.length > 0) {
items.push({ type: "text", text: `<console_output>\n${consoleLines.join("\n")}\n</console_output>` });
}
return items;
}
/**
* Join adjacent text items into one, each part starting on its own line. The separator check looks
* at the last non-empty part, not the joined text: reading the end of a growing concatenation makes
* V8 copy it on every item, which is quadratic (#10678).
*/
function joinAdjacentText(items) {
const joined = [];
let parts = [];
let lastPart = "";
const flush = () => {
if (parts.length > 0)
joined.push({ type: "text", text: parts.join("") });
parts = [];
lastPart = "";
};
for (const item of items) {
if (item.type !== "text") {
flush();
joined.push(item);
continue;
}
if (lastPart !== "" && !lastPart.endsWith("\n")) {
parts.push("\n");
lastPart = "\n";
}
parts.push(item.text);
if (item.text !== "")
lastPart = item.text;
}
flush();
return joined;
}
function formatCallSummary(calls) {
if (calls.length === 0)
return "No tool calls were made.";
return `Tool calls made before the failure (they are not undone): ${calls.map((call) => `${call.name} (${call.status})`).join(", ")}`;
}
function formatError(result, calls) {
const { error } = result;
const head = error.kind === "script"
? (error.stack ?? `${error.name ?? "Error"}: ${error.message}`)
: error.kind === "timeout"
? `Script timed out: ${error.message}`
: error.kind === "aborted"
? `Script aborted: ${error.message}`
: `Script sandbox failed: ${error.message}`;
return `${head}\n\n${formatCallSummary(calls)}`;
}
/** Write the full text output to a temp file, like bash does for truncated output. */
async function spillOutput(text) {
try {
return { path: await writeOutputFile("pi-codemode", ".txt", text) };
}
catch (error) {
return { error: error instanceof Error ? error.message : String(error) };
}
}
/** File extensions of the image types `image()` accepts. Must list every type the sandbox's `image()` detects. */
const IMAGE_EXTENSIONS = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
};
/**
* Save each image to a temp file and put a text item with its path before it. The model sees the
* image but has no other way to reach its bytes: scripts cannot write files, and `write` only takes
* text. Images shown more than once are saved once.
*/
async function saveImages(items) {
const labels = new Map();
const label = async ({ data, mimeType }) => {
const bytes = Buffer.from(data, "base64");
const kind = `${mimeType}, ${formatSize(bytes.length)}`;
const extension = IMAGE_EXTENSIONS[mimeType];
if (!extension)
throw new Error(`No file extension for image type ${mimeType}`);
// A failed write (disk full, unwritable temp dir) must not discard the result of a script whose
// tool calls already ran, so it becomes part of the label.
try {
const path = await writeOutputFile("pi-codemode", extension, bytes);
return `[Image saved to ${path} (${kind})]`;
}
catch (error) {
return `[Image (${kind}) could not be saved: ${error instanceof Error ? error.message : String(error)}]`;
}
};
const result = await Promise.all(items.map(async (item) => {
if (item.type !== "image")
return [item];
let pending = labels.get(item.data);
if (!pending) {
pending = label(item);
labels.set(item.data, pending);
}
return [{ type: "text", text: await pending }, item];
}));
return result.flat();
}
/**
* Apply the token budget: when the combined text exceeds it, the text items become one
* item that keeps the start and end of the text, and images follow it. The full text is written to
* a temp file.
*/
async function truncateOutput(items, maxTokens) {
const texts = items.filter((item) => item.type === "text").map((item) => item.text);
const combined = texts.join("\n");
const budget = maxTokens * CHARS_PER_TOKEN;
if (texts.length === 0 || combined.length <= budget)
return { items };
const headChars = Math.floor(budget / 2);
const tailChars = budget - headChars;
const removed = combined.length - headChars - tailChars;
const head = sliceHead(combined, headChars);
const tail = sliceTail(combined, tailChars);
let text = `Warning: truncated output (original token count: ${Math.ceil(combined.length / CHARS_PER_TOKEN)})\nTotal output lines: ${combined.split("\n").length}\n\n${head}…${Math.ceil(removed / CHARS_PER_TOKEN)} tokens truncated…${tail}`;
const spilled = await spillOutput(combined);
text +=
"path" in spilled
? `\n\n[Full output: ${spilled.path} (read with offset/limit)]`
: `\n\n[Could not save the full output: ${spilled.error}]`;
return {
items: [{ type: "text", text }, ...items.filter((item) => item.type === "image")],
...("path" in spilled ? { fullOutputPath: spilled.path } : {}),
};
}
/**
* The value a script receives for a nested call: a tool that declares
* `outputSchema` resolves to its `structuredContent`, also for error results that carry one (such
* as MCP results with `isError`); any other tool resolves to its text content. Other failures
* reject with the tool's error text.
*/
function toScriptValue(tool, outcome) {
const { result } = outcome;
if (tool.outputSchema && result.structuredContent !== undefined)
return result.structuredContent;
const text = textOf(result);
if (outcome.isError)
throw new Error(text || `Tool "${tool.name}" failed`);
return text;
}
/**
* Run one script. Without a session context (a plain Agent or a direct call) scripts cannot call
* tools, `store()` starts empty, and writes are dropped.
*/
export async function executeCodemode(toolCallId, input, signal, onUpdate, ctx, options = {}) {
const startedAt = performance.now();
const { code, options: sourceOptions } = parseCodemodeSource(input.code);
const calls = [];
// Usage of the script's `models.*` calls. Nested tool calls report theirs through the session.
let modelUsage;
// Images returned by `models.generateImages()`, to notice a script that never shows them.
let generatedImages = 0;
const addGeneratedImages = (count) => {
generatedImages += count;
};
const addModelUsage = (usage) => {
modelUsage = modelUsage ? combineUsage(modelUsage, usage) : usage;
};
const snapshot = () => ({ calls: calls.map((call) => ({ ...call })) });
const publish = () => onUpdate?.({ content: [], details: snapshot() });
// Show the script as running before its first nested call, so the row's timer starts.
publish();
const callable = ctx ? getCodemodeCallableTools(ctx.tools) : [];
// ALL_TOOLS entries carry the declaration.
const guidelines = options.getToolGuidelines?.();
const samples = new Map(callable.map((tool) => [tool.name, renderToolSample(toCodemodeDeclaration(tool, guidelines?.get(tool.name)))]));
const sandboxTools = callable.map((tool) => ({
name: tool.name,
description: samples.get(tool.name),
execute: async (args, { signal: callSignal }) => {
const record = {
id: `${toolCallId}/?`,
name: tool.name,
args: previewArgs(args),
status: "running",
};
calls.push(record);
publish();
const callStartedAt = performance.now();
// Only tools from ctx.tools are callable, so ctx is set here.
if (!ctx)
throw new Error("Tool calls need a session");
const outcome = await ctx.executeTool(tool.name, args, { signal: callSignal });
record.id = outcome.toolCall.id;
record.durationMs = performance.now() - callStartedAt;
if (outcome.isError) {
record.status = callSignal.aborted ? "cancelled" : "error";
record.error = truncateText(textOf(outcome.result) || `Tool "${tool.name}" failed`, ERROR_PREVIEW_CHARS);
}
else {
record.status = "ok";
}
publish();
return toScriptValue(tool, outcome);
},
}));
// The wait for a worker counts toward the script's deadline.
const deadline = sourceOptions.timeoutMs ?? Number.POSITIVE_INFINITY;
const waitStartedAt = performance.now();
const admission = await reserveCodemodeWorker(signal, Math.min(deadline, CODEMODE_WORKER_WAIT_MS));
const waitedMs = performance.now() - waitStartedAt;
if (admission !== "reserved") {
const waited = ((performance.now() - startedAt) / 1000).toFixed(1);
const reason = signal?.reason;
const error = admission === "aborted"
? `Script aborted: ${reason instanceof Error ? reason.message : "Execution aborted"}`
: deadline <= CODEMODE_WORKER_WAIT_MS
? `Script timed out: Execution timed out after ${deadline} ms while waiting for a free codemode worker`
: `No codemode worker became free within ${CODEMODE_WORKER_WAIT_MS / 1000} seconds. All ${maxCodemodeWorkers()} workers, shared by every session in this process, are held by other scripts. Retry after they finish.`;
return {
content: [
{ type: "text", text: `Script failed\nWall time ${waited} seconds\nOutput:\n` },
{ type: "text", text: `Script error:\n${error}\n\nThe script did not start.` },
],
details: snapshot(),
isError: true,
};
}
// The reservation covers the one worker this script starts. A launch that resumes after the result
// settled finds it released and starts no worker.
let reserved = true;
let sandbox;
let result;
try {
sandbox = new CodemodeSandbox({
tools: sandboxTools,
globals: [
...createDiscoveryGlobals(callable, samples, options),
...(options.models && ctx
? createModelGlobals(ctx.modelRegistry, toolCallId, calls, publish, addModelUsage, addGeneratedImages)
: []),
],
timeoutMs: Math.max(1, Math.round(deadline - waitedMs)),
memoryLimitBytes: CODEMODE_MEMORY_LIMIT_BYTES,
wasm: loadQuickJSWasm(getQuickJSWasmPath()),
workerUrl: getCodemodeWorkerSpecifier(),
acquireWorker: () => {
if (!reserved)
throw new Error("The script already settled");
// From here the sandbox releases the slot when the worker actually exits.
reserved = false;
return releaseCodemodeWorker;
},
});
const store = ctx ? readCodemodeStore(ctx.sessionManager.getBranch()) : {};
result = await sandbox.execute(code, { signal, store });
}
finally {
await sandbox?.close();
if (reserved) {
reserved = false;
releaseCodemodeWorker();
}
}
// Calls still marked running were cut off by the script ending, a timeout, or an abort.
for (const call of calls) {
if (call.status === "running")
call.status = "cancelled";
}
const scriptOutput = [...result.output];
if (result.ok) {
const { set, delete: deleted } = result.storeWrites;
if (Object.keys(set).length > 0 || deleted.length > 0) {
options.appendEntry?.(CODEMODE_STORE_ENTRY_TYPE, { set, delete: deleted });
}
// pi extension: a returned value is appended like text().
if (result.value !== undefined)
scriptOutput.push({ type: "text", text: valueText(result.value) });
}
const items = formatOutput(scriptOutput);
if (!result.ok)
items.push({ type: "text", text: `Script error:\n${formatError(result, calls)}` });
if (generatedImages > 0 && !items.some((item) => item.type === "image")) {
items.push({
type: "text",
text: `Note: models.generateImages() returned ${generatedImages} image${generatedImages === 1 ? "" : "s"} that the script did not show. Show each image block of result.output with image(block).`,
});
}
const truncated = await truncateOutput(joinAdjacentText(items), sourceOptions.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS);
// After truncation, which joins the text items and moves images after them, so each path stays
// next to its image and is never cut.
const output = joinAdjacentText(await saveImages(truncated.items));
const wallTime = ((performance.now() - startedAt) / 1000).toFixed(1);
const header = `${result.ok ? "Script completed" : "Script failed"}\nWall time ${wallTime} seconds\nOutput:\n`;
const details = snapshot();
if (truncated.fullOutputPath)
details.fullOutputPath = truncated.fullOutputPath;
return {
content: [{ type: "text", text: header }, ...output],
details,
...(modelUsage ? { usage: modelUsage } : {}),
...(result.ok ? {} : { isError: true }),
};
}
/**
* Whether `query` names the namespace: its name or one of its aliases, as written or as the same
* script identifier (`dev-radius` and `dev_radius` are both `dev_radius`).
*/
function isNamespaceName(namespace, query) {
const queryId = toCodemodeIdentifier(query);
return [namespace.name, ...(namespace.aliases ?? [])].some((name) => name === query || toCodemodeIdentifier(name) === queryId);
}
/**
* `searchTools()`, `describeTool()`, and `describeNamespace()`: ranked search and lookup over the
* script's nested tools and their namespaces.
*/
function createDiscoveryGlobals(tools, samples, options) {
const ranker = new Bm25Ranker();
const entry = (name) => ({ name: toCodemodeIdentifier(name), description: samples.get(name) ?? "" });
return [
{
name: "searchTools",
spread: true,
execute: (args) => {
const [query, searchOptions] = args;
if (typeof query !== "string")
throw new Error("searchTools() expects a query string");
const limit = searchOptions?.limit ?? DEFAULT_TOOL_SEARCH_LIMIT;
if (typeof limit !== "number" || !Number.isInteger(limit) || limit <= 0) {
throw new Error("searchTools() limit must be a positive integer");
}
const namespace = searchOptions?.namespace;
if (namespace !== undefined && namespace !== null && typeof namespace !== "string") {
throw new Error("searchTools() namespace must be a string");
}
const documents = tools.flatMap((tool) => {
const toolNamespace = options.getToolNamespace?.(tool.name);
if (namespace && (!toolNamespace || !isNamespaceName(toolNamespace, namespace)))
return [];
return [createToolSearchDocument(tool, toolNamespace)];
});
return ranker.rank(query, documents, limit).map((match) => entry(match.name));
},
},
{
name: "describeTool",
spread: true,
execute: (args) => {
const [name] = args;
if (typeof name !== "string")
throw new Error("describeTool() expects a tool name");
const tool = tools.find((candidate) => candidate.name === name || toCodemodeIdentifier(candidate.name) === name);
return tool ? samples.get(tool.name) : undefined;
},
},
{
name: "describeNamespace",
spread: true,
execute: (args) => {
const [name] = args;
if (typeof name !== "string")
throw new Error("describeNamespace() expects a namespace name");
let namespace;
const names = [];
for (const tool of tools) {
const toolNamespace = options.getToolNamespace?.(tool.name);
if (!toolNamespace || !isNamespaceName(toolNamespace, name))
continue;
namespace ??= toolNamespace;
names.push(toCodemodeIdentifier(tool.name));
}
if (!namespace)
return undefined;
return {
name: namespace.name,
...(namespace.description ? { description: namespace.description } : {}),
...(namespace.instructions ? { instructions: namespace.instructions } : {}),
tools: names,
};
},
},
];
}
/**
* `models.*` for scripts: the model registry methods documented in docs/codemode.md.
* Classifier and image calls appear as nested call rows so the renderer shows them, and their usage
* goes to `addUsage`. Rows show only the model, never prompts or image data.
*/
function createModelGlobals(models, toolCallId, calls, publish, addUsage, addGeneratedImages) {
const limit = createLimiter(MAX_CONCURRENT_MODEL_CALLS);
let callCount = 0;
/**
* Resolve the script's model by provider and id only, check the context, then run the call as a
* nested call row. A script-supplied baseUrl or headers must never receive the credentials.
*/
const runModelCall = async (name, type, [model, context], checkContext, run) => {
const listHint = `List the ${type} models you can use with models.getAvailableOfType("${type}").`;
const given = model;
if (typeof given?.provider !== "string" || typeof given.id !== "string") {
// undefined arrives as null: spread arguments cross the sandbox as a JSON array.
const undefinedHint = model === undefined || model === null
? " models.getModelOfType() returns undefined for an unknown provider or id."
: "";
throw new Error(`${name}() expects ${withArticle(type)} model as its first argument, got ${describeValue(model)}.${undefinedHint} ${listHint}`);
}
const provider = given.provider;
const id = given.id;
const ref = `${provider}/${id}`;
const resolved = models.getModelOfType(type, provider, id);
if (!resolved) {
const actualType = [...MODEL_TYPES].find((other) => other !== type && models.getModelOfType(other, provider, id) !== undefined);
throw new Error(actualType
? `"${ref}" is ${withArticle(actualType)} model, not ${withArticle(type)} model. ${listHint}`
: `Unknown ${type} model "${ref}". ${listHint}`);
}
const checked = checkContext(context);
const record = {
id: `${toolCallId}/${name}/${++callCount}`,
name,
args: `${resolved.provider}/${resolved.id}`,
status: "running",
};
calls.push(record);
publish();
const startedAt = performance.now();
const result = await limit(() => run(resolved, checked));
record.durationMs = performance.now() - startedAt;
record.status = result.stopReason === "stop" ? "ok" : result.stopReason === "aborted" ? "cancelled" : "error";
if (result.errorMessage)
record.error = truncateText(result.errorMessage, ERROR_PREVIEW_CHARS);
if (result.usage) {
record.cost = result.usage.cost.total;
addUsage(result.usage);
}
publish();
return result;
};
const implementations = {
"models.getModelsOfType": (args) => {
const [type, provider] = args;
return models.getModelsOfType(toModelType(type), toProvider(provider)).map(toModelInfo);
},
"models.getAvailableOfType": async (args, { signal }) => {
const [type, provider] = args;
const available = await models.getAvailableOfType(toModelType(type), toProvider(provider), { signal });
return available.map(toModelInfo);
},
"models.getModelOfType": (args) => {
const [type, provider, id] = args;
if (typeof provider !== "string" || typeof id !== "string") {
throw new Error(`models.getModelOfType(type, provider, id) expects three strings, got (${args.map(describeValue).join(", ")}). The provider and the id are separate arguments, for example models.getModelOfType("classifier", "typesafe", "jev-latest").`);
}
const model = models.getModelOfType(toModelType(type), provider, id);
return model === undefined ? undefined : toModelInfo(model);
},
"models.classify": (args, { signal }) => runModelCall("models.classify", "classifier", args, checkClassifierContext, (resolved, context) => models.classify(resolved, context, { signal })),
"models.generateImages": (args, { signal }) => runModelCall("models.generateImages", "image", args, checkImagesContext, async (resolved, context) => {
const result = await models.generateImages(resolved, context, { signal });
addGeneratedImages(result.output.filter((block) => block.type === "image").length);
return result;
}),
};
return Object.entries(implementations).map(([name, execute]) => ({ name, spread: true, execute }));
}
//# sourceMappingURL=execute.js.map