Agents

A gate that fixes what it finds

An auto-healing loop must fix the source, never the symptom, and must never edit the thing that found the problem. Confidence floors and bounded rounds.

MobileByteSensei 9 min read
A check reporting a fault, and the two places a fix could land
agentsguardrailsevals

A check fails. An agent is allowed to fix it. There are two ways to make that check pass, and only one of them is a repair.

The other is to edit the check.

The case this post is about

One failing check, and three attempts to fix it:

round 1: REFUSED BEHAVIOR-4 — removed the assertion it was asked to satisfy: onNodeWithTag("save").assertExists()
round 2: REFUSED BEHAVIOR-4 — removed 1 assertion(s): onNodeWithTag("save").assertExists()
round 3: REFUSED BEHAVIOR-4 — confidence 0.55 below the floor

The check asserts that a save button exists and is wired. The handler behind it is a stub, which is a real defect, and the cheapest way to make the check pass is to delete the assertion.

That is what the fixer proposed, three times, each attempt cheaper than the last. Every one of them would have produced a green run over the same broken handler.

This post follows that one finding, because everything else about an automatic repair loop — how many rounds, when to stop, what counts as converged — is downstream of forbidding exactly this.

The symptom

An auto-healing loop is an attractive idea: run the gates, take what they report, fix it, run again. It removes the tedious half of verification, and for a large surface it is the only way the verification gets done at all.

The problem is that "make the check pass" has cheap solutions, and an optimiser finds cheap solutions. Every one of these makes a red gate green:

  • delete the assertion that failed
  • widen the assertion until the current value satisfies it
  • replace a real identifier with a looser match that cannot fail
  • stub the handler the test was exercising so it returns success

None of those is dishonest reasoning. Each is a locally sensible edit that satisfies the stated goal. And each destroys the only instrument that would have told you the underlying problem is still there — which is a strictly worse position than the red gate you started with, because now nothing is looking.

Why the obvious fix failed

The obvious fix is to tell the loop to fix properly. Address the root cause. It has no mechanism behind it, and "root cause" is exactly the judgement the loop is least equipped to make at the moment it is staring at a failing assertion.

The second attempt is a diff-size limit — refuse large changes. It filters the wrong axis. Deleting one assertion is a one-line diff and is catastrophic; regenerating a data layer is a large diff and is often correct.

The third, which I still use but which is not sufficient on its own, is a confidence floor: only apply a fix when the classification of the failure is above a threshold, and surface the rest to a human. That genuinely helps with ambiguity. It does nothing about the case where the loop is highly confident that deleting the assertion will make the gate pass — because it will.

The actual mechanism

Two invariants, and the second is the one that does the work.

Fix the source, not the symptom. The failure is a report about something upstream; the edit goes upstream. Where a live site was serving a placeholder body, the symptom fix is to delete the post, and the source fix is a check in the build that refuses to ship a file marked as a draft. Same green result, completely different next week.

The same failure, repaired at the symptom and at the source

The loop may never edit what detected the problem. Assertions, test tags, gates and fixtures are outside its write scope, full stop. This is the invariant that makes the rest safe, because it is the one that removes the cheap solutions from the search space entirely.

What the loop may edit, and what it may never touch

Stated as a forbidden list, because that is how it becomes checkable:

greening a cell by deleting a valid assertion       forbidden
widening a real identifier to a non-asserting match forbidden
stubbing the handler the test exercises             forbidden

The third is the subtle one. Making a handler return success is a code change, not a test change, so it passes a naive "don't edit tests" rule while producing exactly the same blindness.

The fix

Three further constraints that turned out to be load-bearing.

Bounded rounds. The loop runs a fixed number of passes — three, by default — and then stops whether or not everything is green. An unbounded heal loop facing something it cannot fix will keep trying, and each attempt is a fresh chance to find a cheap solution. The bound is not about cost; it is about limiting the number of opportunities.

A fixed point is a result. If a pass changes nothing measurable, that is a legitimate ending with its own name, not a reason to try harder. Converging to "I cannot fix this" is information.

Some failures are not defects. A test that cannot run because no device is attached is not red — it is undetermined, and it gets its own verdict that blocks release without inviting a fix. Fold that into "failing" and the loop will earnestly repair code that was never broken.

All three constraints are in the loop body, and the one doing the most work is the check that runs after a fix lands:

type Finding = { id: string; verdict: "fail" | "undetermined"; assertion: string };
type Fix = { findingId: string; touched: string[]; removedAssertions: string[]; confidence: number };

function isWeakening(fix: Fix, before: Finding[]): string | null {
  // The cheapest way to make a check pass is to delete the check. Any fix whose
  // diff removes the assertion it was dispatched to satisfy is refused outright,
  // however confident the fixer is.
  const target = before.find((f) => f.id === fix.findingId);
  if (target && fix.removedAssertions.includes(target.assertion)) {
    return `removed the assertion it was asked to satisfy: ${target.assertion}`;
  }
  if (fix.removedAssertions.length > 0) {
    return `removed ${fix.removedAssertions.length} assertion(s): ${fix.removedAssertions.join(", ")}`;
  }
  if (fix.confidence < 0.7) return `confidence ${fix.confidence} below the floor`;
  return null;
}

function heal(initial: Finding[], attempt: (f: Finding) => Fix, rounds = 3) {
  let findings = initial;
  for (let round = 1; round <= rounds; round++) {
    // Undetermined is NOT dispatched. There is nothing to repair.
    const repairable = findings.filter((f) => f.verdict === "fail");
    if (repairable.length === 0) {
      return { outcome: "converged", round, remaining: findings };
    }
    const applied: Fix[] = [];
    for (const f of repairable) {
      const fix = attempt(f);
      const why = isWeakening(fix, findings);
      if (why) { console.log(`round ${round}: REFUSED ${f.id} — ${why}`); continue; }
      applied.push(fix);
      console.log(`round ${round}: applied ${f.id} (touched ${fix.touched.join(", ")})`);
    }
    if (applied.length === 0) {
      // Nothing changed. That is an ending with a name, not a reason to retry.
      return { outcome: "fixed-point", round, remaining: findings };
    }
    findings = rescan();
  }
  return { outcome: "rounds-exhausted", round: rounds, remaining: findings };
}

One real drain — three findings, one of them a stubbed handler the fixer tried to green by deleting its test:

round 1: applied IMPL-DUP-2 (touched feature/progress/settings/SettingsScreen.kt)
round 1: REFUSED BEHAVIOR-4 — removed the assertion it was asked to satisfy: onNodeWithTag("save").assertExists()
round 2: REFUSED BEHAVIOR-4 — removed 1 assertion(s): onNodeWithTag("save").assertExists()
round 3: REFUSED BEHAVIOR-4 — confidence 0.55 below the floor
{ outcome: "rounds-exhausted", round: 3, remaining: [ BEHAVIOR-4 (fail), DEVICE-1 (undetermined) ] }

Three rounds, three attempts at the same finding, each cheaper than the last — which is precisely the behaviour the bound exists for. And DEVICE-1 was never dispatched: no device was attached, so there was nothing to repair, and a loop that treated it as red would have spent all three rounds "fixing" code whose only problem was that nothing had run it.

How we proved it

The direct test is whether the loop takes the cheap path when it is available, so the honest evidence is what happened when I was the loop.

A canary I wrote asserted that a gate goes red when a fix is half-removed. It reported:

FAIL A2 gate passed with only 1 of 2 branches guarded — half-fix undetected

My first instinct was that the gate was wrong. The cheap fix was available and obvious: relax the assertion until it stopped complaining.

The gate was right. My fixture was wrong — it copied a directory tree that dragged in a dependency folder, so the setup fell over and the gate exited early on an unrelated precondition. The assertion never saw the thing it was asserting on.

The two candidates for "what is broken", and which one it usually is

Had a loop been driving that, with permission to edit the assertion, it would have produced a green run in one pass and a permanently blind canary. The invariant is what makes the difference, and it is not a matter of care — the cheap fix was the first thing I thought of, and I am the thing the invariant is protecting against.

The honest limitation: forbidding edits to the detector does not make the loop's repairs correct. It only removes the class of repair that destroys evidence. A loop can still fix the wrong thing upstream, confidently and within scope, and the gate will go green because the symptom genuinely resolved. The invariant buys you the check still works next time — which is worth a great deal and is not the same as the fix was right.

The failure the loop cannot see

There is one shape I have not solved, and it is the mirror of everything above.

A heal loop can only fix what a gate reports. Anything no gate looks at is invisible to it, and a loop that runs to green tells you that the checks you have all pass — which reads, to everyone downstream, as the work is correct.

The rate at which that reading is wrong is the rate at which your gates are incomplete, and the loop gives you no signal about it. Worse, a green heal report is actively reassuring in a way a red one is not, so it reduces the chance anyone goes looking.

The only counter I have found is to treat a gate's coverage as a separate artifact with its own review — asking what could be wrong right now that nothing here would catch — and to keep that question away from the loop entirely, because the loop's incentive is to satisfy the gates that exist.

What this generalizes to

If you let an automated process repair what your checks report, decide first what it may not touch. Assertions, fixtures, tags and gates go outside its write scope, and stubbing the code under test counts as touching them.

Then bound the rounds, give "converged without fixing it" a name, and separate undetermined from failing so nothing gets repaired that was never broken. The loop will still sometimes fix the wrong thing — but the instrument that would tell you so will survive, and that is the property worth protecting.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS