{"slug": "our-incident-response-agent-got-the-root-cause-wrong-7-times-out-of-12-it-still", "title": "Our incident-response agent got the root cause wrong 7 times out of 12. It still never made a bad rollback.", "summary": "An engineer from the Agent K project reports that their incident-response agent correctly diagnosed root causes in only 5 out of 12 seeded production incidents, yet it never executed a bad rollback. The safety mechanism relies on deterministic code checks rather than model correctness, with a policy gate that denies actions if any of six checks fail.", "body_md": "We ran Agent K against 12 seeded production incidents. It named the correct root cause **5 times out of 12** — 3 out of 9 if you discard the runs we couldn't cleanly attribute. Over the same 12 runs it produced the **correct rollback / no-rollback verdict 10 times**, published zero claims without a SigNoz link behind them, and executed zero actions outside its policy gate.\n\nThat gap between \"diagnosed correctly\" and \"behaved correctly\" is the entire project.\n\nAgent K was our entry for **Agents of SigNoz** (WeMakeDevs × SigNoz), Track 01. The setup: a FastAPI RAG support service — 72 synthetic help-centre docs in Postgres + pgvector, local `sentence-transformers`\n\nembeddings, free-tier models behind one OpenAI-compatible client (Groq by default; Cerebras, Gemini and OpenRouter swap in via one env var) — instrumented with OpenTelemetry, shipping traces, metrics and logs to SigNoz over OTLP. We installed SigNoz with **Foundry**, so `casting.yaml`\n\n/`casting.yaml.lock`\n\nare committed and the stack is reproducible; pointing the same exporter at SigNoz Cloud later took no code change.\n\nWhen an alert fires, Agent K investigates through the **SigNoz MCP server**, publishes only claims carrying a resolvable SigNoz link, and rolls the service back **only** if a piece of plain Python says it may.\n\nWe started from an assumption we never tried to engineer away: the model will be wrong. So correctness can't be the safety mechanism. The safety has to be code.\n\nEverything hangs off an in-process flag store with four seeded failures, and one deliberate asymmetry:\n\n```\n# app/flags.py\nFLAG_NAMES: tuple[str, ...] = (\n    \"prompt_regression\", \"retry_storm\", \"retrieval_latency\", \"db_pool_exhaustion\",\n)\n\n# The scenarios that represent a \"deployment\" (a code/prompt change) and therefore\n# get a deployment.marker span; the other two are runtime/infra faults with no marker.\nDEPLOYMENT_CLASS_FLAGS: set[str] = {\"prompt_regression\", \"retry_storm\"}\n```\n\nSigNoz has no native deployment-marker API (we chased [SigNoz/signoz#6162](https://github.com/SigNoz/signoz/issues/6162), closed unshipped), so we emit our own `deployment.marker`\n\nOTel span through the pipeline that already exists. Two scenarios emit one; two don't. **That present/absent asymmetry is the queryable signal** — the absence of a marker is positive evidence that a rollback would not help.\n\n*One POST /ask in SigNoz: the rag.retrieval, rag.prompt_construction and chat spans, with the free SQLAlchemy DB span nested under retrieval.*\n\nSix deterministic checks, all of which must pass:\n\n```\n# app/policy.py — THIS MODULE MUST NEVER CALL AN LLM.\nACTION_ALLOWLIST: tuple[str, ...] = (\"rollback\",)   # exactly one entry\nSANDBOX_SERVICES  = frozenset({\"agent-k-rag-service\"})\nBURN_RATE_THRESHOLD  = 1.0\nCONFIDENCE_THRESHOLD = 0.70\nCOOLDOWN_SECONDS     = 600.0\n```\n\n`slo_breach`\n\n, `allowlist`\n\n, `cooldown`\n\n, `confidence`\n\n, `deployment_related`\n\n, `sandbox_scope`\n\n. Every unknown fails closed — an alert we can't read a burn rate from parses to `0.0`\n\n, which fails the SLO check and denies the action.\n\nThe \"no LLM\" part isn't a comment we trust ourselves to honour. One test walks the module's import graph with `ast`\n\nand fails the build if `app.llm`\n\never appears in it; another monkeypatches `llm.generate`\n\nto raise on any call and then runs a full evaluation, which catches an indirect call a grep would miss. Both live in the 219-test suite.\n\nThis is the part worth reading twice.\n\nThe `deployment_related`\n\ncheck needs to know which scenario it's looking at, and the cheapest source was the model's claim text — our system prompt forces it to start its answer with one of the four scenario names. Fine, until the first 12-run eval printed this: two `db_pool_exhaustion`\n\nincidents narrated as `retry_storm`\n\n, each **approving a rollback** that could not possibly have fixed a database pool problem.\n\nThe gate wasn't broken. It was doing exactly what we wrote — and what we'd written was *\"the model may authorise a rollback by writing the word retry_storm in a sentence.\"* Every safety property we were claiming had a hole in it shaped like a string match.\n\nThe fix was to make the check require corroboration from telemetry, not narration:\n\n```\n# app/policy.py — check 5\nobserved = frozenset(deployment_markers or ())\nis_deployment_class = incident_type in DEPLOYMENT_CLASS_FLAGS\nmarker_seen         = incident_type in observed\ndeployment_ok       = is_deployment_class and marker_seen\n```\n\nAnd `deployment_markers`\n\nis only ever populated from a tool result, never from a claim:\n\n```\n# app/investigation.py — inside the evidence-gathering step\nif ev_type == \"deployment\":\n    inv.deployment_markers_seen.update(\n        name for name in FLAG_NAMES if f'\"{name}\"' in content_text\n    )\n```\n\nWe also made the ordering load-bearing — an investigation that stopped before the marker query could never approve anything — and asserted it at import rather than leaving it in a comment:\n\n```\nassert _DEPLOYMENT_QUERY_INDEX < MIN_EVIDENCE_ITERATIONS, (\n    \"the deployment-marker query must fall within MIN_EVIDENCE_ITERATIONS, or the \"\n    \"policy gate can never corroborate a deployment-class claim\"\n)\n```\n\nAfter that fix, both bad approvals disappeared. The agent still misdiagnoses — it called `retrieval_latency`\n\n\"retry_storm\" in all three of those runs — but a misdiagnosis now produces a *denial*, not a rollback.\n\n*The incident report's policy decision: five checks pass, deployment_related fails, and the action is denied with a next step for the human.*\n\n**1. SigNoz's OTLP receivers don't bind until you finish first-run setup.** A freshly cast stack looks healthy — every container up, `curl localhost:8080`\n\nreturns 200 — while `curl localhost:4318/v1/traces`\n\ngives *connection reset by peer*, which reads exactly like a broken exporter. The collector is OpAMP-managed and can't register an agent without an org, so the backend loops on `cannot create agent without orgId`\n\n. Check it directly:\n\n```\ncurl -s http://localhost:8080/api/v1/version   # \"setupCompleted\":false\n```\n\nRegister an org (UI or `POST /api/v1/register`\n\n) and the receivers come up. We lost real time believing `app/telemetry.py`\n\nwas wrong. It wasn't.\n\n**2. We invented MCP tool names.** We'd written `query_traces`\n\n, `query_logs`\n\n, `query_metrics`\n\nagainst a mental model of the API. The real inventory in signoz-mcp-server v0.9.0 is `signoz_search_traces`\n\n, `signoz_search_logs`\n\n, `signoz_aggregate_traces`\n\n. Every evidence query failed, stripping every claim and escalating every investigation — a total failure that looked like an agent problem. Two more traps followed: no Windows binary ships in the releases (we cross-compiled in WSL), and SigNoz v0.134 rejects JWTs sent via `SIGNOZ-API-KEY`\n\n, wanting `Authorization: Bearer`\n\n— with a token that expires every 30 minutes, which is a delightful thing to discover mid-demo.\n\n**3. The free tier is a real design constraint.** A 20-row trace search measured **8,424 tokens** and came back HTTP 413 before a hypothesis existed. SigNoz returns every possible span attribute per row and most are null for this app, so we strip nulls rather than truncate — same signal, an order of magnitude fewer tokens. Then the model began diagnosing from the *query engine's own statistics*: `\"db_pool_exhaustion is likely due to an extremely high number of rows scanned (1018) and bytes scanned (10434)\"`\n\n. Those are the stats for Agent K's own query. Hence:\n\n```\n_ENGINE_NOISE_KEYS = frozenset({\n    \"meta\", \"rowsScanned\", \"bytesScanned\", \"durationMs\", \"stepIntervals\",\n    \"nextCursor\", \"queryName\", \"columnType\", \"aggregationIndex\", \"signal\",\n    \"fieldContext\", \"fieldDataType\", \"id\", \"warnings\",\n})\n```\n\nEvidence must describe the incident, never the act of looking at it.\n\n**4. The agent investigated our test suite.** Our tests import `app.main`\n\n, which calls `setup_telemetry()`\n\n, which shipped every span pytest produced into the real SigNoz. A live run read deployment-marker counts of `retry_storm=7`\n\nvs `prompt_regression=3`\n\nwhen only `prompt_regression`\n\nhad been injected — the `retry_storm`\n\nmarkers were pytest's — and misdiagnosed on that basis. `AGENT_K_DISABLE_OTLP_EXPORT`\n\nnow suppresses export while still building all three providers.\n\n**5. A 200 from a single-page app is not a resolving link.** We built evidence links to `/deployments`\n\n, a route that does not exist in SigNoz. Our link checker passed anyway — SigNoz is an SPA and answers 200 for every path, so a status check can't tell a real route from a typo. A human clicking the link landed on nothing. Deployment markers are spans, so the honest destination was the traces explorer. (Our default base URL was wrong too: `3301`\n\n, SigNoz's older port, where Foundry serves on `8080`\n\n.)\n\nAgent K never holds the Docker socket. A separate `deployer`\n\nsidecar does, and it exposes exactly one mutating endpoint that takes **an empty body** — the caller names no image, no service, no command. A caller that cannot name an image cannot be tricked into deploying an attacker's image.\n\n```\ndeployer:\n  volumes:\n    # The privilege boundary, in one line. Nothing else in this project gets it.\n    - /var/run/docker.sock:/var/run/docker.sock\n```\n\nOne rule in there took a failed rollback to learn: the command is `docker compose up -d --force-recreate`\n\n, never `restart`\n\n. `restart`\n\nbrings the container back on its *current* image and reports success while changing nothing — the rollback appears to work and the incident continues.\n\n*The Agent K dashboard in SigNoz: policy verdicts, why actions were denied, and recovery-verified counts side by side.*\n\nThe honest summary: the model is the least trustworthy component in the system, and designing around that made everything else simpler. All of this worked for our setup — one machine, one service, four failure modes we wrote ourselves. We haven't tested any of it against an incident we didn't author.\n\n**Code:** [github.com/SujalXplores/Agent-K](https://github.com/SujalXplores/Agent-K) · Built for [Agents of SigNoz](https://www.wemakedevs.org/hackathons/signoz), Track 01. Built with AI coding assistance (Claude Code), disclosed in the repo; the shipped agent itself runs on free-tier providers and local embeddings only.", "url": "https://wpnews.pro/news/our-incident-response-agent-got-the-root-cause-wrong-7-times-out-of-12-it-still", "canonical_source": "https://dev.to/aayush-bharadva/our-incident-response-agent-got-the-root-cause-wrong-7-times-out-of-12-it-still-never-made-a-bad-42ao", "published_at": "2026-07-25 20:54:35+00:00", "updated_at": "2026-07-25 21:30:49.310623+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "developer-tools"], "entities": ["SigNoz", "WeMakeDevs", "Groq", "Cerebras", "Gemini", "OpenRouter", "OpenTelemetry", "Foundry"], "alternates": {"html": "https://wpnews.pro/news/our-incident-response-agent-got-the-root-cause-wrong-7-times-out-of-12-it-still", "markdown": "https://wpnews.pro/news/our-incident-response-agent-got-the-root-cause-wrong-7-times-out-of-12-it-still.md", "text": "https://wpnews.pro/news/our-incident-response-agent-got-the-root-cause-wrong-7-times-out-of-12-it-still.txt", "jsonld": "https://wpnews.pro/news/our-incident-response-agent-got-the-root-cause-wrong-7-times-out-of-12-it-still.jsonld"}}