KaushikSarveswaran commited on
Commit
33dd3ee
·
0 Parent(s):

Initial submission: OpenEnv-Sentinel SRE triage environment

Browse files
.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)