diff --git a/.changeset/tool-bindings-module.md b/.changeset/tool-bindings-module.md new file mode 100644 index 00000000..095e787a --- /dev/null +++ b/.changeset/tool-bindings-module.md @@ -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()`. diff --git a/docs/09_tool_interface.md b/docs/09_tool_interface.md index 37cd862f..9e1ce5a8 100644 --- a/docs/09_tool_interface.md +++ b/docs/09_tool_interface.md @@ -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. diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index bfae193e..36d1e13e 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -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"; @@ -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: diff --git a/packages/computer/README.md b/packages/computer/README.md index 4e52b6ee..b471e017 100644 --- a/packages/computer/README.md +++ b/packages/computer/README.md @@ -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. | diff --git a/packages/computer/package.json b/packages/computer/package.json index 6ef5b251..dc659e8e 100644 --- a/packages/computer/package.json +++ b/packages/computer/package.json @@ -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" diff --git a/packages/computer/rolldown.config.ts b/packages/computer/rolldown.config.ts index 22b9a8a2..2abc787d 100644 --- a/packages/computer/rolldown.config.ts +++ b/packages/computer/rolldown.config.ts @@ -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", diff --git a/packages/computer/src/modules/tools.test.ts b/packages/computer/src/modules/tools.test.ts new file mode 100644 index 00000000..4f52e26d --- /dev/null +++ b/packages/computer/src/modules/tools.test.ts @@ -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 { + 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."); + }); +}); diff --git a/packages/computer/src/modules/tools.ts b/packages/computer/src/modules/tools.ts new file mode 100644 index 00000000..6dbd9add --- /dev/null +++ b/packages/computer/src/modules/tools.ts @@ -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, + context: WorkspaceModuleCallContext, + ): Promise | 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 | (() => Iterable); + +/** 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 = {}; + 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); + } + return functions; + }; + 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 { + 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.`; +} diff --git a/packages/computer/src/tools/pi-ai/bindings.test.ts b/packages/computer/src/tools/pi-ai/bindings.test.ts new file mode 100644 index 00000000..080408ba --- /dev/null +++ b/packages/computer/src/tools/pi-ai/bindings.test.ts @@ -0,0 +1,187 @@ +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { validateToolArguments } from "@earendil-works/pi-ai"; +import { describe, expect, it } from "vitest"; + +import { createToolBindings } from "../../modules/tools.js"; +import type { WorkspaceModuleCallContext, WorkspaceModuleHost } from "../../runtime/types.js"; +import { Workspace } from "../../workspace.js"; +import { createPiTools, forPiTools, type PiAgentTool, type PiAgentToolResult } from "./index.js"; + +// SAFETY: Tool bindings never touch the host's Git, Artifacts, or runtime. +const host = {} as WorkspaceModuleHost; + +function callContext(): WorkspaceModuleCallContext { + return { + signal: new AbortController().signal, + deadline: Date.now() + 60_000, + access: "read-write", + resolvePath: async (path) => path, + }; +} + +const pathParameters = { + type: "object" as const, + properties: { path: { type: "string" } }, + required: ["path"], + additionalProperties: false, +}; + +// A pi agent tool that records what reached it and answers with `result`. +function agentTool(name: string, result: PiAgentToolResult, extra: Partial = {}) { + const seen: { callId: string; params: unknown; signal: AbortSignal | undefined }[] = []; + const tool: PiAgentTool = { + name, + description: `The ${name} tool.`, + parameters: pathParameters, + async execute(callId, params, signal) { + seen.push({ callId, params, signal }); + return result; + }, + ...extra, + }; + return { tool, seen }; +} + +function bindingFor(source: ReturnType, name: string) { + const binding = [...source()].find((candidate) => candidate.name === name); + if (!binding) throw new Error(`no ${name} binding`); + return binding; +} + +describe("forPiTools", () => { + it("prepares, validates, then runs a tool with a call id and the call's signal", async () => { + const order: string[] = []; + const { tool, seen } = agentTool( + "read", + { content: [{ type: "text", text: "hello" }], details: { bytes: 5 } }, + { + prepareArguments(args) { + order.push("prepare"); + return { ...(args as object), path: "prepared.txt" }; + }, + }, + ); + const context = callContext(); + const result = await bindingFor( + forPiTools([tool], { + validate(candidate, call) { + order.push("validate"); + expect(candidate).toBe(tool); + return validateToolArguments(candidate, call); + }, + }), + "read", + ).call({ path: "raw.txt" }, context); + + expect(order).toEqual(["prepare", "validate"]); + expect(seen).toEqual([ + { + callId: expect.stringMatching(/^ws-tools-/), + params: { path: "prepared.txt" }, + signal: context.signal, + }, + ]); + expect(result).toEqual({ text: "hello", details: { bytes: 5 }, isError: false }); + }); + + it("rejects a call that fails validation without running the tool", async () => { + const { tool, seen } = agentTool("read", { content: [] }); + const binding = bindingFor(forPiTools([tool], { validate: validateToolArguments }), "read"); + + await expect(binding.call({ file: "a.txt" }, callContext())).rejects.toThrow( + 'Validation failed for tool "read"', + ); + expect(seen).toEqual([]); + }); + + it("passes arguments through unchecked when no validator is given", async () => { + const { tool, seen } = agentTool("read", { content: [] }); + await bindingFor(forPiTools([tool]), "read").call({ anything: [1, 2] }, callContext()); + expect(seen[0]?.params).toEqual({ anything: [1, 2] }); + }); + + it("joins text parts, names images, and keeps the error flag and structured content", async () => { + const { tool } = agentTool("screenshot", { + content: [ + { type: "text", text: "Captured the page." }, + { type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" }, + { type: "text", text: "It is mostly blue." }, + ], + details: undefined, + structuredContent: { width: 800 }, + isError: true, + }); + await expect( + bindingFor(forPiTools([tool]), "screenshot").call({ path: "x" }, callContext()), + ).resolves.toEqual({ + text: "Captured the page.\n[image: image/png]\nIt is mostly blue.", + details: null, + structuredContent: { width: 800 }, + isError: true, + }); + }); + + it("lets a tool's own failure reject the call", async () => { + const { tool } = agentTool("read", { content: [] }); + tool.execute = async () => { + throw new Error("disk on fire"); + }; + await expect( + bindingFor(forPiTools([tool]), "read").call({ path: "x" }, callContext()), + ).rejects.toThrow("disk on fire"); + }); + + it("reads a function of tools each time its bindings are taken", () => { + let tools = [agentTool("read", { content: [] }).tool]; + const source = forPiTools(() => tools); + expect([...source()].map((binding) => binding.name)).toEqual(["read"]); + tools = [...tools, agentTool("grep", { content: [] }).tool]; + expect([...source()].map((binding) => binding.name)).toEqual(["read", "grep"]); + }); + + it("serves pi tools as ws:tools exports, leaving out exec", async () => { + const read = agentTool("read", { content: [{ type: "text", text: "contents" }] }); + const exec = agentTool("exec", { content: [] }); + const functions = createToolBindings( + forPiTools(() => [read.tool, exec.tool], { validate: validateToolArguments }), + )(host); + + expect(Object.keys(functions)).toEqual(["read"]); + await expect(functions.read?.([{ path: "a.txt" }], callContext())).resolves.toEqual({ + text: "contents", + details: null, + isError: false, + }); + }); + + it("serves the tools createPiTools makes over a Workspace", async () => { + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + now: () => 1_700_000_000_000, + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + await workspace.fs.writeFile("/workspace/notes.txt", "remember the milk\n"); + const functions = createToolBindings(forPiTools(createPiTools({ workspace })))(host); + + expect(Object.keys(functions).sort()).toEqual([ + "delete", + "edit", + "find", + "grep", + "ls", + "read", + "write", + ]); + const found = (await functions.grep?.( + [{ query: "milk", path: "/workspace" }], + callContext(), + )) as { + text: string; + isError: boolean; + }; + expect(found.isError).toBe(false); + expect(found.text).toContain("notes.txt"); + // createPiTools reports a bad call as an error result rather than throwing. + await expect(functions.read?.([{}], callContext())).resolves.toMatchObject({ isError: true }); + }); +}); diff --git a/packages/computer/src/tools/pi-ai/bindings.ts b/packages/computer/src/tools/pi-ai/bindings.ts new file mode 100644 index 00000000..cd9bef1c --- /dev/null +++ b/packages/computer/src/tools/pi-ai/bindings.ts @@ -0,0 +1,140 @@ +/** + * pi tools as `ws:tools` bindings, so code running in `exec` can call + * the same tools the model does. + * + * modules: { "ws:tools": createToolBindings(forPiTools(() => tools, { validate: validateToolArguments })) } + * + * A pi `Tool` from `@earendil-works/pi-ai` is only a declaration: the + * agent loop around it runs it. So this takes either executable tools in + * the shape `@earendil-works/pi-agent-core` defines, or the declarations + * and dispatcher `createPiTools` returns. + */ + +import type { ToolBinding, ToolBindingResult } from "../../modules/tools.js"; +import type { WorkspaceRuntimeValue } from "../../runtime/types.js"; +import type { CreatePiToolsResult, PiToolResultContent } from "./index.js"; + +/** + * Structurally compatible with `AgentTool` from + * `@earendil-works/pi-agent-core`, declared locally so pi is not a + * build-time dependency. + */ +export interface PiAgentTool { + name: string; + description: string; + parameters: object; + /** Repairs raw arguments before they are validated, as pi's agent loop does. */ + prepareArguments?: (args: unknown) => unknown; + execute(toolCallId: string, params: unknown, signal?: AbortSignal): Promise; +} + +/** Structurally compatible with `AgentToolResult` from `@earendil-works/pi-agent-core`. */ +export interface PiAgentToolResult { + content: readonly (PiToolResultContent | { type: string })[]; + details?: unknown; + structuredContent?: unknown; + isError?: boolean; +} + +/** The tool call a validator receives, shaped like pi-ai's `ToolCall`. */ +export interface PiToolCallRequest { + type: "toolCall"; + id: string; + name: string; + // biome-ignore lint/suspicious/noExplicitAny: pi-ai's ToolCall types arguments as its own JSON object, which only `any` matches without a dependency on pi-ai. + arguments: Record; +} + +/** Options for {@link forPiTools}. */ +export interface ForPiToolsOptions { + /** + * Checks a call's arguments against the tool's schema and returns the + * ones to run with. Pass pi-ai's `validateToolArguments` so code is + * held to the same check as the model. Without it, arguments reach + * the tool unchecked. + */ + validate?: (tool: T, call: PiToolCallRequest) => unknown; +} + +/** + * Bindings for {@link createToolBindings} from pi tools. + * + * Each call runs the way pi's agent loop runs a model's call: + * `prepareArguments` first, then `validate`, then `execute` with a fresh + * call id and the call's abort signal. A failure in any step rejects the + * call. A result the tool marks `isError` resolves with `isError: true`. + * + * @param tools - Executable pi tools, a function returning them, or the + * result of `createPiTools`. + * @param options - The validator to check arguments with. + * @returns A function returning the bindings, called each time a + * backend connects. + */ +export function forPiTools( + tools: readonly T[] | (() => readonly T[]), + options?: ForPiToolsOptions, +): () => ToolBinding[]; +export function forPiTools(tools: CreatePiToolsResult): () => ToolBinding[]; +export function forPiTools( + tools: readonly T[] | (() => readonly T[]) | CreatePiToolsResult, + options: ForPiToolsOptions = {}, +): () => ToolBinding[] { + if (typeof tools === "function") return () => tools().map((tool) => agentBinding(tool, options)); + if (Array.isArray(tools)) return () => tools.map((tool) => agentBinding(tool, options)); + const { tools: declarations, execute } = tools as CreatePiToolsResult; + return () => + declarations.map((declaration) => ({ + name: declaration.name, + async call(input, context) { + const result = await execute( + { id: callId(), name: declaration.name, arguments: input }, + { abortSignal: context.signal }, + ); + return bindingResult(result); + }, + })); +} + +function agentBinding(tool: T, options: ForPiToolsOptions): ToolBinding { + return { + name: tool.name, + async call(input, context) { + const id = callId(); + const prepared = tool.prepareArguments ? tool.prepareArguments(input) : input; + const args = options.validate + ? options.validate(tool, { + type: "toolCall", + id, + name: tool.name, + arguments: prepared as PiToolCallRequest["arguments"], + }) + : prepared; + return bindingResult(await tool.execute(id, args, context.signal)); + }, + }; +} + +function callId(): string { + return `ws-tools-${crypto.randomUUID()}`; +} + +// Code reads text, so an image is named in place rather than handed +// back as base64 it would have to recognize and skip. +function bindingResult(result: PiAgentToolResult): ToolBindingResult { + const text = result.content + .map((part) => { + if (part.type === "text") return (part as { text: string }).text; + if (part.type === "image") return `[image: ${(part as { mimeType: string }).mimeType}]`; + return `[${part.type}]`; + }) + .join("\n"); + return { + text, + // SAFETY: pi tool details and structured content are JSON by pi's own contract; the runtime rejects anything else when it sends the result. + details: (result.details ?? null) as WorkspaceRuntimeValue, + ...(result.structuredContent === undefined + ? {} + : { structuredContent: result.structuredContent as WorkspaceRuntimeValue }), + isError: result.isError === true, + }; +} diff --git a/packages/computer/src/tools/pi-ai/index.ts b/packages/computer/src/tools/pi-ai/index.ts index 8006a6d3..4081a0b7 100644 --- a/packages/computer/src/tools/pi-ai/index.ts +++ b/packages/computer/src/tools/pi-ai/index.ts @@ -30,6 +30,14 @@ import { } from "../common/publish.js"; import { settle } from "../common/stream.js"; +export { + type ForPiToolsOptions, + forPiTools, + type PiAgentTool, + type PiAgentToolResult, + type PiToolCallRequest, +} from "./bindings.js"; + export interface ToolCallContext { abortSignal?: AbortSignal; }