Skip to content
Harness Engineering2026-09-1610 min readRev. 2026-09-30

Bound an Agent's Repair Loop by Attempts, Time and Side Effects

A repair loop needs three separate limits: attempts, a deadline, and external work. Derive what each extra repair buys, see why decode time sets the real cap, and measure repair success with an error bar.

Key takeaways

  • Attempts, elapsed time and external work are different quantities; one counter cannot bound all three.
  • With first-pass validity v and repair success q, the chance of a valid output within k repairs is 1 − (1 − v)(1 − q)^k, so each extra repair buys less.
  • Latency per attempt is prefill plus decode; at an illustrative 17 seconds per attempt, a 30-second deadline allows one attempt, whatever the attempt cap says.
  • A valid answer that arrives after the deadline is still a deadline stop; honoring it sometimes turns the deadline into a suggestion.
  • Measure repair success with and without the validator's message on the same items, and report each rate with its binomial interval.

Rod Rivera

Author

Bound an Agent's Repair Loop by Attempts, Time and Side Effects

Rod's note: read with a pencil; the margins are for you.

A correct answer can still arrive too late

An agent in your research repo proposes an eval job as structured tool arguments: which split to run, how many samples. Your validator requires num_samples to be an integer and the split to be dev, because the held-out test split is off limits during development. The first proposal sends "500" as a string. You feed the validator's message back, and the second proposal is correct. It arrives one second after the step's ten-second deadline.

What should the loop report? It could accept the second proposal, since it validates. It could report a bare timeout and drop what it learned. Or it could return a specific stop that names the limit that ran out and keeps the last validation error. Only the third holds up once something downstream depends on the deadline. This article builds the loop with three separate limits, derives what each repair attempt is worth, and gives a protocol for measuring whether repair helps at all.

Chapter 2 of the book covers structured output and typed tools, and Chapter 3 covers loops that stop. What this article adds is the arithmetic of repair and the latency that sets the real cap, plus an experiment you can run on your own models.

Why one counter cannot do three jobs

An attempt limit counts how many times you asked the model again. A deadline measures how long the caller has waited. A work limit counts what the loop has already done outside its own process: jobs dispatched, rows written, tokens billed. These are a count, a duration and a resource total, and folding them into one number fails in both directions.

If attempts stand in for time, two slow attempts can take ninety seconds against a ten-second target. If time stands in for work, a loop can finish within its deadline and still have dispatched a job during an early attempt. Stopping the timer does not cancel the job. AWS's Well-Architected guidance on retries also keeps these concerns apart: bound retries, use backoff and jitter, avoid multiplying attempts across layers, and treat idempotency and timeouts as separate decisions (AWS Well-Architected: control and limit retries).

What each repair buys

Suppose a first attempt is valid with probability v, and each repair after a failure succeeds with probability q. The chance of a valid output within k repairs is

text
P(valid within k repairs) = 1 − (1 − v) · (1 − q)^k

With illustrative values v = 0.9 and q = 0.6: one repair gives 1 − 0.1 × 0.4 = 0.96, and two give 1 − 0.1 × 0.16 = 0.984. The first repair adds 6 points and the second adds 2.4. Each one costs another full attempt of latency and tokens. On top of that, the formula assumes each repair is an independent draw. It is not: the items that fail twice are often the hard ones, so the true q for a second repair is usually lower than for the first. That is a reason to measure, not to assume.

Decode time sets the real cap

An attempt's latency is prefill plus decode: roughly the time to read the prompt, plus the number of output tokens divided by the decode rate. Chapter 9 derives both parts. Take illustrative numbers: 800 output tokens at 50 tokens per second is 16 seconds of decode, plus about a second of prefill, so 17 seconds per attempt. A 30-second deadline fits one attempt. The second would end at 34 seconds. Your attempt cap might say two, but the deadline decides.

Repairs also get more expensive as they go. Each one resends the original prompt plus the failed output plus the validator's message, so the input grows with every round. Chapter 19 shows that resending a transcript makes input tokens grow with the square of the loop's length. Put the numbers for your own model and prompt into these formulas before you choose the limits.

The worked case: two attempts, ten seconds, zero dispatches

Here are the limits for the agent's eval-job proposal:

LimitValue (teaching fixture)What exhausting it means
Attempts2 in total, including the firstNo further generation calls
Deadline10 seconds from the first request, monotonic clockNo response accepted after this point, valid or not
External work0 dispatches before acceptanceAny attempt to reach the scheduler stops the loop

Two timelines exercise the boundary. In the first, the malformed proposal arrives at second 4 and the valid repair at second 11, so the deadline has passed. In the second, two malformed proposals arrive by second 2, so the attempt limit ends the loop while the clock still has eight seconds left.

Three independent limits gating one terminal result. Proposal attempt; Attempt count check; Deadline check; External work check; Any limit exhausted?; repair_exhausted or accepted_draft
Three independent limits gating one terminal result

The late, valid proposal must not be accepted. A deadline exists because something downstream stopped waiting: a scheduler window, an orchestrator step budget, or a person watching a run. If the loop sometimes honors late results, the deadline turns into a suggestion that luck sometimes overrides. The result for the first timeline is a stop that names the deadline and carries the last error seen before it, int_type.

A late valid response is not evidence the deadline was wrong

It is tempting to read a valid-but-late answer as proof the deadline was too tight, and to loosen it for this run. That defeats the deadline. Change the limit for future runs if your latency measurements support it; never let one run's outcome override its own limit.

Build the loop

A policy failure is different from a format failure. Asking for the held-out split is not something a repair can fix; it is a boundary. So schema errors get another attempt, and policy errors stop the loop at once. The dispatch boundary is the only path to the scheduler, and it reserves before it sends. The code runs on Colab's Python 3.12.

python
class SchemaError(Exception):
    pass


class PolicyError(Exception):
    pass


class WorkLimit(Exception):
    pass


class DispatchBoundary:
    """The only path to the scheduler. It reserves before it sends."""

    def __init__(self, send, limit=0):
        self.send = send
        self.limit = limit
        self.reserved = 0

    def call(self, payload):
        if self.reserved >= self.limit:
            raise WorkLimit("external_work")
        self.reserved += 1  # reserve before dispatch, including calls that fail
        return self.send(payload)


def validate_job(raw):
    # A small teaching validator for an eval-job proposal.
    if type(raw.get("num_samples")) is not int:
        raise SchemaError("int_type")
    if raw.get("split") != "dev":
        raise PolicyError("heldout_split")  # no repair can make this allowed
    return {"split": "dev", "num_samples": raw["num_samples"]}


def repair_bounded(propose, validate, clock, boundary, max_attempts=2, deadline_seconds=10):
    expires = clock() + deadline_seconds
    attempts = 0
    last_error = None

    def stopped(reason):
        return {"status": "repair_exhausted", "reason": reason,
                "attempts": attempts, "last_error": last_error}

    while attempts < max_attempts:
        if clock() >= expires:
            return stopped("deadline")
        attempts += 1
        try:
            raw = propose(last_error, boundary)
        except WorkLimit:
            return stopped("external_work")
        if clock() >= expires:
            return stopped("deadline")
        try:
            job = validate(raw)
        except SchemaError as error:
            last_error = str(error)
            continue
        except PolicyError as error:
            last_error = str(error)
            return stopped("policy")
        if clock() >= expires:  # validation also takes time
            return stopped("deadline")
        return {"status": "accepted_draft", "job": job, "attempts": attempts}
    return stopped("attempts")

This synchronous version enforces an acceptance deadline. It cannot return on time if propose blocks forever. A real response deadline also needs a transport timeout and a supervisor that can stop waiting. Stopping the wait may leave remote work running, so a late response must never update a stop that has already been recorded.

The fixture below advances a fake clock by hand, so each case is deterministic.

python
class FakeClock:
    def __init__(self):
        self.now = 0

    def __call__(self):
        return self.now


BAD = {"split": "dev", "num_samples": "500"}
GOOD = {"split": "dev", "num_samples": 500}
TEST = {"split": "test", "num_samples": 500}


def fixture(events):
    clock = FakeClock()
    remaining = iter(events)
    sends = []
    boundary = DispatchBoundary(lambda payload: sends.append(payload), limit=0)

    def propose(feedback, boundary):
        seconds, value = next(remaining)
        clock.now += seconds
        if value == "try_dispatch":
            boundary.call(GOOD)
        return value

    return repair_bounded(propose, validate_job, clock, boundary), sends


late, _ = fixture([(4, BAD), (7, GOOD)])
assert late["reason"] == "deadline" and late["last_error"] == "int_type"
exhausted, _ = fixture([(1, BAD), (1, BAD)])
assert exhausted["reason"] == "attempts" and exhausted["attempts"] == 2
blocked, sends = fixture([(1, "try_dispatch")])
assert blocked["reason"] == "external_work" and sends == []
accepted, _ = fixture([(2, GOOD)])
assert accepted["status"] == "accepted_draft"
heldout, _ = fixture([(1, TEST)])
assert heldout["reason"] == "policy" and heldout["attempts"] == 1
print("deadline, attempt, work and policy stops; the valid control is accepted")

It prints deadline, attempt, work and policy stops; the valid control is accepted. These are simulated seconds, not latency measurements. The figure below shows the four limit cases; it labels the external endpoint a supplier, from an earlier version of this example, and here that endpoint is the scheduler.

Four fake-clock cases exercise the deadline, attempt, external-work and accepted outcomes separately. Valid result at second 11; Deadline stop; Two malformed results by second 2; Attempt stop; First forbidden supplier dispatch; External-work stop before mock send; Valid result at second 2; Accepted
Four fake-clock cases exercise the deadline, attempt, external-work and accepted outcomes separately

Measure whether repair helps

The formula above needs v and q, and nobody should guess them. This protocol measures them. This article has not run it; the numbers are yours to produce.

Fix the items and the validator

Pick 200 prompts that ask for the same structured output, such as a judge verdict with an integer score and a rationale. Freeze the validator and the model settings.

Run first passes

Call the model once per item and record whether the output validates. The share that validates estimates v.

Repair the failures two ways

For each failure, run one repair with the validator's exact message and one with a generic "the output was invalid, try again." Randomize the order. The share fixed in each arm estimates q for that kind of feedback.

Report with error bars

Give each rate with its binomial interval, 1.96 · √(p(1 − p)/n). With only 30 failures to repair, a rate of 0.6 carries about ±0.18, so compare arms on the same items with a paired test. Chapter 16 covers paired comparisons.

Run the same protocol on a small model and a large one, and record each item's latency. You then have the three numbers the limits depend on: v, q and seconds per attempt.

What a receiving system needs from the stop

The caller should branch on a small, fixed vocabulary: repair_exhausted with a reason of deadline, attempts, external_work or policy; the last validation error; an operation reference; and the next permitted action, such as "No job was dispatched; check the split." Test that the caller never treats repair_exhausted as success, for example because both responses happen to carry a job field. You can write that test offline today.

In an eval pipeline, the stop has one more consumer: the score. If a judge's verdict exhausts its repairs, do not drop the item silently and do not score it as a failure of the model under test. Count it and report the count. Items that fail format repeatedly are often the unusual ones, so dropping them shifts the score in a direction you cannot see.

Quick check — An agent's second proposal arrives valid but one second after the ten-second deadline. What should the loop return?

Check the decision

What would prove this wrong

The claim is narrow: three separately tracked limits, checked again just before acceptance, stop a late valid response from overwriting a deadline stop and stop an exhausted invalid proposal from being reported as accepted. A loop that checks the clock only before the first attempt would fail the first timeline. A caller that maps repair_exhausted onto the success path would fail one layer up, however carefully the loop is bounded. The fake-clock fixture proves the loop's own logic under controlled inputs. It proves nothing about a real model's latency or a real scheduler's timeouts.

Why this matters in a lab

Structured output sits under almost every lab pipeline: judge verdicts, extracted fields, tool arguments, graded rubrics. When it fails, a repair loop is the obvious fix, and an unbounded one turns a nightly eval into a morning of unexplained latency and a biased score.

An interviewer might ask: "Your judge returns invalid JSON 8% of the time. What do you do?" A strong answer derives what each repair buys, 1 − (1 − v)(1 − q)^k. It sets the deadline from measured decode time, keeps policy failures terminal, reports exhausted items instead of dropping them, and proposes the paired experiment above to measure whether the validator's message helps. The companion article on recovery policies places repair among the other failure classes.

Read Chapter 3: an agent loop that stops

See why reliability compounds over steps, and build a loop with explicit limits on calls and cost.

Learn the mechanism. Test the claim.

Join the Prof Rod newsletter for one educational lesson a week, with worked examples attached. It is free to register for and separate from the Zero Employee community.