DEV Community

Cover image for Your AI Agent Is Confidently Wrong and You Can't Tell: Building a Judge Gate
Info Inlet
Info Inlet

Posted on

Your AI Agent Is Confidently Wrong and You Can't Tell: Building a Judge Gate

Here is the failure mode nobody screenshots.

You give an agent a goal. It thinks for ninety seconds, prints a confident summary, and hands you a document. The document is well-formatted. The citations are formatted correctly too. One of them is to a paper that does not exist. You don't find out for three weeks.

This is the actual problem with agents in production, and it is not the problem the demos are solving. Demos optimize for "it did something." Production needs "it did the right thing, and something other than the model's own opinion established that."

The gap between those two is an architecture problem, not a prompting problem. Below is the architecture we landed on after getting burned by every shortcut in it. I'll use our own system for the examples because those are the numbers I can quote honestly, but none of this is product-specific — it's the same four pieces whether you're using LangGraph, a homegrown loop, or the SDK of the week.


The core mistake: the agent grades its own homework

Almost every agent framework has this shape:

plan → execute → reflect → done
Enter fullscreen mode Exit fullscreen mode

The reflect step is where the trouble lives. In most implementations, reflection is the same model, in the same conversation, looking at its own output. You have asked a model that just spent 40,000 tokens convincing itself it did a good job whether it did a good job.

It says yes. It says yes ~95% of the time in our measurements, including on tasks we had deliberately sabotaged.

The reason isn't that the model is dumb. It's that the reasoning trace is in the context. Every wrong turn arrives pre-justified. You cannot get an independent opinion from a mind that already has the argument in front of it.

So the first rule of the whole design:

The thing that verifies must not be the thing that produced, and must not see how it was produced.

Everything below is machinery to make that rule enforceable rather than aspirational.


1. Make the plan a data structure, not a paragraph

If your planner outputs prose — "First I'll research the market, then analyze the competitors, then write the summary" — you have nothing to schedule, nothing to price, and nothing to verify. You have a vibe.

Force the plan into a typed graph:

type TaskId = string

interface Task {
  id: TaskId
  goal: string           // what this task must produce
  dependsOn: TaskId[]    // edges — this is what makes waves possible
  accepts: string[]      // acceptance criteria, written BEFORE the work
  budgetUsd: number      // hard ceiling for this node
}

interface Mission {
  goal: string
  tasks: Task[]
}
Enter fullscreen mode Exit fullscreen mode

The field that earns its keep is accepts. The planner has to write the acceptance criteria before any work happens — while it still has no sunk cost and no output to rationalize. Criteria written afterwards always describe what the agent happened to produce.

Concretely, for a "research the top 3 vector databases for a 10M-embedding workload" task, accepts looks like:

[
  "names exactly 3 databases",
  "each has a stated price at 10M vectors, with the source URL",
  "every source URL returns HTTP 200 and contains the quoted figure",
  "states at least one disqualifying limitation per option"
]
Enter fullscreen mode Exit fullscreen mode

Notice these are checkable by someone who never saw the work. That's the bar. If a criterion can only be evaluated by reading the agent's reasoning, it's not a criterion, it's a wish.


2. Run waves, not chains

Once you have edges, scheduling is just a topological sort by depth. Everything at the same depth has no dependency on its siblings, so it runs concurrently:

function waves(tasks: Task[]): Task[][] {
  const byId = new Map(tasks.map(t => [t.id, t]))
  const depth = new Map<TaskId, number>()

  const resolve = (id: TaskId, seen = new Set<TaskId>()): number => {
    if (depth.has(id)) return depth.get(id)!
    if (seen.has(id)) throw new Error(`cycle at ${id}`)
    seen.add(id)
    const t = byId.get(id)!
    const d = t.dependsOn.length
      ? Math.max(...t.dependsOn.map(p => resolve(p, seen))) + 1
      : 0
    depth.set(id, d)
    return d
  }

  tasks.forEach(t => resolve(t.id))
  const out: Task[][] = []
  for (const t of tasks) (out[depth.get(t.id)!] ??= []).push(t)
  return out
}
Enter fullscreen mode Exit fullscreen mode

Two things this buys you that a sequential chain doesn't:

Wall-clock. A five-task mission where three tasks are independent finishes in the time of the longest chain, not the sum. Ours run four waves and typically land in the time one careful sequential pass would have taken to reach step two.

Blast radius. When a task in wave 2 fails verification, you re-run that node, not the mission. In a chain, everything downstream of a bad step is contaminated and you can't tell which parts.

The tempting mistake here is to put a barrier between every stage "for cleanliness." Don't. A barrier is only correct when the next step genuinely needs all of the previous results together — deduplication, a cross-cutting comparison, an early exit when the count is zero. Otherwise you're making your fastest tasks wait on your slowest for no reason.


3. Type the output or you're parsing vibes

Free-text output between agents is where silent corruption enters. "about 200ms" and { p95Ms: 200 } are the same fact to a human and completely different to the next task in the graph.

Force structured output at the tool-call layer, so validation failures become model-visible retries rather than exceptions in your code:

const FINDING = {
  type: 'object',
  required: ['claim', 'sourceUrl', 'confidence'],
  additionalProperties: false,
  properties: {
    claim:      { type: 'string', maxLength: 300 },
    sourceUrl:  { type: 'string', format: 'uri' },
    confidence: { type: 'number', minimum: 0, maximum: 1 },
  },
} as const
Enter fullscreen mode Exit fullscreen mode

additionalProperties: false matters more than it looks. Without it, a model that isn't sure will happily add a notes field containing the hedge it wasn't allowed to put in claim — and your downstream code will never read it. Force the uncertainty into a field you actually check.


4. The judge gate

This is the part that makes the whole thing worth building.

After a task produces an artifact, it does not go downstream. It goes to a judge: a fresh context that receives exactly two things — the acceptance criteria, and the artifact. Not the plan. Not the reasoning. Not the conversation.

async function judge(artifact: string, accepts: string[], lens: string) {
  return callModel({
    system:
      `You are reviewing a work product against fixed criteria, through this lens: ${lens}. ` +
      `You did not produce this and you cannot ask the author anything. ` +
      `Your job is to find the reason it FAILS. If you cannot verify a criterion ` +
      `from the artifact alone, that criterion FAILS. Default to fail when uncertain.`,
    user: `CRITERIA:\n${accepts.join('\n')}\n\nARTIFACT:\n${artifact}`,
    schema: VERDICT,   // { pass: boolean, failed: string[], reason: string }
  })
}
Enter fullscreen mode Exit fullscreen mode

Three details, each of which we got wrong first:

Frame it as refutation, not evaluation. "Does this meet the criteria?" gets you agreement. "Find the reason this fails" gets you the actual defects. Same model, same artifact, wildly different hit rate — asking a model to grade produces a rubber stamp, asking it to attack produces a bug report.

Default to fail on uncertainty. An unverifiable claim is a failed claim. This one rule caught most of our fabricated-citation cases, because a judge that can't confirm a URL from the artifact has to mark it failed rather than assume good faith.

Use multiple lenses, not multiple copies. Three identical judges is one judge with noise. Three judges with different lenses — correctness, does-it-reproduce, what's-missing — catch different failure classes:

const LENSES = ['factual correctness', 'reproducibility', 'completeness'] as const

const verdicts = await Promise.all(
  LENSES.map(l => judge(artifact, task.accepts, l))
)
const passed = verdicts.filter(v => v.pass).length >= 2   // majority
Enter fullscreen mode Exit fullscreen mode

Majority-of-three, not unanimity. Unanimity means one pedantic judge blocks everything and you'll disable the gate within a week — which is worse than never building it, because now you have a gate-shaped hole in your architecture diagram and no gate.

On our mission runs the gate rejects roughly one task in six on the first pass. Every one of those is a document that would previously have shipped looking finished.


5. Ship an artifact, not a message

Chat scrolls away. If the output of a mission is a message in a thread, then a week later nobody can answer "which version was approved, and by what criteria?"

Make the unit of output a first-class, addressable, versioned object:

interface Artifact {
  id: string
  type: 'doc' | 'deck' | 'sheet' | 'code' | 'image'
  version: number          // monotonic — v1 stays openable forever
  producedBy: TaskId
  verdicts: Verdict[]      // the judge's actual reasoning, stored
  criteria: string[]       // what it was accepted against
}
Enter fullscreen mode Exit fullscreen mode

Storing verdicts and criteria alongside the content is the difference between "the AI made this" and "this was accepted against these four criteria by three independent reviewers on this date." One of those you can put in front of a client.

We keep every version — a document at v16 still opens at v1 — and it turns out the version history is the single most convincing artifact of all, because it shows the work converging instead of just appearing.


6. Price every node, or the loop eats your month

Autonomy plus a retry loop is an unbounded spend. Put the ceiling in the scheduler, not in a dashboard you check on Fridays:

let spent = 0
for (const wave of waves(mission.tasks)) {
  await Promise.all(wave.map(async t => {
    if (spent + t.budgetUsd > MISSION_CAP) throw new BudgetExceeded(t.id)
    const { artifact, usd } = await run(t)
    spent += usd
  }))
}
Enter fullscreen mode Exit fullscreen mode

Two habits that came out of this:

  • Show the price next to the result. A mission that reports "5 agents · 4 waves · $0.021" is one the user trusts, because they can see it isn't hiding anything. Hidden cost reads as hidden behaviour.
  • Cheap models for finders, expensive models for judges. The asymmetry is the whole trick: generation is forgiving, verification is not. Spending your budget on a bigger generator and a cheaper judge is exactly backwards.

What still doesn't work

Being honest about the edges, since every article like this pretends there aren't any:

  • Judges inherit the generator's blind spots when they're the same base model. Different lenses help; a different model family helps more. If a fact is wrong in the training data, three judges from the same family will all wave it through.
  • Retry loops don't converge unless you dedupe against everything already seen — not just against what passed. Dedupe against accepted-only and rejected findings come back every round, forever.
  • Acceptance criteria are still written by a model. We haven't solved this. Writing them before the work removes the rationalization problem, not the imagination problem — a planner that doesn't know a failure mode exists won't write a criterion for it.
  • Verification is not free. The gate costs roughly 20–30% on top of a mission. It is the cheapest 25% in the system, but if someone tells you correctness is free they're selling something.

The checklist

If you're adding agents to a product this quarter, this is the short version:

  1. Plan is a typed graph with dependencies and acceptance criteria written before the work.
  2. Schedule by dependency depth — parallel within a wave, barriers only where a step genuinely needs all prior results.
  3. Every inter-task payload is schema-validated with additionalProperties: false.
  4. A judge sees criteria + artifact only, is prompted to refute, defaults to fail on uncertainty, and comes in three different lenses with a majority vote.
  5. Output is a versioned artifact carrying its verdicts and criteria, not a chat message.
  6. Every node has a budget ceiling enforced in the scheduler.

None of this makes the model smarter. That's the point. It makes the system's confidence earned rather than asserted — and "earned" is the only version anyone can safely put in front of a customer.


If you've built something similar, I'd genuinely like to know how you handled judges inheriting the generator's blind spots — it's the piece I'm least happy with.

Top comments (0)