DEV Community

yureki_lab
yureki_lab

Posted on

How I Got My AI Agent to Catch Its Own Bugs: 5 Lessons on Self-Verification

TL;DR

I built an autonomous coding agent on Claude Code that kept confidently shipping code that looked right and was subtly broken. The fix wasn't a smarter model β€” it was a second agent whose only job is to try to prove the first one wrong. Here are 5 lessons from bolting an adversarial self-verification loop onto an AI agent. πŸ§ͺ

The Problem

My setup is simple on paper: a "builder" agent takes a task, edits code, and reports back "βœ… done." For months I trusted that report. Then I started actually diffing what it shipped.

The failure mode was never a dramatic crash. It was quiet:

  • An off-by-one in a pagination helper that only bit past page 1
  • A "fixed" race condition that just moved the window where it happened
  • A refactor that passed the existing tests because the existing tests never covered the branch it changed

The model was not lying. It genuinely believed the work was correct β€” the same way a human is worst at reviewing their own code five minutes after writing it. Confidence is not correctness, and an agent that grades its own homework will happily give itself an A.

The one that finally broke me: the agent "fixed" a caching bug by clearing the cache on every read. Green checkmark. Tests passed. It also quietly made a hot path do a full recompute on every single request β€” a ~40x slowdown that no assertion in the suite was looking for. The agent had solved the stated problem and created a worse one, and its self-report said "βœ… done, cache bug resolved." That's when I stopped trusting the reporter and started building a referee.

I needed a referee. Not a smarter builder β€” a skeptic that assumes the builder is wrong until proven otherwise.

How I Solved It

The core idea: after the builder finishes, spawn a separate verifier agent whose prompt is adversarial by construction. It is not asked "is this correct?" (models love to say yes). It is asked "find the input that makes this wrong. Default to REJECT if you can't prove it's safe."

Here's the loop:

flowchart LR
    A[Task] --> B[Builder agent<br/>writes the change]
    B --> C{Verifier agents<br/>try to refute}
    C -->|majority reject| D[Send back with<br/>the failing case]
    D --> B
    C -->|majority accept| E[Merge]
Enter fullscreen mode Exit fullscreen mode

The verifier is forced to return a structured verdict instead of prose, so I can act on it in code rather than parsing vibes:

type Verdict = {
  refuted: boolean;           // true = builder's work is broken
  failingCase: string | null; // concrete input/state that breaks it
  lens: "correctness" | "security" | "repro";
  confidence: number;         // 0-1
};
Enter fullscreen mode Exit fullscreen mode

Two details did most of the work.

1. Multiple verifiers, distinct lenses. One skeptic misses things a redundant skeptic also misses. So I run three in parallel, each with a different mandate β€” one hunts logic bugs, one hunts security/input-validation holes, one just tries to reproduce the claimed behavior from scratch. Diversity catches failure modes that redundancy can't.

const lenses = ["correctness", "security", "repro"] as const;

const verdicts = await Promise.all(
  lenses.map((lens) =>
    runVerifier({
      change: builderDiff,
      lens,
      instruction:
        `You are a skeptic. Try to REFUTE this change through the ` +
        `${lens} lens. Return a concrete failing case. ` +
        `If you cannot prove it is safe, set refuted = true.`,
    })
  )
);

// Majority vote β€” one loud false positive can't block progress,
// and one optimistic pass can't wave broken code through.
const rejects = verdicts.filter((v) => v.refuted).length;
const accepted = rejects < 2;
Enter fullscreen mode Exit fullscreen mode

2. Reject with a repro, not an opinion. A verdict of "this looks risky" is useless to the builder β€” it'll just reword the same code. A verdict of failingCase: "empty array β†’ throws on line 12" is a test case. When rejection carries a concrete failing input, the builder's next attempt is grounded instead of guessing.

The whole thing is a negotiation: propose β†’ refute β†’ repro β†’ fix β†’ re-refute, until two of three skeptics can't break it.

One thing I didn't expect: the loop usually terminates in one or two rounds, not five. Most changes are actually fine, and the panel confirms that fast. The value isn't in endlessly grinding β€” it's that the ~15% of changes that are broken get caught before they land, with a concrete repro attached. I cap the loop at three rounds; if the skeptics and the builder still can't agree by then, I escalate to a human instead of burning tokens in a standoff. A loop with no exit condition is just a more expensive way to hang.

async function verifiedBuild(task: Task, maxRounds = 3) {
  let diff = await build(task);
  for (let round = 0; round < maxRounds; round++) {
    const verdicts = await runPanel(diff);
    if (verdicts.filter((v) => v.refuted).length < 2) return diff; // accepted
    const repros = verdicts.filter((v) => v.refuted).map((v) => v.failingCase);
    diff = await build(task, { previousDiff: diff, mustHandle: repros });
  }
  return escalateToHuman(task, diff); // no silent "good enough"
}
Enter fullscreen mode Exit fullscreen mode

Lessons Learned

1. An agent grading its own work is worthless. Self-assessment from the same context that produced the code inherits the same blind spots. The verifier must run in a fresh context with an adversarial prompt. The moment they share context, you're back to a rubber stamp.

2. "Prove it's correct" and "find where it breaks" produce opposite behavior. Framing is not cosmetic. Ask a model to confirm and it confirms; ask it to break something and it actually looks. I default every verifier to refuted = true unless it can show safety β€” guilty until proven innocent.

3. Diverse skeptics beat more identical skeptics. My first version ran three copies of the same verifier. They agreed with each other and missed the same bugs β€” expensive redundancy. Giving each a distinct lens (correctness / security / repro) roughly doubled the real issues caught for the same token spend.

4. Structured verdicts turn judgment into control flow. Free-text reviews force you to re-parse the model's mood. A typed Verdict with refuted and failingCase lets a plain if decide whether to merge or loop. Push judgment to the tool-call layer and your orchestration stays boring β€” which is exactly what you want.

5. Verification isn't free, so gate it. Three verifiers per change is real latency and tokens. I only run the full panel on changes that touch logic; typo-fixes and doc edits skip it. Cranking the skeptic count to five bought me almost nothing over three β€” there's a sharp diminishing return, so measure before you scale it.

Here's the rough shape of what the panel actually caught over a couple hundred logic-touching changes, once I started logging it:

Signal Roughly
Changes that passed clean on round 1 ~70%
Changes fixed after 1 repro ~20%
Changes needing 2+ rounds ~8%
Escalated to a human ~2%

The headline number for me: about 1 in 6 changes that the builder called "done" were not done β€” and almost none of those would have been caught by the pre-existing test suite, because the builder tends to write code that satisfies the tests it can see. The skeptic's job is to care about the tests that don't exist yet.

What's Next

Right now the failing cases the skeptics invent are thrown away after the loop closes. The obvious next step is to promote them into the real test suite β€” every bug the verifier catches becomes a permanent regression test, so the builder can never reintroduce it. An adversary that leaves behind tests is an adversary that compounds.

I'm also experimenting with letting the verifier grade itself out of the loop: if a lens hasn't caught anything real in N runs, it's probably miscalibrated and should be re-prompted or retired. And I want to feed the failing cases back as few-shot examples for the builder, so it starts pre-empting the classes of bug the skeptics keep finding β€” closing the gap from the other side instead of relying on the referee to catch everything forever.

Wrap-up

The single highest-leverage change I made to an autonomous agent wasn't a better model or a longer prompt β€” it was giving it an opponent. If your AI agent reports "βœ… done" and you believe it, you're the verifier now, and you will miss things too.

If you're building with Claude Code, try this: next time your agent finishes a task, spawn a second one whose only instruction is "prove this is broken." You'll be surprised how often it can.

If this was useful:

  • πŸ”– Follow me here on Dev.to β€” I write build-in-public logs on AI agent design
  • πŸ’¬ Drop your own agent war stories in the comments β€” what's the sneakiest bug yours has shipped?
  • πŸš€ Try Claude Code and wire up your own skeptic loop

Catch you in the next one. πŸ‘‹

Top comments (0)