Spaces:
Sleeping
Sleeping
Pranav Dhiran commited on
Commit ·
44c4c2d
0
Parent(s):
feat: SRE Incident Response OpenEnv v1.0.0
Browse files- .gitignore +13 -0
- Dockerfile +45 -0
- README.md +0 -0
- app/__init__.py +1 -0
- app/environment.py +216 -0
- app/main.py +436 -0
- app/models.py +181 -0
- app/tasks/__init__.py +11 -0
- app/tasks/base.py +137 -0
- app/tasks/task1.py +359 -0
- app/tasks/task2.py +445 -0
- app/tasks/task3.py +487 -0
- baseline.py +401 -0
- openenv.yaml +191 -0
- requirements.txt +6 -0
.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
|