From a27c222adf7f61569ab95d2576db61f18f772439 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 22 May 2026 14:19:58 +0000 Subject: [PATCH] feat(apply-fix): tool-augmented Claude can explore + patch any repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single-shot Apply Fix with an agentic loop: Claude has tools to navigate the repository and stages writes until it's satisfied. The previous implementation pre-loaded a heuristic set of files (e.g. "robots.* finding → public/robots.txt") and asked for a one-shot patch; that failed any time the file lived somewhere we didn't anticipate. Tools exposed to Claude: - list_directory(path): list immediate children of a path. Use "" for root. - read_file(path): read file contents from the default branch. Truncates at 50KB to bound token usage. - search_code(query): GitHub Code Search for a literal substring across the repo. Useful for finding components / patterns in monorepos. - write_file(path, content): STAGE a write. Buffered until done(). - done(summary): signal completion with a PR-body-ready explanation. Safety / cost limits hardcoded: - 20 iteration cap. - 50KB per file read, 100KB per file write, 200KB total writes. - 100 dir entries per list. - Blocklist for generated / vendored paths (.git, node_modules, dist, build, .next, .cache, coverage, *.lock, pnpm-lock.yaml, etc.). - Prompt cache on the static system prompt + tools block via cache_control: ephemeral — drops typical input cost ~80% per turn. Cost expectation: ~$0.10 typical, ~$0.40 worst-case (per fix). Still 1 credit; healthy margin at $0.50-$1.00 per credit. API changes: - POST /api/projects/[id]/github/apply-fix now also accepts root_path as an optional monorepo hint. Threaded to applyFix() which passes the hint into the user prompt. - maxDuration bumped to 300s for the worst-case 20-turn loop. Existing UI / credit accounting / refund-on-failure unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../projects/[id]/github/apply-fix/route.ts | 7 +- lib/github/apply-fix.ts | 531 +++++++++++------- lib/github/repos.ts | 38 ++ 3 files changed, 382 insertions(+), 194 deletions(-) diff --git a/app/api/projects/[id]/github/apply-fix/route.ts b/app/api/projects/[id]/github/apply-fix/route.ts index 77889aea..ed056b59 100644 --- a/app/api/projects/[id]/github/apply-fix/route.ts +++ b/app/api/projects/[id]/github/apply-fix/route.ts @@ -11,7 +11,9 @@ import { getOrMintInstallationToken } from "@/lib/github/installations"; import { applyFix } from "@/lib/github/apply-fix"; export const runtime = "nodejs"; -export const maxDuration = 120; // Claude calls can take a while. +// Agentic mode runs up to 20 Claude tool turns; each turn ~5s. Worst +// case is ~2 minutes, but realistic fixes finish in 20-40s. +export const maxDuration = 300; const bodySchema = z.object({ owner: z.string().min(1), @@ -19,6 +21,8 @@ const bodySchema = z.object({ installation_id: z.number().int().positive(), audit_id: z.string().uuid(), finding_key: z.string().min(1), + /** Optional starting hint for monorepos (e.g. "apps/web"). */ + root_path: z.string().max(500).optional(), }); export async function POST( @@ -164,6 +168,7 @@ export async function POST( evidence: (finding as { evidence: unknown }).evidence, }, targetUrl: (audit as { target_url: string }).target_url, + rootPath: body.root_path, }); // A "noop" result still consumed the credit because we did call diff --git a/lib/github/apply-fix.ts b/lib/github/apply-fix.ts index 34dfb800..540e015a 100644 --- a/lib/github/apply-fix.ts +++ b/lib/github/apply-fix.ts @@ -1,10 +1,11 @@ -// Apply a CrawlProof audit fix as a GitHub pull request. Loads relevant -// repo files, asks Claude to produce a patched version that addresses -// the specific check, commits the changes on a branch, opens a PR. +// Apply a CrawlProof audit fix as a GitHub pull request, with Claude +// driving the change via tool use. Claude can list directories, read +// files, search code, and stage file writes; the loop runs until Claude +// calls the `done` tool or hits the iteration cap. Then we commit the +// staged writes on a branch and open the PR. // -// Credit accounting: callers consume 1 credit BEFORE invoking this and -// refund on any failure. The implementation here is purely the GitHub + -// LLM dance; billing lives in the API route. +// Credit accounting lives in the API route; this function just runs the +// tool loop and pushes the PR. import Anthropic from "@anthropic-ai/sdk"; import { env } from "@/lib/env"; @@ -12,9 +13,10 @@ import { createBranch, getFileContent, getRepo, + listRepoDirectory, openPullRequest, putFile, - type FileContent, + searchRepoCode, } from "./repos"; interface FindingInput { @@ -32,6 +34,8 @@ interface ApplyFixInput { repo: string; finding: FindingInput; targetUrl: string; + /** Optional subdirectory hint (e.g. "apps/web") to start exploration in. */ + rootPath?: string; } export interface ApplyFixResult { @@ -43,148 +47,143 @@ export interface ApplyFixResult { detail: string; } -// Heuristic candidate files per finding family. We probe these on the -// repo's default branch; missing files are silently skipped. Claude -// receives only the files we successfully fetch. -const CANDIDATES_BY_PREFIX: Record = { - "robots.": [ - "public/robots.txt", - "static/robots.txt", - "robots.txt", - "app/robots.txt", - "app/robots.ts", - ], - "llms.": [ - "public/llms.txt", - "static/llms.txt", - "llms.txt", - "app/llms.txt", - "app/llms.txt/route.ts", - ], - "schema.": [ - "app/layout.tsx", - "app/layout.jsx", - "src/app/layout.tsx", - "pages/_document.tsx", - "pages/_document.jsx", - "src/layouts/Layout.astro", - "index.html", - "public/index.html", - ], - "meta.": [ - "app/layout.tsx", - "app/layout.jsx", - "src/app/layout.tsx", - "pages/_document.tsx", - "src/layouts/Layout.astro", - "index.html", - "public/index.html", - ], - "positioning.": [ - "app/page.tsx", - "app/page.jsx", - "src/app/page.tsx", - "pages/index.tsx", - "pages/index.jsx", - "src/pages/index.astro", - "index.html", - "public/index.html", - ], - "sitemap.": [ - "public/sitemap.xml", - "static/sitemap.xml", - "app/sitemap.ts", - "app/sitemap.xml", - ], -}; - -const FALLBACK_CANDIDATES = [ - "app/layout.tsx", - "app/layout.jsx", - "src/app/layout.tsx", - "pages/_document.tsx", - "src/layouts/Layout.astro", - "index.html", - "public/index.html", +// Hard limits keep worst-case cost bounded. +const MAX_ITERATIONS = 20; +const MAX_FILE_READ_BYTES = 50_000; +const MAX_FILE_WRITE_BYTES = 100_000; +const MAX_TOTAL_WRITE_BYTES = 200_000; +const MAX_DIR_ENTRIES = 100; + +// Paths Claude shouldn't touch — keeps it focused on app code. +const BLOCKED_PATH_PATTERNS = [ + /^\./, // dotfiles / dotdirs (.git, .github, etc.) + /(^|\/)node_modules(\/|$)/, + /(^|\/)dist(\/|$)/, + /(^|\/)build(\/|$)/, + /(^|\/)\.next(\/|$)/, + /(^|\/)\.cache(\/|$)/, + /(^|\/)coverage(\/|$)/, + /\.lock$/, + /pnpm-lock\.yaml$/, + /package-lock\.json$/, + /yarn\.lock$/, ]; -function candidatesFor(checkKey: string): string[] { - for (const prefix of Object.keys(CANDIDATES_BY_PREFIX)) { - if (checkKey.startsWith(prefix)) return CANDIDATES_BY_PREFIX[prefix]; - } - return FALLBACK_CANDIDATES; +function isPathBlocked(path: string): boolean { + const normalized = path.replace(/^\/+/, ""); + return BLOCKED_PATH_PATTERNS.some((re) => re.test(normalized)); } -async function loadCandidateFiles(input: { - token: string; - owner: string; - repo: string; - ref: string; - paths: string[]; -}): Promise { - const found: FileContent[] = []; - for (const path of input.paths) { - const f = await getFileContent({ - token: input.token, - owner: input.owner, - repo: input.repo, - path, - ref: input.ref, - }); - if (f) found.push(f); - } - return found; -} +const TOOLS: Anthropic.Tool[] = [ + { + name: "list_directory", + description: + "List the immediate children of a directory in the repository. Use an empty string for the repo root.", + input_schema: { + type: "object", + properties: { + path: { + type: "string", + description: "Repository path. Empty string for root.", + }, + }, + required: ["path"], + }, + }, + { + name: "read_file", + description: + "Read a file's contents from the default branch. Returns null if the file doesn't exist. Large files are truncated.", + input_schema: { + type: "object", + properties: { + path: { type: "string", description: "Repository path to the file." }, + }, + required: ["path"], + }, + }, + { + name: "search_code", + description: + "Search the repository for files containing a literal substring. Useful for finding components, configs, or markup patterns across an unfamiliar repo.", + input_schema: { + type: "object", + properties: { + query: { + type: "string", + description: 'Literal substring to find (e.g. "", "JSON-LD", "robots").', + }, + }, + required: ["query"], + }, + }, + { + name: "write_file", + description: + "Stage a file change. Writes the full new contents (not a diff). Use the same path you read; for new files, pick a canonical location for the framework. Writes are buffered and only land if you eventually call done().", + input_schema: { + type: "object", + properties: { + path: { type: "string", description: "Repository path to write." }, + content: { + type: "string", + description: "Full new contents of the file.", + }, + }, + required: ["path", "content"], + }, + }, + { + name: "done", + description: + "Call this when the fix is complete (or you've determined the finding can't be fixed from the repo). Provide a short summary that will appear in the pull request body.", + input_schema: { + type: "object", + properties: { + summary: { + type: "string", + description: + "What changed and why, in 1-3 sentences. If no fix was possible, explain why.", + }, + }, + required: ["summary"], + }, + }, +]; -const MAX_FILE_BYTES = 50_000; // Truncate huge files; Claude's window has limits. +function buildSystemPrompt(owner: string, repo: string): string { + return `You are CrawlProof's automated fixer for AEO (Answer Engine Optimization) audit findings. The user has approved one specific finding to be fixed via a pull request on the repository ${owner}/${repo}. -function truncate(s: string): string { - if (s.length <= MAX_FILE_BYTES) return s; - return s.slice(0, MAX_FILE_BYTES) + "\n\n[…truncated for brevity…]"; -} +You have these tools: +- list_directory(path) — explore the project structure. +- read_file(path) — read a file. Large files are truncated. +- search_code(query) — find files containing a literal substring across the repo. +- write_file(path, content) — STAGE a file change. Writes are buffered; nothing leaves the system until you call done(). Use the full new file contents (not a diff). +- done(summary) — signal completion. -interface ClaudePatchResponse { - files: { path: string; content: string }[]; - explanation: string; +Rules: +1. **Be surgical.** Make the minimum change that addresses the finding. Don't refactor unrelated code, restyle untouched lines, or rename variables you don't need to. +2. **Understand before you write.** Use list_directory + read_file to learn the project's structure and conventions before staging any change. +3. **Match the existing style** — quotes, indentation, framework idioms (e.g. Next.js \`