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 a3dc2ced..d49f1857 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. | +| `createPublishTool` | 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 `publish` 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. +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({ + 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 `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. | +| `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. + +## `publish` + +```ts +createPublishTool({ 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. 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. ## `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..b7bc105d 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 publish. | 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`. | diff --git a/examples/think/README.md b/examples/think/README.md index 0bc1b5b2..98889e83 100644 --- a/examples/think/README.md +++ b/examples/think/README.md @@ -55,23 +55,29 @@ 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) | - -The `share` tool uploads a workspace file to R2 and returns a +| Tool | Source | +| --------------- | ------------------------------------------------ | +| `read` | `@cloudflare/workspace/tools` | +| `ls` | `@cloudflare/workspace/tools` | +| `write` | `@cloudflare/workspace/tools` | +| `edit` | `@cloudflare/workspace/tools` | +| `exec` | `@cloudflare/workspace/tools` (optional shell) | +| `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 `publish` only when the +Workspace assets publisher is configured. + +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"` @@ -178,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: @@ -198,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/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..4241190a 100644 --- a/examples/think/src/agent.ts +++ b/examples/think/src/agent.ts @@ -8,12 +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 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). + * `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")` @@ -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 @@ -216,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) => @@ -229,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; @@ -600,20 +593,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 +634,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, - }), - } - : {}), }; } @@ -759,163 +739,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 - * 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; -} - -// ── 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/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/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/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/package-lock.json b/package-lock.json index 613d5bd8..603600ca 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" @@ -12863,6 +12851,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 +12860,27 @@ "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": "*", - "diff": "^9.0.0", - "isomorphic-git": "^1.27.0" + "ai": "^6.0.196", + "isomorphic-git": "^1.27.0", + "zod": "^4.4.3" }, "peerDependenciesMeta": { "@platformatic/vfs": { "optional": true }, - "diff": { + "ai": { "optional": true }, "isomorphic-git": { "optional": true + }, + "zod": { + "optional": true } } }, diff --git a/packages/workspace/README.md b/packages/workspace/README.md index 7bf386ca..e3299c37 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 `publish`. See + [`docs/09_tool_interface.md`](../../docs/09_tool_interface.md). ## Typical DO-side usage @@ -139,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/package.json b/packages/workspace/package.json index 07fd70bf..f704d3d4 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,22 @@ }, "peerDependencies": { "@platformatic/vfs": "*", - "diff": "^9.0.0", - "isomorphic-git": "^1.27.0" + "ai": "^6.0.196", + "isomorphic-git": "^1.27.0", + "zod": "^4.4.3" }, "peerDependenciesMeta": { "@platformatic/vfs": { "optional": true }, - "diff": { + "ai": { "optional": true }, "isomorphic-git": { "optional": true + }, + "zod": { + "optional": true } }, "devDependencies": { @@ -88,6 +96,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 +105,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/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. */ diff --git a/packages/workspace/src/tools/ai.test.ts b/packages/workspace/src/tools/ai.test.ts new file mode 100644 index 00000000..3e676ab2 --- /dev/null +++ b/packages/workspace/src/tools/ai.test.ts @@ -0,0 +1,561 @@ +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 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 }); +} + +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"); + }); + + 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", () => { + 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("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(); + + expect(() => + createAITools({ + workspace, + shell: { defaultBackend: "missing", backends: { shell: { description: "test" } } }, + }), + ).toThrow(/defaultBackend/); + }); +}); + +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, + 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.publish).toBeDefined(); + await expect( + 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 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, + sessionId: "session-a", + assets: { share: async () => "https://example.test" }, + }; + + expect(createAITools({ workspace, assets: false }).publish).toBeUndefined(); + expect(createAITools({ workspace, readonly: true }).publish).toBeUndefined(); + }); + + it("returns structured publish 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.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 new file mode 100644 index 00000000..fb3dd960 --- /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 { createPublishTool, type PublishWorkspaceLike } from "./publish.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.publish = createPublishTool({ workspace: options.workspace as PublishWorkspaceLike }); + } + + return tools; +} diff --git a/packages/workspace/src/tools/exec.ts b/packages/workspace/src/tools/exec.ts new file mode 100644 index 00000000..0afdb560 --- /dev/null +++ b/packages/workspace/src/tools/exec.ts @@ -0,0 +1,122 @@ +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)}. 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 + .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. If a command fails because the backend lacks that tool, retry on a backend whose description covers it.", + ].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; + 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), + }; + } + }, + }); +} + +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/examples/think/src/tools/fs/edit-diff.ts b/packages/workspace/src/tools/fs/edit-diff.ts similarity index 100% rename from examples/think/src/tools/fs/edit-diff.ts rename to packages/workspace/src/tools/fs/edit-diff.ts diff --git a/examples/think/src/tools/fs/tools/edit.ts b/packages/workspace/src/tools/fs/edit.ts similarity index 66% rename from examples/think/src/tools/fs/tools/edit.ts rename to packages/workspace/src/tools/fs/edit.ts index f72eb867..042aa383 100644 --- a/examples/think/src/tools/fs/tools/edit.ts +++ b/packages/workspace/src/tools/fs/edit.ts @@ -9,8 +9,8 @@ import { normalizeToLF, restoreLineEndings, stripBom, -} from "../edit-diff.js"; -import type { FileStore } from "../stores/types.js"; +} from "./edit-diff.js"; +import type { FileStore } from "./types.js"; export interface EditToolOptions { store: FileStore; @@ -106,49 +106,53 @@ export function createEditTool(options: EditToolOptions) { } return withFileLock(path, async () => { - const stat = await store.stat(path); - if (!stat) return { error: `File not found: ${path}` }; - if (stat.size > maxBytes) { + 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 { - error: `File too large to edit: ${stat.size} bytes exceeds the ${maxBytes}-byte cap. Use the write tool to rewrite the file from scratch.`, + path, + editsApplied: edits.length, + diff: diffResult.diff, + patch, + firstChangedLine: diffResult.firstChangedLine, }; - } - - 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/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/examples/think/src/tools/fs/tools/read.ts b/packages/workspace/src/tools/fs/read.ts similarity index 99% rename from examples/think/src/tools/fs/tools/read.ts rename to packages/workspace/src/tools/fs/read.ts index 4cdf2ced..50ae5a5a 100644 --- a/examples/think/src/tools/fs/tools/read.ts +++ b/packages/workspace/src/tools/fs/read.ts @@ -1,6 +1,6 @@ import { tool } from "ai"; import { z } from "zod"; -import type { FileStore } from "../stores/types.js"; +import type { FileStore } from "./types.js"; export interface ReadToolOptions { store: FileStore; diff --git a/examples/think/src/tools/fs/stores/workspace.ts b/packages/workspace/src/tools/fs/store.ts similarity index 61% rename from examples/think/src/tools/fs/stores/workspace.ts rename to packages/workspace/src/tools/fs/store.ts index 6c5c9e35..5b7d06e6 100644 --- a/examples/think/src/tools/fs/stores/workspace.ts +++ b/packages/workspace/src/tools/fs/store.ts @@ -1,22 +1,21 @@ /** - * `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. + * `FileStore` adapter over `Workspace.fs`. * - * 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`). + * 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` we depend - * on. Avoids a hard type-time import so this module can be vendored - * around the example with no fuss. + * Structural subset of `@cloudflare/workspace.Workspace` the tools + * depend on. */ export interface WorkspaceLike { fs: { @@ -28,10 +27,6 @@ export interface WorkspaceLike { 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; @@ -69,20 +64,54 @@ export class WorkspaceFileStore implements FileStore { } 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); + 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; + let completed = false; try { while (true) { const { value, done } = await reader.read(); - if (done) break; - if (value && value.byteLength > 0) yield value; + if (done) { + completed = true; + 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 { + if (!completed) await reader.cancel(); reader.releaseLock(); } } diff --git a/examples/think/src/tools/fs/stores/types.ts b/packages/workspace/src/tools/fs/types.ts similarity index 78% rename from examples/think/src/tools/fs/stores/types.ts rename to packages/workspace/src/tools/fs/types.ts index 0caaa7b2..eac26d65 100644 --- a/examples/think/src/tools/fs/stores/types.ts +++ b/packages/workspace/src/tools/fs/types.ts @@ -1,10 +1,9 @@ /** - * 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. + * 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. */ diff --git a/examples/think/src/tools/fs/tools/write.ts b/packages/workspace/src/tools/fs/write.ts similarity index 66% rename from examples/think/src/tools/fs/tools/write.ts rename to packages/workspace/src/tools/fs/write.ts index 992c53d6..88f30426 100644 --- a/examples/think/src/tools/fs/tools/write.ts +++ b/packages/workspace/src/tools/fs/write.ts @@ -1,6 +1,6 @@ import { tool } from "ai"; import { z } from "zod"; -import type { FileStore } from "../stores/types.js"; +import type { FileStore } from "./types.js"; export interface WriteToolOptions { store: FileStore; @@ -32,12 +32,16 @@ export function createWriteTool(options: WriteToolOptions) { 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 }; + 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..45dc297c --- /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 { createPublishTool, type PublishToolOptions } from "./publish.js"; diff --git a/packages/workspace/src/tools/publish.ts b/packages/workspace/src/tools/publish.ts new file mode 100644 index 00000000..aa92423d --- /dev/null +++ b/packages/workspace/src/tools/publish.ts @@ -0,0 +1,49 @@ +import { tool } from "ai"; +import { z } from "zod"; +import type { AssetsClient } from "../assets/index.js"; + +export interface PublishWorkspaceLike { + readonly sessionId: string; + readonly assets?: AssetsClient; +} + +export interface PublishToolOptions { + workspace: PublishWorkspaceLike; +} + +const DEFAULT_EXPIRY_MS = 60 * 60 * 1000; + +export function createPublishTool(options: PublishToolOptions) { + const assets = options.workspace.assets; + if (!assets) { + throw new Error("createPublishTool: workspace.assets is not configured"); + } + + return tool({ + description: + "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 + .number() + .int() + .positive() + .optional() + .describe("Link lifetime in milliseconds. Defaults to one hour."), + }), + 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 ? { prefix } : {}), + }); + return { ok: true, url }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + }); +} 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); +}