Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/exec-tool-single-backend.md
Original file line number Diff line number Diff line change
@@ -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, `defaultBackend` becomes optional, 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 `shell: { backends: { "worker-javascript": {} } }` is enough and the module list the model reads cannot drift from `modules`. A backend `description` is required only for a backend that does not describe itself.
25 changes: 23 additions & 2 deletions docs/09_tool_interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,16 @@ 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:
Pass `shell` only when the Workspace has matching backend ids. With one backend, `exec` has no `backend` argument and always runs there:

```ts
const tools = createAITools({
workspace,
shell: { backends: { "worker-javascript": {} } },
});
```

With more than one, pass `defaultBackend` and the model picks a backend per call:

```ts
const tools = createAITools({
Expand Down Expand Up @@ -244,7 +253,19 @@ 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` is opt-in. It calls `workspace.runtime.exec` with the configured backend and streams bounded output.

Each backend's entry in the tool description joins two parts: the `description` you pass, 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 `{ "worker-javascript": {} }` is enough and the list stays in step with `modules`. A backend that does not describe itself needs a `description`. Describe capabilities and startup cost in plain language.

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`, `env`, plus `input` when any is callable. `defaultBackend` is required. |

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.

Expand Down
4 changes: 2 additions & 2 deletions docs/17_isolate_javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,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. 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.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:

```text
`command` is ECMAScript module source, run in an isolated JavaScript runtime. Relative imports resolve from `cwd` in the workspace.
Expand All @@ -161,7 +161,7 @@ Modules code can import:
- `ws:weather`: exports `forecast`.
```

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.
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

Expand Down
37 changes: 37 additions & 0 deletions packages/computer/src/text-truncation.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
174 changes: 173 additions & 1 deletion packages/computer/src/tools/ai.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
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 { createGitModule } from "../modules/git.js";
import type { WorkspaceRuntimeExecHandle, WorkspaceRuntimeResult } from "../runtime/types.js";
import { Workspace } from "../workspace.js";
import {
Expand Down Expand Up @@ -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<string, unknown> };
return Object.keys(json.properties ?? {}).sort();
}

function makeWorkspace(): Workspace {
return new Workspace({ storage: new SQLiteTestStorage(), now: () => 1_700_000_000_000 });
}
Expand Down Expand Up @@ -1697,7 +1711,165 @@ 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");
},
isCallable: () => true,
describe: (id: string) =>
id === "js" ? "Modules: `ws:weather` exports `forecast`." : undefined,
},
};
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("requires a description for a backend that does not describe itself", () => {
const workspace = {
runtime: {
async exec() {
throw new Error("not used");
},
},
};

expect(() => createAITools({ workspace, shell: { backends: { shell: {} } } })).toThrow(
/does not describe itself/,
);
});
});

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 }) };
},
isCallable: (id: string) => callable && id === "worker-javascript",
},
};
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 defaultBackend when more than one backend is configured", () => {
const { workspace } = recordingWorkspace(false);

expect(() =>
createAITools({
workspace,
shell: {
backends: { shell: { description: "Fast shell." }, container: { description: "Linux." } },
},
}),
).toThrow(/defaultBackend/);
});
});

Expand Down
Loading
Loading