Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/computer-workspace-file-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": minor
---

Add bounded `find` and `grep` tools, a read-only-aware `delete` tool, and shared locking for file mutations.
205 changes: 202 additions & 3 deletions packages/computer/src/tools/ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import type { WorkspaceRuntimeExecHandle, WorkspaceRuntimeResult } from "../runt
import { Workspace } from "../workspace.js";
import {
createAITools,
createDeleteTool,
createEditTool,
createGrepTool,
createReadTool,
createWriteTool,
type FileStore,
Expand Down Expand Up @@ -259,10 +261,18 @@ describe("WorkspaceFileStore", () => {
});

describe("createAITools filesystem tools", () => {
it("creates fixed read, write, edit, and ls tools by default", () => {
it("creates the complete filesystem tool set by default", () => {
const tools = createAITools({ workspace: makeWorkspace() });

expect(Object.keys(tools).sort()).toEqual(["edit", "ls", "read", "write"]);
expect(Object.keys(tools).sort()).toEqual([
"delete",
"edit",
"find",
"grep",
"ls",
"read",
"write",
]);
});

it("returns only read-only tools when readonly is true", () => {
Expand All @@ -275,7 +285,7 @@ describe("createAITools filesystem tools", () => {
},
});

expect(Object.keys(tools).sort()).toEqual(["ls", "read"]);
expect(Object.keys(tools).sort()).toEqual(["find", "grep", "ls", "read"]);
});

it("reads, lists, writes, and edits workspace files", async () => {
Expand Down Expand Up @@ -349,6 +359,195 @@ describe("createAITools filesystem tools", () => {
});
});

it("serializes write behind an edit on the same store and path", async () => {
let releaseRead: (() => void) | undefined;
let markReadStarted: (() => void) | undefined;
const readGate = new Promise<void>((resolve) => {
releaseRead = resolve;
});
const readStarted = new Promise<void>((resolve) => {
markReadStarted = resolve;
});
const writes: string[] = [];
const store: FileStore = {
async stat() {
return { size: 3, mtime: 1 };
},
async readAll() {
markReadStarted?.();
await readGate;
return bytes("old");
},
async *readChunks() {
yield bytes("old");
},
async write(_path, content) {
writes.push(decode(content));
},
};
const edit = executeTool(createEditTool({ store }), {
path: "/workspace/file.txt",
edits: [{ oldText: "old", newText: "edited" }],
});
await readStarted;

const write = executeTool(createWriteTool({ store }), {
path: "/workspace/file.txt",
content: "written",
});
await Promise.resolve();
const writesBeforeEditFinished = [...writes];

releaseRead?.();
await Promise.all([edit, write]);
expect(writesBeforeEditFinished).toEqual([]);
expect(writes).toEqual(["edited", "written"]);
});

it("does not share edit locks between stores", async () => {
let releaseRead: (() => void) | undefined;
let markFirstStarted: (() => void) | undefined;
let markSecondStarted: (() => void) | undefined;
const readGate = new Promise<void>((resolve) => {
releaseRead = resolve;
});
const firstStarted = new Promise<void>((resolve) => {
markFirstStarted = resolve;
});
const secondStarted = new Promise<void>((resolve) => {
markSecondStarted = resolve;
});
const first = memoryStore({ content: "old" });
first.readAll = async () => {
markFirstStarted?.();
await readGate;
return bytes("old");
};
const second = memoryStore({ content: "old" });
second.readAll = async () => {
markSecondStarted?.();
return bytes("old");
};

const firstEdit = executeTool(createEditTool({ store: first }), {
path: "/workspace/file.txt",
edits: [{ oldText: "old", newText: "first" }],
});
await firstStarted;
const secondEdit = executeTool(createEditTool({ store: second }), {
path: "/workspace/file.txt",
edits: [{ oldText: "old", newText: "second" }],
});

const secondAcquired = await Promise.race([
secondStarted.then(() => true),
new Promise<false>((resolve) => setTimeout(() => resolve(false), 0)),
]);

releaseRead?.();
await Promise.all([firstEdit, secondEdit]);
expect(secondAcquired).toBe(true);
});

it("accepts grep continuation offsets produced after large result sets", () => {
const tool = createGrepTool({
workspace: {
fs: {
async find() {
return [];
},
async grep() {
return [];
},
},
},
});
const schema = tool.inputSchema as {
safeParse(input: unknown): { success: boolean };
};

expect(schema.safeParse({ path: "/workspace", query: "needle", offset: 10_200 }).success).toBe(
true,
);
});

it("finds, greps, and deletes files through a real Workspace", async () => {
const workspace = makeWorkspace();
const tools = createAITools({ workspace });
await workspace.fs.mkdir("/workspace/src", { recursive: true });
await workspace.fs.writeFile("/workspace/src/a.ts", "const value = 'TODO';\n");
await workspace.fs.writeFile("/workspace/src/b.md", "todo in docs\n");

await expect(
executeTool(tools.find, { path: "/workspace", pattern: "**/*.ts", limit: 20 }),
).resolves.toEqual({
path: "/workspace",
pattern: "**/*.ts",
count: 1,
entries: [{ path: "/workspace/src/a.ts", type: "file" }],
});
await expect(
executeTool(tools.grep, {
path: "/workspace",
query: "todo",
include: "**/*.ts",
limit: 20,
}),
).resolves.toMatchObject({
count: 1,
matches: [{ path: "/workspace/src/a.ts", line: 1, text: "const value = 'TODO';" }],
});
await expect(executeTool(tools.delete, { path: "/workspace/src/a.ts" })).resolves.toEqual({
deleted: "/workspace/src/a.ts",
});
await expect(workspace.fs.stat("/workspace/src/a.ts")).rejects.toMatchObject({
code: "ENOENT",
});
});

it("serializes delete behind an edit on the same store and path", async () => {
let releaseRead: (() => void) | undefined;
let markReadStarted: (() => void) | undefined;
const readGate = new Promise<void>((resolve) => {
releaseRead = resolve;
});
const readStarted = new Promise<void>((resolve) => {
markReadStarted = resolve;
});
const events: string[] = [];
const store = memoryStore({
content: "old",
onWrite() {
events.push("edit");
},
});
store.readAll = async () => {
markReadStarted?.();
await readGate;
return bytes("old");
};
const deleteStore = Object.assign(store, {
async remove() {
events.push("delete");
},
});
const edit = executeTool(createEditTool({ store }), {
path: "/workspace/file.txt",
edits: [{ oldText: "old", newText: "edited" }],
});
await readStarted;
const deletion = executeTool(createDeleteTool({ store: deleteStore }), {
path: "/workspace/file.txt",
});
await Promise.resolve();
const eventsBeforeEditFinished = [...events];

releaseRead?.();
await Promise.all([edit, deletion]);
expect(eventsBeforeEditFinished).toEqual([]);
expect(events).toEqual(["edit", "delete"]);
});

it("preserves file mode when write overwrites an existing file", async () => {
const writes: Array<{ path: string; content: string; mode?: number }> = [];
const tool = createWriteTool({
Expand Down
9 changes: 9 additions & 0 deletions packages/computer/src/tools/ai.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { ToolSet } from "ai";
import { createExecTool, type ExecToolOptions, type ExecWorkspaceLike } from "./exec.js";
import { createDeleteTool, type DeleteToolOptions } from "./fs/delete.js";
import { createEditTool, type EditToolOptions } from "./fs/edit.js";
import { createFindTool, type FindToolOptions } from "./fs/find.js";
import { createGrepTool, type GrepToolOptions } from "./fs/grep.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";
Expand All @@ -14,6 +17,9 @@ export interface CreateAIToolsOptions {
read?: Omit<ReadToolOptions, "store">;
write?: Omit<WriteToolOptions, "store">;
edit?: Omit<EditToolOptions, "store">;
find?: Omit<FindToolOptions, "workspace">;
grep?: Omit<GrepToolOptions, "workspace">;
delete?: Omit<DeleteToolOptions, "store">;
shell?: Omit<ExecToolOptions, "workspace">;
}

Expand All @@ -22,12 +28,15 @@ export function createAITools(options: CreateAIToolsOptions): ToolSet {
const tools: ToolSet = {
read: createReadTool({ store, ...options.read }),
ls: createListTool({ workspace: options.workspace }),
find: createFindTool({ workspace: options.workspace, ...options.find }),
grep: createGrepTool({ workspace: options.workspace, ...options.grep }),
};

if (options.readonly === true) return tools;

tools.write = createWriteTool({ store, ...options.write });
tools.edit = createEditTool({ store, ...options.edit });
tools.delete = createDeleteTool({ store, ...options.delete });

if (options.shell !== undefined) {
tools.exec = createExecTool({
Expand Down
34 changes: 34 additions & 0 deletions packages/computer/src/tools/fs/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { type Tool, tool } from "ai";
import { z } from "zod";
import { withFileLock } from "./locks.js";
import type { MutableFileStore } from "./types.js";

export interface DeleteToolOptions {
store: MutableFileStore;
}

const inputSchema = z.object({
path: z.string().describe("Absolute path to the file or directory to delete."),
recursive: z
.boolean()
.optional()
.describe("Remove a directory and all of its contents. Defaults to false."),
});

export function createDeleteTool(options: DeleteToolOptions): Tool<z.infer<typeof inputSchema>> {
const { store } = options;
return tool({
description:
"Delete a file or directory. Set recursive to true to remove a non-empty directory.",
inputSchema,
execute: async ({ path, recursive }) =>
withFileLock(store, path, async () => {
try {
await store.remove(path, { recursive, force: true });
return { deleted: path };
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) };
}
}),
});
}
22 changes: 2 additions & 20 deletions packages/computer/src/tools/fs/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
restoreLineEndings,
stripBom,
} from "./edit-diff.js";
import { withFileLock } from "./locks.js";
import type { FileStore } from "./types.js";

export interface EditToolOptions {
Expand Down Expand Up @@ -71,25 +72,6 @@ function prepareArguments(input: unknown): { path: string; edits: Edit[] } {
return args as { path: string; edits: Edit[] };
}

// Per-path mutation queue. Edit and write should never race on the same file:
// fuzzy matching reads the entire buffer, applies a textual change, then
// writes — a concurrent edit landing between read and write would silently
// clobber the first edit. Module-scoped so all tools sharing a store also
// share the queue.
const fileLocks = new Map<string, Promise<unknown>>();
async function withFileLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
const prev = fileLocks.get(path) ?? Promise.resolve();
const next = prev.then(fn, fn);
fileLocks.set(
path,
next.finally(() => {
// Clear only if we're still the tail of the chain.
if (fileLocks.get(path) === next) fileLocks.delete(path);
}),
);
return next;
}

export function createEditTool(options: EditToolOptions): Tool<z.infer<typeof inputSchema>> {
const { store } = options;
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
Expand All @@ -105,7 +87,7 @@ export function createEditTool(options: EditToolOptions): Tool<z.infer<typeof in
return { error: "edits must contain at least one replacement." };
}

return withFileLock(path, async () => {
return withFileLock(store, path, async () => {
try {
const stat = await store.stat(path);
if (!stat) return { error: `File not found: ${path}` };
Expand Down
Loading
Loading