DEV Community

Morgan Li
Morgan Li

Posted on

Retry the Cheap Model or Escalate? A Decision Rule for Failed Agent SQL Drafts

A drafting loop can spend its entire allowance on a failure the model was never able to fix. Consider a cheap tier that keeps emitting the same unknown column name because the catalog never contained it. Four repairs later the loop has burned its budget, returned nothing usable, and taught you nothing about the schema. The question is not whether to retry, but which failures deserve another attempt on the same tier at all.

My earlier post about an agent-written query that locked a production table ended with a gate, not a model choice. This post stays inside the loop and argues the retry policy itself, because that policy decides how much free capacity either position consumes. Two credible positions follow, then a failure taxonomy and a small harness that turns the argument into a test you can run against your own recorded drafts.

Position A: repair in place, keep the cheap tier

The first position holds that most failed drafts are phrasing failures, and phrasing is exactly what a repair prompt fixes cheaply. Give the same tier the parser error, the offending line, and the target dialect, then ask for a minimal diff instead of a full rewrite. Proponents point out that switching tiers on the first error throws away valid reasoning attached to a single bad identifier. They also argue that cheap tiers improve faster than escalation ladders, so a policy tuned to today's weak tier ages badly.

The strongest version of this position is narrow: repair only when the validator produced a specific, localizable error. A parser that says syntax error at or near "GROUPS" on line 7 gives the model something to act on. A zero-row result gives it almost nothing, and a repair prompt against nothing tends to produce confident guesses. Position A is defensible exactly to the extent that your validator emits precise diagnostics.

Position B: escalate on failure class, never on count

The second position says the retry count is the wrong variable and the failure class is the right one. A missing column is a retrieval problem: the model did not see the catalog, and asking again with the same context cannot help. A dialect break is a translation problem with a known mapping table. Answering both with the same "try again" is how loops burn budget while producing identical failures.

Advocates of class-based escalation add an uncomfortable observation about empty results. A query that runs cleanly and returns nothing usually means the request was underspecified or the filters are wrong, so escalation to a stronger tier tends to produce a more articulate wrong answer. This position would rather stop and surface the ambiguity than pay for better-sounding noise. That argument is worth taking seriously, because it is the failure mode that reviews rarely catch.

Where both positions break

Both positions assume your validator can name the failure, and most harnesses cannot. If the only signal is "the query failed", you cannot classify anything, and escalation collapses into expensive guessing. Instrument the validator first: distinguish parser rejection, catalog miss, dialect refusal, empty result, and timeout as separate codes. Until those five codes exist, neither position is testable.

Both positions also ignore transport failures, which deserve their own rule. A 429 is not a model problem, and retrying around a rate limit is how agents get blocked instead of helped. Treat throttling as a stop-and-back-off event with no escalation and no repair, and record it in the ledger so the budget report stays honest. Escalating after a rate limit is the one behaviour that reliably makes things worse.

The artifact: a failure taxonomy and a policy harness

The code below is a policy skeleton, not a benchmark. Model tiers are abstract labels you supply, token weights are arbitrary units you define, and nothing here assumes a vendor, a price, or a quota. Run it against recorded failures before you attach it to live drafts.

from dataclasses import dataclass, field
from enum import Enum


class FailClass(str, Enum):
    SYNTAX      = "syntax"        # parser rejected the text
    DIALECT     = "dialect"       # parses, target engine refuses it
    UNKNOWN_OBJ = "unknown_obj"   # table or column absent from catalog
    EMPTY_RES   = "empty_result"  # executed, returned zero rows
    TIMEOUT     = "timeout"       # statement or scan budget exceeded
    TRANSPORT   = "transport"     # 429, 5xx, dropped connection


@dataclass
class Attempt:
    tier: str
    fail: FailClass | None
    tokens: int


@dataclass
class Ledger:
    attempts: list[Attempt] = field(default_factory=list)

    def charge(self, tier, fail, tokens):
        self.attempts.append(Attempt(tier, fail, tokens))

    @property
    def spent(self):
        return sum(a.tokens for a in self.attempts)

    def failures_at(self, tier):
        return sum(1 for a in self.attempts if a.tier == tier and a.fail is not None)


def decide(fail, prior_failures_at_tier, max_same_tier=1):
    if fail is FailClass.TRANSPORT:
        return "stop_backoff"           # never retry around a rate limit
    if fail is FailClass.TIMEOUT:
        return "stop"                   # a stronger tier will not make it faster
    if fail is FailClass.EMPTY_RES:
        return "escalate" if prior_failures_at_tier == 0 else "stop"
    if fail is FailClass.UNKNOWN_OBJ:
        return "escalate"               # retrieval gap, not phrasing
    if prior_failures_at_tier < max_same_tier:
        return "repair"                # syntax and dialect get one local retry
    return "escalate"


def run(draft, token_cost, max_same_tier=1):
    ledger, tier = Ledger(), "cheap"
    while True:
        fail = draft(tier)
        ledger.charge(tier, fail, token_cost(tier))
        if fail is None:
            return "accepted", tier, ledger
        prior = ledger.failures_at(tier) - 1
        action = decide(fail, prior, max_same_tier)
        if action == "repair":
            continue
        if action == "escalate" and tier == "cheap":
            tier = "strong"
            continue
        return action, tier, ledger
Enter fullscreen mode Exit fullscreen mode

Running the fixture test

  1. Record your last fifty failed drafts with the validator code that rejected each one, and store them as a replay file.
  2. Replace draft with a replay function that returns the recorded failure for each step, so the run is deterministic and free of new spend.
  3. Set token_cost to your own unit weights, for example 1 for the cheap tier and 8 for the strong tier, to model relative spend without quoting prices.
  4. Run the fixture matrix below and check that outcomes and ledger totals match the expected column.
  5. Only then wire the policy to live drafting, and log every decision next to the validator code that caused it.
CASES = [
    # name,                cheap failures,            strong failures, expected
    ("repair once",        [FailClass.SYNTAX, None],  [],              "accepted/cheap"),
    ("repair then climb",  [FailClass.SYNTAX] * 2,    [None],          "accepted/strong"),
    ("catalog miss",       [FailClass.UNKNOWN_OBJ],   [None],          "accepted/strong"),
    ("throttled",          [FailClass.TRANSPORT],     [],              "stop_backoff/cheap"),
    ("slow query",         [FailClass.TIMEOUT],       [],              "stop/cheap"),
    ("empty result",       [FailClass.EMPTY_RES],     [None],          "accepted/strong"),
]
Enter fullscreen mode Exit fullscreen mode

With cheap attempts weighted at one unit and strong attempts at eight, the fixture separates the two positions by spend rather than by opinion. Repair-once succeeds for two units, one catalog miss escalated early costs nine, and the same shape escalated late costs ten. Those numbers are placeholders for your weights, but the shape of the comparison is the useful part.

The decision table

Failure class Same-tier repair Escalate now Stop Rule of thumb
syntax yes, once after one failed repair — localizable error, cheap fix
dialect yes, once if the mapping is unknown — pair with a translation table
unknown_obj no yes — retrieval gap, retry cannot help
empty_result no once, then stop second time ambiguity, not phrasing
timeout no no yes cost problem, not capability
transport no no yes respect the limit

The decision rule that survives both positions is this: repair only what the validator localized, escalate only what retrieval or capability caused, and stop on anything that is really about cost or rate limits. Everything else is a rounding error in your budget report.

Where free model access and a free server fit

This policy is cheap to try because the cheap rung does not need a paid key. Free model access covers the repair loops and the fixture replays, while a free server gives the validator somewhere isolated to parse, replay, and log decisions without touching your laptop or your production network. Disclose plainly: Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Availability claims age, so verify the current terms on the project page rather than trusting a number in a blog post, including this one. I deliberately quote no token allowance, no hardware, and no model name here, because free-tier terms move faster than articles. If the escalation experiment appeals to you, start with the fixture file and the project's own documentation, then compare spend per accepted draft against your current loop.

Limitations and who should skip this

The harness proves nothing without validator codes, so teams whose only signal is a boolean failure should invest there first. Teams whose failures are mostly empty results will find escalation expensive and unhelpful, since the missing input is specification quality rather than model strength. Teams on metered paid tiers should re-weight the table, because their escalation cost may dwarf any retry savings and invert the conclusion.

Finally, this is a policy layer, not a safety layer. Read-only credentials, statement timeouts, and a parser gate still carry the responsibility for what the query does to your database. A clean escalation ladder only decides who drafts next, and it should never be the reason a query reaches production unreviewed.

Top comments (0)