Ajjack4 commited on
Commit
125deae
·
0 Parent(s):

initial submission — Dev Environment Debugger OpenEnv hackathon

Browse files
Files changed (9) hide show
  1. Dockerfile +19 -0
  2. README.md +242 -0
  3. api.py +151 -0
  4. baseline.py +214 -0
  5. environment.py +391 -0
  6. models.py +45 -0
  7. openenv.yaml +72 -0
  8. requirements.txt +5 -0
  9. 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."}