📒 problems 📖 glossary
Mock interview
46:30
ready
of 46:30
Lesson 82 · AI-product eng

System design — autonomous code-review agent

📖 Walk me through it — plain English

This lesson is a system design question: design software that reviews pull requests on its own. A "pull request" (PR) is the bundle of code changes a developer proposes before it gets merged into the main codebase — the place where a human reviewer normally leaves comments like "this will crash if the list is empty." The goal here is to build an agent that does that reviewing by itself: reads the change, finds bugs, leaves comments, and maybe even opens its own fix. An "agent" just means an LLM (a large language model — the AI that predicts text, like the thing behind ChatGPT) wrapped in a loop that lets it take actions — call tools, read files, run tests — instead of only spitting out one answer. The interviewer wants to see if you can wire together tools, planning, memory, and testing into something that runs with nobody at the wheel.

Here is the analogy. Imagine hiring a brand-new junior reviewer for your team. On day one you would NOT hand them the keys to merge code into production. You would give them three things. First, a library card so they can look things up — not just the changed page, but every other place in the codebase that calls the changed function (because a bug is usually about how the new code talks to the old code around it). Second, a checklist and a time-box — "spend at most an hour, look at these specific files, don't go down rabbit holes" — so they don't read the entire repository forever. Third, you have a senior look over their comments before posting — "is this actually a bug, or are you imagining one?" — because an eager junior who cries wolf ten times a day gets ignored, and then everyone stops trusting the reviews entirely. That third step is the whole game.

Map that analogy onto the architecture in the lesson, step by step. A code push fires a webhook (an automatic "hey, something happened" ping from GitHub) which drops the job on a queue (a waiting line of work — reviews don't need to be instant). The system keeps a searchable index of the codebase organized by symbol (by function and class, not by file) so the agent can jump straight to "show me everyone who calls this function" — that is the find_references tool, the library card. Then a first LLM call figures out the PR's intent ("this claims to be a bugfix"), a planner writes a short concrete to-do list, and the agent enters a ReAct loop — "Reason, then Act": think, call a tool, read the result, think again — but with hard caps (at most ~30 tool calls, ~10 minutes, a dollar limit) so a giant messy diff can't make it spin forever and burn money.

The two ideas that win the interview, said out loud and unprompted, are the critic pass and the trust ladder. The critic pass is the senior-checking-the-junior step: a second model re-reads every proposed comment against the actual diff and asks "real issue, or hallucinated?" A "hallucination" is when the LLM confidently states something false — here, a bug that isn't really there. This second pass throws out 30–50% of false alarms, which matters enormously because false alarms are what kill these tools in real life. The trust ladder is the rollout plan: phase 1 the agent only comments; phase 2 it suggests fixes a human applies; phase 3 it opens fix PRs that still need human approval; phase 4 it auto-merges only the safest, most boring changes. You earn each rung — you don't hand a day-one junior the merge button.

Two more senior touches. Use determinism where you can: linters, type-checkers, and test runners give the same answer every time, so call the real tool and feed its output to the LLM rather than asking the LLM to "act like a linter" (it'll guess; the real tool knows). And treat your eval set as the moat — a curated pile of 200+ old PRs whose real bugs and human comments you already know, that you re-run on every prompt or model change. Without it you're "vibe-coding": tweaking and hoping, with no way to tell if a change made the agent better or worse. The throughline of the whole design is the false-positive problem: an agent that's wrong too often gets muted, and a muted reviewer catches zero bugs — so almost every choice (symbol-level lookup, the critic pass, real linters, the trust ladder) exists to keep the comments trustworthy.

Cursor / Cognition (Devin) / Sourcegraph (Amp) / GitHub Copilot / Greptile-shaped question. "Design an agent that reviews PRs on its own — comments on bugs, flags style issues, optionally opens a fix PR." Tests whether you can compose tools + planning + memory + evals into a system that runs without a human at the wheel. Harder than the copilot — the failure modes are nastier.

First, the vocabulary — every moving part, defined once

Before the architecture, lock down the words. The interviewer will use these casually and expect you to volley back. None of them are hard once you stop treating them as jargon.

  • Agent vs. pipeline. A pipeline is a fixed sequence of steps you wrote by hand: do A, then B, then C, always in that order. An agent is an LLM that decides for itself what to do next, in a loop, choosing from a menu of tools — it might read three files, then grep, then read one more, depending on what it finds. Pipelines are predictable and cheap; agents are flexible but can wander. The senior move is to use a pipeline for the simple 80% and reserve the agent for the gnarly 20% where you genuinely can't predict the steps in advance.
  • Tool calling. The mechanism that turns an LLM from a text-generator into something that can act. You hand the model a list of functions it's allowed to call (each with a name, a description, and typed arguments — e.g. read_file(path, range)). The model replies not with prose but with a structured request: "call read_file with path='app/auth.py'." Your code runs the real function, returns the result as text, and the model continues. The LLM never touches your systems directly — it only asks, and your code decides whether and how to honor each request. That gap is your safety boundary.
  • The trigger (webhook on PR). A webhook is GitHub calling you: when a PR is opened or pushed, GitHub sends an HTTP POST to a URL you registered, carrying the event details. You don't poll ("any new PRs yet? ...now? ...now?"); you get pinged. You verify the payload signature (so a stranger can't fake the ping), then immediately drop the job on a queue and return 200 OK fast — webhook senders time out and retry if you're slow, which would double-trigger your review.
  • Context gathering (diff + repo). The diff is the patch — exactly which lines were added (+) and removed (-). It's necessary but never sufficient: a diff shows you what changed, not what depends on it. So you also pull repo context — the surrounding function, the file, and crucially the callers and callees of anything the diff touched. "Context gathering" is the act of assembling that bundle before reasoning, because an LLM reviewing only the diff is reviewing in a vacuum.
  • Chunking large diffs. LLMs have a context window — a hard ceiling on how much text fits in one call. A 4,000-line diff blows past it (and even when it fits, quality drops as the model drowns in tokens). Chunking means slicing the work into reviewable pieces — ideally one logical unit per chunk (a file, or a single function and its immediate context) rather than blind 500-line slabs — reviewing each separately, then merging findings. Smart chunking keeps related code together so the model never judges half a function.
  • The review prompt. The instructions you give the reviewing model: its role ("you are a careful senior reviewer"), what to look for (bugs, security, perf — scoped to the PR's intent), the output format (a list of {file, line, severity, message} objects, not freeform prose), and the rules of restraint ("only flag issues you are confident about; if unsure, stay silent"). A vague prompt produces a chatty, nitpicky reviewer; a tight prompt with explicit severity and a confidence bar produces a quiet, trusted one.
  • Posting comments via API. GitHub's REST/GraphQL API lets you attach a comment to a specific line of a specific file in the PR's review (the "inline comment" endpoint). You map each finding to its exact line and post it. Two subtleties bite here: the line numbers must be relative to the diff GitHub knows about (not the raw file), and you should batch findings into one review rather than firing N separate comment calls (cleaner UI, fewer notifications, fewer rate-limit hits).
  • Guardrails / false-positive control. Everything that keeps the agent from being wrong in public: the critic pass, a confidence threshold below which findings are dropped, allow/deny lists (never comment on generated or vendored files), and severity gating (only surface high-severity items at first). A "false positive" is a comment about a bug that isn't real; controlling them is the single highest-leverage thing you do, because trust is asymmetric — one dumb comment erases the credit from ten good ones.
  • Idempotency (don't double-comment). Idempotent means running the same operation twice has the same effect as running it once. Webhooks fire multiple times for one PR (every push, plus retries), so a naive agent re-reviews and re-posts the same comment on every push — spam. You make posting idempotent by keying each finding (e.g. a hash of file + line + message) and checking "did I already post this?" before posting, and by only reviewing the incremental diff since your last review rather than the whole PR again.
  • Cost / latency budget. Each tool call and model call costs tokens (money) and wall-clock time. A budget is the hard ceiling per PR — e.g. max 30 tool calls, max 10 minutes, max $1 — enforced in code, not hoped for in the prompt. Without it, one pathological diff sends the agent into an expensive loop. Reviews aren't realtime, so you trade latency for cost freely; what you cannot do is let either run unbounded.
  • Eval of review quality. An eval set is your test suite for the agent: a curated collection of past PRs where you already know the ground truth — which were real bugs, which comments humans accepted. You re-run the agent over this set after any change and measure precision (of the comments it made, how many were real) and recall (of the real bugs present, how many it caught). This is the only way to know whether a prompt tweak helped or hurt instead of guessing.
  • Human override. The escape hatch and the feedback signal in one. Humans can dismiss any comment, resolve a thread, or thumbs-down a finding — and the agent must (a) never re-post a dismissed comment and (b) feed those reactions back into the eval set and tuning. The human is always the final authority; the agent's job is to be useful enough that overriding it is rare.
Clarify first (5 min)
  • Read-only (comments only) or write-capable (can open fix PRs)? Approval gate?
  • Scope — bugs, security, style, performance, test coverage? Different tools per goal.
  • Repo size — <1MLOC fits in context (with tricks); monorepos need symbol-level retrieval.
  • Languages / frameworks — Python+JS only, or polyglot? Affects tool surface.
  • Success metric — % of real bugs caught, false-positive rate, dev acceptance rate of comments.

Why these five and not "what's the QPS?" — this is a quality-and-trust product, not a throughput product. The volume is low (a team merges tens to low-hundreds of PRs a day, not millions of requests a second), so the interesting constraints are about correctness and permission, not scale. Pin down read-only-vs-write first because it changes the entire risk profile: a commenting agent that's wrong wastes someone's attention; a merging agent that's wrong ships a bug to production. Pin down scope because "find security bugs" and "enforce import ordering" want completely different tools and prompts. Pin down the success metric early so that every later decision can be justified against it.

Reference architecture
  1. Trigger — GitHub/GitLab webhook on PR open / push. Queue the job (SQS / Cloud Tasks); reviews are not realtime.
  2. Repo indexing — incremental embedding of the codebase by symbol (functions, classes), not by file. Refresh on push. Treesitter for parse, content-hash to skip unchanged.
  3. Diff understanding — first LLM call: classify the PR (bugfix / feature / refactor / config) and extract the "intent" claim from title + description.
  4. Planner — agent produces a review plan: which files need deep read, which symbols need cross-reference, what tests should exist, what security checks apply. Plan is concrete + bounded.
  5. Toolsread_file(path, range), grep(pattern), find_references(symbol), run_tests(targets), lint(files), get_pr_context(pr_id), post_comment(line, body). Read-mostly until you trust the agent.
  6. ReAct loop with budget — max 30 tool calls, max 10 min wall, max $X spend per PR. Hard cap or you ship a money-printer-in-reverse.
  7. Critic pass — second model re-reads each proposed comment against the diff: is this a real issue or hallucinated? Drops 30–50% of false positives. Worth its cost.
  8. Post + learn — accepted comments and resolved threads feed the eval set. Acceptance rate is the north-star metric.

Walking the flow: trigger → fetch → chunk → analyze → post → learn

The reference box lists the parts; this section traces a single PR through them so you can narrate the whole journey in the interview without losing the thread. Picture a developer pushing a 600-line PR titled "fix: handle empty cart in checkout."

1. Trigger — the ping that starts it all

The push fires a webhook to your endpoint. Your handler does almost nothing on purpose: verify the signature, extract the PR id and head SHA (the commit fingerprint), enqueue a job, and return 200 in milliseconds. All the slow work happens off the queue, by a worker. This split is what makes the system robust — if a worker crashes mid-review, the job is still on the queue to retry, and the webhook sender already got its fast acknowledgment.

# webhook handler — fast, dumb, idempotent
def on_pr_event(req):
    verify_signature(req)              # reject forged pings
    job = {"pr": req.pr_id, "sha": req.head_sha}
    queue.enqueue(job, dedup_key=req.head_sha)  # same SHA twice -> one job
    return 200                          # return now; worker does the review later

Note the dedup_key: GitHub may deliver the same event twice, and a fast typist pushes three times in a minute. Keying the job by head SHA means redundant pings collapse into one review — the first brush with idempotency, before you've even posted anything.

2. Fetch diff + context — assemble the evidence

The worker pulls the diff via the API, then enriches it. For each changed symbol it consults the symbol index (built ahead of time, refreshed incrementally on every push) to find callers and callees. "Symbol index" means: instead of treating the repo as a pile of files, you've parsed it with a tool like Treesitter into functions and classes, so "who calls checkout()?" is a lookup, not a brute-force text search. This is the difference between a reviewer who reads only the changed page and one who flips to every page that references it.

Why index by symbol and not just grep on demand? Because grep finds the string checkout everywhere — in comments, in unrelated variables — while a symbol index knows the definition and its true call sites. And why incremental (content-hash each file, re-index only what changed)? Because re-parsing a million-line monorepo on every push is wasteful; you skip the 99.9% that didn't change.

3. Chunk — make it fit and keep it coherent

A 600-line diff across eight files won't review well as one blob. You split along natural seams: group the hunks by file, and within a large file by function, so each chunk is a self-contained unit the model can judge fully. Each chunk carries its gathered context (the changed code plus its callers) so the model never reasons about a function in isolation. The cardinal sin is splitting mid-function — half a function looks buggy in a hundred harmless ways.

4. Analyze — plan, then the ReAct loop

First a cheap LLM call classifies intent ("this is a bugfix for empty-cart checkout") so the reviewer knows what "done right" looks like. Then the planner writes a short, concrete to-do list — "verify the empty-cart branch, check the two callers of checkout(), confirm a test covers the empty case." Then the agent runs the ReAct loop: it reasons, calls a tool, reads the result, reasons again — under the budget. Crucially, where a question has a deterministic answer, the agent calls the real tool: it runs the actual linter and the actual test suite rather than imagining their output.

# ReAct loop, bounded — pseudocode
budget = Budget(tool_calls=30, seconds=600, dollars=1.0)
while not agent.done and budget.ok():
    thought, action = model.step(plan, history)   # reason -> pick a tool
    result = tools.run(action)                  # real linter, real tests, real grep
    history.append(thought, action, result)
    budget.charge(result.cost)
findings = agent.collect_findings()             # {file, line, severity, msg}

The budget is enforced here, in code — not requested in the prompt, because a model under a confusing diff will happily ignore a polite "please be brief." When the budget runs out, the agent stops and posts what it has (with a note), rather than failing silently.

5. Critic pass + post — the senior check, then publish

Before anything is posted, a second model re-reads each proposed finding against the real diff and asks one question: "is this a genuine issue here, or is it hallucinated / out of scope / nitpicky?" It drops the failures. This single step removes 30–50% of false positives — far cheaper and more reliable than trying to make the first reviewer perfect. Survivors are filtered again by severity and by allow/deny lists (skip generated files), then posted via the API as one batched review, each comment pinned to its diff line.

Posting is made idempotent: each finding is hashed (file + line + normalized message); before posting, you check the PR's existing bot comments and skip any hash already present. On the next push you review only the incremental diff and only post net-new findings — so the developer who pushes five times doesn't get the same comment five times.

6. Learn / eval — close the loop

When humans react — accept a suggestion, dismiss a comment, resolve a thread — those signals flow back. Dismissed comments train the deny side; accepted ones validate the reviewer. Periodically you fold notable PRs into the eval set, the curated 200+ historical PRs with known answers, and re-run it on every prompt or model change to measure precision and recall. This is what turns the agent from a static script into something that gets measurably better — and what stops a "harmless" prompt edit from silently doubling your false-positive rate.

Symbol-level retrieval beats file-level

"Show me the diff" is not enough — the bug is usually about how the changed function interacts with its callers. find_references as a first-class tool. Without it the agent reviews in a vacuum.

The critic pass is the magic

Naive agents over-comment. A second-pass "is this actually a problem in context?" model drops false positives dramatically. Cheaper than tuning the first prompt for 6 months.

Failure modes — name them

(1) Hallucinated bugs (false positives that erode trust), (2) Missed real bugs (silent), (3) Comment spam on autogenerated files, (4) Runaway loops on large diffs, (5) Wrong context retrieved. Cite + mitigate each.

Determinism where possible

Linters, type-checkers, test runners are deterministic. Run them as tools and feed results to the LLM. Don't ask an LLM to "be a linter" — call the actual linter.

Trust ladder — start read-only

Phase 1: comments only. Phase 2: suggest fixes inline (still human-applied). Phase 3: open fix PRs with required human approval. Phase 4: auto-merge low-risk classes. Skipping phases destroys trust.

Cost math

30 tool calls × ~2K tokens each + 5K-token plan + critic pass ≈ $0.30–$1.00 per PR on Sonnet. At 1000 PRs/day = $300–$1k/day. Cascading and caching matter at this volume.

Eval is the moat

Curated set of 200+ historical PRs with known bugs and human review comments. Every prompt/model change re-runs the set. Without this you're vibe-coding the agent.

When to NOT use an agent

PRs that change one config line don't need agent orchestration — a single LLM call against the diff is fine. Reserve the agent for multi-file diffs where cross-reference matters. Senior judgment.

The tradeoffs you must say out loud

A design answer is only senior if you name what you're giving up. Two tensions sit at the heart of this system.

Agent loop vs. fixed pipeline

A fixed pipeline (classify → run linter → one review call → post) is cheap, fast, and predictable — but blind to anything that needs exploration. A full agent loop can chase a bug across files, but costs more, takes longer, and can wander or loop. Resolution: route by complexity. Trivial diffs take the pipeline; multi-file diffs that need cross-reference get the bounded agent. Don't pay agent prices for a one-line config change.

Precision vs. recall of findings

Precision = of comments posted, how many are real. Recall = of real bugs present, how many you caught. You can't max both: a chatty agent catches more bugs (high recall) but spams (low precision); a cautious one is trusted (high precision) but misses some (lower recall). Resolution: for an autonomous reviewer, bias hard toward precision early. A tool that's usually right gets read; a tool that cries wolf gets muted — and a muted reviewer has zero effective recall anyway.

The pitfalls that sink real deployments

Each of these has killed a code-review agent in the wild. Name the pitfall and its mitigation — that pairing is the signal.

  • Noisy comments. Too many low-value nits (style, naming, "consider extracting this") bury the one comment that mattered, and devs stop reading. Mitigate: severity gating, a confidence threshold, and the critic pass; surface only high-severity findings until trust is earned.
  • Hallucinated issues. The model confidently flags a bug that doesn't exist — often by misreading context it never fetched. Mitigate: the critic pass re-checking each finding against the real diff, plus requiring the model to cite the exact lines its claim rests on (an uncitable claim gets dropped).
  • Re-reviewing on every push. Without idempotency the agent re-runs the full review and re-posts every comment each time the dev pushes — instant spam, instant distrust. Mitigate: review only the incremental diff since the last reviewed SHA, hash each finding, and skip anything already posted or already dismissed.
  • Runaway loops / cost blowout. A huge or adversarial diff sends the agent into 200 tool calls. Mitigate: hard budgets (tool-call, time, dollar) enforced in code; stop and post-partial when exceeded.
  • Comment spam on generated files. Reviewing package-lock.json or generated protobufs wastes the budget and annoys everyone. Mitigate: a deny-list of paths/globs skipped before analysis.
  • Wrong context retrieved. The agent fetches a similarly-named-but-unrelated function and reasons about the wrong code. Mitigate: symbol-level (not text-level) retrieval, and feeding the agent the resolved definition rather than a fuzzy search hit.
The senior signal: talking about the trust ladder and the critic pass unprompted. Anyone can describe a ReAct loop. The interview is won by candidates who name the false-positive problem and design specifically for it — because that's what kills real code-review agents in production.

Takeaway: the flow is trigger → fetch diff + context → chunk → analyze (plan + bounded ReAct loop, real tools) → critic pass → post (batched, idempotent) → learn/eval. The whole design bends around one fact: an autonomous reviewer that's wrong too often gets muted, and a muted reviewer catches nothing. So bias toward precision, gate by severity and confidence, make posting idempotent, enforce cost/latency budgets in code, and earn write-access one rung of the trust ladder at a time — measuring every change against an eval set you treat as the moat.

Go deeper (optional): the ReAct pattern (reason + act interleaved) comes from the "ReAct: Synergizing Reasoning and Acting in Language Models" paper; the broader framing of LLM agents as tool-callers in a loop is well covered by Anthropic's "Building effective agents" write-up. Both are worth a read once the design above feels natural — but you do not need either to answer this question.

→ Going deeper: Multi-agent systems start as single-agent loops. See Agent design round.