quietfail
Catch LLM agents that fail silently — no ground truth required.
Agent observability for LangGraph and LangChain. Detects silent failures, behavioural drift, and runaway cost in production AI agents by learning what normal looks like and alerting when a run falls outside it.
Your agent didn't crash. It turned an error into fluent, plausible prose, returned 200 OK, and your dashboard stayed green.
The problem
An 8-week longitudinal study of a production LLM agent runtime (arXiv 2606.14589) found:
- ~70% of incidents were discovered by a human happening to notice something off
- 4,286 unit tests and 827 governance checks caught approximately none of them
- Silence latency ranged from 13 hours to 60 days
- The novel failure mode: the model transforms an error into plausible narrative content instead of surfacing it
Tracing tools show you what happened if you go and look. The failures that hurt are the ones nobody looks at. Evaluation frameworks need labelled ground truth, which most teams never build — evaluation has been the #1 reported challenge in the AI stack for three consecutive years, and the most common method remains the vibe check.
quietfail takes the third path: no ground truth, no labels. Just the agent's own historical behaviour, and an alert when today deviates from it.
How it works
Run in observe mode, build a baseline, then watch.
| Signal | Catches | Needs labels? |
|---|---|---|
| Output shape — schema conformance, field presence, length distribution | Model silently dropping fields after a version bump | No |
| Trajectory — node sequence and per-run counts | Agent stopped calling validate entirely |
No |
| Tool health — empty-result rate, error rate, latency p95 | An API returning [] instead of a 500 |
No |
| Terminal-state mix — outcome distribution | Escalation rate quietly dropping to zero | No |
| Content drift — output distance from a baseline centroid | Upstream extraction decaying while every field stays present | No |
| Budget breach — tokens, USD, tool calls, wall-clock per run | The retry loop that burned $4,200 in 63 hours | No |
Detection is deliberately statistical, not ML — frequency tables, percentile bands, observed-path sets. When an alert fires you get a one-sentence reason, not a model score.
Does it actually work?
examples/invoice_agent/inject.py breaks a working agent in five realistic
ways, none of which raise an exception or turn a dashboard red, and checks
that each one is caught. It exits non-zero if any is missed, so CI gates on
it.
$ poetry run python -m invoice_agent.inject
Phase 1 - record normal behaviour
20 runs recorded, baseline built
outcome mix : {'posted': '20%', 'awaiting_human': '80%'}
Phase 2 - break it five ways
[QUIET] control - 15 healthy runs, 0 alerts
[CAUGHT] tool goes silently empty
-> critical: vendor_lookup empty rate 100% vs baseline 0%
[CAUGHT] approval gate removed
-> critical: outcome 'awaiting_human' collapsed vs baseline 80%
[CAUGHT] output field dropped
-> critical: run completed without reaching any terminal outcome
[CAUGHT] resolution loop never converges
-> warn: 15 steps vs baseline p99 of 9
[CAUGHT] extraction quietly degrades
-> warn: output wording diverged from baseline (distance 0.84)
5/5 silent failures detected
The control line matters as much as the four catches. A monitor that fires on healthy traffic gets muted, and a muted monitor is worse than none.
Quickstart
from quietfail import Store, Watcher, Budget
store = Store("quietfail.sqlite")
watcher = Watcher(store, agent="my_agent")
final, record, alerts = watcher.run(
graph, payload, config=config, budget=Budget(max_usd=2.0, max_steps=40)
)
Record runs you trust, then build the baseline:
quietfail baseline --agent my_agent # needs >= 10 recorded runs
quietfail alerts --agent my_agent -v
quietfail report --agent my_agent -o report.html
To track tool health, decorate the tools your agent calls:
from quietfail import instrument_tool
@instrument_tool(is_empty=lambda r: not r.get("vendor_id"))
def vendor_lookup(name: str) -> dict:
...
Alerts can go anywhere:
from quietfail import Watcher, fan_out, severity_filter, slack_sink, stdout_sink
watcher = Watcher(
store, agent="my_agent",
output_text=lambda state: state.get("answer", ""), # enables content drift
on_alert=fan_out(stdout_sink(), severity_filter(slack_sink(URL), "critical")),
)
Works with both agent styles
Raw StateGraph — Watcher streams the compiled graph, no graph changes:
final, record, alerts = watcher.run(graph, payload, config=config)
create_agent — middleware plugs into the same lifecycle from the inside:
from quietfail.middleware import QuietfailMiddleware
agent = create_agent(model=..., tools=[...], middleware=[
QuietfailMiddleware(store, agent="support_bot",
budget_factory=lambda: Budget(max_usd=1.0)),
])
Same store, same baselines, same CLI. The trajectory differs — create_agent
has no graph nodes, so the path is the model/tool call sequence instead.
Storage
SQLite by default: one file, no server, inspectable with any SQL client. For several processes recording concurrently:
from quietfail.postgres import PostgresStore
store = PostgresStore("postgresql://user:pass@host/quietfail")
Identical interface — everything downstream is unchanged.
Status
v0.1.0 — all six signals implemented, both agent styles supported, 85 tests, CI on Python 3.11-3.13 against a live Postgres.
- Run store (SQLite and Postgres), collection via
graph.stream -
create_agentmiddleware adapter - Baseline profiles, per-run and aggregate drift evaluation
- Content drift with a pluggable embedder
- Budget enforcement and circuit breaking
- Alert sinks: stdout, webhook, Slack, fan-out, severity filtering
- CLI and standalone HTML report
Known limits
Stated plainly, because a monitoring tool that oversells itself is worse than none:
- The default embedder is lexical, not semantic. A hashed bag of tokens
catches garbled or rewritten output; it will not catch a fluent paraphrase
that means something different in the same vocabulary. Pass any object with
.embed(text) -> list[float]to fix that. - Aggregate signals need >= 10 runs after the baseline before they say anything. Low-traffic agents get per-run signals only.
- A baseline learned from broken behaviour treats broken as normal. Build it from runs you have actually looked at.
- The LLM extraction path in the example is tested with an injected fake model, not against a live API. Retry and error handling are covered; real-model output quality is not.
- One
QuietfailMiddlewareinstance tracks one run at a time. LangGraph rebuilds state between nodes and may run them on different threads, so neither state identity nor a ContextVar can correlate a run — the instance is the run scope. Call.for_run()per concurrent invocation.
The reference agent
examples/invoice_agent is a real AP invoice exception resolver, not a toy. It exists so quietfail has something realistic to watch, and it demonstrates the LangGraph patterns worth copying:
- Deterministic validation, not LLM validation — totals arithmetic, duplicate detection and PO existence are exact operations. Models do judgment; code does arithmetic.
- A genuine cycle — proposed fixes flow back through validation (
apply_resolution → validate), so a partially fixed invoice can never post. This is why you reach for LangGraph over a chain. - A bounded loop — resolution rounds are capped. An unbounded retry loop is the documented shape of a $4,200 incident.
- Confidence-gated human-in-the-loop —
interrupt()fires only for low-confidence or never-auto-resolvable classes, and the payload leads with why this one is unusual. Approval fatigue is a documented antipattern: a human rubber-stamping 73 actions a day is not a control. - Reducers chosen deliberately —
resolutionsaccumulates (it's the audit trail);findingsis replaced (validation re-runs, so accumulating stale errors prevents convergence).
poetry install
poetry run python -m invoice_agent
======================================================================
unknown-vendor-003
----------------------------------------------------------------------
[error] missing_gl_code: 1 line item(s) missing GL code: ['Subscription, Q3']
class : coding_incomplete
attempt : vendor_lookup (confidence 1.0) -> {'vendor_id': 'V-1003', ...}
attempt : gl_code_history (confidence 0.333) -> {'Subscription, Q3': '6450'}
rounds : 1
--> PAUSED at human_review: coding_incomplete
resumed -> posted
Round 1 resolves the vendor; re-validation then surfaces the GL gap rather than posting a half-fixed invoice.
All fixtures are synthetic. No proprietary data.
Install
poetry install # runtime + dev
poetry install --with llm,postgres # model-backed extraction + Postgres store
poetry run pytest
poetry run ruff check .
poetry run python -m invoice_agent.inject # the acceptance test
By default the reference agent runs offline against deterministic JSON fixtures — no API key needed. Set IEA_EXTRACTOR=llm (plus OPENAI_API_KEY) to run extraction against a real model over the matching .txt invoice documents.
Useful while learning the graph:
poetry run python -m invoice_agent # all fixtures, summarised
poetry run python -m invoice_agent.trace unknown-vendor-003 # state, node by node
Why not just use LangSmith / Langfuse / Arize?
Use them — quietfail is not a replacement. They answer "what did this run do?" extremely well. quietfail answers a different question: "did anything change, and should someone be woken up?" — without you having to build a labelled eval set first. They are complementary: trace there, alert here.
Design notes
Design decisions, the bugs found while building, and the questions this
codebase should let you answer are in DESIGN_NOTES.md.
License
MIT
Contributing
Issues and PRs welcome, especially failure modes you've hit in production that the signal set above would miss.
Release files for quietfail 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| quietfail-0.1.0.tar.gz | 42.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| quietfail-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 94.0 kB
Release files / quietfail-0.1.0.tar.gz
| Download URL | quietfail-0.1.0.tar.gz |
|---|---|
| Size | 42.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ddf86428743eb1c2306a9e52b7cdc9b0ebadcf8480bc6aabf65456c04639c58d
|
|
BLAKE2b-256 checksum How to use checksums |
c0792ecd2fc6fac74318d08dc13a6d1c2fafe6c51f666ab1af60a203efec591a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
poetry/2.1.1 CPython/3.13.2 Darwin/25.5.0
|
Release files / quietfail-0.1.0-py3-none-any.whl
| Download URL | quietfail-0.1.0-py3-none-any.whl |
|---|---|
| Size | 51.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a83b4a852c01944e1dd67c930c04b8c6b22c2eba5d094b3b89717f727bd3ea8f
|
|
BLAKE2b-256 checksum How to use checksums |
b94e3fa6381379f45aad56e89acb7825c1c7e6575c0699a74835af797cc21a3f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
poetry/2.1.1 CPython/3.13.2 Darwin/25.5.0
|