Ajjack4 commited on
Commit
095c966
·
1 Parent(s): 6c700b3

major upgrade: 5 tasks, 7 faults, live HTML dashboard, richer reward shaping

Browse files
Files changed (3) hide show
  1. api.py +256 -1
  2. environment.py +70 -58
  3. tasks.py +123 -45
api.py CHANGED
@@ -4,6 +4,7 @@ FastAPI server — exposes OpenEnv standard endpoints + required hackathon endpo
4
 
5
  from fastapi import FastAPI, HTTPException, Request
6
  from fastapi.middleware.cors import CORSMiddleware
 
7
  import uvicorn
8
 
9
  from models import Action, Observation
@@ -219,17 +220,271 @@ async def mcp(request: Request):
219
  }
220
 
221
 
222
- @app.get("/")
223
  def root():
 
 
 
 
 
224
  return {
225
  "name": "Dev Environment Debugger",
226
  "version": "1.0.0",
227
  "description": "OpenEnv environment for AI-powered dev environment debugging.",
 
228
  "endpoints": ["/reset", "/step", "/state", "/tasks", "/grader",
229
  "/baseline", "/health", "/metadata", "/schema", "/mcp"],
230
  "openenv": True,
231
  }
232
 
233
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  if __name__ == "__main__":
235
  uvicorn.run("api:app", host="0.0.0.0", port=7860, reload=False)
 
4
 
5
  from fastapi import FastAPI, HTTPException, Request
6
  from fastapi.middleware.cors import CORSMiddleware
7
+ from fastapi.responses import HTMLResponse
8
  import uvicorn
9
 
10
  from models import Action, Observation
 
220
  }
221
 
222
 
223
+ @app.get("/", response_class=HTMLResponse)
224
  def root():
225
+ return HTMLResponse(content=DASHBOARD_HTML)
226
+
227
+
228
+ @app.get("/api")
229
+ def api_info():
230
  return {
231
  "name": "Dev Environment Debugger",
232
  "version": "1.0.0",
233
  "description": "OpenEnv environment for AI-powered dev environment debugging.",
234
+ "tasks": list(TASKS.keys()),
235
  "endpoints": ["/reset", "/step", "/state", "/tasks", "/grader",
236
  "/baseline", "/health", "/metadata", "/schema", "/mcp"],
237
  "openenv": True,
238
  }
239
 
240
 
241
+ DASHBOARD_HTML = """<!DOCTYPE html>
242
+ <html lang="en">
243
+ <head>
244
+ <meta charset="UTF-8">
245
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
246
+ <title>Dev Environment Debugger — OpenEnv</title>
247
+ <style>
248
+ * { box-sizing: border-box; margin: 0; padding: 0; }
249
+ body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0d1117; color: #e6edf3; min-height: 100vh; }
250
+ header { background: linear-gradient(135deg, #161b22 0%, #1f2937 100%); border-bottom: 1px solid #30363d; padding: 24px 32px; display: flex; align-items: center; gap: 16px; }
251
+ header h1 { font-size: 1.5rem; font-weight: 700; }
252
+ header .badge { background: #238636; color: #fff; padding: 4px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
253
+ header .subtitle { color: #8b949e; font-size: 0.9rem; margin-top: 4px; }
254
+ main { max-width: 1100px; margin: 0 auto; padding: 32px; }
255
+ .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 32px; }
256
+ @media(max-width: 700px) { .grid { grid-template-columns: 1fr; } }
257
+ .card { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 24px; }
258
+ .card h2 { font-size: 1rem; font-weight: 600; color: #58a6ff; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
259
+ .service { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; border-radius: 8px; margin-bottom: 8px; background: #0d1117; border: 1px solid #21262d; }
260
+ .service-name { font-weight: 600; font-size: 0.9rem; }
261
+ .status { padding: 3px 10px; border-radius: 10px; font-size: 0.78rem; font-weight: 700; }
262
+ .healthy { background: #1a4a2a; color: #3fb950; border: 1px solid #238636; }
263
+ .error { background: #4a1a1a; color: #f85149; border: 1px solid #da3633; }
264
+ .task-card { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 20px; margin-bottom: 16px; cursor: pointer; transition: border-color 0.2s; }
265
+ .task-card:hover { border-color: #58a6ff; }
266
+ .task-card.active { border-color: #58a6ff; background: #1c2433; }
267
+ .task-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
268
+ .task-name { font-weight: 700; }
269
+ .diff { padding: 3px 10px; border-radius: 10px; font-size: 0.75rem; font-weight: 700; }
270
+ .easy { background: #1a4a2a; color: #3fb950; }
271
+ .medium { background: #4a3a1a; color: #d29922; }
272
+ .hard { background: #4a1a1a; color: #f85149; }
273
+ .expert { background: #2d1a4a; color: #bc8cff; }
274
+ .task-desc { color: #8b949e; font-size: 0.85rem; line-height: 1.5; }
275
+ .actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 16px; }
276
+ button { padding: 9px 18px; border-radius: 8px; border: none; cursor: pointer; font-size: 0.85rem; font-weight: 600; transition: opacity 0.2s; }
277
+ button:hover { opacity: 0.85; }
278
+ .btn-primary { background: #238636; color: #fff; }
279
+ .btn-secondary { background: #21262d; color: #e6edf3; border: 1px solid #30363d; }
280
+ .btn-danger { background: #da3633; color: #fff; }
281
+ .log { background: #0d1117; border: 1px solid #21262d; border-radius: 8px; padding: 14px; font-family: 'Cascadia Code', 'Fira Code', monospace; font-size: 0.8rem; line-height: 1.6; max-height: 260px; overflow-y: auto; color: #8b949e; white-space: pre-wrap; word-break: break-all; }
282
+ .log .ok { color: #3fb950; }
283
+ .log .err { color: #f85149; }
284
+ .log .info { color: #58a6ff; }
285
+ .score-bar { height: 8px; border-radius: 4px; background: #21262d; margin-top: 8px; overflow: hidden; }
286
+ .score-fill { height: 100%; border-radius: 4px; background: linear-gradient(90deg, #238636, #3fb950); transition: width 0.5s; }
287
+ .step-counter { font-size: 2rem; font-weight: 800; color: #58a6ff; }
288
+ .stat-label { color: #8b949e; font-size: 0.8rem; margin-top: 4px; }
289
+ .stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 16px; }
290
+ .stat { text-align: center; }
291
+ #action-panel { margin-top: 24px; }
292
+ select, input[type=text] { background: #0d1117; border: 1px solid #30363d; color: #e6edf3; border-radius: 6px; padding: 8px 12px; font-size: 0.85rem; width: 100%; margin-bottom: 8px; }
293
+ .full-width { grid-column: 1 / -1; }
294
+ .arch { display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; padding: 16px 0; }
295
+ .svc-box { padding: 8px 16px; border-radius: 8px; font-size: 0.85rem; font-weight: 600; border: 2px solid; text-align: center; min-width: 80px; }
296
+ .arrow { color: #58a6ff; font-size: 1.2rem; }
297
+ </style>
298
+ </head>
299
+ <body>
300
+ <header>
301
+ <div>
302
+ <div style="display:flex;align-items:center;gap:12px">
303
+ <h1>🔧 Dev Environment Debugger</h1>
304
+ <span class="badge">OpenEnv</span>
305
+ </div>
306
+ <div class="subtitle">AI agent benchmark · 5 tasks · realistic multi-service debugging</div>
307
+ </div>
308
+ </header>
309
+ <main>
310
+ <div class="grid">
311
+ <!-- Services Panel -->
312
+ <div class="card">
313
+ <h2>⚙️ Service Status</h2>
314
+ <div id="arch" class="arch">
315
+ <div class="svc-box" id="box-proxy">proxy</div>
316
+ <div class="arrow">→</div>
317
+ <div class="svc-box" id="box-api">api</div>
318
+ <div class="arrow">→</div>
319
+ <div class="svc-box" id="box-database">database</div>
320
+ </div>
321
+ <div style="text-align:center;margin-bottom:16px">
322
+ <div class="arrow" style="font-size:1rem">↓</div>
323
+ <div class="svc-box" id="box-worker" style="display:inline-block;margin-top:4px">worker</div>
324
+ </div>
325
+ <div id="services-list"></div>
326
+ <div class="stats" style="margin-top:16px">
327
+ <div class="stat"><div class="step-counter" id="step-count">0</div><div class="stat-label">Steps</div></div>
328
+ <div class="stat"><div class="step-counter" id="fixed-count" style="color:#3fb950">0</div><div class="stat-label">Fixed</div></div>
329
+ <div class="stat"><div class="step-counter" id="score-val" style="color:#d29922">—</div><div class="stat-label">Score</div></div>
330
+ </div>
331
+ </div>
332
+
333
+ <!-- Action Panel -->
334
+ <div class="card">
335
+ <h2>🎮 Actions</h2>
336
+ <select id="action-type" onchange="updateActionFields()">
337
+ <option value="read_logs">read_logs</option>
338
+ <option value="inspect_env">inspect_env</option>
339
+ <option value="edit_env">edit_env</option>
340
+ <option value="restart_service">restart_service</option>
341
+ <option value="run_healthcheck">run_healthcheck</option>
342
+ <option value="submit">submit</option>
343
+ </select>
344
+ <div id="service-row">
345
+ <select id="action-service">
346
+ <option value="api">api</option>
347
+ <option value="worker">worker</option>
348
+ <option value="database">database</option>
349
+ <option value="proxy">proxy</option>
350
+ </select>
351
+ </div>
352
+ <div id="kv-row" style="display:none">
353
+ <input type="text" id="action-key" placeholder="env key (e.g. DB_PORT)" />
354
+ <input type="text" id="action-value" placeholder="new value (e.g. 5432)" />
355
+ </div>
356
+ <div class="actions">
357
+ <button class="btn-primary" onclick="doStep()">▶ Execute Action</button>
358
+ <button class="btn-secondary" onclick="doGrade()">📊 Grade</button>
359
+ </div>
360
+ <div class="log" id="action-log" style="margin-top:16px">No actions yet. Start an episode below.</div>
361
+ </div>
362
+
363
+ <!-- Tasks Panel -->
364
+ <div class="card full-width">
365
+ <h2>📋 Tasks</h2>
366
+ <div id="tasks-list"></div>
367
+ </div>
368
+ </div>
369
+ </main>
370
+
371
+ <script>
372
+ let currentTask = 'task1';
373
+
374
+ async function fetchTasks() {
375
+ const r = await fetch('/tasks');
376
+ const d = await r.json();
377
+ const el = document.getElementById('tasks-list');
378
+ el.innerHTML = d.tasks.map(t => `
379
+ <div class="task-card ${t.id === currentTask ? 'active' : ''}" id="tc-${t.id}" onclick="selectTask('${t.id}')">
380
+ <div class="task-header">
381
+ <span class="task-name">${t.id} — ${t.name}</span>
382
+ <span class="diff ${t.difficulty}">${t.difficulty}</span>
383
+ </div>
384
+ <div class="task-desc">${t.description}</div>
385
+ <div class="actions" style="margin-top:12px">
386
+ <button class="btn-primary" onclick="event.stopPropagation(); resetTask('${t.id}')">▶ Start Episode</button>
387
+ <span style="color:#8b949e;font-size:0.8rem;align-self:center">${t.faults_count} fault${t.faults_count>1?'s':''}</span>
388
+ </div>
389
+ </div>
390
+ `).join('');
391
+ }
392
+
393
+ function selectTask(id) {
394
+ currentTask = id;
395
+ document.querySelectorAll('.task-card').forEach(c => c.classList.remove('active'));
396
+ const el = document.getElementById('tc-' + id);
397
+ if (el) el.classList.add('active');
398
+ }
399
+
400
+ async function resetTask(taskId) {
401
+ selectTask(taskId);
402
+ const r = await fetch('/reset?task_id=' + taskId, {method:'POST'});
403
+ const obs = await r.json();
404
+ updateServices(obs.services);
405
+ log('info', 'Episode started: ' + taskId);
406
+ log('ok', obs.last_action_result.message);
407
+ document.getElementById('step-count').textContent = obs.step;
408
+ document.getElementById('fixed-count').textContent = '0';
409
+ document.getElementById('score-val').textContent = '—';
410
+ }
411
+
412
+ async function doStep() {
413
+ const type = document.getElementById('action-type').value;
414
+ const service = document.getElementById('action-service').value;
415
+ const key = document.getElementById('action-key').value;
416
+ const value = document.getElementById('action-value').value;
417
+ const action = {type};
418
+ if (type !== 'submit') action.service = service;
419
+ if (type === 'edit_env') { action.key = key; action.value = value; }
420
+
421
+ const r = await fetch('/step', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(action)});
422
+ const d = await r.json();
423
+ const obs = d.observation;
424
+ updateServices(obs.services);
425
+ document.getElementById('step-count').textContent = obs.step;
426
+ const cls = d.reward > 0 ? 'ok' : d.reward < 0 ? 'err' : 'info';
427
+ log(cls, `[${type}${service && type!=='submit'?' → '+service:''}] reward=${d.reward > 0 ? '+' : ''}${d.reward.toFixed(2)}`);
428
+ log('info', obs.last_action_result.message.replace(/\\n/g, ' '));
429
+ if (d.done) { log('ok', '✓ Episode done!'); doGrade(); }
430
+ }
431
+
432
+ async function doGrade() {
433
+ const r = await fetch('/grader?task_id=' + currentTask);
434
+ const d = await r.json();
435
+ const gr = d.grader_result;
436
+ document.getElementById('score-val').textContent = (gr.score * 100).toFixed(1) + '%';
437
+ document.getElementById('fixed-count').textContent = gr.faults_fixed || 0;
438
+ log('ok', `Score: ${(gr.score*100).toFixed(1)}% — ${gr.reason}`);
439
+ }
440
+
441
+ function updateServices(services) {
442
+ const colors = {healthy: '#3fb950', error: '#f85149'};
443
+ const bg = {healthy: '#1a4a2a', error: '#4a1a1a'};
444
+ const border = {healthy: '#238636', error: '#da3633'};
445
+ const list = document.getElementById('services-list');
446
+ list.innerHTML = Object.entries(services).map(([name, s]) => `
447
+ <div class="service">
448
+ <span class="service-name">${name}</span>
449
+ <div style="display:flex;align-items:center;gap:8px">
450
+ ${s.port ? `<span style="color:#8b949e;font-size:0.8rem">:${s.port}</span>` : ''}
451
+ <span class="status ${s.status}">${s.status}</span>
452
+ </div>
453
+ </div>
454
+ `).join('');
455
+ ['proxy','api','database','worker'].forEach(name => {
456
+ const box = document.getElementById('box-' + name);
457
+ if (box && services[name]) {
458
+ const st = services[name].status;
459
+ box.style.borderColor = border[st] || '#30363d';
460
+ box.style.color = colors[st] || '#e6edf3';
461
+ box.style.background = bg[st] || '#21262d';
462
+ }
463
+ });
464
+ }
465
+
466
+ function log(cls, msg) {
467
+ const el = document.getElementById('action-log');
468
+ const line = document.createElement('div');
469
+ line.className = cls;
470
+ line.textContent = '› ' + msg;
471
+ el.appendChild(line);
472
+ el.scrollTop = el.scrollHeight;
473
+ if (el.children.length > 100) el.removeChild(el.firstChild);
474
+ }
475
+
476
+ function updateActionFields() {
477
+ const type = document.getElementById('action-type').value;
478
+ document.getElementById('service-row').style.display = type === 'submit' ? 'none' : 'block';
479
+ document.getElementById('kv-row').style.display = type === 'edit_env' ? 'block' : 'none';
480
+ }
481
+
482
+ fetchTasks();
483
+ resetTask('task1');
484
+ </script>
485
+ </body>
486
+ </html>"""
487
+
488
+
489
  if __name__ == "__main__":
490
  uvicorn.run("api:app", host="0.0.0.0", port=7860, reload=False)
environment.py CHANGED
@@ -5,12 +5,11 @@ 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 = {
@@ -21,8 +20,8 @@ FAULT_CATALOG = {
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",
@@ -31,7 +30,7 @@ FAULT_CATALOG = {
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": {
@@ -41,7 +40,7 @@ FAULT_CATALOG = {
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": {
@@ -51,13 +50,43 @@ FAULT_CATALOG = {
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 = {
@@ -96,7 +125,7 @@ SERVICE_PORTS = {
96
  "proxy": 80,
97
  }
98
 
99
- MAX_STEPS = 25
100
 
101
 
102
  # ---------------------------------------------------------------------------
@@ -124,12 +153,10 @@ class DevEnvEnvironment:
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": {},
@@ -141,16 +168,17 @@ class DevEnvEnvironment:
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]:
@@ -162,9 +190,7 @@ class DevEnvEnvironment:
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
@@ -184,10 +210,9 @@ class DevEnvEnvironment:
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
@@ -196,7 +221,11 @@ class DevEnvEnvironment:
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 {
@@ -217,28 +246,27 @@ class DevEnvEnvironment:
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
@@ -246,22 +274,21 @@ class DevEnvEnvironment:
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:
@@ -270,11 +297,10 @@ class DevEnvEnvironment:
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(
@@ -285,12 +311,11 @@ class DevEnvEnvironment:
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
@@ -302,25 +327,23 @@ class DevEnvEnvironment:
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(
@@ -329,9 +352,7 @@ class DevEnvEnvironment:
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,
@@ -341,7 +362,7 @@ class DevEnvEnvironment:
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
  # ------------------------------------------------------------------
@@ -349,34 +370,25 @@ class DevEnvEnvironment:
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
 
 
5
  """
6
 
7
  import copy
 
8
  from typing import Any
9
  from models import Observation, Action, Reward, ServiceStatus, ActionResult
10
 
11
  # ---------------------------------------------------------------------------
12
+ # Fault catalog — injectable faults with realistic log messages
13
  # ---------------------------------------------------------------------------
14
 
15
  FAULT_CATALOG = {
 
20
  "bad_value": "5433",
21
  "fix_key": "DB_PORT",
22
  "fix_value": "5432",
23
+ "log": "Error: cannot connect to database on port 5433. Connection refused. Is the database running?",
24
+ "requires_restart": ["api"],
25
  },
26
  "missing_queue_url": {
27
  "service": "worker",
 
30
  "bad_value": None, # missing
31
  "fix_key": "WORKER_QUEUE_URL",
32
  "fix_value": "redis://localhost:6379",
33
+ "log": "Error: WORKER_QUEUE_URL is not set. Worker cannot connect to queue. Set WORKER_QUEUE_URL.",
34
  "requires_restart": ["worker"],
35
  },
36
  "wrong_api_port": {
 
40
  "bad_value": "http://api:9999",
41
  "fix_key": "PROXY_UPSTREAM",
42
  "fix_value": "http://api:8000",
43
+ "log": "Error: upstream http://api:9999 unreachable. 502 Bad Gateway. Check PROXY_UPSTREAM.",
44
  "requires_restart": ["proxy"],
45
  },
46
  "wrong_db_password": {
 
50
  "bad_value": "wrongpassword",
51
  "fix_key": "DB_PASSWORD",
52
  "fix_value": "secret",
53
+ "log": "FATAL: password authentication failed for user 'app'. Check DB_PASSWORD env var.",
54
  "requires_restart": ["database", "api", "worker"],
55
  },
56
+ "wrong_db_host": {
57
+ "service": "api",
58
+ "description": "DB_HOST points to wrong hostname",
59
+ "env_key": "DB_HOST",
60
+ "bad_value": "db-prod-01.internal",
61
+ "fix_key": "DB_HOST",
62
+ "fix_value": "localhost",
63
+ "log": "Error: could not resolve host 'db-prod-01.internal'. Check DB_HOST. Is this a prod config?",
64
+ "requires_restart": ["api"],
65
+ },
66
+ "wrong_api_host": {
67
+ "service": "worker",
68
+ "description": "Worker API_HOST points to wrong address",
69
+ "env_key": "API_HOST",
70
+ "bad_value": "http://api:9000",
71
+ "fix_key": "API_HOST",
72
+ "fix_value": "http://api:8000",
73
+ "log": "Error: worker cannot reach API at http://api:9000. Connection refused. Check API_HOST.",
74
+ "requires_restart": ["worker"],
75
+ },
76
+ "missing_db_password": {
77
+ "service": "api",
78
+ "description": "DB_PASSWORD not set on API service",
79
+ "env_key": "DB_PASSWORD",
80
+ "bad_value": None,
81
+ "fix_key": "DB_PASSWORD",
82
+ "fix_value": "secret",
83
+ "log": "Error: DB_PASSWORD is not set. Database connection rejected (auth requires password).",
84
+ "requires_restart": ["api"],
85
+ },
86
  }
87
 
88
  # ---------------------------------------------------------------------------
89
+ # Service dependency graph
90
  # ---------------------------------------------------------------------------
91
 
92
  DEPENDENCIES = {
 
125
  "proxy": 80,
126
  }
127
 
128
+ MAX_STEPS = 30
129
 
130
 
131
  # ---------------------------------------------------------------------------
 
153
  self._fixed_faults = set()
154
  self._log_history = []
155
 
 
156
  from tasks import TASKS
157
  task = TASKS[task_id]
158
  self._active_faults = list(task["faults"])
159
 
 
160
  self._state = {
161
  "envs": copy.deepcopy(DEFAULT_ENV),
162
  "services": {},
 
168
  svc = fault["service"]
169
  key = fault["env_key"]
170
  if fault["bad_value"] is None:
 
171
  self._state["envs"][svc].pop(key, None)
172
  else:
173
  self._state["envs"][svc][key] = fault["bad_value"]
174
 
 
175
  self._recompute_statuses()
176
 
177
  return self._build_observation(
178
+ last_action_result=ActionResult(
179
+ success=True,
180
+ message="Environment initialized. Services started. Check service statuses and read logs."
181
+ )
182
  )
183
 
184
  def step(self, action: Action) -> tuple[Observation, float, bool, dict]:
 
190
 
191
  self._step_count += 1
192
  reward = 0.0
 
193
 
 
194
  if action.type == "read_logs":
195
  result, r = self._handle_read_logs(action)
196
  reward += r
 
210
  result, r, self._done = self._handle_submit()
211
  reward += r
212
  else:
213
+ result = ActionResult(success=False, message=f"Unknown action type: '{action.type}'. Valid: read_logs, inspect_env, edit_env, restart_service, run_healthcheck, submit")
214
  reward -= 0.05
215
 
 
216
  if self._step_count >= MAX_STEPS and not self._done:
217
  self._done = True
218
  reward -= 0.2
 
221
  self._log_history.append(f"Step {self._step_count}: {action.type} → {result.message}")
222
 
223
  obs = self._build_observation(result)
224
+ return obs, round(reward, 3), self._done, {
225
+ "step": self._step_count,
226
+ "fixed_faults": list(self._fixed_faults),
227
+ "remaining_faults": [f for f in self._active_faults if f not in self._fixed_faults],
228
+ }
229
 
230
  def state(self) -> dict:
231
  return {
 
246
  def _handle_read_logs(self, action: Action) -> tuple[ActionResult, float]:
247
  svc = action.service
248
  if svc not in self._state["services"]:
249
+ return ActionResult(success=False, message=f"Unknown service '{svc}'. Valid: api, worker, database, proxy"), -0.05
250
 
251
  status = self._state["services"][svc]
252
  reward = 0.0
253
 
254
  if status.status == "error":
 
255
  logs = []
256
  for fault_id in self._active_faults:
257
  if fault_id not in self._fixed_faults:
258
  fault = FAULT_CATALOG[fault_id]
259
  if fault["service"] == svc:
260
+ logs.append(f"[ERROR] {fault['log']}")
261
+ # Add downstream symptom logs for dependent failures
262
  for fault_id in self._active_faults:
263
  if fault_id not in self._fixed_faults:
264
  fault = FAULT_CATALOG[fault_id]
265
  if svc in fault.get("requires_restart", []) and fault["service"] != svc:
266
+ logs.append(f"[ERROR] Service unavailable — upstream dependency '{fault['service']}' is failing.")
267
 
268
  log_text = "\n".join(logs) if logs else "No errors in log."
269
+ reward += 0.05
270
  return ActionResult(success=True, message=f"[{svc} logs]\n{log_text}"), reward
271
  else:
272
  return ActionResult(success=True, message=f"[{svc} logs]\nNo errors. Service running normally."), 0.0
 
274
  def _handle_inspect_env(self, action: Action) -> tuple[ActionResult, float]:
275
  svc = action.service
276
  if svc not in self._state["envs"]:
277
+ return ActionResult(success=False, message=f"Unknown service '{svc}'. Valid: api, worker, database, proxy"), -0.05
278
 
279
  env = self._state["envs"][svc]
280
+ env_text = "\n".join(f" {k}={v}" for k, v in sorted(env.items()))
281
  return ActionResult(success=True, message=f"[{svc} env]\n{env_text}"), 0.02
282
 
283
  def _handle_edit_env(self, action: Action) -> tuple[ActionResult, float]:
284
  svc = action.service
285
  if not action.key or action.value is None:
286
+ return ActionResult(success=False, message="edit_env requires 'key' and 'value'."), -0.05
287
  if svc not in self._state["envs"]:
288
+ return ActionResult(success=False, message=f"Unknown service '{svc}'."), -0.05
289
 
290
  self._state["envs"][svc][action.key] = action.value
291
 
 
292
  reward = 0.0
293
  for fault_id in self._active_faults:
294
  if fault_id in self._fixed_faults:
 
297
  if (fault["service"] == svc and
298
  fault["fix_key"] == action.key and
299
  fault["fix_value"] == action.value):
 
300
  reward += 0.1
301
  return ActionResult(
302
  success=True,
303
+ message=f"Updated {svc}.{action.key}={action.value}. Now restart {svc} to apply."
304
  ), reward
305
 
306
  return ActionResult(
 
311
  def _handle_restart_service(self, action: Action) -> tuple[ActionResult, float]:
312
  svc = action.service
313
  if svc not in self._state["services"]:
314
+ return ActionResult(success=False, message=f"Unknown service '{svc}'."), -0.05
315
 
316
  reward = 0.0
317
  prev_statuses = {s: self._state["services"][s].status for s in self._state["services"]}
318
 
 
319
  for fault_id in self._active_faults:
320
  if fault_id in self._fixed_faults:
321
  continue
 
327
 
328
  self._recompute_statuses()
329
 
 
330
  for s, status_obj in self._state["services"].items():
331
  if prev_statuses[s] != "healthy" and status_obj.status == "healthy":
332
+ reward += 0.3
333
 
 
334
  if self._state["services"][svc].status == "error":
335
  reward -= 0.1
336
  return ActionResult(
337
  success=True,
338
+ message=f"{svc} restarted but still in error. Root cause not fixed yet."
339
  ), reward
340
 
341
+ return ActionResult(success=True, message=f"{svc} restarted successfully. Status: healthy."), reward
342
 
343
  def _handle_healthcheck(self, action: Action) -> tuple[ActionResult, float]:
344
  svc = action.service
345
  if svc not in self._state["services"]:
346
+ return ActionResult(success=False, message=f"Unknown service '{svc}'."), -0.05
347
 
348
  status = self._state["services"][svc]
349
  return ActionResult(
 
352
  ), 0.01
353
 
354
  def _handle_submit(self) -> tuple[ActionResult, float, bool]:
355
+ all_healthy = all(s.status == "healthy" for s in self._state["services"].values())
 
 
356
  if all_healthy:
357
  return ActionResult(
358
  success=True,
 
362
  unhealthy = [k for k, v in self._state["services"].items() if v.status != "healthy"]
363
  return ActionResult(
364
  success=False,
365
+ message=f"Submit failed. Services still unhealthy: {unhealthy}. Fix all services before submitting."
366
  ), -0.3, True
367
 
368
  # ------------------------------------------------------------------
 
370
  # ------------------------------------------------------------------
371
 
372
  def _recompute_statuses(self):
 
 
373
  statuses = {svc: "healthy" for svc in DEFAULT_ENV}
374
 
 
 
375
  directly_broken = set()
376
+ for fault_id in self._active_faults:
377
+ if fault_id not in self._fixed_faults:
378
+ fault = FAULT_CATALOG[fault_id]
379
+ directly_broken.add(fault["service"])
380
 
 
381
  for svc in ["database", "api", "worker", "proxy"]:
382
  if svc in directly_broken:
383
  statuses[svc] = "error"
384
  else:
 
385
  for dep in DEPENDENCIES.get(svc, []):
386
  if statuses.get(dep) == "error":
387
  statuses[svc] = "error"
388
  break
389
 
390
  self._state["services"] = {
391
+ svc: ServiceStatus(name=svc, status=statuses[svc], port=SERVICE_PORTS[svc])
 
 
 
 
392
  for svc in DEFAULT_ENV
393
  }
394
 
tasks.py CHANGED
@@ -1,6 +1,6 @@
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 = {
@@ -31,13 +31,37 @@ TASKS = {
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
 
@@ -53,12 +77,11 @@ def grade_episode(task_id: str, env_state: dict) -> dict:
53
  """
54
  task = TASKS.get(task_id)
55
  if not task:
56
- return {"score": _clamp(0.0), "reason": f"Unknown task: {task_id}"}
57
 
58
  services = env_state.get("services", {})
59
  fixed_faults = set(env_state.get("fixed_faults", []))
60
  active_faults = set(env_state.get("active_faults", []))
61
- remaining = active_faults - fixed_faults
62
  steps = env_state.get("step", 0)
63
 
64
  all_healthy = all(s["status"] == "healthy" for s in services.values())
@@ -66,13 +89,12 @@ def grade_episode(task_id: str, env_state: dict) -> dict:
66
  faults_fixed = len(fixed_faults)
67
 
68
  # ---------------------------------------------------------------
69
- # Task 1 grader — single fault, binary but with partial for partial fix
70
  # ---------------------------------------------------------------
71
  if task_id == "task1":
72
  if all_healthy and faults_fixed == total_faults:
73
- # Efficiency bonus for doing it quickly
74
- efficiency = max(0.0, 1.0 - (steps - 3) * 0.03)
75
- score = min(1.0, 0.85 + efficiency * 0.15)
76
  return {
77
  "score": _clamp(score),
78
  "reason": f"All services healthy. Fixed in {steps} steps.",
@@ -81,55 +103,52 @@ def grade_episode(task_id: str, env_state: dict) -> dict:
81
  }
82
  elif faults_fixed > 0:
83
  return {
84
- "score": _clamp(0.4),
85
- "reason": "Fault fixed but service not restarted or submit not called.",
86
  "faults_fixed": faults_fixed,
87
  "total_faults": total_faults,
88
  }
89
- else:
90
- return {
91
- "score": _clamp(0.0),
92
- "reason": "No faults fixed.",
93
- "faults_fixed": 0,
94
- "total_faults": total_faults,
95
- }
96
 
97
  # ---------------------------------------------------------------
98
- # Task 2 grader — partial credit per fault fixed
99
  # ---------------------------------------------------------------
100
  if task_id == "task2":
101
  if all_healthy and faults_fixed == total_faults:
102
- efficiency = max(0.0, 1.0 - (steps - 5) * 0.02)
103
- score = min(1.0, 0.85 + efficiency * 0.15)
104
  return {
105
  "score": _clamp(score),
106
- "reason": f"All services healthy. Both faults fixed in {steps} steps.",
107
  "faults_fixed": faults_fixed,
108
  "total_faults": total_faults,
109
  }
110
  elif faults_fixed == 1:
111
  return {
112
- "score": _clamp(0.45),
113
- "reason": f"One of two faults fixed. Partial credit.",
114
  "faults_fixed": faults_fixed,
115
  "total_faults": total_faults,
116
  }
117
- else:
118
- return {
119
- "score": _clamp(0.0),
120
- "reason": "No faults fixed.",
121
- "faults_fixed": 0,
122
- "total_faults": total_faults,
123
- }
124
 
125
  # ---------------------------------------------------------------
126
- # Task 3 grader — penalizes symptom-patching, rewards root cause fix
127
  # ---------------------------------------------------------------
128
  if task_id == "task3":
129
  if all_healthy and faults_fixed == total_faults:
130
- # Reward fewer steps heavily (cascading fix is fast if root cause found)
131
- efficiency = max(0.0, 1.0 - (steps - 4) * 0.04)
132
- score = min(1.0, 0.80 + efficiency * 0.20)
133
  return {
134
  "score": _clamp(score),
135
  "reason": f"Root cause found and fixed. All services recovered in {steps} steps.",
@@ -137,19 +156,78 @@ def grade_episode(task_id: str, env_state: dict) -> dict:
137
  "total_faults": total_faults,
138
  }
139
  elif faults_fixed > 0:
140
- # Fixed something but not all healthy — partial
141
  return {
142
- "score": _clamp(0.3),
143
- "reason": "Partial fix. Root cause addressed but services not fully recovered.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  "faults_fixed": faults_fixed,
145
  "total_faults": total_faults,
146
  }
147
- else:
148
  return {
149
- "score": _clamp(0.0),
150
- "reason": "Root cause not identified. All services still broken.",
151
- "faults_fixed": 0,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  "total_faults": total_faults,
153
  }
 
 
 
 
 
 
154
 
155
- return {"score": _clamp(0.0), "reason": "Unhandled task."}
 
1
  """
2
  Task definitions and programmatic graders.
3
+ Each task defines faults to inject and a grader that scores strictly in (0, 1).
4
  """
5
 
6
  TASKS = {
 
31
  "name": "Cascading Failure Debug",
32
  "difficulty": "hard",
33
  "description": (
34
+ "The database has wrong credentials, causing the API to log a misleading "
35
+ "'connection timeout' error, which cascades to the worker and proxy. "
36
+ "Logs are misleading — the agent must trace the root cause (DB_PASSWORD on the "
37
+ "database service) rather than patching downstream symptoms."
38
  ),
39
  "faults": ["wrong_db_password"],
40
  },
41
+ "task4": {
42
+ "id": "task4",
43
+ "name": "Triple Fault Storm",
44
+ "difficulty": "hard",
45
+ "description": (
46
+ "Three independent faults hit at once: wrong DB_HOST on the API, "
47
+ "wrong API_HOST on the worker, and wrong PROXY_UPSTREAM on the proxy. "
48
+ "Each service is broken for a different reason. Agent must diagnose and fix all three "
49
+ "without being misled by cascading errors."
50
+ ),
51
+ "faults": ["wrong_db_host", "wrong_api_host", "wrong_api_port"],
52
+ },
53
+ "task5": {
54
+ "id": "task5",
55
+ "name": "Silent Misconfiguration",
56
+ "difficulty": "expert",
57
+ "description": (
58
+ "Two subtle faults with no obvious error messages: DB_PASSWORD is missing on the API "
59
+ "(not wrong — missing entirely), and the worker's API_HOST points to the wrong port. "
60
+ "Services appear to start but immediately fail silently. "
61
+ "The agent must inspect env vars carefully rather than relying on logs alone."
62
+ ),
63
+ "faults": ["missing_db_password", "wrong_api_host"],
64
+ },
65
  }
66
 
67
 
 
77
  """
78
  task = TASKS.get(task_id)
79
  if not task:
80
+ return {"score": _clamp(0.001), "reason": f"Unknown task: {task_id}"}
81
 
82
  services = env_state.get("services", {})
83
  fixed_faults = set(env_state.get("fixed_faults", []))
84
  active_faults = set(env_state.get("active_faults", []))
 
85
  steps = env_state.get("step", 0)
86
 
87
  all_healthy = all(s["status"] == "healthy" for s in services.values())
 
89
  faults_fixed = len(fixed_faults)
90
 
91
  # ---------------------------------------------------------------
92
+ # Task 1 — single fault, efficiency bonus
93
  # ---------------------------------------------------------------
94
  if task_id == "task1":
95
  if all_healthy and faults_fixed == total_faults:
96
+ efficiency = max(0.0, 1.0 - max(0, steps - 4) * 0.04)
97
+ score = 0.75 + efficiency * 0.20
 
98
  return {
99
  "score": _clamp(score),
100
  "reason": f"All services healthy. Fixed in {steps} steps.",
 
103
  }
104
  elif faults_fixed > 0:
105
  return {
106
+ "score": _clamp(0.35),
107
+ "reason": "Fault fixed but episode not completed correctly.",
108
  "faults_fixed": faults_fixed,
109
  "total_faults": total_faults,
110
  }
111
+ return {
112
+ "score": _clamp(0.05),
113
+ "reason": "No faults fixed.",
114
+ "faults_fixed": 0,
115
+ "total_faults": total_faults,
116
+ }
 
117
 
118
  # ---------------------------------------------------------------
119
+ # Task 2 — partial credit per fault fixed
120
  # ---------------------------------------------------------------
121
  if task_id == "task2":
122
  if all_healthy and faults_fixed == total_faults:
123
+ efficiency = max(0.0, 1.0 - max(0, steps - 6) * 0.03)
124
+ score = 0.70 + efficiency * 0.25
125
  return {
126
  "score": _clamp(score),
127
+ "reason": f"Both faults fixed. All services healthy in {steps} steps.",
128
  "faults_fixed": faults_fixed,
129
  "total_faults": total_faults,
130
  }
131
  elif faults_fixed == 1:
132
  return {
133
+ "score": _clamp(0.40),
134
+ "reason": "One of two faults fixed. Partial credit.",
135
  "faults_fixed": faults_fixed,
136
  "total_faults": total_faults,
137
  }
138
+ return {
139
+ "score": _clamp(0.05),
140
+ "reason": "No faults fixed.",
141
+ "faults_fixed": 0,
142
+ "total_faults": total_faults,
143
+ }
 
144
 
145
  # ---------------------------------------------------------------
146
+ # Task 3 — cascading root cause
147
  # ---------------------------------------------------------------
148
  if task_id == "task3":
149
  if all_healthy and faults_fixed == total_faults:
150
+ efficiency = max(0.0, 1.0 - max(0, steps - 5) * 0.04)
151
+ score = 0.70 + efficiency * 0.25
 
152
  return {
153
  "score": _clamp(score),
154
  "reason": f"Root cause found and fixed. All services recovered in {steps} steps.",
 
156
  "total_faults": total_faults,
157
  }
158
  elif faults_fixed > 0:
 
159
  return {
160
+ "score": _clamp(0.30),
161
+ "reason": "Root cause addressed but services not fully recovered.",
162
+ "faults_fixed": faults_fixed,
163
+ "total_faults": total_faults,
164
+ }
165
+ return {
166
+ "score": _clamp(0.05),
167
+ "reason": "Root cause not identified.",
168
+ "faults_fixed": 0,
169
+ "total_faults": total_faults,
170
+ }
171
+
172
+ # ---------------------------------------------------------------
173
+ # Task 4 — triple fault storm (partial credit per fault)
174
+ # ---------------------------------------------------------------
175
+ if task_id == "task4":
176
+ if all_healthy and faults_fixed == total_faults:
177
+ efficiency = max(0.0, 1.0 - max(0, steps - 10) * 0.025)
178
+ score = 0.65 + efficiency * 0.30
179
+ return {
180
+ "score": _clamp(score),
181
+ "reason": f"All 3 faults fixed. All services healthy in {steps} steps.",
182
+ "faults_fixed": faults_fixed,
183
+ "total_faults": total_faults,
184
+ }
185
+ elif faults_fixed == 2:
186
+ return {
187
+ "score": _clamp(0.55),
188
+ "reason": "2 of 3 faults fixed.",
189
  "faults_fixed": faults_fixed,
190
  "total_faults": total_faults,
191
  }
192
+ elif faults_fixed == 1:
193
  return {
194
+ "score": _clamp(0.28),
195
+ "reason": "1 of 3 faults fixed.",
196
+ "faults_fixed": faults_fixed,
197
+ "total_faults": total_faults,
198
+ }
199
+ return {
200
+ "score": _clamp(0.05),
201
+ "reason": "No faults fixed.",
202
+ "faults_fixed": 0,
203
+ "total_faults": total_faults,
204
+ }
205
+
206
+ # ---------------------------------------------------------------
207
+ # Task 5 — silent misconfiguration (expert)
208
+ # ---------------------------------------------------------------
209
+ if task_id == "task5":
210
+ if all_healthy and faults_fixed == total_faults:
211
+ efficiency = max(0.0, 1.0 - max(0, steps - 8) * 0.03)
212
+ score = 0.65 + efficiency * 0.30
213
+ return {
214
+ "score": _clamp(score),
215
+ "reason": f"Both silent faults found and fixed in {steps} steps. Expert diagnosis.",
216
+ "faults_fixed": faults_fixed,
217
+ "total_faults": total_faults,
218
+ }
219
+ elif faults_fixed == 1:
220
+ return {
221
+ "score": _clamp(0.38),
222
+ "reason": "One silent fault found. Second still undetected.",
223
+ "faults_fixed": faults_fixed,
224
  "total_faults": total_faults,
225
  }
226
+ return {
227
+ "score": _clamp(0.05),
228
+ "reason": "No faults found. Agent must inspect env vars, not just logs.",
229
+ "faults_fixed": 0,
230
+ "total_faults": total_faults,
231
+ }
232
 
233
+ return {"score": _clamp(0.05), "reason": "Unhandled task."}