Spaces:
Sleeping
Sleeping
File size: 16,050 Bytes
822ca51 5e2a88b 822ca51 5e2a88b 822ca51 5e2a88b 822ca51 5e2a88b 822ca51 c979cbb 822ca51 c979cbb 822ca51 5e2a88b 822ca51 5e2a88b 822ca51 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 | """DBEREnvironment -- the core OpenEnv Environment implementation.
This class wires together:
- fixtures (corrupted DB per task, golden DB for grading)
- safety (pre/post-execution guards)
- grader (terminal score 0.0-1.0)
- reward shaping (intermediate signals)
Episode lifecycle:
reset(seed, task_id) -> fresh DB, clean state, first observation
step(action) -> execute SQL or submit; returns updated observation
state -> server-side state snapshot
See PROMPT.md sec2 for the full specification.
"""
from __future__ import annotations
import re
import sqlite3
from typing import Any, Dict, List, Optional
from openenv.core.env_server.interfaces import Environment, EnvironmentMetadata
from db_er.models import DBERAction, DBERObservation, DBERState
from server.fixtures import (
CORE_TABLES,
GOLDEN_ROW_COUNTS,
TASK_TICKETS,
create_corrupted_db,
get_initial_row_counts,
)
from server.grader import compute_score, compute_violations
from server.safety import (
SafetyViolation,
budget_cost,
check_post_execution,
check_pre_execution,
classify_sql,
)
# Module-level shared episode state
# openenv-core creates a NEW DBEREnvironment() instance per HTTP request
# (reset_handler and step_handler each call self._env_factory()). All state
# must therefore live outside any instance - in this module-level object whose
# attributes are mutated in-place so every DBEREnvironment instance sees them.
class _EpisodeState:
def __init__(self) -> None:
self.conn: Optional[sqlite3.Connection] = None
self.task_id: int = 1
self.budget: int = 100
self.violation_count: int = 0
self.initial_row_counts: Dict[str, int] = {}
self.safety_row_counts: Dict[str, int] = {}
self.query_history: Dict[str, int] = {}
self.step_count: int = 0
self.episode_id: Optional[str] = None
self.done: bool = False
self.last_obs: Optional[DBERObservation] = None
_S = _EpisodeState() # singleton - shared by all DBEREnvironment instances
class DBEREnvironment(Environment[DBERAction, DBERObservation, DBERState]):
"""Database Emergency Response OpenEnv environment.
Supports 5 tasks of increasing difficulty:
1 -- Phantom Duplicates (Easy)
2 -- Cascading Failure (Medium)
3 -- Payroll Black Hole (Medium-Hard)
4 -- Schema Drift (Hard)
5 -- Referential Maze (Very Hard)
"""
SUPPORTS_CONCURRENT_SESSIONS = False
def get_metadata(self) -> EnvironmentMetadata:
return EnvironmentMetadata(
name="DB-ER: Database Emergency Response",
description=(
"An OpenEnv benchmark where an AI agent is paged into a production "
"database incident and must diagnose and repair corruption using raw "
"SQL queries under a strict action budget. Features 5 tasks spanning "
"duplicate cleanup, FK restoration, payroll migration, schema repair, "
"and multi-table referential integrity recovery. Graded deterministically "
"via F1 score on exact row sets against a hidden golden database."
),
version="1.0.0",
author="Team maxout (Aadi Joshi, Kavya Bhand)",
documentation_url="https://huggingface.co/spaces/aadi-joshi/openenvhack",
)
# OpenEnv API
def reset(
self,
seed: Optional[int] = None,
episode_id: Optional[str] = None,
task_id: Optional[int] = None,
**kwargs,
) -> DBERObservation:
"""Start a new episode.
Args:
seed: Controls task selection when *task_id* is not given.
seed % 5 -> 0=task 1, 1=task 2, 2=task 3, 3=task 4, 4=task 5.
episode_id: Opaque identifier stored in state.
task_id: Override task selection (1-5).
"""
# 1. Pick task
if task_id is not None:
if task_id not in (1, 2, 3, 4, 5):
raise ValueError(f"task_id must be 1, 2, 3, 4, or 5 -- got {task_id!r}")
_S.task_id = task_id
elif seed is not None:
_S.task_id = (seed % 5) + 1
else:
_S.task_id = 1
# 2. Spin up a fresh corrupted DB
if _S.conn is not None:
try:
_S.conn.close()
except Exception:
pass
_S.conn = create_corrupted_db(_S.task_id)
_S.initial_row_counts = get_initial_row_counts(_S.conn, _S.task_id)
# Safety floor = min(corrupted_initial, golden) per table.
# - Task 1 (users 11->8 fix): min(11,8)=8 -> can delete 3 dupes, can't go below 8
# - Task 2 (products 9->12 fix): min(9,12)=9 -> can INSERT more, can't delete any
# - Task 3 (employees_old 18->15 fix): min(18,15)=15 -> can delete 3 dupes
golden = GOLDEN_ROW_COUNTS[_S.task_id]
_S.safety_row_counts = {
table: min(_S.initial_row_counts.get(table, 0), golden.get(table, 0))
for table in CORE_TABLES[_S.task_id]
}
# 3. Reset episode bookkeeping
_S.budget = 100
_S.violation_count = compute_violations(_S.conn, _S.task_id)
_S.query_history = {}
_S.step_count = 0
_S.episode_id = episode_id
_S.done = False
# 4. Build and return initial observation
obs = self._build_observation(
last_action_type="NONE",
last_query_result=[],
error_logs="",
reward=None,
done=False,
)
_S.last_obs = obs
return obs
def step(
self,
action: DBERAction,
timeout_s: Optional[float] = None,
**kwargs,
) -> DBERObservation:
"""Execute one action and return the resulting observation."""
if _S.conn is None or _S.done:
raise RuntimeError(
"Environment not initialised or episode already done. Call reset() first."
)
_S.step_count += 1
if action.action_type == "execute_sql":
return self._handle_execute_sql(action)
elif action.action_type == "submit_resolution":
return self._handle_submit_resolution(action)
else:
# Unknown action type - count as an error
obs = self._build_observation(
last_action_type="ERROR",
last_query_result=[],
error_logs=f"Unknown action_type: {action.action_type!r}",
reward=-0.05,
done=False,
)
_S.last_obs = obs
return obs
@property
def state(self) -> DBERState:
"""Server-side state snapshot (for GET /state)."""
return DBERState(
task_id=_S.task_id,
budget_remaining=_S.budget,
violation_count=_S.violation_count,
initial_row_counts=_S.initial_row_counts,
query_history=dict(_S.query_history),
episode_id=_S.episode_id,
step_count=_S.step_count,
)
def close(self) -> None:
# No-op: openenv-core calls close() after each HTTP request, but the
# SQLite connection must persist across requests in _S (the singleton).
pass
# Private helpers
def _handle_execute_sql(self, action: DBERAction) -> DBERObservation:
query = (action.query or "").strip()
if not query:
return self._build_observation(
last_action_type="ERROR",
last_query_result=[],
error_logs="execute_sql action requires a non-empty 'query' field.",
reward=-0.05,
done=False,
)
sql_type = classify_sql(query)
core_tables = CORE_TABLES[_S.task_id]
# Pre-execution safety check
violation = check_pre_execution(query, core_tables)
if violation:
_S.done = True
obs = self._build_observation(
last_action_type="ERROR",
last_query_result=[],
error_logs=violation.message,
reward=-1.0,
done=True,
)
_S.last_obs = obs
return obs
# Budget check
cost = budget_cost(sql_type)
if _S.budget < cost:
_S.done = True
obs = self._build_observation(
last_action_type="ERROR",
last_query_result=[],
error_logs=(
f"Budget exhausted. Remaining={_S.budget}, "
f"required={cost} for {sql_type}. Episode ended."
),
reward=-0.5,
done=True,
)
_S.last_obs = obs
return obs
# Spam penalty
spam_penalty = 0.0
if sql_type in ("SELECT", "META"):
norm = _normalise_query(query)
count = _S.query_history.get(norm, 0) + 1
_S.query_history[norm] = count
if count >= 3:
spam_penalty = -0.05
# Execute the SQL
_S.budget -= cost
prev_violation_count = _S.violation_count
result_rows: List[Dict[str, Any]] = []
error_msg = ""
action_type_label = sql_type
try:
if sql_type == "MUTATION":
# Use a savepoint so we can rollback on post-execution safety failure.
# IMPORTANT: do NOT call conn.commit() before releasing the savepoint --
# commit() would persist the changes before we can check row counts.
_S.conn.execute("SAVEPOINT before_mutation")
try:
cursor = _S.conn.execute(query)
affected = cursor.rowcount
# Post-execution safety check (pre-commit)
# Use golden row counts as floor so agents can delete corrupted rows
post_violation = check_post_execution(
_S.conn, _S.safety_row_counts
)
if post_violation:
# Roll back everything to before the mutation
_S.conn.execute("ROLLBACK TO before_mutation")
_S.conn.execute("RELEASE before_mutation")
_S.done = True
obs = self._build_observation(
last_action_type="ERROR",
last_query_result=[],
error_logs=post_violation.message,
reward=-1.0,
done=True,
)
_S.last_obs = obs
return obs
# Safe: commit by releasing the savepoint
_S.conn.execute("RELEASE before_mutation")
# Intermediate reward for 0-row mutations
if affected == 0:
spam_penalty += -0.1 # -0.1 for 0-row UPDATE/DELETE
result_rows = [{"rows_affected": affected}]
action_type_label = "MUTATION"
except sqlite3.Error as exc:
try:
_S.conn.execute("ROLLBACK TO before_mutation")
_S.conn.execute("RELEASE before_mutation")
except Exception:
pass
error_msg = f"SQLite error: {exc}"
action_type_label = "ERROR"
else:
# SELECT / PRAGMA / META
cursor = _S.conn.execute(query)
rows = cursor.fetchall()
if rows:
cols = [d[0] for d in cursor.description or []]
result_rows = [dict(zip(cols, row)) for row in rows]
action_type_label = sql_type
except sqlite3.Error as exc:
error_msg = f"SQLite error: {exc}"
action_type_label = "ERROR"
except Exception as exc:
error_msg = f"Unexpected error: {exc}"
action_type_label = "ERROR"
# Recompute violations
_S.violation_count = compute_violations(_S.conn, _S.task_id)
# Intermediate reward for reducing violations
violation_reward = 0.0
if _S.violation_count < prev_violation_count:
reduction = prev_violation_count - _S.violation_count
violation_reward = 0.2 * reduction
total_reward = violation_reward + spam_penalty
# Budget exhausted mid-episode?
if _S.budget <= 0:
_S.done = True
obs = self._build_observation(
last_action_type=action_type_label,
last_query_result=result_rows if not error_msg else error_msg,
error_logs=error_msg,
reward=total_reward if total_reward != 0 else None,
done=_S.done,
)
_S.last_obs = obs
return obs
def _handle_submit_resolution(self, action: DBERAction) -> DBERObservation:
"""Trigger the terminal grader and end the episode."""
_S.done = True
# Run the deterministic grader
terminal_score = compute_score(_S.conn, _S.task_id)
# False submission penalty - if the DB still has unresolved violations,
# the agent submitted too early. Apply -0.5 regardless of grader score.
current_violations = compute_violations(_S.conn, _S.task_id)
if current_violations > 0:
final_reward = terminal_score - 0.5
else:
final_reward = terminal_score
obs = self._build_observation(
last_action_type="META",
last_query_result=[
{
"grader_score": terminal_score,
"message": (
f"Episode complete. Grader score: {terminal_score:.4f}. "
f"Notes: {action.notes or '(none)'}"
),
}
],
error_logs="",
reward=final_reward,
done=True,
)
_S.last_obs = obs
return obs
# Observation builder
def _build_observation(
self,
last_action_type: str,
last_query_result,
error_logs: str,
reward: Optional[float],
done: bool,
) -> DBERObservation:
db_summary = _get_db_summary(_S.conn) if _S.conn else {}
return DBERObservation(
incident_ticket=TASK_TICKETS[_S.task_id],
task_id=_S.task_id,
last_action_type=last_action_type,
last_query_result=last_query_result,
error_logs=error_logs,
db_summary=db_summary,
violation_count=_S.violation_count,
budget_remaining=_S.budget,
step_count=_S.step_count,
done=done,
reward=reward,
)
# Utilities
def _normalise_query(query: str) -> str:
"""Normalise a SQL query for spam detection (case-fold + collapse whitespace)."""
q = query.upper()
q = re.sub(r"\s+", " ", q).strip()
return q
def _get_db_summary(conn: Optional[sqlite3.Connection]) -> Dict[str, Any]:
"""Return {"tables": [...], "row_counts": {...}} from the live connection."""
if conn is None:
return {"tables": [], "row_counts": {}}
try:
tables = [
row[0]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
).fetchall()
]
row_counts = {}
for table in tables:
row = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()
row_counts[table] = row[0]
return {"tables": tables, "row_counts": row_counts}
except Exception:
return {"tables": [], "row_counts": {}}
|