UNPKG

@earendil-works/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

740 lines • 34.7 kB
/** * 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