The night my agent shipped nothing: giving generation agents an abstain outcome
When you score a background generation agent by how much it produces, the quality gate quietly loosens over time. Here is a three-valued ACCEPT / ABSTAIN / REJECT design that counts a zero-artifact run as a success, with the code and the measurements from running it.
A little past 2 a.m., the completion notice for a scheduled run arrived. The payload: "artifacts produced: 0." Back then I treated that as a failure, and my first habit each morning was to start digging into why.
But those zero artifacts were not an error. Candidates had been generated, and the quality gate had rejected every one of them. The gate had done exactly its job. My operations were simply wired around the assumption that zero equals broken. It took me several re-readings of that notice before I saw that the assumption itself was the broken part.
This article is the record of inverting that assumption. For a background generation agent — the kind you run as an Antigravity 2.0 scheduled task or managed agent, autonomously producing artifacts like posts, assets, or reports — what breaks when you make "how much it produced" the success metric? And how does operations change once the gate can return a third outcome: abstain? I will walk through it with the code I actually run.
Score by throughput, and the gate always loosens
When you first wire up a generation agent, the metric you reach for is artifacts per run. It is easy to chart, and the up-and-down is legible at a glance.
That is where the trap is. The moment production count becomes the success signal, a zero-artifact run becomes a failure. Failures pile up, alerts fire, and the operator quietly reacts. There are two available moves: improve the inputs (topics, context) or loosen the gate. The first takes time; the second is a one-line change.
Under a deadline, tired, people pick the cheap move. Nudge the threshold down. Wave one candidate through "just this once." And so the gate loosens, without anyone intending harm, a little at a time.
The generation pipeline I run as an indie developer, for my own apps, hit exactly this. During the stretch when I chased production count, something shipped almost every night — and some of what shipped passed the syntactic checks while leaving the reader with nothing. Count went up, value thinned out. The metric looked healthy while the system was wasting away.
The fix was to change the design of the metric itself. The target is "net positive, or nothing." Ship only artifacts that provably raise the system's average quality; otherwise ship nothing. A mediocre single artifact is a negative, and zero is zero. Zero ranks above negative — that ordering has to be written into the code.
Make the gate three-valued: ACCEPT / ABSTAIN / REJECT
The starting move is to change the gate's return from binary (pass/fail) to three-valued.
Verdict
Meaning
Contribution to the run
ACCEPT
Judged net positive. Safe to publish
Commits one artifact
ABSTAIN
Not bad, but not clearly net positive. Skip it this time
Zero artifacts, but not a failure
REJECT
Requirement violation, broken, or duplicate. Must not pass
Zero artifacts; target for regeneration
A binary world has no ABSTAIN. So the most common state — "not bad, but not worth shipping" — gets shoved onto the ACCEPT side. The point of going three-valued is to give that middle band a home of its own.
from __future__ import annotationsfrom dataclasses import dataclass, fieldfrom enum import Enumclass Verdict(str, Enum): ACCEPT = "accept" ABSTAIN = "abstain" REJECT = "reject"@dataclassclass GateResult: verdict: Verdict reasons: list[str] = field(default_factory=list) signals: dict[str, float] = field(default_factory=dict)class AcceptanceGate: """Score a candidate three ways. REJECT = violation, ABSTAIN = below net-positive.""" def __init__(self, *, min_utility_signals: int = 3, novelty_floor: float = 0.35): self.min_utility_signals = min_utility_signals self.novelty_floor = novelty_floor def evaluate(self, candidate: "Candidate") -> GateResult: reasons: list[str] = [] # --- 1) Hard requirements: any one missing -> REJECT (before net-positive) --- if candidate.has_broken_frontmatter(): reasons.append("broken frontmatter (YAML parses as a map, 500s)") if candidate.duplicates_existing(threshold=0.90): reasons.append("near-identical to an existing artifact (dilution, not gain)") if candidate.violates_policy(): reasons.append("policy violation (banned terms, out-of-scope drift)") if reasons: return GateResult(Verdict.REJECT, reasons) # --- 2) Net-positive test: if unmet, ABSTAIN (skip, do not fail) --- utility = candidate.count_utility_signals() # runnable code / measurements / steps... novelty = candidate.novelty_score() # delta against the existing corpus signals = {"utility": float(utility), "novelty": novelty} if utility < self.min_utility_signals: reasons.append(f"utility signals short {utility}/{self.min_utility_signals}") if novelty < self.novelty_floor: reasons.append(f"novelty below floor {novelty:.2f} < {self.novelty_floor}") if reasons: return GateResult(Verdict.ABSTAIN, reasons, signals) return GateResult(Verdict.ACCEPT, ["judged net positive"], signals)
There is one thing I deliberately do not do here: auto-promote an ABSTAIN candidate to ACCEPT because "it's so close." Auto-promoting near-misses only invites the looseness back inside the code. Let the middle band be the middle band and skip it honestly. That restraint pays off later.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A three-valued gate that returns ACCEPT / ABSTAIN / REJECT with reasons, and deliberately refuses to auto-promote near-misses
✦A run classifier that decouples success from artifact count, plus abstention rate read as a leading indicator
✦A fail-closed grounding check that abstains when reference data is missing or stale, so unattended nights fail loud instead of silent
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Even with a three-valued gate, if you judge the whole run by "was there at least one ACCEPT," you have slid right back to a throughput metric. Define the run outcome on a different axis from production count.
from collections import Counterclass RunOutcome(str, Enum): PRODUCED = "produced" # >= 1 ACCEPT. net positive happened HEALTHY_ABSTAIN = "healthy_abstain" # all ABSTAIN. a correct, successful skip DEGRADED = "degraded" # REJECT-dominated. input or model anomaly BLOCKED = "blocked" # never entered generation (see below)def classify_run(results: list[GateResult]) -> RunOutcome: if not results: return RunOutcome.BLOCKED tally = Counter(r.verdict for r in results) accepts = tally[Verdict.ACCEPT] rejects = tally[Verdict.REJECT] if accepts >= 1: return RunOutcome.PRODUCED # zero ACCEPT still means different things by composition if rejects > len(results) // 2: # more than half were violations = not the gate's win, an input anomaly return RunOutcome.DEGRADED # mostly ABSTAIN = a healthy conclusion that nothing was worth shipping return RunOutcome.HEALTHY_ABSTAIN
The crux is treating HEALTHY_ABSTAIN as a success state. The only outcomes that page you are DEGRADED and BLOCKED. A night that ends with every candidate abstained just leaves a quiet log line and goes back to sleep. That 2 a.m. notification stopped firing because of this single classification.
There is a reason DEGRADED is kept separate too. The surface looks the same — zero ACCEPT — but "skipped" and "everything came back broken" call for entirely different moves. The former means change the inputs. The latter means suspect the model, the prompt, or corrupted reference data. If you do not decompose the surface number, you will confuse the two.
Read abstention rate as a leading indicator, not a failure
Once run outcomes are separated, the next axis is time. The single number I lean on most is not production count but the abstention rate — ABSTAIN over total candidates — and its trend.
@dataclassclass AbstentionWindow: """Track abstention rate over the last N runs and warn on trend.""" window: int = 14 history: list[float] = field(default_factory=list) def record(self, results: list[GateResult]) -> None: if not results: return abstains = sum(1 for r in results if r.verdict is Verdict.ABSTAIN) self.history.append(abstains / len(results)) self.history = self.history[-self.window:] def rate(self) -> float: return sum(self.history) / len(self.history) if self.history else 0.0 def trend_signal(self) -> str: if len(self.history) < self.window: return "collecting" recent = sum(self.history[-3:]) / 3 base = sum(self.history[:-3]) / (len(self.history) - 3) if recent > base + 0.20: # a rising abstention rate is usually topic saturation, not quality loss return "input_saturation" # -> change inputs, leave the gate alone if recent < base - 0.20 and self.rate() < 0.10: return "gate_too_loose" # -> too much is passing, revisit the gate return "stable"
When trend_signal returns input_saturation, intuition whispers "the gate is too strict, loosen it." In practice the opposite held. A rising abstention rate is almost always a sign that you have written the topic area dry, and the right response is to swap the inputs — the topic source, the grounding data. Loosen the gate here and you merely stack thin artifacts onto a saturated field.
Guaranteeing grounding freshness before a run pairs well with the idea in measuring generation opportunity with a freshness SLO. Abstention rate reports input saturation; the freshness SLO reports input decay — two different angles on the same input.
A threshold only means something paired with its metric
The gate above carries a default of novelty_floor: float = 0.35. When I wrote it, I picked 0.35 because it felt about right. Looking back, that single line was the most dangerous one in the file.
If you implement novelty_score() as the inverse of similarity against your existing corpus, there are at least two obvious ways to do it, and both take only a few lines:
Compare against every document with Python's difflib.SequenceMatcher and take the highest quick_ratio() as similarity
Split the body into 3-gram shingles, narrow candidates with an inverted index, then take the highest Jaccard coefficient as similarity
I measured both on my own Linux environment (Python 3.10) against the 1,059 Japanese articles on Antigravity Lab — body text only, code blocks and HTML tags stripped, median length 3,372 characters. Comparing one document against the whole corpus, with a fixed random seed, gave me this:
Implementation
Per document
Setup cost
difflib, all pairs (truncated to 4,000 chars each)
2,336 ms
none
3-gram shingles + inverted index
64 ms
1.0 s to shingle, 2.8 s to index
That is a 36x difference. Read on its own, it looks like a simple swap. But the same run surfaced something that matters far more than speed. The two implementations produce nearest-neighbour scores that do not overlap at all.
Similarity measure
Median nearest-neighbour score
Observed range
Relative to 0.35
3-gram Jaccard (120 documents)
0.091
0.057 – 0.150
every one below 0.35
difflib quick_ratio (12 documents)
0.730
0.668 – 0.747
every one above 0.35
Define novelty as 1 - similarity and abstain when novelty < 0.35, and here is what you get. With the Jaccard version, novelty sits around 0.9, so the condition never fires once — the novelty gate exists while judging nothing at all. With the quick_ratio version, novelty lands around 0.27, so every candidate abstains unconditionally.
The same 0.35 flipped from "can never fire" to "always fires" on nothing but an implementation swap. The first case is the nastier one——the gate appears to be running while one of its conditions is quietly dead. That failure shape is structurally the same as the one in my notes on the allowlist line that matched nothing and approved everything.
While I was there, I checked whether the fast implementation can stand in for the slow one. The difflib nearest neighbour appeared inside the inverted index's top 50 candidates in 6 of 15 documents. Treat one as a faster version of the other and the meaning of the verdict changes underneath you. Either choice is defensible — but if you change the measure, you have to re-derive the threshold in the same commit.
So I bound the threshold to a metric ID and a measured sample, and made anything outside the observed range fail at startup.
from __future__ import annotationsfrom dataclasses import dataclassimport statisticsclass VacuousThreshold(RuntimeError): """A threshold outside the observed distribution. Fail at startup."""@dataclass(frozen=True)class CalibratedFloor: """Keeps a threshold bound to the metric it was measured with. If metric_id changes, the floor must be re-derived. `samples` is the measured nearest-neighbour series from your own corpus, already converted to novelty = 1 - similarity. """ metric_id: str floor: float samples: tuple[float, ...] def __post_init__(self) -> None: if len(self.samples) < 30: raise VacuousThreshold( f"{self.metric_id}: only {len(self.samples)} samples; calibrate on 30+" ) lo, hi = min(self.samples), max(self.samples) if not (lo <= self.floor <= hi): side = "never abstains" if self.floor < lo else "always abstains" raise VacuousThreshold( f"{self.metric_id}: floor={self.floor} is outside the observed range " f"[{lo:.3f}, {hi:.3f}] ({side})" ) def fires_on(self) -> float: """Share of the measured samples this threshold would abstain on.""" return sum(1 for v in self.samples if v < self.floor) / len(self.samples) def describe(self) -> str: return ( f"{self.metric_id}: floor={self.floor:.3f} " f"median={statistics.median(self.samples):.3f} " f"expected abstention={self.fires_on():.0%}" )def load_novelty_samples(path: str) -> tuple[float, ...]: """Read the calibration series, converting similarity into novelty.""" with open(path, encoding="utf-8") as fp: sims = [float(line) for line in fp if line.strip()] return tuple(1.0 - s for s in sims)
Feeding it the 120 measured values from above prints this:
samples: 120OK : jaccard3gram-novelty-v1: floor=0.900 median=0.909 expected abstention=25%BLOCK: jaccard3gram-novelty-v1: floor=0.35 is outside the observed range [0.850, 0.943] (never abstains)OK : jaccard3gram-novelty-v1: floor=0.860 median=0.909 expected abstention=1%
fires_on() is there because I wanted to be able to say out loud, at the moment of choosing a threshold, what share of candidates it would turn away. On paper 0.900 and 0.860 differ by 0.04; in expected abstention they split into 25% and 1%. Those three lines may be the ones I would most want to show the version of myself who committed a threshold without ever looking at the spread.
When grounding is missing, abstain fail-closed
The scariest thing in unattended execution is generation running while the reference data is missing. Handed an empty file or a truncated context, an agent does not fall silent and stop — it fills in something plausible and ships it. At night, that piles up until morning.
Make this fail-closed. If the inputs are not satisfied, do not even enter generation; end the run as BLOCKED. Not even ABSTAIN — stop short of the gate.
@dataclassclass GroundingCheck: required_paths: list[str] min_bytes: int = 512 max_age_hours: float = 48.0 def ready(self, fs, now_ts: float) -> tuple[bool, list[str]]: problems: list[str] = [] for path in self.required_paths: meta = fs.stat(path) if meta is None: problems.append(f"missing: {path}") # never fail silently elif meta.size < self.min_bytes: problems.append(f"no substance ({meta.size}B): {path}") elif (now_ts - meta.mtime) / 3600 > self.max_age_hours: age = (now_ts - meta.mtime) / 3600 problems.append(f"stale ({age:.0f}h): {path}") return (len(problems) == 0, problems)def run_generation(candidates_fn, gate, grounding, fs, now_ts, log): ok, problems = grounding.ready(fs, now_ts) if not ok: # do not enter generation on inputs that cannot guarantee net positive log.warn("BLOCKED: grounding not ready", extra={"problems": problems}) return RunOutcome.BLOCKED, [] results = [gate.evaluate(c) for c in candidates_fn()] outcome = classify_run(results) log.info("run complete", extra={"outcome": outcome, "n": len(results)}) return outcome, results
max_age_hours is there because it is not only absence that is dangerous — a reference file whose updates have stalled is dangerous too. "Present, but three days old" is worse than missing precisely because it is harder to notice. Folding freshness into the fail-closed decision keeps quiet staleness from eroding quality.
Three things that ran counter to my expectations
After a few weeks on the three-valued gate and the net-positive SLO, three things came out the opposite of what I had predicted.
One. Raising production count made outcomes worse. During the period I pushed toward shipping at least one artifact every night, I was mixing should-abstain candidates into ACCEPT, and the average quality drifted down. From the day I allowed zero, the density of what passed went up and the downstream metrics turned toward recovery. Give yourself the freedom not to produce, and what you do produce gets better. I knew that in my head, and my hand still reached for the button every time the dashboard showed a zero — it is the kind of paradox I may never fully stop feeling.
Two. A rising abstention rate was usually good news. I braced for it as a sign of decline, but in practice it almost always meant "you've written that topic enough." The move to make is swapping inputs, not relaxing the gate. Had I read abstention as a failure rate, I would have trampled that signal every single time.
Three. Auto-repairing near-misses bred silent corruption. I once added a step that mechanically edited ABSTAIN candidates up to ACCEPT, and pulled it out fast. Repair only erased the surface violation marker; the underlying "not net positive" state remained, and the traces of it were hard to find later. Surfacing the violation at the gate and regenerating — rewritten in its own specific context — turned out faster and cleaner. That call is of a piece with treating candidates in a three-pattern validation loop.
The order to adopt this, and the first line to hold
You do not need to swap everything in at once. The lightest start is to drop in classify_run alone and take HEALTHY_ABSTAIN off the alert list. That by itself stops the nightly false alarms.
Next, add abstention-rate recording, watch two weeks of it, and only then enable trend detection. Set thresholds before you have a feel for the numbers and you will usually miss. Last, add the grounding fail-closed. One silent-failure incident and its value sinks in for good.
There is only one line to hold at the start: do not put "how much it produced" at the top of your success metrics. Hold that and the gate is freed from the pressure to loosen. You become able to count a zero-artifact night, quietly, as a success. Running an agent solo, that quiet on its own may be what lowers the cost of operations more than anything else I changed.
I am still tuning this myself, but I hope it helps the design of anyone facing an unattended generation pipeline of their own. Thank you for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.