diff --git a/packages/durable/CHANGELOG.md b/packages/durable/CHANGELOG.md index 41a7a9e68..fa1c1016e 100644 --- a/packages/durable/CHANGELOG.md +++ b/packages/durable/CHANGELOG.md @@ -16,12 +16,12 @@ - Input submissions take `entry: { kind, data }` (`InputEntry`): the input is placed as an entry of that kind carrying that data, with its content as the user message, instead of `pi.user`, and behaves as any other input; the kind must not start with `pi.`. Queued inputs carry it in their `pi.inbox` item ([#10407](https://github.com/earendil-works/pi/issues/10407)). - `Session.closed` settles once the Session has closed, Storage included: `{ reason: "closed" }` after `close()`, or `{ reason: "failed", error }` after a storage error. `WatchEnd` gains `{ reason: "session_failed", error }`, and the README has an Errors section. - `ToolExecutionApi.executeTool(name, args, context, { key?, progress? })`: a tool calls another tool as a nested call. The nested call is its own `pi.tool` task owned by the caller, runs validation, the `ToolTask` hooks (whose `ToolHookCall` carries `parent`), output limits, and its replay policy, and returns a `NestedToolExecutionResult` (its `taskId`, `structuredOutput`, `details`, `diagnostics`, `usage`, `isError`, `durationMs`, but not the model's `output`) to the caller instead of the transcript. Each nested call is a member of `NestedCallDoc`, a task-scoped document family of the caller keyed by the call's key, holding its `taskId` and, once it ends, its `result` as returned, so it is gone from storage once the caller settles; its task's receipt stays small. Keys, by default the call's position, let a replay-safe caller's rerun reattach to its nested calls; a caller that is not replay-safe has its unfinished nested calls abandoned after a restart. Nested calls show in `pi.live.nestedTools`, with the arguments they run with and a `summary` (error, duration, usage) once done (`progress: false` keeps their running output out), and as tool events with `parentToolCallId` and `parentTaskId`; every tool event also carries its call's `taskId`. A caller aborts the nested calls it left running before it settles. -- Structured tool output: a tool declares `structuredOutputSchema` and returns `structuredOutput`, which the Harness validates and gives to programs that call the tool; a tool without a schema gives them its bounded output, one text item as a string, one image as itself, and otherwise the content list, for errors too, and so does a schema tool's error result without `structuredOutput`, so programs get the text of a returned error; a thrown error's message is in its `tool_error` diagnostic. A nested call with invalid structured output gets an error result; a model-issued one only loses it, and the host gets a report. `api.retainedOutput()` reads the bounded output the model will see. `bash` and `powershell` return `{ output, truncated, fullOutputPath?, exitCode }`. +- Structured tool output: a tool declares `structuredOutputSchema` and returns `structuredOutput`, which the Harness validates and gives to programs that call the tool; a tool without a schema gives them its bounded output, one text item as a string, one image as itself, and otherwise the content list, for errors too. `structuredOutput` is always the tool's own value: a schema tool's error result keeps the value it returned, and the text of one without becomes a `tool_error` diagnostic of the nested result, as a thrown error's message does. A nested call with invalid structured output gets an error result; a model-issued one only loses it, and the host gets a report. `api.retainedOutput()` reads the bounded output the model will see. `bash` and `powershell` return `{ output, truncated, fullOutputPath?, exitCode }`. - `ToolRegistration.callers` (`model`, `tools`, or both, the default) and `pi.agent` `modelTools`: who may call a tool, and which of its tools a conversation offers the model; `Agent.callable` lists the tools nested calls resolve among. `addTools` also extends `modelTools`. - `TaskOptions.abandonOnRestart`: a task its creator awaits only in memory is aborted, with its owned work, when a later Harness starts scheduling, before any of it runs again. Its mark carries `abortReason: "restart"`, which cascades; such a task waits for a missing definition instead of becoming `orphaned`. Nested calls and `api.createTask()` children of tools that are not replay-safe get it by default. No task below an owner with cancellation intent starts a run phase any more, even before the cascade marks it. - `TaskRuntime.abortOwned(id, context)`: abort a task this task owns and wait until it is terminal. - `TaskRuntime.ownedTasks(context)`: the live tasks a task owns directly, whichever invocation created them, as copies. A tool call settling finds the nested calls it left running with it. -- `@earendil-works/pi-durable/codemode`: a `codemode` tool (`createCodemodeExtension({ sandbox })`) whose scripts call the conversation's tools as nested calls and survive a restart: the script is replayed deterministically from a journal of the results it got, written before each delivery, reattaching to its nested calls, and then continues. Divergence, a changed sandbox fingerprint or module version, and a passed deadline end the call with an error. Scripts keep `store()`/`load()` values per conversation, forked as of the fork's entry. The result's `details.calls` list the script's tool calls with their status; live progress is in `pi.live.nestedTools` (`npm run bench:codemode`). +- `@earendil-works/pi-durable/codemode`: a `codemode` tool (`createCodemodeExtension({ sandbox })`) whose scripts call the conversation's tools as nested calls and survive a restart: the script is replayed deterministically from a journal of the results it got, written before each delivery, reattaching to its nested calls, and then continues. Divergence, a changed sandbox fingerprint or module version, and a passed deadline end the call with an error. As in the coding agent, a `codemode` prompt section lists the tools scripts can call (`namespaces`, an inline token budget), scripts find tools with `searchTools()`, `describeTool()`, and `describeNamespace()`, and the result is laid out with a header, text item markers, a console block, truncation to `max_output_tokens` with a spill file, and saved images. Scripts get `models.*` (the catalog, `models.classify()`, `models.generateImages()`; the last two as nested calls of internal tools, at most four at once), and the tool declares the Lark grammar of its source for models that support grammar-constrained sampling. A failed call rejects in the script with its text, except an error result of a schema tool with its own value, which resolves: `tools.bash` gives `{ output, exitCode, ... }` for a nonzero exit. Scripts keep `store()`/`load()` values per conversation, forked as of the fork's entry. The result's `details.calls` list the script's tool calls with their status; live progress is in `pi.live.nestedTools` (`npm run bench:codemode`). - `read` returns images as one image block instead of an `unsupported_image` error: PNG, JPEG, GIF, and WebP within the model's image limits (by default 2000x2000 pixels and 4.5 MB of base64) as they are, or, with `createCodingTools({ images })`, oriented, converted, and shrunk to fit. `@earendil-works/pi-durable/images` provides a Photon (WebAssembly) image processor, with `/images/node` and `/images/cloudflare` loaders; `/images/node` prepares images in a worker thread, so a large photo never blocks the event loop, and the thread exits after 30 s without images; nothing loads it unless imported. A model without image input gets a diagnostic saying it sees a placeholder. - `pi.live.compactionFailure` keeps the newest compaction failure (task, reason, error) until a compaction produces a summary; the agent events' snapshot carries it as `compactionFailure`, and `compaction_end` carries `error` for a compaction that failed. A failed background or manual compaction was invisible once its status was removed. diff --git a/packages/durable/README.md b/packages/durable/README.md index b857471e4..d62267b1c 100644 --- a/packages/durable/README.md +++ b/packages/durable/README.md @@ -180,7 +180,7 @@ registry.install(defineExtension({ name: "count", tools: [count] })); Each call runs as its own durable task. Its intent is committed before `execute()` runs. If the process dies mid-call, the tool reruns on reopen only when it is declared `replay: "safe"`; otherwise the model gets an `interrupted` error result with the output committed so far. Throwing from `execute()` gives the model an error result. A result can also return `usage`, which is added to the conversation's [usage](#usage-and-cost). It can also return `control: { terminate: true }`: when every result of the round asks for it, the run ends without another model request. -A result has three channels: `output` for the model, `structuredOutput` for programs that call the tool (see [Calling tools from tools](#calling-tools-from-tools)), and `details` for UIs. A tool that declares `structuredOutputSchema` returns a matching `structuredOutput`, which the Harness validates. Programs calling a tool without one get its bounded output: one text item as a string, one image as its `ImageContent`, nothing as `""`, and anything else as the content list, for errors too. They get the same for a schema tool's error result without `structuredOutput`, so they still see the text of a returned error; a thrown error's message is in its `tool_error` diagnostic. The transcript stores only `output` and `details`: +A result has three channels: `output` for the model, `structuredOutput` for programs that call the tool (see [Calling tools from tools](#calling-tools-from-tools)), and `details` for UIs. A tool that declares `structuredOutputSchema` returns a matching `structuredOutput`, which the Harness validates. Programs calling a tool without one get its bounded output: one text item as a string, one image as its `ImageContent`, nothing as `""`, and anything else as the content list, for errors too. `structuredOutput` is always the tool's own value: a schema tool's error result keeps the value it returned, and the text of one without is in a `tool_error` diagnostic, as a thrown error's message is. The transcript stores only `output` and `details`: ```typescript const lines = defineTool({ @@ -196,7 +196,7 @@ const lines = defineTool({ }); ``` -A missing, undeclared, or invalid `structuredOutput` turns a nested call's result into an error result with an `invalid_structured_output` diagnostic; for a call the model made, which never sees it, it is dropped and reported through `onReport`. An error result may omit it, and programs then get its bounded output; error results the Harness writes itself (blocked, invalid, interrupted, aborted) carry none, and their `diagnostics` say what went wrong. An `afterTool` hook that redacts a schema tool's `output` must redact its `structuredOutput` too. The built-in `bash` returns `{ output, truncated, fullOutputPath?, exitCode }`, where `output` is the retained tail the model sees, read with `api.retainedOutput()`, and answers a nonzero exit with an error result that still carries it. +A missing, undeclared, or invalid `structuredOutput` turns a nested call's result into an error result with an `invalid_structured_output` diagnostic; for a call the model made, which never sees it, it is dropped and reported through `onReport`. An error result may omit it, and programs then find its text in a `tool_error` diagnostic; error results the Harness writes itself (blocked, invalid, interrupted, aborted) carry none, and their `diagnostics` say what went wrong. An `afterTool` hook that redacts a schema tool's `output` must redact its `structuredOutput` too. The built-in `bash` returns `{ output, truncated, fullOutputPath?, exitCode }`, where `output` is the retained tail the model sees, read with `api.retainedOutput()`, and answers a nonzero exit with an error result that still carries it. A later extension's tool with the same name replaces an earlier one where both are selected, and `wrapTool()` decorates whichever tool won: @@ -265,7 +265,7 @@ import { createCodemodeExtension } from "@earendil-works/pi-durable/codemode"; registry.install(createCodemodeExtension({ sandbox: new CodemodeSandbox({ memoryLimitBytes: 256 * 1024 * 1024 }) })); ``` -On Cloudflare, pass a `RemoteCodemodeSandbox` instead. Closing the sandbox while a script runs (for example when shutting down) is not an outcome: the script resumes with the next process's sandbox. A failed nested call rejects in the script with its error text; its `structuredOutput`, if any, is on the error as `error.structuredOutput`. `store(key, value)` and `load(key)` keep JSON values per conversation; a fork sees them as they were at its entry. +The extension also adds a `codemode` prompt section listing the tools scripts can call, as TypeScript declarations within a token budget; scripts find the others with `searchTools()`, `describeTool()`, and `describeNamespace()`. Group tools by name prefix with `namespaces: [{ name, description, prefix }]`, for example an MCP server's `mcp__github__`. The result reads like the coding agent's: a header, the script's output, and on failure the calls already made; text past `max_output_tokens` is cut, with the full text in a temp file. Scripts also get `models.*`: the model catalog, `models.classify()`, and `models.generateImages()`, which run as nested calls of internal tools (`models: false` turns them off). On Cloudflare, pass a `RemoteCodemodeSandbox` instead. Closing the sandbox while a script runs (for example when shutting down) is not an outcome: the script resumes with the next process's sandbox. A failed nested call rejects in the script with its error text, unless the tool declares a schema and returned its own value with the error: `tools.bash` resolves with `{ output, exitCode, ... }` also for a nonzero exit, so scripts check `exitCode`. `store(key, value)` and `load(key)` keep JSON values per conversation; a fork sees them as they were at its entry. How it survives a restart: the sandbox runs the script deterministically (a virtual clock, seeded randomness, UTC), and the tool writes each result the script gets to a journal before handing it over. After a restart, the tool runs the script again and hands it the journaled results in the same order, so it makes the same calls with the same arguments; these reattach to their nested calls by key, and then the script continues. A rerun ends the call with an error instead when the script takes another path than before, when the sandbox's fingerprint or the code mode version changed, or when its `timeout_ms` passed meanwhile. A nested call running at the restart follows its own replay policy. diff --git a/packages/durable/docs/spec.md b/packages/durable/docs/spec.md index aadffcb0d..e4a3633f9 100644 --- a/packages/durable/docs/spec.md +++ b/packages/durable/docs/spec.md @@ -2856,7 +2856,7 @@ interface ToolExecutionApi extends Docum type NestedToolExecutionResult = { /** The nested call's tool task. */ readonly taskId: TaskId; - /** A schema tool's validated `structuredOutput`; else, and for an error result without one, its bounded output, collapsed (below). */ + /** The tool's own value: a schema tool's validated `structuredOutput` (absent on an error result without one, whose text is then a `tool_error` diagnostic); a schema-less tool's bounded output, collapsed (below). */ readonly structuredOutput?: JsonValue; readonly isError: boolean; readonly details?: JsonValue; @@ -2984,19 +2984,20 @@ on an error result, where it may omit it; the Harness validates it after the programs get its bounded output, the content the model sees without the rendered diagnostics, for a success and for an error alike: one text item as its string, one image as its `ImageContent`, no items as `""`, and anything -else as the content list. A schema tool's error result without -`structuredOutput` gives programs its bounded output the same way, so they get -the text of a returned error, for example `"permission denied: /etc"`. A -thrown error's message is in its `tool_error` diagnostic, not in the output, -so programs read it there, as they do for any tool. So a -hook that redacts a tool's output redacts what programs get of a schema-less -tool and of an error result without structured output. An +else as the content list. `structuredOutput` is always the tool's own value, +never text the Harness makes: a schema tool's error result keeps the value it +returned, and one without gives programs no `structuredOutput`; the text of its +output becomes a `tool_error` diagnostic of the nested result instead, unless an +error diagnostic already says what went wrong, so programs get it, for example +`"permission denied: /etc"`. A thrown error's message is in its `tool_error` +diagnostic too. So a hook that redacts a schema-less tool's output redacts +what programs get. An error result the Harness writes itself (unavailable, invalid arguments, blocked, interrupted, aborted, abandoned, or a scheduler fault) has no `structuredOutput`; its diagnostics say what went wrong. A thrown error becomes a `tool_error` diagnostic with its message. A nested call whose result breaks this gets an error result -with an `invalid_structured_output` diagnostic, without the broken value, and -with its bounded output as `structuredOutput`; +with an `invalid_structured_output` diagnostic, without the broken value: a +schema tool's then has no `structuredOutput`, a schema-less tool's its output; a model-issued call, whose `structuredOutput` the model never sees, only loses it, and the break is reported through `HarnessOptions.onReport`. `structuredOutput` has no size bound; keeping it small is the tool's job, as `bash` keeps its retained tail and the @@ -3557,10 +3558,56 @@ any more (section 2.2). `callers: ["model"]`, `replay: "safe"`. Its argument is a script, run with pi-codemode's deterministic `sandbox.start()`. The script calls the agent's `callable` tools as `tools.(args)`; each call is a nested call with key -`c`, the VM's own number for the call. A success resolves with the nested -result's `structuredOutput`; every error rejects, with that `structuredOutput` -as message when it is a non-empty string (the joined texts of a content list), -else the error diagnostics' messages, and the value as `error.structuredOutput`. +`c`, the VM's own number for the call. As in the coding agent, a tool that +declares `structuredOutputSchema` resolves with its `structuredOutput`, also on an +error result that carries one (`bash` with a nonzero exit resolves with +`{ output, exitCode, ... }`, which the script checks), and a schema-less tool +resolves with its text output, images left out, as its declaration +(`Promise`) says. Every other error rejects, with the schema-less tool's +text as message when it is not empty, else the error diagnostics' messages, and +the value, if any, as `error.structuredOutput`. Which tools declare a schema is +recorded in the start data, so a resumed script maps results as it began. + +The tool's description is fixed: the script environment and its globals. The +extension's `codemode` prompt section lists the agent's `callable` tools as +TypeScript declarations, grouped by `namespaces` (a tool belongs to the one with +the longest matching name prefix), within an inline budget of 3000 estimated +tokens: every namespace places its cheapest tool before any places a second, +and what does not fit is said so and found with `searchTools()`. Scripts find +tools with `ALL_TOOLS`, `searchTools(query, { limit?, namespace? })` (BM25), +`describeTool(name)`, and `describeNamespace(name)`, answered from the start +data and journaled like other globals. The result's content is a +`Script completed`/`Script failed` header with the wall time since +`startedAt`, the text items (`==> text N/M <==` when there are several), the +`console` lines in one `` block, the returned value, and on +failure the error with the calls already made. Text past `max_output_tokens` +(default 10000) keeps its start and end, the complete text written to a file +(`details.fullOutputPath`); images are saved to files, their paths next to them. +The Harness cuts a result past the tool's `outputLimits` (4 MiB, 100,000 lines) +without such a file, so the text is also cut when it exceeds 1 Mi characters or +the line limit, whatever `max_output_tokens` says. The files go to a temp +directory of the environment that the run creates once and records in its run +document (`outputDir`) before writing to it, under fixed names (`output.txt`, +`image-.`): a rerun after a crash overwrites them instead of adding new +ones. + +With `models` (default on), scripts get the model catalog +(`models.getModelsOfType`, `getAvailableOfType`, `getModelOfType`; journaled +globals, configured `headers` removed) and `models.classify()` and +`models.generateImages()`. Those two are paid effects, so each runs as an +internal tool of the extension, `pi.codemode.models.classify` and +`pi.codemode.models.generateImages` (`callers: ["tools"]`): the code mode tool +turns the script's call of the global into a nested call of the tool, which then +has its own task, live row, abort, usage accounting, and replay policy. Tools +named `pi.codemode.*` are recorded in the start data as `hidden`: never in the +section, `tools`, `ALL_TOOLS`, or tool search. They declare a schema, so like any +such tool they resolve with the model's result whatever its `stopReason`, which +the script checks; their rows show a failed one as an error. At most four such +calls of one script run at once, and a call waiting for a slot when the script +has ended is not started. A script that +received generated images and showed none gets a note in its result. The tool's +declaration carries the Lark grammar of the script source as +`constrainedSampling`, which pi-ai applies for models that support it. The result's \`details\` list the script's tool calls in call order (\`CodemodeToolDetails\`: id, name, cut arguments, status \`ok\`/\`error\`/ diff --git a/packages/durable/src/codemode/codemode.ts b/packages/durable/src/codemode/codemode.ts index 7fcea9795..cf6fd2393 100644 --- a/packages/durable/src/codemode/codemode.ts +++ b/packages/durable/src/codemode/codemode.ts @@ -35,7 +35,7 @@ * call with an error result. */ import { type Context, copyJson, type JsonValue } from "@earendil-works/chord"; -import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; +import type { TextContent } from "@earendil-works/pi-ai"; import { type CodemodeHostCall, type CodemodeOutcome, @@ -44,11 +44,12 @@ import { type CodemodeSandboxBase, toCodemodeIdentifier, } from "@earendil-works/pi-codemode/portable"; -import { parseCodemodeSource } from "@earendil-works/pi-codemode/source"; +import { CODEMODE_SOURCE_GRAMMAR, parseCodemodeSource } from "@earendil-works/pi-codemode/source"; import { type Static, Type } from "typebox"; import { defineDoc } from "../documents.ts"; +import type { ExecutionEnv } from "../env/index.ts"; import { errorMessage } from "../errors.ts"; -import { defineExtension, defineTool } from "../harness/define.ts"; +import { defineExtension, defineTool, section } from "../harness/define.ts"; import { NestedCallDoc, type ToolTaskInput } from "../harness/tool.ts"; import type { Extension, @@ -58,17 +59,41 @@ import type { ToolRegistration, } from "../harness/types.ts"; import type { JsonObject } from "../types.ts"; +import { + type CodemodeNamespace, + codemodeDescription, + codemodeSection, + DEFAULT_CODEMODE_INLINE_BUDGET, + namespaceOf, + sampleOf, +} from "./describe.ts"; +import { + CATALOG_GLOBALS, + CODEMODE_INTERNAL_PREFIX, + catalogCall, + createModelTools, + GENERATE_IMAGES_TOOL, + MAX_CONCURRENT_MODEL_CALLS, + MODEL_CALLS, +} from "./models.ts"; +import { DEFAULT_MAX_OUTPUT_TOKENS, formatResult, type OutputFile } from "./output.ts"; +import { rankDocuments } from "./search.ts"; /** * Version of everything in this module that turns stored results into what a script sees: result mapping, globals, * start data. Bump it when that changes, so a call started before an upgrade is not replayed by different code. */ -export const CODEMODE_MODULE_VERSION = 1; +export const CODEMODE_MODULE_VERSION = 2; export const CODEMODE_TOOL_NAME = "codemode"; /** One tool as the script sees it: `tools.`. */ -type DeclaredTool = { name: string; jsName: string; description: string }; +/** + * `structured`: the tool declares `structuredOutputSchema`, so an error result with a value resolves with it. + * `hidden`: an internal tool of this extension (`pi.codemode.*`), which scripts reach only through a global such as + * `models.classify()`; never in `tools`, `ALL_TOOLS`, or tool search. + */ +type DeclaredTool = { name: string; jsName: string; description: string; structured?: true; hidden?: true }; /** What a call starts with; a rerun starts the VM from exactly this. */ type Start = { @@ -80,12 +105,18 @@ type Start = { /** `startedAt + timeout_ms` when the script set one. */ deadline?: number; tools: DeclaredTool[]; + /** The namespaces of the tools, as `describeNamespace()` and `searchTools()` see them. */ + namespaces: { name: string; description?: string; instructions?: string; tools: string[] }[]; globals: { name: string; spread: boolean }[]; + /** `max_output_tokens` of the script's options line, or the default. */ + maxOutputTokens: number; store: Record; }; type RunState = { start?: Start; + /** Directory of the result's files (the full output, images), created once: a rerun writes the same files again. */ + outputDir?: string; /** The outcome, decided once. */ result?: JsonObject; }; @@ -136,7 +167,7 @@ export const CodemodeStoreDoc = defineDoc<{ values: Record }> /** One tool call a script made, as the code mode call's details show it. */ export type CodemodeNestedCall = { - /** The VM's number for the call; its nested call ID is \`/c\`. */ + /** The VM's number for the call; its nested call ID is `/c`. */ id: number; name: string; /** Compact JSON of the arguments, cut for display. */ @@ -149,10 +180,14 @@ export type CodemodeNestedCall = { /** * The code mode call's details, in its result: its tool calls in the order it made them, with their status. Calls to - * globals are not listed. While the script runs, UIs show its nested calls from \`pi.live.nestedTools\`, so the list is + * globals are not listed. While the script runs, UIs show its nested calls from `pi.live.nestedTools`, so the list is * written once, with the result. */ -export type CodemodeToolDetails = { calls: CodemodeNestedCall[] }; +export type CodemodeToolDetails = { + calls: CodemodeNestedCall[]; + /** Temp file with the complete text output, when the output was cut to its token budget. */ + fullOutputPath?: string; +}; const ARGS_PREVIEW_CHARS = 200; const ERROR_PREVIEW_CHARS = 500; @@ -175,38 +210,67 @@ export type CodemodeToolInput = Static; export type CodemodeToolOptions = { /** Runs the scripts: `CodemodeSandbox` on Node, `RemoteCodemodeSandbox` on Cloudflare. */ readonly sandbox: CodemodeSandboxBase; + /** Groups of tools by name prefix, such as MCP servers, for the `codemode` section and tool search. */ + readonly namespaces?: readonly CodemodeNamespace[]; + /** Estimated tokens the tool declarations in the `codemode` section may use. Default 3000. */ + readonly inlineBudget?: number; + /** Give scripts `models.*`: the model catalog, classifiers, and image generation. Default true. */ + readonly models?: boolean; /** @internal Host functions scripts call as globals. */ readonly globals?: readonly CodemodeGlobal[]; }; +/** Outputs bounded by the script's own token budget; the Harness's limits only stop a runaway. */ +const OUTPUT_LIMITS = { maxBytes: 4 * 1024 * 1024, maxLines: 100_000 } as const; + +/** Globals for finding tools, answered from the start data, so a replay sees what the first run saw. */ +const DISCOVERY_GLOBALS = ["searchTools", "describeTool", "describeNamespace"] as const; +const DEFAULT_SEARCH_LIMIT = 8; + /** The code mode tool. Scripts call the conversation's callable tools as `tools.(args)`. */ export function createCodemodeTool(options: CodemodeToolOptions): ToolRegistration { return defineTool({ name: CODEMODE_TOOL_NAME, - description: - "Run JavaScript that calls other tools. The input is raw JavaScript, 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.({ ...args })` resolves to the tool's result and rejects with an Error on failure. `text(value)` and `console.log()` add output.", + description: codemodeDescription(options.models !== false), parameters: codemodeSchema, + outputLimits: OUTPUT_LIMITS, + // Capable models write the script as raw text instead of a JSON-escaped string; pi-ai applies it only for them. + constrainedSampling: { type: "grammar", variants: { openai_lark: CODEMODE_SOURCE_GRAMMAR } }, callers: ["model"], replay: "safe", execute: (args, api, context) => new CodemodeCall(options, api, context).execute(args.code), }); } -/** The `codemode` extension: the code mode tool. */ +/** + * The `codemode` extension: the code mode tool, and the `codemode` prompt section listing the tools scripts can call. + */ export function createCodemodeExtension(options: CodemodeToolOptions): Extension { - return defineExtension({ name: "codemode", tools: [createCodemodeTool(options)] }); + return defineExtension({ + name: "codemode", + tools: [createCodemodeTool(options), ...(options.models === false ? [] : createModelTools())], + sections: [ + section("codemode", (input) => + codemodeSection( + input.agent.callable.filter((tool) => !tool.name.startsWith(CODEMODE_INTERNAL_PREFIX)), + options.namespaces ?? [], + options.inlineBudget ?? DEFAULT_CODEMODE_INLINE_BUDGET, + ), + ), + ], + }); } -/** What a script gets for a nested call: success resolves with its value; every error rejects (design §4.5). */ -function outcomeOf(name: string, result: NestedToolExecutionResult): CodemodeOutcome { - const value = result.structuredOutput; - if (!result.isError) return { ok: true, value }; - const message = - typeof value === "string" && value !== "" - ? value - : Array.isArray(value) - ? value.flatMap((item) => (isText(item) ? [item.text] : [])).join("\n") - : ""; +/** + * What a script gets for a nested call, as in the coding agent. A tool with a schema resolves with its value, also on + * an error result that carries one (`bash`'s `{ output, exitCode }` for a nonzero exit); a schema-less tool + * resolves with its text output, images left out, as its declaration says. Any other error rejects, with its text: a + * schema-less tool's output, else its error diagnostics. + */ +function outcomeOf(name: string, structured: boolean, result: NestedToolExecutionResult): CodemodeOutcome { + const value = structured ? result.structuredOutput : textOf(result.structuredOutput); + if (!result.isError || (structured && value !== undefined)) return { ok: true, value }; + const message = structured ? "" : (value as string); const diagnostics = result.diagnostics .filter((diagnostic) => diagnostic.severity === "error") .map((diagnostic) => diagnostic.message) @@ -218,25 +282,15 @@ function outcomeOf(name: string, result: NestedToolExecutionResult): CodemodeOut }; } -function isText(item: unknown): item is TextContent { - return typeof item === "object" && item !== null && (item as TextContent).type === "text"; +/** The text of a schema-less tool's collapsed output: a string, one image, or an array of text and image blocks. */ +function textOf(value: JsonValue | undefined): string { + if (typeof value === "string") return value; + if (!Array.isArray(value)) return ""; + return value.flatMap((item) => (isText(item) ? [item.text] : [])).join("\n"); } -/** The tool result for a script's result. */ -function toolResult(result: CodemodeResult): ToolExecutionResult { - const output: (TextContent | ImageContent)[] = []; - for (const item of result.output) output.push(item.type === "image" ? item : { type: "text", text: item.text }); - if (result.ok && result.value !== undefined) { - output.push({ - type: "text", - text: typeof result.value === "string" ? result.value : JSON.stringify(result.value), - }); - } - if (!result.ok) { - const { error } = result; - output.push({ type: "text", text: `Script error (${error.kind}): ${error.stack ?? error.message}` }); - } - return { output, isError: !result.ok }; +function isText(item: unknown): item is TextContent { + return typeof item === "object" && item !== null && (item as TextContent).type === "text"; } function errorResult(message: string): ToolExecutionResult { @@ -284,7 +338,12 @@ class CodemodeCall { /** Set once the call decided an outcome other than the script's own (divergence, mismatch) or failed. */ private ended: ToolExecutionResult | undefined; private failure: unknown; - /** The script's tool calls for \`details\`, by VM call ID. */ + /** `models.classify()`/`generateImages()` calls in flight, and those waiting for a slot. */ + private modelCalls = 0; + private readonly modelWaiters: (() => void)[] = []; + /** Images `models.generateImages()` returned, to notice a script that never shows them. */ + private generatedImages = 0; + /** The script's tool calls for `details`, by VM call ID. */ private readonly rows = new Map(); /** Resolves once the VM's fingerprint is checked or recorded; nothing is delivered or admitted before. */ private gate!: Promise; @@ -306,12 +365,33 @@ class CodemodeCall { const tools: DeclaredTool[] = agent.callable.map((tool) => ({ name: tool.name, jsName: toCodemodeIdentifier(tool.name), - description: tool.description, - })); - const globals = (this.options.globals ?? []).map((global) => ({ - name: global.name, - spread: global.spread === true, + description: sampleOf(tool), + ...(tool.structuredOutputSchema === undefined ? {} : { structured: true as const }), + ...(tool.name.startsWith(CODEMODE_INTERNAL_PREFIX) ? { hidden: true as const } : {}), })); + const callable = agent.callable.filter((tool) => !tool.name.startsWith(CODEMODE_INTERNAL_PREFIX)); + const models = this.options.models === false ? [] : [...CATALOG_GLOBALS, ...Object.keys(MODEL_CALLS)]; + const globals = [ + ...DISCOVERY_GLOBALS.map((name) => ({ name, spread: true })), + ...models.map((name) => ({ name, spread: true })), + ...(this.options.globals ?? []).map((global) => ({ name: global.name, spread: global.spread === true })), + ]; + const namespaces: Start["namespaces"] = []; + for (const tool of callable) { + const namespace = namespaceOf(tool.name, this.options.namespaces ?? []); + if (namespace === undefined) continue; + let entry = namespaces.find((candidate) => candidate.name === namespace.name); + if (entry === undefined) { + entry = { + name: namespace.name, + ...(namespace.description === undefined ? {} : { description: namespace.description }), + ...(namespace.instructions === undefined ? {} : { instructions: namespace.instructions }), + tools: [], + }; + namespaces.push(entry); + } + entry.tools.push(tool.name); + } const now = Date.now(); const seed = crypto.getRandomValues(new Uint32Array(1))[0]!; type Found = @@ -330,7 +410,9 @@ class CodemodeCall { seed, ...(timeout === undefined ? {} : { deadline: now + timeout }), tools, + namespaces, globals, + maxOutputTokens: sourceOptions.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS, store: copyJson(store.values as JsonValue) as Record, }; } @@ -375,7 +457,7 @@ class CodemodeCall { this.run = this.options.sandbox.start(code, { startedAt: this.start.startedAt, seed: this.start.seed, - tools: this.start.tools, + tools: this.start.tools.filter((tool) => tool.hidden !== true), globals: this.start.globals, store: this.start.store, signal: this.controller.signal, @@ -458,7 +540,23 @@ class CodemodeCall { /** The script's own outcome, stored with its store writes in one transaction. */ private async complete(result: CodemodeResult): Promise { - const tool = { ...toolResult(result), details: this.details() as unknown as JsonValue }; + const details = this.details(); + const formatted = await formatResult(result, { + generatedImages: this.generatedImages, + wallMs: Date.now() - this.start.startedAt, + maxOutputTokens: this.start.maxOutputTokens, + calls: details.calls, + limits: OUTPUT_LIMITS, + write: this.outputFiles(), + }); + const tool: ToolExecutionResult = { + output: formatted.content, + isError: !result.ok, + details: { + ...details, + ...(formatted.fullOutputPath === undefined ? {} : { fullOutputPath: formatted.fullOutputPath }), + } as unknown as JsonValue, + }; const writes = result.ok ? result.storeWrites : undefined; return this.api.commit(async (tx) => { const run = await tx.doc(CodemodeRunDoc, this.api.taskId); @@ -473,6 +571,39 @@ class CodemodeCall { }, this.context); } + /** Writes the result's files under fixed names into the run's output directory, so a rerun overwrites them. */ + private outputFiles(): OutputFile { + const env = this.api.env; + let directory: Promise | undefined; + return async (name, data) => { + if (env === undefined) return { error: "no execution environment" }; + try { + directory ??= this.outputDir(env); + const path = `${await directory}/${name}`; + const written = await env.writeFile(path, data, this.context); + return written.ok ? { path } : { error: written.error.message }; + } catch (error) { + return { error: errorMessage(error) }; + } + }; + } + + /** The run's output directory: the recorded one, else a new temp directory, recorded before any file is written. */ + private async outputDir(env: ExecutionEnv): Promise { + const recorded = await this.api.commit( + async (tx) => (await tx.doc(CodemodeRunDoc, this.api.taskId)).outputDir, + this.context, + ); + if (recorded !== undefined) return recorded; + const created = await env.createTempDir("pi-codemode-", this.context); + if (!created.ok) throw new Error(created.error.message); + return this.api.commit(async (tx) => { + const run = await tx.doc(CodemodeRunDoc, this.api.taskId); + run.outputDir ??= created.value; + return run.outputDir!; + }, this.context); + } + private async checkFingerprint(fingerprint: string): Promise { const recorded = this.start.fingerprint; if (recorded !== undefined) { @@ -502,8 +633,9 @@ class CodemodeCall { else if (!this.rerun) void this.release(); } - private onCall(call: CodemodeHostCall): void { + private onCall(made: CodemodeHostCall): void { if (this.stopped) return; + const call = asToolCall(made); if (call.target === "tool") this.started(call); this.issued = Math.max(this.issued, call.id); const index = this.replaying ? (this.replayIndex.get(call.id) ?? -1) : -1; @@ -536,6 +668,7 @@ class CodemodeCall { this.deliverReplayed(); }, (error: unknown) => { + if (this.stopped) return; if (this.context.abortSignal?.aborted) this.fail(error); // The key names another tool or other arguments. else this.diverge(`call ${call.id} is ${describe(call)}: ${errorMessage(error)}`); @@ -650,11 +783,10 @@ class CodemodeCall { /** Run one live call and journal its result. */ private launch(call: CodemodeHostCall): void { if (this.stopped) return; - const global = call.target === "global" ? this.options.globals?.find((g) => g.name === call.name) : undefined; const running: Promise<{ outcome: CodemodeOutcome; global?: JournalEntry["global"] }> = call.target === "tool" ? this.callTool(call).then((outcome) => ({ outcome })) - : this.callGlobal(call, global).then((outcome) => ({ + : this.callGlobal(call).then((outcome) => ({ outcome, global: { name: call.name, @@ -668,6 +800,8 @@ class CodemodeCall { void this.pump(); }, (error: unknown) => { + // Stopped meanwhile: the outcome is decided, or a waiting model call gave up. + if (this.stopped) return; if (this.context.abortSignal?.aborted) this.fail(error); // A key an earlier attempt admitted with another tool or other arguments: this attempt took another path. else this.diverge(`call ${call.id} is ${describe(call)}: ${errorMessage(error)}`); @@ -678,9 +812,25 @@ class CodemodeCall { private async callTool(call: CodemodeHostCall): Promise { // Passed as the script gave them; validation reports arguments that are not an object. const args = (call.args ?? {}) as JsonObject; - const result = await this.api.executeTool(call.name, args, this.context, { key: `c${call.id}` }); - const outcome = outcomeOf(call.name, result); - this.finishRow(call.id, outcome, result.durationMs); + const model = call.name.startsWith(CODEMODE_INTERNAL_PREFIX); + if (model) { + await this.modelSlot(); + // The call may have stopped while this one waited: a finished script admits no new paid call. + if (this.stopped) { + this.releaseModelSlot(); + throw new Error("The script has ended"); + } + } + let result: NestedToolExecutionResult; + try { + result = await this.api.executeTool(call.name, args, this.context, { key: `c${call.id}` }); + } finally { + if (model) this.releaseModelSlot(); + } + if (call.name === GENERATE_IMAGES_TOOL) this.generatedImages += countImages(result.structuredOutput); + const structured = this.start.tools.find((tool) => tool.name === call.name)?.structured === true; + const outcome = outcomeOf(call.name, structured, result); + this.finishRow(call.id, outcome, result); return outcome; } @@ -688,16 +838,22 @@ class CodemodeCall { private started(call: CodemodeHostCall): void { if (this.rows.has(call.id)) return; const args = call.args === undefined ? "" : (JSON.stringify(call.args) ?? ""); - this.rows.set(call.id, { id: call.id, name: call.name, args: cut(args, ARGS_PREVIEW_CHARS), status: "running" }); + const name = call.name.startsWith(CODEMODE_INTERNAL_PREFIX) + ? call.name.slice(CODEMODE_INTERNAL_PREFIX.length) + : call.name; + this.rows.set(call.id, { id: call.id, name, args: cut(args, ARGS_PREVIEW_CHARS), status: "running" }); } /** A tool call ended: its row gets its status, duration, and error. */ - private finishRow(id: number, outcome: CodemodeOutcome, durationMs: number | undefined): void { + /** The call ended: an error result is an error row, also when the script got its value (bash's nonzero exit). */ + private finishRow(id: number, outcome: CodemodeOutcome, result: NestedToolExecutionResult): void { const row = this.rows.get(id); if (row === undefined) return; - row.status = outcome.ok ? "ok" : "error"; - if (durationMs !== undefined) row.durationMs = durationMs; - if (!outcome.ok) row.error = cut(outcome.message, ERROR_PREVIEW_CHARS); + row.status = result.isError ? "error" : "ok"; + if (result.durationMs !== undefined) row.durationMs = result.durationMs; + const errors = result.diagnostics.filter((diagnostic) => diagnostic.severity === "error"); + const error = !outcome.ok ? outcome.message : errors.map((diagnostic) => diagnostic.message).join("\n"); + if (result.isError && error !== "") row.error = cut(error, ERROR_PREVIEW_CHARS); } /** The rows in call order; calls still running when the script ended were cancelled. */ @@ -708,17 +864,91 @@ class CodemodeCall { return { calls }; } - private async callGlobal(call: CodemodeHostCall, global: CodemodeGlobal | undefined): Promise { - if (global === undefined) return { ok: false, message: `Unknown global ${call.name}` }; + private async callGlobal(call: CodemodeHostCall): Promise { + const discovery = (DISCOVERY_GLOBALS as readonly string[]).includes(call.name); + const catalog = (CATALOG_GLOBALS as readonly string[]).includes(call.name); + const global = + discovery || catalog ? undefined : this.options.globals?.find((candidate) => candidate.name === call.name); + if (!discovery && !catalog && global === undefined) return { ok: false, message: `Unknown global ${call.name}` }; try { // `undefined` stays undefined: the journal omits the value, and a replay resolves with undefined again. - return { ok: true, value: await global.execute(call.args, this.api, this.context) }; + const value = discovery + ? this.discover(call) + : catalog + ? await catalogCall(call.name, call.args, this.api.models, this.context) + : await global!.execute(call.args, this.api, this.context); + return { ok: true, value }; } catch (error) { if (this.context.abortSignal?.aborted) throw error; return { ok: false, message: errorMessage(error) }; } } + private async modelSlot(): Promise { + if (this.modelCalls >= MAX_CONCURRENT_MODEL_CALLS) { + await new Promise((resolve) => this.modelWaiters.push(resolve)); + } + this.modelCalls++; + } + + private releaseModelSlot(): void { + this.modelCalls--; + this.modelWaiters.shift()?.(); + } + + /** `searchTools()`, `describeTool()`, `describeNamespace()`: answered from the start data. */ + private discover(call: CodemodeHostCall): JsonValue | undefined { + const args = Array.isArray(call.args) ? call.args : []; + const { namespaces } = this.start; + const tools = this.start.tools.filter((tool) => tool.hidden !== true); + const namespaceOfTool = (name: string) => namespaces.find((namespace) => namespace.tools.includes(name)); + const matchesNamespace = (namespace: Start["namespaces"][number], query: string) => + namespace.name === query || toCodemodeIdentifier(namespace.name) === toCodemodeIdentifier(query); + if (call.name === "describeTool") { + const [name] = args; + if (typeof name !== "string") throw new Error("describeTool() expects a tool name"); + return tools.find((tool) => tool.name === name || tool.jsName === name)?.description; + } + if (call.name === "describeNamespace") { + const [name] = args; + if (typeof name !== "string") throw new Error("describeNamespace() expects a namespace name"); + const namespace = namespaces.find((candidate) => matchesNamespace(candidate, name)); + if (namespace === undefined) return undefined; + return { + name: namespace.name, + ...(namespace.description === undefined ? {} : { description: namespace.description }), + ...(namespace.instructions === undefined ? {} : { instructions: namespace.instructions }), + tools: namespace.tools.map((tool) => toCodemodeIdentifier(tool)), + }; + } + const [query, options] = args as [unknown, { limit?: unknown; namespace?: unknown } | undefined]; + if (typeof query !== "string") throw new Error("searchTools() expects a query string"); + const limit = options?.limit ?? DEFAULT_SEARCH_LIMIT; + if (typeof limit !== "number" || !Number.isInteger(limit) || limit <= 0) { + throw new Error("searchTools() limit must be a positive integer"); + } + const namespace = options?.namespace; + if (namespace !== undefined && namespace !== null && typeof namespace !== "string") { + throw new Error("searchTools() namespace must be a string"); + } + const documents = tools.flatMap((tool) => { + const own = namespaceOfTool(tool.name); + if (typeof namespace === "string" && (own === undefined || !matchesNamespace(own, namespace))) return []; + const text = [ + tool.name, + tool.name.replaceAll("_", " "), + tool.description, + own?.name ?? "", + own?.description ?? "", + ]; + return [{ name: tool.name, text: text.join(" ") }]; + }); + return rankDocuments(query, documents, limit).map((name) => { + const tool = tools.find((candidate) => candidate.name === name)!; + return { name: tool.jsName, description: tool.description }; + }); + } + /** * Journal finished calls and acknowledged counts in one commit, then deliver the calls and launch the calls the * counts cover; one commit in flight at a time, so what arrives meanwhile goes into the next. @@ -766,6 +996,20 @@ class CodemodeCall { } } +/** A script's call of `models.classify()` or `models.generateImages()` as the call of its internal tool. */ +function asToolCall(call: CodemodeHostCall): CodemodeHostCall { + const tool = call.target === "global" ? MODEL_CALLS[call.name] : undefined; + if (tool === undefined) return call; + const [model, context] = Array.isArray(call.args) ? call.args : []; + return { id: call.id, target: "tool", name: tool, args: { model: model ?? null, context: context ?? null } }; +} + +/** Image blocks in a `models.generateImages()` result. */ +function countImages(value: unknown): number { + const output = typeof value === "object" && value !== null ? (value as { output?: unknown }).output : undefined; + return Array.isArray(output) ? output.filter((item) => (item as { type?: unknown })?.type === "image").length : 0; +} + function describe(call: CodemodeHostCall): string { return `${call.target === "tool" ? "tools." : ""}${call.name}(${JSON.stringify(call.args) ?? ""})`; } diff --git a/packages/durable/src/codemode/describe.ts b/packages/durable/src/codemode/describe.ts new file mode 100644 index 000000000..5dd9ca153 --- /dev/null +++ b/packages/durable/src/codemode/describe.ts @@ -0,0 +1,171 @@ +/** + * 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 { + MCP_TYPESCRIPT_PREAMBLE, + mcpStructuredContentSchema, + 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.({ ...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 `` 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 { + const queues = groups.map((group) => [...group.entries].sort((a, b) => a.cost - b.cost)); + const shown = new Set(); + 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, and the shared MCP types when a listed tool needs them. `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([["", { 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 sections: string[] = []; + if ( + tools.some( + (tool) => shown.has(tool.name) && mcpStructuredContentSchema(declarationOf(tool).outputSchema) !== undefined, + ) + ) { + sections.push(`Shared MCP Types:\n\`\`\`ts\n${MCP_TYPESCRIPT_PREAMBLE}\n\`\`\``); + } + const toolSections = ["Tools scripts can call (`await tools.(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); + } + sections.push(toolSections.join("\n\n")); + return sections.join("\n\n"); +} diff --git a/packages/durable/src/codemode/index.ts b/packages/durable/src/codemode/index.ts index d223c7d11..e3b91f6c1 100644 --- a/packages/durable/src/codemode/index.ts +++ b/packages/durable/src/codemode/index.ts @@ -10,3 +10,4 @@ export { createCodemodeExtension, createCodemodeTool, } from "./codemode.ts"; +export { type CodemodeNamespace, DEFAULT_CODEMODE_INLINE_BUDGET } from "./describe.ts"; diff --git a/packages/durable/src/codemode/models.ts b/packages/durable/src/codemode/models.ts new file mode 100644 index 000000000..047d40062 --- /dev/null +++ b/packages/durable/src/codemode/models.ts @@ -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> = { + "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 = new Set(["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: { : { type: "choice", instructions, criteria: {