diff --git a/lib/agent/__tests__/buildAgentTools.test.ts b/lib/agent/__tests__/buildAgentTools.test.ts index 52479cad0..5478c59ca 100644 --- a/lib/agent/__tests__/buildAgentTools.test.ts +++ b/lib/agent/__tests__/buildAgentTools.test.ts @@ -1,17 +1,35 @@ import { describe, it, expect } from "vitest"; import { buildAgentTools } from "@/lib/agent/buildAgentTools"; +const EXPECTED_TOOL_NAMES = [ + "bash", + "read", + "write", + "edit", + "grep", + "glob", + "todo_write", + "web_fetch", +] as const; + describe("buildAgentTools", () => { - it("returns a tools record keyed by tool name", () => { + it("returns a tools record with all 8 leaf tools registered", () => { const tools = buildAgentTools(); - expect(tools).toHaveProperty("bash"); - expect(typeof tools.bash).toBe("object"); + for (const name of EXPECTED_TOOL_NAMES) { + expect(tools).toHaveProperty(name); + } }); - it("each tool has an inputSchema, description, and execute", () => { - const tools = buildAgentTools(); - expect(tools.bash.inputSchema).toBeDefined(); - expect(tools.bash.description).toBeDefined(); - expect(typeof tools.bash.execute).toBe("function"); + it("each tool exposes the AI SDK shape (description + inputSchema + execute)", () => { + const tools = buildAgentTools() as Record< + string, + { description?: unknown; inputSchema?: unknown; execute?: unknown } + >; + for (const name of EXPECTED_TOOL_NAMES) { + const t = tools[name]!; + expect(typeof t.description).toBe("string"); + expect(t.inputSchema).toBeDefined(); + expect(typeof t.execute).toBe("function"); + } }); }); diff --git a/lib/agent/buildAgentTools.ts b/lib/agent/buildAgentTools.ts index be6bde085..f9cbc2b39 100644 --- a/lib/agent/buildAgentTools.ts +++ b/lib/agent/buildAgentTools.ts @@ -1,4 +1,11 @@ import { bashTool } from "@/lib/agent/tools/bashTool"; +import { readFileTool } from "@/lib/agent/tools/readFileTool"; +import { writeFileTool } from "@/lib/agent/tools/writeFileTool"; +import { editFileTool } from "@/lib/agent/tools/editFileTool"; +import { grepTool } from "@/lib/agent/tools/grepTool"; +import { globTool } from "@/lib/agent/tools/globTool"; +import { todoWriteTool } from "@/lib/agent/tools/todoWriteTool"; +import { webFetchTool } from "@/lib/agent/tools/webFetchTool"; /** * Factory for the full agent tool set passed into `streamText({ tools })`. @@ -6,14 +13,26 @@ import { bashTool } from "@/lib/agent/tools/bashTool"; * at execute time — the factory takes no arguments because the tools are * stateless modulo that context. * - * Slim PR 4 exposes only `bash`. The remaining sandbox tools (`read`, - * `write`, `grep`, `glob`, `todo`, `task`, `ask_user_question`, `skill`, - * `fetch`) port in follow-up PRs and slot into this record one-by-one - * without changing the factory signature. + * Currently ships 8 leaf tools: + * - bash, read, write, edit, grep, glob (sandbox / file ops) + * - todo_write (planning surface; stateless, echoes the list back) + * - web_fetch (HTTP via curl inside the sandbox) + * + * Composite tools (`task` subagent, `ask_user_question` UI part, + * `skill` skill discovery) port in a follow-up PR — they require + * subagent context plumbing / UI rendering / skill discovery infra + * that isn't in api today. */ export function buildAgentTools() { return { - bash: bashTool(), + bash: bashTool, + read: readFileTool, + write: writeFileTool, + edit: editFileTool, + grep: grepTool, + glob: globTool, + todo_write: todoWriteTool, + web_fetch: webFetchTool, }; } diff --git a/lib/agent/tools/__tests__/bashTool.test.ts b/lib/agent/tools/__tests__/bashTool.test.ts index da9a999d3..568a7f72d 100644 --- a/lib/agent/tools/__tests__/bashTool.test.ts +++ b/lib/agent/tools/__tests__/bashTool.test.ts @@ -34,7 +34,7 @@ describe("bashTool.execute", () => { }); vi.mocked(connectVercel).mockResolvedValue(sandbox as never); - const tool = bashTool(); + const tool = bashTool; const result = await tool.execute!({ command: "ls" }, { experimental_context: baseContext, } as never); @@ -64,7 +64,7 @@ describe("bashTool.execute", () => { }); vi.mocked(connectVercel).mockResolvedValue(sandbox as never); - const tool = bashTool(); + const tool = bashTool; const result = (await tool.execute!({ command: "find ." }, { experimental_context: baseContext, } as never)) as { truncated?: boolean }; @@ -83,7 +83,7 @@ describe("bashTool.execute", () => { }); vi.mocked(connectVercel).mockResolvedValue(sandbox as never); - const tool = bashTool(); + const tool = bashTool; await tool.execute!({ command: "ls", cwd: "apps/web" }, { experimental_context: baseContext, } as never); @@ -107,7 +107,7 @@ describe("bashTool.execute", () => { }); vi.mocked(connectVercel).mockResolvedValue(sandbox as never); - const tool = bashTool(); + const tool = bashTool; await tool.execute!({ command: "curl example.com" }, { experimental_context: { ...baseContext, recoupOrgId: "org-uuid" }, } as never); @@ -121,7 +121,7 @@ describe("bashTool.execute", () => { }); vi.mocked(connectVercel).mockResolvedValue(sandbox as never); - const tool = bashTool(); + const tool = bashTool; const result = (await tool.execute!({ command: "npm run dev", detached: true }, { experimental_context: baseContext, } as never)) as { success: boolean; stdout: string }; @@ -134,7 +134,7 @@ describe("bashTool.execute", () => { const sandbox = makeSandbox({ execDetached: undefined }); vi.mocked(connectVercel).mockResolvedValue(sandbox as never); - const tool = bashTool(); + const tool = bashTool; const result = (await tool.execute!({ command: "npm run dev", detached: true }, { experimental_context: baseContext, } as never)) as { success: boolean; stderr: string }; @@ -148,7 +148,7 @@ describe("bashTool.execute", () => { }); vi.mocked(connectVercel).mockResolvedValue(sandbox as never); - const tool = bashTool(); + const tool = bashTool; await tool.execute!({ command: "npm run dev", detached: true }, { experimental_context: { ...baseContext, recoupOrgId: "org-uuid" }, } as never); diff --git a/lib/agent/tools/__tests__/editFileTool.test.ts b/lib/agent/tools/__tests__/editFileTool.test.ts new file mode 100644 index 000000000..3a2cac81d --- /dev/null +++ b/lib/agent/tools/__tests__/editFileTool.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { editFileTool } from "@/lib/agent/tools/editFileTool"; +import { connectVercel } from "@/lib/sandbox/vercel/connect/connectVercel"; + +vi.mock("@/lib/sandbox/vercel/connect/connectVercel", () => ({ + connectVercel: vi.fn(), +})); + +const ctx = { sandbox: { state: { sandboxName: "x" }, workingDirectory: "/sandbox/mono" } }; + +function makeSandbox(initialContent: string) { + let stored = initialContent; + return { + workingDirectory: "/sandbox/mono", + readFile: vi.fn(async () => stored), + writeFile: vi.fn(async (_path: string, content: string) => { + stored = content; + }), + getStored: () => stored, + }; +} + +beforeEach(() => vi.clearAllMocks()); + +describe("editFileTool", () => { + it("replaces a unique oldString once and reports the startLine", async () => { + const sb = makeSandbox("line one\nold value\nline three"); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const tool = editFileTool; + const result = (await tool.execute!( + { filePath: "a.txt", oldString: "old value", newString: "new value" }, + { experimental_context: ctx } as never, + )) as { success: boolean; replacements: number; startLine: number }; + expect(result.success).toBe(true); + expect(result.replacements).toBe(1); + expect(result.startLine).toBe(2); + expect(sb.getStored()).toBe("line one\nnew value\nline three"); + }); + + it("rejects when oldString === newString (no-op)", async () => { + const sb = makeSandbox("anything"); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const tool = editFileTool; + const result = (await tool.execute!({ filePath: "a.txt", oldString: "x", newString: "x" }, { + experimental_context: ctx, + } as never)) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error).toMatch(/must be different/); + }); + + it("rejects when oldString is not in the file", async () => { + const sb = makeSandbox("hello world"); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const tool = editFileTool; + const result = (await tool.execute!( + { filePath: "a.txt", oldString: "missing", newString: "other" }, + { experimental_context: ctx } as never, + )) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error).toMatch(/not found/); + }); + + it("rejects ambiguous edits (multiple matches without replaceAll)", async () => { + const sb = makeSandbox("foo\nfoo\nbar"); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const tool = editFileTool; + const result = (await tool.execute!({ filePath: "a.txt", oldString: "foo", newString: "baz" }, { + experimental_context: ctx, + } as never)) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error).toMatch(/2 times/); + }); + + it("replaces all occurrences when replaceAll:true", async () => { + const sb = makeSandbox("foo bar foo baz foo"); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const tool = editFileTool; + const result = (await tool.execute!( + { filePath: "a.txt", oldString: "foo", newString: "qux", replaceAll: true }, + { experimental_context: ctx } as never, + )) as { success: boolean; replacements: number }; + expect(result.success).toBe(true); + expect(result.replacements).toBe(3); + expect(sb.getStored()).toBe("qux bar qux baz qux"); + }); +}); diff --git a/lib/agent/tools/__tests__/globTool.test.ts b/lib/agent/tools/__tests__/globTool.test.ts new file mode 100644 index 000000000..3f35d0a71 --- /dev/null +++ b/lib/agent/tools/__tests__/globTool.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { globTool } from "@/lib/agent/tools/globTool"; +import { connectVercel } from "@/lib/sandbox/vercel/connect/connectVercel"; + +vi.mock("@/lib/sandbox/vercel/connect/connectVercel", () => ({ + connectVercel: vi.fn(), +})); + +const ctx = { sandbox: { state: { sandboxName: "x" }, workingDirectory: "/sandbox/mono" } }; + +function makeSandbox(exec: ReturnType) { + return { workingDirectory: "/sandbox/mono", exec }; +} + +beforeEach(() => vi.clearAllMocks()); + +describe("globTool", () => { + it("parses `mtime\\tsize\\tpath` output into structured file entries", async () => { + // Two files, newest first (sort already happens server-side in the command). + const sb = makeSandbox( + vi.fn().mockResolvedValue({ + success: true, + exitCode: 0, + stdout: + "1700000000.0\t512\t/sandbox/mono/src/index.ts\n1699999000.5\t256\t/sandbox/mono/src/util.ts", + stderr: "", + truncated: false, + }), + ); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const tool = globTool; + const result = (await tool.execute!({ pattern: "**/*.ts" }, { + experimental_context: ctx, + } as never)) as { + success: boolean; + count: number; + files: Array<{ path: string; size: number; modifiedAt: string }>; + }; + expect(result.success).toBe(true); + expect(result.count).toBe(2); + expect(result.files[0]?.path).toBe("src/index.ts"); + expect(result.files[0]?.size).toBe(512); + expect(typeof result.files[0]?.modifiedAt).toBe("string"); // ISO + }); + + it("emits a recursive find (no -maxdepth) for `**/*.ts`", async () => { + const sb = makeSandbox( + vi.fn().mockResolvedValue({ + success: true, + exitCode: 0, + stdout: "", + stderr: "", + truncated: false, + }), + ); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const tool = globTool; + await tool.execute!({ pattern: "**/*.ts" }, { experimental_context: ctx } as never); + const cmd = sb.exec.mock.calls[0]?.[0] as string; + expect(cmd).not.toContain("-maxdepth"); + }); + + it("emits -maxdepth 1 for a bare `*.json` pattern (no recursion)", async () => { + const sb = makeSandbox( + vi.fn().mockResolvedValue({ + success: true, + exitCode: 0, + stdout: "", + stderr: "", + truncated: false, + }), + ); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const tool = globTool; + await tool.execute!({ pattern: "*.json" }, { experimental_context: ctx } as never); + expect(sb.exec.mock.calls[0]?.[0]).toMatch(/-maxdepth\s+1/); + }); + + it("returns success:false on non-1 exit codes", async () => { + const sb = makeSandbox( + vi.fn().mockResolvedValue({ + success: false, + exitCode: 2, + stdout: "err", + stderr: "", + truncated: false, + }), + ); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const tool = globTool; + const result = (await tool.execute!({ pattern: "**/*.ts" }, { + experimental_context: ctx, + } as never)) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error).toMatch(/exit 2/); + }); +}); diff --git a/lib/agent/tools/__tests__/grepTool.test.ts b/lib/agent/tools/__tests__/grepTool.test.ts new file mode 100644 index 000000000..e3545f501 --- /dev/null +++ b/lib/agent/tools/__tests__/grepTool.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { grepTool } from "@/lib/agent/tools/grepTool"; +import { connectVercel } from "@/lib/sandbox/vercel/connect/connectVercel"; + +vi.mock("@/lib/sandbox/vercel/connect/connectVercel", () => ({ + connectVercel: vi.fn(), +})); + +const ctx = { sandbox: { state: { sandboxName: "x" }, workingDirectory: "/sandbox/mono" } }; + +function makeSandbox(exec: ReturnType) { + return { workingDirectory: "/sandbox/mono", exec }; +} + +beforeEach(() => vi.clearAllMocks()); + +describe("grepTool", () => { + it("parses `file:line:content` output into structured matches", async () => { + const sb = makeSandbox( + vi.fn().mockResolvedValue({ + success: true, + exitCode: 0, + stdout: + "/sandbox/mono/src/a.ts:5:export function login() {\n/sandbox/mono/src/a.ts:42: login();\n/sandbox/mono/src/b.ts:7:login()", + stderr: "", + truncated: false, + }), + ); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const tool = grepTool; + const result = (await tool.execute!({ pattern: "login", path: "src" }, { + experimental_context: ctx, + } as never)) as { + success: boolean; + matches: Array<{ file: string; line: number; content: string }>; + filesWithMatches: number; + }; + expect(result.success).toBe(true); + expect(result.matches).toHaveLength(3); + expect(result.matches[0]).toEqual({ + file: "src/a.ts", + line: 5, + content: "export function login() {", + }); + expect(result.filesWithMatches).toBe(2); + }); + + it("treats exit code 1 (no matches) as success:true with empty matches", async () => { + const sb = makeSandbox( + vi.fn().mockResolvedValue({ + success: false, + exitCode: 1, + stdout: "", + stderr: "", + truncated: false, + }), + ); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const tool = grepTool; + const result = (await tool.execute!({ pattern: "nothing", path: "src" }, { + experimental_context: ctx, + } as never)) as { success: boolean; matchCount: number }; + expect(result.success).toBe(true); + expect(result.matchCount).toBe(0); + }); + + it("returns success:false for real grep errors (non-1 exit)", async () => { + const sb = makeSandbox( + vi.fn().mockResolvedValue({ + success: false, + exitCode: 2, + stdout: "", + stderr: "grep: invalid regex", + truncated: false, + }), + ); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const tool = grepTool; + const result = (await tool.execute!({ pattern: "[", path: "src" }, { + experimental_context: ctx, + } as never)) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error).toMatch(/invalid regex/); + }); + + it("passes -i for caseSensitive:false", async () => { + const sb = makeSandbox( + vi.fn().mockResolvedValue({ + success: true, + exitCode: 0, + stdout: "", + stderr: "", + truncated: false, + }), + ); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const tool = grepTool; + await tool.execute!({ pattern: "x", path: ".", caseSensitive: false }, { + experimental_context: ctx, + } as never); + expect(sb.exec.mock.calls[0]?.[0]).toContain(" -i "); + }); +}); diff --git a/lib/agent/tools/__tests__/readFileTool.test.ts b/lib/agent/tools/__tests__/readFileTool.test.ts new file mode 100644 index 000000000..6d1d27fa3 --- /dev/null +++ b/lib/agent/tools/__tests__/readFileTool.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { readFileTool } from "@/lib/agent/tools/readFileTool"; +import { connectVercel } from "@/lib/sandbox/vercel/connect/connectVercel"; + +vi.mock("@/lib/sandbox/vercel/connect/connectVercel", () => ({ + connectVercel: vi.fn(), +})); + +const ctx = { + sandbox: { state: { sandboxName: "x" }, workingDirectory: "/sandbox/mono" }, +}; + +function makeSandbox(over: Record = {}) { + return { + workingDirectory: "/sandbox/mono", + stat: vi.fn(), + readFile: vi.fn(), + ...over, + }; +} + +beforeEach(() => vi.clearAllMocks()); + +describe("readFileTool", () => { + it("reads a file and returns numbered lines", async () => { + const sb = makeSandbox({ + stat: vi + .fn() + .mockResolvedValue({ isDirectory: () => false, isFile: () => true, size: 10, mtimeMs: 0 }), + readFile: vi.fn().mockResolvedValue("line one\nline two\nline three"), + }); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const tool = readFileTool; + const result = (await tool.execute!({ filePath: "README.md" }, { + experimental_context: ctx, + } as never)) as { success: boolean; content: string; totalLines: number; path: string }; + expect(result.success).toBe(true); + expect(result.totalLines).toBe(3); + expect(result.content).toBe("1: line one\n2: line two\n3: line three"); + expect(result.path).toBe("README.md"); + }); + + it("honors offset + limit (1-indexed)", async () => { + const sb = makeSandbox({ + stat: vi + .fn() + .mockResolvedValue({ isDirectory: () => false, isFile: () => true, size: 0, mtimeMs: 0 }), + readFile: vi.fn().mockResolvedValue("a\nb\nc\nd\ne"), + }); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const tool = readFileTool; + const result = (await tool.execute!({ filePath: "x.txt", offset: 2, limit: 2 }, { + experimental_context: ctx, + } as never)) as { content: string; startLine: number; endLine: number }; + expect(result.startLine).toBe(2); + // `endLine` is the last line included (1-indexed). With offset=2,limit=2 + // we read lines 2 + 3 of a 5-line file, so endLine=3. + expect(result.endLine).toBe(3); + expect(result.content).toBe("2: b\n3: c"); + }); + + it("rejects directories", async () => { + const sb = makeSandbox({ + stat: vi + .fn() + .mockResolvedValue({ isDirectory: () => true, isFile: () => false, size: 0, mtimeMs: 0 }), + }); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const tool = readFileTool; + const result = (await tool.execute!({ filePath: "src" }, { + experimental_context: ctx, + } as never)) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error).toMatch(/directory/i); + }); + + it("returns success:false with an error string on stat/readFile failure", async () => { + const sb = makeSandbox({ + stat: vi.fn().mockRejectedValue(new Error("not found")), + }); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const tool = readFileTool; + const result = (await tool.execute!({ filePath: "missing.ts" }, { + experimental_context: ctx, + } as never)) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error).toMatch(/not found/); + }); +}); diff --git a/lib/agent/tools/__tests__/shellEscape.test.ts b/lib/agent/tools/__tests__/shellEscape.test.ts new file mode 100644 index 000000000..699605129 --- /dev/null +++ b/lib/agent/tools/__tests__/shellEscape.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { shellEscape } from "@/lib/agent/tools/shellEscape"; + +describe("shellEscape", () => { + it("wraps a plain string in single quotes", () => { + expect(shellEscape("hello")).toBe("'hello'"); + }); + + it("escapes embedded single quotes via the standard ' → '\\'' dance", () => { + expect(shellEscape("it's")).toBe("'it'\\''s'"); + }); + + it("handles strings with shell metacharacters unchanged inside single quotes", () => { + expect(shellEscape("$VAR `cmd` && rm -rf /")).toBe("'$VAR `cmd` && rm -rf /'"); + }); + + it("returns just '' for the empty string", () => { + expect(shellEscape("")).toBe("''"); + }); +}); diff --git a/lib/agent/tools/__tests__/toDisplayPath.test.ts b/lib/agent/tools/__tests__/toDisplayPath.test.ts new file mode 100644 index 000000000..e862f7276 --- /dev/null +++ b/lib/agent/tools/__tests__/toDisplayPath.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { toDisplayPath } from "@/lib/agent/tools/toDisplayPath"; + +const WORKDIR = "/sandbox/mono"; + +describe("toDisplayPath", () => { + it("strips the workingDirectory prefix when the file is inside", () => { + expect(toDisplayPath("/sandbox/mono/src/index.ts", WORKDIR)).toBe("src/index.ts"); + }); + + it("returns `.` for the workingDirectory itself", () => { + expect(toDisplayPath("/sandbox/mono", WORKDIR)).toBe("."); + }); + + it("keeps an absolute path when it's outside the working directory", () => { + expect(toDisplayPath("/etc/hosts", WORKDIR)).toBe("/etc/hosts"); + }); + + it("resolves a relative input against the working directory", () => { + expect(toDisplayPath("apps/web/page.tsx", WORKDIR)).toBe("apps/web/page.tsx"); + }); + + it("normalizes back-slashes to forward slashes (Windows-style absolute input)", () => { + // path.resolve on POSIX leaves backslashes inside the segment; the + // helper should still emit forward slashes for paths it keeps absolute. + const result = toDisplayPath("/tmp/win\\path", WORKDIR); + expect(result.includes("\\")).toBe(false); + }); +}); diff --git a/lib/agent/tools/__tests__/todoWriteTool.test.ts b/lib/agent/tools/__tests__/todoWriteTool.test.ts new file mode 100644 index 000000000..7b5d88c9e --- /dev/null +++ b/lib/agent/tools/__tests__/todoWriteTool.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { todoWriteTool } from "@/lib/agent/tools/todoWriteTool"; + +describe("todoWriteTool", () => { + it("echoes the todos back with a count message", async () => { + const todos = [ + { id: "1", content: "ls the workspace", status: "in_progress" as const }, + { id: "2", content: "summarize what we found", status: "pending" as const }, + ]; + const result = (await todoWriteTool.execute!({ todos }, {} as never)) as { + success: boolean; + message: string; + todos: typeof todos; + }; + expect(result.success).toBe(true); + expect(result.message).toBe("Updated task list with 2 items"); + expect(result.todos).toEqual(todos); + }); + + it("accepts an empty list", async () => { + const result = (await todoWriteTool.execute!({ todos: [] }, {} as never)) as { + success: boolean; + message: string; + }; + expect(result.success).toBe(true); + expect(result.message).toBe("Updated task list with 0 items"); + }); +}); diff --git a/lib/agent/tools/__tests__/webFetchTool.test.ts b/lib/agent/tools/__tests__/webFetchTool.test.ts new file mode 100644 index 000000000..47fb75c92 --- /dev/null +++ b/lib/agent/tools/__tests__/webFetchTool.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { webFetchTool } from "@/lib/agent/tools/webFetchTool"; +import { connectVercel } from "@/lib/sandbox/vercel/connect/connectVercel"; + +vi.mock("@/lib/sandbox/vercel/connect/connectVercel", () => ({ + connectVercel: vi.fn(), +})); + +const ctx = { sandbox: { state: { sandboxName: "x" }, workingDirectory: "/sandbox/mono" } }; + +function makeSandbox(exec: ReturnType) { + return { workingDirectory: "/sandbox/mono", exec }; +} + +beforeEach(() => vi.clearAllMocks()); + +describe("webFetchTool", () => { + it("parses body + trailing status code on success", async () => { + // Body, then newline, then status code "200" (per the curl -w '%{http_code}' contract). + const sb = makeSandbox( + vi.fn().mockResolvedValue({ + success: true, + exitCode: 0, + stdout: '{"ok":true}\n200', + stderr: "", + truncated: false, + }), + ); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const result = (await webFetchTool.execute!({ url: "https://example.com/api" }, { + experimental_context: ctx, + } as never)) as { success: boolean; status: number; body: string; truncated: boolean }; + expect(result).toEqual({ + success: true, + status: 200, + body: '{"ok":true}', + truncated: false, + }); + }); + + it("marks truncated:true on curl exit 23 (head -c cut off the body)", async () => { + const sb = makeSandbox( + vi.fn().mockResolvedValue({ + success: false, + exitCode: 23, + stdout: "huge body fragment\n200", + stderr: "", + truncated: false, + }), + ); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const result = (await webFetchTool.execute!({ url: "https://example.com/huge" }, { + experimental_context: ctx, + } as never)) as { success: boolean; truncated: boolean }; + expect(result.success).toBe(true); + expect(result.truncated).toBe(true); + }); + + it("returns success:false on non-0, non-23 curl exit", async () => { + const sb = makeSandbox( + vi.fn().mockResolvedValue({ + success: false, + exitCode: 7, + stdout: "", + stderr: "Failed to connect", + truncated: false, + }), + ); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const result = (await webFetchTool.execute!({ url: "https://example.com/unreachable" }, { + experimental_context: ctx, + } as never)) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error).toMatch(/Failed to connect/); + }); + + it("passes the request body for POST", async () => { + const sb = makeSandbox( + vi.fn().mockResolvedValue({ + success: true, + exitCode: 0, + stdout: "ok\n201", + stderr: "", + truncated: false, + }), + ); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + await webFetchTool.execute!( + { url: "https://example.com/api", method: "POST", body: '{"x":1}' }, + { experimental_context: ctx } as never, + ); + const cmd = sb.exec.mock.calls[0]?.[0] as string; + expect(cmd).toContain("-X POST"); + expect(cmd).toContain("-d '{\"x\":1}'"); + }); +}); diff --git a/lib/agent/tools/__tests__/writeFileTool.test.ts b/lib/agent/tools/__tests__/writeFileTool.test.ts new file mode 100644 index 000000000..3656a777c --- /dev/null +++ b/lib/agent/tools/__tests__/writeFileTool.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { writeFileTool } from "@/lib/agent/tools/writeFileTool"; +import { connectVercel } from "@/lib/sandbox/vercel/connect/connectVercel"; + +vi.mock("@/lib/sandbox/vercel/connect/connectVercel", () => ({ + connectVercel: vi.fn(), +})); + +const ctx = { sandbox: { state: { sandboxName: "x" }, workingDirectory: "/sandbox/mono" } }; + +function makeSandbox(over: Record = {}) { + return { + workingDirectory: "/sandbox/mono", + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + stat: vi + .fn() + .mockResolvedValue({ size: 42, mtimeMs: 0, isDirectory: () => false, isFile: () => true }), + ...over, + }; +} + +beforeEach(() => vi.clearAllMocks()); + +describe("writeFileTool", () => { + it("creates parent dirs and writes content via sandbox.writeFile", async () => { + const sb = makeSandbox(); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const tool = writeFileTool; + const result = (await tool.execute!({ filePath: "src/index.ts", content: "export {}" }, { + experimental_context: ctx, + } as never)) as { success: boolean; path: string; bytesWritten: number }; + expect(result.success).toBe(true); + expect(result.path).toBe("src/index.ts"); + expect(result.bytesWritten).toBe(42); + expect(sb.mkdir).toHaveBeenCalledWith("/sandbox/mono/src", { recursive: true }); + expect(sb.writeFile).toHaveBeenCalledWith("/sandbox/mono/src/index.ts", "export {}", "utf-8"); + }); + + it("returns success:false on sandbox failure", async () => { + const sb = makeSandbox({ + writeFile: vi.fn().mockRejectedValue(new Error("EACCES")), + }); + vi.mocked(connectVercel).mockResolvedValue(sb as never); + const tool = writeFileTool; + const result = (await tool.execute!({ filePath: "a.ts", content: "x" }, { + experimental_context: ctx, + } as never)) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error).toMatch(/EACCES/); + }); +}); diff --git a/lib/agent/tools/bashTool.ts b/lib/agent/tools/bashTool.ts index 908113812..479a608db 100644 --- a/lib/agent/tools/bashTool.ts +++ b/lib/agent/tools/bashTool.ts @@ -21,9 +21,9 @@ const bashInputSchema = z.object({ }); /** - * Factory for the `bash` sandbox tool. Runs `bash -c ""` inside - * the agent's sandbox via `sandbox.exec`, defaulting cwd to the sandbox's - * working directory. + * `bash` sandbox tool. Runs `bash -c ""` inside the agent's + * sandbox via `sandbox.exec`, defaulting cwd to the sandbox's working + * directory. * * Approval gating is intentionally absent — model-issued commands are * trusted in this PR. Add a host-side gate at the route/UI layer if that @@ -34,9 +34,8 @@ const bashInputSchema = z.object({ * the right org. Detached execs deliberately skip env injection — those * processes outlive the prompt. */ -export const bashTool = () => - tool({ - description: `Execute a bash command in the user's shell (non-interactive). +export const bashTool = tool({ + description: `Execute a bash command in the user's shell (non-interactive). WHEN TO USE: - Running existing project commands (build, test, lint, typecheck) @@ -61,56 +60,56 @@ IMPORTANT: - Never use interactive commands (vim, nano, top, bash, ssh, etc.) - Always quote file paths that may contain spaces - Use detached: true to start dev servers / long-running processes in the background`, - inputSchema: bashInputSchema, - execute: async ({ command, cwd, detached }, { experimental_context, abortSignal }) => { - const sandbox = await getSandbox(experimental_context, "bash"); - const workingDirectory = sandbox.workingDirectory; - const workingDir = cwd - ? path.isAbsolute(cwd) - ? cwd - : path.resolve(workingDirectory, cwd) - : workingDirectory; + inputSchema: bashInputSchema, + execute: async ({ command, cwd, detached }, { experimental_context, abortSignal }) => { + const sandbox = await getSandbox(experimental_context, "bash"); + const workingDirectory = sandbox.workingDirectory; + const workingDir = cwd + ? path.isAbsolute(cwd) + ? cwd + : path.resolve(workingDirectory, cwd) + : workingDirectory; - if (detached) { - if (!sandbox.execDetached) { - return { - success: false, - exitCode: null, - stdout: "", - stderr: - "Detached mode is not supported in this sandbox environment. Only cloud sandboxes support background processes.", - }; - } - try { - const { commandId } = await sandbox.execDetached(command, workingDir); - return { - success: true, - exitCode: null, - stdout: `Process started in background (command ID: ${commandId}). The server is now running.`, - stderr: "", - }; - } catch (error) { - return { - success: false, - exitCode: null, - stdout: "", - stderr: error instanceof Error ? error.message : String(error), - }; - } + if (detached) { + if (!sandbox.execDetached) { + return { + success: false, + exitCode: null, + stdout: "", + stderr: + "Detached mode is not supported in this sandbox environment. Only cloud sandboxes support background processes.", + }; } + try { + const { commandId } = await sandbox.execDetached(command, workingDir); + return { + success: true, + exitCode: null, + stdout: `Process started in background (command ID: ${commandId}). The server is now running.`, + stderr: "", + }; + } catch (error) { + return { + success: false, + exitCode: null, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + }; + } + } - const recoupEnv = buildRecoupExecEnv(experimental_context); - const result = await sandbox.exec(command, workingDir, TIMEOUT_MS, { - signal: abortSignal, - ...(recoupEnv ? { env: recoupEnv } : {}), - }); + const recoupEnv = buildRecoupExecEnv(experimental_context); + const result = await sandbox.exec(command, workingDir, TIMEOUT_MS, { + signal: abortSignal, + ...(recoupEnv ? { env: recoupEnv } : {}), + }); - return { - success: result.success, - exitCode: result.exitCode, - stdout: result.stdout, - stderr: result.stderr, - ...(result.truncated && { truncated: true }), - }; - }, - }); + return { + success: result.success, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + ...(result.truncated && { truncated: true }), + }; + }, +}); diff --git a/lib/agent/tools/editFileTool.ts b/lib/agent/tools/editFileTool.ts new file mode 100644 index 000000000..d8274c0bc --- /dev/null +++ b/lib/agent/tools/editFileTool.ts @@ -0,0 +1,100 @@ +import { tool } from "ai"; +import { z } from "zod"; +import * as path from "path"; +import { getSandbox } from "@/lib/agent/tools/getSandbox"; +import { toDisplayPath } from "@/lib/agent/tools/toDisplayPath"; + +const editInputSchema = z.object({ + filePath: z.string().describe("Workspace-relative path to the file to edit (e.g., src/auth.ts)"), + oldString: z.string().describe("The exact text to replace"), + newString: z.string().describe("The text to replace it with (must differ from oldString)"), + replaceAll: z.boolean().optional().describe("Replace all occurrences. Default: false"), + startLine: z + .number() + .optional() + .describe("Line number where oldString starts (for diff display)"), +}); + +/** + * `edit` — exact-string replacement inside a sandboxed file. Requires the + * model to have already read the file so it can produce a unique + * `oldString`. Rejects ambiguous matches unless `replaceAll` is set. + */ +export const editFileTool = tool({ + description: `Perform exact string replacement in a file. + +WHEN TO USE: +- Making small, precise edits to an existing file you have already read +- Renaming a variable or identifier consistently within a single file +- Changing a specific block of code or configuration exactly as seen in the read output + +WHEN NOT TO USE: +- Creating new files (use writeFileTool instead) +- Large structural rewrites where it's simpler to rewrite the entire file (use writeFileTool) + +USAGE: +- Use workspace-relative file paths (e.g., "src/auth.ts") +- You must read the file first with readFileTool in this conversation +- Provide oldString as the EXACT text to replace, including whitespace and indentation +- By default, oldString must be UNIQUE in the file; otherwise the edit will fail +- Use replaceAll: true to change ALL occurrences (e.g., for a rename) +- ALWAYS provide startLine when known: the line number where oldString begins + +IMPORTANT: +- Preserve exact indentation and spacing from the file's content as returned by readFileTool +- Never include line numbers or the "N: " line prefixes from the read output in oldString or newString +- If oldString appears multiple times and replaceAll is false, the tool FAILS with an error and occurrence count`, + inputSchema: editInputSchema, + execute: async ( + { filePath, oldString, newString, replaceAll = false }, + { experimental_context }, + ) => { + const sandbox = await getSandbox(experimental_context, "edit"); + const workingDirectory = sandbox.workingDirectory; + + try { + if (oldString === newString) { + return { success: false, error: "oldString and newString must be different" }; + } + + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.resolve(workingDirectory, filePath); + const content = await sandbox.readFile(absolutePath, "utf-8"); + + if (!content.includes(oldString)) { + return { + success: false, + error: "oldString not found in file", + hint: "Make sure to match exact whitespace and indentation", + }; + } + + const occurrences = content.split(oldString).length - 1; + if (occurrences > 1 && !replaceAll) { + return { + success: false, + error: `oldString found ${occurrences} times. Use replaceAll=true or provide more context to make it unique.`, + }; + } + + const matchIndex = content.indexOf(oldString); + const startLine = content.slice(0, matchIndex).split("\n").length; + const newContent = replaceAll + ? content.replaceAll(oldString, newString) + : content.replace(oldString, newString); + + await sandbox.writeFile(absolutePath, newContent, "utf-8"); + + return { + success: true, + path: toDisplayPath(absolutePath, workingDirectory), + replacements: replaceAll ? occurrences : 1, + startLine, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: `Failed to edit file: ${message}` }; + } + }, +}); diff --git a/lib/agent/tools/globTool.ts b/lib/agent/tools/globTool.ts new file mode 100644 index 000000000..d1de234d2 --- /dev/null +++ b/lib/agent/tools/globTool.ts @@ -0,0 +1,165 @@ +import { tool } from "ai"; +import { z } from "zod"; +import * as path from "path"; +import { getSandbox } from "@/lib/agent/tools/getSandbox"; +import { shellEscape } from "@/lib/agent/tools/shellEscape"; +import { toDisplayPath } from "@/lib/agent/tools/toDisplayPath"; + +interface FileInfo { + path: string; + size: number; + modifiedAt: number; +} + +const globInputSchema = z.object({ + pattern: z.string().describe("Glob pattern to match (e.g., '**/*.ts')"), + path: z + .string() + .optional() + .describe("Workspace-relative base directory to search from (e.g., src)"), + limit: z.number().optional().describe("Maximum number of results. Default: 100"), +}); + +const GLOB_TIMEOUT_MS = 30_000; +const DEFAULT_LIMIT = 100; + +/** + * `glob` — find files matching a glob pattern, sorted by mtime (newest + * first). Skips hidden files and `node_modules`. Uses `find -printf` on + * GNU find (Linux sandboxes), falling back to `xargs stat` on BSD find. + */ +export const globTool = tool({ + description: `Find files matching a glob pattern. + +WHEN TO USE: +- Locating files by extension or naming pattern (e.g., all *.test.ts files) +- Discovering where components, migrations, or configs live +- Getting a quick list of recently modified files of a given type + +WHEN NOT TO USE: +- Searching inside file contents (use grepTool instead) +- Reading file contents (use readFileTool instead) + +USAGE: +- Supports patterns like "**/*.ts", "src/**/*.js", "*.json" +- Returns FILES (not directories) sorted by modification time (newest first) +- Skips hidden files (names starting with ".") and node_modules +- If path is omitted, the current working directory is used as the base +- Use workspace-relative paths when setting path +- Results are limited by the limit parameter (default: 100) + +IMPORTANT: +- Patterns are matched primarily on the final path segment (file name), with basic "*" and "**" support +- Use this to narrow down candidate files before calling readFileTool or grepTool`, + inputSchema: globInputSchema, + execute: async ( + { pattern, path: basePath, limit = DEFAULT_LIMIT }, + { experimental_context, abortSignal }, + ) => { + const sandbox = await getSandbox(experimental_context, "glob"); + const workingDirectory = sandbox.workingDirectory; + + try { + let searchDir: string; + if (basePath) { + searchDir = path.isAbsolute(basePath) ? basePath : path.resolve(workingDirectory, basePath); + } else { + searchDir = workingDirectory; + } + + // Extract file-name pattern (last segment) + literal directory prefix + // (segments before any wildcards) so we can constrain `find -maxdepth`. + const patternParts = pattern.split("/").filter(Boolean); + const namePattern = patternParts[patternParts.length - 1] ?? "*"; + const literalPrefix: string[] = []; + for (let i = 0; i < patternParts.length - 1; i++) { + const part = patternParts[i]!; + if (part.includes("*") || part.includes("?") || part.includes("[")) break; + literalPrefix.push(part); + } + if (literalPrefix.length > 0) { + searchDir = path.join(searchDir, ...literalPrefix); + } + + const remainingDirSegments = patternParts.slice( + literalPrefix.length, + patternParts.length - 1, + ); + const hasRecursiveWildcard = + remainingDirSegments.some(s => s === "**") || namePattern === "**"; + + let maxDepth: number | undefined; + if (!hasRecursiveWildcard) { + maxDepth = remainingDirSegments.length + 1; + } + + const findArgs: string[] = ["find", shellEscape(searchDir)]; + if (maxDepth !== undefined) findArgs.push("-maxdepth", String(maxDepth)); + findArgs.push( + "-not", + "-path", + "'*/.*'", + "-not", + "-path", + "'*/node_modules/*'", + "-type", + "f", + "-name", + shellEscape(namePattern), + ); + + // GNU `find -printf` (Linux) vs BSD `find` (macOS) compatibility. + const findBase = findArgs.join(" "); + const command = [ + `{ ${findBase} -printf '%T@\\t%s\\t%p\\n' 2>/dev/null`, + `|| ${findBase} -print0 | xargs -0 stat -f '%m%t%z%t%N' ; }`, + `| sort -t$'\\t' -k1 -rn | head -n ${limit}`, + ].join(" "); + + const result = await sandbox.exec(command, workingDirectory, GLOB_TIMEOUT_MS, { + signal: abortSignal, + }); + + // find may exit 1 on permission errors but still produce valid output. + if (!result.success && result.exitCode !== 1) { + return { + success: false, + error: `Glob failed (exit ${result.exitCode}): ${result.stdout.slice(0, 500)}`, + }; + } + + const files: FileInfo[] = []; + const lines = result.stdout.split("\n").filter(Boolean); + for (const line of lines) { + const firstTab = line.indexOf("\t"); + if (firstTab === -1) continue; + const secondTab = line.indexOf("\t", firstTab + 1); + if (secondTab === -1) continue; + const mtimeSeconds = parseFloat(line.slice(0, firstTab)); + const size = parseInt(line.slice(firstTab + 1, secondTab), 10); + const filePath = line.slice(secondTab + 1); + if (isNaN(mtimeSeconds) || isNaN(size) || !filePath) continue; + files.push({ + path: toDisplayPath(filePath, workingDirectory), + size, + modifiedAt: mtimeSeconds * 1000, + }); + } + + return { + success: true, + pattern, + baseDir: toDisplayPath(searchDir, workingDirectory), + count: files.length, + files: files.map(f => ({ + path: f.path, + size: f.size, + modifiedAt: new Date(f.modifiedAt).toISOString(), + })), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: `Glob failed: ${message}` }; + } + }, +}); diff --git a/lib/agent/tools/grepTool.ts b/lib/agent/tools/grepTool.ts new file mode 100644 index 000000000..f172f61af --- /dev/null +++ b/lib/agent/tools/grepTool.ts @@ -0,0 +1,143 @@ +import { tool } from "ai"; +import { z } from "zod"; +import * as path from "path"; +import { getSandbox } from "@/lib/agent/tools/getSandbox"; +import { shellEscape } from "@/lib/agent/tools/shellEscape"; +import { toDisplayPath } from "@/lib/agent/tools/toDisplayPath"; + +interface GrepMatch { + file: string; + line: number; + content: string; +} + +const grepInputSchema = z.object({ + pattern: z.string().describe("Regex pattern to search for"), + path: z.string().describe("Workspace-relative file or directory to search in (e.g., src)"), + glob: z.string().optional().describe("Glob pattern to filter files (e.g., '*.ts')"), + caseSensitive: z.boolean().optional().describe("Case-sensitive search. Default: true"), +}); + +const GREP_TIMEOUT_MS = 30_000; +const MAX_TOTAL_MATCHES = 100; +const MAX_PER_FILE_MATCHES = 10; +const MAX_LINE_LENGTH = 200; + +/** + * `grep` — search for POSIX-ERE patterns across files in the sandbox via + * `grep -rn`. Caps results to 100 total / 10 per file / 200 chars per + * match line so long stdouts don't blow the model context. + */ +export const grepTool = tool({ + description: `Search for patterns in files using POSIX Extended Regular Expressions (ERE). + +WHEN TO USE: +- Finding where a function, variable, or string literal is used +- Locating configuration keys, routes, or error messages across files +- Narrowing down which files to read or edit + +WHEN NOT TO USE: +- Simple filename-only searches (use globTool instead) +- Directory listings, builds, or other shell tasks (use bashTool instead) + +USAGE: +- Uses POSIX ERE syntax (e.g., "log.*Error", "function[[:space:]]+[a-zA-Z_]+") +- Perl-style shorthands like \\s, \\w, \\d are NOT supported; use POSIX classes instead: [[:space:]], [[:alnum:]_], [[:digit:]] +- Search a specific file OR an entire directory via the path parameter +- Use workspace-relative paths for path (e.g., "src") +- Optionally filter files with glob (e.g., "*.ts", "*.test.js") +- Matches are SINGLE-LINE: patterns do not span across newline characters +- Results are limited to 100 matches total, with up to 10 matches per file; each match line is truncated to 200 characters + +IMPORTANT: +- ALWAYS use this tool for code/content searches instead of running grep/rg via bashTool +- Use caseSensitive: false for case-insensitive searches +- Hidden files and node_modules are skipped when searching directories`, + inputSchema: grepInputSchema, + execute: async ( + { pattern, path: searchPath, glob, caseSensitive = true }, + { experimental_context, abortSignal }, + ) => { + const sandbox = await getSandbox(experimental_context, "grep"); + const workingDirectory = sandbox.workingDirectory; + + try { + const absolutePath = path.isAbsolute(searchPath) + ? searchPath + : path.resolve(workingDirectory, searchPath); + + const args: string[] = ["grep", "-rn"]; + if (!caseSensitive) args.push("-i"); + args.push( + `--exclude-dir=${shellEscape(".*")}`, + `--exclude-dir=${shellEscape("node_modules")}`, + ); + if (glob) args.push(`--include=${shellEscape(glob)}`); + args.push( + "-m", + String(MAX_PER_FILE_MATCHES), + "-E", + shellEscape(pattern), + shellEscape(absolutePath), + ); + const command = args.join(" "); + + const result = await sandbox.exec(command, workingDirectory, GREP_TIMEOUT_MS, { + signal: abortSignal, + }); + + // grep exits with 1 when no matches found — that's not an error. + if (!result.success && result.exitCode !== 1) { + const errorOutput = (result.stderr || result.stdout).slice(0, 500); + return { + success: false, + error: `Grep failed (exit ${result.exitCode}): ${errorOutput}`, + }; + } + + const matches: GrepMatch[] = []; + const filesSet = new Set(); + const fileMatchCounts = new Map(); + + const lines = result.stdout.split("\n").filter(Boolean); + for (const line of lines) { + if (matches.length >= MAX_TOTAL_MATCHES) break; + + // grep -rn output: file:line:content. Find the `:digits:` separator. + const match = line.match(/:(\d+):/); + if (!match || match.index === undefined) continue; + const file = line.slice(0, match.index); + const rest = line.slice(match.index + 1); + const colonIndex = rest.indexOf(":"); + if (colonIndex === -1) continue; + + const lineNum = parseInt(rest.slice(0, colonIndex), 10); + const content = rest.slice(colonIndex + 1); + if (isNaN(lineNum)) continue; + + const displayFile = toDisplayPath(file, workingDirectory); + filesSet.add(displayFile); + const currentFileCount = fileMatchCounts.get(displayFile) ?? 0; + if (currentFileCount >= MAX_PER_FILE_MATCHES) continue; + + fileMatchCounts.set(displayFile, currentFileCount + 1); + matches.push({ + file: displayFile, + line: lineNum, + content: content.slice(0, MAX_LINE_LENGTH), + }); + } + + return { + success: true, + pattern, + matchCount: matches.length, + filesWithMatches: filesSet.size, + matches, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: `Grep failed: ${message}` }; + } + }, +}); diff --git a/lib/agent/tools/readFileTool.ts b/lib/agent/tools/readFileTool.ts new file mode 100644 index 000000000..f5a486a64 --- /dev/null +++ b/lib/agent/tools/readFileTool.ts @@ -0,0 +1,70 @@ +import { tool } from "ai"; +import { z } from "zod"; +import * as path from "path"; +import { getSandbox } from "@/lib/agent/tools/getSandbox"; +import { toDisplayPath } from "@/lib/agent/tools/toDisplayPath"; + +const readInputSchema = z.object({ + filePath: z.string().describe("Workspace-relative path to the file to read (e.g., src/index.ts)"), + offset: z.number().optional().describe("Line number to start reading from (1-indexed)"), + limit: z.number().optional().describe("Maximum number of lines to read. Default: 2000"), +}); + +/** + * `read` — read a file from the sandbox. Returns numbered lines in the + * format `N: ` so the model can refer to specific lines when + * later editing. + */ +export const readFileTool = tool({ + description: `Read a file from the filesystem. + +USAGE: +- Use workspace-relative paths (e.g., "src/index.ts") +- Paths are resolved from the workspace root +- By default reads up to 2000 lines starting from line 1 +- Use offset and limit for long files (both are line-based, 1-indexed) +- Results include line numbers starting at 1 in "N: content" format + +IMPORTANT: +- Always read a file at least once before editing it with the edit/write tools +- This tool can only read files, not directories — attempting to read a directory returns an error +- You can call multiple reads in parallel to speculatively load several files`, + inputSchema: readInputSchema, + execute: async ({ filePath, offset = 1, limit = 2000 }, { experimental_context }) => { + const sandbox = await getSandbox(experimental_context, "read"); + const workingDirectory = sandbox.workingDirectory; + + try { + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.resolve(workingDirectory, filePath); + + const stats = await sandbox.stat(absolutePath); + if (stats.isDirectory()) { + return { + success: false, + error: "Cannot read a directory. Use glob or ls command instead.", + }; + } + + const content = await sandbox.readFile(absolutePath, "utf-8"); + const lines = content.split("\n"); + const startLine = Math.max(1, offset) - 1; + const endLine = Math.min(lines.length, startLine + limit); + const selectedLines = lines.slice(startLine, endLine); + const numberedLines = selectedLines.map((line, i) => `${startLine + i + 1}: ${line}`); + + return { + success: true, + path: toDisplayPath(absolutePath, workingDirectory), + totalLines: lines.length, + startLine: startLine + 1, + endLine, + content: numberedLines.join("\n"), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: `Failed to read file: ${message}` }; + } + }, +}); diff --git a/lib/agent/tools/shellEscape.ts b/lib/agent/tools/shellEscape.ts new file mode 100644 index 000000000..8ba4a71a3 --- /dev/null +++ b/lib/agent/tools/shellEscape.ts @@ -0,0 +1,14 @@ +/** + * Escape a string for safe use as a single-quoted shell argument. + * + * Wraps the string in single quotes and escapes any embedded single + * quotes via the standard `' → '\''` dance (close quote, escape literal + * quote, reopen quote). Everything else stays verbatim inside single + * quotes — shell metacharacters like `$`, `` ` ``, `&`, `*` are NOT + * expanded so the result is safe to pass to `bash -c` or `sh -c`. + * + * @param s - The string to escape. + */ +export function shellEscape(s: string): string { + return "'" + s.replace(/'/g, "'\\''") + "'"; +} diff --git a/lib/agent/tools/toDisplayPath.ts b/lib/agent/tools/toDisplayPath.ts new file mode 100644 index 000000000..827c391af --- /dev/null +++ b/lib/agent/tools/toDisplayPath.ts @@ -0,0 +1,34 @@ +import * as path from "path"; + +function isPathWithinDirectory(filePath: string, directory: string): boolean { + const resolvedPath = path.resolve(filePath); + const resolvedDir = path.resolve(directory); + return resolvedPath.startsWith(resolvedDir + path.sep) || resolvedPath === resolvedDir; +} + +/** + * Convert an absolute (or relative-to-workingDirectory) path into a compact + * model-friendly display path. + * + * Paths inside the working directory are returned relative (e.g. + * `src/index.ts`) to avoid repeating long absolute prefixes in tool output. + * Paths outside the working directory remain absolute for clarity and safety + * (e.g. `/etc/hosts`). All separators are normalized to `/`. + * + * @param filePath - Absolute or workspace-relative file path. + * @param workingDirectory - The sandbox's working directory (always absolute). + */ +export function toDisplayPath(filePath: string, workingDirectory: string): string { + const absolutePath = path.isAbsolute(filePath) + ? path.resolve(filePath) + : path.resolve(workingDirectory, filePath); + + if (!isPathWithinDirectory(absolutePath, workingDirectory)) { + return absolutePath.replace(/\\/g, "/"); + } + + const relativePath = path.relative(workingDirectory, absolutePath); + if (relativePath === "") return "."; + + return relativePath.replace(/\\/g, "/"); +} diff --git a/lib/agent/tools/todoWriteTool.ts b/lib/agent/tools/todoWriteTool.ts new file mode 100644 index 000000000..d91e9147a --- /dev/null +++ b/lib/agent/tools/todoWriteTool.ts @@ -0,0 +1,65 @@ +import { tool } from "ai"; +import { z } from "zod"; + +export const todoStatusSchema = z.enum(["pending", "in_progress", "completed"]); +export type TodoStatus = z.infer; + +export const todoItemSchema = z.object({ + id: z.string().describe("Unique identifier for the todo item"), + content: z.string().describe("The task description"), + status: todoStatusSchema.describe( + "Current status. Only ONE task should be in_progress at a time.", + ), +}); +export type TodoItem = z.infer; + +/** + * `todo_write` — the agent's planning surface. Stateless on the server side + * (the tool simply echoes the list back to the chat UI so the user sees the + * current plan). The agent uses this to track multi-step work and signal + * intent between turns. + * + * Slot into `buildAgentTools` as `todo_write: todoWriteTool`. + */ +export const todoWriteTool = tool({ + description: `Create and manage a structured task list for the current session. + +WHEN TO USE: +- Complex multi-step tasks requiring 3 or more distinct steps +- When the user provides multiple requirements or a checklist +- After receiving new instructions - immediately capture them as todos +- When starting work on a task - mark that todo as in_progress BEFORE beginning +- After completing a task - mark it as completed immediately + +WHEN NOT TO USE: +- A single, straightforward task that can be done in one step +- Trivial tasks requiring fewer than 3 minor steps +- Purely conversational or informational queries + +TASK STATES: +- "pending": Task not yet started +- "in_progress": Currently being worked on (ONLY ONE todo should be in this state at a time) +- "completed": Task finished successfully + +USAGE: +- This tool REPLACES the entire todo list - always send the full, updated list of todos +- Use it frequently to keep the task list in sync with your actual progress +- Update statuses as you start and finish work, rather than batching updates later + +IMPORTANT: +- Only one todo should be in_progress at a time; avoid parallel in_progress tasks +- Mark todos as completed as soon as they are done - do not wait to batch completions +- Use clear, concise todo content so the list remains readable to the user`, + inputSchema: z.object({ + todos: z + .array(todoItemSchema) + .describe("The complete list of todo items. This replaces existing todos."), + }), + execute: async ({ todos }) => { + return { + success: true, + message: `Updated task list with ${todos.length} items`, + todos, + }; + }, +}); diff --git a/lib/agent/tools/webFetchTool.ts b/lib/agent/tools/webFetchTool.ts new file mode 100644 index 000000000..b395457f9 --- /dev/null +++ b/lib/agent/tools/webFetchTool.ts @@ -0,0 +1,124 @@ +import { tool } from "ai"; +import { z } from "zod"; +import { buildRecoupExecEnv } from "@/lib/agent/tools/buildRecoupExecEnv"; +import { getSandbox } from "@/lib/agent/tools/getSandbox"; +import { shellEscape } from "@/lib/agent/tools/shellEscape"; + +const FETCH_TIMEOUT_MS = 30_000; +export const MAX_BODY_LENGTH = 10_000; + +const fetchInputSchema = z.object({ + url: z.string().url().describe("The URL to fetch"), + method: z + .enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]) + .optional() + .describe("HTTP method. Default: GET"), + headers: z + .record(z.string(), z.string()) + .optional() + .describe("Optional HTTP headers as key-value pairs"), + body: z.string().optional().describe("Optional request body (for POST/PUT/PATCH)"), +}); + +const fetchOutputSchema = z.union([ + z.object({ + success: z.literal(true), + status: z.number().int().nullable(), + body: z.string(), + truncated: z.boolean(), + }), + z.object({ success: z.literal(false), error: z.string() }), +]); + +/** + * `web_fetch` — make an HTTP request from inside the sandbox via curl. + * Lives in the sandbox (not on the worker) so requests come from the + * sandbox's network egress, can reuse its env, and don't bypass any + * sandbox-level policies. Truncates response bodies to 10KB to protect + * model context. + */ +export const webFetchTool = tool({ + description: `Fetch a URL from the web. + +USAGE: +- Make HTTP requests to external URLs +- Supports GET, POST, PUT, PATCH, DELETE, and HEAD methods +- Returns the response status and body text +- Body is truncated to ${MAX_BODY_LENGTH} characters to avoid overwhelming context`, + inputSchema: fetchInputSchema, + outputSchema: fetchOutputSchema, + execute: async ( + { url, method = "GET", headers, body }, + { experimental_context, abortSignal }, + ) => { + const sandbox = await getSandbox(experimental_context, "web_fetch"); + const workingDirectory = sandbox.workingDirectory; + const recoupEnv = buildRecoupExecEnv(experimental_context); + + const args: string[] = [ + "curl", + "-sS", + "-X", + method, + "--max-time", + String(Math.ceil(FETCH_TIMEOUT_MS / 1000)), + "-o", + `>(head -c ${MAX_BODY_LENGTH} >&3)`, + "-w", + shellEscape("%{http_code}"), + ]; + + if (headers) { + for (const [key, value] of Object.entries(headers)) { + args.push("-H", shellEscape(`${key}: ${value}`)); + } + } + if (method !== "GET" && method !== "HEAD" && body) { + args.push("-d", shellEscape(body)); + } + args.push(shellEscape(url)); + + // Use fd 3 to split curl's response body (truncated by `head -c`) from + // the status code written via `-w`. The body goes to stdout via fd 3 + // → fd 1, then we append the status code on its own newline. + const command = [ + "exec 3>&1", + `status=$(${args.join(" ")})`, + "curlExit=$?", + "exec 3>&-", + "printf '\\n%s' \"$status\"", + "exit $curlExit", + ].join("\n"); + + try { + const result = await sandbox.exec(command, workingDirectory, FETCH_TIMEOUT_MS, { + signal: abortSignal, + ...(recoupEnv ? { env: recoupEnv } : {}), + }); + + // exit 23 = curl wrote partial output (`head -c` cut it off — expected for large responses). + if (result.exitCode !== 0 && result.exitCode !== 23) { + return { + success: false, + error: `Fetch failed: ${result.stderr || result.stdout || "Unknown error"}`, + }; + } + + const output = result.stdout ?? ""; + const lastNewline = output.lastIndexOf("\n"); + const statusText = lastNewline !== -1 ? output.slice(lastNewline + 1).trim() : ""; + const responseBody = lastNewline !== -1 ? output.slice(0, lastNewline) : output; + const status = /^\d+$/.test(statusText) ? parseInt(statusText, 10) : null; + + return { + success: true, + status, + body: responseBody, + truncated: result.exitCode === 23, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: `Fetch failed: ${message}` }; + } + }, +}); diff --git a/lib/agent/tools/writeFileTool.ts b/lib/agent/tools/writeFileTool.ts new file mode 100644 index 000000000..c8e59e3c3 --- /dev/null +++ b/lib/agent/tools/writeFileTool.ts @@ -0,0 +1,65 @@ +import { tool } from "ai"; +import { z } from "zod"; +import * as path from "path"; +import { getSandbox } from "@/lib/agent/tools/getSandbox"; +import { toDisplayPath } from "@/lib/agent/tools/toDisplayPath"; + +const writeInputSchema = z.object({ + filePath: z + .string() + .describe("Workspace-relative path to the file to write (e.g., src/user.test.ts)"), + content: z.string().describe("Content to write to the file"), +}); + +/** + * `write` — create or completely overwrite a file in the sandbox. Parent + * directories are created as needed. For small targeted edits prefer + * `editFileTool`. + */ +export const writeFileTool = tool({ + description: `Write content to a file on the filesystem. + +WHEN TO USE: +- Creating a new file that does not yet exist +- Completely replacing the contents of an existing file after you've read it + +WHEN NOT TO USE: +- Small or localized changes to an existing file (prefer editFileTool) +- Reading files (use readFileTool instead) +- Searching (use grepTool or globTool instead) + +USAGE: +- Use workspace-relative paths (e.g., "src/user.test.ts") +- This will OVERWRITE existing files entirely +- Parent directories are created automatically if they do not exist + +IMPORTANT: +- ALWAYS read an existing file with readFileTool before overwriting it +- Prefer editing existing files over creating new ones unless a new file is explicitly needed +- NEVER proactively create documentation files (e.g., *.md) unless the user explicitly requests them +- Do not write files that contain secrets or credentials (API keys, passwords, .env, etc.)`, + inputSchema: writeInputSchema, + execute: async ({ filePath, content }, { experimental_context }) => { + const sandbox = await getSandbox(experimental_context, "write"); + const workingDirectory = sandbox.workingDirectory; + + try { + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.resolve(workingDirectory, filePath); + const dir = path.dirname(absolutePath); + await sandbox.mkdir(dir, { recursive: true }); + await sandbox.writeFile(absolutePath, content, "utf-8"); + const stats = await sandbox.stat(absolutePath); + + return { + success: true, + path: toDisplayPath(absolutePath, workingDirectory), + bytesWritten: stats.size, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: `Failed to write file: ${message}` }; + } + }, +});