← palnag.in
Source Say hi →
Case study · AI agents · Distributed systems

GraphReview: code review agents that know what your diff can break.

Specialist Claude agents review every pull request in parallel over Kafka. Instead of pasting whole files into a prompt, they investigate through a code knowledge graph and a hybrid vector index. A judge filters the noise, and one review lands on GitHub.

By Pallav Nag
Oct 2026
~8 min read
Python · Kafka · Kubernetes · Claude
4
Agents per PR, in parallel
5
GraphRAG tools per agent
1→6
Pods in ~20 s on a 40-PR burst
11
Seeded-bug eval cases
TL;DR

A webhook hits the gateway, which publishes one Kafka message. Four consumer groups pick it up: ruff, plus Claude agents for correctness, security and tests. Each runs a bounded tool loop against a Graphify code graph and a Qdrant hybrid index. An aggregator merges duplicates and boosts confidence when agents agree, an LLM-as-judge critic drops false positives, and a publisher posts one GitHub review with inline comments. It runs in one process with no keys, on Docker Compose with a live dashboard, or on Kubernetes where KEDA scales each agent role on its own consumer lag.

DashboardLive pipeline stepper per job, per-agent latency, tool calls and tokens, findings with agent attribution and evidence, plus the impact report the code graph produced.
00 · The problem

A diff is the wrong unit of context

Change a function's signature and the diff shows you the function. It doesn't show the caller two files away that still passes two arguments. A reviewer working from the diff alone can't see that break. One fed entire files pays for code that doesn't matter and still has to guess which parts do.

diff_only
The diff alone

Cheap, and blind to anything outside the hunk.

full_files
Diff + changed files

Sees more, pays for all of it, and the signal gets diluted.

graph_rag
Impact report + tools

Knows what can break up front, then pulls only what it needs.

GraphReview takes the third path, and the eval harness runs all three as an ablation so the difference is measured, not asserted.

01 · The whole system

A review path and a retrieval path

Fig 01Teal arrows are Kafka topics, dashed arrows are HTTP calls, amber boxes call Claude, violet boxes hold state.
  • Left column, the review path. Gateway → four agents → aggregator → critic → publisher. Every hop between services is a Kafka topic.
  • Right column, retrieval. The indexer builds a code graph (Redis) and a vector index (Qdrant) ahead of time. The context service answers the agents' tool calls from them.
Kafka topicKeyProducer → consumer group(s)
pr.review.requestedjob_idgateway → agent-static, agent-reviewer, agent-security, agent-tests (fan-out)
review.agent.resultsjob_idagents → aggregator (all results for a job land on one partition)
review.aggregatedjob_idaggregator → critic
review.completedjob_idcritic → publisher
repo.index.requestedrepogateway → indexer
review.dlqoriginalany worker, after 3 failed attempts
02 · One review, end to end

Services talk only through Kafka

Fig 02Time runs downward. The violet pills are the job status the dashboard's stepper reads: queued → agents → judging → completed.
  1. Gateway verifies the X-Hub-Signature-256 HMAC, fetches PR files and patches, dedupes redeliveries per head SHA with SETNX, stores the job in Redis and publishes it.
  2. Agents each own a consumer group, so every role sees every job while replicas within a role split partitions. All four run their tool loops at the same time.
  3. Aggregator joins results per job in Redis, clusters near-duplicates and raises confidence when independent agents agree. A deadline sweeper flushes partial results if an agent never reports.
  4. Critic judges each LLM finding against the diff and packed graph context, drops false positives, recalibrates severity and writes the verdict. Deterministic static findings skip the judge.
  5. Publisher posts one GitHub review. Findings on diff lines become inline comments; findings outside the diff, like a broken caller in an untouched file, go in the review body.

Fan-out without a router

Fig 03Different groups each get every message; consumers in the same group share partitions.

That's how one PR reaches all four agents without the gateway knowing they exist, and how one role scales out on its own. A seventh reviewer replica would sit idle with only six partitions to own, so autoscaling caps at six.

03 · Inside an agent

A bounded tool loop, not a giant prompt

The reviewer, security and tests agents are one engine with different system prompts, tool lists and effort levels. Claude gets the diff with new-side line numbers in the margin, so it can cite exact lines, plus a short graph impact report. Then it decides what else to look at.

Fig 04Three exits per turn: tool_use loops, end_turn carries schema-valid findings, anything else becomes an error result so the pipeline keeps moving. At most 8 turns; the last one disables tools.
ToolQuestion it answersBacked by
get_callersWho calls this function, 1–3 hops out, and what does it call?code graph
find_testsWhich tests exercise this symbol?code graph
search_codeWhere else does the codebase do something similar?vectors (code)
search_docsWhat do this repo's own docs say the rule is?vectors (docs)
read_snippetShow me lines 40–80 of this file.PR head / index
  • Parallel tool calls run concurrently and all results go back in one message. Splitting them would teach the model to stop batching.
  • Assistant turns are replayed verbatim, thinking blocks included. History is append-only, so the prompt cache hits on every turn.
  • PR text is data, never instructions. Every system prompt frames diff, code and docs as untrusted, and read_snippet blocks path traversal.
04 · GraphRAG

A graph for structure, vectors for meaning

Vector search can't reliably tell you who calls a function, and a graph can't find code that is merely similar. So each question goes to the structure that can answer it.

Question a reviewer asksAnswered by
Which functions did this hunk touch?graph line → innermost enclosing symbol
Who breaks if this signature changes?graph reverse BFS over calls / imports / inherits
Is this behaviour tested?graph tests reachable from the changed symbols
How does the rest of the codebase handle X?vectors dense BGE-small + sparse BM25, RRF fusion
Does this violate a team convention?vectors over repo docs (CONTRIBUTING, ADRs)
Fig 05Built once per push to the default branch; only changed paths are re-embedded. Chunks are AST-aligned and tile each file completely, so read_snippet can rebuild any line range without cloning at review time.

From an edited line to what it can break

Seeded case 05 gives compute_tax a new required parameter. The graph finds the innermost function around the edited line, then walks edges backwards, callee to caller, up to two hops. Callees are skipped: changing compute_tax can't break what it calls.

Fig 06order_total still calls it with two arguments and isn't in the diff. A diff-only reviewer never sees it.

This is the text every agent receives at the top of its prompt:

CHANGED SYMBOLS (graph nodes enclosing the edited lines): compute_tax [function] @ shopapp/pricing.py:19 DEPENDENTS (reverse deps, ≤2 hops), code that may break: [d1] order_total [function] @ shopapp/pricing.py:24 [d1] test_tax_rounds_half_up @ tests/test_pricing.py:15 [d2] checkout [function] @ shopapp/api.py:27 [d2] test_order_total @ tests/test_pricing.py:19 TESTS REACHING THE CHANGE: test_tax_rounds_half_up @ tests/test_pricing.py:15 test_order_total @ tests/test_pricing.py:19

Hybrid search: meaning and exact words

Fig 07Dense vectors catch meaning ("integer cents" finds text about money); BM25 catches identifiers like compute_tax. Reciprocal rank fusion puts results that rank well in both first.

Graph builder. The indexer runs graphify extract --code-only (tree-sitter, 25+ languages, zero LLM cost) and falls back to a built-in, import-aware Python AST builder that emits the same schema. Graph blobs live in Redis, gzip-compressed and versioned, so context-service replicas stay stateless.

Bonus · MCP

The same retrieval tools are exposed over MCP, so the index can be queried from Claude Code or Cursor:

claude mcp add graphreview -- graphreview mcp --repo acme/shop --context-url http://localhost:8001
05 · Merge, then judge

Agreement is evidence; a skeptic gets the last word

Fig 08A real run of seeded case 01: the security agent and ruff both flag line 8, so they merge into one finding tagged with both.

Four agents on the same diff often flag the same problem. The aggregator clusters findings in the same file within ±3 lines that share a category or similar wording, keeps the most severe and confident one, and records who agreed.

Consensus boost
confidence = 1 − (1 − c) × 0.6(agents − 1)

Two agents at 0.90 → 0.94. Independent agreement is treated as evidence, not noise.

The critic is a second Claude pass with a different job: be skeptical. It sees the diff, the packed graph context and the proposed findings, returns keep/drop, confidence and severity per finding, drops anything under 0.6, and writes the summary and verdict. Rejected findings aren't deleted; the dashboard lists them under "Rejected by critic".

06 · When things fail

At-least-once delivery, idempotent everywhere

Fig 09Offsets commit only after the handler and its publish succeed, or after the message is parked in review.dlq. One bad message can't block a partition forever.
What goes wrongWhat happens
GitHub redelivers a webhookSame repo + PR + head SHA returns the existing job id (SETNX dedupe:…)
Claude refuses or the API errorsSDK retries 429/5xx; server-side fallback re-runs refusals; otherwise the agent publishes a result carrying the error
An agent never reportsDeadline sweeper flushes partial results after 300 s; agent shown as timeout
The same result arrives twiceStored per (job_id, agent), so the second write is a no-op; flush guarded by SETNX
Posting to GitHub failsGuard key removed so the retry can post; after 3 failures → review.dlq
A pod is killed mid-reviewOffset not committed, another replica re-reads it; a 300 s grace period lets most reviews finish
PR says "ignore previous instructions…"All PR content framed as untrusted data; the output schema limits what the model can return

Long LLM handlers get max.poll.interval.ms=600000 and max_poll_records=1. The producer is idempotent with acks=all.

07 · Kubernetes & autoscaling

Scale agents on lag, not CPU

Agents spend their time waiting on Claude, so CPU says nothing about how far behind they are. The right signal is consumer lag: messages waiting for the group. KEDA turns lag into replicas, with max replicas equal to the partition count.

Fig 10Gateway and context service scale on CPU via HPA; every agent role and the critic scale on their own lag via KEDA ScaledObject resources.

Measured: 40 PRs at once on a laptop cluster

Fig 11Sampled every 10 s. The security and tests agents followed the same curve; the critic scaled separately, to 4.
1→6
Replicas in ~20 s
~50 s
Backlog drained
41/41
Reviews completed
Setup: kind, one node, 6 vCPU / 10 GB, offline LLM with 2 s simulated latency. A fourth critic pod stayed Pending: memory requests hit 98% of the node while real usage was 44%. In production a cluster autoscaler adds a node; the dev overlay lowers requests instead.
  • Strimzi runs Kafka (KRaft) from Kafka, KafkaNodePool and KafkaTopic resources. Prod overlay: 3 brokers, RF=3, min ISR 2, TLS Ingress.
  • Hardened pods: non-root, read-only root FS, all capabilities dropped, seccomp, default-deny NetworkPolicies, PodDisruptionBudgets.
  • Observability: Prometheus metrics for bus outcomes (ok / retry / dlq), LLM tokens by kind including cache reads, tool calls, retrieval latency, critic rejections and webhook-to-review latency, with a provisioned Grafana dashboard. JSON logs carry job_id across services.
08 · Evaluation

Seeded bugs, neutral PR titles, three context modes

A small storefront app with team conventions in CONTRIBUTING.md, plus 11 cases. Each is a find/replace edit under a neutral PR title, so the agent can't read the answer off the description.

#Seeded bugNeedsOffline engine
01Email lookup switched to an f-string SQL querysecuritycaught
02Discount computed with floatsconventionmissed
03Authorization check removed from a handlersecurityconventionmissed
04Payment call loses its timeoutconventioncaught
05compute_tax gains a parameter; callers not updatedgraphmissed
06Refund failures swallowederror handlingcaught
07PBKDF2 replaced with MD5securitycaught
08Pagination offset off by one pagelogicmissed
09average() divides by a possibly empty listedge casecaught
10Stock check invertedlogicmissed
11Docstring-only changenegative controlclean

The deterministic offline engine scores precision 1.0, recall 0.5: it finds the five pattern bugs and nothing else. The other five need reasoning, team conventions or the graph, which is exactly what the Claude ablation measures. make eval runs diff_only vs full_files vs graph_rag and reports recall, precision, false positives on the clean PR, tokens, estimated $ per review and latency.

Recall counts a seeded bug as hit by a finding in the same file within ±3 lines. Test-gap findings are reported separately from precision. The offline engine is what CI runs; it speaks the same response format as the Claude API, so Kafka, the tool loop, merging, the critic step and the dashboard all run for real without a key.
09 · Claude usage

Lean on the API's guarantees, not regex

Structured outputs

Final answers are schema-constrained JSON and tool schemas are strict. Parsing never fails.

Prompt caching

Each loop turn re-reads system prompt, tools and earlier turns from cache. Grafana tracks cache-hit share per agent.

Effort per role

claude-opus-5-5 by default; high effort for reviewer and security, medium for tests and critic.

Refusal fallback

Server-side fallback re-runs a declined request on a fallback model instead of failing the review.

Injection hygiene

Diff, code and docs are framed as untrusted data; repo names are validated before cloning.

LLM-as-judge

A second, skeptical pass decides what ships, so four agents don't bury a PR in plausible noise.

10 · Trade-offs

Decisions I'd defend, and what they cost

  • One consumer group per agent role gives fan-out without a router and lets each role scale on its own lag. A slow security agent doesn't hold back static analysis.
  • The graph is built from the default branch. Symbols a PR adds aren't in it yet, so the impact report falls back to file-level seeds and agents read the PR head directly.
  • Single-message processing (max_poll_records=1) keeps commit semantics simple. Throughput comes from partitions × replicas, not in-process concurrency.
  • Graph blobs in Redis keep context replicas stateless with one cache-invalidation signal. For very large monorepos they'd move to object storage.

Next

  • A "review memory": index resolved and dismissed comments so the critic learns from reviewer feedback.
  • GitHub App auth and Check Runs instead of a personal token.
  • OpenTelemetry tracing across Kafka hops.
  • Per-repo agent configuration via .graphreview.yml.
Run it

Whole pipeline in one command, no Docker, no API key

# in-process: every service on an in-memory bus, embedded Qdrant make install && make demo # full stack + live dashboard on :8080, Grafana on :3000 make up # kind + Strimzi + KEDA make k8s-up