DEV Community

Cover image for 98 Calls, 98 Errors, 0 Detections: The Bug My Test Suite Called Green
Debashish Ghosal
Debashish Ghosal

Posted on AI-assisted

98 Calls, 98 Errors, 0 Detections: The Bug My Test Suite Called Green

A detector returned None on every call for two full corpus runs, and the test suite reported green the whole time.

Not "it ran and found nothing." It never executed a successful call. Every signal I trusted — green tests, firing detectors, clean telemetry — said fine. The only reason I found it is one line in a summary file I'd been skimming past.

This is the kind of bug except Exception: return None makes possible, and most of us have written that line.

The run that looked great

I was piloting 100 synthetic agent traces through the LLM-augmented detectors in agentwatch — one tool in agentic-suite, a suite of open-source, harness-agnostic security tools for AI agents. The summary came back and it looked genuinely good:

traces_processed: 100
chat_calls: 363
embed_calls: 98
errors: 98
total_tokens: 52,578
json_parse_success: 363
json_parse_fail: 0
Enter fullscreen mode Exit fullscreen mode

363 chat calls. 100% JSON parse success. 52,578 tokens. Detectors firing: hallucination: 98, confusion_pattern: 2, quality_degradation: 1. All sensible numbers.

Then one line: embed_calls: 98, errors: 98.

Every single embedding call failed. And I didn't notice — because the corpus run counts fires, not non-fires. A detector that never fires doesn't show up as "0." It's simply absent. And absent is not zero. Absent means this detector never ran.

This wasn't the first time, either. An earlier run, FT-11b, had 24 embed calls with 24 errors — same 100% failure rate, same invisibility. The bug had been there across two full corpus runs (FT-11b at 25 traces, FT-11d at 100 traces), and between them 122 embed calls had all failed. The error rate was in the telemetry the whole time; it just wasn't in the assertions.

The error was sitting in the logs the entire time:

LLM embed call failed: Error code: 404 - {'error': {'message':
"Model 'all-MiniLM-L6-v2' not found. Available models:
Llama-3.2-3B-Instruct-4bit, Qwen3-4B-Instruct-2507-4bit, Qwen3-8B-4bit,
Qwen3.5-4B-4bit, Qwen3.5-9B-MLX-4bit"}}}
Enter fullscreen mode Exit fullscreen mode

Logged 98 times. The tests passed, so nobody went looking for it.

The code path that made it invisible

The client was written to be resilient — a failed embedding shouldn't crash the pipeline:

async def embed(self, text):
    try:
        resp = await client.embeddings.create(model=self.embed_model, input=text)
        return list(resp.data[0].embedding)
    except Exception as exc:
        self._record_error(start)
        logger.warning("LLM embed call failed: %s", exc)
        return None  # ← silent failure
Enter fullscreen mode Exit fullscreen mode

The detector treats None as "no vector available," which is reasonable:

current_vector = await self._client.embed(output_text)
if current_vector is None:
    return None  # "can't compare vectors → no anomaly"
Enter fullscreen mode Exit fullscreen mode

And the test reads None as "didn't fire," which is correct for a negative case:

fired = anomaly is not None
fire_ok = fired == scenario.expect_fire   # False == False → pass
Enter fullscreen mode Exit fullscreen mode

Three different meanings collapsed into one None:

A None from "I checked and found no anomaly" == a None from "I couldn't check because the API failed" == a None from "I never called the API at all."

Without call telemetry, those are indistinguishable. All three look like a passing negative case.

The reason this stayed hidden in the corpus runs is structural. Those runs count fires. output_drift wasn't "0" in the anomaly counts — it was absent. And absent reads exactly like "the detector ran and found nothing" when you're scanning a summary.

Making the detector actually work

The endpoint had no embedding model at all. OMLX only serves chat completions — all-MiniLM-L6-v2 isn't on the list. So when embed() comes back empty, I now ask the same model to rate how similar two texts are, on a 0-to-1 scale:

if current_vector is not None and baseline is not None:
    sim = cosine_similarity(current_vector, baseline)
else:
    # Embedding unavailable — fall back to chat-based similarity
    sim = await self._chat_similarity(baseline_text, output_text)
distance = 1.0 - sim
if distance >= self._threshold:
    return Anomaly(..., evidence={"method": "chat-fallback", ...})
Enter fullscreen mode Exit fullscreen mode

The prompt is small and strict: "Compare two texts and rate their semantic similarity on a scale of 0.0 to 1.0. Return only JSON: {"similarity": <float>}." The distance is 1.0 - similarity. It's coarser than vector cosine — the model gives you a judgment, not a continuous metric — but it gets the fire-or-don't-fire decision right.

tip: When a primary API is unavailable, a chat-based fallback can produce correct verdicts with the same model. The tradeoff is precision, not correctness. Record the method in the evidence dict so operators know which path produced a given verdict.

There's a subtlety I almost missed. The first call has to set the baseline, so it returns None regardless — same as the vector path. The fallback only kicks in on the second call forward, once there's a baseline text to compare against. Get that ordering wrong and you fire on the very first trace, which is noise.

The check I should have written first

This is the part most suites skip. Before each scenario, snapshot the client's call counters. After, measure the delta for that scenario:

stats_before = dict(client._stats)
anomaly = await det.detect_async(summary, spans, pool=None)
stats_after = dict(client._stats)

llm_calls = stats_after["chat_calls"] - stats_before["chat_calls"]
embed_calls = stats_after["embed_calls"] - stats_before["embed_calls"]
llm_called_ok = (llm_calls + embed_calls) > 0

ok = fire_ok and sev_ok and type_ok and expl_ok and evid_ok and llm_called_ok
Enter fullscreen mode Exit fullscreen mode

If the LLM was never called, the scenario fails — even if the detector returned the "right" answer. A silent None with zero calls is a hidden skip, not a pass.

That check is the difference between a real pass and a hidden skip, and it's the one most suites don't have. It's also cheap: the client already tracks its own call counts for telemetry, so the harness just reads them.

After the fix, both scenarios run with real calls and real evidence:

✅ ED1  output_drift  fire=True   sev=critical  llm_calls=1  llm_errors=2
✅ ED2  output_drift  fire=False  sev=None      llm_calls=1  llm_errors=2
Enter fullscreen mode Exit fullscreen mode

The two llm_errors are the failed embed calls — still 404, still logged, but now they don't define the outcome. The one llm_calls is the chat fallback doing the actual work. The model's actual verdicts:

ED1 (completely different texts):  {"similarity": 0.1}   → distance 0.9 → fires critical
ED2 (nearly identical texts):      {"similarity": 0.95}  → distance 0.05 → silent (correct)
Enter fullscreen mode Exit fullscreen mode

Both were the right call. And this time I can prove the model actually made them.

This isn't about embeddings

The shape repeats anywhere a dependency returns a sentinel on failure:

Dependency Sentinel Looks like
LLM embed() None "no anomaly"
DB fetchrow() None "no baseline"
HTTP GET "" "no data"
Cache get() None "not in cache"
File read() "" "file is empty"

In every case, the failure wears the costume of legitimate emptiness. The test passes. The system is broken. Nobody knows — until someone counts the calls or checks the status, which is the thing the test didn't do.

The fix is always the same shape: prove the dependency was exercised, not just check the output. Count the calls. Check the response status. Assert the network was reached. A None return is only trustworthy if you can prove the call happened.

What I changed

A missing anomaly type is not "zero anomalies." It's "this detector never ran." Check for presence, not just count.

Log warnings are not alerts. The 404 was logged 98 times in one run, 24 in an earlier one, and nobody saw it. If a failure matters, make it an assertion, not a log line.

None is a terrible sentinel because it doesn't tell you which of its three meanings it has. "I checked," "I couldn't check," and "I never tried" all collapse into the same value and the same passing test.

The deeper habit change for me was the assertion. Before this, my tests asked "did the detector return the right answer?" After this, they also ask "did it actually do the work that produces an answer?" The first question can be satisfied by accident. The second one can't.

All of it is fixed in agentwatch v0.1.0, with the llm_called_ok check enforced on every LLM scenario.

The question worth asking of your own repo: which green test would still pass if the service behind it were down? The answer is usually the one that asserts the output but never asserts the dependency ran.

References

Top comments (8)

Collapse
 
vitayao profile image
Ziwei Yao •

Does llm_called_ok distinguish attempted calls from successful, parsed responses? In the snippet it only checks that the call-count delta is positive. If the embedding request and the chat fallback both fail, a negative scenario could still return None with a positive call count. A case where both paths are unavailable seems worth adding alongside ED1/ED2; it should report an evaluation error rather than a clean negative.

Collapse
 
debashish_ghosal profile image
Debashish Ghosal •

You're right, and thanks for commenting. llm_called_ok counts attempts, not answers — _record() runs in the except branch too, so chat_calls/embed_calls increment even when the call fails. A scenario where both the embed and the chat fallback fail would still satisfy (chat_calls + embed_calls) > 0. The honest fix is your second option: the detector should report "couldn't check" (a non-None, indeterminate result) instead of a clean negative. Great catch.

Collapse
 
vitayao profile image
Ziwei Yao •

Thanks for checking the failure branch. I’d keep three cases next to each other in the regression test: both calls fail → indeterminate; embedding fails but the fallback returns a valid result → use that result; a successful check finds nothing → clean negative. That would catch this without making every fallback look broken, and keep “couldn’t check” visible to callers.

Collapse
 
pierrelaurentmedori profile image
Pierre-Laurent Medori •

"Absent is not zero" is the line I'm taking into code reviews.

Ziwei's question made me curious, so I opened the repo. Short answer: it doesn't distinguish them. In llm_client.py (v0.1.0, and still in v0.3.0), embed() and chat() call _record() in their except branch too, which is why your summary shows embed_calls: 98 next to errors: 98. So llm_called_ok counts attempts, not answers. If the chat fallback fails as well, ED2 returns None after three failed calls, llm_io stays empty, verdict_ok defaults to True, and the scenario goes green. The inline comment on that branch expects llm_called_ok to catch exactly this case.

Counting answers instead of attempts would close it. Or, closer to your own conclusion, a detector that returns something other than None when it couldn't check.

Which may be the best proof of your thesis: the same collapse, one level up. _record()'s docstring says it records a successful call.

Thanks for publishing the raw logs and the field test report with the defect number. That part is rarer than the bug.

Collapse
 
debashish_ghosal profile image
Debashish Ghosal •

Thank you — you read it exactly right. embed() and chat() both call _record() in their except branch, so the counters track attempts, and _record()'s own docstring ("records a successful call") is the giveaway that intent and code diverged. Your point that the inline comment expects llm_called_ok to catch this — and it can't — is the real finding. Counting answers instead of attempts, or having the detector return a non-None "couldn't check" state, closes it. Thanks for opening the repo and writing it up so carefully.

Collapse
 
ascendra_ventures_92c21f2 profile image
Ascendra Ventures •

The title says it well. Ninety eight calls, ninety eight errors and a green test suite is what happens when a broad except returns None and the counter measures attempts instead of successes. Nothing was hidden on purpose. The code simply had no way to tell anyone.

Pierre-Laurent's line, absent is not zero, is the rule I would put on the wall. A missing embedding should be a visible state, not a value that quietly flows into the next step and looks like a normal result.

On client AI work we now write the failure cases into the acceptance criteria next to the success cases. For a pipeline like this one that means a test where the provider fails every call, and the expected outcome is a loud error and a count of zero successes, not a clean run. It is a small test and it would have caught this on day one.

After the fix, did you keep a separate metric for successful calls, or replace the old one?

Collapse
 
innokentyb profile image
Kent Bodrov •

Ziwei's availability test would close one gap. I'd also keep results split by method: a cosine distance and a model's 0–1 similarity judgment need separate calibration. Passing ED1/ED2 establishes those two extremes, not the behavior near your alert threshold. A held-out set concentrated around that boundary could show whether fallback changes false alarms or missed drift.

Collapse
 
debashish_ghosal profile image
Debashish Ghosal •

Agreed, and thanks for the comment. The fallback records method: "chat-fallback" in the anomaly evidence, so verdicts from the model's 0–1 judgment stay separable from vector cosine in the output. But you're right that ED1/ED2 only pin the two extremes (very different vs near-identical) and neither exercises the region near the alert threshold, which is exactly where a fallback method could shift false alarms. A boundary-concentrated held-out set is the missing piece. Appreciate it.