Repository navigation
computer: Serve an agent's own tools to isolate JavaScript as ws:tools #216
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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()`. |
| 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."); | ||
| }); | ||
| }); |
| 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> = {}; | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟥 Read-only executions can modify Workspace files On a read-only backend, Was this helpful? React with 👍 or 👎 to provide feedback.
Comment on lines
+92
to
+93
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
| return functions; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Empty tool sets block execution When no tools remain after filtering, Learn moreThe JavaScript backend checks every host module's exports when it connects, before running any source. A tool list can be empty, or the default Example: With Recommended fix: Define how an empty 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.`; | ||
| } | ||
There was a problem hiding this comment.
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 tofunctionsinvokes the inherited setter. The export disappears, and a sole__proto__tool blocks backend connection.Was this helpful? React with 👍 or 👎 to provide feedback.