Spaces:
Sleeping
Sleeping
Commit ·
5510ae2
1
Parent(s): 11f8f10
updated logic to use qwen with intended tasks
Browse files- .env.example +4 -0
- .gitignore +2 -1
- README.md +141 -54
- app.py +8 -0
- data.py +14 -576
- environment.py +63 -4
- graders.py +3 -6
- inference.py +215 -88
- openenv.yaml +14 -2
- pyproject.toml +21 -0
- requirements.txt +2 -1
- server/__init__.py +0 -0
- server/app.py +20 -0
- test_integration.py +0 -116
- tests_new.py +0 -248
- uv.lock +3 -0
.env.example
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export OPENENV_BASE_URL="http://127.0.0.1:7860"
|
| 2 |
+
export API_BASE_URL="https://router.huggingface.co/v1"
|
| 3 |
+
export MODEL_NAME="model name eg. - Qwen/Qwen2.5-72B-Instruct"
|
| 4 |
+
export HF_TOKEN="your_token"
|
.gitignore
CHANGED
|
@@ -8,4 +8,5 @@ venv/
|
|
| 8 |
dist/
|
| 9 |
build/
|
| 10 |
.pytest_cache/
|
| 11 |
-
myenv/
|
|
|
|
|
|
| 8 |
dist/
|
| 9 |
build/
|
| 10 |
.pytest_cache/
|
| 11 |
+
myenv/
|
| 12 |
+
res/
|
README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
---
|
| 2 |
title: DevOpsEnv
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: green
|
| 6 |
sdk: docker
|
|
@@ -16,84 +16,171 @@ pinned: false
|
|
| 16 |
|
| 17 |
# DevOpsEnv
|
| 18 |
|
| 19 |
-
DevOpsEnv is
|
| 20 |
|
| 21 |
-
|
| 22 |
-
- Running shell commands
|
| 23 |
-
- Editing files
|
| 24 |
-
- Submitting when the fix is done
|
| 25 |
|
| 26 |
-
|
| 27 |
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
|
| 31 |
-
- Task 1: Nginx is down. Bring service back and verify HTTP is OK.
|
| 32 |
-
- Task 2: Docker compose port mapping is wrong. Fix and redeploy.
|
| 33 |
-
- Task 3: Python API has memory leak behavior. Diagnose and reduce memory usage.
|
| 34 |
|
| 35 |
-
##
|
| 36 |
|
| 37 |
-
|
| 38 |
-
1. Call POST /reset with task_id.
|
| 39 |
-
2. You get episode_id plus current system_state.
|
| 40 |
-
3. Call POST /step with an action.
|
| 41 |
-
4. Repeat steps until done, or send action_type submit.
|
| 42 |
-
5. Call POST /grader to get final score and breakdown.
|
| 43 |
|
| 44 |
-
|
| 45 |
-
-
|
| 46 |
-
-
|
| 47 |
-
-
|
| 48 |
-
- POST /
|
| 49 |
-
-
|
| 50 |
-
- POST /grader
|
| 51 |
|
| 52 |
-
##
|
| 53 |
|
| 54 |
-
|
| 55 |
-
- file_edit: Replace content of a file path
|
| 56 |
-
- submit: End episode and grade
|
| 57 |
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
-
##
|
| 61 |
|
| 62 |
-
|
| 63 |
|
| 64 |
-
|
|
|
|
|
|
|
| 65 |
|
| 66 |
-
##
|
| 67 |
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
### 3) Check health
|
| 71 |
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
-
|
|
|
|
|
|
|
| 75 |
|
| 76 |
-
|
| 77 |
|
| 78 |
-
|
|
|
|
|
|
|
| 79 |
|
| 80 |
-
|
| 81 |
|
| 82 |
-
|
| 83 |
|
| 84 |
-
|
|
|
|
|
|
|
| 85 |
|
| 86 |
-
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
|
|
|
| 90 |
|
| 91 |
-
|
| 92 |
-
episode_id = $episodeId
|
| 93 |
-
action = @{
|
| 94 |
-
action_type = "bash_cmd"
|
| 95 |
-
command = "systemctl restart nginx"
|
| 96 |
-
}
|
| 97 |
-
} | ConvertTo-Json -Depth 5
|
| 98 |
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: DevOpsEnv
|
| 3 |
+
emoji: "tools"
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: green
|
| 6 |
sdk: docker
|
|
|
|
| 16 |
|
| 17 |
# DevOpsEnv
|
| 18 |
|
| 19 |
+
DevOpsEnv is an OpenEnv-compliant environment for training and evaluating AI agents on realistic DevOps/SRE incident response workflows.
|
| 20 |
|
| 21 |
+
## Motivation
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
+
This environment models a real operational workflow that engineers do in production:
|
| 24 |
|
| 25 |
+
- inspect system state
|
| 26 |
+
- run diagnostic commands
|
| 27 |
+
- apply targeted config/code fixes
|
| 28 |
+
- verify impact
|
| 29 |
+
- submit a final resolution
|
| 30 |
|
| 31 |
+
It is intentionally designed around common SRE failure classes (service outage, deployment misconfiguration, runtime memory issue) instead of toy interactions.
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
+
## OpenEnv Compliance
|
| 34 |
|
| 35 |
+
The project implements the required OpenEnv interface:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
+
- typed Pydantic models for `Observation`, `Action`, `Reward`, `StepResult`, `State`
|
| 38 |
+
- `POST /reset` returns the initial observation
|
| 39 |
+
- `POST /step` returns `observation`, `reward`, `done`, `info`
|
| 40 |
+
- `GET /state` returns current episode state
|
| 41 |
+
- `POST /grader` returns deterministic final score and breakdown
|
| 42 |
+
- `openenv.yaml` metadata/spec included
|
|
|
|
| 43 |
|
| 44 |
+
## Observation Space
|
| 45 |
|
| 46 |
+
`Observation` includes:
|
|
|
|
|
|
|
| 47 |
|
| 48 |
+
- task metadata (`task_id`, `task_description`)
|
| 49 |
+
- episode controls (`episode_id`, `step_number`, `max_steps`)
|
| 50 |
+
- `system_state`:
|
| 51 |
+
- running processes
|
| 52 |
+
- service status
|
| 53 |
+
- open HTTP ports
|
| 54 |
+
- docker containers
|
| 55 |
+
- logs
|
| 56 |
+
- filesystem snapshot
|
| 57 |
+
- cpu and memory metrics
|
| 58 |
+
- interaction history and current `available_actions`
|
| 59 |
|
| 60 |
+
## Action Space
|
| 61 |
|
| 62 |
+
`Action.action_type` is one of:
|
| 63 |
|
| 64 |
+
- `bash_cmd`: execute simulated shell command (`command`)
|
| 65 |
+
- `file_edit`: overwrite known config/source file (`file_path`, `file_content`)
|
| 66 |
+
- `submit`: terminate and grade current episode (`summary` optional)
|
| 67 |
|
| 68 |
+
## Tasks and Difficulty
|
| 69 |
|
| 70 |
+
The environment ships with 3 graded tasks:
|
| 71 |
+
|
| 72 |
+
1. `task1` (easy): recover crashed Nginx and verify HTTP health.
|
| 73 |
+
2. `task2` (medium): correct docker-compose port mapping and redeploy.
|
| 74 |
+
3. `task3` (hard): diagnose memory leak behavior, patch service code, restart cleanly.
|
| 75 |
+
|
| 76 |
+
Each task has deterministic grading with score in `[0.0, 1.0]` and criterion-level breakdown.
|
| 77 |
+
|
| 78 |
+
## Reward Design
|
| 79 |
+
|
| 80 |
+
Rewards are dense and shaped to provide trajectory signal:
|
| 81 |
+
|
| 82 |
+
- per-step cost discourages long loops
|
| 83 |
+
- action-type reward for useful commands/edits
|
| 84 |
+
- progress bonuses for key milestones (validation, successful restart, verified outputs)
|
| 85 |
+
- penalties for repeated identical actions and invalid edits
|
| 86 |
+
- terminal bonus from grader score on episode completion
|
| 87 |
+
|
| 88 |
+
## Local Setup
|
| 89 |
+
|
| 90 |
+
### 1) Install dependencies
|
| 91 |
+
|
| 92 |
+
```bash
|
| 93 |
+
pip install -r requirements.txt
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
### 2) Run API server
|
| 97 |
+
|
| 98 |
+
```bash
|
| 99 |
+
uvicorn app:app --host 0.0.0.0 --port 7860
|
| 100 |
+
```
|
| 101 |
|
| 102 |
### 3) Check health
|
| 103 |
|
| 104 |
+
```bash
|
| 105 |
+
curl http://127.0.0.1:7860/health
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
### 4) Validate OpenEnv package
|
| 109 |
+
|
| 110 |
+
```bash
|
| 111 |
+
openenv validate
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
## Baseline Inference Script
|
| 115 |
+
|
| 116 |
+
The required baseline script is at project root: `inference.py`.
|
| 117 |
+
|
| 118 |
+
It:
|
| 119 |
+
|
| 120 |
+
- uses the OpenAI Python client
|
| 121 |
+
- reads mandatory LLM variables:
|
| 122 |
+
- `API_BASE_URL`
|
| 123 |
+
- `MODEL_NAME`
|
| 124 |
+
- `HF_TOKEN`
|
| 125 |
+
- runs all three tasks by default
|
| 126 |
+
- emits strict structured stdout lines:
|
| 127 |
+
- `[START] ...`
|
| 128 |
+
- `[STEP] ...`
|
| 129 |
+
- `[END] ...`
|
| 130 |
+
|
| 131 |
+
### Inference environment variables
|
| 132 |
+
|
| 133 |
+
```bash
|
| 134 |
+
export OPENENV_BASE_URL="http://127.0.0.1:7860"
|
| 135 |
+
export API_BASE_URL="https://router.huggingface.co/v1"
|
| 136 |
+
export MODEL_NAME="Qwen/Qwen2.5-72B-Instruct"
|
| 137 |
+
export HF_TOKEN="<your_token>"
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
### Run baseline
|
| 141 |
+
|
| 142 |
+
```bash
|
| 143 |
+
python inference.py
|
| 144 |
+
```
|
| 145 |
+
|
| 146 |
+
Run a single task:
|
| 147 |
+
|
| 148 |
+
```bash
|
| 149 |
+
python inference.py --task task2
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
## Docker
|
| 153 |
+
|
| 154 |
+
Build:
|
| 155 |
|
| 156 |
+
```bash
|
| 157 |
+
docker build -t devopsenv:latest .
|
| 158 |
+
```
|
| 159 |
|
| 160 |
+
Run:
|
| 161 |
|
| 162 |
+
```bash
|
| 163 |
+
docker run --rm -p 7860:7860 devopsenv:latest
|
| 164 |
+
```
|
| 165 |
|
| 166 |
+
## Hugging Face Spaces Deployment
|
| 167 |
|
| 168 |
+
This repository is configured for Docker Spaces:
|
| 169 |
|
| 170 |
+
- README frontmatter sets `sdk: docker`
|
| 171 |
+
- container exposes and serves on port `7860`
|
| 172 |
+
- includes `openenv` tag
|
| 173 |
|
| 174 |
+
After pushing to a Space, verify:
|
| 175 |
|
| 176 |
+
- `POST /reset` returns 200
|
| 177 |
+
- `openenv validate` passes
|
| 178 |
+
- `python inference.py` completes within runtime constraints
|
| 179 |
|
| 180 |
+
## Pre-Submission Checklist
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
+
- HF Space endpoint responds to `/reset`
|
| 183 |
+
- `openenv validate` passes
|
| 184 |
+
- `docker build` succeeds
|
| 185 |
+
- `inference.py` runs and logs strict `[START]/[STEP]/[END]` format
|
| 186 |
+
- all 3 tasks produce valid grader scores in `[0.0, 1.0]`
|
app.py
CHANGED
|
@@ -156,3 +156,11 @@ if __name__ == "__main__":
|
|
| 156 |
port = int(os.environ.get("PORT", 7860))
|
| 157 |
uvicorn.run(app, host="0.0.0.0", port=port, workers=1)
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
port = int(os.environ.get("PORT", 7860))
|
| 157 |
uvicorn.run(app, host="0.0.0.0", port=port, workers=1)
|
| 158 |
|
| 159 |
+
|
| 160 |
+
def run_server() -> None:
|
| 161 |
+
"""Console-script entry point used by packaging validators."""
|
| 162 |
+
import uvicorn
|
| 163 |
+
|
| 164 |
+
port = int(os.environ.get("PORT", 7860))
|
| 165 |
+
uvicorn.run(app, host="0.0.0.0", port=port, workers=1)
|
| 166 |
+
|
data.py
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
"""
|
| 2 |
Linux DevOps tasks for SRE troubleshooting environment.
|
| 3 |
|
| 4 |
-
Task 1 (easy)
|
| 5 |
-
Task 2 (medium) — Fix Docker container misconfiguration
|
| 6 |
-
Task 3 (hard)
|
| 7 |
"""
|
| 8 |
from __future__ import annotations
|
| 9 |
from typing import Any, Dict, List
|
|
@@ -24,586 +24,24 @@ MOCK_API_PATH = "/opt/mockapi/app.py"
|
|
| 24 |
|
| 25 |
TASK_META: Dict[str, Dict[str, Any]] = {
|
| 26 |
"task1": {
|
| 27 |
-
"name": "
|
| 28 |
-
"description": (
|
| 29 |
-
"Production Nginx service has crashed. Restart the service, "
|
| 30 |
-
"verify the configuration syntax, and ensure the server "
|
| 31 |
-
"returns HTTP 200 on port 80. Failing checklist:\n"
|
| 32 |
-
"1. Restart nginx (systemctl restart nginx)\n"
|
| 33 |
-
"2. Verify config syntax (nginx -t)\n"
|
| 34 |
-
"3. Confirm service is running (systemctl status nginx)\n"
|
| 35 |
-
"4. Check HTTP 200 response (curl http://localhost:80)"
|
| 36 |
-
),
|
| 37 |
"difficulty": "easy",
|
| 38 |
"max_steps": 10,
|
| 39 |
-
"
|
| 40 |
-
"
|
| 41 |
-
"nginx_running",
|
| 42 |
-
"config_valid",
|
| 43 |
-
"http_200_response",
|
| 44 |
-
],
|
| 45 |
},
|
| 46 |
"task2": {
|
| 47 |
-
"name": "
|
| 48 |
-
"description": (
|
| 49 |
-
"A critical microservice container is misconfigured. The port "
|
| 50 |
-
"mapping in docker-compose.yml is broken. Fix the configuration, "
|
| 51 |
-
"redeploy the container, and verify it's accessible on the "
|
| 52 |
-
"correct port.\n"
|
| 53 |
-
"1. Edit docker-compose.yml (fix port mapping)\n"
|
| 54 |
-
"2. Restart containers (docker-compose up -d)\n"
|
| 55 |
-
"3. Verify container is running\n"
|
| 56 |
-
"4. Check service responds on mapped port"
|
| 57 |
-
),
|
| 58 |
"difficulty": "medium",
|
| 59 |
"max_steps": 15,
|
| 60 |
-
"
|
| 61 |
-
"
|
| 62 |
-
"docker_compose_valid",
|
| 63 |
-
"container_running",
|
| 64 |
-
"port_accessible",
|
| 65 |
-
],
|
| 66 |
},
|
| 67 |
"task3": {
|
| 68 |
-
"name": "
|
| 69 |
-
"description": (
|
| 70 |
-
"The Python API service is leaking memory and consuming excessive "
|
| 71 |
-
"resources. Diagnose the memory leak in /opt/mockapi/app.py, fix "
|
| 72 |
-
"the offending code, and restart the service without root access.\n"
|
| 73 |
-
"1. Identify the memory leak (check processes, logs)\n"
|
| 74 |
-
"2. Kill the runaway process\n"
|
| 75 |
-
"3. Fix the code in app.py (patch the leak)\n"
|
| 76 |
-
"4. Restart the service as appuser\n"
|
| 77 |
-
"5. Verify memory usage is normal"
|
| 78 |
-
),
|
| 79 |
"difficulty": "hard",
|
| 80 |
"max_steps": 20,
|
| 81 |
-
"
|
| 82 |
-
"
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
"service_restarted",
|
| 86 |
-
"memory_normal",
|
| 87 |
-
],
|
| 88 |
-
},
|
| 89 |
-
}
|
| 90 |
-
# Agent must choose: category + priority
|
| 91 |
-
# Categories: billing | technical | account | feature_request | complaint | general
|
| 92 |
-
# Priorities: low | medium | high | critical
|
| 93 |
-
# ---------------------------------------------------------------------------
|
| 94 |
-
|
| 95 |
-
TASK1_TICKETS: List[Dict[str, Any]] = [
|
| 96 |
-
{
|
| 97 |
-
"ticket_id": "T1-001",
|
| 98 |
-
"subject": "Double-charged on my Pro subscription this month",
|
| 99 |
-
"body": (
|
| 100 |
-
"Hi, I noticed I was billed $49.99 twice on my credit card "
|
| 101 |
-
"on March 3rd for my Pro subscription. My account email is "
|
| 102 |
-
"alice@example.com. This is really frustrating — please issue "
|
| 103 |
-
"a refund for the duplicate charge as soon as possible."
|
| 104 |
-
),
|
| 105 |
-
"customer_tier": "pro",
|
| 106 |
-
"account_age_days": 420,
|
| 107 |
-
"previous_tickets": 0,
|
| 108 |
-
"attachments": [],
|
| 109 |
-
"ground_truth": {
|
| 110 |
-
"category": "billing",
|
| 111 |
-
"priority": "high",
|
| 112 |
-
},
|
| 113 |
-
},
|
| 114 |
-
{
|
| 115 |
-
"ticket_id": "T1-002",
|
| 116 |
-
"subject": "API rate limit keeps hitting 429 even though we're Enterprise",
|
| 117 |
-
"body": (
|
| 118 |
-
"Our production service has been receiving HTTP 429 errors from "
|
| 119 |
-
"your API for the past 2 hours. We're on the Enterprise plan which "
|
| 120 |
-
"should give us 10 000 req/min. Current usage is ~3 000 req/min "
|
| 121 |
-
"so we're well within limits. This is causing a production outage "
|
| 122 |
-
"for our customers. Ticket urgency: CRITICAL."
|
| 123 |
-
),
|
| 124 |
-
"customer_tier": "enterprise",
|
| 125 |
-
"account_age_days": 730,
|
| 126 |
-
"previous_tickets": 3,
|
| 127 |
-
"attachments": ["rate_limit_screenshot.png"],
|
| 128 |
-
"ground_truth": {
|
| 129 |
-
"category": "technical",
|
| 130 |
-
"priority": "critical",
|
| 131 |
-
},
|
| 132 |
-
},
|
| 133 |
-
{
|
| 134 |
-
"ticket_id": "T1-003",
|
| 135 |
-
"subject": "Can I change my account email address?",
|
| 136 |
-
"body": (
|
| 137 |
-
"Hello, I recently changed jobs and want to update the email "
|
| 138 |
-
"associated with my account from oldname@corp.com to "
|
| 139 |
-
"newname@newcorp.com. Can you walk me through how to do this? "
|
| 140 |
-
"There's no rush — just want to get it sorted at some point."
|
| 141 |
-
),
|
| 142 |
-
"customer_tier": "free",
|
| 143 |
-
"account_age_days": 60,
|
| 144 |
-
"previous_tickets": 0,
|
| 145 |
-
"attachments": [],
|
| 146 |
-
"ground_truth": {
|
| 147 |
-
"category": "account",
|
| 148 |
-
"priority": "low",
|
| 149 |
-
},
|
| 150 |
-
},
|
| 151 |
-
{
|
| 152 |
-
"ticket_id": "T1-004",
|
| 153 |
-
"subject": "Would love a dark mode option in the dashboard",
|
| 154 |
-
"body": (
|
| 155 |
-
"Hi team, long-time Pro user here. I spend a lot of time in the "
|
| 156 |
-
"dashboard and it would be fantastic if you could add a dark mode "
|
| 157 |
-
"toggle. Many other SaaS tools have this now and it really reduces "
|
| 158 |
-
"eye strain. Would be great to see this in a future release!"
|
| 159 |
-
),
|
| 160 |
-
"customer_tier": "pro",
|
| 161 |
-
"account_age_days": 900,
|
| 162 |
-
"previous_tickets": 1,
|
| 163 |
-
"attachments": [],
|
| 164 |
-
"ground_truth": {
|
| 165 |
-
"category": "feature_request",
|
| 166 |
-
"priority": "low",
|
| 167 |
-
},
|
| 168 |
-
},
|
| 169 |
-
{
|
| 170 |
-
"ticket_id": "T1-005",
|
| 171 |
-
"subject": "Your customer service is absolutely terrible",
|
| 172 |
-
"body": (
|
| 173 |
-
"I submitted a ticket three weeks ago about a bug in your export "
|
| 174 |
-
"feature and nobody has responded. This is completely unacceptable. "
|
| 175 |
-
"I am paying for a Pro subscription and I expect timely support. "
|
| 176 |
-
"If this is not resolved in 48 hours I will be requesting a full "
|
| 177 |
-
"refund and posting a review online."
|
| 178 |
-
),
|
| 179 |
-
"customer_tier": "pro",
|
| 180 |
-
"account_age_days": 200,
|
| 181 |
-
"previous_tickets": 2,
|
| 182 |
-
"attachments": [],
|
| 183 |
-
"ground_truth": {
|
| 184 |
-
"category": "complaint",
|
| 185 |
-
"priority": "high",
|
| 186 |
-
},
|
| 187 |
-
},
|
| 188 |
-
]
|
| 189 |
-
|
| 190 |
-
# ---------------------------------------------------------------------------
|
| 191 |
-
# TASK 2 — Information Extraction
|
| 192 |
-
# Agent must populate extracted_entities and required_actions.
|
| 193 |
-
# Ground truth defines exact entity keys and accepted values.
|
| 194 |
-
# ---------------------------------------------------------------------------
|
| 195 |
-
|
| 196 |
-
TASK2_TICKETS: List[Dict[str, Any]] = [
|
| 197 |
-
{
|
| 198 |
-
"ticket_id": "T2-001",
|
| 199 |
-
"subject": "Refund request – incorrect charge on invoice #INV-20240312",
|
| 200 |
-
"body": (
|
| 201 |
-
"Dear Support, I was incorrectly charged $199.00 on invoice "
|
| 202 |
-
"#INV-20240312 dated 2024-03-12. My account ID is ACC-78234. "
|
| 203 |
-
"The charge should have been $99.00 as per our agreed annual plan. "
|
| 204 |
-
"Please issue a refund of $100.00 to my card on file and send a "
|
| 205 |
-
"corrected invoice. My name is Robert Chen."
|
| 206 |
-
),
|
| 207 |
-
"customer_tier": "pro",
|
| 208 |
-
"account_age_days": 540,
|
| 209 |
-
"previous_tickets": 1,
|
| 210 |
-
"attachments": ["invoice_INV-20240312.pdf"],
|
| 211 |
-
"ground_truth": {
|
| 212 |
-
"entities": {
|
| 213 |
-
"customer_name": "Robert Chen",
|
| 214 |
-
"account_id": "ACC-78234",
|
| 215 |
-
"invoice_number": "INV-20240312",
|
| 216 |
-
"incorrect_amount": "199.00",
|
| 217 |
-
"correct_amount": "99.00",
|
| 218 |
-
"refund_amount": "100.00",
|
| 219 |
-
},
|
| 220 |
-
"required_actions": [
|
| 221 |
-
"issue_refund",
|
| 222 |
-
"send_corrected_invoice",
|
| 223 |
-
],
|
| 224 |
-
},
|
| 225 |
-
},
|
| 226 |
-
{
|
| 227 |
-
"ticket_id": "T2-002",
|
| 228 |
-
"subject": "SSO login broken after company domain migration",
|
| 229 |
-
"body": (
|
| 230 |
-
"Hi, our company just migrated from acme-old.com to acme-new.com. "
|
| 231 |
-
"Since the migration last Tuesday (2024-03-19), our 45 users cannot "
|
| 232 |
-
"log in via SSO. The error message is: 'SAML assertion domain "
|
| 233 |
-
"mismatch'. Our org ID is ORG-5512. We need this fixed ASAP as "
|
| 234 |
-
"nobody can access the platform. Contact: Maria Gonzalez, "
|
| 235 |
-
"IT Director."
|
| 236 |
-
),
|
| 237 |
-
"customer_tier": "enterprise",
|
| 238 |
-
"account_age_days": 1100,
|
| 239 |
-
"previous_tickets": 5,
|
| 240 |
-
"attachments": [],
|
| 241 |
-
"ground_truth": {
|
| 242 |
-
"entities": {
|
| 243 |
-
"contact_name": "Maria Gonzalez",
|
| 244 |
-
"contact_role": "IT Director",
|
| 245 |
-
"org_id": "ORG-5512",
|
| 246 |
-
"old_domain": "acme-old.com",
|
| 247 |
-
"new_domain": "acme-new.com",
|
| 248 |
-
"error_message": "SAML assertion domain mismatch",
|
| 249 |
-
"affected_users": "45",
|
| 250 |
-
},
|
| 251 |
-
"required_actions": [
|
| 252 |
-
"update_sso_domain_config",
|
| 253 |
-
"verify_saml_settings",
|
| 254 |
-
"notify_affected_users",
|
| 255 |
-
],
|
| 256 |
-
},
|
| 257 |
-
},
|
| 258 |
-
{
|
| 259 |
-
"ticket_id": "T2-003",
|
| 260 |
-
"subject": "Need to add 3 seats to our Team plan before end of quarter",
|
| 261 |
-
"body": (
|
| 262 |
-
"Hello, I'm the account admin for Brightfield Analytics "
|
| 263 |
-
"(account: ACC-11099). We currently have 12 seats on Team plan "
|
| 264 |
-
"and need to add 3 more seats before March 31st (end of our fiscal "
|
| 265 |
-
"quarter). The 3 new team members are: "
|
| 266 |
-
"dev1@brightfield.io, dev2@brightfield.io, dev3@brightfield.io. "
|
| 267 |
-
"Please prorate the cost. — James Park, VP Engineering"
|
| 268 |
-
),
|
| 269 |
-
"customer_tier": "pro",
|
| 270 |
-
"account_age_days": 820,
|
| 271 |
-
"previous_tickets": 3,
|
| 272 |
-
"attachments": [],
|
| 273 |
-
"ground_truth": {
|
| 274 |
-
"entities": {
|
| 275 |
-
"contact_name": "James Park",
|
| 276 |
-
"contact_role": "VP Engineering",
|
| 277 |
-
"account_id": "ACC-11099",
|
| 278 |
-
"company_name": "Brightfield Analytics",
|
| 279 |
-
"current_seats": "12",
|
| 280 |
-
"seats_to_add": "3",
|
| 281 |
-
"deadline": "March 31st",
|
| 282 |
-
"new_users": [
|
| 283 |
-
"dev1@brightfield.io",
|
| 284 |
-
"dev2@brightfield.io",
|
| 285 |
-
"dev3@brightfield.io",
|
| 286 |
-
],
|
| 287 |
-
},
|
| 288 |
-
"required_actions": [
|
| 289 |
-
"add_seats",
|
| 290 |
-
"send_prorated_invoice",
|
| 291 |
-
"provision_new_users",
|
| 292 |
-
],
|
| 293 |
-
},
|
| 294 |
-
},
|
| 295 |
-
{
|
| 296 |
-
"ticket_id": "T2-004",
|
| 297 |
-
"subject": "Data export stuck at 0% for 6 hours – export ID EXP-990021",
|
| 298 |
-
"body": (
|
| 299 |
-
"Our scheduled data export (ID: EXP-990021) has been stuck at 0% "
|
| 300 |
-
"for over 6 hours. This export contains 90 days of transaction "
|
| 301 |
-
"data for our compliance report due tomorrow. Account: ACC-30041, "
|
| 302 |
-
"region: eu-west-1. We need this export completed or the raw data "
|
| 303 |
-
"sent via secure link by 09:00 UTC tomorrow. If the export cannot "
|
| 304 |
-
"be fixed, please escalate to engineering. — Priya Sharma"
|
| 305 |
-
),
|
| 306 |
-
"customer_tier": "enterprise",
|
| 307 |
-
"account_age_days": 650,
|
| 308 |
-
"previous_tickets": 7,
|
| 309 |
-
"attachments": [],
|
| 310 |
-
"ground_truth": {
|
| 311 |
-
"entities": {
|
| 312 |
-
"contact_name": "Priya Sharma",
|
| 313 |
-
"export_id": "EXP-990021",
|
| 314 |
-
"account_id": "ACC-30041",
|
| 315 |
-
"region": "eu-west-1",
|
| 316 |
-
"data_range": "90 days",
|
| 317 |
-
"deadline": "09:00 UTC tomorrow",
|
| 318 |
-
},
|
| 319 |
-
"required_actions": [
|
| 320 |
-
"investigate_export_job",
|
| 321 |
-
"fix_or_restart_export",
|
| 322 |
-
"escalate_to_engineering",
|
| 323 |
-
],
|
| 324 |
-
},
|
| 325 |
-
},
|
| 326 |
-
{
|
| 327 |
-
"ticket_id": "T2-005",
|
| 328 |
-
"subject": "GDPR deletion request for former employee account",
|
| 329 |
-
"body": (
|
| 330 |
-
"We need to permanently delete all data associated with a former "
|
| 331 |
-
"employee's account as per GDPR Article 17 (right to erasure). "
|
| 332 |
-
"The account to delete: user ID USR-88821, email "
|
| 333 |
-
"j.doe@departed.co.uk. Our DPO is Claire Lambert "
|
| 334 |
-
"(dpo@ourenterprise.com). Legal reference: GDPR-REQ-2024-03. "
|
| 335 |
-
"Please confirm deletion in writing within 30 days. "
|
| 336 |
-
"Org ID: ORG-7740."
|
| 337 |
-
),
|
| 338 |
-
"customer_tier": "enterprise",
|
| 339 |
-
"account_age_days": 1400,
|
| 340 |
-
"previous_tickets": 2,
|
| 341 |
-
"attachments": ["gdpr_deletion_form.pdf"],
|
| 342 |
-
"ground_truth": {
|
| 343 |
-
"entities": {
|
| 344 |
-
"user_id": "USR-88821",
|
| 345 |
-
"user_email": "j.doe@departed.co.uk",
|
| 346 |
-
"org_id": "ORG-7740",
|
| 347 |
-
"dpo_name": "Claire Lambert",
|
| 348 |
-
"dpo_email": "dpo@ourenterprise.com",
|
| 349 |
-
"legal_reference": "GDPR-REQ-2024-03",
|
| 350 |
-
"legal_basis": "GDPR Article 17",
|
| 351 |
-
},
|
| 352 |
-
"required_actions": [
|
| 353 |
-
"delete_user_data",
|
| 354 |
-
"send_written_confirmation",
|
| 355 |
-
"log_gdpr_request",
|
| 356 |
-
],
|
| 357 |
-
},
|
| 358 |
-
},
|
| 359 |
-
]
|
| 360 |
-
|
| 361 |
-
# ---------------------------------------------------------------------------
|
| 362 |
-
# TASK 3 — Resolution Generation
|
| 363 |
-
# Agent must submit a response_text + resolution_steps.
|
| 364 |
-
# Ground truth contains required keywords and accepted resolution steps.
|
| 365 |
-
# Scoring is deterministic (keyword matching + step coverage).
|
| 366 |
-
# ---------------------------------------------------------------------------
|
| 367 |
-
|
| 368 |
-
TASK3_TICKETS: List[Dict[str, Any]] = [
|
| 369 |
-
{
|
| 370 |
-
"ticket_id": "T3-001",
|
| 371 |
-
"subject": "Cannot reset my password — reset email never arrives",
|
| 372 |
-
"body": (
|
| 373 |
-
"Hi, I've tried resetting my password 5 times today but the reset "
|
| 374 |
-
"email never arrives. I've checked my spam folder. My account email "
|
| 375 |
-
"is user@example.com. I need access to my account urgently as I "
|
| 376 |
-
"have a demo with a client in 2 hours."
|
| 377 |
-
),
|
| 378 |
-
"customer_tier": "pro",
|
| 379 |
-
"account_age_days": 280,
|
| 380 |
-
"previous_tickets": 0,
|
| 381 |
-
"attachments": [],
|
| 382 |
-
"ground_truth": {
|
| 383 |
-
"required_keywords": [
|
| 384 |
-
"password",
|
| 385 |
-
"reset",
|
| 386 |
-
"email",
|
| 387 |
-
"spam",
|
| 388 |
-
"whitelist",
|
| 389 |
-
],
|
| 390 |
-
"required_resolution_steps": [
|
| 391 |
-
"verify_email_delivery",
|
| 392 |
-
"check_spam_filters",
|
| 393 |
-
"manual_password_reset",
|
| 394 |
-
"follow_up_confirmation",
|
| 395 |
-
],
|
| 396 |
-
"tone_requirements": {
|
| 397 |
-
"must_apologize": True,
|
| 398 |
-
"must_acknowledge_urgency": True,
|
| 399 |
-
"must_provide_timeline": True,
|
| 400 |
-
},
|
| 401 |
-
"expected_response_length_min": 80,
|
| 402 |
-
},
|
| 403 |
-
},
|
| 404 |
-
{
|
| 405 |
-
"ticket_id": "T3-002",
|
| 406 |
-
"subject": "Webhook payloads stopped arriving after updating secret key",
|
| 407 |
-
"body": (
|
| 408 |
-
"We updated our webhook secret key yesterday in the dashboard and "
|
| 409 |
-
"now none of our webhook endpoints are receiving payloads. Our "
|
| 410 |
-
"endpoint is https://hooks.our-app.com/receive. We've verified the "
|
| 411 |
-
"endpoint is up and responding 200. It seems like the new secret "
|
| 412 |
-
"is not being used to sign payloads. Account: ACC-50039."
|
| 413 |
-
),
|
| 414 |
-
"customer_tier": "pro",
|
| 415 |
-
"account_age_days": 510,
|
| 416 |
-
"previous_tickets": 4,
|
| 417 |
-
"attachments": [],
|
| 418 |
-
"ground_truth": {
|
| 419 |
-
"required_keywords": [
|
| 420 |
-
"webhook",
|
| 421 |
-
"secret",
|
| 422 |
-
"signature",
|
| 423 |
-
"HMAC",
|
| 424 |
-
"regenerate",
|
| 425 |
-
],
|
| 426 |
-
"required_resolution_steps": [
|
| 427 |
-
"verify_webhook_config",
|
| 428 |
-
"regenerate_webhook_secret",
|
| 429 |
-
"update_endpoint_verification",
|
| 430 |
-
"test_delivery",
|
| 431 |
-
],
|
| 432 |
-
"tone_requirements": {
|
| 433 |
-
"must_apologize": False,
|
| 434 |
-
"must_acknowledge_urgency": False,
|
| 435 |
-
"must_provide_timeline": True,
|
| 436 |
-
},
|
| 437 |
-
"expected_response_length_min": 100,
|
| 438 |
-
},
|
| 439 |
-
},
|
| 440 |
-
{
|
| 441 |
-
"ticket_id": "T3-003",
|
| 442 |
-
"subject": "Annual invoice shows wrong VAT number — urgent for accounting",
|
| 443 |
-
"body": (
|
| 444 |
-
"Our annual invoice (INV-2024-ANN-00567) shows VAT number "
|
| 445 |
-
"DE123456789 but our correct VAT number is DE987654321. This "
|
| 446 |
-
"invoice needs to be corrected immediately as our accounting team "
|
| 447 |
-
"needs it for Q1 closing (deadline: end of today). "
|
| 448 |
-
"Company: Müller GmbH, Account: ACC-20987."
|
| 449 |
-
),
|
| 450 |
-
"customer_tier": "enterprise",
|
| 451 |
-
"account_age_days": 1200,
|
| 452 |
-
"previous_tickets": 8,
|
| 453 |
-
"attachments": [],
|
| 454 |
-
"ground_truth": {
|
| 455 |
-
"required_keywords": [
|
| 456 |
-
"VAT",
|
| 457 |
-
"invoice",
|
| 458 |
-
"corrected",
|
| 459 |
-
"accounting",
|
| 460 |
-
"reissue",
|
| 461 |
-
],
|
| 462 |
-
"required_resolution_steps": [
|
| 463 |
-
"update_vat_number",
|
| 464 |
-
"reissue_invoice",
|
| 465 |
-
"send_corrected_invoice",
|
| 466 |
-
"confirm_receipt",
|
| 467 |
-
],
|
| 468 |
-
"tone_requirements": {
|
| 469 |
-
"must_apologize": True,
|
| 470 |
-
"must_acknowledge_urgency": True,
|
| 471 |
-
"must_provide_timeline": True,
|
| 472 |
-
},
|
| 473 |
-
"expected_response_length_min": 80,
|
| 474 |
-
},
|
| 475 |
-
},
|
| 476 |
-
{
|
| 477 |
-
"ticket_id": "T3-004",
|
| 478 |
-
"subject": "Team member accidentally deleted our production dataset",
|
| 479 |
-
"body": (
|
| 480 |
-
"One of our team members accidentally deleted dataset "
|
| 481 |
-
"DS-PROD-77. This dataset had 6 months of customer analytics data "
|
| 482 |
-
"that we haven't backed up externally. Is there any way to restore "
|
| 483 |
-
"it? We are on Enterprise plan. Org: ORG-3302. Please help "
|
| 484 |
-
"immediately — this data is critical for our quarterly review."
|
| 485 |
-
),
|
| 486 |
-
"customer_tier": "enterprise",
|
| 487 |
-
"account_age_days": 960,
|
| 488 |
-
"previous_tickets": 2,
|
| 489 |
-
"attachments": [],
|
| 490 |
-
"ground_truth": {
|
| 491 |
-
"required_keywords": [
|
| 492 |
-
"restore",
|
| 493 |
-
"backup",
|
| 494 |
-
"recovery",
|
| 495 |
-
"dataset",
|
| 496 |
-
"retention",
|
| 497 |
-
],
|
| 498 |
-
"required_resolution_steps": [
|
| 499 |
-
"check_retention_policy",
|
| 500 |
-
"attempt_data_recovery",
|
| 501 |
-
"escalate_to_engineering",
|
| 502 |
-
"provide_recovery_status",
|
| 503 |
-
],
|
| 504 |
-
"tone_requirements": {
|
| 505 |
-
"must_apologize": True,
|
| 506 |
-
"must_acknowledge_urgency": True,
|
| 507 |
-
"must_provide_timeline": True,
|
| 508 |
-
},
|
| 509 |
-
"expected_response_length_min": 100,
|
| 510 |
-
},
|
| 511 |
-
},
|
| 512 |
-
{
|
| 513 |
-
"ticket_id": "T3-005",
|
| 514 |
-
"subject": "Need formal SLA documentation for enterprise procurement",
|
| 515 |
-
"body": (
|
| 516 |
-
"Our procurement team is finalizing contracts and requires formal "
|
| 517 |
-
"SLA documentation for your Enterprise plan. Specifically we need: "
|
| 518 |
-
"uptime guarantee percentage, support response time commitments, "
|
| 519 |
-
"incident severity classification, and escalation procedures. "
|
| 520 |
-
"Contact: procurement@bigcorp.io. Org: ORG-8855."
|
| 521 |
-
),
|
| 522 |
-
"customer_tier": "enterprise",
|
| 523 |
-
"account_age_days": 30,
|
| 524 |
-
"previous_tickets": 0,
|
| 525 |
-
"attachments": [],
|
| 526 |
-
"ground_truth": {
|
| 527 |
-
"required_keywords": [
|
| 528 |
-
"SLA",
|
| 529 |
-
"uptime",
|
| 530 |
-
"response time",
|
| 531 |
-
"documentation",
|
| 532 |
-
"enterprise",
|
| 533 |
-
],
|
| 534 |
-
"required_resolution_steps": [
|
| 535 |
-
"provide_sla_document",
|
| 536 |
-
"highlight_enterprise_terms",
|
| 537 |
-
"connect_with_account_manager",
|
| 538 |
-
"confirm_receipt",
|
| 539 |
-
],
|
| 540 |
-
"tone_requirements": {
|
| 541 |
-
"must_apologize": False,
|
| 542 |
-
"must_acknowledge_urgency": False,
|
| 543 |
-
"must_provide_timeline": True,
|
| 544 |
-
},
|
| 545 |
-
"expected_response_length_min": 80,
|
| 546 |
-
},
|
| 547 |
-
},
|
| 548 |
-
]
|
| 549 |
-
|
| 550 |
-
# ---------------------------------------------------------------------------
|
| 551 |
-
# Task-level metadata
|
| 552 |
-
# ---------------------------------------------------------------------------
|
| 553 |
-
|
| 554 |
-
TASK_META: Dict[str, Dict[str, Any]] = {
|
| 555 |
-
"task1": {
|
| 556 |
-
"name": "Ticket Classification",
|
| 557 |
-
"description": (
|
| 558 |
-
"Classify each support ticket by category and priority. "
|
| 559 |
-
"Correct classification routes the ticket to the right team and "
|
| 560 |
-
"sets appropriate response SLAs."
|
| 561 |
-
),
|
| 562 |
-
"difficulty": "easy",
|
| 563 |
-
"max_steps": 3,
|
| 564 |
-
"tickets": TASK1_TICKETS,
|
| 565 |
-
"available_actions": ["classify", "submit"],
|
| 566 |
-
},
|
| 567 |
-
"task2": {
|
| 568 |
-
"name": "Information Extraction",
|
| 569 |
-
"description": (
|
| 570 |
-
"Extract structured entities (IDs, names, amounts, dates) from "
|
| 571 |
-
"tickets and identify the list of required actions needed to "
|
| 572 |
-
"resolve each case. Accuracy drives downstream automation."
|
| 573 |
-
),
|
| 574 |
-
"difficulty": "medium",
|
| 575 |
-
"max_steps": 5,
|
| 576 |
-
"tickets": TASK2_TICKETS,
|
| 577 |
-
"available_actions": ["extract", "respond", "submit"],
|
| 578 |
-
},
|
| 579 |
-
"task3": {
|
| 580 |
-
"name": "Resolution Generation",
|
| 581 |
-
"description": (
|
| 582 |
-
"Generate a complete, professional customer-facing response "
|
| 583 |
-
"plus an ordered list of resolution steps for each ticket. "
|
| 584 |
-
"Responses are graded on keyword coverage, step completeness, "
|
| 585 |
-
"tone adherence, and minimum length."
|
| 586 |
-
),
|
| 587 |
-
"difficulty": "hard",
|
| 588 |
-
"max_steps": 8,
|
| 589 |
-
"tickets": TASK3_TICKETS,
|
| 590 |
-
"available_actions": ["respond", "resolve", "escalate", "submit"],
|
| 591 |
-
},
|
| 592 |
-
}
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
def get_tickets(task_id: str) -> List[Dict[str, Any]]:
|
| 596 |
-
"""Return the ticket list for a given task."""
|
| 597 |
-
return TASK_META[task_id]["tickets"]
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
def get_task_meta(task_id: str) -> Dict[str, Any]:
|
| 601 |
-
"""Return task metadata (without ticket ground truth exposed to agent)."""
|
| 602 |
-
meta = dict(TASK_META[task_id])
|
| 603 |
-
# Strip ground_truth from tickets before returning to agents
|
| 604 |
-
safe_tickets = []
|
| 605 |
-
for t in meta["tickets"]:
|
| 606 |
-
safe_t = {k: v for k, v in t.items() if k != "ground_truth"}
|
| 607 |
-
safe_tickets.append(safe_t)
|
| 608 |
-
meta["tickets"] = safe_tickets
|
| 609 |
-
return meta
|
|
|
|
| 1 |
"""
|
| 2 |
Linux DevOps tasks for SRE troubleshooting environment.
|
| 3 |
|
| 4 |
+
Task 1 (easy) — Restart crashed Nginx service
|
| 5 |
+
Task 2 (medium) — Fix Docker container misconfiguration
|
| 6 |
+
Task 3 (hard) — Debug and fix memory leak in mock API
|
| 7 |
"""
|
| 8 |
from __future__ import annotations
|
| 9 |
from typing import Any, Dict, List
|
|
|
|
| 24 |
|
| 25 |
TASK_META: Dict[str, Dict[str, Any]] = {
|
| 26 |
"task1": {
|
| 27 |
+
"name": "Nginx Service Recovery",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
"difficulty": "easy",
|
| 29 |
"max_steps": 10,
|
| 30 |
+
"description": "The Nginx web server has crashed. Diagnose the issue, restart the service, and verify it returns HTTP 200 OK.",
|
| 31 |
+
"available_actions": ["bash_cmd", "file_edit", "submit"]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
},
|
| 33 |
"task2": {
|
| 34 |
+
"name": "Docker Compose Port Fix",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
"difficulty": "medium",
|
| 36 |
"max_steps": 15,
|
| 37 |
+
"description": "A Docker container is misconfigured with the wrong port mapping. Edit docker-compose.yml to fix it and restart the container.",
|
| 38 |
+
"available_actions": ["bash_cmd", "file_edit", "submit"]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
},
|
| 40 |
"task3": {
|
| 41 |
+
"name": "Python API Memory Leak",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
"difficulty": "hard",
|
| 43 |
"max_steps": 20,
|
| 44 |
+
"description": "A Python mock API has a memory leak. Find the process, kill it, patch app.py, and restart the service.",
|
| 45 |
+
"available_actions": ["bash_cmd", "file_edit", "submit"]
|
| 46 |
+
}
|
| 47 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
environment.py
CHANGED
|
@@ -126,7 +126,7 @@ services:
|
|
| 126 |
mockapi:
|
| 127 |
image: mockapi:latest
|
| 128 |
ports:
|
| 129 |
-
|
| 130 |
environment:
|
| 131 |
- PORT=3000
|
| 132 |
volumes:
|
|
@@ -241,7 +241,7 @@ def _simulate_bash_cmd(cmd: str, task_id: str, ep_state: Dict[str, Any]) -> str:
|
|
| 241 |
if DOCKER_COMPOSE_PATH in state_dict["files"]:
|
| 242 |
compose_content = state_dict["files"][DOCKER_COMPOSE_PATH]
|
| 243 |
# Check if port is now correct
|
| 244 |
-
if "3000:3000" in compose_content:
|
| 245 |
state_dict["docker_containers"] = [
|
| 246 |
{"id": "xyz789", "name": "mockapi-svc", "status": "running", "ports": "3000:3000/tcp"}
|
| 247 |
]
|
|
@@ -265,12 +265,17 @@ def _simulate_bash_cmd(cmd: str, task_id: str, ep_state: Dict[str, Any]) -> str:
|
|
| 265 |
if "300" in lower_cmd or "python" in lower_cmd:
|
| 266 |
state_dict["running_processes"] = [p for p in state_dict["running_processes"] if p.get("name") != "python3"]
|
| 267 |
state_dict["service_status"]["mockapi"] = "inactive"
|
|
|
|
| 268 |
return "Process killed"
|
| 269 |
return "Process not found"
|
| 270 |
elif "python3 /opt/mockapi/app.py &" in lower_cmd or "python3 /opt/mockapi/app.py" in lower_cmd:
|
| 271 |
-
state_dict
|
|
|
|
|
|
|
|
|
|
| 272 |
state_dict["service_status"]["mockapi"] = "active"
|
| 273 |
state_dict["http_ports_open"] = [80, 5000]
|
|
|
|
| 274 |
return "Application started"
|
| 275 |
|
| 276 |
return f"Command '{cmd}' executed (simulated)"
|
|
@@ -304,18 +309,72 @@ def _simulate_file_edit(file_path: str, new_content: str, ep_state: Dict[str, An
|
|
| 304 |
|
| 305 |
def _calculate_step_reward(task_id: str, action: Action, ep_state: Dict[str, Any]) -> Tuple[float, str]:
|
| 306 |
"""Calculate reward based on action and task."""
|
| 307 |
-
base_step_cost = -0.
|
| 308 |
reward = base_step_cost
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
|
| 310 |
if action.action_type == "bash_cmd":
|
| 311 |
cmd = action.command or ""
|
| 312 |
reward += 0.05
|
| 313 |
explanation = f"Executed: {cmd[:50]}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
return reward, explanation
|
| 315 |
|
| 316 |
elif action.action_type == "file_edit":
|
| 317 |
reward += 0.03
|
| 318 |
explanation = f"Edited: {action.file_path}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
return reward, explanation
|
| 320 |
|
| 321 |
elif action.action_type == "submit":
|
|
|
|
| 126 |
mockapi:
|
| 127 |
image: mockapi:latest
|
| 128 |
ports:
|
| 129 |
+
- "8000:3000"
|
| 130 |
environment:
|
| 131 |
- PORT=3000
|
| 132 |
volumes:
|
|
|
|
| 241 |
if DOCKER_COMPOSE_PATH in state_dict["files"]:
|
| 242 |
compose_content = state_dict["files"][DOCKER_COMPOSE_PATH]
|
| 243 |
# Check if port is now correct
|
| 244 |
+
if "3000:3000" in compose_content and "8000:3000" not in compose_content:
|
| 245 |
state_dict["docker_containers"] = [
|
| 246 |
{"id": "xyz789", "name": "mockapi-svc", "status": "running", "ports": "3000:3000/tcp"}
|
| 247 |
]
|
|
|
|
| 265 |
if "300" in lower_cmd or "python" in lower_cmd:
|
| 266 |
state_dict["running_processes"] = [p for p in state_dict["running_processes"] if p.get("name") != "python3"]
|
| 267 |
state_dict["service_status"]["mockapi"] = "inactive"
|
| 268 |
+
state_dict["memory_usage_mb"] = 1100
|
| 269 |
return "Process killed"
|
| 270 |
return "Process not found"
|
| 271 |
elif "python3 /opt/mockapi/app.py &" in lower_cmd or "python3 /opt/mockapi/app.py" in lower_cmd:
|
| 272 |
+
app_content = state_dict.get("files", {}).get(MOCK_API_PATH, "")
|
| 273 |
+
leak_fixed = "request_cache.append" not in app_content
|
| 274 |
+
rss_mb = 256 if leak_fixed else 1700
|
| 275 |
+
state_dict["running_processes"].append({"pid": 301, "name": "python3", "rss_mb": rss_mb, "user": "appuser"})
|
| 276 |
state_dict["service_status"]["mockapi"] = "active"
|
| 277 |
state_dict["http_ports_open"] = [80, 5000]
|
| 278 |
+
state_dict["memory_usage_mb"] = 700 if leak_fixed else 1800
|
| 279 |
return "Application started"
|
| 280 |
|
| 281 |
return f"Command '{cmd}' executed (simulated)"
|
|
|
|
| 309 |
|
| 310 |
def _calculate_step_reward(task_id: str, action: Action, ep_state: Dict[str, Any]) -> Tuple[float, str]:
|
| 311 |
"""Calculate reward based on action and task."""
|
| 312 |
+
base_step_cost = -0.02
|
| 313 |
reward = base_step_cost
|
| 314 |
+
explanation = "Step taken"
|
| 315 |
+
|
| 316 |
+
history = ep_state.get("action_history", [])
|
| 317 |
+
if len(history) >= 2:
|
| 318 |
+
prev = history[-2]
|
| 319 |
+
curr = history[-1]
|
| 320 |
+
if (
|
| 321 |
+
prev.get("action_type") == curr.get("action_type")
|
| 322 |
+
and prev.get("command") == curr.get("command")
|
| 323 |
+
and prev.get("file_path") == curr.get("file_path")
|
| 324 |
+
):
|
| 325 |
+
reward -= 0.05
|
| 326 |
+
explanation = "Repeated identical action penalty"
|
| 327 |
|
| 328 |
if action.action_type == "bash_cmd":
|
| 329 |
cmd = action.command or ""
|
| 330 |
reward += 0.05
|
| 331 |
explanation = f"Executed: {cmd[:50]}"
|
| 332 |
+
|
| 333 |
+
if task_id == "task1" and "nginx -t" in cmd.lower():
|
| 334 |
+
reward += 0.05
|
| 335 |
+
explanation += " | validated nginx config"
|
| 336 |
+
if task_id == "task1" and "curl" in cmd.lower():
|
| 337 |
+
last_output = str(ep_state["action_history"][-1].get("output", ""))
|
| 338 |
+
if "OK" in last_output:
|
| 339 |
+
reward += 0.08
|
| 340 |
+
explanation += " | verified HTTP health"
|
| 341 |
+
|
| 342 |
+
if task_id == "task2" and "docker-compose up -d" in cmd.lower():
|
| 343 |
+
output = str(ep_state["action_history"][-1].get("output", "")).lower()
|
| 344 |
+
if "done" in output or "creating" in output:
|
| 345 |
+
reward += 0.1
|
| 346 |
+
explanation += " | compose bring-up success"
|
| 347 |
+
|
| 348 |
+
if task_id == "task3" and "kill" in cmd.lower():
|
| 349 |
+
reward += 0.07
|
| 350 |
+
explanation += " | terminated leaky process"
|
| 351 |
+
if task_id == "task3" and "python3 /opt/mockapi/app.py" in cmd.lower():
|
| 352 |
+
mem = ep_state["system_state"].get("memory_usage_mb", 2048)
|
| 353 |
+
if mem < 1024:
|
| 354 |
+
reward += 0.12
|
| 355 |
+
explanation += " | restarted with lower memory"
|
| 356 |
+
|
| 357 |
return reward, explanation
|
| 358 |
|
| 359 |
elif action.action_type == "file_edit":
|
| 360 |
reward += 0.03
|
| 361 |
explanation = f"Edited: {action.file_path}"
|
| 362 |
+
|
| 363 |
+
result = str(ep_state["action_history"][-1].get("result", ""))
|
| 364 |
+
if "ERROR" in result:
|
| 365 |
+
reward -= 0.12
|
| 366 |
+
explanation += " | invalid edit target"
|
| 367 |
+
elif task_id == "task2" and action.file_path == DOCKER_COMPOSE_PATH:
|
| 368 |
+
content = action.file_content or ""
|
| 369 |
+
if "3000:3000" in content and "8000:3000" not in content:
|
| 370 |
+
reward += 0.12
|
| 371 |
+
explanation += " | corrected port mapping"
|
| 372 |
+
elif task_id == "task3" and action.file_path == MOCK_API_PATH:
|
| 373 |
+
content = action.file_content or ""
|
| 374 |
+
if "request_cache.append" not in content:
|
| 375 |
+
reward += 0.12
|
| 376 |
+
explanation += " | removed leak pattern"
|
| 377 |
+
|
| 378 |
return reward, explanation
|
| 379 |
|
| 380 |
elif action.action_type == "submit":
|
graders.py
CHANGED
|
@@ -96,7 +96,7 @@ def grade_task2(episode_state: Dict[str, Any]) -> Tuple[float, Dict[str, float],
|
|
| 96 |
compose_file = "/srv/docker-compose.yml"
|
| 97 |
if compose_file in files:
|
| 98 |
content = files[compose_file]
|
| 99 |
-
if content and "3000:3000" in str(content):
|
| 100 |
breakdown["file_edited"] = 0.25
|
| 101 |
|
| 102 |
# Check if docker-compose up -d was run
|
|
@@ -163,11 +163,8 @@ def grade_task3(episode_state: Dict[str, Any]) -> Tuple[float, Dict[str, float],
|
|
| 163 |
app_file = "/opt/mockapi/app.py"
|
| 164 |
if app_file in files:
|
| 165 |
content = files[app_file]
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
# If it has been removed or replaced with something better
|
| 169 |
-
if "request_cache" not in str(content) or "# " in str(content):
|
| 170 |
-
breakdown["code_fixed"] = 0.25
|
| 171 |
|
| 172 |
# Check if service was restarted
|
| 173 |
service_status = state_dict.get("service_status", {})
|
|
|
|
| 96 |
compose_file = "/srv/docker-compose.yml"
|
| 97 |
if compose_file in files:
|
| 98 |
content = files[compose_file]
|
| 99 |
+
if content and "3000:3000" in str(content) and "8000:3000" not in str(content):
|
| 100 |
breakdown["file_edited"] = 0.25
|
| 101 |
|
| 102 |
# Check if docker-compose up -d was run
|
|
|
|
| 163 |
app_file = "/opt/mockapi/app.py"
|
| 164 |
if app_file in files:
|
| 165 |
content = files[app_file]
|
| 166 |
+
if content and "request_cache.append" not in str(content):
|
| 167 |
+
breakdown["code_fixed"] = 0.25
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
# Check if service was restarted
|
| 170 |
service_status = state_dict.get("service_status", {})
|
inference.py
CHANGED
|
@@ -1,98 +1,225 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Baseline LLM inference agent for DevOpsEnv.
|
| 3 |
-
Strictly uses the OpenAI Client, but can route to Gemini via base_url.
|
| 4 |
-
"""
|
| 5 |
-
import os
|
| 6 |
-
import json
|
| 7 |
import argparse
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
import requests
|
| 9 |
from openai import OpenAI
|
| 10 |
|
| 11 |
-
#
|
| 12 |
-
API_BASE_URL = os.
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
| 37 |
)
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
)
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
)
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
action = json.loads(action_str)
|
| 71 |
-
print(f"Step {step} Action: {action}")
|
| 72 |
-
|
| 73 |
-
except Exception as e:
|
| 74 |
-
print(f"LLM Error or JSON parse failed: {e}. Submitting to end early.")
|
| 75 |
-
action = {"action_type": "submit", "summary": "Failed to parse LLM response"}
|
| 76 |
-
|
| 77 |
-
# 4. Step Environment
|
| 78 |
-
step_res = requests.post(
|
| 79 |
-
f"{API_BASE_URL}/step",
|
| 80 |
-
json={"episode_id": episode_id, "action": action}
|
| 81 |
-
).json()
|
| 82 |
-
|
| 83 |
-
if step_res.get("done") or action.get("action_type") == "submit":
|
| 84 |
-
print("\nEpisode finished!")
|
| 85 |
-
break
|
| 86 |
-
|
| 87 |
-
# 5. Grade
|
| 88 |
-
grade = requests.post(f"{API_BASE_URL}/grader", json={"episode_id": episode_id}).json()
|
| 89 |
-
print(f"\nGrader Results:")
|
| 90 |
-
print(f"Final Score: {grade['score']} / 1.0")
|
| 91 |
-
print(f"Feedback: {grade['feedback']}")
|
| 92 |
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
parser = argparse.ArgumentParser()
|
| 95 |
-
parser.add_argument("--task",
|
| 96 |
args = parser.parse_args()
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import argparse
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
from typing import Any, Dict, List, Optional
|
| 5 |
+
|
| 6 |
import requests
|
| 7 |
from openai import OpenAI
|
| 8 |
|
| 9 |
+
# LLM config (required by hackathon instructions)
|
| 10 |
+
API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
|
| 11 |
+
MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
|
| 12 |
+
HF_TOKEN = os.getenv("HF_TOKEN", "")
|
| 13 |
+
|
| 14 |
+
# Environment endpoint for OpenEnv server
|
| 15 |
+
OPENENV_BASE_URL = os.getenv("OPENENV_BASE_URL", "http://localhost:7860").rstrip("/")
|
| 16 |
+
BENCHMARK = "devopsenv"
|
| 17 |
+
MAX_STEPS_CAP = 20
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _to_bool_str(value: bool) -> str:
|
| 21 |
+
return str(bool(value)).lower()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _safe_action_text(action: Dict[str, Any]) -> str:
|
| 25 |
+
text = json.dumps(action, separators=(",", ":"), ensure_ascii=True)
|
| 26 |
+
return text.replace("\n", " ").replace("\r", " ")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def log_start(task: str, env: str, model: str) -> None:
|
| 30 |
+
print(f"[START] task={task} env={env} model={model}", flush=True)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
|
| 34 |
+
error_val = error if error else "null"
|
| 35 |
+
print(
|
| 36 |
+
f"[STEP] step={step} action={action} reward={reward:.2f} done={_to_bool_str(done)} error={error_val}",
|
| 37 |
+
flush=True,
|
| 38 |
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def log_end(success: bool, steps: int, rewards: List[float]) -> None:
|
| 42 |
+
rewards_str = ",".join(f"{value:.2f}" for value in rewards)
|
| 43 |
+
print(f"[END] success={_to_bool_str(success)} steps={steps} rewards={rewards_str}", flush=True)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _llm_side_call(client: OpenAI, task_id: str, step_num: int, state_payload: Dict[str, Any]) -> None:
|
| 47 |
+
"""Lightweight LLM call for baseline parity using OpenAI client without changing deterministic actions."""
|
| 48 |
+
if not HF_TOKEN:
|
| 49 |
+
return
|
| 50 |
+
brief_state = {
|
| 51 |
+
"task_id": task_id,
|
| 52 |
+
"step_number": state_payload.get("step_number"),
|
| 53 |
+
"done": state_payload.get("done"),
|
| 54 |
+
"service_status": state_payload.get("system_state", {}).get("service_status", {}),
|
| 55 |
+
}
|
| 56 |
+
try:
|
| 57 |
+
client.chat.completions.create(
|
| 58 |
+
model=MODEL_NAME,
|
| 59 |
+
messages=[
|
| 60 |
+
{"role": "system", "content": "You are a concise DevOps assistant."},
|
| 61 |
+
{
|
| 62 |
+
"role": "user",
|
| 63 |
+
"content": (
|
| 64 |
+
f"Task={task_id}, step={step_num}. "
|
| 65 |
+
f"State={json.dumps(brief_state, ensure_ascii=True)}. "
|
| 66 |
+
"Reply with one short sentence describing next best move."
|
| 67 |
+
),
|
| 68 |
+
},
|
| 69 |
+
],
|
| 70 |
+
temperature=0.0,
|
| 71 |
+
max_tokens=24,
|
| 72 |
+
stream=False,
|
| 73 |
)
|
| 74 |
+
except Exception:
|
| 75 |
+
# Do not fail the submission run if the optional LLM call fails.
|
| 76 |
+
return
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _task_plan(task_id: str) -> List[Dict[str, Any]]:
|
| 80 |
+
if task_id == "task1":
|
| 81 |
+
return [
|
| 82 |
+
{"action_type": "bash_cmd", "command": "systemctl status nginx"},
|
| 83 |
+
{"action_type": "bash_cmd", "command": "nginx -t"},
|
| 84 |
+
{"action_type": "bash_cmd", "command": "systemctl restart nginx"},
|
| 85 |
+
{"action_type": "bash_cmd", "command": "curl http://localhost"},
|
| 86 |
+
{"action_type": "submit", "summary": "Nginx restored and verified"},
|
| 87 |
+
]
|
| 88 |
+
if task_id == "task2":
|
| 89 |
+
return [
|
| 90 |
+
{"action_type": "bash_cmd", "command": "cat /srv/docker-compose.yml"},
|
| 91 |
+
{
|
| 92 |
+
"action_type": "file_edit",
|
| 93 |
+
"file_path": "/srv/docker-compose.yml",
|
| 94 |
+
"file_content": (
|
| 95 |
+
"version: '3.8'\n"
|
| 96 |
+
"services:\n"
|
| 97 |
+
" mockapi:\n"
|
| 98 |
+
" image: mockapi:latest\n"
|
| 99 |
+
" ports:\n"
|
| 100 |
+
" - \"3000:3000\"\n"
|
| 101 |
+
" environment:\n"
|
| 102 |
+
" - PORT=3000\n"
|
| 103 |
+
" volumes:\n"
|
| 104 |
+
" - ./app.py:/app/app.py"
|
| 105 |
+
),
|
| 106 |
+
},
|
| 107 |
+
{"action_type": "bash_cmd", "command": "docker-compose up -d"},
|
| 108 |
+
{"action_type": "bash_cmd", "command": "docker ps"},
|
| 109 |
+
{"action_type": "submit", "summary": "Docker compose mapping fixed"},
|
| 110 |
+
]
|
| 111 |
+
return [
|
| 112 |
+
{"action_type": "bash_cmd", "command": "ps aux | grep python"},
|
| 113 |
+
{"action_type": "bash_cmd", "command": "kill 300"},
|
| 114 |
+
{
|
| 115 |
+
"action_type": "file_edit",
|
| 116 |
+
"file_path": "/opt/mockapi/app.py",
|
| 117 |
+
"file_content": (
|
| 118 |
+
"import json\n"
|
| 119 |
+
"from flask import Flask\n\n"
|
| 120 |
+
"app = Flask(__name__)\n\n"
|
| 121 |
+
"@app.route('/api/data', methods=['GET'])\n"
|
| 122 |
+
"def get_data():\n"
|
| 123 |
+
" data = {'timestamp': 123456, 'value': 42}\n"
|
| 124 |
+
" return json.dumps(data)\n\n"
|
| 125 |
+
"if __name__ == '__main__':\n"
|
| 126 |
+
" app.run(host='0.0.0.0', port=5000)\n"
|
| 127 |
+
),
|
| 128 |
+
},
|
| 129 |
+
{"action_type": "bash_cmd", "command": "python3 /opt/mockapi/app.py &"},
|
| 130 |
+
{"action_type": "submit", "summary": "Memory leak patched and service restarted"},
|
| 131 |
+
]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def run_task(client: OpenAI, task_id: str) -> float:
|
| 135 |
+
rewards: List[float] = []
|
| 136 |
+
steps_taken = 0
|
| 137 |
+
success = False
|
| 138 |
+
episode_id = ""
|
| 139 |
+
|
| 140 |
+
log_start(task=task_id, env=BENCHMARK, model=MODEL_NAME)
|
| 141 |
+
try:
|
| 142 |
+
reset_response = requests.post(
|
| 143 |
+
f"{OPENENV_BASE_URL}/reset", json={"task_id": task_id}, timeout=20
|
| 144 |
+
)
|
| 145 |
+
reset_response.raise_for_status()
|
| 146 |
+
reset_payload = reset_response.json()
|
| 147 |
+
episode_id = reset_payload["episode_id"]
|
| 148 |
+
|
| 149 |
+
for step_num, action in enumerate(_task_plan(task_id), start=1):
|
| 150 |
+
if step_num > MAX_STEPS_CAP:
|
| 151 |
+
break
|
| 152 |
+
|
| 153 |
+
state_response = requests.get(
|
| 154 |
+
f"{OPENENV_BASE_URL}/state", params={"episode_id": episode_id}, timeout=20
|
| 155 |
)
|
| 156 |
+
state_response.raise_for_status()
|
| 157 |
+
state_payload = state_response.json()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
+
_llm_side_call(client, task_id, step_num, state_payload)
|
| 160 |
+
|
| 161 |
+
step_error = None
|
| 162 |
+
try:
|
| 163 |
+
step_response = requests.post(
|
| 164 |
+
f"{OPENENV_BASE_URL}/step",
|
| 165 |
+
json={"episode_id": episode_id, "action": action},
|
| 166 |
+
timeout=30,
|
| 167 |
+
)
|
| 168 |
+
step_response.raise_for_status()
|
| 169 |
+
step_payload = step_response.json()
|
| 170 |
+
except Exception as exc:
|
| 171 |
+
step_error = str(exc).replace("\n", " ").replace("\r", " ")
|
| 172 |
+
log_step(
|
| 173 |
+
step=step_num,
|
| 174 |
+
action=_safe_action_text(action),
|
| 175 |
+
reward=0.0,
|
| 176 |
+
done=True,
|
| 177 |
+
error=step_error,
|
| 178 |
+
)
|
| 179 |
+
steps_taken = step_num
|
| 180 |
+
break
|
| 181 |
+
|
| 182 |
+
reward = float(step_payload.get("reward", {}).get("step_reward", 0.0))
|
| 183 |
+
done = bool(step_payload.get("done", False))
|
| 184 |
+
rewards.append(reward)
|
| 185 |
+
steps_taken = step_num
|
| 186 |
+
log_step(
|
| 187 |
+
step=step_num,
|
| 188 |
+
action=_safe_action_text(action),
|
| 189 |
+
reward=reward,
|
| 190 |
+
done=done,
|
| 191 |
+
error=step_error,
|
| 192 |
+
)
|
| 193 |
+
if done:
|
| 194 |
+
break
|
| 195 |
+
|
| 196 |
+
if episode_id:
|
| 197 |
+
grade_response = requests.post(
|
| 198 |
+
f"{OPENENV_BASE_URL}/grader", json={"episode_id": episode_id}, timeout=20
|
| 199 |
+
)
|
| 200 |
+
grade_response.raise_for_status()
|
| 201 |
+
score = float(grade_response.json().get("score", 0.0))
|
| 202 |
+
success = score > 0.0
|
| 203 |
+
return score
|
| 204 |
+
return 0.0
|
| 205 |
+
except Exception:
|
| 206 |
+
success = False
|
| 207 |
+
return 0.0
|
| 208 |
+
finally:
|
| 209 |
+
log_end(success=success, steps=steps_taken, rewards=rewards)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def main() -> None:
|
| 213 |
parser = argparse.ArgumentParser()
|
| 214 |
+
parser.add_argument("--task", choices=["task1", "task2", "task3", "all"], default="all")
|
| 215 |
args = parser.parse_args()
|
| 216 |
+
|
| 217 |
+
client = OpenAI(api_key=HF_TOKEN, base_url=API_BASE_URL)
|
| 218 |
+
|
| 219 |
+
tasks = [args.task] if args.task != "all" else ["task1", "task2", "task3"]
|
| 220 |
+
for task_id in tasks:
|
| 221 |
+
run_task(client, task_id)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
if __name__ == "__main__":
|
| 225 |
+
main()
|
openenv.yaml
CHANGED
|
@@ -66,17 +66,29 @@ models:
|
|
| 66 |
task_id: string
|
| 67 |
task_description: string
|
| 68 |
episode_id: string
|
| 69 |
-
system_state:
|
| 70 |
thread_history: list[dict]
|
| 71 |
available_actions: list[string]
|
| 72 |
step_number: integer
|
| 73 |
max_steps: integer
|
| 74 |
hint: string | null
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
Action:
|
| 77 |
action_type: string # bash_cmd | file_edit | submit
|
| 78 |
command: string | null
|
| 79 |
-
|
| 80 |
file_content: string | null
|
| 81 |
summary: string | null
|
| 82 |
|
|
|
|
| 66 |
task_id: string
|
| 67 |
task_description: string
|
| 68 |
episode_id: string
|
| 69 |
+
system_state: SystemState
|
| 70 |
thread_history: list[dict]
|
| 71 |
available_actions: list[string]
|
| 72 |
step_number: integer
|
| 73 |
max_steps: integer
|
| 74 |
hint: string | null
|
| 75 |
|
| 76 |
+
SystemState:
|
| 77 |
+
task_id: string
|
| 78 |
+
available_commands: list[string]
|
| 79 |
+
filesystem_snapshot: string
|
| 80 |
+
running_processes: list[dict]
|
| 81 |
+
service_status: dict[string, string]
|
| 82 |
+
logs: string
|
| 83 |
+
http_ports_open: list[integer]
|
| 84 |
+
docker_containers: list[dict]
|
| 85 |
+
cpu_usage: float
|
| 86 |
+
memory_usage_mb: integer
|
| 87 |
+
|
| 88 |
Action:
|
| 89 |
action_type: string # bash_cmd | file_edit | submit
|
| 90 |
command: string | null
|
| 91 |
+
file_path: string | null
|
| 92 |
file_content: string | null
|
| 93 |
summary: string | null
|
| 94 |
|
pyproject.toml
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=61.0"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "devopsenv"
|
| 7 |
+
version = "1.0.0"
|
| 8 |
+
description = "A DevOps/SRE environment for OpenEnv"
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
requires-python = ">=3.11"
|
| 11 |
+
dependencies = [
|
| 12 |
+
"fastapi>=0.111.0",
|
| 13 |
+
"uvicorn[standard]>=0.30.0",
|
| 14 |
+
"pydantic>=2.7.0",
|
| 15 |
+
"requests>=2.31.0",
|
| 16 |
+
"openai>=1.35.0",
|
| 17 |
+
"openenv-core>=0.2.0"
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
[project.scripts]
|
| 21 |
+
server = "server.app:main"
|
requirements.txt
CHANGED
|
@@ -5,4 +5,5 @@ openai>=1.35.0
|
|
| 5 |
httpx>=0.27.0
|
| 6 |
python-multipart>=0.0.9
|
| 7 |
requests>=2.31.0
|
| 8 |
-
google-genai>=1.15.0
|
|
|
|
|
|
| 5 |
httpx>=0.27.0
|
| 6 |
python-multipart>=0.0.9
|
| 7 |
requests>=2.31.0
|
| 8 |
+
google-genai>=1.15.0
|
| 9 |
+
openenv-core>=0.2.0
|
server/__init__.py
ADDED
|
File without changes
|
server/app.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compatibility shim for validators expecting server/app.py."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 9 |
+
if str(ROOT) not in sys.path:
|
| 10 |
+
sys.path.insert(0, str(ROOT))
|
| 11 |
+
|
| 12 |
+
from app import app, run_server # noqa: E402,F401
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def main() -> None:
|
| 16 |
+
run_server()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
if __name__ == "__main__":
|
| 20 |
+
main()
|
test_integration.py
DELETED
|
@@ -1,116 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Quick integration test for DevOpsEnv.
|
| 3 |
-
|
| 4 |
-
This runs a full episode for each task to verify everything works.
|
| 5 |
-
"""
|
| 6 |
-
import environment as env
|
| 7 |
-
from models import Action
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
def test_task(task_id: str, max_test_steps: int = 5):
|
| 11 |
-
"""Test a single task."""
|
| 12 |
-
print(f"\n{'='*60}")
|
| 13 |
-
print(f"Testing {task_id}")
|
| 14 |
-
print(f"{'='*60}")
|
| 15 |
-
|
| 16 |
-
# Reset
|
| 17 |
-
print("1. Calling reset()...")
|
| 18 |
-
obs = env.reset(task_id)
|
| 19 |
-
episode_id = obs.episode_id
|
| 20 |
-
print(f"[OK] Episode created: {episode_id}")
|
| 21 |
-
print(f" Task: {obs.task_description[:80]}...")
|
| 22 |
-
print(f" Max steps: {obs.max_steps}")
|
| 23 |
-
print(f" System state: cpu={obs.system_state.cpu_usage:.1f}%, mem={obs.system_state.memory_usage_mb}MB")
|
| 24 |
-
|
| 25 |
-
# Take steps
|
| 26 |
-
print(f"\n2. Taking {max_test_steps} steps...")
|
| 27 |
-
for i in range(max_test_steps):
|
| 28 |
-
if task_id == "task1":
|
| 29 |
-
if i == 0:
|
| 30 |
-
action = Action(action_type="bash_cmd", command="systemctl status nginx")
|
| 31 |
-
elif i == 1:
|
| 32 |
-
action = Action(action_type="bash_cmd", command="systemctl try-restart nginx")
|
| 33 |
-
elif i == 2:
|
| 34 |
-
action = Action(action_type="bash_cmd", command="nginx -t")
|
| 35 |
-
else:
|
| 36 |
-
action = Action(action_type="bash_cmd", command="curl http://localhost")
|
| 37 |
-
elif task_id == "task2":
|
| 38 |
-
if i == 0:
|
| 39 |
-
action = Action(action_type="bash_cmd", command="cat /srv/docker-compose.yml")
|
| 40 |
-
elif i == 1:
|
| 41 |
-
action = Action(
|
| 42 |
-
action_type="file_edit",
|
| 43 |
-
file_path="/srv/docker-compose.yml",
|
| 44 |
-
file_content="version: '3.8'\nservices:\n mockapi:\n image: mockapi:latest\n ports:\n - \"3000:3000\""
|
| 45 |
-
)
|
| 46 |
-
elif i == 2:
|
| 47 |
-
action = Action(action_type="bash_cmd", command="docker-compose up -d")
|
| 48 |
-
else:
|
| 49 |
-
action = Action(action_type="bash_cmd", command="docker ps")
|
| 50 |
-
else: # task3
|
| 51 |
-
if i == 0:
|
| 52 |
-
action = Action(action_type="bash_cmd", command="ps aux | grep python")
|
| 53 |
-
elif i == 1:
|
| 54 |
-
action = Action(action_type="bash_cmd", command="kill 300")
|
| 55 |
-
elif i == 2:
|
| 56 |
-
action = Action(
|
| 57 |
-
action_type="file_edit",
|
| 58 |
-
file_path="/opt/mockapi/app.py",
|
| 59 |
-
file_content="import json\nfrom flask import Flask\n\napp = Flask(__name__)\n\n@app.route('/api/data')\ndef get_data():\n return json.dumps({'status': 'ok'})\n\nif __name__ == '__main__':\n app.run()\n"
|
| 60 |
-
)
|
| 61 |
-
else:
|
| 62 |
-
action = Action(action_type="bash_cmd", command="python3 /opt/mockapi/app.py &")
|
| 63 |
-
|
| 64 |
-
try:
|
| 65 |
-
result = env.step(episode_id, action)
|
| 66 |
-
print(f" Step {i+1}: {action.action_type} - Reward: {result.reward.step_reward:+.3f}")
|
| 67 |
-
if result.done:
|
| 68 |
-
print(f" -> Episode completed early")
|
| 69 |
-
break
|
| 70 |
-
except Exception as e:
|
| 71 |
-
print(f" Step {i+1} ERROR: {e}")
|
| 72 |
-
break
|
| 73 |
-
|
| 74 |
-
# Check state
|
| 75 |
-
print(f"\n3. Calling get_state()...")
|
| 76 |
-
state = env.get_state(episode_id)
|
| 77 |
-
print(f"[OK] State: step_number={state.step_number}, total_reward={state.total_reward:.3f}, done={state.done}")
|
| 78 |
-
|
| 79 |
-
# Finish episode if not already done
|
| 80 |
-
if not state.done:
|
| 81 |
-
print(f"\n4. Calling submit()...")
|
| 82 |
-
result = env.step(episode_id, Action(action_type="submit"))
|
| 83 |
-
print(f"[OK] Episode submitted, done={result.done}")
|
| 84 |
-
|
| 85 |
-
# Grade
|
| 86 |
-
print(f"\n5. Calling grade()...")
|
| 87 |
-
try:
|
| 88 |
-
score, breakdown, feedback = env.grade(episode_id)
|
| 89 |
-
print(f"[OK] Score: {score:.3f}/1.0")
|
| 90 |
-
print(f" Breakdown: {breakdown}")
|
| 91 |
-
print(f" Feedback: {feedback}")
|
| 92 |
-
except Exception as e:
|
| 93 |
-
print(f"[ERROR] Grading error: {e}")
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
def main():
|
| 97 |
-
"""Run all tests."""
|
| 98 |
-
print("DevOpsEnv Integration Test")
|
| 99 |
-
print("="*60)
|
| 100 |
-
|
| 101 |
-
try:
|
| 102 |
-
test_task("task1", max_test_steps=5)
|
| 103 |
-
test_task("task2", max_test_steps=5)
|
| 104 |
-
test_task("task3", max_test_steps=5)
|
| 105 |
-
|
| 106 |
-
print(f"\n{'='*60}")
|
| 107 |
-
print("[OK] All tests completed successfully!")
|
| 108 |
-
print(f"{'='*60}")
|
| 109 |
-
except Exception as e:
|
| 110 |
-
print(f"\n[ERROR] Test failed: {e}")
|
| 111 |
-
import traceback
|
| 112 |
-
traceback.print_exc()
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
if __name__ == "__main__":
|
| 116 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests_new.py
DELETED
|
@@ -1,248 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Comprehensive tests for DevOpsEnv.
|
| 3 |
-
|
| 4 |
-
Run with: pytest tests/test_environment.py -v
|
| 5 |
-
"""
|
| 6 |
-
import pytest
|
| 7 |
-
import json
|
| 8 |
-
from unittest.mock import patch
|
| 9 |
-
|
| 10 |
-
import environment as env
|
| 11 |
-
from models import Action, Observation, StepResult, State
|
| 12 |
-
from data import TASK_META
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
class TestReset:
|
| 16 |
-
"""Test episode reset functionality."""
|
| 17 |
-
|
| 18 |
-
def test_reset_valid_task(self):
|
| 19 |
-
"""Reset creates a valid episode."""
|
| 20 |
-
obs = env.reset("task1")
|
| 21 |
-
|
| 22 |
-
assert isinstance(obs, Observation)
|
| 23 |
-
assert obs.task_id == "task1"
|
| 24 |
-
assert obs.episode_id is not None
|
| 25 |
-
assert len(obs.episode_id) > 0
|
| 26 |
-
assert obs.system_state is not None
|
| 27 |
-
assert obs.step_number == 0
|
| 28 |
-
assert obs.max_steps == TASK_META["task1"]["max_steps"]
|
| 29 |
-
|
| 30 |
-
def test_reset_invalid_task(self):
|
| 31 |
-
"""Reset raises error for unknown task."""
|
| 32 |
-
with pytest.raises(ValueError):
|
| 33 |
-
env.reset("invalid_task")
|
| 34 |
-
|
| 35 |
-
def test_reset_creates_episode_state(self):
|
| 36 |
-
"""Reset creates episode in internal state."""
|
| 37 |
-
obs = env.reset("task2")
|
| 38 |
-
|
| 39 |
-
assert obs.episode_id in env._EPISODES
|
| 40 |
-
ep = env._EPISODES[obs.episode_id]
|
| 41 |
-
assert ep["task_id"] == "task2"
|
| 42 |
-
assert ep["step_number"] == 0
|
| 43 |
-
assert ep["done"] is False
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
class TestStep:
|
| 47 |
-
"""Test step execution."""
|
| 48 |
-
|
| 49 |
-
def test_step_bash_command(self):
|
| 50 |
-
"""Step handles bash_cmd action."""
|
| 51 |
-
obs = env.reset("task1")
|
| 52 |
-
|
| 53 |
-
action = Action(action_type="bash_cmd", command="systemctl status nginx")
|
| 54 |
-
result = env.step(obs.episode_id, action)
|
| 55 |
-
|
| 56 |
-
assert isinstance(result, StepResult)
|
| 57 |
-
assert result.observation.step_number == 1
|
| 58 |
-
assert result.reward.step_reward != 0
|
| 59 |
-
assert result.done is False
|
| 60 |
-
|
| 61 |
-
def test_step_file_edit(self):
|
| 62 |
-
"""Step handles file_edit action."""
|
| 63 |
-
obs = env.reset("task2")
|
| 64 |
-
|
| 65 |
-
action = Action(
|
| 66 |
-
action_type="file_edit",
|
| 67 |
-
file_path="/srv/docker-compose.yml",
|
| 68 |
-
file_content="version: '3.8'\nservices:\n test: {}"
|
| 69 |
-
)
|
| 70 |
-
result = env.step(obs.episode_id, action)
|
| 71 |
-
|
| 72 |
-
assert isinstance(result, StepResult)
|
| 73 |
-
assert result.observation.step_number == 1
|
| 74 |
-
|
| 75 |
-
def test_step_submit(self):
|
| 76 |
-
"""Step with submit action marks episode done."""
|
| 77 |
-
obs = env.reset("task1")
|
| 78 |
-
|
| 79 |
-
action = Action(action_type="submit", summary="Done")
|
| 80 |
-
result = env.step(obs.episode_id, action)
|
| 81 |
-
|
| 82 |
-
assert result.done is True
|
| 83 |
-
assert result.observation.available_actions == []
|
| 84 |
-
|
| 85 |
-
def test_step_invalid_episode(self):
|
| 86 |
-
"""Step raises error for invalid episode."""
|
| 87 |
-
action = Action(action_type="bash_cmd", command="ls")
|
| 88 |
-
|
| 89 |
-
with pytest.raises(KeyError):
|
| 90 |
-
env.step("invalid_episode_id", action)
|
| 91 |
-
|
| 92 |
-
def test_step_after_done(self):
|
| 93 |
-
"""Step raises error after episode is done."""
|
| 94 |
-
obs = env.reset("task1")
|
| 95 |
-
|
| 96 |
-
# End the episode
|
| 97 |
-
action1 = Action(action_type="submit")
|
| 98 |
-
env.step(obs.episode_id, action1)
|
| 99 |
-
|
| 100 |
-
# Try to step again
|
| 101 |
-
with pytest.raises(ValueError):
|
| 102 |
-
env.step(obs.episode_id, action1)
|
| 103 |
-
|
| 104 |
-
def test_step_max_steps_limit(self):
|
| 105 |
-
"""Episode ends after max_steps."""
|
| 106 |
-
obs = env.reset("task1")
|
| 107 |
-
max_steps = obs.max_steps
|
| 108 |
-
|
| 109 |
-
for i in range(max_steps):
|
| 110 |
-
action = Action(action_type="bash_cmd", command="ps aux")
|
| 111 |
-
result = env.step(obs.episode_id, action)
|
| 112 |
-
|
| 113 |
-
if i < max_steps - 1:
|
| 114 |
-
assert result.done is False
|
| 115 |
-
else:
|
| 116 |
-
assert result.done is True
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
class TestState:
|
| 120 |
-
"""Test state retrieval."""
|
| 121 |
-
|
| 122 |
-
def test_get_state(self):
|
| 123 |
-
"""get_state returns current episode state."""
|
| 124 |
-
obs = env.reset("task3")
|
| 125 |
-
|
| 126 |
-
state = env.get_state(obs.episode_id)
|
| 127 |
-
|
| 128 |
-
assert isinstance(state, State)
|
| 129 |
-
assert state.episode_id == obs.episode_id
|
| 130 |
-
assert state.task_id == "task3"
|
| 131 |
-
assert state.step_number == 0
|
| 132 |
-
assert state.done is False
|
| 133 |
-
|
| 134 |
-
def test_get_state_invalid_episode(self):
|
| 135 |
-
"""get_state raises error for invalid episode."""
|
| 136 |
-
with pytest.raises(KeyError):
|
| 137 |
-
env.get_state("invalid_id")
|
| 138 |
-
|
| 139 |
-
def test_state_history(self):
|
| 140 |
-
"""State includes action history."""
|
| 141 |
-
obs = env.reset("task1")
|
| 142 |
-
|
| 143 |
-
# Take Actions
|
| 144 |
-
action1 = Action(action_type="bash_cmd", command="ps aux")
|
| 145 |
-
env.step(obs.episode_id, action1)
|
| 146 |
-
|
| 147 |
-
state = env.get_state(obs.episode_id)
|
| 148 |
-
|
| 149 |
-
assert len(state.history) == 1
|
| 150 |
-
assert state.history[0]["action_type"] == "bash_cmd"
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
class TestGrading:
|
| 154 |
-
"""Test episode grading."""
|
| 155 |
-
|
| 156 |
-
def test_grade_task1_nginx_running(self):
|
| 157 |
-
"""Task 1 grades based on nginx status."""
|
| 158 |
-
obs = env.reset("task1")
|
| 159 |
-
|
| 160 |
-
# Run commands to fix nginx
|
| 161 |
-
env.step(obs.episode_id, Action(action_type="bash_cmd", command="systemctl restart nginx"))
|
| 162 |
-
env.step(obs.episode_id, Action(action_type="bash_cmd", command="nginx -t"))
|
| 163 |
-
env.step(obs.episode_id, Action(action_type="bash_cmd", command="curl http://localhost"))
|
| 164 |
-
env.step(obs.episode_id, Action(action_type="submit"))
|
| 165 |
-
|
| 166 |
-
score, breakdown, feedback = env.grade(obs.episode_id)
|
| 167 |
-
|
| 168 |
-
assert 0.0 <= score <= 1.0
|
| 169 |
-
assert "nginx_running" in breakdown
|
| 170 |
-
assert "config_valid" in breakdown
|
| 171 |
-
assert "http_200" in breakdown
|
| 172 |
-
|
| 173 |
-
def test_grade_invalid_episode(self):
|
| 174 |
-
"""grade raises error for invalid episode."""
|
| 175 |
-
with pytest.raises(KeyError):
|
| 176 |
-
env.grade("invalid_id")
|
| 177 |
-
|
| 178 |
-
def test_grade_not_done(self):
|
| 179 |
-
"""grade raises error if episode not done."""
|
| 180 |
-
obs = env.reset("task1")
|
| 181 |
-
# Don't finish the episode
|
| 182 |
-
|
| 183 |
-
with pytest.raises(ValueError):
|
| 184 |
-
env.grade(obs.episode_id)
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
class TestSystemSimulation:
|
| 188 |
-
"""Test mock system state simulation."""
|
| 189 |
-
|
| 190 |
-
def test_task1_initial_state(self):
|
| 191 |
-
"""Task 1 initializes with nginx crashed."""
|
| 192 |
-
obs = env.reset("task1")
|
| 193 |
-
|
| 194 |
-
assert obs.system_state.service_status.get("nginx") == "inactive"
|
| 195 |
-
assert 80 not in obs.system_state.http_ports_open
|
| 196 |
-
|
| 197 |
-
def test_task2_initial_state(self):
|
| 198 |
-
"""Task 2 initializes with docker misconfigured."""
|
| 199 |
-
obs = env.reset("task2")
|
| 200 |
-
|
| 201 |
-
assert obs.system_state.service_status.get("docker") == "active"
|
| 202 |
-
assert 80 in obs.system_state.http_ports_open
|
| 203 |
-
|
| 204 |
-
def test_task3_initial_state(self):
|
| 205 |
-
"""Task 3 initializes with memory leak."""
|
| 206 |
-
obs = env.reset("task3")
|
| 207 |
-
|
| 208 |
-
assert obs.system_state.service_status.get("mockapi") == "active"
|
| 209 |
-
# Should have high memory usage
|
| 210 |
-
assert obs.system_state.memory_usage_mb > 1024
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
class TestRewards:
|
| 214 |
-
"""Test reward calculation."""
|
| 215 |
-
|
| 216 |
-
def test_step_reward_positive(self):
|
| 217 |
-
"""Taking actions yields positive reward."""
|
| 218 |
-
obs = env.reset("task1")
|
| 219 |
-
|
| 220 |
-
action = Action(action_type="bash_cmd", command="ps aux")
|
| 221 |
-
result = env.step(obs.episode_id, action)
|
| 222 |
-
|
| 223 |
-
assert result.reward.step_reward > -1.0 # Not all negative
|
| 224 |
-
|
| 225 |
-
def test_total_reward_accumulation(self):
|
| 226 |
-
"""Total reward accumulates across steps."""
|
| 227 |
-
obs = env.reset("task1")
|
| 228 |
-
|
| 229 |
-
env.step(obs.episode_id, Action(action_type="bash_cmd", command="ps aux"))
|
| 230 |
-
result1 = env.step(obs.episode_id, Action(action_type="bash_cmd", command="ls"))
|
| 231 |
-
total1 = result1.reward.total_reward
|
| 232 |
-
|
| 233 |
-
result2 = env.step(obs.episode_id, Action(action_type="bash_cmd", command="pwd"))
|
| 234 |
-
total2 = result2.reward.total_reward
|
| 235 |
-
|
| 236 |
-
# Total reward should accumulate
|
| 237 |
-
assert total2 >= total1 or total2 < total1 # Can go either way depending on grader
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
@pytest.fixture(autouse=True)
|
| 241 |
-
def cleanup():
|
| 242 |
-
"""Clean up episodes after each test."""
|
| 243 |
-
yield
|
| 244 |
-
env._EPISODES.clear()
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
if __name__ == "__main__":
|
| 248 |
-
pytest.main([__file__, "-v"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
uv.lock
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version = 1
|
| 2 |
+
revision = 1
|
| 3 |
+
requires-python = ">=3.11"
|