Since AI started writing most of the code in my PRs, my review time has paradoxically increased. AI-generated code looks clean on the surface, but it often skips error handling, misses security considerations, or carries subtle design issues that are easy to miss if you're reviewing five PRs in an afternoon.
The turning point came when I realized: if AI is writing the code, why not use AI to review it? But throwing a diff at a single LLM with "please review this" doesn't work well. Code review, test coverage analysis, and security scanning require different expertise, different prompting strategies, and different models. Cramming them into one agent produces mediocre results across all three.
This guide walks you through building a multi-agent quality gate — three specialized agents running in parallel, coordinated by an AgentKit 2.0 orchestrator — that posts a unified review comment to every PR. I'll share the design decisions, working code, and the production mistakes that shaped the architecture.
Architecture Overview: Three Agents, One Gate
The quality gate triggers on every PR open and update event. Here's how the pieces fit together.
GitHub Actions fires a workflow that spins up an AgentKit 2.0 orchestrator. The orchestrator launches three sub-agents in parallel:
- Code Review Agent — reads the diff, identifies design issues, readability problems, and best-practice violations
- Test Generation Agent — checks whether changed source files have adequate test coverage, and generates missing test cases
- Security Scan Agent — looks for SQL injection, XSS, hardcoded secrets, unsafe randomness, and other OWASP-class issues
When all three complete, the orchestrator aggregates their findings and posts a single structured comment to the PR. If any finding is marked critical, it fails the commit status check and blocks merging.
The key design principle here is agent specialization. I tried a single-agent approach first, and it consistently missed security issues — the model was context-switching between "is this readable?" and "is this vulnerable?" and doing neither well. Dedicated agents with focused system prompts perform noticeably better.
Orchestrator Implementation
The orchestrator uses Promise.allSettled to run all three agents in parallel and handle individual agent failures gracefully.
// scripts/quality-gate/orchestrator.ts
import { GoogleGenAI } from "@google/genai";
import { Octokit } from "@octokit/rest";
interface PRContext {
owner: string;
repo: string;
prNumber: number;
diff: string;
changedFiles: string[];
headSha: string;
}
interface Finding {
file: string;
line?: number;
message: string;
severity: "critical" | "warning" | "info";
suggestion?: string;
}
interface AgentResult {
agentName: string;
findings: Finding[];
severity: "critical" | "warning" | "info" | "pass";
tokensUsed: number;
}
export async function runQualityGate(ctx: PRContext): Promise<void> {
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
// Run all three agents in parallel — failure of one doesn't block others
const [reviewResult, testResult, securityResult] = await Promise.allSettled([
runCodeReviewAgent(ctx),
runTestGenerationAgent(ctx),
runSecurityScanAgent(ctx),
]);
const results: AgentResult[] = [];
for (const result of [reviewResult, testResult, securityResult]) {
if (result.status === "fulfilled") {
results.push(result.value);
} else {
// Treat individual agent failures as warnings, not blockers
console.error("Agent failed:", result.reason);
results.push({
agentName: "unknown",
findings: [{
file: "N/A",
message: `Agent execution error: ${result.reason?.message ?? "unknown"}`,
severity: "warning",
}],
severity: "warning",
tokensUsed: 0,
});
}
}
const comment = formatComment(results);
// Update existing comment or create new one
const existingComments = await octokit.issues.listComments({
owner: ctx.owner, repo: ctx.repo, issue_number: ctx.prNumber,
});
const existingComment = existingComments.data.find(c =>
c.body?.includes("AI Quality Gate Results")
);
if (existingComment) {
await octokit.issues.updateComment({
owner: ctx.owner, repo: ctx.repo,
comment_id: existingComment.id, body: comment,
});
} else {
await octokit.issues.createComment({
owner: ctx.owner, repo: ctx.repo,
issue_number: ctx.prNumber, body: comment,
});
}
// Block merge on critical findings
const hasCritical = results.some(r =>
r.findings.some(f => f.severity === "critical")
);
await octokit.repos.createCommitStatus({
owner: ctx.owner,
repo: ctx.repo,
sha: ctx.headSha,
state: hasCritical ? "failure" : "success",
description: hasCritical
? "Critical issues found — review required"
: "AI quality gate passed",
context: "ai-quality-gate",
});
if (hasCritical) process.exit(1);
}The choice of Promise.allSettled over Promise.all matters in production. AI agents talk to external APIs, and transient failures are common — rate limits, timeout spikes, model overload. With Promise.all, a single agent failure aborts the entire gate. With allSettled, you get the results from two healthy agents even when one hiccups, and you record the failure as a warning rather than an outright blocker.
Code Review Agent
The code review agent uses Gemini 2.5 Pro. For this task, deeper reasoning matters more than speed — Pro catches design-level issues that Flash often glosses over.
// scripts/quality-gate/agents/code-review-agent.ts
const client = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
export async function runCodeReviewAgent(ctx: PRContext): Promise<AgentResult> {
const systemPrompt = `You are a senior engineer performing a code review.
Analyze the diff and return findings as JSON.
Review for:
- Readability and maintainability
- DRY and single responsibility violations
- Missing error handling
- Performance issues (N+1 queries, redundant loops)
- Type safety gaps (for TypeScript)
Output format:
{
"findings": [
{
"file": "path/to/file",
"line": 42,
"message": "Description of issue",
"severity": "critical|warning|info",
"suggestion": "How to fix it"
}
]
}
Severity guide:
- critical: bug risk, data loss, serious performance issue
- warning: best-practice violation, significant readability problem
- info: style suggestion, minor improvement
- Return an empty array if no issues found — do not force findings.`;
const response = await client.models.generateContent({
model: "gemini-2.5-pro",
contents: [{
role: "user",
parts: [{ text: `Review this diff:\n\`\`\`diff\n${ctx.diff.slice(0, 50000)}\n\`\`\`` }],
}],
config: {
systemInstruction: systemPrompt,
responseMimeType: "application/json",
temperature: 0.1,
maxOutputTokens: 4096,
},
});
let parsed: { findings: Finding[] };
try {
parsed = JSON.parse(response.text ?? "{}");
} catch {
parsed = { findings: [] };
}
const severity = parsed.findings.some(f => f.severity === "critical") ? "critical"
: parsed.findings.some(f => f.severity === "warning") ? "warning"
: "pass";
return {
agentName: "Code Review",
findings: parsed.findings,
severity,
tokensUsed: response.usageMetadata?.totalTokenCount ?? 0,
};
}Setting responseMimeType: "application/json" is non-negotiable. Without it, Gemini returns JSON wrapped in markdown code fences, which requires fragile regex extraction. With it, you get clean JSON every time. I wasted an afternoon writing a JSON extractor before I found this option.
The instruction "Return an empty array if no issues found" is equally important. Without it, the agent feels compelled to flag something on every PR — and your engineers start ignoring it.
Test Generation Agent
The test agent runs on Flash. Generating test scaffolding is more about pattern matching than deep reasoning, and Flash handles it faster and at lower cost.
// scripts/quality-gate/agents/test-generation-agent.ts
export async function runTestGenerationAgent(ctx: PRContext): Promise<AgentResult> {
// Only analyze source files — skip test files themselves
const sourceFiles = ctx.changedFiles.filter(f =>
!f.includes(".test.") &&
!f.includes(".spec.") &&
!f.includes("__tests__") &&
(f.endsWith(".ts") || f.endsWith(".tsx") || f.endsWith(".js"))
);
if (sourceFiles.length === 0) {
return {
agentName: "Test Generation",
findings: [{ file: "N/A", message: "No testable source files changed", severity: "info" }],
severity: "pass",
tokensUsed: 0,
};
}
const systemPrompt = `You are a test engineer.
Review the diff and assess whether the changed source files have adequate test coverage.
If tests are missing, suggest concrete test code using Vitest/Jest syntax.
Output format:
{
"findings": [
{
"file": "changed source file",
"message": "Description of coverage gap",
"severity": "warning|info",
"suggestion": "Example test code to add"
}
]
}
Check specifically:
1. New functions/methods — do they have unit tests?
2. Error paths and edge cases
3. Async operations — are they properly awaited in tests?`;
const response = await client.models.generateContent({
model: "gemini-2.5-flash",
contents: [{
role: "user",
parts: [{
text: `Changed files: ${sourceFiles.join(", ")}\n\n\`\`\`diff\n${ctx.diff.slice(0, 40000)}\n\`\`\``,
}],
}],
config: {
systemInstruction: systemPrompt,
responseMimeType: "application/json",
temperature: 0.2,
maxOutputTokens: 8192,
},
});
let parsed: { findings: Finding[] };
try {
parsed = JSON.parse(response.text ?? "{}");
} catch {
parsed = { findings: [{ file: "N/A", message: "Failed to parse test analysis", severity: "info" }] };
}
const severity = parsed.findings.some(f => f.severity === "warning") ? "warning" : "pass";
return {
agentName: "Test Generation",
findings: parsed.findings,
severity,
tokensUsed: response.usageMetadata?.totalTokenCount ?? 0,
};
}The source file filter prevents a silly but real problem: when test files themselves change, you don't want the agent suggesting tests for your tests. Filtering by extension also avoids the agent trying to analyze YAML, markdown, or migration SQL files.
Security Scan Agent with Retry Logic
The security agent uses Flash and implements exponential backoff retry — because security gaps you don't catch are worse than a slightly slower CI run.
// scripts/quality-gate/agents/security-scan-agent.ts
export async function runSecurityScanAgent(ctx: PRContext): Promise<AgentResult> {
const systemPrompt = `You are a security engineer performing a code security review.
Check for these vulnerability patterns:
- SQL injection (dynamic query construction)
- XSS (unsanitized user input in output)
- Hardcoded secrets (API keys, passwords, tokens — but NOT placeholder values like YOUR_API_KEY)
- Unsafe randomness (Math.random() for security purposes)
- Authentication/authorization bypass
- Open redirect
- Path traversal
IMPORTANT: Only mark something as critical/warning if you are confident it is a real vulnerability.
Mark uncertain cases as info only. Return an empty array if nothing is found.
Ignore .env.example and .env.sample files.
Output format:
{
"findings": [
{
"file": "path/to/file",
"line": 42,
"message": "Specific vulnerability description",
"severity": "critical|warning|info",
"suggestion": "How to fix it"
}
]
}`;
const maxRetries = 3;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await client.models.generateContent({
model: "gemini-2.5-flash",
contents: [{
role: "user",
parts: [{ text: `\`\`\`diff\n${ctx.diff.slice(0, 45000)}\n\`\`\`` }],
}],
config: {
systemInstruction: systemPrompt,
responseMimeType: "application/json",
temperature: 0.1,
maxOutputTokens: 4096,
},
});
let parsed: { findings: Finding[] };
try {
parsed = JSON.parse(response.text ?? "{}");
} catch {
parsed = { findings: [] };
}
const severity = parsed.findings.some(f => f.severity === "critical") ? "critical"
: parsed.findings.some(f => f.severity === "warning") ? "warning"
: "pass";
return {
agentName: "Security Scan",
findings: parsed.findings,
severity,
tokensUsed: response.usageMetadata?.totalTokenCount ?? 0,
};
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt < maxRetries) {
// Exponential backoff: 2s, 4s, 8s
await new Promise(r => setTimeout(r, 2 ** attempt * 1000));
}
}
}
// After 3 failures, record as warning — don't block the pipeline
return {
agentName: "Security Scan",
findings: [{
file: "N/A",
message: `Security scan could not complete: ${lastError?.message ?? "unknown"}`,
severity: "warning",
}],
severity: "warning",
tokensUsed: 0,
};
}Why does only the security agent get retry logic here? Because a missed security issue is more costly than a slow PR. The other agents are informational — missing a code style suggestion is inconvenient. Missing an SQL injection is a different category of problem.
The three-failure fallback marks the result as warning rather than critical. If the scan infrastructure is down, blocking every merge in the repository helps no one. Better to flag "we couldn't check this" and let the engineer make a human judgment call.
GitHub Actions Workflow
Place this in .github/workflows/ai-quality-gate.yml:
name: AI Quality Gate
on:
pull_request:
types: [opened, synchronize, reopened]
branches: [main, develop]
concurrency:
group: quality-gate-${{ github.head_ref }}
cancel-in-progress: true
jobs:
quality-gate:
name: AI Code Review & Security Scan
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
pull-requests: write
statuses: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- run: npm ci --workspace=scripts/quality-gate
- name: Get PR diff
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr diff ${{ github.event.pull_request.number }} \
--repo ${{ github.repository }} > /tmp/pr.diff
- name: Run AI Quality Gate
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO_OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
npx tsx scripts/quality-gate/index.ts \
--diff /tmp/pr.diff \
--pr $PR_NUMBER \
--owner $REPO_OWNER \
--repo $REPO_NAME \
--sha $HEAD_SHA
- name: Upload logs
if: always()
uses: actions/upload-artifact@v4
with:
name: quality-gate-logs
path: /tmp/quality-gate-*.json
retention-days: 7Three settings here that I got wrong before getting right:
timeout-minutes: 10 — without this, a rate-limited agent stuck in retry can run for 30+ minutes and burn through your GitHub Actions quota. Set a hard cap.
fetch-depth: 0 — the default shallow clone doesn't include enough history to generate a proper diff against the base branch. This one caused mysterious empty diffs that had me confused for hours.
concurrency.cancel-in-progress: true — when you push multiple commits quickly, you only care about the latest diff. This cancels queued runs for the same branch, saving API costs and keeping CI fast.
Common Pitfalls and Fixes
Diff overflow crashes context windows. I trim diffs to 50,000 characters per agent and skip PRs with more than 20 changed files, posting a comment that says "This PR is too large for automated review — please split it." Large PRs are hard for humans to review too, so enforcing smaller PRs has been a net positive for the team.
Parallel agents hit rate limits. Three simultaneous Gemini API calls from the same project can trigger RPM limits. The fix is mixing Pro (code review) and Flash (test, security) to distribute quota, and using concurrency in Actions to prevent simultaneous workflows on the same branch.
Security agent flags placeholder values as secrets. Entries like YOUR_API_KEY_HERE in .env.example files were being flagged as hardcoded secrets. Adding "Ignore placeholder values and .env.example files" to the system prompt cut those false positives by about 80%.
Duplicate comments accumulate on every push. Each synchronize event creates a new comment unless you check for existing ones first. The orchestrator code above handles this by searching for a previous "AI Quality Gate Results" comment and updating it instead of appending.
Agents always find something. Without explicit permission to return an empty array, models feel compelled to flag something on every PR. The phrase "Return an empty array if nothing is found" in the system prompt is genuinely necessary — I've A/B tested it and it makes a measurable difference to false positive rates.
Cost Management and Monitoring
Typical token consumption per PR, based on my usage:
- Code Review Agent (Pro): 8,000–25,000 tokens
- Test Generation Agent (Flash): 5,000–15,000 tokens
- Security Scan Agent (Flash): 4,000–12,000 tokens
For a medium-sized PR, total consumption is around 20,000 tokens. At current Gemini 2.5 Pro/Flash pricing, that's roughly $0.05–$0.15 per PR. A team merging 10 PRs/day runs about $30–$50/month — comparable to one seat of a developer tool.
I track agent costs via LangFuse (see Antigravity × LangFuse Agent Observability Guide for setup details). The four metrics I focus on:
- Per-agent latency
- Token consumption by model
- Finding count and severity distribution
- False positive rate (manually tracked by reviewing dismissed comments monthly)
The false positive rate is the hardest to measure but the most important. When engineers start ignoring the quality gate because it's too noisy, you've lost the whole benefit. I track it by sampling 20 PR comments per month and counting how many findings were addressed versus dismissed. My target is below 20%.
For deeper patterns on parallel agent orchestration, see Advanced Multi-Agent Orchestration and AgentKit 2.0 Complete Guide.
PR Comment Formatting: Making Results Actionable
A quality gate is only useful if engineers read the results. Walls of text get skimmed; structured, scannable output gets acted on. Here's the comment formatter that produces clean, consistently formatted output:
// scripts/quality-gate/format-comment.ts
export function formatComment(results: AgentResult[]): string {
const allFindings = results.flatMap(r => r.findings);
const criticals = allFindings.filter(f => f.severity === "critical");
const warnings = allFindings.filter(f => f.severity === "warning");
const totalTokens = results.reduce((sum, r) => sum + r.tokensUsed, 0);
const statusEmoji = criticals.length > 0 ? "🔴" : warnings.length > 0 ? "🟡" : "✅";
const statusLine = criticals.length > 0
? `${criticals.length} critical issue(s) found — merge blocked until resolved.`
: warnings.length > 0
? `${warnings.length} warning(s) found — review recommended before merging.`
: "All checks passed. No issues detected.";
let comment = `## ${statusEmoji} AI Quality Gate Results
${statusLine}
`;
for (const result of results) {
const nonInfoFindings = result.findings.filter(f => f.severity !== "info");
if (nonInfoFindings.length === 0 && result.severity === "pass") {
comment += `### ${result.agentName} ✅
No issues found.
`;
continue;
}
comment += `### ${result.agentName}
`;
// Sort by severity: critical first, then warning, then info
const sorted = [...result.findings].sort((a, b) => {
const order = { critical: 0, warning: 1, info: 2 };
return order[a.severity] - order[b.severity];
});
for (const finding of sorted) {
const icon = { critical: "🔴", warning: "🟡", info: "ℹ️" }[finding.severity];
const location = finding.line
? `\`${finding.file}:${finding.line}\``
: `\`${finding.file}\``;
comment += `${icon} ${location}
`;
comment += `${finding.message}
`;
if (finding.suggestion) {
comment += `> 💡 **Suggestion:** ${finding.suggestion}
`;
}
}
}
comment += `---
`;
comment += `*Tokens used: ${totalTokens.toLocaleString()} | `;
comment += `Agents: ${results.map(r => r.agentName).join(", ")}*
`;
return comment;
}Displaying the token count in the comment has a behavioral effect I didn't anticipate: engineers start writing smaller PRs. When every review comment ends with "Tokens used: 47,221," the implicit message is "this PR was expensive to review, and probably expensive for humans too." It nudges teams toward tighter, more focused pull requests without any policy enforcement.
Sorting findings by severity — criticals first — ensures the most important issues are visible above the fold. Engineers scan quickly; if warnings appear before criticals, criticals get missed.
Tuning the System Prompts Over Time
The system prompts I've shown here are a starting point, not a final answer. Expect to iterate on them for the first few months. Here's how I approach prompt tuning for each agent.
Code Review Agent tuning. The most common failure mode is over-flagging style issues as warning. If your codebase has ESLint or Biome configured, add a line to the system prompt: "This codebase uses ESLint/Biome for style enforcement — do not flag style issues that would be caught by a linter." This alone reduced warning volume by roughly 30% in my setup.
Test Generation Agent tuning. The agent tends to suggest tests that duplicate what already exists when it can't see the test file. Adding "Note: existing test files are not included in this diff — assume basic coverage exists unless the diff shows it is missing" helps. You can also feed the agent the existing test file content alongside the diff for higher-quality suggestions, though this increases token consumption.
Security Agent tuning. False positives cluster around a few categories: example files, commented-out code, and documentation snippets that contain SQL-like syntax. Addressing each one explicitly in the system prompt compounds — each addition reduces a different class of noise. My current security prompt has eight specific exclusion clauses built up over six months of operation.
The key discipline is not to tune prompts reactively after every false positive. Instead, collect a batch of false positives each month, find the patterns, and make one deliberate update per agent. Micro-optimizing after each PR leads to brittle prompts that overfit to recent history.
Rolling Out to an Existing Team
If you're adding this to a codebase with an established review culture, a gradual rollout reduces friction.
Phase 1 — Observation only (weeks 1–2). Run the gate but don't fail the commit status. Post the comment as informational only. This lets you tune prompts without blocking anyone's work.
Phase 2 — Warnings visible, criticals soft-blocked (weeks 3–4). Fail the commit status on critical findings only, but make it a non-required check so engineers can still merge if needed. Track how often they choose to merge over a critical finding.
Phase 3 — Full gate (week 5+). Make the check required on main. By this point, you've tuned the prompts enough that criticals are genuinely critical, and the team trusts the signal.
I skipped Phase 1 on my first deployment and immediately had engineers pushing --no-verify workarounds within a week. The gradual rollout feels slower, but the adoption rate is meaningfully better.
One more practical note: add a [skip-ai-gate] escape hatch to the workflow trigger. When an engineer needs to merge an emergency hotfix and the gate is slow, having no escape route creates real production risk.
on:
pull_request:
types: [opened, synchronize, reopened]
branches: [main, develop]
jobs:
quality-gate:
if: "!contains(github.event.pull_request.title, '[skip-ai-gate]')"
# ... rest of job configUse this sparingly — but the existence of an emergency exit makes engineers more willing to accept the gate as a default.
Handling Large Diffs and Monorepos
The 50,000-character truncation works well for typical PRs but breaks down in two scenarios: large feature PRs and monorepos where a single PR touches dozens of packages.
For large diffs, I've added a pre-filter that extracts the most changed files:
// Get the top 10 most-changed files by line count
function extractTopChangedFiles(diff: string, maxFiles = 10): string {
const fileSections = diff.split(/^diff --git/m).filter(Boolean);
// Sort by section length (proxy for number of changes)
const sorted = fileSections
.map(section => ({ section, size: section.length }))
.sort((a, b) => b.size - a.size)
.slice(0, maxFiles);
return sorted.map(s => "diff --git" + s.section).join("");
}This isn't perfect — a large file with few meaningful changes scores higher than a small file with many critical changes — but it's a practical heuristic that keeps token consumption bounded while retaining the most relevant context.
For monorepos, I scope the quality gate per package directory. If only packages/api changed, there's no reason to send packages/web's diff to the agents. The changedFiles list makes this straightforward:
// Detect which packages are affected
const affectedPackages = [...new Set(
ctx.changedFiles
.filter(f => f.startsWith("packages/"))
.map(f => f.split("/")[1])
)];
// Filter diff to affected packages only
const filteredDiff = ctx.diff
.split(/^diff --git/m)
.filter(section => affectedPackages.some(pkg => section.includes(`packages/${pkg}/`)))
.join("
");Combined with the top-files filter, this keeps per-PR token consumption predictable even as the codebase grows.
Measuring Real-World Impact
After six months of running this system, here are the metrics that convinced me it was worth the investment:
Bug escape rate to production dropped by ~35%. I track this by counting post-merge bug reports that could have been caught at review time. The security agent has been the biggest contributor — it caught three instances of SQL injection in dynamically constructed queries that human reviewers missed.
Review cycle time decreased. Counter-intuitively, adding an automated review step that takes 2–3 minutes made PRs merge faster. Reviewers spend less time on surface-level issues (the agent handles those) and can focus their attention on architecture and business logic. PRs that used to sit waiting for review for 2–3 hours now often get human approval within 30 minutes.
Test coverage trends upward without mandate. When engineers see the test generation agent flagging missing coverage on every PR, they start writing tests preemptively. The social pressure of the visible comment is a lighter-weight mechanism than enforcing coverage thresholds in CI.
False positive rate stabilized at around 18%. This means roughly 1 in 5 flagged issues isn't worth acting on. That's not perfect, but it's well below the 40% I started with, and it's low enough that engineers don't dismiss the gate reflexively.
The most surprising outcome: engineers started citing the AI gate in code reviews. "The security agent flagged a potential issue in this pattern — here's the context" became a normal phrase in PR comments. The gate stopped being a CI hurdle and became part of the review conversation.
Start With One Agent
The fastest path to production is starting with just the security scan agent. It's the simplest to implement, the easiest to justify to your team ("we're scanning for vulnerabilities"), and the results are the most concrete.
Get it running on a real branch this week. Watch what it flags. Tune the system prompt to reduce false positives. Then, once you trust it, layer in the code review and test generation agents.
AI reviewing AI-generated code sounds circular, but in practice it works — each agent brings a different lens, and catching issues before merge is categorically better than catching them in production.