Pranav Dhiran commited on
Commit
44c4c2d
·
0 Parent(s):

feat: SRE Incident Response OpenEnv v1.0.0

Browse files
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .env
5
+ .venv/
6
+ venv/
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ .pytest_cache/
11
+ *.log
12
+ baseline_results.json
13
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SRE Incident Response — OpenEnv Environment
2
+ # Dockerfile for Hugging Face Spaces
3
+ # Build: docker build -t sre-incident-env .
4
+ # Run: docker run -p 7860:7860 sre-incident-env
5
+
6
+ FROM python:3.11-slim
7
+
8
+ LABEL maintainer="SRE Incident Response Environment"
9
+ LABEL description="OpenEnv SRE Incident Response RL Environment"
10
+ LABEL org.opencontainers.image.title="sre-incident-response"
11
+ LABEL org.opencontainers.image.version="1.0.0"
12
+
13
+ # System deps
14
+ RUN apt-get update && apt-get install -y --no-install-recommends \
15
+ curl \
16
+ && rm -rf /var/lib/apt/lists/*
17
+
18
+ # Create non-root user (HF Spaces requirement)
19
+ RUN useradd -m -u 1000 appuser
20
+
21
+ WORKDIR /app
22
+
23
+ # Install Python dependencies
24
+ COPY requirements.txt .
25
+ RUN pip install --no-cache-dir -r requirements.txt
26
+
27
+ # Copy application code
28
+ COPY app/ ./app/
29
+ COPY openenv.yaml .
30
+ COPY baseline.py .
31
+
32
+ # Ensure correct ownership
33
+ RUN chown -R appuser:appuser /app
34
+
35
+ USER appuser
36
+
37
+ # HF Spaces uses port 7860
38
+ EXPOSE 7860
39
+
40
+ # Health check
41
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
42
+ CMD curl -f http://localhost:7860/health || exit 1
43
+
44
+ # Start the server
45
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
README.md ADDED
Binary file (13.8 kB). View file
 
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # SRE Incident Response — OpenEnv Environment
app/environment.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Session manager for the SRE Incident Response environment.
3
+ Manages per-session state, step processing, and reward accumulation.
4
+ """
5
+
6
+ import uuid
7
+ import copy
8
+ from typing import Dict, Optional, Tuple
9
+ from datetime import datetime, timezone
10
+
11
+ from app.models import (
12
+ Observation, Action, Reward, StepResponse,
13
+ StateResponse, GraderResponse,
14
+ )
15
+ from app.tasks.base import AVAILABLE_ACTIONS
16
+ from app.tasks import TASK_REGISTRY
17
+
18
+
19
+ class Session:
20
+ """In-memory session state for one episode."""
21
+
22
+ def __init__(self, task_id: str, seed: int = 42):
23
+ self.session_id = str(uuid.uuid4())
24
+ self.task_id = task_id
25
+ self.step = 0
26
+ self.done = False
27
+ self.total_reward = 0.0
28
+ self.action_history: list = []
29
+ self.created_at = datetime.now(timezone.utc).isoformat()
30
+
31
+ task = TASK_REGISTRY[task_id]
32
+ self.world_state = task.initial_state(seed=seed)
33
+
34
+ def to_state_response(self, grader_score: Optional[float] = None) -> StateResponse:
35
+ return StateResponse(
36
+ session_id=self.session_id,
37
+ task_id=self.task_id,
38
+ step=self.step,
39
+ done=self.done,
40
+ total_reward=round(self.total_reward, 4),
41
+ world_state=_serialize_state(self.world_state),
42
+ action_history=self.action_history,
43
+ grader_score=grader_score,
44
+ )
45
+
46
+
47
+ def _serialize_state(state: dict) -> dict:
48
+ """Make world_state JSON-serializable (convert sets, etc.)."""
49
+ result = {}
50
+ for k, v in state.items():
51
+ if k.startswith("_"):
52
+ continue # hide internal fields
53
+ if isinstance(v, set):
54
+ result[k] = list(v)
55
+ elif isinstance(v, dict):
56
+ result[k] = _serialize_state(v)
57
+ else:
58
+ result[k] = v
59
+ return result
60
+
61
+
62
+ class EnvironmentManager:
63
+ """Manages all active sessions."""
64
+
65
+ def __init__(self):
66
+ self._sessions: Dict[str, Session] = {}
67
+
68
+ def reset(self, task_id: str, seed: int = 42) -> Tuple[Observation, str]:
69
+ """Start a new episode. Returns (initial_observation, session_id)."""
70
+ if task_id not in TASK_REGISTRY:
71
+ raise ValueError(f"Unknown task_id '{task_id}'. Valid: {list(TASK_REGISTRY.keys())}")
72
+
73
+ session = Session(task_id=task_id, seed=seed)
74
+ self._sessions[session.session_id] = session
75
+
76
+ task = TASK_REGISTRY[task_id]
77
+ obs = task.get_observation(session.world_state, session.session_id, step=0)
78
+ obs.message = (
79
+ f"New episode started. Task: {task.name} (difficulty: {task.difficulty}). "
80
+ f"Max steps: {task.max_steps}. Investigate the incident and resolve it."
81
+ )
82
+ return obs, session.session_id
83
+
84
+ def step(self, session_id: str, action: Action) -> StepResponse:
85
+ """Process one action and return (observation, reward, done, info)."""
86
+ if session_id not in self._sessions:
87
+ raise KeyError(f"Session '{session_id}' not found. Call /reset first.")
88
+
89
+ session = self._sessions[session_id]
90
+
91
+ if session.done:
92
+ task = TASK_REGISTRY[session.task_id]
93
+ obs = task.get_observation(session.world_state, session_id, session.step)
94
+ obs.message = "Episode already complete. Call /reset to start a new episode."
95
+ return StepResponse(
96
+ observation=obs,
97
+ reward=Reward(value=0.0, cumulative=session.total_reward, breakdown={},
98
+ message="Episode already complete."),
99
+ done=True,
100
+ info={"episode_complete": True},
101
+ )
102
+
103
+ task = TASK_REGISTRY[session.task_id]
104
+ session.step += 1
105
+
106
+ # Check max steps
107
+ if session.step > task.max_steps:
108
+ session.done = True
109
+ obs = task.get_observation(session.world_state, session_id, session.step)
110
+ obs.message = f"Episode ended: max steps ({task.max_steps}) reached without resolution."
111
+ timeout_reward = -0.10
112
+ session.total_reward += timeout_reward
113
+ return StepResponse(
114
+ observation=obs,
115
+ reward=Reward(
116
+ value=timeout_reward,
117
+ cumulative=round(session.total_reward, 4),
118
+ breakdown={"timeout_penalty": timeout_reward},
119
+ message="Max steps reached.",
120
+ ),
121
+ done=True,
122
+ info={"timeout": True, "steps": session.step},
123
+ )
124
+
125
+ # Process action
126
+ new_state, step_reward, done, message = task.process_action(
127
+ action.action_type,
128
+ action.parameters,
129
+ session.world_state,
130
+ )
131
+ session.world_state = new_state
132
+ session.done = done
133
+ session.total_reward += step_reward
134
+
135
+ # Record history
136
+ session.action_history.append({
137
+ "step": session.step,
138
+ "action_type": action.action_type,
139
+ "parameters": action.parameters,
140
+ "reward": round(step_reward, 4),
141
+ "message_preview": message[:120] if message else "",
142
+ })
143
+
144
+ # Build observation
145
+ obs = task.get_observation(session.world_state, session_id, session.step)
146
+ obs.message = message
147
+
148
+ # Grade for info
149
+ score, grade_breakdown = task.grade(session.world_state, session.action_history)
150
+
151
+ reward_obj = Reward(
152
+ value=round(step_reward, 4),
153
+ cumulative=round(session.total_reward, 4),
154
+ breakdown=_build_reward_breakdown(action.action_type, step_reward),
155
+ message=f"Step reward: {step_reward:+.3f} | Cumulative: {session.total_reward:+.3f}",
156
+ )
157
+
158
+ return StepResponse(
159
+ observation=obs,
160
+ reward=reward_obj,
161
+ done=done,
162
+ info={
163
+ "step": session.step,
164
+ "max_steps": task.max_steps,
165
+ "grader_score": score,
166
+ "episode_complete": done,
167
+ },
168
+ )
169
+
170
+ def get_state(self, session_id: str) -> StateResponse:
171
+ if session_id not in self._sessions:
172
+ raise KeyError(f"Session '{session_id}' not found.")
173
+ session = self._sessions[session_id]
174
+ task = TASK_REGISTRY[session.task_id]
175
+ score, _ = task.grade(session.world_state, session.action_history)
176
+ return session.to_state_response(grader_score=score)
177
+
178
+ def grade(self, session_id: str) -> GraderResponse:
179
+ if session_id not in self._sessions:
180
+ raise KeyError(f"Session '{session_id}' not found.")
181
+ session = self._sessions[session_id]
182
+ task = TASK_REGISTRY[session.task_id]
183
+ score, breakdown = task.grade(session.world_state, session.action_history)
184
+
185
+ passing = score >= task.passing_score
186
+ return GraderResponse(
187
+ session_id=session_id,
188
+ task_id=session.task_id,
189
+ score=score,
190
+ breakdown=breakdown,
191
+ episode_complete=session.done,
192
+ steps_taken=session.step,
193
+ message=(
194
+ f"Score: {score:.4f} | "
195
+ f"{'PASS' if passing else 'FAIL'} "
196
+ f"(threshold: {task.passing_score}) | "
197
+ f"Steps: {session.step}/{task.max_steps}"
198
+ ),
199
+ )
200
+
201
+ def cleanup_session(self, session_id: str) -> bool:
202
+ if session_id in self._sessions:
203
+ del self._sessions[session_id]
204
+ return True
205
+ return False
206
+
207
+ def active_sessions(self) -> int:
208
+ return len(self._sessions)
209
+
210
+
211
+ def _build_reward_breakdown(action_type: str, value: float) -> Dict[str, float]:
212
+ if value > 0:
213
+ return {f"{action_type}_reward": round(value, 4)}
214
+ elif value < 0:
215
+ return {f"{action_type}_penalty": round(value, 4)}
216
+ return {}
app/main.py ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SRE Incident Response — OpenEnv Environment
3
+ ============================================
4
+ FastAPI application implementing the full OpenEnv spec.
5
+
6
+ Endpoints:
7
+ POST /reset — Start a new episode
8
+ POST /step — Take one action
9
+ GET /state — Get current session state
10
+ GET /tasks — List tasks and action schema
11
+ POST /grader — Get grader score for a session
12
+ POST /baseline — Run baseline agent on all tasks
13
+ GET /health — Health check
14
+ """
15
+
16
+ import os
17
+ import json
18
+ import asyncio
19
+ from typing import Optional, List
20
+ from contextlib import asynccontextmanager
21
+
22
+ from fastapi import FastAPI, HTTPException, Query, Body
23
+ from fastapi.middleware.cors import CORSMiddleware
24
+ from fastapi.responses import JSONResponse
25
+ from pydantic import BaseModel
26
+
27
+ from app.models import (
28
+ Observation, Action, Reward, StepResponse, ResetRequest,
29
+ StateResponse, TaskInfo, GraderResponse, BaselineResult,
30
+ )
31
+ from app.environment import EnvironmentManager
32
+ from app.tasks import TASK_REGISTRY
33
+ from app.tasks.base import ACTION_SCHEMA, AVAILABLE_ACTIONS
34
+
35
+
36
+ # ─── App Setup ───────────────────────────────────────────────────────────────
37
+
38
+ env_manager = EnvironmentManager()
39
+
40
+
41
+ @asynccontextmanager
42
+ async def lifespan(app: FastAPI):
43
+ print("SRE Incident Response Environment starting up...")
44
+ yield
45
+ print("Shutting down...")
46
+
47
+
48
+ app = FastAPI(
49
+ title="SRE Incident Response — OpenEnv Environment",
50
+ description=(
51
+ "An OpenEnv-compliant reinforcement learning environment where an AI agent "
52
+ "acts as an on-call Site Reliability Engineer. The agent receives production "
53
+ "incident alerts, investigates root causes using logs/metrics/configs, and "
54
+ "takes remediation actions to restore service health.\n\n"
55
+ "Three tasks of increasing difficulty: CPU spike investigation (easy), "
56
+ "database connection pool exhaustion (medium), and cascading service failure (hard)."
57
+ ),
58
+ version="1.0.0",
59
+ lifespan=lifespan,
60
+ docs_url="/docs",
61
+ redoc_url="/redoc",
62
+ )
63
+
64
+ app.add_middleware(
65
+ CORSMiddleware,
66
+ allow_origins=["*"],
67
+ allow_methods=["*"],
68
+ allow_headers=["*"],
69
+ )
70
+
71
+
72
+ # ─── Request/Response Models ─────────────────────────────────────────────────
73
+
74
+ class StepRequest(BaseModel):
75
+ session_id: str
76
+ action: Action
77
+
78
+
79
+ class GraderRequest(BaseModel):
80
+ session_id: str
81
+
82
+
83
+ class BaselineRequest(BaseModel):
84
+ model: str = "gpt-4o-mini"
85
+ max_steps: int = 12
86
+ tasks: List[str] = ["task1", "task2", "task3"]
87
+
88
+
89
+ # ─── OpenEnv Endpoints ───────────────────────────────────────────────────────
90
+
91
+ @app.get("/health")
92
+ async def health():
93
+ """Health check endpoint."""
94
+ return {
95
+ "status": "healthy",
96
+ "environment": "sre-incident-response",
97
+ "version": "1.0.0",
98
+ "active_sessions": env_manager.active_sessions(),
99
+ }
100
+
101
+
102
+ @app.post("/reset", response_model=Observation, tags=["OpenEnv"])
103
+ async def reset(request: ResetRequest = Body(...)):
104
+ """
105
+ Start a new episode.
106
+
107
+ Returns the initial observation for the specified task.
108
+ The session_id in the response must be passed to subsequent /step calls.
109
+
110
+ - **task_id**: One of `task1` (easy), `task2` (medium), `task3` (hard)
111
+ - **seed**: Optional random seed for reproducibility
112
+ """
113
+ try:
114
+ obs, session_id = env_manager.reset(
115
+ task_id=request.task_id,
116
+ seed=request.seed or 42,
117
+ )
118
+ return obs
119
+ except ValueError as e:
120
+ raise HTTPException(status_code=400, detail=str(e))
121
+
122
+
123
+ @app.post("/step", response_model=StepResponse, tags=["OpenEnv"])
124
+ async def step(request: StepRequest = Body(...)):
125
+ """
126
+ Take one action in the environment.
127
+
128
+ Returns observation, reward, done flag, and info dict.
129
+
130
+ Action types: `query_logs`, `check_metrics`, `restart_service`,
131
+ `rollback_deployment`, `scale_service`, `kill_query`, `acknowledge_alert`,
132
+ `examine_trace`, `check_config`, `resolve_incident`
133
+ """
134
+ try:
135
+ return env_manager.step(request.session_id, request.action)
136
+ except KeyError as e:
137
+ raise HTTPException(status_code=404, detail=str(e))
138
+ except Exception as e:
139
+ raise HTTPException(status_code=500, detail=f"Step error: {str(e)}")
140
+
141
+
142
+ @app.get("/state", response_model=StateResponse, tags=["OpenEnv"])
143
+ async def state(session_id: str = Query(..., description="Session ID from /reset")):
144
+ """
145
+ Get the full current state of a session (for debugging/grading).
146
+
147
+ Returns internal world state including hidden state variables and
148
+ action history. The grader_score field shows current progress.
149
+ """
150
+ try:
151
+ return env_manager.get_state(session_id)
152
+ except KeyError as e:
153
+ raise HTTPException(status_code=404, detail=str(e))
154
+
155
+
156
+ # ─── Additional Required Endpoints ───────────────────────────────────────────
157
+
158
+ @app.get("/tasks", tags=["Environment Info"])
159
+ async def list_tasks():
160
+ """
161
+ List all available tasks with descriptions, difficulty levels,
162
+ and the full action schema.
163
+ """
164
+ tasks_info = []
165
+ for task_id, task in TASK_REGISTRY.items():
166
+ tasks_info.append({
167
+ "task_id": task.task_id,
168
+ "name": task.name,
169
+ "description": task.description,
170
+ "difficulty": task.difficulty,
171
+ "max_steps": task.max_steps,
172
+ "passing_score": task.passing_score,
173
+ })
174
+
175
+ return {
176
+ "tasks": tasks_info,
177
+ "action_schema": ACTION_SCHEMA,
178
+ "available_actions": AVAILABLE_ACTIONS,
179
+ "observation_fields": [
180
+ "session_id", "task_id", "step", "timestamp",
181
+ "alerts", "services", "logs", "metrics",
182
+ "available_actions", "incident_resolved", "message",
183
+ "recent_deployments", "runbook_hints",
184
+ ],
185
+ "reward_range": [-999.0, 1.0],
186
+ }
187
+
188
+
189
+ @app.post("/grader", response_model=GraderResponse, tags=["Environment Info"])
190
+ async def grader(request: GraderRequest = Body(...)):
191
+ """
192
+ Get the current grader score for a session.
193
+
194
+ Graders are deterministic and can be called mid-episode or after completion.
195
+ Score range: 0.0 (no progress) to 1.0 (perfect solution).
196
+ """
197
+ try:
198
+ return env_manager.grade(request.session_id)
199
+ except KeyError as e:
200
+ raise HTTPException(status_code=404, detail=str(e))
201
+
202
+
203
+ @app.post("/baseline", tags=["Evaluation"])
204
+ async def baseline(request: BaselineRequest = Body(default=BaselineRequest())):
205
+ """
206
+ Run the baseline inference agent against all tasks.
207
+
208
+ Requires OPENAI_API_KEY environment variable.
209
+ Uses a ReAct-style prompting strategy with the specified model.
210
+ Returns per-task scores and episode logs.
211
+ """
212
+ api_key = os.environ.get("OPENAI_API_KEY")
213
+ if not api_key:
214
+ raise HTTPException(
215
+ status_code=400,
216
+ detail="OPENAI_API_KEY environment variable not set. "
217
+ "Set it to run the baseline agent.",
218
+ )
219
+
220
+ try:
221
+ results = await run_baseline_agent(
222
+ api_key=api_key,
223
+ model=request.model,
224
+ max_steps=request.max_steps,
225
+ task_ids=request.tasks,
226
+ )
227
+ return {
228
+ "model": request.model,
229
+ "results": results,
230
+ "summary": {
231
+ "mean_score": round(
232
+ sum(r["score"] for r in results) / len(results), 4
233
+ ) if results else 0.0,
234
+ "tasks_passed": sum(
235
+ 1 for r in results
236
+ if r["score"] >= TASK_REGISTRY[r["task_id"]].passing_score
237
+ ),
238
+ "total_tasks": len(results),
239
+ },
240
+ }
241
+ except Exception as e:
242
+ raise HTTPException(status_code=500, detail=f"Baseline error: {str(e)}")
243
+
244
+
245
+ # ─── Baseline Agent Logic ─────────────────────────────────────────────────────
246
+
247
+ SYSTEM_PROMPT = """You are an expert Site Reliability Engineer (SRE) responding to a production incident.
248
+ You will receive alerts, service statuses, and investigation results.
249
+ Your goal is to identify the root cause and resolve the incident efficiently.
250
+
251
+ At each step, respond with ONLY a valid JSON object in this exact format:
252
+ {"action_type": "<action>", "parameters": {<params>}}
253
+
254
+ Available actions:
255
+ - query_logs: {"service": "<name>"} — fetch logs for a service
256
+ - check_metrics: {"service": "<name>"} — get metrics for a service
257
+ - check_config: {"service": "<name>"} — inspect live config
258
+ - restart_service: {"service": "<name>"} — restart a service
259
+ - rollback_deployment: {"service": "<name>"} — roll back to previous version
260
+ - kill_query: {"source": "<service>"} — kill long-running DB queries from a source
261
+ - scale_service: {"service": "<name>", "replicas": <n>} — change replica count
262
+ - examine_trace: {"trace_id": "<id>"} — examine a distributed trace
263
+ - acknowledge_alert: {"alert_id": "<id>"} — acknowledge an alert
264
+ - resolve_incident: {} — mark incident resolved (use only when services are healthy)
265
+
266
+ Strategy:
267
+ 1. Assess the alerts and service statuses
268
+ 2. Investigate (query logs, check metrics) before acting
269
+ 3. Form a hypothesis about root cause
270
+ 4. Apply the minimal targeted fix
271
+ 5. Verify services recovered, then resolve_incident
272
+
273
+ Respond ONLY with JSON. No explanation. No markdown."""
274
+
275
+
276
+ def _format_observation(obs: dict) -> str:
277
+ """Format observation as a prompt string for the LLM."""
278
+ lines = [f"=== INCIDENT — Step {obs['step']} ===\n"]
279
+
280
+ # Alerts
281
+ lines.append("ACTIVE ALERTS:")
282
+ for alert in obs.get("alerts", []):
283
+ ack = " [ACK]" if alert.get("acknowledged") else ""
284
+ lines.append(f" [{alert['severity'].upper()}]{ack} {alert['service']}: {alert['message']}")
285
+
286
+ # Service statuses
287
+ lines.append("\nSERVICE STATUS:")
288
+ for name, svc in obs.get("services", {}).items():
289
+ conn_info = ""
290
+ if svc.get("connections") is not None:
291
+ conn_info = f" | connections: {svc['connections']}/{svc.get('max_connections', '?')}"
292
+ lines.append(
293
+ f" {name}: {svc['status'].upper()} | cpu: {svc['cpu_percent']:.1f}% | "
294
+ f"mem: {svc['memory_percent']:.1f}% | errors: {svc['error_rate']:.1f}/s | "
295
+ f"v{svc.get('version', '?')}{conn_info}"
296
+ )
297
+
298
+ # Recent deployments
299
+ if obs.get("recent_deployments"):
300
+ lines.append("\nRECENT DEPLOYMENTS:")
301
+ for dep in obs["recent_deployments"]:
302
+ lines.append(f" {dep['service']}: v{dep.get('previous','?')} → v{dep['version']} at {dep['deployed_at']}")
303
+
304
+ # Last action result
305
+ if obs.get("message"):
306
+ lines.append(f"\nLAST ACTION RESULT:\n{obs['message']}")
307
+
308
+ # Hints
309
+ if obs.get("runbook_hints"):
310
+ lines.append("\nRUNBOOK HINTS:")
311
+ for hint in obs["runbook_hints"]:
312
+ lines.append(f" • {hint}")
313
+
314
+ return "\n".join(lines)
315
+
316
+
317
+ async def run_baseline_agent(
318
+ api_key: str, model: str, max_steps: int, task_ids: list
319
+ ) -> list:
320
+ """Run a ReAct-style baseline agent against specified tasks."""
321
+ import httpx
322
+
323
+ base_url = "http://localhost:7860"
324
+ results = []
325
+
326
+ for task_id in task_ids:
327
+ if task_id not in TASK_REGISTRY:
328
+ continue
329
+
330
+ task = TASK_REGISTRY[task_id]
331
+ episode_log = []
332
+ score = 0.0
333
+ steps_taken = 0
334
+
335
+ try:
336
+ async with httpx.AsyncClient(timeout=60.0) as client:
337
+ # Reset
338
+ reset_resp = await client.post(
339
+ f"{base_url}/reset",
340
+ json={"task_id": task_id, "seed": 42},
341
+ )
342
+ obs = reset_resp.json()
343
+ session_id = obs["session_id"]
344
+ conversation = []
345
+ done = False
346
+
347
+ for step_num in range(max_steps):
348
+ obs_text = _format_observation(obs)
349
+ conversation.append({"role": "user", "content": obs_text})
350
+
351
+ # Call LLM
352
+ llm_response = await client.post(
353
+ "https://api.openai.com/v1/chat/completions",
354
+ headers={"Authorization": f"Bearer {api_key}"},
355
+ json={
356
+ "model": model,
357
+ "messages": [
358
+ {"role": "system", "content": SYSTEM_PROMPT},
359
+ *conversation[-6:], # keep last 3 turns
360
+ ],
361
+ "max_tokens": 200,
362
+ "temperature": 0.0,
363
+ },
364
+ )
365
+ llm_data = llm_response.json()
366
+ action_text = llm_data["choices"][0]["message"]["content"].strip()
367
+ conversation.append({"role": "assistant", "content": action_text})
368
+
369
+ # Parse action
370
+ try:
371
+ action_dict = json.loads(action_text)
372
+ except json.JSONDecodeError:
373
+ # Try to extract JSON from text
374
+ import re
375
+ match = re.search(r'\{.*\}', action_text, re.DOTALL)
376
+ if match:
377
+ action_dict = json.loads(match.group())
378
+ else:
379
+ action_dict = {"action_type": "acknowledge_alert",
380
+ "parameters": {"alert_id": "ALT-001"}}
381
+
382
+ action_type = action_dict.get("action_type", "")
383
+ parameters = action_dict.get("parameters", {})
384
+
385
+ # Step
386
+ step_resp = await client.post(
387
+ f"{base_url}/step",
388
+ json={
389
+ "session_id": session_id,
390
+ "action": {"action_type": action_type, "parameters": parameters},
391
+ },
392
+ )
393
+ step_data = step_resp.json()
394
+ obs = step_data["observation"]
395
+ done = step_data["done"]
396
+ steps_taken = step_num + 1
397
+
398
+ episode_log.append({
399
+ "step": step_num + 1,
400
+ "action": {"action_type": action_type, "parameters": parameters},
401
+ "reward": step_data["reward"]["value"],
402
+ "message_preview": obs.get("message", "")[:100],
403
+ })
404
+
405
+ if done:
406
+ break
407
+
408
+ # Get final grader score
409
+ grader_resp = await client.post(
410
+ f"{base_url}/grader",
411
+ json={"session_id": session_id},
412
+ )
413
+ grader_data = grader_resp.json()
414
+ score = grader_data["score"]
415
+
416
+ except Exception as e:
417
+ episode_log.append({"error": str(e)})
418
+
419
+ results.append({
420
+ "task_id": task_id,
421
+ "task_name": task.name,
422
+ "difficulty": task.difficulty,
423
+ "score": score,
424
+ "steps_taken": steps_taken,
425
+ "episode_log": episode_log,
426
+ "success": score >= task.passing_score,
427
+ })
428
+
429
+ return results
430
+
431
+
432
+ # ─── Entry Point ──────────────────────────────────────────────────────────────
433
+
434
+ if __name__ == "__main__":
435
+ import uvicorn
436
+ uvicorn.run("app.main:app", host="0.0.0.0", port=7860, reload=False)
app/models.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OpenEnv typed models for SRE Incident Response environment.
3
+ Complies with OpenEnv spec: Observation, Action, Reward as Pydantic models.
4
+ """
5
+
6
+ from pydantic import BaseModel, Field
7
+ from typing import Dict, List, Optional, Any, Literal
8
+ from datetime import datetime
9
+
10
+
11
+ # ─── Core Domain Models ──────────────────────────────────────────────────────
12
+
13
+ class ServiceStatus(BaseModel):
14
+ name: str
15
+ status: Literal["healthy", "degraded", "down", "unknown"]
16
+ cpu_percent: float = Field(..., ge=0.0, le=100.0)
17
+ memory_percent: float = Field(..., ge=0.0, le=100.0)
18
+ error_rate: float = Field(..., ge=0.0, description="Errors per second")
19
+ connections: Optional[int] = None
20
+ max_connections: Optional[int] = None
21
+ replicas: int = 1
22
+ version: str = "1.0.0"
23
+ tags: Dict[str, str] = {}
24
+
25
+
26
+ class Alert(BaseModel):
27
+ alert_id: str
28
+ severity: Literal["critical", "warning", "info"]
29
+ service: str
30
+ message: str
31
+ triggered_at: str
32
+ acknowledged: bool = False
33
+
34
+
35
+ class LogEntry(BaseModel):
36
+ timestamp: str
37
+ level: Literal["ERROR", "WARN", "INFO", "DEBUG"]
38
+ service: str
39
+ message: str
40
+ trace_id: Optional[str] = None
41
+
42
+
43
+ class MetricPoint(BaseModel):
44
+ name: str
45
+ value: float
46
+ unit: str
47
+ service: str
48
+ timestamp: str
49
+
50
+
51
+ # ─── OpenEnv Core Types ───────────────────────────────────────────────────────
52
+
53
+ class Observation(BaseModel):
54
+ """
55
+ The agent's view of the environment at each step.
56
+ Implements OpenEnv Observation spec.
57
+ """
58
+ session_id: str
59
+ task_id: str
60
+ step: int
61
+ timestamp: str
62
+
63
+ # Incident data (always visible)
64
+ alerts: List[Alert]
65
+ services: Dict[str, ServiceStatus]
66
+
67
+ # Queried data (only populated after agent investigates)
68
+ logs: List[LogEntry] = []
69
+ metrics: List[MetricPoint] = []
70
+
71
+ # Episode state
72
+ available_actions: List[str]
73
+ incident_resolved: bool = False
74
+ message: str = ""
75
+
76
+ # Contextual hints
77
+ recent_deployments: List[Dict[str, Any]] = []
78
+ runbook_hints: List[str] = []
79
+
80
+
81
+ class Action(BaseModel):
82
+ """
83
+ An action the agent can take in the environment.
84
+ Implements OpenEnv Action spec.
85
+
86
+ action_type options:
87
+ - query_logs: Fetch recent logs for a service
88
+ - check_metrics: Retrieve metrics for a service
89
+ - restart_service: Restart a named service
90
+ - rollback_deployment: Roll back a service to its previous version
91
+ - scale_service: Change replica count
92
+ - kill_query: Terminate a running database query from a named source
93
+ - acknowledge_alert: Acknowledge an alert by ID
94
+ - examine_trace: Examine a distributed trace by trace_id
95
+ - check_config: Inspect the live configuration of a service
96
+ - resolve_incident: Mark the incident as resolved (terminal action)
97
+ """
98
+ action_type: str = Field(
99
+ ...,
100
+ description="The type of action to perform",
101
+ examples=["query_logs", "restart_service", "resolve_incident"],
102
+ )
103
+ parameters: Dict[str, Any] = Field(
104
+ default_factory=dict,
105
+ description="Action-specific parameters. E.g., {'service': 'web-api'}",
106
+ examples=[{"service": "web-api"}, {"service": "db-primary", "source": "analytics-worker"}],
107
+ )
108
+
109
+
110
+ class Reward(BaseModel):
111
+ """
112
+ Per-step reward with breakdown for interpretability.
113
+ Implements OpenEnv Reward spec.
114
+ """
115
+ value: float = Field(..., description="Reward for this step")
116
+ cumulative: float = Field(..., description="Total reward so far this episode")
117
+ breakdown: Dict[str, float] = Field(
118
+ default_factory=dict,
119
+ description="Named reward components for debugging",
120
+ )
121
+ message: str = Field("", description="Human-readable explanation of reward")
122
+
123
+
124
+ class StepResponse(BaseModel):
125
+ """Full response from a step() call."""
126
+ observation: Observation
127
+ reward: Reward
128
+ done: bool
129
+ info: Dict[str, Any] = {}
130
+
131
+
132
+ class ResetRequest(BaseModel):
133
+ """Request body for reset()."""
134
+ task_id: str = Field("task1", description="One of: task1, task2, task3")
135
+ seed: Optional[int] = Field(None, description="Random seed for reproducibility")
136
+
137
+
138
+ class StateResponse(BaseModel):
139
+ """Full internal state (for grading/debugging)."""
140
+ session_id: str
141
+ task_id: str
142
+ step: int
143
+ done: bool
144
+ total_reward: float
145
+ world_state: Dict[str, Any]
146
+ action_history: List[Dict[str, Any]]
147
+ grader_score: Optional[float] = None
148
+
149
+
150
+ class TaskInfo(BaseModel):
151
+ """Metadata about a task."""
152
+ task_id: str
153
+ name: str
154
+ description: str
155
+ difficulty: Literal["easy", "medium", "hard"]
156
+ max_steps: int
157
+ passing_score: float
158
+ action_schema: Dict[str, Any]
159
+ observation_schema: Dict[str, Any]
160
+
161
+
162
+ class GraderResponse(BaseModel):
163
+ """Response from /grader endpoint."""
164
+ session_id: str
165
+ task_id: str
166
+ score: float = Field(..., ge=0.0, le=1.0)
167
+ breakdown: Dict[str, float]
168
+ episode_complete: bool
169
+ steps_taken: int
170
+ message: str
171
+
172
+
173
+ class BaselineResult(BaseModel):
174
+ """Result from /baseline endpoint."""
175
+ task_id: str
176
+ task_name: str
177
+ difficulty: str
178
+ score: float
179
+ steps_taken: int
180
+ episode_log: List[Dict[str, Any]]
181
+ success: bool
app/tasks/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.tasks.task1 import CPUSpikeTask
2
+ from app.tasks.task2 import DBConnectionPoolTask
3
+ from app.tasks.task3 import CascadingFailureTask
4
+
5
+ TASK_REGISTRY = {
6
+ "task1": CPUSpikeTask(),
7
+ "task2": DBConnectionPoolTask(),
8
+ "task3": CascadingFailureTask(),
9
+ }
10
+
11
+ __all__ = ["TASK_REGISTRY", "CPUSpikeTask", "DBConnectionPoolTask", "CascadingFailureTask"]
app/tasks/base.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Base class for all SRE incident tasks."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Dict, Any, Tuple, List
5
+ from app.models import Observation, Alert, ServiceStatus, LogEntry, MetricPoint
6
+ from datetime import datetime, timezone
7
+
8
+
9
+ BASE_INCIDENT_TIME = "2024-11-15T09:47:00Z"
10
+
11
+ AVAILABLE_ACTIONS = [
12
+ "query_logs",
13
+ "check_metrics",
14
+ "restart_service",
15
+ "rollback_deployment",
16
+ "scale_service",
17
+ "kill_query",
18
+ "acknowledge_alert",
19
+ "examine_trace",
20
+ "check_config",
21
+ "resolve_incident",
22
+ ]
23
+
24
+ ACTION_SCHEMA = {
25
+ "query_logs": {
26
+ "description": "Fetch recent log entries for a service",
27
+ "parameters": {
28
+ "service": {"type": "string", "required": True, "description": "Service name"},
29
+ "lines": {"type": "integer", "required": False, "default": 50},
30
+ },
31
+ },
32
+ "check_metrics": {
33
+ "description": "Retrieve current metrics for a service",
34
+ "parameters": {
35
+ "service": {"type": "string", "required": True},
36
+ },
37
+ },
38
+ "restart_service": {
39
+ "description": "Restart a service (rolling restart, brief downtime)",
40
+ "parameters": {
41
+ "service": {"type": "string", "required": True},
42
+ },
43
+ },
44
+ "rollback_deployment": {
45
+ "description": "Roll back a service to its previous deployment version",
46
+ "parameters": {
47
+ "service": {"type": "string", "required": True},
48
+ },
49
+ },
50
+ "scale_service": {
51
+ "description": "Change the number of replicas for a service",
52
+ "parameters": {
53
+ "service": {"type": "string", "required": True},
54
+ "replicas": {"type": "integer", "required": True, "min": 1, "max": 20},
55
+ },
56
+ },
57
+ "kill_query": {
58
+ "description": "Kill long-running database queries from a specific source/application",
59
+ "parameters": {
60
+ "source": {"type": "string", "required": True, "description": "Application or service holding queries"},
61
+ },
62
+ },
63
+ "acknowledge_alert": {
64
+ "description": "Acknowledge an alert to stop paging",
65
+ "parameters": {
66
+ "alert_id": {"type": "string", "required": True},
67
+ },
68
+ },
69
+ "examine_trace": {
70
+ "description": "Examine a distributed trace to identify slow spans",
71
+ "parameters": {
72
+ "trace_id": {"type": "string", "required": True},
73
+ },
74
+ },
75
+ "check_config": {
76
+ "description": "Inspect the live runtime configuration of a service",
77
+ "parameters": {
78
+ "service": {"type": "string", "required": True},
79
+ },
80
+ },
81
+ "resolve_incident": {
82
+ "description": "Mark the incident as resolved. Terminal action — ends the episode.",
83
+ "parameters": {},
84
+ },
85
+ }
86
+
87
+
88
+ class BaseTask(ABC):
89
+ task_id: str
90
+ name: str
91
+ description: str
92
+ difficulty: str
93
+ max_steps: int
94
+ passing_score: float = 0.6
95
+
96
+ @abstractmethod
97
+ def initial_state(self, seed: int = 42) -> Dict[str, Any]:
98
+ """Return initial world state dict."""
99
+ ...
100
+
101
+ @abstractmethod
102
+ def process_action(
103
+ self, action_type: str, params: Dict[str, Any], state: Dict[str, Any]
104
+ ) -> Tuple[Dict[str, Any], float, bool, str]:
105
+ """
106
+ Apply action to state.
107
+ Returns: (new_state, step_reward, done, message)
108
+ """
109
+ ...
110
+
111
+ @abstractmethod
112
+ def get_observation(self, state: Dict[str, Any], session_id: str, step: int) -> Observation:
113
+ """Build Observation from state."""
114
+ ...
115
+
116
+ @abstractmethod
117
+ def grade(self, state: Dict[str, Any], history: List[Dict]) -> Tuple[float, Dict[str, float]]:
118
+ """
119
+ Grade the episode.
120
+ Returns: (score 0.0–1.0, breakdown dict)
121
+ """
122
+ ...
123
+
124
+ def _make_service(self, name: str, status: str, cpu: float, mem: float,
125
+ err: float, **kwargs) -> ServiceStatus:
126
+ return ServiceStatus(
127
+ name=name, status=status,
128
+ cpu_percent=cpu, memory_percent=mem, error_rate=err,
129
+ **kwargs,
130
+ )
131
+
132
+ def _make_alert(self, alert_id: str, severity: str, service: str,
133
+ message: str, ack: bool = False) -> Alert:
134
+ return Alert(
135
+ alert_id=alert_id, severity=severity, service=service,
136
+ message=message, triggered_at=BASE_INCIDENT_TIME, acknowledged=ack,
137
+ )
app/tasks/task1.py ADDED
@@ -0,0 +1,359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Task 1: CPU Spike Investigation (Easy)
3
+ =======================================
4
+ Scenario: The web-api service has been pegged at 95% CPU for 12 minutes,
5
+ causing elevated latency. A memory leak in the request handler is causing
6
+ a GC spin loop. The agent must investigate and restart the service.
7
+
8
+ Optimal solution: query_logs(web-api) → restart_service(web-api) → resolve_incident()
9
+ Max steps: 15 | Passing score: 0.6
10
+ """
11
+
12
+ from typing import Dict, Any, Tuple, List
13
+ from app.models import Observation, Alert, ServiceStatus, LogEntry, MetricPoint
14
+ from app.tasks.base import BaseTask, AVAILABLE_ACTIONS, BASE_INCIDENT_TIME
15
+
16
+
17
+ class CPUSpikeTask(BaseTask):
18
+ task_id = "task1"
19
+ name = "CPU Spike Investigation"
20
+ description = (
21
+ "The web-api service is consuming 95% CPU. Investigate the root cause "
22
+ "using logs and metrics, then remediate the incident."
23
+ )
24
+ difficulty = "easy"
25
+ max_steps = 15
26
+ passing_score = 0.6
27
+
28
+ def initial_state(self, seed: int = 42) -> Dict[str, Any]:
29
+ return {
30
+ "services": {
31
+ "web-api": {
32
+ "status": "degraded", "cpu": 95.2, "memory": 78.4,
33
+ "error_rate": 3.1, "version": "2.3.1", "replicas": 2,
34
+ },
35
+ "db-primary": {
36
+ "status": "healthy", "cpu": 14.0, "memory": 48.0,
37
+ "error_rate": 0.0, "connections": 28, "max_connections": 100,
38
+ "version": "14.5",
39
+ },
40
+ "cache": {
41
+ "status": "healthy", "cpu": 6.0, "memory": 32.0,
42
+ "error_rate": 0.0, "version": "7.2.0",
43
+ },
44
+ "load-balancer": {
45
+ "status": "healthy", "cpu": 3.0, "memory": 15.0,
46
+ "error_rate": 0.0, "version": "1.28.0",
47
+ },
48
+ },
49
+ "alerts": [
50
+ {"id": "ALT-001", "sev": "critical", "svc": "web-api",
51
+ "msg": "CPU utilization at 95.2% — sustained for 12 minutes (threshold: 80%)",
52
+ "ack": False},
53
+ {"id": "ALT-002", "sev": "warning", "svc": "web-api",
54
+ "msg": "Request latency P99 = 8.4s (SLA threshold: 2s)",
55
+ "ack": False},
56
+ {"id": "ALT-003", "sev": "info", "svc": "load-balancer",
57
+ "msg": "Increased error routing to healthy upstream",
58
+ "ack": False},
59
+ ],
60
+ "recent_deployments": [
61
+ {"service": "web-api", "version": "2.3.1", "previous": "2.3.0",
62
+ "deployed_at": "2024-11-15T06:00:00Z", "deployer": "ci-pipeline"},
63
+ {"service": "cache", "version": "7.2.0", "previous": "7.1.9",
64
+ "deployed_at": "2024-11-14T22:00:00Z", "deployer": "ci-pipeline"},
65
+ ],
66
+ # Tracking agent progress
67
+ "logs_queried": [],
68
+ "metrics_checked": [],
69
+ "configs_checked": [],
70
+ "traces_examined": [],
71
+ "web_api_restarted": False,
72
+ "wrong_actions": 0,
73
+ "incident_resolved": False,
74
+ }
75
+
76
+ def process_action(
77
+ self, action_type: str, params: Dict[str, Any], state: Dict[str, Any]
78
+ ) -> Tuple[Dict[str, Any], float, bool, str]:
79
+ reward = 0.0
80
+ done = False
81
+ message = ""
82
+ service = params.get("service", "").strip()
83
+
84
+ if action_type == "query_logs":
85
+ if not service:
86
+ return state, -0.02, False, "Parameter 'service' is required for query_logs."
87
+ if service in state["logs_queried"]:
88
+ reward = 0.0
89
+ message = f"[Cached] Logs for {service} already retrieved."
90
+ elif service == "web-api":
91
+ state["logs_queried"].append(service)
92
+ reward = 0.12
93
+ message = (
94
+ "2024-11-15T09:34:11Z [ERROR] web-api RequestHandler: OutOfMemoryError "
95
+ "caught, forcing GC — heap 98% full\n"
96
+ "2024-11-15T09:35:02Z [ERROR] web-api RequestHandler: GC pause 1240ms, "
97
+ "thread stalled — possible memory leak in connection pool\n"
98
+ "2024-11-15T09:36:45Z [WARN] web-api ConnectionPool: 512 leaked "
99
+ "connections detected, pool not releasing objects\n"
100
+ "2024-11-15T09:38:00Z [ERROR] web-api RequestHandler: CPU spin on GC "
101
+ "collect() — recommend service restart\n"
102
+ "2024-11-15T09:44:20Z [ERROR] web-api: request timeout after 8000ms "
103
+ "(3 occurrences in last 60s)\n"
104
+ "ROOT CAUSE HINT: Connection pool is leaking — GC is spinning trying "
105
+ "to reclaim memory, causing CPU spike."
106
+ )
107
+ elif service == "db-primary":
108
+ state["logs_queried"].append(service)
109
+ reward = 0.02
110
+ message = (
111
+ "2024-11-15T09:40:00Z [INFO] db-primary: checkpoint completed\n"
112
+ "2024-11-15T09:45:00Z [INFO] db-primary: autovacuum finished on table users\n"
113
+ "No anomalies in database logs."
114
+ )
115
+ elif service == "cache":
116
+ state["logs_queried"].append(service)
117
+ reward = 0.02
118
+ message = (
119
+ "2024-11-15T09:30:00Z [INFO] cache: eviction rate 0.2%\n"
120
+ "No anomalies in cache logs."
121
+ )
122
+ else:
123
+ state["logs_queried"].append(service)
124
+ reward = 0.01
125
+ message = f"No logs found for service '{service}'."
126
+
127
+ elif action_type == "check_metrics":
128
+ if not service:
129
+ return state, -0.02, False, "Parameter 'service' is required for check_metrics."
130
+ if service in state["metrics_checked"]:
131
+ message = f"[Cached] Metrics for {service} already retrieved."
132
+ reward = 0.0
133
+ elif service == "web-api":
134
+ state["metrics_checked"].append(service)
135
+ reward = 0.06
136
+ message = (
137
+ "web-api metrics (last 15 min):\n"
138
+ " cpu_percent: 95.2% ↑ (was 12% before 09:30)\n"
139
+ " memory_percent: 78.4% ↑ (growing 1.2%/min)\n"
140
+ " heap_used_mb: 3840 / 4096\n"
141
+ " gc_pause_ms_avg: 950ms (normal: <50ms)\n"
142
+ " request_latency_p99: 8.4s\n"
143
+ " connections_leaked: 512 (normal: 0)\n"
144
+ "INSIGHT: Memory growth is linear — classic leak pattern."
145
+ )
146
+ else:
147
+ state["metrics_checked"].append(service)
148
+ reward = 0.02
149
+ message = f"Metrics for {service}: All values within normal operating ranges."
150
+
151
+ elif action_type == "check_config":
152
+ if not service:
153
+ return state, -0.02, False, "Parameter 'service' is required for check_config."
154
+ state["configs_checked"].append(service)
155
+ if service == "web-api":
156
+ reward = 0.04
157
+ message = (
158
+ "web-api live config:\n"
159
+ " connection_pool_max: 512\n"
160
+ " connection_pool_timeout: 30s\n"
161
+ " connection_pool_recycle: DISABLED ← note: recycle was enabled in v2.3.0\n"
162
+ " heap_size: 4096m\n"
163
+ " gc_policy: G1GC\n"
164
+ "Config change in v2.3.1: connection_pool_recycle was accidentally disabled."
165
+ )
166
+ else:
167
+ reward = 0.01
168
+ message = f"Config for {service}: No unusual settings detected."
169
+
170
+ elif action_type == "restart_service":
171
+ if not service:
172
+ return state, -0.02, False, "Parameter 'service' is required for restart_service."
173
+ if service == "web-api":
174
+ state["web_api_restarted"] = True
175
+ state["services"]["web-api"]["status"] = "healthy"
176
+ state["services"]["web-api"]["cpu"] = 11.0
177
+ state["services"]["web-api"]["memory"] = 34.0
178
+ state["services"]["web-api"]["error_rate"] = 0.0
179
+ reward = 0.40
180
+ message = (
181
+ "✓ web-api restarted successfully (rolling restart, ~15s downtime).\n"
182
+ " CPU: 95.2% → 11.0%\n"
183
+ " Memory: 78.4% → 34.0%\n"
184
+ " Error rate: 3.1/s → 0.0/s\n"
185
+ " Status: healthy\n"
186
+ "NOTE: Root cause (disabled connection pool recycle) is still present. "
187
+ "CPU spike may recur without a permanent fix."
188
+ )
189
+ else:
190
+ state["wrong_actions"] += 1
191
+ reward = -0.08
192
+ message = (
193
+ f"Restarted {service}, but this service is healthy and unrelated to "
194
+ f"the incident. No improvement observed. Avoid unnecessary restarts."
195
+ )
196
+
197
+ elif action_type == "rollback_deployment":
198
+ if not service:
199
+ return state, -0.02, False, "Parameter 'service' is required for rollback_deployment."
200
+ if service == "web-api":
201
+ # Rollback also fixes it (alternative valid solution)
202
+ state["web_api_restarted"] = True # treat as resolved
203
+ state["services"]["web-api"]["status"] = "healthy"
204
+ state["services"]["web-api"]["cpu"] = 10.0
205
+ state["services"]["web-api"]["memory"] = 32.0
206
+ state["services"]["web-api"]["error_rate"] = 0.0
207
+ reward = 0.45 # slightly better because it fixes root cause
208
+ message = (
209
+ "✓ web-api rolled back to v2.3.0.\n"
210
+ " connection_pool_recycle re-enabled.\n"
211
+ " CPU: 95.2% → 10.0%\n"
212
+ " Memory: 78.4% → 32.0%\n"
213
+ " Status: healthy\n"
214
+ "EXCELLENT: This addresses the root cause, not just the symptom."
215
+ )
216
+ else:
217
+ state["wrong_actions"] += 1
218
+ reward = -0.05
219
+ message = f"Rolled back {service}, but this is unrelated to the incident."
220
+
221
+ elif action_type == "scale_service":
222
+ if service == "web-api":
223
+ replicas = params.get("replicas", 2)
224
+ reward = 0.05
225
+ message = (
226
+ f"Scaled web-api to {replicas} replicas. This distributes load but "
227
+ f"does NOT fix the underlying memory leak. CPU per instance still high."
228
+ )
229
+ else:
230
+ reward = -0.03
231
+ message = f"Scaling {service} has no effect on the current incident."
232
+
233
+ elif action_type == "acknowledge_alert":
234
+ alert_id = params.get("alert_id", "")
235
+ for a in state["alerts"]:
236
+ if a["id"] == alert_id:
237
+ a["ack"] = True
238
+ reward = 0.01
239
+ message = f"Alert {alert_id} acknowledged."
240
+
241
+ elif action_type == "examine_trace":
242
+ state["traces_examined"].append(params.get("trace_id", "unknown"))
243
+ reward = 0.03
244
+ message = (
245
+ "Trace analysis: Request span shows 7.8s blocked in GC pause within "
246
+ "web-api RequestHandler. All downstream services (db, cache) responding "
247
+ "normally. Bottleneck is exclusively in web-api."
248
+ )
249
+
250
+ elif action_type == "resolve_incident":
251
+ if state["web_api_restarted"]:
252
+ state["incident_resolved"] = True
253
+ done = True
254
+ reward = 0.30
255
+ message = (
256
+ "✓ Incident resolved.\n"
257
+ "Summary: web-api experienced a CPU spike due to a memory leak "
258
+ "(connection pool not recycling in v2.3.1). Service was remediated."
259
+ )
260
+ else:
261
+ reward = -0.05
262
+ message = (
263
+ "Cannot resolve: web-api is still degraded (CPU 95.2%). "
264
+ "Investigate and fix the root cause before resolving."
265
+ )
266
+
267
+ else:
268
+ reward = -0.03
269
+ message = f"Unknown or inapplicable action: {action_type}."
270
+
271
+ return state, reward, done, message
272
+
273
+ def get_observation(self, state: Dict[str, Any], session_id: str, step: int) -> Observation:
274
+ services = {}
275
+ for name, s in state["services"].items():
276
+ services[name] = ServiceStatus(
277
+ name=name,
278
+ status=s["status"],
279
+ cpu_percent=s["cpu"],
280
+ memory_percent=s["memory"],
281
+ error_rate=s["error_rate"],
282
+ connections=s.get("connections"),
283
+ max_connections=s.get("max_connections"),
284
+ version=s.get("version", "1.0.0"),
285
+ replicas=s.get("replicas", 1),
286
+ )
287
+
288
+ alerts = [
289
+ Alert(
290
+ alert_id=a["id"], severity=a["sev"], service=a["svc"],
291
+ message=a["msg"], triggered_at=BASE_INCIDENT_TIME,
292
+ acknowledged=a["ack"],
293
+ )
294
+ for a in state["alerts"]
295
+ ]
296
+
297
+ return Observation(
298
+ session_id=session_id,
299
+ task_id=self.task_id,
300
+ step=step,
301
+ timestamp=BASE_INCIDENT_TIME,
302
+ alerts=alerts,
303
+ services=services,
304
+ logs=[],
305
+ metrics=[],
306
+ available_actions=AVAILABLE_ACTIONS,
307
+ incident_resolved=state["incident_resolved"],
308
+ message="",
309
+ recent_deployments=state["recent_deployments"],
310
+ runbook_hints=[
311
+ "High CPU often caused by: memory leaks, infinite loops, or hot code paths.",
312
+ "Check recent deployments for configuration changes.",
313
+ "query_logs and check_metrics before taking disruptive actions.",
314
+ ],
315
+ )
316
+
317
+ def grade(self, state: Dict[str, Any], history: List[Dict]) -> Tuple[float, Dict[str, float]]:
318
+ breakdown = {}
319
+ score = 0.0
320
+
321
+ # Root cause investigated?
322
+ if "web-api" in state.get("logs_queried", []) or \
323
+ "web-api" in state.get("metrics_checked", []) or \
324
+ "web-api" in state.get("configs_checked", []):
325
+ breakdown["investigated_root_service"] = 0.15
326
+ score += 0.15
327
+
328
+ # Service remediated?
329
+ if state.get("web_api_restarted", False):
330
+ breakdown["service_remediated"] = 0.45
331
+ score += 0.45
332
+
333
+ # Incident formally closed?
334
+ if state.get("incident_resolved", False):
335
+ breakdown["incident_resolved"] = 0.25
336
+ score += 0.25
337
+
338
+ # Efficiency bonus
339
+ steps = len(history)
340
+ if steps <= 4:
341
+ breakdown["efficiency_bonus"] = 0.15
342
+ score += 0.15
343
+ elif steps <= 7:
344
+ breakdown["efficiency_bonus"] = 0.10
345
+ score += 0.10
346
+ elif steps <= 10:
347
+ breakdown["efficiency_bonus"] = 0.05
348
+ score += 0.05
349
+ else:
350
+ breakdown["efficiency_bonus"] = 0.0
351
+
352
+ # Penalty for wrong actions
353
+ wrong = state.get("wrong_actions", 0)
354
+ if wrong > 0:
355
+ penalty = min(wrong * 0.08, 0.20)
356
+ breakdown["wrong_action_penalty"] = -penalty
357
+ score -= penalty
358
+
359
+ return round(min(max(score, 0.0), 1.0), 4), breakdown
app/tasks/task2.py ADDED
@@ -0,0 +1,445 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Task 2: Database Connection Pool Exhaustion (Medium)
3
+ =====================================================
4
+ Scenario: The db-primary connection pool is fully exhausted (100/100 connections).
5
+ Multiple dependent services (payment-api, user-service) are timing out.
6
+ Root cause: analytics-worker is running unbounded full-table scans, holding 78
7
+ long-running connections that never release.
8
+
9
+ Trap: Restarting the DB makes things temporarily worse (all services lose their
10
+ remaining connections during restart and have to reconnect).
11
+
12
+ Optimal: check_metrics(db-primary) → query_logs(db-primary) →
13
+ kill_query(analytics-worker) → resolve_incident()
14
+ Max steps: 18 | Passing score: 0.6
15
+ """
16
+
17
+ from typing import Dict, Any, Tuple, List
18
+ from app.models import Observation, Alert, ServiceStatus, LogEntry, MetricPoint
19
+ from app.tasks.base import BaseTask, AVAILABLE_ACTIONS, BASE_INCIDENT_TIME
20
+
21
+
22
+ class DBConnectionPoolTask(BaseTask):
23
+ task_id = "task2"
24
+ name = "Database Connection Pool Exhaustion"
25
+ description = (
26
+ "The db-primary connection pool is exhausted (100/100). Multiple services "
27
+ "are timing out on DB calls. Identify which application is holding excess "
28
+ "connections and remediate without restarting the database."
29
+ )
30
+ difficulty = "medium"
31
+ max_steps = 18
32
+ passing_score = 0.6
33
+
34
+ def initial_state(self, seed: int = 42) -> Dict[str, Any]:
35
+ return {
36
+ "services": {
37
+ "db-primary": {
38
+ "status": "degraded", "cpu": 31.0, "memory": 62.0,
39
+ "error_rate": 8.2, "connections": 100, "max_connections": 100,
40
+ "version": "14.8",
41
+ },
42
+ "payment-api": {
43
+ "status": "degraded", "cpu": 45.0, "memory": 55.0,
44
+ "error_rate": 12.4, "connections": 8, "max_connections": 20,
45
+ "version": "3.1.2",
46
+ },
47
+ "user-service": {
48
+ "status": "degraded", "cpu": 38.0, "memory": 50.0,
49
+ "error_rate": 9.7, "connections": 6, "max_connections": 20,
50
+ "version": "2.4.0",
51
+ },
52
+ "analytics-worker": {
53
+ "status": "healthy", "cpu": 42.0, "memory": 70.0,
54
+ "error_rate": 0.0, "connections": 78, "max_connections": 80,
55
+ "version": "1.0.9",
56
+ },
57
+ "cache": {
58
+ "status": "healthy", "cpu": 5.0, "memory": 28.0,
59
+ "error_rate": 0.0, "version": "7.2.0",
60
+ },
61
+ },
62
+ "alerts": [
63
+ {"id": "ALT-010", "sev": "critical", "svc": "db-primary",
64
+ "msg": "Connection pool exhausted: 100/100 connections in use",
65
+ "ack": False},
66
+ {"id": "ALT-011", "sev": "critical", "svc": "payment-api",
67
+ "msg": "High error rate 12.4/s — DB connection timeout after 30s",
68
+ "ack": False},
69
+ {"id": "ALT-012", "sev": "critical", "svc": "user-service",
70
+ "msg": "High error rate 9.7/s — DB connection timeout after 30s",
71
+ "ack": False},
72
+ {"id": "ALT-013", "sev": "warning", "svc": "analytics-worker",
73
+ "msg": "High memory usage 70% on analytics-worker",
74
+ "ack": False},
75
+ ],
76
+ "recent_deployments": [
77
+ {"service": "analytics-worker", "version": "1.0.9", "previous": "1.0.8",
78
+ "deployed_at": "2024-11-15T08:00:00Z", "deployer": "data-team"},
79
+ {"service": "payment-api", "version": "3.1.2", "previous": "3.1.1",
80
+ "deployed_at": "2024-11-14T16:00:00Z", "deployer": "ci-pipeline"},
81
+ ],
82
+ # Tracking agent progress
83
+ "logs_queried": [],
84
+ "metrics_checked": [],
85
+ "configs_checked": [],
86
+ "queries_killed": [],
87
+ "db_restarted": False,
88
+ "analytics_worker_killed": False,
89
+ "wrong_actions": 0,
90
+ "incident_resolved": False,
91
+ # Hidden state — what's actually happening
92
+ "_analytics_holding_connections": True,
93
+ "_db_connections_freed": False,
94
+ }
95
+
96
+ def process_action(
97
+ self, action_type: str, params: Dict[str, Any], state: Dict[str, Any]
98
+ ) -> Tuple[Dict[str, Any], float, bool, str]:
99
+ reward = 0.0
100
+ done = False
101
+ message = ""
102
+ service = params.get("service", "").strip()
103
+
104
+ if action_type == "query_logs":
105
+ if not service:
106
+ return state, -0.02, False, "Parameter 'service' is required."
107
+ if service in state["logs_queried"]:
108
+ return state, 0.0, False, f"[Cached] Logs for {service} already retrieved."
109
+ state["logs_queried"].append(service)
110
+
111
+ if service == "db-primary":
112
+ reward = 0.14
113
+ message = (
114
+ "2024-11-15T09:32:00Z [WARN] db-primary: connection count 95/100\n"
115
+ "2024-11-15T09:35:00Z [ERROR] db-primary: connection pool full, "
116
+ "new connections queued\n"
117
+ "2024-11-15T09:38:00Z [ERROR] db-primary: query from analytics-worker "
118
+ "pid=28441 running 18min on table 'events' (full table scan, no index)\n"
119
+ "2024-11-15T09:40:00Z [ERROR] db-primary: 78 long-running queries from "
120
+ "analytics-worker — these are consuming all connections\n"
121
+ "2024-11-15T09:44:00Z [ERROR] db-primary: payment-api cannot acquire "
122
+ "connection — pool exhausted\n"
123
+ "ROOT CAUSE: analytics-worker is running full table scans on 'events' "
124
+ "table, holding 78 connections indefinitely."
125
+ )
126
+ elif service == "analytics-worker":
127
+ reward = 0.10
128
+ message = (
129
+ "2024-11-15T08:01:00Z [INFO] analytics-worker: v1.0.9 deployed\n"
130
+ "2024-11-15T08:05:00Z [INFO] analytics-worker: starting daily report job\n"
131
+ "2024-11-15T08:05:10Z [WARN] analytics-worker: query_timeout config "
132
+ "missing — defaulting to no timeout\n"
133
+ "2024-11-15T09:10:00Z [WARN] analytics-worker: 78 concurrent queries "
134
+ "running for >60min — possible misconfiguration\n"
135
+ "BUG IN v1.0.9: query_timeout was removed from config, causing unbounded "
136
+ "full-table scans that never terminate."
137
+ )
138
+ elif service == "payment-api":
139
+ reward = 0.06
140
+ message = (
141
+ "2024-11-15T09:38:00Z [ERROR] payment-api: db connection timeout "
142
+ "after 30s — pool exhausted upstream\n"
143
+ "2024-11-15T09:39:00Z [ERROR] payment-api: 12 transaction failures "
144
+ "due to DB unavailability\n"
145
+ "payment-api is a victim, not the root cause."
146
+ )
147
+ elif service == "user-service":
148
+ reward = 0.05
149
+ message = (
150
+ "2024-11-15T09:38:30Z [ERROR] user-service: DB connection timeout — "
151
+ "pool exhausted upstream\n"
152
+ "user-service is a victim, not the root cause."
153
+ )
154
+ else:
155
+ reward = 0.01
156
+ message = f"No relevant logs found for '{service}'."
157
+
158
+ elif action_type == "check_metrics":
159
+ if not service:
160
+ return state, -0.02, False, "Parameter 'service' is required."
161
+ if service in state["metrics_checked"]:
162
+ return state, 0.0, False, f"[Cached] Metrics for {service} already retrieved."
163
+ state["metrics_checked"].append(service)
164
+
165
+ if service == "db-primary":
166
+ reward = 0.12
167
+ message = (
168
+ "db-primary metrics:\n"
169
+ " active_connections: 100 / 100 ← FULL\n"
170
+ " connections_by_client:\n"
171
+ " analytics-worker: 78 ← 78% of pool\n"
172
+ " payment-api: 8\n"
173
+ " user-service: 6\n"
174
+ " other: 8\n"
175
+ " longest_query_duration: 18m 42s (from analytics-worker)\n"
176
+ " queries_waiting_for_lock: 12\n"
177
+ " replication_lag: 0ms\n"
178
+ "CRITICAL: analytics-worker holds 78/100 connections."
179
+ )
180
+ elif service == "analytics-worker":
181
+ reward = 0.06
182
+ message = (
183
+ "analytics-worker metrics:\n"
184
+ " active_db_connections: 78\n"
185
+ " cpu_percent: 42.0%\n"
186
+ " memory_percent: 70.2%\n"
187
+ " rows_scanned_per_sec: 45000 (full table scan pattern)\n"
188
+ " queries_timed_out: 0 ← no query timeout configured!\n"
189
+ )
190
+ else:
191
+ reward = 0.02
192
+ message = f"Metrics for {service}: Elevated error rates due to DB connection failures."
193
+
194
+ elif action_type == "check_config":
195
+ state["configs_checked"].append(service)
196
+ if service == "analytics-worker":
197
+ reward = 0.08
198
+ message = (
199
+ "analytics-worker config (v1.0.9):\n"
200
+ " db_connection_pool_size: 80\n"
201
+ " query_timeout: (not set) ← MISSING in v1.0.9\n"
202
+ " max_concurrent_queries: (not set)\n"
203
+ " report_schedule: 0 8 * * *\n\n"
204
+ "analytics-worker config (v1.0.8 — previous):\n"
205
+ " db_connection_pool_size: 20\n"
206
+ " query_timeout: 600 ← was 10 minutes\n"
207
+ " max_concurrent_queries: 5\n"
208
+ "REGRESSION: v1.0.9 removed query_timeout and raised pool_size to 80."
209
+ )
210
+ elif service == "db-primary":
211
+ reward = 0.04
212
+ message = (
213
+ "db-primary config:\n"
214
+ " max_connections: 100\n"
215
+ " statement_timeout: (not set at server level)\n"
216
+ " idle_in_transaction_session_timeout: 0 (disabled)\n"
217
+ )
218
+ else:
219
+ reward = 0.01
220
+ message = f"Config for {service}: No unusual settings."
221
+
222
+ elif action_type == "kill_query":
223
+ source = params.get("source", "").strip()
224
+ if source == "analytics-worker":
225
+ state["queries_killed"].append("analytics-worker")
226
+ state["analytics_worker_killed"] = True
227
+ state["_db_connections_freed"] = True
228
+ # Update db-primary state
229
+ state["services"]["db-primary"]["connections"] = 22
230
+ state["services"]["db-primary"]["status"] = "healthy"
231
+ state["services"]["db-primary"]["error_rate"] = 0.0
232
+ # Update dependent services
233
+ state["services"]["payment-api"]["status"] = "healthy"
234
+ state["services"]["payment-api"]["error_rate"] = 0.1
235
+ state["services"]["user-service"]["status"] = "healthy"
236
+ state["services"]["user-service"]["error_rate"] = 0.1
237
+ reward = 0.40
238
+ message = (
239
+ "✓ Killed 78 long-running queries from analytics-worker.\n"
240
+ " db-primary connections: 100 → 22\n"
241
+ " db-primary status: degraded → healthy\n"
242
+ " payment-api: recovering (error rate dropping)\n"
243
+ " user-service: recovering (error rate dropping)\n"
244
+ "NOTE: analytics-worker may restart the runaway queries on next job run. "
245
+ "Consider also rolling back analytics-worker to v1.0.8."
246
+ )
247
+ else:
248
+ state["wrong_actions"] += 1
249
+ reward = -0.05
250
+ message = (
251
+ f"No long-running queries found from '{source}'. "
252
+ "Check db metrics to identify the actual source of connection exhaustion."
253
+ )
254
+
255
+ elif action_type == "restart_service":
256
+ if service == "db-primary":
257
+ state["db_restarted"] = True
258
+ state["wrong_actions"] += 1
259
+ # Restarting DB causes brief outage for all — connections drop but analytics-worker
260
+ # reconnects immediately and fills the pool again
261
+ reward = -0.15
262
+ message = (
263
+ "⚠ db-primary restarted — ALL services lost their connections.\n"
264
+ " payment-api: connection errors spiking\n"
265
+ " user-service: connection errors spiking\n"
266
+ " analytics-worker: reconnected immediately, refilling pool with 78 queries\n"
267
+ "RESULT: Restart did not fix the root cause. analytics-worker filled the "
268
+ "pool again within 30 seconds. This approach is ineffective here."
269
+ )
270
+ elif service == "analytics-worker":
271
+ # Partial fix — stops current queries but doesn't prevent recurrence
272
+ state["queries_killed"].append("analytics-worker-restart")
273
+ state["services"]["db-primary"]["connections"] = 22
274
+ state["services"]["db-primary"]["status"] = "healthy"
275
+ state["services"]["payment-api"]["status"] = "healthy"
276
+ state["services"]["user-service"]["status"] = "healthy"
277
+ reward = 0.20 # partial credit — works but is heavy-handed
278
+ message = (
279
+ "analytics-worker restarted. Current runaway queries terminated.\n"
280
+ " db-primary connections: 100 → 22 (analytics-worker queries cleared)\n"
281
+ " payment-api, user-service: recovering\n"
282
+ "NOTE: This is a blunt fix. analytics-worker will restart its job and "
283
+ "may cause the same issue again without a config fix."
284
+ )
285
+ else:
286
+ state["wrong_actions"] += 1
287
+ reward = -0.08
288
+ message = f"Restarting {service} does not affect the root cause."
289
+
290
+ elif action_type == "rollback_deployment":
291
+ if service == "analytics-worker":
292
+ if not state["analytics_worker_killed"] and not any("analytics-worker" in k for k in state["queries_killed"]):
293
+ # Rollback also kills queries as part of restart
294
+ state["services"]["db-primary"]["connections"] = 22
295
+ state["services"]["db-primary"]["status"] = "healthy"
296
+ state["services"]["payment-api"]["status"] = "healthy"
297
+ state["services"]["user-service"]["status"] = "healthy"
298
+ reward = 0.35
299
+ message = (
300
+ "✓ analytics-worker rolled back to v1.0.8.\n"
301
+ " query_timeout restored to 600s\n"
302
+ " max_concurrent_queries restored to 5\n"
303
+ " db_connection_pool_size reduced to 20\n"
304
+ " db-primary connections: dropping to 22\n"
305
+ "This addresses the root cause AND prevents recurrence."
306
+ )
307
+ else:
308
+ state["wrong_actions"] += 1
309
+ reward = -0.05
310
+ message = f"Rolling back {service} does not address the connection pool issue."
311
+
312
+ elif action_type == "acknowledge_alert":
313
+ alert_id = params.get("alert_id", "")
314
+ for a in state["alerts"]:
315
+ if a["id"] == alert_id:
316
+ a["ack"] = True
317
+ reward = 0.01
318
+ message = f"Alert {alert_id} acknowledged."
319
+
320
+ elif action_type == "resolve_incident":
321
+ db_ok = state["services"]["db-primary"]["connections"] < 80
322
+ if db_ok:
323
+ state["incident_resolved"] = True
324
+ done = True
325
+ reward = 0.25
326
+ message = (
327
+ "✓ Incident resolved.\n"
328
+ "Summary: analytics-worker v1.0.9 introduced unbounded DB queries "
329
+ "(no query_timeout, large connection pool) causing pool exhaustion. "
330
+ "Remediated by killing runaway queries and/or rolling back analytics-worker."
331
+ )
332
+ else:
333
+ reward = -0.05
334
+ message = (
335
+ f"Cannot resolve: db-primary still at "
336
+ f"{state['services']['db-primary']['connections']}/100 connections. "
337
+ "Identify and fix the source of connection exhaustion first."
338
+ )
339
+
340
+ else:
341
+ reward = -0.03
342
+ message = f"Unknown or inapplicable action: {action_type}."
343
+
344
+ return state, reward, done, message
345
+
346
+ def get_observation(self, state: Dict[str, Any], session_id: str, step: int) -> Observation:
347
+ services = {}
348
+ for name, s in state["services"].items():
349
+ services[name] = ServiceStatus(
350
+ name=name, status=s["status"],
351
+ cpu_percent=s["cpu"], memory_percent=s["memory"],
352
+ error_rate=s["error_rate"],
353
+ connections=s.get("connections"),
354
+ max_connections=s.get("max_connections"),
355
+ version=s.get("version", "1.0.0"),
356
+ replicas=s.get("replicas", 1),
357
+ )
358
+
359
+ alerts = [
360
+ Alert(
361
+ alert_id=a["id"], severity=a["sev"], service=a["svc"],
362
+ message=a["msg"], triggered_at=BASE_INCIDENT_TIME,
363
+ acknowledged=a["ack"],
364
+ )
365
+ for a in state["alerts"]
366
+ ]
367
+
368
+ return Observation(
369
+ session_id=session_id,
370
+ task_id=self.task_id,
371
+ step=step,
372
+ timestamp=BASE_INCIDENT_TIME,
373
+ alerts=alerts,
374
+ services=services,
375
+ available_actions=AVAILABLE_ACTIONS,
376
+ incident_resolved=state["incident_resolved"],
377
+ message="",
378
+ recent_deployments=state["recent_deployments"],
379
+ runbook_hints=[
380
+ "check_metrics(db-primary) shows connections broken down by client.",
381
+ "Restarting the database during connection exhaustion can worsen the situation.",
382
+ "kill_query terminates long-running queries from a specific source application.",
383
+ "Recent deployments are often correlated with sudden incidents.",
384
+ ],
385
+ )
386
+
387
+ def grade(self, state: Dict[str, Any], history: List[Dict]) -> Tuple[float, Dict[str, float]]:
388
+ breakdown = {}
389
+ score = 0.0
390
+
391
+ # Root cause identified? (checked db metrics OR db logs which reveal analytics-worker)
392
+ root_cause_found = (
393
+ "db-primary" in state.get("metrics_checked", []) or
394
+ "db-primary" in state.get("logs_queried", []) or
395
+ "analytics-worker" in state.get("logs_queried", []) or
396
+ "analytics-worker" in state.get("configs_checked", [])
397
+ )
398
+ if root_cause_found:
399
+ breakdown["root_cause_identified"] = 0.20
400
+ score += 0.20
401
+
402
+ # Correct attribution? (analytics-worker named as source)
403
+ targeted_analytics = (
404
+ state.get("analytics_worker_killed", False) or
405
+ any("analytics-worker" in k for k in state.get("queries_killed", []))
406
+ )
407
+ if targeted_analytics:
408
+ breakdown["correct_attribution"] = 0.30
409
+ score += 0.30
410
+
411
+ # DB recovered (connections < 80)?
412
+ if state["services"]["db-primary"]["connections"] < 80:
413
+ breakdown["db_recovered"] = 0.20
414
+ score += 0.20
415
+
416
+ # Incident formally resolved?
417
+ if state.get("incident_resolved", False):
418
+ breakdown["incident_resolved"] = 0.20
419
+ score += 0.20
420
+
421
+ # Efficiency bonus
422
+ steps = len(history)
423
+ if steps <= 5:
424
+ breakdown["efficiency_bonus"] = 0.10
425
+ score += 0.10
426
+ elif steps <= 8:
427
+ breakdown["efficiency_bonus"] = 0.07
428
+ score += 0.07
429
+ elif steps <= 12:
430
+ breakdown["efficiency_bonus"] = 0.03
431
+ score += 0.03
432
+
433
+ # Penalty: restarted DB (bad practice)
434
+ if state.get("db_restarted", False):
435
+ breakdown["db_restart_penalty"] = -0.15
436
+ score -= 0.15
437
+
438
+ # Penalty: other wrong actions
439
+ wrong = state.get("wrong_actions", 0) - (1 if state.get("db_restarted", False) else 0)
440
+ if wrong > 0:
441
+ p = min(wrong * 0.07, 0.15)
442
+ breakdown["wrong_action_penalty"] = -p
443
+ score -= p
444
+
445
+ return round(min(max(score, 0.0), 1.0), 4), breakdown
app/tasks/task3.py ADDED
@@ -0,0 +1,487 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Task 3: Cascading Service Failure (Hard)
3
+ =========================================
4
+ Scenario: Four production services are down simultaneously. A recent deployment
5
+ of config-service v1.2.0 introduced a broken service-discovery URL, causing all
6
+ services that depend on it to fail to resolve each other.
7
+
8
+ Traps:
9
+ - user-service has an unrelated memory warning (red herring)
10
+ - order-service v2.1.0 was deployed 2h ago (not the cause)
11
+ - Restarting any individual service without fixing config-service makes no progress
12
+ - Need to investigate and identify config-service as the common dependency
13
+
14
+ Optimal: check_config(config-service) OR query_logs(any-down-service) →
15
+ rollback_deployment(config-service) → resolve_incident()
16
+ Max steps: 20 | Passing score: 0.6
17
+ """
18
+
19
+ from typing import Dict, Any, Tuple, List
20
+ from app.models import Observation, Alert, ServiceStatus
21
+ from app.tasks.base import BaseTask, AVAILABLE_ACTIONS, BASE_INCIDENT_TIME
22
+
23
+
24
+ class CascadingFailureTask(BaseTask):
25
+ task_id = "task3"
26
+ name = "Cascading Service Failure"
27
+ description = (
28
+ "Four services (api-gateway, user-service, order-service, payment-service) "
29
+ "are simultaneously down. Identify the common root cause and remediate "
30
+ "the entire incident with minimal blast radius."
31
+ )
32
+ difficulty = "hard"
33
+ max_steps = 20
34
+ passing_score = 0.6
35
+
36
+ def initial_state(self, seed: int = 42) -> Dict[str, Any]:
37
+ return {
38
+ "services": {
39
+ "api-gateway": {
40
+ "status": "down", "cpu": 0.5, "memory": 12.0,
41
+ "error_rate": 100.0, "version": "1.4.2", "replicas": 3,
42
+ },
43
+ "user-service": {
44
+ "status": "down", "cpu": 0.8, "memory": 88.0,
45
+ "error_rate": 100.0, "version": "4.0.1", "replicas": 2,
46
+ },
47
+ "order-service": {
48
+ "status": "down", "cpu": 0.3, "memory": 45.0,
49
+ "error_rate": 100.0, "version": "2.1.0", "replicas": 2,
50
+ },
51
+ "payment-service": {
52
+ "status": "down", "cpu": 0.2, "memory": 40.0,
53
+ "error_rate": 100.0, "version": "5.2.3", "replicas": 2,
54
+ },
55
+ "config-service": {
56
+ "status": "healthy", "cpu": 18.0, "memory": 35.0,
57
+ "error_rate": 0.0, "version": "1.2.0", "replicas": 1,
58
+ },
59
+ "db-primary": {
60
+ "status": "healthy", "cpu": 22.0, "memory": 55.0,
61
+ "error_rate": 0.0, "connections": 15, "max_connections": 100,
62
+ "version": "14.8",
63
+ },
64
+ "message-queue": {
65
+ "status": "healthy", "cpu": 10.0, "memory": 42.0,
66
+ "error_rate": 0.0, "version": "3.12.0",
67
+ },
68
+ },
69
+ "alerts": [
70
+ {"id": "ALT-020", "sev": "critical", "svc": "api-gateway",
71
+ "msg": "api-gateway is DOWN — all requests returning 503",
72
+ "ack": False},
73
+ {"id": "ALT-021", "sev": "critical", "svc": "user-service",
74
+ "msg": "user-service is DOWN — health check failing for 8 minutes",
75
+ "ack": False},
76
+ {"id": "ALT-022", "sev": "critical", "svc": "order-service",
77
+ "msg": "order-service is DOWN — all replicas unhealthy",
78
+ "ack": False},
79
+ {"id": "ALT-023", "sev": "critical", "svc": "payment-service",
80
+ "msg": "payment-service is DOWN — cannot process transactions",
81
+ "ack": False},
82
+ {"id": "ALT-024", "sev": "warning", "svc": "user-service",
83
+ "msg": "user-service memory at 88% (elevated but not critical)",
84
+ "ack": False},
85
+ ],
86
+ "recent_deployments": [
87
+ {"service": "config-service", "version": "1.2.0", "previous": "1.1.9",
88
+ "deployed_at": "2024-11-15T09:30:00Z", "deployer": "platform-team",
89
+ "change": "Updated service discovery URLs for new datacenter migration"},
90
+ {"service": "order-service", "version": "2.1.0", "previous": "2.0.8",
91
+ "deployed_at": "2024-11-15T07:45:00Z", "deployer": "ci-pipeline",
92
+ "change": "New checkout flow feature"},
93
+ {"service": "user-service", "version": "4.0.1", "previous": "4.0.0",
94
+ "deployed_at": "2024-11-14T14:00:00Z", "deployer": "ci-pipeline",
95
+ "change": "Bug fix for profile update endpoint"},
96
+ ],
97
+ # Tracking agent progress
98
+ "logs_queried": [],
99
+ "metrics_checked": [],
100
+ "configs_checked": [],
101
+ "services_restarted": [],
102
+ "rollbacks_attempted": {},
103
+ "wrong_rollbacks": 0,
104
+ "config_service_rolledback": False,
105
+ "services_recovered": [],
106
+ "incident_resolved": False,
107
+ "_all_services_down_due_to_config": True,
108
+ }
109
+
110
+ def _check_recovery(self, state: Dict[str, Any]) -> None:
111
+ """Update service statuses based on whether config-service was fixed."""
112
+ if state["config_service_rolledback"]:
113
+ for svc in ["api-gateway", "user-service", "order-service", "payment-service"]:
114
+ if svc not in state["services_recovered"]:
115
+ state["services_recovered"].append(svc)
116
+ state["services"][svc]["status"] = "healthy"
117
+ state["services"][svc]["error_rate"] = 0.0
118
+ state["services"][svc]["cpu"] = float(
119
+ {"api-gateway": 8.0, "user-service": 22.0,
120
+ "order-service": 15.0, "payment-service": 12.0}[svc]
121
+ )
122
+
123
+ def process_action(
124
+ self, action_type: str, params: Dict[str, Any], state: Dict[str, Any]
125
+ ) -> Tuple[Dict[str, Any], float, bool, str]:
126
+ reward = 0.0
127
+ done = False
128
+ message = ""
129
+ service = params.get("service", "").strip()
130
+
131
+ if action_type == "query_logs":
132
+ if not service:
133
+ return state, -0.02, False, "Parameter 'service' is required."
134
+ if service in state["logs_queried"]:
135
+ return state, 0.0, False, f"[Cached] Logs for {service} already retrieved."
136
+ state["logs_queried"].append(service)
137
+
138
+ down_services = ["api-gateway", "user-service", "order-service", "payment-service"]
139
+
140
+ if service in down_services:
141
+ reward = 0.08
142
+ service_logs = {
143
+ "api-gateway": (
144
+ "2024-11-15T09:31:00Z [ERROR] api-gateway: failed to resolve "
145
+ "user-service endpoint via config-service: "
146
+ "GET http://config-service/discover/user-service → "
147
+ "returned 'http://svc-mesh-BROKEN.internal:8080' (unreachable)\n"
148
+ "2024-11-15T09:31:01Z [ERROR] api-gateway: failed to resolve "
149
+ "order-service — same issue\n"
150
+ "2024-11-15T09:31:05Z [FATAL] api-gateway: no healthy upstreams "
151
+ "available — entering 503 mode\n"
152
+ "PATTERN: All service discovery calls returning broken URLs from config-service."
153
+ ),
154
+ "user-service": (
155
+ "2024-11-15T09:31:00Z [ERROR] user-service: startup failed — cannot "
156
+ "resolve db endpoint via config-service: received 'db-BROKEN.internal' "
157
+ "(expected 'db-primary.internal')\n"
158
+ "2024-11-15T09:31:02Z [FATAL] user-service: health check failed — "
159
+ "cannot connect to database\n"
160
+ "PATTERN: config-service returning incorrect service discovery data."
161
+ ),
162
+ "order-service": (
163
+ "2024-11-15T09:31:00Z [ERROR] order-service: failed to start — "
164
+ "config-service returned broken payment-service URL\n"
165
+ "2024-11-15T09:31:03Z [FATAL] order-service: dependency check failed, "
166
+ "exiting\n"
167
+ "NOTE: order-service v2.1.0 deployed at 07:45 ran fine until 09:30 "
168
+ "when config-service was updated."
169
+ ),
170
+ "payment-service": (
171
+ "2024-11-15T09:31:00Z [ERROR] payment-service: cannot resolve fraud-check "
172
+ "service — config-service lookup returned null endpoint\n"
173
+ "2024-11-15T09:31:05Z [FATAL] payment-service: aborting startup due to "
174
+ "missing required service dependencies\n"
175
+ ),
176
+ }
177
+ message = service_logs.get(service, "No logs found.")
178
+
179
+ elif service == "config-service":
180
+ reward = 0.12
181
+ message = (
182
+ "2024-11-15T09:28:00Z [INFO] config-service: v1.2.0 deployment started\n"
183
+ "2024-11-15T09:29:50Z [INFO] config-service: service discovery URLs updated "
184
+ "for datacenter migration\n"
185
+ "2024-11-15T09:30:00Z [INFO] config-service: v1.2.0 deployment complete\n"
186
+ "2024-11-15T09:30:05Z [WARN] config-service: 4 downstream services "
187
+ "reporting connection failures immediately after deploy\n"
188
+ "2024-11-15T09:30:10Z [ERROR] config-service: config validation failed "
189
+ "in post-deploy check — service_discovery_urls contain unreachable hosts\n"
190
+ "ROOT CAUSE CONFIRMED: config-service v1.2.0 deployed broken service "
191
+ "discovery URLs. All dependent services cannot resolve each other."
192
+ )
193
+ else:
194
+ reward = 0.02
195
+ message = f"No anomalies in logs for {service}."
196
+
197
+ elif action_type == "check_metrics":
198
+ if not service:
199
+ return state, -0.02, False, "Parameter 'service' is required."
200
+ if service in state["metrics_checked"]:
201
+ return state, 0.0, False, f"[Cached] Metrics for {service} already retrieved."
202
+ state["metrics_checked"].append(service)
203
+
204
+ if service in ["api-gateway", "user-service", "order-service", "payment-service"]:
205
+ reward = 0.06
206
+ message = (
207
+ f"{service} metrics:\n"
208
+ f" status: DOWN\n"
209
+ f" error_rate: 100% (all requests failing)\n"
210
+ f" last_healthy: 2024-11-15T09:30:02Z\n"
211
+ f" restart_attempts: 3 (all failed)\n"
212
+ f" failure_reason: dependency resolution failure at startup\n"
213
+ f"CORRELATES: All 4 services went down within 15 seconds of each other "
214
+ f"at 09:30 — timing matches config-service v1.2.0 deployment."
215
+ )
216
+ elif service == "config-service":
217
+ reward = 0.08
218
+ message = (
219
+ "config-service metrics:\n"
220
+ " status: healthy\n"
221
+ " cpu: 18%\n"
222
+ " requests_per_sec: 240\n"
223
+ " cache_hit_rate: 12% ← very low (normal: 95%+)\n"
224
+ " discovery_errors_per_sec: 180 ← HIGH\n"
225
+ " version: 1.2.0 (deployed 17min ago)\n"
226
+ "SUSPICIOUS: High discovery_errors and low cache_hit_rate after recent deploy."
227
+ )
228
+ else:
229
+ reward = 0.02
230
+ message = f"Metrics for {service}: Normal."
231
+
232
+ elif action_type == "check_config":
233
+ state["configs_checked"].append(service)
234
+ if service == "config-service":
235
+ reward = 0.15
236
+ message = (
237
+ "config-service LIVE CONFIG (v1.2.0):\n"
238
+ " service_discovery:\n"
239
+ " user-service: http://svc-mesh-BROKEN.dc2.internal:8080\n"
240
+ " order-service: http://svc-mesh-BROKEN.dc2.internal:8081\n"
241
+ " payment-service: http://svc-mesh-BROKEN.dc2.internal:8082\n"
242
+ " db-primary: http://db-BROKEN.dc2.internal:5432\n"
243
+ " api-gateway: http://gw-BROKEN.dc2.internal:80\n\n"
244
+ "config-service PREVIOUS CONFIG (v1.1.9):\n"
245
+ " service_discovery:\n"
246
+ " user-service: http://user-service.svc.cluster.local:8080 ✓\n"
247
+ " order-service: http://order-service.svc.cluster.local:8081 ✓\n"
248
+ " payment-service: http://payment-service.svc.cluster.local:8082 ✓\n"
249
+ " db-primary: http://db-primary.svc.cluster.local:5432 ✓\n\n"
250
+ "ROOT CAUSE CONFIRMED: v1.2.0 changed ALL service discovery URLs to "
251
+ "non-existent dc2.internal addresses. Datacenter migration was incomplete."
252
+ )
253
+ elif service in ["api-gateway", "user-service", "order-service", "payment-service"]:
254
+ reward = 0.04
255
+ message = (
256
+ f"{service} config appears normal. Service discovery endpoint "
257
+ f"points to config-service (as expected). The issue is in what "
258
+ f"config-service returns, not in {service}'s config itself."
259
+ )
260
+ else:
261
+ reward = 0.01
262
+ message = f"Config for {service}: Nothing unusual."
263
+
264
+ elif action_type == "examine_trace":
265
+ trace_id = params.get("trace_id", "unknown")
266
+ state["logs_queried"].append(f"trace:{trace_id}")
267
+ reward = 0.06
268
+ message = (
269
+ f"Trace {trace_id}:\n"
270
+ " api-gateway → [service discovery lookup] → config-service (2ms)\n"
271
+ " config-service → returned URL: http://svc-mesh-BROKEN.dc2.internal\n"
272
+ " api-gateway → [connection attempt to broken URL] → TIMEOUT after 5000ms\n"
273
+ " Root span: 100% of failures originate from bad service discovery response."
274
+ )
275
+
276
+ elif action_type == "restart_service":
277
+ if service in ["api-gateway", "user-service", "order-service", "payment-service"]:
278
+ if not state["config_service_rolledback"]:
279
+ state["services_restarted"].append(service)
280
+ # Restarting without fixing config does nothing
281
+ reward = -0.05
282
+ message = (
283
+ f"Restarted {service}... but it failed to start again.\n"
284
+ f" Startup error: cannot resolve service dependencies via config-service\n"
285
+ f" Status: still DOWN\n"
286
+ f"The underlying config-service issue must be fixed first."
287
+ )
288
+ else:
289
+ # After config fix, manual restart not needed (auto-recovery)
290
+ reward = 0.0
291
+ message = f"{service} already recovering after config-service rollback."
292
+ elif service == "config-service":
293
+ # Restarting config-service doesn't fix the bad config
294
+ state["services_restarted"].append(service)
295
+ reward = -0.08
296
+ message = (
297
+ "config-service restarted — but it loaded the same broken v1.2.0 config.\n"
298
+ " All downstream services still failing.\n"
299
+ " A restart does not fix a misconfiguration. Use rollback_deployment."
300
+ )
301
+ else:
302
+ reward = 0.0
303
+ message = f"{service} is healthy and does not need a restart."
304
+
305
+ elif action_type == "rollback_deployment":
306
+ if service == "config-service":
307
+ state["config_service_rolledback"] = True
308
+ self._check_recovery(state)
309
+ reward = 0.45
310
+ message = (
311
+ "✓ config-service rolled back from v1.2.0 → v1.1.9.\n"
312
+ " Service discovery URLs restored to cluster-internal addresses.\n"
313
+ " api-gateway: DOWN → healthy (restarted automatically)\n"
314
+ " user-service: DOWN → healthy (restarted automatically)\n"
315
+ " order-service: DOWN → healthy (restarted automatically)\n"
316
+ " payment-service: DOWN → healthy (restarted automatically)\n"
317
+ "All 4 services recovered within 45 seconds of config-service rollback."
318
+ )
319
+ elif service == "order-service":
320
+ # Red herring — order-service v2.1.0 was NOT the cause
321
+ state["rollbacks_attempted"][service] = True
322
+ state["wrong_rollbacks"] += 1
323
+ reward = -0.08
324
+ message = (
325
+ "Rolled back order-service to v2.0.8... but it immediately failed again.\n"
326
+ " Error: still cannot resolve service dependencies via config-service.\n"
327
+ " RESULT: order-service v2.1.0 was not the root cause. "
328
+ "The issue is upstream."
329
+ )
330
+ elif service in ["api-gateway", "user-service", "payment-service"]:
331
+ state["rollbacks_attempted"][service] = True
332
+ state["wrong_rollbacks"] += 1
333
+ reward = -0.06
334
+ message = (
335
+ f"Rolled back {service}... but it still cannot start.\n"
336
+ f" Error: service discovery failing — same as before.\n"
337
+ f" This service is not the root cause."
338
+ )
339
+ else:
340
+ reward = -0.02
341
+ message = f"Rolling back {service} has no effect on the current incident."
342
+
343
+ elif action_type == "scale_service":
344
+ reward = -0.05
345
+ message = (
346
+ "Scaling has no effect — services are failing due to misconfiguration, "
347
+ "not insufficient capacity."
348
+ )
349
+
350
+ elif action_type == "acknowledge_alert":
351
+ alert_id = params.get("alert_id", "")
352
+ for a in state["alerts"]:
353
+ if a["id"] == alert_id:
354
+ a["ack"] = True
355
+ reward = 0.01
356
+ message = f"Alert {alert_id} acknowledged."
357
+
358
+ elif action_type == "resolve_incident":
359
+ all_healthy = all(
360
+ state["services"][svc]["status"] == "healthy"
361
+ for svc in ["api-gateway", "user-service", "order-service", "payment-service"]
362
+ )
363
+ if all_healthy:
364
+ state["incident_resolved"] = True
365
+ done = True
366
+ reward = 0.25
367
+ message = (
368
+ "✓ Incident resolved.\n"
369
+ "Post-mortem: config-service v1.2.0 was deployed with incorrect service "
370
+ "discovery URLs targeting a non-existent dc2 datacenter. This caused all "
371
+ "dependent services to fail at startup. Rollback to v1.1.9 restored service.\n"
372
+ "Recommendation: Add config validation to deployment pipeline."
373
+ )
374
+ else:
375
+ still_down = [
376
+ s for s in ["api-gateway", "user-service", "order-service", "payment-service"]
377
+ if state["services"][s]["status"] != "healthy"
378
+ ]
379
+ reward = -0.05
380
+ message = (
381
+ f"Cannot resolve: {len(still_down)} services still down: "
382
+ f"{', '.join(still_down)}. Fix the root cause first."
383
+ )
384
+
385
+ else:
386
+ reward = -0.03
387
+ message = f"Unknown or inapplicable action: {action_type}."
388
+
389
+ return state, reward, done, message
390
+
391
+ def get_observation(self, state: Dict[str, Any], session_id: str, step: int) -> Observation:
392
+ services = {}
393
+ for name, s in state["services"].items():
394
+ services[name] = ServiceStatus(
395
+ name=name, status=s["status"],
396
+ cpu_percent=s["cpu"], memory_percent=s["memory"],
397
+ error_rate=s["error_rate"],
398
+ connections=s.get("connections"),
399
+ max_connections=s.get("max_connections"),
400
+ version=s.get("version", "1.0.0"),
401
+ replicas=s.get("replicas", 1),
402
+ )
403
+
404
+ alerts = [
405
+ Alert(
406
+ alert_id=a["id"], severity=a["sev"], service=a["svc"],
407
+ message=a["msg"], triggered_at=BASE_INCIDENT_TIME,
408
+ acknowledged=a["ack"],
409
+ )
410
+ for a in state["alerts"]
411
+ ]
412
+
413
+ return Observation(
414
+ session_id=session_id,
415
+ task_id=self.task_id,
416
+ step=step,
417
+ timestamp=BASE_INCIDENT_TIME,
418
+ alerts=alerts,
419
+ services=services,
420
+ available_actions=AVAILABLE_ACTIONS,
421
+ incident_resolved=state["incident_resolved"],
422
+ message="",
423
+ recent_deployments=state["recent_deployments"],
424
+ runbook_hints=[
425
+ "When multiple services fail simultaneously, look for a common dependency.",
426
+ "Check the timing: what changed just before the incident?",
427
+ "check_config reveals live runtime configuration values.",
428
+ "Restarting services without fixing the root cause will not help.",
429
+ "rollback_deployment reverts to the previous known-good version.",
430
+ ],
431
+ )
432
+
433
+ def grade(self, state: Dict[str, Any], history: List[Dict]) -> Tuple[float, Dict[str, float]]:
434
+ breakdown = {}
435
+ score = 0.0
436
+
437
+ # Root cause investigated?
438
+ root_investigated = (
439
+ "config-service" in state.get("configs_checked", []) or
440
+ "config-service" in state.get("logs_queried", []) or
441
+ "config-service" in state.get("metrics_checked", []) or
442
+ any(svc in state.get("logs_queried", [])
443
+ for svc in ["api-gateway", "user-service", "order-service", "payment-service"])
444
+ )
445
+ if root_investigated:
446
+ breakdown["investigated_root_cause"] = 0.15
447
+ score += 0.15
448
+
449
+ # config-service identified and rolled back?
450
+ if state.get("config_service_rolledback", False):
451
+ breakdown["correct_rollback"] = 0.40
452
+ score += 0.40
453
+
454
+ # All services recovered?
455
+ recovered = state.get("services_recovered", [])
456
+ if len(recovered) >= 4:
457
+ breakdown["full_recovery"] = 0.20
458
+ score += 0.20
459
+ elif len(recovered) >= 2:
460
+ breakdown["partial_recovery"] = 0.10
461
+ score += 0.10
462
+
463
+ # Incident formally resolved?
464
+ if state.get("incident_resolved", False):
465
+ breakdown["incident_resolved"] = 0.15
466
+ score += 0.15
467
+
468
+ # Efficiency bonus
469
+ steps = len(history)
470
+ if steps <= 4:
471
+ breakdown["efficiency_bonus"] = 0.10
472
+ score += 0.10
473
+ elif steps <= 7:
474
+ breakdown["efficiency_bonus"] = 0.07
475
+ score += 0.07
476
+ elif steps <= 12:
477
+ breakdown["efficiency_bonus"] = 0.03
478
+ score += 0.03
479
+
480
+ # Penalty for wrong rollbacks
481
+ wrong_rollbacks = state.get("wrong_rollbacks", 0)
482
+ if wrong_rollbacks > 0:
483
+ p = min(wrong_rollbacks * 0.08, 0.20)
484
+ breakdown["wrong_rollback_penalty"] = -p
485
+ score -= p
486
+
487
+ return round(min(max(score, 0.0), 1.0), 4), breakdown
baseline.py ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Baseline Inference Script — SRE Incident Response OpenEnv
4
+ ==========================================================
5
+ Runs a ReAct-style OpenAI agent against all three tasks and
6
+ reports reproducible baseline scores.
7
+
8
+ Usage:
9
+ export OPENAI_API_KEY="sk-..."
10
+ export OPENENV_BASE_URL="http://localhost:7860" # or your HF Space URL
11
+ python baseline.py
12
+
13
+ # Run specific tasks:
14
+ python baseline.py --tasks task1 task2
15
+
16
+ # Use a different model:
17
+ python baseline.py --model gpt-4o
18
+
19
+ Requirements:
20
+ pip install openai httpx rich
21
+ """
22
+
23
+ import os
24
+ import sys
25
+ import json
26
+ import re
27
+ import argparse
28
+ import time
29
+ from typing import Optional
30
+
31
+ import httpx
32
+
33
+ try:
34
+ from rich.console import Console
35
+ from rich.table import Table
36
+ from rich.panel import Panel
37
+ from rich import print as rprint
38
+ RICH = True
39
+ except ImportError:
40
+ RICH = False
41
+ Console = None
42
+
43
+
44
+ # ─── Config ──────────────────────────────────────────────────────────────────
45
+
46
+ DEFAULT_MODEL = "gpt-4o-mini"
47
+ DEFAULT_BASE_URL = os.environ.get("OPENENV_BASE_URL", "http://localhost:7860")
48
+ OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
49
+
50
+ SYSTEM_PROMPT = """You are an expert Site Reliability Engineer (SRE) responding to a production incident.
51
+ You will receive alerts, service statuses, and investigation results.
52
+ Your goal is to identify the root cause and resolve the incident efficiently.
53
+
54
+ At each step, respond with ONLY a valid JSON object in this exact format:
55
+ {"action_type": "<action>", "parameters": {<params>}}
56
+
57
+ Available actions:
58
+ - query_logs: {"service": "<name>"} — fetch recent logs for a service
59
+ - check_metrics: {"service": "<name>"} — get current metrics for a service
60
+ - check_config: {"service": "<name>"} — inspect live runtime configuration
61
+ - restart_service: {"service": "<name>"} — restart a service (use carefully)
62
+ - rollback_deployment: {"service": "<name>"} — roll back to previous version
63
+ - kill_query: {"source": "<service>"} — terminate long-running DB queries from a source
64
+ - scale_service: {"service": "<name>", "replicas": <int>} — change replica count
65
+ - examine_trace: {"trace_id": "<id>"} — examine distributed trace
66
+ - acknowledge_alert: {"alert_id": "<id>"} — acknowledge an alert
67
+ - resolve_incident: {} — mark incident as resolved (only when services are healthy)
68
+
69
+ SRE Investigation Strategy:
70
+ 1. Read ALL alerts and service statuses carefully
71
+ 2. Look at recent deployments — they are often correlated with incidents
72
+ 3. Use query_logs and check_metrics to gather evidence before acting
73
+ 4. Form a clear hypothesis about the root cause
74
+ 5. Apply the most targeted fix (prefer rollback over restart when deployment changed)
75
+ 6. Verify all affected services are healthy
76
+ 7. Call resolve_incident to complete the episode
77
+
78
+ Respond ONLY with JSON. No markdown. No explanation."""
79
+
80
+
81
+ # ─── Helpers ─────────────────────────────────────────────────────────────────
82
+
83
+ def log(msg: str, level: str = "INFO"):
84
+ prefix = {"INFO": "ℹ", "OK": "✓", "WARN": "⚠", "ERR": "✗"}.get(level, "•")
85
+ print(f" {prefix} {msg}")
86
+
87
+
88
+ def format_observation(obs: dict) -> str:
89
+ """Format observation dict into a concise prompt string."""
90
+ lines = [f"=== INCIDENT — Step {obs.get('step', 0)} ===\n"]
91
+
92
+ lines.append("ACTIVE ALERTS:")
93
+ for alert in obs.get("alerts", []):
94
+ ack = " [ACK]" if alert.get("acknowledged") else ""
95
+ sev = alert.get("severity", "?").upper()
96
+ lines.append(f" [{sev}]{ack} {alert.get('service')}: {alert.get('message')}")
97
+
98
+ lines.append("\nSERVICE STATUS:")
99
+ for name, svc in obs.get("services", {}).items():
100
+ conn = ""
101
+ if svc.get("connections") is not None:
102
+ conn = f" | conns: {svc['connections']}/{svc.get('max_connections', '?')}"
103
+ lines.append(
104
+ f" {name}: {svc.get('status', '?').upper()} | "
105
+ f"cpu: {svc.get('cpu_percent', 0):.1f}% | "
106
+ f"mem: {svc.get('memory_percent', 0):.1f}% | "
107
+ f"errors: {svc.get('error_rate', 0):.1f}/s | "
108
+ f"v{svc.get('version', '?')}{conn}"
109
+ )
110
+
111
+ if obs.get("recent_deployments"):
112
+ lines.append("\nRECENT DEPLOYMENTS:")
113
+ for dep in obs["recent_deployments"]:
114
+ lines.append(
115
+ f" {dep.get('service')}: v{dep.get('previous', '?')} → "
116
+ f"v{dep.get('version')} deployed at {dep.get('deployed_at')}"
117
+ )
118
+
119
+ if obs.get("message"):
120
+ lines.append(f"\nLAST ACTION RESULT:\n{obs['message']}")
121
+
122
+ if obs.get("runbook_hints"):
123
+ lines.append("\nRUNBOOK HINTS:")
124
+ for h in obs["runbook_hints"]:
125
+ lines.append(f" • {h}")
126
+
127
+ return "\n".join(lines)
128
+
129
+
130
+ def call_llm(client: httpx.Client, model: str, messages: list) -> str:
131
+ """Call OpenAI chat completions API."""
132
+ response = client.post(
133
+ "https://api.openai.com/v1/chat/completions",
134
+ headers={
135
+ "Authorization": f"Bearer {OPENAI_API_KEY}",
136
+ "Content-Type": "application/json",
137
+ },
138
+ json={
139
+ "model": model,
140
+ "messages": messages,
141
+ "max_tokens": 200,
142
+ "temperature": 0.0,
143
+ },
144
+ timeout=30.0,
145
+ )
146
+ response.raise_for_status()
147
+ return response.json()["choices"][0]["message"]["content"].strip()
148
+
149
+
150
+ def parse_action(text: str) -> dict:
151
+ """Parse JSON action from LLM output, with fallback."""
152
+ text = text.strip()
153
+ # Remove markdown code blocks if present
154
+ text = re.sub(r"```(?:json)?\s*|\s*```", "", text).strip()
155
+ try:
156
+ return json.loads(text)
157
+ except json.JSONDecodeError:
158
+ match = re.search(r'\{[^{}]*\}', text, re.DOTALL)
159
+ if match:
160
+ try:
161
+ return json.loads(match.group())
162
+ except json.JSONDecodeError:
163
+ pass
164
+ # Fallback: safe no-op
165
+ return {"action_type": "acknowledge_alert", "parameters": {"alert_id": "ALT-001"}}
166
+
167
+
168
+ # ─── Core Runner ─────────────────────────────────────────────────────────────
169
+
170
+ def run_task(
171
+ env_client: httpx.Client,
172
+ llm_client: httpx.Client,
173
+ task_id: str,
174
+ model: str,
175
+ max_steps: int,
176
+ verbose: bool = True,
177
+ ) -> dict:
178
+ """Run one complete episode for a task. Returns result dict."""
179
+
180
+ if verbose:
181
+ print(f"\n{'─'*60}")
182
+ print(f" Task: {task_id.upper()}")
183
+ print(f"{'─'*60}")
184
+
185
+ episode_log = []
186
+ score = 0.0
187
+ steps_taken = 0
188
+ session_id = None
189
+
190
+ try:
191
+ # ── Reset ────────────────────────────────────────────────────
192
+ reset_resp = env_client.post("/reset", json={"task_id": task_id, "seed": 42})
193
+ reset_resp.raise_for_status()
194
+ obs = reset_resp.json()
195
+ session_id = obs["session_id"]
196
+
197
+ if verbose:
198
+ task_name = obs.get("message", "").split("Task:")[1].split("(")[0].strip() \
199
+ if "Task:" in obs.get("message", "") else task_id
200
+ log(f"Session: {session_id[:8]}...", "INFO")
201
+ log(obs.get("message", ""), "INFO")
202
+
203
+ conversation = []
204
+ done = False
205
+
206
+ # ── Episode Loop ─────────────────────────────────────────────
207
+ for step_num in range(max_steps):
208
+ obs_text = format_observation(obs)
209
+ conversation.append({"role": "user", "content": obs_text})
210
+
211
+ # Trim conversation to last 4 turns (keep it focused)
212
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}]
213
+ messages += conversation[-8:]
214
+
215
+ # Get action from LLM
216
+ action_text = call_llm(llm_client, model, messages)
217
+ conversation.append({"role": "assistant", "content": action_text})
218
+
219
+ action_dict = parse_action(action_text)
220
+ action_type = action_dict.get("action_type", "unknown")
221
+ parameters = action_dict.get("parameters", {})
222
+
223
+ if verbose:
224
+ params_str = json.dumps(parameters) if parameters else "{}"
225
+ print(f" Step {step_num+1:2d}: {action_type}({params_str})", end="")
226
+
227
+ # Take step
228
+ step_resp = env_client.post("/step", json={
229
+ "session_id": session_id,
230
+ "action": {"action_type": action_type, "parameters": parameters},
231
+ })
232
+ step_resp.raise_for_status()
233
+ step_data = step_resp.json()
234
+
235
+ obs = step_data["observation"]
236
+ reward_val = step_data["reward"]["value"]
237
+ done = step_data["done"]
238
+ steps_taken = step_num + 1
239
+
240
+ if verbose:
241
+ reward_str = f"{reward_val:+.3f}"
242
+ current_score = step_data["info"].get("grader_score", 0.0)
243
+ print(f" → reward: {reward_str} | score: {current_score:.3f}")
244
+
245
+ episode_log.append({
246
+ "step": step_num + 1,
247
+ "action_type": action_type,
248
+ "parameters": parameters,
249
+ "reward": reward_val,
250
+ "message_preview": obs.get("message", "")[:150],
251
+ })
252
+
253
+ if done:
254
+ break
255
+
256
+ # ── Get Final Grade ───────────────────────────────────────────
257
+ grader_resp = env_client.post("/grader", json={"session_id": session_id})
258
+ grader_resp.raise_for_status()
259
+ grader_data = grader_resp.json()
260
+ score = grader_data["score"]
261
+ breakdown = grader_data.get("breakdown", {})
262
+
263
+ if verbose:
264
+ print(f"\n {'─'*30}")
265
+ log(f"Final score: {score:.4f}", "OK" if score >= 0.6 else "WARN")
266
+ log(f"Steps taken: {steps_taken}", "INFO")
267
+ if breakdown:
268
+ log("Breakdown:", "INFO")
269
+ for k, v in breakdown.items():
270
+ print(f" {k}: {v:+.4f}")
271
+
272
+ except Exception as e:
273
+ if verbose:
274
+ log(f"Error: {e}", "ERR")
275
+ episode_log.append({"error": str(e)})
276
+
277
+ # Get task info for name/difficulty
278
+ tasks_resp = env_client.get("/tasks")
279
+ task_info = {}
280
+ if tasks_resp.status_code == 200:
281
+ for t in tasks_resp.json().get("tasks", []):
282
+ if t["task_id"] == task_id:
283
+ task_info = t
284
+ break
285
+
286
+ return {
287
+ "task_id": task_id,
288
+ "task_name": task_info.get("name", task_id),
289
+ "difficulty": task_info.get("difficulty", "?"),
290
+ "score": score,
291
+ "steps_taken": steps_taken,
292
+ "success": score >= task_info.get("passing_score", 0.6),
293
+ "episode_log": episode_log,
294
+ }
295
+
296
+
297
+ # ─── Main ─────────────────────────────────────────────────────────────────────
298
+
299
+ def main():
300
+ parser = argparse.ArgumentParser(
301
+ description="Run baseline agent against SRE Incident Response environment"
302
+ )
303
+ parser.add_argument("--model", default=DEFAULT_MODEL, help="OpenAI model to use")
304
+ parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Environment base URL")
305
+ parser.add_argument("--max-steps", type=int, default=12, help="Max steps per episode")
306
+ parser.add_argument("--tasks", nargs="+", default=["task1", "task2", "task3"],
307
+ help="Tasks to run (task1, task2, task3)")
308
+ parser.add_argument("--quiet", action="store_true", help="Suppress step-by-step output")
309
+ parser.add_argument("--output", help="Save results to JSON file")
310
+ args = parser.parse_args()
311
+
312
+ if not OPENAI_API_KEY:
313
+ print("ERROR: OPENAI_API_KEY environment variable not set.")
314
+ sys.exit(1)
315
+
316
+ print(f"\n{'═'*60}")
317
+ print(f" SRE Incident Response — Baseline Evaluation")
318
+ print(f"{'═'*60}")
319
+ print(f" Model: {args.model}")
320
+ print(f" Env URL: {args.base_url}")
321
+ print(f" Tasks: {', '.join(args.tasks)}")
322
+ print(f" MaxSteps: {args.max_steps}")
323
+ print(f"{'═'*60}")
324
+
325
+ # Verify environment is reachable
326
+ with httpx.Client(base_url=args.base_url, timeout=30.0) as env_client:
327
+ try:
328
+ health = env_client.get("/health")
329
+ health.raise_for_status()
330
+ print(f"\n ✓ Environment healthy: {health.json()}")
331
+ except Exception as e:
332
+ print(f"\n ✗ Environment not reachable at {args.base_url}: {e}")
333
+ sys.exit(1)
334
+
335
+ results = []
336
+ start = time.time()
337
+
338
+ with httpx.Client(timeout=60.0) as llm_client:
339
+ for task_id in args.tasks:
340
+ result = run_task(
341
+ env_client=env_client,
342
+ llm_client=llm_client,
343
+ task_id=task_id,
344
+ model=args.model,
345
+ max_steps=args.max_steps,
346
+ verbose=not args.quiet,
347
+ )
348
+ results.append(result)
349
+ time.sleep(0.5) # Rate limiting courtesy
350
+
351
+ # ── Summary ───────────────────────────────────────────────────────────────
352
+ elapsed = time.time() - start
353
+ mean_score = sum(r["score"] for r in results) / len(results) if results else 0.0
354
+ passed = sum(1 for r in results if r["success"])
355
+
356
+ print(f"\n{'═'*60}")
357
+ print(f" BASELINE RESULTS SUMMARY")
358
+ print(f"{'═'*60}")
359
+ print(f" {'Task':<35} {'Diff':<8} {'Score':<8} {'Steps':<7} {'Status'}")
360
+ print(f" {'─'*55}")
361
+ for r in results:
362
+ status = "✓ PASS" if r["success"] else "✗ FAIL"
363
+ print(
364
+ f" {r['task_name']:<35} {r['difficulty']:<8} "
365
+ f"{r['score']:.4f} {r['steps_taken']:<7} {status}"
366
+ )
367
+ print(f" {'─'*55}")
368
+ print(f" {'Mean Score':<35} {'':8} {mean_score:.4f}")
369
+ print(f" Tasks passed: {passed}/{len(results)}")
370
+ print(f" Elapsed: {elapsed:.1f}s")
371
+ print(f"{'═'*60}\n")
372
+
373
+ # ── Save results ──────────────────────────────────────────────────────────
374
+ output = {
375
+ "model": args.model,
376
+ "environment": "sre-incident-response",
377
+ "results": results,
378
+ "summary": {
379
+ "mean_score": round(mean_score, 4),
380
+ "tasks_passed": passed,
381
+ "total_tasks": len(results),
382
+ "elapsed_seconds": round(elapsed, 1),
383
+ },
384
+ }
385
+
386
+ if args.output:
387
+ with open(args.output, "w") as f:
388
+ json.dump(output, f, indent=2)
389
+ print(f" Results saved to {args.output}")
390
+ else:
391
+ # Always save a baseline_results.json for reproducibility
392
+ with open("baseline_results.json", "w") as f:
393
+ json.dump(output, f, indent=2)
394
+ print(f" Results saved to baseline_results.json")
395
+
396
+ # Exit code: 0 if all tasks pass, 1 otherwise
397
+ sys.exit(0 if passed == len(results) else 1)
398
+
399
+
400
+ if __name__ == "__main__":
401
+ main()
openenv.yaml ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: sre-incident-response
2
+ version: "1.0.0"
3
+ description: >
4
+ An OpenEnv environment where an AI agent acts as an on-call Site Reliability Engineer.
5
+ The agent receives production incident alerts, investigates root causes through logs,
6
+ metrics, and configuration inspection, then applies targeted remediation actions to
7
+ restore service health. Three tasks of increasing difficulty model real SRE workflows.
8
+
9
+ author: SRE Incident Response Environment
10
+ license: MIT
11
+ tags:
12
+ - openenv
13
+ - sre
14
+ - incident-response
15
+ - devops
16
+ - real-world
17
+ - multi-step
18
+
19
+ environment:
20
+ type: text-based
21
+ episodic: true
22
+ deterministic: true
23
+ observable: partial # agent must investigate to reveal full state
24
+
25
+ observation_space:
26
+ type: structured
27
+ description: >
28
+ JSON object containing active alerts, service health metrics (CPU, memory,
29
+ error rate, connection counts), investigation results (logs, metrics, configs),
30
+ recent deployment history, available actions, and contextual runbook hints.
31
+ fields:
32
+ session_id: string
33
+ task_id: string
34
+ step: integer
35
+ timestamp: string (ISO 8601)
36
+ alerts:
37
+ type: array
38
+ items:
39
+ alert_id: string
40
+ severity: critical or warning or info
41
+ service: string
42
+ message: string
43
+ triggered_at: string
44
+ acknowledged: boolean
45
+ services:
46
+ type: object
47
+ description: "Map of service_name → ServiceStatus"
48
+ value_fields:
49
+ status: healthy or degraded or down or unknown
50
+ cpu_percent: float [0, 100]
51
+ memory_percent: float [0, 100]
52
+ error_rate: float (errors/second)
53
+ connections: integer | null
54
+ max_connections: integer | null
55
+ version: string
56
+ replicas: integer
57
+ logs: array of LogEntry (populated after query_logs action)
58
+ metrics: array of MetricPoint (populated after check_metrics action)
59
+ available_actions: array of strings
60
+ incident_resolved: boolean
61
+ message: string (result of last action)
62
+ recent_deployments: array
63
+ runbook_hints: array of strings
64
+
65
+ action_space:
66
+ type: discrete+parametric
67
+ description: >
68
+ Categorical action type with optional typed parameters. The agent selects
69
+ an action_type and provides relevant parameters.
70
+ actions:
71
+ query_logs:
72
+ description: Fetch recent log entries for a service
73
+ parameters:
74
+ service: {type: string, required: true}
75
+ check_metrics:
76
+ description: Retrieve current metrics for a service
77
+ parameters:
78
+ service: {type: string, required: true}
79
+ check_config:
80
+ description: Inspect live runtime configuration of a service
81
+ parameters:
82
+ service: {type: string, required: true}
83
+ restart_service:
84
+ description: Restart a service with rolling restart
85
+ parameters:
86
+ service: {type: string, required: true}
87
+ rollback_deployment:
88
+ description: Roll back service to previous deployment version
89
+ parameters:
90
+ service: {type: string, required: true}
91
+ kill_query:
92
+ description: Terminate long-running DB queries from a specific source
93
+ parameters:
94
+ source: {type: string, required: true, description: "Application holding the queries"}
95
+ scale_service:
96
+ description: Change the number of replicas for a service
97
+ parameters:
98
+ service: {type: string, required: true}
99
+ replicas: {type: integer, required: true, min: 1, max: 20}
100
+ examine_trace:
101
+ description: Examine a distributed trace to identify slow spans
102
+ parameters:
103
+ trace_id: {type: string, required: true}
104
+ acknowledge_alert:
105
+ description: Acknowledge an alert to stop paging
106
+ parameters:
107
+ alert_id: {type: string, required: true}
108
+ resolve_incident:
109
+ description: Mark incident as resolved (terminal action)
110
+ parameters: {}
111
+
112
+ reward:
113
+ type: dense
114
+ range: [-inf, 1.0]
115
+ description: >
116
+ Per-step shaped rewards guide investigation and remediation. Positive rewards
117
+ for relevant investigation (+0.06–0.15) and correct fixes (+0.20–0.45).
118
+ Negative rewards for destructive or irrelevant actions (-0.05–-0.15).
119
+ Terminal reward on correct resolution (+0.25–0.30). Efficiency bonus for
120
+ fewer steps. Penalty for max-step timeout.
121
+
122
+ tasks:
123
+ - id: task1
124
+ name: "CPU Spike Investigation"
125
+ description: >
126
+ The web-api service is consuming 95% CPU due to a memory leak in v2.3.1
127
+ (connection pool recycling disabled). Agent must investigate logs/metrics
128
+ and restart or roll back the service.
129
+ difficulty: easy
130
+ max_steps: 15
131
+ passing_score: 0.60
132
+ optimal_steps: 3
133
+ grader:
134
+ investigated_root_service: 0.15
135
+ service_remediated: 0.45
136
+ incident_resolved: 0.25
137
+ efficiency_bonus: 0.15
138
+
139
+ - id: task2
140
+ name: "Database Connection Pool Exhaustion"
141
+ description: >
142
+ db-primary connection pool is exhausted (100/100). analytics-worker v1.0.9
143
+ introduced unbounded full-table scans with no query timeout, holding 78/100
144
+ connections. Dependent services (payment-api, user-service) are timing out.
145
+ Restarting the DB worsens the situation.
146
+ difficulty: medium
147
+ max_steps: 18
148
+ passing_score: 0.60
149
+ optimal_steps: 4
150
+ grader:
151
+ root_cause_identified: 0.20
152
+ correct_attribution: 0.30
153
+ db_recovered: 0.20
154
+ incident_resolved: 0.20
155
+ efficiency_bonus: 0.10
156
+
157
+ - id: task3
158
+ name: "Cascading Service Failure"
159
+ description: >
160
+ Four services (api-gateway, user-service, order-service, payment-service)
161
+ are simultaneously down. config-service v1.2.0 was deployed 17 minutes ago
162
+ with broken service discovery URLs. All downstream services fail to resolve
163
+ each other. Red herrings: user-service memory warning, order-service recent
164
+ deployment. Restarting individual services has no effect.
165
+ difficulty: hard
166
+ max_steps: 20
167
+ passing_score: 0.60
168
+ optimal_steps: 3
169
+ grader:
170
+ investigated_root_cause: 0.15
171
+ correct_rollback: 0.40
172
+ full_recovery: 0.20
173
+ incident_resolved: 0.15
174
+ efficiency_bonus: 0.10
175
+
176
+ endpoints:
177
+ reset: "POST /reset"
178
+ step: "POST /step"
179
+ state: "GET /state"
180
+ tasks: "GET /tasks"
181
+ grader: "POST /grader"
182
+ baseline: "POST /baseline"
183
+ health: "GET /health"
184
+
185
+ baseline:
186
+ model: gpt-4o-mini
187
+ seed: 42
188
+ expected_scores:
189
+ task1: 0.85
190
+ task2: 0.65
191
+ task3: 0.55
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi==0.115.5
2
+ uvicorn[standard]==0.32.1
3
+ pydantic==2.10.3
4
+ httpx==0.28.1
5
+ python-multipart==0.0.19
6
+ pyyaml==6.0.2