From f1962d05bdefd36d72c2e3b57a47e9870bc03a12 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:55:22 +0000 Subject: [PATCH 1/7] workspace: Add AI SDK tools entrypoint Add @cloudflare/workspace/tools as a separate package subpath for AI SDK tool wrappers. The entrypoint exposes createAITools() plus the underlying read, write, edit, ls, exec, and share helpers, while keeping ai and zod as optional peers so the main Workspace entrypoint stays independent of the AI SDK. Cover the tool set with focused tests for read-only mode, file operations, byte caps, backend routing, UTF-8-safe exec truncation, and asset sharing. --- package-lock.json | 14 +- packages/workspace/README.md | 4 + packages/workspace/package.json | 18 +- packages/workspace/rolldown.config.ts | 3 + packages/workspace/src/tools/ai.test.ts | 424 +++++++++++++++++++ packages/workspace/src/tools/ai.ts | 44 ++ packages/workspace/src/tools/exec.ts | 113 +++++ packages/workspace/src/tools/fs/edit-diff.ts | 322 ++++++++++++++ packages/workspace/src/tools/fs/edit.ts | 159 +++++++ packages/workspace/src/tools/fs/list.ts | 39 ++ packages/workspace/src/tools/fs/read.ts | 179 ++++++++ packages/workspace/src/tools/fs/store.ts | 153 +++++++ packages/workspace/src/tools/fs/types.ts | 45 ++ packages/workspace/src/tools/fs/write.ts | 47 ++ packages/workspace/src/tools/index.ts | 9 + packages/workspace/src/tools/share.ts | 46 ++ 16 files changed, 1615 insertions(+), 4 deletions(-) create mode 100644 packages/workspace/src/tools/ai.test.ts create mode 100644 packages/workspace/src/tools/ai.ts create mode 100644 packages/workspace/src/tools/exec.ts create mode 100644 packages/workspace/src/tools/fs/edit-diff.ts create mode 100644 packages/workspace/src/tools/fs/edit.ts create mode 100644 packages/workspace/src/tools/fs/list.ts create mode 100644 packages/workspace/src/tools/fs/read.ts create mode 100644 packages/workspace/src/tools/fs/store.ts create mode 100644 packages/workspace/src/tools/fs/types.ts create mode 100644 packages/workspace/src/tools/fs/write.ts create mode 100644 packages/workspace/src/tools/index.ts create mode 100644 packages/workspace/src/tools/share.ts diff --git a/package-lock.json b/package-lock.json index 613d5bd8..e98e65a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12863,6 +12863,7 @@ "@cloudflare/workers-types": "^4.20260616.1", "@cloudflare/workspace-rpc": "*", "@platformatic/vfs": "^0.4.0", + "ai": "^6.0.196", "diff": "^9.0.0", "esbuild": "^0.28.1", "isomorphic-git": "^1.38.3", @@ -12871,22 +12872,31 @@ "rolldown-plugin-dts": "^0.25.2", "typescript": "^6.0.3", "vitest": "^4.1.7", - "wrangler": "^4.96.0" + "wrangler": "^4.96.0", + "zod": "^4.4.3" }, "peerDependencies": { "@platformatic/vfs": "*", + "ai": "^6.0.196", "diff": "^9.0.0", - "isomorphic-git": "^1.27.0" + "isomorphic-git": "^1.27.0", + "zod": "^4.4.3" }, "peerDependenciesMeta": { "@platformatic/vfs": { "optional": true }, + "ai": { + "optional": true + }, "diff": { "optional": true }, "isomorphic-git": { "optional": true + }, + "zod": { + "optional": true } } }, diff --git a/packages/workspace/README.md b/packages/workspace/README.md index 7bf386ca..64e89bba 100644 --- a/packages/workspace/README.md +++ b/packages/workspace/README.md @@ -68,6 +68,10 @@ uniform; the counts are just always zero. an Artifacts binding, the worker backend exposes the same CLI as an `artifacts` custom command. See [`docs/15_artifacts_interface.md`](../../docs/15_artifacts_interface.md). +- `createAITools` (from `@cloudflare/workspace/tools`) — AI SDK + tools for agents: `read`, `write`, `edit`, `ls`, optional `exec`, + and optional asset sharing. See + [`docs/09_tool_interface.md`](../../docs/09_tool_interface.md). ## Typical DO-side usage diff --git a/packages/workspace/package.json b/packages/workspace/package.json index 07fd70bf..c7d5b7b2 100644 --- a/packages/workspace/package.json +++ b/packages/workspace/package.json @@ -27,6 +27,10 @@ "types": "./dist/artifacts/index.d.ts", "import": "./dist/artifacts/index.js" }, + "./tools": { + "types": "./dist/tools/index.d.ts", + "import": "./dist/tools/index.js" + }, "./backends/container": { "types": "./dist/backends/container/index.d.ts", "import": "./dist/backends/container/index.js" @@ -68,18 +72,26 @@ }, "peerDependencies": { "@platformatic/vfs": "*", + "ai": "^6.0.196", "diff": "^9.0.0", - "isomorphic-git": "^1.27.0" + "isomorphic-git": "^1.27.0", + "zod": "^4.4.3" }, "peerDependenciesMeta": { "@platformatic/vfs": { "optional": true }, + "ai": { + "optional": true + }, "diff": { "optional": true }, "isomorphic-git": { "optional": true + }, + "zod": { + "optional": true } }, "devDependencies": { @@ -88,6 +100,7 @@ "@cloudflare/workers-types": "^4.20260616.1", "@cloudflare/workspace-rpc": "*", "@platformatic/vfs": "^0.4.0", + "ai": "^6.0.196", "diff": "^9.0.0", "esbuild": "^0.28.1", "isomorphic-git": "^1.38.3", @@ -96,6 +109,7 @@ "rolldown-plugin-dts": "^0.25.2", "typescript": "^6.0.3", "vitest": "^4.1.7", - "wrangler": "^4.96.0" + "wrangler": "^4.96.0", + "zod": "^4.4.3" } } diff --git a/packages/workspace/rolldown.config.ts b/packages/workspace/rolldown.config.ts index ebcb3381..a4c91e57 100644 --- a/packages/workspace/rolldown.config.ts +++ b/packages/workspace/rolldown.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ git: "src/git/index.ts", "artifacts/index": "src/artifacts/index.ts", "assets/index": "src/assets/index.ts", + "tools/index": "src/tools/index.ts", "backends/container/index": "src/backends/container/index.ts", "backends/worker/index": "src/backends/worker/index.ts", "observe/cloudflare": "src/observe/cloudflare.ts", @@ -35,6 +36,8 @@ export default defineConfig({ "cloudflare:workers", "capnweb", "@platformatic/vfs", + "ai", + "zod", "isomorphic-git", /^isomorphic-git\//, "just-bash", diff --git a/packages/workspace/src/tools/ai.test.ts b/packages/workspace/src/tools/ai.test.ts new file mode 100644 index 00000000..83362927 --- /dev/null +++ b/packages/workspace/src/tools/ai.test.ts @@ -0,0 +1,424 @@ +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { describe, expect, it } from "vitest"; +import type { ExecHandle, ExecResult } from "../shell.js"; +import { Workspace } from "../workspace.js"; +import { + createAITools, + createEditTool, + createReadTool, + createWriteTool, + type FileStore, + WorkspaceFileStore, +} from "./index.js"; + +const toolOptions = { toolCallId: "test-call", messages: [] }; + +async function executeTool(tool: unknown, input: unknown): Promise { + const execute = (tool as { execute?: (input: unknown, options: typeof toolOptions) => unknown }) + .execute; + if (!execute) throw new Error("tool has no execute function"); + return await execute(input, toolOptions); +} + +function makeWorkspace(): Workspace { + return new Workspace({ storage: new SQLiteTestStorage(), now: () => 1_700_000_000_000 }); +} + +function bytes(text: string): Uint8Array { + return new TextEncoder().encode(text); +} + +function decode(data: Uint8Array): string { + return new TextDecoder().decode(data); +} + +async function drainChunks(chunks: AsyncIterable): Promise { + const parts: Uint8Array[] = []; + let total = 0; + for await (const chunk of chunks) { + parts.push(chunk); + total += chunk.byteLength; + } + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.byteLength; + } + return out; +} + +function memoryStore(options: { + content?: string; + mode?: number; + size?: number; + statError?: Error; + readError?: Error; + writeError?: Error; + onWrite?: (path: string, content: Uint8Array, opts?: { mode?: number }) => void; +}): FileStore { + const content = options.content ?? ""; + return { + async stat() { + if (options.statError) throw options.statError; + return { + size: options.size ?? bytes(content).byteLength, + mtime: 1, + mode: options.mode, + }; + }, + async readAll() { + if (options.readError) throw options.readError; + return bytes(content); + }, + async *readChunks() { + if (options.readError) throw options.readError; + yield bytes(content); + }, + async write(path, nextContent, opts) { + if (options.writeError) throw options.writeError; + options.onWrite?.(path, nextContent, opts); + }, + }; +} + +describe("WorkspaceFileStore", () => { + it("slices byte ranges while reading chunks from Workspace.fs", async () => { + const workspace = makeWorkspace(); + await workspace.fs.mkdir("/workspace", { recursive: true }); + await workspace.fs.writeFile("/workspace/range.txt", bytes("abcdefghij")); + const store = new WorkspaceFileStore(workspace); + + await expect( + drainChunks(store.readChunks("/workspace/range.txt", 2, 5)).then(decode), + ).resolves.toBe("cdefg"); + }); +}); + +describe("createAITools filesystem tools", () => { + it("creates fixed read, write, edit, and ls tools by default", () => { + const tools = createAITools({ workspace: makeWorkspace() }); + + expect(Object.keys(tools).sort()).toEqual(["edit", "ls", "read", "write"]); + }); + + it("returns only read-only tools when readonly is true", () => { + const tools = createAITools({ + workspace: makeWorkspace(), + readonly: true, + shell: { + defaultBackend: "shell", + backends: { shell: { description: "test shell" } }, + }, + }); + + expect(Object.keys(tools).sort()).toEqual(["ls", "read"]); + }); + + it("reads, lists, writes, and edits workspace files", async () => { + const workspace = makeWorkspace(); + const tools = createAITools({ workspace }); + + await executeTool(tools.write, { path: "/workspace/notes/todo.txt", content: "one\ntwo\n" }); + + await expect(workspace.fs.readFile("/workspace/notes/todo.txt", "utf8")).resolves.toBe( + "one\ntwo\n", + ); + await expect(executeTool(tools.ls, { path: "/workspace/notes" })).resolves.toEqual({ + path: "/workspace/notes", + entries: [{ name: "todo.txt", isFile: true, isDirectory: false }], + }); + await expect( + executeTool(tools.read, { path: "/workspace/notes/todo.txt", limit: 1 }), + ).resolves.toMatchObject({ + path: "/workspace/notes/todo.txt", + content: "one", + startLine: 1, + endLine: 1, + truncated: true, + nextOffset: 2, + }); + + await expect( + executeTool(tools.edit, { + path: "/workspace/notes/todo.txt", + edits: [{ oldText: "two", newText: "three" }], + }), + ).resolves.toMatchObject({ path: "/workspace/notes/todo.txt", editsApplied: 1 }); + await expect(workspace.fs.readFile("/workspace/notes/todo.txt", "utf8")).resolves.toBe( + "one\nthree\n", + ); + }); + + it("preserves file mode when write overwrites an existing file", async () => { + const writes: Array<{ path: string; content: string; mode?: number }> = []; + const tool = createWriteTool({ + store: memoryStore({ + content: "old", + mode: 0o100755, + onWrite(path, content, opts) { + writes.push({ path, content: decode(content), mode: opts?.mode }); + }, + }), + }); + + await expect( + executeTool(tool, { path: "/workspace/script.sh", content: "new" }), + ).resolves.toEqual({ path: "/workspace/script.sh", bytesWritten: 3 }); + expect(writes).toEqual([{ path: "/workspace/script.sh", content: "new", mode: 0o100755 }]); + }); + + it("returns structured write errors for filesystem failures", async () => { + const tool = createWriteTool({ + store: memoryStore({ content: "old", writeError: new Error("disk full") }), + }); + + await expect( + executeTool(tool, { path: "/workspace/out.txt", content: "new" }), + ).resolves.toEqual({ error: "disk full" }); + }); + + it("rejects writes over the byte cap", async () => { + const tool = createWriteTool({ store: memoryStore({}), maxBytes: 3 }); + + await expect( + executeTool(tool, { path: "/workspace/out.txt", content: "abcd" }), + ).resolves.toMatchObject({ error: expect.stringContaining("exceeds the 3-byte write cap") }); + }); + + it("returns structured edit errors for non-unique replacements", async () => { + const tool = createEditTool({ store: memoryStore({ content: "same\nsame\n" }) }); + + await expect( + executeTool(tool, { + path: "/workspace/file.txt", + edits: [{ oldText: "same", newText: "different" }], + }), + ).resolves.toMatchObject({ error: expect.stringContaining("must be unique") }); + }); + + it("returns structured edit errors for filesystem failures", async () => { + const tool = createEditTool({ + store: memoryStore({ content: "old", writeError: new Error("read-only filesystem") }), + }); + + await expect( + executeTool(tool, { + path: "/workspace/file.txt", + edits: [{ oldText: "old", newText: "new" }], + }), + ).resolves.toEqual({ error: "read-only filesystem" }); + }); + + it("rejects edits for files over the byte cap", async () => { + const tool = createEditTool({ store: memoryStore({ content: "old", size: 10 }), maxBytes: 3 }); + + await expect( + executeTool(tool, { + path: "/workspace/file.txt", + edits: [{ oldText: "old", newText: "new" }], + }), + ).resolves.toMatchObject({ error: expect.stringContaining("exceeds the 3-byte cap") }); + }); + + it("caps large reads and reports first-line overflow", async () => { + const tool = createReadTool({ store: memoryStore({ content: "abcdef\n" }), maxBytes: 3 }); + + await expect(executeTool(tool, { path: "/workspace/file.txt" })).resolves.toEqual({ + error: + "Line 1 exceeds the 3-byte read cap. Increase the cap or read a narrower range with offset/limit.", + }); + }); +}); + +describe("createAITools exec tool", () => { + it("adds exec only when shell options are provided", () => { + const workspace = makeWorkspace(); + + expect(createAITools({ workspace }).exec).toBeUndefined(); + expect( + createAITools({ + workspace, + shell: { + defaultBackend: "shell", + backends: { shell: { description: "test shell" } }, + }, + }).exec, + ).toBeDefined(); + }); + + it("runs shell commands on the selected backend and truncates output", async () => { + const calls: Array<{ command: string; cwd: string | undefined; backend: string | undefined }> = + []; + const workspace = { + shell: { + async exec(command: string, options: { cwd?: string; encoding: "utf8"; backend?: string }) { + calls.push({ command, cwd: options.cwd, backend: options.backend }); + const result: ExecResult<"utf8"> = { + exitCode: 2, + stdout: "abcdef", + stderr: "uvwxyz", + pushed: 0, + pulled: 0, + skipped: [], + }; + return { result: async () => result } as ExecHandle<"utf8">; + }, + }, + }; + const tools = createAITools({ + workspace, + shell: { + defaultBackend: "shell", + backends: { + shell: { description: "fast shell" }, + container: { description: "full Linux" }, + }, + maxBytes: 3, + }, + }); + + await expect( + executeTool(tools.exec, { command: "npm test", cwd: "/workspace", backend: "container" }), + ).resolves.toEqual({ + command: "npm test", + cwd: "/workspace", + backend: "container", + exitCode: 2, + stdout: "abc\n\n[truncated, 3 more bytes]", + stderr: "uvw\n\n[truncated, 3 more bytes]", + }); + expect(calls).toEqual([{ command: "npm test", cwd: "/workspace", backend: "container" }]); + }); + + it("truncates exec output on UTF-8 byte boundaries", async () => { + const workspace = { + shell: { + async exec() { + const result: ExecResult<"utf8"> = { + exitCode: 0, + stdout: "a🙂b", + stderr: "🙂🙂", + pushed: 0, + pulled: 0, + skipped: [], + }; + return { result: async () => result } as ExecHandle<"utf8">; + }, + }, + }; + const tools = createAITools({ + workspace, + shell: { + defaultBackend: "shell", + backends: { shell: { description: "fast shell" } }, + maxBytes: 5, + }, + }); + + await expect(executeTool(tools.exec, { command: "echo emoji" })).resolves.toMatchObject({ + stdout: "a🙂\n\n[truncated, 1 more bytes]", + stderr: "🙂\n\n[truncated, 4 more bytes]", + }); + }); + + it("routes omitted backend to defaultBackend", async () => { + const calls: Array<{ command: string; backend: string | undefined }> = []; + const workspace = { + shell: { + async exec(command: string, options: { encoding: "utf8"; backend?: string }) { + calls.push({ command, backend: options.backend }); + const result: ExecResult<"utf8"> = { + exitCode: 0, + stdout: "ok", + stderr: "", + pushed: 0, + pulled: 0, + skipped: [], + }; + return { result: async () => result } as ExecHandle<"utf8">; + }, + }, + }; + const tools = createAITools({ + workspace, + shell: { + defaultBackend: "shell", + backends: { shell: { description: "fast shell" } }, + }, + }); + + await expect(executeTool(tools.exec, { command: "echo ok" })).resolves.toMatchObject({ + backend: "shell", + exitCode: 0, + }); + expect(calls).toEqual([{ command: "echo ok", backend: "shell" }]); + }); + + it("rejects invalid shell backend configuration", () => { + const workspace = makeWorkspace(); + + expect(() => + createAITools({ + workspace, + shell: { defaultBackend: "missing", backends: { shell: { description: "test" } } }, + }), + ).toThrow(/defaultBackend/); + }); +}); + +describe("createAITools share tool", () => { + it("adds share by default when assets are configured", async () => { + const calls: Array<{ path: string; expiresAfter: number; prefix?: string }> = []; + const workspace = { + fs: makeWorkspace().fs, + sessionId: "session-a", + assets: { + async share(path: string, opts: { expiresAfter: number; prefix?: string }) { + calls.push({ path, ...opts }); + return "https://example.test/report.html"; + }, + }, + }; + const tools = createAITools({ workspace }); + + expect(tools.share).toBeDefined(); + await expect( + executeTool(tools.share, { path: "/workspace/out/report.html", expiresAfterMs: 1234 }), + ).resolves.toEqual({ ok: true, url: "https://example.test/report.html" }); + expect(calls).toEqual([ + { path: "/workspace/out/report.html", expiresAfter: 1234, prefix: "agent-session-a" }, + ]); + }); + + it("omits share when assets are disabled or readonly is true", () => { + const workspace = { + fs: makeWorkspace().fs, + sessionId: "session-a", + assets: { share: async () => "https://example.test" }, + }; + + expect(createAITools({ workspace, assets: false }).share).toBeUndefined(); + expect(createAITools({ workspace, readonly: true }).share).toBeUndefined(); + }); + + it("returns structured share errors", async () => { + const workspace = { + fs: makeWorkspace().fs, + sessionId: "session-a", + assets: { + async share() { + throw new Error("upload failed"); + }, + }, + }; + const tools = createAITools({ workspace }); + + await expect(executeTool(tools.share, { path: "/workspace/out/report.html" })).resolves.toEqual( + { + ok: false, + error: "upload failed", + }, + ); + }); +}); diff --git a/packages/workspace/src/tools/ai.ts b/packages/workspace/src/tools/ai.ts new file mode 100644 index 00000000..b8c88ff2 --- /dev/null +++ b/packages/workspace/src/tools/ai.ts @@ -0,0 +1,44 @@ +import type { ToolSet } from "ai"; +import { createExecTool, type ExecToolOptions, type ExecWorkspaceLike } from "./exec.js"; +import { createEditTool, type EditToolOptions } from "./fs/edit.js"; +import { createListTool } from "./fs/list.js"; +import { createReadTool, type ReadToolOptions } from "./fs/read.js"; +import { type WorkspaceLike as FileWorkspaceLike, WorkspaceFileStore } from "./fs/store.js"; +import { createWriteTool, type WriteToolOptions } from "./fs/write.js"; +import { createShareTool, type ShareWorkspaceLike } from "./share.js"; + +export interface CreateAIToolsOptions { + workspace: FileWorkspaceLike & Partial & Partial; + readonly?: boolean; + assets?: boolean; + read?: Omit; + write?: Omit; + edit?: Omit; + shell?: Omit; +} + +export function createAITools(options: CreateAIToolsOptions): ToolSet { + const store = new WorkspaceFileStore(options.workspace); + const tools: ToolSet = { + read: createReadTool({ store, ...options.read }), + ls: createListTool({ workspace: options.workspace }), + }; + + if (options.readonly === true) return tools; + + tools.write = createWriteTool({ store, ...options.write }); + tools.edit = createEditTool({ store, ...options.edit }); + + if (options.shell !== undefined) { + tools.exec = createExecTool({ + workspace: options.workspace as ExecWorkspaceLike, + ...options.shell, + }); + } + + if (options.assets !== false && options.workspace.assets !== undefined) { + tools.share = createShareTool({ workspace: options.workspace as ShareWorkspaceLike }); + } + + return tools; +} diff --git a/packages/workspace/src/tools/exec.ts b/packages/workspace/src/tools/exec.ts new file mode 100644 index 00000000..1b7c5b1b --- /dev/null +++ b/packages/workspace/src/tools/exec.ts @@ -0,0 +1,113 @@ +import { tool } from "ai"; +import { z } from "zod"; + +export interface ExecWorkspaceLike { + shell: { + exec( + command: string, + options: { cwd?: string; encoding: "utf8"; backend?: string }, + ): Promise<{ + result(): Promise<{ + exitCode: number; + stdout: string; + stderr: string; + }>; + }>; + }; +} + +export interface ExecBackendDescription { + description: string; +} + +export interface ExecToolOptions { + workspace: ExecWorkspaceLike; + backends: Record; + defaultBackend: string; + maxBytes?: number; +} + +const DEFAULT_MAX_BYTES = 64 * 1024; + +export function createExecTool(options: ExecToolOptions) { + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + const backendIds = Object.keys(options.backends); + if (backendIds.length === 0) { + throw new Error("createExecTool: pass at least one backend in `backends`"); + } + if (!backendIds.includes(options.defaultBackend)) { + throw new Error( + `createExecTool: defaultBackend ${JSON.stringify(options.defaultBackend)} is not one of ${backendIds.map((id) => JSON.stringify(id)).join(", ")}`, + ); + } + + const backendGuidance = backendIds + .map((id) => `- ${JSON.stringify(id)}: ${options.backends[id].description}`) + .join("\n"); + const description = [ + "Run a shell command in the workspace. The workspace exposes multiple backends, each with different capabilities.", + "Pick the cheapest backend that can run the command; fall back to a heavier one only when the lighter backend's command set doesn't cover what you need.", + "", + "Backends:", + backendGuidance, + "", + `Default backend: ${JSON.stringify(options.defaultBackend)}.`, + "Prefer the dedicated read, write, and edit tools for file operations. Long output is truncated to keep tool replies small.", + ].join("\n"); + + const backendSchema = z + .enum(backendIds as [string, ...string[]]) + .optional() + .describe( + [ + "Which backend to run on. Omit to use the default", + `(${JSON.stringify(options.defaultBackend)}). Set explicitly when the`, + "default backend is not capable of running the command.", + ].join(" "), + ); + + return tool({ + description, + inputSchema: z.object({ + command: z.string().describe("Shell command, e.g. 'npm test' or 'git diff HEAD'."), + cwd: z.string().optional().describe("Working directory. Defaults to the workspace root."), + backend: backendSchema, + }), + execute: async ({ command, cwd, backend }) => { + const selectedBackend = backend ?? options.defaultBackend; + const handle = await options.workspace.shell.exec(command, { + cwd, + encoding: "utf8", + backend: selectedBackend, + }); + const result = await handle.result(); + return { + command, + cwd: cwd ?? null, + backend: selectedBackend, + exitCode: result.exitCode, + stdout: truncate(result.stdout, maxBytes), + stderr: truncate(result.stderr, maxBytes), + }; + }, + }); +} + +const encoder = new TextEncoder(); + +function truncate(value: string, maxBytes: number): string { + if (!value) return value; + const totalBytes = encoder.encode(value).byteLength; + if (totalBytes <= maxBytes) return value; + + let usedBytes = 0; + let endOffset = 0; + for (const char of value) { + const charBytes = encoder.encode(char).byteLength; + if (usedBytes + charBytes > maxBytes) break; + usedBytes += charBytes; + endOffset += char.length; + } + + return `${value.slice(0, endOffset)}\n\n[truncated, ${totalBytes - usedBytes} more bytes]`; +} diff --git a/packages/workspace/src/tools/fs/edit-diff.ts b/packages/workspace/src/tools/fs/edit-diff.ts new file mode 100644 index 00000000..80e3ab99 --- /dev/null +++ b/packages/workspace/src/tools/fs/edit-diff.ts @@ -0,0 +1,322 @@ +/** + * Pure text-manipulation primitives for the edit tool. No filesystem I/O lives + * here — that's deliberate so this module stays trivially testable. Adapted + * from earendil-works/pi (packages/coding-agent/src/core/tools/edit-diff.ts) + * with the fs/promises preview path removed. + */ + +import * as Diff from "diff"; + +// ---------- line endings & BOM ---------- + +export function detectLineEnding(content: string): "\r\n" | "\n" { + const crlfIdx = content.indexOf("\r\n"); + const lfIdx = content.indexOf("\n"); + if (lfIdx === -1) return "\n"; + if (crlfIdx === -1) return "\n"; + return crlfIdx < lfIdx ? "\r\n" : "\n"; +} + +export function normalizeToLF(text: string): string { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} + +export function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string { + return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text; +} + +export function stripBom(content: string): { bom: string; text: string } { + return content.startsWith("\uFEFF") + ? { bom: "\uFEFF", text: content.slice(1) } + : { bom: "", text: content }; +} + +// ---------- fuzzy matching ---------- + +/** + * Progressive normalization for fuzzy matching: + * - NFKC unicode normalization + * - strip trailing whitespace per line + * - smart quotes → ASCII + * - assorted unicode dashes → "-" + * - non-breaking / typographic spaces → " " + */ +export function normalizeForFuzzyMatch(text: string): string { + return text + .normalize("NFKC") + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + .replace(/[\u2018\u2019\u201A\u201B]/g, "'") + .replace(/[\u201C\u201D\u201E\u201F]/g, '"') + .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-") + .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " "); +} + +export interface FuzzyMatchResult { + found: boolean; + index: number; + matchLength: number; + usedFuzzyMatch: boolean; + /** The content the caller should treat as the base for replacement. */ + contentForReplacement: string; +} + +export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResult { + const exactIndex = content.indexOf(oldText); + if (exactIndex !== -1) { + return { + found: true, + index: exactIndex, + matchLength: oldText.length, + usedFuzzyMatch: false, + contentForReplacement: content, + }; + } + + const fuzzyContent = normalizeForFuzzyMatch(content); + const fuzzyOldText = normalizeForFuzzyMatch(oldText); + const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText); + if (fuzzyIndex === -1) { + return { + found: false, + index: -1, + matchLength: 0, + usedFuzzyMatch: false, + contentForReplacement: content, + }; + } + return { + found: true, + index: fuzzyIndex, + matchLength: fuzzyOldText.length, + usedFuzzyMatch: true, + contentForReplacement: fuzzyContent, + }; +} + +// ---------- edit application ---------- + +export interface Edit { + oldText: string; + newText: string; +} + +interface MatchedEdit { + editIndex: number; + matchIndex: number; + matchLength: number; + newText: string; +} + +export interface AppliedEditsResult { + baseContent: string; + newContent: string; +} + +function countOccurrences(content: string, oldText: string): number { + const fuzzyContent = normalizeForFuzzyMatch(content); + const fuzzyOldText = normalizeForFuzzyMatch(oldText); + return fuzzyContent.split(fuzzyOldText).length - 1; +} + +function notFound(path: string, idx: number, total: number): Error { + if (total === 1) { + return new Error( + `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`, + ); + } + return new Error( + `Could not find edits[${idx}] in ${path}. The oldText must match exactly including all whitespace and newlines.`, + ); +} +function duplicate(path: string, idx: number, total: number, n: number): Error { + if (total === 1) { + return new Error( + `Found ${n} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`, + ); + } + return new Error( + `Found ${n} occurrences of edits[${idx}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`, + ); +} +function emptyOldText(path: string, idx: number, total: number): Error { + if (total === 1) return new Error(`oldText must not be empty in ${path}.`); + return new Error(`edits[${idx}].oldText must not be empty in ${path}.`); +} +function noChange(path: string, total: number): Error { + if (total === 1) { + return new Error( + `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`, + ); + } + return new Error(`No changes made to ${path}. The replacements produced identical content.`); +} + +/** + * Apply one or more edits to LF-normalized content. All edits are matched + * against the original content; replacements are then applied right-to-left + * so earlier offsets stay valid. + */ +export function applyEditsToNormalizedContent( + normalizedContent: string, + edits: Edit[], + path: string, +): AppliedEditsResult { + const normalized = edits.map((e) => ({ + oldText: normalizeToLF(e.oldText), + newText: normalizeToLF(e.newText), + })); + + for (let i = 0; i < normalized.length; i++) { + if (normalized[i].oldText.length === 0) throw emptyOldText(path, i, normalized.length); + } + + // If any edit needs fuzzy matching, do all replacement in fuzzy-normalized + // space so indices line up. Otherwise stay in the caller's exact content. + const initial = normalized.map((e) => fuzzyFindText(normalizedContent, e.oldText)); + const baseContent = initial.some((m) => m.usedFuzzyMatch) + ? normalizeForFuzzyMatch(normalizedContent) + : normalizedContent; + + const matched: MatchedEdit[] = []; + for (let i = 0; i < normalized.length; i++) { + const e = normalized[i]; + const m = fuzzyFindText(baseContent, e.oldText); + if (!m.found) throw notFound(path, i, normalized.length); + const occurrences = countOccurrences(baseContent, e.oldText); + if (occurrences > 1) throw duplicate(path, i, normalized.length, occurrences); + matched.push({ + editIndex: i, + matchIndex: m.index, + matchLength: m.matchLength, + newText: e.newText, + }); + } + + matched.sort((a, b) => a.matchIndex - b.matchIndex); + for (let i = 1; i < matched.length; i++) { + const prev = matched[i - 1]; + const cur = matched[i]; + if (prev.matchIndex + prev.matchLength > cur.matchIndex) { + throw new Error( + `edits[${prev.editIndex}] and edits[${cur.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`, + ); + } + } + + let newContent = baseContent; + for (let i = matched.length - 1; i >= 0; i--) { + const m = matched[i]; + newContent = + newContent.substring(0, m.matchIndex) + + m.newText + + newContent.substring(m.matchIndex + m.matchLength); + } + + if (baseContent === newContent) throw noChange(path, normalized.length); + return { baseContent, newContent }; +} + +// ---------- diffs ---------- + +export function generateUnifiedPatch( + path: string, + oldContent: string, + newContent: string, + contextLines = 4, +): string { + return Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, { + context: contextLines, + }); +} + +export interface EditDiffResult { + diff: string; + firstChangedLine: number | undefined; +} + +/** Display-oriented diff with line numbers and bounded context. */ +export function generateDiffString( + oldContent: string, + newContent: string, + contextLines = 4, +): EditDiffResult { + const parts = Diff.diffLines(oldContent, newContent); + const output: string[] = []; + + const oldLines = oldContent.split("\n"); + const newLines = newContent.split("\n"); + const maxLineNum = Math.max(oldLines.length, newLines.length); + const w = String(maxLineNum).length; + + let oldLineNum = 1; + let newLineNum = 1; + let lastWasChange = false; + let firstChangedLine: number | undefined; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const raw = part.value.split("\n"); + if (raw[raw.length - 1] === "") raw.pop(); + + if (part.added || part.removed) { + if (firstChangedLine === undefined) firstChangedLine = newLineNum; + for (const line of raw) { + if (part.added) { + output.push(`+${String(newLineNum).padStart(w, " ")} ${line}`); + newLineNum++; + } else { + output.push(`-${String(oldLineNum).padStart(w, " ")} ${line}`); + oldLineNum++; + } + } + lastWasChange = true; + } else { + const nextIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed); + const leading = lastWasChange; + const trailing = nextIsChange; + + const emit = (line: string) => { + output.push(` ${String(oldLineNum).padStart(w, " ")} ${line}`); + oldLineNum++; + newLineNum++; + }; + + if (leading && trailing) { + if (raw.length <= contextLines * 2) { + raw.forEach(emit); + } else { + raw.slice(0, contextLines).forEach(emit); + const skipped = raw.length - contextLines * 2; + output.push(` ${"".padStart(w, " ")} ...`); + oldLineNum += skipped; + newLineNum += skipped; + raw.slice(raw.length - contextLines).forEach(emit); + } + } else if (leading) { + raw.slice(0, contextLines).forEach(emit); + const skipped = raw.length - contextLines; + if (skipped > 0) { + output.push(` ${"".padStart(w, " ")} ...`); + oldLineNum += skipped; + newLineNum += skipped; + } + } else if (trailing) { + const skipped = Math.max(0, raw.length - contextLines); + if (skipped > 0) { + output.push(` ${"".padStart(w, " ")} ...`); + oldLineNum += skipped; + newLineNum += skipped; + } + raw.slice(skipped).forEach(emit); + } else { + oldLineNum += raw.length; + newLineNum += raw.length; + } + lastWasChange = false; + } + } + + return { diff: output.join("\n"), firstChangedLine }; +} diff --git a/packages/workspace/src/tools/fs/edit.ts b/packages/workspace/src/tools/fs/edit.ts new file mode 100644 index 00000000..042aa383 --- /dev/null +++ b/packages/workspace/src/tools/fs/edit.ts @@ -0,0 +1,159 @@ +import { tool } from "ai"; +import { z } from "zod"; +import { + applyEditsToNormalizedContent, + detectLineEnding, + type Edit, + generateDiffString, + generateUnifiedPatch, + normalizeToLF, + restoreLineEndings, + stripBom, +} from "./edit-diff.js"; +import type { FileStore } from "./types.js"; + +export interface EditToolOptions { + store: FileStore; + /** + * Reject edits to files larger than this byte cap. Fuzzy matching needs the + * whole buffer in memory, so we'd rather force the model to use `write`. + * Default 2 MiB. + */ + maxBytes?: number; +} + +const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; + +const replacementSchema = z + .object({ + oldText: z + .string() + .describe( + "Exact text for one targeted replacement. Must be unique in the original file and not overlap with any other edits[].oldText in the same call.", + ), + newText: z.string().describe("Replacement text for this targeted edit."), + }) + .strict(); + +const inputSchema = z.object({ + path: z.string().describe("Path to the file to edit"), + edits: z + .array(replacementSchema) + .describe( + "One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits.", + ), +}); + +/** Best-effort coercion for inputs from quirky models. */ +function prepareArguments(input: unknown): { path: string; edits: Edit[] } { + if (!input || typeof input !== "object") return input as { path: string; edits: Edit[] }; + const args = input as Record; + + // Some models pack edits into a JSON string. + if (typeof args.edits === "string") { + try { + const parsed = JSON.parse(args.edits); + if (Array.isArray(parsed)) args.edits = parsed; + } catch { + /* fall through to validation error */ + } + } + + // Legacy single-edit shape: oldText/newText siblings on the root object. + if (typeof args.oldText === "string" && typeof args.newText === "string") { + const edits = Array.isArray(args.edits) ? [...(args.edits as Edit[])] : []; + edits.push({ oldText: args.oldText as string, newText: args.newText as string }); + args.edits = edits; + delete args.oldText; + delete args.newText; + } + + return args as { path: string; edits: Edit[] }; +} + +// Per-path mutation queue. Edit and write should never race on the same file: +// fuzzy matching reads the entire buffer, applies a textual change, then +// writes — a concurrent edit landing between read and write would silently +// clobber the first edit. Module-scoped so all tools sharing a store also +// share the queue. +const fileLocks = new Map>(); +async function withFileLock(path: string, fn: () => Promise): Promise { + const prev = fileLocks.get(path) ?? Promise.resolve(); + const next = prev.then(fn, fn); + fileLocks.set( + path, + next.finally(() => { + // Clear only if we're still the tail of the chain. + if (fileLocks.get(path) === next) fileLocks.delete(path); + }), + ); + return next; +} + +export function createEditTool(options: EditToolOptions) { + const { store } = options; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + + return tool({ + description: + "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes touch the same block, merge them into one edit.", + inputSchema, + execute: async (rawInput: unknown) => { + const { path, edits } = prepareArguments(rawInput); + + if (!Array.isArray(edits) || edits.length === 0) { + return { error: "edits must contain at least one replacement." }; + } + + return withFileLock(path, async () => { + try { + const stat = await store.stat(path); + if (!stat) return { error: `File not found: ${path}` }; + if (stat.size > maxBytes) { + return { + error: `File too large to edit: ${stat.size} bytes exceeds the ${maxBytes}-byte cap. Use the write tool to rewrite the file from scratch.`, + }; + } + + const bytes = await store.readAll(path); + if (!bytes) return { error: `File not found: ${path}` }; + + const rawContent = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true }).decode( + bytes, + ); + const { bom, text } = stripBom(rawContent); + const ending = detectLineEnding(text); + const normalized = normalizeToLF(text); + + let baseContent: string; + let newContent: string; + try { + ({ baseContent, newContent } = applyEditsToNormalizedContent(normalized, edits, path)); + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + + const finalContent = bom + restoreLineEndings(newContent, ending); + // Round-trip the file's mode so editing an executable script (or any + // file with a non-default mode) doesn't silently drop bits. `stat.mode` + // is undefined for stores that don't track modes; pass `undefined` in + // that case so the store applies its own default. + await store.write(path, new TextEncoder().encode(finalContent), { mode: stat.mode }); + + const diffResult = generateDiffString(baseContent, newContent); + const patch = generateUnifiedPatch(path, baseContent, newContent); + + return { + path, + editsApplied: edits.length, + diff: diffResult.diff, + patch, + firstChangedLine: diffResult.firstChangedLine, + }; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + }); + }, + }); +} diff --git a/packages/workspace/src/tools/fs/list.ts b/packages/workspace/src/tools/fs/list.ts new file mode 100644 index 00000000..cd6ad790 --- /dev/null +++ b/packages/workspace/src/tools/fs/list.ts @@ -0,0 +1,39 @@ +import { tool } from "ai"; +import { z } from "zod"; + +export interface ListWorkspaceLike { + fs: { + readdir(path: string): Promise>; + }; +} + +export interface ListToolOptions { + workspace: ListWorkspaceLike; +} + +const inputSchema = z.object({ + path: z.string().describe("Absolute directory path to list, e.g. /workspace/src."), +}); + +export function createListTool(options: ListToolOptions) { + return tool({ + description: + "List entries in a workspace directory. Returns each entry name and whether it is a file or directory.", + inputSchema, + execute: async ({ path }) => { + try { + const entries = await options.workspace.fs.readdir(path); + return { + path, + entries: entries.map((entry) => ({ + name: entry.name, + isFile: entry.isFile, + isDirectory: entry.isDirectory, + })), + }; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + }, + }); +} diff --git a/packages/workspace/src/tools/fs/read.ts b/packages/workspace/src/tools/fs/read.ts new file mode 100644 index 00000000..50ae5a5a --- /dev/null +++ b/packages/workspace/src/tools/fs/read.ts @@ -0,0 +1,179 @@ +import { tool } from "ai"; +import { z } from "zod"; +import type { FileStore } from "./types.js"; + +export interface ReadToolOptions { + store: FileStore; + /** Hard line cap. Default 2000. */ + maxLines?: number; + /** Hard byte cap. Default 256 KiB. */ + maxBytes?: number; +} + +const DEFAULT_MAX_LINES = 2000; +const DEFAULT_MAX_BYTES = 256 * 1024; + +const inputSchema = z.object({ + path: z.string().describe("Path to the file to read"), + offset: z + .number() + .int() + .min(1) + .optional() + .describe("Line number to start reading from (1-indexed)"), + limit: z.number().int().min(1).optional().describe("Maximum number of lines to read"), +}); + +interface ReadResult { + path: string; + content: string; + startLine: number; + endLine: number; + totalLines: number | null; + truncated: boolean; + nextOffset?: number; +} + +/** + * Memory-efficient line reader. Pulls chunks lazily through the store's + * `readChunks` iterable; stops the moment the line/byte budget is filled. + * Never materializes the full file unless the file itself fits within the + * budget. + * + * Returns a continuation `nextOffset` whenever output was truncated so the + * model can call `read` again with `offset=nextOffset` to keep going. Total + * line count is reported as `null` when truncation cut us off — counting + * every line in a multi-megabyte file would defeat the streaming approach. + */ + +// Workerd has no Buffer. TextEncoder.encode allocates a Uint8Array per call, +// but it's the only portable byte-length primitive available across Node and +// workers runtimes. +const _enc = new TextEncoder(); +function utf8ByteLength(s: string): number { + return _enc.encode(s).length; +} +export function createReadTool(options: ReadToolOptions) { + const { store } = options; + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + + return tool({ + description: `Read the contents of a file. Output is truncated to ${maxLines} lines or ${Math.round(maxBytes / 1024)}KB, whichever is reached first; use offset/limit to page through large files.`, + inputSchema, + execute: async ({ path, offset, limit }): Promise => { + const stat = await store.stat(path); + if (!stat) return { error: `File not found: ${path}` }; + + const startLine = offset ?? 1; + const wantedLines = limit ?? maxLines; + const lineCap = Math.min(wantedLines, maxLines); + + const decoder = new TextDecoder("utf-8"); + let carry = ""; // bytes from previous chunk that didn't end on a newline + let currentLine = 1; // 1-indexed line we're about to emit + const collected: string[] = []; + let collectedBytes = 0; + let firstEmittedLine: number | null = null; + let truncatedByBudget = false; + let firstLineOverflow = false; + + const processLine = (line: string): boolean => { + // Returns true to keep going, false to stop the outer pump. + if (currentLine < startLine) { + currentLine++; + return true; + } + // We're at or past `startLine` — try to emit. + const lineBytes = utf8ByteLength(line); + if (collected.length === 0 && lineBytes > maxBytes) { + firstLineOverflow = true; + return false; + } + // Stop before emitting if this line would push us over either cap. + if (collected.length >= lineCap) { + truncatedByBudget = true; + return false; + } + if (collectedBytes + lineBytes + (collected.length > 0 ? 1 : 0) > maxBytes) { + truncatedByBudget = true; + return false; + } + if (firstEmittedLine === null) firstEmittedLine = currentLine; + collected.push(line); + collectedBytes += lineBytes + (collected.length > 1 ? 1 : 0); + currentLine++; + return true; + }; + + let keepGoing = true; + for await (const chunk of store.readChunks(path)) { + if (!keepGoing) break; + carry += decoder.decode(chunk, { stream: true }); + // Process every complete line in `carry`. Keep the final partial line + // for the next iteration. + let nl = carry.indexOf("\n"); + while (nl !== -1) { + const line = carry.slice(0, nl); + carry = carry.slice(nl + 1); + if (!processLine(line)) { + keepGoing = false; + break; + } + nl = carry.indexOf("\n"); + } + } + // Flush the decoder and process any trailing line. + if (keepGoing) { + carry += decoder.decode(); + if (carry.length > 0) { + processLine(carry); + } else if (currentLine === 1 && stat.size === 0) { + // empty file + } + } + + if (firstLineOverflow) { + return { + error: `Line ${currentLine} exceeds the ${maxBytes}-byte read cap. Increase the cap or read a narrower range with offset/limit.`, + }; + } + + if (firstEmittedLine === null) { + // Either an empty file (totalLines = 0 or 1) or offset past EOF. + // currentLine reflects how many lines we've seen so far. + const linesSeen = currentLine - 1; + if (stat.size === 0) { + return { + path, + content: "", + startLine: 1, + endLine: 0, + totalLines: 0, + truncated: false, + }; + } + if (offset !== undefined && startLine > Math.max(1, linesSeen)) { + return { error: `Offset ${offset} is beyond end of file (${linesSeen} line(s))` }; + } + } + + const startLineActual = firstEmittedLine ?? startLine; + const endLine = startLineActual + collected.length - 1; + const truncated = truncatedByBudget; + const totalLines = truncated ? null : currentLine - 1; + const nextOffset = truncated ? endLine + 1 : undefined; + + const result: ReadResult = { + path, + content: collected.join("\n"), + startLine: startLineActual, + endLine, + totalLines, + truncated, + }; + if (nextOffset !== undefined) result.nextOffset = nextOffset; + return result; + }, + }); +} diff --git a/packages/workspace/src/tools/fs/store.ts b/packages/workspace/src/tools/fs/store.ts new file mode 100644 index 00000000..d240958f --- /dev/null +++ b/packages/workspace/src/tools/fs/store.ts @@ -0,0 +1,153 @@ +/** + * `FileStore` adapter over `Workspace.fs`. + * + * The AI tools operate on a small file-store contract so their read, + * write, and edit behavior can stay independent of the full Workspace + * class. This adapter is the bridge from that contract to the public + * `workspace.fs` surface. + * + * Reads go through `fs.readFile(path)` as a `ReadableStream` + * and are stitched together either chunk-by-chunk (`readChunks`) or all + * at once (`readAll`). + */ + +import type { FileStat, FileStore } from "./types.js"; + +/** + * Structural subset of `@cloudflare/workspace.Workspace` the tools + * depend on. + */ +export interface WorkspaceLike { + fs: { + stat(path: string): Promise<{ + size: number; + mtime: number; + mode: number; + isFile: boolean; + isDirectory: boolean; + }>; + readFile(path: string): Promise>; + writeFile(path: string, content: Uint8Array, options?: { mode?: number }): Promise; + mkdir(path: string, options?: { recursive?: boolean }): Promise; + rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise; + readdir(path: string): Promise>; + }; +} + +export class WorkspaceFileStore implements FileStore { + constructor(private readonly ws: WorkspaceLike) {} + + async stat(path: string): Promise { + try { + const s = await this.ws.fs.stat(path); + if (!s.isFile) return null; + return { size: s.size, mtime: s.mtime, mode: s.mode }; + } catch (err) { + if (isEnoent(err)) return null; + throw err; + } + } + + async readAll(path: string): Promise { + try { + const stream = await this.ws.fs.readFile(path); + return await drain(stream); + } catch (err) { + if (isEnoent(err)) return null; + throw err; + } + } + + async write(path: string, content: Uint8Array, opts?: { mode?: number }): Promise { + await ensureParentDir(this.ws, path); + await this.ws.fs.writeFile(path, content, opts); + } + + async *readChunks(path: string, byteOffset = 0, byteLength?: number): AsyncIterable { + if (byteOffset < 0) throw new Error("readChunks: byteOffset must be non-negative"); + if (byteLength !== undefined && byteLength < 0) { + throw new Error("readChunks: byteLength must be non-negative"); + } + if (byteLength === 0) return; + + const stream = await this.ws.fs.readFile(path); + const reader = stream.getReader(); + let skipped = 0; + let yielded = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (!value || value.byteLength === 0) continue; + + let start = 0; + if (skipped < byteOffset) { + const needed = byteOffset - skipped; + if (value.byteLength <= needed) { + skipped += value.byteLength; + continue; + } + start = needed; + skipped = byteOffset; + } + + let end = value.byteLength; + if (byteLength !== undefined) { + const remaining = byteLength - yielded; + if (remaining <= 0) break; + end = Math.min(end, start + remaining); + } + + if (end > start) { + const chunk = value.slice(start, end); + yielded += chunk.byteLength; + yield chunk; + } + + if (byteLength !== undefined && yielded >= byteLength) break; + } + } finally { + reader.releaseLock(); + } + } +} + +async function drain(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const parts: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value) { + parts.push(value); + total += value.byteLength; + } + } + } finally { + reader.releaseLock(); + } + if (parts.length === 1) return parts[0]; + const out = new Uint8Array(total); + let off = 0; + for (const p of parts) { + out.set(p, off); + off += p.byteLength; + } + return out; +} + +async function ensureParentDir(ws: WorkspaceLike, path: string): Promise { + const i = path.lastIndexOf("/"); + if (i <= 0) return; + const parent = path.slice(0, i); + await ws.fs.mkdir(parent, { recursive: true }); +} + +function isEnoent(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + const e = err as { code?: string; message?: string }; + if (e.code === "ENOENT") return true; + return typeof e.message === "string" && /ENOENT|no such/i.test(e.message); +} diff --git a/packages/workspace/src/tools/fs/types.ts b/packages/workspace/src/tools/fs/types.ts new file mode 100644 index 00000000..eac26d65 --- /dev/null +++ b/packages/workspace/src/tools/fs/types.ts @@ -0,0 +1,45 @@ +/** + * File-store boundary used by the workspace file tools. Stores are + * cheap to construct and safe to share across concurrent tool + * invocations. Streaming methods MUST NOT load the full file into + * memory at any single point — that is the whole reason the boundary + * exists. + */ +export interface FileStat { + /** Size in bytes. */ + size: number; + /** Last-modified epoch milliseconds. */ + mtime: number; + /** + * POSIX-style mode bits (e.g. 0o100644 for a regular file, 0o100755 + * for an executable). Omitted by stores that don't track modes. + */ + mode?: number; +} + +export interface FileStore { + /** Return file metadata, or null if the path does not exist or is not a file. */ + stat(path: string): Promise; + + /** + * Stream the file (or a byte range of it) in chunks. The total + * bytes yielded MUST equal min(byteLength ?? size - byteOffset, + * size - byteOffset). Throws if the path does not exist. + */ + readChunks(path: string, byteOffset?: number, byteLength?: number): AsyncIterable; + + /** + * Read the entire file into memory. Used for images and for the + * edit tool where fuzzy matching needs the whole buffer. Returns + * null if missing. + */ + readAll(path: string): Promise; + + /** + * Overwrite (or create) a file with the given bytes. `opts.mode` + * lets the caller pin the resulting file's POSIX mode — used by + * `edit` and `write` to preserve the executable bit across an + * overwrite. + */ + write(path: string, content: Uint8Array, opts?: { mode?: number }): Promise; +} diff --git a/packages/workspace/src/tools/fs/write.ts b/packages/workspace/src/tools/fs/write.ts new file mode 100644 index 00000000..88f30426 --- /dev/null +++ b/packages/workspace/src/tools/fs/write.ts @@ -0,0 +1,47 @@ +import { tool } from "ai"; +import { z } from "zod"; +import type { FileStore } from "./types.js"; + +export interface WriteToolOptions { + store: FileStore; + /** + * Reject writes whose UTF-8 byte length exceeds this cap. The model is + * pointed at the edit tool instead. Default 2 MiB. + */ + maxBytes?: number; +} + +const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; + +const inputSchema = z.object({ + path: z.string().describe("Absolute path, e.g. /workspace/main.zig"), + content: z.string().describe("File content"), +}); + +export function createWriteTool(options: WriteToolOptions) { + const { store } = options; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + + return tool({ + description: "Write content to a file. Overwrites any existing file at the path.", + inputSchema, + execute: async ({ path, content }) => { + const bytes = new TextEncoder().encode(content); + if (bytes.length > maxBytes) { + return { + error: `Content too large: ${bytes.length} bytes exceeds the ${maxBytes}-byte write cap. Use the edit tool for incremental changes to existing files, or split the write into smaller pieces.`, + }; + } + try { + // Preserve the existing file's mode when overwriting so executable + // scripts don't silently lose their +x bit. For new files we leave + // `mode` undefined and let the store apply its own default. + const existing = await store.stat(path); + await store.write(path, bytes, existing ? { mode: existing.mode } : undefined); + return { path, bytesWritten: bytes.length }; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + }, + }); +} diff --git a/packages/workspace/src/tools/index.ts b/packages/workspace/src/tools/index.ts new file mode 100644 index 00000000..29501ea3 --- /dev/null +++ b/packages/workspace/src/tools/index.ts @@ -0,0 +1,9 @@ +export { type CreateAIToolsOptions, createAITools } from "./ai.js"; +export { createExecTool, type ExecBackendDescription, type ExecToolOptions } from "./exec.js"; +export { createEditTool, type EditToolOptions } from "./fs/edit.js"; +export { createListTool, type ListToolOptions } from "./fs/list.js"; +export { createReadTool, type ReadToolOptions } from "./fs/read.js"; +export { WorkspaceFileStore, type WorkspaceLike } from "./fs/store.js"; +export type { FileStat, FileStore } from "./fs/types.js"; +export { createWriteTool, type WriteToolOptions } from "./fs/write.js"; +export { createShareTool, type ShareToolOptions } from "./share.js"; diff --git a/packages/workspace/src/tools/share.ts b/packages/workspace/src/tools/share.ts new file mode 100644 index 00000000..a904fc00 --- /dev/null +++ b/packages/workspace/src/tools/share.ts @@ -0,0 +1,46 @@ +import { tool } from "ai"; +import { z } from "zod"; +import type { AssetsClient } from "../assets/index.js"; + +export interface ShareWorkspaceLike { + readonly sessionId: string; + readonly assets?: AssetsClient; +} + +export interface ShareToolOptions { + workspace: ShareWorkspaceLike; +} + +const DEFAULT_EXPIRY_MS = 60 * 60 * 1000; + +export function createShareTool(options: ShareToolOptions) { + const assets = options.workspace.assets; + if (!assets) { + throw new Error("createShareTool: workspace.assets is not configured"); + } + + return tool({ + description: + "Share a file from the workspace by publishing it through the configured assets publisher and returning a time-limited link. Use this to hand the user an artifact you produced, such as a chart, screenshot, build output, or report.", + inputSchema: z.object({ + path: z.string().min(1).describe("Absolute workspace path, e.g. /workspace/out/chart.png."), + expiresAfterMs: z + .number() + .int() + .positive() + .optional() + .describe("Link lifetime in milliseconds. Defaults to one hour."), + }), + execute: async ({ path, expiresAfterMs }) => { + try { + const url = await assets.share(path, { + expiresAfter: expiresAfterMs ?? DEFAULT_EXPIRY_MS, + prefix: `agent-${options.workspace.sessionId}`, + }); + return { ok: true, url }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + }); +} From 2e769a061b34f13827355b889b277df9a59eab5e Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:55:59 +0000 Subject: [PATCH 2/7] examples/think: Use workspace AI tools Replace the example's local workspace tool wrappers with createAITools() from @cloudflare/workspace/tools. The example keeps its report_update tool, passes the same backend guidance to the shared exec tool, and relies on Workspace assets for optional sharing. Remove the now-unused diff dependencies and update the example README so the documented tool sources match the migrated wiring. --- examples/think/README.md | 24 +- examples/think/package.json | 2 - examples/think/src/agent.ts | 88 +---- examples/think/src/tools/exec.ts | 170 --------- examples/think/src/tools/fs/edit-diff.ts | 322 ------------------ examples/think/src/tools/fs/index.ts | 17 - examples/think/src/tools/fs/stores/types.ts | 46 --- .../think/src/tools/fs/stores/workspace.ts | 129 ------- examples/think/src/tools/fs/tools/edit.ts | 155 --------- examples/think/src/tools/fs/tools/read.ts | 179 ---------- examples/think/src/tools/fs/tools/write.ts | 43 --- examples/think/src/tools/share.ts | 69 ---- package-lock.json | 14 +- 13 files changed, 33 insertions(+), 1225 deletions(-) delete mode 100644 examples/think/src/tools/exec.ts delete mode 100644 examples/think/src/tools/fs/edit-diff.ts delete mode 100644 examples/think/src/tools/fs/index.ts delete mode 100644 examples/think/src/tools/fs/stores/types.ts delete mode 100644 examples/think/src/tools/fs/stores/workspace.ts delete mode 100644 examples/think/src/tools/fs/tools/edit.ts delete mode 100644 examples/think/src/tools/fs/tools/read.ts delete mode 100644 examples/think/src/tools/fs/tools/write.ts delete mode 100644 examples/think/src/tools/share.ts diff --git a/examples/think/README.md b/examples/think/README.md index 0bc1b5b2..56e4cd6e 100644 --- a/examples/think/README.md +++ b/examples/think/README.md @@ -55,15 +55,21 @@ right phase. `explore` has the full toolset; `structure` has none — the model can't be tempted to keep calling tools when its job is to just emit JSON. -| Tool | Source | -| --------------- | ---------------------------------- | -| `read` | vendored from `hackspace/fs-tools` | -| `ls` | `src/agent.ts` | -| `write` | vendored from `hackspace/fs-tools` | -| `edit` | vendored from `hackspace/fs-tools` | -| `exec` | `src/tools/exec.ts` | -| `report_update` | `src/tools/report-update.ts` | -| `share` | `src/tools/share.ts` (optional) | +| Tool | Source | +| --------------- | ------------------------------------------------ | +| `read` | `@cloudflare/workspace/tools` | +| `ls` | `@cloudflare/workspace/tools` | +| `write` | `@cloudflare/workspace/tools` | +| `edit` | `@cloudflare/workspace/tools` | +| `exec` | `@cloudflare/workspace/tools` (optional shell) | +| `share` | `@cloudflare/workspace/tools` (optional assets) | +| `report_update` | `src/tools/report-update.ts` (example-specific) | + +The Workspace tools come from `createAITools()` in +`@cloudflare/workspace/tools`. In the explore phase this example +includes `read`, `write`, `edit`, and `ls`, opts into `exec` by +passing shell backend descriptions, and includes `share` only when the +Workspace assets publisher is configured. The `share` tool uploads a workspace file to R2 and returns a time-limited link, so the agent can hand the user an artifact it diff --git a/examples/think/package.json b/examples/think/package.json index efc73700..683d264a 100644 --- a/examples/think/package.json +++ b/examples/think/package.json @@ -21,14 +21,12 @@ "@platformatic/vfs": "^0.4.0", "agents": "^0.14.1", "ai": "^6.0.196", - "diff": "^9.0.0", "isomorphic-git": "^1.38.3", "workers-ai-provider": "^3.1.14", "zod": "^4.4.3" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260616.1", - "@types/diff": "^8.0.0", "typescript": "^6.0.3", "wrangler": "^4.96.0" } diff --git a/examples/think/src/agent.ts b/examples/think/src/agent.ts index 347d9db9..0ed5f188 100644 --- a/examples/think/src/agent.ts +++ b/examples/think/src/agent.ts @@ -10,10 +10,11 @@ * - Think's own `workspace` field expects a string-based * `WorkspaceLike` for its built-in workspace tools. We satisfy * it with a small adapter over the container workspace and turn - * off `workspaceBash` because we expose our own `exec` tool. - * We also shadow Think's `read`/`write`/`edit` tool names with - * our vendored fs-tools (their streaming/byte-cap behaviour is - * friendlier for this example). + * off `workspaceBash` because `@cloudflare/workspace/tools` + * provides the `exec` tool. We also shadow Think's + * `read`/`write`/`edit` tool names with the shared Workspace + * tools so this example uses the same caps and edit behavior as + * package consumers. * * Phase model: * - The workflow flips `phase` via `setPhase("explore" | "structure")` @@ -46,19 +47,10 @@ import { withWorkspaceContainer, } from "@cloudflare/workspace/backends/container"; import { WorkerBackend } from "@cloudflare/workspace/backends/worker"; -import { type ToolSet, tool } from "ai"; +import { createAITools } from "@cloudflare/workspace/tools"; +import type { ToolSet } from "ai"; import { createWorkersAI } from "workers-ai-provider"; -import { z } from "zod"; -import { createExecTool } from "./tools/exec.js"; -import { - createEditTool, - createReadTool, - createWriteTool, - type WorkspaceLike as FsWorkspaceLike, - WorkspaceFileStore, -} from "./tools/fs/index.js"; import { createReportUpdateTool } from "./tools/report-update.js"; -import { createShareTool } from "./tools/share.js"; // Re-export so the runtime can build loopback bindings the DO // uses: WorkspaceProxy carries container egress traffic back to @@ -600,20 +592,17 @@ export class TriageAgent extends withWorkspaceContainer(TriageBase) { const phase = this.#phase ?? "explore"; if (phase === "structure") return {} as ToolSet; - const store = new WorkspaceFileStore(adaptToFsWorkspace(this.#workspaceFs)); const ws = this.#workspaceFs; - return { + const workspaceTools = createAITools({ + workspace: ws, + assets: hasAssetsConfig(this.env), // Per-tool caps. Kimi K2.6 has a 262k context window so we // don't need to be paranoid; the caps are mostly so a // pathological tool call (giant lockfile, multi-MB log) doesn't // burn through the input budget on a single turn. ~32 KiB ≈ // ~8k tokens per read. - read: createReadTool({ store, maxBytes: 32 * 1024, maxLines: 800 }), - ls: createLsTool(ws), - write: createWriteTool({ store }), - edit: createEditTool({ store }), - exec: createExecTool({ - workspace: ws, + read: { maxBytes: 32 * 1024, maxLines: 800 }, + shell: { maxBytes: 32 * 1024, backends: { shell: { @@ -644,22 +633,12 @@ export class TriageAgent extends withWorkspaceContainer(TriageBase) { }, }, defaultBackend: "shell", - }), + }, + }); + + return { + ...workspaceTools, report_update: createReportUpdateTool({ webhookUrl: ctx.webhookUrl }), - // Only offered when R2 S3 credentials are configured; the - // bucket binding alone can't mint the presigned URL the tool - // returns. Absent credentials, the agent simply has no share - // tool rather than one that fails on every call. - ...(hasAssetsConfig(this.env) - ? { - share: createShareTool({ - workspace: ws, - bucket: this.env.ASSETS, - s3Bucket: "think-example-assets", - env: this.env as unknown as Record, - }), - } - : {}), }; } @@ -761,16 +740,6 @@ export class TriageAgent extends withWorkspaceContainer(TriageBase) { // ── Adapters ─────────────────────────────────────────────────────── -/** - * Bridge from `@cloudflare/workspace.Workspace` to the vendored - * fs-tools' `WorkspaceLike` shape. The vendored tools only call into - * `fs.{stat,readFile,writeFile,mkdir}`, which the container - * workspace already exposes directly. - */ -function adaptToFsWorkspace(ws: Workspace): FsWorkspaceLike { - return ws as unknown as FsWorkspaceLike; -} - /** * Bridge from `@cloudflare/workspace.Workspace` to Think's * string-shaped `WorkspaceLike`. Think only constructs the default @@ -893,29 +862,6 @@ async function drain(stream: ReadableStream): Promise { return out; } -// ── A small `ls` tool: vendored fs-tools don't include it. ───────── - -function createLsTool(ws: Workspace) { - return tool({ - description: - "List the immediate children of a workspace directory. Returns " + - "names with their type (file/dir). One level only.", - inputSchema: z.object({ - path: z.string().describe("Absolute directory path."), - }), - execute: async ({ path }) => { - const entries = await ws.fs.readdir(path); - return { - path, - entries: entries.map((e) => ({ - name: e.name, - type: e.isDirectory ? "dir" : "file", - })), - }; - }, - }); -} - // ── Small helpers ───────────────────────────────────────────────────────────── /** diff --git a/examples/think/src/tools/exec.ts b/examples/think/src/tools/exec.ts deleted file mode 100644 index 27a84cb2..00000000 --- a/examples/think/src/tools/exec.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * `exec` — run a shell command inside the workspace's configured - * backends. The tool exposes a `backend` parameter so the model - * picks where each command runs. - * - * The agent is told about each backend's tradeoffs through the - * descriptions on `ExecToolOptions.backends`. A typical setup - * (see agent.ts) declares two: a "shell" backend (just-bash in a - * Dynamic Worker, cold-start fast but limited to its built-in - * command set) and a "container" backend (Cloudflare Container - * running wsd, full Linux userland but slow to boot). The tool - * description hints that the model should try the default backend - * first and fall through to a heavier one only when the lighter - * shell can't run the command. - * - * Borrowed from the hackspace agent's exec tool but stripped of - * the streaming-UI machinery (`LoopTracker`, `ExecOutputBuffer`, - * per-tool-call cancellation). This example has no UI to stream - * into and the loop is short enough that running an exec to - * completion in one tool round is fine. - */ - -import { tool } from "ai"; -import { z } from "zod"; - -/** - * Minimal subset of `@cloudflare/workspace.Workspace` we depend on: - * the shell facade exposes `exec(command, { cwd, encoding, backend })` - * and the returned handle resolves to a `{ exitCode, stdout, stderr }` - * result. - */ -export interface ExecWorkspaceLike { - shell: { - exec( - command: string, - options: { cwd?: string; encoding: "utf8"; backend?: string }, - ): Promise<{ - result(): Promise<{ - exitCode: number; - stdout: string; - stderr: string; - }>; - }>; - }; -} - -export interface ExecBackendDescription { - /** - * One-paragraph summary of what this backend can and can't run. - * The model reads it through the tool's input-schema description - * to decide which backend a given command belongs on. - */ - description: string; -} - -export interface ExecToolOptions { - workspace: ExecWorkspaceLike; - /** - * The set of backend ids the tool advertises to the model. Each - * entry's description is folded into the `backend` parameter's - * schema so the model can read the tradeoffs. - * - * Keys must match the `id` of a backend the underlying Workspace - * was constructed with. An unknown id reaches the Workspace and - * rejects with a clear error from there. - */ - backends: Record; - /** - * Which backend the tool picks when the model omits `backend`. - * Must be one of the keys in `backends`. Typically the cheapest - * / fastest one (a worker-isolate shell rather than a container). - */ - defaultBackend: string; - /** Truncate captured stdout/stderr above this many bytes. */ - maxBytes?: number; -} - -const DEFAULT_MAX_BYTES = 64 * 1024; // 64 KiB per stream - -export function createExecTool(opts: ExecToolOptions) { - const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES; - const backendIds = Object.keys(opts.backends); - if (backendIds.length === 0) { - throw new Error("createExecTool: pass at least one backend in `backends`"); - } - if (!backendIds.includes(opts.defaultBackend)) { - throw new Error( - `createExecTool: defaultBackend ${JSON.stringify(opts.defaultBackend)} is not one of ${backendIds.map((id) => JSON.stringify(id)).join(", ")}`, - ); - } - - // Render the per-backend descriptions into one block of guidance - // the model reads on every tool call. Keeps the agent's mental - // model of "which backend can run what" in front of it without - // forcing a separate tools-table read. - const backendGuidance = backendIds - .map((id) => `- ${JSON.stringify(id)}: ${opts.backends[id].description}`) - .join("\n"); - - const description = [ - "Run a shell command in the workspace. The workspace exposes", - "multiple backends, each with different capabilities. Pick the", - "cheapest backend that can run the command; fall back to a", - "heavier one only when the lighter backend's command set", - "doesn't cover what you need.", - "", - "Backends:", - backendGuidance, - "", - `Default backend: ${JSON.stringify(opts.defaultBackend)}. Try this`, - "first for any command you're not sure about; if it fails with a", - '"command not found" or a similar capability error, retry on a', - "backend whose description covers the missing tool.", - "", - "Use for builds, test runs, typechecks, formatters, and `git`", - "plumbing. Prefer the dedicated `read` / `write` / `edit` tools", - "for file ops. Long output is truncated to keep tool replies", - "small.", - ].join("\n"); - - // Schema-side description for the backend field. zod's - // describe() metadata threads through to the JSON schema the - // model sees on each call. - const backendSchema = z - .enum(backendIds as [string, ...string[]]) - .optional() - .describe( - [ - "Which backend to run on. Omit to use the default", - `(${JSON.stringify(opts.defaultBackend)}). Set explicitly when the`, - "default backend isn't capable of running the command (see the", - "per-backend descriptions in the tool summary).", - ].join(" "), - ); - - const inputSchema = z.object({ - command: z.string().describe("Shell command, e.g. 'npm test -- --run' or 'git diff HEAD'."), - cwd: z.string().optional().describe("Working directory. Defaults to the workspace root."), - backend: backendSchema, - }); - - return tool({ - description, - inputSchema, - execute: async ({ command, cwd, backend }) => { - const handle = await opts.workspace.shell.exec(command, { - cwd, - encoding: "utf8", - backend, - }); - const result = await handle.result(); - return { - command, - cwd: cwd ?? null, - backend: backend ?? opts.defaultBackend, - exitCode: result.exitCode, - stdout: truncate(result.stdout, maxBytes), - stderr: truncate(result.stderr, maxBytes), - }; - }, - }); -} - -function truncate(value: string, maxBytes: number): string { - if (!value) return value; - // Approximate bytes via length; UTF-8 worst case overcounts but - // never undercounts, which is what we want for a soft cap. - if (value.length <= maxBytes) return value; - return `${value.slice(0, maxBytes)}\n\n[truncated, ${value.length - maxBytes} more bytes]`; -} diff --git a/examples/think/src/tools/fs/edit-diff.ts b/examples/think/src/tools/fs/edit-diff.ts deleted file mode 100644 index 80e3ab99..00000000 --- a/examples/think/src/tools/fs/edit-diff.ts +++ /dev/null @@ -1,322 +0,0 @@ -/** - * Pure text-manipulation primitives for the edit tool. No filesystem I/O lives - * here — that's deliberate so this module stays trivially testable. Adapted - * from earendil-works/pi (packages/coding-agent/src/core/tools/edit-diff.ts) - * with the fs/promises preview path removed. - */ - -import * as Diff from "diff"; - -// ---------- line endings & BOM ---------- - -export function detectLineEnding(content: string): "\r\n" | "\n" { - const crlfIdx = content.indexOf("\r\n"); - const lfIdx = content.indexOf("\n"); - if (lfIdx === -1) return "\n"; - if (crlfIdx === -1) return "\n"; - return crlfIdx < lfIdx ? "\r\n" : "\n"; -} - -export function normalizeToLF(text: string): string { - return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); -} - -export function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string { - return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text; -} - -export function stripBom(content: string): { bom: string; text: string } { - return content.startsWith("\uFEFF") - ? { bom: "\uFEFF", text: content.slice(1) } - : { bom: "", text: content }; -} - -// ---------- fuzzy matching ---------- - -/** - * Progressive normalization for fuzzy matching: - * - NFKC unicode normalization - * - strip trailing whitespace per line - * - smart quotes → ASCII - * - assorted unicode dashes → "-" - * - non-breaking / typographic spaces → " " - */ -export function normalizeForFuzzyMatch(text: string): string { - return text - .normalize("NFKC") - .split("\n") - .map((line) => line.trimEnd()) - .join("\n") - .replace(/[\u2018\u2019\u201A\u201B]/g, "'") - .replace(/[\u201C\u201D\u201E\u201F]/g, '"') - .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-") - .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " "); -} - -export interface FuzzyMatchResult { - found: boolean; - index: number; - matchLength: number; - usedFuzzyMatch: boolean; - /** The content the caller should treat as the base for replacement. */ - contentForReplacement: string; -} - -export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResult { - const exactIndex = content.indexOf(oldText); - if (exactIndex !== -1) { - return { - found: true, - index: exactIndex, - matchLength: oldText.length, - usedFuzzyMatch: false, - contentForReplacement: content, - }; - } - - const fuzzyContent = normalizeForFuzzyMatch(content); - const fuzzyOldText = normalizeForFuzzyMatch(oldText); - const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText); - if (fuzzyIndex === -1) { - return { - found: false, - index: -1, - matchLength: 0, - usedFuzzyMatch: false, - contentForReplacement: content, - }; - } - return { - found: true, - index: fuzzyIndex, - matchLength: fuzzyOldText.length, - usedFuzzyMatch: true, - contentForReplacement: fuzzyContent, - }; -} - -// ---------- edit application ---------- - -export interface Edit { - oldText: string; - newText: string; -} - -interface MatchedEdit { - editIndex: number; - matchIndex: number; - matchLength: number; - newText: string; -} - -export interface AppliedEditsResult { - baseContent: string; - newContent: string; -} - -function countOccurrences(content: string, oldText: string): number { - const fuzzyContent = normalizeForFuzzyMatch(content); - const fuzzyOldText = normalizeForFuzzyMatch(oldText); - return fuzzyContent.split(fuzzyOldText).length - 1; -} - -function notFound(path: string, idx: number, total: number): Error { - if (total === 1) { - return new Error( - `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`, - ); - } - return new Error( - `Could not find edits[${idx}] in ${path}. The oldText must match exactly including all whitespace and newlines.`, - ); -} -function duplicate(path: string, idx: number, total: number, n: number): Error { - if (total === 1) { - return new Error( - `Found ${n} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`, - ); - } - return new Error( - `Found ${n} occurrences of edits[${idx}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`, - ); -} -function emptyOldText(path: string, idx: number, total: number): Error { - if (total === 1) return new Error(`oldText must not be empty in ${path}.`); - return new Error(`edits[${idx}].oldText must not be empty in ${path}.`); -} -function noChange(path: string, total: number): Error { - if (total === 1) { - return new Error( - `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`, - ); - } - return new Error(`No changes made to ${path}. The replacements produced identical content.`); -} - -/** - * Apply one or more edits to LF-normalized content. All edits are matched - * against the original content; replacements are then applied right-to-left - * so earlier offsets stay valid. - */ -export function applyEditsToNormalizedContent( - normalizedContent: string, - edits: Edit[], - path: string, -): AppliedEditsResult { - const normalized = edits.map((e) => ({ - oldText: normalizeToLF(e.oldText), - newText: normalizeToLF(e.newText), - })); - - for (let i = 0; i < normalized.length; i++) { - if (normalized[i].oldText.length === 0) throw emptyOldText(path, i, normalized.length); - } - - // If any edit needs fuzzy matching, do all replacement in fuzzy-normalized - // space so indices line up. Otherwise stay in the caller's exact content. - const initial = normalized.map((e) => fuzzyFindText(normalizedContent, e.oldText)); - const baseContent = initial.some((m) => m.usedFuzzyMatch) - ? normalizeForFuzzyMatch(normalizedContent) - : normalizedContent; - - const matched: MatchedEdit[] = []; - for (let i = 0; i < normalized.length; i++) { - const e = normalized[i]; - const m = fuzzyFindText(baseContent, e.oldText); - if (!m.found) throw notFound(path, i, normalized.length); - const occurrences = countOccurrences(baseContent, e.oldText); - if (occurrences > 1) throw duplicate(path, i, normalized.length, occurrences); - matched.push({ - editIndex: i, - matchIndex: m.index, - matchLength: m.matchLength, - newText: e.newText, - }); - } - - matched.sort((a, b) => a.matchIndex - b.matchIndex); - for (let i = 1; i < matched.length; i++) { - const prev = matched[i - 1]; - const cur = matched[i]; - if (prev.matchIndex + prev.matchLength > cur.matchIndex) { - throw new Error( - `edits[${prev.editIndex}] and edits[${cur.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`, - ); - } - } - - let newContent = baseContent; - for (let i = matched.length - 1; i >= 0; i--) { - const m = matched[i]; - newContent = - newContent.substring(0, m.matchIndex) + - m.newText + - newContent.substring(m.matchIndex + m.matchLength); - } - - if (baseContent === newContent) throw noChange(path, normalized.length); - return { baseContent, newContent }; -} - -// ---------- diffs ---------- - -export function generateUnifiedPatch( - path: string, - oldContent: string, - newContent: string, - contextLines = 4, -): string { - return Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, { - context: contextLines, - }); -} - -export interface EditDiffResult { - diff: string; - firstChangedLine: number | undefined; -} - -/** Display-oriented diff with line numbers and bounded context. */ -export function generateDiffString( - oldContent: string, - newContent: string, - contextLines = 4, -): EditDiffResult { - const parts = Diff.diffLines(oldContent, newContent); - const output: string[] = []; - - const oldLines = oldContent.split("\n"); - const newLines = newContent.split("\n"); - const maxLineNum = Math.max(oldLines.length, newLines.length); - const w = String(maxLineNum).length; - - let oldLineNum = 1; - let newLineNum = 1; - let lastWasChange = false; - let firstChangedLine: number | undefined; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - const raw = part.value.split("\n"); - if (raw[raw.length - 1] === "") raw.pop(); - - if (part.added || part.removed) { - if (firstChangedLine === undefined) firstChangedLine = newLineNum; - for (const line of raw) { - if (part.added) { - output.push(`+${String(newLineNum).padStart(w, " ")} ${line}`); - newLineNum++; - } else { - output.push(`-${String(oldLineNum).padStart(w, " ")} ${line}`); - oldLineNum++; - } - } - lastWasChange = true; - } else { - const nextIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed); - const leading = lastWasChange; - const trailing = nextIsChange; - - const emit = (line: string) => { - output.push(` ${String(oldLineNum).padStart(w, " ")} ${line}`); - oldLineNum++; - newLineNum++; - }; - - if (leading && trailing) { - if (raw.length <= contextLines * 2) { - raw.forEach(emit); - } else { - raw.slice(0, contextLines).forEach(emit); - const skipped = raw.length - contextLines * 2; - output.push(` ${"".padStart(w, " ")} ...`); - oldLineNum += skipped; - newLineNum += skipped; - raw.slice(raw.length - contextLines).forEach(emit); - } - } else if (leading) { - raw.slice(0, contextLines).forEach(emit); - const skipped = raw.length - contextLines; - if (skipped > 0) { - output.push(` ${"".padStart(w, " ")} ...`); - oldLineNum += skipped; - newLineNum += skipped; - } - } else if (trailing) { - const skipped = Math.max(0, raw.length - contextLines); - if (skipped > 0) { - output.push(` ${"".padStart(w, " ")} ...`); - oldLineNum += skipped; - newLineNum += skipped; - } - raw.slice(skipped).forEach(emit); - } else { - oldLineNum += raw.length; - newLineNum += raw.length; - } - lastWasChange = false; - } - } - - return { diff: output.join("\n"), firstChangedLine }; -} diff --git a/examples/think/src/tools/fs/index.ts b/examples/think/src/tools/fs/index.ts deleted file mode 100644 index 9de8f645..00000000 --- a/examples/think/src/tools/fs/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Vendored, slimmed-down copy of `@cloudflare/fs-tools` from the - * `hackspace` branch. Tools/edit-diff are verbatim; the - * `WorkspaceFileStore` is adapted to the next-branch - * `@cloudflare/workspace` shape (see `stores/workspace.ts`). - */ -export type { FileStat, FileStore } from "./stores/types.js"; -export { - WorkspaceFileStore, - type WorkspaceLike, -} from "./stores/workspace.js"; -export { createEditTool, type EditToolOptions } from "./tools/edit.js"; -export { createReadTool, type ReadToolOptions } from "./tools/read.js"; -export { - createWriteTool, - type WriteToolOptions, -} from "./tools/write.js"; diff --git a/examples/think/src/tools/fs/stores/types.ts b/examples/think/src/tools/fs/stores/types.ts deleted file mode 100644 index 0caaa7b2..00000000 --- a/examples/think/src/tools/fs/stores/types.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Vendored verbatim from `@cloudflare/fs-tools` on the `hackspace` - * branch. The interface is the boundary every fs-tool talks to; - * stores are cheap to construct and safe to share across concurrent - * tool invocations. Streaming methods MUST NOT load the full file - * into memory at any single point — that is the whole reason the - * boundary exists. - */ -export interface FileStat { - /** Size in bytes. */ - size: number; - /** Last-modified epoch milliseconds. */ - mtime: number; - /** - * POSIX-style mode bits (e.g. 0o100644 for a regular file, 0o100755 - * for an executable). Omitted by stores that don't track modes. - */ - mode?: number; -} - -export interface FileStore { - /** Return file metadata, or null if the path does not exist or is not a file. */ - stat(path: string): Promise; - - /** - * Stream the file (or a byte range of it) in chunks. The total - * bytes yielded MUST equal min(byteLength ?? size - byteOffset, - * size - byteOffset). Throws if the path does not exist. - */ - readChunks(path: string, byteOffset?: number, byteLength?: number): AsyncIterable; - - /** - * Read the entire file into memory. Used for images and for the - * edit tool where fuzzy matching needs the whole buffer. Returns - * null if missing. - */ - readAll(path: string): Promise; - - /** - * Overwrite (or create) a file with the given bytes. `opts.mode` - * lets the caller pin the resulting file's POSIX mode — used by - * `edit` and `write` to preserve the executable bit across an - * overwrite. - */ - write(path: string, content: Uint8Array, opts?: { mode?: number }): Promise; -} diff --git a/examples/think/src/tools/fs/stores/workspace.ts b/examples/think/src/tools/fs/stores/workspace.ts deleted file mode 100644 index 6c5c9e35..00000000 --- a/examples/think/src/tools/fs/stores/workspace.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * `FileStore` adapter over `@cloudflare/workspace`'s `Workspace.fs` - * surface. Hackspace's fs-tools assumed a flat `stat / readFile / - * writeFile` shape; the next-branch `Workspace` nests them under - * `.fs` and returns a different stat result. This adapter is the - * only place that knows. - * - * Reads go through `fs.readFile(path, "utf8" | ReadFileOptions)` — - * for binary we ask for a `ReadableStream` and stitch it - * back together either chunk-by-chunk (`readChunks`) or all at once - * (`readAll`). - */ - -import type { FileStat, FileStore } from "./types.js"; - -/** - * Structural subset of `@cloudflare/workspace.Workspace` we depend - * on. Avoids a hard type-time import so this module can be vendored - * around the example with no fuss. - */ -export interface WorkspaceLike { - fs: { - stat(path: string): Promise<{ - size: number; - mtime: number; - mode: number; - isFile: boolean; - isDirectory: boolean; - }>; - readFile(path: string): Promise>; - readFile( - path: string, - options: { offset?: number; length?: number }, - ): Promise>; - writeFile(path: string, content: Uint8Array, options?: { mode?: number }): Promise; - mkdir(path: string, options?: { recursive?: boolean }): Promise; - rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise; - readdir(path: string): Promise>; - }; -} - -export class WorkspaceFileStore implements FileStore { - constructor(private readonly ws: WorkspaceLike) {} - - async stat(path: string): Promise { - try { - const s = await this.ws.fs.stat(path); - if (!s.isFile) return null; - return { size: s.size, mtime: s.mtime, mode: s.mode }; - } catch (err) { - if (isEnoent(err)) return null; - throw err; - } - } - - async readAll(path: string): Promise { - try { - const stream = await this.ws.fs.readFile(path); - return await drain(stream); - } catch (err) { - if (isEnoent(err)) return null; - throw err; - } - } - - async write(path: string, content: Uint8Array, opts?: { mode?: number }): Promise { - await ensureParentDir(this.ws, path); - await this.ws.fs.writeFile(path, content, opts); - } - - async *readChunks(path: string, byteOffset = 0, byteLength?: number): AsyncIterable { - // The Workspace readFile already returns a chunked stream; we just - // re-yield with the requested offset/length applied at the source. - const options: { offset?: number; length?: number } = {}; - if (byteOffset > 0) options.offset = byteOffset; - if (byteLength !== undefined) options.length = byteLength; - const stream = await this.ws.fs.readFile(path, options); - const reader = stream.getReader(); - try { - while (true) { - const { value, done } = await reader.read(); - if (done) break; - if (value && value.byteLength > 0) yield value; - } - } finally { - reader.releaseLock(); - } - } -} - -async function drain(stream: ReadableStream): Promise { - const reader = stream.getReader(); - const parts: Uint8Array[] = []; - let total = 0; - try { - while (true) { - const { value, done } = await reader.read(); - if (done) break; - if (value) { - parts.push(value); - total += value.byteLength; - } - } - } finally { - reader.releaseLock(); - } - if (parts.length === 1) return parts[0]; - const out = new Uint8Array(total); - let off = 0; - for (const p of parts) { - out.set(p, off); - off += p.byteLength; - } - return out; -} - -async function ensureParentDir(ws: WorkspaceLike, path: string): Promise { - const i = path.lastIndexOf("/"); - if (i <= 0) return; - const parent = path.slice(0, i); - await ws.fs.mkdir(parent, { recursive: true }); -} - -function isEnoent(err: unknown): boolean { - if (!err || typeof err !== "object") return false; - const e = err as { code?: string; message?: string }; - if (e.code === "ENOENT") return true; - return typeof e.message === "string" && /ENOENT|no such/i.test(e.message); -} diff --git a/examples/think/src/tools/fs/tools/edit.ts b/examples/think/src/tools/fs/tools/edit.ts deleted file mode 100644 index f72eb867..00000000 --- a/examples/think/src/tools/fs/tools/edit.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { tool } from "ai"; -import { z } from "zod"; -import { - applyEditsToNormalizedContent, - detectLineEnding, - type Edit, - generateDiffString, - generateUnifiedPatch, - normalizeToLF, - restoreLineEndings, - stripBom, -} from "../edit-diff.js"; -import type { FileStore } from "../stores/types.js"; - -export interface EditToolOptions { - store: FileStore; - /** - * Reject edits to files larger than this byte cap. Fuzzy matching needs the - * whole buffer in memory, so we'd rather force the model to use `write`. - * Default 2 MiB. - */ - maxBytes?: number; -} - -const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; - -const replacementSchema = z - .object({ - oldText: z - .string() - .describe( - "Exact text for one targeted replacement. Must be unique in the original file and not overlap with any other edits[].oldText in the same call.", - ), - newText: z.string().describe("Replacement text for this targeted edit."), - }) - .strict(); - -const inputSchema = z.object({ - path: z.string().describe("Path to the file to edit"), - edits: z - .array(replacementSchema) - .describe( - "One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits.", - ), -}); - -/** Best-effort coercion for inputs from quirky models. */ -function prepareArguments(input: unknown): { path: string; edits: Edit[] } { - if (!input || typeof input !== "object") return input as { path: string; edits: Edit[] }; - const args = input as Record; - - // Some models pack edits into a JSON string. - if (typeof args.edits === "string") { - try { - const parsed = JSON.parse(args.edits); - if (Array.isArray(parsed)) args.edits = parsed; - } catch { - /* fall through to validation error */ - } - } - - // Legacy single-edit shape: oldText/newText siblings on the root object. - if (typeof args.oldText === "string" && typeof args.newText === "string") { - const edits = Array.isArray(args.edits) ? [...(args.edits as Edit[])] : []; - edits.push({ oldText: args.oldText as string, newText: args.newText as string }); - args.edits = edits; - delete args.oldText; - delete args.newText; - } - - return args as { path: string; edits: Edit[] }; -} - -// Per-path mutation queue. Edit and write should never race on the same file: -// fuzzy matching reads the entire buffer, applies a textual change, then -// writes — a concurrent edit landing between read and write would silently -// clobber the first edit. Module-scoped so all tools sharing a store also -// share the queue. -const fileLocks = new Map>(); -async function withFileLock(path: string, fn: () => Promise): Promise { - const prev = fileLocks.get(path) ?? Promise.resolve(); - const next = prev.then(fn, fn); - fileLocks.set( - path, - next.finally(() => { - // Clear only if we're still the tail of the chain. - if (fileLocks.get(path) === next) fileLocks.delete(path); - }), - ); - return next; -} - -export function createEditTool(options: EditToolOptions) { - const { store } = options; - const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; - - return tool({ - description: - "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes touch the same block, merge them into one edit.", - inputSchema, - execute: async (rawInput: unknown) => { - const { path, edits } = prepareArguments(rawInput); - - if (!Array.isArray(edits) || edits.length === 0) { - return { error: "edits must contain at least one replacement." }; - } - - return withFileLock(path, async () => { - const stat = await store.stat(path); - if (!stat) return { error: `File not found: ${path}` }; - if (stat.size > maxBytes) { - return { - error: `File too large to edit: ${stat.size} bytes exceeds the ${maxBytes}-byte cap. Use the write tool to rewrite the file from scratch.`, - }; - } - - const bytes = await store.readAll(path); - if (!bytes) return { error: `File not found: ${path}` }; - - const rawContent = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true }).decode( - bytes, - ); - const { bom, text } = stripBom(rawContent); - const ending = detectLineEnding(text); - const normalized = normalizeToLF(text); - - let baseContent: string; - let newContent: string; - try { - ({ baseContent, newContent } = applyEditsToNormalizedContent(normalized, edits, path)); - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } - - const finalContent = bom + restoreLineEndings(newContent, ending); - // Round-trip the file's mode so editing an executable script (or any - // file with a non-default mode) doesn't silently drop bits. `stat.mode` - // is undefined for stores that don't track modes; pass `undefined` in - // that case so the store applies its own default. - await store.write(path, new TextEncoder().encode(finalContent), { mode: stat.mode }); - - const diffResult = generateDiffString(baseContent, newContent); - const patch = generateUnifiedPatch(path, baseContent, newContent); - - return { - path, - editsApplied: edits.length, - diff: diffResult.diff, - patch, - firstChangedLine: diffResult.firstChangedLine, - }; - }); - }, - }); -} diff --git a/examples/think/src/tools/fs/tools/read.ts b/examples/think/src/tools/fs/tools/read.ts deleted file mode 100644 index 4cdf2ced..00000000 --- a/examples/think/src/tools/fs/tools/read.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { tool } from "ai"; -import { z } from "zod"; -import type { FileStore } from "../stores/types.js"; - -export interface ReadToolOptions { - store: FileStore; - /** Hard line cap. Default 2000. */ - maxLines?: number; - /** Hard byte cap. Default 256 KiB. */ - maxBytes?: number; -} - -const DEFAULT_MAX_LINES = 2000; -const DEFAULT_MAX_BYTES = 256 * 1024; - -const inputSchema = z.object({ - path: z.string().describe("Path to the file to read"), - offset: z - .number() - .int() - .min(1) - .optional() - .describe("Line number to start reading from (1-indexed)"), - limit: z.number().int().min(1).optional().describe("Maximum number of lines to read"), -}); - -interface ReadResult { - path: string; - content: string; - startLine: number; - endLine: number; - totalLines: number | null; - truncated: boolean; - nextOffset?: number; -} - -/** - * Memory-efficient line reader. Pulls chunks lazily through the store's - * `readChunks` iterable; stops the moment the line/byte budget is filled. - * Never materializes the full file unless the file itself fits within the - * budget. - * - * Returns a continuation `nextOffset` whenever output was truncated so the - * model can call `read` again with `offset=nextOffset` to keep going. Total - * line count is reported as `null` when truncation cut us off — counting - * every line in a multi-megabyte file would defeat the streaming approach. - */ - -// Workerd has no Buffer. TextEncoder.encode allocates a Uint8Array per call, -// but it's the only portable byte-length primitive available across Node and -// workers runtimes. -const _enc = new TextEncoder(); -function utf8ByteLength(s: string): number { - return _enc.encode(s).length; -} -export function createReadTool(options: ReadToolOptions) { - const { store } = options; - const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; - const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; - - return tool({ - description: `Read the contents of a file. Output is truncated to ${maxLines} lines or ${Math.round(maxBytes / 1024)}KB, whichever is reached first; use offset/limit to page through large files.`, - inputSchema, - execute: async ({ path, offset, limit }): Promise => { - const stat = await store.stat(path); - if (!stat) return { error: `File not found: ${path}` }; - - const startLine = offset ?? 1; - const wantedLines = limit ?? maxLines; - const lineCap = Math.min(wantedLines, maxLines); - - const decoder = new TextDecoder("utf-8"); - let carry = ""; // bytes from previous chunk that didn't end on a newline - let currentLine = 1; // 1-indexed line we're about to emit - const collected: string[] = []; - let collectedBytes = 0; - let firstEmittedLine: number | null = null; - let truncatedByBudget = false; - let firstLineOverflow = false; - - const processLine = (line: string): boolean => { - // Returns true to keep going, false to stop the outer pump. - if (currentLine < startLine) { - currentLine++; - return true; - } - // We're at or past `startLine` — try to emit. - const lineBytes = utf8ByteLength(line); - if (collected.length === 0 && lineBytes > maxBytes) { - firstLineOverflow = true; - return false; - } - // Stop before emitting if this line would push us over either cap. - if (collected.length >= lineCap) { - truncatedByBudget = true; - return false; - } - if (collectedBytes + lineBytes + (collected.length > 0 ? 1 : 0) > maxBytes) { - truncatedByBudget = true; - return false; - } - if (firstEmittedLine === null) firstEmittedLine = currentLine; - collected.push(line); - collectedBytes += lineBytes + (collected.length > 1 ? 1 : 0); - currentLine++; - return true; - }; - - let keepGoing = true; - for await (const chunk of store.readChunks(path)) { - if (!keepGoing) break; - carry += decoder.decode(chunk, { stream: true }); - // Process every complete line in `carry`. Keep the final partial line - // for the next iteration. - let nl = carry.indexOf("\n"); - while (nl !== -1) { - const line = carry.slice(0, nl); - carry = carry.slice(nl + 1); - if (!processLine(line)) { - keepGoing = false; - break; - } - nl = carry.indexOf("\n"); - } - } - // Flush the decoder and process any trailing line. - if (keepGoing) { - carry += decoder.decode(); - if (carry.length > 0) { - processLine(carry); - } else if (currentLine === 1 && stat.size === 0) { - // empty file - } - } - - if (firstLineOverflow) { - return { - error: `Line ${currentLine} exceeds the ${maxBytes}-byte read cap. Increase the cap or read a narrower range with offset/limit.`, - }; - } - - if (firstEmittedLine === null) { - // Either an empty file (totalLines = 0 or 1) or offset past EOF. - // currentLine reflects how many lines we've seen so far. - const linesSeen = currentLine - 1; - if (stat.size === 0) { - return { - path, - content: "", - startLine: 1, - endLine: 0, - totalLines: 0, - truncated: false, - }; - } - if (offset !== undefined && startLine > Math.max(1, linesSeen)) { - return { error: `Offset ${offset} is beyond end of file (${linesSeen} line(s))` }; - } - } - - const startLineActual = firstEmittedLine ?? startLine; - const endLine = startLineActual + collected.length - 1; - const truncated = truncatedByBudget; - const totalLines = truncated ? null : currentLine - 1; - const nextOffset = truncated ? endLine + 1 : undefined; - - const result: ReadResult = { - path, - content: collected.join("\n"), - startLine: startLineActual, - endLine, - totalLines, - truncated, - }; - if (nextOffset !== undefined) result.nextOffset = nextOffset; - return result; - }, - }); -} diff --git a/examples/think/src/tools/fs/tools/write.ts b/examples/think/src/tools/fs/tools/write.ts deleted file mode 100644 index 992c53d6..00000000 --- a/examples/think/src/tools/fs/tools/write.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { tool } from "ai"; -import { z } from "zod"; -import type { FileStore } from "../stores/types.js"; - -export interface WriteToolOptions { - store: FileStore; - /** - * Reject writes whose UTF-8 byte length exceeds this cap. The model is - * pointed at the edit tool instead. Default 2 MiB. - */ - maxBytes?: number; -} - -const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; - -const inputSchema = z.object({ - path: z.string().describe("Absolute path, e.g. /workspace/main.zig"), - content: z.string().describe("File content"), -}); - -export function createWriteTool(options: WriteToolOptions) { - const { store } = options; - const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; - - return tool({ - description: "Write content to a file. Overwrites any existing file at the path.", - inputSchema, - execute: async ({ path, content }) => { - const bytes = new TextEncoder().encode(content); - if (bytes.length > maxBytes) { - return { - error: `Content too large: ${bytes.length} bytes exceeds the ${maxBytes}-byte write cap. Use the edit tool for incremental changes to existing files, or split the write into smaller pieces.`, - }; - } - // Preserve the existing file's mode when overwriting so executable - // scripts don't silently lose their +x bit. For new files we leave - // `mode` undefined and let the store apply its own default. - const existing = await store.stat(path); - await store.write(path, bytes, existing ? { mode: existing.mode } : undefined); - return { path, bytesWritten: bytes.length }; - }, - }); -} diff --git a/examples/think/src/tools/share.ts b/examples/think/src/tools/share.ts deleted file mode 100644 index 170a92cf..00000000 --- a/examples/think/src/tools/share.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * `share` — upload a workspace file to R2 and return a link the - * caller can open. Built on `@cloudflare/workspace/assets`: the - * bucket binding and credentials are bound at construction time, so - * the model only supplies the path and an optional lifetime. - * - * The presigner needs R2 S3 credentials the bucket binding can't - * surface, so the tool is only registered when those are present in - * the environment (see `createTools` in `agent.ts`). Failures are - * returned, not thrown, so a bad path doesn't unwind the agentic - * loop. - */ - -import type { Workspace } from "@cloudflare/workspace"; -import { createAssets } from "@cloudflare/workspace/assets"; -import { tool } from "ai"; -import { z } from "zod"; - -export interface ShareToolOptions { - workspace: Workspace; - // R2 binding the upload goes through. - bucket: R2Bucket; - // S3 bucket name and credential source for the presigner. - s3Bucket: string; - env: Record; -} - -const DEFAULT_EXPIRY_MS = 60 * 60 * 1000; // one hour - -export function createShareTool(opts: ShareToolOptions) { - const assets = createAssets({ - ws: opts.workspace, - bucket: opts.bucket, - s3: { bucket: opts.s3Bucket }, - env: opts.env, - }); - - return tool({ - description: - "Share a file from the workspace by uploading it to R2 and " + - "returning a time-limited link. Use this to hand the user an " + - "artifact you produced — a chart, screenshot, build output, or " + - "report. The link expires; pass expiresAfterMs to control how " + - "long it lives (default one hour).", - inputSchema: z.object({ - path: z.string().min(1).describe("Absolute workspace path, e.g. /workspace/out/chart.png."), - expiresAfterMs: z - .number() - .int() - .positive() - .optional() - .describe("Link lifetime in milliseconds. Defaults to one hour."), - }), - execute: async ({ path, expiresAfterMs }) => { - try { - const url = await assets.share(path, { - expiresAfter: expiresAfterMs ?? DEFAULT_EXPIRY_MS, - prefix: `agent-${opts.workspace.sessionId}`, - }); - return { ok: true, url }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : String(err), - }; - } - }, - }); -} diff --git a/package-lock.json b/package-lock.json index e98e65a3..ca5ab7a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,7 +68,6 @@ "@platformatic/vfs": "^0.4.0", "agents": "^0.14.1", "ai": "^6.0.196", - "diff": "^9.0.0", "isomorphic-git": "^1.38.3", "workers-ai-provider": "^3.1.14", "zod": "^4.4.3" @@ -78,7 +77,6 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20260616.1", - "@types/diff": "^8.0.0", "typescript": "^6.0.3", "wrangler": "^4.96.0" } @@ -4674,17 +4672,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/diff": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@types/diff/-/diff-8.0.0.tgz", - "integrity": "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw==", - "deprecated": "This is a stub types definition. diff provides its own type definitions, so you do not need this installed.", - "dev": true, - "license": "MIT", - "dependencies": { - "diff": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -6004,6 +5991,7 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" From 743424ed48bc91139bf319d3784ecc16e038da32 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:57:31 +0000 Subject: [PATCH 3/7] docs: Document workspace tool interface Update the tool interface guide for the shipped @cloudflare/workspace/tools entrypoint. The docs now cover createAITools(), fixed tool names, read-only mode, opt-in exec, asset sharing, FileStore ranges, and the validation commands for the change. --- docs/09_tool_interface.md | 322 +++++++++++++++++++++----------------- docs/README.md | 5 +- 2 files changed, 182 insertions(+), 145 deletions(-) diff --git a/docs/09_tool_interface.md b/docs/09_tool_interface.md index a3dc2ced..98bea41e 100644 --- a/docs/09_tool_interface.md +++ b/docs/09_tool_interface.md @@ -1,82 +1,108 @@ -# 09. Tool Interface (Agents) - -> [!IMPORTANT] -> The `@cloudflare/fs-tools` package described here is **not yet -> implemented**. The substrate (`workspace.fs.*`, `workspace.shell.exec`) -> is in place; only the AI-SDK wrappers and the `FileStore` abstraction -> are missing. This doc remains the design target. -> -> Git access already ships through `workspace.git`, the third major -> surface on `Workspace` alongside `fs` and `shell`. The original plan -> was a sibling `@cloudflare/git-tools` package, but the typed and -> argv-driven APIs (`workspace.git.clone(...)`, -> `workspace.git.cli({ argv })`) live in `@cloudflare/workspace/git` -> today. AI-SDK tool wrappers around that surface can land later -> against a stable target. See -> [`13_git_interface.md`](./13_git_interface.md). - -The `@cloudflare/fs-tools` package ships ready-made -[AI SDK](https://github.com/vercel/ai) tools that drive a `Workspace` -through its `FileStore` adapter. Drop them into a `@cloudflare/agents` -agent and the model can read, write, and edit files in the workspace -without you wiring tool definitions by hand. +# 09. Tool interface (agents) + +`@cloudflare/workspace/tools` ships ready-made [AI SDK](https://github.com/vercel/ai) tools for agents that use a `Workspace`. The first provider target is the AI SDK because it is the tool layer used by the `agents` SDK and the Think example. + +The tools are thin wrappers over the existing `Workspace` surfaces: + +- `workspace.fs` for file reads, writes, edits, and directory listing. +- `workspace.shell.exec` for command execution when the caller opts in. +- `workspace.assets` for publishing generated files when an assets publisher is configured. + +Git access already ships through `workspace.git`, the third major surface on `Workspace` alongside `fs` and `shell`. AI SDK tool wrappers around that surface can land later against a stable target. See [`13_git_interface.md`](./13_git_interface.md). ## What ships -| Tool | Purpose | +| Export | Purpose | | --- | --- | +| `createAITools` | Create the default AI SDK `ToolSet` for a Workspace. | | `createReadTool` | Memory-efficient, line-windowed file read. | | `createWriteTool` | Whole-file write with a UTF-8 byte cap. | -| `createEditTool` | Fuzzy-matched targeted replacements with unified-diff preview. | -| `createGrepTool` | Recursive content search across the workspace. | -| `createExecTool` | Run a shell command inside the sandbox container. | +| `createEditTool` | Exact targeted replacements with unified-diff preview. | +| `createListTool` | One-level directory listing. | +| `createExecTool` | Run a shell command through a configured Workspace backend. | +| `createShareTool` | Publish a workspace file through `workspace.assets`. | +| `WorkspaceFileStore` | Adapt `Workspace.fs` to the file-store shape used by the file tools. | -Plus the low-level building blocks: - -- `WorkspaceFileStore` — adapts a `Workspace` to the `FileStore` shape - the tools consume. -- `InMemoryFileStore` — in-memory implementation for tests. -- `FileStore`, `FileStat` types and the diff helpers (`generateDiffString`, - `generateUnifiedPatch`, `applyEditsToNormalizedContent`, etc.). +The fixed tool names from `createAITools()` are `read`, `write`, `edit`, and `ls`. When present, the conditional tool names are also fixed: `exec` for shell commands and `share` for asset publishing. ## Wiring up ```ts -import { AIChatAgent } from "@cloudflare/agents"; // TODO: confirm exact subpath, e.g. "@cloudflare/agents/ai-chat-agent" import { Workspace } from "@cloudflare/workspace"; -import { - WorkspaceFileStore, - createReadTool, - createWriteTool, - createEditTool, - createGrepTool, - createExecTool, -} from "@cloudflare/fs-tools"; - -export class Agent extends AIChatAgent { +import { createAITools } from "@cloudflare/workspace/tools"; + +export class Agent { workspace: Workspace; - constructor(...args: ConstructorParameters) { - super(...(args as [any, any])); - this.workspace = new Workspace({ /* ... */ }); + constructor(ctx: DurableObjectState) { + this.workspace = new Workspace({ + storage: ctx.storage, + }); } - tools() { - const store = new WorkspaceFileStore(this.workspace); - return { - read: createReadTool({ store }), - write: createWriteTool({ store }), - edit: createEditTool({ store }), - grep: createGrepTool({ workspace: this.workspace }), - exec: createExecTool({ workspace: this.workspace }), - }; + getTools() { + return createAITools({ + workspace: this.workspace, + read: { maxBytes: 32 * 1024, maxLines: 800 }, + }); } } ``` -The tools are plain AI SDK `Tool` objects — pass them straight to -`generateText` / `streamText` or expose them through the agent's tool -registry. +Pass `shell` only when the `Workspace` was constructed with matching backend ids: + +```ts +const workspace = new Workspace({ + storage: ctx.storage, + backends: [ + new WorkerBackend({ id: "shell", /* ... */ }), + new CloudflareContainerBackend({ id: "container", /* ... */ }), + ], +}); + +const tools = createAITools({ + workspace, + shell: { + defaultBackend: "shell", + backends: { + shell: { + description: "Fast Worker shell with built-in textual commands.", + }, + container: { + description: "Full Linux userland in a Cloudflare Container.", + }, + }, + }, +}); +``` + +The returned value is an AI SDK `ToolSet`. Pass it to `generateText`, `streamText`, or an agent framework hook such as `getTools()`. + +## `createAITools` + +```ts +createAITools({ + workspace, + readonly?, + assets?, + read?, + write?, + edit?, + shell?, +}); +``` + +| Option | Default | Notes | +| --- | --- | --- | +| `workspace` | required | A `Workspace` or structural equivalent with `fs`, and optionally `shell`, `assets`, and `sessionId`. | +| `readonly` | `false` | When true, return only `read` and `ls`. This omits mutation tools, `exec`, and `share` even if other options are present. | +| `assets` | `true` | Set to `false` to omit `share`. When not false, `share` appears only if `workspace.assets` is configured. | +| `read` | default caps | Options passed to `createReadTool`. | +| `write` | default caps | Options passed to `createWriteTool`. Ignored when `readonly` is true. | +| `edit` | default caps | Options passed to `createEditTool`. Ignored when `readonly` is true. | +| `shell` | omitted | Options passed to `createExecTool`. `exec` appears only when this is present and `readonly` is not true. | + +`createAITools({ workspace, readonly: true })` is the safe mode for agents that should inspect a workspace but not change it or run commands. ## `read` @@ -93,16 +119,40 @@ Schema: ```ts { - path: string; // absolute path - offset?: number; // 1-indexed start line - limit?: number; // max lines this call + path: string; + offset?: number; // 1-indexed start line + limit?: number; // max lines this call } ``` -Returns the line window plus a `nextOffset` whenever the result was -truncated, so the model can call `read` again to keep going. Lazy -through `store.readChunks(path)` — never materializes the full file -unless the file itself fits in the budget. +Returns the line window plus `nextOffset` whenever the result was truncated, so the model can call `read` again to keep going. Reads stream through `store.readChunks(path)` and stop as soon as the line or byte cap is hit. + +## `ls` + +```ts +createListTool({ workspace }); +``` + +Schema: + +```ts +{ + path: string; +} +``` + +Calls `workspace.fs.readdir(path)` and returns: + +```ts +{ + path: string; + entries: Array<{ + name: string; + isFile: boolean; + isDirectory: boolean; + }>; +} +``` ## `write` @@ -118,14 +168,12 @@ Schema: ```ts { - path: string; + path: string; content: string; } ``` -Overwrites the file. Preserves an existing file's `mode` so executable -scripts keep their `+x` bit. Rejects writes larger than `maxBytes` with -a structured error pointing the model at the `edit` tool. +Overwrites the file. Preserves an existing file's `mode` so executable scripts keep their executable bit. Rejects writes larger than `maxBytes` with a structured error pointing the model at the `edit` tool or a smaller write. ## `edit` @@ -141,126 +189,114 @@ Schema: ```ts { - path: string; + path: string; edits: Array<{ oldText: string; newText: string }>; } ``` -Each edit is matched against the *original* file content (not -incrementally), so overlapping or nested edits are rejected. The tool -handles: - -- BOM stripping and line-ending normalization (LF for matching, restored - on write). -- Fuzzy matching that tolerates whitespace drift. -- Unified-diff generation for the model to review. +Each edit is matched against the original file content, not incrementally. Overlapping or nested edits are rejected. The tool normalizes line endings for matching, restores the original line ending style on write, preserves the existing file mode, and returns a unified patch for review. -## `grep` +## `exec` ```ts -createGrepTool({ workspace, maxHits?, maxBytesPerLine? }); +createExecTool({ + workspace, + backends, + defaultBackend, + maxBytes?, +}); ``` | Option | Default | Notes | | --- | --- | --- | -| `maxHits` | 200 | Hard cap on returned hits. Truncation is reported in the result. | -| `maxBytesPerLine` | 1 KiB | Lines longer than this are truncated to keep the model context manageable. | +| `backends` | required | Map of backend id to a model-facing description. | +| `defaultBackend` | required | Backend used when the model omits `backend`. Must be a key in `backends`. | +| `maxBytes` | 64 KiB | UTF-8 byte cap for each of stdout and stderr. | Schema: ```ts { - pattern: string; // literal by default, or a regex if `regex: true` - path: string; // absolute path; directory or file - regex?: boolean; // treat pattern as a regex - ignoreCase?: boolean; - glob?: string; // restrict to paths matching this glob + command: string; + cwd?: string; + backend?: string; } ``` -Delegates to `workspace.fs.grep` (see -[04. Filesystem Interface](./04_filesystem_interface.md#grep)). Runs -container-side when a sandbox is available so big trees use ripgrep; -falls back to the DO-side scan otherwise. Returns -`{ hits: Array<{ path, line, text }>, truncated: boolean }` so the model -can tell when results were capped and refine the query. - -## `exec` +Calls `workspace.shell.exec(command, { cwd, encoding: "utf8", backend })`, waits for `result()`, and returns: ```ts -createExecTool({ workspace, defaultCwd?, allowedCommands?, timeoutMs? }); +{ + command: string; + cwd: string | null; + backend: string; + exitCode: number; + stdout: string; + stderr: string; +} ``` -| Option | Default | Notes | -| --- | --- | --- | -| `defaultCwd` | workspace root | Applied when the model doesn't pass `cwd`. | -| `allowedCommands` | `undefined` (anything) | Optional allow-list of command prefixes. Anything else is rejected before reaching the sandbox. | -| `timeoutMs` | 60_000 | Auto-`kill()` after this long. Set to `0` to disable. | +`exec` is opt-in. `createAITools()` includes it only when the caller passes `shell` options and `readonly` is not true. The backend descriptions are included in the tool description so the model can choose the cheapest backend that can run the command. + +Wire this tool up carefully: it executes arbitrary shell commands inside the configured backend. Use `readonly: true` for inspection-only agents, or omit `shell` when command execution is not part of the agent's job. + +## `share` + +```ts +createShareTool({ workspace }); +``` Schema: ```ts { - command: string; // full command line, run through a shell - cwd?: string; // absolute path inside the workspace + path: string; + expiresAfterMs?: number; } ``` -Calls `Workspace.shell.exec` with `encoding: "utf8"`, waits for -`result()`, and returns -`{ exitCode, stdout, stderr, truncated }`. stdout and stderr are each -capped at a fixed byte budget (default 32 KiB) so a chatty command -can't blow the model's context window; `truncated` flags when the cap -was hit. See [05. Shell Interface](./05_shell_interface.md) for the -underlying API and the open questions around long-running execs. +Calls `workspace.assets.share(path, { expiresAfter, prefix })` and returns either: + +```ts +{ ok: true; url: string } +``` + +or: -Wire this tool up carefully: it executes arbitrary shell commands -inside the sandbox. Pair it with `allowedCommands` (or a system-prompt -policy) unless the agent is fully trusted. +```ts +{ ok: false; error: string } +``` + +The default expiry is one hour. The prefix is `agent-${workspace.sessionId}` so generated links are grouped by workspace session. + +`createAITools()` includes `share` by default when `readonly` is not true, `assets` is not false, and `workspace.assets` is configured. Pass `assets: false` to hide the tool even when credentials are present. ## `FileStore` -The shape the tools depend on: +The file tools depend on this shape: ```ts interface FileStore { stat(path: string): Promise; - read(path: string): Promise; - readChunks(path: string): AsyncIterable; + readAll(path: string): Promise; + readChunks(path: string, byteOffset?: number, byteLength?: number): AsyncIterable; write(path: string, bytes: Uint8Array, options?: { mode?: number }): Promise; } interface FileStat { - size: number; - mode: number; + size: number; + mode?: number; mtime: number; - type: "file" | "dir"; } ``` -`WorkspaceFileStore` adapts these to `Workspace.fs.readFile` / -`writeFile` / `stat`. Custom stores let the same tools drive an SSH -bridge, a remote git working tree, or any other FS-shaped backend. - -Note: `grep` and `exec` take the `Workspace` directly rather than a -`FileStore`. They need the shell and search surfaces, which aren't part -of the `FileStore` contract. +`WorkspaceFileStore` adapts `Workspace.fs.stat`, `Workspace.fs.readFile`, `Workspace.fs.writeFile`, and `Workspace.fs.mkdir` to this contract. Custom stores can use the same tools against another filesystem-shaped backend. ## Conventions for agents -- Tools take absolute paths. Pre-resolve user input against the - configured workspace root before calling (see - [01. VFS](./01_vfs.md)). -- The `read` tool returns continuation offsets — feed them back to the - model on truncation rather than asking for the whole file. -- Pair the `edit` tool with a system prompt that tells the model edits - apply against the *original* file. Models that incrementally update - their mental model of the file will produce overlapping edits and - get the rejection error. -- The `grep` tool returns a `truncated` flag when its hit cap is - reached — prompt the model to refine the query instead of asking for - more pages. -- The `exec` tool is the most dangerous of the set. Use - `allowedCommands` to limit blast radius, and treat its - `stdout`/`stderr` as untrusted attacker-controlled input when - feeding them back into the model. +- Tools take absolute paths. Pre-resolve user input against the configured workspace root before calling. See [01. VFS](./01_vfs.md). +- The `read` tool returns continuation offsets. Feed them back to the model on truncation rather than asking for the whole file. +- Pair the `edit` tool with a system prompt that says edits apply against the original file. Models that incrementally update their mental model of the file will produce overlapping edits and get a rejection error. +- Describe every shell backend in plain language. The model reads those descriptions when deciding where a command should run. +- Treat `exec` output as untrusted text when feeding it back into the model. +- Use `readonly: true` for review, indexing, or support agents that should not modify the workspace. diff --git a/docs/README.md b/docs/README.md index 3c520207..01d5480d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,7 +21,7 @@ It provides: - Durability over DO restarts for all file operations. - A pluggable shell backend: a Cloudflare Container running the `wsd` FUSE daemon (full Linux userland) or a Dynamic Worker running [just-bash](https://github.com/vercel-labs/just-bash) (no container, broad textual tooling). - Workspace constructable without a backend, for filesystem-only use cases. - - Out-of-the-box tools for `@cloudflare/agents`. **(not yet implemented)** + - Out-of-the-box AI SDK tools for `@cloudflare/agents` through `@cloudflare/workspace/tools`. It comes with the following limitations: @@ -46,6 +46,7 @@ The package ships several entrypoints: | `@cloudflare/workspace/backends/worker` | `WorkerBackend` and the bundled just-bash shell. The shell ships as a record of code-split modules the Dynamic Worker loads on demand: a ~290 KB entry parsed on cold start, plus ~2.5 MB of chunks that stay cold until a script reaches for them. | | `@cloudflare/workspace/git` | Isomorphic-git glue for working with checkouts inside the workspace. | | `@cloudflare/workspace/artifacts` | `createArtifact`, a session-scoped facade over the Cloudflare Artifacts Workers binding, plus its argv CLI. | +| `@cloudflare/workspace/tools` | AI SDK tools for agents: read, write, edit, ls, optional exec, and optional asset sharing. | A consumer that only uses the container backend never imports the worker subpath, so the just-bash payload tree-shakes away. @@ -228,7 +229,7 @@ above, then dive into the area you're working on. | [06. Mount Interface](./06_mount_interface.md) | Pre-filling paths from R2, Artifacts, GitHub, and custom sources. **(not yet implemented)** | | [07. Injected Service](./07_injected_service.md) | The in-container `wsd` service that backs FUSE and shell. | | [08. Capnweb Interface](./08_capnweb_interface.md) | RPC wire protocol between the DO and the sandbox. | -| [09. Tool Interface (Agents)](./09_tool_interface.md) | Ready-made tools for `@cloudflare/agents`. **(not yet implemented)** | +| [09. Tool Interface (Agents)](./09_tool_interface.md) | Ready-made AI SDK tools for `@cloudflare/agents`. | | [10. Project Layout](./10_project_layout.md) | Source tree of this package and how the pieces fit together. | | [11. Lifecycle](./11_lifecycle.md) | DO incarnations, container lifetime, capnweb session lifecycle, and hibernation. | | [12. Worker backend](./12_worker_backend.md) | Running the shell as just-bash inside a Dynamic Worker loaded through `env.LOADER`. | From 205cd2a8512e15b68dca0ac9b7f274a93b49343c Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:57:31 +0000 Subject: [PATCH 4/7] workspace: Use a function type for remote add Represent RemoteAddFn as a function type instead of an interface with only a call signature. This keeps the artifacts CLI type shape the same while satisfying the workspace lint rule. --- packages/workspace/src/artifacts/cli.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/workspace/src/artifacts/cli.ts b/packages/workspace/src/artifacts/cli.ts index d1b4eb82..9aac5f36 100644 --- a/packages/workspace/src/artifacts/cli.ts +++ b/packages/workspace/src/artifacts/cli.ts @@ -30,13 +30,11 @@ import type { ArtifactScope } from "./types.js"; * When `force` is set the implementation updates the existing remote * (the moral equivalent of `git remote set-url`) rather than failing. */ -export interface RemoteAddFn { - (opts: { - name: string; - url: string; - force?: boolean; - }): Promise<{ ok: boolean; exists?: boolean; message?: string }>; -} +export type RemoteAddFn = (opts: { + name: string; + url: string; + force?: boolean; +}) => Promise<{ ok: boolean; exists?: boolean; message?: string }>; export interface ArtifactsCLIInput { /** Argv as seen by the shell command. `argv[0]` is the group. */ From e1cf9675ef152187a4535672184312004ab8cc70 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:04:58 +0000 Subject: [PATCH 5/7] workspace, examples/think, docs: Rename publish tool Expose the assets-backed AI tool as publish instead of share while keeping the underlying Workspace assets client unchanged. Update the package exports, tests, Think documentation, and tool interface docs so the model-facing name is consistent. --- docs/09_tool_interface.md | 14 +++++----- docs/README.md | 2 +- examples/think/README.md | 14 +++++----- examples/think/wrangler.jsonc | 4 +-- packages/workspace/README.md | 4 +-- packages/workspace/src/tools/ai.test.ts | 28 +++++++++---------- packages/workspace/src/tools/ai.ts | 6 ++-- packages/workspace/src/tools/index.ts | 2 +- .../src/tools/{share.ts => publish.ts} | 12 ++++---- 9 files changed, 43 insertions(+), 43 deletions(-) rename packages/workspace/src/tools/{share.ts => publish.ts} (68%) diff --git a/docs/09_tool_interface.md b/docs/09_tool_interface.md index 98bea41e..5121666b 100644 --- a/docs/09_tool_interface.md +++ b/docs/09_tool_interface.md @@ -20,10 +20,10 @@ Git access already ships through `workspace.git`, the third major surface on `Wo | `createEditTool` | Exact targeted replacements with unified-diff preview. | | `createListTool` | One-level directory listing. | | `createExecTool` | Run a shell command through a configured Workspace backend. | -| `createShareTool` | Publish a workspace file through `workspace.assets`. | +| `createPublishTool` | Publish a workspace file through `workspace.assets`. | | `WorkspaceFileStore` | Adapt `Workspace.fs` to the file-store shape used by the file tools. | -The fixed tool names from `createAITools()` are `read`, `write`, `edit`, and `ls`. When present, the conditional tool names are also fixed: `exec` for shell commands and `share` for asset publishing. +The fixed tool names from `createAITools()` are `read`, `write`, `edit`, and `ls`. When present, the conditional tool names are also fixed: `exec` for shell commands and `publish` for asset publishing. ## Wiring up @@ -95,8 +95,8 @@ createAITools({ | Option | Default | Notes | | --- | --- | --- | | `workspace` | required | A `Workspace` or structural equivalent with `fs`, and optionally `shell`, `assets`, and `sessionId`. | -| `readonly` | `false` | When true, return only `read` and `ls`. This omits mutation tools, `exec`, and `share` even if other options are present. | -| `assets` | `true` | Set to `false` to omit `share`. When not false, `share` appears only if `workspace.assets` is configured. | +| `readonly` | `false` | When true, return only `read` and `ls`. This omits mutation tools, `exec`, and `publish` even if other options are present. | +| `assets` | `true` | Set to `false` to omit `publish`. When not false, `publish` appears only if `workspace.assets` is configured. | | `read` | default caps | Options passed to `createReadTool`. | | `write` | default caps | Options passed to `createWriteTool`. Ignored when `readonly` is true. | | `edit` | default caps | Options passed to `createEditTool`. Ignored when `readonly` is true. | @@ -240,10 +240,10 @@ Calls `workspace.shell.exec(command, { cwd, encoding: "utf8", backend })`, waits Wire this tool up carefully: it executes arbitrary shell commands inside the configured backend. Use `readonly: true` for inspection-only agents, or omit `shell` when command execution is not part of the agent's job. -## `share` +## `publish` ```ts -createShareTool({ workspace }); +createPublishTool({ workspace }); ``` Schema: @@ -269,7 +269,7 @@ or: The default expiry is one hour. The prefix is `agent-${workspace.sessionId}` so generated links are grouped by workspace session. -`createAITools()` includes `share` by default when `readonly` is not true, `assets` is not false, and `workspace.assets` is configured. Pass `assets: false` to hide the tool even when credentials are present. +`createAITools()` includes `publish` by default when `readonly` is not true, `assets` is not false, and `workspace.assets` is configured. Pass `assets: false` to hide the tool even when credentials are present. ## `FileStore` diff --git a/docs/README.md b/docs/README.md index 01d5480d..b7bc105d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -46,7 +46,7 @@ The package ships several entrypoints: | `@cloudflare/workspace/backends/worker` | `WorkerBackend` and the bundled just-bash shell. The shell ships as a record of code-split modules the Dynamic Worker loads on demand: a ~290 KB entry parsed on cold start, plus ~2.5 MB of chunks that stay cold until a script reaches for them. | | `@cloudflare/workspace/git` | Isomorphic-git glue for working with checkouts inside the workspace. | | `@cloudflare/workspace/artifacts` | `createArtifact`, a session-scoped facade over the Cloudflare Artifacts Workers binding, plus its argv CLI. | -| `@cloudflare/workspace/tools` | AI SDK tools for agents: read, write, edit, ls, optional exec, and optional asset sharing. | +| `@cloudflare/workspace/tools` | AI SDK tools for agents: read, write, edit, ls, optional exec, and optional publish. | A consumer that only uses the container backend never imports the worker subpath, so the just-bash payload tree-shakes away. diff --git a/examples/think/README.md b/examples/think/README.md index 56e4cd6e..706032ca 100644 --- a/examples/think/README.md +++ b/examples/think/README.md @@ -62,22 +62,22 @@ just emit JSON. | `write` | `@cloudflare/workspace/tools` | | `edit` | `@cloudflare/workspace/tools` | | `exec` | `@cloudflare/workspace/tools` (optional shell) | -| `share` | `@cloudflare/workspace/tools` (optional assets) | +| `publish` | `@cloudflare/workspace/tools` (optional assets) | | `report_update` | `src/tools/report-update.ts` (example-specific) | The Workspace tools come from `createAITools()` in `@cloudflare/workspace/tools`. In the explore phase this example includes `read`, `write`, `edit`, and `ls`, opts into `exec` by -passing shell backend descriptions, and includes `share` only when the +passing shell backend descriptions, and includes `publish` only when the Workspace assets publisher is configured. -The `share` tool uploads a workspace file to R2 and returns a +The `publish` tool uploads a workspace file to R2 and returns a time-limited link, so the agent can hand the user an artifact it produced. The worker backend shell also exposes `assets publish []`, which prints the same kind of link to stdout from `exec`. Both are registered only when the R2 credentials below are set; without them the agent runs unchanged and -the share surfaces are omitted. See +the publish surfaces are omitted. See [`docs/14_assets_interface.md`](../../docs/14_assets_interface.md). `exec` is wired to a Workspace with two backends: a `"shell"` @@ -184,14 +184,14 @@ The worker is configured in [`wrangler.jsonc`](./wrangler.jsonc): worker backend through `env.LOADER` and the container backend through `this.ctx.container`. - `TRIAGE_WORKFLOW` — workflow binding pointing at `TriageWorkflow`. -- `ASSETS` — R2 bucket the `share` tool uploads to. Create it once +- `ASSETS` — R2 bucket the `publish` tool uploads to. Create it once before deploying: ```sh wrangler r2 bucket create think-example-assets ``` -The `share` tool also needs R2 S3 credentials to presign URLs — the +The `publish` tool also needs R2 S3 credentials to presign URLs — the bucket binding alone can't mint them. Create an R2 API token scoped to the bucket and set the values as secrets: @@ -204,7 +204,7 @@ wrangler secret put CLOUDFLARE_ACCOUNT_ID `R2_ENDPOINT` can be set instead of `CLOUDFLARE_ACCOUNT_ID` when using a custom S3-compatible endpoint. -Without these the worker still runs; the `share` tool is not offered +Without these the worker still runs; the `publish` tool is not offered to the model, and `assets publish` is not configured in the shell. - `ARTIFACTS` — optional Cloudflare Artifacts binding, commented out diff --git a/examples/think/wrangler.jsonc b/examples/think/wrangler.jsonc index 6f3b3fc8..e0a15cdb 100644 --- a/examples/think/wrangler.jsonc +++ b/examples/think/wrangler.jsonc @@ -53,10 +53,10 @@ "binding": "R2_SKILLS", "bucket_name": "think-example-skills" }, - // Bucket the `share` tool uploads to. The presigner also needs + // Bucket the `publish` tool uploads to. The presigner also needs // R2 S3 credentials, set as secrets: R2_ACCESS_KEY_ID, // R2_SECRET_ACCESS_KEY, and CLOUDFLARE_ACCOUNT_ID. Without - // them the agent runs fine but the `share` tool is omitted. + // them the agent runs fine but the `publish` tool is omitted. { "binding": "ASSETS", "bucket_name": "think-example-assets" diff --git a/packages/workspace/README.md b/packages/workspace/README.md index 64e89bba..61263a72 100644 --- a/packages/workspace/README.md +++ b/packages/workspace/README.md @@ -52,7 +52,7 @@ uniform; the counts are just always zero. worker backend's shell exposes the same dispatcher through a built-in `git` custom command. See [`docs/13_git_interface.md`](../../docs/13_git_interface.md). -- `createAssets` (from `@cloudflare/workspace/assets`) — `share` a +- `createAssets` (from `@cloudflare/workspace/assets`) — `publish` a workspace file to an R2 bucket and get back a presigned URL. Binds the workspace and bucket once, like `workspace.git`. When attached through `WorkspaceOptions.assets`, the worker backend's @@ -70,7 +70,7 @@ uniform; the counts are just always zero. [`docs/15_artifacts_interface.md`](../../docs/15_artifacts_interface.md). - `createAITools` (from `@cloudflare/workspace/tools`) — AI SDK tools for agents: `read`, `write`, `edit`, `ls`, optional `exec`, - and optional asset sharing. See + and optional `publish`. See [`docs/09_tool_interface.md`](../../docs/09_tool_interface.md). ## Typical DO-side usage diff --git a/packages/workspace/src/tools/ai.test.ts b/packages/workspace/src/tools/ai.test.ts index 83362927..51296de0 100644 --- a/packages/workspace/src/tools/ai.test.ts +++ b/packages/workspace/src/tools/ai.test.ts @@ -367,8 +367,8 @@ describe("createAITools exec tool", () => { }); }); -describe("createAITools share tool", () => { - it("adds share by default when assets are configured", async () => { +describe("createAITools publish tool", () => { + it("adds publish by default when assets are configured", async () => { const calls: Array<{ path: string; expiresAfter: number; prefix?: string }> = []; const workspace = { fs: makeWorkspace().fs, @@ -382,27 +382,27 @@ describe("createAITools share tool", () => { }; const tools = createAITools({ workspace }); - expect(tools.share).toBeDefined(); + expect(tools.publish).toBeDefined(); await expect( - executeTool(tools.share, { path: "/workspace/out/report.html", expiresAfterMs: 1234 }), + executeTool(tools.publish, { path: "/workspace/out/report.html", expiresAfterMs: 1234 }), ).resolves.toEqual({ ok: true, url: "https://example.test/report.html" }); expect(calls).toEqual([ { path: "/workspace/out/report.html", expiresAfter: 1234, prefix: "agent-session-a" }, ]); }); - it("omits share when assets are disabled or readonly is true", () => { + it("omits publish when assets are disabled or readonly is true", () => { const workspace = { fs: makeWorkspace().fs, sessionId: "session-a", assets: { share: async () => "https://example.test" }, }; - expect(createAITools({ workspace, assets: false }).share).toBeUndefined(); - expect(createAITools({ workspace, readonly: true }).share).toBeUndefined(); + expect(createAITools({ workspace, assets: false }).publish).toBeUndefined(); + expect(createAITools({ workspace, readonly: true }).publish).toBeUndefined(); }); - it("returns structured share errors", async () => { + it("returns structured publish errors", async () => { const workspace = { fs: makeWorkspace().fs, sessionId: "session-a", @@ -414,11 +414,11 @@ describe("createAITools share tool", () => { }; const tools = createAITools({ workspace }); - await expect(executeTool(tools.share, { path: "/workspace/out/report.html" })).resolves.toEqual( - { - ok: false, - error: "upload failed", - }, - ); + await expect( + executeTool(tools.publish, { path: "/workspace/out/report.html" }), + ).resolves.toEqual({ + ok: false, + error: "upload failed", + }); }); }); diff --git a/packages/workspace/src/tools/ai.ts b/packages/workspace/src/tools/ai.ts index b8c88ff2..fb3dd960 100644 --- a/packages/workspace/src/tools/ai.ts +++ b/packages/workspace/src/tools/ai.ts @@ -5,10 +5,10 @@ import { createListTool } from "./fs/list.js"; import { createReadTool, type ReadToolOptions } from "./fs/read.js"; import { type WorkspaceLike as FileWorkspaceLike, WorkspaceFileStore } from "./fs/store.js"; import { createWriteTool, type WriteToolOptions } from "./fs/write.js"; -import { createShareTool, type ShareWorkspaceLike } from "./share.js"; +import { createPublishTool, type PublishWorkspaceLike } from "./publish.js"; export interface CreateAIToolsOptions { - workspace: FileWorkspaceLike & Partial & Partial; + workspace: FileWorkspaceLike & Partial & Partial; readonly?: boolean; assets?: boolean; read?: Omit; @@ -37,7 +37,7 @@ export function createAITools(options: CreateAIToolsOptions): ToolSet { } if (options.assets !== false && options.workspace.assets !== undefined) { - tools.share = createShareTool({ workspace: options.workspace as ShareWorkspaceLike }); + tools.publish = createPublishTool({ workspace: options.workspace as PublishWorkspaceLike }); } return tools; diff --git a/packages/workspace/src/tools/index.ts b/packages/workspace/src/tools/index.ts index 29501ea3..45dc297c 100644 --- a/packages/workspace/src/tools/index.ts +++ b/packages/workspace/src/tools/index.ts @@ -6,4 +6,4 @@ export { createReadTool, type ReadToolOptions } from "./fs/read.js"; export { WorkspaceFileStore, type WorkspaceLike } from "./fs/store.js"; export type { FileStat, FileStore } from "./fs/types.js"; export { createWriteTool, type WriteToolOptions } from "./fs/write.js"; -export { createShareTool, type ShareToolOptions } from "./share.js"; +export { createPublishTool, type PublishToolOptions } from "./publish.js"; diff --git a/packages/workspace/src/tools/share.ts b/packages/workspace/src/tools/publish.ts similarity index 68% rename from packages/workspace/src/tools/share.ts rename to packages/workspace/src/tools/publish.ts index a904fc00..69ca26fb 100644 --- a/packages/workspace/src/tools/share.ts +++ b/packages/workspace/src/tools/publish.ts @@ -2,26 +2,26 @@ import { tool } from "ai"; import { z } from "zod"; import type { AssetsClient } from "../assets/index.js"; -export interface ShareWorkspaceLike { +export interface PublishWorkspaceLike { readonly sessionId: string; readonly assets?: AssetsClient; } -export interface ShareToolOptions { - workspace: ShareWorkspaceLike; +export interface PublishToolOptions { + workspace: PublishWorkspaceLike; } const DEFAULT_EXPIRY_MS = 60 * 60 * 1000; -export function createShareTool(options: ShareToolOptions) { +export function createPublishTool(options: PublishToolOptions) { const assets = options.workspace.assets; if (!assets) { - throw new Error("createShareTool: workspace.assets is not configured"); + throw new Error("createPublishTool: workspace.assets is not configured"); } return tool({ description: - "Share a file from the workspace by publishing it through the configured assets publisher and returning a time-limited link. Use this to hand the user an artifact you produced, such as a chart, screenshot, build output, or report.", + "Publish a file from the workspace through the configured assets publisher and return a time-limited link. Use this to hand the user an artifact you produced, such as a chart, screenshot, build output, or report.", inputSchema: z.object({ path: z.string().min(1).describe("Absolute workspace path, e.g. /workspace/out/chart.png."), expiresAfterMs: z From 461d71d2d727d895f3d34fde65b8bb3cc362667e Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:26:49 +0000 Subject: [PATCH 6/7] workspace, examples/think, docs: Add Think compatibility option Add a useThink constructor option that attaches Think's WorkspaceLike filesystem methods to the Workspace instance. This lets Think consumers assign the Workspace directly while keeping the primary API on workspace.fs. Update the Think example to use the option, remove its local compatibility adapter, and document the new constructor option. --- docs/01_vfs.md | 13 +- docs/09_tool_interface.md | 2 +- examples/think/src/agent.ts | 143 ++--------------- packages/workspace/README.md | 4 + packages/workspace/src/workspace.test.ts | 68 +++++++- packages/workspace/src/workspace.ts | 196 +++++++++++++++++++++++ 6 files changed, 288 insertions(+), 138 deletions(-) diff --git a/docs/01_vfs.md b/docs/01_vfs.md index 5373e1a1..0d363b88 100644 --- a/docs/01_vfs.md +++ b/docs/01_vfs.md @@ -33,9 +33,16 @@ new Workspace({ Workspace where `fs` works against the local SQLite store but `shell` throws. -`WorkspaceOptions` is `{ storage, backends, now? }`. There is no -`root`, `sandbox`, or `sessionId` field on the host facade — sandbox -wiring lives behind a `WorkspaceBackend`. +`WorkspaceOptions` includes the storage handle, optional backends, +clock, session id, mounts, observer, git identity, assets, artifacts, +and `useThink`. There is no `root` or `sandbox` field on the host +facade — sandbox wiring lives behind a `WorkspaceBackend`. + +Set `useThink: true` when assigning the Workspace to +`Think.workspace`. This adds Think's string-oriented filesystem +compatibility methods (`readFile`, `readFileBytes`, `writeFile`, +`readDir`, `rm`, `glob`, `mkdir`, and `stat`) directly to that +Workspace instance while leaving the primary API on `workspace.fs`. Illustrative layout (nothing below `/` is auto-created beyond `ROOT_INODE` itself): diff --git a/docs/09_tool_interface.md b/docs/09_tool_interface.md index 5121666b..cb9d79e2 100644 --- a/docs/09_tool_interface.md +++ b/docs/09_tool_interface.md @@ -49,7 +49,7 @@ export class Agent { } ``` -Pass `shell` only when the `Workspace` was constructed with matching backend ids: +When assigning the same instance to `Think.workspace`, construct it with `useThink: true` so Think's built-in workspace tools can use the compatibility filesystem methods. Pass `shell` only when the `Workspace` was constructed with matching backend ids: ```ts const workspace = new Workspace({ diff --git a/examples/think/src/agent.ts b/examples/think/src/agent.ts index 0ed5f188..4241190a 100644 --- a/examples/think/src/agent.ts +++ b/examples/think/src/agent.ts @@ -8,13 +8,13 @@ * by a Cloudflare Container running `wsd`. Mirrors the pattern * in examples/container. * - Think's own `workspace` field expects a string-based - * `WorkspaceLike` for its built-in workspace tools. We satisfy - * it with a small adapter over the container workspace and turn - * off `workspaceBash` because `@cloudflare/workspace/tools` - * provides the `exec` tool. We also shadow Think's - * `read`/`write`/`edit` tool names with the shared Workspace - * tools so this example uses the same caps and edit behavior as - * package consumers. + * `WorkspaceLike` for its built-in workspace tools. The Workspace + * is constructed with `useThink: true` to add that compatibility + * surface, and `workspaceBash` is off because + * `@cloudflare/workspace/tools` provides the `exec` tool. We also + * shadow Think's `read`/`write`/`edit` tool names with the shared + * Workspace tools so this example uses the same caps and edit + * behavior as package consumers. * * Phase model: * - The workflow flips `phase` via `setPhase("explore" | "structure")` @@ -208,6 +208,7 @@ export class TriageAgent extends withWorkspaceContainer(TriageBase) { mounts: { "/workspace/.agents": R2Bucket(env.R2_SKILLS, { prefix: ".agents/" }), }, + useThink: true, ...(hasAssetsConfig(env) ? { assets: (ws: Workspace) => @@ -221,11 +222,11 @@ export class TriageAgent extends withWorkspaceContainer(TriageBase) { : {}), }); - // Hand Think an adapter that satisfies its WorkspaceLike, so the + // Hand Think the compatibility methods added by useThink, so the // baseline read/write/edit tools have something to delegate to — // even though we shadow most of those names below. Think's // built-in tools land on the default backend (the shell). - this.workspace = adaptToThinkWorkspace(this.#workspaceFs) as unknown as ThinkWorkspaceLike; + this.workspace = this.#workspaceFs as unknown as ThinkWorkspaceLike; this.ctx.blockConcurrencyWhile(async () => { this.#context = (await this.ctx.storage.get(CONTEXT_KEY)) ?? null; @@ -738,130 +739,6 @@ export class TriageAgent extends withWorkspaceContainer(TriageBase) { } } -// ── Adapters ─────────────────────────────────────────────────────── - -/** - * Bridge from `@cloudflare/workspace.Workspace` to Think's - * string-shaped `WorkspaceLike`. Think only constructs the default - * workspace tools lazily; nothing calls these methods unless the - * model actually invokes a default tool, and our `getTools()` - * shadows the names we care about. The adapters exist so the Think - * baseline doesn't crash if it does fire. - */ -function adaptToThinkWorkspace(ws: Workspace) { - return { - async readFile(path: string): Promise { - try { - return await ws.fs.readFile(path, "utf8"); - } catch (err) { - if (isEnoent(err)) return null; - throw err; - } - }, - async readFileBytes(path: string): Promise { - try { - const stream = await ws.fs.readFile(path); - return await drain(stream); - } catch (err) { - if (isEnoent(err)) return null; - throw err; - } - }, - async writeFile(path: string, content: string): Promise { - await ws.fs.writeFile(path, new TextEncoder().encode(content)); - }, - async mkdir(path: string, opts?: { recursive?: boolean }): Promise { - await ws.fs.mkdir(path, opts?.recursive ? { recursive: true } : {}); - }, - async rm(path: string, opts?: { recursive?: boolean; force?: boolean }): Promise { - await ws.fs.rm(path, { - ...(opts?.recursive ? { recursive: true as const } : {}), - ...(opts?.force ? { force: true as const } : {}), - }); - }, - async stat(path: string) { - try { - const s = await ws.fs.stat(path); - return { - path, - name: path.split("/").pop() ?? path, - type: s.isDirectory ? ("directory" as const) : ("file" as const), - size: s.size, - modifiedAt: new Date(s.mtime), - isDirectory: s.isDirectory, - isFile: s.isFile, - }; - } catch (err) { - if (isEnoent(err)) return null; - throw err; - } - }, - async readDir(dir: string) { - const entries = await ws.fs.readdir(dir); - return entries.map((e) => ({ - path: `${dir}/${e.name}`, - name: e.name, - type: e.isDirectory ? ("directory" as const) : ("file" as const), - size: 0, - modifiedAt: new Date(0), - isDirectory: e.isDirectory, - isFile: e.isFile, - })); - }, - async glob(pattern: string) { - // Cheap shim — full glob semantics aren't needed in this demo. - // Think's built-in grep filters by `entry.type === "file"`, so - // we have to stat each candidate to know whether it's a blob. - // ws.fs.find already returns the type alongside the path, so - // forward it directly instead of re-stat'ing. Without this, - // every grep returned filesSearched: 0. - const matches = await ws.fs.find("/workspace", pattern); - return matches.map((m) => ({ - path: m.path, - name: m.path.split("/").pop() ?? m.path, - type: m.type === "dir" ? ("directory" as const) : ("file" as const), - size: 0, - modifiedAt: new Date(0), - isDirectory: m.type === "dir", - isFile: m.type === "file", - })); - }, - }; -} - -function isEnoent(err: unknown): boolean { - if (!err || typeof err !== "object") return false; - const e = err as { code?: string; message?: string }; - if (e.code === "ENOENT") return true; - return typeof e.message === "string" && /ENOENT|no such/i.test(e.message); -} - -async function drain(stream: ReadableStream): Promise { - const reader = stream.getReader(); - const parts: Uint8Array[] = []; - let total = 0; - try { - while (true) { - const { value, done } = await reader.read(); - if (done) break; - if (value) { - parts.push(value); - total += value.byteLength; - } - } - } finally { - reader.releaseLock(); - } - if (parts.length === 1) return parts[0]; - const out = new Uint8Array(total); - let off = 0; - for (const p of parts) { - out.set(p, off); - off += p.byteLength; - } - return out; -} - // ── Small helpers ───────────────────────────────────────────────────────────── /** diff --git a/packages/workspace/README.md b/packages/workspace/README.md index 61263a72..0f6b97ec 100644 --- a/packages/workspace/README.md +++ b/packages/workspace/README.md @@ -143,6 +143,10 @@ const body = await ws.fs.readFile("/notes.md", "utf8"); // ws.shell throws — there's no backend wired up. ``` +Pass `useThink: true` when assigning the same instance to +`Think.workspace`. That adds Think's compatibility methods directly +on the instance while keeping the primary file API on `workspace.fs`. + Git, also without a backend: ```ts diff --git a/packages/workspace/src/workspace.test.ts b/packages/workspace/src/workspace.test.ts index fb192f13..89ea22c3 100644 --- a/packages/workspace/src/workspace.test.ts +++ b/packages/workspace/src/workspace.test.ts @@ -3,12 +3,25 @@ import { describe, expect, it, vi } from "vitest"; import type { BackendHandle, WorkspaceBackend } from "./backend.js"; import { WorkspaceTransportError } from "./transport-failure.js"; -import { Workspace } from "./workspace.js"; +import { type ThinkWorkspaceCompatibility, Workspace } from "./workspace.js"; function makeStorage(): SQLiteTestStorage { return new SQLiteTestStorage(); } +function expectThinkWorkspace( + ws: Workspace, +): asserts ws is Workspace & ThinkWorkspaceCompatibility { + expect(ws).toHaveProperty("readFile"); + expect(ws).toHaveProperty("readFileBytes"); + expect(ws).toHaveProperty("writeFile"); + expect(ws).toHaveProperty("readDir"); + expect(ws).toHaveProperty("glob"); + expect(ws).toHaveProperty("mkdir"); + expect(ws).toHaveProperty("rm"); + expect(ws).toHaveProperty("stat"); +} + // In-process fakes. We never spawn anything from the package // code; the backend's only contract is "produce a SyncRPC // stub that wsd would speak". A plain object is enough. @@ -1065,3 +1078,56 @@ describe("Workspace transport-failure invalidation", () => { ]); }); }); +describe("Workspace Think compatibility", () => { + it("adds Think-compatible filesystem methods when useThink is true", async () => { + const ws = new Workspace({ + storage: makeStorage(), + useThink: true, + now: () => 1_700_000_000_000, + }); + + expectThinkWorkspace(ws); + + await ws.mkdir("/workspace/notes", { recursive: true }); + await ws.writeFile("/workspace/notes/a.txt", "hello"); + await ws.writeFile("/workspace/notes/b.md", "# title"); + + expect(await ws.readFile("/workspace/notes/a.txt")).toBe("hello"); + expect(await ws.readFile("/workspace/missing.txt")).toBeNull(); + expect(new TextDecoder().decode(await ws.readFileBytes("/workspace/notes/a.txt"))).toBe( + "hello", + ); + expect(await ws.readFileBytes("/workspace/missing.txt")).toBeNull(); + + await expect(ws.stat("/workspace/notes/a.txt")).resolves.toMatchObject({ + path: "/workspace/notes/a.txt", + name: "a.txt", + type: "file", + size: 5, + }); + await expect(ws.stat("/workspace/missing.txt")).resolves.toBeNull(); + + await expect(ws.readDir("/workspace/notes", { limit: 1, offset: 1 })).resolves.toEqual([ + expect.objectContaining({ path: "/workspace/notes/b.md", name: "b.md", type: "file" }), + ]); + await expect(ws.glob("/workspace/notes/**/*.txt")).resolves.toEqual([ + expect.objectContaining({ path: "/workspace/notes/a.txt", name: "a.txt", type: "file" }), + ]); + + await ws.rm("/workspace/notes/a.txt", { force: true }); + expect(await ws.readFile("/workspace/notes/a.txt")).toBeNull(); + }); + + it("does not add Think compatibility methods by default", () => { + const ws = new Workspace({ storage: makeStorage() }); + + expect(ws).not.toHaveProperty("readFile"); + expect(ws).not.toHaveProperty("readFileBytes"); + expect(ws).not.toHaveProperty("writeFile"); + expect(ws).not.toHaveProperty("readDir"); + expect(ws).not.toHaveProperty("glob"); + expect(ws).not.toHaveProperty("mkdir"); + expect(ws).not.toHaveProperty("rm"); + expect(ws).not.toHaveProperty("stat"); + }); +}); diff --git a/packages/workspace/src/workspace.ts b/packages/workspace/src/workspace.ts index 77e66eac..3290c051 100644 --- a/packages/workspace/src/workspace.ts +++ b/packages/workspace/src/workspace.ts @@ -96,6 +96,33 @@ export interface WorkspaceOptions { binding: Artifacts; sessionId?: string; }; + + // Add Think's string-oriented WorkspaceLike filesystem methods + // directly to the Workspace instance. This is off by default so + // the primary Workspace API stays on the `workspace.fs` facade; + // enable it when assigning a Workspace to `Think.workspace`. + useThink?: boolean; +} + +export interface ThinkFileInfo { + path: string; + name: string; + type: "file" | "directory"; + mimeType: string; + size: number; + createdAt: number; + updatedAt: number; +} + +export interface ThinkWorkspaceCompatibility { + readFile(path: string): Promise; + readFileBytes(path: string): Promise; + writeFile(path: string, content: string): Promise; + readDir(dir: string, opts?: { limit?: number; offset?: number }): Promise; + rm(path: string, opts?: { recursive?: boolean; force?: boolean }): Promise; + glob(pattern: string): Promise; + mkdir(path: string, opts?: { recursive?: boolean }): Promise; + stat(path: string): Promise; } export class Workspace { @@ -140,6 +167,15 @@ export class Workspace { // and updates it. See docs/02 "Concurrent mutators". readonly #mutationTails = new Map>(); + declare readonly readFile?: ThinkWorkspaceCompatibility["readFile"]; + declare readonly readFileBytes?: ThinkWorkspaceCompatibility["readFileBytes"]; + declare readonly writeFile?: ThinkWorkspaceCompatibility["writeFile"]; + declare readonly readDir?: ThinkWorkspaceCompatibility["readDir"]; + declare readonly rm?: ThinkWorkspaceCompatibility["rm"]; + declare readonly glob?: ThinkWorkspaceCompatibility["glob"]; + declare readonly mkdir?: ThinkWorkspaceCompatibility["mkdir"]; + declare readonly stat?: ThinkWorkspaceCompatibility["stat"]; + constructor(options: WorkspaceOptions) { this.#now = options.now ?? Date.now; this.#sessionId = options.sessionId ?? ""; @@ -182,6 +218,10 @@ export class Workspace { mounts: this.#mounts, }); this.#assets = typeof options.assets === "function" ? options.assets(this) : options.assets; + if (options.useThink) { + const think = createThinkCompatibility(this); + Object.assign(this, think); + } } // Force every registered mount to materialize. Idempotent; safe to @@ -763,3 +803,159 @@ class WorkspaceShellRouter { return execHandle; } } + +function createThinkCompatibility(ws: Workspace): ThinkWorkspaceCompatibility { + return { + async readFile(path) { + try { + return await ws.fs.readFile(path, "utf8"); + } catch (err) { + if (isEnoent(err)) return null; + throw err; + } + }, + async readFileBytes(path) { + try { + return await drainBytes(await ws.fs.readFile(path)); + } catch (err) { + if (isEnoent(err)) return null; + throw err; + } + }, + async writeFile(path, content) { + await ws.fs.writeFile(path, content); + }, + async readDir(dir, opts) { + const entries = await ws.fs.readdir(dir); + const offset = opts?.offset ?? 0; + const limit = opts?.limit ?? entries.length; + return entries.slice(offset, offset + limit).map((entry) => + toThinkFileInfo({ + path: joinPath(dir, entry.name), + name: entry.name, + size: 0, + mtime: 0, + isDirectory: entry.isDirectory, + isFile: entry.isFile, + }), + ); + }, + async rm(path, opts) { + await ws.fs.rm(path, opts); + }, + async glob(pattern) { + const { directory, relativePattern } = splitGlobPattern(pattern); + const matches = await ws.fs.find(directory, relativePattern); + return matches.map((match) => + toThinkFileInfo({ + path: match.path, + name: basename(match.path), + size: 0, + mtime: 0, + isDirectory: match.type === "dir", + isFile: match.type === "file", + }), + ); + }, + async mkdir(path, opts) { + await ws.fs.mkdir(path, opts); + }, + async stat(path) { + try { + const stat = await ws.fs.stat(path); + return toThinkFileInfo({ ...stat, path, name: basename(path) }); + } catch (err) { + if (isEnoent(err)) return null; + throw err; + } + }, + }; +} + +async function drainBytes(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const parts: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (!value) continue; + parts.push(value); + total += value.byteLength; + } + } finally { + reader.releaseLock(); + } + if (parts.length === 1) return parts[0]; + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.byteLength; + } + return out; +} + +function toThinkFileInfo(input: { + path: string; + name: string; + size: number; + mtime: number; + isDirectory: boolean; + isFile: boolean; +}): ThinkFileInfo { + const type = input.isDirectory ? "directory" : "file"; + return { + path: input.path, + name: input.name, + type, + mimeType: type === "directory" ? "inode/directory" : "application/octet-stream", + size: input.size, + createdAt: input.mtime, + updatedAt: input.mtime, + }; +} + +function splitGlobPattern(pattern: string): { directory: string; relativePattern?: string } { + const normalized = pattern.startsWith("/") ? pattern : `/workspace/${pattern}`; + const wildcard = firstWildcardIndex(normalized); + if (wildcard === -1) { + return { directory: dirname(normalized), relativePattern: basename(normalized) }; + } + const slash = normalized.lastIndexOf("/", wildcard); + const directory = slash <= 0 ? "/" : normalized.slice(0, slash); + const relativePattern = normalized.slice(slash + 1); + return { directory, relativePattern }; +} + +function firstWildcardIndex(pattern: string): number { + const star = pattern.indexOf("*"); + const question = pattern.indexOf("?"); + if (star === -1) return question; + if (question === -1) return star; + return Math.min(star, question); +} + +function joinPath(dir: string, name: string): string { + return dir === "/" ? `/${name}` : `${dir}/${name}`; +} + +function dirname(path: string): string { + const index = path.lastIndexOf("/"); + if (index <= 0) return "/"; + return path.slice(0, index); +} + +function basename(path: string): string { + const trimmed = path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path; + const index = trimmed.lastIndexOf("/"); + return index === -1 ? trimmed : trimmed.slice(index + 1); +} + +function isEnoent(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + const e = err as { code?: string; message?: string }; + if (e.code === "ENOENT") return true; + return typeof e.message === "string" && /ENOENT|no such/i.test(e.message); +} From b94808cddfe4238c2b0b0c9ba48c633cfd41b9a9 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:38:12 +0000 Subject: [PATCH 7/7] workspace, examples/think, docs: Address tools review feedback Restore model-facing exec fallback guidance, return structured exec errors, omit the publish prefix when no session id is configured, and cancel file read streams when byte-range reads stop before EOF. Keep diff bundled as an implementation detail by removing its optional peer declaration, and fix the assets README wording and Think tools table alignment. --- docs/09_tool_interface.md | 2 +- examples/think/README.md | 2 +- package-lock.json | 4 - packages/workspace/README.md | 2 +- packages/workspace/package.json | 4 - packages/workspace/src/tools/ai.test.ts | 137 +++++++++++++++++++++++ packages/workspace/src/tools/exec.ts | 43 ++++--- packages/workspace/src/tools/fs/store.ts | 7 +- packages/workspace/src/tools/publish.ts | 5 +- 9 files changed, 176 insertions(+), 30 deletions(-) diff --git a/docs/09_tool_interface.md b/docs/09_tool_interface.md index cb9d79e2..d49f1857 100644 --- a/docs/09_tool_interface.md +++ b/docs/09_tool_interface.md @@ -267,7 +267,7 @@ or: { ok: false; error: string } ``` -The default expiry is one hour. The prefix is `agent-${workspace.sessionId}` so generated links are grouped by workspace session. +The default expiry is one hour. When `workspace.sessionId` is non-empty, the prefix is `agent-${workspace.sessionId}` so generated links are grouped by workspace session. If no session id was configured, the tool leaves the prefix unset. `createAITools()` includes `publish` by default when `readonly` is not true, `assets` is not false, and `workspace.assets` is configured. Pass `assets: false` to hide the tool even when credentials are present. diff --git a/examples/think/README.md b/examples/think/README.md index 706032ca..98889e83 100644 --- a/examples/think/README.md +++ b/examples/think/README.md @@ -62,7 +62,7 @@ just emit JSON. | `write` | `@cloudflare/workspace/tools` | | `edit` | `@cloudflare/workspace/tools` | | `exec` | `@cloudflare/workspace/tools` (optional shell) | -| `publish` | `@cloudflare/workspace/tools` (optional assets) | +| `publish` | `@cloudflare/workspace/tools` (optional assets) | | `report_update` | `src/tools/report-update.ts` (example-specific) | The Workspace tools come from `createAITools()` in diff --git a/package-lock.json b/package-lock.json index ca5ab7a2..603600ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12866,7 +12866,6 @@ "peerDependencies": { "@platformatic/vfs": "*", "ai": "^6.0.196", - "diff": "^9.0.0", "isomorphic-git": "^1.27.0", "zod": "^4.4.3" }, @@ -12877,9 +12876,6 @@ "ai": { "optional": true }, - "diff": { - "optional": true - }, "isomorphic-git": { "optional": true }, diff --git a/packages/workspace/README.md b/packages/workspace/README.md index 0f6b97ec..e3299c37 100644 --- a/packages/workspace/README.md +++ b/packages/workspace/README.md @@ -52,7 +52,7 @@ uniform; the counts are just always zero. worker backend's shell exposes the same dispatcher through a built-in `git` custom command. See [`docs/13_git_interface.md`](../../docs/13_git_interface.md). -- `createAssets` (from `@cloudflare/workspace/assets`) — `publish` a +- `createAssets` (from `@cloudflare/workspace/assets`) — `share` a workspace file to an R2 bucket and get back a presigned URL. Binds the workspace and bucket once, like `workspace.git`. When attached through `WorkspaceOptions.assets`, the worker backend's diff --git a/packages/workspace/package.json b/packages/workspace/package.json index c7d5b7b2..f704d3d4 100644 --- a/packages/workspace/package.json +++ b/packages/workspace/package.json @@ -73,7 +73,6 @@ "peerDependencies": { "@platformatic/vfs": "*", "ai": "^6.0.196", - "diff": "^9.0.0", "isomorphic-git": "^1.27.0", "zod": "^4.4.3" }, @@ -84,9 +83,6 @@ "ai": { "optional": true }, - "diff": { - "optional": true - }, "isomorphic-git": { "optional": true }, diff --git a/packages/workspace/src/tools/ai.test.ts b/packages/workspace/src/tools/ai.test.ts index 51296de0..3e676ab2 100644 --- a/packages/workspace/src/tools/ai.test.ts +++ b/packages/workspace/src/tools/ai.test.ts @@ -20,6 +20,12 @@ async function executeTool(tool: unknown, input: unknown): Promise { return await execute(input, toolOptions); } +function toolDescription(tool: unknown): string { + const description = (tool as { description?: unknown }).description; + if (typeof description !== "string") throw new Error("tool has no description"); + return description; +} + function makeWorkspace(): Workspace { return new Workspace({ storage: new SQLiteTestStorage(), now: () => 1_700_000_000_000 }); } @@ -93,6 +99,39 @@ describe("WorkspaceFileStore", () => { drainChunks(store.readChunks("/workspace/range.txt", 2, 5)).then(decode), ).resolves.toBe("cdefg"); }); + + it("cancels read streams when a byte range stops before EOF", async () => { + let cancelled = false; + const workspace = { + fs: { + async stat() { + return { size: 10, mtime: 1, mode: 0o100644, isFile: true, isDirectory: false }; + }, + async readFile() { + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes("abcdefghij")); + }, + cancel() { + cancelled = true; + }, + }); + }, + async writeFile() {}, + async mkdir() {}, + async rm() {}, + async readdir() { + return []; + }, + }, + }; + const store = new WorkspaceFileStore(workspace); + + await expect( + drainChunks(store.readChunks("/workspace/range.txt", 2, 5)).then(decode), + ).resolves.toBe("cdefg"); + expect(cancelled).toBe(true); + }); }); describe("createAITools filesystem tools", () => { @@ -355,6 +394,81 @@ describe("createAITools exec tool", () => { expect(calls).toEqual([{ command: "echo ok", backend: "shell" }]); }); + it("tells the model to retry on a capable backend after command-not-found errors", () => { + const workspace = { + shell: { + async exec() { + throw new Error("not used"); + }, + }, + }; + const tools = createAITools({ + workspace, + shell: { + defaultBackend: "shell", + backends: { + shell: { description: "fast shell with a limited built-in command set" }, + container: { description: "full Linux userland with npm and node" }, + }, + }, + }); + + expect(toolDescription(tools.exec)).toContain("command not found"); + expect(toolDescription(tools.exec)).toContain("retry on a backend whose description covers"); + }); + + it("returns structured exec errors", async () => { + const workspace = { + shell: { + async exec() { + throw new Error("backend unavailable"); + }, + }, + }; + const tools = createAITools({ + workspace, + shell: { + defaultBackend: "shell", + backends: { shell: { description: "fast shell" } }, + }, + }); + + await expect(executeTool(tools.exec, { command: "npm test" })).resolves.toEqual({ + command: "npm test", + cwd: null, + backend: "shell", + error: "backend unavailable", + }); + }); + + it("returns structured exec result errors", async () => { + const workspace = { + shell: { + async exec() { + return { + async result() { + throw new Error("transport closed"); + }, + }; + }, + }, + }; + const tools = createAITools({ + workspace, + shell: { + defaultBackend: "shell", + backends: { shell: { description: "fast shell" } }, + }, + }); + + await expect(executeTool(tools.exec, { command: "npm test" })).resolves.toEqual({ + command: "npm test", + cwd: null, + backend: "shell", + error: "transport closed", + }); + }); + it("rejects invalid shell backend configuration", () => { const workspace = makeWorkspace(); @@ -391,6 +505,29 @@ describe("createAITools publish tool", () => { ]); }); + it("omits the publish prefix when sessionId is empty", async () => { + const calls: Array<{ path: string; expiresAfter: number; prefix?: string }> = []; + const workspace = { + fs: makeWorkspace().fs, + sessionId: "", + assets: { + async share(path: string, opts: { expiresAfter: number; prefix?: string }) { + calls.push({ path, ...opts }); + return "https://example.test/report.html"; + }, + }, + }; + const tools = createAITools({ workspace }); + + await expect( + executeTool(tools.publish, { path: "/workspace/out/report.html" }), + ).resolves.toEqual({ + ok: true, + url: "https://example.test/report.html", + }); + expect(calls).toEqual([{ path: "/workspace/out/report.html", expiresAfter: 60 * 60 * 1000 }]); + }); + it("omits publish when assets are disabled or readonly is true", () => { const workspace = { fs: makeWorkspace().fs, diff --git a/packages/workspace/src/tools/exec.ts b/packages/workspace/src/tools/exec.ts index 1b7c5b1b..0afdb560 100644 --- a/packages/workspace/src/tools/exec.ts +++ b/packages/workspace/src/tools/exec.ts @@ -51,8 +51,8 @@ export function createExecTool(options: ExecToolOptions) { "Backends:", backendGuidance, "", - `Default backend: ${JSON.stringify(options.defaultBackend)}.`, - "Prefer the dedicated read, write, and edit tools for file operations. Long output is truncated to keep tool replies small.", + `Default backend: ${JSON.stringify(options.defaultBackend)}. Try this first for any command you're not sure about; if it fails with a "command not found" or a similar capability error, retry on a backend whose description covers the missing tool.`, + "Use for builds, test runs, typechecks, formatters, and git plumbing. Prefer the dedicated read, write, and edit tools for file operations. Long output is truncated to keep tool replies small.", ].join("\n"); const backendSchema = z @@ -62,7 +62,7 @@ export function createExecTool(options: ExecToolOptions) { [ "Which backend to run on. Omit to use the default", `(${JSON.stringify(options.defaultBackend)}). Set explicitly when the`, - "default backend is not capable of running the command.", + "default backend is not capable of running the command. If a command fails because the backend lacks that tool, retry on a backend whose description covers it.", ].join(" "), ); @@ -75,20 +75,29 @@ export function createExecTool(options: ExecToolOptions) { }), execute: async ({ command, cwd, backend }) => { const selectedBackend = backend ?? options.defaultBackend; - const handle = await options.workspace.shell.exec(command, { - cwd, - encoding: "utf8", - backend: selectedBackend, - }); - const result = await handle.result(); - return { - command, - cwd: cwd ?? null, - backend: selectedBackend, - exitCode: result.exitCode, - stdout: truncate(result.stdout, maxBytes), - stderr: truncate(result.stderr, maxBytes), - }; + try { + const handle = await options.workspace.shell.exec(command, { + cwd, + encoding: "utf8", + backend: selectedBackend, + }); + const result = await handle.result(); + return { + command, + cwd: cwd ?? null, + backend: selectedBackend, + exitCode: result.exitCode, + stdout: truncate(result.stdout, maxBytes), + stderr: truncate(result.stderr, maxBytes), + }; + } catch (err) { + return { + command, + cwd: cwd ?? null, + backend: selectedBackend, + error: err instanceof Error ? err.message : String(err), + }; + } }, }); } diff --git a/packages/workspace/src/tools/fs/store.ts b/packages/workspace/src/tools/fs/store.ts index d240958f..5b7d06e6 100644 --- a/packages/workspace/src/tools/fs/store.ts +++ b/packages/workspace/src/tools/fs/store.ts @@ -74,10 +74,14 @@ export class WorkspaceFileStore implements FileStore { const reader = stream.getReader(); let skipped = 0; let yielded = 0; + let completed = false; try { while (true) { const { value, done } = await reader.read(); - if (done) break; + if (done) { + completed = true; + break; + } if (!value || value.byteLength === 0) continue; let start = 0; @@ -107,6 +111,7 @@ export class WorkspaceFileStore implements FileStore { if (byteLength !== undefined && yielded >= byteLength) break; } } finally { + if (!completed) await reader.cancel(); reader.releaseLock(); } } diff --git a/packages/workspace/src/tools/publish.ts b/packages/workspace/src/tools/publish.ts index 69ca26fb..aa92423d 100644 --- a/packages/workspace/src/tools/publish.ts +++ b/packages/workspace/src/tools/publish.ts @@ -33,9 +33,12 @@ export function createPublishTool(options: PublishToolOptions) { }), execute: async ({ path, expiresAfterMs }) => { try { + const prefix = options.workspace.sessionId + ? `agent-${options.workspace.sessionId}` + : undefined; const url = await assets.share(path, { expiresAfter: expiresAfterMs ?? DEFAULT_EXPIRY_MS, - prefix: `agent-${options.workspace.sessionId}`, + ...(prefix ? { prefix } : {}), }); return { ok: true, url }; } catch (err) {