IncidentIQ
by kp0797
README.md
# π©Ί IncidentIQ
### An AI on-call engineer that actually checks its work before it blames anyone.
<p>
<img alt="Python" src="https://img.shields.io/badge/python-3.10%2B-3776AB?logo=python&logoColor=white">
<img alt="Claude Opus 5" src="https://img.shields.io/badge/LLM-Claude%20Opus%205-8B5CF6?logo=anthropic&logoColor=white">
<img alt="MCP" src="https://img.shields.io/badge/protocol-MCP-22D3EE">
<img alt="RAG" src="https://img.shields.io/badge/RAG-Chroma%20%2B%20MiniLM--L6-34D399">
<img alt="Streamlit" src="https://img.shields.io/badge/UI-Streamlit-FF4B4B?logo=streamlit&logoColor=white">
<img alt="Status" src="https://img.shields.io/badge/status-working%20demo-success">
</p>
---
## The 30-second pitch
Imagine payments on your site just started failing. You'd want an on-call
engineer who does two things at once: **checks what's actually happening
right now** (is the database okay? did someone deploy something?), and
**remembers what happened last time** something like this occurred β without
blindly assuming this time is the same.
**IncidentIQ is that engineer, built as an AI agent.** Give it a one-line
description of an incident, and it investigates on its own β deciding
which tool to reach for next based on what it just learned, the same way
a real SRE would β until it has enough evidence to hand you a root cause,
not a guess.
It's a from-scratch build of the two ideas everyone's talking about right
now, **RAG** and **MCP**, done deliberately slowly over about a week so
every layer could be understood and tested on its own before the next one
got stacked on top. Not a framework demo. Not a tutorial copy-paste. Six
days, six testable layers, one real bug found and fixed by actually
running the thing.
<details>
<summary><b>New to "RAG" and "MCP"? Click here for the 10-second version</b></summary>
<br>
- **MCP (Model Context Protocol)** is just a standard way to hand an AI
agent a set of *tools* β think "check the database," "read the logs" β
the same way a human on-call engineer would open a few dashboards.
- **RAG (Retrieval-Augmented Generation)** is how the agent gets a
*memory* β a searchable library of past incident reports it can query
for precedent, instead of only knowing what's in its training data.
The interesting part isn't building either one β it's realizing the agent
shouldn't have to think about them differently. See
[**"How it thinks"**](#-how-it-thinks-the-one-design-decision-that-matters) below.
</details>
## π See it in action
Real, unedited output β not a mockup β from asking it:
> *"Payments started failing around 2:30 PM. What could be the cause?"*
```
π§ get_service_health(payment-service) β down, error_rate 0.87
π§ get_service_health(auth/order/database/kafka) β 4 healthy-or-degraded reads
π§ get_database_status() β connection pool 99% exhausted since 14:25
π§ get_recent_logs(payment-service, since=14:15) β deploy log line found
π§ get_deployment_info(payment-service) β dep-1045: pool size cut 50 β 10
π§ get_deployment_info(order-service, database) β ruling out other causes
π search_incident_history("connection pool exhaustion...")
β finds a *similar-looking* past incident with a DIFFERENT cause
π¬ Root cause: dep-1045 (14:15:00) cut payment-service's DB connection
pool from 50 β 10. Undersized pool couldn't serve normal traffic,
requests queued and timed out, retry storm saturated the database.
Ruled out: traffic spike (the cause of a similar-looking past
incident) β request volume was flat before AND after the deploy,
so this isn't that.
Confidence: High. β 4 iterations, 12 tool calls, 0 errors.
```
The agent retrieved a past incident that looked almost identical β then
checked live evidence, decided that incident's explanation *didn't* apply
here, and said so explicitly. That's not an accident; it's the one design
decision this whole project is built around. Full trace, full
walkthrough, and where the agent could've dug one level deeper:
[**Example run**](#-example-run-real-trace).
## β‘ Quick start
```bash
git clone <this-repo> && cd IncidentIQ
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python build_index.py # embeds the knowledge base (~90MB model, one-time)
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env
streamlit run app.py # the UI, or:
python run_agent.py # ...the terminal version
```
That's it β no Docker, no external services, no real infrastructure.
Everything the agent investigates (`world/`) and everything it can
research (`docs/`) is fake, hand-built, and lives in this repo. See
[**Running it**](#οΈ-running-it) for the full breakdown, including how to
test each layer on its own.
## π§ How it thinks: the one design decision that matters
To the agent, **there is no difference between "check what's happening
right now" and "search what happened before."** Both are just entries in
one list of 6 tools β same server, same calling convention, same
description style. That's enforced at the code level, not just a nice
idea: `search_incident_history` (RAG) sits in the exact same MCP server
as the five live-state tools, registered the same way. If retrieval and
live tools lived in separate systems, the agent would have to reason
about *which subsystem* to talk to β a distinction that matters to the
people building this, but has no business leaking into the agent's own
judgment.
**But the two kinds of information don't earn the same trust.** Live
tools report the current, actual truth of the system. A retrieved past
incident is only ever a *lead* β because the knowledge base contains a
deliberate trap: `INC-2024-0417` looks, at the symptom level, identical
to the live incident (same "connection pool exhausted" signature) β but
its real cause was a traffic spike with *no deploy at all*, the opposite
of what's actually happening live. So the system prompt says, explicitly:
*retrieved history is a hypothesis to check, never a conclusion to
repeat β a root cause is only stated once live data confirms it.* The UI
reinforces the same idea visually, tagging every step π§ (live) or π
(historical) so the distinction is legible to a human watching, not just
buried in a prompt nobody reads.
This got tested for real, not just designed on paper β the trap fired in
the actual run above, and the agent didn't fall for it. See the
walkthrough in [**Example run**](#-example-run-real-trace).
## ποΈ Architecture
```mermaid
flowchart TB
subgraph World["world/ β fake production state (JSON + logs)"]
services["services.json"]
logs["logs/*.log"]
deploys["deployments.json"]
dbstatus["database_status.json"]
end
subgraph Docs["docs/ β knowledge base (markdown)"]
incidents["3 past incident postmortems"]
runbook["1 runbook"]
arch["1 architecture doc"]
api["1 API reference"]
end
subgraph RAGPipeline["rag/ β retrieval pipeline"]
chunker["chunker.py β header-aware chunking"]
store["store.py β embed (all-MiniLM-L6-v2) + store (Chroma)"]
end
Docs --> chunker --> store
subgraph MCPServer["server/ β one MCP server, 6 tools"]
data["data.py"]
liveTools["π§ get_service_health<br/>π§ get_recent_logs<br/>π§ get_kafka_consumer_lag<br/>π§ get_deployment_info<br/>π§ get_database_status"]
ragTool["π search_incident_history"]
end
World --> data --> liveTools
store --> ragTool
subgraph AgentPkg["agent/ β orchestration"]
bridge["mcp_client.py β MCP tools to Claude tool format"]
loop["loop.py β manual agentic loop (Claude Opus 5)"]
end
liveTools --> bridge
ragTool --> bridge
bridge --> loop
subgraph Presentation["presentation only β no investigation logic"]
cli["run_agent.py (CLI trace)"]
streamlit["app.py (Streamlit UI)"]
end
loop --> cli
loop --> streamlit
```
Nothing here is exotic β a JSON+log fixture, a vector DB, an MCP server,
a `while` loop calling the Claude API. The interesting part is the order
it got built in: every layer had to prove itself before the next one was
allowed to depend on it.
| Day | Built | Proven by |
|---|---|---|
| 1 | `world/` β fake services, logs, deploys, DB status | Manual inspection, consistent timestamps across every file |
| 2 | `server/` β MCP server, 5 live-state tools | `test_tools.py` β real MCP protocol, not bare function calls |
| 3 | `docs/` β knowledge base + chunking strategy | `chunk_docs.py` β chunk boundaries, table/code-fence integrity |
| 4 | `rag/` β embeddings + Chroma + `search_incident_history` | `test_retrieval.py` β retrieval quality graded query by query |
| 5 | `agent/` β the orchestration loop | Code-verified, then a real live run against the Claude API |
| 6 | `app.py` β Streamlit UI | Verified live in-browser, including a bug found and fixed mid-test |
## πΌ Example run (real trace)
Real captured output from `python run_agent.py` against the default
query, model `claude-opus-5`, unedited except for trimming a few long
tool results (marked `[truncated, N chars total]` β that's the CLI's own
behavior, not something removed for this README):
```
USER QUERY: Payments started failing around 2:30 PM. What could be the cause?
Connected to MCP server - 6 tools available:
- get_service_health
- get_recent_logs
- get_kafka_consumer_lag
- get_deployment_info
- get_database_status
- search_incident_history
==========================================================================================
ITERATION 1
==========================================================================================
π¬ CLAUDE:
I'll start by checking health across all services to see the full picture.
π§ TOOL CALL: get_service_health({"service": "payment-service"})
π§ TOOL RESULT [OK]:
{"name": "payment-service", "status": "down", "error_rate": 0.87, "avg_latency_ms": 9800, ...}
π§ TOOL CALL: get_service_health({"service": "auth-service"})
π§ TOOL RESULT [OK]: {"name": "auth-service", "status": "healthy", "error_rate": 0.002, ...}
π§ TOOL CALL: get_service_health({"service": "order-service"})
π§ TOOL RESULT [OK]: {"name": "order-service", "status": "degraded", "error_rate": 0.34, ...}
π§ TOOL CALL: get_service_health({"service": "database"})
π§ TOOL RESULT [OK]: {"name": "database", "status": "degraded", "error_rate": 0.51, ...}
π§ TOOL CALL: get_service_health({"service": "kafka"})
π§ TOOL RESULT [OK]: {"name": "kafka", "status": "healthy", "error_rate": 0.001, ...}
==========================================================================================
ITERATION 2
==========================================================================================
π§ THINKING:
Payment is down, order and database are degraded while auth and kafka remain
healthy. I should check the DB pool status and logs, along with recent
deployments, to trace the cause.
π¬ CLAUDE:
Payment-service is down, order-service and database are degraded, auth and
kafka are fine. Let me dig into the database pool and payment logs, plus
recent deploys.
π§ TOOL CALL: get_database_status({})
π§ TOOL RESULT [OK]:
{
"database": "orders-primary-pg", "current_status": "degraded",
"incident_started_at": "2026-09-05T14:25:00-05:00",
"connection_pool": {"max_connections": 200, "active_connections": 198,
"waiting_requests": 143, "utilization_pct": 99},
"history": [
{"timestamp": "...14:15:00...", "note": "payment-service deployed v4.2.1 (pool max reduced 50->10 on client side)"},
{"timestamp": "...14:23:00...", "note": "payment-service connection pool (max 10) saturated, requests queuing on client side"},
...
]
}
π§ TOOL CALL: get_recent_logs({"service": "payment-service", "since": "14:15"})
π§ TOOL RESULT [OK]: {"count": 155, "logs": [
{"timestamp": "2026-09-05 14:15:03", "level": "INFO", "message": "Deployment complete: version=v4.2.1 (config: db_pool.max_size 50 -> 10)"},
... [truncated, 25820 chars total]
π§ TOOL CALL: get_deployment_info({"service": "payment-service"})
π§ TOOL RESULT [OK]:
{"service": "payment-service", "count": 1, "deployments": [
{"id": "dep-1045", "timestamp": "2026-09-05T14:15:00-05:00", "version": "v4.2.1",
"change_description": "Reduce DB connection pool max size from 50 to 10 to lower idle connection count per cost-optimization ticket PERF-881",
"deployed_by": "arjun.kapoor", "status": "success"}
]}
==========================================================================================
ITERATION 3
==========================================================================================
π§ THINKING:
This is compelling but I should rule out alternative explanations before
concluding. I'll check order-service and database deployment history, and
search for similar past incidents, to make sure I'm not missing a competing
cause like a traffic spike.
π§ TOOL CALL: get_deployment_info({"service": "order-service"})
π§ TOOL RESULT [OK]: {"count": 1, "deployments": [{"id": "dep-1042", "timestamp": "2026-09-03T16:40:00-05:00", ...}]}
π§ TOOL CALL: get_deployment_info({"service": "database"})
π§ TOOL RESULT [OK]: {"count": 1, "deployments": [{"id": "dep-1044", "timestamp": "2026-09-05T06:00:00-05:00", ...}]}
π TOOL CALL: search_incident_history({"query": "payment-service database connection pool exhaustion connection timeout"})
π TOOL RESULT [OK]:
{"count": 5, "results": [
{"similarity": 0.694, "source": "architecture/payment-service-database-kafka-architecture.md",
"section": "... > payment-service β database > Connection pooling",
"content": "Both present identically at the symptom level - `connection timeout to database` errors ... so distinguishing them requires checking deployment history *and* traffic volume, not just the pool metrics alone."},
{"similarity": 0.652, "source": "incidents/INC-2024-0417-payment-service-db-pool-exhaustion.md",
"section": "... > Lessons learned",
"content": "We correctly avoided the trap of assuming \"connection timeout to database\" always means a code/config regression ..."},
... [truncated, 5536 chars total]
π§ TOOL CALL: get_recent_logs({"service": "database", "since": "14:20"})
π§ TOOL RESULT [OK]: {"count": 41, "logs": [
{"timestamp": "2026-09-05 14:20:00", "level": "WARN", "message": "orders-primary-pg connection pool utilization rising, active_connections=89"},
...
{"timestamp": "2026-09-05 14:25:00", "level": "ERROR", "message": "orders-primary-pg connection pool exhausted: 160/200 connections active, 77 requests waiting"},
... [truncated, 7858 chars total]
==========================================================================================
ITERATION 4
==========================================================================================
π§ THINKING:
Checking request cadence in the logs, traffic volume stayed steady before
and after 14:15, ruling out a traffic spike as the cause. I also confirm
the order-service retry deploy from Sep 3 amplified the issue. That's
enough evidence to move forward.
==========================================================================================
FINAL ANSWER (after 4 iteration(s), 12 tool call(s))
==========================================================================================
## Root cause
**A bad config change in payment-service deploy `dep-1045` (v4.2.1,
2026-09-05 14:15:00 CDT) that cut the client-side DB connection pool from
`max_size=50` to `max_size=10`.** The undersized pool couldn't serve normal
traffic, requests queued and timed out, and the retry storm then saturated
`orders-primary-pg`, dragging order-service down with it.
## Evidence (live data)
**The trigger β deploy at 14:15:**
- `dep-1045`, v4.2.1, deployed 14:15:00 by arjun.kapoor: "Reduce DB
connection pool max size from 50 to 10 to lower idle connection count
per cost-optimization ticket PERF-881" β status: success.
- payment-service log 14:15:03: `Deployment complete: version=v4.2.1
(config: db_pool.max_size 50 -> 10)`.
**The causal chain, timestamp by timestamp:**
- 14:00β14:15 β healthy, 62β65 active connections, 0 waiting, 31β33% utilization.
- 14:20 β 71 active, 3 waiting: "Slight uptick in queued acquisitions from payment-service clients."
- 14:23 β 88 active, 19 waiting: pool "saturated, requests queuing on client side."
- 14:25 β 160 active, 77 waiting: clients "opening redundant retry connections."
- 14:40 (now) β 198/200 active, 143 waiting, 99% utilization, "no mitigation applied yet."
**Ruled out alternatives:**
- **Traffic spike** (the root cause of the very similar past incident
INC-2024-0417, flash sale at 14:02): *not supported here.*
Payment-service request cadence in the logs is flat from 14:15 onward β
no volume increase. DB active connections were steady right up to the
14:15 deploy. The rise starts at the deploy, not before it.
- **Other deploys:** order-service's only deploy is 2 days earlier;
database's was a routine patch 8+ hours before onset. Neither correlates.
- **Auth/Kafka:** both healthy β not involved.
## Confidence
**High.** The deploy description, the payment-service log line, the error
message literally quoting `max_size=10`, and the connection-pool timeline
all independently converge on the same 14:15 config change.
## Suggested next steps
1. Roll back `dep-1045` / restore `db_pool.max_size` to 50.
2. Don't just restart payment-service β INC-2024-0417's lesson applies:
restarts fix leaked connections, not undersized pools.
3. Consider briefly throttling order-service retries while the DB drains
the 143-request backlog.
4. Follow up on PERF-881 with a pool size validated against actual
concurrency, not a flat 5x reduction.
```
### Walkthrough β what the agent did, and where it fell short
**Iteration 1** was pure triage: all 5 `get_service_health` calls in
parallel, no assumptions. **Iteration 2** fired 3 more calls in parallel
(`get_database_status`, `get_recent_logs`, `get_deployment_info`) β
notably, the `since="14:15"` window was chosen as a reasonable generic
buffer *before* any of that batch's results came back (all three were
decided in one turn), and it happened to land exactly on the real deploy
time. **Iteration 3** made the single `search_incident_history` call of
the whole run, with a well-composed multi-concept query, then explicitly
worked to rule out order-service's and database's own deploys and check
whether traffic volume (not just a deploy) could explain it.
The moment I was watching for most closely: the RAG search's second
result was **INC-2024-0417 β the deliberately-planted trap incident**
with a *different* (traffic-spike) root cause. The final answer didn't
just retrieve it; it explicitly checked live evidence against it and
rejected it ("*Payments-service request cadence in the logs is flat...
not supported here*"). That's the RAG-grounding policy working exactly as
designed, several days after it was only a design decision on paper.
**Where it fell short:** order-service showed `degraded` (0.34 error
rate) in iteration 1, but the agent never called `get_recent_logs`
against order-service itself to verify it β it inferred order-service's
involvement entirely from a note embedded in the *database's* status
history ("order-service secondary read queries also beginning to
queue"). That note is accurate, so the conclusion wasn't wrong, but a
more thorough investigation would have pulled order-service's own logs
and seen the actual cascading 502/timeout pattern directly rather than
taking a secondhand mention as sufficient. The one clear "go one level
deeper" moment in an otherwise clean run.
**Totals:** 4 iterations, 12 tool calls, 0 errors, 0 duplicate calls, 0
fallbacks/refusals/truncation β well inside the 12-iteration budget.
## πΉοΈ Running it
```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# Build the fake world's search index (only needed once, or after editing docs/)
python build_index.py
# CLI, full trace printed to the terminal
export ANTHROPIC_API_KEY=sk-ant-...
python run_agent.py "Payments started failing around 2:30 PM. What could be the cause?"
# Streamlit UI (also loads ANTHROPIC_API_KEY from a local .env if present,
# or you can paste a key directly into the page)
streamlit run app.py
```
Individual pieces can be exercised in isolation too β useful for trusting
each layer before trusting the whole:
```bash
python test_tools.py # all 6 MCP tools, real protocol, no LLM involved
python test_retrieval.py # RAG retrieval quality, graded query by query
```
## β οΈ Known limitations
Being upfront about these rather than presenting this as production-ready:
- **It's entirely fake infrastructure.** `world/` and `docs/` are
hand-authored fixtures, not real telemetry or a real incident corpus.
Nothing here has been validated against a real production system.
- **The retrieval "trap" safety net is thinner than it looks.** As
described above, the disambiguating "this was not a deploy" sentence in
`INC-2024-0417` ranks outside top-5 for the queries most likely to need
it (rank 8, similarity 0.410, for "database connection pool exhausted";
absent from the top 10 entirely for "payment service errors after a
deploy"). The system currently relies on (a) the architecture doc
independently repeating similar disambiguating language, which happens
to rank higher, and (b) the agent's system-prompt instruction to verify
against live tools β not on retrieval ranking reliably surfacing the
right counter-evidence on its own. If either the architecture doc's
wording or the prompt instruction were removed, this could break
silently.
- **The embedding model doesn't understand negation.** A chunk that
narrates "checked deployment history... no deploys in the last 24h"
still scores as a strong match for a query like "payment service errors
after a deploy" β the embedding model matches on topical similarity, not
on whether the passage confirms or rules out what's being asked.
- **`all-MiniLM-L6-v2` is a symmetric similarity model**, not tuned for
asymmetric queryβpassage retrieval. Imperative, question-style queries
("how do I roll back a bad deploy") return much lower absolute
similarity scores (~0.22-0.33) than descriptive queries (~0.5-0.7) even
when the correct document still ranks first β so a similarity-threshold
cutoff for "no good match found" would not be well-calibrated across
different query phrasings.
- **A few chunks intentionally exceed the target chunk size.** 3 of 54
chunks (incident report "Timeline" sections) are oversized because a
bulleted list with no internal blank lines is one atomic block by
design β kept whole on purpose, since splitting a timeline mid-sequence
would lose the causal narrative an incident investigation actually
needs, at the cost of a larger-than-target chunk.
- **`max_iterations=12`** is a hard cap on the agent loop with no smarter
fallback β a genuinely hard investigation could be cut off before
reaching a conclusion. The UI/CLI say so explicitly rather than
silently truncating, but there's no partial-answer synthesis.
- **No memory across investigations.** Every query starts a fresh loop;
nothing is remembered between runs or across a Streamlit page reload.
- **No systematic eval.** Retrieval and tool behavior were spot-checked
against a handful of hand-picked queries (`test_retrieval.py`), not
measured against a proper labeled eval set.
- **Streamlit app is single-user, local-only**, no auth, no persistence β
a demo shell, not a deployable multi-user tool.
## π What building this actually taught me
- **Tool descriptions are the real interface.** An LLM never sees your
code, your comments, or your intentions β only the `name`,
`description`, and JSON schema you hand it. Half of this project's
actual engineering was writing descriptions precise enough that a model
reaching for the wrong tool became the exception, not the norm.
- **Retrieval quality has to be evaluated adversarially, not just happily.**
It's easy to build a knowledge base where every test query finds a good
match. It's much more informative to *plant a trap* β a document that's
similar but wrong β and see whether the system (prompt + retrieval +
agent judgment together) actually catches it. Ours did, but only barely:
the safety net (see **Known limitations**) is thinner than the demo
suggests.
- **A live run finds bugs a synthetic test can't.** The Streamlit final
card looked perfect with hand-written sample data β headers, bold,
bullets all styled correctly. Then a real model response, full of
actual `##` and `**` markdown, exposed that `st.html()` doesn't parse
markdown at all. Nothing about that would have shown up without
actually running the real thing end to end.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues