FunnyEnough

Engineering notes & expertise

Reading

Size
Theme
Motion
For long-form reading
Type
Width
Space
Aids

I had a scheduled agent that wrote a short briefing four times a day, off a data feed that refreshed on its own timer. One morning the feed didn’t refresh. The agent didn’t crash and it didn’t log an error. It read the data it had, twenty-six hours old, and wrote a clean, confident briefing with numbers in it. The numbers were wrong, in the exact way that gets a human to act.

That failure never shows up in your dashboards. A thread on r/AI_Agents put it plainly: “the worst failures are not loud. The model does not crash. It does not throw an error.” Another thread named the debugging problem: “nothing errored, the agent just made a bad decision. The ‘bug’ is probabilistic.” There’s no stack trace. The agent did what it was built to do, on inputs it had no business trusting.

So I stopped trying to make the agent smarter and gave it a check that runs before the briefing renders: one function that decides whether the agent has earned the right to speak.

You can’t prompt your way to “I don’t know”

The reflex is to tell the model to be careful — add “if you’re unsure, say so” to the system prompt and move on. It helps a little, then it stops helping.

The clearest measurement is AbstentionBench (Kirichenko et al., Meta FAIR; arXiv:2506.09038, NeurIPS 2025), which tests whether frontier models know when not to answer, across twenty datasets of questions that are unanswerable, underspecified, or based on outdated information. Across twenty models and 35k+ questions, abstention is “an unsolved problem, and one where scaling models is of little use”. Worse for anyone shipping a reasoning model: reasoning fine-tuning degraded abstention by 24% on average, even on the maths and science those models are trained for. A careful system prompt helped but “does not resolve models’ fundamental inability to reason about uncertainty”. (Read the 24% as a strong signal, not a constant: it’s measured against each model’s own non-reasoning base over two model families, and graded by an LLM judge that hit 88% accuracy on a hand-annotated sample.)

The research community has built mechanisms that genuinely help, and the instructive part is where they sit. Conformal abstention bounds the error rate at a tolerance you choose, under the usual calibration assumptions; selection heads and semantic-entropy probes score the model’s confidence in its own answer. Every one of them is machinery wrapped around the model, not the model deciding mid-sentence to be humble. If the decision to stay quiet matters, put it where you can read, version, and test it.

The gate

What follows is the pattern distilled into something you can run. The gate reads one state dict, and every field has an owner: the scan that gathers data writes generated_at and data_quality; an optional fast refresh that tops up a few items writes refresh; the scheduler writes last_run after each run.

from datetime import datetime

FRESH_MAX_HOURS = 24.0
SKEW_HOURS = 0.1                     # tolerate small clock differences

def _section(x):
    return x if isinstance(x, dict) else {}   # malformed subtree -> empty, no crash

def can_inform(state, now=None, max_hours=FRESH_MAX_HOURS):
    """Decide whether actionable lines may reach a human. Returns (ok, reason).

    ok=True  -> show actionable lines; reason is 'data Nh old'.
    ok=False -> suppress them; reason is the one honest line to show instead.

    Never raises on a malformed `state` — the input you don't control. (Pass a
    real datetime for `now` and a number for `max_hours`.)
    """
    state = _section(state)
    now = now or datetime.now()

    # 1. Systemic source failure: most inputs failed this run, not one bad item.
    q = _section(state.get("data_quality"))
    if q.get("mass_failure"):
        nf, tot = q.get("n_failed"), q.get("total")
        detail = f"{nf}/{tot} sources failed" if nf and tot else "most sources failed"
        return False, f"data unreliable — {detail}; no read until the next clean run"

    # 2. A recorded run failure. The broad signal: the pipeline never finished,
    #    so it wins early and by design. It only fires if the failure is at least
    #    as fresh as the data we hold — an old failure must not gag fresh data
    #    (see "the nuance"). Equal ages block; a missing age blocks.
    last = _section(state.get("last_run"))
    if str(last.get("status", "")).lower() == "fail":
        fail_age = _age_hours(last.get("at"), now)
        data_age = _data_age_hours(state, now)
        if fail_age is None or data_age is None or fail_age <= data_age:
            return False, "last run failed — no fresh read"

    # 3. An attempted refresh that failed, carrying a specific reason.
    refresh = _section(state.get("refresh"))
    if refresh.get("status") == "failed":
        return False, f"{refresh.get('reason') or 'refresh failed'} — lines suppressed"

    # 4. Staleness, and a future timestamp (a broken clock, not fresh data).
    age = _data_age_hours(state, now)
    if age is None:
        return False, "no data yet — refresh before acting on anything"
    if age < -SKEW_HOURS:
        return False, "data timestamp is in the future — clock skew; refusing"
    if age > max_hours:
        return False, f"data is {age:.0f}h old — refresh before acting"

    return True, f"data {age:.0f}h old"

The helpers absorb their own parse errors, which is what lets the gate promise never to raise on bad state:

def _age_hours(raw, now):
    """Hours since an ISO timestamp, or None if missing/unparseable. Assumes the
    naive local timestamps your producers write; if yours are tz-aware or end in
    'Z', normalise to naive UTC before you store them. That's a hard rule, not a
    footnote: mixed aware/naive subtraction lies or throws."""
    if not raw:
        return None
    try:
        return (now - datetime.fromisoformat(str(raw)[:19])).total_seconds() / 3600.0
    except Exception:
        return None

def _data_age_hours(state, now):
    refresh = _section(state.get("refresh"))
    if refresh.get("status") in ("ok", "partial"):
        age = _age_hours(refresh.get("at"), now)
        if age is not None:
            return age
    return _age_hours(state.get("generated_at"), now)

Two choices carry the weight. First, ok=False is not silence; it returns one true sentence to show in place of the suppressed content. “Data is 30h old — refresh before acting” is the most useful thing the agent can say that morning, and it’s what the stale briefing was hiding. Second, the checks run in order and the first match wins, so the wording a reader sees at 7am is a decision you make, not an accident.

For provenance, since the whole post is about not overclaiming: the gate’s decision logic, the partial-refresh handling, the per-row freshness flag below, and the lock and atomic write further down all shipped. I’ve inlined the inputs here — production reads the run record from disk. Two things are honestly additions I made writing this up, not shipped code: the _section guard against a malformed state, and the clock-skew check. The shipped gate uses a plainer state or {} and has no future-timestamp guard; it should have both, and that’s on me. So: the decision logic is what ran, hardened with two guards it still wants.

The nuance that cost me a rewrite

My first version of check #2 was one line: if the last run failed, suppress. It over-suppressed, which is the failure that quietly kills trust.

Picture four runs a day. The afternoon run fails. The next morning’s run then succeeds cleanly, fresh data and all. The one-line check still gags that clean morning briefing, because “the last run failed” is, technically, the most recent thing on record. An agent that goes dark on good data gets muted by its users, and then it’s silent on the morning it’s right. So the check fires only when the recorded failure is at least as fresh as the data you hold; a failure from before your freshest data can’t blind you to it. There’s a post on AWS’s dev.to titled “Runtime Guardrails for AI Agents — Steer, Don’t Block” for the same reason: guardrails that only ever block get torn out. Refuse narrowly, and inform the rest of the time.

Check #3 looks like the opposite rule and isn’t. A failed refresh always suppresses, even when the older scan is still inside its window, because a refresh that tried for fresh data and failed leaves you with a mix of rows you can’t tell apart — worse than a clean scan that’s simply ageing. An old run failure age-gates because the scan it produced may be perfectly good. The asymmetry is the point: suppress on “I tried and couldn’t”, inform on “I succeeded a while ago”.

One dead symbol blinded ninety-nine lines

A single global verdict has a hole, and it’s where most “add a confidence threshold” advice falls down. If a refresh updates ninety of a hundred items, calling the whole briefing fresh is a lie about the other ten.

I learned this from one illiquid ticker. A delisted name the price feed couldn’t quote was enough, under my original all-or-nothing rule, to fail-close the entire briefing — so on a morning when ninety-nine of a hundred positions had perfectly fresh prices, the agent went DATA-BLIND on all of them because one dead symbol came back empty. The fix was to stop treating freshness as global. The run-level gate still handles catastrophes (no data, a mass failure, a failed run). But within a partial refresh, freshness is per item: the names that resolve get fresh prices, and the ones that don’t stay at their last value carrying a price_fresh = False flag.

You then get a real choice — mark the stale line or hide it. Production marks it, rendering a small “couldn’t price this” chip so the reader sees exactly which line is old. The tidier gate hides it:

def line_visible(item, run_ok, partial):
    """Per-item gate for the partial case. On a partial run an item must be
    explicitly fresh to show; on a full run every line is fresh. Fail closed: a
    missing flag during a partial run hides the line rather than guessing."""
    if not run_ok:
        return False                              # run-level gate already said no
    if partial:
        return _section(item).get("price_fresh") is True
    return True

Either way, one dead symbol can no longer blind the other ninety-nine. A confidence check that can’t say which line it’s unsure about isn’t finished.

Prove it stays quiet

An abstention gate is a safety claim: this stays quiet on bad data. So the gate ships with fixtures whose only job is to assert it refuses — including the over-suppression case the rewrite was about, and the partial case that hides one line without hiding the brief:

from datetime import datetime, timedelta

NOW = datetime(2026, 6, 16, 12, 0, 0)
def t(h):  # an ISO timestamp h hours before NOW
    return (NOW - timedelta(hours=h)).isoformat()

def test_speaks_only_on_fresh_data():
    assert can_inform({"generated_at": t(2)}, now=NOW)[0] is True
    assert can_inform({"generated_at": t(30)}, now=NOW)[0] is False   # stale
    assert can_inform({}, now=NOW)[0] is False                        # no data
    assert can_inform({"data_quality": 5}, now=NOW)[0] is False       # malformed, no crash

def test_does_not_over_suppress():
    old_fail = {"generated_at": t(2), "last_run": {"status": "fail", "at": t(20)}}
    assert can_inform(old_fail, now=NOW)[0] is True       # old failure, newer data: speak
    fresh_fail = {"generated_at": t(20), "last_run": {"status": "fail", "at": t(2)}}
    assert can_inform(fresh_fail, now=NOW)[0] is False    # fresh failure, old data: block

def test_partial_hides_the_line_not_the_brief():
    state = {"generated_at": t(0.2), "refresh": {"status": "partial", "at": t(0.1)}}
    run_ok, _ = can_inform(state, now=NOW)
    assert run_ok is True
    assert line_visible({"price_fresh": True},  run_ok, partial=True) is True
    assert line_visible({"price_fresh": False}, run_ok, partial=True) is False
    assert line_visible({}, run_ok, partial=True) is False            # unflagged: fail closed

These sit in the smoke check I run before every commit, so a refactor that quietly makes the gate chattier fails before it ships. That guard matters because abstention is structurally under-measured: standard accuracy metrics reward answering and cost nothing for a confident wrong answer to an unanswerable question. That gap is why AbstentionBench had to exist. If you don’t write the test that says “refuse here”, nothing in your normal suite ever will.

What silence looks like

When the gate says no, the agent still owes the human a calm note: it has nothing to say, and why. Not a stack trace, and not yesterday’s numbers in today’s font. The whole-run failure output for my briefing is fixed-shape (wording representative):

═══════════════════════════════════
  MORNING BRIEFING — 2026-06-16 07:00
═══════════════════════════════════

DATA-BLIND: the scheduled refresh did not complete.
No price levels, actions, or risk lines are shown from old data.
The last good snapshot is linked below, as a snapshot, not a fresh read.

It names the one thing wrong and points at the last known-good state. The link below is that snapshot, labelled as stale, not a fresh read. Two paths share the principle: the whole-run failure above, where no usable state got built, and per-line gating, where state exists and the gate lets context lines through while holding price lines back.

Protect the gate’s input

The gate is only as honest as the state file it reads, and a scheduled writer can poison its own input. If a run is slow and the next tick fires early, two processes write the file at once; if a process dies mid-write, the next reader gets half a JSON file. A non-blocking lock makes a run that can’t get the lock skip its tick instead of racing, and an atomic write means a crash can’t leave a torn file behind.

import contextlib, errno, fcntl, json, os, tempfile
from pathlib import Path

LOCK = Path(__file__).resolve().parent / "agent.lock"   # absolute: cron/launchd cwd lies

@contextlib.contextmanager
def run_lock():
    """Yields True if acquired, False if another run holds it. flock is advisory
    and tied to the fd, so the OS drops it if the holder dies — no stale lockfile."""
    f = open(LOCK, "a")
    got = False
    try:
        try:
            fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
            got = True
        except OSError as e:
            if e.errno in (errno.EAGAIN, errno.EACCES, errno.EWOULDBLOCK):
                got = False              # already held: skip this tick
            else:
                raise                    # a real lock error is not a skip
        yield got
    finally:
        if got:
            with contextlib.suppress(Exception):
                fcntl.flock(f.fileno(), fcntl.LOCK_UN)
        f.close()

def atomic_write_json(path, obj):
    """Temp file in the same dir, fsync, then os.replace. No reader sees a
    half-written target on a normal crash. (Power-loss durability also wants an
    fsync on the containing directory; add it if a yanked cord is in scope.)"""
    path = Path(path)
    fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=path.name + ".", suffix=".tmp")
    try:
        with os.fdopen(fd, "w") as f:
            json.dump(obj, f)
            f.flush()
            os.fsync(f.fileno())
        os.replace(tmp, path)
    except Exception:
        with contextlib.suppress(Exception):
            os.unlink(tmp)
        raise

flock is advisory and local: it coordinates cooperating processes on one machine, not two boxes on a shared mount, where you’d want a real lock service. The retry-driven version of this hazard — an agent that retries a side-effecting step and double-fires it — is its own well-documented trap; idempotency keys are the usual fix.

Freshness is just my predicate

Nothing here is really about staleness. The shape underneath it is: name the one condition that makes your agent’s output worthless, write it as a function that returns a verdict and a reason, and run it before anything reaches a human. Mine is “is the data fresh enough to act on”, because I brief off a live feed. Yours probably isn’t:

  • Coverage — did enough of the inputs resolve? (the partial case above, generalised)
  • Source agreement — do your retrievers or tools corroborate each other, or did one contradict the rest?
  • Confidence — is the model’s own score under a threshold you set? This is where conformal abstention and semantic-entropy probes plug in.
  • Schema validity — did the upstream payload actually parse into the shape you expected, or are you about to reason over a null?

Swap the predicate and everything else in this post carries over unchanged: the pure function, the one honest line on refusal, the per-item split, the fixtures that prove it stays quiet. The freshness gate is one instance. The pattern points at any failure mode you can name and test.

When this is the wrong tool

This is a data-freshness gate. It is not a content filter, a jailbreak defence, or a replacement for the model-IO guardrails you’ve read about. NeMo Guardrails and Guardrails AI police what goes into and comes out of the model; LangGraph’s interrupts pause the workflow for a human. The confidence methods from earlier — conformal abstention, semantic entropy — police whether the model trusts its own answer. This gate polices whether the inputs are worth speaking on at all. Different axis, and it’s the one the others miss: a model can be perfectly calibrated about an answer it computed from data that went stale overnight. Those confidence methods also judge single answers, not the uncertainty that piles up across a long agent run.

The gate is also only as good as its freshness signals. If a feed stamps “now” on cached data, or a run exits 0 after failing, the gate waves it through; most of the real work is upstream, making “is this fresh?” answerable honestly. And refusing has a cost. Every abstention is a morning the human doesn’t get their briefing, and the abstention literature is blunt that the trade is real: you answer fewer questions to make the answers trustworthy. For a digest or newsletter agent, stale context plus a few calendar facts may beat silence — tune the gate looser, or scope it to the lines that can do harm. The rule I’d defend is narrow: for the lines a human acts on, a wrong answer costs more than no answer.

The takeaway

Models will act all day, on anything. Getting one to abstain is the harder job, and the research is clear the model won’t do it reliably on its own, whether you scale it up or prompt it harder. It won’t refuse a bad question, and it certainly won’t refuse a good question asked of stale data unless something outside the model makes it.

So put that decision in deterministic code beside the model. Give it one input it can trust and keep it pure, so the fixtures that assert it stays quiet actually mean something. Track freshness per item, so one dead symbol can’t blind the rest. None of it is clever. It’s just the code I trust at 7am, when the briefing goes out and nobody’s watching.

Further reading

Comments & Reactions

Got a thought, a war story, or a “well, actually”? Sign in with GitHub and jump in.

Loading comments…