import "server-only"; import { agentCommitter } from "../actor"; import { removeBranch } from "../branches"; import { AGENT_BRANCH_PREFIX } from "../branch-rules"; import { defaultBranch, grep, objectType, readBlob, revParse } from "../git"; import { applyPatchToTree, commitFiles, isValidBranchName, treeOf, updateBranch, writeCommit, type FileChange } from "../git-write"; import { assertCanCommit } from "../protection"; import { RevertError, revertCommit } from "../revert"; import { markPushed, type RepoRecord } from "../repos"; import { actorOf, afterPush, MAX_FILE_BYTES, openRepo, owner, ref, repo, repoPath, resolveRef, tool, truncate, type ToolContext } from "./kit"; import { array, boolean, integer, object, string, ToolError } from "./params"; // Finding code, and changing it without sending whole files: search_code, edit_files (exact // replacements) and apply_patch (a unified diff). Edits land as one commit by the agent. // --------------------------------------------------------------------------- // Search /** A git pathspec for a path or glob the agent gave ("src", "src/**\/*.ts", "*.md"). */ function pathspec(input: string) { const path = input.trim().replace(/^\/+/, ""); if (!path) return []; return [/[*?[]/.test(path) ? `:(glob)${path}` : path]; } export const codeSearchTools = [ tool({ name: "search_code", title: "Search code", description: [ "Finds lines in a repository's files (at a branch, tag or commit) that contain the query, like git grep:", "plain text by default, or an extended regular expression with regex. Results are grouped by file with line numbers.", "Narrow it with path (a directory, file or glob like src/**/*.ts).", ].join(" "), access: "read", params: { owner, repo, query: string("Text (or with regex, a pattern) to find.", { min: 1, max: 500 }), ref, path: string("Only in this directory, file or glob.", { max: 1024 }).optional(), regex: boolean("Treat query as an extended regular expression.").default(false), case_sensitive: boolean("Match case exactly.").default(false), context_lines: integer("Lines to show around each match.", { min: 0, max: 10 }).default(0), limit: integer("At most this many matching lines.", { min: 1, max: 500 }).default(100), }, run: async (args, ctx) => { const { repo } = await openRepo(ctx, args.owner, args.repo, "read"); const { ref, sha } = await resolveRef(repo.dir, args.ref); const { matches, truncated } = await grep(repo.dir, sha, args.query, { regex: args.regex, caseSensitive: args.case_sensitive, paths: args.path ? pathspec(args.path) : [], limit: args.limit, }).catch((e: Error) => { throw new ToolError(`The search failed: ${e.message.split("\n")[0]}`); }); if (matches.length === 0) return `No matches for ${JSON.stringify(args.query)} at ${ref}.`; const byFile = new Map(); for (const m of matches) byFile.set(m.path, [...(byFile.get(m.path) ?? []), m.line]); const sections: string[] = []; for (const [file, lines] of byFile) { const matched = new Set(lines); let text: string[]; if (args.context_lines > 0) { const blob = await readBlob(repo.dir, sha, file); const all = blob.content.toString("utf8").split("\n"); const shown = new Set(); for (const n of lines) for (let i = Math.max(1, n - args.context_lines); i <= Math.min(all.length, n + args.context_lines); i++) shown.add(i); text = []; let last = 0; for (const n of [...shown].sort((a, b) => a - b)) { if (last && n > last + 1) text.push("--"); text.push(`${n}${matched.has(n) ? ":" : "-"} ${all[n - 1]}`); last = n; } } else { text = matches.filter((m) => m.path === file).map((m) => `${m.line}: ${m.text}`); } sections.push(`${file}\n${text.join("\n")}`); } const head = `${matches.length}${truncated ? "+" : ""} matching line${matches.length === 1 ? "" : "s"} in ${byFile.size} file${byFile.size === 1 ? "" : "s"} at ${ref} (${sha.slice(0, 7)}).${truncated ? " There are more: narrow the search or raise limit." : ""}`; return truncate(`${head}\n\n${sections.join("\n\n")}`, "Narrow the search with path."); }, }), ]; // --------------------------------------------------------------------------- // Edits /** Where a commit on `branch` goes: on its head, or (a new branch) on base or the default branch. */ async function commitTarget(repo: RepoRecord, ctx: ToolContext, branch: string, base: string | undefined, expectedHead: string | undefined) { const head = await revParse(repo.dir, `refs/heads/${branch}`); let parent: string | null; if (head) { if (expectedHead && (expectedHead.length < 7 || !head.startsWith(expectedHead.toLowerCase()))) { throw new ToolError(`${branch} is at ${head} now, not ${expectedHead}. Read the files again and retry.`); } parent = head; } else { if (!(await isValidBranchName(repo.dir, branch))) throw new ToolError(`${branch} isn't a valid branch name.`); const empty = base === undefined && !(await defaultBranch(repo.dir)); parent = empty ? null : (await resolveRef(repo.dir, base)).sha; } await assertCanCommit(repo, actorOf(ctx), branch, head); return { head, parent }; } const signatures = (ctx: ToolContext) => ({ author: { name: ctx.user.name || ctx.user.username, email: ctx.user.email }, committer: agentCommitter(ctx.agent) }); const edit = object("A replacement in one file.", { path: string("The file's path.", { min: 1, max: 1024 }), old_text: string("The exact text to replace, with enough lines around it to be unique. Empty to create a new file.", { max: 1024 * 1024 }), new_text: string("What replaces it.", { max: 5 * 1024 * 1024 }), replace_all: boolean("Replace every occurrence instead of exactly one.").default(false), }); const branchParams = { branch: string(`The branch to commit to, e.g. ${AGENT_BRANCH_PREFIX}fix-login. Created from base if it doesn't exist.`, { min: 1, max: 255 }), message: string("The commit message.", { min: 1, max: 10_000 }), base: string("Where a new branch starts: a branch, tag or commit. Defaults to the default branch.", { max: 255 }).optional(), expected_head: string("The commit SHA the branch must still be at.", { max: 64 }).optional(), }; const count = (text: string, part: string) => (part ? text.split(part).length - 1 : 0); export const editTools = [ tool({ name: "edit_files", title: "Edit files", description: [ "Changes parts of files in one commit: each edit replaces old_text (which must appear exactly once, unless replace_all) with new_text.", "Edits to the same file apply in order. An empty old_text creates a new file. Cheaper than commit_files for small changes to large files.", ].join(" "), access: "write", params: { owner, repo, ...branchParams, edits: array("The replacements.", edit, { max: 200 }) }, run: async (args, ctx) => { const { repo, url } = await openRepo(ctx, args.owner, args.repo, "write"); if (args.edits.length === 0) throw new ToolError("Give at least one edit."); const { head, parent } = await commitTarget(repo, ctx, args.branch, args.base, args.expected_head); const files = new Map(); for (const [i, e] of args.edits.entries()) { const path = repoPath(e.path); if (!path) throw new ToolError(`edits[${i}].path is empty.`); if (!files.has(path)) { const type = parent ? await objectType(repo.dir, parent, path) : null; if (type === "tree") throw new ToolError(`${path} is a directory.`); if (type === "blob") { const blob = await readBlob(repo.dir, parent!, path); if (blob.tooLarge || blob.binary) throw new ToolError(`${path} is binary or too large to edit here. Use commit_files.`); files.set(path, blob.content.toString("utf8")); } else if (e.old_text === "") { files.set(path, e.new_text); continue; } else { throw new ToolError(`There's no file ${path}. To create it, pass an empty old_text.`); } } const text = files.get(path)!; if (e.old_text === "") throw new ToolError(`edits[${i}]: ${path} exists, so old_text can't be empty.`); const n = count(text, e.old_text); if (n === 0) throw new ToolError(`edits[${i}]: old_text isn't in ${path}${i > 0 ? " (after the earlier edits)" : ""}. Read the file again; whitespace must match exactly.`); if (n > 1 && !e.replace_all) throw new ToolError(`edits[${i}]: old_text appears ${n} times in ${path}. Include more lines around it, or set replace_all.`); files.set(path, e.replace_all ? text.split(e.old_text).join(e.new_text) : text.replace(e.old_text, () => e.new_text)); } const changes: FileChange[] = [...files].map(([path, content]) => ({ path, content: Buffer.from(content) })); for (const c of changes) if ("content" in c && c.content.length > MAX_FILE_BYTES) throw new ToolError(`${c.path} would be larger than 10 MB.`); const commit = await commitFiles(repo.dir, { branch: args.branch, parent, expectedHead: head, changes, message: `${args.message.trim()}\n`, ...signatures(ctx) }); await markPushed(repo.id); afterPush(repo, ctx, `refs/heads/${args.branch}`, head, commit); return { commit, branch: args.branch, created_branch: !head, files: [...files.keys()], url: `${url}/commit/${commit}` }; }, }), tool({ name: "apply_patch", title: "Apply a patch", description: [ "Commits a unified diff (as git diff or diff -u writes it, with a/ and b/ paths) to a branch. It may create, change, rename and delete files.", "Hunk line counts are recounted, but the context lines must match the files.", ].join(" "), access: "write", params: { owner, repo, ...branchParams, patch: string("The unified diff.", { min: 1, max: 10 * 1024 * 1024 }) }, run: async (args, ctx) => { const { repo, url } = await openRepo(ctx, args.owner, args.repo, "write"); const { head, parent } = await commitTarget(repo, ctx, args.branch, args.base, args.expected_head); const patch = args.patch.endsWith("\n") ? args.patch : `${args.patch}\n`; const tree = await applyPatchToTree(repo.dir, parent, patch); if (parent && tree === (await treeOf(repo.dir, parent))) throw new ToolError("The patch changes nothing."); const commit = await writeCommit(repo.dir, { tree, parents: parent ? [parent] : [], message: `${args.message.trim()}\n`, ...signatures(ctx) }); await updateBranch(repo.dir, args.branch, commit, head); await markPushed(repo.id); afterPush(repo, ctx, `refs/heads/${args.branch}`, head, commit); return { commit, branch: args.branch, created_branch: !head, url: `${url}/commit/${commit}` }; }, }), tool({ name: "revert_commit", title: "Revert a commit", description: [ "Undoes a commit with a new commit on top of a branch that has it (the default branch if it does, or the branch you name), like git revert.", "If the branch is protected, the revert lands on its own branch with a pull request instead.", ].join(" "), access: "write", params: { owner, repo, sha: string("The commit to undo.", { min: 4, max: 64 }), branch: string("The branch to revert it on.", { max: 255 }).optional(), }, run: async (args, ctx) => { const { repo, url } = await openRepo(ctx, args.owner, args.repo, "write"); const result = await revertCommit(repo, actorOf(ctx), args.sha, args.branch).catch((e: Error) => { if (e instanceof RevertError) throw new ToolError(e.message); throw e; }); return { ...result, url: result.pull ? `${url}/pulls/${result.pull}` : `${url}/commit/${result.commit}` }; }, }), tool({ name: "delete_branch", title: "Delete a branch", description: "Deletes a branch, e.g. one whose pull request was merged. The default branch can't be deleted, nor branches the rules protect.", access: "write", destructive: true, params: { owner, repo, branch: string("The branch to delete.", { min: 1, max: 255 }), expected_sha: string("The commit the branch must still be at.", { max: 64 }).optional(), }, run: async (args, ctx) => { const { repo } = await openRepo(ctx, args.owner, args.repo, "write"); const { sha, open } = await removeBranch(repo, actorOf(ctx), args.branch, args.expected_sha); const names = open.map((p) => (p.repoId === repo.id ? `#${p.number}` : `#${p.number} in the repository it was forked from`)); return { deleted: args.branch, was_at: sha, ...(open.length ? { note: `Open pull requests used it: ${names.join(", ")}. Close them, or push the branch again.` } : {}), }; }, }), ];