diff --git a/.changeset/exec-tool-review-fixes.md b/.changeset/exec-tool-review-fixes.md new file mode 100644 index 00000000..220bb2ed --- /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. `CloudflareContainerBackend` 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 index a524ec8d..6594ddf7 100644 --- a/.changeset/exec-tool-single-backend.md +++ b/.changeset/exec-tool-single-backend.md @@ -4,4 +4,4 @@ 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.describe(id)`. For `WorkerJavaScriptBackend` that is its source language and every module code can import, so the module list the model reads cannot drift from `modules`. +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/modules.md b/.changeset/modules.md index 52f178a3..603c03c3 100644 --- a/.changeset/modules.md +++ b/.changeset/modules.md @@ -6,6 +6,6 @@ `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.describe(id)` returns. +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/docs/09_tool_interface.md b/docs/09_tool_interface.md index 6a9543a7..50d8b277 100644 --- a/docs/09_tool_interface.md +++ b/docs/09_tool_interface.md @@ -245,7 +245,7 @@ The tool uses forced removal, so deleting a missing path succeeds. Set `recursiv `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.describe(id)`). `WorkerJavaScriptBackend` describes its source language and every module code can import, so the list stays in step with `modules`. `WorkerShellBackend` and `CloudflareContainerBackend` describe their command sets and startup cost. A backend that says nothing gets a one-line default, so add text for a custom backend. +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 `CloudflareContainerBackend` describe their command sets 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: diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index 6e3f351d..33c31f9f 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -150,7 +150,7 @@ new WorkerJavaScriptBackend({ 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.describe(id)` 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: +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. 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/think/src/agent.ts b/examples/think/src/agent.ts index c706d3f4..2401300c 100644 --- a/examples/think/src/agent.ts +++ b/examples/think/src/agent.ts @@ -137,8 +137,8 @@ export class Assistant extends withWorkspaceContainer(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.", diff --git a/packages/computer/src/backends/container/cloudflare-container.test.ts b/packages/computer/src/backends/container/cloudflare-container.test.ts index f34a0135..4fcce561 100644 --- a/packages/computer/src/backends/container/cloudflare-container.test.ts +++ b/packages/computer/src/backends/container/cloudflare-container.test.ts @@ -177,6 +177,20 @@ function makeFakeHost(opts: FakeHostOptions = {}): FakeHost { const fakeWorkspace: WorkspaceRef = { binding: "TestDO", id: "abc123" }; describe("CloudflareContainerBackend", () => { + it.each([ + [undefined, "It has no network access."], + [{ mode: "none" as const }, "It has no network access."], + [{ mode: "direct" as const }, "It has network access."], + ])("describes network access that matches egress %o", (egress, expected) => { + const backend = new CloudflareContainerBackend({ + container: () => ({ getWorkspaceContainer: () => ({}) }) as never, + workspace: fakeWorkspace, + ...(egress === undefined ? {} : { egress }), + }); + + expect(backend.description).toContain(expected); + }); + test("connect() classifies a container start failure as transport", async () => { const platformError = new Error( "There is no container instance that can be provided to this Durable Object, try again later", diff --git a/packages/computer/src/backends/container/cloudflare-container.ts b/packages/computer/src/backends/container/cloudflare-container.ts index 29862b23..2438b533 100644 --- a/packages/computer/src/backends/container/cloudflare-container.ts +++ b/packages/computer/src/backends/container/cloudflare-container.ts @@ -138,6 +138,15 @@ export interface CloudflareContainerBackendOptions { } 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.", +}; + // Paths the egress proxy serves. The container assembles no paths of // its own, so these travel in the /connect request and both ends stay // in step from one place. @@ -180,9 +189,8 @@ function bearerMatches(header: string | null, expected: string | undefined): boo export class CloudflareContainerBackend implements WorkspaceBackend { readonly type = "cloudflare-container"; - /** What this backend tells a model: a full Linux shell that is slower to start. */ - readonly description = - "A shell in a full Linux container: npm, node, python, package managers, test runners, native binaries, and network access. Starts much more slowly than an in-Worker backend because the container must boot."; + /** What this backend tells a model: a full Linux shell, its network access, and its slow start. */ + readonly description: string; readonly id: string; readonly #options: Required< @@ -212,6 +220,11 @@ export class CloudflareContainerBackend 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/client.test.ts b/packages/computer/src/client.test.ts index 035b0e9b..7111edab 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.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 b98ab49a..c8764af6 100644 --- a/packages/computer/src/index.ts +++ b/packages/computer/src/index.ts @@ -70,6 +70,7 @@ export { type WorkspaceServiceProxyProps, } from "./proxy.js"; export type { WorkspaceEgressPolicy } from "./runtime/egress.js"; +export type { WorkspaceBackendInfo } from "./runtime/runtime.js"; export type { ModuleExecutionEnvelope, ModuleExecutionInput, diff --git a/packages/computer/src/runtime/runtime.ts b/packages/computer/src/runtime/runtime.ts index 64842597..bd43f8b9 100644 --- a/packages/computer/src/runtime/runtime.ts +++ b/packages/computer/src/runtime/runtime.ts @@ -28,6 +28,16 @@ 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; + /** 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; @@ -43,19 +53,16 @@ export class WorkspaceRuntime { return this.#options.backends.get(id)?.callable === true; } - // Every registered backend id, in registration order. The first is - // the default. The exec tool uses this when the caller does not pick - // backends itself. - backendIds(): string[] { - return [...this.#options.backends.keys()]; - } - - // What the named backend says about itself for a model: its source - // language and, for the JavaScript backend, the modules code can - // import. The exec tool adds it to the backend's entry so a caller - // does not have to repeat it. - describe(id: string): string | undefined { - return this.#options.backends.get(id)?.description; + // 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, + callable: backend.callable === true, + ...(backend.description === undefined ? {} : { description: backend.description }), + })); } exec(source: string): 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/tools/ai-sdk.test.ts b/packages/computer/src/tools/ai-sdk.test.ts index 66bed393..d7ff742f 100644 --- a/packages/computer/src/tools/ai-sdk.test.ts +++ b/packages/computer/src/tools/ai-sdk.test.ts @@ -1565,6 +1565,24 @@ describe("createAITools exec tool", () => { }); }); + it("lets exec win over the deprecated shell option", () => { + const workspace = { + runtime: { + async exec() { + throw new Error("not used"); + }, + }, + }; + + expect( + createAITools({ + workspace, + 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"/, @@ -1607,7 +1625,7 @@ describe("createAITools callable exec", () => { }), }; }, - isCallable: (id: string) => id === "js", + backends: () => [{ id: "js", callable: true }], }, }; const tools = createAITools({ @@ -1653,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({ @@ -1677,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({ @@ -1732,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({ @@ -1754,9 +1775,9 @@ describe("createAITools callable exec", () => { async exec() { throw new Error("not used"); }, - isCallable: () => true, - describe: (id: string) => - id === "js" ? "Modules: `ws:weather` exports `forecast`." : undefined, + backends: () => [ + { id: "js", callable: true, description: "Modules: `ws:weather` exports `forecast`." }, + ], }, }; const withBoth = createAITools({ @@ -1827,7 +1848,11 @@ describe("createAITools exec with one backend", () => { calls.push({ command, backend: options.backend, input: options.input }); return { result: async () => ({ exitCode: 0, stdout: "", stderr: "", value: 1 }) }; }, - isCallable: (id: string) => callable && id === "worker-javascript", + backends: () => [ + { id: "worker-javascript", callable }, + { id: "shell", callable: false }, + { id: "container", callable: false }, + ], }, }; return { calls, workspace }; @@ -1999,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.ts b/packages/computer/src/tools/ai-sdk.ts index f0593dac..b0157cb6 100644 --- a/packages/computer/src/tools/ai-sdk.ts +++ b/packages/computer/src/tools/ai-sdk.ts @@ -84,10 +84,12 @@ function execOptions( options: CreateAIToolsOptions, 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 }; } - if (options.exec !== undefined) return { backends: options.exec }; - return { backends: Object.fromEntries((runtime.backendIds?.() ?? []).map((id) => [id, {}])) }; + return { backends: Object.fromEntries((runtime.backends?.() ?? []).map(({ id }) => [id, {}])) }; } diff --git a/packages/computer/src/tools/exec.ts b/packages/computer/src/tools/exec.ts index 08eb864a..fd24385d 100644 --- a/packages/computer/src/tools/exec.ts +++ b/packages/computer/src/tools/exec.ts @@ -1,6 +1,6 @@ import { type Tool, tool } from "ai"; import { z } from "zod"; - +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"; @@ -60,18 +60,12 @@ 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 a backend says about itself for a model, such as the - // language it runs and the modules that code can import. The tool - // shows it after the caller's own text. - describe?(id: string): string | undefined; - // Every registered backend id. Used when the caller does not pick - // backends, and to reject an unknown id up front. - backendIds?(): string[]; + // 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[]; }; } @@ -143,14 +137,16 @@ export function createExecTool(options: ExecToolOptions): Tool backend.id); const single = backendIds.length === 1; + const known = runtime.backends?.(); const backends = selected.map(({ id, guidance }) => { - const callable = runtime.isCallable?.(id) === true; - const own = runtime.describe?.(id); + 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."); @@ -355,9 +351,9 @@ function describeTool(backends: readonly DescribedBackend[]): string { // Resolve the caller's choice to a list of backends. function selectBackends( backends: ExecBackends | undefined, - runtime: ExecWorkspaceLike["runtime"], + registered: readonly WorkspaceBackendInfo[] | undefined, ): Array<{ id: string; guidance: string | undefined }> { - const known = runtime.backendIds?.(); + const known = registered?.map((backend) => backend.id); let selected: Array<{ id: string; guidance: string | undefined }>; if (backends === undefined) { if (known === undefined) {