chore(vendor): import pi 1.3.0

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
2026-10-15 17:58:48 -04:00
parent 719370c8f7
commit 6081e3e325
1743 changed files with 205460 additions and 131705 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,157 @@
/**
* What the model reads about code mode, copied from the coding agent's code mode: the tool's fixed description (the
* script environment and its globals), and the `codemode` prompt section, which lists the tools scripts can call as
* TypeScript declarations, grouped by namespace and limited to a token budget. Tools that do not fit are found with
* `searchTools()`. The section renders from the conversation's callable tools, so it changes with them; the tool's
* description never does.
*/
import type { CodemodeJsonSchema } from "@earendil-works/pi-codemode";
import { renderToolSample, toCodemodeIdentifier } from "@earendil-works/pi-codemode/declarations";
import type { ToolRegistration } from "../harness/types.ts";
import { MODELS_DESCRIPTION } from "./models.ts";
/** A group of tools named by a common prefix, such as an MCP server's `mcp__github__`. */
export type CodemodeNamespace = {
readonly name: string;
readonly description?: string;
readonly instructions?: string;
/** Tool names starting with it belong to the namespace; the longest matching prefix wins. */
readonly prefix: string;
};
/** Default token budget of the tool declarations in the `codemode` section, as in the coding agent. */
export const DEFAULT_CODEMODE_INLINE_BUDGET = 3000;
/** Characters per token when estimating the cost of a tool section. */
const CHARS_PER_TOKEN = 4;
const TEXT_OUTPUT_SCHEMA: CodemodeJsonSchema = { type: "string" };
const DESCRIPTION_INTRO = `Run JavaScript that calls other tools. The input is raw JavaScript (not JSON, no code fence), run as an async function body in a QuickJS sandbox: top-level \`await\` and \`return\` work. No Node, file system, network, or timers.
- \`await tools.<name>({ ...args })\` resolves to a string, or an object if the tool's declaration says so, and rejects with an Error on failure. A tool whose declaration returns an object resolves on failure too when it returned that object; check its fields. Calls still running when the script ends are cancelled.
- The script survives a restart: it resumes where it was, without running finished calls again.
- Optional first line: \`// @options: {"max_output_tokens": 10000, "timeout_ms": 60000}\``;
/** The code mode tool's description: the script environment and its globals, the same for every conversation. */
export function codemodeDescription(models: boolean): string {
const globals = [
"Globals:",
"- `text(value)`, `image(dataUrlOrImageBlock)`, `console.log(...)`, and top-level `return` add output; `exit()` ends the script. With several text items, each starts with a `==> text N/M <==` line, and `console` lines follow the other output in one `<console_output>` block. `image()` also saves the image to a temp file and the result names its path.",
"- `store(key, value)` and `load(key)` keep JSON values across codemode calls.",
"- `ALL_TOOLS`, `await searchTools(query, { limit?, namespace? })`, `await describeTool(name)`, `await describeNamespace(name)`: find tools the `codemode` section does not list.",
];
return [
DESCRIPTION_INTRO,
globals.join("\n"),
...(models ? [MODELS_DESCRIPTION] : []),
"The `codemode` section of the system prompt lists the tools scripts can call, by namespace, as far as space allows.",
].join("\n\n");
}
/** What a script sees of a tool: its declaration. Tools without an output schema resolve to their text output. */
export function declarationOf(tool: ToolRegistration): {
name: string;
description: string;
inputSchema: CodemodeJsonSchema;
outputSchema: CodemodeJsonSchema;
} {
return {
name: tool.name,
description: tool.description,
inputSchema: tool.parameters as CodemodeJsonSchema,
outputSchema: (tool.structuredOutputSchema as CodemodeJsonSchema | undefined) ?? TEXT_OUTPUT_SCHEMA,
};
}
/** `toolSample` of a tool: its description and TypeScript declaration. */
export function sampleOf(tool: ToolRegistration): string {
return renderToolSample(declarationOf(tool));
}
/** The namespace of a tool: the one with the longest prefix its name starts with. */
export function namespaceOf(name: string, namespaces: readonly CodemodeNamespace[]): CodemodeNamespace | undefined {
let found: CodemodeNamespace | undefined;
for (const namespace of namespaces) {
if (name.startsWith(namespace.prefix) && (found === undefined || namespace.prefix.length > found.prefix.length)) {
found = namespace;
}
}
return found;
}
/** `### \`id\` (\`raw name\`)` followed by the tool's description and declaration. */
function renderToolSection(tool: ToolRegistration): string {
const id = toCodemodeIdentifier(tool.name);
const heading = id === tool.name ? `### \`${id}\`` : `### \`${id}\` (\`${tool.name}\`)`;
return `${heading}\n${sampleOf(tool).trim()}`;
}
type CatalogEntry = { name: string; section: string; cost: number };
type CatalogGroup = { namespace: CodemodeNamespace | undefined; entries: CatalogEntry[] };
/**
* Pick the tool sections that fit the budget: in each round every group (tools without a namespace first, then
* namespaces by name) places its cheapest remaining tool; a group whose next tool does not fit drops out while the
* others continue. Every namespace is represented before any namespace is complete.
*/
function selectCatalog(groups: readonly CatalogGroup[], budget: number): Set<string> {
const queues = groups.map((group) => [...group.entries].sort((a, b) => a.cost - b.cost));
const shown = new Set<string>();
let remaining = budget;
let active = queues.filter((queue) => queue.length > 0);
while (active.length > 0) {
active = active.filter((queue) => {
const next = queue[0]!;
if (next.cost > remaining) return false;
remaining -= next.cost;
shown.add(next.name);
queue.shift();
return queue.length > 0;
});
}
return shown;
}
/**
* The `codemode` section: the tools scripts can call, grouped by namespace, with their declarations as far as the
* budget allows. `undefined` without callable tools.
*/
export function codemodeSection(
tools: readonly ToolRegistration[],
namespaces: readonly CodemodeNamespace[],
budget: number,
): string | undefined {
if (tools.length === 0) return undefined;
const groups = new Map<string, CatalogGroup>([["", { namespace: undefined, entries: [] }]]);
for (const tool of tools) {
const namespace = namespaceOf(tool.name, namespaces);
const key = namespace ? `ns:${namespace.name}` : "";
const group = groups.get(key) ?? { namespace, entries: [] };
groups.set(key, group);
const section = renderToolSection(tool);
group.entries.push({ name: tool.name, section, cost: Math.ceil(section.length / CHARS_PER_TOKEN) });
}
const ordered = [...groups.values()].sort((a, b) =>
a.namespace === undefined ? -1 : b.namespace === undefined ? 1 : a.namespace.name.localeCompare(b.namespace.name),
);
const shown = selectCatalog(ordered, budget);
const toolSections = ["Tools scripts can call (`await tools.<name>(args)`):"];
for (const { namespace, entries } of ordered) {
if (entries.length === 0) continue;
const visible = entries.filter((entry) => shown.has(entry.name));
if (namespace) {
const listing =
visible.length === entries.length
? ""
: visible.length === 0
? " (tools not listed)"
: " (some tools not listed)";
const description = namespace.description?.trim();
toolSections.push(`## ${namespace.name}${listing}${description ? `\n${description}` : ""}`);
} else if (visible.length < entries.length) {
toolSections.push("Some tools are not listed: find them with `searchTools()`.");
}
for (const entry of visible) toolSections.push(entry.section);
}
return toolSections.join("\n\n");
}

View File

@@ -0,0 +1,13 @@
export {
CODEMODE_MODULE_VERSION,
CODEMODE_TOOL_NAME,
type CodemodeGlobal,
type CodemodeNestedCall,
CodemodeStoreDoc,
type CodemodeToolDetails,
type CodemodeToolInput,
type CodemodeToolOptions,
createCodemodeExtension,
createCodemodeTool,
} from "./codemode.ts";
export { type CodemodeNamespace, DEFAULT_CODEMODE_INLINE_BUDGET } from "./describe.ts";

View File

@@ -0,0 +1,285 @@
/**
* `models.*` for code mode scripts, copied from the coding agent's code mode: the model catalog, and classifier and
* image model calls.
*
* The catalog functions are host globals, journaled like any global. `models.classify()` and `models.generateImages()`
* are paid effects, so each runs as an internal tool (`callers: ["tools"]`, a reserved name, hidden from scripts'
* tool listings): a nested durable call with its own task, live row, abort, usage accounting, and replay policy. The
* code mode tool turns a script's call of the global into a call of that tool.
*/
import type { Context, JsonValue } from "@earendil-works/chord";
import type {
AnyModel,
ClassifierContext,
ImagesContext,
Models,
ModelType,
ModelTypeMap,
Usage,
} from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { defineTool } from "../harness/define.ts";
import type { ToolRegistration } from "../harness/types.ts";
/** Reserved names of code mode's internal tools; they are never listed to scripts or the model. */
export const CODEMODE_INTERNAL_PREFIX = "pi.codemode.";
export const CLASSIFY_TOOL = `${CODEMODE_INTERNAL_PREFIX}models.classify`;
export const GENERATE_IMAGES_TOOL = `${CODEMODE_INTERNAL_PREFIX}models.generateImages`;
/** The global a script calls, by internal tool. */
export const MODEL_CALLS: Readonly<Record<string, string>> = {
"models.classify": CLASSIFY_TOOL,
"models.generateImages": GENERATE_IMAGES_TOOL,
};
/** The catalog functions, host globals of the `models` namespace. */
export const CATALOG_GLOBALS = [
"models.getModelsOfType",
"models.getAvailableOfType",
"models.getModelOfType",
] as const;
/** `models.classify()` and `models.generateImages()` calls one script may have in flight; the rest wait. */
export const MAX_CONCURRENT_MODEL_CALLS = 4;
const MODEL_TYPES: ReadonlySet<string> = new Set<ModelType>(["chat", "image", "classifier"]);
export const MODELS_DESCRIPTION = [
"`models` (all functions return promises):",
'- `models.getModelsOfType(type, provider?)`, `models.getAvailableOfType(type, provider?)` (configured auth only), `models.getModelOfType(type, provider, id)`: the catalog; `type` is "chat", "image", or "classifier".',
'- `models.classify(model, { state: { ... }, images?: [{ type: "image", data, 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> } } } })` resolves to `{ answers: { <id>: ... }, stopReason, errorMessage?, usage? }`.',
'- `models.generateImages(model, { input: [{ type: "text", text: <prompt> }, ...optional { type: "image", data, mimeType }] })` resolves to `{ output: [image blocks], stopReason, errorMessage?, usage? }`; show each image with `image(block)`.',
`At most ${MAX_CONCURRENT_MODEL_CALLS} classify/generateImages calls run at once; more wait.`,
].join("\n");
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** A script value in an error message: `undefined`, `a string`, `an array`, or its keys (`{ prompt }`). */
function describeValue(value: unknown): string {
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}`;
}
/** `an image`, `a classifier`. */
function withArticle(word: string): string {
return `${/^[aeiou]/.test(word) ? "an" : "a"} ${word}`;
}
function toModelType(value: unknown): ModelType {
if (typeof value === "string" && MODEL_TYPES.has(value)) return value as ModelType;
throw new Error(`Unknown model type ${JSON.stringify(value)}. Use "chat", "image", or "classifier".`);
}
function toProvider(value: unknown): string | undefined {
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 configured headers can carry credentials. */
function toModelInfo(model: AnyModel): JsonValue {
const info: Record<string, unknown> = { ...model };
delete info.headers;
return JSON.parse(JSON.stringify(info)) as JsonValue;
}
/** The catalog functions: what a script's call of `name` with `args` (the argument list) resolves to. */
export async function catalogCall(
name: string,
args: unknown,
models: Models,
context: Context,
): Promise<JsonValue | undefined> {
const list = Array.isArray(args) ? args : [];
const [type, provider, id] = list;
if (name === "models.getModelsOfType")
return models.getModelsOfType(toModelType(type), toProvider(provider)).map(toModelInfo);
if (name === "models.getAvailableOfType") {
const available = await models.getAvailableOfType(toModelType(type), toProvider(provider), {
...(context.abortSignal === undefined ? {} : { signal: context.abortSignal }),
});
return available.map(toModelInfo);
}
if (typeof provider !== "string" || typeof id !== "string") {
throw new Error(
`models.getModelOfType(type, provider, id) expects three strings, got (${list.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);
}
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: unknown): ClassifierContext {
const fail = (problem: string) =>
new Error(`models.classify() ${problem}. Expected context: ${CLASSIFIER_CONTEXT_SHAPE}.`);
if (!isRecord(context)) throw fail(`expects a context object as its second argument, got ${describeValue(context)}`);
if (!isRecord(context.state)) throw fail(`context.state must be an object, got ${describeValue(context.state)}`);
const { images } = context;
if (images !== undefined) {
if (!Array.isArray(images)) throw fail(`context.images must be an array, got ${describeValue(images)}`);
images.forEach((image: unknown, index) => {
if (
!isRecord(image) ||
image.type !== "image" ||
typeof image.data !== "string" ||
typeof image.mimeType !== "string"
) {
throw fail(`context.images[${index}] must be an image block, got ${describeValue(image)}`);
}
});
}
const { questions } = context;
if (!isRecord(questions) || Object.keys(questions).length === 0) {
throw fail(`context.questions must map question IDs to questions, got ${describeValue(questions)}`);
}
const isStrings = (values: unknown[]) => values.length > 0 && values.every((value) => typeof value === "string");
for (const [id, question] of Object.entries(questions)) {
const at = `context.questions.${id}`;
if (!isRecord(question)) throw fail(`${at} must be a question object, got ${describeValue(question)}`);
if (typeof question.instructions !== "string") throw fail(`${at}.instructions must be a string`);
const { criteria } = question;
if (question.type === "choice") {
if (!isRecord(criteria) || !isStrings(Object.values(criteria))) {
throw fail(`${at} is a "choice" question, so criteria must map each label to its meaning`);
}
} else if (question.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 (question.type === "bool") {
if (!isRecord(criteria) || typeof criteria.true !== "string" || typeof criteria.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(question.type)}`);
}
}
return context as unknown as ClassifierContext;
}
/** Check a script's image context, so mistakes such as `{ prompt }` fail with the expected shape. */
function checkImagesContext(context: unknown): ImagesContext {
const fail = (problem: string) =>
new Error(
`models.generateImages() ${problem}. Expected context: { input: [{ type: "text", text: <prompt> }, ...optional { type: "image", data: <base64>, mimeType } references] }.`,
);
if (!isRecord(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: unknown, index) => {
if (isRecord(block) && block.type === "text" && typeof block.text === "string") return;
if (
isRecord(block) &&
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 as unknown as ImagesContext;
}
/**
* Resolve the script's model by provider and id only, so a script-supplied baseUrl or headers never receive the
* credentials, with errors that say what to do.
*/
function resolveModel<TType extends "classifier" | "image">(
name: string,
type: TType,
model: unknown,
models: Models,
): ModelTypeMap[TType] {
const listHint = `List the ${type} models you can use with models.getAvailableOfType("${type}").`;
if (!isRecord(model) || typeof model.provider !== "string" || typeof model.id !== "string") {
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, id } = model;
const resolved = models.getModelOfType(type, provider, id);
if (resolved !== undefined) return resolved;
const actualType = [...MODEL_TYPES].find(
(other) => other !== type && models.getModelOfType(other as ModelType, provider, id) !== undefined,
);
throw new Error(
actualType
? `"${provider}/${id}" is ${withArticle(actualType)} model, not ${withArticle(type)} model. ${listHint}`
: `Unknown ${type} model "${provider}/${id}". ${listHint}`,
);
}
const ModelCallParameters = Type.Object({ model: Type.Unknown(), context: Type.Unknown() });
/** The result of a model call as the tool returns it: the whole result for the script, usage counted by the Harness. */
function modelResult(
label: string,
result: { stopReason: string; errorMessage?: string; usage?: Usage },
value: unknown,
) {
const ok = result.stopReason === "stop";
return {
output: [
{
type: "text" as const,
text: ok ? `${label} done` : `${label} ${result.stopReason}: ${result.errorMessage ?? ""}`,
},
],
structuredOutput: JSON.parse(JSON.stringify(value)) as JsonValue,
...(ok ? {} : { isError: true }),
...(result.usage === undefined ? {} : { usage: result.usage }),
};
}
/** The internal tools behind `models.classify()` and `models.generateImages()`. */
export function createModelTools(): ToolRegistration[] {
const classify = defineTool({
name: CLASSIFY_TOOL,
description: "models.classify() of code mode scripts.",
parameters: ModelCallParameters,
structuredOutputSchema: Type.Unknown(),
callers: ["tools"],
execute: async (args, api, context) => {
const model = resolveModel("models.classify", "classifier", args.model, api.models);
const checked = checkClassifierContext(args.context);
const result = await api.models.classify(model, checked, {
...(context.abortSignal === undefined ? {} : { signal: context.abortSignal }),
});
return modelResult(`classify ${model.provider}/${model.id}`, result, result);
},
});
const generateImages = defineTool({
name: GENERATE_IMAGES_TOOL,
description: "models.generateImages() of code mode scripts.",
parameters: ModelCallParameters,
structuredOutputSchema: Type.Unknown(),
callers: ["tools"],
execute: async (args, api, context) => {
const model = resolveModel("models.generateImages", "image", args.model, api.models);
const checked = checkImagesContext(args.context);
const result = await api.models.generateImages(model, checked, {
...(context.abortSignal === undefined ? {} : { signal: context.abortSignal }),
});
return modelResult(`generateImages ${model.provider}/${model.id}`, result, result);
},
});
return [classify as unknown as ToolRegistration, generateImages as unknown as ToolRegistration];
}

View File

@@ -0,0 +1,206 @@
/**
* A script's result as the model reads it, copied from the coding agent's code mode: a "Script completed/failed"
* header with the wall time, the script's text items (`==> text N/M <==` when there are several), its `console` lines
* in one `<console_output>` block, the returned value, and on failure the error with the calls already made. Text past
* the token budget is cut to its start and end, with the complete text in a temp file; images are saved to temp files
* too, with their path next to them, because a script has no other way to hand their bytes on.
*/
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import type { CodemodeOutputItem, CodemodeResult } from "@earendil-works/pi-codemode/portable";
type Item = TextContent | ImageContent;
/** Writes a file of the result under a fixed name; its path, or why it could not be written. */
export type OutputFile = (name: string, data: string | Uint8Array) => Promise<{ path: string } | { error: string }>;
/** The tool's output limits, which the Harness enforces by cutting without a spill file: results stay inside them. */
type Limits = { readonly maxBytes: number; readonly maxLines: number };
/** Lines of the result besides the script's text: the header, markers, notes. */
const LINE_MARGIN = 100;
/** Default token budget for script output. */
export const DEFAULT_MAX_OUTPUT_TOKENS = 10_000;
/** Characters per token when estimating. */
const CHARS_PER_TOKEN = 4;
/** File extensions of the image types `image()` accepts. */
const IMAGE_EXTENSIONS: Record<string, string> = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
};
/** Like the script's `text()`: strings as is, other values as compact JSON. */
function valueText(value: unknown): string {
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: with more than one text item, each starts with a
* `==> text N/M <==` line; `console` lines follow all other output in one `<console_output>` block.
*/
function formatItems(output: readonly CodemodeOutputItem[]): Item[] {
const total = output.filter((item) => item.type === "text" && !item.console).length;
const items: Item[] = [];
const consoleLines: string[] = [];
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.
*/
function joinAdjacentText(items: readonly Item[]): Item[] {
const joined: Item[] = [];
let parts: string[] = [];
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;
}
/**
* 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 complete text is written to a temp file.
*/
async function truncate(
items: readonly Item[],
maxTokens: number,
limits: Limits,
write: OutputFile,
): Promise<{ items: Item[]; fullOutputPath?: string }> {
const texts = items.filter((item): item is TextContent => item.type === "text").map((item) => item.text);
const combined = texts.join("\n");
// Within the limits too: a UTF-16 unit is at most 3 UTF-8 bytes, and the rest of the result is a few short lines.
const budget = Math.min(maxTokens * CHARS_PER_TOKEN, Math.floor(limits.maxBytes / 4));
const lineBudget = Math.floor((limits.maxLines - LINE_MARGIN) / 2);
const lines = combined.split("\n").length;
if (texts.length === 0 || (combined.length <= budget && lines <= 2 * lineBudget)) return { items: [...items] };
const headChars = Math.floor(budget / 2);
const tailChars = budget - headChars;
const head = combined.slice(0, headChars).split("\n").slice(0, lineBudget).join("\n");
const tail = tailChars > 0 ? combined.slice(-tailChars).split("\n").slice(-lineBudget).join("\n") : "";
const removed = combined.length - head.length - tail.length;
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 write("output.txt", 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 } : {}),
};
}
/** Save each image to a file and put a text item with its path before it; an image shown twice is saved once. */
async function saveImages(items: readonly Item[], write: OutputFile): Promise<Item[]> {
const labels = new Map<string, Promise<string>>();
const label = async ({ data, mimeType }: ImageContent, index: number): Promise<string> => {
const bytes = Uint8Array.from(atob(data), (char) => char.charCodeAt(0));
const kind = `${mimeType}, ${bytes.length} bytes`;
// A failed write must not discard the result of a script whose calls already ran: it becomes part of the label.
const saved = await write(`image-${index}${IMAGE_EXTENSIONS[mimeType] ?? ".bin"}`, bytes);
return "path" in saved
? `[Image saved to ${saved.path} (${kind})]`
: `[Image (${kind}) could not be saved: ${saved.error}]`;
};
const result = await Promise.all(
items.map(async (item): Promise<Item[]> => {
if (item.type !== "image") return [item];
let pending = labels.get(item.data);
if (pending === undefined) {
pending = label(item, labels.size + 1);
labels.set(item.data, pending);
}
return [{ type: "text", text: await pending }, item];
}),
);
return result.flat();
}
function formatError(
result: Extract<CodemodeResult, { ok: false }>,
calls: readonly { name: string; status: string }[],
) {
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}`;
const summary =
calls.length === 0
? "No tool calls were made."
: `Tool calls made before the failure (they are not undone): ${calls.map((call) => `${call.name} (${call.status})`).join(", ")}`;
return `${head}\n\n${summary}`;
}
/** The tool result's content for a script's result, and the path of the complete text when it was cut. */
export async function formatResult(
result: CodemodeResult,
options: {
/** Images `models.generateImages()` returned; when the script showed none, the result says so. */
readonly generatedImages: number;
readonly wallMs: number;
readonly maxOutputTokens: number;
readonly calls: readonly { name: string; status: string }[];
readonly limits: Limits;
readonly write: OutputFile;
},
): Promise<{ content: Item[]; fullOutputPath?: string }> {
const output = [...result.output];
if (result.ok && result.value !== undefined) output.push({ type: "text", text: valueText(result.value) });
const items = formatItems(output);
if (!result.ok) items.push({ type: "text", text: `Script error:\n${formatError(result, options.calls)}` });
const generated = options.generatedImages;
if (generated > 0 && !items.some((item) => item.type === "image")) {
items.push({
type: "text",
text: `Note: models.generateImages() returned ${generated} image${generated === 1 ? "" : "s"} that the script did not show. Show each image block of result.output with image(block).`,
});
}
const truncated = await truncate(joinAdjacentText(items), options.maxOutputTokens, options.limits, options.write);
// After truncation, which joins the text items and moves images after them, so each path stays next to its image.
const content = joinAdjacentText(await saveImages(truncated.items, options.write));
const header = `${result.ok ? "Script completed" : "Script failed"}\nWall time ${(options.wallMs / 1000).toFixed(1)} seconds\nOutput:\n`;
return {
content: [{ type: "text", text: header }, ...content],
...(truncated.fullOutputPath === undefined ? {} : { fullOutputPath: truncated.fullOutputPath }),
};
}