diff --git a/.changeset/container-backend-legacy.md b/.changeset/container-backend-legacy.md index 2cc3c694..7b4f0255 100644 --- a/.changeset/container-backend-legacy.md +++ b/.changeset/container-backend-legacy.md @@ -1,5 +1,7 @@ --- -"@cloudflare/computer": minor +"@cloudflare/computer": major +"@cloudflare/dofs": minor +"@cloudflare/computer-rpc": minor --- -Rename the platform-scheduled container backend to `LegacyContainerBackend`; see [container backend documentation](https://github.com/cloudflare/computer/blob/main/docs/07_injected_service.md#cloudflare-containers-specifics). +Rename the platform-scheduled container backend to `LegacyContainerBackend`. diff --git a/.changeset/container-ignore-assertion.md b/.changeset/container-ignore-assertion.md index 14458187..2a91c2f4 100644 --- a/.changeset/container-ignore-assertion.md +++ b/.changeset/container-ignore-assertion.md @@ -1,5 +1,5 @@ --- -"@cloudflare/computer": patch +"@cloudflare/computer": minor --- -Keep configured paths local to the container instead of syncing them with the Durable Object using `ContainerBackend.ignore`; see [local-only path documentation](https://github.com/cloudflare/computer/blob/main/docs/19_performance.md#local-only-paths-mount_ignore). +Add `ignore` to `ContainerBackend` to configure pass-through to the container disk. diff --git a/.changeset/container-instance-backend.md b/.changeset/container-instance-backend.md index cb9f130f..e952abb8 100644 --- a/.changeset/container-instance-backend.md +++ b/.changeset/container-instance-backend.md @@ -1,5 +1,5 @@ --- -"@cloudflare/computer": patch +"@cloudflare/computer": minor --- -Add `ContainerBackend` for durable-object-scheduled containers; see [container backend documentation](https://github.com/cloudflare/computer/blob/main/docs/07_injected_service.md#cloudflare-containers-specifics). +Add a container backend for durable-object-scheduled containers diff --git a/.changeset/exec-tool-options.md b/.changeset/exec-tool-options.md new file mode 100644 index 00000000..43992570 --- /dev/null +++ b/.changeset/exec-tool-options.md @@ -0,0 +1,11 @@ +--- +"@cloudflare/computer": minor +--- + +`createAITools` takes an `exec` option that lists the backends the model can use, keyed by backend id: `exec: { "worker-javascript": { description: "Use for data work." } }`. Leave it out to use every backend the Workspace has. `{}` exposes a backend with nothing beyond its own description, and `exec: {}` means no exec tool. `createExecTool` takes the same map as `backends`, and `defaultBackend` goes away: with more than one backend the model must name one on every call. + +`WorkerShellBackend` and `ContainerBackend` now describe themselves to the model, as `WorkerJavaScriptBackend` does, so the default needs no descriptions. A backend that says nothing gets a one-line default instead of an error. + +`shell` still works and is deprecated. `shell: { backends }` becomes `exec: backends`, and its `defaultBackend` is ignored. Output limits stay on `createExecTool`. + +`createAITools` moves to its own entry point, `@cloudflare/computer/tools/ai-sdk`. `@cloudflare/computer/tools` keeps the individual `create*Tool` functions and `WorkspaceFileStore`. Change `import { createAITools } from "@cloudflare/computer/tools"` to `from "@cloudflare/computer/tools/ai-sdk"`. diff --git a/.changeset/exec-tool-review-fixes.md b/.changeset/exec-tool-review-fixes.md new file mode 100644 index 00000000..94612fcb --- /dev/null +++ b/.changeset/exec-tool-review-fixes.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": patch +--- + +A `WorkspaceClient` from `getWorkspace()` now answers `runtime.backends()`, locally and over RPC, from a snapshot taken when the client is created. `createAITools({ workspace: await getWorkspace(this) })` therefore offers `exec` over every backend, and a callable backend keeps its `input` argument and module list. `ContainerBackend` describes network access that matches its `egress` setting, and `exec` takes precedence over the deprecated `shell` option. diff --git a/.changeset/exec-tool-single-backend.md b/.changeset/exec-tool-single-backend.md new file mode 100644 index 00000000..6594ddf7 --- /dev/null +++ b/.changeset/exec-tool-single-backend.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/computer": minor +--- + +The `exec` tool offers only the arguments that can work. With one backend there is no `backend` argument, the tool always runs there, and the description talks about what that backend does rather than how to choose one. `input` appears only when a configured backend accepts it. + +Each backend's entry now adds what the backend says about itself, read through `workspace.runtime.backends()`. For `WorkerJavaScriptBackend` that is its source language and every module code can import, so the module list the model reads cannot drift from `modules`. diff --git a/.changeset/git-full-history-clone.md b/.changeset/git-full-history-clone.md index 78019f8e..58a28c55 100644 --- a/.changeset/git-full-history-clone.md +++ b/.changeset/git-full-history-clone.md @@ -1,5 +1,11 @@ --- -"@cloudflare/computer": patch +"@cloudflare/computer": minor --- -Expand `ws:git` with full-history clones by default and additional `cat-file`, `log`, and command-specific `help` options; see [Git interface documentation](https://github.com/cloudflare/computer/blob/main/docs/13_git_interface.md). +`git clone` now fetches the full history by default instead of a single commit. The shallow default was faster, but a caller who cloned a repository and then pushed it somewhere else sent only the one commit it had fetched: the push reported success and the remote's tip matched, while every earlier commit was missing. Pass `--depth` to ask for a shallow clone when the history genuinely is not needed. + +`git cat-file` gained `-t` and `-s` to report an object's type and size, alongside the existing `-p`. Exactly one of the three is required, as in real git. + +`git log` gained `--format` and its alias `--pretty`, expanding the placeholders `%H`, `%h`, `%s`, `%b`, `%an`, `%ae`, `%ad`, `%cn`, `%ce`, `%cd`, and `%%`, plus the named format `oneline`. A placeholder outside that set is left as written so it is visible in the output rather than silently dropped. + +`git help ` now prints the usage line for one command instead of ignoring its argument and reprinting the full list. Only the flags this wrapper accepts are listed, so the output says what works here rather than what real git would take. diff --git a/.changeset/modules.md b/.changeset/modules.md new file mode 100644 index 00000000..603c03c3 --- /dev/null +++ b/.changeset/modules.md @@ -0,0 +1,11 @@ +--- +"@cloudflare/computer": minor +--- + +`WorkerJavaScriptBackend` takes a single `modules` option. A string is bundled source, as before. An object of functions is a host module that runs in the Durable Object under a `ws:*` specifier, and each function becomes a named export: `modules: { "ws:weather": { forecast } }` lets code write `import { forecast } from "ws:weather"`. A factory, `(host) => ({ ... })`, builds a host module from the Workspace's Git client, Artifacts client, or runtime. Each function receives `(args, { signal, deadline, access, resolvePath })` and may return any JSON-compatible value. + +`ws:git` and `ws:artifacts` are no longer installed automatically. Add `createGitModule()` from `@cloudflare/computer/modules/git` and `createArtifactsModule()` from `@cloudflare/computer/modules/artifacts`. `node:fs` and `node:fs/promises` stay built in. + +The backend describes its source language and every importable module for a model in `backend.description`, which `workspace.runtime.backends()` returns along with each backend's id and whether it is callable. + +To migrate, move `trustedModules` entries into `modules`, replacing any `call(method, args)` handler with one function per method. Replace `allowGitNetwork: true` with `createGitModule({ allowNetwork: true })` and `allowArtifactNetwork: true` with `createArtifactsModule({ allowNetwork: true })`. diff --git a/.changeset/pi-ai-tanstack-ai-tools.md b/.changeset/pi-ai-tanstack-ai-tools.md new file mode 100644 index 00000000..b3388bfc --- /dev/null +++ b/.changeset/pi-ai-tanstack-ai-tools.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": minor +--- + +Add Workspace tool sets for pi (`createPiTools` from `@cloudflare/computer/tools/pi-ai`) and TanStack AI (`createTanStackTools` from `@cloudflare/computer/tools/tanstack-ai`); see [the tool interface docs](https://github.com/cloudflare/computer/blob/main/docs/09_tool_interface.md). diff --git a/.changeset/ws-container-module.md b/.changeset/ws-container-module.md new file mode 100644 index 00000000..0b7d0985 --- /dev/null +++ b/.changeset/ws-container-module.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": minor +--- + +Add `createContainerModule()` in `@cloudflare/computer/modules/container`. Install it as `modules: { "ws:container": createContainerModule() }` on a `WorkerJavaScriptBackend`, and JavaScript can run shell commands in the Workspace's `ContainerBackend` with `import { exec } from "ws:container"`. The JavaScript backend fails to connect if that backend is missing or runs module source rather than shell commands. The container shares the Workspace's files, a canceled execution kills the command, and `exec` refuses to run on a read-only backend. The module describes itself, so the `exec` tool tells the model about it without extra configuration. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25b57935..1fa88480 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,6 +148,12 @@ jobs: - name: mcp workspace: "@example/computer-mcp" path: examples/mcp + - name: pi-ai + workspace: "@example/computer-pi-ai" + path: examples/pi-ai + - name: tanstack-ai + workspace: "@example/computer-tanstack-ai" + path: examples/tanstack-ai - name: think workspace: "@cloudflare/example-think" path: examples/think diff --git a/README.md b/README.md index 8e1ea38e..11727db5 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ SQLite and exposes one pluggable execution surface through Workers RPC, so there is no second store or sync round trip. - **Isolate JavaScript** runs an ECMAScript module in a fresh Dynamic Worker with structured input/results, durable relative imports, - configured libraries, Workspace-backed `node:fs/promises`, and trusted `ws:git` and - `ws:artifacts` modules. + configured libraries, Workspace-backed `node:fs/promises`, and host modules such as + `ws:git`, `ws:artifacts`, and `ws:container`. A Workspace may register multiple backends under stable IDs. `workspace.runtime.exec(source, { backend })` is the single execution @@ -74,6 +74,11 @@ public surface. Each is a Worker workspace with its own README. - [`examples/rlm`](examples/rlm) — shows how generated JavaScript can read long context from a Computer Workspace, call bounded model workers, and reduce their structured results with code. +- [`examples/pi-ai`](examples/pi-ai) — a one-shot [pi](https://github.com/earendil-works/pi) + agent. Its loop asks the model, runs the workspace tools it asked for, + and repeats until the model stops asking. +- [`examples/tanstack-ai`](examples/tanstack-ai) — the same one-shot agent on + [TanStack AI](https://tanstack.com/ai), where `chat()` runs the loop. - [`examples/think`](examples/think) — a [`@cloudflare/think`](https://www.npmjs.com/package/@cloudflare/think) chat agent that uses the workspace as its working directory, reachable from a terminal. diff --git a/docs/09_tool_interface.md b/docs/09_tool_interface.md index a8629a1c..21ff384c 100644 --- a/docs/09_tool_interface.md +++ b/docs/09_tool_interface.md @@ -1,11 +1,19 @@ # 09. Tool interface (agents) -`@cloudflare/computer/tools` ships ready-made [AI SDK](https://github.com/vercel/ai) tools for agents that use a `Workspace`. +Computer ships a ready-made tool set for agents that use a `Workspace`, once for each of three agent libraries: + +| Library | Entry point | Factory | +| --- | --- | --- | +| [AI SDK](https://github.com/vercel/ai) (`ai`) | `@cloudflare/computer/tools/ai-sdk` | `createAITools` | +| [pi](https://github.com/earendil-works/pi) (`@earendil-works/pi-ai`) | `@cloudflare/computer/tools/pi-ai` | `createPiTools` | +| [TanStack AI](https://tanstack.com/ai) (`@tanstack/ai`) | `@cloudflare/computer/tools/tanstack-ai` | `createTanStackTools` | + +All three take the same options and build the same tools, with the same names, descriptions, schemas, and limits. Only the shape they return differs. Each entry point imports only `zod` and its own library's types, so a pi agent never loads `ai` and an AI SDK agent never loads pi. The individual AI SDK `create*Tool` functions and `WorkspaceFileStore` come from `@cloudflare/computer/tools`. The tools wrap three Workspace surfaces: - `workspace.fs` for file reads, writes, edits, searches, listings, and deletion; -- `workspace.runtime.exec` for command execution when the caller opts in; +- `workspace.runtime.exec` for running commands and code on the Workspace's backends; - `workspace.assets` for publishing generated files when an assets publisher is configured. ## What ships @@ -13,6 +21,8 @@ The tools wrap three Workspace surfaces: | Export | Purpose | | --- | --- | | `createAITools` | Create the default AI SDK `ToolSet` for a Workspace. | +| `createPiTools` | Create pi tool declarations and the function that runs a pi tool call. | +| `createTanStackTools` | Create the TanStack AI tool list for a Workspace. | | `createReadTool` | Stream text by line and pass images or PDFs to capable models. | | `createWriteTool` | Write a whole file with a UTF-8 byte cap. | | `createEditTool` | Apply atomic targeted replacements and return a unified diff. | @@ -24,13 +34,13 @@ The tools wrap three Workspace surfaces: | `createPublishTool` | Publish a workspace file through `workspace.assets`. | | `WorkspaceFileStore` | Adapt `workspace.fs` to the store used by file tools. | -`createAITools()` always names its tools `read`, `ls`, `find`, `grep`, `write`, `edit`, and `delete`. `exec` appears when the caller supplies `shell` options. `publish` appears when assets are configured. In read-only mode the set is `read`, `ls`, `find`, and `grep`. +Every tool set names its tools `read`, `ls`, `find`, `grep`, `write`, `edit`, and `delete`. `exec` appears when the Workspace has a backend, unless you pass `exec: {}`. `publish` appears when assets are configured. In read-only mode the set is `read`, `ls`, `find`, and `grep`. ## Wiring up ```ts import { Workspace } from "@cloudflare/computer"; -import { createAITools } from "@cloudflare/computer/tools"; +import { createAITools } from "@cloudflare/computer/tools/ai-sdk"; export class Agent { workspace: Workspace; @@ -55,22 +65,87 @@ export class Agent { Pass the returned AI SDK `ToolSet` to `generateText`, `streamText`, or an agent framework hook such as `getTools()`. -Pass `shell` only when the Workspace has matching backend ids: +`exec` lists the backends the model can use, keyed by backend id. Leave it out to use every backend. ```ts -const tools = createAITools({ +createAITools({ workspace }); // every backend +createAITools({ workspace, exec: { "worker-javascript": {} } }); // just this one +createAITools({ workspace, - shell: { - defaultBackend: "shell", - backends: { - shell: { description: "Fast Worker shell with built-in text commands." }, - container: { description: "Full Linux userland in a Cloudflare Container." }, - }, - }, + exec: { "worker-javascript": { description: "Use for data work." } }, // with your own text }); ``` -## `createAITools` +Each backend describes itself, and a `description` you pass comes first. `exec: {}` means no exec tool. With one backend, `exec` has no `backend` argument and always runs there. With several, the model must name a backend on every call; there is no default. + +`createPiTools` and `createTanStackTools` take `exec` the same way. + +## pi + +pi keeps tool declarations apart from the code that runs them. `Context.tools` carries declarations with JSON Schema `parameters`, and the caller's own loop runs each call. `createPiTools` returns both, so they cannot drift apart. + +```ts +import { createPiTools } from "@cloudflare/computer/tools/pi-ai"; + +const { tools, execute } = createPiTools({ workspace }); + +const message = await models.complete(model, { systemPrompt, messages, tools }); +messages.push(message); + +for (const block of message.content) { + if (block.type !== "toolCall") continue; + const { content, isError } = await execute(block); + messages.push({ + role: "toolResult", + toolCallId: block.id, + toolName: block.name, + content, + isError, + timestamp: Date.now(), + }); +} +``` + +`execute` checks the call's arguments against the tool's schema and returns pi `toolResult` content. A bad call or a failed tool comes back as `isError: true`, so the model can retry and the loop does not throw. pi describes tool parameters with TypeBox, which also accepts plain JSON Schema, so the Zod schemas are converted to JSON Schema and pi needs nothing else. A field with a default stays optional for the model. + +`read`, `write`, and `edit` carry byte offsets and long verbatim strings, so they ask for pi's `constrainedSampling`. A provider that supports it enforces the schema while sampling, and a malformed `edit` never reaches the tool. The declarations stay open. pi closes a schema itself when the provider supports strict mode, making every field required and the optional ones nullable. `execute` drops a null on an optional field that does not accept one, and keeps a null the tool accepts, such as `exec`'s `input`. + +The default is `"prefer"`, which falls back to ordinary tool calling on a provider that cannot enforce a schema. `"require"` fails the request instead, for a pinned model known to support it. `false` turns it off and keeps the schemas open: + +```ts +createPiTools({ workspace, constrainedSampling: "require" }); +``` + +pi tool results carry text and images. An image from `read` comes back as an `image` block; a PDF comes back as text saying it cannot be attached. `exec` returns its final snapshot. + +## TanStack AI + +A TanStack tool's `inputSchema` is a Standard Schema, which Zod implements, so the schemas pass through unchanged. The tools come back as a list, the shape `chat({ tools })`, `mergeAgentTools`, and `createToolRegistry` take. `format: "object"` keys them by name instead, for reaching one tool directly. + +```ts +import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { createTanStackTools } from "@cloudflare/computer/tools/tanstack-ai"; + +const abortController = new AbortController(); +const tools = createTanStackTools({ workspace, approve: "mutating" }); + +return toServerSentEventsResponse(chat({ adapter, messages, tools, abortController })); +``` + +| Option | Default | Notes | +| --- | --- | --- | +| `format` | `"array"` | `"object"` keys the tools by name. | +| `approve` | none | Tool names that pause for TanStack's `needsApproval`, or `"mutating"` for every tool that changes the Workspace. | +| `lazy` | none | Tool names, or `"all"`, to withhold from the prompt until TanStack's lazy discovery asks for them. | +| `streamEventName` | none | Forward each running `exec` snapshot through `emitCustomEvent` under this name. | + +`write`, `edit`, `delete`, and `publish` have one fixed result shape, so they also carry an `outputSchema`. It covers failures too, because TanStack validates every return against it, and a success-only schema would replace the real error with a validation complaint. Paged tools such as `ls` have none. + +An image or PDF from `read` comes back as a text part plus an `image` or `document` content part, the array shape `chat()` passes to the adapter as multimodal content instead of stringifying it. + +Aborting the chat run through its `abortController` kills a running `exec`. A TanStack tool settles on one value, so `exec` returns its final snapshot. + +## Options ```ts createAITools({ @@ -80,7 +155,7 @@ createAITools({ read?, write?, edit?, - shell?, + exec?, }); ``` @@ -92,7 +167,10 @@ createAITools({ | `read` | default caps | Options passed to `createReadTool`. | | `write` | default caps | Options passed to `createWriteTool`. | | `edit` | default caps | Options passed to `createEditTool`. | -| `shell` | omitted | Options passed to `createExecTool`. | +| `exec` | every backend | Backend id to `{ description? }`. `{}` omits `exec`. | +| `shell` | omitted | Deprecated. `{ backends }` becomes `exec: backends`; `defaultBackend` is ignored. | + +`createPiTools` and `createTanStackTools` take the same options, plus their own listed above. ## `read` @@ -244,9 +322,21 @@ The tool uses forced removal, so deleting a missing path succeeds. Set `recursiv ## `exec` -`exec` is opt-in. It calls `workspace.runtime.exec` with the configured backend and streams bounded output. Backend descriptions are included in the model-facing tool description, so describe capabilities and startup cost in plain language. +`exec` calls `workspace.runtime.exec` on the chosen backend and streams bounded output. `createExecTool({ workspace, backends?, maxBytes?, streamMaxBytes? })` takes the same `backends` as the `exec` option, plus output limits. + +Each backend's entry in the tool description joins two parts: your text, if any, and what the backend says about itself (`backend.description`, read through `workspace.runtime.backends()`). `WorkerJavaScriptBackend` describes its source language and every module code can import, so the list stays in step with `modules`. `WorkerShellBackend` and `ContainerBackend` describe their command sets, network access, and startup cost. A backend that says nothing gets a one-line default, so add text for a custom backend. + +The tool offers only the arguments that can work: + +| Backends | Arguments | +| --- | --- | +| One shell backend | `command`, `cwd`, `env` | +| One callable backend | `command`, `cwd`, `env`, `input` | +| More than one | `command`, `cwd`, `backend` (required), `env`, plus `input` when any is callable | + +A `backend` value the model sends anyway is dropped when only one backend is configured. The output still names the backend that ran. -Wire this tool carefully: it executes arbitrary shell commands inside the configured backend. Treat its output as untrusted text when including it in later model input. Omit `shell` or use `readonly: true` when command execution is not part of the agent's job. +Wire this tool carefully: it executes arbitrary shell commands inside the configured backend. Treat its output as untrusted text when including it in later model input. Pass `exec: {}` or `readonly: true` when command execution is not part of the agent's job, and list backends explicitly when the Workspace has one the model should not use directly. ## `publish` diff --git a/docs/10_project_layout.md b/docs/10_project_layout.md index 8718132f..3a122316 100644 --- a/docs/10_project_layout.md +++ b/docs/10_project_layout.md @@ -175,10 +175,17 @@ produces the Node SEA single-file binary at ## Tools -AI SDK tools (`read`, `write`, `edit`, `ls`, optional `exec`, and -optional `publish`) ship from the `@cloudflare/computer/tools` subpath -rather than a separate package, under -[`packages/computer/src/tools/`](../packages/computer/src/tools/). See +Agent tools (`read`, `write`, `edit`, `ls`, `exec`, and optional +`publish`) ship from the package rather than a separate one, with one +entry point per agent library: `createAITools()` from +`@cloudflare/computer/tools/ai-sdk`, `createPiTools()` from +`@cloudflare/computer/tools/pi-ai`, and `createTanStackTools()` from +`@cloudflare/computer/tools/tanstack-ai`. The individual AI SDK +`create*Tool` functions come from `@cloudflare/computer/tools`. They live under +[`packages/computer/src/tools/`](../packages/computer/src/tools/): +`common/` holds each tool's schema, description, and executor with no +agent library in it, and `ai-sdk/`, `pi-ai/`, and `tanstack-ai/` wrap +those in each library's tool shape. See [09. Tool Interface (Agents)](./09_tool_interface.md). ## Git diff --git a/docs/16_code_execution.md b/docs/16_code_execution.md index 26371512..34c316a2 100644 --- a/docs/16_code_execution.md +++ b/docs/16_code_execution.md @@ -17,7 +17,7 @@ The selected backend defines how it interprets `source`. | --- | --- | --- | | `container-shell` | shell command | Full Linux, native binaries, installed packages, processes | | `worker-shell` | just-bash command | Fast text tools and Workspace Git without a Container | -| `worker-javascript` | ECMAScript module | Isolated structured JavaScript with trusted Workspace modules | +| `worker-javascript` | ECMAScript module | Isolated structured JavaScript with Workspace host modules | Applications may register additional command or module backends under their own IDs. Backend IDs are part of the execution contract: changing the backend may change the source language. @@ -85,4 +85,4 @@ new WorkerJavaScriptBackend({ The backend argument is never itself authorization. -See [17. Isolate JavaScript](./17_isolate_javascript.md) for module and trusted-package behavior. +See [17. Isolate JavaScript](./17_isolate_javascript.md) for module behavior. diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index 2db84ecf..b37af753 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -88,7 +88,7 @@ Completed execution records remain available for replay for sixty minutes by def Cancellation stops new host capability calls, disposes the Dynamic Worker, and waits for host calls that were already accepted. Exit 130 is published only after those calls settle. Normal completion uses the same drain rule, so an unawaited capability call cannot mutate the workspace after exit 0. -Host calls have a caller-visible deadline, controlled by `maxHostCallMs` and defaulting to `maxTimeoutMs`. Missing the deadline fails the capability call and marks the execution failed, even if caller code catches that error. Execution still waits for the accepted host operation itself before publishing a terminal event because many host APIs cannot roll back an external side effect after dispatch. Trusted modules receive an optional `{ signal, deadline }` context and must stop promptly when the signal aborts. A trusted module that ignores cancellation and never settles will keep execution in its finalizing state. `compatibilityDate` and `compatibilityFlags` control the Dynamic Worker runtime and default to the package-tested settings. +Host calls have a caller-visible deadline, controlled by `maxHostCallMs` and defaulting to `maxTimeoutMs`. Missing the deadline fails the capability call and marks the execution failed, even if caller code catches that error. Execution still waits for the accepted host operation itself before publishing a terminal event because many host APIs cannot roll back an external side effect after dispatch. Host module functions receive a `signal` in their context and must stop promptly when it aborts. A host module that ignores cancellation and never settles will keep execution in its finalizing state. `compatibilityDate` and `compatibilityFlags` control the Dynamic Worker runtime and default to the package-tested settings. ## Environment, standard input, and the `process` shim @@ -119,22 +119,54 @@ const handle = await workspace.runtime.exec( ); ``` -## Configured modules +## Modules -Bare imports are installed at backend construction, not passed on individual executions: +Caller source can import three kinds of module, and all of them are fixed when the backend is constructed: + +| Kind | Configured with | Runs in | Example | +| --- | --- | --- | --- | +| Built in | Always installed | The isolate, backed by the Workspace | `node:fs`, `node:fs/promises` | +| Source | `modules: { name: "source" }` | The isolate | a bundled library | +| Host | `modules: { "ws:name": { fn } }`, or a factory | The Durable Object | `ws:git`, `ws:container`, your own | ```ts +import { createArtifactsModule } from "@cloudflare/computer/modules/artifacts"; +import { createContainerModule } from "@cloudflare/computer/modules/container"; +import { createGitModule } from "@cloudflare/computer/modules/git"; + new WorkerJavaScriptBackend({ loader: env.LOADER, modules: { "tar-stream": TAR_STREAM_BUNDLE, + "ws:git": createGitModule(), + "ws:artifacts": createArtifactsModule(), + "ws:container": createContainerModule(), + "ws:weather": { + forecast: ([city]) => lookUpForecast(String(city)), + }, }, }); ``` -Unknown bare imports fail before Worker creation. `node:fs` and `node:fs/promises` are host-installed exceptions backed by the durable Workspace. Configured modules are code, not host authority, and may not use the reserved `ws:` namespace or shadow either filesystem specifier. +An import that is not built in, configured, or a relative Workspace path fails before the Worker is created. Caller source and durable files cannot shadow a configured or built-in module. + +The backend describes its modules for a model in `backend.description`, which `workspace.runtime.backends()` returns and the `exec` tool shows. It is built from the same `modules` option the backend runs with, so it always matches what is installed: + +```text +`command` is ECMAScript module source, run in an isolated JavaScript runtime. Relative imports resolve from `cwd` in the workspace. +Code has no direct network access. + +Modules code can import: +- `node:fs/promises` (also `node:fs`): the workspace's files. ... +- `tar-stream`: a bundled library. +- `ws:git`: The workspace's Git repository tools: `status({ dir })`, ... +- `ws:container`: Runs shell commands in a full Linux container that shares this workspace's files. ... +- `ws:weather`: exports `forecast`. +``` -## Trusted Workspace modules +A factory adds its own text through a `description` property, as the prebuilt modules do. An object of functions is listed by its export names; say more about it in the `exec` tool's backend description if the model needs it. + +### Built-in filesystem Filesystem access uses the familiar asynchronous Node API, but is backed by the durable Workspace rather than an isolate-local filesystem. Both forms are installed automatically: @@ -148,7 +180,56 @@ await fs.writeFile("/workspace/output.txt", text.toUpperCase()); Supported promise APIs are `readFile`, `writeFile`, `mkdir`, `rm`, `chmod`, `symlink`, `readlink`, `readdir`, `stat`, `lstat`, and `access`. `readFile` returns bytes when encoding is omitted and supports `"utf8"` / `"utf-8"` for text; other encodings are rejected. `writeFile` supports the default `"w"` flag and exclusive `"wx"`; other Node flags are rejected, and—as in Node—the parent directory must already exist. Relative symlink targets are preserved by `readlink`, while reads and writes through symlinks are rejected by the Workspace confinement boundary. Synchronous and callback-style Node filesystem APIs are intentionally unavailable because every operation crosses the isolate-to-Workspace capability boundary. -The entire `ws:` namespace remains reserved for other Workspace-maintained host capabilities. The built-in runtime installs `ws:git` and `ws:artifacts`. +Path confinement rejects lexical escapes and every symlink component before an operation. These checks are not an atomic inode-style “resolve beneath root” primitive: do not treat one isolate capability as a security boundary against a separate, more privileged principal concurrently replacing paths in the same mutable Workspace. Deployments requiring that adversarial concurrency need a future transactional DOFS primitive or separate Workspace identities. + +### Source modules + +A string value is JavaScript source installed as a bare import, such as a bundled library. It is plain code with no host access, and it cannot use the `ws:` namespace or replace `node:fs` or `node:fs/promises`. + +### Host modules + +A host module runs in the Durable Object, and each of its functions becomes a named export in the isolate. Host modules must use a simple `ws:*` specifier. Nothing under `ws:` is installed unless you configure it. + +Pass an object of functions: + +```ts +modules: { + "ws:weather": { + forecast: ([city]) => lookUpForecast(String(city)), + }, +} +``` + +When the functions need the Workspace's Git client, Artifacts client, or runtime, pass a factory instead. The backend calls it once when it connects to its Workspace. This is how the prebuilt modules work: + +```ts +modules: { + "ws:repo": (host) => ({ + async recent(args, context) { + const dir = await context.resolvePath(String(args[0] ?? ".")); + return host.git.log({ dir, depth: 5 }); + }, + }), +} +``` + +```js +import { recent } from "ws:repo"; +export default () => recent("/workspace/app"); +``` + +Each function receives the arguments the isolate passed, as an array of JSON-compatible values, and a context: + +| Field | Meaning | +| --- | --- | +| `signal` | Aborts when the call passes its deadline or the execution is cancelled. | +| `deadline` | Epoch milliseconds after which the isolate stops waiting. | +| `access` | The backend's `"read"` or `"read-write"` access. Check it before any write. | +| `resolvePath(path, { allowMissing })` | Confines a caller path to the backend root and rejects symlinks. | + +The arguments come from caller code, so parse them before use. A function may return a value or a promise. The result must be JSON-compatible, and the bridge checks it at runtime: `undefined` becomes `null` and `undefined` object fields are dropped, as with `JSON.stringify`. It fits within the same capability byte limits as every other host call. A function that ignores `signal` and never settles keeps the execution in its finalizing state. + +Specifiers and the export names of an object are checked at construction. A factory's export names are checked when the backend connects and the factory runs. A module must export at least one function, and every export name must be a JavaScript identifier name other than `default` or `then`. A reserved word such as `delete` is allowed, and caller code renames it on import: `import { delete as remove } from "ws:files"`. Importing a name the module does not export fails when the module graph links, before any code runs. ### `ws:git` @@ -156,25 +237,63 @@ The entire `ws:` namespace remains reserved for other Workspace-maintained host import { clone, diff, status, log, cli } from "ws:git"; ``` -`ws:git` is explicit host authority rather than ambient isolate networking. Clone, fetch, pull, push, `ls-remote`, and submodule commands can perform host-side requests even when the Dynamic Worker has `globalOutbound: null`, so they are denied by default. Enable them only on a trusted backend construction with `allowGitNetwork: true`; local Git operations remain available without that authority. Remote `ws:artifacts.importArtifact()` is independently denied unless backend construction sets `allowArtifactNetwork: true`. +`createGitModule()` from `@cloudflare/computer/modules/git` wraps the Workspace's Git client. Every `dir` and `cwd` is confined to the backend root, `clone` and `cli` need a read-write backend, and `cli` treats a leading `-C ` as its working directory, confined the same way, while rejecting any other `-C`, `--git-dir`, and `--work-tree`. Clone, fetch, pull, push, `ls-remote`, and submodule commands run from the host, even when the Dynamic Worker has `globalOutbound: null`, so they are denied unless you pass `createGitModule({ allowNetwork: true })`. ### `ws:artifacts` ```js -import { - create, - get, - list, - importArtifact, - deleteArtifact, -} from "ws:artifacts"; +import { create, get, list, importArtifact, deleteArtifact } from "ws:artifacts"; ``` -These modules are sandbox-side shims over host RPC. Loader bindings, credentials, Durable Object storage, and unrestricted Workspace objects never enter user code. The host bridge checks the backend's fixed read/read-write authority on every mutation. Artifacts methods fail clearly when no Artifacts binding is configured. +`createArtifactsModule()` from `@cloudflare/computer/modules/artifacts` wraps the Workspace's Artifacts client. Calls that change Artifacts need a read-write backend. `importArtifact()` fetches from a caller-chosen URL on the host, so it is denied unless you pass `createArtifactsModule({ allowNetwork: true })`. Every call fails clearly when no Artifacts binding is configured. -Caller modules and durable files cannot shadow `node:fs`, `node:fs/promises`, or `ws:*`. +### `ws:container` -Path confinement rejects lexical escapes and every symlink component before an operation. These checks are not an atomic inode-style “resolve beneath root” primitive: do not treat one isolate capability as a security boundary against a separate, more privileged principal concurrently replacing paths in the same mutable Workspace. Deployments requiring that adversarial concurrency need a future transactional DOFS primitive or separate Workspace identities. +`createContainerModule()` from `@cloudflare/computer/modules/container` lets JavaScript run shell commands in the Workspace's container backend. With it, JavaScript is the only backend the model sees, and the container is something that JavaScript can call: + +```ts +import { ContainerBackend, withWorkspaceContainer } from "@cloudflare/computer/backends/container"; + +class Agent extends withWorkspaceContainer(class extends DurableObject {}) { + workspace = new Workspace({ + storage: this.ctx.storage, + backends: [ + new WorkerJavaScriptBackend({ + loader: this.env.LOADER, + access: "read-write", + modules: { "ws:container": createContainerModule() }, + }), + new ContainerBackend({ + container: () => this, + workspace: { binding: "Agent", id: this.ctx.id.toString() }, + egress: { mode: "direct" }, + }), + ], + }); +} + +// Offer only the JavaScript backend; the container is reached through ws:container. +const tools = createAITools({ workspace: this.workspace, exec: { "worker-javascript": {} } }); +``` + +```js +import { exec } from "ws:container"; + +export default async function () { + const { exitCode, stdout, stderr } = await exec("npm test", { cwd: "/workspace/app" }); + return { passed: exitCode === 0, stdout, stderr }; +} +``` + +`exec(command, { cwd, env, stdin, timeoutMs })` runs through `workspace.runtime.exec` on the container backend: `ContainerBackend`, registered as `"container-shell"` unless you pass `backend`. If that backend is missing, or runs module source rather than shell commands, the JavaScript backend fails to connect. The container shares the Workspace's files: writes the module made before the call are pushed to the container, and the container's changes are pulled back before `exec` returns. A non-zero exit code comes back as a value, not as an error. + +A few limits follow from `exec` being a host call: + +- Output comes back when the command finishes, not while it runs. Each stream is cut at `maxOutputBytes` (64 KiB by default), which must stay well under the backend's `maxCapabilityBytes`. +- The command's timeout is capped at the time left before the host call deadline (`maxHostCallMs`, which defaults to `maxTimeoutMs`). Raise `defaultTimeoutMs`, `maxTimeoutMs`, and `maxHostCallMs` for slow installs and builds, and remember the container's first start. +- Cancelling the execution kills the running command. + +A container command can write to the Workspace, so `exec` refuses to run on a read-only backend. Whether it can reach the network follows `ContainerBackend`'s own `egress` setting, not the JavaScript backend's. ## Isolation and lifecycle @@ -190,7 +309,3 @@ Each execution receives a fresh Dynamic Worker with: - retained events and result rows in the Workspace database. Standard output and standard error stream live. The Dynamic Worker hands the readable end of its output stream to the host through the `attachOutput` bridge call, and the host drains it frame by frame while user code is still running, appending each chunk to the execution event stream as it arrives rather than buffering the run and publishing at the end. The structured result and the exit event settle once the output stream closes, so the terminal events always follow the last output. Output remains bounded by `maxStdioBytes` across both streams. Completed writes are durable immediately. Failure or cancellation does not roll back filesystem effects already completed. - -## Trusted integrations - -A host can configure additional reserved capability modules through `WorkerJavaScriptBackend.trustedModules`; these modules are fixed when the backend is constructed and cannot be supplied or replaced by caller source. diff --git a/docs/README.md b/docs/README.md index 06ca0d7a..00d89b7c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,9 +20,9 @@ It provides: - R2-backed mounts for pre-filling read-only data into the workspace tree. - Durability over DO restarts for all file operations. - Pluggable execution backends selected through `workspace.runtime`: a Cloudflare Container shell, a just-bash Dynamic Worker, or an isolated ECMAScript-module Dynamic Worker. - - Isolated JavaScript with structured input/results, durable relative imports, configured libraries, durable `node:fs/promises`, trusted `ws:git` / `ws:artifacts`, and managed execution records. + - Isolated JavaScript with structured input/results, durable relative imports, configured libraries, durable `node:fs/promises`, host modules such as `ws:git` and `ws:container`, and managed execution records. - Workspace constructable without a backend, for filesystem-only use cases. - - Out-of-the-box AI SDK tools for `@cloudflare/agents` through `@cloudflare/computer/tools`. + - Out-of-the-box agent tools for the AI SDK (`createAITools()` in `@cloudflare/computer/tools/ai-sdk`), pi (`createPiTools()` in `@cloudflare/computer/tools/pi-ai`), and TanStack AI (`createTanStackTools()` in `@cloudflare/computer/tools/tanstack-ai`). It comes with the following limitations: @@ -46,10 +46,16 @@ The package ships several entrypoints: | `@cloudflare/computer/backends/container` | `ContainerBackend` and `withWorkspaceContainer`, for a container the durable object schedules (`scheduling_policy: "durable_object"`). Same sync plumbing; the launch names the image and the instance size. | | `@cloudflare/computer/backends/container-legacy` | `LegacyContainerBackend` and `withLegacyWorkspaceContainer`, for a container the platform schedules and sizes from the containers block. | | `@cloudflare/computer/backends/worker-shell` | `WorkerShellBackend` and the bundled just-bash command runtime. | -| `@cloudflare/computer/backends/worker-javascript` | `WorkerJavaScriptBackend`, configured libraries, durable relative imports, `node:fs/promises`, and trusted `ws:git` / `ws:artifacts`. | +| `@cloudflare/computer/backends/worker-javascript` | `WorkerJavaScriptBackend`, configured libraries, durable relative imports, `node:fs/promises`, and host modules. | | `@cloudflare/computer/git` | Opt-in isomorphic-git glue for working with checkouts inside the workspace. Bundled lazily, with `pako` replaced by Workers `node:zlib`, and kept out of the default `@cloudflare/computer` graph. | | `@cloudflare/computer/artifacts` | `createArtifact`, an optionally session-scoped wrapper over the Cloudflare Artifacts Workers binding, plus its argv CLI. | +| `@cloudflare/computer/modules/container` | `createContainerModule()` for `ws:container`: run container commands from isolate JavaScript. | +| `@cloudflare/computer/modules/git` | `createGitModule()` for `ws:git`: confined Git from isolate JavaScript. | +| `@cloudflare/computer/modules/artifacts` | `createArtifactsModule()` for `ws:artifacts`: Artifacts from isolate JavaScript. | | `@cloudflare/computer/tools` | AI SDK tools for agents: read, write, edit, ls, optional exec, and optional publish. | +| `@cloudflare/computer/tools/ai-sdk` | `createAITools()`: the AI SDK tool set for a Workspace. | +| `@cloudflare/computer/tools/pi-ai` | `createPiTools()`: the same tool set for pi, as declarations plus a function that runs a tool call. | +| `@cloudflare/computer/tools/tanstack-ai` | `createTanStackTools()`: the same tool set for TanStack AI, as the list `chat({ tools })` takes. | A consumer that only uses the container backend never imports the worker subpath, so the just-bash payload tree-shakes away. @@ -247,7 +253,7 @@ above, then dive into the area you're working on. | [14. Assets interface](./14_assets_interface.md) | `share` a workspace file to R2 and get back a presigned URL. | | [15. Artifacts interface](./15_artifacts_interface.md) | `createArtifact` and the `artifacts` CLI, an optionally session-scoped wrapper over the Cloudflare Artifacts binding. | | [16. Execution runtime architecture](./16_code_execution.md) | One runtime entry point over command and module backends. | -| [17. Isolate JavaScript runtime](./17_isolate_javascript.md) | ECMAScript modules, durable imports, configured libraries, durable `node:fs/promises`, trusted `ws:git` / `ws:artifacts`, and managed lifecycle. | +| [17. Isolate JavaScript runtime](./17_isolate_javascript.md) | ECMAScript modules, durable imports, configured libraries, durable `node:fs/promises`, host modules, and managed lifecycle. | | [18. Runtime migration](./18_runtime_migration.md) | Breaking preview-API mappings from public shell and script-execution surfaces to `workspace.runtime`. | | [19. Performance](./19_performance.md) | Filesystem benchmarks: `fs-bench` numbers, an `npm install` comparison, and how to reproduce them. | diff --git a/examples/celld/README.md b/examples/celld/README.md index 7eec14a0..5d2d2220 100644 --- a/examples/celld/README.md +++ b/examples/celld/README.md @@ -128,7 +128,7 @@ the message or `CELLD_EXPECT` to use a different expected phrase. ## Workspace tools -The agent receives these tools from `@cloudflare/computer/tools`: +The agent receives these tools from `createAITools()` in `@cloudflare/computer/tools/ai-sdk`: | Tool | Purpose | | --- | --- | diff --git a/examples/celld/src/index.ts b/examples/celld/src/index.ts index fbcf24bd..ab9d23f1 100644 --- a/examples/celld/src/index.ts +++ b/examples/celld/src/index.ts @@ -5,7 +5,7 @@ import { type WorkspaceRuntimeLoader, withWorkspace, } from "@cloudflare/computer"; -import { createAITools } from "@cloudflare/computer/tools"; +import { createAITools } from "@cloudflare/computer/tools/ai-sdk"; import { routeAgentRequest } from "agents"; import { convertToModelMessages, isStepCount, streamText } from "ai"; import { createWorkersAI } from "workers-ai-provider"; @@ -49,24 +49,21 @@ export class CelldAgent extends withWorkspace(CelldAgentBase, (self) => { assets: false, ...(this.bindings.LOADER ? { - shell: { - defaultBackend: CELLD_JAVASCRIPT_BACKEND_ID, - backends: { - [CELLD_JAVASCRIPT_BACKEND_ID]: { - description: [ - "Runs a complete JavaScript module in a celld Dynamic Worker with structured input and output.", - "Pass module source, not a filename or bare script. The module must have a default export. Export a function to receive `(input, ctx)` and return structured output.", - "", - "```js", - "export default async function main(input, ctx) {", - ' console.log("cwd:", ctx.cwd);', - " return { received: input };", - "}", - "```", - "", - "The loaded worker cannot access the Workspace filesystem. Use read, write, edit, ls, find, grep, and delete outside exec.", - ].join("\n"), - }, + exec: { + [CELLD_JAVASCRIPT_BACKEND_ID]: { + description: [ + "Runs a complete JavaScript module in a celld Dynamic Worker with structured input and output.", + "Pass module source, not a filename or bare script. The module must have a default export. Export a function to receive `(input, ctx)` and return structured output.", + "", + "```js", + "export default async function main(input, ctx) {", + ' console.log("cwd:", ctx.cwd);', + " return { received: input };", + "}", + "```", + "", + "The loaded worker cannot access the Workspace filesystem. Use read, write, edit, ls, find, grep, and delete outside exec.", + ].join("\n"), }, }, } diff --git a/examples/mcp/README.md b/examples/mcp/README.md index 3be5d529..ffc2f1a5 100644 --- a/examples/mcp/README.md +++ b/examples/mcp/README.md @@ -83,7 +83,7 @@ Once connected, ask your MCP client to work in the Computer workspace. For examp Create /workspace/hello.txt, read it back, and list the workspace files. ``` -Commands use `worker-shell` by default. Select the container when the task needs a full Linux environment: +Every command names its backend: `worker-shell` for quick shell work, or `container-shell` when the task needs a full Linux environment: ```text Use container-shell to create a small Node.js project in /workspace, install its dependencies, and run its tests. @@ -118,7 +118,7 @@ You do not need to call the underlying Computer tools individually. The `code` t | `codemode.write({ path, content })` | Create or replace a file. | | `codemode.edit({ path, edits })` | Apply exact text replacements to a file. | | `codemode.delete_({ path, recursive? })` | Delete a file or directory. | -| `codemode.exec({ command, cwd?, backend?, env? })` | Run a command, using `worker-shell` unless another backend is selected. | +| `codemode.exec({ command, backend, cwd?, env? })` | Run a command on `worker-shell` or `container-shell`. | ## How it works @@ -126,7 +126,7 @@ You do not need to call the underlying Computer tools individually. The `code` t | Backend | Use it for | | --- | --- | -| `worker-shell` | The fast default for common commands. It has no ambient network access; its built-in Git command supports HTTPS remotes. | +| `worker-shell` | Fast, and the one to try first for common commands. It has no ambient network access; its built-in Git command supports HTTPS remotes. | | `container-shell` | Full Debian Linux with Node.js, npm, git, native binaries, and outbound networking. | The model can select a backend in `codemode.exec()`. The example does not retry automatically, so backend choice, cost, and failures remain visible. diff --git a/examples/mcp/src/index.test.ts b/examples/mcp/src/index.test.ts index 189e062e..57d4035c 100644 --- a/examples/mcp/src/index.test.ts +++ b/examples/mcp/src/index.test.ts @@ -85,8 +85,11 @@ describe("Computer Code Mode MCP", () => { }); const file = await codemode.read({ path: "/workspace/message.txt" }); const listing = await codemode.ls({ path: "/workspace" }); - const shell = await codemode.exec({ command: "pwd" }); - const git = await codemode.exec({ command: "git init && git status --short" }); + const shell = await codemode.exec({ command: "pwd", backend: "worker-shell" }); + const git = await codemode.exec({ + command: "git init && git status --short", + backend: "worker-shell", + }); return { content: file.content, listed: listing.entries.some((entry) => entry.name === "message.txt"), diff --git a/examples/mcp/src/index.ts b/examples/mcp/src/index.ts index e74b3c87..4394367d 100644 --- a/examples/mcp/src/index.ts +++ b/examples/mcp/src/index.ts @@ -7,10 +7,7 @@ import { WorkspaceServiceProxy, withWorkspace, } from "@cloudflare/computer"; -import { - LegacyContainerBackend, - withLegacyWorkspaceContainer, -} from "@cloudflare/computer/backends/container-legacy"; +import { ContainerBackend, withWorkspaceContainer } from "@cloudflare/computer/backends/container"; import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell"; import { createGitClient } from "@cloudflare/computer/git"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; @@ -29,7 +26,7 @@ const TOKEN_ENCODER = new TextEncoder(); class ComputerMCPDurableObject extends DurableObject {} -class ComputerMCPBase extends withLegacyWorkspaceContainer(ComputerMCPDurableObject) { +class ComputerMCPBase extends withWorkspaceContainer(ComputerMCPDurableObject) { readonly workerShell = new WorkerShellBackend({ loader: this.env.LOADER, workspace: { binding: "COMPUTER_MCP", id: this.ctx.id.toString() }, @@ -37,10 +34,13 @@ class ComputerMCPBase extends withLegacyWorkspaceContainer(ComputerMCPDurableObj egress: { mode: "none" }, }); - readonly containerShell = new LegacyContainerBackend({ + readonly containerShell = new ContainerBackend({ container: () => this, workspace: { binding: "COMPUTER_MCP", id: this.ctx.id.toString() }, egress: { mode: "direct" }, + // The durable object schedules this container, so it asks for its + // size at launch; wrangler.jsonc names the image under `images.app`. + instance: "standard-2", }); } diff --git a/examples/mcp/src/server.ts b/examples/mcp/src/server.ts index 090275db..52774350 100644 --- a/examples/mcp/src/server.ts +++ b/examples/mcp/src/server.ts @@ -1,7 +1,7 @@ import { DynamicWorkerExecutor } from "@cloudflare/codemode"; import { codeMcpServer } from "@cloudflare/codemode/mcp"; import type { WorkspaceClient } from "@cloudflare/computer"; -import { createAITools } from "@cloudflare/computer/tools"; +import { createAITools } from "@cloudflare/computer/tools/ai-sdk"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { ToolSet } from "ai"; @@ -11,29 +11,26 @@ export async function createComputerMCPServer(workspace: WorkspaceClient, loader const tools = createAITools({ workspace, assets: false, - shell: { - backends: { - "worker-shell": { - description: - "just-bash in an isolated Dynamic Worker. Starts quickly, " + - "does not boot a container, and has no ambient outbound network. " + - "Use it for common shell commands, quick file inspection, and " + - "text transformations. Its built-in git command supports clone, " + - "status, diff, and log; clone accepts HTTPS URLs through the " + - "durable workspace. Prefer the dedicated read, write, and edit " + - "tools for file operations. Cannot run npm, Node.js, Python, " + - "package managers, or arbitrary native binaries.", - }, - "container-shell": { - description: - "Full Debian Linux in a Cloudflare Container with Node.js, npm, " + - "git, package management, native binaries, and outbound network. " + - "Use it for dependency installation, builds, tests, or commands " + - "that worker-shell cannot run. Cold starts more slowly because " + - "the container must boot; prefer worker-shell for simple tasks.", - }, + exec: { + "worker-shell": { + description: + "just-bash in an isolated Dynamic Worker. Starts quickly, " + + "does not boot a container, and has no ambient outbound network. " + + "Use it for common shell commands, quick file inspection, and " + + "text transformations. Its built-in git command supports clone, " + + "status, diff, and log; clone accepts HTTPS URLs through the " + + "durable workspace. Prefer the dedicated read, write, and edit " + + "tools for file operations. Cannot run npm, Node.js, Python, " + + "package managers, or arbitrary native binaries.", + }, + "container-shell": { + description: + "Full Debian Linux in a Cloudflare Container with Node.js, npm, " + + "git, package management, native binaries, and outbound network. " + + "Use it for dependency installation, builds, tests, or commands " + + "that worker-shell cannot run. Cold starts more slowly because " + + "the container must boot; prefer worker-shell for simple tasks.", }, - defaultBackend: "worker-shell", }, }); diff --git a/examples/mcp/wrangler.jsonc b/examples/mcp/wrangler.jsonc index a627409b..52e3f1d5 100644 --- a/examples/mcp/wrangler.jsonc +++ b/examples/mcp/wrangler.jsonc @@ -9,11 +9,10 @@ "containers": [ { "class_name": "ComputerMCP", - "image": "./Dockerfile", - "instance_type": "standard-2", - "max_instances": 1, - "rollout_active_grace_period": 0, - "rollout_step_percentage": [100] + "scheduling_policy": "durable_object", + "images": { + "app": { "dockerfile": "./Dockerfile" } + } } ], "durable_objects": { diff --git a/examples/pi-ai/.gitignore b/examples/pi-ai/.gitignore new file mode 100644 index 00000000..0dcc8a41 --- /dev/null +++ b/examples/pi-ai/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.wrangler/ diff --git a/examples/pi-ai/README.md b/examples/pi-ai/README.md new file mode 100644 index 00000000..b4b001bb --- /dev/null +++ b/examples/pi-ai/README.md @@ -0,0 +1,50 @@ +# pi-ai agent + +A one-shot agent built on [pi](https://github.com/earendil-works/pi). Send it a +task, it works in a durable Workspace, and it replies when it is done. + +The whole agent loop is the `run` method in [`src/index.ts`](src/index.ts): ask +the model, run whatever tools it asked for, repeat until it stops asking. pi +keeps the list of tools separate from the code that runs them, so +`createPiTools` hands back both — `tools` to show the model, and `execute` to +run one of its requests. + +The workspace tools come from +[`@cloudflare/computer/tools/pi-ai`](../../docs/09_tool_interface.md): `read`, +`ls`, `find`, `grep`, `write`, `edit`, `delete`, and `exec`. `exec` runs on +the Workspace's one backend, a Worker shell, so the model never has to name it. + +[`src/workers-ai.ts`](src/workers-ai.ts) teaches pi to reach Workers AI through +the `AI` binding rather than the REST endpoint, so the example needs no API +key. It is lifted from the pi harness example in +[cloudflare/agents](https://github.com/cloudflare/agents). + +## Run it + +```sh +npm install +npm run dev --workspace @example/computer-pi-ai +``` + +Then give it something to do: + +```sh +curl -X POST http://localhost:8787 \ + -H 'content-type: application/json' \ + -d '{"task":"Write a haiku about durable objects to /workspace/haiku.txt, then read it back."}' +``` + +The agent writes the file with the `write` tool and reads it back with `read`, +then says what it did. Ask it to `grep` or run a shell command and it will +reach for those tools instead. + +To check the agent loop without a Cloudflare account, `npm run local --workspace @example/computer-pi-ai` +drives it in Node with a scripted model in place of Workers AI. + +This uses the remote Workers AI binding and counts against your account's +Workers AI usage. If your Wrangler login has access to more than one account, +set `CLOUDFLARE_ACCOUNT_ID` before starting. + +Small models pick tools less reliably than large ones. If the agent replies +without touching a file, say the task more plainly or try a bigger model in +`MODEL`. diff --git a/examples/pi-ai/local-shim.mjs b/examples/pi-ai/local-shim.mjs new file mode 100644 index 00000000..2fd0eee0 --- /dev/null +++ b/examples/pi-ai/local-shim.mjs @@ -0,0 +1,16 @@ +// `npm run local` loads this before run-local.mjs. @cloudflare/computer +// imports `cloudflare:workers`, which only workerd provides; the local +// run never reaches those classes, so empty stand-ins are enough. +import { register } from "node:module"; + +const stub = + "export class RpcTarget {} export class WorkerEntrypoint {} export class DurableObject {} export const env = {};"; + +register( + `data:text/javascript,${encodeURIComponent(`export async function resolve(specifier, context, next) { + if (specifier === "cloudflare:workers") { + return { url: ${JSON.stringify(`data:text/javascript,${stub}`)}, shortCircuit: true }; + } + return next(specifier, context); + }`)}`, +); diff --git a/examples/pi-ai/package.json b/examples/pi-ai/package.json new file mode 100644 index 00000000..106bd8a7 --- /dev/null +++ b/examples/pi-ai/package.json @@ -0,0 +1,24 @@ +{ + "name": "@example/computer-pi-ai", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Example Worker + Durable Object running a one-shot pi agent against a Workspace, with tools from @cloudflare/computer/tools/pi-ai.", + "scripts": { + "dev": "wrangler dev", + "local": "node --import ./local-shim.mjs run-local.mjs", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "build:types": "wrangler types" + }, + "dependencies": { + "@cloudflare/computer": "*", + "@earendil-works/pi-ai": "^0.99.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "@cloudflare/dofs": "*", + "typescript": "^6.0.3", + "wrangler": "^4.137.0" + } +} diff --git a/examples/pi-ai/run-local.mjs b/examples/pi-ai/run-local.mjs new file mode 100644 index 00000000..5d173ad4 --- /dev/null +++ b/examples/pi-ai/run-local.mjs @@ -0,0 +1,114 @@ +// Drive the pi example's agent loop locally, with no Cloudflare +// account. The loop, tools, and Workspace are real; only the model and +// the storage are substituted, so the tool calls below are scripted +// rather than chosen. +// +// npm run local + +import { Workspace } from "@cloudflare/computer"; +import { createPiTools } from "@cloudflare/computer/tools/pi-ai"; +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { + createModels, + fauxAssistantMessage, + fauxProvider, + fauxText, + fauxToolCall, +} from "@earendil-works/pi-ai"; + +const MAX_TURNS = 10; + +const workspace = new Workspace({ storage: new SQLiteTestStorage() }); +// No backend runs under plain node, so there is no `exec` tool here. +// The unit tests cover exec's argument handling. +const { tools, execute } = createPiTools({ workspace }); + +const faux = fauxProvider(); +const models = createModels(); +models.setProvider(faux.provider); +const model = faux.getModel(); + +// One scripted reply per turn: write a file, read it back, then answer. +faux.setResponses([ + fauxAssistantMessage( + [ + fauxToolCall("write", { + path: "/workspace/haiku.txt", + content: "durable object\nholds a file across restarts\npatient as a stone\n", + }), + ], + { stopReason: "toolUse" }, + ), +]); + +const messages = [ + { + role: "user", + content: "Write a haiku to /workspace/haiku.txt then read it back.", + timestamp: Date.now(), + }, +]; + +let answer = "(no answer)"; +for (let turn = 0; turn < MAX_TURNS; turn += 1) { + const reply = await models.complete(model, { + systemPrompt: "You are working in a directory at /workspace.", + messages, + tools, + }); + messages.push(reply); + + const calls = reply.content.filter((block) => block.type === "toolCall"); + if (calls.length === 0) { + answer = reply.content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join(""); + break; + } + + for (const call of calls) { + const { content, isError } = await execute(call); + console.log( + `turn ${turn}: ${call.name}(${JSON.stringify(call.arguments).slice(0, 60)}) -> isError=${isError} ${JSON.stringify(content).slice(0, 110)}`, + ); + messages.push({ + role: "toolResult", + toolCallId: call.id, + toolName: call.name, + content, + isError, + timestamp: Date.now(), + }); + } + + if (turn === 0) { + faux.setResponses([ + fauxAssistantMessage([fauxToolCall("read", { path: "/workspace/haiku.txt" })], { + stopReason: "toolUse", + }), + ]); + } else if (turn === 1) { + faux.setResponses([fauxAssistantMessage([fauxText("Wrote the haiku and read it back.")])]); + } +} + +console.log("\nanswer:", answer); + +// Prove the tools really touched the workspace, not a mock of it. +const onDisk = await workspace.fs.readFile("/workspace/haiku.txt", "utf8"); +console.log("file on disk:", JSON.stringify(onDisk)); + +// A failure must come back as a retryable error result, not a throw. +const missing = await execute({ + id: "x", + name: "read", + arguments: { path: "/workspace/nope.txt" }, +}); +console.log( + "missing file -> isError=%s %s", + missing.isError, + JSON.stringify(missing.content).slice(0, 80), +); + +await workspace.close?.(); diff --git a/examples/pi-ai/src/index.ts b/examples/pi-ai/src/index.ts new file mode 100644 index 00000000..3980702e --- /dev/null +++ b/examples/pi-ai/src/index.ts @@ -0,0 +1,108 @@ +// A one-shot agent on pi, working in a durable Workspace. pi leaves +// the agent loop to the caller, so `run` below is that whole loop. +// +// client ──► Worker / ──► PiAgent DO ──► Workspace (files + shell) +// │ +// └──► Workers AI, through env.AI + +import { DurableObject } from "cloudflare:workers"; + +import { + type DurableObjectStorageLike, + Workspace, + WorkspaceServiceProxy, + type WorkspaceStub, +} from "@cloudflare/computer"; +import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell"; +import { createPiTools } from "@cloudflare/computer/tools/pi-ai"; +import { createModels, type Message } from "@earendil-works/pi-ai"; + +import { WORKERS_AI_PROVIDER, workersAI } from "./workers-ai.js"; + +// The worker-shell backend reaches back into this durable object by +// binding name and id, so the shell shares the agent's filesystem. +export { WorkspaceServiceProxy }; + +const MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast"; + +// Bound the spend if the model fails to converge. +const MAX_TURNS = 10; + +export class PiAgent extends DurableObject { + workspace = new Workspace({ + storage: this.ctx.storage as unknown as DurableObjectStorageLike, + backends: [ + new WorkerShellBackend({ + id: "shell", + loader: this.env.LOADER, + workspace: { binding: "PiAgent", id: this.ctx.id.toString() }, + ctx: this.ctx, + }), + ], + }); + + /** Lets the shell in the Dynamic Worker reach this workspace. */ + async __getWorkspaceStub(): Promise { + await this.workspace.ready(); + return this.workspace.stub(); + } + + async run(task: string): Promise { + // `exec` offers every backend the Workspace has; here that is the + // one worker shell, so the model never names a backend. + const { tools, execute } = createPiTools({ workspace: this.workspace }); + + const models = createModels(); + models.setProvider(workersAI(this.env.AI, MODEL)); + const model = models.getModel(WORKERS_AI_PROVIDER, MODEL); + if (!model) throw new Error(`model ${MODEL} is not registered`); + + const messages: Message[] = [{ role: "user", content: task, timestamp: Date.now() }]; + + for (let turn = 0; turn < MAX_TURNS; turn += 1) { + const reply = await models.complete(model, { + systemPrompt: + "You are working in a directory at /workspace. Use the tools to do what the user asks, then say what you did.", + messages, + tools, + }); + messages.push(reply); + + const calls = reply.content.filter((block) => block.type === "toolCall"); + if (calls.length === 0) { + return reply.content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join(""); + } + + for (const call of calls) { + const { content, isError } = await execute(call); + messages.push({ + role: "toolResult", + toolCallId: call.id, + toolName: call.name, + content, + isError, + timestamp: Date.now(), + }); + } + } + + return `Gave up after ${MAX_TURNS} turns.`; + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + if (request.method !== "POST") { + return new Response('POST a task, e.g. {"task":"write hello.txt"}\n', { status: 405 }); + } + + const { task } = (await request.json()) as { task?: string }; + if (!task) return new Response("body needs a task\n", { status: 400 }); + + const agent = env.PiAgent.get(env.PiAgent.idFromName("demo")); + return new Response(`${await agent.run(task)}\n`); + }, +} satisfies ExportedHandler; diff --git a/examples/pi-ai/src/workers-ai.ts b/examples/pi-ai/src/workers-ai.ts new file mode 100644 index 00000000..793b11b0 --- /dev/null +++ b/examples/pi-ai/src/workers-ai.ts @@ -0,0 +1,96 @@ +// pi's Workers AI provider, transported over the `AI` binding rather +// than the REST endpoint, so the example needs no API key. Only the +// transport differs from pi's own provider. +// +// Lifted from the pi harness example in cloudflare/agents. + +import { + type ApiStreamOptions, + createProvider, + type Model, + type ProviderStreams, +} from "@earendil-works/pi-ai"; +import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy"; + +export const WORKERS_AI_PROVIDER = "cloudflare-workers-ai"; + +type RunBinding = { + run( + model: string, + input: Record, + options: { returnRawResponse: true; signal?: AbortSignal }, + ): Promise; +}; + +function bodyText(body: BodyInit | null | undefined): string { + if (typeof body === "string") return body; + if (body instanceof Uint8Array) return new TextDecoder().decode(body); + throw new TypeError("Workers AI pi requests require a JSON request body"); +} + +function model(id: string): Model<"openai-completions"> { + return { + id, + name: id, + api: "openai-completions", + provider: WORKERS_AI_PROVIDER, + // Never dialed: the fetch below answers every request through the + // binding instead. pi still wants a syntactically valid base URL. + baseUrl: "https://workers-ai.binding.invalid/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + compat: { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + supportsStrictMode: false, + maxTokensField: "max_tokens", + }, + }; +} + +export function workersAI(binding: Ai, modelId: string) { + // SAFETY: Workers AI returns a Response when `returnRawResponse` is + // set. The public `Ai` overload cannot express that correlation. + const runBinding = binding as unknown as RunBinding; + + const fetch = async (_input: RequestInfo | URL, init?: RequestInit): Promise => { + const input = JSON.parse(bodyText(init?.body)) as Record; + const name = typeof input.model === "string" ? input.model : undefined; + if (!name) throw new TypeError("Workers AI pi request is missing its model"); + delete input.model; + return runBinding.run(name, input, { + returnRawResponse: true, + ...(init?.signal ? { signal: init.signal } : {}), + }); + }; + + const api = openAICompletionsApi(); + const streams: ProviderStreams = { + stream: (m, context, options) => + api.stream(m, context, { ...options, fetch } as ApiStreamOptions), + streamSimple: (m, context, options) => api.streamSimple(m, context, { ...options, fetch }), + }; + + return createProvider({ + id: WORKERS_AI_PROVIDER, + name: "Cloudflare Workers AI", + // The binding carries its own authorization, so there is no key to + // resolve. pi still requires every provider to declare auth. + auth: { + apiKey: { + name: "Workers AI binding", + check: async () => ({ type: "api_key" as const, source: "Workers AI binding" }), + resolve: async () => ({ + auth: { apiKey: "workers-ai-binding" }, + source: "Workers AI binding", + }), + }, + }, + models: [model(modelId)], + api: streams, + }); +} diff --git a/examples/pi-ai/tsconfig.json b/examples/pi-ai/tsconfig.json new file mode 100644 index 00000000..5a6253fb --- /dev/null +++ b/examples/pi-ai/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "types": ["./worker-configuration.d.ts"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["worker-configuration.d.ts", "src/**/*.ts"] +} diff --git a/examples/pi-ai/wrangler.jsonc b/examples/pi-ai/wrangler.jsonc new file mode 100644 index 00000000..8391f590 --- /dev/null +++ b/examples/pi-ai/wrangler.jsonc @@ -0,0 +1,40 @@ +{ + // Example: a one-shot pi agent in a Durable Object. + // + // The DO holds a Workspace whose shell is a Dynamic Worker, and + // talks to Workers AI through the AI binding, so the example needs + // no API key. + "$schema": "node_modules/wrangler/config-schema.json", + "name": "computer-pi-ai-example", + "main": "src/index.ts", + "compatibility_date": "2026-05-26", + "compatibility_flags": ["nodejs_compat", "experimental"], + + // Workers AI. Running this uses your account's Workers AI quota. + "ai": { + "binding": "AI" + }, + + // The workspace shell runs in a Dynamic Worker minted through this. + "worker_loaders": [ + { + "binding": "LOADER" + } + ], + + "durable_objects": { + "bindings": [ + { + "name": "PiAgent", + "class_name": "PiAgent" + } + ] + }, + + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["PiAgent"] + } + ] +} diff --git a/examples/rlm/README.md b/examples/rlm/README.md index 7d08e6db..899533f3 100644 --- a/examples/rlm/README.md +++ b/examples/rlm/README.md @@ -66,7 +66,7 @@ const backend = new WorkerJavaScriptBackend({ root: "/workspace", access: "read", egress: { mode: "none" }, - trustedModules: { + modules: { "ws:model": modelCapability, }, }); @@ -77,13 +77,13 @@ const workspace = new Workspace({ }); ``` -The important line is `trustedModules`. Generated code cannot read model credentials or call the network directly. It can only use the host-owned `ws:model` interface. +The important line is `modules`. Generated code cannot read model credentials or call the network directly. It can only use the host-owned `ws:model` interface. A generated module follows this shape: ```js import fs from "node:fs/promises"; -import { call as callModel } from "ws:model"; +import { batch } from "ws:model"; export default async function () { const manifest = JSON.parse( @@ -96,7 +96,7 @@ export default async function () { })), ); - const mapped = await callModel("batch", requests); + const mapped = await batch(requests); const totals = validateAndSum(mapped); return { answer: largestLabel(totals) }; } @@ -150,7 +150,7 @@ The reducer is exact relative to its inputs. Model classifications can still be The shortest path through the example is: 1. [`worker/rlm-agent.ts`](worker/rlm-agent.ts) wires together the model, Workspace, Worker JavaScript backend, executor tool, and `ws:model`. -2. [`worker/capability.ts`](worker/capability.ts) implements the bounded `ws:model("batch", requests)` interface. +2. [`worker/capability.ts`](worker/capability.ts) implements the bounded `batch(requests)` function behind `ws:model`. 3. [`worker/structured-rlm.ts`](worker/structured-rlm.ts) describes the map result and JavaScript reduction for each task family. 4. [`worker/agent-common.ts`](worker/agent-common.ts) writes the same corpus into each Computer Workspace. 5. [`worker/executor-tool.ts`](worker/executor-tool.ts) creates the native Computer executor tool and keeps its browser-facing result small. diff --git a/examples/rlm/worker/capability.test.ts b/examples/rlm/worker/capability.test.ts index d6bc9fcf..c902cdea 100644 --- a/examples/rlm/worker/capability.test.ts +++ b/examples/rlm/worker/capability.test.ts @@ -1,3 +1,4 @@ +import type { WorkspaceModuleCallContext } from "@cloudflare/computer"; import type { LanguageModel } from "ai"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -30,6 +31,15 @@ function successfulResult(text = "ok") { }; } +function context(signal = new AbortController().signal): WorkspaceModuleCallContext { + return { + signal, + deadline: Date.now() + 1_000, + access: "read", + resolvePath: async (path) => path, + }; +} + async function waitForCalls(count: number): Promise { for (let attempt = 0; attempt < 50; attempt += 1) { if (generateTextMock.mock.calls.length >= count) return; @@ -44,28 +54,25 @@ beforeEach(() => { }); describe("recursive model batch capability", () => { - it("exposes only the batch method", async () => { + it("exposes only the batch function", () => { const capability = createModelCapability(model, hooks()); - await expect(capability.call("generate", [[]])).rejects.toThrow( - "Unknown model capability method", - ); - expect(generateTextMock).not.toHaveBeenCalled(); + expect(Object.keys(capability)).toEqual(["batch"]); }); it("strictly validates the external argument and request shapes", async () => { const capability = createModelCapability(model, hooks()); - await expect(capability.call("batch", [])).rejects.toThrow("exactly one argument"); - await expect(capability.call("batch", [null])).rejects.toThrow("non-empty array"); - await expect(capability.call("batch", [[]])).rejects.toThrow("non-empty array"); + await expect(capability.batch([], context())).rejects.toThrow("exactly one argument"); + await expect(capability.batch([null], context())).rejects.toThrow("non-empty array"); + await expect(capability.batch([[]], context())).rejects.toThrow("non-empty array"); await expect( - capability.call("batch", [[{ prompt: "classify", input: null, extra: true }], null]), + capability.batch([[{ prompt: "classify", input: null, extra: true }], null], context()), ).rejects.toThrow("exactly one argument"); await expect( - capability.call("batch", [[{ prompt: "classify", input: null, extra: true }]]), + capability.batch([[{ prompt: "classify", input: null, extra: true }]], context()), ).rejects.toThrow("Invalid batch request"); - await expect(capability.call("batch", [[{ prompt: "", input: null }]])).rejects.toThrow( + await expect(capability.batch([[{ prompt: "", input: null }]], context())).rejects.toThrow( "requires a prompt", ); expect(generateTextMock).not.toHaveBeenCalled(); @@ -78,7 +85,9 @@ describe("recursive model batch capability", () => { input: null, })); - await expect(capability.call("batch", [requests])).rejects.toThrow("cannot exceed 24 requests"); + await expect(capability.batch([requests], context())).rejects.toThrow( + "cannot exceed 24 requests", + ); expect(generateTextMock).not.toHaveBeenCalled(); }); @@ -88,7 +97,7 @@ describe("recursive model batch capability", () => { const capability = createModelCapability(model, { ...observed, admit }); const requests = [{ prompt: "classify", input: "evidence" }]; - await expect(capability.call("batch", [requests])).rejects.toThrow( + await expect(capability.batch([requests], context())).rejects.toThrow( "exhausted its child-model call budget", ); expect(admit).toHaveBeenCalledWith(1); @@ -99,7 +108,7 @@ describe("recursive model batch capability", () => { const capability = createModelCapability(model, hooks()); await expect( - capability.call("batch", [[{ prompt: "é".repeat(8 * 1024 + 1), input: null }]]), + capability.batch([[{ prompt: "é".repeat(8 * 1024 + 1), input: null }]], context()), ).rejects.toThrow("16384 bytes"); expect(generateTextMock).not.toHaveBeenCalled(); }); @@ -109,10 +118,10 @@ describe("recursive model batch capability", () => { const maximumBody = "x".repeat(48 * 1024 - 11); await expect( - capability.call("batch", [[{ prompt: "classify", input: { body: maximumBody } }]]), + capability.batch([[{ prompt: "classify", input: { body: maximumBody } }]], context()), ).resolves.toHaveLength(1); await expect( - capability.call("batch", [[{ prompt: "classify", input: { body: `${maximumBody}x` } }]]), + capability.batch([[{ prompt: "classify", input: { body: `${maximumBody}x` } }]], context()), ).rejects.toThrow("49152 bytes"); }); @@ -123,7 +132,7 @@ describe("recursive model batch capability", () => { input: { body: "x".repeat(48 * 1024 - 12) }, })); - await expect(capability.call("batch", [requests])).rejects.toThrow( + await expect(capability.batch([requests], context())).rejects.toThrow( "Model batch request cannot exceed", ); expect(generateTextMock).not.toHaveBeenCalled(); @@ -145,9 +154,10 @@ describe("recursive model batch capability", () => { }), ); const capability = createModelCapability(model, hooks()); - const resultPromise = capability.call("batch", [ - Array.from({ length: 8 }, (_, index) => ({ prompt: `request ${index}`, input: null })), - ]); + const resultPromise = capability.batch( + [Array.from({ length: 8 }, (_, index) => ({ prompt: `request ${index}`, input: null }))], + context(), + ); await waitForCalls(4); expect(generateTextMock).toHaveBeenCalledTimes(4); @@ -186,10 +196,7 @@ describe("recursive model batch capability", () => { const signal = new AbortController().signal; await expect( - capability.call("batch", [[{ prompt: "classify", input: { evidence: "safe" } }]], { - signal, - deadline: Date.now() + 1_000, - }), + capability.batch([[{ prompt: "classify", input: { evidence: "safe" } }]], context(signal)), ).resolves.toEqual([ { id: expect.any(String), index: 0, ok: true, text: "classification", error: null }, ]); @@ -225,7 +232,7 @@ describe("recursive model batch capability", () => { const capability = createModelCapability(model, observer); await expect( - capability.call("batch", [[{ prompt: "classify", input: null }]]), + capability.batch([[{ prompt: "classify", input: null }]], context()), ).resolves.toEqual([ { id: expect.any(String), @@ -255,10 +262,9 @@ describe("recursive model batch capability", () => { const observer = hooks(); const capability = createModelCapability(model, observer); const controller = new AbortController(); - const result = capability.call( - "batch", + const result = capability.batch( [Array.from({ length: 8 }, (_, index) => ({ prompt: `request ${index}`, input: null }))], - { signal: controller.signal, deadline: Date.now() + 1_000 }, + context(controller.signal), ); await waitForCalls(4); @@ -274,9 +280,10 @@ describe("recursive model batch capability", () => { const observer = hooks(); const capability = createModelCapability(model, observer); - await capability.call("batch", [ - [{ prompt: "SECRET_PROMPT", input: { evidence: "SECRET_INPUT" } }], - ]); + await capability.batch( + [[{ prompt: "SECRET_PROMPT", input: { evidence: "SECRET_INPUT" } }]], + context(), + ); const synchronizedHookData = JSON.stringify({ started: observer.started.mock.calls, diff --git a/examples/rlm/worker/capability.ts b/examples/rlm/worker/capability.ts index bc8b0e05..7ad79083 100644 --- a/examples/rlm/worker/capability.ts +++ b/examples/rlm/worker/capability.ts @@ -1,4 +1,4 @@ -import type { WorkspaceRuntimeValue, WorkspaceTrustedModule } from "@cloudflare/computer"; +import type { WorkspaceModuleFunction, WorkspaceRuntimeValue } from "@cloudflare/computer"; import { generateText, type LanguageModel } from "ai"; import { z } from "zod"; @@ -73,18 +73,20 @@ interface ChildHooks { failed(metadata: FailedMetadata): void; } -export function createModelCapability( - model: LanguageModel, - hooks: ChildHooks, -): WorkspaceTrustedModule { +/** The `ws:model` trusted module: one `batch` function over bounded child requests. */ +export type ModelCapability = { + /** Run up to 24 child model requests and return one result per request. */ + readonly batch: WorkspaceModuleFunction; +}; + +export function createModelCapability(model: LanguageModel, hooks: ChildHooks): ModelCapability { return { - async call(method, args, context) { - if (method !== "batch") throw new Error(`Unknown model capability method: ${method}`); + async batch(args, context) { const requests = parseBatchArgs(args); if (hooks.admit && !hooks.admit(requests.length)) { throw new Error("This run has exhausted its child-model call budget."); } - const signal = context?.signal; + const signal = context.signal; throwIfAborted(signal); const results: Array = new Array(requests.length); @@ -165,7 +167,7 @@ async function runChild( } } -function parseBatchArgs(args: WorkspaceRuntimeValue[]): ChildRequest[] { +function parseBatchArgs(args: readonly WorkspaceRuntimeValue[]): ChildRequest[] { if (args.length !== 1) throw new Error("Model batch requires exactly one argument."); const value = args[0]; if (!Array.isArray(value) || value.length === 0) { diff --git a/examples/rlm/worker/executor-tool.ts b/examples/rlm/worker/executor-tool.ts index 07921ea9..ec5e53dd 100644 --- a/examples/rlm/worker/executor-tool.ts +++ b/examples/rlm/worker/executor-tool.ts @@ -19,7 +19,6 @@ export function createExecutorTool( "Callable isolated JavaScript. The command must be a complete ES module with a default async function.", }, }, - defaultBackend: backend, maxBytes: 16 * 1024, streamMaxBytes: 16 * 1024, }); diff --git a/examples/rlm/worker/prompts.ts b/examples/rlm/worker/prompts.ts index 3243e9c0..ca554246 100644 --- a/examples/rlm/worker/prompts.ts +++ b/examples/rlm/worker/prompts.ts @@ -16,11 +16,11 @@ The executor command must be a complete ES module with export default async func export const RLM_SYSTEM_PROMPT = `Solve the Oolong benchmark with one comprehensive recursive Computer execution, then answer briefly. -The executor command must be a complete ES module with export default async function. Use import fs from "node:fs/promises" and import { call as callModel } from "ws:model". Read /workspace/oolong-real/manifest.json. Its contextChunks contain the long corpus. +The executor command must be a complete ES module with export default async function. Use import fs from "node:fs/promises" and import { batch } from "ws:model". Read /workspace/oolong-real/manifest.json. Its contextChunks contain the long corpus. Use Computer as an RLM. Do not make manifest-inspection, schema-discovery, or diagnostic-only calls: 1. Read the bounded corpus chunks. -2. You have one child-call budget of 24 total requests. Call callModel("batch", requests) exactly once with at most 24 focused requests. Each request is { prompt, input }; keep each input to one chunk and ask for structured, question-specific evidence. +2. You have one child-call budget of 24 total requests. Call batch(requests) exactly once with at most 24 focused requests. Each request is { prompt, input }; keep each input to one chunk and ask for structured, question-specific evidence. 3. Each child result is { index, ok, text, error }. Aggregate successful findings in JavaScript and tolerate failed workers. For first/last-event questions, retain chunk indexes and choose evidence by transcript position; never replace an earlier event with a later, more salient one. 4. Parse the child text, aggregate findings in corpus chunk order, and return { answer } from the default function. If a later execution is needed to finalize from prior findings, it must still return { answer }. diff --git a/examples/rlm/worker/rlm-agent.ts b/examples/rlm/worker/rlm-agent.ts index 7d0fd706..bd34039e 100644 --- a/examples/rlm/worker/rlm-agent.ts +++ b/examples/rlm/worker/rlm-agent.ts @@ -107,7 +107,7 @@ export class RlmAgent extends AIChatAgent { root: WORKSPACE_ROOT, access: "read", egress: { mode: "none" }, - trustedModules: { "ws:model": modelCapability }, + modules: { "ws:model": modelCapability }, maxConcurrentExecutions: 1, maxConcurrentCapabilityCalls: 4, // One manifest read + 24 chunk reads + one bounded ws:model batch. diff --git a/examples/rlm/worker/rlm-interfaces.txt b/examples/rlm/worker/rlm-interfaces.txt index 7e876c9a..139ce825 100644 --- a/examples/rlm/worker/rlm-interfaces.txt +++ b/examples/rlm/worker/rlm-interfaces.txt @@ -1,7 +1,7 @@ Module interface: import fs from "node:fs/promises"; -import { call as callModel } from "ws:model"; +import { batch } from "ws:model"; export default async function () { // Put every file read, batch call, reduction, and return statement inside this function. } -No asynchronous operation or return statement may appear at module scope. Call recursive inference exactly once as callModel("batch", requests). +No asynchronous operation or return statement may appear at module scope. Call recursive inference exactly once as batch(requests). diff --git a/examples/rlm/worker/step-settings.ts b/examples/rlm/worker/step-settings.ts index 7e91f873..443bcfbb 100644 --- a/examples/rlm/worker/step-settings.ts +++ b/examples/rlm/worker/step-settings.ts @@ -5,7 +5,7 @@ export const COMPUTER_FINALIZATION_STEP = 1; const FINALIZATION_INSTRUCTION = 'Return the benchmark answer now. The next executor module must return a typed { answer } object using evidence already seen. Do not inspect more data, return diagnostics, or call ws:model again. The final module must contain no imports or file I/O. Use exactly this shape: export default async function () { return { answer: "derived answer" }; }'; const RECURSION_RETRY_INSTRUCTION = - 'The previous execution did not use recursive inference. Call ws:model("batch", requests) now with question-specific requests over the Workspace chunks, then return the child findings.'; + "The previous execution did not use recursive inference. Call batch(requests) from ws:model now with question-specific requests over the Workspace chunks, then return the child findings."; export function requiredComputerStep( stepNumber: number, diff --git a/examples/rlm/worker/structured-rlm.test.ts b/examples/rlm/worker/structured-rlm.test.ts index 4c76af39..789808c8 100644 --- a/examples/rlm/worker/structured-rlm.test.ts +++ b/examples/rlm/worker/structured-rlm.test.ts @@ -114,7 +114,7 @@ describe("structured RLM strategy", () => { expect(prompt).toContain("structured-v1"); expect(prompt).toContain("last_spell_by_episode"); - expect(prompt).toContain('callModel("batch", requests)'); + expect(prompt).toContain("batch(requests)"); expect(prompt).not.toContain("SECRET_GOLD"); }); }); diff --git a/examples/tanstack-ai/.gitignore b/examples/tanstack-ai/.gitignore new file mode 100644 index 00000000..0dcc8a41 --- /dev/null +++ b/examples/tanstack-ai/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.wrangler/ diff --git a/examples/tanstack-ai/README.md b/examples/tanstack-ai/README.md new file mode 100644 index 00000000..36b5fc0d --- /dev/null +++ b/examples/tanstack-ai/README.md @@ -0,0 +1,51 @@ +# TanStack AI agent + +A one-shot agent built on [TanStack AI](https://tanstack.com/ai). Send it a +task, it works in a durable Workspace, and it replies when it is done. + +There is no loop to write here. `chat()` owns it: it calls the tools the model +asks for, feeds the results back, and keeps going until the model is finished. +`streamToText` waits for that and returns the final text. The agent is about +ten lines in [`src/index.ts`](src/index.ts). + +The workspace tools come from +[`@cloudflare/computer/tools/tanstack-ai`](../../docs/09_tool_interface.md): +`read`, `ls`, `find`, `grep`, `write`, `edit`, `delete`, and `exec`. They +arrive as a list, which is the shape `chat()` wants. + +The Cloudflare adapter talks to Workers AI through the `AI` binding, so the +example needs no API key. + +## Run it + +```sh +npm install +npm run dev --workspace @example/computer-tanstack-ai +``` + +Then give it something to do: + +```sh +curl -X POST http://localhost:8787 \ + -H 'content-type: application/json' \ + -d '{"task":"Write a haiku about durable objects to /workspace/haiku.txt, then read it back."}' +``` + +The agent writes the file with the `write` tool and reads it back with `read`, +then says what it did. Ask it to `grep` or run a shell command and it will +reach for those tools instead. + +To make it ask before changing anything, pass `approve: "mutating"` to +`createTanStackTools`. Tools marked that way pause for confirmation instead of +running straight away. + +To check the agent loop without a Cloudflare account, `npm run local --workspace @example/computer-tanstack-ai` +drives it in Node with a scripted model in place of Workers AI. + +This uses the remote Workers AI binding and counts against your account's +Workers AI usage. If your Wrangler login has access to more than one account, +set `CLOUDFLARE_ACCOUNT_ID` before starting. + +Small models pick tools less reliably than large ones. If the agent replies +without touching a file, say the task more plainly or try a bigger model in +`MODEL`. diff --git a/examples/tanstack-ai/local-shim.mjs b/examples/tanstack-ai/local-shim.mjs new file mode 100644 index 00000000..2fd0eee0 --- /dev/null +++ b/examples/tanstack-ai/local-shim.mjs @@ -0,0 +1,16 @@ +// `npm run local` loads this before run-local.mjs. @cloudflare/computer +// imports `cloudflare:workers`, which only workerd provides; the local +// run never reaches those classes, so empty stand-ins are enough. +import { register } from "node:module"; + +const stub = + "export class RpcTarget {} export class WorkerEntrypoint {} export class DurableObject {} export const env = {};"; + +register( + `data:text/javascript,${encodeURIComponent(`export async function resolve(specifier, context, next) { + if (specifier === "cloudflare:workers") { + return { url: ${JSON.stringify(`data:text/javascript,${stub}`)}, shortCircuit: true }; + } + return next(specifier, context); + }`)}`, +); diff --git a/examples/tanstack-ai/package.json b/examples/tanstack-ai/package.json new file mode 100644 index 00000000..c5de1cc9 --- /dev/null +++ b/examples/tanstack-ai/package.json @@ -0,0 +1,25 @@ +{ + "name": "@example/computer-tanstack-ai", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Example Worker + Durable Object running a one-shot TanStack AI agent against a Workspace, with tools from @cloudflare/computer/tools/tanstack-ai.", + "scripts": { + "dev": "wrangler dev", + "local": "node --import ./local-shim.mjs run-local.mjs", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "build:types": "wrangler types" + }, + "dependencies": { + "@cloudflare/computer": "*", + "@tanstack/ai": "^0.63.0", + "@tanstack/ai-cloudflare": "^0.2.1", + "zod": "^4.4.3" + }, + "devDependencies": { + "@cloudflare/dofs": "*", + "typescript": "^6.0.3", + "wrangler": "^4.137.0" + } +} diff --git a/examples/tanstack-ai/run-local.mjs b/examples/tanstack-ai/run-local.mjs new file mode 100644 index 00000000..bb8ff7b8 --- /dev/null +++ b/examples/tanstack-ai/run-local.mjs @@ -0,0 +1,103 @@ +// Drive the TanStack AI example's agent loop locally, with no +// Cloudflare account. `chat()`, the tools, and the Workspace are real; +// only the provider is substituted, so the tool calls below are +// scripted rather than chosen. +// +// npm run local + +import { Workspace } from "@cloudflare/computer"; +import { createTanStackTools } from "@cloudflare/computer/tools/tanstack-ai"; +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { chat, maxIterations } from "@tanstack/ai"; + +const workspace = new Workspace({ storage: new SQLiteTestStorage() }); +const tools = createTanStackTools({ workspace }); + +// One scripted turn per agent-loop iteration. +const script = [ + { + toolCalls: [ + { + name: "write", + args: { + path: "/workspace/haiku.txt", + content: "durable object\nholds a file across restarts\npatient as a stone\n", + }, + }, + ], + }, + { toolCalls: [{ name: "read", args: { path: "/workspace/haiku.txt" } }] }, + { text: "Wrote the haiku and read it back." }, +]; + +let turn = 0; + +// The smallest adapter shape chat() will drive: AG-UI events for one +// assistant turn, then stop. +const scriptedAdapter = { + name: "scripted", + model: "scripted", + provider: "scripted", + capabilities: { streaming: true, tools: true }, + async *chatStream() { + const step = script[Math.min(turn, script.length - 1)]; + turn += 1; + const messageId = `m${turn}`; + yield { type: "RUN_STARTED", timestamp: Date.now() }; + + if (step.toolCalls) { + for (const [i, call] of step.toolCalls.entries()) { + const toolCallId = `call-${turn}-${i}`; + yield { + type: "TOOL_CALL_START", + toolCallId, + toolCallName: call.name, + toolName: call.name, + index: i, + timestamp: Date.now(), + }; + yield { + type: "TOOL_CALL_ARGS", + toolCallId, + delta: JSON.stringify(call.args), + timestamp: Date.now(), + }; + yield { + type: "TOOL_CALL_END", + toolCallId, + toolCallName: call.name, + toolName: call.name, + input: call.args, + timestamp: Date.now(), + }; + } + yield { type: "RUN_FINISHED", finishReason: "tool_calls", timestamp: Date.now() }; + return; + } + + yield { type: "TEXT_MESSAGE_START", messageId, role: "assistant", timestamp: Date.now() }; + yield { type: "TEXT_MESSAGE_CONTENT", messageId, delta: step.text, timestamp: Date.now() }; + yield { type: "TEXT_MESSAGE_END", messageId, timestamp: Date.now() }; + yield { type: "RUN_FINISHED", finishReason: "stop", timestamp: Date.now() }; + }, +}; + +const stream = chat({ + adapter: scriptedAdapter, + systemPrompts: ["You are working in a directory at /workspace."], + messages: [{ role: "user", content: "Write a haiku to /workspace/haiku.txt then read it back." }], + tools, + agentLoopStrategy: maxIterations(10), +}); + +const chunks = []; +for await (const chunk of stream) { + if (chunk.type === "TOOL_CALL_END") { + chunks.push(JSON.stringify(chunk).slice(0, 320)); + } + if (chunk.type === "TEXT_MESSAGE_CONTENT") chunks.push(`text: ${chunk.delta}`); +} +for (const line of chunks) console.log(line); + +const onDisk = await workspace.fs.readFile("/workspace/haiku.txt", "utf8"); +console.log("\nfile on disk:", JSON.stringify(onDisk)); diff --git a/examples/tanstack-ai/src/index.ts b/examples/tanstack-ai/src/index.ts new file mode 100644 index 00000000..55d3e63f --- /dev/null +++ b/examples/tanstack-ai/src/index.ts @@ -0,0 +1,81 @@ +// A one-shot agent on TanStack AI, working in a durable Workspace. +// +// client ──► Worker / ──► TanStackAgent DO ──► Workspace (files + shell) +// │ +// └──► Workers AI, through env.AI + +import { DurableObject } from "cloudflare:workers"; + +import { + type DurableObjectStorageLike, + Workspace, + WorkspaceServiceProxy, + type WorkspaceStub, +} from "@cloudflare/computer"; +import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell"; +import { createTanStackTools } from "@cloudflare/computer/tools/tanstack-ai"; +import { chat, maxIterations, streamToText } from "@tanstack/ai"; +import { cloudflareText } from "@tanstack/ai-cloudflare"; + +// The worker-shell backend reaches back into this durable object by +// binding name and id, so the shell shares the agent's filesystem. +export { WorkspaceServiceProxy }; + +const MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast"; + +export class TanStackAgent extends DurableObject { + workspace = new Workspace({ + storage: this.ctx.storage as unknown as DurableObjectStorageLike, + backends: [ + new WorkerShellBackend({ + id: "shell", + loader: this.env.LOADER, + workspace: { binding: "TanStackAgent", id: this.ctx.id.toString() }, + ctx: this.ctx, + }), + ], + }); + + /** Lets the shell in the Dynamic Worker reach this workspace. */ + async __getWorkspaceStub(): Promise { + await this.workspace.ready(); + return this.workspace.stub(); + } + + async run(task: string): Promise { + const tools = createTanStackTools({ + workspace: this.workspace, + }); + + const stream = chat({ + // The cast is a version mismatch, not a real one: the adapter is + // on @cloudflare/workers-types v4 and this repo is on v5, so the + // two structurally identical `Ai` types will not unify. Drop it + // once the adapter moves to v5. + adapter: cloudflareText(MODEL, { binding: this.env.AI as unknown as never }), + systemPrompts: [ + "You are working in a directory at /workspace. Use the tools to do what the user asks, then say what you did.", + ], + messages: [{ role: "user", content: task }], + tools, + // Bound the spend if the model fails to converge. + agentLoopStrategy: maxIterations(10), + }); + + return await streamToText(stream); + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + if (request.method !== "POST") { + return new Response('POST a task, e.g. {"task":"write hello.txt"}\n', { status: 405 }); + } + + const { task } = (await request.json()) as { task?: string }; + if (!task) return new Response("body needs a task\n", { status: 400 }); + + const agent = env.TanStackAgent.get(env.TanStackAgent.idFromName("demo")); + return new Response(`${await agent.run(task)}\n`); + }, +} satisfies ExportedHandler; diff --git a/examples/tanstack-ai/tsconfig.json b/examples/tanstack-ai/tsconfig.json new file mode 100644 index 00000000..5a6253fb --- /dev/null +++ b/examples/tanstack-ai/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "types": ["./worker-configuration.d.ts"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["worker-configuration.d.ts", "src/**/*.ts"] +} diff --git a/examples/tanstack-ai/wrangler.jsonc b/examples/tanstack-ai/wrangler.jsonc new file mode 100644 index 00000000..16d79fbc --- /dev/null +++ b/examples/tanstack-ai/wrangler.jsonc @@ -0,0 +1,40 @@ +{ + // Example: a one-shot TanStack AI agent in a Durable Object. + // + // The DO holds a Workspace whose shell is a Dynamic Worker, and + // talks to Workers AI through the AI binding, so the example needs + // no API key. + "$schema": "node_modules/wrangler/config-schema.json", + "name": "computer-tanstack-ai-example", + "main": "src/index.ts", + "compatibility_date": "2026-05-26", + "compatibility_flags": ["nodejs_compat", "experimental"], + + // Workers AI. Running this uses your account's Workers AI quota. + "ai": { + "binding": "AI" + }, + + // The workspace shell runs in a Dynamic Worker minted through this. + "worker_loaders": [ + { + "binding": "LOADER" + } + ], + + "durable_objects": { + "bindings": [ + { + "name": "TanStackAgent", + "class_name": "TanStackAgent" + } + ] + }, + + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["TanStackAgent"] + } + ] +} diff --git a/examples/think/README.md b/examples/think/README.md index 9049e2bd..f6cd582d 100644 --- a/examples/think/README.md +++ b/examples/think/README.md @@ -24,7 +24,7 @@ would use, so no bespoke HTTP route or transport is involved. [think]: https://www.npmjs.com/package/@cloudflare/think [workspace]: ../../packages/computer -[tools]: ../../packages/computer/src/tools +[tools]: ../../packages/computer/src/tools/ai-sdk.ts [aisdk7]: https://vercel.com/blog/ai-sdk-7 ## Shape @@ -53,9 +53,10 @@ model, a Workspace, and the workspace tools. ## Tools The tools come from `createAITools()` in -[`@cloudflare/computer/tools`][tools]. This example enables the file -tools and opts into `exec` by passing a shell backend description; it -does not configure the assets publisher, so `publish` is not offered. +[`@cloudflare/computer/tools/ai-sdk`][tools]. This example offers the +file tools and an `exec` tool over both backends, each of which +describes itself to the model. It does not configure the assets +publisher, so `publish` is not offered. | Tool | What it does | | ------- | --------------------------------------------------------- | @@ -75,8 +76,9 @@ does not configure the assets publisher, so `publish` is not offered. and `git log` work from inside `exec` even though the shell isolate has no public network of its own. Only `https://` URLs are supported. -- `"container"` — a Cloudflare Container running `computerd` over capnweb, - modelled on [`examples/container-legacy`](../container-legacy). It has full Linux +- `"container"` — a `ContainerBackend` running `computerd` over capnweb + in a Cloudflare Container the durable object schedules, modelled on + [`examples/container`](../container). It has full Linux userland, public network, `npm`, `node`, `python`, package managers, test runners, and other real binaries on `$PATH`. It cold-starts more slowly, so use it when the shell backend cannot run the @@ -87,7 +89,7 @@ The system prompt tells the model to prefer `read`/`ls` over fast `shell` backend before falling through to `container`. See [`docs/05_runtime_interface.md`](../../docs/05_runtime_interface.md), [`docs/13_git_interface.md`](../../docs/13_git_interface.md), and -[`examples/container-legacy`](../container-legacy). +[`examples/container`](../container). ## Running it locally diff --git a/examples/think/src/agent.ts b/examples/think/src/agent.ts index 755cf4fc..1fc2ddc6 100644 --- a/examples/think/src/agent.ts +++ b/examples/think/src/agent.ts @@ -15,8 +15,8 @@ * store, agentic loop, and chat protocol. * - We own a `@cloudflare/computer.Workspace` with two backends: * a WorkerShellBackend (`"shell"`) for fast just-bash text tooling and - * a LegacyContainerBackend (`"container"`) for full Linux - * userland through computerd. This mirrors examples/container-legacy while + * a ContainerBackend (`"container"`) for full Linux + * userland through computerd. This mirrors examples/container while * keeping the chat surface unchanged. * - `useThink: true` adds the string-based compatibility surface * Think expects; the cast promotes it from optional to present. @@ -32,12 +32,9 @@ import { WorkspaceServiceProxy, type WorkspaceStub, } from "@cloudflare/computer"; -import { - LegacyContainerBackend, - withLegacyWorkspaceContainer, -} from "@cloudflare/computer/backends/container-legacy"; +import { ContainerBackend, withWorkspaceContainer } from "@cloudflare/computer/backends/container"; import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell"; -import { createAITools } from "@cloudflare/computer/tools"; +import { createAITools } from "@cloudflare/computer/tools/ai-sdk"; import { Think } from "@cloudflare/think"; import type { ToolSet } from "ai"; import { createWorkersAI } from "workers-ai-provider"; @@ -58,11 +55,11 @@ function workspaceRef(ctx: DurableObjectState) { return { binding: "Assistant", id: ctx.id.toString() }; } -// Anchor Think's generic before the mixin so withLegacyWorkspaceContainer +// Anchor Think's generic before the mixin so withWorkspaceContainer // sees a concrete constructor. class AssistantBase extends Think {} -export class Assistant extends withLegacyWorkspaceContainer(AssistantBase) { +export class Assistant extends withWorkspaceContainer(AssistantBase) { /** We have a dedicated `exec` tool; skip Think's built-in bash. */ override workspaceBash = false; @@ -72,15 +69,18 @@ export class Assistant extends withLegacyWorkspaceContainer(AssistantBase) { /** * Container backend used when `exec` needs a real Linux userland. * The DO itself owns the container binding through the - * withLegacyWorkspaceContainer mixin; LegacyContainerBackend handles + * withWorkspaceContainer mixin; ContainerBackend handles * startup, outbound egress interception, the /api upgrade, and the * capnweb session. */ - readonly #containerBackend = new LegacyContainerBackend({ + readonly #containerBackend = new ContainerBackend({ id: "container", container: () => this, workspace: workspaceRef(this.ctx), egress: { mode: "direct" }, + // The durable object schedules this container, so it asks for its + // size at launch; wrangler.jsonc names the image under `images.app`. + instance: "standard-2", }); /** @@ -137,8 +137,8 @@ export class Assistant extends withLegacyWorkspaceContainer(AssistantBase) { " `exec cat` / `exec ls`.", " - write, edit: create and modify files. Prefer these over", " `exec sed` / shell heredocs.", - " - exec: run shell commands. Use the default `shell`", - " backend first: it is just-bash in a Dynamic", + " - exec: run shell commands. Name a backend on every call.", + " Try backend `shell` first: it is just-bash in a Dynamic", " Worker, cold-starts quickly, and includes `git`", " (clone / status / diff / log) via the host", " workspace. Only https:// git URLs are supported.", @@ -154,35 +154,8 @@ export class Assistant extends withLegacyWorkspaceContainer(AssistantBase) { } override getTools(): ToolSet { - return createAITools({ - workspace: this.workspace, - shell: { - defaultBackend: "shell", - backends: { - shell: { - description: - "just-bash in a Dynamic Worker. Cold-start fast, no " + - "container, no public network. Good for cat / grep / sed / " + - "awk / jq / head / tail / sort / find, quick file " + - "inspection, text transformations, and `git` (clone / " + - "status / diff / log) — the shell registers a built-in " + - "`git` command that forwards to the host workspace, so " + - "network-bound subcommands like `git clone` work even " + - "though the isolate itself has no public network. Only " + - "https:// URLs are supported. Cannot run npm, node, python, " + - "or any binary outside just-bash's built-in command set.", - }, - container: { - description: - "Cloudflare Container running computerd over capnweb. Full Linux " + - "userland: npm, node, python, package managers, test " + - "runners, real binaries on $PATH, and public network. Cold " + - "start is much slower because the container must boot; " + - "reach for it when the shell backend can't run the command. " + - "For git itself, prefer the shell backend.", - }, - }, - }, - }); + // Every backend the Workspace has, "shell" first. Both describe + // themselves to the model. + return createAITools({ workspace: this.workspace }); } } diff --git a/examples/think/wrangler.jsonc b/examples/think/wrangler.jsonc index 03cf2b88..5c988174 100644 --- a/examples/think/wrangler.jsonc +++ b/examples/think/wrangler.jsonc @@ -15,14 +15,15 @@ "containers": [ { "class_name": "Assistant", - // Built from the local Dockerfile, which copies computerd into a - // small Debian image with Node/npm/git for real build and test - // workflows. - "image": "./Dockerfile", - "instance_type": "standard-2", - "max_instances": 5, - "rollout_active_grace_period": 0, - "rollout_step_percentage": [100] + // The durable object schedules this container and asks for its + // size at launch, so the block names images instead of + // instance_type and max_instances. The image is built from the + // local Dockerfile, which copies computerd into a small Debian + // image with Node/npm/git for real build and test workflows. + "scheduling_policy": "durable_object", + "images": { + "app": { "dockerfile": "./Dockerfile" } + } } ], diff --git a/package-lock.json b/package-lock.json index f371cda0..1614cea1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5938,6 +5938,56 @@ } } }, + "examples/pi-ai": { + "name": "@example/computer-pi-ai", + "version": "0.0.0", + "dependencies": { + "@cloudflare/computer": "*", + "@earendil-works/pi-ai": "^0.99.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "@cloudflare/dofs": "*", + "typescript": "^6.0.3", + "wrangler": "^4.137.0" + } + }, + "examples/pi-ai/node_modules/wrangler": { + "version": "4.137.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.137.0.tgz", + "integrity": "sha512-vq2JmxkvwOjnsMUejQwd89/EK6u1d20OMlGUVVmb55gVi2zSBKp2rvmxUiPqrSEntK8NwzpTF3DQ/S2I9/TLsg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260921.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260921.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260921.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, "examples/rlm": { "name": "@cloudflare/example-rlm", "version": "0.0.0", @@ -6794,6 +6844,57 @@ } } }, + "examples/tanstack-ai": { + "name": "@example/computer-tanstack-ai", + "version": "0.0.0", + "dependencies": { + "@cloudflare/computer": "*", + "@tanstack/ai": "^0.63.0", + "@tanstack/ai-cloudflare": "^0.2.1", + "zod": "^4.4.3" + }, + "devDependencies": { + "@cloudflare/dofs": "*", + "typescript": "^6.0.3", + "wrangler": "^4.137.0" + } + }, + "examples/tanstack-ai/node_modules/wrangler": { + "version": "4.137.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.137.0.tgz", + "integrity": "sha512-vq2JmxkvwOjnsMUejQwd89/EK6u1d20OMlGUVVmb55gVi2zSBKp2rvmxUiPqrSEntK8NwzpTF3DQ/S2I9/TLsg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260921.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260921.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260921.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, "examples/think": { "name": "@cloudflare/example-think", "version": "0.0.0", @@ -11088,6 +11189,20 @@ } } }, + "node_modules/@ag-ui/core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-1.0.0.tgz", + "integrity": "sha512-yCRhsQvb4+lmGMrDsi4TOJxqKpm/kb3H64wwL+Z90jNVrkhy17RErfzsNgybfbjNKLM0nenEDYDVJsZHJJSigQ==", + "license": "MIT", + "peerDependencies": { + "zod": "^3.25.18 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@ai-sdk/anthropic": { "version": "4.0.24", "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-4.0.24.tgz", @@ -11217,6 +11332,27 @@ "node": ">=22" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.124.0.tgz", + "integrity": "sha512-cN5O8i9UVxHeOQAzj/XjshWXG8KiibJDw9OGpH2Z/eR3n/RBxdoLxDJOcfqAJWvjaMDFfHTBADU04hWRJVkDyA==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", @@ -11268,6 +11404,347 @@ "dev": true, "license": "MIT" }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1127.0.tgz", + "integrity": "sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.82", + "@aws-sdk/eventstream-handler-node": "^3.972.34", + "@aws-sdk/middleware-eventstream": "^3.972.29", + "@aws-sdk/middleware-websocket": "^3.972.52", + "@aws-sdk/token-providers": "3.1127.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.978.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz", + "integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@aws-sdk/xml-builder": "^3.972.41", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.35.0", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz", + "integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz", + "integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz", + "integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/credential-provider-env": "^3.972.72", + "@aws-sdk/credential-provider-http": "^3.972.74", + "@aws-sdk/credential-provider-login": "^3.972.79", + "@aws-sdk/credential-provider-process": "^3.972.72", + "@aws-sdk/credential-provider-sso": "^3.973.16", + "@aws-sdk/credential-provider-web-identity": "^3.972.78", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz", + "integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.84", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz", + "integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.72", + "@aws-sdk/credential-provider-http": "^3.972.74", + "@aws-sdk/credential-provider-ini": "^3.973.17", + "@aws-sdk/credential-provider-process": "^3.972.72", + "@aws-sdk/credential-provider-sso": "^3.973.16", + "@aws-sdk/credential-provider-web-identity": "^3.972.78", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz", + "integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz", + "integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/token-providers": "3.1138.0", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1138.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz", + "integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz", + "integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.35.tgz", + "integrity": "sha512-a8xilRoRaalvSPZdfrs0VY3/BPc0uYhXW56Z/k6nmZ5Vk430LHeowf9APIPq8utM3tMJSNGSSuJMlB9YrPsN+A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.30.tgz", + "integrity": "sha512-B6gvZlcnRBNWraKgyEjKsbhv+VjtbyHmpU7hmtTHzXpDSxHPpCJnYLpglEOOsF+SFne20DbZC5IVC1aNr2Pb4A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.54.tgz", + "integrity": "sha512-bhESdru8u8KosziH8jcVta/NM/dNreeRz2+2aU86si5bfBYOiJ6IiqD+9U4URUcU5WJO3Yw+jO3GLDIHlscXMQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz", + "integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/signature-v4-multi-region": "^3.996.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.47", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz", + "integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1127.0.tgz", + "integrity": "sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz", + "integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz", + "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -12885,6 +13362,39 @@ "node": ">=20.19.0" } }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.99.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.99.2.tgz", + "integrity": "sha512-9RFOEdY+ZTJ1AI+UuAFs4RM0tF2Tje/R/CEv9gWTJHt0iTu8XHZs64U/EIZxNSllFmec/4HfwsOxYj1qQek9bg==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.124.0", + "@aws-sdk/client-bedrock-runtime": "3.1127.0", + "@earendil-works/pi-telemetry": "^0.99.2", + "@google/genai": "2.21.0", + "@smithy/node-http-handler": "4.12.1", + "http-proxy-agent": "9.1.0", + "https-proxy-agent": "9.1.0", + "openai": "7.19.0", + "partial-json": "0.1.7", + "typebox": "1.3.27" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "0.99.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.99.2.tgz", + "integrity": "sha512-WThYU4XM6jjjzH4FzLdreN7QAPS9SDdokL0W0Ldheg1ssCIJkfpK7PgebFm7/PoQub7OiXx+SRF6B/oF7z7lXA==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@emnapi/core": { "version": "2.0.0-alpha.3", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", @@ -13360,6 +13870,14 @@ "resolved": "examples/mcp", "link": true }, + "node_modules/@example/computer-pi-ai": { + "resolved": "examples/pi-ai", + "link": true + }, + "node_modules/@example/computer-tanstack-ai": { + "resolved": "examples/tanstack-ai", + "link": true + }, "node_modules/@example/computer-tutorial": { "resolved": "examples/tutorial", "link": true @@ -13390,6 +13908,30 @@ } } }, + "node_modules/@google/genai": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.21.0.tgz", + "integrity": "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "node_modules/@hono/node-server": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz", @@ -14836,6 +15378,63 @@ "dev": true, "license": "MIT" }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", @@ -15242,6 +15841,87 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@smithy/core": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.1.tgz", + "integrity": "sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz", + "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz", + "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@speed-highlight/core": { "version": "1.2.17", "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", @@ -15249,6 +15929,12 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -15536,7 +16222,176 @@ "tailwindcss": "4.3.3" }, "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/ai": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@tanstack/ai/-/ai-0.63.0.tgz", + "integrity": "sha512-4S4hOOc/2LvxNkMzwuBaECtchPQsxrlLNqnmi/WjcXmX8gyboy8UNPwwS/9XzFFJ4VwSEW2Md+h+OtTGmf6Y/g==", + "license": "MIT", + "dependencies": { + "@ag-ui/core": "1.0.0", + "@standard-schema/spec": "^1.1.0", + "@tanstack/ai-event-client": "^0.13.0", + "@tanstack/ai-utils": "^0.4.1", + "partial-json": "^0.1.7" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@tanstack/ai-cloudflare": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@tanstack/ai-cloudflare/-/ai-cloudflare-0.2.1.tgz", + "integrity": "sha512-Qo/UlQ/1Db4/k7rSXa1UV8x29IYM+Jd4Rsbzr3ygzF6oMzqNOT7INZS2I28XbpiNzwAhnHMUhKGPM1u9swC7aQ==", + "license": "MIT", + "dependencies": { + "@cloudflare/workers-types": "^4.20260317.1", + "@tanstack/ai-utils": "^0.4.1", + "@tanstack/openai-base": "^0.12.1", + "openai": "^6.41.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/ai": "^0.63.0" + } + }, + "node_modules/@tanstack/ai-cloudflare/node_modules/@cloudflare/workers-types": { + "version": "4.20260702.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz", + "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==", + "license": "MIT OR Apache-2.0" + }, + "node_modules/@tanstack/ai-cloudflare/node_modules/openai": { + "version": "6.49.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.49.0.tgz", + "integrity": "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@tanstack/ai-event-client": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@tanstack/ai-event-client/-/ai-event-client-0.13.0.tgz", + "integrity": "sha512-qGN7saScQHqDd06DE5CNG+wl1O9v6anwsGCCeP4N7zFbZd5k9/2C1u4cdJ2+Qxl2aA8drusLFZMhY/ljhBJqLQ==", + "license": "MIT", + "dependencies": { + "@tanstack/devtools-event-client": "^0.4.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/ai-utils": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tanstack/ai-utils/-/ai-utils-0.4.1.tgz", + "integrity": "sha512-B3PGn2WYiRtivZCt7MUN2iXeoK9fwZhgdbrPIz5hPqVR8r8sNDZMmMqtiOkp7sxzPvVlCBItFVHw7QpZ9g6e9w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/devtools-event-client": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.4.tgz", + "integrity": "sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==", + "license": "MIT", + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/openai-base": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@tanstack/openai-base/-/openai-base-0.12.1.tgz", + "integrity": "sha512-mkt86u9kIW4oScA738ntrpTMXTqkTxZl5XU54lcUkdl5xT3Kx4fM4HT9x9x49twiUNHJEP7y44m7gK7FkZbiMA==", + "license": "MIT", + "dependencies": { + "@tanstack/ai-utils": "^0.4.1", + "openai": "^6.41.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/ai": "^0.63.0" + } + }, + "node_modules/@tanstack/openai-base/node_modules/openai": { + "version": "6.49.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.49.0.tgz", + "integrity": "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } } }, "node_modules/@testing-library/dom": { @@ -15716,7 +16571,6 @@ "version": "25.9.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -15741,6 +16595,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -15954,6 +16814,15 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, "node_modules/agents": { "version": "0.20.1", "resolved": "https://registry.npmjs.org/agents/-/agents-0.20.1.tgz", @@ -16286,6 +17155,15 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/birpc": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/birpc/-/birpc-4.0.0.tgz", @@ -16392,6 +17270,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", @@ -16475,6 +17359,12 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -16886,6 +17776,15 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -17118,6 +18017,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -17541,6 +18449,12 @@ "node": ">=8.6.0" } }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", @@ -17624,6 +18538,29 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-type": { "version": "21.3.4", "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", @@ -17705,6 +18642,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -17813,6 +18762,74 @@ "integrity": "sha512-Dj4ssxo1/MKGvOsVWRblSRu+o5F5OJTrVPDkjSyGDU2yKvVnIzQSwy1deiWA0qCcS/Q8iJMlZaCpCcZWSwvoug==", "license": "MIT" }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -17955,6 +18972,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -18150,6 +19193,34 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/human-id": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.2.0.tgz", @@ -18574,12 +19645,34 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-schema": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", "license": "(AFL-2.1 OR BSD-3-Clause)" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -18665,6 +19758,27 @@ "node": ">=0.3.1" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -18968,6 +20082,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -20209,6 +21329,26 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -20370,6 +21510,43 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/openai": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.19.0.tgz", + "integrity": "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "undici": ">=5 <9", + "ws": "^8.21.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "undici": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/outdent": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz", @@ -20429,6 +21606,19 @@ "node": ">=6" } }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -20508,6 +21698,12 @@ "node": ">= 0.8" } }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, "node_modules/partyserver": { "version": "0.5.9", "resolved": "https://registry.npmjs.org/partyserver/-/partyserver-0.5.9.tgz", @@ -20805,6 +22001,29 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -20818,6 +22037,23 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -21218,6 +22454,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -21995,6 +23240,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -22468,11 +23723,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "devOptional": true, "license": "0BSD" }, "node_modules/tunnel-agent": { @@ -22532,6 +23792,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typebox": { + "version": "1.3.27", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.27.tgz", + "integrity": "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==", + "license": "MIT" + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -22585,7 +23851,6 @@ "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "devOptional": true, "license": "MIT" }, "node_modules/unenv": { @@ -23246,6 +24511,15 @@ "node": ">=18" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", @@ -23478,7 +24752,6 @@ "version": "8.21.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -23659,7 +24932,9 @@ "@cloudflare/dofs": "*", "@cloudflare/vitest-pool-workers": "^0.22.0", "@cloudflare/workers-types": "^4.20260616.1 || ^5.20260921.1", + "@earendil-works/pi-ai": "^0.99.2", "@platformatic/vfs": "^0.4.0", + "@tanstack/ai": "^0.63.0", "ai": "^7.0.0", "diff": "^9.0.0", "esbuild": "^0.28.1", diff --git a/packages/computer/README.md b/packages/computer/README.md index dc9b81b4..4e52b6ee 100644 --- a/packages/computer/README.md +++ b/packages/computer/README.md @@ -50,8 +50,11 @@ worker-shell and worker-javascript backends additionally need the own binding requirements — see [Choosing a backend](#choosing-a-backend). Optional peer dependencies, installed only if you use the matching -feature: `ai` and `zod` (for `@cloudflare/computer/tools`), -`@platformatic/vfs` (for the Node-side VFS provider). +feature: `zod` for every tools entry point, `ai` for +`@cloudflare/computer/tools` and `@cloudflare/computer/tools/ai-sdk`, and +`@platformatic/vfs` for the Node-side VFS provider. The pi and TanStack +AI entry points need nothing beyond `zod`; your agent brings its own +library. ## Quick start @@ -255,8 +258,8 @@ Alongside `exec`, the runtime exposes `getExec`, `killExec`, and [`examples/worker-shell`](../../examples/worker-shell). - **Worker JavaScript** evaluates a module with structured input/results, durable relative imports, configured libraries, - Workspace-backed `node:fs/promises`, and trusted `ws:git` / - `ws:artifacts` modules. It runs after `runtime.exec()` returns; the + Workspace-backed `node:fs/promises`, and host modules such as + `ws:git`, `ws:artifacts`, and `ws:container`. It runs after `runtime.exec()` returns; the run stays alive while its event stream is consumed. See [`docs/17_isolate_javascript.md`](../../docs/17_isolate_javascript.md) and [`examples/worker-javascript`](../../examples/worker-javascript). @@ -266,31 +269,27 @@ to a named one — see [Multiple backends](#multiple-backends). ## Tools for agents -`@cloudflare/computer/tools` ships AI SDK tools that wrap the Workspace +`@cloudflare/computer/tools/ai-sdk` ships `createAITools()`, AI SDK tools that wrap the Workspace surfaces, ready to hand to `generateText`, `streamText`, or an agent framework's `getTools()`. The default set is `read`, `ls`, `find`, -`grep`, `write`, `edit`, and `delete`; `exec` and `publish` are added -when you configure them. Read-only mode keeps `read`, `ls`, `find`, and +`grep`, `write`, `edit`, and `delete`, plus `exec` when the Workspace +has a backend and `publish` when assets are configured. Read-only mode keeps `read`, `ls`, `find`, and `grep`. ```ts -import { createAITools } from "@cloudflare/computer/tools"; +import { createAITools } from "@cloudflare/computer/tools/ai-sdk"; const tools = createAITools({ workspace, read: { maxBytes: 32 * 1024, maxLines: 800 }, - shell: { - defaultBackend: "shell", - backends: { - shell: { description: "Fast Worker shell with built-in text commands." }, - container: { description: "Full Linux userland in a Cloudflare Container." }, - }, - }, + // The backends the model can use. Omit for every backend. + exec: { shell: { description: "Try this first." }, container: {} }, }); ``` -The model reads each backend's `description` when deciding where a -command should run, so write them in plain language. Truncated text +Each backend describes itself to the model, and the text you give in +`exec` comes first. The model reads both when deciding where a command +should run, so write yours in plain language. Truncated text model output keeps both line and byte continuations; pass both to the next call to avoid transferring the same bytes again. Eligible image and PDF bytes are captured once during the bounded tool execution and returned @@ -303,6 +302,21 @@ mutations share locks across tool sets for the same workspace, and recursive deletion excludes mutations throughout its subtree. See [`docs/09_tool_interface.md`](../../docs/09_tool_interface.md). +The same tools, with the same options, come for two more agent +libraries. Each entry point loads only `zod` and its own code, so +importing one never pulls in another library. + +```ts +import { createPiTools } from "@cloudflare/computer/tools/pi-ai"; +import { createTanStackTools } from "@cloudflare/computer/tools/tanstack-ai"; + +// pi: declarations for the model, and a function your loop calls per tool call. +const { tools, execute } = createPiTools({ workspace }); + +// TanStack AI: a list for chat({ tools }). This one asks before changing files. +const tanstackTools = createTanStackTools({ workspace, approve: "mutating" }); +``` + ## Git `workspace.git` is an opt-in typed git client backed by @@ -418,8 +432,14 @@ on a computerd instance. | `@cloudflare/computer` | The `Workspace` wrapper, `workspace.runtime`, stub types, the R2 mount, and proxy classes. | | `@cloudflare/computer/backends/container-legacy` | `LegacyContainerBackend` and `withLegacyWorkspaceContainer`. Pulls in the computerd / capnweb sync plumbing. | | `@cloudflare/computer/backends/worker-shell` | `WorkerShellBackend` and the bundled just-bash runtime. | -| `@cloudflare/computer/backends/worker-javascript` | `WorkerJavaScriptBackend`, configured libraries, durable imports, `node:fs/promises`, and trusted `ws:git` / `ws:artifacts`. | +| `@cloudflare/computer/backends/worker-javascript` | `WorkerJavaScriptBackend`, configured libraries, durable imports, `node:fs/promises`, and host modules. | +| `@cloudflare/computer/modules/container` | `createContainerModule()` for `ws:container`: run container commands from isolate JavaScript. | +| `@cloudflare/computer/modules/git` | `createGitModule()` for `ws:git`: confined Git from isolate JavaScript. | +| `@cloudflare/computer/modules/artifacts` | `createArtifactsModule()` for `ws:artifacts`: Artifacts from isolate JavaScript. | | `@cloudflare/computer/tools` | AI SDK tools for agents: `read`, `ls`, `find`, `grep`, `write`, `edit`, `delete`, and optional `exec` and `publish`. | +| `@cloudflare/computer/tools/ai-sdk` | `createAITools()`: the AI SDK tool set for a Workspace. | +| `@cloudflare/computer/tools/pi-ai` | `createPiTools()`: the same tool set for pi (`@earendil-works/pi-ai`). | +| `@cloudflare/computer/tools/tanstack-ai` | `createTanStackTools()`: the same tool set for TanStack AI (`@tanstack/ai`). | | `@cloudflare/computer/git` | Opt-in `isomorphic-git` glue for checkouts inside the workspace. | | `@cloudflare/computer/assets` | `createAssets` — share a workspace file to R2 as a presigned URL. | | `@cloudflare/computer/artifacts` | `createArtifact` and its CLI, an optionally session-scoped wrapper over the Cloudflare Artifacts binding. | diff --git a/packages/computer/package.json b/packages/computer/package.json index 516e38d4..f2f64b59 100644 --- a/packages/computer/package.json +++ b/packages/computer/package.json @@ -31,6 +31,18 @@ "types": "./dist/artifacts/index.d.ts", "import": "./dist/artifacts/index.js" }, + "./modules/container": { + "types": "./dist/modules/container.d.ts", + "import": "./dist/modules/container.js" + }, + "./modules/git": { + "types": "./dist/modules/git.d.ts", + "import": "./dist/modules/git.js" + }, + "./modules/artifacts": { + "types": "./dist/modules/artifacts.d.ts", + "import": "./dist/modules/artifacts.js" + }, "./tools": { "types": "./dist/tools/index.d.ts", "import": "./dist/tools/index.js" @@ -39,6 +51,18 @@ "types": "./dist/backends/container-legacy/index.d.ts", "import": "./dist/backends/container-legacy/index.js" }, + "./tools/ai-sdk": { + "types": "./dist/tools/ai-sdk.d.ts", + "import": "./dist/tools/ai-sdk.js" + }, + "./tools/pi-ai": { + "types": "./dist/tools/pi-ai.d.ts", + "import": "./dist/tools/pi-ai.js" + }, + "./tools/tanstack-ai": { + "types": "./dist/tools/tanstack-ai.d.ts", + "import": "./dist/tools/tanstack-ai.js" + }, "./backends/container": { "types": "./dist/backends/container/index.d.ts", "import": "./dist/backends/container/index.js" @@ -149,7 +173,9 @@ "@cloudflare/dofs": "*", "@cloudflare/vitest-pool-workers": "^0.22.0", "@cloudflare/workers-types": "^4.20260616.1 || ^5.20260921.1", + "@earendil-works/pi-ai": "^0.99.2", "@platformatic/vfs": "^0.4.0", + "@tanstack/ai": "^0.63.0", "ai": "^7.0.0", "diff": "^9.0.0", "esbuild": "^0.28.1", diff --git a/packages/computer/rolldown.config.ts b/packages/computer/rolldown.config.ts index 0a78e602..22b9a8a2 100644 --- a/packages/computer/rolldown.config.ts +++ b/packages/computer/rolldown.config.ts @@ -31,6 +31,12 @@ export default defineConfig({ "artifacts/index": "src/artifacts/index.ts", "assets/index": "src/assets/index.ts", "tools/index": "src/tools/index.ts", + "tools/ai-sdk": "src/tools/ai-sdk/index.ts", + "tools/pi-ai": "src/tools/pi-ai/index.ts", + "tools/tanstack-ai": "src/tools/tanstack-ai/index.ts", + "modules/container": "src/modules/container.ts", + "modules/git": "src/modules/git.ts", + "modules/artifacts": "src/modules/artifacts.ts", "backends/container-legacy/index": "src/backends/container-legacy/index.ts", "backends/container/index": "src/backends/container/index.ts", "backends/worker-javascript/index": "src/backends/worker-javascript/index.ts", diff --git a/packages/computer/src/backend.ts b/packages/computer/src/backend.ts index bc549aa5..bde8d45c 100644 --- a/packages/computer/src/backend.ts +++ b/packages/computer/src/backend.ts @@ -60,6 +60,11 @@ export interface WorkspaceBackend { // too. Defaults to false when omitted. readonly callable?: boolean; + // What the backend tells a model about itself, such as the language + // it runs and what that code can use. The exec tool shows it next to + // the caller's own description. Omit when there is nothing to add. + readonly description?: string; + // Materialise a connection. Called lazily on first use, once // per backend per workspace lifetime. The Workspace caches the // resulting handle by `id`; subsequent exec / push / pull diff --git a/packages/computer/src/backends/container/container-backend-launch.test.ts b/packages/computer/src/backends/container/container-backend-launch.test.ts index 8d198b87..4aa4a267 100644 --- a/packages/computer/src/backends/container/container-backend-launch.test.ts +++ b/packages/computer/src/backends/container/container-backend-launch.test.ts @@ -106,3 +106,20 @@ describe("both launch paths request the same container", () => { } }); }); + +describe("ContainerBackend description", () => { + test.each([ + [undefined, "It has no network access."], + [{ mode: "none" as const }, "It has no network access."], + [{ mode: "direct" as const }, "It has network access."], + ])("matches egress %o", (egress, expected) => { + const backend = new ContainerBackend({ + container: () => ({ getWorkspaceContainer: () => ({}) }) as never, + workspace: { binding: "SESSIONS", id: "session-1" }, + ...(egress === undefined ? {} : { egress }), + }); + + expect(backend.description).toContain("full Linux container"); + expect(backend.description).toContain(expected); + }); +}); diff --git a/packages/computer/src/backends/container/container-backend.ts b/packages/computer/src/backends/container/container-backend.ts index fb837cda..ded576a5 100644 --- a/packages/computer/src/backends/container/container-backend.ts +++ b/packages/computer/src/backends/container/container-backend.ts @@ -173,6 +173,14 @@ export interface ContainerBackendOptions { } const DEFAULT_EGRESS_HOST = "computer.internal"; +// What the model is told about network access, by egress mode. The +// container only gets the internet with "direct"; "http-gateway" +// routes HTTP through the host, and "none" blocks it. +const NETWORK_DESCRIPTION: Record = { + direct: "It has network access.", + "http-gateway": "Outbound HTTP goes through a gateway the host controls.", + none: "It has no network access.", +}; // Image key assumed when a caller names none. Kept in step with the // same default in container-host.ts, which resolves it. const DEFAULT_IMAGE_NAME = "app"; @@ -218,6 +226,8 @@ function bearerMatches(header: string | null, expected: string | undefined): boo export class ContainerBackend implements WorkspaceBackend { readonly type = "cloudflare-container"; + /** What this backend tells a model: a full Linux shell, its network access, and its slow start. */ + readonly description: string; readonly id: string; // `ignore` sits with the un-defaulted options rather than under @@ -261,6 +271,11 @@ export class ContainerBackend implements WorkspaceBackend { this.id = options.id ?? "container-shell"; this.#egress = options.egress ?? { mode: "none" }; this.#egressToken = this.#egress.mode === "http-gateway" ? crypto.randomUUID() : undefined; + this.description = [ + "A shell in a full Linux container: npm, node, python, package managers, test runners, and native binaries.", + NETWORK_DESCRIPTION[this.#egress.mode], + "Starts much more slowly than an in-Worker backend because the container must boot.", + ].join(" "); this.#options = { container: options.container, workspace: options.workspace, diff --git a/packages/computer/src/backends/worker-javascript/module-graph.ts b/packages/computer/src/backends/worker-javascript/module-graph.ts index 8280e7b5..2e94ca65 100644 --- a/packages/computer/src/backends/worker-javascript/module-graph.ts +++ b/packages/computer/src/backends/worker-javascript/module-graph.ts @@ -1,7 +1,12 @@ import { parse } from "acorn"; import type { WorkspaceRuntimeCapability } from "../../runtime/capability.js"; -import type { WorkspaceRuntimeLoader } from "../../runtime/types.js"; +import type { + WorkspaceModule, + WorkspaceModuleFactory, + WorkspaceModuleFunctions, + WorkspaceRuntimeLoader, +} from "../../runtime/types.js"; export type JavaScriptModuleMap = WorkspaceRuntimeLoader extends { load(code: { modules: infer Modules }): unknown; @@ -12,14 +17,119 @@ export type JavaScriptModuleMap = WorkspaceRuntimeLoader extends { const ENTRY_BASENAME = "__workspace_entry__.js"; const RUNNER_MODULE = "workspace-runtime-runner.js"; const CAPABILITIES_MODULE = "workspace-capabilities.js"; -const TRUSTED_MODULES = ["node:fs", "node:fs/promises", "ws:git", "ws:artifacts"] as const; +// Installed in every execution and backed by the Workspace. No module +// in the `modules` option may use these names. +const BUILT_IN_MODULES = ["node:fs", "node:fs/promises"] as const; +const HOST_SPECIFIER = /^ws:[A-Za-z0-9][A-Za-z0-9._-]*$/; +const EXPORT_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +// `default` would turn the function into the default export, and a +// `then` export makes the module namespace look like a promise to +// `await import(...)`. +const RESERVED_EXPORT_NAMES = new Set(["default", "then"]); + +/** The `modules` option, parsed once when the backend is constructed. */ +export interface ParsedModules { + readonly source: Readonly>; + readonly host: ReadonlyMap; + /** One markdown bullet per importable module, for a model. */ + readonly description: string; +} + +const FILESYSTEM_DESCRIPTION = + "- `node:fs/promises` (also `node:fs`): the workspace's files. `readFile`, `writeFile`, `mkdir`, `rm`, `readdir`, `stat`, `lstat`, `readlink`, `symlink`, `chmod`, and `access`. Async only."; + +/** + * Parse the backend's `modules` option: split it into bundled source + * and host module factories, check every specifier and every object's + * export names, and describe each module for a model. A factory's + * export names are checked when the backend connects and it runs. + * + * @param modules - The modules passed to the backend. + * @returns Source modules, host module factories, and their description. + * @throws When a specifier or an object's export names are not allowed. + * The host configured the backend wrongly and no execution can use it. + */ +export function parseModules(modules: Readonly>): ParsedModules { + const source: Record = Object.create(null); + const host = new Map(); + const lines = [FILESYSTEM_DESCRIPTION]; + for (const [specifier, module] of Object.entries(modules)) { + const name = `\`${specifier}\``; + if (BUILT_IN_MODULES.some((builtIn) => builtIn === specifier)) { + throw new Error(`Module ${JSON.stringify(specifier)} is built in and cannot be replaced.`); + } + if (typeof module === "string") { + if (specifier.startsWith("ws:")) { + throw new Error( + `Module ${JSON.stringify(specifier)} uses the ws:* namespace, which is only for host modules.`, + ); + } + if (specifier.includes("/") || isInternalModuleName(specifier)) { + throw new Error(`Module ${JSON.stringify(specifier)} uses a reserved module name.`); + } + source[specifier] = module; + lines.push(`- ${name}: a bundled library.`); + continue; + } + if (!HOST_SPECIFIER.test(specifier)) { + throw new Error( + `Host module ${JSON.stringify(specifier)} must use a simple ws:* name, such as "ws:git".`, + ); + } + if (typeof module === "function") { + host.set(specifier, module); + lines.push(`- ${name}: ${module.description ?? "a host module."}`); + continue; + } + if (module === null || typeof module !== "object" || Array.isArray(module)) { + throw new Error( + `Module ${JSON.stringify(specifier)} must be source text, an object of functions, or a factory.`, + ); + } + assertHostModuleExports(specifier, module); + host.set(specifier, () => module); + const exports = Object.keys(module).map((key) => `\`${key}\``); + lines.push(`- ${name}: exports ${exports.join(", ")}.`); + } + return { source, host, description: lines.join("\n") }; +} + +/** + * Check the functions a host module exports. + * + * @param specifier - The module's specifier, for error messages. + * @param functions - The module's functions. + * @throws When the module exports nothing, a name is not allowed, or a + * value is not a function. + */ +export function assertHostModuleExports( + specifier: string, + functions: WorkspaceModuleFunctions, +): void { + const names = Object.keys(functions); + if (names.length === 0) { + throw new Error(`Host module ${JSON.stringify(specifier)} must export a function.`); + } + for (const name of names) { + if (!EXPORT_NAME.test(name) || RESERVED_EXPORT_NAMES.has(name)) { + throw new Error( + `Host module ${JSON.stringify(specifier)} export ${JSON.stringify(name)} must be a JavaScript identifier other than "default" or "then".`, + ); + } + if (typeof functions[name] !== "function") { + throw new Error( + `Host module ${JSON.stringify(specifier)} export ${JSON.stringify(name)} must be a function.`, + ); + } + } +} export interface BuildModuleGraphOptions { source: string; cwd: string; capability: WorkspaceRuntimeCapability; - configuredModules: Record; - trustedModuleNames?: string[]; + configuredModules: Readonly>; + hostModules: ReadonlyMap; maxSourceBytes: number; maxCapabilityBytes: number; maxModules?: number; @@ -39,18 +149,10 @@ export async function buildModuleGraph(options: BuildModuleGraphOptions) { let totalBytes = new TextEncoder().encode(options.source).byteLength; const maxModules = options.maxModules ?? 128; const maxDepth = options.maxDepth ?? 32; - const trustedModuleNames = new Set(TRUSTED_MODULES); - for (const name of options.trustedModuleNames ?? []) { - if ( - !/^ws:[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || - TRUSTED_MODULES.includes(name as (typeof TRUSTED_MODULES)[number]) - ) { - throw new Error( - `Trusted module ${JSON.stringify(name)} must use a unique simple reserved ws:* name.`, - ); - } - trustedModuleNames.add(name); - } + const importableModuleNames = new Set([ + ...BUILT_IN_MODULES, + ...options.hostModules.keys(), + ]); async function visit(path: string, source: string, depth: number): Promise { if (depth > maxDepth) throw new Error(`Workspace JavaScript import depth exceeds ${maxDepth}.`); @@ -63,12 +165,14 @@ export async function buildModuleGraph(options: BuildModuleGraphOptions) { } for (const specifier of imports(source)) { - if (trustedModuleNames.has(specifier)) continue; + if (importableModuleNames.has(specifier)) continue; if (specifier === CAPABILITIES_MODULE) { throw new Error(`Module ${JSON.stringify(specifier)} is reserved for Workspace internals.`); } if (specifier.startsWith("ws:")) { - throw new Error(`Unknown trusted Workspace module ${JSON.stringify(specifier)}.`); + throw new Error( + `Module ${JSON.stringify(specifier)} is not configured. Add it to the backend's modules option.`, + ); } if (specifier.startsWith(".")) { const resolved = resolveRelative(path, specifier); @@ -111,21 +215,6 @@ export async function buildModuleGraph(options: BuildModuleGraphOptions) { await visit(entryPath, options.source, 0); - for (const specifier of Object.keys(options.configuredModules)) { - if ( - trustedModuleNames.has(specifier) || - specifier.startsWith("ws:") || - specifier === ENTRY_BASENAME || - specifier === RUNNER_MODULE || - specifier === CAPABILITIES_MODULE || - specifier.includes("/") - ) { - throw new Error( - `Configured module ${JSON.stringify(specifier)} uses a reserved module name.`, - ); - } - } - // node:* specifiers use protocol-style resolution and therefore need exact // module-map keys rather than the importer-directory aliases used by ws:*. modules["node:fs/promises"] = { js: nodeFsPromisesModule() }; @@ -134,11 +223,9 @@ export async function buildModuleGraph(options: BuildModuleGraphOptions) { for (const directory of directories) { const prefix = directory ? `${directory}/` : ""; const toCapabilities = relativeModule(directory, CAPABILITIES_MODULE); - modules[`${prefix}ws:git`] = { js: gitModule(toCapabilities) }; - modules[`${prefix}ws:artifacts`] = { js: artifactsModule(toCapabilities) }; - for (const specifier of options.trustedModuleNames ?? []) { + for (const [specifier, functions] of options.hostModules) { modules[`${prefix}${specifier}`] = { - js: trustedModule(toCapabilities, specifier), + js: hostModule(toCapabilities, specifier, Object.keys(functions)), }; } for (const [specifier, source] of Object.entries(options.configuredModules)) { @@ -297,17 +384,15 @@ function capabilitiesModule(maxCapabilityBytes: number) { `; } -function proxyModule(capabilitiesImport: string, namespace: string, methods: string[]) { +// Exports go through `export { local as name }` rather than +// `export const name`, so a reserved word such as `delete` still works +// as an export name. +function hostModule(capabilitiesImport: string, specifier: string, names: readonly string[]) { + const namespace = JSON.stringify(`host/${specifier}`); return ` import { call } from ${JSON.stringify(capabilitiesImport)}; - ${methods.map((method) => `export const ${method} = (...args) => call(${JSON.stringify(namespace)}, ${JSON.stringify(method)}, args);`).join("\n")} - `; -} - -function trustedModule(capabilitiesImport: string, specifier: string) { - return ` - import { call as hostCall } from ${JSON.stringify(capabilitiesImport)}; - export const call = (method, ...args) => hostCall(${JSON.stringify(`trusted/${specifier}`)}, "call", [method, ...args]); + ${names.map((name, index) => `const fn${index} = (...args) => call(${namespace}, ${JSON.stringify(name)}, args);`).join("\n")} + export { ${names.map((name, index) => `fn${index} as ${name}`).join(", ")} }; `; } @@ -370,17 +455,3 @@ function nodeFsPromisesModule() { function nodeFsModule() { return `${nodeFsPromisesModule()}\nexport { default as promises } from "node:fs/promises";`; } - -function gitModule(capabilitiesImport: string) { - return proxyModule(capabilitiesImport, "git", ["clone", "diff", "status", "log", "cli"]); -} - -function artifactsModule(capabilitiesImport: string) { - return proxyModule(capabilitiesImport, "artifacts", [ - "create", - "get", - "list", - "importArtifact", - "deleteArtifact", - ]); -} diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts index f75ef9bc..e75b00fb 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts @@ -244,7 +244,13 @@ describe("WorkerJavaScriptBackend", () => { ); const fs = new WorkspaceFilesystem(db); const backend = new WorkerJavaScriptBackend({ loader: throwingLoader("unused") }); - await backend.connect({ db, fs, git: undefined as never, artifacts: undefined as never }); + await backend.connect({ + db, + fs, + git: undefined as never, + artifacts: undefined as never, + runtime: undefined as never, + }); const columns = db.all<{ name: string }>("PRAGMA table_info(workspace_runtime_executions)"); expect(columns.map((column) => column.name)).toEqual( expect.arrayContaining(["created_at", "finished_at"]), @@ -310,6 +316,7 @@ describe("WorkerJavaScriptBackend", () => { fs, git: undefined as never, artifacts: undefined as never, + runtime: undefined as never, }); const execution = handle.exec({ source: `import task from "./task.js"; export default task;`, @@ -564,6 +571,7 @@ describe("WorkerJavaScriptBackend", () => { fs, git: undefined as never, artifacts: undefined as never, + runtime: undefined as never, }); const execution = await handle.exec({ id: "successful-host-call", source: "export default 1" }); let settled = false; @@ -628,6 +636,7 @@ describe("WorkerJavaScriptBackend", () => { fs, git: undefined as never, artifacts: undefined as never, + runtime: undefined as never, }); const execution = await handle.exec({ id: "live-stream", source: "export default 1" }); const reader = execution.events.getReader(); @@ -682,6 +691,7 @@ describe("WorkerJavaScriptBackend", () => { fs, git: undefined as never, artifacts: undefined as never, + runtime: undefined as never, }); const execution = await handle.exec({ id: "kill-mid-stream", source: "export default 1" }); const reader = execution.events.getReader(); @@ -744,6 +754,7 @@ describe("WorkerJavaScriptBackend", () => { fs, git: undefined as never, artifacts: undefined as never, + runtime: undefined as never, }); const execution = await handle.exec({ id: "no-exit", source: "export default 1" }); const events = []; @@ -754,7 +765,7 @@ describe("WorkerJavaScriptBackend", () => { await handle.close(); }); - it("aborts cooperative trusted-module calls at their deadline", async () => { + it("aborts cooperative host module calls at their deadline", async () => { const db = new Database(new SQLiteTestStorage()); initializeSchema(db, () => 0); const fs = new WorkspaceFilesystem(db); @@ -762,11 +773,11 @@ describe("WorkerJavaScriptBackend", () => { let aborted = false; const backend = new WorkerJavaScriptBackend({ maxHostCallMs: 5, - trustedModules: { + modules: { "ws:test": { - call(_method, _args, context) { + run(_args, context) { return new Promise((_resolve, reject) => { - context?.signal.addEventListener("abort", () => { + context.signal.addEventListener("abort", () => { aborted = true; reject(context.signal.reason); }); @@ -783,7 +794,7 @@ describe("WorkerJavaScriptBackend", () => { _input: unknown, host: { call(name: string, args: string): Promise }, ) { - await host.call("trusted/ws:test.call", JSON.stringify(["run"])); + await host.call("host/ws:test.run", JSON.stringify([])); }, }; }, @@ -796,6 +807,7 @@ describe("WorkerJavaScriptBackend", () => { fs, git: undefined as never, artifacts: undefined as never, + runtime: undefined as never, }); const execution = await handle.exec({ id: "trusted-timeout", source: "export default 1" }); const events = []; @@ -847,6 +859,7 @@ describe("WorkerJavaScriptBackend", () => { fs, git: undefined as never, artifacts: undefined as never, + runtime: undefined as never, }); const execution = await handle.exec({ id: "cancel-host-call", source: "export default 1" }); await started; @@ -989,6 +1002,7 @@ describe("WorkerJavaScriptBackend", () => { fs, git: undefined as never, artifacts: undefined as never, + runtime: undefined as never, }); await handle.exec({ id: "subscribers", source: "export default 1" }); await handle.getExec({ id: "subscribers", after: "tail" }); @@ -997,28 +1011,163 @@ describe("WorkerJavaScriptBackend", () => { await handle.close(); }); - it("rejects malformed host trusted-module names", async () => { - const workspace = new Workspace({ - storage: new SQLiteTestStorage(), - backends: [ + it.each([ + ["a host module with a path", { "ws:bad/path": { run: async () => null } }, /simple ws:\*/], + ["a host module outside ws:*", { container: { run: async () => null } }, /simple ws:\*/], + ["source under ws:*", { "ws:lib": "export const x = 1;" }, /only for host modules/], + ["a replacement node:fs", { "node:fs": "export default {};" }, /built in/], + [ + "a replacement node:fs host module", + { "node:fs/promises": { run: async () => null } }, + /built in/, + ], + ["a number", { "ws:test": 42 }, /source text, an object of functions, or a factory/], + ["an object with no functions", { "ws:test": {} }, /must export a function/], + [ + "an object with a non-identifier name", + { "ws:test": { "not-a-name": async () => null } }, + /JavaScript identifier/, + ], + [ + "an object with a default export", + { "ws:test": { default: async () => null } }, + /JavaScript identifier/, + ], + [ + "an object with a then export", + // biome-ignore lint/suspicious/noThenProperty: The case checks that the backend rejects a `then` export. + { "ws:test": { then: async () => null } }, + /JavaScript identifier/, + ], + ["an object with a non-function export", { "ws:test": { run: "nope" } }, /must be a function/], + ])("rejects %s at construction", (_label, modules, message) => { + expect( + () => new WorkerJavaScriptBackend({ loader: throwingLoader("must not load"), - trustedModules: { - "ws:bad/path": { - async call() { - return null; - }, - }, - } as never, + // SAFETY: Each case hands the constructor a shape the types may forbid, to check its runtime guard. + modules: modules as never, }), - ], + ).toThrow(message); + }); + + it("rejects a factory whose functions are not allowed when it connects", async () => { + const db = new Database(new SQLiteTestStorage()); + initializeSchema(db, () => 0); + const backend = new WorkerJavaScriptBackend({ + loader: throwingLoader("must not load"), + modules: { "ws:test": () => ({ "not-a-name": async () => null }) }, }); - await workspace.fs.mkdir("/workspace", { recursive: true }); await expect( - workspace.runtime.exec(`import { call } from "ws:bad/path"; export default call;`, { - backend: "worker-javascript", + backend.connect({ + db, + fs: new WorkspaceFilesystem(db), + git: undefined as never, + artifacts: undefined as never, + runtime: undefined as never, }), - ).rejects.toThrow(/simple reserved ws:\*/); + ).rejects.toThrow(/JavaScript identifier/); + }); + + it("describes its modules for a model", () => { + const backend = new WorkerJavaScriptBackend({ + loader: throwingLoader("must not load"), + access: "read", + modules: { + lib: "export const x = 1;", + "ws:weather": { forecast: () => null, alerts: () => null }, + "ws:described": Object.assign(() => ({ run: () => null }), { + description: "Does a thing.", + }), + "ws:plain": () => ({ run: () => null }), + }, + }); + + expect(backend.description).toContain("ECMAScript module source"); + expect(backend.description).toContain("read-only"); + expect(backend.description).toContain("- `lib`: a bundled library."); + expect(backend.description).toContain("- `ws:weather`: exports `forecast`, `alerts`."); + expect(backend.description).toContain("- `ws:described`: Does a thing."); + expect(backend.description).toContain("- `ws:plain`: a host module."); + }); + + it("builds host modules from the Workspace services when it connects", async () => { + const db = new Database(new SQLiteTestStorage()); + initializeSchema(db, () => 0); + const git = { marker: "git" }; + let seen: unknown; + const backend = new WorkerJavaScriptBackend({ + loader: throwingLoader("must not load"), + modules: { + "ws:test": (host) => { + seen = host.git; + return { run: async () => null }; + }, + }, + }); + await backend.connect({ + db, + fs: new WorkspaceFilesystem(db), + git: git as never, + artifacts: undefined as never, + runtime: undefined as never, + }); + expect(seen).toBe(git); + }); + + it("does not dispatch inherited members of a host module", async () => { + const db = new Database(new SQLiteTestStorage()); + initializeSchema(db, () => 0); + const fs = new WorkspaceFilesystem(db); + await fs.mkdir("/workspace", { recursive: true }); + let response = ""; + const backend = new WorkerJavaScriptBackend({ + modules: { "ws:test": { run: async () => null } }, + loader: { + load() { + return { + getEntrypoint() { + return { + async evaluate( + _input: unknown, + host: { call(name: string, args: string): Promise }, + ) { + response = await host.call("host/ws:test.toString", JSON.stringify([])); + }, + }; + }, + }; + }, + }, + }); + const handle = await backend.connect({ + db, + fs, + git: undefined as never, + artifacts: undefined as never, + runtime: undefined as never, + }); + const execution = await handle.exec({ id: "inherited", source: "export default 1" }); + for await (const _event of execution.events) { + // Drain the run so the host call settles. + } + expect(JSON.parse(response)).toMatchObject({ + error: { message: expect.stringContaining("Unknown Workspace host module call") }, + }); + await handle.close?.(); + }); + + it("does not install ws:git or ws:artifacts unless they are configured", async () => { + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [new WorkerJavaScriptBackend({ loader: throwingLoader("must not load") })], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + for (const specifier of ["ws:git", "ws:artifacts"]) { + await expect( + workspace.runtime.exec(`import * as m from "${specifier}"; export default () => m;`), + ).rejects.toThrow(/is not configured/); + } }); it("rejects relative imports that collide with internal Loader modules", async () => { @@ -1036,24 +1185,16 @@ describe("WorkerJavaScriptBackend", () => { expect(load).not.toHaveBeenCalled(); }); - it("rejects configured module names that collide with generated modules", async () => { - const load = vi.fn(); - const workspace = new Workspace({ - storage: new SQLiteTestStorage(), - backends: [ - new WorkerJavaScriptBackend({ - loader: { load }, - modules: { - "__workspace_entry__.js": "export default 42", - "node:fs": "export default {};", - }, - }), - ], - }); - await workspace.fs.mkdir("/workspace", { recursive: true }); - await expect( - workspace.runtime.exec("export default 1", { backend: "worker-javascript" }), - ).rejects.toThrow(/reserved module name/); - expect(load).not.toHaveBeenCalled(); - }); + it.each(["__workspace_entry__.js", "workspace-capabilities.js", "nested/lib"])( + "rejects a source module named %s at construction", + (specifier) => { + expect( + () => + new WorkerJavaScriptBackend({ + loader: throwingLoader("must not load"), + modules: { [specifier]: "export default 42" }, + }), + ).toThrow(/reserved module name/); + }, + ); }); diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index 4bd5fcfe..280ba395 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -4,29 +4,50 @@ import { dynamicWorkerEgress, type WorkspaceEgressPolicy } from "../../runtime/e import type { ModuleExecutionEnvelope, ModuleExecutionInput, + WorkspaceModule, WorkspaceModuleBackend, WorkspaceModuleBackendHandle, WorkspaceModuleBackendHost, + WorkspaceModuleFunctions, WorkspaceRuntimeAccess, WorkspaceRuntimeEvent, WorkspaceRuntimeLoader, WorkspaceRuntimeValue, - WorkspaceTrustedModule, } from "../../runtime/types.js"; import { decodeRuntimeFrames, type RuntimeFrame } from "./frames.js"; -import { buildModuleGraph } from "./module-graph.js"; +import { + assertHostModuleExports, + buildModuleGraph, + type ParsedModules, + parseModules, +} from "./module-graph.js"; export interface WorkerJavaScriptBackendOptions { loader: WorkspaceRuntimeLoader; id?: string; root?: string; access?: WorkspaceRuntimeAccess; - modules?: Record; /** - * Host-owned capability modules installed under reserved ws:* specifiers. - * Caller source may import them, but cannot provide or replace them. + * Modules caller source can import by specifier. + * + * A string value is JavaScript source bundled into the isolate, such as + * a library build. An object of functions, or a factory that builds + * one, is a host module: it runs in the Durable Object under a `ws:*` + * specifier, and each function becomes a named export. + * + * ```ts + * modules: { + * "tar-stream": TAR_STREAM_BUNDLE, + * "ws:git": createGitModule(), + * "ws:weather": { forecast: ([city]) => lookUpForecast(String(city)) }, + * } + * ``` + * + * `node:fs` and `node:fs/promises` are always installed and cannot be + * replaced. The constructor throws when a specifier is not allowed, and + * connecting throws when a host module's export names are not allowed. */ - trustedModules?: Record<`ws:${string}`, WorkspaceTrustedModule>; + modules?: Record; defaultTimeoutMs?: number; maxTimeoutMs?: number; maxSourceBytes?: number; @@ -56,10 +77,6 @@ export interface WorkerJavaScriptBackendOptions { compatibilityFlags?: string[]; egress?: WorkspaceEgressPolicy; globalOutbound?: Fetcher | null; - /** Allow ws:git operations that can perform host-side network requests. */ - allowGitNetwork?: boolean; - /** Allow ws:artifacts imports from caller-selected remote URLs. */ - allowArtifactNetwork?: boolean; } type ResolvedWorkerJavaScriptBackendOptions = Required< @@ -90,8 +107,9 @@ type ResolvedWorkerJavaScriptBackendOptions = Required< | "compatibilityFlags" > > & - Omit & { + Omit & { egress: WorkspaceEgressPolicy; + modules: ParsedModules; }; interface WorkspaceExecutionContext { @@ -139,6 +157,8 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { readonly protocol = "module" as const; readonly type = "worker-javascript"; readonly callable = true; + /** What this backend tells a model: the source language and every importable module. */ + readonly description: string; readonly id: string; readonly #options: ResolvedWorkerJavaScriptBackendOptions; @@ -198,6 +218,7 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { this.#options = { ...backendOptions, egress: resolvedEgress, + modules: parseModules(options.modules ?? {}), root: options.root ?? "/workspace", access: options.access ?? "read-write", defaultTimeoutMs, @@ -222,6 +243,14 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { compatibilityDate, compatibilityFlags: options.compatibilityFlags ?? ["nodejs_compat"], }; + this.description = [ + "`command` is ECMAScript module source, run in an isolated JavaScript runtime. Relative imports resolve from `cwd` in the workspace.", + ...(resolvedEgress.mode === "none" ? ["Code has no direct network access."] : []), + ...(this.#options.access === "read" ? ["The workspace is read-only here."] : []), + "", + "Modules code can import:", + this.#options.modules.description, + ].join("\n"); } async connect(host: WorkspaceModuleBackendHost): Promise { @@ -232,6 +261,7 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { readonly #options: ResolvedWorkerJavaScriptBackendOptions; readonly #host: WorkspaceModuleBackendHost; + readonly #hostModuleFunctions: ReadonlyMap; readonly #records = new Map(); readonly #pendingIds = new Set(); #closed = false; @@ -243,6 +273,13 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { constructor(options: ResolvedWorkerJavaScriptBackendOptions, host: WorkspaceModuleBackendHost) { this.#options = options; this.#host = host; + const functions = new Map(); + for (const [specifier, factory] of options.modules.host) { + const built = factory({ git: host.git, artifacts: host.artifacts, runtime: host.runtime }); + assertHostModuleExports(specifier, built); + functions.set(specifier, built); + } + this.#hostModuleFunctions = functions; host.db.run(` CREATE TABLE IF NOT EXISTS workspace_runtime_executions ( backend TEXT NOT NULL, @@ -353,8 +390,8 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { source: input.source, cwd: input.cwd ?? this.#options.root, capability, - configuredModules: this.#options.modules ?? {}, - trustedModuleNames: Object.keys(this.#options.trustedModules ?? {}), + configuredModules: this.#options.modules.source, + hostModules: this.#hostModuleFunctions, maxSourceBytes: this.#options.maxSourceBytes, maxCapabilityBytes: this.#options.maxCapabilityBytes, }); @@ -389,11 +426,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { this.#records.set(id, record); try { const bridge = new WorkspaceRuntimeBridge(capability, { - git: this.#host.git, - artifacts: this.#host.artifacts, - trustedModules: this.#options.trustedModules, - allowGitNetwork: this.#options.allowGitNetwork ?? false, - allowArtifactNetwork: this.#options.allowArtifactNetwork ?? false, + hostModules: this.#hostModuleFunctions, maxPayloadBytes: this.#options.maxCapabilityBytes, maxCallDurationMs: this.#options.maxHostCallMs, maxConcurrentCalls: this.#options.maxConcurrentCapabilityCalls, diff --git a/packages/computer/src/backends/worker-shell/worker-shell.ts b/packages/computer/src/backends/worker-shell/worker-shell.ts index 981059d1..1fe4a1b2 100644 --- a/packages/computer/src/backends/worker-shell/worker-shell.ts +++ b/packages/computer/src/backends/worker-shell/worker-shell.ts @@ -139,6 +139,9 @@ const DEFAULT_COMPAT_FLAGS = ["nodejs_compat"]; export class WorkerShellBackend implements WorkspaceBackend { readonly type = "worker-shell"; + /** What this backend tells a model: a fast shell with a fixed command set. */ + readonly description = + "A just-bash shell in a Dynamic Worker. Starts fast, with no container and no direct network. Good for cat, grep, sed, awk, jq, head, tail, sort, find, text transformations, and a built-in `git` (clone, status, diff, log) that works through the workspace. Cannot run npm, node, python, or binaries outside its built-in command set."; readonly id: string; readonly #options: WorkerShellBackendOptions; readonly #egress: WorkspaceEgressPolicy; diff --git a/packages/computer/src/client.test.ts b/packages/computer/src/client.test.ts index 035b0e9b..99e0e982 100644 --- a/packages/computer/src/client.test.ts +++ b/packages/computer/src/client.test.ts @@ -9,8 +9,13 @@ import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { WorkerJavaScriptBackend } from "./backends/worker-javascript/worker-javascript.js"; import { getWorkspace, type WorkspaceClient } from "./client.js"; +import type { WorkspaceBackendInfo } from "./runtime/runtime.js"; +import type { WorkspaceModuleBackend } from "./runtime/types.js"; +import { createAITools } from "./tools/ai-sdk/index.js"; import { WORKSPACE, type WorkspaceStubHost } from "./with-workspace.js"; import { type ThinkWorkspaceCompatibility, Workspace } from "./workspace.js"; @@ -71,6 +76,9 @@ function fakeRuntime(promisedProperties = false) { calls.push({ command: `dispose:${id}`, options }); return Promise.resolve(); }, + backends() { + return promisedProperties ? Promise.resolve([]) : []; + }, }, }; } @@ -329,3 +337,132 @@ describe("client runtime.exec — remote handle rebuild", () => { expect(disposedHandles()).toBe(1); }); }); + +// A callable module backend that answers every execution with the +// structured input it was given, so a test can follow `input` from the +// exec tool through a client to the backend and back. +function echoBackend(): WorkspaceModuleBackend { + return { + protocol: "module", + id: "echo", + type: "echo", + callable: true, + description: "Echoes its input.", + async connect() { + return { + async exec(input) { + const id = input.id ?? "echo-1"; + return { + id, + events: new ReadableStream({ + start(controller) { + controller.enqueue({ + id, + seq: 1, + name: "exit", + code: 0, + result: { received: input.input ?? null }, + }); + controller.close(); + }, + }), + }; + }, + getExec: () => Promise.reject(new Error("not used")), + killExec: () => Promise.resolve(), + disposeExec: () => Promise.resolve(), + }; + }, + }; +} + +describe("getWorkspace — backend information", () => { + // A Workspace with one callable JavaScript backend that describes its + // modules. The loader is never reached; only construction runs. + function workspaceWithJavaScript() { + return new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + new WorkerJavaScriptBackend({ + loader: { load: () => ({ getEntrypoint: () => ({}) }) }, + modules: { "ws:weather": { forecast: () => null } }, + }), + ], + }); + } + + for (const [path, connect] of [ + ["local", (ws: Workspace) => getWorkspace({ [WORKSPACE]: ws })], + [ + "remote", + (ws: Workspace) => getWorkspace({ __getWorkspaceStub: () => Promise.resolve(ws.stub()) }), + ], + ] as const) { + it(`sends structured input through a ${path} client to a callable backend`, async () => { + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [echoBackend()], + }); + const client = await connect(workspace); + const exec = createAITools({ workspace: client }).exec as { + execute?: (input: unknown, options: unknown) => AsyncIterable; + }; + if (!exec.execute) throw new Error("exec has no execute function"); + + let last: unknown; + for await (const snapshot of exec.execute( + { command: "export default (input) => input", input: { value: 42 } }, + { toolCallId: "call", messages: [] }, + )) { + last = snapshot; + } + + expect(last).toMatchObject({ + backend: "echo", + exitCode: 0, + result: { received: { value: 42 } }, + }); + await workspace.close(); + }); + } + + it("keeps its backend snapshot from being edited", async () => { + const client = await getWorkspace({ + [WORKSPACE]: new Workspace({ storage: new SQLiteTestStorage(), backends: [echoBackend()] }), + }); + const list = client.runtime.backends(); + + expect(() => (list as WorkspaceBackendInfo[]).pop()).toThrow(); + expect(client.runtime.backends()).toHaveLength(1); + }); + + for (const [path, connect] of [ + ["local", (ws: Workspace) => getWorkspace({ [WORKSPACE]: ws })], + [ + "remote", + (ws: Workspace) => getWorkspace({ __getWorkspaceStub: () => Promise.resolve(ws.stub()) }), + ], + ] as const) { + it(`answers backend questions on a ${path} client`, async () => { + const client = await connect(workspaceWithJavaScript()); + + const [backend, ...others] = client.runtime.backends(); + + expect(others).toEqual([]); + expect(backend).toMatchObject({ id: "worker-javascript", callable: true }); + expect(backend?.description).toContain("`ws:weather`: exports"); + }); + + it(`builds a callable exec tool from a ${path} client`, async () => { + const client = await connect(workspaceWithJavaScript()); + const tools = createAITools({ workspace: client }); + const exec = tools.exec as { description?: string; inputSchema?: unknown } | undefined; + if (!(exec?.inputSchema instanceof z.ZodType)) + throw new Error("exec has no zod input schema"); + const schema = z.toJSONSchema(exec.inputSchema) as { properties: Record }; + + expect(exec.description).toContain("`ws:weather`: exports `forecast`."); + expect(Object.keys(schema.properties)).toContain("input"); + }); + } +}); diff --git a/packages/computer/src/client.ts b/packages/computer/src/client.ts index 6e3bf4b2..5247b105 100644 --- a/packages/computer/src/client.ts +++ b/packages/computer/src/client.ts @@ -31,7 +31,7 @@ // over RPC. import type { WorkspaceFilesystem } from "@cloudflare/dofs"; - +import type { WorkspaceBackendInfo } from "./runtime/runtime.js"; import type { WorkspaceRuntimeEvent, WorkspaceRuntimeExecHandle, @@ -215,6 +215,8 @@ export interface WorkspaceRuntimeClient { ): Promise>; killExec(id: string, options?: RuntimeKillOptions): Promise; disposeExec(id: string, options?: { backend?: string }): Promise; + /** What each backend says about itself, as of when the client was created. */ + backends(): readonly WorkspaceBackendInfo[]; } // Options accepted by the plain `exec` form, common to both paths. @@ -270,6 +272,10 @@ function makeRuntimeClient( // Adapts the handle the underlying `exec` resolves to: identity on // the local path (already a host handle), rebuild on the remote path. rehydrate: RehydrateRuntimeHandle, + // Backends are fixed when the Workspace is constructed, so one + // snapshot serves the client's lifetime. It keeps backends() + // synchronous over RPC, where the tools need it at construction. + backends: readonly WorkspaceBackendInfo[], ): WorkspaceRuntimeClient { async function exec( commandOrStrings: string | TemplateStringsArray, @@ -302,7 +308,18 @@ function makeRuntimeClient( const killExec = (id: string, options?: RuntimeKillOptions) => runtime.killExec(id, options); const disposeExec = (id: string, options?: { backend?: string }) => runtime.disposeExec(id, options); - return { exec, getExec, killExec, disposeExec } as WorkspaceRuntimeClient; + // Frozen so a caller that edits the list cannot change what later + // tool sets see. + const snapshot: readonly WorkspaceBackendInfo[] = Object.freeze( + backends.map((info) => Object.freeze({ ...info })), + ); + return { + exec, + getExec, + killExec, + disposeExec, + backends: () => snapshot, + } as WorkspaceRuntimeClient; } function withExecutionId( @@ -333,10 +350,12 @@ function makeClient( rehydrate: (handle: unknown, metadata?: RuntimeHandleMetadata) => unknown, dispose: () => void, useThink: boolean, + backends: readonly WorkspaceBackendInfo[], ): WorkspaceClient { const runtime = makeRuntimeClient( surface.runtime as UnderlyingRuntime, rehydrate as RehydrateRuntimeHandle, + backends, ); const client: WorkspaceClient = { get fs() { @@ -384,6 +403,7 @@ export async function getWorkspace(handle: WorkspaceHandle): Promise h, () => {}, local.useThink, + local.runtime.backends(), ); } // Remote path: fetch the stub over RPC and delegate to it. Handle @@ -398,6 +418,7 @@ export async function getWorkspace(handle: WorkspaceHandle): Promise void })[Symbol.dispose]?.(); }, await stub.useThink, + await stub.runtime.backends(), ); } catch (error) { (stub as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.(); diff --git a/packages/computer/src/index.ts b/packages/computer/src/index.ts index 70a134c7..dd18a93a 100644 --- a/packages/computer/src/index.ts +++ b/packages/computer/src/index.ts @@ -70,12 +70,19 @@ export { type WorkspaceServiceProxyProps, } from "./proxy.js"; export type { WorkspaceEgressPolicy } from "./runtime/egress.js"; +export type { WorkspaceBackendInfo } from "./runtime/runtime.js"; export type { ModuleExecutionEnvelope, ModuleExecutionInput, + WorkspaceModule, WorkspaceModuleBackend, WorkspaceModuleBackendHandle, WorkspaceModuleBackendHost, + WorkspaceModuleCallContext, + WorkspaceModuleFactory, + WorkspaceModuleFunction, + WorkspaceModuleFunctions, + WorkspaceModuleHost, WorkspaceRegisteredBackend, WorkspaceRuntimeAccess, WorkspaceRuntimeDisposeOptions, @@ -88,7 +95,6 @@ export type { WorkspaceRuntimeResult, WorkspaceRuntimeStatus, WorkspaceRuntimeValue, - WorkspaceTrustedModule, } from "./runtime/types.js"; export { decodeRuntimeEvents, encodeRuntimeEvent } from "./runtime/wire.js"; export { type RawShellValue, type ShellValue, sh, shellQuote } from "./sh.js"; diff --git a/packages/computer/src/modules/artifacts.ts b/packages/computer/src/modules/artifacts.ts new file mode 100644 index 00000000..e577c5ef --- /dev/null +++ b/packages/computer/src/modules/artifacts.ts @@ -0,0 +1,83 @@ +// `ws:artifacts`: the Workspace's Artifacts client for isolate JavaScript. +// +// import { create, get, list, importArtifact, deleteArtifact } from "ws:artifacts"; +// +// Calls that change Artifacts need a read-write backend. Importing from +// a caller-chosen URL is denied unless the module is created with +// `allowNetwork: true`: the request runs from the host, so the +// isolate's own egress settings do not stop it. + +import type { ArtifactClient } from "../artifacts/index.js"; +import type { + WorkspaceModuleCallContext, + WorkspaceModuleFactory, + WorkspaceModuleFunctions, + WorkspaceModuleHost, +} from "../runtime/types.js"; + +/** Options for {@link createArtifactsModule}. */ +export interface ArtifactsModuleOptions { + /** + * Allow `importArtifact` from a caller-chosen remote URL. Defaults to + * `false`. The request runs from the host, so the JavaScript + * backend's egress settings do not apply to it. + */ + readonly allowNetwork?: boolean; +} + +/** + * Build the `ws:artifacts` host module over the Workspace's Artifacts client. + * + * It exports `create`, `get`, `list`, `importArtifact`, and + * `deleteArtifact`, with the same arguments as the matching + * `ArtifactClient` methods. + * + * @param options - Whether remote imports are allowed. + * @returns The module to pass as `modules["ws:artifacts"]`. + */ +export function createArtifactsModule( + options: ArtifactsModuleOptions = {}, +): WorkspaceModuleFactory { + const allowNetwork = options.allowNetwork ?? false; + + // SAFETY for the casts below: the isolate's arguments pass through to the Artifacts client, as they did when ws:artifacts was built in. The client checks its own inputs. + const create = (host: WorkspaceModuleHost): WorkspaceModuleFunctions => ({ + create([name, createOptions], context) { + requireWrite(context, "Artifacts create"); + return host.artifacts.create( + String(name), + createOptions as unknown as Parameters[1], + ); + }, + get([name]) { + return host.artifacts.get(String(name)); + }, + list() { + return host.artifacts.list(); + }, + importArtifact([name, source, importOptions], context) { + requireWrite(context, "Artifacts import"); + if (!allowNetwork) { + throw new Error("Artifacts import requires createArtifactsModule({ allowNetwork: true })."); + } + return host.artifacts.import( + String(name), + source as unknown as Parameters[1], + importOptions as unknown as Parameters[2], + ); + }, + deleteArtifact([name], context) { + requireWrite(context, "Artifacts delete"); + return host.artifacts.delete(String(name)); + }, + }); + return Object.assign(create, { + description: `Git repositories stored in Cloudflare Artifacts: \`create(name)\`, \`get(name)\`, \`list()\`, \`importArtifact(name, source)\`, and \`deleteArtifact(name)\`.${allowNetwork ? "" : " Importing from a remote URL is not allowed."}`, + }); +} + +function requireWrite(context: WorkspaceModuleCallContext, operation: string) { + if (context.access !== "read-write") { + throw new Error(`${operation} requires Workspace write access.`); + } +} diff --git a/packages/computer/src/modules/container.test.ts b/packages/computer/src/modules/container.test.ts new file mode 100644 index 00000000..6a1ff849 --- /dev/null +++ b/packages/computer/src/modules/container.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from "vitest"; + +import type { + WorkspaceModuleCallContext, + WorkspaceModuleFunction, + WorkspaceModuleHost, +} from "../runtime/types.js"; +import { createContainerModule } from "./container.js"; + +interface ExecOptions { + readonly backend: string; + readonly encoding: "utf8"; + readonly cwd?: string; + readonly env?: Record; + readonly stdin?: string; + readonly timeoutMs: number; +} + +interface Run { + readonly command: string; + readonly options: ExecOptions; + killed: boolean; +} + +// An in-memory Workspace runtime that records each command and finishes +// it with the given output, or holds it open until it is killed. +function fakeRuntime(output: { + exitCode?: number; + stdout?: string; + stderr?: string; + hang?: boolean; +}) { + const runs: Run[] = []; + const runtime = { + backends: () => [ + { id: "container-shell", protocol: "command" as const, callable: false }, + { id: "linux", protocol: "command" as const, callable: true }, + { id: "worker-javascript", protocol: "module" as const, callable: true }, + ], + async exec(command: string, options: ExecOptions) { + const run: Run = { command, options, killed: false }; + runs.push(run); + let stop: () => void = () => undefined; + const stopped = new Promise((resolve) => { + stop = resolve; + }); + return { + async result() { + if (output.hang) await stopped; + return { + exitCode: run.killed ? 130 : (output.exitCode ?? 0), + stdout: output.stdout ?? "", + stderr: output.stderr ?? "", + }; + }, + async kill() { + run.killed = true; + stop(); + }, + }; + }, + }; + return { runtime, runs }; +} + +// Build the module's functions the way the backend does when it connects. +function build( + runtime: ReturnType["runtime"], + options?: Parameters[0], +): { readonly exec: WorkspaceModuleFunction } { + // SAFETY: The module only calls runtime.exec, and the fake implements the part of WorkspaceRuntime it uses. + const host = { runtime, git: undefined, artifacts: undefined } as unknown as WorkspaceModuleHost; + const functions = createContainerModule(options)(host); + const exec = functions.exec; + if (!exec) throw new Error("ws:container must export exec"); + return { exec }; +} + +function callContext( + overrides: Partial = {}, +): WorkspaceModuleCallContext { + return { + signal: new AbortController().signal, + deadline: Date.now() + 60_000, + access: "read-write", + resolvePath: async (path) => path, + ...overrides, + }; +} + +describe("createContainerModule", () => { + it("runs the command on the container backend and returns its output", async () => { + const { runtime, runs } = fakeRuntime({ exitCode: 3, stdout: "out", stderr: "err" }); + const container = build(runtime); + + await expect( + container.exec( + ["npm test", { cwd: "/workspace/app", env: { CI: "1" }, stdin: "y\n" }], + callContext(), + ), + ).resolves.toEqual({ exitCode: 3, stdout: "out", stderr: "err" }); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ + command: "npm test", + options: { + backend: "container-shell", + encoding: "utf8", + cwd: "/workspace/app", + env: { CI: "1" }, + stdin: "y\n", + }, + }); + }); + + it("uses the configured backend id and omits unset options", async () => { + const { runtime, runs } = fakeRuntime({}); + const container = build(runtime, { backend: "linux" }); + + await container.exec(["ls"], callContext()); + expect(Object.keys(runs[0]?.options ?? {}).sort()).toEqual([ + "backend", + "encoding", + "timeoutMs", + ]); + expect(runs[0]?.options.backend).toBe("linux"); + }); + + it("refuses to run on a read-only backend", async () => { + const { runtime, runs } = fakeRuntime({}); + const container = build(runtime); + + await expect(container.exec(["ls"], callContext({ access: "read" }))).rejects.toThrow( + /write access/, + ); + expect(runs).toHaveLength(0); + }); + + it("caps the timeout at the time left before the host call deadline", async () => { + const { runtime, runs } = fakeRuntime({}); + const container = build(runtime); + + await container.exec( + ["sleep 1", { timeoutMs: 600_000 }], + callContext({ deadline: Date.now() + 5_000 }), + ); + await container.exec(["sleep 1", { timeoutMs: 1_000 }], callContext()); + expect(runs[0]?.options.timeoutMs).toBeLessThanOrEqual(5_000); + expect(runs[1]?.options.timeoutMs).toBe(1_000); + }); + + it("kills the command when the call is aborted", async () => { + const { runtime, runs } = fakeRuntime({ hang: true }); + const container = build(runtime); + const controller = new AbortController(); + + const pending = container.exec(["sleep 100"], callContext({ signal: controller.signal })); + await new Promise((resolve) => setTimeout(resolve, 0)); + controller.abort(new Error("cancelled")); + + await expect(pending).resolves.toMatchObject({ exitCode: 130 }); + expect(runs[0]?.killed).toBe(true); + }); + + it("does not start a command once the call is aborted", async () => { + const { runtime, runs } = fakeRuntime({}); + const container = build(runtime); + const controller = new AbortController(); + controller.abort(new Error("cancelled")); + + await expect( + container.exec(["ls"], callContext({ signal: controller.signal })), + ).rejects.toThrow("cancelled"); + expect(runs).toHaveLength(0); + }); + + it("truncates each stream on UTF-8 boundaries", async () => { + const { runtime } = fakeRuntime({ stdout: "a🙂b", stderr: "🙂🙂" }); + const container = build(runtime, { maxOutputBytes: 5 }); + + await expect(container.exec(["echo"], callContext())).resolves.toEqual({ + exitCode: 0, + stdout: "a🙂\n\n[truncated, 1 more bytes]", + stderr: "🙂\n\n[truncated, 4 more bytes]", + }); + }); + + it.each([ + ["no arguments", [], /takes a command/], + ["too many arguments", ["ls", {}, {}], /takes a command/], + ["an empty command", [" "], /non-empty string/], + ["a non-string command", [["ls"]], /non-empty string/], + ["non-object options", ["ls", "fast"], /options must be an object/], + ["an unknown option", ["ls", { shell: "zsh" }], /unknown option "shell"/], + ["a non-string cwd", ["ls", { cwd: 1 }], /cwd must be a string/], + ["a non-string env value", ["ls", { env: { A: 1 } }], /env "A" must be a string/], + ["a non-positive timeout", ["ls", { timeoutMs: 0 }], /timeoutMs must be a positive number/], + ])("rejects %s without running anything", async (_label, args, message) => { + const { runtime, runs } = fakeRuntime({}); + const container = build(runtime); + + // SAFETY: Each case hands exec arguments that isolate code could send; the cast only widens the test table's inferred type. + await expect(container.exec(args as never, callContext())).rejects.toThrow(message); + expect(runs).toHaveLength(0); + }); + + it("rejects a bad maxOutputBytes at construction", () => { + expect(() => createContainerModule({ maxOutputBytes: 0 })).toThrow(/maxOutputBytes/); + }); + + it("fails when it connects to a Workspace without the backend", () => { + const { runtime } = fakeRuntime({}); + expect(() => build(runtime, { backend: "missing" })).toThrow(/no backend "missing"/); + }); + + it("accepts a callable shell backend", () => { + const { runtime } = fakeRuntime({}); + expect(() => build(runtime, { backend: "linux" })).not.toThrow(); + }); + + it("refuses a backend that runs module source", () => { + const { runtime } = fakeRuntime({}); + expect(() => build(runtime, { backend: "worker-javascript" })).toThrow( + /runs module source, not shell commands/, + ); + }); + + it("describes itself for a model", () => { + expect(createContainerModule().description).toContain("full Linux container"); + }); +}); diff --git a/packages/computer/src/modules/container.ts b/packages/computer/src/modules/container.ts new file mode 100644 index 00000000..0002a082 --- /dev/null +++ b/packages/computer/src/modules/container.ts @@ -0,0 +1,212 @@ +// `ws:container`: lets isolate JavaScript run shell commands in the +// Workspace's container backend. +// +// Installed on a WorkerJavaScriptBackend, it turns the container into a +// library the JavaScript backend calls, rather than a second backend +// the model has to choose between: +// +// import { exec } from "ws:container"; +// const { exitCode, stdout } = await exec("npm test", { cwd: "/workspace" }); +// +// Each call goes through `workspace.runtime.exec`, so the container +// sees the same files as the isolate: the usual sync bracket pushes +// pending Workspace writes before the command and pulls the +// container's changes after it. + +import type { + WorkspaceModuleCallContext, + WorkspaceModuleFactory, + WorkspaceModuleFunction, + WorkspaceModuleFunctions, + WorkspaceModuleHost, + WorkspaceRuntimeValue, +} from "../runtime/types.js"; +import { truncateText } from "../text-truncation.js"; + +const DEFAULT_BACKEND = "container-shell"; +const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024; +const EXEC_OPTION_KEYS = new Set(["cwd", "env", "stdin", "timeoutMs"]); + +/** Options for {@link createContainerModule}. */ +export interface ContainerModuleOptions { + /** Id of the container backend. Defaults to `"container-shell"`. */ + readonly backend?: string; + /** + * Largest standard output and standard error returned to the + * isolate, in bytes per stream. Output past it is cut and ends with + * a truncation marker. Defaults to 64 KiB. Keep both streams well + * under the backend's `maxCapabilityBytes`. + */ + readonly maxOutputBytes?: number; +} + +/** + * Build the `ws:container` host module over the Workspace's container + * backend. + * + * It exports `exec(command, { cwd, env, stdin, timeoutMs })`, which + * returns `{ exitCode, stdout, stderr }` once the command finishes. A + * non-zero exit code is a normal result, not an error. Cancelling the + * execution kills the command. + * + * A container command can write to the Workspace, so `exec` refuses to + * run on a read-only backend. Network access follows the container + * backend's own egress setting; the JavaScript backend's does not apply. + * + * @param options - Which backend to use and how much output to return. + * @returns The module to pass as `modules["ws:container"]`. Its + * `description` tells the model how to use it. + * @throws When `maxOutputBytes` is not a positive integer. The module + * also throws when the backend connects if the Workspace has no + * such backend, or that backend runs module source rather than shell + * commands. A callable shell backend is fine. + */ +export function createContainerModule( + options: ContainerModuleOptions = {}, +): WorkspaceModuleFactory { + const backend = options.backend ?? DEFAULT_BACKEND; + const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + if (!Number.isInteger(maxOutputBytes) || maxOutputBytes <= 0) { + throw new Error("createContainerModule: maxOutputBytes must be a positive integer."); + } + + const create = (host: WorkspaceModuleHost): WorkspaceModuleFunctions => { + // The factory runs when the JavaScript backend connects, so a + // missing or wrong container backend fails there, before any code + // runs, rather than on the first exec. + const target = host.runtime.backends().find((info) => info.id === backend); + if (target === undefined) { + throw new Error( + `ws:container: the Workspace has no backend ${JSON.stringify(backend)}. Register a ContainerBackend, or pass createContainerModule({ backend }).`, + ); + } + // A module backend reads `exec` source as code, so a shell command + // sent there would run as JavaScript, or start a nested run. + if (target.protocol !== "command") { + throw new Error( + `ws:container: backend ${JSON.stringify(backend)} runs module source, not shell commands.`, + ); + } + return { exec: execOn(host) }; + }; + const execOn = + (host: WorkspaceModuleHost): WorkspaceModuleFunction => + async (args, context) => { + if (context.access !== "read-write") { + throw new Error("ws:container exec requires Workspace write access."); + } + const request = parseExecArgs(args); + const timeoutMs = remainingTime(request.timeoutMs, context); + context.signal.throwIfAborted(); + + const handle = await host.runtime.exec(request.command, { + backend, + encoding: "utf8", + timeoutMs, + ...(request.cwd === undefined ? {} : { cwd: request.cwd }), + ...(request.env === undefined ? {} : { env: request.env }), + ...(request.stdin === undefined ? {} : { stdin: request.stdin }), + }); + // Cancelling the isolate execution, or passing the host call + // deadline, stops the command instead of leaving it running. + const kill = () => void handle.kill().catch(() => undefined); + if (context.signal.aborted) kill(); + else context.signal.addEventListener("abort", kill, { once: true }); + try { + const result = await handle.result(); + return { + exitCode: result.exitCode, + stdout: truncateText(result.stdout, maxOutputBytes), + stderr: truncateText(result.stderr, maxOutputBytes), + }; + } finally { + context.signal.removeEventListener("abort", kill); + } + }; + return Object.assign(create, { description: DESCRIPTION }); +} + +const DESCRIPTION = [ + "Runs shell commands in a full Linux container that shares this workspace's files.", + "Use it for npm, node, python, package managers, and native binaries. The container can take a while to start on first use.", + 'Call `const { exitCode, stdout, stderr } = await exec("npm test", { cwd: "/workspace" })`. Options are `cwd`, `env`, `stdin`, and `timeoutMs`.', + "Output comes back when the command finishes, and long output is truncated. A non-zero `exitCode` is returned, not thrown.", +].join(" "); + +interface ExecRequest { + readonly command: string; + readonly cwd: string | undefined; + readonly env: Record | undefined; + readonly stdin: string | undefined; + readonly timeoutMs: number | undefined; +} + +// Arguments come from isolate code. A malformed call throws, and the +// bridge hands that error back to the isolate as a rejected promise. +function parseExecArgs(args: readonly WorkspaceRuntimeValue[]): ExecRequest { + if (args.length === 0 || args.length > 2) { + throw new TypeError("exec(command, options?) takes a command and an optional options object."); + } + const [command, options] = args; + if (typeof command !== "string" || command.trim().length === 0) { + throw new TypeError("exec: command must be a non-empty string."); + } + if (options === undefined || options === null) { + return { command, cwd: undefined, env: undefined, stdin: undefined, timeoutMs: undefined }; + } + if (typeof options !== "object" || Array.isArray(options)) { + throw new TypeError("exec: options must be an object."); + } + for (const key of Object.keys(options)) { + if (!EXEC_OPTION_KEYS.has(key)) { + throw new TypeError( + `exec: unknown option ${JSON.stringify(key)}. Use cwd, env, stdin, or timeoutMs.`, + ); + } + } + return { + command, + cwd: optionalString(options.cwd, "cwd"), + env: optionalEnv(options.env), + stdin: optionalString(options.stdin, "stdin"), + timeoutMs: optionalTimeout(options.timeoutMs), + }; +} + +function optionalString(value: WorkspaceRuntimeValue | undefined, name: string) { + if (value === undefined || value === null) return undefined; + if (typeof value !== "string") throw new TypeError(`exec: ${name} must be a string.`); + return value; +} + +function optionalEnv(value: WorkspaceRuntimeValue | undefined) { + if (value === undefined || value === null) return undefined; + if (typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("exec: env must be an object of strings."); + } + const env: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry !== "string") { + throw new TypeError(`exec: env ${JSON.stringify(key)} must be a string.`); + } + env[key] = entry; + } + return env; +} + +function optionalTimeout(value: WorkspaceRuntimeValue | undefined) { + if (value === undefined || value === null) return undefined; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + throw new TypeError("exec: timeoutMs must be a positive number."); + } + return value; +} + +// The command must finish before the host call deadline, or the +// isolate stops waiting while the container keeps working. Cap the +// requested timeout at the time left. +function remainingTime(requested: number | undefined, context: WorkspaceModuleCallContext) { + const remaining = context.deadline - Date.now(); + if (remaining <= 0) throw new Error("exec: the host call deadline has already passed."); + return requested === undefined ? remaining : Math.min(requested, remaining); +} diff --git a/packages/computer/src/modules/git.ts b/packages/computer/src/modules/git.ts new file mode 100644 index 00000000..cce42eed --- /dev/null +++ b/packages/computer/src/modules/git.ts @@ -0,0 +1,145 @@ +// `ws:git`: the Workspace's Git client for isolate JavaScript. +// +// import { status, diff, log, clone, cli } from "ws:git"; +// +// Every path the isolate passes is confined to the JavaScript backend's +// root. Commands that change the repository need a read-write backend, +// and commands that reach the network are denied unless the module is +// created with `allowNetwork: true`: they run from the host, so the +// isolate's own egress settings do not stop them. + +import type { GitClient } from "../git/index.js"; +import type { + WorkspaceModuleCallContext, + WorkspaceModuleFactory, + WorkspaceModuleFunctions, + WorkspaceModuleHost, + WorkspaceRuntimeValue, +} from "../runtime/types.js"; + +const NETWORK_COMMANDS = new Set(["clone", "fetch", "pull", "push", "ls-remote", "submodule"]); + +/** Options for {@link createGitModule}. */ +export interface GitModuleOptions { + /** + * Allow `clone` and network `cli` commands such as `fetch` and `push`. + * Defaults to `false`. These requests run from the host, so the + * JavaScript backend's egress settings do not apply to them. + */ + readonly allowNetwork?: boolean; +} + +/** + * Build the `ws:git` host module over the Workspace's Git client. + * + * It exports `clone`, `diff`, `status`, `log`, and `cli`. Each takes the + * same options object as the matching `GitClient` method, with `dir` or + * `cwd` resolved against the backend root. + * + * @param options - Whether network commands are allowed. + * @returns The module to pass as `modules["ws:git"]`. + */ +export function createGitModule(options: GitModuleOptions = {}): WorkspaceModuleFactory { + const allowNetwork = options.allowNetwork ?? false; + const requireNetwork = (operation: string) => { + if (!allowNetwork) { + throw new Error(`${operation} requires createGitModule({ allowNetwork: true }).`); + } + }; + + const create = (host: WorkspaceModuleHost): WorkspaceModuleFunctions => ({ + async clone([value], context) { + requireWrite(context, "Git clone"); + requireNetwork("Git clone"); + // SAFETY: The isolate's options object passes through to the Git client, as it did when ws:git was built in. The client checks its own options; only the path is rewritten here. + return host.git.clone( + (await withDir(value, context, true)) as unknown as Parameters[0], + ); + }, + async diff([value], context) { + // SAFETY: As for clone. + return host.git.diff((await withDir(value, context)) as Parameters[0]); + }, + async status([value], context) { + // SAFETY: As for clone. + return host.git.status((await withDir(value, context)) as Parameters[0]); + }, + async log([value], context) { + // SAFETY: As for clone. + return host.git.log((await withDir(value, context)) as Parameters[0]); + }, + async cli([value], context) { + requireWrite(context, "Git CLI"); + // SAFETY: As for clone. + const input = (value ?? {}) as unknown as Parameters[0]; + const { argv, cwd } = leadingDirectory(input.argv ?? [], input.cwd ?? "."); + assertSafeCliArguments(argv); + if (argv.some((argument) => NETWORK_COMMANDS.has(argument.toLowerCase()))) { + requireNetwork("Git CLI network command"); + } + return host.git.cli({ + ...input, + argv, + cwd: await context.resolvePath(cwd, { allowMissing: true }), + }); + }, + }); + return Object.assign(create, { + description: `The workspace's Git repository tools: \`status({ dir })\`, \`diff({ dir })\`, \`log({ dir, depth })\`, \`clone({ url, dir })\`, and \`cli({ argv, cwd })\` for any other git subcommand.${allowNetwork ? "" : " Network commands such as clone, fetch, and push are not allowed."}`, + }); +} + +function requireWrite(context: WorkspaceModuleCallContext, operation: string) { + if (context.access !== "read-write") { + throw new Error(`${operation} requires Workspace write access.`); + } +} + +async function withDir( + value: WorkspaceRuntimeValue | undefined, + context: WorkspaceModuleCallContext, + allowMissing = false, +): Promise> { + const options = value !== null && typeof value === "object" && !Array.isArray(value) ? value : {}; + return { + ...options, + dir: await context.resolvePath(typeof options.dir === "string" ? options.dir : ".", { + allowMissing, + }), + }; +} + +// Agents often run `git -C `. A leading `-C` becomes +// the working directory, so it goes through the same confinement as +// `cwd` instead of reaching the Git client as a path override. +function leadingDirectory(argv: string[], cwd: string): { argv: string[]; cwd: string } { + if (argv[0] !== "-C") return { argv, cwd }; + const directory = argv[1]; + if (directory === undefined || directory === "") { + throw new Error("Git CLI option '-C' requires a value."); + } + return { + argv: argv.slice(2), + cwd: directory.startsWith("/") ? directory : `${cwd.replace(/\/+$/, "")}/${directory}`, + }; +} + +// Any other path override would let a command escape the confined +// directory. +function assertSafeCliArguments(argv: string[] | undefined) { + if ( + argv?.some( + (argument) => + argument === "-C" || + argument.startsWith("-C") || + argument === "--git-dir" || + argument.startsWith("--git-dir=") || + argument === "--work-tree" || + argument.startsWith("--work-tree="), + ) + ) { + throw new Error( + "Git CLI path overrides are not available inside a confined Workspace runtime.", + ); + } +} diff --git a/packages/computer/src/runtime/bridge.test.ts b/packages/computer/src/runtime/bridge.test.ts index 18f330a0..94d16a4f 100644 --- a/packages/computer/src/runtime/bridge.test.ts +++ b/packages/computer/src/runtime/bridge.test.ts @@ -4,7 +4,7 @@ import { WorkspaceRuntimeBridge } from "./bridge.js"; import type { WorkspaceRuntimeCapability } from "./capability.js"; const encoder = new TextEncoder(); -const args = JSON.stringify(["run", "value"]); +const args = JSON.stringify(["value"]); function bridge(limits: { maxCalls?: number; @@ -13,13 +13,7 @@ function bridge(limits: { }) { return new WorkspaceRuntimeBridge({} as WorkspaceRuntimeCapability, { ...limits, - trustedModules: { - "ws:test": { - async call() { - return "ok"; - }, - }, - }, + hostModules: new Map([["ws:test", { run: async () => "ok" }]]), }); } @@ -30,9 +24,9 @@ async function message(response: Promise) { describe("WorkspaceRuntimeBridge cumulative limits", () => { it("accepts the configured call count and rejects the next call", async () => { const target = bridge({ maxCalls: 2 }); - await expect(message(target.call("trusted/ws:test.call", args))).resolves.toBeUndefined(); - await expect(message(target.call("trusted/ws:test.call", args))).resolves.toBeUndefined(); - await expect(message(target.call("trusted/ws:test.call", args))).resolves.toContain( + await expect(message(target.call("host/ws:test.run", args))).resolves.toBeUndefined(); + await expect(message(target.call("host/ws:test.run", args))).resolves.toBeUndefined(); + await expect(message(target.call("host/ws:test.run", args))).resolves.toContain( "exceeds 2 capability calls", ); }); @@ -40,20 +34,20 @@ describe("WorkspaceRuntimeBridge cumulative limits", () => { it("accepts requests at the cumulative byte boundary and rejects the next request", async () => { const bytes = encoder.encode(args).byteLength; const target = bridge({ maxTotalRequestBytes: bytes * 2 }); - await expect(message(target.call("trusted/ws:test.call", args))).resolves.toBeUndefined(); - await expect(message(target.call("trusted/ws:test.call", args))).resolves.toBeUndefined(); - await expect(message(target.call("trusted/ws:test.call", args))).resolves.toContain( + await expect(message(target.call("host/ws:test.run", args))).resolves.toBeUndefined(); + await expect(message(target.call("host/ws:test.run", args))).resolves.toBeUndefined(); + await expect(message(target.call("host/ws:test.run", args))).resolves.toContain( `requests exceed ${bytes * 2} bytes`, ); }); it("accepts responses at the cumulative byte boundary and rejects the next response", async () => { - const sample = await bridge({}).call("trusted/ws:test.call", args); + const sample = await bridge({}).call("host/ws:test.run", args); const bytes = encoder.encode(sample).byteLength; const target = bridge({ maxTotalResponseBytes: bytes * 2 }); - await expect(message(target.call("trusted/ws:test.call", args))).resolves.toBeUndefined(); - await expect(message(target.call("trusted/ws:test.call", args))).resolves.toBeUndefined(); - await expect(message(target.call("trusted/ws:test.call", args))).resolves.toContain( + await expect(message(target.call("host/ws:test.run", args))).resolves.toBeUndefined(); + await expect(message(target.call("host/ws:test.run", args))).resolves.toBeUndefined(); + await expect(message(target.call("host/ws:test.run", args))).resolves.toContain( `responses exceed ${bytes * 2} bytes`, ); }); diff --git a/packages/computer/src/runtime/bridge.ts b/packages/computer/src/runtime/bridge.ts index 552e28f0..cfe4a6e8 100644 --- a/packages/computer/src/runtime/bridge.ts +++ b/packages/computer/src/runtime/bridge.ts @@ -1,17 +1,11 @@ import { RpcTarget } from "cloudflare:workers"; -import type { ArtifactClient } from "../artifacts/index.js"; -import type { GitClient } from "../git/index.js"; import { assertRuntimeValue, type WorkspaceRuntimeCapability } from "./capability.js"; -import type { WorkspaceTrustedModule } from "./types.js"; +import type { WorkspaceModuleCallContext, WorkspaceModuleFunctions } from "./types.js"; export class WorkspaceRuntimeBridge extends RpcTarget { readonly #capability: WorkspaceRuntimeCapability; - readonly #git: GitClient | undefined; - readonly #artifacts: ArtifactClient | undefined; - readonly #trustedModules: Record; - readonly #allowGitNetwork: boolean; - readonly #allowArtifactNetwork: boolean; + readonly #hostModules: ReadonlyMap; readonly #maxPayloadBytes: number; readonly #maxCallDurationMs: number; readonly #maxConcurrentCalls: number; @@ -31,11 +25,7 @@ export class WorkspaceRuntimeBridge extends RpcTarget { constructor( capability: WorkspaceRuntimeCapability, integrations: { - git?: GitClient; - artifacts?: ArtifactClient; - trustedModules?: Record; - allowGitNetwork?: boolean; - allowArtifactNetwork?: boolean; + hostModules?: ReadonlyMap; maxPayloadBytes?: number; maxCallDurationMs?: number; maxConcurrentCalls?: number; @@ -48,11 +38,7 @@ export class WorkspaceRuntimeBridge extends RpcTarget { ) { super(); this.#capability = capability; - this.#git = integrations.git; - this.#artifacts = integrations.artifacts; - this.#trustedModules = integrations.trustedModules ?? {}; - this.#allowGitNetwork = integrations.allowGitNetwork ?? false; - this.#allowArtifactNetwork = integrations.allowArtifactNetwork ?? false; + this.#hostModules = integrations.hostModules ?? new Map(); this.#maxPayloadBytes = integrations.maxPayloadBytes ?? 1024 * 1024; this.#maxCallDurationMs = integrations.maxCallDurationMs ?? 30_000; this.#maxConcurrentCalls = integrations.maxConcurrentCalls ?? 16; @@ -114,10 +100,14 @@ export class WorkspaceRuntimeBridge extends RpcTarget { const operation = encodeCall(async () => { const encodedArgs = JSON.parse(argsJson) as unknown[]; const args = encodedArgs.map(decodeBridgeValue); - if (name.startsWith("git.")) return this.#callGit(name.slice(4), args); - if (name.startsWith("artifacts.")) return this.#callArtifacts(name.slice(10), args); - if (name.startsWith("trusted/")) { - return this.#callTrusted(name, args, { signal: abort.signal, deadline }); + if (name.startsWith("host/")) { + return this.#callHostModule(name, args, { + signal: abort.signal, + deadline, + access: this.#capability.access, + resolvePath: (path, options) => + this.#capability.resolveConfined(path, options?.allowMissing ?? false), + }); } const operation = name.startsWith("fs.") ? name.slice(3) : name; switch (operation) { @@ -221,140 +211,28 @@ export class WorkspaceRuntimeBridge extends RpcTarget { } } - async #callTrusted( - name: string, - args: unknown[], - context: { signal: AbortSignal; deadline: number }, - ) { - const suffix = ".call"; - const specifier = name.endsWith(suffix) ? name.slice("trusted/".length, -suffix.length) : ""; - const trusted = this.#trustedModules[specifier]; - if (!trusted) { - throw new Error(`Unknown trusted Workspace module call ${JSON.stringify(name)}.`); + // `name` is `host/.`. Function names are + // identifiers and never contain a dot, so the last dot splits them + // from a specifier such as `ws:a.b`. Own-property checks keep + // isolate code from reaching `toString` or other inherited members. + async #callHostModule(name: string, args: unknown[], context: WorkspaceModuleCallContext) { + const target = name.slice("host/".length); + const dot = target.lastIndexOf("."); + const specifier = dot === -1 ? "" : target.slice(0, dot); + const functionName = dot === -1 ? "" : target.slice(dot + 1); + const functions = this.#hostModules.get(specifier); + const fn = + functions !== undefined && Object.hasOwn(functions, functionName) + ? functions[functionName] + : undefined; + if (typeof fn !== "function") { + throw new Error(`Unknown Workspace host module call ${JSON.stringify(name)}.`); } - const method = String(args[0]); - const callArgs = args.slice(1); - assertBridgeValues(callArgs); - const result = await trusted.call(method, callArgs, context); + assertBridgeValues(args); + const result = (await fn(args, context)) ?? null; assertBridgeValues([result]); return result; } - - async #callGit(name: string, args: unknown[]) { - if (!this.#git) throw new Error("Workspace Git is not configured for this execution."); - switch (name) { - case "clone": - this.#requireWrite("Git clone"); - this.#requireGitNetwork("Git clone"); - return this.#git - .clone( - (await this.#gitOptions(args[0], true)) as unknown as Parameters[0], - ) - .then(() => null); - case "diff": - return this.#git.diff( - (await this.#gitOptions(args[0])) as Parameters[0], - ); - case "status": - return this.#git.status( - (await this.#gitOptions(args[0])) as Parameters[0], - ); - case "log": - return this.#git.log((await this.#gitOptions(args[0])) as Parameters[0]); - case "cli": { - this.#requireWrite("Git CLI"); - const input = (args[0] ?? {}) as Parameters[0]; - assertSafeGitCliArguments(input.argv); - if (isGitNetworkCommand(input.argv)) this.#requireGitNetwork("Git CLI network command"); - return this.#git.cli({ - ...input, - cwd: await this.#capability.resolveConfined(input.cwd ?? ".", true), - }); - } - default: - throw new Error(`Unknown Workspace Git operation ${JSON.stringify(name)}.`); - } - } - - #callArtifacts(name: string, args: unknown[]) { - if (!this.#artifacts) - throw new Error("Workspace Artifacts are not configured for this execution."); - switch (name) { - case "create": - this.#requireWrite("Artifacts create"); - return this.#artifacts.create( - String(args[0]), - args[1] as Parameters[1], - ); - case "get": - return this.#artifacts.get(String(args[0])); - case "list": - return this.#artifacts.list(); - case "importArtifact": - this.#requireWrite("Artifacts import"); - if (!this.#allowArtifactNetwork) { - throw new Error( - "Artifacts import requires WorkerJavaScriptBackend allowArtifactNetwork: true.", - ); - } - return this.#artifacts.import( - String(args[0]), - args[1] as Parameters[1], - args[2] as Parameters[2], - ); - case "deleteArtifact": - this.#requireWrite("Artifacts delete"); - return this.#artifacts.delete(String(args[0])); - default: - throw new Error(`Unknown Workspace Artifacts operation ${JSON.stringify(name)}.`); - } - } - - async #gitOptions(value: unknown, allowMissing = false): Promise> { - const options = (value ?? {}) as Record; - return { - ...options, - dir: await this.#capability.resolveConfined( - typeof options.dir === "string" ? options.dir : ".", - allowMissing, - ), - }; - } - - #requireGitNetwork(operation: string) { - if (!this.#allowGitNetwork) { - throw new Error(`${operation} requires WorkerJavaScriptBackend allowGitNetwork: true.`); - } - } - - #requireWrite(operation: string) { - if (this.#capability.access !== "read-write") { - throw new Error(`${operation} requires Workspace write access.`); - } - } -} - -function assertSafeGitCliArguments(argv: string[] | undefined) { - if ( - argv?.some( - (argument) => - argument === "-C" || - argument.startsWith("-C") || - argument === "--git-dir" || - argument.startsWith("--git-dir=") || - argument === "--work-tree" || - argument.startsWith("--work-tree="), - ) - ) { - throw new Error( - "Git CLI path overrides are not available inside a confined Workspace runtime.", - ); - } -} - -function isGitNetworkCommand(argv: string[] | undefined) { - const networkCommands = new Set(["clone", "fetch", "pull", "push", "ls-remote", "submodule"]); - return argv?.some((argument) => networkCommands.has(argument.toLowerCase())) ?? false; } function assertBridgeValues( @@ -369,17 +247,19 @@ function assertBridgeValues( (typeof value === "number" && Number.isFinite(value)) ) return; - if (typeof value !== "object") - throw new Error("Trusted module values must be JSON-compatible."); - if (seen.has(value)) throw new Error("Trusted module values must be acyclic."); + if (typeof value !== "object") throw new Error("Host module values must be JSON-compatible."); + if (seen.has(value)) throw new Error("Host module values must be acyclic."); seen.add(value); if (Array.isArray(value)) for (const item of value) visit(item); else { const prototype = Object.getPrototypeOf(value); if (prototype !== Object.prototype && prototype !== null) { - throw new Error("Trusted module values must contain only plain objects."); + throw new Error("Host module values must contain only plain objects."); + } + // An undefined field is absent, as in JSON. encodeBridgeValue drops it. + for (const item of Object.values(value as Record)) { + if (item !== undefined) visit(item); } - for (const item of Object.values(value as Record)) visit(item); } seen.delete(value); }; @@ -398,7 +278,9 @@ function encodeBridgeValue(value: unknown): unknown { if (Array.isArray(value)) return wrap("array", { items: value.map(encodeBridgeValue) }); if (value && typeof value === "object") { return wrap("object", { - entries: Object.entries(value).map(([key, child]) => [key, encodeBridgeValue(child)]), + entries: Object.entries(value) + .filter(([, child]) => child !== undefined) + .map(([key, child]) => [key, encodeBridgeValue(child)]), }); } return value; diff --git a/packages/computer/src/runtime/runtime.test.ts b/packages/computer/src/runtime/runtime.test.ts index f91ca460..7cdb3a23 100644 --- a/packages/computer/src/runtime/runtime.test.ts +++ b/packages/computer/src/runtime/runtime.test.ts @@ -52,7 +52,7 @@ function replayBackend(events: WorkspaceRuntimeEvent[]): WorkspaceModuleBackendH function runtimeFor(handle: WorkspaceModuleBackendHandle): WorkspaceRuntime { return new WorkspaceRuntime({ - callableBackendIds: new Set(), + backends: new Map(), backendHandle: async () => handle, resolveBackendId: () => "backend", }); @@ -181,7 +181,7 @@ describe("WorkspaceRuntime utf8 encoding", () => { describe("WorkspaceRuntime callable gate", () => { it("rejects structured input for a non-callable backend", async () => { const runtime = new WorkspaceRuntime({ - callableBackendIds: new Set(), + backends: new Map(), backendHandle: async () => moduleHandleStub(), resolveBackendId: () => "worker-shell", }); @@ -194,7 +194,7 @@ describe("WorkspaceRuntime callable gate", () => { it("accepts structured input for a callable module backend", async () => { const handle = moduleHandleStub(); const runtime = new WorkspaceRuntime({ - callableBackendIds: new Set(["worker-javascript"]), + backends: new Map([["worker-javascript", { callable: true }]]), backendHandle: async () => handle, resolveBackendId: () => "worker-javascript", }); diff --git a/packages/computer/src/runtime/runtime.ts b/packages/computer/src/runtime/runtime.ts index ec43d5df..f51cec6f 100644 --- a/packages/computer/src/runtime/runtime.ts +++ b/packages/computer/src/runtime/runtime.ts @@ -1,20 +1,23 @@ import type { SkippedEntry } from "@cloudflare/dofs"; import type { ExecEncoding } from "../shell.js"; -import type { - ModuleExecutionEnvelope, - WorkspaceModuleBackendHandle, - WorkspaceRuntimeDisposeOptions, - WorkspaceRuntimeEvent, - WorkspaceRuntimeExecHandle, - WorkspaceRuntimeExecOptions, - WorkspaceRuntimeGetOptions, - WorkspaceRuntimeKillOptions, - WorkspaceRuntimeResult, +import { + isModuleBackend, + type ModuleExecutionEnvelope, + type WorkspaceModuleBackendHandle, + type WorkspaceRegisteredBackend, + type WorkspaceRuntimeDisposeOptions, + type WorkspaceRuntimeEvent, + type WorkspaceRuntimeExecHandle, + type WorkspaceRuntimeExecOptions, + type WorkspaceRuntimeGetOptions, + type WorkspaceRuntimeKillOptions, + type WorkspaceRuntimeResult, } from "./types.js"; interface WorkspaceRuntimeRouterOptions { - callableBackendIds: ReadonlySet; + // What each registered backend says about itself. + backends: ReadonlyMap; backendHandle: (id: string) => Promise; resolveBackendId: (id: string | undefined) => string; } @@ -27,6 +30,21 @@ export function notCallableMessage(backend: string): string { return `Backend ${JSON.stringify(backend)} is not callable; it does not accept structured input.`; } +/** What a registered backend says about itself. */ +export interface WorkspaceBackendInfo { + /** The id the backend is registered under. */ + readonly id: string; + /** + * What `exec` source means on this backend: a shell command + * (`"command"`) or module source (`"module"`). + */ + readonly protocol: "command" | "module"; + /** Whether the backend takes structured `input` and returns a `result`. */ + readonly callable: boolean; + /** What the backend tells a model about itself. */ + readonly description?: string; +} + export class WorkspaceRuntime { readonly #options: WorkspaceRuntimeRouterOptions; @@ -39,7 +57,20 @@ export class WorkspaceRuntime { // this to know whether a backend is callable without the caller // having to declare it a second time. isCallable(id: string): boolean { - return this.#options.callableBackendIds.has(id); + return this.#options.backends.get(id)?.callable === true; + } + + // What each registered backend says about itself, in registration + // order. The exec tool builds itself from this, and a Workspace + // client snapshots it when it is created, so the answer is the same + // locally and over RPC. + backends(): WorkspaceBackendInfo[] { + return [...this.#options.backends].map(([id, backend]) => ({ + id, + protocol: isModuleBackend(backend) ? ("module" as const) : ("command" as const), + callable: backend.callable === true, + ...(backend.description === undefined ? {} : { description: backend.description }), + })); } exec(source: string): Promise>; diff --git a/packages/computer/src/runtime/types.ts b/packages/computer/src/runtime/types.ts index c92a9f64..84bebae0 100644 --- a/packages/computer/src/runtime/types.ts +++ b/packages/computer/src/runtime/types.ts @@ -4,15 +4,83 @@ import type { ExecEncoding, ExecSyncResult, KillSignal } from "../shell.js"; export type WorkspaceRuntimeAccess = "read" | "read-write"; -export interface WorkspaceTrustedModule { - /** Dispatch a call made through a host-installed reserved ws:* module. */ - call( - method: string, - args: WorkspaceRuntimeValue[], - context?: { signal: AbortSignal; deadline: number }, - ): Promise; +/** Per-call context the backend passes to every host module function. */ +export interface WorkspaceModuleCallContext { + /** Aborts when the call passes its deadline or the execution is cancelled. */ + readonly signal: AbortSignal; + /** Epoch milliseconds after which the caller stops waiting for this call. */ + readonly deadline: number; + /** Access level of the backend running the call. */ + readonly access: WorkspaceRuntimeAccess; + /** + * Resolve a path the isolate passed against the backend's root. + * Rejects paths that escape the root or pass through a symlink. + * + * @param path - An absolute path, or one relative to the backend root. + * @param options - Set `allowMissing` when the path may not exist yet. + * @returns The confined absolute path. + */ + resolvePath(path: string, options?: { readonly allowMissing?: boolean }): Promise; } +/** + * One host function exported by a host module. + * + * `args` holds the arguments the isolate passed, decoded from the wire. + * They come from untrusted code, so parse them before use. The function + * may return a value or a promise of one. The result must be + * JSON-compatible: the bridge checks it at runtime, treats `undefined` + * as `null`, and drops `undefined` object fields, the way + * `JSON.stringify` does. + */ +export type WorkspaceModuleFunction = ( + args: readonly WorkspaceRuntimeValue[], + context: WorkspaceModuleCallContext, +) => unknown; + +/** Named functions a host module exports into the isolate. */ +export type WorkspaceModuleFunctions = Readonly>; + +/** Workspace services a host module factory can build its functions from. */ +export interface WorkspaceModuleHost { + /** The Workspace's Git client. Throws on use when Git is not configured. */ + readonly git: import("../git/index.js").GitClient; + /** The Workspace's Artifacts client. Throws on use when Artifacts is not configured. */ + readonly artifacts: import("../artifacts/index.js").ArtifactClient; + /** The Workspace runtime, for running commands on other backends. */ + readonly runtime: import("./runtime.js").WorkspaceRuntime; +} + +/** + * Builds a host module's functions from the Workspace's services. The + * backend calls it once when it connects to its Workspace. The + * prebuilt modules in `@cloudflare/computer/modules/*` are factories. + */ +export interface WorkspaceModuleFactory { + (host: WorkspaceModuleHost): WorkspaceModuleFunctions; + /** + * What the module does and how to call it, for a model. The + * JavaScript backend adds it to its own description, which the exec + * tool shows. Objects of functions are listed by their export names. + */ + readonly description?: string; +} + +/** + * A module caller source can import. + * + * - A string is JavaScript source bundled into the isolate, with no host access. + * - An object of functions is a host module. Its functions run in the + * Durable Object and each becomes a named export: + * `{ "ws:weather": { forecast } }` lets code write + * `import { forecast } from "ws:weather"`. + * - A factory is a host module that needs the Workspace's Git client, + * Artifacts client, or runtime, such as `createGitModule()`. + * + * Host modules must use a `ws:*` specifier. + */ +export type WorkspaceModule = string | WorkspaceModuleFunctions | WorkspaceModuleFactory; + export type WorkspaceRuntimeValue = | null | boolean @@ -181,13 +249,19 @@ export interface WorkspaceModuleBackendHandle { close?(): Promise; } -export type WorkspaceModuleBackendHost = import("../backend.js").WorkspaceBackendHost; +/** What a module backend receives when it connects to its Workspace. */ +export type WorkspaceModuleBackendHost = import("../backend.js").WorkspaceBackendHost & { + /** The Workspace runtime, handed to host modules. */ + readonly runtime: import("./runtime.js").WorkspaceRuntime; +}; export interface WorkspaceModuleBackend { readonly protocol: "module"; readonly id: string; readonly type: string; readonly callable?: boolean; + /** What the backend tells a model about itself. Shown by the exec tool. */ + readonly description?: string; connect(host: WorkspaceModuleBackendHost): Promise; } diff --git a/packages/computer/src/stub.ts b/packages/computer/src/stub.ts index 682bc0fb..2759b141 100644 --- a/packages/computer/src/stub.ts +++ b/packages/computer/src/stub.ts @@ -68,6 +68,7 @@ import type { import type { ShareOptions } from "./assets/index.js"; import type { GitCliInput, GitCliResult } from "./git/index.js"; import { withSpan } from "./observe.js"; +import type { WorkspaceBackendInfo } from "./runtime/runtime.js"; import type { WorkspaceRuntimeEvent, WorkspaceRuntimeExecHandle, @@ -407,6 +408,11 @@ export class WorkspaceRuntimeStub extends RpcTarget { untrackStub(this); } + /** What each backend says about itself. A client snapshots this when it is created. */ + backends(): WorkspaceBackendInfo[] { + return this.#ws.runtime.backends(); + } + exec(source: string): Promise>; exec( source: string, diff --git a/packages/computer/src/text-truncation.ts b/packages/computer/src/text-truncation.ts new file mode 100644 index 00000000..8100d812 --- /dev/null +++ b/packages/computer/src/text-truncation.ts @@ -0,0 +1,37 @@ +const encoder = new TextEncoder(); + +/** + * Cut text to at most `maxBytes` UTF-8 bytes without splitting a + * character, and say how much was left out. + * + * @param value - The text to cut. + * @param maxBytes - The largest number of UTF-8 bytes to keep. + * @returns The text unchanged when it fits, or its longest whole-character + * prefix followed by a `[truncated, N more bytes]` marker. + */ +export function truncateText(value: string, maxBytes: number): string { + const totalBytes = encoder.encode(value).byteLength; + if (totalBytes <= maxBytes) return value; + const { text, bytes } = utf8Prefix(value, maxBytes); + return `${text}\n\n[truncated, ${totalBytes - bytes} more bytes]`; +} + +/** + * The longest whole-character prefix of `value` that fits in `maxBytes` + * UTF-8 bytes. + * + * @param value - The text to cut. + * @param maxBytes - The largest number of UTF-8 bytes to keep. + * @returns The prefix and its size in bytes. + */ +export function utf8Prefix(value: string, maxBytes: number): { text: string; bytes: number } { + let bytes = 0; + let end = 0; + for (const char of value) { + const charBytes = encoder.encode(char).byteLength; + if (bytes + charBytes > maxBytes) break; + bytes += charBytes; + end += char.length; + } + return { text: value.slice(0, end), bytes }; +} diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai-sdk/index.test.ts similarity index 86% rename from packages/computer/src/tools/ai.test.ts rename to packages/computer/src/tools/ai-sdk/index.test.ts index 5d283781..bc71c6e4 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai-sdk/index.test.ts @@ -1,9 +1,11 @@ import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; import { describe, expect, it } from "vitest"; -import type { WorkspaceRuntimeExecHandle, WorkspaceRuntimeResult } from "../runtime/types.js"; -import { Workspace } from "../workspace.js"; +import { z } from "zod"; +import { WorkerJavaScriptBackend } from "../../backends/worker-javascript/worker-javascript.js"; +import { createGitModule } from "../../modules/git.js"; +import type { WorkspaceRuntimeExecHandle, WorkspaceRuntimeResult } from "../../runtime/types.js"; +import { Workspace } from "../../workspace.js"; import { - createAITools, createDeleteTool, createEditTool, createFindTool, @@ -12,7 +14,8 @@ import { createWriteTool, type FileStore, WorkspaceFileStore, -} from "./index.js"; +} from "../index.js"; +import { createAITools } from "./index.js"; const toolOptions = { toolCallId: "test-call", messages: [] }; @@ -85,6 +88,17 @@ function toolDescription(tool: unknown): string { return description; } +function inputSchema(tool: unknown): z.ZodType { + const schema = (tool as { inputSchema?: unknown }).inputSchema; + if (!(schema instanceof z.ZodType)) throw new Error("tool has no zod input schema"); + return schema; +} + +function inputProperties(tool: unknown): string[] { + const json = z.toJSONSchema(inputSchema(tool)) as { properties?: Record }; + return Object.keys(json.properties ?? {}).sort(); +} + function makeWorkspace(): Workspace { return new Workspace({ storage: new SQLiteTestStorage(), now: () => 1_700_000_000_000 }); } @@ -1316,19 +1330,56 @@ describe("createAITools filesystem tools", () => { }); describe("createAITools exec tool", () => { - it("adds exec only when shell options are provided", () => { - const workspace = makeWorkspace(); + it("offers exec by default only when the workspace has a backend", () => { + const withBackend = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [streamingCommandBackend([]) as never], + }); - expect(createAITools({ workspace }).exec).toBeUndefined(); - expect( - createAITools({ - workspace, - shell: { - defaultBackend: "shell", - backends: { shell: { description: "test shell" } }, - }, - }).exec, - ).toBeDefined(); + expect(createAITools({ workspace: makeWorkspace() }).exec).toBeUndefined(); + expect(createAITools({ workspace: withBackend }).exec).toBeDefined(); + expect(createAITools({ workspace: withBackend, exec: {} }).exec).toBeUndefined(); + expect(createAITools({ workspace: withBackend, readonly: true }).exec).toBeUndefined(); + }); + + it("offers every workspace backend by default", () => { + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + streamingCommandBackend([]) as never, + new WorkerJavaScriptBackend({ loader: { load: () => ({ getEntrypoint: () => ({}) }) } }), + ], + }); + const tools = createAITools({ workspace }); + const schema = z.toJSONSchema(inputSchema(tools.exec)) as { + properties: { backend?: { enum?: string[] } }; + required?: string[]; + }; + + expect(schema.properties.backend?.enum).toEqual(["shell", "worker-javascript"]); + expect(schema.required).toContain("backend"); + expect(toolDescription(tools.exec)).not.toMatch(/default backend/i); + expect(toolDescription(tools.exec)).toContain('- "shell": Runs shell commands.'); + expect(toolDescription(tools.exec)).toContain("ECMAScript module source"); + }); + + it("takes the backends to offer, each with a note for the model", () => { + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + streamingCommandBackend([]) as never, + new WorkerJavaScriptBackend({ loader: { load: () => ({ getEntrypoint: () => ({}) }) } }), + ], + }); + const listed = createAITools({ workspace, exec: { "worker-javascript": {} } }); + const mapped = createAITools({ + workspace, + exec: { "worker-javascript": { description: "Use for data work." }, shell: {} }, + }); + + expect(inputProperties(listed.exec)).not.toContain("backend"); + expect(toolDescription(listed.exec)).not.toContain('"shell"'); + expect(toolDescription(mapped.exec)).toContain("Use for data work.\n\n`command` is ECMAScript"); }); it("runs shell commands on the selected backend and truncates output", async () => { @@ -1514,15 +1565,28 @@ describe("createAITools exec tool", () => { }); }); - it("rejects invalid shell backend configuration", () => { - const workspace = makeWorkspace(); + it("lets exec win over the deprecated shell option", () => { + const workspace = { + runtime: { + async exec() { + throw new Error("not used"); + }, + }, + }; - expect(() => + expect( createAITools({ workspace, - shell: { defaultBackend: "missing", backends: { shell: { description: "test" } } }, - }), - ).toThrow(/defaultBackend/); + exec: {}, + shell: { backends: { shell: { description: "Commands." } } }, + }).exec, + ).toBeUndefined(); + }); + + it("rejects a backend the workspace does not have", () => { + expect(() => createAITools({ workspace: makeWorkspace(), exec: { missing: {} } })).toThrow( + /unknown backend "missing"/, + ); }); }); @@ -1561,7 +1625,7 @@ describe("createAITools callable exec", () => { }), }; }, - isCallable: (id: string) => id === "js", + backends: () => [{ id: "js", callable: true }], }, }; const tools = createAITools({ @@ -1607,7 +1671,7 @@ describe("createAITools callable exec", () => { result: async () => ({ exitCode: 0, stdout: "ok", stderr: "" }), }; }, - isCallable: (id: string) => id === "js", + backends: () => [{ id: "js", callable: true }], }, }; const tools = createAITools({ @@ -1631,7 +1695,10 @@ describe("createAITools callable exec", () => { called = true; return { result: async () => ({ exitCode: 0, stdout: "", stderr: "" }) }; }, - isCallable: (id: string) => id === "js", + backends: () => [ + { id: "shell", callable: false }, + { id: "js", callable: true }, + ], }, }; const tools = createAITools({ @@ -1686,7 +1753,7 @@ describe("createAITools callable exec", () => { async exec() { throw new Error("not used"); }, - isCallable: (id: string) => id === "js", + backends: () => [{ id: "js", callable: true }], }, }; const tools = createAITools({ @@ -1697,7 +1764,170 @@ describe("createAITools callable exec", () => { }, }); - expect(toolDescription(tools.exec)).toContain("callable"); + expect(toolDescription(tools.exec)).toContain("Run code in the workspace"); + expect(toolDescription(tools.exec)).toContain("`result` field"); + expect(toolDescription(tools.exec)).toContain("JavaScript module runtime"); + }); + + it("adds what the backend says about itself after the caller's description", () => { + const workspace = { + runtime: { + async exec() { + throw new Error("not used"); + }, + backends: () => [ + { id: "js", callable: true, description: "Modules: `ws:weather` exports `forecast`." }, + ], + }, + }; + const withBoth = createAITools({ + workspace, + shell: { backends: { js: { description: "Use for data work." } } }, + }); + const withBackendOnly = createAITools({ workspace, shell: { backends: { js: {} } } }); + + expect(toolDescription(withBoth.exec)).toContain( + "Use for data work.\n\nModules: `ws:weather` exports `forecast`.", + ); + expect(toolDescription(withBackendOnly.exec)).toContain("`ws:weather` exports `forecast`"); + }); + + it("falls back to a short description for a backend that does not describe itself", () => { + const workspace = { + runtime: { + async exec() { + throw new Error("not used"); + }, + }, + }; + const tools = createAITools({ workspace, exec: { shell: {} } }); + + expect(toolDescription(tools.exec)).toContain("Runs shell commands."); + }); +}); + +describe("createAITools exec against a real JavaScript backend", () => { + it("lists the backend's modules in the tool description", () => { + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + new WorkerJavaScriptBackend({ + loader: { load: () => ({ getEntrypoint: () => ({}) }) }, + modules: { + "tar-stream": "export default {};", + "ws:weather": { forecast: ([city]) => ({ city, sky: "clear" }) }, + "ws:git": createGitModule(), + }, + }), + ], + }); + const tools = createAITools({ + workspace, + shell: { backends: { "worker-javascript": {} } }, + }); + const description = toolDescription(tools.exec); + + expect(description).toContain("`node:fs/promises`"); + expect(description).toContain("- `tar-stream`: a bundled library."); + expect(description).toContain("- `ws:weather`: exports `forecast`."); + expect(description).toContain("- `ws:git`: The workspace's Git repository tools"); + expect(description).toContain("no direct network access"); + expect(inputProperties(tools.exec)).toEqual(["command", "cwd", "env", "input"]); + }); +}); + +describe("createAITools exec with one backend", () => { + function recordingWorkspace(callable: boolean) { + const calls: Array<{ command: string; backend: string | undefined; input: unknown }> = []; + const workspace = { + runtime: { + async exec( + command: string, + options: { encoding: "utf8"; backend?: string; input?: unknown }, + ) { + calls.push({ command, backend: options.backend, input: options.input }); + return { result: async () => ({ exitCode: 0, stdout: "", stderr: "", value: 1 }) }; + }, + backends: () => [ + { id: "worker-javascript", callable }, + { id: "shell", callable: false }, + { id: "container", callable: false }, + ], + }, + }; + return { calls, workspace }; + } + + it("has no backend argument and runs on the only backend without defaultBackend", async () => { + const { calls, workspace } = recordingWorkspace(true); + const tools = createAITools({ + workspace, + shell: { backends: { "worker-javascript": { description: "Isolated JavaScript." } } }, + }); + + expect(inputProperties(tools.exec)).toEqual(["command", "cwd", "env", "input"]); + const parsed = inputSchema(tools.exec).parse({ + command: "export default () => 1", + backend: "container", + }); + expect(parsed).toEqual({ command: "export default () => 1" }); + await executeTool(tools.exec, parsed); + expect(calls).toEqual([ + { command: "export default () => 1", backend: "worker-javascript", input: undefined }, + ]); + }); + + it("does not mention backends in the description", () => { + const { workspace } = recordingWorkspace(true); + const tools = createAITools({ + workspace, + shell: { backends: { "worker-javascript": { description: "Isolated JavaScript." } } }, + }); + + expect(toolDescription(tools.exec)).not.toMatch(/backend/i); + }); + + it("drops input for a single shell backend", () => { + const { workspace } = recordingWorkspace(false); + const tools = createAITools({ + workspace, + shell: { backends: { shell: { description: "Fast shell." } } }, + }); + + expect(inputProperties(tools.exec)).toEqual(["command", "cwd", "env"]); + expect(toolDescription(tools.exec)).toContain("Run a shell command"); + expect(toolDescription(tools.exec)).not.toMatch(/backend/i); + }); + + it("keeps the backend argument when more than one backend is configured", () => { + const { workspace } = recordingWorkspace(true); + const tools = createAITools({ + workspace, + shell: { + defaultBackend: "worker-javascript", + backends: { + "worker-javascript": { description: "Isolated JavaScript." }, + container: { description: "Full Linux." }, + }, + }, + }); + + expect(inputProperties(tools.exec)).toEqual(["backend", "command", "cwd", "env", "input"]); + }); + + it("requires the model to name a backend when there is a choice", async () => { + const { calls, workspace } = recordingWorkspace(false); + const tools = createAITools({ + workspace, + exec: { container: { description: "Linux." }, shell: { description: "Fast." } }, + }); + + expect(() => inputSchema(tools.exec).parse({ command: "ls" })).toThrow(); + await expect(executeTool(tools.exec, { command: "ls" })).resolves.toMatchObject({ + error: "Name a backend to run on.", + }); + await executeTool(tools.exec, { command: "ls", backend: "shell" }); + expect(calls.map((call) => call.backend)).toEqual(["shell"]); }); }); @@ -1794,7 +2024,7 @@ describe("createAITools exec streaming", () => { { name: "exit", code: 0, result: { ok: true } }, ]); }, - isCallable: (id: string) => id === "js", + backends: () => [{ id: "js", callable: true }], }, }; const tools = createAITools({ diff --git a/packages/computer/src/tools/ai-sdk/index.ts b/packages/computer/src/tools/ai-sdk/index.ts new file mode 100644 index 00000000..be84570a --- /dev/null +++ b/packages/computer/src/tools/ai-sdk/index.ts @@ -0,0 +1,66 @@ +import type { ToolSet } from "ai"; +import { type CreateToolsOptions, resolveToolOptions } from "../common/options.js"; +import type { PublishWorkspaceLike } from "../common/publish.js"; +import { + createDeleteTool, + createEditTool, + createExecTool, + createFindTool, + createGrepTool, + createListTool, + createPublishTool, + createReadTool, + createWriteTool, +} from "./tools.js"; + +/** Options for {@link createAITools}. */ +export type CreateAIToolsOptions = CreateToolsOptions; + +export { + createDeleteTool, + createEditTool, + createExecTool, + createFindTool, + createGrepTool, + createListTool, + createPublishTool, + createReadTool, + createWriteTool, +} from "./tools.js"; + +/** + * Build the AI SDK tool set for a Workspace: `read`, `ls`, `find`, and + * `grep`, plus `write`, `edit`, `delete`, `exec`, and `publish` unless + * the set is read-only. `exec` offers every backend the Workspace has + * unless `exec` picks them. + * + * @param options - The Workspace and per-tool options. + * @returns An AI SDK `ToolSet` for `generateText`, `streamText`, or an agent's `getTools()`. + */ +export function createAITools(options: CreateAIToolsOptions): ToolSet { + const resolved = resolveToolOptions(options); + const workspace = resolved.workspace; + + const tools: ToolSet = { + read: createReadTool(resolved.read), + ls: createListTool({ workspace }), + find: createFindTool({ workspace }), + grep: createGrepTool({ workspace }), + }; + + if (resolved.readonly) return tools; + + tools.write = createWriteTool(resolved.write); + tools.edit = createEditTool(resolved.edit); + tools.delete = createDeleteTool(resolved.delete); + + if (resolved.exec !== undefined) { + tools.exec = createExecTool(resolved.exec); + } + + if (resolved.publish) { + tools.publish = createPublishTool({ workspace: workspace as PublishWorkspaceLike }); + } + + return tools; +} diff --git a/packages/computer/src/tools/ai-sdk/output.ts b/packages/computer/src/tools/ai-sdk/output.ts new file mode 100644 index 00000000..963a93c2 --- /dev/null +++ b/packages/computer/src/tools/ai-sdk/output.ts @@ -0,0 +1,35 @@ +import type { JSONValue } from "ai"; +import type { ModelOutput } from "../common/model-output.js"; + +export function toAISDKOutput(output: ModelOutput) { + switch (output.type) { + case "text": + return { type: "text" as const, value: output.value }; + case "error-text": + return { type: "error-text" as const, value: output.value }; + case "json": + return { type: "json" as const, value: toJSONValue(output.value) }; + case "media": + return { + type: "content" as const, + value: [ + { type: "text" as const, text: output.text }, + { + type: "file" as const, + data: { type: "data" as const, data: output.data }, + mediaType: output.mediaType, + filename: output.filename, + }, + ], + }; + } +} + +export function toJSONValue(value: unknown): JSONValue { + try { + const json = JSON.stringify(value); + return json === undefined ? null : (JSON.parse(json) as JSONValue); + } catch { + return String(value); + } +} diff --git a/packages/computer/src/tools/ai-sdk/tools.ts b/packages/computer/src/tools/ai-sdk/tools.ts new file mode 100644 index 00000000..f4b40a42 --- /dev/null +++ b/packages/computer/src/tools/ai-sdk/tools.ts @@ -0,0 +1,141 @@ +import { type Tool, tool } from "ai"; +import type { z } from "zod"; +import { + defineExec, + type ExecInput, + type ExecToolOptions, + type ExecToolOutput, +} from "../common/exec.js"; +import { + type DeleteToolOptions, + deleteDescription, + deleteFromStore, + deleteInputSchema, +} from "../common/fs/delete.js"; +import { + type EditToolOptions, + editDescription, + editInputSchema, + editInStore, +} from "../common/fs/edit.js"; +import { + type FindToolOptions, + findDescription, + findInputSchema, + findInWorkspace, +} from "../common/fs/find.js"; +import { + type GrepToolOptions, + grepDescription, + grepInputSchema, + grepInWorkspace, +} from "../common/fs/grep.js"; +import { + type ListToolOptions, + listDescription, + listInputSchema, + listWorkspace, +} from "../common/fs/list.js"; +import { + createReadExecutor, + type ReadInput, + type ReadToolOptions, + type ReadToolResult, + readDescription, + readInputSchema, + readModelOutput, +} from "../common/fs/read.js"; +import { + type WriteToolOptions, + writeDescription, + writeInputSchema, + writeToStore, +} from "../common/fs/write.js"; +import { + createPublishExecutor, + type PublishToolOptions, + publishDescription, + publishInputSchema, +} from "../common/publish.js"; +import { toAISDKOutput } from "./output.js"; + +export function createReadTool(options: ReadToolOptions): Tool> { + const toModelOutput = readModelOutput(options); + return tool({ + description: readDescription(options), + inputSchema: readInputSchema, + execute: createReadExecutor(options), + toModelOutput: ({ input, output }: { input: unknown; output: unknown }) => + toAISDKOutput(toModelOutput({ input: input as ReadInput, output: output as ReadToolResult })), + }); +} + +export function createWriteTool(options: WriteToolOptions): Tool> { + return tool({ + description: writeDescription, + inputSchema: writeInputSchema, + execute: (input) => writeToStore(options, input), + }); +} + +export function createEditTool(options: EditToolOptions): Tool> { + return tool({ + description: editDescription, + inputSchema: editInputSchema, + execute: (rawInput) => editInStore(options, rawInput), + }); +} + +export function createDeleteTool( + options: DeleteToolOptions, +): Tool> { + return tool({ + description: deleteDescription, + inputSchema: deleteInputSchema, + execute: (input) => deleteFromStore(options, input), + }); +} + +export function createListTool(options: ListToolOptions): Tool> { + return tool({ + description: listDescription, + inputSchema: listInputSchema, + execute: (input) => listWorkspace(options.workspace, input), + }); +} + +export function createFindTool(options: FindToolOptions): Tool> { + return tool({ + description: findDescription, + inputSchema: findInputSchema, + execute: (input) => findInWorkspace(options.workspace, input), + }); +} + +export function createGrepTool(options: GrepToolOptions): Tool> { + return tool({ + description: grepDescription, + inputSchema: grepInputSchema, + execute: (input) => grepInWorkspace(options.workspace, input), + }); +} + +export function createExecTool(options: ExecToolOptions): Tool { + const exec = defineExec(options); + return tool({ + description: exec.description, + inputSchema: exec.inputSchema, + execute: (input, { abortSignal }) => exec.execute(input, { abortSignal }), + }); +} + +export function createPublishTool( + options: PublishToolOptions, +): Tool> { + const execute = createPublishExecutor(options.workspace); + return tool({ + description: publishDescription, + inputSchema: publishInputSchema, + execute: (input) => execute(input), + }); +} diff --git a/packages/computer/src/tools/ai.ts b/packages/computer/src/tools/ai.ts deleted file mode 100644 index 78cc8358..00000000 --- a/packages/computer/src/tools/ai.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { ToolSet } from "ai"; -import { createExecTool, type ExecToolOptions, type ExecWorkspaceLike } from "./exec.js"; -import { createDeleteTool } from "./fs/delete.js"; -import { createEditTool, type EditToolOptions } from "./fs/edit.js"; -import { createFindTool } from "./fs/find.js"; -import { createGrepTool } from "./fs/grep.js"; -import { createListTool } from "./fs/list.js"; -import { createReadTool, type ReadToolOptions } from "./fs/read.js"; -import { type WorkspaceLike as FileWorkspaceLike, WorkspaceFileStore } from "./fs/store.js"; -import { createWriteTool, type WriteToolOptions } from "./fs/write.js"; -import { createPublishTool, type PublishWorkspaceLike } from "./publish.js"; - -export interface CreateAIToolsOptions { - workspace: FileWorkspaceLike & Partial & Partial; - readonly?: boolean; - assets?: boolean; - read?: Omit; - write?: Omit; - edit?: Omit; - shell?: Omit; -} - -export function createAITools(options: CreateAIToolsOptions): ToolSet { - const store = new WorkspaceFileStore(options.workspace); - const tools: ToolSet = { - read: createReadTool({ store, ...options.read }), - ls: createListTool({ workspace: options.workspace }), - find: createFindTool({ workspace: options.workspace }), - grep: createGrepTool({ workspace: options.workspace }), - }; - - if (options.readonly === true) return tools; - - tools.write = createWriteTool({ store, ...options.write }); - tools.edit = createEditTool({ store, ...options.edit }); - tools.delete = createDeleteTool({ store }); - - if (options.shell !== undefined) { - tools.exec = createExecTool({ - workspace: options.workspace as ExecWorkspaceLike, - ...options.shell, - }); - } - - if (options.assets !== false && options.workspace.assets !== undefined) { - tools.publish = createPublishTool({ workspace: options.workspace as PublishWorkspaceLike }); - } - - return tools; -} diff --git a/packages/computer/src/tools/exec.ts b/packages/computer/src/tools/common/exec.ts similarity index 51% rename from packages/computer/src/tools/exec.ts rename to packages/computer/src/tools/common/exec.ts index 5a58fb36..381efce3 100644 --- a/packages/computer/src/tools/exec.ts +++ b/packages/computer/src/tools/common/exec.ts @@ -1,8 +1,8 @@ -import { type Tool, tool } from "ai"; import { z } from "zod"; - -import { notCallableMessage } from "../runtime/runtime.js"; -import type { WorkspaceRuntimeValue } from "../runtime/types.js"; +import type { WorkspaceBackendInfo } from "../../runtime/runtime.js"; +import { notCallableMessage } from "../../runtime/runtime.js"; +import type { WorkspaceRuntimeValue } from "../../runtime/types.js"; +import { truncateText, utf8Prefix } from "../../text-truncation.js"; // A finite JSON value: what a callable backend accepts as `input` and // returns as `result`. Declared as a concrete recursive schema rather @@ -59,22 +59,35 @@ export interface ExecWorkspaceLike { input?: WorkspaceRuntimeValue; }, ): Promise; - // Whether a backend accepts a structured `input` value and returns - // a structured result. The tool asks this to know which backends - // are callable; the runtime derives it from each backend's - // `callable` flag. Omit when no backend is callable. - isCallable?(id: string): boolean; + // What each registered backend says about itself: whether it takes + // structured `input`, and its description for the model. Used to + // build the tool, to offer every backend when the caller picks none, + // and to reject an unknown id up front. Without it, every backend + // must be named and is treated as a shell. + backends?(): readonly WorkspaceBackendInfo[]; }; } -export interface ExecBackendDescription { - description: string; +/** Options for one backend the exec tool may run on. */ +export interface ExecBackendOptions { + /** Shown to the model before the backend's own description. */ + readonly description?: string; } +/** + * The backends the exec tool may run on, keyed by backend id: + * `{ "worker-javascript": { description: "Use for data work." } }`. + * Pass `{}` for a backend that needs nothing beyond its own + * description. + */ +export type ExecBackends = Readonly>; + export interface ExecToolOptions { workspace: ExecWorkspaceLike; - backends: Record; - defaultBackend: string; + // Omit to offer every backend the Workspace has. With one backend + // the tool has no `backend` argument; with several the model must + // name one on every call. + backends?: ExecBackends; // Per-snapshot display cap for each of stdout and stderr, in bytes. // Output past it is shown as a truncation marker. Defaults to 64 KiB. maxBytes?: number; @@ -110,91 +123,101 @@ export type ExecToolOutput = } | { command: string; cwd: string | null; backend: string; error: string }; -export function createExecTool(options: ExecToolOptions): Tool< - { - command: string; - cwd?: string; - backend?: string; - env?: Record; - input?: WorkspaceRuntimeValue; - }, - ExecToolOutput -> { +export interface ExecInput { + command: string; + cwd?: string; + backend?: string; + env?: Record; + input?: WorkspaceRuntimeValue; +} + +export interface ExecCallContext { + abortSignal?: AbortSignal; +} + +/** The exec tool with no agent library attached. Each library wraps it in its own tool shape. */ +export interface ExecDefinition { + description: string; + inputSchema: z.ZodType; + /** + * Yields running snapshots while the command streams, then one + * terminal snapshot. Every snapshot is a complete result, so a + * library that cannot stream tool output keeps the last one. + */ + execute(input: ExecInput, context?: ExecCallContext): AsyncGenerator; +} + +/** + * Resolve the backends once and build the exec tool's description, + * input schema, and executor. Throws when no backend is left or an id + * is unknown, so a misconfigured tool fails when it is built. + */ +export function defineExec(options: ExecToolOptions): ExecDefinition { const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; const streamMaxBytes = options.streamMaxBytes ?? DEFAULT_STREAM_MAX_BYTES; const now = options.now ?? Date.now; - const backendIds = Object.keys(options.backends); - if (backendIds.length === 0) { - throw new Error("createExecTool: pass at least one backend in `backends`"); + const runtime = options.workspace.runtime; + const selected = selectBackends(options.backends, runtime.backends?.()); + const [first] = selected; + if (first === undefined) throw new Error("createExecTool: no backends to run on"); + const backendIds = selected.map((backend) => backend.id); + const single = backendIds.length === 1; + const known = runtime.backends?.(); + const backends = selected.map(({ id, guidance }) => { + const info = known?.find((backend) => backend.id === id); + const callable = info?.callable === true; + const own = info?.description; + const text = + [guidance, own].filter((part) => part !== undefined && part !== "").join("\n\n") || + (callable ? "Runs `command` as module source." : "Runs shell commands."); + return { id, text, callable }; + }); + const callableBackendIds = new Set(backends.filter((b) => b.callable).map((b) => b.id)); + const description = describeTool(backends); + // Offer only the fields that can work: `backend` when there is a + // choice, `input` when some backend accepts it. + const shape: Record = { + command: z.string().describe(commandHint(backends)), + cwd: z.string().optional().describe("Working directory. Defaults to the workspace root."), + env: z + .record(z.string(), z.string()) + .optional() + .describe( + "Environment variables for this run only. Values override the base environment without affecting later runs.", + ), + }; + if (!single) { + shape.backend = z + // SAFETY: defineExec checked that backendIds has at least one entry. + .enum(backendIds as [string, ...string[]]) + .describe( + "Which backend to run on. If a command fails because the backend lacks that tool, retry on a backend whose description covers it.", + ); } - if (!backendIds.includes(options.defaultBackend)) { - throw new Error( - `createExecTool: defaultBackend ${JSON.stringify(options.defaultBackend)} is not one of ${backendIds.map((id) => JSON.stringify(id)).join(", ")}`, - ); + if (callableBackendIds.size > 0) { + shape.input = jsonValueSchema + .optional() + .describe( + single + ? "Structured value handed to the module." + : "Structured value handed to a callable backend's module. Other backends reject it.", + ); } + // SAFETY: Every field in `shape` has the type ExecInput gives it, and the fields left out are optional there. + const inputSchema = z.object(shape) as unknown as z.ZodType; - const isCallable = options.workspace.runtime.isCallable?.bind(options.workspace.runtime); - const callableBackendIds = new Set(backendIds.filter((id) => isCallable?.(id) === true)); - const backendGuidance = backendIds - .map((id) => { - const suffix = callableBackendIds.has(id) ? " (callable)" : ""; - return `- ${JSON.stringify(id)}${suffix}: ${options.backends[id].description}`; - }) - .join("\n"); - const callableGuidance = - callableBackendIds.size > 0 - ? [ - "", - `Callable backends (${[...callableBackendIds].map((id) => JSON.stringify(id)).join(", ")}) run \`command\` as module source rather than a shell command. Pass \`input\` to hand the module a structured value, and read the module's returned value back from the \`result\` field. Other backends reject \`input\`.`, - ].join("\n") - : ""; - const description = [ - "Run a shell command in the workspace. The workspace exposes multiple backends, each with different capabilities.", - "Pick the cheapest backend that can run the command; fall back to a heavier one only when the lighter backend's command set doesn't cover what you need.", - "", - "Backends:", - backendGuidance, - "", - `Default backend: ${JSON.stringify(options.defaultBackend)}. Try this first for any command you're not sure about; if it fails with a "command not found" or a similar capability error, retry on a backend whose description covers the missing tool.`, - "Use for builds, test runs, typechecks, formatters, and git plumbing. Prefer the dedicated read, write, and edit tools for file operations. Long output is truncated to keep tool replies small.", - callableGuidance, - ].join("\n"); - - const backendSchema = z - .enum(backendIds as [string, ...string[]]) - .optional() - .describe( - [ - "Which backend to run on. Omit to use the default", - `(${JSON.stringify(options.defaultBackend)}). Set explicitly when the`, - "default backend is not capable of running the command. If a command fails because the backend lacks that tool, retry on a backend whose description covers it.", - ].join(" "), - ); - - return tool({ + return { description, - inputSchema: z.object({ - command: z - .string() - .describe( - "Shell command, e.g. 'npm test' or 'git diff HEAD'. For a callable backend this is the module source to run.", - ), - cwd: z.string().optional().describe("Working directory. Defaults to the workspace root."), - backend: backendSchema, - env: z - .record(z.string(), z.string()) - .optional() - .describe( - "Environment variables for this run only. Values override the backend's base environment without affecting later runs.", - ), - input: jsonValueSchema - .optional() - .describe( - "Structured value handed to a callable backend's module. Only callable backends accept it; other backends reject it.", - ), - }), - execute: async function* ({ command, cwd, backend, env, input }, { abortSignal }) { - const selectedBackend = backend ?? options.defaultBackend; + inputSchema, + execute: async function* ({ command, cwd, backend, env, input }, { abortSignal } = {}) { + // With one backend there is nothing to choose. With several the + // schema requires `backend`; a caller that skips the schema gets + // the same answer as an error. + const selectedBackend = single ? first.id : backend; + if (selectedBackend === undefined) { + yield { command, cwd: cwd ?? null, backend: "", error: "Name a backend to run on." }; + return; + } const base = { command, cwd: cwd ?? null, backend: selectedBackend }; if (input !== undefined && !callableBackendIds.has(selectedBackend)) { yield { ...base, error: notCallableMessage(selectedBackend) }; @@ -285,8 +308,8 @@ export function createExecTool(options: ExecToolOptions): Tool< yield { ...base, exitCode: result.exitCode, - stdout: truncate(result.stdout, maxBytes), - stderr: truncate(result.stderr, maxBytes), + stdout: truncateText(result.stdout, maxBytes), + stderr: truncateText(result.stderr, maxBytes), ...(result.value === undefined ? {} : { result: result.value }), }; } catch (err) { @@ -294,7 +317,90 @@ export function createExecTool(options: ExecToolOptions): Tool< } } }, - }); + }; +} + +const FILE_TOOLS_HINT = + "Prefer the dedicated read, write, and edit tools for file operations. Long output is truncated to keep tool replies small."; +const SHELL_HINT = "Use for builds, test runs, typechecks, formatters, and git plumbing."; +const CALLABLE_HINT = + "Pass `input` to hand the module a structured value, and read its return value back from the `result` field."; + +interface DescribedBackend { + readonly id: string; + readonly text: string; + readonly callable: boolean; +} + +// With one backend the description is about what it does. With several +// it lists them and explains how to choose. +function describeTool(backends: readonly DescribedBackend[]): string { + const [only, ...others] = backends; + if (only !== undefined && others.length === 0) { + return only.callable + ? ["Run code in the workspace.", "", only.text, "", CALLABLE_HINT, FILE_TOOLS_HINT].join("\n") + : [ + "Run a shell command in the workspace.", + "", + only.text, + "", + `${SHELL_HINT} ${FILE_TOOLS_HINT}`, + ].join("\n"); + } + const callable = backends.filter((backend) => backend.callable).map((b) => JSON.stringify(b.id)); + return [ + "Run a shell command in the workspace. The workspace exposes multiple backends, each with different capabilities.", + "Pick the cheapest backend that can run the command; fall back to a heavier one only when the lighter backend's command set doesn't cover what you need.", + "", + "Backends:", + ...backends.map( + (b) => `- ${JSON.stringify(b.id)}${b.callable ? " (callable)" : ""}: ${b.text}`, + ), + "", + 'Name a backend on every call. If a command fails with a "command not found" or a similar capability error, retry on a backend whose description covers the missing tool.', + `${SHELL_HINT} ${FILE_TOOLS_HINT}`, + ...(callable.length === 0 + ? [] + : [ + "", + `Callable backends (${callable.join(", ")}) run \`command\` as module source rather than a shell command. ${CALLABLE_HINT} Other backends reject \`input\`.`, + ]), + ].join("\n"); +} + +// Resolve the caller's choice to a list of backends. +function selectBackends( + backends: ExecBackends | undefined, + registered: readonly WorkspaceBackendInfo[] | undefined, +): Array<{ id: string; guidance: string | undefined }> { + const known = registered?.map((backend) => backend.id); + let selected: Array<{ id: string; guidance: string | undefined }>; + if (backends === undefined) { + if (known === undefined) { + throw new Error("createExecTool: pass `backends`; this workspace cannot list its backends"); + } + selected = known.map((id) => ({ id, guidance: undefined })); + } else { + selected = Object.entries(backends).map(([id, backend]) => ({ + id, + guidance: backend.description, + })); + } + const unknown = known === undefined ? [] : selected.filter((b) => !known.includes(b.id)); + if (unknown.length > 0) { + throw new Error( + `createExecTool: unknown backend ${unknown.map((b) => JSON.stringify(b.id)).join(", ")}; the workspace has ${known?.map((id) => JSON.stringify(id)).join(", ") || "none"}`, + ); + } + return selected; +} + +function commandHint(backends: readonly DescribedBackend[]): string { + if (backends.every((backend) => backend.callable)) return "Module source to run."; + if (backends.every((backend) => !backend.callable)) { + return "Shell command, e.g. 'npm test' or 'git diff HEAD'."; + } + return "Shell command, e.g. 'npm test' or 'git diff HEAD'. For a callable backend this is the module source to run."; } function errorMessage(err: unknown): string { @@ -329,47 +435,16 @@ class StreamBuffer { } // The chunk crosses the cap: keep the largest whole-character // prefix that fits, then stop growing the head. - let used = this.#headBytes; - let end = 0; - for (const char of chunk) { - const charBytes = encoder.encode(char).byteLength; - if (used + charBytes > this.#cap) break; - used += charBytes; - end += char.length; - } - this.#head += chunk.slice(0, end); - this.#headBytes = used; + const prefix = utf8Prefix(chunk, this.#cap - this.#headBytes); + this.#head += prefix.text; + this.#headBytes += prefix.bytes; } render(maxBytes: number): string { if (this.#totalBytes <= maxBytes && this.#totalBytes === this.#headBytes) { return this.#head; } - let used = 0; - let end = 0; - for (const char of this.#head) { - const charBytes = encoder.encode(char).byteLength; - if (used + charBytes > maxBytes) break; - used += charBytes; - end += char.length; - } - return `${this.#head.slice(0, end)}\n\n[truncated, ${this.#totalBytes - used} more bytes]`; + const shown = utf8Prefix(this.#head, maxBytes); + return `${shown.text}\n\n[truncated, ${this.#totalBytes - shown.bytes} more bytes]`; } } - -function truncate(value: string, maxBytes: number): string { - if (!value) return value; - const totalBytes = encoder.encode(value).byteLength; - if (totalBytes <= maxBytes) return value; - - let usedBytes = 0; - let endOffset = 0; - for (const char of value) { - const charBytes = encoder.encode(char).byteLength; - if (usedBytes + charBytes > maxBytes) break; - usedBytes += charBytes; - endOffset += char.length; - } - - return `${value.slice(0, endOffset)}\n\n[truncated, ${totalBytes - usedBytes} more bytes]`; -} diff --git a/packages/computer/src/tools/fs/delete.test.ts b/packages/computer/src/tools/common/fs/delete.test.ts similarity index 100% rename from packages/computer/src/tools/fs/delete.test.ts rename to packages/computer/src/tools/common/fs/delete.test.ts diff --git a/packages/computer/src/tools/fs/delete.ts b/packages/computer/src/tools/common/fs/delete.ts similarity index 63% rename from packages/computer/src/tools/fs/delete.ts rename to packages/computer/src/tools/common/fs/delete.ts index 41cf6315..365cfdc5 100644 --- a/packages/computer/src/tools/fs/delete.ts +++ b/packages/computer/src/tools/common/fs/delete.ts @@ -1,4 +1,3 @@ -import { type Tool, tool } from "ai"; import { z } from "zod"; import { withFileLock } from "./locks.js"; import type { MutableFileStore } from "./types.js"; @@ -7,7 +6,7 @@ export interface DeleteToolOptions { store: MutableFileStore; } -const inputSchema = z.object({ +export const deleteInputSchema = z.object({ path: z.string().describe("Absolute path to the file or directory to delete."), recursive: z .boolean() @@ -15,6 +14,22 @@ const inputSchema = z.object({ .describe("Remove a directory and all of its contents. Defaults to false."), }); +/** + * Shape of the result. + * + * A failure is an ordinary outcome for a filesystem tool, not a + * violation, so the error branch belongs in the schema. An SDK that + * validates a tool return against this would otherwise replace the + * real reason with a schema complaint. + */ +export const deleteOutputSchema = z.union([ + z.object({ deleted: z.string() }), + z.object({ error: z.string() }), +]); + +export const deleteDescription = + "Delete a file or directory. Set recursive to true to remove a non-empty directory."; + export interface DeleteInput { path: string; recursive?: boolean; @@ -38,12 +53,3 @@ export function deleteFromStore( { subtree: recursive === true }, ); } - -export function createDeleteTool(options: DeleteToolOptions): Tool> { - return tool({ - description: - "Delete a file or directory. Set recursive to true to remove a non-empty directory.", - inputSchema, - execute: (input) => deleteFromStore(options, input), - }); -} diff --git a/packages/computer/src/tools/fs/edit-diff.test.ts b/packages/computer/src/tools/common/fs/edit-diff.test.ts similarity index 100% rename from packages/computer/src/tools/fs/edit-diff.test.ts rename to packages/computer/src/tools/common/fs/edit-diff.test.ts diff --git a/packages/computer/src/tools/fs/edit-diff.ts b/packages/computer/src/tools/common/fs/edit-diff.ts similarity index 100% rename from packages/computer/src/tools/fs/edit-diff.ts rename to packages/computer/src/tools/common/fs/edit-diff.ts diff --git a/packages/computer/src/tools/common/fs/edit.ts b/packages/computer/src/tools/common/fs/edit.ts new file mode 100644 index 00000000..ffd00f6f --- /dev/null +++ b/packages/computer/src/tools/common/fs/edit.ts @@ -0,0 +1,178 @@ +import { z } from "zod"; +import { + applyEditsToNormalizedContent, + detectLineEnding, + type Edit, + generateDiffString, + generateUnifiedPatch, + normalizeToLF, + restoreLineEndings, + stripBom, +} from "./edit-diff.js"; +import { withFileLock } from "./locks.js"; +import type { FileStore } from "./types.js"; + +export interface EditToolOptions { + store: FileStore; + /** + * Reject edits to files larger than this byte cap. Fuzzy matching needs the + * whole buffer in memory, so we'd rather force the model to use `write`. + * Default 2 MiB. + */ + maxBytes?: number; +} + +const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; + +const replacementSchema = z + .object({ + oldText: z + .string() + .describe( + "Exact text for one targeted replacement. Must be unique in the original file and not overlap with any other edits[].oldText in the same call.", + ), + newText: z.string().describe("Replacement text for this targeted edit."), + }) + .strict(); + +export const editInputSchema = z.object({ + path: z.string().describe("Path to the file to edit"), + edits: z + .array(replacementSchema) + .describe( + "One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits.", + ), +}); + +/** + * Shape of the result. + * + * A failure is an ordinary outcome for a filesystem tool, not a + * violation, so the error branch belongs in the schema. An SDK that + * validates a tool return against this would otherwise replace the + * real reason with a schema complaint. + */ +export const editOutputSchema = z.union([ + z.object({ + path: z.string(), + editsApplied: z.number().int(), + diff: z.string(), + patch: z.string(), + firstChangedLine: z.number().int().optional(), + }), + z.object({ error: z.string() }), +]); + +export const editDescription = + "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes touch the same block, merge them into one edit."; + +export interface EditInput { + path: string; + edits: Edit[]; +} + +export interface EditSuccess { + path: string; + editsApplied: number; + diff: string; + patch: string; + /** Undefined when the edit produced no line-level change. */ + firstChangedLine: number | undefined; +} + +export type EditResult = EditSuccess | { error: string }; + +/** Best-effort coercion for inputs from quirky models. */ +function prepareArguments(input: unknown): { path: string; edits: Edit[] } { + if (!input || typeof input !== "object") return input as { path: string; edits: Edit[] }; + const args = input as Record; + + // Some models pack edits into a JSON string. + if (typeof args.edits === "string") { + try { + const parsed = JSON.parse(args.edits); + if (Array.isArray(parsed)) args.edits = parsed; + } catch { + /* fall through to validation error */ + } + } + + // Legacy single-edit shape: oldText/newText siblings on the root object. + if (typeof args.oldText === "string" && typeof args.newText === "string") { + const edits = Array.isArray(args.edits) ? [...(args.edits as Edit[])] : []; + edits.push({ oldText: args.oldText as string, newText: args.newText as string }); + args.edits = edits; + delete args.oldText; + delete args.newText; + } + + return args as { path: string; edits: Edit[] }; +} + +/** + * Apply a batch of targeted replacements to one file. + * + * Takes the raw tool input because the coercion in `prepareArguments` + * has to run before validation: models sometimes pack `edits` into a + * JSON string or send a single `oldText`/`newText` pair at the root. + */ +export async function editInStore( + options: EditToolOptions, + rawInput: unknown, +): Promise { + const { store } = options; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + const { path, edits } = prepareArguments(rawInput); + + if (!Array.isArray(edits) || edits.length === 0) { + return { error: "edits must contain at least one replacement." }; + } + + return withFileLock(store, path, async () => { + try { + const stat = await store.stat(path); + if (!stat) return { error: `File not found: ${path}` }; + if (stat.size > maxBytes) { + return { + error: `File too large to edit: ${stat.size} bytes exceeds the ${maxBytes}-byte cap. Use the write tool to rewrite the file from scratch.`, + }; + } + + const bytes = await store.readAll(path); + if (!bytes) return { error: `File not found: ${path}` }; + + const rawContent = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true }).decode(bytes); + const { bom, text } = stripBom(rawContent); + const ending = detectLineEnding(text); + const normalized = normalizeToLF(text); + + let baseContent: string; + let newContent: string; + try { + ({ baseContent, newContent } = applyEditsToNormalizedContent(normalized, edits, path)); + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + + const finalContent = bom + restoreLineEndings(newContent, ending); + // Round-trip the file's mode so editing an executable script (or any + // file with a non-default mode) doesn't silently drop bits. `stat.mode` + // is undefined for stores that don't track modes; pass `undefined` in + // that case so the store applies its own default. + await store.write(path, new TextEncoder().encode(finalContent), { mode: stat.mode }); + + const diffResult = generateDiffString(baseContent, newContent); + const patch = generateUnifiedPatch(path, baseContent, newContent); + + return { + path, + editsApplied: edits.length, + diff: diffResult.diff, + patch, + firstChangedLine: diffResult.firstChangedLine, + }; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + }); +} diff --git a/packages/computer/src/tools/common/fs/find.ts b/packages/computer/src/tools/common/fs/find.ts new file mode 100644 index 00000000..6cae445a --- /dev/null +++ b/packages/computer/src/tools/common/fs/find.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; + +interface FoundEntry { + path: string; + type: "file" | "dir"; +} + +export interface FindWorkspaceLike { + fs: { + find( + directory: string, + pattern?: string, + options?: { limit?: number; offset?: number; exclude?: string[] }, + ): Promise; + }; +} + +export interface FindToolOptions { + workspace: FindWorkspaceLike; +} + +const DEFAULT_LIMIT = 200; +const MAX_LIMIT = 1000; + +export const findInputSchema = z.object({ + path: z.string().default("/workspace").describe("Absolute directory to search."), + pattern: z + .string() + .describe('Glob pattern relative to path, for example "**/*.ts" or "src/?.js".'), + exclude: z + .array(z.string()) + .optional() + .describe( + 'Glob patterns to leave out, for example ["node_modules/**", "**/.git/**"]. An excluded directory is skipped along with everything below it.', + ), + limit: z.number().int().min(1).max(MAX_LIMIT).optional(), + offset: z.number().int().min(0).optional(), +}); + +export const findDescription = + "Find files and directories matching a glob. * stays within one path segment, ** crosses directories, and ? matches one character."; + +export interface FindInput { + path?: string; + pattern: string; + exclude?: string[]; + limit?: number; + offset?: number; +} + +export type FindResult = + | { + path: string; + pattern: string; + count: number; + entries: FoundEntry[]; + nextOffset?: number; + } + | { error: string }; + +/** + * Page glob matches under a directory. + * + * `path` carries a schema default, but an executor can also be called + * directly by an SDK that does not apply Zod defaults, so the root + * fallback is repeated here. + */ +export async function findInWorkspace( + workspace: FindWorkspaceLike, + { path, pattern, exclude, limit, offset }: FindInput, +): Promise { + const directory = path ?? "/workspace"; + try { + const pageSize = limit ?? DEFAULT_LIMIT; + const pageOffset = offset ?? 0; + const matches = await workspace.fs.find(directory, pattern, { + limit: pageSize + 1, + offset: pageOffset, + exclude, + }); + const truncated = matches.length > pageSize; + const entries = truncated ? matches.slice(0, pageSize) : matches; + const result: { + path: string; + pattern: string; + count: number; + entries: FoundEntry[]; + nextOffset?: number; + } = { path: directory, pattern, count: entries.length, entries }; + if (truncated) result.nextOffset = pageOffset + pageSize; + return result; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} diff --git a/packages/computer/src/tools/common/fs/grep.ts b/packages/computer/src/tools/common/fs/grep.ts new file mode 100644 index 00000000..6f3f6cd9 --- /dev/null +++ b/packages/computer/src/tools/common/fs/grep.ts @@ -0,0 +1,125 @@ +import { z } from "zod"; + +interface GrepContextLine { + line: number; + text: string; + isMatch: boolean; +} + +interface GrepMatch { + path: string; + line: number; + text: string; + context?: GrepContextLine[]; +} + +interface GrepOptions { + regex?: boolean; + ignoreCase?: boolean; + context?: number; + limit?: number; + offset?: number; + include?: string; + exclude?: string[]; +} + +export interface GrepWorkspaceLike { + fs: { + grep(pattern: string, path: string, options?: GrepOptions): Promise; + }; +} + +export interface GrepToolOptions { + workspace: GrepWorkspaceLike; +} + +const DEFAULT_LIMIT = 200; +const MAX_LIMIT = 1000; + +export const grepInputSchema = z.object({ + path: z.string().default("/workspace").describe("Absolute file or directory to search."), + query: z.string().describe("Literal string or regular expression to search for."), + include: z + .string() + .optional() + .describe('Glob relative to path that limits searched files, for example "**/*.ts".'), + exclude: z + .array(z.string()) + .optional() + .describe( + 'Glob patterns to leave out, for example ["node_modules/**", "**/.git/**"]. An excluded directory is skipped along with everything below it.', + ), + regex: z.boolean().optional().describe("Interpret query as a regular expression."), + ignoreCase: z.boolean().optional().describe("Ignore letter case."), + context: z.number().int().min(0).max(10).optional(), + limit: z.number().int().min(1).max(MAX_LIMIT).optional(), + offset: z.number().int().min(0).optional(), +}); + +export const grepDescription = + "Search workspace text with a literal string or regular expression. Results include paths and line numbers and can include surrounding lines."; + +export interface GrepInput { + path?: string; + query: string; + include?: string; + exclude?: string[]; + regex?: boolean; + ignoreCase?: boolean; + context?: number; + limit?: number; + offset?: number; +} + +export type GrepResult = + | { + path: string; + query: string; + count: number; + matches: GrepMatch[]; + nextOffset?: number; + } + | { error: string }; + +/** + * Page matches for one query. + * + * Matching is literal and case-sensitive unless the caller opts into + * `regex` or `ignoreCase`, which keeps a model's plain-string query from + * being reinterpreted as a pattern. + */ +export async function grepInWorkspace( + workspace: GrepWorkspaceLike, + { path, query, include, exclude, regex, ignoreCase, context, limit, offset }: GrepInput, +): Promise { + const target = path ?? "/workspace"; + try { + const pageSize = limit ?? DEFAULT_LIMIT; + const pageOffset = offset ?? 0; + const searchOptions = { + regex: regex ?? false, + ignoreCase: ignoreCase ?? false, + context: context ?? 0, + }; + const matches = await workspace.fs.grep(query, target, { + ...searchOptions, + include, + exclude, + limit: pageSize + 1, + offset: pageOffset, + }); + const truncated = matches.length > pageSize; + const page = truncated ? matches.slice(0, pageSize) : matches; + const result: { + path: string; + query: string; + count: number; + matches: GrepMatch[]; + nextOffset?: number; + } = { path: target, query, count: page.length, matches: page }; + if (truncated) result.nextOffset = pageOffset + pageSize; + return result; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} diff --git a/packages/computer/src/tools/common/fs/list.ts b/packages/computer/src/tools/common/fs/list.ts new file mode 100644 index 00000000..b028a321 --- /dev/null +++ b/packages/computer/src/tools/common/fs/list.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; + +export interface ListWorkspaceLike { + fs: { + readdir( + path: string, + options?: { limit?: number; offset?: number }, + ): Promise< + Array<{ + name: string; + size: number; + mtime: number; + isFile: boolean; + isDirectory: boolean; + isSymbolicLink: boolean; + }> + >; + }; +} + +export interface ListToolOptions { + workspace: ListWorkspaceLike; +} + +const DEFAULT_LIMIT = 200; +const MAX_LIMIT = 1000; + +export const listInputSchema = z.object({ + path: z.string().describe("Absolute directory path to list, e.g. /workspace/src."), + limit: z + .number() + .int() + .min(1) + .max(MAX_LIMIT) + .optional() + .describe(`Maximum entries to return. Defaults to ${DEFAULT_LIMIT}.`), + offset: z.number().int().min(0).optional().describe("Number of entries to skip in name order."), +}); + +export const listDescription = `List entries in a workspace directory with file sizes and modification times. The result defaults to ${DEFAULT_LIMIT} entries; use limit and offset to page through large directories.`; + +export interface ListInput { + path: string; + limit?: number; + offset?: number; +} + +interface ListEntry { + name: string; + size: number; + mtime: number; + isFile: boolean; + isDirectory: boolean; + isSymbolicLink: boolean; +} + +export type ListResult = + | { path: string; count: number; entries: ListEntry[]; nextOffset?: number } + | { error: string }; + +/** + * Page one directory. + * + * Reads one more entry than the page size to learn whether a further + * page exists without a second call, then reports `nextOffset` when it + * does. + */ +export async function listWorkspace( + workspace: ListWorkspaceLike, + { path, limit, offset }: ListInput, +): Promise { + try { + const pageSize = limit ?? DEFAULT_LIMIT; + const pageOffset = offset ?? 0; + const entries = await workspace.fs.readdir(path, { + limit: pageSize + 1, + offset: pageOffset, + }); + const truncated = entries.length > pageSize; + const page = (truncated ? entries.slice(0, pageSize) : entries).map((entry) => ({ + name: entry.name, + size: entry.size, + mtime: entry.mtime, + isFile: entry.isFile, + isDirectory: entry.isDirectory, + isSymbolicLink: entry.isSymbolicLink, + })); + const result: { + path: string; + count: number; + entries: typeof page; + nextOffset?: number; + } = { + path, + count: page.length, + entries: page, + }; + if (truncated) result.nextOffset = pageOffset + pageSize; + return result; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/packages/computer/src/tools/fs/locks.test.ts b/packages/computer/src/tools/common/fs/locks.test.ts similarity index 100% rename from packages/computer/src/tools/fs/locks.test.ts rename to packages/computer/src/tools/common/fs/locks.test.ts diff --git a/packages/computer/src/tools/fs/locks.ts b/packages/computer/src/tools/common/fs/locks.ts similarity index 100% rename from packages/computer/src/tools/fs/locks.ts rename to packages/computer/src/tools/common/fs/locks.ts diff --git a/packages/computer/src/tools/fs/media.test.ts b/packages/computer/src/tools/common/fs/media.test.ts similarity index 100% rename from packages/computer/src/tools/fs/media.test.ts rename to packages/computer/src/tools/common/fs/media.test.ts diff --git a/packages/computer/src/tools/fs/media.ts b/packages/computer/src/tools/common/fs/media.ts similarity index 100% rename from packages/computer/src/tools/fs/media.ts rename to packages/computer/src/tools/common/fs/media.ts diff --git a/packages/computer/src/tools/fs/read.test.ts b/packages/computer/src/tools/common/fs/read.test.ts similarity index 100% rename from packages/computer/src/tools/fs/read.test.ts rename to packages/computer/src/tools/common/fs/read.test.ts diff --git a/packages/computer/src/tools/fs/read.ts b/packages/computer/src/tools/common/fs/read.ts similarity index 82% rename from packages/computer/src/tools/fs/read.ts rename to packages/computer/src/tools/common/fs/read.ts index f24cff01..e5755ae2 100644 --- a/packages/computer/src/tools/fs/read.ts +++ b/packages/computer/src/tools/common/fs/read.ts @@ -1,5 +1,5 @@ -import { type JSONValue, type Tool, tool } from "ai"; import { z } from "zod"; +import type { ModelOutput } from "../model-output.js"; import { detectMedia } from "./media.js"; import type { FileStore } from "./types.js"; @@ -27,7 +27,7 @@ const DEFAULT_MAX_MODEL_BYTES = 3.5 * 1024 * 1024; const DEFAULT_MEDIA_SNIFF_BYTES = 512; const TRUNCATION_MARKER = "... (truncated)"; -const inputSchema = z +export const readInputSchema = z .object({ path: z.string().describe("Path to the file to read"), offset: z @@ -84,7 +84,7 @@ interface MediaReadResult { unsupported?: true; } -type ReadToolResult = ReadResult | MediaReadResult | { error: string }; +export type ReadToolResult = ReadResult | MediaReadResult | { error: string }; const encoder = new TextEncoder(); const decoder = new TextDecoder("utf-8", { fatal: false }); @@ -93,7 +93,7 @@ function utf8ByteLength(value: string): number { return encoder.encode(value).length; } -function createReadExecutor( +export function createReadExecutor( options: ReadToolOptions, ): (input: ReadInput) => Promise { const { store } = options; @@ -303,58 +303,73 @@ export function readFromStore(options: ReadToolOptions, input: ReadInput): Promi return createReadExecutor(options)(input); } -export function createReadTool(options: ReadToolOptions): Tool> { +/** + * The model-facing description, which quotes the configured caps so the + * model can plan continuations instead of discovering the limit by + * hitting it. + */ +export function readDescription(options: ReadToolOptions): string { const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + return `Read a workspace file. Images and PDFs are passed to capable models. Text output is capped at ${maxLines} lines or ${Math.round(maxBytes / 1024)}KB and includes line and byte continuations when truncated.`; +} + +/** + * Build the SDK-neutral model representation for a read result. + * + * A complete, unpositioned text read is returned as bare text because + * that is what the model actually wants to see. Truncated, empty, and + * explicitly positioned reads keep their JSON envelope so the + * continuation offsets survive. Eligible images and PDFs become a + * `media` output carrying the bytes captured during execution, so + * regenerating prompt history cannot observe a later version of the + * file. + */ +export function readModelOutput( + options: ReadToolOptions, +): (args: { input: ReadInput; output: ReadToolResult }) => ModelOutput { const maxModelBytes = validateBoundedReadLimit( "maxModelBytes", options.maxModelBytes ?? DEFAULT_MAX_MODEL_BYTES, ); - return tool({ - description: `Read a workspace file. Images and PDFs are passed to capable models. Text output is capped at ${maxLines} lines or ${Math.round(maxBytes / 1024)}KB and includes line and byte continuations when truncated.`, - inputSchema, - execute: createReadExecutor(options), - toModelOutput: async ({ input, output }: { input: unknown; output: unknown }) => { - if (!isRecord(output)) return { type: "text", value: String(output) }; - if (typeof output.error === "string") { - return { type: "error-text", value: output.error }; - } - if (typeof output.content === "string") { - const positioned = - isReadInput(input) && (input.offset !== undefined || input.byteOffset !== undefined); - return output.truncated === true || output.content.length === 0 || positioned - ? { type: "json", value: toJSONValue(output) } - : { type: "text", value: output.content }; - } - if (output.kind === "binary") return { type: "json", value: toJSONValue(output) }; - if (!isMediaReadResult(output)) return { type: "json", value: toJSONValue(output) }; - if (output.sizeBytes > maxModelBytes) { - return inlineMediaLimitError(output, output.sizeBytes, maxModelBytes); - } - if (output.data === undefined) { - return { type: "error-text", value: `Could not read captured file bytes: ${output.path}` }; - } - if (output.data.length === 0) { - return { type: "error-text", value: `Cannot attach empty file: ${output.path}` }; - } - return { - type: "content", - value: [ - { - type: "text", - text: `Read ${output.path} (${output.mediaType}, ${output.sizeBytes} bytes).`, - }, - { - type: "file", - data: { type: "data", data: output.data }, - mediaType: output.mediaType, - filename: output.name, - }, - ], - }; - }, - }); + return ({ input, output: settled }) => { + // Inspect the result as an open record. The union's members are + // distinguished by which fields are present rather than by a tag, + // so narrowing field-by-field is clearer than reconstructing the + // discriminator, and every branch below re-establishes the shape it + // needs before using it. + const output: Record = settled as unknown as Record; + if (!isRecord(output)) return { type: "text", value: String(output) }; + if (typeof output.error === "string") { + return { type: "error-text", value: output.error }; + } + if (typeof output.content === "string") { + const positioned = + isReadInput(input) && (input.offset !== undefined || input.byteOffset !== undefined); + return output.truncated === true || output.content.length === 0 || positioned + ? { type: "json", value: output } + : { type: "text", value: output.content }; + } + if (output.kind === "binary") return { type: "json", value: output }; + if (!isMediaReadResult(output)) return { type: "json", value: output }; + if (output.sizeBytes > maxModelBytes) { + return inlineMediaLimitError(output, output.sizeBytes, maxModelBytes); + } + if (output.data === undefined) { + return { type: "error-text", value: `Could not read captured file bytes: ${output.path}` }; + } + if (output.data.length === 0) { + return { type: "error-text", value: `Cannot attach empty file: ${output.path}` }; + } + return { + type: "media", + text: `Read ${output.path} (${output.mediaType}, ${output.sizeBytes} bytes).`, + data: output.data, + mediaType: output.mediaType, + filename: output.name, + }; + }; } function validateBoundedReadLimit(name: string, value: number): number { @@ -460,15 +475,6 @@ function inlineMediaLimitError( }; } -function toJSONValue(value: unknown): JSONValue { - try { - const json = JSON.stringify(value); - return json === undefined ? null : (JSON.parse(json) as JSONValue); - } catch { - return String(value); - } -} - function isReadInput( value: unknown, ): value is { path: string; offset?: number; byteOffset?: number } { diff --git a/packages/computer/src/tools/fs/store.ts b/packages/computer/src/tools/common/fs/store.ts similarity index 100% rename from packages/computer/src/tools/fs/store.ts rename to packages/computer/src/tools/common/fs/store.ts diff --git a/packages/computer/src/tools/fs/types.ts b/packages/computer/src/tools/common/fs/types.ts similarity index 100% rename from packages/computer/src/tools/fs/types.ts rename to packages/computer/src/tools/common/fs/types.ts diff --git a/packages/computer/src/tools/fs/write.test.ts b/packages/computer/src/tools/common/fs/write.test.ts similarity index 100% rename from packages/computer/src/tools/fs/write.test.ts rename to packages/computer/src/tools/common/fs/write.test.ts diff --git a/packages/computer/src/tools/fs/write.ts b/packages/computer/src/tools/common/fs/write.ts similarity index 74% rename from packages/computer/src/tools/fs/write.ts rename to packages/computer/src/tools/common/fs/write.ts index 89d3d479..907fcfd5 100644 --- a/packages/computer/src/tools/fs/write.ts +++ b/packages/computer/src/tools/common/fs/write.ts @@ -1,4 +1,3 @@ -import { type Tool, tool } from "ai"; import { z } from "zod"; import { withFileLock } from "./locks.js"; import type { FileStore } from "./types.js"; @@ -14,11 +13,27 @@ export interface WriteToolOptions { const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; -const inputSchema = z.object({ +export const writeInputSchema = z.object({ path: z.string().describe("Absolute path, e.g. /workspace/main.zig"), content: z.string().describe("File content"), }); +/** + * Shape of the result. + * + * A failure is an ordinary outcome for a filesystem tool, not a + * violation, so the error branch belongs in the schema. An SDK that + * validates a tool return against this would otherwise replace the + * real reason with a schema complaint. + */ +export const writeOutputSchema = z.union([ + z.object({ path: z.string(), bytesWritten: z.number().int() }), + z.object({ error: z.string() }), +]); + +export const writeDescription = + "Write content to a file. Overwrites any existing file at the path."; + export interface WriteInput { path: string; content: string; @@ -48,11 +63,3 @@ export async function writeToStore( } }); } - -export function createWriteTool(options: WriteToolOptions): Tool> { - return tool({ - description: "Write content to a file. Overwrites any existing file at the path.", - inputSchema, - execute: (input) => writeToStore(options, input), - }); -} diff --git a/packages/computer/src/tools/common/model-output.ts b/packages/computer/src/tools/common/model-output.ts new file mode 100644 index 00000000..cbf20c06 --- /dev/null +++ b/packages/computer/src/tools/common/model-output.ts @@ -0,0 +1,22 @@ +/** + * A model-facing representation of a tool result, in terms no agent + * library owns. Each provider lowers it onto its own library's shape, + * degrading to text where there is no equivalent. + */ +export type ModelOutput = + | { type: "text"; value: string } + | { type: "error-text"; value: string } + | { type: "json"; value: unknown } + /** `data` is base64: how the read tool captures bytes, and what pi and TanStack want on the wire. */ + | { type: "media"; text: string; data: string; mediaType: string; filename?: string }; + +export function defaultModelOutput(output: unknown): ModelOutput { + if ( + typeof output === "object" && + output !== null && + typeof (output as { error?: unknown }).error === "string" + ) { + return { type: "error-text", value: (output as { error: string }).error }; + } + return { type: "json", value: output }; +} diff --git a/packages/computer/src/tools/common/options.ts b/packages/computer/src/tools/common/options.ts new file mode 100644 index 00000000..27b4e510 --- /dev/null +++ b/packages/computer/src/tools/common/options.ts @@ -0,0 +1,86 @@ +import type { ExecBackends, ExecToolOptions, ExecWorkspaceLike } from "./exec.js"; +import type { EditToolOptions } from "./fs/edit.js"; +import type { ReadToolOptions } from "./fs/read.js"; +import { type WorkspaceLike as FileWorkspaceLike, WorkspaceFileStore } from "./fs/store.js"; +import type { WriteToolOptions } from "./fs/write.js"; +import type { PublishWorkspaceLike } from "./publish.js"; + +/** Options every tool set takes: `createAITools`, `createPiTools`, and `createTanStackTools`. */ +export interface CreateToolsOptions { + workspace: FileWorkspaceLike & Partial & Partial; + /** Omit `write`, `edit`, `delete`, `exec`, and `publish`. */ + readonly?: boolean; + /** Set `false` to omit `publish` even when assets are configured. */ + assets?: boolean; + read?: Omit; + write?: Omit; + edit?: Omit; + /** + * The backends `exec` may run on, keyed by id, each with an optional + * description for the model. Omit to offer every backend the + * Workspace has; `{}` means no exec tool. + */ + exec?: ExecBackends; + /** + * @deprecated Use `exec`. `{ backends }` becomes `exec: backends`; + * `defaultBackend` is ignored, because the model names a backend + * whenever there is a choice. Output limits move to `createExecTool`. + */ + shell?: LegacyShellOptions; +} + +interface LegacyShellOptions extends Omit { + backends: ExecBackends; + defaultBackend?: string; +} + +export interface ResolvedToolOptions { + read: ReadToolOptions; + write: WriteToolOptions; + edit: EditToolOptions; + delete: { store: WorkspaceFileStore }; + /** Absent when the set is read-only, the Workspace has no runtime, or no backend is selected. */ + exec?: ExecToolOptions; + publish: boolean; + readonly: boolean; + workspace: CreateToolsOptions["workspace"]; +} + +/** Resolve the options into what each tool needs, so every tool set offers the same tools. */ +export function resolveToolOptions(options: CreateToolsOptions): ResolvedToolOptions { + const store = new WorkspaceFileStore(options.workspace); + const readonly = options.readonly === true; + return { + read: { store, ...options.read }, + write: { store, ...options.write }, + edit: { store, ...options.edit }, + delete: { store }, + exec: readonly ? undefined : execOptions(options), + publish: !readonly && options.assets !== false && options.workspace.assets !== undefined, + readonly, + workspace: options.workspace, + }; +} + +// Turn `exec`, or the deprecated `shell`, into exec tool options. +function execOptions(options: CreateToolsOptions): ExecToolOptions | undefined { + const runtime = options.workspace.runtime; + if (runtime === undefined) return undefined; + const exec = selectExec(options, runtime); + if (Object.keys(exec.backends).length === 0) return undefined; + return { workspace: { runtime }, ...exec }; +} + +function selectExec( + options: CreateToolsOptions, + runtime: ExecWorkspaceLike["runtime"], +): Omit & { backends: ExecBackends } { + // `exec` wins over the deprecated `shell`, so `exec: {}` always means + // no exec tool. + if (options.exec !== undefined) return { backends: options.exec }; + if (options.shell !== undefined) { + const { backends, defaultBackend: _ignored, ...limits } = options.shell; + return { ...limits, backends }; + } + return { backends: Object.fromEntries((runtime.backends?.() ?? []).map(({ id }) => [id, {}])) }; +} diff --git a/packages/computer/src/tools/common/publish.ts b/packages/computer/src/tools/common/publish.ts new file mode 100644 index 00000000..bec94112 --- /dev/null +++ b/packages/computer/src/tools/common/publish.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; +import type { AssetsClient } from "../../assets/index.js"; + +export interface PublishWorkspaceLike { + readonly sessionId: string; + readonly assets?: AssetsClient; +} + +export interface PublishToolOptions { + workspace: PublishWorkspaceLike; +} + +const DEFAULT_EXPIRY_MS = 60 * 60 * 1000; + +export const publishInputSchema = z.object({ + path: z.string().min(1).describe("Absolute workspace path, e.g. /workspace/out/chart.png."), + expiresAfterMs: z + .number() + .int() + .positive() + .optional() + .describe("Link lifetime in milliseconds. Defaults to one hour."), +}); + +/** Successful publish carries the link; a failure carries the reason. */ +export const publishOutputSchema = z.union([ + z.object({ ok: z.literal(true), url: z.string() }), + z.object({ ok: z.literal(false), error: z.string() }), +]); + +export const publishDescription = + "Publish a file from the workspace through the configured assets publisher and return a time-limited link. Use this to hand the user an artifact you produced, such as a chart, screenshot, build output, or report."; + +export interface PublishInput { + path: string; + expiresAfterMs?: number; +} + +export type PublishResult = { ok: true; url: string } | { ok: false; error: string }; + +/** + * Bind a publish executor to one workspace. + * + * The assets client is resolved once, at construction, so a workspace + * without a configured publisher fails loudly when the tool is built + * rather than on the model's first call. + */ +export function createPublishExecutor( + workspace: PublishWorkspaceLike, +): (input: PublishInput) => Promise { + const assets = workspace.assets; + if (!assets) { + throw new Error("createPublishTool: workspace.assets is not configured"); + } + + return async ({ path, expiresAfterMs }) => { + try { + const prefix = workspace.sessionId ? `agent-${workspace.sessionId}` : undefined; + const url = await assets.share(path, { + expiresAfter: expiresAfterMs ?? DEFAULT_EXPIRY_MS, + ...(prefix ? { prefix } : {}), + }); + return { ok: true, url }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + }; +} diff --git a/packages/computer/src/tools/common/stream.ts b/packages/computer/src/tools/common/stream.ts new file mode 100644 index 00000000..23a322cf --- /dev/null +++ b/packages/computer/src/tools/common/stream.ts @@ -0,0 +1,29 @@ +/** + * Drain an executor to its settled result. + * + * A streaming executor yields successive complete snapshots of one run + * rather than deltas, so the last one is the whole result. + */ +export async function settle( + returned: Promise | AsyncIterable, +): Promise { + if (isAsyncIterable(returned)) { + let last: Output | undefined; + let seen = false; + for await (const chunk of returned) { + last = chunk; + seen = true; + } + if (!seen) throw new Error("tool executor yielded no result"); + return last as Output; + } + return await returned; +} + +export function isAsyncIterable(value: unknown): value is AsyncIterable { + return ( + typeof value === "object" && + value !== null && + Symbol.asyncIterator in (value as Record) + ); +} diff --git a/packages/computer/src/tools/fs/edit.ts b/packages/computer/src/tools/fs/edit.ts deleted file mode 100644 index ec338b9a..00000000 --- a/packages/computer/src/tools/fs/edit.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { type Tool, tool } from "ai"; -import { z } from "zod"; -import { - applyEditsToNormalizedContent, - detectLineEnding, - type Edit, - generateDiffString, - generateUnifiedPatch, - normalizeToLF, - restoreLineEndings, - stripBom, -} from "./edit-diff.js"; -import { withFileLock } from "./locks.js"; -import type { FileStore } from "./types.js"; - -export interface EditToolOptions { - store: FileStore; - /** - * Reject edits to files larger than this byte cap. Fuzzy matching needs the - * whole buffer in memory, so we'd rather force the model to use `write`. - * Default 2 MiB. - */ - maxBytes?: number; -} - -const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; - -const replacementSchema = z - .object({ - oldText: z - .string() - .describe( - "Exact text for one targeted replacement. Must be unique in the original file and not overlap with any other edits[].oldText in the same call.", - ), - newText: z.string().describe("Replacement text for this targeted edit."), - }) - .strict(); - -const inputSchema = z.object({ - path: z.string().describe("Path to the file to edit"), - edits: z - .array(replacementSchema) - .describe( - "One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits.", - ), -}); - -/** Best-effort coercion for inputs from quirky models. */ -function prepareArguments(input: unknown): { path: string; edits: Edit[] } { - if (!input || typeof input !== "object") return input as { path: string; edits: Edit[] }; - const args = input as Record; - - // Some models pack edits into a JSON string. - if (typeof args.edits === "string") { - try { - const parsed = JSON.parse(args.edits); - if (Array.isArray(parsed)) args.edits = parsed; - } catch { - /* fall through to validation error */ - } - } - - // Legacy single-edit shape: oldText/newText siblings on the root object. - if (typeof args.oldText === "string" && typeof args.newText === "string") { - const edits = Array.isArray(args.edits) ? [...(args.edits as Edit[])] : []; - edits.push({ oldText: args.oldText as string, newText: args.newText as string }); - args.edits = edits; - delete args.oldText; - delete args.newText; - } - - return args as { path: string; edits: Edit[] }; -} - -export function createEditTool(options: EditToolOptions): Tool> { - const { store } = options; - const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; - - return tool({ - description: - "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes touch the same block, merge them into one edit.", - inputSchema, - execute: async (rawInput: z.infer) => { - const { path, edits } = prepareArguments(rawInput); - - if (!Array.isArray(edits) || edits.length === 0) { - return { error: "edits must contain at least one replacement." }; - } - - return withFileLock(store, path, async () => { - try { - const stat = await store.stat(path); - if (!stat) return { error: `File not found: ${path}` }; - if (stat.size > maxBytes) { - return { - error: `File too large to edit: ${stat.size} bytes exceeds the ${maxBytes}-byte cap. Use the write tool to rewrite the file from scratch.`, - }; - } - - const bytes = await store.readAll(path); - if (!bytes) return { error: `File not found: ${path}` }; - - const rawContent = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true }).decode( - bytes, - ); - const { bom, text } = stripBom(rawContent); - const ending = detectLineEnding(text); - const normalized = normalizeToLF(text); - - let baseContent: string; - let newContent: string; - try { - ({ baseContent, newContent } = applyEditsToNormalizedContent(normalized, edits, path)); - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } - - const finalContent = bom + restoreLineEndings(newContent, ending); - // Round-trip the file's mode so editing an executable script (or any - // file with a non-default mode) doesn't silently drop bits. `stat.mode` - // is undefined for stores that don't track modes; pass `undefined` in - // that case so the store applies its own default. - await store.write(path, new TextEncoder().encode(finalContent), { mode: stat.mode }); - - const diffResult = generateDiffString(baseContent, newContent); - const patch = generateUnifiedPatch(path, baseContent, newContent); - - return { - path, - editsApplied: edits.length, - diff: diffResult.diff, - patch, - firstChangedLine: diffResult.firstChangedLine, - }; - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } - }); - }, - }); -} diff --git a/packages/computer/src/tools/fs/find.ts b/packages/computer/src/tools/fs/find.ts deleted file mode 100644 index b19b23f9..00000000 --- a/packages/computer/src/tools/fs/find.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { type Tool, tool } from "ai"; -import { z } from "zod"; - -interface FoundEntry { - path: string; - type: "file" | "dir"; -} - -export interface FindWorkspaceLike { - fs: { - find( - directory: string, - pattern?: string, - options?: { limit?: number; offset?: number; exclude?: string[] }, - ): Promise; - }; -} - -export interface FindToolOptions { - workspace: FindWorkspaceLike; -} - -const DEFAULT_LIMIT = 200; -const MAX_LIMIT = 1000; - -const inputSchema = z.object({ - path: z.string().default("/workspace").describe("Absolute directory to search."), - pattern: z - .string() - .describe('Glob pattern relative to path, for example "**/*.ts" or "src/?.js".'), - exclude: z - .array(z.string()) - .optional() - .describe( - 'Glob patterns to leave out, for example ["node_modules/**", "**/.git/**"]. An excluded directory is skipped along with everything below it.', - ), - limit: z.number().int().min(1).max(MAX_LIMIT).optional(), - offset: z.number().int().min(0).optional(), -}); - -export function createFindTool(options: FindToolOptions): Tool> { - return tool({ - description: - "Find files and directories matching a glob. * stays within one path segment, ** crosses directories, and ? matches one character.", - inputSchema, - execute: async ({ path, pattern, exclude, limit, offset }) => { - try { - const pageSize = limit ?? DEFAULT_LIMIT; - const pageOffset = offset ?? 0; - const matches = await options.workspace.fs.find(path, pattern, { - limit: pageSize + 1, - offset: pageOffset, - exclude, - }); - const truncated = matches.length > pageSize; - const entries = truncated ? matches.slice(0, pageSize) : matches; - const result: { - path: string; - pattern: string; - count: number; - entries: FoundEntry[]; - nextOffset?: number; - } = { path, pattern, count: entries.length, entries }; - if (truncated) result.nextOffset = pageOffset + pageSize; - return result; - } catch (error) { - return { error: error instanceof Error ? error.message : String(error) }; - } - }, - }); -} diff --git a/packages/computer/src/tools/fs/grep.ts b/packages/computer/src/tools/fs/grep.ts deleted file mode 100644 index 430df6f2..00000000 --- a/packages/computer/src/tools/fs/grep.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { type Tool, tool } from "ai"; -import { z } from "zod"; - -interface GrepContextLine { - line: number; - text: string; - isMatch: boolean; -} - -interface GrepMatch { - path: string; - line: number; - text: string; - context?: GrepContextLine[]; -} - -interface GrepOptions { - regex?: boolean; - ignoreCase?: boolean; - context?: number; - limit?: number; - offset?: number; - include?: string; - exclude?: string[]; -} - -export interface GrepWorkspaceLike { - fs: { - grep(pattern: string, path: string, options?: GrepOptions): Promise; - }; -} - -export interface GrepToolOptions { - workspace: GrepWorkspaceLike; -} - -const DEFAULT_LIMIT = 200; -const MAX_LIMIT = 1000; - -const inputSchema = z.object({ - path: z.string().default("/workspace").describe("Absolute file or directory to search."), - query: z.string().describe("Literal string or regular expression to search for."), - include: z - .string() - .optional() - .describe('Glob relative to path that limits searched files, for example "**/*.ts".'), - exclude: z - .array(z.string()) - .optional() - .describe( - 'Glob patterns to leave out, for example ["node_modules/**", "**/.git/**"]. An excluded directory is skipped along with everything below it.', - ), - regex: z.boolean().optional().describe("Interpret query as a regular expression."), - ignoreCase: z.boolean().optional().describe("Ignore letter case."), - context: z.number().int().min(0).max(10).optional(), - limit: z.number().int().min(1).max(MAX_LIMIT).optional(), - offset: z.number().int().min(0).optional(), -}); - -export function createGrepTool(options: GrepToolOptions): Tool> { - return tool({ - description: - "Search workspace text with a literal string or regular expression. Results include paths and line numbers and can include surrounding lines.", - inputSchema, - execute: async ({ - path, - query, - include, - exclude, - regex, - ignoreCase, - context, - limit, - offset, - }) => { - try { - const pageSize = limit ?? DEFAULT_LIMIT; - const pageOffset = offset ?? 0; - const searchOptions = { - regex: regex ?? false, - ignoreCase: ignoreCase ?? false, - context: context ?? 0, - }; - const matches = await options.workspace.fs.grep(query, path, { - ...searchOptions, - include, - exclude, - limit: pageSize + 1, - offset: pageOffset, - }); - const truncated = matches.length > pageSize; - const page = truncated ? matches.slice(0, pageSize) : matches; - const result: { - path: string; - query: string; - count: number; - matches: GrepMatch[]; - nextOffset?: number; - } = { path, query, count: page.length, matches: page }; - if (truncated) result.nextOffset = pageOffset + pageSize; - return result; - } catch (error) { - return { error: error instanceof Error ? error.message : String(error) }; - } - }, - }); -} diff --git a/packages/computer/src/tools/fs/list.ts b/packages/computer/src/tools/fs/list.ts deleted file mode 100644 index 744dd757..00000000 --- a/packages/computer/src/tools/fs/list.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { type Tool, tool } from "ai"; -import { z } from "zod"; - -export interface ListWorkspaceLike { - fs: { - readdir( - path: string, - options?: { limit?: number; offset?: number }, - ): Promise< - Array<{ - name: string; - size: number; - mtime: number; - isFile: boolean; - isDirectory: boolean; - isSymbolicLink: boolean; - }> - >; - }; -} - -export interface ListToolOptions { - workspace: ListWorkspaceLike; -} - -const DEFAULT_LIMIT = 200; -const MAX_LIMIT = 1000; - -const inputSchema = z.object({ - path: z.string().describe("Absolute directory path to list, e.g. /workspace/src."), - limit: z - .number() - .int() - .min(1) - .max(MAX_LIMIT) - .optional() - .describe(`Maximum entries to return. Defaults to ${DEFAULT_LIMIT}.`), - offset: z.number().int().min(0).optional().describe("Number of entries to skip in name order."), -}); - -export function createListTool(options: ListToolOptions): Tool> { - return tool({ - description: `List entries in a workspace directory with file sizes and modification times. The result defaults to ${DEFAULT_LIMIT} entries; use limit and offset to page through large directories.`, - inputSchema, - execute: async ({ path, limit, offset }) => { - try { - const pageSize = limit ?? DEFAULT_LIMIT; - const pageOffset = offset ?? 0; - const entries = await options.workspace.fs.readdir(path, { - limit: pageSize + 1, - offset: pageOffset, - }); - const truncated = entries.length > pageSize; - const page = (truncated ? entries.slice(0, pageSize) : entries).map((entry) => ({ - name: entry.name, - size: entry.size, - mtime: entry.mtime, - isFile: entry.isFile, - isDirectory: entry.isDirectory, - isSymbolicLink: entry.isSymbolicLink, - })); - const result: { - path: string; - count: number; - entries: typeof page; - nextOffset?: number; - } = { - path, - count: page.length, - entries: page, - }; - if (truncated) result.nextOffset = pageOffset + pageSize; - return result; - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } - }, - }); -} diff --git a/packages/computer/src/tools/index.ts b/packages/computer/src/tools/index.ts index 9bad4745..9ca3d5cf 100644 --- a/packages/computer/src/tools/index.ts +++ b/packages/computer/src/tools/index.ts @@ -1,19 +1,35 @@ -export { type CreateAIToolsOptions, createAITools } from "./ai.js"; +// The individual AI SDK tools and the file store under them. Tool sets +// for each agent library have their own entry points, so importing one +// never pulls in another library: +// @cloudflare/computer/tools/ai-sdk createAITools +// @cloudflare/computer/tools/pi-ai createPiTools +// @cloudflare/computer/tools/tanstack-ai createTanStackTools export { + createDeleteTool, + createEditTool, createExecTool, - type ExecBackendDescription, - type ExecRuntimeHandle, - type ExecStreamEvent, - type ExecToolOptions, - type ExecToolOutput, -} from "./exec.js"; -export { createDeleteTool, type DeleteToolOptions } from "./fs/delete.js"; -export { createEditTool, type EditToolOptions } from "./fs/edit.js"; -export { createFindTool, type FindToolOptions } from "./fs/find.js"; -export { createGrepTool, type GrepToolOptions } from "./fs/grep.js"; -export { createListTool, type ListToolOptions } from "./fs/list.js"; -export { createReadTool, type LineTruncation, type ReadToolOptions } from "./fs/read.js"; -export { WorkspaceFileStore, type WorkspaceLike } from "./fs/store.js"; -export type { FileStat, FileStore, MutableFileStore } from "./fs/types.js"; -export { createWriteTool, type WriteToolOptions } from "./fs/write.js"; -export { createPublishTool, type PublishToolOptions } from "./publish.js"; + createFindTool, + createGrepTool, + createListTool, + createPublishTool, + createReadTool, + createWriteTool, +} from "./ai-sdk/tools.js"; +export type { + ExecBackendOptions, + ExecBackends, + ExecRuntimeHandle, + ExecStreamEvent, + ExecToolOptions, + ExecToolOutput, +} from "./common/exec.js"; +export type { DeleteToolOptions } from "./common/fs/delete.js"; +export type { EditToolOptions } from "./common/fs/edit.js"; +export type { FindToolOptions } from "./common/fs/find.js"; +export type { GrepToolOptions } from "./common/fs/grep.js"; +export type { ListToolOptions } from "./common/fs/list.js"; +export type { LineTruncation, ReadToolOptions } from "./common/fs/read.js"; +export { WorkspaceFileStore, type WorkspaceLike } from "./common/fs/store.js"; +export type { FileStat, FileStore, MutableFileStore } from "./common/fs/types.js"; +export type { WriteToolOptions } from "./common/fs/write.js"; +export type { PublishToolOptions } from "./common/publish.js"; diff --git a/packages/computer/src/tools/pi-ai/index.test.ts b/packages/computer/src/tools/pi-ai/index.test.ts new file mode 100644 index 00000000..83c0e063 --- /dev/null +++ b/packages/computer/src/tools/pi-ai/index.test.ts @@ -0,0 +1,333 @@ +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { validateToolCall } from "@earendil-works/pi-ai"; +import { makeStrictJsonSchema } from "@earendil-works/pi-ai/api/constrained-sampling"; +import { describe, expect, it } from "vitest"; +import type { WorkspaceBackendInfo } from "../../runtime/runtime.js"; +import { Workspace } from "../../workspace.js"; +import { createPiTools, type PiJSONSchema } from "./index.js"; + +function makeWorkspace(): Workspace { + return new Workspace({ storage: new SQLiteTestStorage(), now: () => 1_700_000_000_000 }); +} + +// Stands in for registered backends, so the tests can shape what the +// exec tool sees without running one. +function fakeBackends(workspace: Workspace, backends: WorkspaceBackendInfo[]): void { + (workspace.runtime as unknown as Record).backends = () => backends; +} + +function declaration(tools: ReturnType, name: string) { + const tool = tools.tools.find((candidate) => candidate.name === name); + if (!tool) throw new Error(`no ${name} tool`); + return tool; +} + +describe("createPiTools declarations", () => { + it("declares the default tool set with object parameter schemas", () => { + const tools = createPiTools({ workspace: makeWorkspace() }); + + expect(tools.tools.map((tool) => tool.name).sort()).toEqual([ + "delete", + "edit", + "find", + "grep", + "ls", + "read", + "write", + ]); + for (const tool of tools.tools) { + expect(tool.parameters.type).toBe("object"); + expect(tool.description.length).toBeGreaterThan(0); + } + }); + + it("omits mutating tools when readonly", () => { + const tools = createPiTools({ workspace: makeWorkspace(), readonly: true }); + + expect(tools.tools.map((tool) => tool.name).sort()).toEqual(["find", "grep", "ls", "read"]); + }); + + it("names a backend on every exec call when there are several", () => { + const workspace = makeWorkspace(); + fakeBackends(workspace, [ + { id: "worker-shell", callable: false, description: "Fast worker shell." }, + { id: "container-shell", callable: false, description: "Full Linux container." }, + ]); + const tools = createPiTools({ workspace }); + + const exec = declaration(tools, "exec"); + expect(exec.description).toContain("Fast worker shell."); + expect(exec.description).toContain("Full Linux container."); + const backend = exec.parameters.properties?.backend as { enum?: string[] }; + expect(backend.enum).toEqual(["worker-shell", "container-shell"]); + expect(exec.parameters.required).toContain("backend"); + }); + + it("offers only the backends `exec` lists, with no backend argument for one", () => { + const workspace = makeWorkspace(); + fakeBackends(workspace, [ + { id: "worker-shell", callable: false, description: "Fast worker shell." }, + { id: "container-shell", callable: false, description: "Full Linux container." }, + ]); + const tools = createPiTools({ + workspace, + exec: { "worker-shell": { description: "Use for quick checks." } }, + }); + + const exec = declaration(tools, "exec"); + expect(exec.description).toContain("Use for quick checks."); + expect(exec.description).not.toContain("Full Linux container."); + expect(exec.parameters.properties).not.toHaveProperty("backend"); + expect(exec.parameters.properties).not.toHaveProperty("input"); + }); + + it("leaves exec out for `exec: {}` and for a read-only set", () => { + const workspace = makeWorkspace(); + fakeBackends(workspace, [{ id: "worker-shell", callable: false }]); + + expect(createPiTools({ workspace, exec: {} }).tools.map((t) => t.name)).not.toContain("exec"); + expect(createPiTools({ workspace, readonly: true }).tools.map((t) => t.name)).not.toContain( + "exec", + ); + }); + + it("emits required fields without a $schema key and keeps defaults optional", () => { + const tools = createPiTools({ workspace: makeWorkspace() }); + + const write = declaration(tools, "write"); + expect(write.parameters.$schema).toBeUndefined(); + expect(write.parameters.required?.sort()).toEqual(["content", "path"]); + + // `find.path` carries a Zod default, so the model may omit it. + const find = declaration(tools, "find"); + expect(find.parameters.required).toEqual(["pattern"]); + const path = find.parameters.properties?.path as { default?: string } | undefined; + expect(path?.default).toBe("/workspace"); + }); +}); + +describe("createPiTools constrained sampling", () => { + it("requests provider-side strict schemas for the fussy tools only", () => { + const tools = createPiTools({ workspace: makeWorkspace() }); + + // `edit` and `write` carry long verbatim strings a model can mangle. + expect(declaration(tools, "edit").constrainedSampling).toEqual({ + type: "json_schema", + strict: "prefer", + }); + expect(declaration(tools, "write").constrainedSampling).toEqual({ + type: "json_schema", + strict: "prefer", + }); + // A plain listing has nothing worth constraining. + expect(declaration(tools, "ls").constrainedSampling).toBeUndefined(); + }); + + it("sends open schemas that pi validates and makes strict itself", () => { + const tools = createPiTools({ workspace: makeWorkspace() }); + const read = declaration(tools, "read"); + const call = (args: Record) => ({ + type: "toolCall" as const, + id: "1", + name: "read", + arguments: args, + }); + + // A provider that falls back to ordinary tool calling may leave the + // optional fields out; pi's own validator must accept that. + expect(read.parameters.required).toEqual(["path"]); + expect(validateToolCall(tools.tools as never, call({ path: "/w/a.txt" }))).toEqual({ + path: "/w/a.txt", + }); + // Under strict sampling pi closes the schema and lets the optional + // fields be null. + const strict = makeStrictJsonSchema(read.parameters as never) as PiJSONSchema; + expect(strict.additionalProperties).toBe(false); + expect(strict.required?.sort()).toEqual(["byteOffset", "limit", "offset", "path"]); + }); + + it("escalates to require or opts out when asked", () => { + const required = createPiTools({ + workspace: makeWorkspace(), + constrainedSampling: "require", + }); + expect(declaration(required, "edit").constrainedSampling).toEqual({ + type: "json_schema", + strict: "require", + }); + + const off = createPiTools({ workspace: makeWorkspace(), constrainedSampling: false }); + expect(declaration(off, "edit").constrainedSampling).toBeUndefined(); + expect(declaration(off, "read").parameters.required).toEqual(["path"]); + }); + + it("accepts a strict-mode call that fills optional fields with null", async () => { + const workspace = makeWorkspace(); + const tools = createPiTools({ workspace }); + + await tools.execute({ + id: "1", + name: "write", + arguments: { path: "/w/a.txt", content: "hi\n" }, + }); + // A provider enforcing the closed schema sends every property. + const result = await tools.execute({ + id: "2", + name: "read", + arguments: { path: "/w/a.txt", offset: null, byteOffset: null, limit: null }, + }); + + expect(result.isError).toBe(false); + expect(result.content).toEqual([{ type: "text", text: "hi" }]); + }); +}); + +describe("createPiTools execution", () => { + it("runs a tool call and returns text content for a complete read", async () => { + const workspace = makeWorkspace(); + const tools = createPiTools({ workspace }); + + await tools.execute({ + id: "1", + name: "write", + arguments: { path: "/w/a.txt", content: "hi\n" }, + }); + const result = await tools.execute({ id: "2", name: "read", arguments: { path: "/w/a.txt" } }); + + expect(result.isError).toBe(false); + expect(result.content).toEqual([{ type: "text", text: "hi" }]); + }); + + it("returns structured results as JSON text", async () => { + const workspace = makeWorkspace(); + const tools = createPiTools({ workspace }); + + await tools.execute({ id: "1", name: "write", arguments: { path: "/w/a.txt", content: "x" } }); + const result = await tools.execute({ id: "2", name: "ls", arguments: { path: "/w" } }); + + expect(result.isError).toBe(false); + const parsed = JSON.parse((result.content[0] as { text: string }).text); + expect(parsed.count).toBe(1); + expect(parsed.entries[0].name).toBe("a.txt"); + }); + + it("marks a missing file as an error result", async () => { + const tools = createPiTools({ workspace: makeWorkspace() }); + + const result = await tools.execute({ + id: "1", + name: "read", + arguments: { path: "/w/missing.txt" }, + }); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("missing.txt"); + }); + + it("rejects invalid arguments as a retryable error rather than throwing", async () => { + const tools = createPiTools({ workspace: makeWorkspace() }); + + const result = await tools.execute({ id: "1", name: "read", arguments: { path: 42 } }); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("Invalid arguments for read"); + }); + + it("reports an unknown tool name with the available names", async () => { + const tools = createPiTools({ workspace: makeWorkspace() }); + + const result = await tools.execute({ id: "1", name: "nope", arguments: {} }); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain('Unknown tool "nope"'); + }); + + it("keeps a null the tool genuinely accepts", async () => { + // `exec`'s structured input is any JSON value, so null means null. + const seen: Array<{ input: unknown }> = []; + const workspace = makeWorkspace(); + (workspace.runtime as unknown as Record).exec = async ( + _command: string, + options: { input?: unknown }, + ) => { + seen.push({ input: options.input }); + return { result: async () => ({ exitCode: 0, stdout: "", stderr: "" }) }; + }; + fakeBackends(workspace, [{ id: "js", callable: true, description: "callable" }]); + const tools = createPiTools({ workspace }); + + await tools.execute({ id: "1", name: "exec", arguments: { command: "a", input: null } }); + await tools.execute({ id: "2", name: "exec", arguments: { command: "b" } }); + + expect(seen[0].input).toBeNull(); + expect(seen[1].input).toBeUndefined(); + }); + + it("applies a schema default when the model omits the field", async () => { + const workspace = makeWorkspace(); + const tools = createPiTools({ workspace }); + + await tools.execute({ + id: "1", + name: "write", + arguments: { path: "/workspace/found.ts", content: "export {};" }, + }); + const result = await tools.execute({ + id: "2", + name: "find", + arguments: { pattern: "**/*.ts" }, + }); + + const parsed = JSON.parse((result.content[0] as { text: string }).text); + expect(parsed.path).toBe("/workspace"); + expect(parsed.entries.map((entry: { path: string }) => entry.path)).toContain( + "/workspace/found.ts", + ); + }); + + it("reports a failed publish as an error result", async () => { + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + assets: { + share: async () => { + throw new Error("bucket unavailable"); + }, + } as never, + }); + const tools = createPiTools({ workspace }); + + const result = await tools.execute({ + id: "1", + name: "publish", + arguments: { path: "/workspace/out.png" }, + }); + + expect(result).toEqual({ + content: [{ type: "text", text: "bucket unavailable" }], + isError: true, + }); + }); + + it("returns an image read as a base64 image block", async () => { + const workspace = makeWorkspace(); + const tools = createPiTools({ workspace }); + // A one-pixel PNG, written through the filesystem so the read tool + // classifies it by extension and captures its bytes. + const png = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, + 0x52, + ]); + await workspace.fs.mkdir("/workspace", { recursive: true }); + await workspace.fs.writeFile("/workspace/pixel.png", png); + + const result = await tools.execute({ + id: "1", + name: "read", + arguments: { path: "/workspace/pixel.png" }, + }); + + expect(result.isError).toBe(false); + expect(result.content[0]).toMatchObject({ type: "text" }); + expect(result.content[1]).toMatchObject({ type: "image", mimeType: "image/png" }); + }); +}); diff --git a/packages/computer/src/tools/pi-ai/index.ts b/packages/computer/src/tools/pi-ai/index.ts new file mode 100644 index 00000000..8006a6d3 --- /dev/null +++ b/packages/computer/src/tools/pi-ai/index.ts @@ -0,0 +1,358 @@ +/** + * pi keeps tool declarations and tool execution apart: declarations + * travel in `Context.tools` while the caller's own agent loop runs the + * tools. So this module returns both halves together. + */ + +import { z } from "zod"; +import { defineExec } from "../common/exec.js"; +import { deleteDescription, deleteFromStore, deleteInputSchema } from "../common/fs/delete.js"; +import { editDescription, editInputSchema, editInStore } from "../common/fs/edit.js"; +import { findDescription, findInputSchema, findInWorkspace } from "../common/fs/find.js"; +import { grepDescription, grepInputSchema, grepInWorkspace } from "../common/fs/grep.js"; +import { listDescription, listInputSchema, listWorkspace } from "../common/fs/list.js"; +import { + createReadExecutor, + type ReadInput, + type ReadToolResult, + readDescription, + readInputSchema, + readModelOutput, +} from "../common/fs/read.js"; +import { writeDescription, writeInputSchema, writeToStore } from "../common/fs/write.js"; +import { defaultModelOutput, type ModelOutput } from "../common/model-output.js"; +import { type CreateToolsOptions, resolveToolOptions } from "../common/options.js"; +import { + createPublishExecutor, + type PublishWorkspaceLike, + publishDescription, + publishInputSchema, +} from "../common/publish.js"; +import { settle } from "../common/stream.js"; + +export interface ToolCallContext { + abortSignal?: AbortSignal; +} + +interface PiToolEntry { + name: string; + description: string; + inputSchema: z.ZodType; + strictArguments?: boolean; + execute: (input: never, context: ToolCallContext) => Promise | AsyncIterable; + toModelOutput?: (args: { input: never; output: never }) => ModelOutput; +} + +/** Structurally compatible with `Tool` from `@earendil-works/pi-ai`, declared locally so pi is not a build-time dependency. */ +export interface PiTool { + name: string; + description: string; + parameters: PiJSONSchema; + constrainedSampling?: { type: "json_schema"; strict: "prefer" | "require" }; +} + +export interface PiJSONSchema { + type: "object"; + properties?: Record; + required?: string[]; + [key: string]: unknown; +} + +/** One tool call as pi reports it on a `toolcall_end` event. */ +export interface PiToolCall { + id: string; + name: string; + arguments?: unknown; +} + +export type PiToolResultContent = + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string }; + +/** A tool result minus the routing fields (`toolCallId`, `toolName`, `timestamp`), which the caller owns. */ +export interface PiToolResult { + content: PiToolResultContent[]; + isError: boolean; +} + +export interface CreatePiToolsResult { + tools: PiTool[]; + execute: (call: PiToolCall, context?: ToolCallContext) => Promise; +} + +export function createPiTools(options: CreatePiToolsOptions): CreatePiToolsResult { + const entries = piToolEntries(options); + return { + tools: declarations(entries, options), + execute: dispatcher(entries), + }; +} + +export interface CreatePiToolsOptions extends CreateToolsOptions, PiDeclarationOptions {} + +function piToolEntries(options: CreateToolsOptions): PiToolEntry[] { + const resolved = resolveToolOptions(options); + const workspace = resolved.workspace; + const readExecutor = createReadExecutor(resolved.read); + const toReadOutput = readModelOutput(resolved.read); + + const entries: PiToolEntry[] = [ + { + name: "read", + description: readDescription(resolved.read), + inputSchema: readInputSchema, + // Byte offsets must be echoed back verbatim on the next call. + strictArguments: true, + execute: (input: ReadInput) => readExecutor(input), + toModelOutput: ({ input, output }: { input: ReadInput; output: ReadToolResult }) => + toReadOutput({ input, output }), + } as PiToolEntry, + { + name: "ls", + description: listDescription, + inputSchema: listInputSchema, + execute: (input) => listWorkspace(workspace, input), + } as PiToolEntry, + { + name: "find", + description: findDescription, + inputSchema: findInputSchema, + execute: (input) => findInWorkspace(workspace, input), + } as PiToolEntry, + { + name: "grep", + description: grepDescription, + inputSchema: grepInputSchema, + execute: (input) => grepInWorkspace(workspace, input), + } as PiToolEntry, + ]; + + if (resolved.readonly) return entries; + + entries.push( + { + name: "write", + description: writeDescription, + inputSchema: writeInputSchema, + // The whole file body travels as one string argument. + strictArguments: true, + execute: (input) => writeToStore(resolved.write, input), + } as PiToolEntry, + { + name: "edit", + description: editDescription, + inputSchema: editInputSchema, + // A nested array of exact-match strings is easy to malform. + strictArguments: true, + execute: (input) => editInStore(resolved.edit, input), + } as PiToolEntry, + { + name: "delete", + description: deleteDescription, + inputSchema: deleteInputSchema, + execute: (input) => deleteFromStore(resolved.delete, input), + } as PiToolEntry, + ); + + if (resolved.exec !== undefined) { + const exec = defineExec(resolved.exec); + entries.push({ + name: "exec", + description: exec.description, + inputSchema: exec.inputSchema, + execute: (input, context) => exec.execute(input, context), + } as PiToolEntry); + } + + if (resolved.publish) { + const executor = createPublishExecutor(workspace as PublishWorkspaceLike); + entries.push({ + name: "publish", + description: publishDescription, + inputSchema: publishInputSchema, + execute: (input) => executor(input), + } as PiToolEntry); + } + + return entries; +} + +// The schemas stay open. pi closes a schema itself when it sends a +// `constrainedSampling` tool in strict mode, and keeps the open one for +// a provider that falls back to ordinary tool calling. +function declarations(entries: readonly PiToolEntry[], options: PiDeclarationOptions): PiTool[] { + const strict = options.constrainedSampling ?? "prefer"; + return entries.map((entry) => { + const tool: PiTool = { + name: entry.name, + description: entry.description, + parameters: toPiParameters(entry.inputSchema), + }; + if (strict !== false && entry.strictArguments === true) { + tool.constrainedSampling = { type: "json_schema", strict }; + } + return tool; + }); +} + +export interface PiDeclarationOptions { + /** + * `"prefer"` (default) falls back to ordinary tool calling where the + * provider cannot enforce a schema; `"require"` fails the request + * instead, so it suits only a pinned model known to support it. + */ + constrainedSampling?: "prefer" | "require" | false; +} + +/** + * Validation failures and thrown executors both come back as error + * results rather than exceptions, so a bad call costs the model a turn + * instead of breaking the caller's loop. + */ +function dispatcher( + entries: readonly PiToolEntry[], +): (call: PiToolCall, context?: ToolCallContext) => Promise { + const byName = new Map(entries.map((entry) => [entry.name, entry])); + const nullable = new Map(entries.map((entry) => [entry.name, absentWhenNull(entry.inputSchema)])); + return async (call, context = {}) => { + const entry = byName.get(call.name); + if (!entry) { + return errorResult( + `Unknown tool ${JSON.stringify(call.name)}. Available tools: ${entries + .map((e) => JSON.stringify(e.name)) + .join(", ")}.`, + ); + } + + const args = dropPlaceholderNulls(call.arguments ?? {}, nullable.get(entry.name) ?? EMPTY); + const parsed = entry.inputSchema.safeParse(args); + if (!parsed.success) { + return errorResult(`Invalid arguments for ${call.name}: ${formatZodError(parsed.error)}`); + } + + const run = entry.execute as ( + i: unknown, + c: ToolCallContext, + ) => Promise | AsyncIterable; + const toOutput = entry.toModelOutput as + | ((args: { input: unknown; output: unknown }) => ModelOutput) + | undefined; + try { + const output = await settle(run(parsed.data, context)); + return toPiResult( + toOutput ? toOutput({ input: parsed.data, output }) : defaultModelOutput(output), + ); + } catch (err) { + return errorResult(err instanceof Error ? err.message : String(err)); + } + }; +} + +function toPiResult(output: ModelOutput): PiToolResult { + switch (output.type) { + case "text": + return { content: [{ type: "text", text: output.value }], isError: false }; + case "error-text": + return { content: [{ type: "text", text: output.value }], isError: true }; + case "json": + return { + content: [{ type: "text", text: stringify(output.value) }], + isError: false, + }; + case "media": { + // pi's tool results carry images but nothing else, so a PDF + // degrades to text rather than being dropped. + if (!output.mediaType.startsWith("image/")) { + return { + content: [ + { + type: "text", + text: `${output.text} This file type cannot be attached to a tool result; read it with a dedicated tool if its contents are needed.`, + }, + ], + isError: false, + }; + } + return { + content: [ + { type: "text", text: output.text }, + { type: "image", data: output.data, mimeType: output.mediaType }, + ], + isError: false, + }; + } + } +} + +/** + * `io: "input"` keeps a field with a Zod `.default()` optional: the + * default is emitted as a JSON Schema `default`. + */ +function toPiParameters(schema: z.ZodType): PiJSONSchema { + const json = z.toJSONSchema(schema, { + target: "draft-7", + io: "input", + // Providers reject `$ref` pointers into a definitions section. + reused: "inline", + unrepresentable: "any", + }) as Record; + delete json.$schema; + if (json.type !== "object") { + throw new Error(`pi tool parameters must be an object schema, got ${String(json.type)}`); + } + return json as PiJSONSchema; +} + +/** + * The optional fields that do not accept null. Under strict sampling pi + * makes every field required and lets the optional ones be null, so a + * null there means the model left the field out. + */ +function absentWhenNull(schema: z.ZodType): ReadonlySet { + if (!(schema instanceof z.ZodObject)) return EMPTY; + const names = new Set(); + for (const [name, field] of Object.entries(schema.shape as Record)) { + if (field.safeParse(undefined).success && !field.safeParse(null).success) names.add(name); + } + return names; +} + +/** + * Drops the nulls that stand for an absent field. A null on any other + * field is a value the tool accepts (`exec`'s structured `input` is any + * JSON) and survives. + */ +function dropPlaceholderNulls(args: unknown, nullable: ReadonlySet): unknown { + if (typeof args !== "object" || args === null || Array.isArray(args)) return args; + if (nullable.size === 0) return args; + const out: Record = {}; + for (const [key, value] of Object.entries(args as Record)) { + if (value === null && nullable.has(key)) continue; + out[key] = value; + } + return out; +} + +const EMPTY: ReadonlySet = new Set(); + +function errorResult(message: string): PiToolResult { + return { content: [{ type: "text", text: message }], isError: true }; +} + +function formatZodError(error: z.ZodError): string { + return error.issues + .map((issue) => { + const path = issue.path.join("."); + return path ? `${path}: ${issue.message}` : issue.message; + }) + .join("; "); +} + +function stringify(value: unknown): string { + try { + const json = JSON.stringify(value); + return json === undefined ? String(value) : json; + } catch { + return String(value); + } +} diff --git a/packages/computer/src/tools/pi-ai/model-output.test.ts b/packages/computer/src/tools/pi-ai/model-output.test.ts new file mode 100644 index 00000000..c80a51ad --- /dev/null +++ b/packages/computer/src/tools/pi-ai/model-output.test.ts @@ -0,0 +1,32 @@ +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { describe, expect, it, vi } from "vitest"; +import { Workspace } from "../../workspace.js"; +import { createPiTools } from "./index.js"; + +// Only read shapes its own model output, so a failing formatter is +// simulated by replacing it. +vi.mock("../common/fs/read.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readModelOutput: () => () => { + throw new Error("formatter exploded"); + }, + }; +}); + +describe("createPiTools model output", () => { + it("returns a failing formatter as an error result rather than throwing", async () => { + const workspace = new Workspace({ storage: new SQLiteTestStorage() }); + const tools = createPiTools({ workspace }); + await tools.execute({ id: "1", name: "write", arguments: { path: "/w/a.txt", content: "hi" } }); + + const result = await tools.execute({ id: "2", name: "read", arguments: { path: "/w/a.txt" } }); + + expect(result).toEqual({ + content: [{ type: "text", text: "formatter exploded" }], + isError: true, + }); + await workspace.close(); + }); +}); diff --git a/packages/computer/src/tools/publish.ts b/packages/computer/src/tools/publish.ts deleted file mode 100644 index 75b6ab46..00000000 --- a/packages/computer/src/tools/publish.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { type Tool, tool } from "ai"; -import { z } from "zod"; -import type { AssetsClient } from "../assets/index.js"; - -export interface PublishWorkspaceLike { - readonly sessionId: string; - readonly assets?: AssetsClient; -} - -export interface PublishToolOptions { - workspace: PublishWorkspaceLike; -} - -const DEFAULT_EXPIRY_MS = 60 * 60 * 1000; - -export function createPublishTool( - options: PublishToolOptions, -): Tool<{ path: string; expiresAfterMs?: number }> { - const assets = options.workspace.assets; - if (!assets) { - throw new Error("createPublishTool: workspace.assets is not configured"); - } - - return tool({ - description: - "Publish a file from the workspace through the configured assets publisher and return a time-limited link. Use this to hand the user an artifact you produced, such as a chart, screenshot, build output, or report.", - inputSchema: z.object({ - path: z.string().min(1).describe("Absolute workspace path, e.g. /workspace/out/chart.png."), - expiresAfterMs: z - .number() - .int() - .positive() - .optional() - .describe("Link lifetime in milliseconds. Defaults to one hour."), - }), - execute: async ({ path, expiresAfterMs }) => { - try { - const prefix = options.workspace.sessionId - ? `agent-${options.workspace.sessionId}` - : undefined; - const url = await assets.share(path, { - expiresAfter: expiresAfterMs ?? DEFAULT_EXPIRY_MS, - ...(prefix ? { prefix } : {}), - }); - return { ok: true, url }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } - }, - }); -} diff --git a/packages/computer/src/tools/tanstack-ai/index.test.ts b/packages/computer/src/tools/tanstack-ai/index.test.ts new file mode 100644 index 00000000..a11154f3 --- /dev/null +++ b/packages/computer/src/tools/tanstack-ai/index.test.ts @@ -0,0 +1,424 @@ +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { isContentPartArray } from "@tanstack/ai"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { WorkerJavaScriptBackend } from "../../backends/worker-javascript/worker-javascript.js"; +import { Workspace } from "../../workspace.js"; +import { createTanStackTools } from "./index.js"; + +function makeWorkspace(): Workspace { + return new Workspace({ storage: new SQLiteTestStorage(), now: () => 1_700_000_000_000 }); +} + +// Streams a fixed event sequence through a real WorkspaceRuntime +// handle, rather than a hand-shaped fake. +function streamingCommandBackend(events: import("@cloudflare/computer-rpc").ExecEvent[]): { + id: string; + type: string; + connect(): Promise<{ + rpc: import("@cloudflare/computer-rpc").WorkspaceRPC; + sync: "none"; + close(): Promise; + }>; +} { + const shell: import("@cloudflare/computer-rpc").ShellRPC = { + async exec(input) { + const id = input.id ?? "cmd-1"; + return { + id, + events: new ReadableStream({ + start(controller) { + for (const event of events) controller.enqueue({ ...event, id }); + controller.close(); + }, + }), + }; + }, + getExec: () => Promise.reject(new Error("not used")), + killExec: () => Promise.resolve(), + disposeExec: () => Promise.resolve(), + }; + const noopSync = new Proxy( + {}, + { get: () => () => Promise.reject(new Error("sync: none")) }, + ) as import("@cloudflare/computer-rpc").SyncRPC; + return { + id: "shell", + type: "fake-command", + async connect() { + return { rpc: { sync: noopSync, shell }, sync: "none", close: async () => {} }; + }, + }; +} + +// A command that prints once and then stays quiet until killed or +// released, the shape that exposes buffering and cancellation bugs. +function quietCommandBackend(): { + backend: ReturnType; + killed: Promise; + release(): void; +} { + let finish: (() => void) | undefined; + let markKilled: () => void = () => {}; + const killed = new Promise((resolve) => { + markKilled = resolve; + }); + const base = streamingCommandBackend([]); + const backend = { + ...base, + async connect() { + const connection = await base.connect(); + connection.rpc.shell.exec = async (input) => { + const id = input.id ?? "cmd-1"; + return { + id, + events: new ReadableStream({ + start(controller) { + controller.enqueue({ + id, + seq: 1, + name: "stdout", + value: new TextEncoder().encode("starting\n"), + }); + finish = () => { + controller.enqueue({ id, seq: 2, name: "exit", code: 0 }); + controller.close(); + }; + }, + }), + }; + }; + connection.rpc.shell.killExec = async () => { + markKilled(); + finish?.(); + }; + return connection; + }, + }; + return { backend, killed, release: () => finish?.() }; +} + +describe("createTanStackTools", () => { + it("returns a list, the shape every TanStack entry point takes", () => { + const tools = createTanStackTools({ workspace: makeWorkspace() }); + + // chat(), mergeAgentTools and createToolRegistry all call array + // methods on what they are given, so an array is the contract. + expect(Array.isArray(tools)).toBe(true); + expect(tools.map((tool) => tool.name).sort()).toEqual([ + "delete", + "edit", + "find", + "grep", + "ls", + "read", + "write", + ]); + for (const tool of tools) { + expect(typeof tool.execute).toBe("function"); + } + }); + + it("keys the tools by name when asked", () => { + const tools = createTanStackTools({ workspace: makeWorkspace() }); + const set = createTanStackTools({ workspace: makeWorkspace(), format: "object" }); + + expect(Array.isArray(set)).toBe(false); + expect(Object.keys(set).sort()).toEqual(tools.map((tool) => tool.name).sort()); + for (const [name, tool] of Object.entries(set)) { + expect(tool.name).toBe(name); + } + }); + + it("omits mutating tools when readonly", () => { + const tools = createTanStackTools({ workspace: makeWorkspace(), readonly: true }); + + expect(tools.map((tool) => tool.name).sort()).toEqual(["find", "grep", "ls", "read"]); + }); + + it("passes the Zod schema through untouched for standard-schema validation", () => { + const tools = createTanStackTools({ workspace: makeWorkspace(), format: "object" }); + + const schema = tools.write.inputSchema as unknown as { + "~standard": { version: number }; + safeParse: (value: unknown) => { success: boolean }; + }; + expect(schema["~standard"].version).toBe(1); + expect(schema.safeParse({ path: "/w/a.txt", content: "x" }).success).toBe(true); + expect(schema.safeParse({ path: "/w/a.txt" }).success).toBe(false); + }); + + it("flags only the requested tools as needing approval", () => { + const tools = createTanStackTools({ + workspace: makeWorkspace(), + approve: ["delete"], + format: "object", + }); + + expect(tools.delete.needsApproval).toBe(true); + expect(tools.write.needsApproval).toBeUndefined(); + }); + + it("gates every mutating tool from one keyword", () => { + const tools = createTanStackTools({ + workspace: makeWorkspace(), + approve: "mutating", + format: "object", + }); + + for (const name of ["write", "edit", "delete"]) { + expect(tools[name].needsApproval).toBe(true); + } + // Reads and searches change nothing, so they run unattended. + for (const name of ["read", "ls", "find", "grep"]) { + expect(tools[name].needsApproval).toBeUndefined(); + } + }); + + it("describes output shapes including the error branch", () => { + const tools = createTanStackTools({ workspace: makeWorkspace(), format: "object" }); + + const schema = tools.write.outputSchema as unknown as { + safeParse: (v: unknown) => { success: boolean }; + }; + expect(schema.safeParse({ path: "/w/a.txt", bytesWritten: 3 }).success).toBe(true); + expect(schema.safeParse({ path: "/w/a.txt" }).success).toBe(false); + // TanStack validates every return against this, failures included. + expect(schema.safeParse({ error: "read-only filesystem" }).success).toBe(true); + // A paged listing has no fixed success shape worth asserting. + expect(tools.ls.outputSchema).toBeUndefined(); + }); + + it("returns the real reason when a mutating tool fails", async () => { + const workspace = makeWorkspace(); + workspace.fs.writeFile = async () => { + throw new Error("read-only filesystem"); + }; + const tools = createTanStackTools({ workspace, format: "object" }); + + const result = (await tools.write.execute({ + path: "/workspace/a.txt", + content: "hi", + } as never)) as { error: string }; + + expect(result.error).toContain("read-only filesystem"); + const schema = tools.write.outputSchema as unknown as { + parse: (v: unknown) => unknown; + }; + expect(schema.parse(result)).toEqual({ error: expect.stringContaining("read-only") }); + }); + + it("marks tools lazy so they stay out of the prompt until discovered", () => { + const all = createTanStackTools({ + workspace: makeWorkspace(), + lazy: "all", + format: "object", + }); + expect(all.read.lazy).toBe(true); + expect(all.write.lazy).toBe(true); + + const some = createTanStackTools({ + workspace: makeWorkspace(), + lazy: ["grep"], + format: "object", + }); + expect(some.grep.lazy).toBe(true); + expect(some.read.lazy).toBeUndefined(); + }); + + it("returns plain text for a complete read and objects for structured results", async () => { + const workspace = makeWorkspace(); + const tools = createTanStackTools({ workspace, format: "object" }); + + await tools.write.execute({ path: "/w/a.txt", content: "hi\n" } as never); + + await expect(tools.read.execute({ path: "/w/a.txt" } as never)).resolves.toBe("hi"); + await expect(tools.ls.execute({ path: "/w" } as never)).resolves.toMatchObject({ + path: "/w", + count: 1, + }); + }); + + it("returns an error object for a failed call", async () => { + const tools = createTanStackTools({ workspace: makeWorkspace(), format: "object" }); + + const result = (await tools.read.execute({ path: "/w/missing.txt" } as never)) as { + error: string; + }; + + expect(result.error).toContain("missing.txt"); + }); + + it("returns an image read as content parts TanStack attaches", async () => { + // chat() passes a tool result through as multimodal content only when + // it is a ContentPart array; anything else becomes JSON text. + const workspace = makeWorkspace(); + const tools = createTanStackTools({ workspace, format: "object" }); + const png = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, + 0x52, + ]); + await workspace.fs.mkdir("/workspace", { recursive: true }); + await workspace.fs.writeFile("/workspace/pixel.png", png); + + const result = await tools.read.execute({ path: "/workspace/pixel.png" } as never); + + expect(result).toEqual([ + { type: "text", content: expect.stringContaining("/workspace/pixel.png") }, + { + type: "image", + source: { type: "data", value: expect.any(String), mimeType: "image/png" }, + }, + ]); + expect(isContentPartArray(result)).toBe(true); + }); + + it("offers every workspace backend by default and requires one per call", async () => { + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + streamingCommandBackend([]) as never, + new WorkerJavaScriptBackend({ loader: { load: () => ({ getEntrypoint: () => ({}) }) } }), + ], + }); + const tools = createTanStackTools({ workspace, format: "object" }); + const schema = z.toJSONSchema(tools.exec.inputSchema) as { + properties: Record; + required?: string[]; + }; + + expect(schema.properties.backend?.enum).toEqual(["shell", "worker-javascript"]); + expect(schema.required).toContain("backend"); + // Only the callable backend takes structured input. + expect(schema.properties).toHaveProperty("input"); + await expect(tools.exec.execute({ command: "ls" } as never)).resolves.toEqual({ + error: "Name a backend to run on.", + }); + expect(createTanStackTools({ workspace, exec: {} }).map((t) => t.name)).not.toContain("exec"); + await workspace.close(); + }); + + it("settles a streaming exec tool on its terminal snapshot", async () => { + // TanStack tools return one value, so a streaming executor has to + // collapse to the run's terminal snapshot rather than a mid-run one. + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + streamingCommandBackend([ + { id: "cmd-1", seq: 1, name: "stdout", value: new TextEncoder().encode("hello\n") }, + { id: "cmd-1", seq: 2, name: "exit", code: 0 }, + ]) as never, + ], + }); + const tools = createTanStackTools({ + workspace, + exec: { shell: { description: "fast shell" } }, + format: "object", + }); + + await expect(tools.exec.execute({ command: "echo hello" } as never)).resolves.toEqual({ + command: "echo hello", + cwd: null, + backend: "shell", + exitCode: 0, + stdout: "hello\n", + stderr: "", + }); + await workspace.close(); + }); + + it("forwards pre-terminal snapshots as custom events when asked", async () => { + const events: Array<{ name: string; value: Record }> = []; + // The last snapshot settles as the return value; earlier ones are + // emitted, so a UI can show output while the command runs. + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [ + streamingCommandBackend([ + { id: "cmd-1", seq: 1, name: "stdout", value: new TextEncoder().encode("partial\n") }, + { id: "cmd-1", seq: 2, name: "exit", code: 0 }, + ]) as never, + ], + }); + const tools = createTanStackTools({ + workspace, + exec: { shell: { description: "fast shell" } }, + streamEventName: "exec-progress", + format: "object", + }); + + const result = await tools.exec.execute({ command: "echo partial" } as never, { + toolCallId: "call-1", + emitCustomEvent: (name, value) => events.push({ name, value }), + }); + + expect(result).toMatchObject({ exitCode: 0, stdout: "partial\n" }); + expect(events.length).toBeGreaterThanOrEqual(1); + expect(events[0].name).toBe("exec-progress"); + await workspace.close(); + }); + + it("emits a running snapshot before the command produces more output", async () => { + const quiet = quietCommandBackend(); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [quiet.backend as never], + }); + const tools = createTanStackTools({ + workspace, + exec: { shell: { description: "fast shell" } }, + streamEventName: "exec-progress", + format: "object", + }); + let firstEvent: () => void = () => {}; + const emitted = new Promise((resolve) => { + firstEvent = resolve; + }); + const events: Array> = []; + + const pending = tools.exec.execute({ command: "build" } as never, { + toolCallId: "call-1", + emitCustomEvent: (_name, value) => { + events.push(value); + firstEvent(); + }, + }); + await emitted; + + expect(events[0]).toMatchObject({ + toolCallId: "call-1", + snapshot: { exitCode: null, stdout: "starting\n" }, + }); + quiet.release(); + expect(await pending).toMatchObject({ exitCode: 0 }); + // The terminal snapshot is returned, not emitted. + expect( + events.every((event) => (event.snapshot as { exitCode: unknown }).exitCode === null), + ).toBe(true); + await workspace.close(); + }); + + it("kills exec when the chat run's abort signal fires", async () => { + const quiet = quietCommandBackend(); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [quiet.backend as never], + }); + const tools = createTanStackTools({ + workspace, + exec: { shell: { description: "fast shell" } }, + format: "object", + }); + const controller = new AbortController(); + + const pending = tools.exec.execute({ command: "npm test" } as never, { + toolCallId: "call-1", + abortSignal: controller.signal, + }); + controller.abort(); + + await quiet.killed; + await pending; + await workspace.close(); + }); +}); diff --git a/packages/computer/src/tools/tanstack-ai/index.ts b/packages/computer/src/tools/tanstack-ai/index.ts new file mode 100644 index 00000000..186e8ed4 --- /dev/null +++ b/packages/computer/src/tools/tanstack-ai/index.ts @@ -0,0 +1,291 @@ +/** + * `inputSchema` is a Standard Schema, which Zod v4 implements, so the + * schemas from `../common` are passed through untouched. + */ + +import type { z } from "zod"; +import { defineExec } from "../common/exec.js"; +import { + deleteDescription, + deleteFromStore, + deleteInputSchema, + deleteOutputSchema, +} from "../common/fs/delete.js"; +import { + editDescription, + editInputSchema, + editInStore, + editOutputSchema, +} from "../common/fs/edit.js"; +import { findDescription, findInputSchema, findInWorkspace } from "../common/fs/find.js"; +import { grepDescription, grepInputSchema, grepInWorkspace } from "../common/fs/grep.js"; +import { listDescription, listInputSchema, listWorkspace } from "../common/fs/list.js"; +import { + createReadExecutor, + type ReadInput, + type ReadToolResult, + readDescription, + readInputSchema, + readModelOutput, +} from "../common/fs/read.js"; +import { + writeDescription, + writeInputSchema, + writeOutputSchema, + writeToStore, +} from "../common/fs/write.js"; +import { defaultModelOutput, type ModelOutput } from "../common/model-output.js"; +import { type CreateToolsOptions, resolveToolOptions } from "../common/options.js"; +import { + createPublishExecutor, + type PublishWorkspaceLike, + publishDescription, + publishInputSchema, + publishOutputSchema, +} from "../common/publish.js"; +import { isAsyncIterable, settle } from "../common/stream.js"; + +/** Structurally compatible with `toolDefinition().server()`, declared locally so `@tanstack/ai` is not a build-time dependency. */ +export interface TanStackTool { + name: string; + description: string; + inputSchema: z.ZodType; + outputSchema?: z.ZodType; + // biome-ignore lint/suspicious/noExplicitAny: matches the signature chat() calls + execute: (input: any, context?: TanStackToolExecutionContext) => Promise; + needsApproval?: boolean; + lazy?: boolean; + /** Phantom marker carrying no runtime value; declaring it satisfies the union `chat({ tools })` accepts. */ + readonly "~toolKind"?: undefined; +} + +export interface TanStackToolExecutionContext { + toolCallId?: string; + /** Fires when the chat run's `abortController` aborts; a running `exec` is killed. */ + abortSignal?: AbortSignal; + emitCustomEvent?: (eventName: string, value: Record) => void; +} + +export type TanStackToolList = TanStackTool[]; + +export type TanStackToolSet = Record>; + +/** `chat()`, `mergeAgentTools` and `createToolRegistry` all take an array, so `"array"` is the default. */ +export type TanStackToolFormat = "array" | "object"; + +export type TanStackToolsFor = Format extends "object" + ? TanStackToolSet + : TanStackToolList; + +export interface CreateTanStackToolsOptions + extends CreateToolsOptions { + format?: Format; + /** Tools that pause for approval. `"mutating"` selects every tool that changes workspace state. */ + approve?: string[] | "mutating"; + /** Forward pre-terminal `exec` snapshots through `emitCustomEvent` under this name; otherwise they are discarded. */ + streamEventName?: string; + /** Tools withheld from the prompt until TanStack lazy discovery asks for them. */ + lazy?: string[] | "all"; +} + +export function createTanStackTools( + options: CreateTanStackToolsOptions, +): TanStackToolsFor { + const resolved = resolveToolOptions(options); + const workspace = resolved.workspace; + const readExecutor = createReadExecutor(resolved.read); + const toReadOutput = readModelOutput(resolved.read); + + const tools: TanStackToolList = []; + const add = (entry: { + name: string; + description: string; + inputSchema: z.ZodType; + outputSchema?: z.ZodType; + mutates?: boolean; + streams?: boolean; + run: (input: never, context?: TanStackToolExecutionContext) => unknown; + }) => { + const needsApproval = wants(options.approve, entry.name, entry.mutates === true); + const lazy = wants(options.lazy, entry.name, options.lazy === "all"); + tools.push({ + name: entry.name, + description: entry.description, + inputSchema: entry.inputSchema, + // TanStack validates every return against this, errors included, + // so a success-only schema would mask the real failure reason. + outputSchema: entry.outputSchema, + needsApproval: needsApproval ? true : undefined, + lazy: lazy ? true : undefined, + execute: entry.run, + } as TanStackTool); + }; + + add({ + name: "read", + description: readDescription(resolved.read), + inputSchema: readInputSchema, + run: async (input: ReadInput) => { + const output = (await readExecutor(input)) as ReadToolResult; + return toTanStackOutput(toReadOutput({ input, output })); + }, + }); + + add({ + name: "ls", + description: listDescription, + inputSchema: listInputSchema, + run: async (input: never) => plain(await listWorkspace(workspace, input)), + }); + + add({ + name: "find", + description: findDescription, + inputSchema: findInputSchema, + run: async (input: never) => plain(await findInWorkspace(workspace, input)), + }); + + add({ + name: "grep", + description: grepDescription, + inputSchema: grepInputSchema, + run: async (input: never) => plain(await grepInWorkspace(workspace, input)), + }); + + if (!resolved.readonly) { + add({ + name: "write", + description: writeDescription, + inputSchema: writeInputSchema, + outputSchema: writeOutputSchema, + mutates: true, + run: async (input: never) => plain(await writeToStore(resolved.write, input)), + }); + + add({ + name: "edit", + description: editDescription, + inputSchema: editInputSchema, + outputSchema: editOutputSchema, + mutates: true, + run: async (input: never) => plain(await editInStore(resolved.edit, input)), + }); + + add({ + name: "delete", + description: deleteDescription, + inputSchema: deleteInputSchema, + outputSchema: deleteOutputSchema, + mutates: true, + run: async (input: never) => plain(await deleteFromStore(resolved.delete, input)), + }); + + if (resolved.exec !== undefined) { + const exec = defineExec(resolved.exec); + add({ + name: "exec", + description: exec.description, + inputSchema: exec.inputSchema, + mutates: true, + streams: true, + run: async (input: never, context?: TanStackToolExecutionContext) => { + const returned = exec.execute(input, { abortSignal: context?.abortSignal }); + const output = options.streamEventName + ? await settleWithEvents(returned, options.streamEventName, context) + : await settle(returned); + return plain(output); + }, + }); + } + + if (resolved.publish) { + const executor = createPublishExecutor(workspace as PublishWorkspaceLike); + add({ + name: "publish", + description: publishDescription, + inputSchema: publishInputSchema, + outputSchema: publishOutputSchema, + mutates: true, + run: async (input: never) => plain(await executor(input)), + }); + } + } + + if (options.format === "object") { + const set: TanStackToolSet = {}; + for (const tool of tools) set[tool.name] = tool; + // The generic resolves to one branch or the other at each call + // site, which a return inside the function cannot prove. + return set as TanStackToolsFor; + } + return tools as TanStackToolsFor; +} + +function plain(output: unknown): unknown { + return toTanStackOutput(defaultModelOutput(output)); +} + +/** + * Running snapshots are emitted as they arrive, so a command that + * prints once and goes quiet shows that output straight away. The + * terminal snapshot is returned rather than emitted, so a consumer + * ignoring custom events still sees the complete outcome. + */ +async function settleWithEvents( + returned: Promise | AsyncIterable, + eventName: string, + context: TanStackToolExecutionContext | undefined, +): Promise { + const emit = context?.emitCustomEvent; + if (!emit || !isAsyncIterable(returned)) return settle(returned); + + let last: Output | undefined; + let seen = false; + for await (const chunk of returned) { + if (isRunning(chunk)) { + emit(eventName, { toolCallId: context?.toolCallId, snapshot: chunk as never }); + } + last = chunk; + seen = true; + } + if (!seen) throw new Error("tool executor yielded no result"); + return last as Output; +} + +/** An exec snapshot is running until it carries an exit code or an error. */ +function isRunning(snapshot: unknown): boolean { + const s = snapshot as { exitCode?: unknown; error?: unknown }; + return s.exitCode === null && s.error === undefined; +} + +function wants(option: string[] | string | undefined, name: string, byTrait: boolean): boolean { + if (option === undefined) return false; + if (Array.isArray(option)) return option.includes(name); + return byTrait; +} + +/** + * TanStack passes a tool result through as multimodal content only when + * it is an array of content parts; anything else is JSON-stringified. So + * an image or PDF comes back as a text part plus an `image` or + * `document` part, which the adapter attaches rather than sending the + * base64 as text. + */ +function toTanStackOutput(output: ModelOutput): unknown { + switch (output.type) { + case "text": + return output.value; + case "error-text": + return { error: output.value }; + case "json": + return output.value; + case "media": + return [ + { type: "text", content: output.text }, + { + type: output.mediaType.startsWith("image/") ? "image" : "document", + source: { type: "data", value: output.data, mimeType: output.mediaType }, + }, + ]; + } +} diff --git a/packages/computer/src/workspace.ts b/packages/computer/src/workspace.ts index 25f797a2..d8d4b325 100644 --- a/packages/computer/src/workspace.ts +++ b/packages/computer/src/workspace.ts @@ -219,8 +219,7 @@ export class Workspace { readonly #backends: WorkspaceBackend[]; readonly #backendsById: Map; readonly #moduleBackendsById: Map; - readonly #registeredBackendIds: Set; - readonly #callableBackendIds: Set; + readonly #registeredBackends: Map; readonly #defaultBackendId: string | undefined; readonly #observer: WorkspaceObserver; readonly #syncLogger: SyncLogger; @@ -305,19 +304,16 @@ export class Workspace { this.#moduleBackendsById = new Map( registered.filter(isModuleBackend).map((backend) => [backend.id, backend]), ); - this.#registeredBackendIds = new Set(); - this.#callableBackendIds = new Set( - registered.filter((backend) => backend.callable === true).map((backend) => backend.id), - ); + this.#registeredBackends = new Map(); for (const backend of registered) { - if (this.#registeredBackendIds.has(backend.id)) { + if (this.#registeredBackends.has(backend.id)) { throw new Error( `Workspace: duplicate backend id ${JSON.stringify(backend.id)}. ` + "Pass an explicit `id` on each backend's constructor options to " + "distinguish them.", ); } - this.#registeredBackendIds.add(backend.id); + this.#registeredBackends.set(backend.id, backend); } this.#defaultBackendId = registered[0]?.id; this.#observer = options.observer ?? noopObserver; @@ -429,7 +425,7 @@ export class Workspace { get runtime(): WorkspaceRuntime { if (!this.#runtime) { this.#runtime = new WorkspaceRuntime({ - callableBackendIds: this.#callableBackendIds, + backends: this.#registeredBackends, backendHandle: (id) => this.#backendHandleFor(id), resolveBackendId: (id) => this.#resolveBackendId(id) ?? "", }); @@ -861,13 +857,13 @@ export class Workspace { // workspace; throws on an unknown id. Omitted ids fall through // to the first backend in the list (the default). #resolveBackendId(id: string | undefined): string | undefined { - if (this.#registeredBackendIds.size === 0) return undefined; + if (this.#registeredBackends.size === 0) return undefined; const target = id ?? this.#defaultBackendId; if (target === undefined) return undefined; - if (!this.#registeredBackendIds.has(target)) { + if (!this.#registeredBackends.has(target)) { throw new Error( `Workspace: no backend with id ${JSON.stringify(target)}. ` + - `Configured backends: ${[...this.#registeredBackendIds].map((key) => JSON.stringify(key)).join(", ") || ""}.`, + `Configured backends: ${[...this.#registeredBackends.keys()].map((key) => JSON.stringify(key)).join(", ") || ""}.`, ); } return target; @@ -998,6 +994,7 @@ export class Workspace { fs: this.#fs, git: this.#gitFactory ? this.git : DISABLED_GIT_CLIENT, artifacts: this.#artifacts, + runtime: this.runtime, }), ) .then(async (handle) => { diff --git a/packages/computer/tests/script-runner-worker.ts b/packages/computer/tests/script-runner-worker.ts index 244b548b..86fac5fc 100644 --- a/packages/computer/tests/script-runner-worker.ts +++ b/packages/computer/tests/script-runner-worker.ts @@ -1,4 +1,6 @@ import { DurableObject, RpcTarget, WorkerEntrypoint } from "cloudflare:workers"; +import type { ShellRPC, SyncRPC } from "@cloudflare/computer-rpc"; +import type { WorkspaceBackend } from "../src/backend.js"; import { WorkerJavaScriptBackend } from "../src/backends/worker-javascript/index.js"; import { createGitClient } from "../src/git/index.js"; import type { @@ -7,12 +9,55 @@ import type { WorkspaceStub, } from "../src/index.js"; import { Workspace } from "../src/index.js"; +import { createArtifactsModule } from "../src/modules/artifacts.js"; +import { createContainerModule } from "../src/modules/container.js"; +import { createGitModule } from "../src/modules/git.js"; export interface Env { HOST: DurableObjectNamespace; LOADER: WorkerLoader; } +// A command backend that stands in for the container. It echoes the +// command, working directory, one environment variable, and standard +// input, and exits with the length of the command. +function fakeContainerBackend(): WorkspaceBackend { + const encoder = new TextEncoder(); + const shell: ShellRPC = { + async exec(input) { + const id = input.id ?? crypto.randomUUID(); + const stdin = input.stdin ? new TextDecoder().decode(input.stdin) : ""; + const stdout = `ran ${input.source} in ${input.cwd ?? "?"} with ${input.env?.WHO ?? "-"} and ${stdin || "-"}\n`; + return { + id, + events: new ReadableStream({ + start(controller) { + controller.enqueue({ id, seq: 1, name: "stdout", value: encoder.encode(stdout) }); + controller.enqueue({ id, seq: 2, name: "stderr", value: encoder.encode("warn\n") }); + controller.enqueue({ id, seq: 3, name: "exit", code: input.source.length % 256 }); + controller.close(); + }, + }), + }; + }, + getExec: () => Promise.reject(new Error("not used")), + killExec: () => Promise.resolve(), + disposeExec: () => Promise.resolve(), + }; + // SAFETY: The fake backend declares sync "none", so the Workspace never calls these methods. + const sync = new Proxy( + {}, + { get: () => () => Promise.reject(new Error("sync: none")) }, + ) as SyncRPC; + return { + id: "container-shell", + type: "fake-container", + async connect() { + return { rpc: { sync, shell }, sync: "none", close: async () => {} }; + }, + }; +} + export class HostDO extends DurableObject { readonly #workspace: Workspace; @@ -30,27 +75,43 @@ export class HostDO extends DurableObject { maxConcurrentCapabilityCalls: 2, modules: { "math-kit": "export const double = (value) => value * 2;", - }, - trustedModules: { + "ws:git": createGitModule(), + "ws:artifacts": createArtifactsModule(), + "ws:container": createContainerModule(), "ws:test-host": { - async call(method, args) { - if (method === "invalid-result") return new Date() as never; - if (method === "large-error") throw new Error("x".repeat(5000)); - if (method === "slow") { - await new Promise((resolve) => setTimeout(resolve, 20)); - return null; - } - if (method === "marker") { - return { - __workspace_codec__: { version: 1, type: "bytes", data: [1] }, - keep: true, - }; - } - return { method, args }; + async echo(args) { + return { args: [...args] }; + }, + async sum(args) { + return args.reduce( + (total, value) => total + (typeof value === "number" ? value : 0), + 0, + ); + }, + async delete(args) { + return { deleted: args[0] ?? null }; + }, + async invalidResult() { + // SAFETY: The test hands the bridge a non-JSON value on purpose to check that it rejects it. + return new Date() as never; + }, + async largeError() { + throw new Error("x".repeat(5000)); + }, + async slow() { + await new Promise((resolve) => setTimeout(resolve, 20)); + return null; + }, + async marker() { + return { + __workspace_codec__: { version: 1, type: "bytes", data: [1] }, + keep: true, + }; }, }, }, }), + fakeContainerBackend(), ], }); } diff --git a/packages/computer/tests/script-runner.test.ts b/packages/computer/tests/script-runner.test.ts index 61c8b63f..b017a34f 100644 --- a/packages/computer/tests/script-runner.test.ts +++ b/packages/computer/tests/script-runner.test.ts @@ -49,14 +49,14 @@ describe("WorkspaceRuntime", () => { }); }); - it("executes an ES module with configured and trusted modules", async () => { + it("executes an ES module with source and host modules", async () => { const response = await runtime({ source: ` import { double } from "math-kit"; import fs from "node:fs/promises"; import { promises as nodeFs } from "node:fs"; import * as git from "ws:git"; - import { call } from "ws:test-host"; + import { echo, sum, delete as remove } from "ws:test-host"; export default async function main(input) { const value = double(input.value); await fs.writeFile("/workspace/runtime-result.txt", String(value)); @@ -65,7 +65,9 @@ describe("WorkspaceRuntime", () => { value, persisted: await fs.readFile("/workspace/runtime-result.txt", "utf8"), gitExitCode: initialized.exitCode, - trusted: await call("echo", input.value), + trusted: await echo(input.value, "second"), + summed: await sum(1, 2, 3), + removed: await remove("/workspace/gone.txt"), nodeFs: { isFile: (await nodeFs.stat("/workspace/runtime-result.txt")).isFile(), entries: await nodeFs.readdir("/workspace"), @@ -86,7 +88,9 @@ describe("WorkspaceRuntime", () => { value: 42, persisted: "42", gitExitCode: 0, - trusted: { method: "echo", args: [21] }, + trusted: { args: [21, "second"] }, + summed: 6, + removed: { deleted: "/workspace/gone.txt" }, nodeFs: { isFile: true, entries: expect.arrayContaining(["runtime-result.txt"]), @@ -100,14 +104,14 @@ describe("WorkspaceRuntime", () => { const response = await runtime({ source: ` import fs from "node:fs/promises"; - import { call } from "ws:test-host"; + import { marker } from "ws:test-host"; export default async () => { await fs.writeFile("/workspace/bytes.bin", new Uint8Array([0, 127, 255])); const value = await fs.readFile("/workspace/bytes.bin"); return { isBytes: value instanceof Uint8Array, bytes: Array.from(value), - marker: await call("marker"), + marker: await marker(), }; }; `, @@ -230,11 +234,11 @@ describe("WorkspaceRuntime", () => { expect(payload.result.stdout).toContain("stdio truncated"); }); - it("bounds oversized trusted-module error responses", async () => { + it("bounds oversized host module error responses", async () => { const response = await runtime({ source: ` - import { call } from "ws:test-host"; - export default () => call("large-error"); + import { largeError } from "ws:test-host"; + export default () => largeError(); `, cwd: "/workspace", }); @@ -261,9 +265,9 @@ describe("WorkspaceRuntime", () => { it("bounds concurrent host capability calls", async () => { const response = await runtime({ source: ` - import { call } from "ws:test-host"; + import { slow } from "ws:test-host"; export default async () => { - const settled = await Promise.allSettled([call("slow"), call("slow"), call("slow")]); + const settled = await Promise.allSettled([slow(), slow(), slow()]); return settled.map((item) => item.status); }; `, @@ -274,11 +278,11 @@ describe("WorkspaceRuntime", () => { expect(JSON.parse(text).result.value).toEqual(["fulfilled", "fulfilled", "rejected"]); }); - it("rejects non-plain results from host trusted modules", async () => { + it("rejects non-plain results from host modules", async () => { const response = await runtime({ source: ` - import { call } from "ws:test-host"; - export default () => call("invalid-result"); + import { invalidResult } from "ws:test-host"; + export default () => invalidResult(); `, cwd: "/workspace", }); @@ -292,6 +296,85 @@ describe("WorkspaceRuntime", () => { }); }); + it("exposes only the functions a host module declares", async () => { + const response = await runtime({ + source: ` + import * as host from "ws:test-host"; + export default () => Object.keys(host).sort(); + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text).result.value).toEqual([ + "delete", + "echo", + "invalidResult", + "largeError", + "marker", + "slow", + "sum", + ]); + }); + + it("fails to link an import the host module does not export", async () => { + const response = await runtime({ + source: ` + import { call } from "ws:test-host"; + export default () => call("echo"); + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text), text).toMatchObject({ + result: { status: "failed", stderr: expect.stringContaining("does not provide an export") }, + }); + }); + + it("runs container commands from isolate code through ws:container", async () => { + const response = await runtime({ + source: ` + import { exec } from "ws:container"; + export default () => + exec("npm test", { cwd: "/workspace/app", env: { WHO: "isolate" }, stdin: "y" }); + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text), text).toMatchObject({ + result: { + status: "completed", + value: { + exitCode: 8, + stdout: "ran npm test in /workspace/app with isolate and y\n", + stderr: "warn\n", + }, + }, + }); + }); + + it("rejects a malformed ws:container call inside the isolate", async () => { + const response = await runtime({ + source: ` + import { exec } from "ws:container"; + export default async () => { + try { + await exec("ls", { shell: "zsh" }); + return "ran"; + } catch (error) { + return error.message; + } + }; + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text).result.value).toContain('unknown option "shell"'); + }); + it("does not expose unrestricted host operations through the node:fs dispatcher", async () => { const response = await runtime({ source: ` @@ -356,7 +439,7 @@ describe("WorkspaceRuntime", () => { }); }); - it("confines trusted Git operations to the backend root", async () => { + it("confines ws:git operations to the backend root", async () => { const response = await runtime({ source: ` import { status } from "ws:git"; @@ -374,7 +457,7 @@ describe("WorkspaceRuntime", () => { }); }); - it("rejects Git CLI path overrides that bypass the runtime root", async () => { + it("confines a leading Git CLI -C to the runtime root", async () => { const response = await runtime({ source: ` import { cli } from "ws:git"; @@ -384,6 +467,43 @@ describe("WorkspaceRuntime", () => { }); const text = await response.text(); expect(response.status, text).toBe(200); + expect(JSON.parse(text), text).toMatchObject({ + result: { + status: "failed", + stderr: expect.stringContaining("must stay under /workspace"), + }, + }); + }); + + it("runs a Git CLI command in a leading -C directory", async () => { + const response = await runtime({ + source: ` + import { cli } from "ws:git"; + export default async () => { + await cli({ cwd: "/workspace", argv: ["init", "c-repo"] }); + return cli({ cwd: "/workspace", argv: ["-C", "c-repo", "rev-parse", "--show-toplevel"] }); + }; + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text).result, text).toMatchObject({ + status: "completed", + value: { exitCode: 0, stdout: expect.stringContaining("/workspace/c-repo") }, + }); + }); + + it("rejects Git CLI path overrides after the subcommand", async () => { + const response = await runtime({ + source: ` + import { cli } from "ws:git"; + export default () => cli({ cwd: "/workspace", argv: ["status", "--git-dir=/outside"] }); + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); expect(JSON.parse(text), text).toMatchObject({ result: { status: "failed", @@ -405,7 +525,7 @@ describe("WorkspaceRuntime", () => { expect(JSON.parse(text), text).toMatchObject({ result: { status: "failed", - stderr: expect.stringContaining("allowArtifac"), + stderr: expect.stringContaining("createArtifactsModule"), }, }); }); @@ -423,12 +543,12 @@ describe("WorkspaceRuntime", () => { expect(JSON.parse(text), text).toMatchObject({ result: { status: "failed", - stderr: expect.stringContaining("allowGitNetwork"), + stderr: expect.stringContaining("createGitModule"), }, }); }); - it("rejects trusted Git paths that traverse a symlink", async () => { + it("rejects ws:git paths that traverse a symlink", async () => { await write("/outside/repository/README.md", "outside"); await symlink("/outside/repository", "/workspace/linked-repository"); const response = await runtime({