From ba4e290eb008d3766f71e8d108429777b1fd80e5 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:27:03 +0000 Subject: [PATCH 1/4] computer: Serialize file mutations Share a store-scoped, normalized-path lock between edit and write so read-modify-write cycles cannot clobber concurrent writes or block unrelated workspaces. --- packages/computer/src/tools/ai.test.ts | 90 +++++++++++++++++++++++++ packages/computer/src/tools/fs/edit.ts | 22 +----- packages/computer/src/tools/fs/locks.ts | 47 +++++++++++++ packages/computer/src/tools/fs/write.ts | 23 ++++--- 4 files changed, 152 insertions(+), 30 deletions(-) create mode 100644 packages/computer/src/tools/fs/locks.ts diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 1b861411..d6ab8b18 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -349,6 +349,96 @@ 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((resolve) => { + releaseRead = resolve; + }); + const readStarted = new Promise((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((resolve) => { + releaseRead = resolve; + }); + const firstStarted = new Promise((resolve) => { + markFirstStarted = resolve; + }); + const secondStarted = new Promise((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((resolve) => setTimeout(() => resolve(false), 0)), + ]); + + releaseRead?.(); + await Promise.all([firstEdit, secondEdit]); + expect(secondAcquired).toBe(true); + }); + it("preserves file mode when write overwrites an existing file", async () => { const writes: Array<{ path: string; content: string; mode?: number }> = []; const tool = createWriteTool({ diff --git a/packages/computer/src/tools/fs/edit.ts b/packages/computer/src/tools/fs/edit.ts index 3076c8de..ec338b9a 100644 --- a/packages/computer/src/tools/fs/edit.ts +++ b/packages/computer/src/tools/fs/edit.ts @@ -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 { @@ -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>(); -async function withFileLock(path: string, fn: () => Promise): Promise { - const prev = fileLocks.get(path) ?? Promise.resolve(); - const next = prev.then(fn, fn); - fileLocks.set( - path, - next.finally(() => { - // Clear only if we're still the tail of the chain. - if (fileLocks.get(path) === next) fileLocks.delete(path); - }), - ); - return next; -} - export function createEditTool(options: EditToolOptions): Tool> { const { store } = options; const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; @@ -105,7 +87,7 @@ export function createEditTool(options: EditToolOptions): Tool { + return withFileLock(store, path, async () => { try { const stat = await store.stat(path); if (!stat) return { error: `File not found: ${path}` }; diff --git a/packages/computer/src/tools/fs/locks.ts b/packages/computer/src/tools/fs/locks.ts new file mode 100644 index 00000000..f8f7b710 --- /dev/null +++ b/packages/computer/src/tools/fs/locks.ts @@ -0,0 +1,47 @@ +import type { FileStore } from "./types.js"; + +const storeLocks = new WeakMap>>(); + +export async function withFileLock( + store: FileStore, + path: string, + operation: () => Promise, +): Promise { + let paths = storeLocks.get(store); + if (paths === undefined) { + paths = new Map(); + storeLocks.set(store, paths); + } + + const key = normalizePath(path); + const previous = paths.get(key) ?? Promise.resolve(); + let release: (() => void) | undefined; + const current = new Promise((resolve) => { + release = resolve; + }); + paths.set(key, current); + + await previous; + try { + return await operation(); + } finally { + release?.(); + if (paths.get(key) === current) paths.delete(key); + if (paths.size === 0) storeLocks.delete(store); + } +} + +function normalizePath(path: string): string { + const absolute = path.startsWith("/"); + const parts: string[] = []; + for (const part of path.split("/")) { + if (part === "" || part === ".") continue; + if (part === "..") { + parts.pop(); + } else { + parts.push(part); + } + } + const normalized = parts.join("/"); + return absolute ? `/${normalized}` : normalized; +} diff --git a/packages/computer/src/tools/fs/write.ts b/packages/computer/src/tools/fs/write.ts index d7c1132b..139b01f0 100644 --- a/packages/computer/src/tools/fs/write.ts +++ b/packages/computer/src/tools/fs/write.ts @@ -1,5 +1,6 @@ import { type Tool, tool } from "ai"; import { z } from "zod"; +import { withFileLock } from "./locks.js"; import type { FileStore } from "./types.js"; export interface WriteToolOptions { @@ -32,16 +33,18 @@ export function createWriteTool(options: WriteToolOptions): Tool { + try { + // Preserve the existing file's mode when overwriting so executable + // scripts don't silently lose its executable bits. For new files we + // 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) }; + } + }); }, }); } From a26c08b62f25096f7e0ec0816e9529f659282513 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:30:46 +0000 Subject: [PATCH 2/4] computer: Add find grep and delete tools Expose the missing workspace tools, keep find and grep available in read-only mode, bound their result pages, and serialize delete with other mutations. --- packages/computer/src/tools/ai.test.ts | 92 ++++++++++++++- packages/computer/src/tools/ai.ts | 9 ++ packages/computer/src/tools/fs/delete.ts | 34 ++++++ packages/computer/src/tools/fs/find.ts | 58 +++++++++ packages/computer/src/tools/fs/grep.ts | 144 +++++++++++++++++++++++ packages/computer/src/tools/fs/store.ts | 38 +++++- packages/computer/src/tools/fs/types.ts | 5 + packages/computer/src/tools/index.ts | 5 +- 8 files changed, 377 insertions(+), 8 deletions(-) create mode 100644 packages/computer/src/tools/fs/delete.ts create mode 100644 packages/computer/src/tools/fs/find.ts create mode 100644 packages/computer/src/tools/fs/grep.ts diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index d6ab8b18..03b2f2c8 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -4,6 +4,7 @@ import type { WorkspaceRuntimeExecHandle, WorkspaceRuntimeResult } from "../runt import { Workspace } from "../workspace.js"; import { createAITools, + createDeleteTool, createEditTool, createReadTool, createWriteTool, @@ -259,10 +260,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", () => { @@ -275,7 +284,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 () => { @@ -439,6 +448,83 @@ describe("createAITools filesystem tools", () => { expect(secondAcquired).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((resolve) => { + releaseRead = resolve; + }); + const readStarted = new Promise((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({ diff --git a/packages/computer/src/tools/ai.ts b/packages/computer/src/tools/ai.ts index fb3dd960..a4bc5e03 100644 --- a/packages/computer/src/tools/ai.ts +++ b/packages/computer/src/tools/ai.ts @@ -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"; @@ -14,6 +17,9 @@ export interface CreateAIToolsOptions { read?: Omit; write?: Omit; edit?: Omit; + find?: Omit; + grep?: Omit; + delete?: Omit; shell?: Omit; } @@ -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({ diff --git a/packages/computer/src/tools/fs/delete.ts b/packages/computer/src/tools/fs/delete.ts new file mode 100644 index 00000000..6f758c29 --- /dev/null +++ b/packages/computer/src/tools/fs/delete.ts @@ -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> { + 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) }; + } + }), + }); +} diff --git a/packages/computer/src/tools/fs/find.ts b/packages/computer/src/tools/fs/find.ts new file mode 100644 index 00000000..5d71bdbd --- /dev/null +++ b/packages/computer/src/tools/fs/find.ts @@ -0,0 +1,58 @@ +import { type Tool, tool } from "ai"; +import { z } from "zod"; + +interface FoundEntry { + path: string; + type: "file" | "dir"; +} + +export interface FindWorkspaceLike { + fs: { + find(directory: string, pattern?: string): Promise; + }; +} + +export interface FindToolOptions { + workspace: FindWorkspaceLike; +} + +const DEFAULT_LIMIT = 200; +const MAX_LIMIT = 1000; + +const inputSchema = z.object({ + path: z.string().default("/workspace").describe("Absolute directory to search."), + pattern: z + .string() + .describe('Glob pattern relative to path, for example "**/*.ts" or "src/?.js".'), + limit: z.number().int().min(1).max(MAX_LIMIT).optional(), + offset: z.number().int().min(0).optional(), +}); + +export function createFindTool(options: FindToolOptions): Tool> { + return tool({ + description: + "Find files and directories matching a glob. * stays within one path segment, ** crosses directories, and ? matches one character.", + inputSchema, + execute: async ({ path, pattern, limit, offset }) => { + try { + const pageSize = limit ?? DEFAULT_LIMIT; + const pageOffset = offset ?? 0; + const matches = await options.workspace.fs.find(path, pattern); + const page = matches.slice(pageOffset, pageOffset + pageSize + 1); + const truncated = page.length > pageSize; + const entries = truncated ? page.slice(0, pageSize) : page; + const result: { + path: string; + pattern: string; + count: number; + entries: FoundEntry[]; + nextOffset?: number; + } = { path, pattern, count: entries.length, entries }; + if (truncated) result.nextOffset = pageOffset + pageSize; + return result; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } + }, + }); +} diff --git a/packages/computer/src/tools/fs/grep.ts b/packages/computer/src/tools/fs/grep.ts new file mode 100644 index 00000000..2b657a19 --- /dev/null +++ b/packages/computer/src/tools/fs/grep.ts @@ -0,0 +1,144 @@ +import { type Tool, tool } from "ai"; +import { z } from "zod"; + +interface GrepContextLine { + line: number; + text: string; + isMatch: boolean; +} + +interface GrepMatch { + path: string; + line: number; + text: string; + context?: GrepContextLine[]; +} + +interface FoundEntry { + path: string; + type: "file" | "dir"; +} + +interface GrepOptions { + fixedString?: boolean; + caseSensitive?: boolean; + contextLines?: number; + limit?: number; + offset?: number; +} + +export interface GrepWorkspaceLike { + fs: { + find(directory: string, pattern?: string): Promise; + grep(pattern: string, path: string, options?: GrepOptions): Promise; + }; +} + +export interface GrepToolOptions { + workspace: GrepWorkspaceLike; +} + +const DEFAULT_LIMIT = 200; +const MAX_LIMIT = 1000; + +const inputSchema = z.object({ + path: z.string().default("/workspace").describe("Absolute file or directory to search."), + query: z.string().describe("Regular expression or fixed string to search for."), + include: z + .string() + .optional() + .describe('Glob relative to path that limits searched files, for example "**/*.ts".'), + fixedString: z.boolean().optional().describe("Treat query as plain text instead of a regex."), + caseSensitive: z.boolean().optional().describe("Match letter case. Defaults to false."), + contextLines: z.number().int().min(0).max(10).optional(), + limit: z.number().int().min(1).max(MAX_LIMIT).optional(), + offset: z.number().int().min(0).max(10_000).optional(), +}); + +export function createGrepTool(options: GrepToolOptions): Tool> { + return tool({ + description: + "Search workspace text with a regular expression or fixed string. Results include paths and line numbers and can include surrounding lines.", + inputSchema, + execute: async ({ + path, + query, + include, + fixedString, + caseSensitive, + contextLines, + limit, + offset, + }) => { + try { + const pageSize = limit ?? DEFAULT_LIMIT; + const pageOffset = offset ?? 0; + const searchOptions = { + fixedString: fixedString ?? false, + caseSensitive: caseSensitive ?? false, + contextLines: contextLines ?? 0, + }; + const matches = + include === undefined + ? await options.workspace.fs.grep(query, path, { + ...searchOptions, + limit: pageSize + 1, + offset: pageOffset, + }) + : await grepIncludedFiles( + options.workspace, + query, + path, + include, + searchOptions, + pageOffset, + pageSize + 1, + ); + const truncated = matches.length > pageSize; + const page = truncated ? matches.slice(0, pageSize) : matches; + const result: { + path: string; + query: string; + count: number; + matches: GrepMatch[]; + nextOffset?: number; + } = { path, query, count: page.length, matches: page }; + if (truncated) result.nextOffset = pageOffset + pageSize; + return result; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } + }, + }); +} + +async function grepIncludedFiles( + workspace: GrepWorkspaceLike, + query: string, + path: string, + include: string, + options: Pick, + offset: number, + limit: number, +): Promise { + const files = (await workspace.fs.find(path, include)) + .filter((entry) => entry.type === "file") + .map((entry) => entry.path) + .sort(); + const matches: GrepMatch[] = []; + let skipped = offset; + for (const file of files) { + const fileMatches = await workspace.fs.grep(query, file, { + ...options, + limit: skipped + (limit - matches.length), + }); + if (skipped >= fileMatches.length) { + skipped -= fileMatches.length; + continue; + } + matches.push(...fileMatches.slice(skipped, skipped + (limit - matches.length))); + skipped = 0; + if (matches.length >= limit) break; + } + return matches; +} diff --git a/packages/computer/src/tools/fs/store.ts b/packages/computer/src/tools/fs/store.ts index 76172e8f..87738df8 100644 --- a/packages/computer/src/tools/fs/store.ts +++ b/packages/computer/src/tools/fs/store.ts @@ -10,7 +10,7 @@ * and multimodal output still drain `fs.readFile(path)`. */ -import type { FileStat, FileStore } from "./types.js"; +import type { FileStat, MutableFileStore } from "./types.js"; const RANGE_CHUNK_BYTES = 64 * 1024; @@ -32,6 +32,28 @@ export interface WorkspaceLike { 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; + find( + directory: string, + pattern?: string, + ): Promise>; + grep( + pattern: string, + path: string, + options?: { + fixedString?: boolean; + caseSensitive?: boolean; + contextLines?: number; + limit?: number; + offset?: number; + }, + ): Promise< + Array<{ + path: string; + line: number; + text: string; + context?: Array<{ line: number; text: string; isMatch: boolean }>; + }> + >; readdir( path: string, options?: { limit?: number; offset?: number }, @@ -48,8 +70,12 @@ export interface WorkspaceLike { }; } -export class WorkspaceFileStore implements FileStore { - constructor(private readonly ws: WorkspaceLike) {} +type WorkspaceFileStoreLike = { + fs: Pick; +}; + +export class WorkspaceFileStore implements MutableFileStore { + constructor(private readonly ws: WorkspaceFileStoreLike) {} async stat(path: string): Promise { try { @@ -77,6 +103,10 @@ export class WorkspaceFileStore implements FileStore { await this.ws.fs.writeFile(path, content, opts); } + async remove(path: string, opts?: { recursive?: boolean; force?: boolean }): Promise { + await this.ws.fs.rm(path, opts); + } + async *readChunks(path: string, byteOffset = 0, byteLength?: number): AsyncIterable { if (!Number.isSafeInteger(byteOffset) || byteOffset < 0) { throw new Error("readChunks: byteOffset must be a non-negative safe integer"); @@ -128,7 +158,7 @@ async function drain(stream: ReadableStream): Promise { return out; } -async function ensureParentDir(ws: WorkspaceLike, path: string): Promise { +async function ensureParentDir(ws: WorkspaceFileStoreLike, path: string): Promise { const i = path.lastIndexOf("/"); if (i <= 0) return; const parent = path.slice(0, i); diff --git a/packages/computer/src/tools/fs/types.ts b/packages/computer/src/tools/fs/types.ts index eac26d65..e5425cf7 100644 --- a/packages/computer/src/tools/fs/types.ts +++ b/packages/computer/src/tools/fs/types.ts @@ -43,3 +43,8 @@ export interface FileStore { */ write(path: string, content: Uint8Array, opts?: { mode?: number }): Promise; } + +export interface MutableFileStore extends FileStore { + /** Remove a file or directory. */ + remove(path: string, opts?: { recursive?: boolean; force?: boolean }): Promise; +} diff --git a/packages/computer/src/tools/index.ts b/packages/computer/src/tools/index.ts index b2686a55..7e064689 100644 --- a/packages/computer/src/tools/index.ts +++ b/packages/computer/src/tools/index.ts @@ -7,10 +7,13 @@ export { type ExecToolOptions, type ExecToolOutput, } from "./exec.js"; +export { createDeleteTool, type DeleteToolOptions } from "./fs/delete.js"; export { createEditTool, type EditToolOptions } from "./fs/edit.js"; +export { createFindTool, type FindToolOptions } from "./fs/find.js"; +export { createGrepTool, type GrepToolOptions } from "./fs/grep.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 type { FileStat, FileStore, MutableFileStore } from "./fs/types.js"; export { createWriteTool, type WriteToolOptions } from "./fs/write.js"; export { createPublishTool, type PublishToolOptions } from "./publish.js"; From 401388922c83702ce7d8d189018a1f7b8764f4d5 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:55:20 +0000 Subject: [PATCH 3/4] computer: Accept all grep continuations Allow any non-negative grep offset so continuation values emitted after large result sets remain valid inputs to the next tool call. --- packages/computer/src/tools/ai.test.ts | 23 +++++++++++++++++++++++ packages/computer/src/tools/fs/grep.ts | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 03b2f2c8..9c296ecf 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -6,6 +6,7 @@ import { createAITools, createDeleteTool, createEditTool, + createGrepTool, createReadTool, createWriteTool, type FileStore, @@ -448,6 +449,28 @@ describe("createAITools filesystem tools", () => { 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 }); diff --git a/packages/computer/src/tools/fs/grep.ts b/packages/computer/src/tools/fs/grep.ts index 2b657a19..71f3b589 100644 --- a/packages/computer/src/tools/fs/grep.ts +++ b/packages/computer/src/tools/fs/grep.ts @@ -52,7 +52,7 @@ const inputSchema = z.object({ caseSensitive: z.boolean().optional().describe("Match letter case. Defaults to false."), contextLines: z.number().int().min(0).max(10).optional(), limit: z.number().int().min(1).max(MAX_LIMIT).optional(), - offset: z.number().int().min(0).max(10_000).optional(), + offset: z.number().int().min(0).optional(), }); export function createGrepTool(options: GrepToolOptions): Tool> { From 8166cb0d3b7cda75fd85635fbb3967f259293219 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:24:47 +0000 Subject: [PATCH 4/4] computer: Add workspace tools changeset Record the new search and deletion tools and shared mutation locking with the Computer package that exposes them. --- .changeset/computer-workspace-file-tools.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/computer-workspace-file-tools.md diff --git a/.changeset/computer-workspace-file-tools.md b/.changeset/computer-workspace-file-tools.md new file mode 100644 index 00000000..d574c104 --- /dev/null +++ b/.changeset/computer-workspace-file-tools.md @@ -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.