Spaces:
Sleeping
Sleeping
Ajjack4 commited on
Commit ·
125deae
0
Parent(s):
initial submission — Dev Environment Debugger OpenEnv hackathon
Browse files- Dockerfile +19 -0
- README.md +242 -0
- api.py +151 -0
- baseline.py +214 -0
- environment.py +391 -0
- models.py +45 -0
- openenv.yaml +72 -0
- requirements.txt +5 -0
- tasks.py +150 -0
Dockerfile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install dependencies
|
| 6 |
+
COPY requirements.txt .
|
| 7 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 8 |
+
|
| 9 |
+
# Copy source
|
| 10 |
+
COPY . .
|
| 11 |
+
|
| 12 |
+
# HuggingFace Spaces uses port 7860
|
| 13 |
+
EXPOSE 7860
|
| 14 |
+
|
| 15 |
+
# Health check
|
| 16 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
| 17 |
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')"
|
| 18 |
+
|
| 19 |
+
CMD ["python", "api.py"]
|
README.md
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dev Environment Debugger
|
| 2 |
+
|
| 3 |
+
An [OpenEnv](https://huggingface.co/openenv) environment where an AI agent debugs a broken multi-service development stack. The agent must read logs, inspect configurations, fix environment variables, and restart services to restore a healthy system — exactly as a senior engineer would during onboarding or an incident.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## Motivation
|
| 8 |
+
|
| 9 |
+
Setting up and debugging a local development environment is one of the most time-consuming tasks in software engineering. Developers routinely lose days diagnosing misconfigured ports, missing env vars, and cascading service failures. No existing agent benchmark targets this problem.
|
| 10 |
+
|
| 11 |
+
This environment fills that gap: it tests whether an agent can **reason across multiple failure signals, take stateful corrective actions, and know when the system is healthy**.
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## Environment Description
|
| 16 |
+
|
| 17 |
+
Simulates a realistic 4-service application stack:
|
| 18 |
+
|
| 19 |
+
```
|
| 20 |
+
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
| 21 |
+
│ Reverse │────▶│ Backend │────▶│ Database │
|
| 22 |
+
│ Proxy │ │ API │ │ (Postgres) │
|
| 23 |
+
└──────────────┘ └──────┬───────┘ └──────────────┘
|
| 24 |
+
│
|
| 25 |
+
┌──────▼───────┐
|
| 26 |
+
│ Worker │
|
| 27 |
+
│ Process │
|
| 28 |
+
└──────────────┘
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
All services run **in memory** — no real Docker, no real ports. The environment is fully deterministic and self-contained.
|
| 32 |
+
|
| 33 |
+
At the start of each episode, one or more faults are injected (wrong env vars, missing config). The agent must fix them all.
|
| 34 |
+
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
+
## Observation Space
|
| 38 |
+
|
| 39 |
+
```json
|
| 40 |
+
{
|
| 41 |
+
"step": 3,
|
| 42 |
+
"services": {
|
| 43 |
+
"proxy": { "status": "healthy", "port": 80 },
|
| 44 |
+
"api": { "status": "error", "port": 8000 },
|
| 45 |
+
"database": { "status": "healthy", "port": 5432 },
|
| 46 |
+
"worker": { "status": "error", "port": null }
|
| 47 |
+
},
|
| 48 |
+
"last_action_result": {
|
| 49 |
+
"success": false,
|
| 50 |
+
"message": "API failed to start: cannot connect to database on port 5433"
|
| 51 |
+
},
|
| 52 |
+
"available_actions": ["read_logs", "inspect_env", "edit_env", "restart_service", "run_healthcheck", "submit"],
|
| 53 |
+
"done": false
|
| 54 |
+
}
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
---
|
| 58 |
+
|
| 59 |
+
## Action Space
|
| 60 |
+
|
| 61 |
+
| Action | Required Params | Description |
|
| 62 |
+
|---|---|---|
|
| 63 |
+
| `read_logs` | `service` | Read error logs for a service |
|
| 64 |
+
| `inspect_env` | `service` | View environment variables for a service |
|
| 65 |
+
| `edit_env` | `service`, `key`, `value` | Change an environment variable |
|
| 66 |
+
| `restart_service` | `service` | Restart a service after config fix |
|
| 67 |
+
| `run_healthcheck` | `service` | Get current health status |
|
| 68 |
+
| `submit` | — | Declare environment fixed, end episode |
|
| 69 |
+
|
| 70 |
+
Valid services: `api`, `worker`, `database`, `proxy`
|
| 71 |
+
|
| 72 |
+
---
|
| 73 |
+
|
| 74 |
+
## Reward Function
|
| 75 |
+
|
| 76 |
+
| Event | Reward |
|
| 77 |
+
|---|---|
|
| 78 |
+
| Read logs before fixing (correct diagnosis) | +0.05 |
|
| 79 |
+
| Inspect env vars | +0.02 |
|
| 80 |
+
| Correct env var edit (fixes a known fault) | +0.10 |
|
| 81 |
+
| Service transitions unhealthy → healthy | +0.30 |
|
| 82 |
+
| Submit with all services healthy | +0.50 |
|
| 83 |
+
| Restart service without fixing root cause | -0.10 |
|
| 84 |
+
| Redundant/invalid action | -0.05 |
|
| 85 |
+
| Submit while services still unhealthy | -0.30 |
|
| 86 |
+
| Exceed max steps (25) | -0.20 |
|
| 87 |
+
|
| 88 |
+
---
|
| 89 |
+
|
| 90 |
+
## Tasks
|
| 91 |
+
|
| 92 |
+
### Task 1 — Single Service Fault (Easy)
|
| 93 |
+
One service is down because `DB_PORT` is set to `5433` instead of `5432`.
|
| 94 |
+
|
| 95 |
+
**Agent must:** read API logs → inspect API env → fix `DB_PORT` → restart API → submit.
|
| 96 |
+
|
| 97 |
+
**Expected score:** ~0.85 (GPT-4o-mini)
|
| 98 |
+
|
| 99 |
+
---
|
| 100 |
+
|
| 101 |
+
### Task 2 — Multi-Fault Diagnosis (Medium)
|
| 102 |
+
Two independent faults:
|
| 103 |
+
- `DB_PORT` wrong on API service
|
| 104 |
+
- `WORKER_QUEUE_URL` missing on worker service
|
| 105 |
+
|
| 106 |
+
**Agent must:** identify and fix both faults independently. Partial score for fixing one.
|
| 107 |
+
|
| 108 |
+
**Expected score:** ~0.60 (GPT-4o-mini)
|
| 109 |
+
|
| 110 |
+
---
|
| 111 |
+
|
| 112 |
+
### Task 3 — Cascading Failure Debug (Hard)
|
| 113 |
+
`DB_PASSWORD` is wrong on the database. This causes:
|
| 114 |
+
- Database → auth failure (`FATAL: password authentication failed`)
|
| 115 |
+
- API → misleading `connection timeout` error
|
| 116 |
+
- Worker and Proxy → downstream failures
|
| 117 |
+
|
| 118 |
+
**Agent must:** trace root cause (database credentials) rather than patching symptoms. Misleading logs make this hard.
|
| 119 |
+
|
| 120 |
+
**Expected score:** ~0.35 (GPT-4o-mini)
|
| 121 |
+
|
| 122 |
+
---
|
| 123 |
+
|
| 124 |
+
## API Endpoints
|
| 125 |
+
|
| 126 |
+
| Method | Endpoint | Description |
|
| 127 |
+
|---|---|---|
|
| 128 |
+
| POST | `/reset?task_id=task1` | Start new episode |
|
| 129 |
+
| POST | `/step` | Submit action, get observation + reward |
|
| 130 |
+
| GET | `/state` | Current environment state |
|
| 131 |
+
| GET | `/tasks` | Task list and action schema |
|
| 132 |
+
| GET | `/grader?task_id=task1` | Score current episode |
|
| 133 |
+
| POST | `/baseline` | Run baseline agent on all tasks |
|
| 134 |
+
| GET | `/health` | Health check |
|
| 135 |
+
|
| 136 |
+
---
|
| 137 |
+
|
| 138 |
+
## Setup & Running
|
| 139 |
+
|
| 140 |
+
### Local (Python)
|
| 141 |
+
|
| 142 |
+
```bash
|
| 143 |
+
git clone https://github.com/your-username/dev-env-debugger
|
| 144 |
+
cd dev-env-debugger
|
| 145 |
+
pip install -r requirements.txt
|
| 146 |
+
python api.py
|
| 147 |
+
# Server starts at http://localhost:7860
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
### Docker
|
| 151 |
+
|
| 152 |
+
```bash
|
| 153 |
+
docker build -t dev-env-debugger .
|
| 154 |
+
docker run -p 7860:7860 dev-env-debugger
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
### Validate OpenEnv spec
|
| 158 |
+
|
| 159 |
+
```bash
|
| 160 |
+
openenv validate
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
---
|
| 164 |
+
|
| 165 |
+
## Running the Baseline
|
| 166 |
+
|
| 167 |
+
```bash
|
| 168 |
+
export OPENAI_API_KEY=your_key_here
|
| 169 |
+
|
| 170 |
+
# Run all tasks
|
| 171 |
+
python baseline.py
|
| 172 |
+
|
| 173 |
+
# Run single task
|
| 174 |
+
python baseline.py --task task1
|
| 175 |
+
|
| 176 |
+
# JSON output (used by /baseline endpoint)
|
| 177 |
+
python baseline.py --output-json
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
### Baseline Scores (GPT-4o-mini)
|
| 181 |
+
|
| 182 |
+
| Task | Difficulty | Expected Score |
|
| 183 |
+
|---|---|---|
|
| 184 |
+
| task1 | Easy | ~0.85 |
|
| 185 |
+
| task2 | Medium | ~0.60 |
|
| 186 |
+
| task3 | Hard | ~0.35 |
|
| 187 |
+
| **Average** | | **~0.60** |
|
| 188 |
+
|
| 189 |
+
---
|
| 190 |
+
|
| 191 |
+
## Quick API Example
|
| 192 |
+
|
| 193 |
+
```bash
|
| 194 |
+
# Start episode
|
| 195 |
+
curl -X POST "http://localhost:7860/reset?task_id=task1"
|
| 196 |
+
|
| 197 |
+
# Read logs
|
| 198 |
+
curl -X POST http://localhost:7860/step \
|
| 199 |
+
-H "Content-Type: application/json" \
|
| 200 |
+
-d '{"type": "read_logs", "service": "api"}'
|
| 201 |
+
|
| 202 |
+
# Fix env var
|
| 203 |
+
curl -X POST http://localhost:7860/step \
|
| 204 |
+
-H "Content-Type: application/json" \
|
| 205 |
+
-d '{"type": "edit_env", "service": "api", "key": "DB_PORT", "value": "5432"}'
|
| 206 |
+
|
| 207 |
+
# Restart service
|
| 208 |
+
curl -X POST http://localhost:7860/step \
|
| 209 |
+
-H "Content-Type: application/json" \
|
| 210 |
+
-d '{"type": "restart_service", "service": "api"}'
|
| 211 |
+
|
| 212 |
+
# Submit
|
| 213 |
+
curl -X POST http://localhost:7860/step \
|
| 214 |
+
-H "Content-Type: application/json" \
|
| 215 |
+
-d '{"type": "submit"}'
|
| 216 |
+
|
| 217 |
+
# Get score
|
| 218 |
+
curl "http://localhost:7860/grader?task_id=task1"
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
---
|
| 222 |
+
|
| 223 |
+
## Project Structure
|
| 224 |
+
|
| 225 |
+
```
|
| 226 |
+
dev-env-debugger/
|
| 227 |
+
├── environment.py # Core simulation — state machine, fault injection, action handlers
|
| 228 |
+
├── tasks.py # Task definitions and programmatic graders
|
| 229 |
+
├── models.py # Pydantic models (Observation, Action, Reward, ServiceStatus)
|
| 230 |
+
├── api.py # FastAPI server exposing all endpoints
|
| 231 |
+
├── baseline.py # LLM baseline inference script
|
| 232 |
+
├── openenv.yaml # OpenEnv metadata
|
| 233 |
+
├── requirements.txt
|
| 234 |
+
├── Dockerfile
|
| 235 |
+
└── README.md
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
---
|
| 239 |
+
|
| 240 |
+
## License
|
| 241 |
+
|
| 242 |
+
MIT
|
api.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI server — exposes OpenEnv standard endpoints + required hackathon endpoints.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from fastapi import FastAPI, HTTPException
|
| 6 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
+
from fastapi.responses import HTMLResponse
|
| 8 |
+
import uvicorn
|
| 9 |
+
|
| 10 |
+
from models import Action, Observation
|
| 11 |
+
from environment import DevEnvEnvironment
|
| 12 |
+
from tasks import TASKS, grade_episode
|
| 13 |
+
|
| 14 |
+
app = FastAPI(
|
| 15 |
+
title="Dev Environment Debugger",
|
| 16 |
+
description="OpenEnv environment where an AI agent debugs a broken multi-service dev stack.",
|
| 17 |
+
version="1.0.0",
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
app.add_middleware(
|
| 21 |
+
CORSMiddleware,
|
| 22 |
+
allow_origins=["*"],
|
| 23 |
+
allow_methods=["*"],
|
| 24 |
+
allow_headers=["*"],
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# One environment instance per server (single-session for hackathon scope)
|
| 28 |
+
env = DevEnvEnvironment()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
# OpenEnv standard endpoints
|
| 33 |
+
# ---------------------------------------------------------------------------
|
| 34 |
+
|
| 35 |
+
@app.post("/reset")
|
| 36 |
+
def reset(task_id: str = "task1") -> dict:
|
| 37 |
+
"""Start a new episode. Returns initial observation."""
|
| 38 |
+
if task_id not in TASKS:
|
| 39 |
+
raise HTTPException(status_code=400, detail=f"Unknown task_id. Valid: {list(TASKS.keys())}")
|
| 40 |
+
obs = env.reset(task_id=task_id)
|
| 41 |
+
return obs.dict()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@app.post("/step")
|
| 45 |
+
def step(action: Action) -> dict:
|
| 46 |
+
"""Submit an action. Returns observation, reward, done, info."""
|
| 47 |
+
obs, reward, done, info = env.step(action)
|
| 48 |
+
return {
|
| 49 |
+
"observation": obs.dict(),
|
| 50 |
+
"reward": reward,
|
| 51 |
+
"done": done,
|
| 52 |
+
"info": info,
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@app.get("/state")
|
| 57 |
+
def state() -> dict:
|
| 58 |
+
"""Returns current environment state."""
|
| 59 |
+
return env.state()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ---------------------------------------------------------------------------
|
| 63 |
+
# Required hackathon endpoints
|
| 64 |
+
# ---------------------------------------------------------------------------
|
| 65 |
+
|
| 66 |
+
@app.get("/tasks")
|
| 67 |
+
def list_tasks() -> dict:
|
| 68 |
+
"""Returns task list and action schema."""
|
| 69 |
+
action_schema = {
|
| 70 |
+
"type": "string — one of: read_logs | inspect_env | edit_env | restart_service | run_healthcheck | submit",
|
| 71 |
+
"service": "string — target service: api | worker | database | proxy",
|
| 72 |
+
"key": "string — env var key (only for edit_env)",
|
| 73 |
+
"value": "string — env var value (only for edit_env)",
|
| 74 |
+
}
|
| 75 |
+
return {
|
| 76 |
+
"tasks": [
|
| 77 |
+
{
|
| 78 |
+
"id": t["id"],
|
| 79 |
+
"name": t["name"],
|
| 80 |
+
"difficulty": t["difficulty"],
|
| 81 |
+
"description": t["description"],
|
| 82 |
+
"faults_count": len(t["faults"]),
|
| 83 |
+
}
|
| 84 |
+
for t in TASKS.values()
|
| 85 |
+
],
|
| 86 |
+
"action_schema": action_schema,
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@app.get("/grader")
|
| 91 |
+
def grader(task_id: str = None) -> dict:
|
| 92 |
+
"""Returns grader score for current or specified episode."""
|
| 93 |
+
current_state = env.state()
|
| 94 |
+
tid = task_id or current_state.get("task_id", "task1")
|
| 95 |
+
result = grade_episode(tid, current_state)
|
| 96 |
+
return {
|
| 97 |
+
"task_id": tid,
|
| 98 |
+
"grader_result": result,
|
| 99 |
+
"state_summary": {
|
| 100 |
+
"step": current_state["step"],
|
| 101 |
+
"fixed_faults": current_state["fixed_faults"],
|
| 102 |
+
"remaining_faults": current_state["remaining_faults"],
|
| 103 |
+
"services": {k: v["status"] for k, v in current_state["services"].items()},
|
| 104 |
+
},
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@app.post("/baseline")
|
| 109 |
+
def baseline() -> dict:
|
| 110 |
+
"""
|
| 111 |
+
Runs the baseline inference script against all 3 tasks.
|
| 112 |
+
Returns scores. Requires OPENAI_API_KEY in environment.
|
| 113 |
+
"""
|
| 114 |
+
try:
|
| 115 |
+
import subprocess
|
| 116 |
+
import json
|
| 117 |
+
result = subprocess.run(
|
| 118 |
+
["python", "baseline.py", "--output-json"],
|
| 119 |
+
capture_output=True, text=True, timeout=300
|
| 120 |
+
)
|
| 121 |
+
if result.returncode == 0:
|
| 122 |
+
scores = json.loads(result.stdout)
|
| 123 |
+
return {"status": "success", "scores": scores}
|
| 124 |
+
else:
|
| 125 |
+
return {"status": "error", "message": result.stderr}
|
| 126 |
+
except Exception as e:
|
| 127 |
+
return {"status": "error", "message": str(e)}
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ---------------------------------------------------------------------------
|
| 131 |
+
# Health check
|
| 132 |
+
# ---------------------------------------------------------------------------
|
| 133 |
+
|
| 134 |
+
@app.get("/")
|
| 135 |
+
def root():
|
| 136 |
+
return {
|
| 137 |
+
"name": "Dev Environment Debugger",
|
| 138 |
+
"version": "1.0.0",
|
| 139 |
+
"description": "OpenEnv environment for AI-powered dev environment debugging.",
|
| 140 |
+
"endpoints": ["/reset", "/step", "/state", "/tasks", "/grader", "/baseline"],
|
| 141 |
+
"openenv": True,
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@app.get("/health")
|
| 146 |
+
def health():
|
| 147 |
+
return {"status": "ok"}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
if __name__ == "__main__":
|
| 151 |
+
uvicorn.run("api:app", host="0.0.0.0", port=7860, reload=False)
|
baseline.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Baseline inference script.
|
| 3 |
+
Runs an LLM agent (via OpenAI API) against all 3 tasks and reports scores.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
export OPENAI_API_KEY=your_key_here
|
| 7 |
+
python baseline.py
|
| 8 |
+
|
| 9 |
+
# For JSON output (used by /baseline endpoint):
|
| 10 |
+
python baseline.py --output-json
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import sys
|
| 15 |
+
import json
|
| 16 |
+
import argparse
|
| 17 |
+
import requests
|
| 18 |
+
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
# Config
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
|
| 23 |
+
API_BASE = os.getenv("ENV_BASE_URL", "http://localhost:7860")
|
| 24 |
+
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
|
| 25 |
+
MODEL = os.getenv("BASELINE_MODEL", "gpt-4o-mini")
|
| 26 |
+
MAX_STEPS = 20
|
| 27 |
+
|
| 28 |
+
SYSTEM_PROMPT = """You are an expert DevOps engineer debugging a broken multi-service development environment.
|
| 29 |
+
|
| 30 |
+
You will receive the current state of services (healthy/error) and the result of your last action.
|
| 31 |
+
Your goal is to diagnose faults and fix them so ALL services reach "healthy" status.
|
| 32 |
+
|
| 33 |
+
Services: api, worker, database, proxy
|
| 34 |
+
Available actions:
|
| 35 |
+
- read_logs: read error logs for a service (do this first when a service is broken)
|
| 36 |
+
- inspect_env: view environment variables for a service
|
| 37 |
+
- edit_env: change an environment variable (requires key and value)
|
| 38 |
+
- restart_service: restart a service after fixing its config
|
| 39 |
+
- run_healthcheck: check current status of a service
|
| 40 |
+
- submit: declare done when all services are healthy
|
| 41 |
+
|
| 42 |
+
STRATEGY:
|
| 43 |
+
1. Look at which services are in "error" state
|
| 44 |
+
2. Read logs for broken services to understand the error
|
| 45 |
+
3. Inspect env vars of the broken service
|
| 46 |
+
4. Edit the wrong/missing env var
|
| 47 |
+
5. Restart the service
|
| 48 |
+
6. Submit when all services show "healthy"
|
| 49 |
+
|
| 50 |
+
Respond ONLY with a valid JSON action object. Nothing else. Examples:
|
| 51 |
+
{"type": "read_logs", "service": "api"}
|
| 52 |
+
{"type": "inspect_env", "service": "database"}
|
| 53 |
+
{"type": "edit_env", "service": "api", "key": "DB_PORT", "value": "5432"}
|
| 54 |
+
{"type": "restart_service", "service": "api"}
|
| 55 |
+
{"type": "submit"}
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def call_openai(messages: list) -> str:
|
| 60 |
+
"""Call OpenAI chat completions API."""
|
| 61 |
+
if not OPENAI_API_KEY:
|
| 62 |
+
raise ValueError("OPENAI_API_KEY not set in environment variables.")
|
| 63 |
+
|
| 64 |
+
import urllib.request
|
| 65 |
+
payload = {
|
| 66 |
+
"model": MODEL,
|
| 67 |
+
"messages": messages,
|
| 68 |
+
"temperature": 0.2,
|
| 69 |
+
"max_tokens": 200,
|
| 70 |
+
}
|
| 71 |
+
req = urllib.request.Request(
|
| 72 |
+
"https://api.openai.com/v1/chat/completions",
|
| 73 |
+
data=json.dumps(payload).encode(),
|
| 74 |
+
headers={
|
| 75 |
+
"Content-Type": "application/json",
|
| 76 |
+
"Authorization": f"Bearer {OPENAI_API_KEY}",
|
| 77 |
+
},
|
| 78 |
+
method="POST",
|
| 79 |
+
)
|
| 80 |
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
| 81 |
+
data = json.loads(resp.read())
|
| 82 |
+
return data["choices"][0]["message"]["content"].strip()
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def obs_to_text(obs: dict) -> str:
|
| 86 |
+
"""Convert observation dict to readable text for the LLM."""
|
| 87 |
+
services = obs.get("services", {})
|
| 88 |
+
svc_lines = "\n".join(
|
| 89 |
+
f" {name}: {s['status']} (port: {s.get('port', 'N/A')})"
|
| 90 |
+
for name, s in services.items()
|
| 91 |
+
)
|
| 92 |
+
last = obs.get("last_action_result", {})
|
| 93 |
+
return (
|
| 94 |
+
f"Step {obs['step']}\n"
|
| 95 |
+
f"Services:\n{svc_lines}\n"
|
| 96 |
+
f"Last action result: {'✓' if last.get('success') else '✗'} {last.get('message', '')}\n"
|
| 97 |
+
f"Done: {obs.get('done', False)}"
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def run_episode(task_id: str, verbose: bool = True) -> dict:
|
| 102 |
+
"""Run one full episode for a task. Returns grader result."""
|
| 103 |
+
if verbose:
|
| 104 |
+
print(f"\n{'='*60}")
|
| 105 |
+
print(f"Running Task: {task_id}")
|
| 106 |
+
print('='*60)
|
| 107 |
+
|
| 108 |
+
# Reset
|
| 109 |
+
resp = requests.post(f"{API_BASE}/reset", params={"task_id": task_id})
|
| 110 |
+
obs = resp.json()
|
| 111 |
+
|
| 112 |
+
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
| 113 |
+
step = 0
|
| 114 |
+
|
| 115 |
+
while not obs.get("done", False) and step < MAX_STEPS:
|
| 116 |
+
step += 1
|
| 117 |
+
obs_text = obs_to_text(obs)
|
| 118 |
+
|
| 119 |
+
if verbose:
|
| 120 |
+
print(f"\n[Step {step}] Observation:\n{obs_text}")
|
| 121 |
+
|
| 122 |
+
messages.append({"role": "user", "content": obs_text})
|
| 123 |
+
|
| 124 |
+
# Get action from LLM
|
| 125 |
+
try:
|
| 126 |
+
raw_action = call_openai(messages)
|
| 127 |
+
except Exception as e:
|
| 128 |
+
if verbose:
|
| 129 |
+
print(f" LLM error: {e}")
|
| 130 |
+
break
|
| 131 |
+
|
| 132 |
+
if verbose:
|
| 133 |
+
print(f" Agent action: {raw_action}")
|
| 134 |
+
|
| 135 |
+
# Parse and validate action
|
| 136 |
+
try:
|
| 137 |
+
action_dict = json.loads(raw_action)
|
| 138 |
+
except json.JSONDecodeError:
|
| 139 |
+
if verbose:
|
| 140 |
+
print(f" Could not parse action JSON: {raw_action}")
|
| 141 |
+
messages.append({"role": "assistant", "content": raw_action})
|
| 142 |
+
messages.append({"role": "user", "content": "Invalid JSON. Respond ONLY with a valid JSON action."})
|
| 143 |
+
continue
|
| 144 |
+
|
| 145 |
+
messages.append({"role": "assistant", "content": raw_action})
|
| 146 |
+
|
| 147 |
+
# Step environment
|
| 148 |
+
step_resp = requests.post(f"{API_BASE}/step", json=action_dict)
|
| 149 |
+
step_data = step_resp.json()
|
| 150 |
+
obs = step_data["observation"]
|
| 151 |
+
reward = step_data["reward"]
|
| 152 |
+
done = step_data["done"]
|
| 153 |
+
|
| 154 |
+
if verbose:
|
| 155 |
+
print(f" Reward: {reward} | Done: {done}")
|
| 156 |
+
|
| 157 |
+
if done:
|
| 158 |
+
break
|
| 159 |
+
|
| 160 |
+
# Get grader score
|
| 161 |
+
grader_resp = requests.get(f"{API_BASE}/grader", params={"task_id": task_id})
|
| 162 |
+
grader_data = grader_resp.json()
|
| 163 |
+
result = grader_data["grader_result"]
|
| 164 |
+
|
| 165 |
+
if verbose:
|
| 166 |
+
print(f"\n[RESULT] Task {task_id}: score={result['score']} | {result['reason']}")
|
| 167 |
+
|
| 168 |
+
return {
|
| 169 |
+
"task_id": task_id,
|
| 170 |
+
"score": result["score"],
|
| 171 |
+
"reason": result["reason"],
|
| 172 |
+
"steps_taken": step,
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def main():
|
| 177 |
+
parser = argparse.ArgumentParser()
|
| 178 |
+
parser.add_argument("--output-json", action="store_true", help="Output scores as JSON")
|
| 179 |
+
parser.add_argument("--task", type=str, default=None, help="Run single task only")
|
| 180 |
+
args = parser.parse_args()
|
| 181 |
+
|
| 182 |
+
verbose = not args.output_json
|
| 183 |
+
tasks = [args.task] if args.task else ["task1", "task2", "task3"]
|
| 184 |
+
|
| 185 |
+
results = []
|
| 186 |
+
for task_id in tasks:
|
| 187 |
+
try:
|
| 188 |
+
result = run_episode(task_id, verbose=verbose)
|
| 189 |
+
results.append(result)
|
| 190 |
+
except Exception as e:
|
| 191 |
+
results.append({
|
| 192 |
+
"task_id": task_id,
|
| 193 |
+
"score": 0.0,
|
| 194 |
+
"reason": f"Error: {str(e)}",
|
| 195 |
+
"steps_taken": 0,
|
| 196 |
+
})
|
| 197 |
+
|
| 198 |
+
avg_score = sum(r["score"] for r in results) / len(results)
|
| 199 |
+
|
| 200 |
+
if args.output_json:
|
| 201 |
+
output = {"tasks": results, "average_score": round(avg_score, 3)}
|
| 202 |
+
print(json.dumps(output))
|
| 203 |
+
else:
|
| 204 |
+
print(f"\n{'='*60}")
|
| 205 |
+
print("BASELINE SCORES SUMMARY")
|
| 206 |
+
print('='*60)
|
| 207 |
+
for r in results:
|
| 208 |
+
print(f" {r['task_id']:10} score={r['score']:.3f} steps={r['steps_taken']} {r['reason']}")
|
| 209 |
+
print(f" {'AVERAGE':10} score={avg_score:.3f}")
|
| 210 |
+
print('='*60)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
if __name__ == "__main__":
|
| 214 |
+
main()
|
environment.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Dev Environment Debugger - Core Environment
|
| 3 |
+
Simulates a broken multi-service dev stack in memory.
|
| 4 |
+
Agent must diagnose and fix faults to bring all services healthy.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import copy
|
| 8 |
+
import random
|
| 9 |
+
from typing import Any
|
| 10 |
+
from models import Observation, Action, Reward, ServiceStatus, ActionResult
|
| 11 |
+
|
| 12 |
+
# ---------------------------------------------------------------------------
|
| 13 |
+
# Fault definitions
|
| 14 |
+
# ---------------------------------------------------------------------------
|
| 15 |
+
|
| 16 |
+
FAULT_CATALOG = {
|
| 17 |
+
"wrong_db_port": {
|
| 18 |
+
"service": "api",
|
| 19 |
+
"description": "DB_PORT set to wrong value",
|
| 20 |
+
"env_key": "DB_PORT",
|
| 21 |
+
"bad_value": "5433",
|
| 22 |
+
"fix_key": "DB_PORT",
|
| 23 |
+
"fix_value": "5432",
|
| 24 |
+
"log": "Error: cannot connect to database on port 5433. Connection refused.",
|
| 25 |
+
"requires_restart": ["api", "worker"],
|
| 26 |
+
},
|
| 27 |
+
"missing_queue_url": {
|
| 28 |
+
"service": "worker",
|
| 29 |
+
"description": "WORKER_QUEUE_URL not set",
|
| 30 |
+
"env_key": "WORKER_QUEUE_URL",
|
| 31 |
+
"bad_value": None, # missing
|
| 32 |
+
"fix_key": "WORKER_QUEUE_URL",
|
| 33 |
+
"fix_value": "redis://localhost:6379",
|
| 34 |
+
"log": "Error: WORKER_QUEUE_URL is not set. Worker cannot connect to queue.",
|
| 35 |
+
"requires_restart": ["worker"],
|
| 36 |
+
},
|
| 37 |
+
"wrong_api_port": {
|
| 38 |
+
"service": "proxy",
|
| 39 |
+
"description": "PROXY_UPSTREAM points to wrong API port",
|
| 40 |
+
"env_key": "PROXY_UPSTREAM",
|
| 41 |
+
"bad_value": "http://api:9999",
|
| 42 |
+
"fix_key": "PROXY_UPSTREAM",
|
| 43 |
+
"fix_value": "http://api:8000",
|
| 44 |
+
"log": "Error: upstream http://api:9999 unreachable. 502 Bad Gateway.",
|
| 45 |
+
"requires_restart": ["proxy"],
|
| 46 |
+
},
|
| 47 |
+
"wrong_db_password": {
|
| 48 |
+
"service": "database",
|
| 49 |
+
"description": "DB_PASSWORD is incorrect causing auth failure",
|
| 50 |
+
"env_key": "DB_PASSWORD",
|
| 51 |
+
"bad_value": "wrongpassword",
|
| 52 |
+
"fix_key": "DB_PASSWORD",
|
| 53 |
+
"fix_value": "secret",
|
| 54 |
+
"log": "FATAL: password authentication failed for user 'app'. Check DB_PASSWORD.",
|
| 55 |
+
"requires_restart": ["database", "api", "worker"],
|
| 56 |
+
},
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
# ---------------------------------------------------------------------------
|
| 60 |
+
# Service dependency graph — which services depend on which
|
| 61 |
+
# ---------------------------------------------------------------------------
|
| 62 |
+
|
| 63 |
+
DEPENDENCIES = {
|
| 64 |
+
"proxy": ["api"],
|
| 65 |
+
"api": ["database"],
|
| 66 |
+
"worker": ["database"],
|
| 67 |
+
"database": [],
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
# Default healthy env vars per service
|
| 71 |
+
DEFAULT_ENV = {
|
| 72 |
+
"api": {
|
| 73 |
+
"DB_HOST": "localhost",
|
| 74 |
+
"DB_PORT": "5432",
|
| 75 |
+
"DB_PASSWORD": "secret",
|
| 76 |
+
"API_PORT": "8000",
|
| 77 |
+
},
|
| 78 |
+
"worker": {
|
| 79 |
+
"WORKER_QUEUE_URL": "redis://localhost:6379",
|
| 80 |
+
"API_HOST": "http://api:8000",
|
| 81 |
+
},
|
| 82 |
+
"database": {
|
| 83 |
+
"DB_PASSWORD": "secret",
|
| 84 |
+
"DB_PORT": "5432",
|
| 85 |
+
},
|
| 86 |
+
"proxy": {
|
| 87 |
+
"PROXY_UPSTREAM": "http://api:8000",
|
| 88 |
+
"PROXY_PORT": "80",
|
| 89 |
+
},
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
SERVICE_PORTS = {
|
| 93 |
+
"api": 8000,
|
| 94 |
+
"worker": None,
|
| 95 |
+
"database": 5432,
|
| 96 |
+
"proxy": 80,
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
MAX_STEPS = 25
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# ---------------------------------------------------------------------------
|
| 103 |
+
# Environment class
|
| 104 |
+
# ---------------------------------------------------------------------------
|
| 105 |
+
|
| 106 |
+
class DevEnvEnvironment:
|
| 107 |
+
def __init__(self):
|
| 108 |
+
self._state: dict[str, Any] = {}
|
| 109 |
+
self._active_faults: list[str] = []
|
| 110 |
+
self._fixed_faults: set[str] = set()
|
| 111 |
+
self._step_count: int = 0
|
| 112 |
+
self._done: bool = False
|
| 113 |
+
self._task_id: str = "task1"
|
| 114 |
+
self._log_history: list[str] = []
|
| 115 |
+
|
| 116 |
+
# ------------------------------------------------------------------
|
| 117 |
+
# OpenEnv API
|
| 118 |
+
# ------------------------------------------------------------------
|
| 119 |
+
|
| 120 |
+
def reset(self, task_id: str = "task1") -> Observation:
|
| 121 |
+
self._task_id = task_id
|
| 122 |
+
self._step_count = 0
|
| 123 |
+
self._done = False
|
| 124 |
+
self._fixed_faults = set()
|
| 125 |
+
self._log_history = []
|
| 126 |
+
|
| 127 |
+
# Load task faults
|
| 128 |
+
from tasks import TASKS
|
| 129 |
+
task = TASKS[task_id]
|
| 130 |
+
self._active_faults = list(task["faults"])
|
| 131 |
+
|
| 132 |
+
# Build initial service env state from defaults
|
| 133 |
+
self._state = {
|
| 134 |
+
"envs": copy.deepcopy(DEFAULT_ENV),
|
| 135 |
+
"services": {},
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
# Inject faults
|
| 139 |
+
for fault_id in self._active_faults:
|
| 140 |
+
fault = FAULT_CATALOG[fault_id]
|
| 141 |
+
svc = fault["service"]
|
| 142 |
+
key = fault["env_key"]
|
| 143 |
+
if fault["bad_value"] is None:
|
| 144 |
+
# Remove the key (missing env var)
|
| 145 |
+
self._state["envs"][svc].pop(key, None)
|
| 146 |
+
else:
|
| 147 |
+
self._state["envs"][svc][key] = fault["bad_value"]
|
| 148 |
+
|
| 149 |
+
# Compute initial service statuses
|
| 150 |
+
self._recompute_statuses()
|
| 151 |
+
|
| 152 |
+
return self._build_observation(
|
| 153 |
+
last_action_result=ActionResult(success=True, message="Environment initialized. Services started.")
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
def step(self, action: Action) -> tuple[Observation, float, bool, dict]:
|
| 157 |
+
if self._done:
|
| 158 |
+
obs = self._build_observation(
|
| 159 |
+
ActionResult(success=False, message="Episode already done. Call reset().")
|
| 160 |
+
)
|
| 161 |
+
return obs, 0.0, True, {}
|
| 162 |
+
|
| 163 |
+
self._step_count += 1
|
| 164 |
+
reward = 0.0
|
| 165 |
+
result: ActionResult
|
| 166 |
+
|
| 167 |
+
# Dispatch action
|
| 168 |
+
if action.type == "read_logs":
|
| 169 |
+
result, r = self._handle_read_logs(action)
|
| 170 |
+
reward += r
|
| 171 |
+
elif action.type == "inspect_env":
|
| 172 |
+
result, r = self._handle_inspect_env(action)
|
| 173 |
+
reward += r
|
| 174 |
+
elif action.type == "edit_env":
|
| 175 |
+
result, r = self._handle_edit_env(action)
|
| 176 |
+
reward += r
|
| 177 |
+
elif action.type == "restart_service":
|
| 178 |
+
result, r = self._handle_restart_service(action)
|
| 179 |
+
reward += r
|
| 180 |
+
elif action.type == "run_healthcheck":
|
| 181 |
+
result, r = self._handle_healthcheck(action)
|
| 182 |
+
reward += r
|
| 183 |
+
elif action.type == "submit":
|
| 184 |
+
result, r, self._done = self._handle_submit()
|
| 185 |
+
reward += r
|
| 186 |
+
else:
|
| 187 |
+
result = ActionResult(success=False, message=f"Unknown action type: {action.type}")
|
| 188 |
+
reward -= 0.05
|
| 189 |
+
|
| 190 |
+
# Penalty for exceeding steps
|
| 191 |
+
if self._step_count >= MAX_STEPS and not self._done:
|
| 192 |
+
self._done = True
|
| 193 |
+
reward -= 0.2
|
| 194 |
+
result.message += " | Max steps exceeded."
|
| 195 |
+
|
| 196 |
+
self._log_history.append(f"Step {self._step_count}: {action.type} → {result.message}")
|
| 197 |
+
|
| 198 |
+
obs = self._build_observation(result)
|
| 199 |
+
return obs, round(reward, 3), self._done, {"step": self._step_count}
|
| 200 |
+
|
| 201 |
+
def state(self) -> dict:
|
| 202 |
+
return {
|
| 203 |
+
"task_id": self._task_id,
|
| 204 |
+
"step": self._step_count,
|
| 205 |
+
"done": self._done,
|
| 206 |
+
"active_faults": self._active_faults,
|
| 207 |
+
"fixed_faults": list(self._fixed_faults),
|
| 208 |
+
"remaining_faults": [f for f in self._active_faults if f not in self._fixed_faults],
|
| 209 |
+
"services": {k: v.dict() for k, v in self._state["services"].items()},
|
| 210 |
+
"envs": self._state["envs"],
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
# ------------------------------------------------------------------
|
| 214 |
+
# Action handlers
|
| 215 |
+
# ------------------------------------------------------------------
|
| 216 |
+
|
| 217 |
+
def _handle_read_logs(self, action: Action) -> tuple[ActionResult, float]:
|
| 218 |
+
svc = action.service
|
| 219 |
+
if svc not in self._state["services"]:
|
| 220 |
+
return ActionResult(success=False, message=f"Unknown service: {svc}"), -0.05
|
| 221 |
+
|
| 222 |
+
status = self._state["services"][svc]
|
| 223 |
+
reward = 0.0
|
| 224 |
+
|
| 225 |
+
if status.status == "error":
|
| 226 |
+
# Find fault log for this service
|
| 227 |
+
logs = []
|
| 228 |
+
for fault_id in self._active_faults:
|
| 229 |
+
if fault_id not in self._fixed_faults:
|
| 230 |
+
fault = FAULT_CATALOG[fault_id]
|
| 231 |
+
if fault["service"] == svc:
|
| 232 |
+
logs.append(fault["log"])
|
| 233 |
+
# Also add downstream symptom logs
|
| 234 |
+
for fault_id in self._active_faults:
|
| 235 |
+
if fault_id not in self._fixed_faults:
|
| 236 |
+
fault = FAULT_CATALOG[fault_id]
|
| 237 |
+
if svc in fault.get("requires_restart", []) and fault["service"] != svc:
|
| 238 |
+
logs.append(f"[downstream] Service unavailable due to dependency failure.")
|
| 239 |
+
|
| 240 |
+
log_text = "\n".join(logs) if logs else "No errors in log."
|
| 241 |
+
reward += 0.05 # reward for reading logs before acting
|
| 242 |
+
return ActionResult(success=True, message=f"[{svc} logs]\n{log_text}"), reward
|
| 243 |
+
else:
|
| 244 |
+
return ActionResult(success=True, message=f"[{svc} logs]\nNo errors. Service running normally."), 0.0
|
| 245 |
+
|
| 246 |
+
def _handle_inspect_env(self, action: Action) -> tuple[ActionResult, float]:
|
| 247 |
+
svc = action.service
|
| 248 |
+
if svc not in self._state["envs"]:
|
| 249 |
+
return ActionResult(success=False, message=f"Unknown service: {svc}"), -0.05
|
| 250 |
+
|
| 251 |
+
env = self._state["envs"][svc]
|
| 252 |
+
env_text = "\n".join(f" {k}={v}" for k, v in env.items())
|
| 253 |
+
return ActionResult(success=True, message=f"[{svc} env]\n{env_text}"), 0.02
|
| 254 |
+
|
| 255 |
+
def _handle_edit_env(self, action: Action) -> tuple[ActionResult, float]:
|
| 256 |
+
svc = action.service
|
| 257 |
+
if not action.key or action.value is None:
|
| 258 |
+
return ActionResult(success=False, message="edit_env requires 'key' and 'value' params."), -0.05
|
| 259 |
+
if svc not in self._state["envs"]:
|
| 260 |
+
return ActionResult(success=False, message=f"Unknown service: {svc}"), -0.05
|
| 261 |
+
|
| 262 |
+
self._state["envs"][svc][action.key] = action.value
|
| 263 |
+
|
| 264 |
+
# Check if this edit fixes a fault
|
| 265 |
+
reward = 0.0
|
| 266 |
+
for fault_id in self._active_faults:
|
| 267 |
+
if fault_id in self._fixed_faults:
|
| 268 |
+
continue
|
| 269 |
+
fault = FAULT_CATALOG[fault_id]
|
| 270 |
+
if (fault["service"] == svc and
|
| 271 |
+
fault["fix_key"] == action.key and
|
| 272 |
+
fault["fix_value"] == action.value):
|
| 273 |
+
# Correct fix applied — mark as pending restart
|
| 274 |
+
reward += 0.1
|
| 275 |
+
return ActionResult(
|
| 276 |
+
success=True,
|
| 277 |
+
message=f"Updated {svc}.{action.key}={action.value}. Restart {svc} to apply."
|
| 278 |
+
), reward
|
| 279 |
+
|
| 280 |
+
return ActionResult(
|
| 281 |
+
success=True,
|
| 282 |
+
message=f"Updated {svc}.{action.key}={action.value}."
|
| 283 |
+
), reward
|
| 284 |
+
|
| 285 |
+
def _handle_restart_service(self, action: Action) -> tuple[ActionResult, float]:
|
| 286 |
+
svc = action.service
|
| 287 |
+
if svc not in self._state["services"]:
|
| 288 |
+
return ActionResult(success=False, message=f"Unknown service: {svc}"), -0.05
|
| 289 |
+
|
| 290 |
+
reward = 0.0
|
| 291 |
+
prev_statuses = {s: self._state["services"][s].status for s in self._state["services"]}
|
| 292 |
+
|
| 293 |
+
# Check if fault is now fixed (env has correct value)
|
| 294 |
+
for fault_id in self._active_faults:
|
| 295 |
+
if fault_id in self._fixed_faults:
|
| 296 |
+
continue
|
| 297 |
+
fault = FAULT_CATALOG[fault_id]
|
| 298 |
+
if svc in fault["requires_restart"]:
|
| 299 |
+
current_val = self._state["envs"].get(fault["service"], {}).get(fault["fix_key"])
|
| 300 |
+
if current_val == fault["fix_value"]:
|
| 301 |
+
self._fixed_faults.add(fault_id)
|
| 302 |
+
|
| 303 |
+
self._recompute_statuses()
|
| 304 |
+
|
| 305 |
+
# Check improvements
|
| 306 |
+
for s, status_obj in self._state["services"].items():
|
| 307 |
+
if prev_statuses[s] != "healthy" and status_obj.status == "healthy":
|
| 308 |
+
reward += 0.3 # service recovered
|
| 309 |
+
|
| 310 |
+
# Penalty: restarted without fixing root cause
|
| 311 |
+
if self._state["services"][svc].status == "error":
|
| 312 |
+
reward -= 0.1
|
| 313 |
+
return ActionResult(
|
| 314 |
+
success=True,
|
| 315 |
+
message=f"{svc} restarted but still in error state. Root cause not fixed."
|
| 316 |
+
), reward
|
| 317 |
+
|
| 318 |
+
return ActionResult(success=True, message=f"{svc} restarted successfully."), reward
|
| 319 |
+
|
| 320 |
+
def _handle_healthcheck(self, action: Action) -> tuple[ActionResult, float]:
|
| 321 |
+
svc = action.service
|
| 322 |
+
if svc not in self._state["services"]:
|
| 323 |
+
return ActionResult(success=False, message=f"Unknown service: {svc}"), -0.05
|
| 324 |
+
|
| 325 |
+
status = self._state["services"][svc]
|
| 326 |
+
return ActionResult(
|
| 327 |
+
success=True,
|
| 328 |
+
message=f"[{svc} healthcheck] status={status.status} port={status.port}"
|
| 329 |
+
), 0.01
|
| 330 |
+
|
| 331 |
+
def _handle_submit(self) -> tuple[ActionResult, float, bool]:
|
| 332 |
+
all_healthy = all(
|
| 333 |
+
s.status == "healthy" for s in self._state["services"].values()
|
| 334 |
+
)
|
| 335 |
+
if all_healthy:
|
| 336 |
+
return ActionResult(
|
| 337 |
+
success=True,
|
| 338 |
+
message="All services healthy. Environment fixed successfully!"
|
| 339 |
+
), 0.5, True
|
| 340 |
+
else:
|
| 341 |
+
unhealthy = [k for k, v in self._state["services"].items() if v.status != "healthy"]
|
| 342 |
+
return ActionResult(
|
| 343 |
+
success=False,
|
| 344 |
+
message=f"Submit failed. Services still unhealthy: {unhealthy}"
|
| 345 |
+
), -0.3, True
|
| 346 |
+
|
| 347 |
+
# ------------------------------------------------------------------
|
| 348 |
+
# Internal helpers
|
| 349 |
+
# ------------------------------------------------------------------
|
| 350 |
+
|
| 351 |
+
def _recompute_statuses(self):
|
| 352 |
+
"""Recompute service statuses based on active unfixed faults and dependencies."""
|
| 353 |
+
# Start all healthy
|
| 354 |
+
statuses = {svc: "healthy" for svc in DEFAULT_ENV}
|
| 355 |
+
|
| 356 |
+
# Apply unfixed faults
|
| 357 |
+
remaining = [f for f in self._active_faults if f not in self._fixed_faults]
|
| 358 |
+
directly_broken = set()
|
| 359 |
+
for fault_id in remaining:
|
| 360 |
+
fault = FAULT_CATALOG[fault_id]
|
| 361 |
+
directly_broken.add(fault["service"])
|
| 362 |
+
|
| 363 |
+
# Propagate through dependency graph
|
| 364 |
+
for svc in ["database", "api", "worker", "proxy"]:
|
| 365 |
+
if svc in directly_broken:
|
| 366 |
+
statuses[svc] = "error"
|
| 367 |
+
else:
|
| 368 |
+
# Check if any dependency is broken
|
| 369 |
+
for dep in DEPENDENCIES.get(svc, []):
|
| 370 |
+
if statuses.get(dep) == "error":
|
| 371 |
+
statuses[svc] = "error"
|
| 372 |
+
break
|
| 373 |
+
|
| 374 |
+
self._state["services"] = {
|
| 375 |
+
svc: ServiceStatus(
|
| 376 |
+
name=svc,
|
| 377 |
+
status=statuses[svc],
|
| 378 |
+
port=SERVICE_PORTS[svc]
|
| 379 |
+
)
|
| 380 |
+
for svc in DEFAULT_ENV
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
def _build_observation(self, last_action_result: ActionResult) -> Observation:
|
| 384 |
+
return Observation(
|
| 385 |
+
step=self._step_count,
|
| 386 |
+
services=self._state["services"],
|
| 387 |
+
last_action_result=last_action_result,
|
| 388 |
+
available_actions=["read_logs", "inspect_env", "edit_env",
|
| 389 |
+
"restart_service", "run_healthcheck", "submit"],
|
| 390 |
+
done=self._done,
|
| 391 |
+
)
|
models.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Typed Pydantic models for OpenEnv spec compliance.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from pydantic import BaseModel
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class ServiceStatus(BaseModel):
|
| 10 |
+
name: str
|
| 11 |
+
status: str # "healthy" | "error" | "stopped"
|
| 12 |
+
port: Optional[int] = None
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ActionResult(BaseModel):
|
| 16 |
+
success: bool
|
| 17 |
+
message: str
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class Observation(BaseModel):
|
| 21 |
+
step: int
|
| 22 |
+
services: dict[str, ServiceStatus]
|
| 23 |
+
last_action_result: ActionResult
|
| 24 |
+
available_actions: list[str]
|
| 25 |
+
done: bool
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class Action(BaseModel):
|
| 29 |
+
type: str # read_logs | inspect_env | edit_env | restart_service | run_healthcheck | submit
|
| 30 |
+
service: Optional[str] = None # target service name
|
| 31 |
+
key: Optional[str] = None # for edit_env
|
| 32 |
+
value: Optional[str] = None # for edit_env
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class Reward(BaseModel):
|
| 36 |
+
value: float
|
| 37 |
+
reason: str
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class TaskInfo(BaseModel):
|
| 41 |
+
id: str
|
| 42 |
+
name: str
|
| 43 |
+
description: str
|
| 44 |
+
difficulty: str
|
| 45 |
+
faults: list[str]
|
openenv.yaml
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: dev-env-debugger
|
| 2 |
+
version: 1.0.0
|
| 3 |
+
description: >
|
| 4 |
+
An OpenEnv environment where an AI agent debugs a broken multi-service
|
| 5 |
+
development stack. The agent must read logs, inspect configs, fix
|
| 6 |
+
environment variables, and restart services to restore a healthy state.
|
| 7 |
+
|
| 8 |
+
tags:
|
| 9 |
+
- openenv
|
| 10 |
+
- devops
|
| 11 |
+
- debugging
|
| 12 |
+
- real-world
|
| 13 |
+
- multi-service
|
| 14 |
+
|
| 15 |
+
author: Ajinkya
|
| 16 |
+
license: MIT
|
| 17 |
+
|
| 18 |
+
environment:
|
| 19 |
+
reset_endpoint: /reset
|
| 20 |
+
step_endpoint: /step
|
| 21 |
+
state_endpoint: /state
|
| 22 |
+
tasks_endpoint: /tasks
|
| 23 |
+
grader_endpoint: /grader
|
| 24 |
+
baseline_endpoint: /baseline
|
| 25 |
+
|
| 26 |
+
tasks:
|
| 27 |
+
- id: task1
|
| 28 |
+
name: Single Service Fault
|
| 29 |
+
difficulty: easy
|
| 30 |
+
description: One service down due to a single misconfigured environment variable.
|
| 31 |
+
|
| 32 |
+
- id: task2
|
| 33 |
+
name: Multi-Fault Diagnosis
|
| 34 |
+
difficulty: medium
|
| 35 |
+
description: Two services failing due to independent faults. Partial credit per fault fixed.
|
| 36 |
+
|
| 37 |
+
- id: task3
|
| 38 |
+
name: Cascading Failure Debug
|
| 39 |
+
difficulty: hard
|
| 40 |
+
description: Root cause fault causing cascading failures with misleading error messages.
|
| 41 |
+
|
| 42 |
+
observation_space:
|
| 43 |
+
type: object
|
| 44 |
+
fields:
|
| 45 |
+
step: integer
|
| 46 |
+
services: object # map of service name → {status, port}
|
| 47 |
+
last_action_result: object # {success: bool, message: string}
|
| 48 |
+
available_actions: array
|
| 49 |
+
done: boolean
|
| 50 |
+
|
| 51 |
+
action_space:
|
| 52 |
+
type: object
|
| 53 |
+
fields:
|
| 54 |
+
type: string # read_logs | inspect_env | edit_env | restart_service | run_healthcheck | submit
|
| 55 |
+
service: string # optional — api | worker | database | proxy
|
| 56 |
+
key: string # optional — env var key (edit_env only)
|
| 57 |
+
value: string # optional — env var value (edit_env only)
|
| 58 |
+
|
| 59 |
+
reward:
|
| 60 |
+
min: -0.5
|
| 61 |
+
max: 1.0
|
| 62 |
+
shaped: true
|
| 63 |
+
description: >
|
| 64 |
+
Partial rewards for correct diagnostic actions and service recovery.
|
| 65 |
+
Penalties for redundant actions and submitting while unhealthy.
|
| 66 |
+
|
| 67 |
+
baseline:
|
| 68 |
+
model: gpt-4o-mini
|
| 69 |
+
expected_scores:
|
| 70 |
+
task1: 0.85
|
| 71 |
+
task2: 0.60
|
| 72 |
+
task3: 0.35
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.111.0
|
| 2 |
+
uvicorn==0.30.1
|
| 3 |
+
pydantic==2.7.1
|
| 4 |
+
requests==2.32.3
|
| 5 |
+
pyyaml==6.0.1
|
tasks.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Task definitions and programmatic graders.
|
| 3 |
+
Each task defines faults to inject and a grader that scores 0.0 - 1.0.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
TASKS = {
|
| 7 |
+
"task1": {
|
| 8 |
+
"id": "task1",
|
| 9 |
+
"name": "Single Service Fault",
|
| 10 |
+
"difficulty": "easy",
|
| 11 |
+
"description": (
|
| 12 |
+
"One service is down due to a single misconfigured environment variable. "
|
| 13 |
+
"The API cannot connect to the database because DB_PORT is set to the wrong value. "
|
| 14 |
+
"Read the logs, find the bad config, fix it, and restart the service."
|
| 15 |
+
),
|
| 16 |
+
"faults": ["wrong_db_port"],
|
| 17 |
+
},
|
| 18 |
+
"task2": {
|
| 19 |
+
"id": "task2",
|
| 20 |
+
"name": "Multi-Fault Diagnosis",
|
| 21 |
+
"difficulty": "medium",
|
| 22 |
+
"description": (
|
| 23 |
+
"Two services are failing due to independent faults. "
|
| 24 |
+
"The API has a wrong database port AND the worker is missing its queue URL. "
|
| 25 |
+
"Both must be fixed for full score. Partial credit for fixing one."
|
| 26 |
+
),
|
| 27 |
+
"faults": ["wrong_db_port", "missing_queue_url"],
|
| 28 |
+
},
|
| 29 |
+
"task3": {
|
| 30 |
+
"id": "task3",
|
| 31 |
+
"name": "Cascading Failure Debug",
|
| 32 |
+
"difficulty": "hard",
|
| 33 |
+
"description": (
|
| 34 |
+
"The database has wrong credentials, causing the API to fail with a misleading "
|
| 35 |
+
"'connection timeout' error, which in turn causes the worker and proxy to fail. "
|
| 36 |
+
"Logs are misleading — the agent must trace the root cause (DB_PASSWORD) rather "
|
| 37 |
+
"than patching downstream symptoms."
|
| 38 |
+
),
|
| 39 |
+
"faults": ["wrong_db_password"],
|
| 40 |
+
},
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def grade_episode(task_id: str, env_state: dict) -> dict:
|
| 45 |
+
"""
|
| 46 |
+
Programmatic grader. Returns score 0.0-1.0 and breakdown.
|
| 47 |
+
Called after episode ends (done=True).
|
| 48 |
+
"""
|
| 49 |
+
task = TASKS.get(task_id)
|
| 50 |
+
if not task:
|
| 51 |
+
return {"score": 0.0, "reason": f"Unknown task: {task_id}"}
|
| 52 |
+
|
| 53 |
+
services = env_state.get("services", {})
|
| 54 |
+
fixed_faults = set(env_state.get("fixed_faults", []))
|
| 55 |
+
active_faults = set(env_state.get("active_faults", []))
|
| 56 |
+
remaining = active_faults - fixed_faults
|
| 57 |
+
steps = env_state.get("step", 0)
|
| 58 |
+
|
| 59 |
+
all_healthy = all(s["status"] == "healthy" for s in services.values())
|
| 60 |
+
total_faults = len(active_faults)
|
| 61 |
+
faults_fixed = len(fixed_faults)
|
| 62 |
+
|
| 63 |
+
# ---------------------------------------------------------------
|
| 64 |
+
# Task 1 grader — single fault, binary but with partial for partial fix
|
| 65 |
+
# ---------------------------------------------------------------
|
| 66 |
+
if task_id == "task1":
|
| 67 |
+
if all_healthy and faults_fixed == total_faults:
|
| 68 |
+
# Efficiency bonus for doing it quickly
|
| 69 |
+
efficiency = max(0.0, 1.0 - (steps - 3) * 0.03)
|
| 70 |
+
score = min(1.0, 0.85 + efficiency * 0.15)
|
| 71 |
+
return {
|
| 72 |
+
"score": round(score, 3),
|
| 73 |
+
"reason": f"All services healthy. Fixed in {steps} steps.",
|
| 74 |
+
"faults_fixed": faults_fixed,
|
| 75 |
+
"total_faults": total_faults,
|
| 76 |
+
}
|
| 77 |
+
elif faults_fixed > 0:
|
| 78 |
+
return {
|
| 79 |
+
"score": 0.4,
|
| 80 |
+
"reason": "Fault fixed but service not restarted or submit not called.",
|
| 81 |
+
"faults_fixed": faults_fixed,
|
| 82 |
+
"total_faults": total_faults,
|
| 83 |
+
}
|
| 84 |
+
else:
|
| 85 |
+
return {
|
| 86 |
+
"score": 0.0,
|
| 87 |
+
"reason": "No faults fixed.",
|
| 88 |
+
"faults_fixed": 0,
|
| 89 |
+
"total_faults": total_faults,
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
# ---------------------------------------------------------------
|
| 93 |
+
# Task 2 grader — partial credit per fault fixed
|
| 94 |
+
# ---------------------------------------------------------------
|
| 95 |
+
if task_id == "task2":
|
| 96 |
+
if all_healthy and faults_fixed == total_faults:
|
| 97 |
+
efficiency = max(0.0, 1.0 - (steps - 5) * 0.02)
|
| 98 |
+
score = min(1.0, 0.85 + efficiency * 0.15)
|
| 99 |
+
return {
|
| 100 |
+
"score": round(score, 3),
|
| 101 |
+
"reason": f"All services healthy. Both faults fixed in {steps} steps.",
|
| 102 |
+
"faults_fixed": faults_fixed,
|
| 103 |
+
"total_faults": total_faults,
|
| 104 |
+
}
|
| 105 |
+
elif faults_fixed == 1:
|
| 106 |
+
return {
|
| 107 |
+
"score": 0.45,
|
| 108 |
+
"reason": f"One of two faults fixed. Partial credit.",
|
| 109 |
+
"faults_fixed": faults_fixed,
|
| 110 |
+
"total_faults": total_faults,
|
| 111 |
+
}
|
| 112 |
+
else:
|
| 113 |
+
return {
|
| 114 |
+
"score": 0.0,
|
| 115 |
+
"reason": "No faults fixed.",
|
| 116 |
+
"faults_fixed": 0,
|
| 117 |
+
"total_faults": total_faults,
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
# ---------------------------------------------------------------
|
| 121 |
+
# Task 3 grader — penalizes symptom-patching, rewards root cause fix
|
| 122 |
+
# ---------------------------------------------------------------
|
| 123 |
+
if task_id == "task3":
|
| 124 |
+
if all_healthy and faults_fixed == total_faults:
|
| 125 |
+
# Reward fewer steps heavily (cascading fix is fast if root cause found)
|
| 126 |
+
efficiency = max(0.0, 1.0 - (steps - 4) * 0.04)
|
| 127 |
+
score = min(1.0, 0.80 + efficiency * 0.20)
|
| 128 |
+
return {
|
| 129 |
+
"score": round(score, 3),
|
| 130 |
+
"reason": f"Root cause found and fixed. All services recovered in {steps} steps.",
|
| 131 |
+
"faults_fixed": faults_fixed,
|
| 132 |
+
"total_faults": total_faults,
|
| 133 |
+
}
|
| 134 |
+
elif faults_fixed > 0:
|
| 135 |
+
# Fixed something but not all healthy — partial
|
| 136 |
+
return {
|
| 137 |
+
"score": 0.3,
|
| 138 |
+
"reason": "Partial fix. Root cause addressed but services not fully recovered.",
|
| 139 |
+
"faults_fixed": faults_fixed,
|
| 140 |
+
"total_faults": total_faults,
|
| 141 |
+
}
|
| 142 |
+
else:
|
| 143 |
+
return {
|
| 144 |
+
"score": 0.0,
|
| 145 |
+
"reason": "Root cause not identified. All services still broken.",
|
| 146 |
+
"faults_fixed": 0,
|
| 147 |
+
"total_faults": total_faults,
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
return {"score": 0.0, "reason": "Unhandled task."}
|