Spaces:
Sleeping
Sleeping
Commit ·
33dd3ee
0
Parent(s):
Initial submission: OpenEnv-Sentinel SRE triage environment
Browse files- .dockerignore +13 -0
- .gitignore +17 -0
- Dockerfile +27 -0
- Pipfile +16 -0
- README.md +141 -0
- USER_GUIDE.md +563 -0
- __init__.py +17 -0
- client.py +25 -0
- conftest.py +1 -0
- grading/__init__.py +1 -0
- grading/grader.py +15 -0
- grading/rewards.py +61 -0
- inference.py +359 -0
- models.py +160 -0
- openenv.yaml +6 -0
- pyproject.toml +35 -0
- scenarios/__init__.py +6 -0
- scenarios/base.py +96 -0
- scenarios/task1_smoking_gun.py +405 -0
- scenarios/task2_upstream_culprit.py +621 -0
- scenarios/task3_cascading_failure.py +560 -0
- server/__init__.py +1 -0
- server/app.py +24 -0
- server/requirements.txt +4 -0
- server/sentinel_environment.py +198 -0
- tests/__init__.py +0 -0
- tests/conftest.py +28 -0
- tests/test_environment.py +102 -0
- tests/test_grading.py +96 -0
- tests/test_models.py +60 -0
- tests/test_tools.py +41 -0
- tools/__init__.py +1 -0
- tools/registry.py +49 -0
.dockerignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
*.egg-info/
|
| 4 |
+
dist/
|
| 5 |
+
build/
|
| 6 |
+
.eggs/
|
| 7 |
+
*.egg
|
| 8 |
+
outputs/
|
| 9 |
+
.env
|
| 10 |
+
.venv/
|
| 11 |
+
venv/
|
| 12 |
+
*.log
|
| 13 |
+
.DS_Store
|
.gitignore
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
*.egg-info/
|
| 4 |
+
dist/
|
| 5 |
+
build/
|
| 6 |
+
.eggs/
|
| 7 |
+
*.egg
|
| 8 |
+
outputs/
|
| 9 |
+
.env
|
| 10 |
+
.venv/
|
| 11 |
+
venv/
|
| 12 |
+
*.log
|
| 13 |
+
.DS_Store
|
| 14 |
+
.inference_run_count
|
| 15 |
+
Pipfile.lock
|
| 16 |
+
inference_azure_local.py
|
| 17 |
+
pat_token
|
Dockerfile
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install curl for healthcheck
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
| 7 |
+
|
| 8 |
+
# Install Python dependencies
|
| 9 |
+
COPY server/requirements.txt /tmp/requirements.txt
|
| 10 |
+
RUN pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt
|
| 11 |
+
|
| 12 |
+
# Copy environment code
|
| 13 |
+
COPY models.py /app/models.py
|
| 14 |
+
COPY __init__.py /app/__init__.py
|
| 15 |
+
COPY client.py /app/client.py
|
| 16 |
+
COPY server/ /app/server/
|
| 17 |
+
COPY scenarios/ /app/scenarios/
|
| 18 |
+
COPY tools/ /app/tools/
|
| 19 |
+
COPY grading/ /app/grading/
|
| 20 |
+
COPY openenv.yaml /app/openenv.yaml
|
| 21 |
+
|
| 22 |
+
# Health check
|
| 23 |
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
| 24 |
+
CMD curl -f http://localhost:8000/health || exit 1
|
| 25 |
+
|
| 26 |
+
EXPOSE 8000
|
| 27 |
+
CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
Pipfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[[source]]
|
| 2 |
+
url = "https://pypi.org/simple"
|
| 3 |
+
verify_ssl = true
|
| 4 |
+
name = "pypi"
|
| 5 |
+
|
| 6 |
+
[packages]
|
| 7 |
+
openenv-sentinel = {extras = ["dev"], file = ".", editable = true}
|
| 8 |
+
openai = "*"
|
| 9 |
+
httpx = "*"
|
| 10 |
+
websockets = "*"
|
| 11 |
+
|
| 12 |
+
[dev-packages]
|
| 13 |
+
|
| 14 |
+
[requires]
|
| 15 |
+
python_version = "3.12"
|
| 16 |
+
python_full_version = "3.12.4"
|
README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: OpenEnv-Sentinel
|
| 3 |
+
emoji: 🚨
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
app_port: 8000
|
| 9 |
+
tags:
|
| 10 |
+
- openenv
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# OpenEnv-Sentinel: SRE Incident Triage Environment
|
| 14 |
+
|
| 15 |
+
An OpenEnv environment that simulates SRE incident triage. An AI agent receives a degraded system state and must use diagnostic tools to identify the root cause and recommend a fix.
|
| 16 |
+
|
| 17 |
+
## Quick Start
|
| 18 |
+
|
| 19 |
+
```bash
|
| 20 |
+
pip install -e .
|
| 21 |
+
uvicorn server.app:app --host 0.0.0.0 --port 8000
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
Or with Docker:
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
docker build -t sentinel-env -f server/Dockerfile .
|
| 28 |
+
docker run -p 8000:8000 sentinel-env
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
## Action Space
|
| 32 |
+
|
| 33 |
+
```python
|
| 34 |
+
class SentinelAction(Action):
|
| 35 |
+
tool_name: str # Tool to invoke
|
| 36 |
+
parameters: dict # Tool-specific parameters
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
### Available Tools
|
| 40 |
+
|
| 41 |
+
| Tool | Parameters | Description |
|
| 42 |
+
|---|---|---|
|
| 43 |
+
| `query_logs` | `service`, `query`, `severity` | Search service logs |
|
| 44 |
+
| `query_metrics` | `service`, `metric` | Get time-series metrics (cpu/memory/error_rate/latency/connections) |
|
| 45 |
+
| `get_service_status` | `service` | Service health, uptime, errors |
|
| 46 |
+
| `get_dependency_map` | `service` (optional) | Service dependency graph |
|
| 47 |
+
| `consult_runbook` | `topic` | SOP/runbook lookup |
|
| 48 |
+
| `check_recent_changes` | `service` (optional) | Recent deployments/config changes |
|
| 49 |
+
| `submit_resolution` | `root_cause`, `affected_service`, `recommendation` | Submit final answer (ends episode) |
|
| 50 |
+
|
| 51 |
+
## Observation Space
|
| 52 |
+
|
| 53 |
+
```python
|
| 54 |
+
class SentinelObservation(Observation):
|
| 55 |
+
incident_summary: str # Alert description
|
| 56 |
+
tool_output: str # Result from last tool call
|
| 57 |
+
available_tools: list[str] # Available tool names
|
| 58 |
+
step_number: int # Current step (0-indexed)
|
| 59 |
+
max_steps: int # Episode limit (20)
|
| 60 |
+
cumulative_reward: float # Running reward total
|
| 61 |
+
last_action_error: str # Error message if action was invalid
|
| 62 |
+
done: bool # Episode finished?
|
| 63 |
+
reward: float | None # Per-step reward
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
## Tasks
|
| 67 |
+
|
| 68 |
+
### Task 1 — The Smoking Gun (Easy)
|
| 69 |
+
**Alert:** payment-api returning HTTP 500 errors. Straightforward single-service crash with a clear root cause in logs and deploy history. Optimal: 2–3 tool calls.
|
| 70 |
+
|
| 71 |
+
### Task 2 — The Upstream Culprit (Medium)
|
| 72 |
+
**Alert:** checkout-service p99 latency > 5 seconds. Requires tracing a dependency chain to find the real culprit (inventory-service OOM). Optimal: 4–6 tool calls.
|
| 73 |
+
|
| 74 |
+
### Task 3 — The Cascading Failure (Hard)
|
| 75 |
+
**Alert:** Multiple services degraded simultaneously. A long-running analytics query exhausts the PostgreSQL connection pool, cascading through auth, user-profile, and notification services. Includes red herrings. Optimal: 6–10 tool calls.
|
| 76 |
+
|
| 77 |
+
## Scoring
|
| 78 |
+
|
| 79 |
+
Each task is scored 0.0–1.0 using deterministic keyword-based grading:
|
| 80 |
+
- **Root cause identification** (weighted by task)
|
| 81 |
+
- **Correct affected service** identification
|
| 82 |
+
- **Actionable recommendation**
|
| 83 |
+
- **Efficiency bonus** (fewer steps = higher score)
|
| 84 |
+
- **Destructive penalty** (recommending harmful actions = score deduction)
|
| 85 |
+
|
| 86 |
+
Per-step rewards provide partial credit signal:
|
| 87 |
+
- Relevant tool call: +0.12
|
| 88 |
+
- Irrelevant tool call: −0.02
|
| 89 |
+
- Repeated call: −0.05
|
| 90 |
+
- Invalid action: −0.03
|
| 91 |
+
- Step cost: −0.01
|
| 92 |
+
|
| 93 |
+
## Running Inference
|
| 94 |
+
|
| 95 |
+
Uses `OpenAI(base_url=...)` — compatible with HF Inference, OpenAI, and any
|
| 96 |
+
OpenAI-compatible API. Azure OpenAI is supported via `AzureOpenAI` client.
|
| 97 |
+
|
| 98 |
+
```bash
|
| 99 |
+
# Environment server URL
|
| 100 |
+
export ENV_URL=http://localhost:8000
|
| 101 |
+
|
| 102 |
+
# LLM config (defaults to HF router)
|
| 103 |
+
export API_BASE_URL=https://router.huggingface.co/v1 # default, can omit
|
| 104 |
+
export MODEL_NAME=openai/gpt-oss-120b:novita # default, can omit
|
| 105 |
+
export API_KEY=your-key # or HF_TOKEN or OPENAI_API_KEY
|
| 106 |
+
|
| 107 |
+
# Azure OpenAI (optional, set AZURE_OPENAI_ENDPOINT to enable)
|
| 108 |
+
# export AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com
|
| 109 |
+
# export AZURE_OPENAI_API_KEY=your-azure-key
|
| 110 |
+
# export MODEL_NAME=your-deployment-name
|
| 111 |
+
|
| 112 |
+
pip install openai websockets
|
| 113 |
+
python inference.py
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
Output:
|
| 117 |
+
```
|
| 118 |
+
Task 1: 0.85
|
| 119 |
+
Task 2: 0.65
|
| 120 |
+
Task 3: 0.40
|
| 121 |
+
Average: 0.63
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
## Baseline Scores
|
| 125 |
+
|
| 126 |
+
| Task | GPT-4o (expected) | Open LLM (expected) |
|
| 127 |
+
|---|---|---|
|
| 128 |
+
| Task 1 (Easy) | 0.80–0.95 | 0.60–0.80 |
|
| 129 |
+
| Task 2 (Medium) | 0.60–0.80 | 0.40–0.60 |
|
| 130 |
+
| Task 3 (Hard) | 0.30–0.60 | 0.15–0.35 |
|
| 131 |
+
|
| 132 |
+
## API Endpoints
|
| 133 |
+
|
| 134 |
+
| Endpoint | Method | Description |
|
| 135 |
+
|---|---|---|
|
| 136 |
+
| `/health` | GET | Health check |
|
| 137 |
+
| `/reset` | POST | Reset environment (`{"task_id": 1\|2\|3}`) |
|
| 138 |
+
| `/step` | POST | Execute action (`{"action": {...}}`) |
|
| 139 |
+
| `/state` | GET | Get current state |
|
| 140 |
+
| `/schema` | GET | JSON schemas for action/observation/state |
|
| 141 |
+
| `/ws` | WebSocket | Persistent session |
|
USER_GUIDE.md
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# OpenEnv-Sentinel — User Guide
|
| 2 |
+
|
| 3 |
+
Step-by-step guide for running, validating, and deploying the SRE Incident Triage environment.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## Table of Contents
|
| 8 |
+
|
| 9 |
+
1. [Prerequisites](#1-prerequisites)
|
| 10 |
+
2. [Local Setup](#2-local-setup)
|
| 11 |
+
3. [Running the Server Locally](#3-running-the-server-locally)
|
| 12 |
+
4. [Manual Validation — Local Server](#4-manual-validation--local-server)
|
| 13 |
+
5. [Docker Build & Validation](#5-docker-build--validation)
|
| 14 |
+
6. [Running Inference (LLM Agent)](#6-running-inference-llm-agent)
|
| 15 |
+
7. [OpenEnv Validate](#7-openenv-validate)
|
| 16 |
+
8. [Deploy to Hugging Face Spaces](#8-deploy-to-hugging-face-spaces)
|
| 17 |
+
9. [Troubleshooting](#9-troubleshooting)
|
| 18 |
+
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
## 1. Prerequisites
|
| 22 |
+
|
| 23 |
+
| Tool | Version | Purpose |
|
| 24 |
+
|---|---|---|
|
| 25 |
+
| Python | ≥ 3.10 | Runtime |
|
| 26 |
+
| pip / pipenv | Latest | Dependency management |
|
| 27 |
+
| Docker | Latest | Container build & test |
|
| 28 |
+
| Git | Latest | Version control, HF push |
|
| 29 |
+
| huggingface-cli | Latest | HF Spaces deployment |
|
| 30 |
+
| openenv-core CLI | ≥ 0.2.3 | `openenv validate` / `openenv push` |
|
| 31 |
+
|
| 32 |
+
Install the OpenEnv CLI and Hugging Face CLI:
|
| 33 |
+
|
| 34 |
+
```bash
|
| 35 |
+
pip install openenv-core huggingface-hub[cli]
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
---
|
| 39 |
+
|
| 40 |
+
## 2. Local Setup
|
| 41 |
+
|
| 42 |
+
### Option A — pip (quick)
|
| 43 |
+
|
| 44 |
+
```bash
|
| 45 |
+
cd openenv-sentinel
|
| 46 |
+
pip install -e ".[dev,inference]"
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
### Option B — pipenv (isolated)
|
| 50 |
+
|
| 51 |
+
```bash
|
| 52 |
+
cd openenv-sentinel
|
| 53 |
+
pipenv install --python 3.12
|
| 54 |
+
pipenv install -e ".[dev]"
|
| 55 |
+
pipenv install openai httpx websockets
|
| 56 |
+
pipenv shell
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
All subsequent commands assume you are inside the virtual environment.
|
| 60 |
+
|
| 61 |
+
---
|
| 62 |
+
|
| 63 |
+
## 3. Running the Server Locally
|
| 64 |
+
|
| 65 |
+
Start the FastAPI server on port 8000:
|
| 66 |
+
|
| 67 |
+
```bash
|
| 68 |
+
uvicorn server.app:app --host 0.0.0.0 --port 8000
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
You should see:
|
| 72 |
+
|
| 73 |
+
```
|
| 74 |
+
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
Verify with:
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
curl http://localhost:8000/health
|
| 81 |
+
# → {"status":"ok"}
|
| 82 |
+
|
| 83 |
+
curl http://localhost:8000/schema
|
| 84 |
+
# → JSON with action, observation, state schemas
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
---
|
| 88 |
+
|
| 89 |
+
## 4. Manual Validation — Local Server
|
| 90 |
+
|
| 91 |
+
With the server running (from step 3), validate the environment in a **second terminal**.
|
| 92 |
+
|
| 93 |
+
### 4.1 Automated test script
|
| 94 |
+
|
| 95 |
+
The quickest way to validate all 3 tasks end-to-end:
|
| 96 |
+
|
| 97 |
+
```bash
|
| 98 |
+
pip install websockets httpx # if not already installed
|
| 99 |
+
python test_local.py
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
Expected output:
|
| 103 |
+
|
| 104 |
+
```
|
| 105 |
+
Health: {'status': 'ok'}
|
| 106 |
+
Schema: action fields=['tool_name', 'parameters']
|
| 107 |
+
|
| 108 |
+
==================================================
|
| 109 |
+
TASK 1
|
| 110 |
+
==================================================
|
| 111 |
+
Reset OK: CRITICAL: payment-api returning HTTP 500 errors...
|
| 112 |
+
Step 1 (status payment-api): reward=0.11
|
| 113 |
+
Step 2 (logs payment-api): reward=0.11
|
| 114 |
+
Resolution: score=0.75, done=True
|
| 115 |
+
State: final_score=1.0, root_cause_correct=True, recommendation_correct=True
|
| 116 |
+
|
| 117 |
+
... (Tasks 2 & 3 similar) ...
|
| 118 |
+
|
| 119 |
+
✅ ALL TESTS PASSED
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
### 4.2 Manual cURL validation (HTTP endpoints)
|
| 123 |
+
|
| 124 |
+
> **Note:** HTTP endpoints are stateless — each request creates a fresh environment
|
| 125 |
+
> instance. Use these for single-shot checks only. For multi-step episodes, use
|
| 126 |
+
> the WebSocket endpoint (section 4.3).
|
| 127 |
+
|
| 128 |
+
**Health check:**
|
| 129 |
+
|
| 130 |
+
```bash
|
| 131 |
+
curl http://localhost:8000/health
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
**Schema check:**
|
| 135 |
+
|
| 136 |
+
```bash
|
| 137 |
+
curl http://localhost:8000/schema | python -m json.tool
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
**Reset (single-shot):**
|
| 141 |
+
|
| 142 |
+
```bash
|
| 143 |
+
curl -X POST http://localhost:8000/reset \
|
| 144 |
+
-H "Content-Type: application/json" \
|
| 145 |
+
-d '{"task_id": 1}'
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
### 4.3 Manual WebSocket validation (stateful sessions)
|
| 149 |
+
|
| 150 |
+
Multi-step episodes require WebSocket because the server maintains session state
|
| 151 |
+
across messages. Install `websocat` or use Python:
|
| 152 |
+
|
| 153 |
+
**Using Python interactively:**
|
| 154 |
+
|
| 155 |
+
```python
|
| 156 |
+
import asyncio, json, websockets
|
| 157 |
+
|
| 158 |
+
async def manual_test():
|
| 159 |
+
async with websockets.connect("ws://localhost:8000/ws") as ws:
|
| 160 |
+
# 1. Reset to Task 1
|
| 161 |
+
await ws.send(json.dumps({"type": "reset", "data": {"task_id": 1}}))
|
| 162 |
+
resp = json.loads(await ws.recv())
|
| 163 |
+
print("Reset:", json.dumps(resp["data"]["observation"]["incident_summary"]))
|
| 164 |
+
|
| 165 |
+
# 2. Call a diagnostic tool
|
| 166 |
+
await ws.send(json.dumps({
|
| 167 |
+
"type": "step",
|
| 168 |
+
"data": {
|
| 169 |
+
"tool_name": "get_service_status",
|
| 170 |
+
"parameters": {"service": "payment-api"}
|
| 171 |
+
}
|
| 172 |
+
}))
|
| 173 |
+
resp = json.loads(await ws.recv())
|
| 174 |
+
print("Step 1:", resp["data"]["observation"]["tool_output"][:200])
|
| 175 |
+
|
| 176 |
+
# 3. Submit resolution
|
| 177 |
+
await ws.send(json.dumps({
|
| 178 |
+
"type": "step",
|
| 179 |
+
"data": {
|
| 180 |
+
"tool_name": "submit_resolution",
|
| 181 |
+
"parameters": {
|
| 182 |
+
"root_cause": "Missing DB_CONNECTION_STRING after v2.3.1 deploy",
|
| 183 |
+
"affected_service": "payment-api",
|
| 184 |
+
"recommendation": "Rollback to v2.3.0 or set the env var"
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
}))
|
| 188 |
+
resp = json.loads(await ws.recv())
|
| 189 |
+
print("Done:", resp["data"]["done"], "Score:", resp["data"]["reward"])
|
| 190 |
+
|
| 191 |
+
# 4. Get final state
|
| 192 |
+
await ws.send(json.dumps({"type": "state"}))
|
| 193 |
+
resp = json.loads(await ws.recv())
|
| 194 |
+
print("Final score:", resp["data"]["final_score"])
|
| 195 |
+
|
| 196 |
+
asyncio.run(manual_test())
|
| 197 |
+
```
|
| 198 |
+
|
| 199 |
+
**Using websocat (CLI tool):**
|
| 200 |
+
|
| 201 |
+
```bash
|
| 202 |
+
brew install websocat # macOS
|
| 203 |
+
websocat ws://localhost:8000/ws
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
Then type JSON messages line by line:
|
| 207 |
+
|
| 208 |
+
```json
|
| 209 |
+
{"type": "reset", "data": {"task_id": 1}}
|
| 210 |
+
{"type": "step", "data": {"tool_name": "get_service_status", "parameters": {"service": "payment-api"}}}
|
| 211 |
+
{"type": "step", "data": {"tool_name": "submit_resolution", "parameters": {"root_cause": "Missing DB_CONNECTION_STRING", "affected_service": "payment-api", "recommendation": "Rollback to v2.3.0"}}}
|
| 212 |
+
{"type": "state"}
|
| 213 |
+
```
|
| 214 |
+
|
| 215 |
+
### 4.4 What to check
|
| 216 |
+
|
| 217 |
+
| Check | Expected |
|
| 218 |
+
|---|---|
|
| 219 |
+
| `/health` returns 200 | `{"status": "ok"}` |
|
| 220 |
+
| `/schema` returns action/observation/state schemas | Three top-level keys with JSON Schema properties |
|
| 221 |
+
| Reset with `task_id` 1, 2, 3 | Returns `incident_summary`, `available_tools` (7 tools), `done: false` |
|
| 222 |
+
| Diagnostic tool steps | Returns `tool_output` (non-empty), per-step `reward` |
|
| 223 |
+
| `submit_resolution` | Sets `done: true`, returns graded `reward` |
|
| 224 |
+
| State after resolution | `final_score` between 0.0–1.0, `root_cause_correct` bool |
|
| 225 |
+
| All 3 tasks produce scores > 0.0 with good resolutions | Task 1 ≈ 1.0, Task 2 ≈ 1.0, Task 3 ≈ 1.0 (with ideal answers) |
|
| 226 |
+
|
| 227 |
+
---
|
| 228 |
+
|
| 229 |
+
## 5. Docker Build & Validation
|
| 230 |
+
|
| 231 |
+
### 5.1 Build the image
|
| 232 |
+
|
| 233 |
+
```bash
|
| 234 |
+
docker build -t sentinel-env:latest -f server/Dockerfile .
|
| 235 |
+
```
|
| 236 |
+
|
| 237 |
+
### 5.2 Run the container
|
| 238 |
+
|
| 239 |
+
```bash
|
| 240 |
+
docker run -p 8000:8000 sentinel-env:latest
|
| 241 |
+
```
|
| 242 |
+
|
| 243 |
+
The server starts on port 8000 inside the container, mapped to your host.
|
| 244 |
+
|
| 245 |
+
### 5.3 Validate against the container
|
| 246 |
+
|
| 247 |
+
Once the container is running, all the same validation steps from section 4 work:
|
| 248 |
+
|
| 249 |
+
```bash
|
| 250 |
+
# Health check
|
| 251 |
+
curl http://localhost:8000/health
|
| 252 |
+
|
| 253 |
+
# Run the automated test suite
|
| 254 |
+
python test_local.py
|
| 255 |
+
|
| 256 |
+
# Or run inference against the containerised server
|
| 257 |
+
ENV_URL=http://localhost:8000 python inference.py
|
| 258 |
+
```
|
| 259 |
+
|
| 260 |
+
### 5.4 Docker — useful commands
|
| 261 |
+
|
| 262 |
+
```bash
|
| 263 |
+
# Build with no cache (clean rebuild)
|
| 264 |
+
docker build --no-cache -t sentinel-env:latest -f server/Dockerfile .
|
| 265 |
+
|
| 266 |
+
# Run in background
|
| 267 |
+
docker run -d --name sentinel -p 8000:8000 sentinel-env:latest
|
| 268 |
+
|
| 269 |
+
# View logs
|
| 270 |
+
docker logs -f sentinel
|
| 271 |
+
|
| 272 |
+
# Stop and remove
|
| 273 |
+
docker stop sentinel && docker rm sentinel
|
| 274 |
+
|
| 275 |
+
# Check image size (should be < 500MB)
|
| 276 |
+
docker images sentinel-env
|
| 277 |
+
```
|
| 278 |
+
|
| 279 |
+
---
|
| 280 |
+
|
| 281 |
+
## 6. Running Inference (LLM Agent)
|
| 282 |
+
|
| 283 |
+
The inference script drives an LLM through all 3 tasks via WebSocket.
|
| 284 |
+
|
| 285 |
+
### 6.1 Set environment variables
|
| 286 |
+
|
| 287 |
+
The inference script supports **HF Inference** (default), **OpenAI**, and **Azure OpenAI** endpoints.
|
| 288 |
+
|
| 289 |
+
> **Important:** `ENV_URL` is the Sentinel environment server. `API_BASE_URL` is
|
| 290 |
+
> the LLM API endpoint (matching the official OpenEnv inference pattern).
|
| 291 |
+
|
| 292 |
+
**Option A — HF Inference API (default, for hackathon submission):**
|
| 293 |
+
|
| 294 |
+
```bash
|
| 295 |
+
export ENV_URL=http://localhost:8000 # env server
|
| 296 |
+
export API_BASE_URL=https://router.huggingface.co/v1 # default, can omit
|
| 297 |
+
export MODEL_NAME=openai/gpt-oss-120b:novita # default, can omit
|
| 298 |
+
export HF_TOKEN=hf_... # or API_KEY
|
| 299 |
+
```
|
| 300 |
+
|
| 301 |
+
**Option B — OpenAI:**
|
| 302 |
+
|
| 303 |
+
```bash
|
| 304 |
+
export ENV_URL=http://localhost:8000
|
| 305 |
+
export API_BASE_URL=https://api.openai.com/v1
|
| 306 |
+
export MODEL_NAME=gpt-4o
|
| 307 |
+
export API_KEY=sk-...
|
| 308 |
+
```
|
| 309 |
+
|
| 310 |
+
**Option C — Azure OpenAI (for local/enterprise testing):**
|
| 311 |
+
|
| 312 |
+
```bash
|
| 313 |
+
export ENV_URL=http://localhost:8000
|
| 314 |
+
export AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com
|
| 315 |
+
export AZURE_OPENAI_API_KEY=your-azure-key
|
| 316 |
+
export MODEL_NAME=your-deployment-name # Azure deployment name
|
| 317 |
+
export AZURE_OPENAI_API_VERSION=2024-12-01-preview # optional, this is the default
|
| 318 |
+
```
|
| 319 |
+
|
| 320 |
+
> When `AZURE_OPENAI_ENDPOINT` is set, the script uses `AzureOpenAI` client.
|
| 321 |
+
> Otherwise it uses `OpenAI(base_url=API_BASE_URL, api_key=...)` — which
|
| 322 |
+
> covers both HF router and direct OpenAI.
|
| 323 |
+
|
| 324 |
+
### 6.2 Install inference dependencies
|
| 325 |
+
|
| 326 |
+
```bash
|
| 327 |
+
pip install openai websockets
|
| 328 |
+
```
|
| 329 |
+
|
| 330 |
+
### 6.3 Run
|
| 331 |
+
|
| 332 |
+
```bash
|
| 333 |
+
python inference.py
|
| 334 |
+
```
|
| 335 |
+
|
| 336 |
+
Expected output:
|
| 337 |
+
|
| 338 |
+
```
|
| 339 |
+
==================================================
|
| 340 |
+
Running Task 1...
|
| 341 |
+
==================================================
|
| 342 |
+
Task 1: 0.85
|
| 343 |
+
|
| 344 |
+
==================================================
|
| 345 |
+
Running Task 2...
|
| 346 |
+
==================================================
|
| 347 |
+
Task 2: 0.65
|
| 348 |
+
|
| 349 |
+
==================================================
|
| 350 |
+
Running Task 3...
|
| 351 |
+
==================================================
|
| 352 |
+
Task 3: 0.40
|
| 353 |
+
|
| 354 |
+
==================================================
|
| 355 |
+
Task 1: 0.85
|
| 356 |
+
Task 2: 0.65
|
| 357 |
+
Task 3: 0.40
|
| 358 |
+
Average: 0.63
|
| 359 |
+
==================================================
|
| 360 |
+
```
|
| 361 |
+
|
| 362 |
+
### 6.4 Inference against a remote HF Space
|
| 363 |
+
|
| 364 |
+
```bash
|
| 365 |
+
# HF model via HF router (hackathon default)
|
| 366 |
+
export ENV_URL=https://your-username-sentinel-env.hf.space
|
| 367 |
+
export HF_TOKEN=hf_...
|
| 368 |
+
python inference.py
|
| 369 |
+
|
| 370 |
+
# OpenAI model
|
| 371 |
+
export ENV_URL=https://your-username-sentinel-env.hf.space
|
| 372 |
+
export API_BASE_URL=https://api.openai.com/v1
|
| 373 |
+
export MODEL_NAME=gpt-4o
|
| 374 |
+
export API_KEY=sk-...
|
| 375 |
+
python inference.py
|
| 376 |
+
|
| 377 |
+
# Azure OpenAI
|
| 378 |
+
export ENV_URL=https://your-username-sentinel-env.hf.space
|
| 379 |
+
export AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com
|
| 380 |
+
export AZURE_OPENAI_API_KEY=your-azure-key
|
| 381 |
+
export MODEL_NAME=your-deployment-name
|
| 382 |
+
python inference.py
|
| 383 |
+
```
|
| 384 |
+
|
| 385 |
+
---
|
| 386 |
+
|
| 387 |
+
## 7. OpenEnv Validate
|
| 388 |
+
|
| 389 |
+
Run the official OpenEnv validation to confirm spec compliance:
|
| 390 |
+
|
| 391 |
+
```bash
|
| 392 |
+
openenv validate
|
| 393 |
+
```
|
| 394 |
+
|
| 395 |
+
This checks:
|
| 396 |
+
- `openenv.yaml` manifest is valid
|
| 397 |
+
- The app entry point (`server.app:app`) is importable
|
| 398 |
+
- A `main()` function exists in the script entry point
|
| 399 |
+
- `uv.lock` is present and up to date
|
| 400 |
+
|
| 401 |
+
If `uv.lock` is missing or stale:
|
| 402 |
+
|
| 403 |
+
```bash
|
| 404 |
+
pip install uv
|
| 405 |
+
uv lock
|
| 406 |
+
openenv validate
|
| 407 |
+
```
|
| 408 |
+
|
| 409 |
+
---
|
| 410 |
+
|
| 411 |
+
## 8. Deploy to Hugging Face Spaces
|
| 412 |
+
|
| 413 |
+
### 8.1 Login to Hugging Face
|
| 414 |
+
|
| 415 |
+
```bash
|
| 416 |
+
huggingface-cli login
|
| 417 |
+
# Paste your HF token when prompted (needs write access)
|
| 418 |
+
```
|
| 419 |
+
|
| 420 |
+
### 8.2 Option A — `openenv push` (recommended)
|
| 421 |
+
|
| 422 |
+
```bash
|
| 423 |
+
openenv push
|
| 424 |
+
```
|
| 425 |
+
|
| 426 |
+
This reads `openenv.yaml` and pushes the environment as a Docker Space tagged
|
| 427 |
+
with `openenv`.
|
| 428 |
+
|
| 429 |
+
### 8.3 Option B — Manual HF Spaces deployment
|
| 430 |
+
|
| 431 |
+
**Step 1: Create the Space**
|
| 432 |
+
|
| 433 |
+
Go to https://huggingface.co/new-space and create a new Space:
|
| 434 |
+
|
| 435 |
+
- **Space name:** `sentinel-env` (or any name)
|
| 436 |
+
- **SDK:** Docker
|
| 437 |
+
- **Hardware:** CPU basic (2 vCPU, 16GB RAM — free tier)
|
| 438 |
+
- **Visibility:** Public
|
| 439 |
+
|
| 440 |
+
**Step 2: Clone the Space repo**
|
| 441 |
+
|
| 442 |
+
```bash
|
| 443 |
+
git clone https://huggingface.co/spaces/YOUR_USERNAME/sentinel-env hf-space
|
| 444 |
+
cd hf-space
|
| 445 |
+
```
|
| 446 |
+
|
| 447 |
+
**Step 3: Copy project files**
|
| 448 |
+
|
| 449 |
+
```bash
|
| 450 |
+
# Copy all source files
|
| 451 |
+
cp -r /path/to/openenv-sentinel/{models.py,__init__.py,client.py,inference.py} .
|
| 452 |
+
cp -r /path/to/openenv-sentinel/{server,scenarios,tools,grading} .
|
| 453 |
+
cp /path/to/openenv-sentinel/openenv.yaml .
|
| 454 |
+
cp /path/to/openenv-sentinel/pyproject.toml .
|
| 455 |
+
cp /path/to/openenv-sentinel/README.md .
|
| 456 |
+
|
| 457 |
+
# The Dockerfile must be at the repo root for HF Spaces
|
| 458 |
+
cp /path/to/openenv-sentinel/server/Dockerfile .
|
| 459 |
+
```
|
| 460 |
+
|
| 461 |
+
> **Important:** HF Spaces expects `Dockerfile` at the repository root. The
|
| 462 |
+
> COPY paths inside the Dockerfile already reference files relative to the
|
| 463 |
+
> build context (repo root), so no changes are needed.
|
| 464 |
+
|
| 465 |
+
**Step 4: Push to HF**
|
| 466 |
+
|
| 467 |
+
```bash
|
| 468 |
+
git add .
|
| 469 |
+
git commit -m "Deploy OpenEnv-Sentinel"
|
| 470 |
+
git push
|
| 471 |
+
```
|
| 472 |
+
|
| 473 |
+
**Step 5: Verify deployment**
|
| 474 |
+
|
| 475 |
+
The Space builds automatically. Once running:
|
| 476 |
+
|
| 477 |
+
```bash
|
| 478 |
+
curl https://YOUR_USERNAME-sentinel-env.hf.space/health
|
| 479 |
+
# → {"status": "ok"}
|
| 480 |
+
```
|
| 481 |
+
|
| 482 |
+
### 8.4 Verify the deployed Space
|
| 483 |
+
|
| 484 |
+
```bash
|
| 485 |
+
# Health
|
| 486 |
+
curl https://YOUR_USERNAME-sentinel-env.hf.space/health
|
| 487 |
+
|
| 488 |
+
# Schema
|
| 489 |
+
curl https://YOUR_USERNAME-sentinel-env.hf.space/schema
|
| 490 |
+
|
| 491 |
+
# Run test_local.py against the Space (edit BASE_HTTP/BASE_WS in the file)
|
| 492 |
+
# Or run inference:
|
| 493 |
+
ENV_URL=https://YOUR_USERNAME-sentinel-env.hf.space \
|
| 494 |
+
HF_TOKEN=hf_... \
|
| 495 |
+
python inference.py
|
| 496 |
+
```
|
| 497 |
+
|
| 498 |
+
### 8.5 HF Spaces tips
|
| 499 |
+
|
| 500 |
+
- **Cold starts:** Free-tier Spaces sleep after inactivity. First request takes ~30s.
|
| 501 |
+
- **Logs:** View build & runtime logs in the Space's "Logs" tab on HF.
|
| 502 |
+
- **Environment variables:** Set secrets (like API keys) in Space Settings → Repository secrets.
|
| 503 |
+
- **Tags:** Ensure the README frontmatter includes `tags: [openenv]` for hackathon discovery.
|
| 504 |
+
- **Port:** The `app_port: 8000` in README frontmatter must match the `EXPOSE` in the Dockerfile.
|
| 505 |
+
|
| 506 |
+
---
|
| 507 |
+
|
| 508 |
+
## 9. Troubleshooting
|
| 509 |
+
|
| 510 |
+
| Problem | Solution |
|
| 511 |
+
|---|---|
|
| 512 |
+
| `ModuleNotFoundError: No module named 'openenv'` | Run `pip install -e .` or `pip install openenv-core>=0.2.3` |
|
| 513 |
+
| `openenv validate` fails with "no main() found" | Ensure `server/app.py` has a `def main()` function and `[project.scripts]` in pyproject.toml |
|
| 514 |
+
| `openenv validate` fails with "uv.lock not found" | Run `pip install uv && uv lock` |
|
| 515 |
+
| WebSocket connection refused | Server must be running (`uvicorn server.app:app --port 8000`) |
|
| 516 |
+
| HTTP `/step` returns fresh state (not continuing episode) | HTTP endpoints are stateless. Use WebSocket `/ws` for multi-step episodes |
|
| 517 |
+
| Docker build fails on `COPY` | Run `docker build` from the project root (not from `server/`) |
|
| 518 |
+
| Docker healthcheck failing | Ensure `curl` is installed in the image (the Dockerfile does this) |
|
| 519 |
+
| `inference.py` error: "ENV_URL required" | `export ENV_URL=http://localhost:8000` |
|
| 520 |
+
| Azure OpenAI 401 / auth error | Verify `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, and that `MODEL_NAME` matches your deployment name |
|
| 521 |
+
| HF Space shows "Building" forever | Check the Logs tab for build errors. Common: missing files in COPY |
|
| 522 |
+
| HF Space returns 502 | The app hasn't started yet (cold start) or crashed. Check runtime logs |
|
| 523 |
+
| Task score is 0.0 | The resolution keywords didn't match. Check grading criteria in HACKATHON_PLAN.md §5 |
|
| 524 |
+
| `websockets` not installed | `pip install websockets` |
|
| 525 |
+
|
| 526 |
+
---
|
| 527 |
+
|
| 528 |
+
## Quick Reference
|
| 529 |
+
|
| 530 |
+
```bash
|
| 531 |
+
# ── Local development ──
|
| 532 |
+
pip install -e ".[dev,inference]"
|
| 533 |
+
uvicorn server.app:app --port 8000 # start server
|
| 534 |
+
python test_local.py # validate all 3 tasks
|
| 535 |
+
openenv validate # check spec compliance
|
| 536 |
+
|
| 537 |
+
# ── Docker ──
|
| 538 |
+
docker build -t sentinel-env -f server/Dockerfile .
|
| 539 |
+
docker run -p 8000:8000 sentinel-env
|
| 540 |
+
|
| 541 |
+
# ── Inference (HF router — hackathon default) ──
|
| 542 |
+
export ENV_URL=http://localhost:8000
|
| 543 |
+
export HF_TOKEN=hf_...
|
| 544 |
+
python inference.py
|
| 545 |
+
|
| 546 |
+
# ── Inference (OpenAI) ──
|
| 547 |
+
export ENV_URL=http://localhost:8000
|
| 548 |
+
export API_BASE_URL=https://api.openai.com/v1
|
| 549 |
+
export MODEL_NAME=gpt-4o
|
| 550 |
+
export API_KEY=sk-...
|
| 551 |
+
python inference.py
|
| 552 |
+
|
| 553 |
+
# ── Inference (Azure OpenAI) ──
|
| 554 |
+
export ENV_URL=http://localhost:8000
|
| 555 |
+
export AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com
|
| 556 |
+
export AZURE_OPENAI_API_KEY=your-azure-key
|
| 557 |
+
export MODEL_NAME=your-deployment-name
|
| 558 |
+
python inference.py
|
| 559 |
+
|
| 560 |
+
# ── Deploy ──
|
| 561 |
+
huggingface-cli login
|
| 562 |
+
openenv push
|
| 563 |
+
```
|
__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenEnv-Sentinel: SRE Incident Triage Environment."""
|
| 2 |
+
|
| 3 |
+
__all__: list = []
|
| 4 |
+
|
| 5 |
+
try:
|
| 6 |
+
from models import SentinelAction, SentinelObservation, SentinelState # noqa: F401
|
| 7 |
+
|
| 8 |
+
__all__ += ["SentinelAction", "SentinelObservation", "SentinelState"]
|
| 9 |
+
except Exception:
|
| 10 |
+
pass
|
| 11 |
+
|
| 12 |
+
try:
|
| 13 |
+
from client import SentinelEnv # noqa: F401
|
| 14 |
+
|
| 15 |
+
__all__ += ["SentinelEnv"]
|
| 16 |
+
except Exception:
|
| 17 |
+
pass
|
client.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SentinelEnv client — connects to the Sentinel environment server."""
|
| 2 |
+
|
| 3 |
+
from openenv.core import EnvClient, StepResult
|
| 4 |
+
from .models import SentinelAction, SentinelObservation, SentinelState
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class SentinelEnv(EnvClient[SentinelAction, SentinelObservation, SentinelState]):
|
| 8 |
+
"""Client for the Sentinel SRE Incident Triage Environment."""
|
| 9 |
+
|
| 10 |
+
def _step_payload(self, action: SentinelAction) -> dict:
|
| 11 |
+
return {
|
| 12 |
+
"tool_name": action.tool_name,
|
| 13 |
+
"parameters": action.param_dict(),
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
def _parse_result(self, payload: dict) -> StepResult[SentinelObservation]:
|
| 17 |
+
obs = SentinelObservation(**payload["observation"])
|
| 18 |
+
return StepResult(
|
| 19 |
+
observation=obs,
|
| 20 |
+
reward=payload.get("reward"),
|
| 21 |
+
done=payload.get("done", False),
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
def _parse_state(self, payload: dict) -> SentinelState:
|
| 25 |
+
return SentinelState(**payload)
|
conftest.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
collect_ignore_glob = ["__init__.py", "client.py", "inference.py", "test_local.py"]
|
grading/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Grading package."""
|
grading/grader.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Terminal grader — delegates to the scenario's grade_resolution method."""
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def normalize_service_name(name: str) -> str:
|
| 5 |
+
"""Normalize an affected_service string for comparison."""
|
| 6 |
+
return name.lower().strip().replace("_", "-")
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def grade(scenario, resolution: dict, step_count: int) -> dict:
|
| 10 |
+
"""Grade a resolution using the scenario's grading logic.
|
| 11 |
+
|
| 12 |
+
Returns dict with keys: score (float 0-1), root_cause_correct (bool),
|
| 13 |
+
recommendation_correct (bool).
|
| 14 |
+
"""
|
| 15 |
+
return scenario.grade_resolution(resolution, step_count)
|
grading/rewards.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-step reward calculator."""
|
| 2 |
+
|
| 3 |
+
from typing import List
|
| 4 |
+
|
| 5 |
+
from tools.registry import make_relevance_key
|
| 6 |
+
|
| 7 |
+
REWARD_RELEVANT = 0.12
|
| 8 |
+
REWARD_IRRELEVANT = -0.02
|
| 9 |
+
REWARD_REPEATED = -0.05
|
| 10 |
+
REWARD_INVALID = -0.03
|
| 11 |
+
REWARD_STEP_COST = -0.01
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _call_signature(tool_name: str, params: dict) -> str:
|
| 15 |
+
"""Create a hashable signature for a tool call to detect repeats."""
|
| 16 |
+
return make_relevance_key(tool_name, params)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _is_relevant(
|
| 20 |
+
tool_name: str,
|
| 21 |
+
params: dict,
|
| 22 |
+
relevant_tools: List[str],
|
| 23 |
+
) -> bool:
|
| 24 |
+
"""Check if a tool call matches the scenario's relevance list.
|
| 25 |
+
|
| 26 |
+
Each entry is a colon-joined string like "query_logs:auth-service".
|
| 27 |
+
A match requires the computed key to appear in the relevance list.
|
| 28 |
+
"""
|
| 29 |
+
key = make_relevance_key(tool_name, params)
|
| 30 |
+
return key in relevant_tools
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def compute_step_reward(
|
| 34 |
+
tool_name: str,
|
| 35 |
+
params: dict,
|
| 36 |
+
is_valid: bool,
|
| 37 |
+
relevant_tools: List[str],
|
| 38 |
+
previous_calls: List[str],
|
| 39 |
+
) -> float:
|
| 40 |
+
"""Compute the reward for a single step.
|
| 41 |
+
|
| 42 |
+
Returns the reward value. Caller is responsible for appending the call
|
| 43 |
+
signature to previous_calls after calling this.
|
| 44 |
+
"""
|
| 45 |
+
reward = REWARD_STEP_COST
|
| 46 |
+
|
| 47 |
+
if not is_valid:
|
| 48 |
+
reward += REWARD_INVALID
|
| 49 |
+
return reward
|
| 50 |
+
|
| 51 |
+
sig = _call_signature(tool_name, params)
|
| 52 |
+
if sig in previous_calls:
|
| 53 |
+
reward += REWARD_REPEATED
|
| 54 |
+
return reward
|
| 55 |
+
|
| 56 |
+
if _is_relevant(tool_name, params, relevant_tools):
|
| 57 |
+
reward += REWARD_RELEVANT
|
| 58 |
+
else:
|
| 59 |
+
reward += REWARD_IRRELEVANT
|
| 60 |
+
|
| 61 |
+
return reward
|
inference.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Baseline inference script for OpenEnv-Sentinel.
|
| 2 |
+
|
| 3 |
+
Drives an LLM agent through all 3 SRE incident triage tasks.
|
| 4 |
+
|
| 5 |
+
Environment variables:
|
| 6 |
+
ENV_URL - Sentinel environment server URL (e.g. http://localhost:8000)
|
| 7 |
+
API_BASE_URL - LLM API base URL (default: https://router.huggingface.co/v1)
|
| 8 |
+
MODEL_NAME - Model or deployment name (default: openai/gpt-oss-120b:novita)
|
| 9 |
+
HF_TOKEN - Hugging Face token (used as API key)
|
| 10 |
+
LOCAL_IMAGE_NAME - Docker image name when using from_docker_image() (optional)
|
| 11 |
+
|
| 12 |
+
# Azure OpenAI (optional, for local testing)
|
| 13 |
+
AZURE_OPENAI_ENDPOINT - Set to enable AzureOpenAI client
|
| 14 |
+
AZURE_OPENAI_API_KEY - Azure API key
|
| 15 |
+
AZURE_OPENAI_API_VERSION - Azure API version (default: 2024-12-01-preview)
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import asyncio
|
| 19 |
+
import json
|
| 20 |
+
import os
|
| 21 |
+
import re
|
| 22 |
+
import sys
|
| 23 |
+
import time
|
| 24 |
+
|
| 25 |
+
from openai import OpenAI, AzureOpenAI
|
| 26 |
+
|
| 27 |
+
# ── configuration ───────────────────────────────────────────────────
|
| 28 |
+
|
| 29 |
+
# Environment server URL (where the Sentinel env is running)
|
| 30 |
+
ENV_URL = os.getenv("ENV_URL", "")
|
| 31 |
+
|
| 32 |
+
# LLM configuration (aligned with official OpenEnv inference examples)
|
| 33 |
+
API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
|
| 34 |
+
MODEL_NAME = os.getenv("MODEL_NAME", "openai/gpt-oss-120b:novita")
|
| 35 |
+
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 36 |
+
|
| 37 |
+
# Optional — if you use from_docker_image():
|
| 38 |
+
LOCAL_IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME")
|
| 39 |
+
|
| 40 |
+
# API key: prefer API_KEY, fall back to HF_TOKEN
|
| 41 |
+
API_KEY = os.getenv("API_KEY") or HF_TOKEN or os.getenv("OPENAI_API_KEY", "")
|
| 42 |
+
|
| 43 |
+
# Azure OpenAI (optional, for local testing with enterprise deployments)
|
| 44 |
+
AZURE_OPENAI_ENDPOINT = os.getenv("AZURE_OPENAI_ENDPOINT", "")
|
| 45 |
+
AZURE_OPENAI_API_KEY = os.getenv("AZURE_OPENAI_API_KEY", "")
|
| 46 |
+
AZURE_OPENAI_API_VERSION = os.getenv("AZURE_OPENAI_API_VERSION", "2024-12-01-preview")
|
| 47 |
+
AZURE_OPENAI_DEPLOYMENT = os.getenv("AZURE_OPENAI_DEPLOYMENT", "")
|
| 48 |
+
|
| 49 |
+
TASK_TIMEOUT = 360 # 6 minutes per task
|
| 50 |
+
MAX_PARSE_RETRIES = 3
|
| 51 |
+
MAX_COMPLETION_TOKENS = 16384 # reasoning models need room for chain-of-thought
|
| 52 |
+
|
| 53 |
+
SYSTEM_PROMPT = """You are an expert SRE agent triaging a production incident.
|
| 54 |
+
You have access to diagnostic tools. Respond with ONLY a single JSON object — no markdown, no explanation, no extra text.
|
| 55 |
+
|
| 56 |
+
Available tools:
|
| 57 |
+
- get_service_status: {"tool_name": "get_service_status", "parameters": {"service": "<name>"}}
|
| 58 |
+
- query_logs: {"tool_name": "query_logs", "parameters": {"service": "<name>", "query": "<text filter, use empty string for all logs>"}}
|
| 59 |
+
- query_metrics: {"tool_name": "query_metrics", "parameters": {"service": "<name>", "metric": "<cpu|memory|error_rate|latency|connections>"}}
|
| 60 |
+
- get_dependency_map: {"tool_name": "get_dependency_map", "parameters": {"service": "<name or omit for full map>"}}
|
| 61 |
+
- consult_runbook: {"tool_name": "consult_runbook", "parameters": {"topic": "<search_topic>"}}
|
| 62 |
+
- check_recent_changes: {"tool_name": "check_recent_changes", "parameters": {"service": "<name or omit for all>"}}
|
| 63 |
+
- submit_resolution: {"tool_name": "submit_resolution", "parameters": {"root_cause": "<detailed explanation>", "affected_service": "<primary ROOT CAUSE service>", "recommendation": "<specific actionable fix>"}}
|
| 64 |
+
|
| 65 |
+
INVESTIGATION PLAN — you have only 20 steps total, be extremely efficient:
|
| 66 |
+
Step 1: get_dependency_map (no service param) to see full architecture
|
| 67 |
+
Step 2: check_recent_changes (no service param) to see all recent deploys and changes
|
| 68 |
+
Step 3-4: get_service_status for the UNHEALTHY/DEGRADED services mentioned in the incident
|
| 69 |
+
Step 5-6: query_logs for unhealthy services (use "" as query to get all logs)
|
| 70 |
+
Step 7-8: query_metrics for the suspicious root-cause service (error_rate, memory, connections)
|
| 71 |
+
Step 9: submit_resolution with your findings
|
| 72 |
+
|
| 73 |
+
CRITICAL RULES:
|
| 74 |
+
- The ROOT CAUSE is often UPSTREAM — a dependency of the symptomatic service, not the alerted service itself
|
| 75 |
+
- Look for: bad deployments, missing env vars, OOM/memory issues, connection pool exhaustion, long-running queries
|
| 76 |
+
- affected_service MUST be the root-cause service, NOT the symptom service
|
| 77 |
+
- root_cause must mention specific service names, error types, versions, and technical details
|
| 78 |
+
- recommendation must be specific and actionable (e.g. rollback, increase memory limit, kill query, set timeout)
|
| 79 |
+
- Do NOT repeat the same tool call — you already have that data
|
| 80 |
+
- You MUST call submit_resolution by step 10 at the latest — do not keep investigating
|
| 81 |
+
- Respond with ONLY a JSON object. No markdown fences, no explanation."""
|
| 82 |
+
|
| 83 |
+
FORCE_RESOLUTION_PROMPT = """URGENT: You MUST call submit_resolution NOW. No more investigation.
|
| 84 |
+
Synthesize everything you have gathered. Your response MUST be ONLY:
|
| 85 |
+
{"tool_name": "submit_resolution", "parameters": {"root_cause": "<detailed with service names, errors, versions>", "affected_service": "<the root cause service>", "recommendation": "<specific fix>"}}
|
| 86 |
+
Do NOT call any other tool. Submit NOW."""
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ── action parsing ──────────────────────────────────────────────────
|
| 90 |
+
|
| 91 |
+
def parse_action(text: str) -> dict | None:
|
| 92 |
+
"""Parse LLM output into an action dict with multiple fallbacks."""
|
| 93 |
+
# 1. Direct JSON parse
|
| 94 |
+
try:
|
| 95 |
+
obj = json.loads(text.strip())
|
| 96 |
+
if isinstance(obj, dict) and "tool_name" in obj:
|
| 97 |
+
return obj
|
| 98 |
+
except json.JSONDecodeError:
|
| 99 |
+
pass
|
| 100 |
+
|
| 101 |
+
# 2. Extract from markdown code fence
|
| 102 |
+
fence_match = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", text, re.DOTALL)
|
| 103 |
+
if fence_match:
|
| 104 |
+
try:
|
| 105 |
+
obj = json.loads(fence_match.group(1).strip())
|
| 106 |
+
if isinstance(obj, dict) and "tool_name" in obj:
|
| 107 |
+
return obj
|
| 108 |
+
except json.JSONDecodeError:
|
| 109 |
+
pass
|
| 110 |
+
|
| 111 |
+
# 3. Regex: first {...} block
|
| 112 |
+
brace_match = re.search(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", text, re.DOTALL)
|
| 113 |
+
if brace_match:
|
| 114 |
+
try:
|
| 115 |
+
obj = json.loads(brace_match.group(0))
|
| 116 |
+
if isinstance(obj, dict) and "tool_name" in obj:
|
| 117 |
+
return obj
|
| 118 |
+
except json.JSONDecodeError:
|
| 119 |
+
pass
|
| 120 |
+
|
| 121 |
+
return None
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# ── history management ──────────────────────────────────────────────
|
| 125 |
+
|
| 126 |
+
def build_initial_prompt(observation: dict) -> str:
|
| 127 |
+
"""Build the first user prompt from the reset observation."""
|
| 128 |
+
parts = []
|
| 129 |
+
parts.append(f"INCIDENT: {observation.get('incident_summary', '')}")
|
| 130 |
+
|
| 131 |
+
tool_descs = observation.get("tool_descriptions")
|
| 132 |
+
if tool_descs:
|
| 133 |
+
parts.append("\n--- AVAILABLE TOOL PARAMETERS ---")
|
| 134 |
+
for tool, meta in tool_descs.items():
|
| 135 |
+
parts.append(f" {tool}: {json.dumps(meta)}")
|
| 136 |
+
|
| 137 |
+
parts.append(
|
| 138 |
+
f"\nStep {observation.get('step_number', 0)}/{observation.get('max_steps', 20)}"
|
| 139 |
+
)
|
| 140 |
+
parts.append("\nBegin your investigation. Respond with your first action as JSON:")
|
| 141 |
+
return "\n".join(parts)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def build_tool_response_prompt(observation: dict) -> str:
|
| 145 |
+
"""Build a follow-up user prompt after a tool call."""
|
| 146 |
+
parts = []
|
| 147 |
+
|
| 148 |
+
tool_output = observation.get("tool_output", "")
|
| 149 |
+
if tool_output:
|
| 150 |
+
parts.append(f"Tool output:\n{tool_output}")
|
| 151 |
+
|
| 152 |
+
if observation.get("last_action_error"):
|
| 153 |
+
parts.append(f"\n⚠ ERROR: {observation['last_action_error']}")
|
| 154 |
+
|
| 155 |
+
step_num = observation.get("step_number", 0)
|
| 156 |
+
max_steps = observation.get("max_steps", 20)
|
| 157 |
+
parts.append(
|
| 158 |
+
f"\nStep {step_num}/{max_steps} | "
|
| 159 |
+
f"Cumulative reward: {observation.get('cumulative_reward', 0.0):.2f}"
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
# Add urgency nudges based on step progress
|
| 163 |
+
if step_num >= max_steps - 5:
|
| 164 |
+
parts.append("\n⚠⚠ CRITICAL: You MUST call submit_resolution NOW! No more investigation!")
|
| 165 |
+
elif step_num >= max_steps - 8:
|
| 166 |
+
parts.append("\n⚠ WARNING: Submit your resolution NOW. Call submit_resolution with your best analysis.")
|
| 167 |
+
elif step_num >= max_steps - 12:
|
| 168 |
+
parts.append("\nNote: Start forming your resolution. You should submit within the next 2-3 steps.")
|
| 169 |
+
|
| 170 |
+
parts.append("\nRespond with your next action as JSON:")
|
| 171 |
+
return "\n".join(parts)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
# ── main loop ───────────────────────────────────────────────────────
|
| 175 |
+
|
| 176 |
+
async def run_task(task_id: int, base_url: str, client: OpenAI) -> float:
|
| 177 |
+
"""Run a single task against the environment via WebSocket. Returns grader score."""
|
| 178 |
+
import websockets
|
| 179 |
+
|
| 180 |
+
ws_url = base_url.replace("http://", "ws://").replace("https://", "wss://")
|
| 181 |
+
ws_url = ws_url.rstrip("/") + "/ws"
|
| 182 |
+
|
| 183 |
+
async with websockets.connect(ws_url, ping_interval=120, ping_timeout=300) as ws:
|
| 184 |
+
# Reset
|
| 185 |
+
await ws.send(json.dumps({"type": "reset", "data": {"task_id": task_id}}))
|
| 186 |
+
resp = json.loads(await ws.recv())
|
| 187 |
+
data = resp["data"]
|
| 188 |
+
observation = data.get("observation", data)
|
| 189 |
+
done = data.get("done", False)
|
| 190 |
+
|
| 191 |
+
# [START] — mandatory structured log
|
| 192 |
+
print(f"[START] task=task_{task_id} env=sentinel_env model={MODEL_NAME}")
|
| 193 |
+
|
| 194 |
+
# Build multi-turn conversation
|
| 195 |
+
messages: list[dict] = [
|
| 196 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 197 |
+
{"role": "user", "content": build_initial_prompt(observation)},
|
| 198 |
+
]
|
| 199 |
+
final_score = 0.0
|
| 200 |
+
local_step = 0 # track client-side loop iterations
|
| 201 |
+
rewards_list: list[str] = [] # collect per-step rewards for [END] line
|
| 202 |
+
|
| 203 |
+
while not done:
|
| 204 |
+
local_step += 1
|
| 205 |
+
step_num = observation.get("step_number", local_step)
|
| 206 |
+
max_steps = observation.get("max_steps", 20)
|
| 207 |
+
|
| 208 |
+
# Safety: break if we've looped too many times without env advancing
|
| 209 |
+
if local_step > max_steps + 10:
|
| 210 |
+
print(f" Exceeded max loop iterations ({local_step}), breaking", flush=True)
|
| 211 |
+
break
|
| 212 |
+
|
| 213 |
+
print(f" Step {step_num} (iter {local_step}): calling LLM...", flush=True)
|
| 214 |
+
|
| 215 |
+
# Force resolution when nearing step limit
|
| 216 |
+
force_resolution = step_num >= max_steps - 8
|
| 217 |
+
if force_resolution:
|
| 218 |
+
print(f" Step {step_num}: FORCING resolution submission", flush=True)
|
| 219 |
+
# Add force prompt as an additional system nudge in the conversation
|
| 220 |
+
force_msg = {"role": "system", "content": FORCE_RESOLUTION_PROMPT}
|
| 221 |
+
call_messages = messages + [force_msg]
|
| 222 |
+
else:
|
| 223 |
+
call_messages = messages
|
| 224 |
+
|
| 225 |
+
# Trim conversation if too long (keep system + first user + last 20 messages)
|
| 226 |
+
if len(call_messages) > 30:
|
| 227 |
+
call_messages = call_messages[:2] + call_messages[-20:]
|
| 228 |
+
|
| 229 |
+
# Try to get a valid action from the LLM
|
| 230 |
+
action_dict = None
|
| 231 |
+
for attempt in range(MAX_PARSE_RETRIES):
|
| 232 |
+
try:
|
| 233 |
+
# Run sync LLM call in a thread so the event loop can
|
| 234 |
+
# still handle WebSocket pings during long reasoning calls
|
| 235 |
+
response = await asyncio.to_thread(
|
| 236 |
+
client.chat.completions.create,
|
| 237 |
+
model=MODEL_NAME,
|
| 238 |
+
messages=call_messages,
|
| 239 |
+
max_completion_tokens=MAX_COMPLETION_TOKENS,
|
| 240 |
+
)
|
| 241 |
+
raw = response.choices[0].message.content or ""
|
| 242 |
+
action_dict = parse_action(raw)
|
| 243 |
+
if action_dict:
|
| 244 |
+
# Store the assistant response in conversation
|
| 245 |
+
messages.append({"role": "assistant", "content": raw})
|
| 246 |
+
print(f" Step {step_num}: action={action_dict.get('tool_name', '?')}", flush=True)
|
| 247 |
+
break
|
| 248 |
+
else:
|
| 249 |
+
print(f" Step {step_num}: parse failed (attempt {attempt + 1}), raw={raw[:200]}", flush=True)
|
| 250 |
+
# On parse failure, add a nudge and retry
|
| 251 |
+
if attempt < MAX_PARSE_RETRIES - 1:
|
| 252 |
+
call_messages = call_messages + [
|
| 253 |
+
{"role": "assistant", "content": raw},
|
| 254 |
+
{"role": "user", "content": "That was not valid JSON. Respond with ONLY a JSON object like {\"tool_name\": \"...\", \"parameters\": {...}}"},
|
| 255 |
+
]
|
| 256 |
+
except Exception as e:
|
| 257 |
+
print(f" LLM error (attempt {attempt + 1}): {e}", file=sys.stderr)
|
| 258 |
+
|
| 259 |
+
if action_dict is None:
|
| 260 |
+
# Fallback: send an invalid action to let the env handle it
|
| 261 |
+
action_dict = {"tool_name": "_invalid_", "parameters": {}}
|
| 262 |
+
messages.append({"role": "assistant", "content": json.dumps(action_dict)})
|
| 263 |
+
|
| 264 |
+
# Normalize "all" query param to empty string (handler uses it as substring filter)
|
| 265 |
+
if action_dict.get("parameters", {}).get("query") == "all":
|
| 266 |
+
action_dict["parameters"]["query"] = ""
|
| 267 |
+
if action_dict.get("parameters", {}).get("severity") == "all":
|
| 268 |
+
action_dict["parameters"].pop("severity", None)
|
| 269 |
+
|
| 270 |
+
# Step the environment via WebSocket
|
| 271 |
+
await ws.send(json.dumps({"type": "step", "data": action_dict}))
|
| 272 |
+
resp = json.loads(await ws.recv())
|
| 273 |
+
data = resp["data"]
|
| 274 |
+
observation = data.get("observation", data)
|
| 275 |
+
done = data.get("done", False)
|
| 276 |
+
|
| 277 |
+
if observation.get("done"):
|
| 278 |
+
done = True
|
| 279 |
+
|
| 280 |
+
step_reward = observation.get("reward", 0.0)
|
| 281 |
+
rewards_list.append(f"{step_reward:.2f}")
|
| 282 |
+
|
| 283 |
+
# [STEP] — mandatory structured log
|
| 284 |
+
print(f"[STEP] step={step_num} action={action_dict.get('tool_name')} "
|
| 285 |
+
f"reward={step_reward:.2f} done={str(done).lower()} "
|
| 286 |
+
f"error={observation.get('last_action_error', 'null')}")
|
| 287 |
+
|
| 288 |
+
# Add tool response as user message in the conversation
|
| 289 |
+
if not done:
|
| 290 |
+
messages.append({"role": "user", "content": build_tool_response_prompt(observation)})
|
| 291 |
+
|
| 292 |
+
# Get final state for score
|
| 293 |
+
await ws.send(json.dumps({"type": "state"}))
|
| 294 |
+
resp = json.loads(await ws.recv())
|
| 295 |
+
state_data = resp["data"]
|
| 296 |
+
final_score = state_data.get("final_score", 0.0)
|
| 297 |
+
|
| 298 |
+
# [END] — mandatory structured log
|
| 299 |
+
print(f"[END] success={str(final_score > 0).lower()} steps={local_step} "
|
| 300 |
+
f"score={final_score:.2f} rewards={','.join(rewards_list)}")
|
| 301 |
+
|
| 302 |
+
return final_score
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
async def main() -> None:
|
| 306 |
+
if not ENV_URL:
|
| 307 |
+
print("ERROR: ENV_URL environment variable is required.", file=sys.stderr)
|
| 308 |
+
print("Set it to the environment server URL (e.g., http://localhost:8000)", file=sys.stderr)
|
| 309 |
+
sys.exit(1)
|
| 310 |
+
|
| 311 |
+
if AZURE_OPENAI_ENDPOINT:
|
| 312 |
+
llm_client = AzureOpenAI(
|
| 313 |
+
azure_endpoint=AZURE_OPENAI_ENDPOINT,
|
| 314 |
+
api_key=AZURE_OPENAI_API_KEY or API_KEY,
|
| 315 |
+
api_version=AZURE_OPENAI_API_VERSION,
|
| 316 |
+
)
|
| 317 |
+
if AZURE_OPENAI_DEPLOYMENT:
|
| 318 |
+
global MODEL_NAME
|
| 319 |
+
MODEL_NAME = AZURE_OPENAI_DEPLOYMENT
|
| 320 |
+
print(f"Using Azure OpenAI: {AZURE_OPENAI_ENDPOINT} / deployment={MODEL_NAME}")
|
| 321 |
+
else:
|
| 322 |
+
llm_client = OpenAI(
|
| 323 |
+
base_url=API_BASE_URL,
|
| 324 |
+
api_key=API_KEY,
|
| 325 |
+
)
|
| 326 |
+
print(f"Using LLM API: {API_BASE_URL} / model={MODEL_NAME}")
|
| 327 |
+
|
| 328 |
+
scores: dict[int, float] = {}
|
| 329 |
+
for task_id in [1, 2, 3]:
|
| 330 |
+
print(f"\n{'='*50}")
|
| 331 |
+
print(f"Running Task {task_id}...")
|
| 332 |
+
print(f"{'='*50}")
|
| 333 |
+
|
| 334 |
+
try:
|
| 335 |
+
score = await asyncio.wait_for(
|
| 336 |
+
run_task(task_id, ENV_URL, llm_client),
|
| 337 |
+
timeout=TASK_TIMEOUT,
|
| 338 |
+
)
|
| 339 |
+
scores[task_id] = score
|
| 340 |
+
except asyncio.TimeoutError:
|
| 341 |
+
print(f" Task {task_id} timed out after {TASK_TIMEOUT}s", file=sys.stderr)
|
| 342 |
+
scores[task_id] = 0.0
|
| 343 |
+
except Exception as e:
|
| 344 |
+
print(f" Task {task_id} failed: {e}", file=sys.stderr)
|
| 345 |
+
scores[task_id] = 0.0
|
| 346 |
+
|
| 347 |
+
print(f"Task {task_id}: {scores[task_id]:.2f}")
|
| 348 |
+
|
| 349 |
+
avg = sum(scores.values()) / len(scores) if scores else 0.0
|
| 350 |
+
print(f"\n{'='*50}")
|
| 351 |
+
print(f"Task 1: {scores.get(1, 0.0):.2f}")
|
| 352 |
+
print(f"Task 2: {scores.get(2, 0.0):.2f}")
|
| 353 |
+
print(f"Task 3: {scores.get(3, 0.0):.2f}")
|
| 354 |
+
print(f"Average: {avg:.2f}")
|
| 355 |
+
print(f"{'='*50}")
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
if __name__ == "__main__":
|
| 359 |
+
asyncio.run(main())
|
models.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic models for OpenEnv-Sentinel.
|
| 2 |
+
|
| 3 |
+
Typed discriminated union for actions — each tool has its own action + params class.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Annotated, List, Literal, Union
|
| 7 |
+
|
| 8 |
+
from openenv.core.env_server.types import Action, Observation, State
|
| 9 |
+
from pydantic import BaseModel, Field, RootModel
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# ── Parameter models ────────────────────────────────────────────────
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class QueryLogsParams(BaseModel):
|
| 16 |
+
service: str
|
| 17 |
+
query: str = "all"
|
| 18 |
+
severity: str = "all"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class QueryMetricsParams(BaseModel):
|
| 22 |
+
service: str
|
| 23 |
+
metric: str
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class GetServiceStatusParams(BaseModel):
|
| 27 |
+
service: str
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class GetDependencyMapParams(BaseModel):
|
| 31 |
+
service: str = ""
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ConsultRunbookParams(BaseModel):
|
| 35 |
+
topic: str
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class CheckRecentChangesParams(BaseModel):
|
| 39 |
+
service: str = ""
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class SubmitResolutionParams(BaseModel):
|
| 43 |
+
root_cause: str
|
| 44 |
+
affected_service: str
|
| 45 |
+
recommendation: str
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ── Action models ───────────────────────────────────────────────────
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class QueryLogsAction(Action):
|
| 52 |
+
tool_name: Literal["query_logs"] = "query_logs"
|
| 53 |
+
parameters: QueryLogsParams
|
| 54 |
+
|
| 55 |
+
def param_dict(self) -> dict:
|
| 56 |
+
return self.parameters.model_dump()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class QueryMetricsAction(Action):
|
| 60 |
+
tool_name: Literal["query_metrics"] = "query_metrics"
|
| 61 |
+
parameters: QueryMetricsParams
|
| 62 |
+
|
| 63 |
+
def param_dict(self) -> dict:
|
| 64 |
+
return self.parameters.model_dump()
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class GetServiceStatusAction(Action):
|
| 68 |
+
tool_name: Literal["get_service_status"] = "get_service_status"
|
| 69 |
+
parameters: GetServiceStatusParams
|
| 70 |
+
|
| 71 |
+
def param_dict(self) -> dict:
|
| 72 |
+
return self.parameters.model_dump()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class GetDependencyMapAction(Action):
|
| 76 |
+
tool_name: Literal["get_dependency_map"] = "get_dependency_map"
|
| 77 |
+
parameters: GetDependencyMapParams
|
| 78 |
+
|
| 79 |
+
def param_dict(self) -> dict:
|
| 80 |
+
return self.parameters.model_dump()
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class ConsultRunbookAction(Action):
|
| 84 |
+
tool_name: Literal["consult_runbook"] = "consult_runbook"
|
| 85 |
+
parameters: ConsultRunbookParams
|
| 86 |
+
|
| 87 |
+
def param_dict(self) -> dict:
|
| 88 |
+
return self.parameters.model_dump()
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class CheckRecentChangesAction(Action):
|
| 92 |
+
tool_name: Literal["check_recent_changes"] = "check_recent_changes"
|
| 93 |
+
parameters: CheckRecentChangesParams
|
| 94 |
+
|
| 95 |
+
def param_dict(self) -> dict:
|
| 96 |
+
return self.parameters.model_dump()
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class SubmitResolutionAction(Action):
|
| 100 |
+
tool_name: Literal["submit_resolution"] = "submit_resolution"
|
| 101 |
+
parameters: SubmitResolutionParams
|
| 102 |
+
|
| 103 |
+
def param_dict(self) -> dict:
|
| 104 |
+
return self.parameters.model_dump()
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ── Discriminated union ─────────────────────────────────────────────
|
| 108 |
+
|
| 109 |
+
_ActionUnion = Annotated[
|
| 110 |
+
Union[
|
| 111 |
+
QueryLogsAction,
|
| 112 |
+
QueryMetricsAction,
|
| 113 |
+
GetServiceStatusAction,
|
| 114 |
+
GetDependencyMapAction,
|
| 115 |
+
ConsultRunbookAction,
|
| 116 |
+
CheckRecentChangesAction,
|
| 117 |
+
SubmitResolutionAction,
|
| 118 |
+
],
|
| 119 |
+
Field(discriminator="tool_name"),
|
| 120 |
+
]
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class SentinelAction(RootModel[_ActionUnion]):
|
| 124 |
+
"""Discriminated union action — delegates to the matched concrete action."""
|
| 125 |
+
|
| 126 |
+
@property
|
| 127 |
+
def tool_name(self) -> str:
|
| 128 |
+
return self.root.tool_name
|
| 129 |
+
|
| 130 |
+
def param_dict(self) -> dict:
|
| 131 |
+
return self.root.param_dict()
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# ── Observation & State ─────────────────────────────────────────────
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class SentinelObservation(Observation):
|
| 138 |
+
"""What the agent sees after each step."""
|
| 139 |
+
|
| 140 |
+
incident_summary: str = Field(default="", description="Initial alert / ongoing context")
|
| 141 |
+
tool_output: str = Field(default="", description="Result from the last tool call")
|
| 142 |
+
available_tools: List[str] = Field(default_factory=list, description="Tools the agent can use")
|
| 143 |
+
step_number: int = Field(default=0, description="Current step number")
|
| 144 |
+
max_steps: int = Field(default=20, description="Maximum steps per episode")
|
| 145 |
+
cumulative_reward: float = Field(default=0.0, description="Running total of per-step rewards")
|
| 146 |
+
last_action_error: str = Field(default="", description="Error from last invalid action")
|
| 147 |
+
tool_descriptions: dict = Field(default_factory=dict, description="Parameter metadata (populated on reset only)")
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
class SentinelState(State):
|
| 151 |
+
"""Internal environment state."""
|
| 152 |
+
|
| 153 |
+
task_id: int = 1
|
| 154 |
+
task_name: str = ""
|
| 155 |
+
tools_called: List[str] = Field(default_factory=list)
|
| 156 |
+
relevant_tools_called: List[str] = Field(default_factory=list)
|
| 157 |
+
resolution_submitted: bool = False
|
| 158 |
+
root_cause_correct: bool = False
|
| 159 |
+
recommendation_correct: bool = False
|
| 160 |
+
final_score: float = 0.0
|
openenv.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
spec_version: 1
|
| 2 |
+
name: sentinel_env
|
| 3 |
+
type: space
|
| 4 |
+
runtime: fastapi
|
| 5 |
+
app: server.app:app
|
| 6 |
+
port: 8000
|
pyproject.toml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=68.0", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "openenv-sentinel"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "SRE Incident Triage Environment for OpenEnv"
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
license = {text = "MIT"}
|
| 11 |
+
requires-python = ">=3.10"
|
| 12 |
+
dependencies = [
|
| 13 |
+
"openenv-core>=0.2.3",
|
| 14 |
+
"fastapi>=0.104.0",
|
| 15 |
+
"uvicorn>=0.24.0",
|
| 16 |
+
"pydantic>=2.0.0",
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
[project.optional-dependencies]
|
| 20 |
+
inference = [
|
| 21 |
+
"openai>=1.0.0",
|
| 22 |
+
]
|
| 23 |
+
dev = [
|
| 24 |
+
"pytest>=7.0",
|
| 25 |
+
"httpx>=0.25.0",
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
[project.scripts]
|
| 29 |
+
server = "server.app:main"
|
| 30 |
+
|
| 31 |
+
[tool.pytest.ini_options]
|
| 32 |
+
testpaths = ["tests"]
|
| 33 |
+
|
| 34 |
+
[tool.setuptools.packages.find]
|
| 35 |
+
include = ["openenv_sentinel*", "server*", "scenarios*", "tools*", "grading*"]
|
scenarios/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Scenario package."""
|
| 2 |
+
|
| 3 |
+
from .base import BaseScenario
|
| 4 |
+
|
| 5 |
+
__all__ = ["BaseScenario"]
|
| 6 |
+
|
scenarios/base.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Base scenario ABC for SRE incident triage tasks."""
|
| 2 |
+
|
| 3 |
+
from abc import ABC, abstractmethod
|
| 4 |
+
from typing import Dict, List
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class BaseScenario(ABC):
|
| 8 |
+
"""Abstract base for all incident scenarios."""
|
| 9 |
+
|
| 10 |
+
@abstractmethod
|
| 11 |
+
def get_incident_summary(self) -> str:
|
| 12 |
+
"""Return the initial alert text for this incident."""
|
| 13 |
+
|
| 14 |
+
@abstractmethod
|
| 15 |
+
def get_services(self) -> Dict[str, dict]:
|
| 16 |
+
"""Return service status data."""
|
| 17 |
+
|
| 18 |
+
@abstractmethod
|
| 19 |
+
def get_tool_response(self, tool_name: str, parameters: dict) -> str:
|
| 20 |
+
"""Return a pre-scripted response for the given tool call."""
|
| 21 |
+
|
| 22 |
+
@abstractmethod
|
| 23 |
+
def get_relevant_tools(self) -> List[str]:
|
| 24 |
+
"""Return list of 'tool_name:param' string keys considered relevant."""
|
| 25 |
+
|
| 26 |
+
@abstractmethod
|
| 27 |
+
def grade_resolution(self, resolution: dict, step_count: int) -> dict:
|
| 28 |
+
"""Grade a submitted resolution. Returns dict with score, root_cause_correct, recommendation_correct."""
|
| 29 |
+
|
| 30 |
+
@abstractmethod
|
| 31 |
+
def get_tool_descriptions(self) -> dict:
|
| 32 |
+
"""Return parameter metadata for LLM context. Called once on reset."""
|
| 33 |
+
|
| 34 |
+
# ── shared helpers ──────────────────────────────────────────────
|
| 35 |
+
|
| 36 |
+
def _format_logs(self, entries: List[dict]) -> str:
|
| 37 |
+
lines = []
|
| 38 |
+
for e in entries:
|
| 39 |
+
lines.append(f"[{e['timestamp']}] {e['level']} - {e['message']}")
|
| 40 |
+
if "source" in e:
|
| 41 |
+
lines.append(f" at {e['source']}")
|
| 42 |
+
return "\n".join(lines) if lines else "No matching log entries found."
|
| 43 |
+
|
| 44 |
+
def _format_metrics(self, metric_name: str, data: dict) -> str:
|
| 45 |
+
lines = [f"Metric: {metric_name}"]
|
| 46 |
+
if "values" in data:
|
| 47 |
+
lines.append(f" Values (recent): {data['values']}")
|
| 48 |
+
if "unit" in data:
|
| 49 |
+
lines.append(f" Unit: {data['unit']}")
|
| 50 |
+
if "annotation" in data:
|
| 51 |
+
lines.append(f" ⚠ {data['annotation']}")
|
| 52 |
+
return "\n".join(lines)
|
| 53 |
+
|
| 54 |
+
def _format_service_status(self, svc: dict) -> str:
|
| 55 |
+
lines = [
|
| 56 |
+
f"Service: {svc['name']}",
|
| 57 |
+
f" Status: {svc['status']}",
|
| 58 |
+
f" Error Rate: {svc.get('error_rate', 'N/A')}",
|
| 59 |
+
f" Uptime: {svc.get('uptime', 'N/A')}",
|
| 60 |
+
]
|
| 61 |
+
if "last_deploy" in svc:
|
| 62 |
+
lines.append(f" Last Deploy: {svc['last_deploy']}")
|
| 63 |
+
if "restarts" in svc:
|
| 64 |
+
lines.append(f" Restarts (30min): {svc['restarts']}")
|
| 65 |
+
if "latency_p99" in svc:
|
| 66 |
+
lines.append(f" Latency (p99): {svc['latency_p99']}")
|
| 67 |
+
if "queue_depth" in svc:
|
| 68 |
+
lines.append(f" Queue Depth: {svc['queue_depth']}")
|
| 69 |
+
if "connections" in svc:
|
| 70 |
+
lines.append(f" Connections: {svc['connections']}")
|
| 71 |
+
return "\n".join(lines)
|
| 72 |
+
|
| 73 |
+
def _format_dependency_map(self, deps: dict) -> str:
|
| 74 |
+
lines = []
|
| 75 |
+
for svc, info in deps.items():
|
| 76 |
+
depends_on = ", ".join(info.get("depends_on", []))
|
| 77 |
+
depended_by = ", ".join(info.get("depended_by", []))
|
| 78 |
+
lines.append(f"{svc}:")
|
| 79 |
+
if depends_on:
|
| 80 |
+
lines.append(f" Depends on: {depends_on}")
|
| 81 |
+
if depended_by:
|
| 82 |
+
lines.append(f" Depended on by: {depended_by}")
|
| 83 |
+
return "\n".join(lines) if lines else "No dependency data available."
|
| 84 |
+
|
| 85 |
+
def _format_changes(self, changes: List[dict]) -> str:
|
| 86 |
+
if not changes:
|
| 87 |
+
return "No recent changes found."
|
| 88 |
+
lines = []
|
| 89 |
+
for c in changes:
|
| 90 |
+
lines.append(f"[{c['timestamp']}] {c['service']} - {c['description']}")
|
| 91 |
+
if "changelog" in c:
|
| 92 |
+
lines.append(f" Changelog: {c['changelog']}")
|
| 93 |
+
return "\n".join(lines)
|
| 94 |
+
|
| 95 |
+
def _format_runbook(self, content: str) -> str:
|
| 96 |
+
return content if content else "No matching runbook found for this topic."
|
scenarios/task1_smoking_gun.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Task 1 — The Smoking Gun: a clear-cut deployment-caused outage."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
from .base import BaseScenario
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class SmokingGunScenario(BaseScenario):
|
| 9 |
+
"""Payment-API HTTP 500s caused by a missing env var after deploy v2.3.1."""
|
| 10 |
+
|
| 11 |
+
def __init__(self) -> None:
|
| 12 |
+
self.incident_text = (
|
| 13 |
+
"CRITICAL: payment-api returning HTTP 500 errors. "
|
| 14 |
+
"Customer checkout failing. Started 10 minutes ago."
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
self.services = {
|
| 18 |
+
"payment-api": {
|
| 19 |
+
"name": "payment-api",
|
| 20 |
+
"status": "UNHEALTHY",
|
| 21 |
+
"error_rate": "92%",
|
| 22 |
+
"uptime": "99.3% (30d)",
|
| 23 |
+
"last_deploy": "v2.3.1 deployed 8 minutes ago",
|
| 24 |
+
"restarts": 0,
|
| 25 |
+
"latency_p99": "timeout",
|
| 26 |
+
"connections": "0 active DB connections",
|
| 27 |
+
},
|
| 28 |
+
"order-service": {
|
| 29 |
+
"name": "order-service",
|
| 30 |
+
"status": "HEALTHY",
|
| 31 |
+
"error_rate": "0.1%",
|
| 32 |
+
"uptime": "99.99% (30d)",
|
| 33 |
+
"last_deploy": "v4.1.0 deployed 3 days ago",
|
| 34 |
+
"latency_p99": "120ms",
|
| 35 |
+
},
|
| 36 |
+
"user-service": {
|
| 37 |
+
"name": "user-service",
|
| 38 |
+
"status": "HEALTHY",
|
| 39 |
+
"error_rate": "0.05%",
|
| 40 |
+
"uptime": "99.99% (30d)",
|
| 41 |
+
"last_deploy": "v1.8.2 deployed 5 days ago",
|
| 42 |
+
"latency_p99": "45ms",
|
| 43 |
+
},
|
| 44 |
+
"postgres-primary": {
|
| 45 |
+
"name": "postgres-primary",
|
| 46 |
+
"status": "HEALTHY",
|
| 47 |
+
"error_rate": "0%",
|
| 48 |
+
"uptime": "99.999% (30d)",
|
| 49 |
+
"connections": "42 active connections",
|
| 50 |
+
},
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
self.log_entries = {
|
| 54 |
+
"payment-api": [
|
| 55 |
+
{
|
| 56 |
+
"timestamp": "2026-04-01T10:32:14Z",
|
| 57 |
+
"level": "ERROR",
|
| 58 |
+
"message": "NullPointerException: DB_CONNECTION_STRING is null",
|
| 59 |
+
"source": "config.DatabaseConfig.getConnection(DatabaseConfig.java:42)",
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
"timestamp": "2026-04-01T10:32:16Z",
|
| 63 |
+
"level": "ERROR",
|
| 64 |
+
"message": "NullPointerException: DB_CONNECTION_STRING is null",
|
| 65 |
+
"source": "config.DatabaseConfig.getConnection(DatabaseConfig.java:42)",
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
"timestamp": "2026-04-01T10:32:18Z",
|
| 69 |
+
"level": "ERROR",
|
| 70 |
+
"message": "NullPointerException: DB_CONNECTION_STRING is null",
|
| 71 |
+
"source": "config.DatabaseConfig.getConnection(DatabaseConfig.java:42)",
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"timestamp": "2026-04-01T10:32:20Z",
|
| 75 |
+
"level": "ERROR",
|
| 76 |
+
"message": "Failed to handle request POST /api/v1/payments: HTTP 500",
|
| 77 |
+
"source": "handler.PaymentHandler.processPayment(PaymentHandler.java:87)",
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"timestamp": "2026-04-01T10:32:22Z",
|
| 81 |
+
"level": "ERROR",
|
| 82 |
+
"message": "NullPointerException: DB_CONNECTION_STRING is null",
|
| 83 |
+
"source": "config.DatabaseConfig.getConnection(DatabaseConfig.java:42)",
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
"timestamp": "2026-04-01T10:32:24Z",
|
| 87 |
+
"level": "WARN",
|
| 88 |
+
"message": "Health check failed: unable to reach database",
|
| 89 |
+
"source": "health.HealthCheckService.check(HealthCheckService.java:23)",
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"timestamp": "2026-04-01T10:32:26Z",
|
| 93 |
+
"level": "ERROR",
|
| 94 |
+
"message": "NullPointerException: DB_CONNECTION_STRING is null",
|
| 95 |
+
"source": "config.DatabaseConfig.getConnection(DatabaseConfig.java:42)",
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
"timestamp": "2026-04-01T10:32:28Z",
|
| 99 |
+
"level": "ERROR",
|
| 100 |
+
"message": "Failed to handle request POST /api/v1/payments: HTTP 500",
|
| 101 |
+
"source": "handler.PaymentHandler.processPayment(PaymentHandler.java:87)",
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
"timestamp": "2026-04-01T10:32:30Z",
|
| 105 |
+
"level": "INFO",
|
| 106 |
+
"message": "Application started: payment-api v2.3.1",
|
| 107 |
+
"source": "main.Application.start(Application.java:15)",
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
"timestamp": "2026-04-01T10:32:32Z",
|
| 111 |
+
"level": "ERROR",
|
| 112 |
+
"message": "NullPointerException: DB_CONNECTION_STRING is null",
|
| 113 |
+
"source": "config.DatabaseConfig.getConnection(DatabaseConfig.java:42)",
|
| 114 |
+
},
|
| 115 |
+
],
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
self.metrics = {
|
| 119 |
+
"payment-api": {
|
| 120 |
+
"error_rate": {
|
| 121 |
+
"values": [
|
| 122 |
+
("2026-04-01T10:20:00Z", "0.1%"),
|
| 123 |
+
("2026-04-01T10:22:00Z", "0.1%"),
|
| 124 |
+
("2026-04-01T10:24:00Z", "0.1%"),
|
| 125 |
+
("2026-04-01T10:25:00Z", "92.0%"),
|
| 126 |
+
("2026-04-01T10:26:00Z", "92.3%"),
|
| 127 |
+
("2026-04-01T10:28:00Z", "91.8%"),
|
| 128 |
+
("2026-04-01T10:30:00Z", "92.1%"),
|
| 129 |
+
("2026-04-01T10:32:00Z", "92.0%"),
|
| 130 |
+
],
|
| 131 |
+
"unit": "percent",
|
| 132 |
+
"annotation": "Spike at 10:25:00Z correlates with deploy v2.3.1",
|
| 133 |
+
},
|
| 134 |
+
"latency_p99": {
|
| 135 |
+
"values": [
|
| 136 |
+
("2026-04-01T10:20:00Z", "85ms"),
|
| 137 |
+
("2026-04-01T10:22:00Z", "82ms"),
|
| 138 |
+
("2026-04-01T10:24:00Z", "88ms"),
|
| 139 |
+
("2026-04-01T10:25:00Z", "timeout"),
|
| 140 |
+
("2026-04-01T10:26:00Z", "timeout"),
|
| 141 |
+
("2026-04-01T10:28:00Z", "timeout"),
|
| 142 |
+
("2026-04-01T10:30:00Z", "timeout"),
|
| 143 |
+
("2026-04-01T10:32:00Z", "timeout"),
|
| 144 |
+
],
|
| 145 |
+
"unit": "milliseconds",
|
| 146 |
+
"annotation": "Latency spiked to timeout at deploy timestamp",
|
| 147 |
+
},
|
| 148 |
+
"request_count": {
|
| 149 |
+
"values": [
|
| 150 |
+
("2026-04-01T10:20:00Z", "1200"),
|
| 151 |
+
("2026-04-01T10:22:00Z", "1180"),
|
| 152 |
+
("2026-04-01T10:24:00Z", "1210"),
|
| 153 |
+
("2026-04-01T10:25:00Z", "1195"),
|
| 154 |
+
("2026-04-01T10:26:00Z", "1050"),
|
| 155 |
+
("2026-04-01T10:28:00Z", "870"),
|
| 156 |
+
("2026-04-01T10:30:00Z", "620"),
|
| 157 |
+
("2026-04-01T10:32:00Z", "430"),
|
| 158 |
+
],
|
| 159 |
+
"unit": "requests/min",
|
| 160 |
+
"annotation": "Request volume dropping as clients receive errors",
|
| 161 |
+
},
|
| 162 |
+
},
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
self.dependency_map = {
|
| 166 |
+
"payment-api": {
|
| 167 |
+
"depends_on": ["postgres-primary", "user-service"],
|
| 168 |
+
"depended_by": ["order-service"],
|
| 169 |
+
},
|
| 170 |
+
"order-service": {
|
| 171 |
+
"depends_on": ["payment-api", "postgres-primary"],
|
| 172 |
+
"depended_by": [],
|
| 173 |
+
},
|
| 174 |
+
"user-service": {
|
| 175 |
+
"depends_on": ["postgres-primary"],
|
| 176 |
+
"depended_by": ["payment-api"],
|
| 177 |
+
},
|
| 178 |
+
"postgres-primary": {
|
| 179 |
+
"depends_on": [],
|
| 180 |
+
"depended_by": ["payment-api", "order-service", "user-service"],
|
| 181 |
+
},
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
self.recent_changes = {
|
| 185 |
+
"payment-api": [
|
| 186 |
+
{
|
| 187 |
+
"timestamp": "2026-04-01T10:24:30Z",
|
| 188 |
+
"service": "payment-api",
|
| 189 |
+
"description": "Deployed v2.3.1",
|
| 190 |
+
"changelog": "Refactored config loader to use new ConfigManager class. Removed legacy environment variable fallback.",
|
| 191 |
+
},
|
| 192 |
+
],
|
| 193 |
+
"order-service": [
|
| 194 |
+
{
|
| 195 |
+
"timestamp": "2026-03-29T14:00:00Z",
|
| 196 |
+
"service": "order-service",
|
| 197 |
+
"description": "Deployed v4.1.0",
|
| 198 |
+
"changelog": "Added bulk order endpoint.",
|
| 199 |
+
},
|
| 200 |
+
],
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
self.runbooks = {
|
| 204 |
+
"payment-api": (
|
| 205 |
+
"## Runbook: payment-api\n\n"
|
| 206 |
+
"### Common Issues\n\n"
|
| 207 |
+
"1. **HTTP 500 errors**\n"
|
| 208 |
+
" - Check database connectivity: verify DB_CONNECTION_STRING env var is set.\n"
|
| 209 |
+
" - Check connection pool exhaustion: review active connections on postgres-primary.\n"
|
| 210 |
+
" - If caused by a bad deploy, rollback to previous version.\n\n"
|
| 211 |
+
"2. **High latency**\n"
|
| 212 |
+
" - Check postgres-primary slow query log.\n"
|
| 213 |
+
" - Review connection pool settings.\n\n"
|
| 214 |
+
"### Rollback Procedure\n"
|
| 215 |
+
" ```\n"
|
| 216 |
+
" kubectl rollout undo deployment/payment-api\n"
|
| 217 |
+
" ```\n\n"
|
| 218 |
+
"### Escalation\n"
|
| 219 |
+
" Contact: #payments-oncall in Slack\n"
|
| 220 |
+
),
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
# ── abstract method implementations ─────────────────────────────
|
| 224 |
+
|
| 225 |
+
def get_incident_summary(self) -> str:
|
| 226 |
+
return self.incident_text
|
| 227 |
+
|
| 228 |
+
def get_services(self) -> dict:
|
| 229 |
+
return self.services
|
| 230 |
+
|
| 231 |
+
def get_tool_response(self, tool_name: str, parameters: dict) -> str:
|
| 232 |
+
dispatch = {
|
| 233 |
+
"get_service_status": self._handle_service_status,
|
| 234 |
+
"query_logs": self._handle_query_logs,
|
| 235 |
+
"query_metrics": self._handle_query_metrics,
|
| 236 |
+
"get_dependency_map": self._handle_dependency_map,
|
| 237 |
+
"consult_runbook": self._handle_runbook,
|
| 238 |
+
"check_recent_changes": self._handle_recent_changes,
|
| 239 |
+
}
|
| 240 |
+
handler = dispatch.get(tool_name)
|
| 241 |
+
if handler is None:
|
| 242 |
+
return f"Unknown tool: {tool_name}"
|
| 243 |
+
return handler(parameters)
|
| 244 |
+
|
| 245 |
+
def get_relevant_tools(self) -> list[str]:
|
| 246 |
+
return [
|
| 247 |
+
"get_service_status:payment-api",
|
| 248 |
+
"query_logs:payment-api",
|
| 249 |
+
"check_recent_changes:payment-api",
|
| 250 |
+
"query_metrics:payment-api:error_rate",
|
| 251 |
+
]
|
| 252 |
+
|
| 253 |
+
def get_tool_descriptions(self) -> dict:
|
| 254 |
+
return {
|
| 255 |
+
"query_logs": {
|
| 256 |
+
"services": list(self.services.keys()),
|
| 257 |
+
"severity_options": ["all", "error", "warning", "info"],
|
| 258 |
+
},
|
| 259 |
+
"query_metrics": {
|
| 260 |
+
"services": list(self.metrics.keys()),
|
| 261 |
+
"metrics": ["error_rate", "latency_p99", "request_count", "cpu", "memory"],
|
| 262 |
+
},
|
| 263 |
+
"get_service_status": {
|
| 264 |
+
"services": list(self.services.keys()),
|
| 265 |
+
},
|
| 266 |
+
"get_dependency_map": {
|
| 267 |
+
"services": list(self.dependency_map.keys()),
|
| 268 |
+
"note": "Omit service for full map",
|
| 269 |
+
},
|
| 270 |
+
"consult_runbook": {
|
| 271 |
+
"topics": list(self.runbooks.keys()),
|
| 272 |
+
},
|
| 273 |
+
"check_recent_changes": {
|
| 274 |
+
"services": list(self.recent_changes.keys()),
|
| 275 |
+
"note": "Omit service for all changes",
|
| 276 |
+
},
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
def grade_resolution(self, resolution: dict, step_count: int) -> dict:
|
| 280 |
+
score = 0.0
|
| 281 |
+
root_cause_text = str(resolution.get("root_cause", "")).lower()
|
| 282 |
+
affected = str(resolution.get("affected_service", "")).strip().lower()
|
| 283 |
+
recommendation_text = str(resolution.get("recommendation", "")).lower()
|
| 284 |
+
|
| 285 |
+
# Root cause mentions config/env var issue (0.30)
|
| 286 |
+
config_match = False
|
| 287 |
+
if "db_connection_string" in root_cause_text:
|
| 288 |
+
config_match = True
|
| 289 |
+
elif any(kw in root_cause_text for kw in ["environment variable", "env var", "config"]):
|
| 290 |
+
if any(kw in root_cause_text for kw in ["missing", "null", "empty"]):
|
| 291 |
+
config_match = True
|
| 292 |
+
if config_match:
|
| 293 |
+
score += 0.30
|
| 294 |
+
|
| 295 |
+
# Root cause mentions deployment (0.20)
|
| 296 |
+
deploy_match = any(
|
| 297 |
+
kw in root_cause_text for kw in ["deploy", "v2.3.1", "release", "v2.3"]
|
| 298 |
+
)
|
| 299 |
+
if deploy_match:
|
| 300 |
+
score += 0.20
|
| 301 |
+
|
| 302 |
+
# Affected service is payment-api (0.15)
|
| 303 |
+
if affected == "payment-api":
|
| 304 |
+
score += 0.15
|
| 305 |
+
|
| 306 |
+
# Recommendation includes rollback or fix (0.15)
|
| 307 |
+
rec_match = False
|
| 308 |
+
if any(kw in recommendation_text for kw in ["rollback", "revert"]):
|
| 309 |
+
rec_match = True
|
| 310 |
+
elif re.search(r"set.*db_connection", recommendation_text):
|
| 311 |
+
rec_match = True
|
| 312 |
+
elif re.search(r"configure.*db_connection", recommendation_text):
|
| 313 |
+
rec_match = True
|
| 314 |
+
if rec_match:
|
| 315 |
+
score += 0.15
|
| 316 |
+
|
| 317 |
+
# Efficiency bonus (0.10)
|
| 318 |
+
if step_count <= 5:
|
| 319 |
+
score += 0.10
|
| 320 |
+
elif step_count <= 8:
|
| 321 |
+
score += 0.05
|
| 322 |
+
|
| 323 |
+
# No destructive recommendations (0.10) — present by default
|
| 324 |
+
destructive = any(
|
| 325 |
+
kw in recommendation_text
|
| 326 |
+
for kw in ["restart-all", "drop database", "delete", "truncate"]
|
| 327 |
+
)
|
| 328 |
+
if not destructive:
|
| 329 |
+
score += 0.10
|
| 330 |
+
|
| 331 |
+
root_cause_correct = config_match and deploy_match
|
| 332 |
+
recommendation_correct = rec_match
|
| 333 |
+
|
| 334 |
+
score = max(0.0, min(1.0, score))
|
| 335 |
+
return {
|
| 336 |
+
"score": score,
|
| 337 |
+
"root_cause_correct": root_cause_correct,
|
| 338 |
+
"recommendation_correct": recommendation_correct,
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
# ── internal tool handlers ──────────────────────────────────────
|
| 342 |
+
|
| 343 |
+
def _handle_service_status(self, parameters: dict) -> str:
|
| 344 |
+
service = parameters.get("service", "")
|
| 345 |
+
svc = self.services.get(service)
|
| 346 |
+
if svc is None:
|
| 347 |
+
return f"Service '{service}' not found."
|
| 348 |
+
return self._format_service_status(svc)
|
| 349 |
+
|
| 350 |
+
def _handle_query_logs(self, parameters: dict) -> str:
|
| 351 |
+
service = parameters.get("service", "")
|
| 352 |
+
query = parameters.get("query", "").lower()
|
| 353 |
+
entries = self.log_entries.get(service)
|
| 354 |
+
if entries is None:
|
| 355 |
+
return f"No logs available for service '{service}'."
|
| 356 |
+
if query:
|
| 357 |
+
matched = [
|
| 358 |
+
e
|
| 359 |
+
for e in entries
|
| 360 |
+
if query in e["message"].lower()
|
| 361 |
+
or query in e.get("level", "").lower()
|
| 362 |
+
or query in e.get("source", "").lower()
|
| 363 |
+
]
|
| 364 |
+
else:
|
| 365 |
+
matched = entries
|
| 366 |
+
return self._format_logs(matched)
|
| 367 |
+
|
| 368 |
+
def _handle_query_metrics(self, parameters: dict) -> str:
|
| 369 |
+
service = parameters.get("service", "")
|
| 370 |
+
metric = parameters.get("metric", "").lower()
|
| 371 |
+
svc_metrics = self.metrics.get(service)
|
| 372 |
+
if svc_metrics is None:
|
| 373 |
+
return f"No metrics available for service '{service}'."
|
| 374 |
+
# Fuzzy substring match on metric name
|
| 375 |
+
for name, data in svc_metrics.items():
|
| 376 |
+
if metric in name.lower() or name.lower() in metric:
|
| 377 |
+
return self._format_metrics(name, data)
|
| 378 |
+
return f"No matching metric '{metric}' for service '{service}'."
|
| 379 |
+
|
| 380 |
+
def _handle_dependency_map(self, parameters: dict) -> str:
|
| 381 |
+
service = parameters.get("service", "")
|
| 382 |
+
if service and service in self.dependency_map:
|
| 383 |
+
subset = {service: self.dependency_map[service]}
|
| 384 |
+
return self._format_dependency_map(subset)
|
| 385 |
+
return self._format_dependency_map(self.dependency_map)
|
| 386 |
+
|
| 387 |
+
def _handle_runbook(self, parameters: dict) -> str:
|
| 388 |
+
service = parameters.get("service", "")
|
| 389 |
+
topic = parameters.get("topic", "").lower()
|
| 390 |
+
# Try service-specific runbook first
|
| 391 |
+
content = self.runbooks.get(service)
|
| 392 |
+
if content:
|
| 393 |
+
return self._format_runbook(content)
|
| 394 |
+
# Try topic match
|
| 395 |
+
for key, value in self.runbooks.items():
|
| 396 |
+
if topic and topic in key.lower():
|
| 397 |
+
return self._format_runbook(value)
|
| 398 |
+
return self._format_runbook("")
|
| 399 |
+
|
| 400 |
+
def _handle_recent_changes(self, parameters: dict) -> str:
|
| 401 |
+
service = parameters.get("service", "")
|
| 402 |
+
changes = self.recent_changes.get(service)
|
| 403 |
+
if changes is None:
|
| 404 |
+
return f"No recent changes found for service '{service}'."
|
| 405 |
+
return self._format_changes(changes)
|
scenarios/task2_upstream_culprit.py
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Task 2 — The Upstream Culprit: checkout latency caused by an upstream OOM."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
from .base import BaseScenario
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class UpstreamCulpritScenario(BaseScenario):
|
| 9 |
+
"""Checkout-service p99 latency caused by inventory-service OOMKilled restarts."""
|
| 10 |
+
|
| 11 |
+
def __init__(self) -> None:
|
| 12 |
+
self.incident_text = (
|
| 13 |
+
"WARNING: checkout-service p99 latency > 5 seconds. "
|
| 14 |
+
"Customer-facing degradation. SLA breach imminent."
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
self.services = {
|
| 18 |
+
"checkout-service": {
|
| 19 |
+
"name": "checkout-service",
|
| 20 |
+
"status": "DEGRADED",
|
| 21 |
+
"error_rate": "12%",
|
| 22 |
+
"uptime": "99.7% (30d)",
|
| 23 |
+
"last_deploy": "v3.8.0 deployed 2 days ago",
|
| 24 |
+
"restarts": 0,
|
| 25 |
+
"latency_p99": "5.2s",
|
| 26 |
+
"connections": "38 active DB connections",
|
| 27 |
+
},
|
| 28 |
+
"inventory-service": {
|
| 29 |
+
"name": "inventory-service",
|
| 30 |
+
"status": "UNHEALTHY",
|
| 31 |
+
"error_rate": "45%",
|
| 32 |
+
"uptime": "94.2% (30d)",
|
| 33 |
+
"last_deploy": "v2.1.4 deployed 5 days ago",
|
| 34 |
+
"restarts": 4,
|
| 35 |
+
"latency_p99": "timeout",
|
| 36 |
+
"connections": "12 active DB connections",
|
| 37 |
+
},
|
| 38 |
+
"redis-cache": {
|
| 39 |
+
"name": "redis-cache",
|
| 40 |
+
"status": "HEALTHY",
|
| 41 |
+
"error_rate": "0%",
|
| 42 |
+
"uptime": "99.999% (30d)",
|
| 43 |
+
"connections": "64 active connections",
|
| 44 |
+
},
|
| 45 |
+
"postgres-primary": {
|
| 46 |
+
"name": "postgres-primary",
|
| 47 |
+
"status": "HEALTHY",
|
| 48 |
+
"error_rate": "0%",
|
| 49 |
+
"uptime": "99.999% (30d)",
|
| 50 |
+
"connections": "55 active connections",
|
| 51 |
+
},
|
| 52 |
+
"api-gateway": {
|
| 53 |
+
"name": "api-gateway",
|
| 54 |
+
"status": "HEALTHY",
|
| 55 |
+
"error_rate": "2.1%",
|
| 56 |
+
"uptime": "99.98% (30d)",
|
| 57 |
+
"last_deploy": "v5.0.2 deployed 1 week ago",
|
| 58 |
+
"latency_p99": "5.4s",
|
| 59 |
+
},
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
self.log_entries = {
|
| 63 |
+
"checkout-service": [
|
| 64 |
+
{
|
| 65 |
+
"timestamp": "2026-04-01T09:31:04Z",
|
| 66 |
+
"level": "ERROR",
|
| 67 |
+
"message": "TimeoutException: inventory-service did not respond within 3000ms",
|
| 68 |
+
"source": "client.InventoryClient.checkStock(InventoryClient.java:67)",
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"timestamp": "2026-04-01T09:31:18Z",
|
| 72 |
+
"level": "ERROR",
|
| 73 |
+
"message": "TimeoutException: inventory-service did not respond within 3000ms",
|
| 74 |
+
"source": "client.InventoryClient.checkStock(InventoryClient.java:67)",
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
"timestamp": "2026-04-01T09:31:22Z",
|
| 78 |
+
"level": "WARN",
|
| 79 |
+
"message": "Retry attempt 2/3 for inventory-service call failed",
|
| 80 |
+
"source": "client.InventoryClient.checkStock(InventoryClient.java:74)",
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"timestamp": "2026-04-01T09:31:35Z",
|
| 84 |
+
"level": "ERROR",
|
| 85 |
+
"message": "TimeoutException: inventory-service did not respond within 3000ms",
|
| 86 |
+
"source": "client.InventoryClient.reserveItem(InventoryClient.java:112)",
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
"timestamp": "2026-04-01T09:31:48Z",
|
| 90 |
+
"level": "ERROR",
|
| 91 |
+
"message": "Failed to handle request POST /api/v1/checkout: HTTP 504 Gateway Timeout",
|
| 92 |
+
"source": "handler.CheckoutHandler.processCheckout(CheckoutHandler.java:91)",
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
"timestamp": "2026-04-01T09:32:01Z",
|
| 96 |
+
"level": "ERROR",
|
| 97 |
+
"message": "TimeoutException: inventory-service did not respond within 3000ms",
|
| 98 |
+
"source": "client.InventoryClient.checkStock(InventoryClient.java:67)",
|
| 99 |
+
},
|
| 100 |
+
{
|
| 101 |
+
"timestamp": "2026-04-01T09:32:15Z",
|
| 102 |
+
"level": "WARN",
|
| 103 |
+
"message": "Circuit breaker OPEN for inventory-service — too many failures",
|
| 104 |
+
"source": "circuitbreaker.CircuitBreakerManager.trip(CircuitBreakerManager.java:43)",
|
| 105 |
+
},
|
| 106 |
+
{
|
| 107 |
+
"timestamp": "2026-04-01T09:32:30Z",
|
| 108 |
+
"level": "INFO",
|
| 109 |
+
"message": "Circuit breaker HALF-OPEN for inventory-service — attempting probe",
|
| 110 |
+
"source": "circuitbreaker.CircuitBreakerManager.probe(CircuitBreakerManager.java:58)",
|
| 111 |
+
},
|
| 112 |
+
{
|
| 113 |
+
"timestamp": "2026-04-01T09:32:44Z",
|
| 114 |
+
"level": "ERROR",
|
| 115 |
+
"message": "TimeoutException: inventory-service did not respond within 3000ms",
|
| 116 |
+
"source": "client.InventoryClient.checkStock(InventoryClient.java:67)",
|
| 117 |
+
},
|
| 118 |
+
{
|
| 119 |
+
"timestamp": "2026-04-01T09:32:58Z",
|
| 120 |
+
"level": "WARN",
|
| 121 |
+
"message": "Circuit breaker OPEN for inventory-service — probe failed",
|
| 122 |
+
"source": "circuitbreaker.CircuitBreakerManager.trip(CircuitBreakerManager.java:43)",
|
| 123 |
+
},
|
| 124 |
+
],
|
| 125 |
+
"inventory-service": [
|
| 126 |
+
{
|
| 127 |
+
"timestamp": "2026-04-01T09:15:02Z",
|
| 128 |
+
"level": "INFO",
|
| 129 |
+
"message": "Starting catalog sync batch job — processing 12,400 items",
|
| 130 |
+
"source": "jobs.CatalogSyncJob.execute(CatalogSyncJob.java:38)",
|
| 131 |
+
},
|
| 132 |
+
{
|
| 133 |
+
"timestamp": "2026-04-01T09:18:33Z",
|
| 134 |
+
"level": "WARN",
|
| 135 |
+
"message": "GC overhead limit exceeded — 98% of time spent in GC",
|
| 136 |
+
"source": "runtime.GarbageCollector",
|
| 137 |
+
},
|
| 138 |
+
{
|
| 139 |
+
"timestamp": "2026-04-01T09:19:47Z",
|
| 140 |
+
"level": "ERROR",
|
| 141 |
+
"message": "java.lang.OutOfMemoryError: Java heap space",
|
| 142 |
+
"source": "jobs.CatalogSyncJob.processBatch(CatalogSyncJob.java:85)",
|
| 143 |
+
},
|
| 144 |
+
{
|
| 145 |
+
"timestamp": "2026-04-01T09:19:48Z",
|
| 146 |
+
"level": "ERROR",
|
| 147 |
+
"message": "OOMKilled: Container exceeded memory limit 512Mi",
|
| 148 |
+
"source": "kubernetes.ContainerRuntime",
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
"timestamp": "2026-04-01T09:19:50Z",
|
| 152 |
+
"level": "INFO",
|
| 153 |
+
"message": "Container restarting (restart count: 1)",
|
| 154 |
+
"source": "kubernetes.ContainerRuntime",
|
| 155 |
+
},
|
| 156 |
+
{
|
| 157 |
+
"timestamp": "2026-04-01T09:20:15Z",
|
| 158 |
+
"level": "INFO",
|
| 159 |
+
"message": "Application started: inventory-service v2.1.4",
|
| 160 |
+
"source": "main.Application.start(Application.java:15)",
|
| 161 |
+
},
|
| 162 |
+
{
|
| 163 |
+
"timestamp": "2026-04-01T09:20:18Z",
|
| 164 |
+
"level": "INFO",
|
| 165 |
+
"message": "Resuming catalog sync batch job — 8,200 items remaining",
|
| 166 |
+
"source": "jobs.CatalogSyncJob.execute(CatalogSyncJob.java:42)",
|
| 167 |
+
},
|
| 168 |
+
{
|
| 169 |
+
"timestamp": "2026-04-01T09:24:11Z",
|
| 170 |
+
"level": "WARN",
|
| 171 |
+
"message": "GC overhead limit exceeded — 97% of time spent in GC",
|
| 172 |
+
"source": "runtime.GarbageCollector",
|
| 173 |
+
},
|
| 174 |
+
{
|
| 175 |
+
"timestamp": "2026-04-01T09:25:03Z",
|
| 176 |
+
"level": "ERROR",
|
| 177 |
+
"message": "java.lang.OutOfMemoryError: Java heap space",
|
| 178 |
+
"source": "jobs.CatalogSyncJob.processBatch(CatalogSyncJob.java:85)",
|
| 179 |
+
},
|
| 180 |
+
{
|
| 181 |
+
"timestamp": "2026-04-01T09:25:04Z",
|
| 182 |
+
"level": "ERROR",
|
| 183 |
+
"message": "OOMKilled: Container exceeded memory limit 512Mi",
|
| 184 |
+
"source": "kubernetes.ContainerRuntime",
|
| 185 |
+
},
|
| 186 |
+
{
|
| 187 |
+
"timestamp": "2026-04-01T09:25:06Z",
|
| 188 |
+
"level": "INFO",
|
| 189 |
+
"message": "Container restarting (restart count: 2)",
|
| 190 |
+
"source": "kubernetes.ContainerRuntime",
|
| 191 |
+
},
|
| 192 |
+
{
|
| 193 |
+
"timestamp": "2026-04-01T09:25:30Z",
|
| 194 |
+
"level": "INFO",
|
| 195 |
+
"message": "Application started: inventory-service v2.1.4",
|
| 196 |
+
"source": "main.Application.start(Application.java:15)",
|
| 197 |
+
},
|
| 198 |
+
{
|
| 199 |
+
"timestamp": "2026-04-01T09:29:41Z",
|
| 200 |
+
"level": "ERROR",
|
| 201 |
+
"message": "java.lang.OutOfMemoryError: Java heap space",
|
| 202 |
+
"source": "jobs.CatalogSyncJob.processBatch(CatalogSyncJob.java:85)",
|
| 203 |
+
},
|
| 204 |
+
{
|
| 205 |
+
"timestamp": "2026-04-01T09:29:42Z",
|
| 206 |
+
"level": "ERROR",
|
| 207 |
+
"message": "OOMKilled: Container exceeded memory limit 512Mi",
|
| 208 |
+
"source": "kubernetes.ContainerRuntime",
|
| 209 |
+
},
|
| 210 |
+
{
|
| 211 |
+
"timestamp": "2026-04-01T09:29:44Z",
|
| 212 |
+
"level": "INFO",
|
| 213 |
+
"message": "Container restarting (restart count: 3)",
|
| 214 |
+
"source": "kubernetes.ContainerRuntime",
|
| 215 |
+
},
|
| 216 |
+
{
|
| 217 |
+
"timestamp": "2026-04-01T09:30:10Z",
|
| 218 |
+
"level": "INFO",
|
| 219 |
+
"message": "Application started: inventory-service v2.1.4",
|
| 220 |
+
"source": "main.Application.start(Application.java:15)",
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"timestamp": "2026-04-01T09:33:58Z",
|
| 224 |
+
"level": "ERROR",
|
| 225 |
+
"message": "java.lang.OutOfMemoryError: Java heap space",
|
| 226 |
+
"source": "jobs.CatalogSyncJob.processBatch(CatalogSyncJob.java:85)",
|
| 227 |
+
},
|
| 228 |
+
{
|
| 229 |
+
"timestamp": "2026-04-01T09:33:59Z",
|
| 230 |
+
"level": "ERROR",
|
| 231 |
+
"message": "OOMKilled: Container exceeded memory limit 512Mi",
|
| 232 |
+
"source": "kubernetes.ContainerRuntime",
|
| 233 |
+
},
|
| 234 |
+
{
|
| 235 |
+
"timestamp": "2026-04-01T09:34:01Z",
|
| 236 |
+
"level": "INFO",
|
| 237 |
+
"message": "Container restarting (restart count: 4)",
|
| 238 |
+
"source": "kubernetes.ContainerRuntime",
|
| 239 |
+
},
|
| 240 |
+
],
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
self.metrics = {
|
| 244 |
+
"inventory-service": {
|
| 245 |
+
"memory": {
|
| 246 |
+
"values": [
|
| 247 |
+
("2026-04-01T09:15:00Z", "210Mi"),
|
| 248 |
+
("2026-04-01T09:16:00Z", "280Mi"),
|
| 249 |
+
("2026-04-01T09:17:00Z", "380Mi"),
|
| 250 |
+
("2026-04-01T09:18:00Z", "450Mi"),
|
| 251 |
+
("2026-04-01T09:19:00Z", "510Mi"),
|
| 252 |
+
("2026-04-01T09:19:47Z", "OOMKilled"),
|
| 253 |
+
("2026-04-01T09:20:15Z", "120Mi"),
|
| 254 |
+
("2026-04-01T09:22:00Z", "260Mi"),
|
| 255 |
+
("2026-04-01T09:24:00Z", "440Mi"),
|
| 256 |
+
("2026-04-01T09:25:00Z", "510Mi"),
|
| 257 |
+
("2026-04-01T09:25:03Z", "OOMKilled"),
|
| 258 |
+
("2026-04-01T09:25:30Z", "125Mi"),
|
| 259 |
+
("2026-04-01T09:28:00Z", "390Mi"),
|
| 260 |
+
("2026-04-01T09:29:41Z", "OOMKilled"),
|
| 261 |
+
("2026-04-01T09:30:10Z", "118Mi"),
|
| 262 |
+
("2026-04-01T09:33:00Z", "480Mi"),
|
| 263 |
+
("2026-04-01T09:33:58Z", "OOMKilled"),
|
| 264 |
+
],
|
| 265 |
+
"unit": "mebibytes",
|
| 266 |
+
"annotation": "RSS climbing: 380Mi → 450Mi → 510Mi → OOMKilled → restart → climb again. Memory limit: 512Mi.",
|
| 267 |
+
},
|
| 268 |
+
"cpu": {
|
| 269 |
+
"values": [
|
| 270 |
+
("2026-04-01T09:15:00Z", "15%"),
|
| 271 |
+
("2026-04-01T09:16:00Z", "32%"),
|
| 272 |
+
("2026-04-01T09:17:00Z", "58%"),
|
| 273 |
+
("2026-04-01T09:18:00Z", "74%"),
|
| 274 |
+
("2026-04-01T09:19:00Z", "91%"),
|
| 275 |
+
("2026-04-01T09:19:47Z", "99%"),
|
| 276 |
+
("2026-04-01T09:20:15Z", "8%"),
|
| 277 |
+
("2026-04-01T09:22:00Z", "35%"),
|
| 278 |
+
("2026-04-01T09:24:00Z", "72%"),
|
| 279 |
+
("2026-04-01T09:25:03Z", "98%"),
|
| 280 |
+
("2026-04-01T09:25:30Z", "10%"),
|
| 281 |
+
("2026-04-01T09:29:41Z", "97%"),
|
| 282 |
+
("2026-04-01T09:30:10Z", "9%"),
|
| 283 |
+
("2026-04-01T09:33:58Z", "96%"),
|
| 284 |
+
],
|
| 285 |
+
"unit": "percent",
|
| 286 |
+
"annotation": "CPU spikes correlated with OOM events — GC thrashing before each kill.",
|
| 287 |
+
},
|
| 288 |
+
},
|
| 289 |
+
"checkout-service": {
|
| 290 |
+
"latency_p99": {
|
| 291 |
+
"values": [
|
| 292 |
+
("2026-04-01T09:10:00Z", "180ms"),
|
| 293 |
+
("2026-04-01T09:15:00Z", "190ms"),
|
| 294 |
+
("2026-04-01T09:19:00Z", "3200ms"),
|
| 295 |
+
("2026-04-01T09:20:00Z", "5100ms"),
|
| 296 |
+
("2026-04-01T09:22:00Z", "1400ms"),
|
| 297 |
+
("2026-04-01T09:25:00Z", "5200ms"),
|
| 298 |
+
("2026-04-01T09:27:00Z", "2100ms"),
|
| 299 |
+
("2026-04-01T09:29:00Z", "4800ms"),
|
| 300 |
+
("2026-04-01T09:31:00Z", "5200ms"),
|
| 301 |
+
("2026-04-01T09:33:00Z", "5400ms"),
|
| 302 |
+
],
|
| 303 |
+
"unit": "milliseconds",
|
| 304 |
+
"annotation": "Latency spikes correlate with inventory-service restart cycles.",
|
| 305 |
+
},
|
| 306 |
+
"error_rate": {
|
| 307 |
+
"values": [
|
| 308 |
+
("2026-04-01T09:10:00Z", "0.3%"),
|
| 309 |
+
("2026-04-01T09:15:00Z", "0.4%"),
|
| 310 |
+
("2026-04-01T09:19:00Z", "8.2%"),
|
| 311 |
+
("2026-04-01T09:20:00Z", "14.1%"),
|
| 312 |
+
("2026-04-01T09:22:00Z", "5.3%"),
|
| 313 |
+
("2026-04-01T09:25:00Z", "15.0%"),
|
| 314 |
+
("2026-04-01T09:27:00Z", "6.1%"),
|
| 315 |
+
("2026-04-01T09:29:00Z", "11.8%"),
|
| 316 |
+
("2026-04-01T09:31:00Z", "12.0%"),
|
| 317 |
+
("2026-04-01T09:33:00Z", "13.5%"),
|
| 318 |
+
],
|
| 319 |
+
"unit": "percent",
|
| 320 |
+
"annotation": "Timeout errors from inventory-service dependency.",
|
| 321 |
+
},
|
| 322 |
+
},
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
self.dependency_map = {
|
| 326 |
+
"api-gateway": {
|
| 327 |
+
"depends_on": ["checkout-service"],
|
| 328 |
+
"depended_by": [],
|
| 329 |
+
},
|
| 330 |
+
"checkout-service": {
|
| 331 |
+
"depends_on": ["inventory-service", "redis-cache"],
|
| 332 |
+
"depended_by": ["api-gateway"],
|
| 333 |
+
},
|
| 334 |
+
"inventory-service": {
|
| 335 |
+
"depends_on": ["redis-cache", "postgres-primary"],
|
| 336 |
+
"depended_by": ["checkout-service"],
|
| 337 |
+
},
|
| 338 |
+
"redis-cache": {
|
| 339 |
+
"depends_on": [],
|
| 340 |
+
"depended_by": ["checkout-service", "inventory-service"],
|
| 341 |
+
},
|
| 342 |
+
"postgres-primary": {
|
| 343 |
+
"depends_on": [],
|
| 344 |
+
"depended_by": ["inventory-service"],
|
| 345 |
+
},
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
self.recent_changes = {
|
| 349 |
+
"checkout-service": [
|
| 350 |
+
{
|
| 351 |
+
"timestamp": "2026-03-30T11:00:00Z",
|
| 352 |
+
"service": "checkout-service",
|
| 353 |
+
"description": "Deployed v3.8.0",
|
| 354 |
+
"changelog": "Added coupon validation at checkout. Minor UI fixes.",
|
| 355 |
+
},
|
| 356 |
+
],
|
| 357 |
+
"inventory-service": [
|
| 358 |
+
{
|
| 359 |
+
"timestamp": "2026-03-27T16:30:00Z",
|
| 360 |
+
"service": "inventory-service",
|
| 361 |
+
"description": "Deployed v2.1.4",
|
| 362 |
+
"changelog": "Bumped catalog sync batch size from 500 to 5000 items per batch for throughput improvement.",
|
| 363 |
+
},
|
| 364 |
+
{
|
| 365 |
+
"timestamp": "2026-04-01T09:14:00Z",
|
| 366 |
+
"service": "inventory-service",
|
| 367 |
+
"description": "Scheduled catalog sync job triggered",
|
| 368 |
+
"changelog": "Cron job started full catalog sync — 12,400 items to process.",
|
| 369 |
+
},
|
| 370 |
+
],
|
| 371 |
+
"api-gateway": [
|
| 372 |
+
{
|
| 373 |
+
"timestamp": "2026-03-25T09:00:00Z",
|
| 374 |
+
"service": "api-gateway",
|
| 375 |
+
"description": "Deployed v5.0.2",
|
| 376 |
+
"changelog": "Updated rate limiting configuration.",
|
| 377 |
+
},
|
| 378 |
+
],
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
self.runbooks = {
|
| 382 |
+
"inventory-service": (
|
| 383 |
+
"## Runbook: inventory-service\n\n"
|
| 384 |
+
"### Common Issues\n\n"
|
| 385 |
+
"1. **OOMKilled / Memory Issues**\n"
|
| 386 |
+
" - Check current memory usage: `kubectl top pod -l app=inventory-service`\n"
|
| 387 |
+
" - Review Java heap settings: `-Xmx` should be ~75% of container memory limit.\n"
|
| 388 |
+
" - If caused by batch processing, reduce batch size or increase memory limit.\n"
|
| 389 |
+
" - Current memory limit: 512Mi. Recommended: 1Gi for large catalog syncs.\n"
|
| 390 |
+
" - To increase limit: `kubectl set resources deployment/inventory-service --limits=memory=1Gi`\n\n"
|
| 391 |
+
"2. **High Latency / Timeouts**\n"
|
| 392 |
+
" - Check downstream dependencies (redis-cache, postgres-primary).\n"
|
| 393 |
+
" - Verify connection pool is not exhausted.\n"
|
| 394 |
+
" - If service is in restart loop, check for OOMKilled events.\n\n"
|
| 395 |
+
"### Scaling\n"
|
| 396 |
+
" ```\n"
|
| 397 |
+
" kubectl scale deployment/inventory-service --replicas=3\n"
|
| 398 |
+
" ```\n\n"
|
| 399 |
+
"### Escalation\n"
|
| 400 |
+
" Contact: #inventory-oncall in Slack\n"
|
| 401 |
+
),
|
| 402 |
+
"checkout-service": (
|
| 403 |
+
"## Runbook: checkout-service\n\n"
|
| 404 |
+
"### Common Issues\n\n"
|
| 405 |
+
"1. **High Latency**\n"
|
| 406 |
+
" - Check upstream dependencies: inventory-service, redis-cache.\n"
|
| 407 |
+
" - Verify circuit breaker state.\n"
|
| 408 |
+
" - If inventory-service is down, checkout will degrade.\n\n"
|
| 409 |
+
"2. **Timeout Errors**\n"
|
| 410 |
+
" - Default timeout to inventory-service: 3000ms.\n"
|
| 411 |
+
" - If inventory-service is restarting frequently, consider enabling graceful degradation mode.\n\n"
|
| 412 |
+
"### Escalation\n"
|
| 413 |
+
" Contact: #checkout-oncall in Slack\n"
|
| 414 |
+
),
|
| 415 |
+
"oom troubleshooting": (
|
| 416 |
+
"## Runbook: OOM Troubleshooting (General)\n\n"
|
| 417 |
+
"### Symptoms\n"
|
| 418 |
+
"- Container status: OOMKilled\n"
|
| 419 |
+
"- Repeated restarts in `kubectl get pods`\n"
|
| 420 |
+
"- Java services: `java.lang.OutOfMemoryError: Java heap space`\n\n"
|
| 421 |
+
"### Investigation Steps\n"
|
| 422 |
+
"1. Check container memory limit vs actual usage: `kubectl top pod`\n"
|
| 423 |
+
"2. Review JVM heap settings: `-Xmx`, `-Xms`\n"
|
| 424 |
+
"3. Look for memory leak patterns: steady RSS climb → OOM → restart → climb\n"
|
| 425 |
+
"4. Check for recent changes that increased memory footprint (batch sizes, cache sizes).\n\n"
|
| 426 |
+
"### Immediate Mitigation\n"
|
| 427 |
+
"- Increase memory limit: `kubectl set resources deployment/<name> --limits=memory=<new_limit>`\n"
|
| 428 |
+
"- Reduce workload (e.g. smaller batch sizes).\n"
|
| 429 |
+
"- Restart with profiling enabled to capture heap dump: `-XX:+HeapDumpOnOutOfMemoryError`\n\n"
|
| 430 |
+
"### Long-term Fix\n"
|
| 431 |
+
"- Profile heap usage and fix leak.\n"
|
| 432 |
+
"- Use streaming/pagination instead of loading full batches into memory.\n"
|
| 433 |
+
),
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
# ── abstract method implementations ─────────────────────────────
|
| 437 |
+
|
| 438 |
+
def get_incident_summary(self) -> str:
|
| 439 |
+
return self.incident_text
|
| 440 |
+
|
| 441 |
+
def get_services(self) -> dict:
|
| 442 |
+
return self.services
|
| 443 |
+
|
| 444 |
+
def get_tool_response(self, tool_name: str, parameters: dict) -> str:
|
| 445 |
+
dispatch = {
|
| 446 |
+
"get_service_status": self._handle_service_status,
|
| 447 |
+
"query_logs": self._handle_query_logs,
|
| 448 |
+
"query_metrics": self._handle_query_metrics,
|
| 449 |
+
"get_dependency_map": self._handle_dependency_map,
|
| 450 |
+
"consult_runbook": self._handle_runbook,
|
| 451 |
+
"check_recent_changes": self._handle_recent_changes,
|
| 452 |
+
}
|
| 453 |
+
handler = dispatch.get(tool_name)
|
| 454 |
+
if handler is None:
|
| 455 |
+
return f"Unknown tool: {tool_name}"
|
| 456 |
+
return handler(parameters)
|
| 457 |
+
|
| 458 |
+
def get_relevant_tools(self) -> list[str]:
|
| 459 |
+
return [
|
| 460 |
+
"get_service_status:checkout-service",
|
| 461 |
+
"query_logs:checkout-service",
|
| 462 |
+
"get_dependency_map:checkout-service",
|
| 463 |
+
"get_service_status:inventory-service",
|
| 464 |
+
"query_logs:inventory-service",
|
| 465 |
+
"query_metrics:inventory-service:memory",
|
| 466 |
+
]
|
| 467 |
+
|
| 468 |
+
def get_tool_descriptions(self) -> dict:
|
| 469 |
+
return {
|
| 470 |
+
"query_logs": {
|
| 471 |
+
"services": list(self.services.keys()),
|
| 472 |
+
"severity_options": ["all", "error", "warning", "info"],
|
| 473 |
+
},
|
| 474 |
+
"query_metrics": {
|
| 475 |
+
"services": list(self.metrics.keys()),
|
| 476 |
+
"metrics": ["cpu", "memory", "error_rate", "latency", "connections"],
|
| 477 |
+
},
|
| 478 |
+
"get_service_status": {
|
| 479 |
+
"services": list(self.services.keys()),
|
| 480 |
+
},
|
| 481 |
+
"get_dependency_map": {
|
| 482 |
+
"services": list(self.dependency_map.keys()),
|
| 483 |
+
"note": "Omit service for full map",
|
| 484 |
+
},
|
| 485 |
+
"consult_runbook": {
|
| 486 |
+
"topics": list(self.runbooks.keys()),
|
| 487 |
+
},
|
| 488 |
+
"check_recent_changes": {
|
| 489 |
+
"services": list(self.recent_changes.keys()),
|
| 490 |
+
"note": "Omit service for all changes",
|
| 491 |
+
},
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
def grade_resolution(self, resolution: dict, step_count: int) -> dict:
|
| 495 |
+
score = 0.0
|
| 496 |
+
root_cause_text = str(resolution.get("root_cause", "")).lower()
|
| 497 |
+
affected = str(resolution.get("affected_service", "")).strip().lower()
|
| 498 |
+
recommendation_text = str(resolution.get("recommendation", "")).lower()
|
| 499 |
+
|
| 500 |
+
# Root cause identifies OOM/memory in inventory-service (0.30)
|
| 501 |
+
memory_keywords = ["memory", "oom", "outofmemory", "outofmemoryerror", "oomkilled"]
|
| 502 |
+
has_memory = any(kw in root_cause_text for kw in memory_keywords)
|
| 503 |
+
has_inventory = "inventory" in root_cause_text
|
| 504 |
+
root_cause_memory = has_memory and has_inventory
|
| 505 |
+
if root_cause_memory:
|
| 506 |
+
score += 0.30
|
| 507 |
+
|
| 508 |
+
# Identifies checkout latency caused by inventory (0.15)
|
| 509 |
+
upstream_keywords = ["upstream", "dependency"]
|
| 510 |
+
inventory_checkout_keywords_a = ["inventory"]
|
| 511 |
+
inventory_checkout_keywords_b = ["checkout", "timeout", "latency"]
|
| 512 |
+
upstream_match = any(kw in root_cause_text for kw in upstream_keywords)
|
| 513 |
+
inv_checkout_match = (
|
| 514 |
+
any(kw in root_cause_text for kw in inventory_checkout_keywords_a)
|
| 515 |
+
and any(kw in root_cause_text for kw in inventory_checkout_keywords_b)
|
| 516 |
+
)
|
| 517 |
+
if upstream_match or inv_checkout_match:
|
| 518 |
+
score += 0.15
|
| 519 |
+
|
| 520 |
+
# Affected service is inventory-service (0.15)
|
| 521 |
+
affected_normalized = re.sub(r"[^a-z0-9-]", "", affected)
|
| 522 |
+
if affected_normalized == "inventory-service":
|
| 523 |
+
score += 0.15
|
| 524 |
+
# Score 0.0 if they say checkout-service (no partial credit)
|
| 525 |
+
|
| 526 |
+
# Recommendation addresses memory (0.20)
|
| 527 |
+
rec_keywords = [
|
| 528 |
+
"memory limit", "heap", "batch", "stream", "1gi",
|
| 529 |
+
"increase memory", "reduce batch", "xmx",
|
| 530 |
+
]
|
| 531 |
+
rec_match = any(kw in recommendation_text for kw in rec_keywords)
|
| 532 |
+
if rec_match:
|
| 533 |
+
score += 0.20
|
| 534 |
+
|
| 535 |
+
# Efficiency bonus (0.10)
|
| 536 |
+
if step_count <= 8:
|
| 537 |
+
score += 0.10
|
| 538 |
+
elif step_count <= 12:
|
| 539 |
+
score += 0.05
|
| 540 |
+
|
| 541 |
+
# No destructive recommendations (0.10) — present by default
|
| 542 |
+
destructive_keywords = ["restart-all", "drop", "delete"]
|
| 543 |
+
destructive = any(kw in recommendation_text for kw in destructive_keywords)
|
| 544 |
+
if not destructive:
|
| 545 |
+
score += 0.10
|
| 546 |
+
|
| 547 |
+
root_cause_correct = root_cause_memory
|
| 548 |
+
recommendation_correct = rec_match
|
| 549 |
+
|
| 550 |
+
score = max(0.0, min(1.0, score))
|
| 551 |
+
return {
|
| 552 |
+
"score": score,
|
| 553 |
+
"root_cause_correct": root_cause_correct,
|
| 554 |
+
"recommendation_correct": recommendation_correct,
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
# ── internal tool handlers ──────────────────────────────────────
|
| 558 |
+
|
| 559 |
+
def _handle_service_status(self, parameters: dict) -> str:
|
| 560 |
+
service = parameters.get("service", "")
|
| 561 |
+
svc = self.services.get(service)
|
| 562 |
+
if svc is None:
|
| 563 |
+
return f"Service '{service}' not found."
|
| 564 |
+
return self._format_service_status(svc)
|
| 565 |
+
|
| 566 |
+
def _handle_query_logs(self, parameters: dict) -> str:
|
| 567 |
+
service = parameters.get("service", "")
|
| 568 |
+
query = parameters.get("query", "").lower()
|
| 569 |
+
entries = self.log_entries.get(service)
|
| 570 |
+
if entries is None:
|
| 571 |
+
return f"No logs available for service '{service}'."
|
| 572 |
+
if query:
|
| 573 |
+
matched = [
|
| 574 |
+
e
|
| 575 |
+
for e in entries
|
| 576 |
+
if query in e["message"].lower()
|
| 577 |
+
or query in e.get("level", "").lower()
|
| 578 |
+
or query in e.get("source", "").lower()
|
| 579 |
+
]
|
| 580 |
+
else:
|
| 581 |
+
matched = entries
|
| 582 |
+
return self._format_logs(matched)
|
| 583 |
+
|
| 584 |
+
def _handle_query_metrics(self, parameters: dict) -> str:
|
| 585 |
+
service = parameters.get("service", "")
|
| 586 |
+
metric = parameters.get("metric", "").lower()
|
| 587 |
+
svc_metrics = self.metrics.get(service)
|
| 588 |
+
if svc_metrics is None:
|
| 589 |
+
return f"No metrics available for service '{service}'."
|
| 590 |
+
# Fuzzy substring match on metric name
|
| 591 |
+
for name, data in svc_metrics.items():
|
| 592 |
+
if metric in name.lower() or name.lower() in metric:
|
| 593 |
+
return self._format_metrics(name, data)
|
| 594 |
+
return f"No matching metric '{metric}' for service '{service}'."
|
| 595 |
+
|
| 596 |
+
def _handle_dependency_map(self, parameters: dict) -> str:
|
| 597 |
+
service = parameters.get("service", "")
|
| 598 |
+
if service and service in self.dependency_map:
|
| 599 |
+
subset = {service: self.dependency_map[service]}
|
| 600 |
+
return self._format_dependency_map(subset)
|
| 601 |
+
return self._format_dependency_map(self.dependency_map)
|
| 602 |
+
|
| 603 |
+
def _handle_runbook(self, parameters: dict) -> str:
|
| 604 |
+
service = parameters.get("service", "")
|
| 605 |
+
topic = parameters.get("topic", "").lower()
|
| 606 |
+
# Try service-specific runbook first
|
| 607 |
+
content = self.runbooks.get(service)
|
| 608 |
+
if content:
|
| 609 |
+
return self._format_runbook(content)
|
| 610 |
+
# Try topic match
|
| 611 |
+
for key, value in self.runbooks.items():
|
| 612 |
+
if topic and topic in key.lower():
|
| 613 |
+
return self._format_runbook(value)
|
| 614 |
+
return self._format_runbook("")
|
| 615 |
+
|
| 616 |
+
def _handle_recent_changes(self, parameters: dict) -> str:
|
| 617 |
+
service = parameters.get("service", "")
|
| 618 |
+
changes = self.recent_changes.get(service)
|
| 619 |
+
if changes is None:
|
| 620 |
+
return f"No recent changes found for service '{service}'."
|
| 621 |
+
return self._format_changes(changes)
|
scenarios/task3_cascading_failure.py
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Task 3 — The Cascading Failure: analytics-worker long query exhausts postgres pool."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from typing import Dict, List
|
| 5 |
+
|
| 6 |
+
from .base import BaseScenario
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class CascadingFailureScenario(BaseScenario):
|
| 10 |
+
"""Multiple services degraded due to postgres connection pool exhaustion caused by analytics-worker."""
|
| 11 |
+
|
| 12 |
+
def __init__(self) -> None:
|
| 13 |
+
self.incident_text = (
|
| 14 |
+
"CRITICAL: Multiple services degraded. auth-service, user-profile-service, "
|
| 15 |
+
"notification-service all reporting errors. PagerDuty escalation triggered."
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
self.services: Dict[str, dict] = {
|
| 19 |
+
"auth-service": {
|
| 20 |
+
"name": "auth-service",
|
| 21 |
+
"status": "UNHEALTHY",
|
| 22 |
+
"error_rate": "98%",
|
| 23 |
+
"uptime": "87.2% (24h)",
|
| 24 |
+
"last_deploy": "v5.2.0 deployed 2 days ago",
|
| 25 |
+
"latency_p99": "timeout",
|
| 26 |
+
},
|
| 27 |
+
"user-profile-service": {
|
| 28 |
+
"name": "user-profile-service",
|
| 29 |
+
"status": "UNHEALTHY",
|
| 30 |
+
"error_rate": "85%",
|
| 31 |
+
"uptime": "91.0% (24h)",
|
| 32 |
+
"last_deploy": "v2.4.1 deployed 1 week ago",
|
| 33 |
+
"latency_p99": "timeout",
|
| 34 |
+
},
|
| 35 |
+
"notification-service": {
|
| 36 |
+
"name": "notification-service",
|
| 37 |
+
"status": "DEGRADED",
|
| 38 |
+
"error_rate": "45%",
|
| 39 |
+
"uptime": "94.5% (24h)",
|
| 40 |
+
"last_deploy": "v3.1.0 deployed 30 minutes ago",
|
| 41 |
+
"queue_depth": "15420 (normal < 100)",
|
| 42 |
+
},
|
| 43 |
+
"postgres-primary": {
|
| 44 |
+
"name": "postgres-primary",
|
| 45 |
+
"status": "DEGRADED",
|
| 46 |
+
"error_rate": "0%",
|
| 47 |
+
"uptime": "99.999% (30d)",
|
| 48 |
+
"connections": "50/50 active (pool exhausted), 23 waiting",
|
| 49 |
+
},
|
| 50 |
+
"analytics-worker": {
|
| 51 |
+
"name": "analytics-worker",
|
| 52 |
+
"status": "HEALTHY",
|
| 53 |
+
"error_rate": "0%",
|
| 54 |
+
"uptime": "99.9% (30d)",
|
| 55 |
+
"last_deploy": "v1.9.0 deployed 1 week ago",
|
| 56 |
+
},
|
| 57 |
+
"api-gateway": {
|
| 58 |
+
"name": "api-gateway",
|
| 59 |
+
"status": "DEGRADED",
|
| 60 |
+
"error_rate": "62%",
|
| 61 |
+
"uptime": "93.1% (24h)",
|
| 62 |
+
"last_deploy": "v8.0.3 deployed 3 days ago",
|
| 63 |
+
"latency_p99": "12.4s",
|
| 64 |
+
},
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
self.log_entries: Dict[str, List[dict]] = {
|
| 68 |
+
"auth-service": [
|
| 69 |
+
{
|
| 70 |
+
"timestamp": "2026-04-01T14:32:10Z",
|
| 71 |
+
"level": "ERROR",
|
| 72 |
+
"message": "PSQLException: Cannot acquire connection from pool. Pool exhausted (max=50, active=50, idle=0)",
|
| 73 |
+
"source": "com.auth.db.ConnectionManager.getConnection()",
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
"timestamp": "2026-04-01T14:32:12Z",
|
| 77 |
+
"level": "ERROR",
|
| 78 |
+
"message": "PSQLException: Cannot acquire connection from pool. Pool exhausted (max=50, active=50, idle=0)",
|
| 79 |
+
"source": "com.auth.db.ConnectionManager.getConnection()",
|
| 80 |
+
},
|
| 81 |
+
{
|
| 82 |
+
"timestamp": "2026-04-01T14:32:14Z",
|
| 83 |
+
"level": "ERROR",
|
| 84 |
+
"message": "Failed to validate authentication token: database connection unavailable",
|
| 85 |
+
"source": "com.auth.service.TokenValidator.validate()",
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"timestamp": "2026-04-01T14:32:16Z",
|
| 89 |
+
"level": "WARN",
|
| 90 |
+
"message": "Health check failing: cannot reach database",
|
| 91 |
+
"source": "com.auth.health.DatabaseHealthCheck",
|
| 92 |
+
},
|
| 93 |
+
{
|
| 94 |
+
"timestamp": "2026-04-01T14:32:18Z",
|
| 95 |
+
"level": "ERROR",
|
| 96 |
+
"message": "PSQLException: Cannot acquire connection from pool. Pool exhausted (max=50, active=50, idle=0)",
|
| 97 |
+
"source": "com.auth.db.ConnectionManager.getConnection()",
|
| 98 |
+
},
|
| 99 |
+
],
|
| 100 |
+
"user-profile-service": [
|
| 101 |
+
{
|
| 102 |
+
"timestamp": "2026-04-01T14:32:11Z",
|
| 103 |
+
"level": "ERROR",
|
| 104 |
+
"message": "AuthenticationException: Token validation failed - auth-service unreachable",
|
| 105 |
+
"source": "com.userprofile.auth.AuthClient.validateToken()",
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
"timestamp": "2026-04-01T14:32:13Z",
|
| 109 |
+
"level": "ERROR",
|
| 110 |
+
"message": "HTTP 401 Unauthorized: auth-service returned connection timeout",
|
| 111 |
+
"source": "com.userprofile.auth.AuthClient.validateToken()",
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
"timestamp": "2026-04-01T14:32:15Z",
|
| 115 |
+
"level": "WARN",
|
| 116 |
+
"message": "Falling back to cached user profile for user_id=8821 (auth unavailable)",
|
| 117 |
+
"source": "com.userprofile.service.ProfileService.getProfile()",
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"timestamp": "2026-04-01T14:32:17Z",
|
| 121 |
+
"level": "ERROR",
|
| 122 |
+
"message": "AuthenticationException: Token validation failed - auth-service unreachable",
|
| 123 |
+
"source": "com.userprofile.auth.AuthClient.validateToken()",
|
| 124 |
+
},
|
| 125 |
+
],
|
| 126 |
+
"notification-service": [
|
| 127 |
+
{
|
| 128 |
+
"timestamp": "2026-04-01T14:32:12Z",
|
| 129 |
+
"level": "ERROR",
|
| 130 |
+
"message": "UserProfileException: Cannot fetch user preferences - user-profile-service returned 401",
|
| 131 |
+
"source": "com.notification.client.UserProfileClient.getPreferences()",
|
| 132 |
+
},
|
| 133 |
+
{
|
| 134 |
+
"timestamp": "2026-04-01T14:32:14Z",
|
| 135 |
+
"level": "WARN",
|
| 136 |
+
"message": "Notification queue depth: 15420 (threshold: 1000). Processing stalled.",
|
| 137 |
+
"source": "com.notification.queue.QueueMonitor",
|
| 138 |
+
},
|
| 139 |
+
{
|
| 140 |
+
"timestamp": "2026-04-01T14:32:16Z",
|
| 141 |
+
"level": "ERROR",
|
| 142 |
+
"message": "UserProfileException: Cannot fetch user preferences - user-profile-service returned 401",
|
| 143 |
+
"source": "com.notification.client.UserProfileClient.getPreferences()",
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
"timestamp": "2026-04-01T14:32:18Z",
|
| 147 |
+
"level": "INFO",
|
| 148 |
+
"message": "v3.1.0 deployment completed successfully. New feature: batch notification grouping.",
|
| 149 |
+
"source": "com.notification.deploy.DeploymentHook",
|
| 150 |
+
},
|
| 151 |
+
],
|
| 152 |
+
"postgres-primary": [
|
| 153 |
+
{
|
| 154 |
+
"timestamp": "2026-04-01T14:10:05Z",
|
| 155 |
+
"level": "LOG",
|
| 156 |
+
"message": "duration: 1245032.456 ms statement: SELECT e.event_id, e.event_type, e.created_at, u.user_name, u.email, s.session_data FROM events e JOIN users u ON e.user_id = u.id JOIN sessions s ON e.session_id = s.id WHERE e.created_at > '2026-01-01' AND e.event_type IN ('purchase', 'refund', 'chargeback') ORDER BY e.created_at -- analytics_worker scheduled_report",
|
| 157 |
+
"source": "postgres/slow_query_log",
|
| 158 |
+
},
|
| 159 |
+
{
|
| 160 |
+
"timestamp": "2026-04-01T14:25:30Z",
|
| 161 |
+
"level": "WARN",
|
| 162 |
+
"message": "connection pool near capacity: 48/50 active connections",
|
| 163 |
+
"source": "postgres/connection_monitor",
|
| 164 |
+
},
|
| 165 |
+
{
|
| 166 |
+
"timestamp": "2026-04-01T14:28:00Z",
|
| 167 |
+
"level": "ERROR",
|
| 168 |
+
"message": "connection pool exhausted: 50/50 active connections, 12 clients waiting",
|
| 169 |
+
"source": "postgres/connection_monitor",
|
| 170 |
+
},
|
| 171 |
+
{
|
| 172 |
+
"timestamp": "2026-04-01T14:30:15Z",
|
| 173 |
+
"level": "ERROR",
|
| 174 |
+
"message": "connection pool exhausted: 50/50 active connections, 23 clients waiting. Longest query running for 1221s (analytics_worker)",
|
| 175 |
+
"source": "postgres/connection_monitor",
|
| 176 |
+
},
|
| 177 |
+
],
|
| 178 |
+
"analytics-worker": [
|
| 179 |
+
{
|
| 180 |
+
"timestamp": "2026-04-01T14:10:00Z",
|
| 181 |
+
"level": "INFO",
|
| 182 |
+
"message": "Starting scheduled report: quarterly_event_analysis. Estimated rows: 4.2M",
|
| 183 |
+
"source": "com.analytics.scheduler.ReportRunner",
|
| 184 |
+
},
|
| 185 |
+
{
|
| 186 |
+
"timestamp": "2026-04-01T14:10:05Z",
|
| 187 |
+
"level": "INFO",
|
| 188 |
+
"message": "Executing query against postgres-primary (no read replica configured)",
|
| 189 |
+
"source": "com.analytics.db.QueryExecutor",
|
| 190 |
+
},
|
| 191 |
+
{
|
| 192 |
+
"timestamp": "2026-04-01T14:32:00Z",
|
| 193 |
+
"level": "INFO",
|
| 194 |
+
"message": "Query still running. Rows processed so far: 2.1M of estimated 4.2M",
|
| 195 |
+
"source": "com.analytics.scheduler.ReportRunner",
|
| 196 |
+
},
|
| 197 |
+
],
|
| 198 |
+
"api-gateway": [
|
| 199 |
+
{
|
| 200 |
+
"timestamp": "2026-04-01T14:32:10Z",
|
| 201 |
+
"level": "ERROR",
|
| 202 |
+
"message": "Upstream timeout: auth-service did not respond within 5000ms",
|
| 203 |
+
"source": "com.gateway.proxy.UpstreamHandler",
|
| 204 |
+
},
|
| 205 |
+
{
|
| 206 |
+
"timestamp": "2026-04-01T14:32:12Z",
|
| 207 |
+
"level": "ERROR",
|
| 208 |
+
"message": "Upstream timeout: user-profile-service did not respond within 5000ms",
|
| 209 |
+
"source": "com.gateway.proxy.UpstreamHandler",
|
| 210 |
+
},
|
| 211 |
+
{
|
| 212 |
+
"timestamp": "2026-04-01T14:32:15Z",
|
| 213 |
+
"level": "WARN",
|
| 214 |
+
"message": "Circuit breaker OPEN for auth-service (failure rate: 98%)",
|
| 215 |
+
"source": "com.gateway.circuit.CircuitBreaker",
|
| 216 |
+
},
|
| 217 |
+
],
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
self.metrics: Dict[str, Dict[str, dict]] = {
|
| 221 |
+
"postgres-primary": {
|
| 222 |
+
"connections": {
|
| 223 |
+
"values": [42, 44, 46, 48, 49, 50, 50, 50, 50, 50],
|
| 224 |
+
"unit": "active connections (max 50)",
|
| 225 |
+
"annotation": "Connection pool saturated at 14:28. 48 connections held by analytics_worker query. 23 queries waiting.",
|
| 226 |
+
},
|
| 227 |
+
"cpu": {
|
| 228 |
+
"values": [15, 18, 22, 35, 42, 55, 58, 60, 62, 61],
|
| 229 |
+
"unit": "percent",
|
| 230 |
+
"annotation": "CPU elevated due to long-running analytics query (full table scan + joins).",
|
| 231 |
+
},
|
| 232 |
+
},
|
| 233 |
+
"analytics-worker": {
|
| 234 |
+
"cpu": {
|
| 235 |
+
"values": [5, 5, 8, 12, 15, 15, 14, 15, 14, 15],
|
| 236 |
+
"unit": "percent",
|
| 237 |
+
"annotation": "Stable elevated CPU — analytics query in progress since 14:10.",
|
| 238 |
+
},
|
| 239 |
+
"memory": {
|
| 240 |
+
"values": [210, 215, 220, 225, 230, 235, 240, 245, 248, 250],
|
| 241 |
+
"unit": "Mi",
|
| 242 |
+
"annotation": "Memory slowly climbing as query result set grows. Within limits (512Mi).",
|
| 243 |
+
},
|
| 244 |
+
},
|
| 245 |
+
"auth-service": {
|
| 246 |
+
"error_rate": {
|
| 247 |
+
"values": [0.1, 0.1, 0.2, 5.0, 45.0, 88.0, 95.0, 97.0, 98.0, 98.0],
|
| 248 |
+
"unit": "percent",
|
| 249 |
+
"annotation": "Error rate spike correlates with postgres pool exhaustion at 14:28.",
|
| 250 |
+
},
|
| 251 |
+
"latency": {
|
| 252 |
+
"values": [50, 55, 120, 800, 3000, 5000, 5000, 5000, 5000, 5000],
|
| 253 |
+
"unit": "ms (p99)",
|
| 254 |
+
"annotation": "Latency spike to timeout threshold. Auth requests waiting for DB connections.",
|
| 255 |
+
},
|
| 256 |
+
},
|
| 257 |
+
"notification-service": {
|
| 258 |
+
"queue_depth": {
|
| 259 |
+
"values": [45, 50, 80, 350, 1200, 4500, 8200, 11000, 13800, 15420],
|
| 260 |
+
"unit": "messages",
|
| 261 |
+
"annotation": "Queue backlog growing rapidly. Processing stalled due to user-profile-service failures.",
|
| 262 |
+
},
|
| 263 |
+
},
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
self.dependency_map: Dict[str, dict] = {
|
| 267 |
+
"api-gateway": {
|
| 268 |
+
"depends_on": ["auth-service", "user-profile-service", "notification-service"],
|
| 269 |
+
"depended_by": [],
|
| 270 |
+
},
|
| 271 |
+
"auth-service": {
|
| 272 |
+
"depends_on": ["postgres-primary"],
|
| 273 |
+
"depended_by": ["api-gateway", "user-profile-service"],
|
| 274 |
+
},
|
| 275 |
+
"user-profile-service": {
|
| 276 |
+
"depends_on": ["auth-service"],
|
| 277 |
+
"depended_by": ["api-gateway", "notification-service"],
|
| 278 |
+
},
|
| 279 |
+
"notification-service": {
|
| 280 |
+
"depends_on": ["user-profile-service"],
|
| 281 |
+
"depended_by": ["api-gateway"],
|
| 282 |
+
},
|
| 283 |
+
"postgres-primary": {
|
| 284 |
+
"depends_on": [],
|
| 285 |
+
"depended_by": ["auth-service", "analytics-worker"],
|
| 286 |
+
},
|
| 287 |
+
"analytics-worker": {
|
| 288 |
+
"depends_on": ["postgres-primary"],
|
| 289 |
+
"depended_by": [],
|
| 290 |
+
},
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
self.recent_changes: Dict[str, List[dict]] = {
|
| 294 |
+
"notification-service": [
|
| 295 |
+
{
|
| 296 |
+
"timestamp": "2026-04-01T14:02:00Z",
|
| 297 |
+
"service": "notification-service",
|
| 298 |
+
"description": "Deployed v3.1.0 — batch notification grouping feature",
|
| 299 |
+
"changelog": "Added batch grouping for push notifications. No database schema changes.",
|
| 300 |
+
},
|
| 301 |
+
],
|
| 302 |
+
"analytics-worker": [
|
| 303 |
+
{
|
| 304 |
+
"timestamp": "2026-04-01T14:10:00Z",
|
| 305 |
+
"service": "analytics-worker",
|
| 306 |
+
"description": "Scheduled job started: quarterly_event_analysis",
|
| 307 |
+
"changelog": "Automated quarterly report. Runs against postgres-primary (no read replica configured).",
|
| 308 |
+
},
|
| 309 |
+
],
|
| 310 |
+
"": [ # all recent changes
|
| 311 |
+
{
|
| 312 |
+
"timestamp": "2026-04-01T14:02:00Z",
|
| 313 |
+
"service": "notification-service",
|
| 314 |
+
"description": "Deployed v3.1.0 — batch notification grouping feature",
|
| 315 |
+
"changelog": "Added batch grouping for push notifications. No database schema changes.",
|
| 316 |
+
},
|
| 317 |
+
{
|
| 318 |
+
"timestamp": "2026-04-01T14:10:00Z",
|
| 319 |
+
"service": "analytics-worker",
|
| 320 |
+
"description": "Scheduled job started: quarterly_event_analysis",
|
| 321 |
+
"changelog": "Automated quarterly report. Runs against postgres-primary (no read replica configured).",
|
| 322 |
+
},
|
| 323 |
+
],
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
self.runbooks: Dict[str, str] = {
|
| 327 |
+
"connection pool exhausted": (
|
| 328 |
+
"Runbook: PostgreSQL Connection Pool Exhausted\n"
|
| 329 |
+
"1. Check active connections: SELECT count(*) FROM pg_stat_activity;\n"
|
| 330 |
+
"2. Identify long-running queries: SELECT pid, now()-query_start AS duration, query FROM pg_stat_activity WHERE state='active' ORDER BY duration DESC;\n"
|
| 331 |
+
"3. Kill the offending query: SELECT pg_terminate_backend(<pid>);\n"
|
| 332 |
+
"4. Monitor pool recovery — connections should free up within seconds.\n"
|
| 333 |
+
"5. Prevent recurrence: Set statement_timeout for batch jobs. Consider using a read replica for analytics."
|
| 334 |
+
),
|
| 335 |
+
"connection pool": (
|
| 336 |
+
"Runbook: PostgreSQL Connection Pool Exhausted\n"
|
| 337 |
+
"1. Check active connections: SELECT count(*) FROM pg_stat_activity;\n"
|
| 338 |
+
"2. Identify long-running queries: SELECT pid, now()-query_start AS duration, query FROM pg_stat_activity WHERE state='active' ORDER BY duration DESC;\n"
|
| 339 |
+
"3. Kill the offending query: SELECT pg_terminate_backend(<pid>);\n"
|
| 340 |
+
"4. Monitor pool recovery — connections should free up within seconds.\n"
|
| 341 |
+
"5. Prevent recurrence: Set statement_timeout for batch jobs. Consider using a read replica for analytics."
|
| 342 |
+
),
|
| 343 |
+
"authentication failure": (
|
| 344 |
+
"Runbook: Authentication Service Failures\n"
|
| 345 |
+
"1. Check auth-service health: GET /health\n"
|
| 346 |
+
"2. Verify database connectivity from auth-service.\n"
|
| 347 |
+
"3. Check for recent auth-service deployments.\n"
|
| 348 |
+
"4. If DB connection issue, check postgres connection pool status.\n"
|
| 349 |
+
"5. Escalate to DBA if postgres is the bottleneck."
|
| 350 |
+
),
|
| 351 |
+
"notification queue": (
|
| 352 |
+
"Runbook: Notification Queue Backlog\n"
|
| 353 |
+
"1. Check queue depth and processing rate.\n"
|
| 354 |
+
"2. Verify upstream dependencies (user-profile-service).\n"
|
| 355 |
+
"3. Check for recent notification-service deployments.\n"
|
| 356 |
+
"4. If upstream is down, queue will naturally drain once restored.\n"
|
| 357 |
+
"5. Do NOT restart notification-service — this will lose queued messages."
|
| 358 |
+
),
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
# ── public interface ────────────────────────────────────────────
|
| 362 |
+
|
| 363 |
+
def get_incident_summary(self) -> str:
|
| 364 |
+
return self.incident_text
|
| 365 |
+
|
| 366 |
+
def get_services(self) -> Dict[str, dict]:
|
| 367 |
+
return self.services
|
| 368 |
+
|
| 369 |
+
def get_tool_response(self, tool_name: str, parameters: dict) -> str:
|
| 370 |
+
handlers = {
|
| 371 |
+
"get_service_status": self._handle_service_status,
|
| 372 |
+
"query_logs": self._handle_query_logs,
|
| 373 |
+
"query_metrics": self._handle_query_metrics,
|
| 374 |
+
"get_dependency_map": self._handle_dependency_map,
|
| 375 |
+
"consult_runbook": self._handle_runbook,
|
| 376 |
+
"check_recent_changes": self._handle_recent_changes,
|
| 377 |
+
}
|
| 378 |
+
handler = handlers.get(tool_name)
|
| 379 |
+
if handler is None:
|
| 380 |
+
return f"Tool '{tool_name}' is not available."
|
| 381 |
+
return handler(parameters)
|
| 382 |
+
|
| 383 |
+
def get_relevant_tools(self) -> list[str]:
|
| 384 |
+
return [
|
| 385 |
+
"get_service_status:auth-service",
|
| 386 |
+
"query_logs:auth-service",
|
| 387 |
+
"get_service_status:postgres-primary",
|
| 388 |
+
"query_metrics:postgres-primary:connections",
|
| 389 |
+
"query_logs:postgres-primary",
|
| 390 |
+
"get_dependency_map",
|
| 391 |
+
"consult_runbook:connection pool",
|
| 392 |
+
"get_service_status:analytics-worker",
|
| 393 |
+
"query_metrics:analytics-worker",
|
| 394 |
+
"check_recent_changes",
|
| 395 |
+
]
|
| 396 |
+
|
| 397 |
+
def get_tool_descriptions(self) -> dict:
|
| 398 |
+
return {
|
| 399 |
+
"query_logs": {
|
| 400 |
+
"services": list(self.services.keys()),
|
| 401 |
+
"severity_options": ["all", "error", "warning", "info"],
|
| 402 |
+
},
|
| 403 |
+
"query_metrics": {
|
| 404 |
+
"services": list(self.metrics.keys()),
|
| 405 |
+
"metrics": ["connections", "cpu", "memory", "error_rate", "latency", "queue_depth"],
|
| 406 |
+
},
|
| 407 |
+
"get_service_status": {
|
| 408 |
+
"services": list(self.services.keys()),
|
| 409 |
+
},
|
| 410 |
+
"get_dependency_map": {
|
| 411 |
+
"services": list(self.dependency_map.keys()),
|
| 412 |
+
"note": "Omit service for full map",
|
| 413 |
+
},
|
| 414 |
+
"consult_runbook": {
|
| 415 |
+
"topics": list(self.runbooks.keys()),
|
| 416 |
+
},
|
| 417 |
+
"check_recent_changes": {
|
| 418 |
+
"services": list(self.recent_changes.keys()),
|
| 419 |
+
"note": "Omit service for all changes",
|
| 420 |
+
},
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
def grade_resolution(self, resolution: dict, step_count: int) -> dict:
|
| 424 |
+
root_cause = resolution.get("root_cause", "").lower()
|
| 425 |
+
affected = resolution.get("affected_service", "").lower().strip().replace("_", "-")
|
| 426 |
+
recommendation = resolution.get("recommendation", "").lower()
|
| 427 |
+
|
| 428 |
+
score = 0.0
|
| 429 |
+
|
| 430 |
+
# Root cause identifies postgres connection pool exhaustion (0.20)
|
| 431 |
+
pool_terms = any(t in root_cause for t in ["connection pool", "connections"])
|
| 432 |
+
exhausted_terms = any(t in root_cause for t in ["exhausted", "maxed", "full", "saturated"])
|
| 433 |
+
pool_match = pool_terms and exhausted_terms
|
| 434 |
+
if pool_match:
|
| 435 |
+
score += 0.20
|
| 436 |
+
|
| 437 |
+
# Root cause identifies analytics-worker / long-running query (0.20)
|
| 438 |
+
analytics_match_a = "analytics" in root_cause
|
| 439 |
+
analytics_match_b = any(t in root_cause for t in ["query", "long-running", "holding connections", "long query"])
|
| 440 |
+
analytics_match = analytics_match_a and analytics_match_b
|
| 441 |
+
if analytics_match:
|
| 442 |
+
score += 0.20
|
| 443 |
+
|
| 444 |
+
# Understands cascade chain (0.10)
|
| 445 |
+
cascade_kw = "cascade" in root_cause
|
| 446 |
+
# Or mentions 3+ services in the chain
|
| 447 |
+
chain_services = ["auth", "user-profile", "notification", "postgres"]
|
| 448 |
+
chain_count = sum(1 for s in chain_services if s in root_cause)
|
| 449 |
+
if cascade_kw or chain_count >= 3:
|
| 450 |
+
score += 0.10
|
| 451 |
+
|
| 452 |
+
# Affected service is postgres-primary or analytics-worker (0.10)
|
| 453 |
+
valid_affected = {"postgres-primary", "postgres", "analytics-worker", "analytics"}
|
| 454 |
+
bad_affected = {"auth-service", "notification-service", "user-profile-service"}
|
| 455 |
+
if affected in valid_affected:
|
| 456 |
+
score += 0.10
|
| 457 |
+
elif affected in bad_affected:
|
| 458 |
+
score += 0.0 # explicit 0
|
| 459 |
+
|
| 460 |
+
# Does NOT blame notification-service deploy (0.05) — present by default
|
| 461 |
+
notif_blamed = ("notification" in root_cause) and any(
|
| 462 |
+
t in root_cause for t in ["deploy", "v3.1"]
|
| 463 |
+
)
|
| 464 |
+
if not notif_blamed:
|
| 465 |
+
score += 0.05
|
| 466 |
+
|
| 467 |
+
# Recommendation: kill query + prevent recurrence (0.15)
|
| 468 |
+
kill_match = any(t in recommendation for t in ["kill", "terminate"]) and "query" in recommendation
|
| 469 |
+
prevent_match = any(
|
| 470 |
+
t in recommendation
|
| 471 |
+
for t in ["timeout", "read replica", "pgbouncer", "statement_timeout", "pool"]
|
| 472 |
+
)
|
| 473 |
+
if kill_match and prevent_match:
|
| 474 |
+
score += 0.15
|
| 475 |
+
elif kill_match or prevent_match:
|
| 476 |
+
score += 0.07
|
| 477 |
+
|
| 478 |
+
# Efficiency bonus (0.10)
|
| 479 |
+
if step_count <= 12:
|
| 480 |
+
score += 0.10
|
| 481 |
+
elif step_count <= 16:
|
| 482 |
+
score += 0.05
|
| 483 |
+
|
| 484 |
+
# No destructive recommendations (0.10) — present by default
|
| 485 |
+
has_kill_query = bool(re.search(r"kill.*query|terminate.*query", recommendation))
|
| 486 |
+
destructive_terms = ["restart postgres", "drop connections", "restart-all"]
|
| 487 |
+
is_destructive = any(t in recommendation for t in destructive_terms) and not has_kill_query
|
| 488 |
+
if not is_destructive:
|
| 489 |
+
score += 0.10
|
| 490 |
+
|
| 491 |
+
root_cause_correct = pool_match and analytics_match
|
| 492 |
+
recommendation_correct = kill_match and prevent_match
|
| 493 |
+
|
| 494 |
+
score = max(0.0, min(1.0, score))
|
| 495 |
+
return {
|
| 496 |
+
"score": score,
|
| 497 |
+
"root_cause_correct": root_cause_correct,
|
| 498 |
+
"recommendation_correct": recommendation_correct,
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
# ── internal tool handlers ──────────────────────────────────────
|
| 502 |
+
|
| 503 |
+
def _handle_service_status(self, parameters: dict) -> str:
|
| 504 |
+
service = parameters.get("service", "")
|
| 505 |
+
svc = self.services.get(service)
|
| 506 |
+
if svc is None:
|
| 507 |
+
return f"Service '{service}' not found in this environment."
|
| 508 |
+
return self._format_service_status(svc)
|
| 509 |
+
|
| 510 |
+
def _handle_query_logs(self, parameters: dict) -> str:
|
| 511 |
+
service = parameters.get("service", "")
|
| 512 |
+
query = parameters.get("query", "").lower()
|
| 513 |
+
entries = self.log_entries.get(service)
|
| 514 |
+
if entries is None:
|
| 515 |
+
return f"No logs available for service '{service}'."
|
| 516 |
+
if query:
|
| 517 |
+
matched = [
|
| 518 |
+
e
|
| 519 |
+
for e in entries
|
| 520 |
+
if query in e["message"].lower()
|
| 521 |
+
or query in e.get("level", "").lower()
|
| 522 |
+
or query in e.get("source", "").lower()
|
| 523 |
+
]
|
| 524 |
+
else:
|
| 525 |
+
matched = entries
|
| 526 |
+
if not matched:
|
| 527 |
+
return f"No matching log entries found for '{service}' with query '{query}'."
|
| 528 |
+
return self._format_logs(matched)
|
| 529 |
+
|
| 530 |
+
def _handle_query_metrics(self, parameters: dict) -> str:
|
| 531 |
+
service = parameters.get("service", "")
|
| 532 |
+
metric = parameters.get("metric", "").lower()
|
| 533 |
+
svc_metrics = self.metrics.get(service)
|
| 534 |
+
if svc_metrics is None:
|
| 535 |
+
return f"No metrics available for service '{service}'."
|
| 536 |
+
for name, data in svc_metrics.items():
|
| 537 |
+
if metric in name.lower() or name.lower() in metric:
|
| 538 |
+
return self._format_metrics(name, data)
|
| 539 |
+
return f"No matching metric '{metric}' for service '{service}'."
|
| 540 |
+
|
| 541 |
+
def _handle_dependency_map(self, parameters: dict) -> str:
|
| 542 |
+
service = parameters.get("service", "")
|
| 543 |
+
if service and service in self.dependency_map:
|
| 544 |
+
subset = {service: self.dependency_map[service]}
|
| 545 |
+
return self._format_dependency_map(subset)
|
| 546 |
+
return self._format_dependency_map(self.dependency_map)
|
| 547 |
+
|
| 548 |
+
def _handle_runbook(self, parameters: dict) -> str:
|
| 549 |
+
topic = parameters.get("topic", "").lower()
|
| 550 |
+
for key, value in self.runbooks.items():
|
| 551 |
+
if topic and topic in key.lower():
|
| 552 |
+
return self._format_runbook(value)
|
| 553 |
+
return self._format_runbook("")
|
| 554 |
+
|
| 555 |
+
def _handle_recent_changes(self, parameters: dict) -> str:
|
| 556 |
+
service = parameters.get("service", "")
|
| 557 |
+
if service and service in self.recent_changes:
|
| 558 |
+
return self._format_changes(self.recent_changes[service])
|
| 559 |
+
# No service specified — return all
|
| 560 |
+
return self._format_changes(self.recent_changes.get("", []))
|
server/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Server package."""
|
server/app.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI application for the Sentinel Environment."""
|
| 2 |
+
|
| 3 |
+
from openenv.core.env_server import create_app
|
| 4 |
+
|
| 5 |
+
from models import SentinelAction, SentinelObservation
|
| 6 |
+
from server.sentinel_environment import SentinelEnvironment
|
| 7 |
+
|
| 8 |
+
app = create_app(
|
| 9 |
+
SentinelEnvironment,
|
| 10 |
+
SentinelAction,
|
| 11 |
+
SentinelObservation,
|
| 12 |
+
env_name="sentinel_env",
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def main():
|
| 17 |
+
"""Entry point for direct execution."""
|
| 18 |
+
import uvicorn
|
| 19 |
+
|
| 20 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
if __name__ == "__main__":
|
| 24 |
+
main()
|
server/requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openenv-core>=0.2.3
|
| 2 |
+
fastapi>=0.104.0
|
| 3 |
+
uvicorn>=0.24.0
|
| 4 |
+
pydantic>=2.0.0
|
server/sentinel_environment.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SentinelEnvironment — core OpenEnv Environment for SRE incident triage."""
|
| 2 |
+
|
| 3 |
+
import uuid
|
| 4 |
+
from typing import Any, Optional
|
| 5 |
+
|
| 6 |
+
from openenv.core.env_server.interfaces import Environment
|
| 7 |
+
from openenv.core.env_server.types import Observation
|
| 8 |
+
|
| 9 |
+
from grading.grader import grade
|
| 10 |
+
from grading.rewards import compute_step_reward, _call_signature, _is_relevant
|
| 11 |
+
from models import SentinelAction, SentinelObservation, SentinelState
|
| 12 |
+
from scenarios.task1_smoking_gun import SmokingGunScenario
|
| 13 |
+
from scenarios.task2_upstream_culprit import UpstreamCulpritScenario
|
| 14 |
+
from scenarios.task3_cascading_failure import CascadingFailureScenario
|
| 15 |
+
from tools.registry import AVAILABLE_TOOLS, dispatch, make_relevance_key
|
| 16 |
+
|
| 17 |
+
MAX_STEPS = 20
|
| 18 |
+
MAX_CONSECUTIVE_INVALID = 5
|
| 19 |
+
|
| 20 |
+
SCENARIOS = {
|
| 21 |
+
1: SmokingGunScenario,
|
| 22 |
+
2: UpstreamCulpritScenario,
|
| 23 |
+
3: CascadingFailureScenario,
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
TASK_NAMES = {
|
| 27 |
+
1: "The Smoking Gun",
|
| 28 |
+
2: "The Upstream Culprit",
|
| 29 |
+
3: "The Cascading Failure",
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class SentinelEnvironment(Environment):
|
| 34 |
+
"""OpenEnv Environment for SRE incident triage."""
|
| 35 |
+
|
| 36 |
+
SUPPORTS_CONCURRENT_SESSIONS = True
|
| 37 |
+
|
| 38 |
+
def __init__(self) -> None:
|
| 39 |
+
super().__init__()
|
| 40 |
+
self._scenario = None
|
| 41 |
+
self._state = SentinelState()
|
| 42 |
+
self._cumulative_reward: float = 0.0
|
| 43 |
+
self._previous_calls: list[str] = []
|
| 44 |
+
self._consecutive_invalid: int = 0
|
| 45 |
+
|
| 46 |
+
# ── helpers ──────────────────────────────────────────────────────
|
| 47 |
+
|
| 48 |
+
def _make_obs(
|
| 49 |
+
self,
|
| 50 |
+
*,
|
| 51 |
+
tool_output: str = "",
|
| 52 |
+
error: str = "",
|
| 53 |
+
done: bool = False,
|
| 54 |
+
reward: Optional[float] = None,
|
| 55 |
+
tool_descriptions: Optional[dict] = None,
|
| 56 |
+
) -> SentinelObservation:
|
| 57 |
+
return SentinelObservation(
|
| 58 |
+
incident_summary=self._scenario.get_incident_summary() if self._scenario else "",
|
| 59 |
+
tool_output=tool_output,
|
| 60 |
+
available_tools=AVAILABLE_TOOLS,
|
| 61 |
+
step_number=self._state.step_count,
|
| 62 |
+
max_steps=MAX_STEPS,
|
| 63 |
+
cumulative_reward=self._cumulative_reward,
|
| 64 |
+
last_action_error=error,
|
| 65 |
+
done=done,
|
| 66 |
+
reward=reward,
|
| 67 |
+
tool_descriptions=tool_descriptions or {},
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
def _handle_submit(self, params: dict, step_num: int) -> SentinelObservation:
|
| 71 |
+
required = ("root_cause", "affected_service", "recommendation")
|
| 72 |
+
missing = [f for f in required if not params.get(f)]
|
| 73 |
+
if missing:
|
| 74 |
+
return self._make_obs(
|
| 75 |
+
error=f"submit_resolution requires: {', '.join(missing)}",
|
| 76 |
+
done=False,
|
| 77 |
+
reward=0.0,
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
self._consecutive_invalid = 0
|
| 81 |
+
result = grade(self._scenario, params, step_num)
|
| 82 |
+
self._state.resolution_submitted = True
|
| 83 |
+
self._state.root_cause_correct = result["root_cause_correct"]
|
| 84 |
+
self._state.recommendation_correct = result["recommendation_correct"]
|
| 85 |
+
self._state.final_score = result["score"]
|
| 86 |
+
self._state.tools_called.append("submit_resolution")
|
| 87 |
+
|
| 88 |
+
return self._make_obs(
|
| 89 |
+
tool_output=f"Resolution graded. Score: {result['score']:.2f}",
|
| 90 |
+
done=True,
|
| 91 |
+
reward=result["score"],
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# ── Environment interface ────────────────────────────────────────
|
| 95 |
+
|
| 96 |
+
def reset(
|
| 97 |
+
self,
|
| 98 |
+
seed: Optional[int] = None,
|
| 99 |
+
episode_id: Optional[str] = None,
|
| 100 |
+
**kwargs: Any,
|
| 101 |
+
) -> SentinelObservation:
|
| 102 |
+
task_id = kwargs.get("task_id", 1)
|
| 103 |
+
if task_id not in SCENARIOS:
|
| 104 |
+
task_id = 1
|
| 105 |
+
|
| 106 |
+
self._scenario = SCENARIOS[task_id]()
|
| 107 |
+
self._cumulative_reward = 0.0
|
| 108 |
+
self._previous_calls = []
|
| 109 |
+
self._consecutive_invalid = 0
|
| 110 |
+
|
| 111 |
+
self._state = SentinelState(
|
| 112 |
+
episode_id=episode_id or str(uuid.uuid4()),
|
| 113 |
+
step_count=0,
|
| 114 |
+
task_id=task_id,
|
| 115 |
+
task_name=TASK_NAMES[task_id],
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
return self._make_obs(
|
| 119 |
+
done=False,
|
| 120 |
+
reward=None,
|
| 121 |
+
tool_descriptions=self._scenario.get_tool_descriptions(),
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
def step(
|
| 125 |
+
self,
|
| 126 |
+
action: SentinelAction,
|
| 127 |
+
timeout_s: Optional[float] = None,
|
| 128 |
+
**kwargs: Any,
|
| 129 |
+
) -> SentinelObservation:
|
| 130 |
+
if self._scenario is None:
|
| 131 |
+
return SentinelObservation(
|
| 132 |
+
incident_summary="",
|
| 133 |
+
last_action_error="Environment not reset. Call reset() first.",
|
| 134 |
+
done=True,
|
| 135 |
+
reward=0.0,
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
self._state.step_count += 1
|
| 139 |
+
step_num = self._state.step_count
|
| 140 |
+
|
| 141 |
+
tool_name = action.tool_name
|
| 142 |
+
params = action.param_dict()
|
| 143 |
+
|
| 144 |
+
# ── submit_resolution ───────────────────────────────────────
|
| 145 |
+
if tool_name == "submit_resolution":
|
| 146 |
+
return self._handle_submit(params, step_num)
|
| 147 |
+
|
| 148 |
+
# ── regular tool dispatch ───────────────────────────────────
|
| 149 |
+
output, is_valid = dispatch(tool_name, params, self._scenario)
|
| 150 |
+
|
| 151 |
+
if not is_valid:
|
| 152 |
+
self._consecutive_invalid += 1
|
| 153 |
+
else:
|
| 154 |
+
self._consecutive_invalid = 0
|
| 155 |
+
|
| 156 |
+
# compute reward
|
| 157 |
+
reward = compute_step_reward(
|
| 158 |
+
tool_name,
|
| 159 |
+
params,
|
| 160 |
+
is_valid,
|
| 161 |
+
self._scenario.get_relevant_tools(),
|
| 162 |
+
self._previous_calls,
|
| 163 |
+
)
|
| 164 |
+
self._cumulative_reward += reward
|
| 165 |
+
|
| 166 |
+
# track call
|
| 167 |
+
sig = _call_signature(tool_name, params)
|
| 168 |
+
self._previous_calls.append(sig)
|
| 169 |
+
self._state.tools_called.append(f"{tool_name}({params})")
|
| 170 |
+
|
| 171 |
+
# track relevant
|
| 172 |
+
if is_valid and _is_relevant(tool_name, params, self._scenario.get_relevant_tools()):
|
| 173 |
+
self._state.relevant_tools_called.append(f"{tool_name}({params})")
|
| 174 |
+
|
| 175 |
+
# ── check termination ───────────────────────────────────────
|
| 176 |
+
done = False
|
| 177 |
+
error_msg = "" if is_valid else output
|
| 178 |
+
|
| 179 |
+
if self._consecutive_invalid >= MAX_CONSECUTIVE_INVALID:
|
| 180 |
+
done = True
|
| 181 |
+
output = f"{output}\n\nEpisode terminated: {MAX_CONSECUTIVE_INVALID} consecutive invalid actions."
|
| 182 |
+
self._state.final_score = 0.0
|
| 183 |
+
|
| 184 |
+
if step_num >= MAX_STEPS:
|
| 185 |
+
done = True
|
| 186 |
+
output = f"{output}\n\nEpisode terminated: maximum steps ({MAX_STEPS}) reached."
|
| 187 |
+
self._state.final_score = 0.0
|
| 188 |
+
|
| 189 |
+
return self._make_obs(
|
| 190 |
+
tool_output=output,
|
| 191 |
+
error=error_msg,
|
| 192 |
+
done=done,
|
| 193 |
+
reward=reward,
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
@property
|
| 197 |
+
def state(self) -> SentinelState:
|
| 198 |
+
return self._state
|
tests/__init__.py
ADDED
|
File without changes
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared fixtures for Sentinel tests."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from scenarios.task1_smoking_gun import SmokingGunScenario
|
| 6 |
+
from scenarios.task2_upstream_culprit import UpstreamCulpritScenario
|
| 7 |
+
from scenarios.task3_cascading_failure import CascadingFailureScenario
|
| 8 |
+
from server.sentinel_environment import SentinelEnvironment
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@pytest.fixture
|
| 12 |
+
def task1():
|
| 13 |
+
return SmokingGunScenario()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@pytest.fixture
|
| 17 |
+
def task2():
|
| 18 |
+
return UpstreamCulpritScenario()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.fixture
|
| 22 |
+
def task3():
|
| 23 |
+
return CascadingFailureScenario()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@pytest.fixture
|
| 27 |
+
def env():
|
| 28 |
+
return SentinelEnvironment()
|
tests/test_environment.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for SentinelEnvironment reset, step, submit, and termination."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from models import SentinelAction
|
| 6 |
+
from server.sentinel_environment import SentinelEnvironment, MAX_STEPS, MAX_CONSECUTIVE_INVALID
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _action(tool_name: str, **params):
|
| 10 |
+
return SentinelAction.model_validate({"tool_name": tool_name, "parameters": params})
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class TestReset:
|
| 14 |
+
def test_reset_returns_observation(self, env):
|
| 15 |
+
obs = env.reset(task_id=1)
|
| 16 |
+
assert obs.done is False
|
| 17 |
+
assert obs.incident_summary != ""
|
| 18 |
+
assert obs.step_number == 0
|
| 19 |
+
|
| 20 |
+
def test_reset_includes_tool_descriptions(self, env):
|
| 21 |
+
obs = env.reset(task_id=1)
|
| 22 |
+
assert obs.tool_descriptions != {}
|
| 23 |
+
assert "query_logs" in obs.tool_descriptions
|
| 24 |
+
|
| 25 |
+
def test_reset_invalid_task_defaults_to_1(self, env):
|
| 26 |
+
obs = env.reset(task_id=999)
|
| 27 |
+
assert obs.done is False
|
| 28 |
+
assert env.state.task_id == 1
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class TestStep:
|
| 32 |
+
def test_step_without_reset(self, env):
|
| 33 |
+
action = _action("get_service_status", service="auth")
|
| 34 |
+
obs = env.step(action)
|
| 35 |
+
assert obs.done is True
|
| 36 |
+
assert "not reset" in obs.last_action_error.lower()
|
| 37 |
+
|
| 38 |
+
def test_valid_step_returns_output(self, env):
|
| 39 |
+
env.reset(task_id=1)
|
| 40 |
+
action = _action("get_service_status", service="payment-api")
|
| 41 |
+
obs = env.step(action)
|
| 42 |
+
assert obs.tool_output != ""
|
| 43 |
+
assert obs.done is False
|
| 44 |
+
assert obs.step_number == 1
|
| 45 |
+
|
| 46 |
+
def test_step_no_tool_descriptions(self, env):
|
| 47 |
+
env.reset(task_id=1)
|
| 48 |
+
action = _action("get_service_status", service="payment-api")
|
| 49 |
+
obs = env.step(action)
|
| 50 |
+
assert obs.tool_descriptions == {}
|
| 51 |
+
|
| 52 |
+
def test_invalid_tool(self, env):
|
| 53 |
+
env.reset(task_id=1)
|
| 54 |
+
# Use a raw dict bypass since pydantic rejects unknown tools
|
| 55 |
+
# Instead, test via unknown service which is still valid dispatch
|
| 56 |
+
action = _action("get_service_status", service="nonexistent")
|
| 57 |
+
obs = env.step(action)
|
| 58 |
+
assert obs.done is False # valid tool, just unknown service
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class TestSubmit:
|
| 62 |
+
def test_submit_resolution_grades(self, env):
|
| 63 |
+
env.reset(task_id=1)
|
| 64 |
+
action = _action(
|
| 65 |
+
"submit_resolution",
|
| 66 |
+
root_cause="Missing DB_CONNECTION_STRING after v2.3.1 deploy",
|
| 67 |
+
affected_service="payment-api",
|
| 68 |
+
recommendation="Rollback to v2.3.0",
|
| 69 |
+
)
|
| 70 |
+
obs = env.step(action)
|
| 71 |
+
assert obs.done is True
|
| 72 |
+
assert obs.reward is not None
|
| 73 |
+
assert obs.reward > 0
|
| 74 |
+
|
| 75 |
+
def test_submit_missing_fields(self, env):
|
| 76 |
+
env.reset(task_id=1)
|
| 77 |
+
action = _action(
|
| 78 |
+
"submit_resolution",
|
| 79 |
+
root_cause="",
|
| 80 |
+
affected_service="",
|
| 81 |
+
recommendation="",
|
| 82 |
+
)
|
| 83 |
+
obs = env.step(action)
|
| 84 |
+
assert obs.last_action_error != ""
|
| 85 |
+
assert obs.done is False
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class TestTermination:
|
| 89 |
+
def test_max_steps(self, env):
|
| 90 |
+
env.reset(task_id=1)
|
| 91 |
+
for _ in range(MAX_STEPS):
|
| 92 |
+
action = _action("get_service_status", service="payment-api")
|
| 93 |
+
obs = env.step(action)
|
| 94 |
+
assert obs.done is True
|
| 95 |
+
assert "maximum steps" in obs.tool_output.lower()
|
| 96 |
+
|
| 97 |
+
def test_consecutive_invalid_not_triggered_by_valid(self, env):
|
| 98 |
+
env.reset(task_id=1)
|
| 99 |
+
for _ in range(MAX_CONSECUTIVE_INVALID + 1):
|
| 100 |
+
action = _action("get_service_status", service="payment-api")
|
| 101 |
+
obs = env.step(action)
|
| 102 |
+
assert obs.done is False # valid actions don't trigger termination
|
tests/test_grading.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for per-task grading: perfect scores, partial credit, wrong service, destructive penalty."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class TestTask1Grading:
|
| 7 |
+
def test_perfect_resolution(self, task1):
|
| 8 |
+
result = task1.grade_resolution(
|
| 9 |
+
{
|
| 10 |
+
"root_cause": "Missing DB_CONNECTION_STRING env var after deploy v2.3.1",
|
| 11 |
+
"affected_service": "payment-api",
|
| 12 |
+
"recommendation": "Rollback to v2.3.0 or set the DB_CONNECTION_STRING env var",
|
| 13 |
+
},
|
| 14 |
+
step_count=3,
|
| 15 |
+
)
|
| 16 |
+
assert result["score"] >= 0.80
|
| 17 |
+
|
| 18 |
+
def test_wrong_service(self, task1):
|
| 19 |
+
result = task1.grade_resolution(
|
| 20 |
+
{
|
| 21 |
+
"root_cause": "Missing DB_CONNECTION_STRING env var after deploy v2.3.1",
|
| 22 |
+
"affected_service": "order-service",
|
| 23 |
+
"recommendation": "Rollback",
|
| 24 |
+
},
|
| 25 |
+
step_count=3,
|
| 26 |
+
)
|
| 27 |
+
# Should lose the affected_service points
|
| 28 |
+
assert result["score"] <= 0.85
|
| 29 |
+
|
| 30 |
+
def test_empty_resolution(self, task1):
|
| 31 |
+
result = task1.grade_resolution(
|
| 32 |
+
{"root_cause": "", "affected_service": "", "recommendation": ""},
|
| 33 |
+
step_count=1,
|
| 34 |
+
)
|
| 35 |
+
assert result["score"] <= 0.20
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class TestTask2Grading:
|
| 39 |
+
def test_perfect_resolution(self, task2):
|
| 40 |
+
result = task2.grade_resolution(
|
| 41 |
+
{
|
| 42 |
+
"root_cause": "inventory-service OOM memory leak from batch processing causing checkout-service timeout",
|
| 43 |
+
"affected_service": "inventory-service",
|
| 44 |
+
"recommendation": "Increase memory limit to 1Gi and reduce batch size or stream results",
|
| 45 |
+
},
|
| 46 |
+
step_count=4,
|
| 47 |
+
)
|
| 48 |
+
assert result["score"] >= 0.70
|
| 49 |
+
|
| 50 |
+
def test_wrong_root_cause(self, task2):
|
| 51 |
+
result = task2.grade_resolution(
|
| 52 |
+
{
|
| 53 |
+
"root_cause": "Network partition",
|
| 54 |
+
"affected_service": "inventory-service",
|
| 55 |
+
"recommendation": "Restart networking",
|
| 56 |
+
},
|
| 57 |
+
step_count=3,
|
| 58 |
+
)
|
| 59 |
+
assert result["score"] < 0.40
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class TestTask3Grading:
|
| 63 |
+
def test_perfect_resolution(self, task3):
|
| 64 |
+
result = task3.grade_resolution(
|
| 65 |
+
{
|
| 66 |
+
"root_cause": "analytics-worker long-running query exhausted the connection pool, cascade to auth-service, user-profile-service, notification-service",
|
| 67 |
+
"affected_service": "postgres-primary",
|
| 68 |
+
"recommendation": "Kill the query and set statement_timeout, use read replica for analytics",
|
| 69 |
+
},
|
| 70 |
+
step_count=5,
|
| 71 |
+
)
|
| 72 |
+
assert result["score"] >= 0.70
|
| 73 |
+
|
| 74 |
+
def test_blames_notification_deploy(self, task3):
|
| 75 |
+
result = task3.grade_resolution(
|
| 76 |
+
{
|
| 77 |
+
"root_cause": "notification-service deploy v3.1 caused the failure",
|
| 78 |
+
"affected_service": "notification-service",
|
| 79 |
+
"recommendation": "Rollback notification-service",
|
| 80 |
+
},
|
| 81 |
+
step_count=3,
|
| 82 |
+
)
|
| 83 |
+
# Should score poorly — wrong root cause and wrong affected service
|
| 84 |
+
assert result["score"] <= 0.20
|
| 85 |
+
|
| 86 |
+
def test_partial_credit_pool_only(self, task3):
|
| 87 |
+
result = task3.grade_resolution(
|
| 88 |
+
{
|
| 89 |
+
"root_cause": "postgres connection pool exhausted and full",
|
| 90 |
+
"affected_service": "postgres-primary",
|
| 91 |
+
"recommendation": "Increase pool size",
|
| 92 |
+
},
|
| 93 |
+
step_count=3,
|
| 94 |
+
)
|
| 95 |
+
# Pool identified but not analytics-worker
|
| 96 |
+
assert 0.20 <= result["score"] <= 0.65
|
tests/test_models.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for typed action models and discriminated union."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from pydantic import ValidationError
|
| 5 |
+
|
| 6 |
+
from models import (
|
| 7 |
+
SentinelAction,
|
| 8 |
+
QueryLogsAction,
|
| 9 |
+
SubmitResolutionAction,
|
| 10 |
+
SentinelObservation,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class TestDeserialization:
|
| 15 |
+
def test_query_logs(self):
|
| 16 |
+
raw = {"tool_name": "query_logs", "parameters": {"service": "auth", "query": "error"}}
|
| 17 |
+
action = SentinelAction.model_validate(raw)
|
| 18 |
+
assert action.tool_name == "query_logs"
|
| 19 |
+
assert action.param_dict() == {"service": "auth", "query": "error", "severity": "all"}
|
| 20 |
+
|
| 21 |
+
def test_submit_resolution(self):
|
| 22 |
+
raw = {
|
| 23 |
+
"tool_name": "submit_resolution",
|
| 24 |
+
"parameters": {"root_cause": "x", "affected_service": "y", "recommendation": "z"},
|
| 25 |
+
}
|
| 26 |
+
action = SentinelAction.model_validate(raw)
|
| 27 |
+
assert action.param_dict()["root_cause"] == "x"
|
| 28 |
+
|
| 29 |
+
def test_get_dependency_map_defaults(self):
|
| 30 |
+
raw = {"tool_name": "get_dependency_map", "parameters": {}}
|
| 31 |
+
action = SentinelAction.model_validate(raw)
|
| 32 |
+
assert action.param_dict() == {"service": ""}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class TestInvalidRejection:
|
| 36 |
+
def test_unknown_tool_name(self):
|
| 37 |
+
raw = {"tool_name": "hack_server", "parameters": {}}
|
| 38 |
+
with pytest.raises(ValidationError):
|
| 39 |
+
SentinelAction.model_validate(raw)
|
| 40 |
+
|
| 41 |
+
def test_missing_required_param(self):
|
| 42 |
+
raw = {"tool_name": "query_logs", "parameters": {}}
|
| 43 |
+
with pytest.raises(ValidationError):
|
| 44 |
+
SentinelAction.model_validate(raw)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class TestDiscriminator:
|
| 48 |
+
def test_schema_has_discriminator(self):
|
| 49 |
+
schema = SentinelAction.model_json_schema()
|
| 50 |
+
assert "$defs" in schema
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class TestObservation:
|
| 54 |
+
def test_tool_descriptions_default_empty(self):
|
| 55 |
+
obs = SentinelObservation()
|
| 56 |
+
assert obs.tool_descriptions == {}
|
| 57 |
+
|
| 58 |
+
def test_tool_descriptions_populated(self):
|
| 59 |
+
obs = SentinelObservation(tool_descriptions={"query_logs": {"services": ["a"]}})
|
| 60 |
+
assert "query_logs" in obs.tool_descriptions
|
tests/test_tools.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for tool dispatch and make_relevance_key."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from tools.registry import dispatch, make_relevance_key
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class TestMakeRelevanceKey:
|
| 9 |
+
def test_tool_with_service(self):
|
| 10 |
+
assert make_relevance_key("query_logs", {"service": "auth"}) == "query_logs:auth"
|
| 11 |
+
|
| 12 |
+
def test_tool_with_service_and_metric(self):
|
| 13 |
+
key = make_relevance_key("query_metrics", {"service": "pg", "metric": "cpu"})
|
| 14 |
+
assert key == "query_metrics:pg:cpu"
|
| 15 |
+
|
| 16 |
+
def test_tool_with_topic(self):
|
| 17 |
+
key = make_relevance_key("consult_runbook", {"topic": "connection pool"})
|
| 18 |
+
assert key == "consult_runbook:connection pool"
|
| 19 |
+
|
| 20 |
+
def test_tool_no_params(self):
|
| 21 |
+
assert make_relevance_key("check_recent_changes", {}) == "check_recent_changes"
|
| 22 |
+
|
| 23 |
+
def test_tool_empty_service(self):
|
| 24 |
+
assert make_relevance_key("get_dependency_map", {"service": ""}) == "get_dependency_map"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class TestDispatch:
|
| 28 |
+
def test_unknown_tool(self, task1):
|
| 29 |
+
output, is_valid = dispatch("hack_server", {}, task1)
|
| 30 |
+
assert is_valid is False
|
| 31 |
+
assert "Unknown tool" in output
|
| 32 |
+
|
| 33 |
+
def test_valid_tool(self, task1):
|
| 34 |
+
output, is_valid = dispatch("get_service_status", {"service": "payment-api"}, task1)
|
| 35 |
+
assert is_valid is True
|
| 36 |
+
assert output != ""
|
| 37 |
+
|
| 38 |
+
def test_submit_resolution_passthrough(self, task1):
|
| 39 |
+
output, is_valid = dispatch("submit_resolution", {}, task1)
|
| 40 |
+
assert is_valid is True
|
| 41 |
+
assert output == ""
|
tools/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Tool registry package."""
|
tools/registry.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tool registry — validates and dispatches tool calls to the active scenario."""
|
| 2 |
+
|
| 3 |
+
from typing import Tuple
|
| 4 |
+
|
| 5 |
+
from scenarios.base import BaseScenario
|
| 6 |
+
|
| 7 |
+
AVAILABLE_TOOLS = [
|
| 8 |
+
"query_logs",
|
| 9 |
+
"query_metrics",
|
| 10 |
+
"get_service_status",
|
| 11 |
+
"get_dependency_map",
|
| 12 |
+
"consult_runbook",
|
| 13 |
+
"check_recent_changes",
|
| 14 |
+
"submit_resolution",
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def make_relevance_key(tool_name: str, params: dict) -> str:
|
| 19 |
+
"""Build a colon-joined relevance key from tool name and significant params.
|
| 20 |
+
|
| 21 |
+
Examples:
|
| 22 |
+
make_relevance_key("query_logs", {"service": "auth"}) -> "query_logs:auth"
|
| 23 |
+
make_relevance_key("get_dependency_map", {}) -> "get_dependency_map"
|
| 24 |
+
"""
|
| 25 |
+
parts = [tool_name]
|
| 26 |
+
for k in ("service", "metric", "topic"):
|
| 27 |
+
v = params.get(k)
|
| 28 |
+
if v:
|
| 29 |
+
parts.append(str(v))
|
| 30 |
+
return ":".join(parts)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def dispatch(tool_name: str, params: dict, scenario: BaseScenario) -> Tuple[str, bool]:
|
| 34 |
+
"""Dispatch a tool call to the scenario.
|
| 35 |
+
|
| 36 |
+
Returns (output_text, is_valid).
|
| 37 |
+
is_valid=False means the tool_name was unknown or parameters were malformed.
|
| 38 |
+
"""
|
| 39 |
+
if tool_name not in AVAILABLE_TOOLS:
|
| 40 |
+
return (
|
| 41 |
+
f"Unknown tool '{tool_name}'. Available tools: {', '.join(AVAILABLE_TOOLS)}",
|
| 42 |
+
False,
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
if tool_name == "submit_resolution":
|
| 46 |
+
# submit_resolution is handled by the environment directly, not here
|
| 47 |
+
return ("", True)
|
| 48 |
+
|
| 49 |
+
return (scenario.get_tool_response(tool_name, params), True)
|