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
5 changes: 5 additions & 0 deletions .changeset/tool-bindings-module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": minor
---

Isolate JavaScript can call an agent's own tools through `ws:tools`. `createToolBindings()` from `@cloudflare/computer/modules/tools` builds the module from tool bindings, and `forPiTools()` from `@cloudflare/computer/tools/pi-ai` makes those bindings from pi tools or from `createPiTools()`.
2 changes: 2 additions & 0 deletions docs/09_tool_interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ 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.

`forPiTools()` serves pi tools to isolate JavaScript as `ws:tools`, through `createToolBindings()`. It takes the result of `createPiTools()` or executable pi tools of your own. See [`ws:tools`](17_isolate_javascript.md#wstools).

## 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.
Expand Down
40 changes: 39 additions & 1 deletion docs/17_isolate_javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ Caller source can import four kinds of module, and all of them are fixed when th
| Built in | Always installed | The isolate, backed by the Workspace | `node:fs`, `node:fs/promises` |
| Node.js | `nodejs_compat` in `compatibilityFlags`, the default | The isolate, provided by the runtime | `node:path`, `node:crypto` |
| 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 |
| Host | `modules: { "ws:name": { fn } }`, or a factory | The Durable Object | `ws:git`, `ws:container`, `ws:tools`, your own |

```ts
import { createArtifactsModule } from "@cloudflare/computer/modules/artifacts";
Expand Down Expand Up @@ -276,6 +276,44 @@ import { create, get, list, importArtifact, deleteArtifact } from "ws:artifacts"

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

### `ws:tools`

`createToolBindings()` from `@cloudflare/computer/modules/tools` exports the agent's own tools to JavaScript, each under its own name. A loop over a hundred files is then one `exec` call instead of a hundred model turns:

```js
import { grep, read } from "ws:tools";

export default async function ({ files }) {
const todos = {};
for (const path of files) {
const { text, isError } = await grep({ query: "TODO", path });
if (!isError && text.trim()) todos[path] = text;
}
return todos;
}
```

Each export takes one object of the tool's arguments, the same object the model would pass, and resolves with `{ text, details, isError }`, plus `structuredContent` when the tool returns it. A tool that throws rejects the call. `exec` is left out by default, since the code is already running inside `exec` and a run that can start runs has no bound on how deep it goes. Pass `exclude` to change the list.

What a tool is and how it runs belongs to the agent library, so `createToolBindings()` takes bindings: a name, and a function to call with the input and the call's context. `forPiTools()` from `@cloudflare/computer/tools/pi-ai` builds them from pi tools:

```ts
import { validateToolArguments } from "@earendil-works/pi-ai";
import { createToolBindings } from "@cloudflare/computer/modules/tools";
import { forPiTools } from "@cloudflare/computer/tools/pi-ai";

new WorkerJavaScriptBackend({
loader: env.LOADER,
modules: {
"ws:tools": createToolBindings(forPiTools(() => agent.tools, { validate: validateToolArguments })),
},
});
```

`forPiTools()` takes executable tools in the shape `@earendil-works/pi-agent-core` defines, or the result of `createPiTools()`. It runs a call the way pi's agent loop runs a model's call: `prepareArguments`, then `validate`, then `execute` with a fresh call id and the call's abort signal. Pass pi-ai's `validateToolArguments` as `validate` to hold code to the same check as the model; without it, arguments reach the tool unchecked. Text parts of the result are joined, and an image is named in place, as `[image: image/png]`.

Pass a function of tools, rather than a list, when the tools need something that does not exist yet when the backend is constructed, such as the Workspace they act on. The function runs each time the backend connects. A tool whose name cannot be a JavaScript export, such as `list-files`, fails the connection with a message naming it; leave it out with `exclude`.

### `ws:container`

`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:
Expand Down
3 changes: 2 additions & 1 deletion packages/computer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,9 +436,10 @@ on a computerd instance.
| `@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/modules/tools` | `createToolBindings()` for `ws:tools`: an agent's own tools, callable 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/pi-ai` | `createPiTools()`: the same tool set for pi (`@earendil-works/pi-ai`), and `forPiTools()` to serve pi tools as `ws:tools`. |
| `@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. |
Expand Down
4 changes: 4 additions & 0 deletions packages/computer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@
"types": "./dist/modules/artifacts.d.ts",
"import": "./dist/modules/artifacts.js"
},
"./modules/tools": {
"types": "./dist/modules/tools.d.ts",
"import": "./dist/modules/tools.js"
},
"./tools": {
"types": "./dist/tools/index.d.ts",
"import": "./dist/tools/index.js"
Expand Down
1 change: 1 addition & 0 deletions packages/computer/rolldown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export default defineConfig({
"modules/container": "src/modules/container.ts",
"modules/git": "src/modules/git.ts",
"modules/artifacts": "src/modules/artifacts.ts",
"modules/tools": "src/modules/tools.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",
Expand Down
130 changes: 130 additions & 0 deletions packages/computer/src/modules/tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";

import type {
WorkspaceModuleCallContext,
WorkspaceModuleFunctions,
WorkspaceModuleHost,
} from "../runtime/types.js";
import { createToolBindings, type ToolBinding } from "./tools.js";

// SAFETY: Tool bindings never touch the host's Git, Artifacts, or runtime.
const host = {} as WorkspaceModuleHost;

function callContext(
overrides: Partial<WorkspaceModuleCallContext> = {},
): WorkspaceModuleCallContext {
return {
signal: new AbortController().signal,
deadline: Date.now() + 60_000,
access: "read-write",
resolvePath: async (path) => path,
...overrides,
};
}

// A binding that records each call and echoes its input back as details.
function recording(name: string) {
const calls: { input: unknown; context: WorkspaceModuleCallContext }[] = [];
const binding: ToolBinding = {
name,
call(input, context) {
calls.push({ input, context });
return { text: `${name} ran`, details: input, isError: false };
},
};
return { binding, calls };
}

function call(functions: WorkspaceModuleFunctions, name: string, args: unknown[]) {
const fn = functions[name];
if (!fn) throw new Error(`no export ${name}`);
// SAFETY: Tests pass values the isolate could send, and some it should not.
return fn(args as never, callContext());
}

describe("createToolBindings", () => {
it("exports each tool under its own name and passes the call through", async () => {
const read = recording("read");
const grep = recording("grep");
const functions = createToolBindings([read.binding, grep.binding])(host);

expect(Object.keys(functions).sort()).toEqual(["grep", "read"]);
const context = callContext();
await expect(functions.read?.([{ path: "a.txt" }], context)).resolves.toEqual({
text: "read ran",
details: { path: "a.txt" },
isError: false,
});
expect(read.calls).toEqual([{ input: { path: "a.txt" }, context }]);
expect(grep.calls).toEqual([]);
});

it("leaves out exec by default so a run cannot start runs", () => {
const functions = createToolBindings([recording("exec").binding, recording("read").binding])(
host,
);
expect(Object.keys(functions)).toEqual(["read"]);
});

it("leaves out the tools named in exclude instead of exec", () => {
const functions = createToolBindings(
[recording("exec").binding, recording("read").binding, recording("write").binding],
{ exclude: ["write"] },
)(host);
expect(Object.keys(functions).sort()).toEqual(["exec", "read"]);
});

it("reads a function of tools each time a backend connects", () => {
let tools = [recording("read").binding];
const factory = createToolBindings(() => tools);

expect(Object.keys(factory(host))).toEqual(["read"]);
tools = [recording("read").binding, recording("grep").binding];
expect(Object.keys(factory(host)).sort()).toEqual(["grep", "read"]);
});

it("treats a call with no arguments as an empty object", async () => {
const read = recording("read");
await call(createToolBindings([read.binding])(host), "read", []);
expect(read.calls[0]?.input).toEqual({});
});

it.each([
["two arguments", [{ path: "a" }, { path: "b" }]],
["an array", [["a"]]],
["null", [null]],
["a string", ["a.txt"]],
])("refuses %s, since every tool takes one object", async (_label, args) => {
const read = recording("read");
const functions = createToolBindings([read.binding])(host);
await expect(async () => call(functions, "read", args)).rejects.toThrow(
"read(arguments) takes one object of the tool's arguments.",
);
expect(read.calls).toEqual([]);
});

it("names a tool whose name cannot be an export, and how to leave it out", () => {
const factory = createToolBindings([
recording("read").binding,
recording("list-files").binding,
]);
expect(() => factory(host)).toThrow(
'Tool "list-files" cannot be exported from ws:tools: export names must be JavaScript identifiers, and not "default" or "then". Leave it out with the exclude option.',
);
});

it("refuses two tools with the same name", () => {
const factory = createToolBindings([recording("read").binding, recording("read").binding]);
expect(() => factory(host)).toThrow('Two tools are named "read".');
});

it("describes the module for a model, saying which tools are left out", () => {
expect(createToolBindings([]).description).toBe(
"The agent's own tools, callable from code, under the same names. Each takes one object of the tool's arguments and returns `{ text, details, isError }`. There is no `exec` export: importing it fails.",
);
expect(createToolBindings([], { exclude: [] }).description).toBe(
"The agent's own tools, callable from code, under the same names. Each takes one object of the tool's arguments and returns `{ text, details, isError }`.",
);
expect(createToolBindings([], { description: "Custom." }).description).toBe("Custom.");
});
});
130 changes: 130 additions & 0 deletions packages/computer/src/modules/tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// `ws:tools`: an agent's own tools for isolate JavaScript.
//
// import { read, grep } from "ws:tools";
// const { text, details, isError } = await read({ path: "README.md" });
//
// Each tool becomes an export under its own name, taking the one object
// of arguments the model would pass. What a tool is and how it runs
// belongs to the agent library, so this module takes bindings: a name
// and a function to call. `forPiTools` in `@cloudflare/computer/tools/pi-ai`
// builds them from pi tools.
//
// `exec` is left out by default. The code calling these tools is
// already running inside `exec`, and a run that can start runs has no
// bound on how deep it goes.

import type {
WorkspaceModuleCallContext,
WorkspaceModuleFactory,
WorkspaceModuleFunction,
WorkspaceModuleFunctions,
WorkspaceModuleHost,
WorkspaceRuntimeValue,
} from "../runtime/types.js";

/** One tool, as code calls it. */
export interface ToolBinding {
readonly name: string;
/**
* Run the tool. `input` is the single object the code passed, or `{}`
* when it passed nothing. Check it as strictly as a model's call.
*/
call(
input: Record<string, WorkspaceRuntimeValue>,
context: WorkspaceModuleCallContext,
): Promise<ToolBindingResult> | ToolBindingResult;
}

/** What a tool call returns to code. */
export interface ToolBindingResult {
/** The tool's text output, as the model would read it. */
readonly text: string;
/** Whether the tool reported a failure. A tool that throws rejects the call instead. */
readonly isError: boolean;
/** The tool's structured result, for code to read. `null` when it has none. */
readonly details?: WorkspaceRuntimeValue;
/** Present when the tool also returns machine-readable output. */
readonly structuredContent?: WorkspaceRuntimeValue;
}

/** The tools to export, or a function returning them, called each time a backend connects. */
export type ToolBindings = Iterable<ToolBinding> | (() => Iterable<ToolBinding>);

/** Options for {@link createToolBindings}. */
export interface ToolBindingsOptions {
/** Tools never exported. Defaults to `["exec"]`; pass `[]` to export every tool. */
readonly exclude?: readonly string[];
/** Replaces the module's description for a model. */
readonly description?: string;
}

// The JavaScript backend's own rule for host module export names.
const EXPORT_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
const RESERVED_EXPORT_NAMES = new Set(["default", "then"]);

/**
* Build the `ws:tools` host module.
*
* Pass a function rather than a list when the tools depend on something
* that does not exist yet when the backend is constructed, such as the
* Workspace they act on. The function runs once per connection, so
* tools added later reach the next connection, not this one.
*
* @param bindings - The tools, or a function returning them.
* @param options - Tools to leave out, and a replacement description.
* @returns The module to pass as `modules["ws:tools"]`.
* @throws When the backend connects, if a tool's name cannot be an
* export name or two tools share a name.
*/
export function createToolBindings(
bindings: ToolBindings,
options: ToolBindingsOptions = {},
): WorkspaceModuleFactory {
const exclude = new Set(options.exclude ?? ["exec"]);
const create = (_host: WorkspaceModuleHost): WorkspaceModuleFunctions => {
const functions: Record<string, WorkspaceModuleFunction> = {};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Tool named proto disappears

A tool named __proto__ passes validation, but assigning its function to functions invokes the inherited setter. The export disappears, and a sole __proto__ tool blocks backend connection.

Suggested change
const functions: Record<string, WorkspaceModuleFunction> = {};
const functions: Record<string, WorkspaceModuleFunction> = Object.create(null);

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

for (const binding of typeof bindings === "function" ? bindings() : bindings) {
if (exclude.has(binding.name)) continue;
assertExportName(binding.name);
if (Object.hasOwn(functions, binding.name)) {
throw new Error(`Two tools are named ${JSON.stringify(binding.name)}.`);
}
functions[binding.name] = async (args, context) =>
binding.call(singleObject(binding.name, args), context);
Comment on lines +92 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Read-only executions can modify Workspace files

On a read-only backend, createToolBindings still exposes mutating agent tools and calls them without checking context.access. Those tools use the host Workspace, so write or delete can change files despite read-only execution.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +92 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Tool calls escape the backend root

With a backend rooted below Workspace root, createToolBindings passes tool paths unchanged instead of using context.resolvePath. Bound filesystem tools then access files outside the backend root.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}
return functions;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Empty tool sets block execution

When no tools remain after filtering, createToolBindings returns an empty module. assertHostModuleExports rejects it during connection, so even executions without ws:tools fail.

Learn more

The JavaScript backend checks every host module's exports when it connects, before running any source. A tool list can be empty, or the default exec exclusion can remove its only tool. In either case, this factory returns no functions, and assertHostModuleExports rejects the entire connection. No execution can start, even one that never imports ws:tools.

Example: With createToolBindings(forPiTools([execTool])), exec is excluded. A script importing only node:path fails to start because the ws:tools factory exports nothing.

Recommended fix: Define how an empty ws:tools module is represented and ensure the backend accepts that representation. Alternatively, fail at configuration with a targeted message if empty tool sets are unsupported; test both an empty list and an all-excluded list.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

};
return Object.assign(create, {
description: options.description ?? describe([...exclude]),
});
}

function assertExportName(name: string) {
if (EXPORT_NAME.test(name) && !RESERVED_EXPORT_NAMES.has(name)) return;
throw new Error(
`Tool ${JSON.stringify(name)} cannot be exported from ws:tools: export names must be JavaScript identifiers, and not "default" or "then". Leave it out with the exclude option.`,
);
}

function singleObject(
name: string,
args: readonly WorkspaceRuntimeValue[],
): Record<string, WorkspaceRuntimeValue> {
const [input = {}, ...rest] = args;
if (rest.length > 0 || input === null || typeof input !== "object" || Array.isArray(input)) {
throw new TypeError(`${name}(arguments) takes one object of the tool's arguments.`);
}
return input;
}

// The description cannot list the tools: the backend reads it when it
// is constructed, before a function of tools has run.
function describe(excluded: readonly string[]): string {
const base =
"The agent's own tools, callable from code, under the same names. Each takes one object of the tool's arguments and returns `{ text, details, isError }`.";
if (excluded.length === 0) return base;
const names = excluded.map((name) => `\`${name}\``);
return excluded.length === 1
? `${base} There is no ${names[0]} export: importing it fails.`
: `${base} There are no ${names.join(" or ")} exports: importing them fails.`;
}
Loading
Loading