AI Engineering2026-08-0514 min read

Five AI Agents, One Bug: When Missing Data Looks Like a Clean Result

We built and shipped five open-source vertical AI agents. Every single one had the same class of defect: absent or unreadable input rendered as a confident, clean answer. Here is what that bug looks like, why tests miss it, and what actually catches it.

Varun Raj Manoharan
Varun Raj ManoharanFounder & Principal Engineer
AI AgentsTestingOpen SourceSoftware DesignCode Review

Key takeaways

  • Across five independently built agents, every Critical defect was the same shape: missing, malformed or unreadable input coerced into a plausible value and presented as a real answer.
  • Unit tests could not catch these because the tests encoded the same assumption the code did, and in three cases the documentation asserted a safety property the code never enforced.
  • The guard always existed at the layer where the bug lived and never at the layer where the user forms a belief, so each layer was locally correct while the composition lied.
  • What caught them was adversarial probing of failure paths, fuzzing with a conservation invariant, and reading prose claims back against the code that supposedly implements them.

We built five open-source AI agents, one per industry, and published them. A freight agent that vets carriers, a contractor agent that prices jobs, an accounting agent that reconciles bank statements, an insurance agent that finds coverage clauses, and an agency agent that scopes statements of work. Different domains, different data sources, different authors. Each one was built by a separate implementer working from its own brief.

Every single one shipped a Critical defect in review. And every one of those defects was the same bug.

Not the same code. The same shape: something was missing, and the agent rendered it as a confident, clean answer.

That is worth writing down, because I did not expect the failure to be that consistent, and because the things that eventually caught it were not the things I would have guessed.

The one that made it obvious

The freight agent's job is to check a motor carrier before a broker hands them a load. You paste an MC number, it queries FMCSA, and it tells you whether the carrier has active authority, insurance on file, and a clean safety record. The point of the tool is catching carriers you should not trust.

FMCSA returns a JSON envelope. The parser pulled the carrier out of it like this:

TypeScript
const carrier = first.carrier ?? first;

Reasonable-looking. Some responses nest the carrier one level down, some put the fields directly on the content object, and ?? handles both.

Except FMCSA also returns { content: { carrier: null } } when nothing matches. null is nullish, so ?? falls through to first, which is { carrier: null }. That is a non-empty object, so it sailed past the emptiness check and went into the normaliser, which filled in defaults for every field it could not find:

JSON
{
  "ok": true,
  "carrier": {
    "dotNumber": 0,
    "legalName": "Unknown",
    "allowedToOperate": false
  }
}

A carrier that does not exist, returned as a successful lookup. Not an error. Not "not found". A record, with ok: true on it, ready to be summarised for a broker who is about to tender forty thousand dollars of freight.

The whole reason the tool exists is to catch carriers that should not be trusted, and it had a path where a nonexistent carrier came back looking like a real one.

The same bug, four more times

Once I had seen it, I started specifically probing for it in the other four. It was in all of them.

The contractor agent invented good weather. It calls Open-Meteo to tell a roofer which days are workable. Open-Meteo returns null in precipitation_probability_max past its forecast horizon. That is documented, normal behaviour, not an error. The code did this:

TypeScript
precipitationProbabilityPct: Number(precip[i]),

Number(null) is 0. So a day with no forecast data at all came back as:

JSON
{ "precipitationProbabilityPct": 0, "windSpeedMaxKph": 0, "workable": true }

A perfectly dry, perfectly calm day, invented out of nothing. A roofer schedules a crew on it.

The insurance agent cited coverage grants as exclusions. Its one hard rule is that it may never state a coverage conclusion without quoting a clause from your own uploaded policy. Good rule. But the parser only started a new section when it hit one of six recognised headings. Any other heading got absorbed into the current section and inherited its classification. A paragraph under ADDITIONAL COVERAGES, following an EXCLUSIONS block, came back tagged exclusions.

On a real ISO HO-3 (the most common homeowners form in the US), SECTION I - PROPERTY COVERAGES was not in the recognised list. So the entire insuring agreement got cited as an exclusion.

The agent then did exactly what its safety rule demanded: it quoted a real clause from the real policy and stated a conclusion. The citation was genuine. The conclusion was backwards. A wrong citation is more dangerous than no citation, because it is more persuasive.

The same agent had a second one. Upload a scanned, image-only PDF, routine in claims work, and no text extracts. Zero sections parsed. The clause search then reported, in these words, that the policy appears silent on this point. The system could not tell "I read nothing" from "I read everything and found nothing", and it defaulted to the confident reading.

The accounting agent reported a real discrepancy as a clean month. Its split detection ran in two directions: bank rows that might match several ledger rows, and the reverse, over the same pool of unmatched entries, independently. So one row could be consumed twice: once as the single side of a split, once as a member of the group in the other direction.

Feed it a bank statement totalling $150 against a ledger totalling $250, a genuine hundred-dollar hole:

Shell
bank:   [$100, $50]
ledger: [$40, $60, $150]

It returned nothing unmatched, two mutually contradictory splits that happened to balance, and needsHumanReview: 0. The instructions tell the model that number is the one that matters. So the agent would tell an accountant the month reconciles.

The agency agent printed undefined into a client document. Its SOW builder took the priced estimate as z.any(). A malformed estimate object produced a complete-looking statement of work containing the literal text Total: undefined across undefined hours, and a missing totals key threw outright. A model dropping a field while re-serialising a large object is the ordinary case, not an exotic one, and the artefact goes to a client.

Why the tests were green

Every one of these repos had a real test suite. The five together carry 570 tests, all passing, no type errors. None of them caught any of this.

That is not because the tests were bad. It is because a test encodes the same assumption the code does. The author who wrote Number(precip[i]) believed Open-Meteo always returns numbers, so the fixture they wrote contains numbers. The test proves the code handles the input the author imagined. It is silent about the input the author did not imagine, which is precisely where this class of bug lives.

The weather agent had eleven tests on its forecast client, four of them specifically about failure: network error, non-2xx response, unparseable body, missing daily key. Not one used a null value inside an otherwise well-formed response, which is the thing the API actually does.

Why the documentation was worse than useless

Here is the part I found genuinely uncomfortable.

In three of these cases, the safety property was documented. Not implied. Stated, in the repo, in prose.

The contractor agent's ARCHITECTURE.md said: "Nothing downstream ever sees a fabricated forecast." Its PRODUCTION.md said there is "never a workable: true verdict built on missing data." Both false, in the same commit that introduced the code that made them false.

The freight agent's transit estimator had an assumptions array it returned with every answer, so a broker could see what the number rested on. One entry read: "Solo driver under the 11-hour driving limit and 14-hour on-duty window." The 14-hour on-duty window was not modelled anywhere in the code. Dock dwell never counted against on-duty time at all. Ask it about 550 miles with four hours of loading and it reported a same-day delivery that a driver cannot legally make.

The sentence was written. The check was not. And the sentence made the number more trustworthy than it deserved to be, which is the exact opposite of what an assumptions list is for.

I have started treating this as a rule: a documented safety property is a claim to be verified, not evidence of anything. When a doc says the system cannot do X, that is the first thing to go and try.

The structural cause

Looking at all five together, there is one thing they share beyond the symptom.

In every case, the guard existed at the layer where the bug lived, and was missing at the layer where the user forms a belief.

The accounting agent is the clearest example. Its CSV parser correctly skipped malformed rows and recorded a warning for each. Its summary correctly counted the rows it had. Its matcher correctly matched what it was given. Every layer was locally right. And the composition told an accountant the reconciliation was clean, because nothing in the chain was responsible for saying three lines never made it in here.

The freight parser correctly returned whatever object it found. The normaliser correctly filled defaults for absent fields, which is what a normaliser is for. Nobody wrote the line that says: if the identity of this record is a default, this is not a record.

Local correctness composes into a global lie surprisingly easily. Each function has a narrow contract it honours; the belief the user ends up with is nobody's contract.

What actually caught them

Three things, none of which are "write more unit tests".

Adversarially probing the failure paths, not the happy path. For each agent I asked one question: is there any input where this returns a clean, successful result built on data it does not have? Then I tried to construct it. Empty envelopes, null-valued fields, arrays shorter than their index, whitespace-only documents, malformed nested objects. The probe that found the freight bug was four lines long.

Fuzzing with a conservation invariant. The accounting bug is not visible by reading. Both split passes look correct in isolation, and the interaction is what fails. It was found by generating a few hundred random reconciliations and checking a property that must hold for every one of them: each input row appears in exactly one output bucket. A handful violated it.

How many depends entirely on the input distribution. The run that found it caught two cases in 399; regenerating against the pre-fix code with a different spread of amounts hit nine in 400. That variance is the point rather than a footnote: the defect was always there, and whether a given sample happens to trip it is luck. Which is why the invariant is what becomes the permanent test, and the sample count is not a property of the bug worth quoting.

That invariant is now a permanent test, and it is worth noticing why it is stronger than a spot test. A spot test asserts a specific answer, and can pass by coincidence. A conservation invariant asserts a structural truth about every possible run. It cannot be satisfied accidentally.

Reading prose claims back against the code. For each repo I took every factual assertion in the README and the docs: every "it will never", every "nothing is", every worked example, and checked it against the implementation. That is how the unenforced hours-of-service window surfaced. It is also how I found that one README's worked example showed two flags where the code produces three, and another had a hand-typed figure of $253.00 where the code returns $252.99.

For worked examples specifically, the fix is mechanical: generate them by running the code, never by writing them. One of these repos now has a test that parses the JSON block out of its own README, runs the real function, and diffs them field by field.

The fix pattern

Across all five, the correction had the same shape too.

Replace the coerced default with a typed refusal:

TypeScript
// before: absent data becomes a plausible value
precipitationProbabilityPct: Number(precip[i]),

// after: absent data becomes a refusal
const value =
  typeof raw === "number" && Number.isFinite(raw) ? raw : null;
if (value === null) {
  return {
    ok: false,
    reason: "unavailable",
    detail: "Open-Meteo response contained incomplete values.",
  };
}

Two details in that snippet matter more than they look.

The check is on the raw value, before coercion. Checking Number.isFinite after calling Number() would also work here, but it leaves the coercion in place for whoever adds the next field. Rejecting at the boundary is the version that stays fixed.

And the failure is typed and returned, not thrown. Every one of these agents runs inside a model loop where a thrown exception ends the turn. A typed failure the caller must handle is how the refusal reaches the user as an answer rather than a crash.

The other half is making the refusal reach the surface. It is not enough for the library to return ok: false. The agent's instructions have to require saying so. The accounting agent now carries a hard rule that a skipped row means an incomplete reconciliation, and that the count must be reported before the results, not after. Because the failure mode was never really "the code was wrong". It was "the user believed something the system never checked".

The five repos

All of these were found and fixed in review, before publication. They are MIT-licensed, standalone, and deploy to your own infrastructure:

Each one has its own post going into what it does and how it is built.

If you are shipping an agent that wraps any uncertain data source (an API that can be down, a document that might not parse, a CSV a human exported), the question worth asking is not whether your tests pass. It is: what does this return when the data is not there, and would anyone be able to tell?

In five out of five, the answer was worse than we assumed.

Available for new projects

Let's build something great.

Have a project in mind? We are an elite software and AI development studio ready to bring your ideas to production. Let's talk about your roadmap.

See our work