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:
1037
third_party/pi/packages/durable/src/codemode/codemode.ts
vendored
Normal file
1037
third_party/pi/packages/durable/src/codemode/codemode.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
157
third_party/pi/packages/durable/src/codemode/describe.ts
vendored
Normal file
157
third_party/pi/packages/durable/src/codemode/describe.ts
vendored
Normal 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");
|
||||
}
|
||||
13
third_party/pi/packages/durable/src/codemode/index.ts
vendored
Normal file
13
third_party/pi/packages/durable/src/codemode/index.ts
vendored
Normal 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";
|
||||
285
third_party/pi/packages/durable/src/codemode/models.ts
vendored
Normal file
285
third_party/pi/packages/durable/src/codemode/models.ts
vendored
Normal 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];
|
||||
}
|
||||
206
third_party/pi/packages/durable/src/codemode/output.ts
vendored
Normal file
206
third_party/pi/packages/durable/src/codemode/output.ts
vendored
Normal 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 }),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user