Spaces:
Sleeping
Sleeping
Commit ·
9c1645f
1
Parent(s): 825c00b
done phase 1
Browse files- .env.example +14 -0
- .gitignore +74 -0
- Dockerfile +29 -0
- PRD.md +2193 -0
- README.md +93 -7
- adversary/__init__.py +0 -0
- adversary/adversary.py +207 -0
- adversary/policy_tracker.py +212 -0
- app/__init__.py +0 -0
- app/server.py +438 -0
- app/static/index.html +477 -0
- baseline/__init__.py +0 -0
- baseline/baseline_agent.py +252 -0
- env/__init__.py +0 -0
- env/actions.py +215 -0
- env/environment.py +392 -0
- env/rewards.py +176 -0
- env/scenarios.py +589 -0
- env/state.py +159 -0
- env/transitions.py +424 -0
- graders/__init__.py +0 -0
- graders/adversarial_grader.py +214 -0
- graders/llm_grader.py +187 -0
- graders/programmatic.py +215 -0
- inference.py +164 -0
- openenv.yaml +57 -0
- requirements.txt +26 -0
- task.md +217 -0
- tests/__init__.py +0 -0
- tests/conftest.py +38 -0
- tests/test_env.py +161 -0
- tests/test_graders.py +79 -0
.env.example
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API Configuration
|
| 2 |
+
API_BASE_URL=https://api.openai.com/v1
|
| 3 |
+
MODEL_NAME=gpt-4o-mini
|
| 4 |
+
OPENAI_API_KEY=your_openai_api_key_here
|
| 5 |
+
|
| 6 |
+
# Hugging Face (for deployment)
|
| 7 |
+
HF_TOKEN=your_huggingface_token_here
|
| 8 |
+
|
| 9 |
+
# Server Configuration
|
| 10 |
+
HOST=0.0.0.0
|
| 11 |
+
PORT=7860
|
| 12 |
+
|
| 13 |
+
# Optional: Logging Level
|
| 14 |
+
LOG_LEVEL=INFO
|
.gitignore
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
develop-eggs/
|
| 9 |
+
dist/
|
| 10 |
+
downloads/
|
| 11 |
+
eggs/
|
| 12 |
+
.eggs/
|
| 13 |
+
lib/
|
| 14 |
+
lib64/
|
| 15 |
+
parts/
|
| 16 |
+
sdist/
|
| 17 |
+
var/
|
| 18 |
+
wheels/
|
| 19 |
+
*.egg-info/
|
| 20 |
+
.installed.cfg
|
| 21 |
+
*.egg
|
| 22 |
+
|
| 23 |
+
# Virtual environments (NOT our env/ source directory!)
|
| 24 |
+
/.env/
|
| 25 |
+
/venv/
|
| 26 |
+
/ENV/
|
| 27 |
+
/env.bak/
|
| 28 |
+
/venv.bak/
|
| 29 |
+
/.sixth/
|
| 30 |
+
|
| 31 |
+
# Environment variables
|
| 32 |
+
.env
|
| 33 |
+
*.env
|
| 34 |
+
!.env.example
|
| 35 |
+
|
| 36 |
+
# IDEs
|
| 37 |
+
.vscode/
|
| 38 |
+
.idea/
|
| 39 |
+
*.swp
|
| 40 |
+
*.swo
|
| 41 |
+
*~
|
| 42 |
+
.DS_Store
|
| 43 |
+
|
| 44 |
+
# Testing
|
| 45 |
+
.pytest_cache/
|
| 46 |
+
.coverage
|
| 47 |
+
htmlcov/
|
| 48 |
+
.tox/
|
| 49 |
+
.hypothesis/
|
| 50 |
+
|
| 51 |
+
# Logs
|
| 52 |
+
*.log
|
| 53 |
+
logs/
|
| 54 |
+
|
| 55 |
+
# Database
|
| 56 |
+
*.db
|
| 57 |
+
*.sqlite
|
| 58 |
+
|
| 59 |
+
# Jupyter
|
| 60 |
+
.ipynb_checkpoints/
|
| 61 |
+
|
| 62 |
+
# mypy
|
| 63 |
+
.mypy_cache/
|
| 64 |
+
.dmypy.json
|
| 65 |
+
dmypy.json
|
| 66 |
+
|
| 67 |
+
# Session state
|
| 68 |
+
.copilot/
|
| 69 |
+
|
| 70 |
+
# Task management (keep task.md but ignore temp files)
|
| 71 |
+
*.tmp
|
| 72 |
+
|
| 73 |
+
# Explicitly include our source code directory
|
| 74 |
+
!env/
|
Dockerfile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install system dependencies
|
| 6 |
+
RUN apt-get update && apt-get install -y \
|
| 7 |
+
curl \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
# Copy and install Python dependencies first (layer caching)
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
# Copy source code
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
# Create non-root user for security
|
| 18 |
+
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
| 19 |
+
USER appuser
|
| 20 |
+
|
| 21 |
+
# Expose port
|
| 22 |
+
EXPOSE 7860
|
| 23 |
+
|
| 24 |
+
# Health check
|
| 25 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
| 26 |
+
CMD curl -f http://localhost:7860/health || exit 1
|
| 27 |
+
|
| 28 |
+
# Run server
|
| 29 |
+
CMD ["uvicorn", "app.server:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
|
PRD.md
ADDED
|
@@ -0,0 +1,2193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SupportOps Arena — Product Requirements Document (PRD)
|
| 2 |
+
## OpenEnv Hackathon 2026 · Meta × PyTorch × Hugging Face
|
| 3 |
+
### Build Target: Claude Sonnet 4.5 via GitHub Copilot CLI
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## TABLE OF CONTENTS
|
| 8 |
+
|
| 9 |
+
1. [Project Overview](#1-project-overview)
|
| 10 |
+
2. [Tech Stack & Tooling Rules](#2-tech-stack--tooling-rules)
|
| 11 |
+
3. [Repository Structure](#3-repository-structure)
|
| 12 |
+
4. [Environment Core (OpenEnv Spec)](#4-environment-core-openenv-spec)
|
| 13 |
+
5. [Pydantic Models & Type System](#5-pydantic-models--type-system)
|
| 14 |
+
6. [State Machine & Transitions](#6-state-machine--transitions)
|
| 15 |
+
7. [Reward Function](#7-reward-function)
|
| 16 |
+
8. [Task Definitions](#8-task-definitions)
|
| 17 |
+
9. [Adaptive Adversary Module](#9-adaptive-adversary-module)
|
| 18 |
+
10. [Grading System](#10-grading-system)
|
| 19 |
+
11. [Baseline Agent](#11-baseline-agent)
|
| 20 |
+
12. [FastAPI Server (OpenEnv HTTP Spec)](#12-fastapi-server-openenv-http-spec)
|
| 21 |
+
13. [openenv.yaml](#13-openenvyaml)
|
| 22 |
+
14. [Dockerfile & Containerization](#14-dockerfile--containerization)
|
| 23 |
+
15. [Frontend Dashboard UI](#15-frontend-dashboard-ui)
|
| 24 |
+
16. [inference.py (Baseline Inference Script)](#16-inferencepy-baseline-inference-script)
|
| 25 |
+
17. [Testing Suite](#17-testing-suite)
|
| 26 |
+
18. [README.md Requirements](#18-readmemd-requirements)
|
| 27 |
+
19. [Hugging Face Spaces Deployment](#19-hugging-face-spaces-deployment)
|
| 28 |
+
20. [Pre-Submission Checklist Compliance](#20-pre-submission-checklist-compliance)
|
| 29 |
+
21. [Design System & UI Rules](#21-design-system--ui-rules)
|
| 30 |
+
22. [Copilot CLI Prompt Strategy](#22-copilot-cli-prompt-strategy)
|
| 31 |
+
23. [Error Handling & Edge Cases](#23-error-handling--edge-cases)
|
| 32 |
+
24. [Absolute Rules & Prohibitions](#24-absolute-rules--prohibitions)
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
## 1. PROJECT OVERVIEW
|
| 37 |
+
|
| 38 |
+
### 1.1 What Is SupportOps Arena
|
| 39 |
+
|
| 40 |
+
SupportOps Arena is a **production-grade, OpenEnv-compliant reinforcement learning benchmark environment** that simulates enterprise IT support operations. An AI agent acts as a Level-1/Level-2 IT support operator. It receives incomplete, noisy incident tickets and must:
|
| 41 |
+
|
| 42 |
+
- Gather evidence through sequential tool use (diagnostic actions)
|
| 43 |
+
- Reason about hidden root causes under partial observability
|
| 44 |
+
- Apply risk-weighted remediation actions
|
| 45 |
+
- Escalate when autonomous resolution is unsafe
|
| 46 |
+
- Generalize policy across an adaptive adversary that targets agent weaknesses
|
| 47 |
+
|
| 48 |
+
### 1.2 Why It Exists
|
| 49 |
+
|
| 50 |
+
This is NOT a toy. IT support is a $50B+ industry. Agents deployed here face genuine operational consequences — wrong actions disrupt real users, wrong escalations waste L3 engineer time, wrong closures leave root causes unresolved. The environment tests:
|
| 51 |
+
|
| 52 |
+
- Long-horizon planning (10–24 step episodes)
|
| 53 |
+
- Partial observability (hidden root causes, noisy logs)
|
| 54 |
+
- Risk-aware action selection
|
| 55 |
+
- Cross-episode pattern recognition (hard task)
|
| 56 |
+
- Policy robustness under adversarial pressure
|
| 57 |
+
|
| 58 |
+
### 1.3 Hackathon Evaluation Weights
|
| 59 |
+
|
| 60 |
+
| Criterion | Weight |
|
| 61 |
+
|-----------|--------|
|
| 62 |
+
| Real-world utility | 30% |
|
| 63 |
+
| Task & grader quality | 25% |
|
| 64 |
+
| Environment design | 20% |
|
| 65 |
+
| Code quality & spec compliance | 15% |
|
| 66 |
+
| Creativity & novelty | 10% |
|
| 67 |
+
|
| 68 |
+
**Every design decision must maximize these weights.**
|
| 69 |
+
|
| 70 |
+
---
|
| 71 |
+
|
| 72 |
+
## 2. TECH STACK & TOOLING RULES
|
| 73 |
+
|
| 74 |
+
### 2.1 Required Stack
|
| 75 |
+
|
| 76 |
+
| Layer | Technology | Version | Rule |
|
| 77 |
+
|-------|-----------|---------|------|
|
| 78 |
+
| Language | Python | 3.11+ | REQUIRED. No 3.10 or below. |
|
| 79 |
+
| Type models | Pydantic | v2 | REQUIRED. All models must use `model_config`, `model_validator`, `field_validator`. No v1 syntax. |
|
| 80 |
+
| API server | FastAPI | 0.110+ | REQUIRED. All endpoints async. |
|
| 81 |
+
| ASGI server | Uvicorn | latest | Run with `--host 0.0.0.0 --port 7860` |
|
| 82 |
+
| Containerization | Docker | latest | Single-stage or multi-stage. Must work with `docker build . && docker run -p 7860:7860` |
|
| 83 |
+
| Testing | pytest + pytest-asyncio | latest | All env tests must be async. |
|
| 84 |
+
| LLM calls | OpenAI Python SDK | v1.x | Use `AsyncOpenAI`. Read `API_BASE_URL`, `MODEL_NAME`, `HF_TOKEN` from env vars. |
|
| 85 |
+
| Frontend | Vanilla HTML/CSS/JS | — | Single `index.html` served by FastAPI. No React needed for UI. |
|
| 86 |
+
| Logging | Python `logging` | stdlib | Structured JSON logs to stdout. |
|
| 87 |
+
| Random seed | Python `random` + `numpy` | — | All stochastic elements must accept a `seed` param for reproducibility. |
|
| 88 |
+
|
| 89 |
+
### 2.2 Environment Variables (MANDATORY)
|
| 90 |
+
|
| 91 |
+
The following must be read from environment at runtime. Never hardcode:
|
| 92 |
+
|
| 93 |
+
```python
|
| 94 |
+
API_BASE_URL = os.environ.get("API_BASE_URL", "https://api.openai.com/v1")
|
| 95 |
+
MODEL_NAME = os.environ.get("MODEL_NAME", "gpt-4o-mini")
|
| 96 |
+
HF_TOKEN = os.environ.get("HF_TOKEN", "")
|
| 97 |
+
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
### 2.3 Prohibited Patterns
|
| 101 |
+
|
| 102 |
+
- ❌ No `from pydantic import validator` (v1 style)
|
| 103 |
+
- ❌ No synchronous `requests` calls anywhere in async paths
|
| 104 |
+
- ❌ No hardcoded API keys in any file
|
| 105 |
+
- ❌ No `print()` for logging — use `logging.getLogger(__name__)`
|
| 106 |
+
- ❌ No global mutable state that breaks concurrent requests
|
| 107 |
+
- ❌ No `time.sleep()` in async code — use `asyncio.sleep()`
|
| 108 |
+
- ❌ No circular imports
|
| 109 |
+
|
| 110 |
+
---
|
| 111 |
+
|
| 112 |
+
## 3. REPOSITORY STRUCTURE
|
| 113 |
+
|
| 114 |
+
Create **exactly** this directory structure. No deviations:
|
| 115 |
+
|
| 116 |
+
```
|
| 117 |
+
supportops-arena/
|
| 118 |
+
├── env/
|
| 119 |
+
│ ├── __init__.py
|
| 120 |
+
│ ├── environment.py # Core OpenEnv class (SupportOpsArena)
|
| 121 |
+
│ ├── state.py # All Pydantic models
|
| 122 |
+
│ ├── actions.py # Action enum + metadata
|
| 123 |
+
│ ├── rewards.py # RewardCalculator class
|
| 124 |
+
│ ├── transitions.py # StateMachine class
|
| 125 |
+
│ └── scenarios.py # TicketGenerator class
|
| 126 |
+
├── adversary/
|
| 127 |
+
│ ├── __init__.py
|
| 128 |
+
│ ├── adversary.py # AdaptiveAdversary class
|
| 129 |
+
│ └── policy_tracker.py # PolicyTracker class
|
| 130 |
+
├── graders/
|
| 131 |
+
│ ├── __init__.py
|
| 132 |
+
│ ├── programmatic.py # ProgrammaticGrader class
|
| 133 |
+
│ ├── llm_grader.py # LLMGrader class
|
| 134 |
+
│ └── adversarial_grader.py # AdversarialGrader class
|
| 135 |
+
├── baseline/
|
| 136 |
+
│ ├── __init__.py
|
| 137 |
+
│ └── baseline_agent.py # BaselineAgent class
|
| 138 |
+
├── tests/
|
| 139 |
+
│ ├── __init__.py
|
| 140 |
+
│ ├── conftest.py # pytest fixtures
|
| 141 |
+
│ ├── test_env.py # Environment unit tests
|
| 142 |
+
│ ├── test_graders.py # Grader accuracy tests
|
| 143 |
+
│ ├── test_transitions.py # State machine tests
|
| 144 |
+
│ └── test_adversary.py # Adversary behavior tests
|
| 145 |
+
├── app/
|
| 146 |
+
│ ├── __init__.py
|
| 147 |
+
│ ├── server.py # FastAPI app
|
| 148 |
+
│ └── static/
|
| 149 |
+
│ └── index.html # Frontend dashboard
|
| 150 |
+
├── openenv.yaml # OpenEnv metadata spec
|
| 151 |
+
├── inference.py # Baseline inference script (ROOT LEVEL)
|
| 152 |
+
├── Dockerfile
|
| 153 |
+
├── requirements.txt
|
| 154 |
+
├── .env.example
|
| 155 |
+
├── PRD.md # This file
|
| 156 |
+
└── README.md
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
---
|
| 160 |
+
|
| 161 |
+
## 4. ENVIRONMENT CORE (OPENENV SPEC)
|
| 162 |
+
|
| 163 |
+
### 4.1 `env/environment.py` — Full Specification
|
| 164 |
+
|
| 165 |
+
The main class `SupportOpsArena` must implement the full OpenEnv interface. Here is the complete contract:
|
| 166 |
+
|
| 167 |
+
```python
|
| 168 |
+
class SupportOpsArena:
|
| 169 |
+
"""
|
| 170 |
+
OpenEnv-compliant environment for enterprise IT incident triage.
|
| 171 |
+
|
| 172 |
+
Implements:
|
| 173 |
+
- async reset(task_level: str) -> EnvObservation
|
| 174 |
+
- async step(action: Action) -> StepResult
|
| 175 |
+
- async state() -> EnvState
|
| 176 |
+
- Pydantic v2 typed models throughout
|
| 177 |
+
- Dense reward shaping
|
| 178 |
+
- Adaptive adversary integration
|
| 179 |
+
- 3 task levels: easy, medium, hard
|
| 180 |
+
"""
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
#### 4.1.1 `reset()` Method
|
| 184 |
+
|
| 185 |
+
```python
|
| 186 |
+
async def reset(self, task_level: str = "easy", seed: int | None = None) -> EnvObservation:
|
| 187 |
+
```
|
| 188 |
+
|
| 189 |
+
Rules:
|
| 190 |
+
- `task_level` must be one of `["easy", "medium", "hard"]`. Raise `ValueError` otherwise.
|
| 191 |
+
- Sample a new ticket scenario using `TicketGenerator` with the adversary's current sampling weights.
|
| 192 |
+
- Set `step_count = 0`.
|
| 193 |
+
- Set `episode_id` as a UUID4 string.
|
| 194 |
+
- Set `escalation_allowed = False` initially (unlocked after minimum diagnostic steps per task).
|
| 195 |
+
- Store `hidden_state` (root cause, correct remediation) in `EnvState` but NOT in `EnvObservation`.
|
| 196 |
+
- Log episode start with `episode_id`, `task_level`, `seed`.
|
| 197 |
+
- Return an `EnvObservation` with all fields populated. `system_logs` starts with 3–5 entries (may include one misleading entry for medium/hard).
|
| 198 |
+
- `action_history` starts as empty list.
|
| 199 |
+
|
| 200 |
+
#### 4.1.2 `step()` Method
|
| 201 |
+
|
| 202 |
+
```python
|
| 203 |
+
async def step(self, action: Action) -> StepResult:
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
`StepResult` is a named tuple / dataclass: `(observation: EnvObservation, reward: float, done: bool, info: dict)`
|
| 207 |
+
|
| 208 |
+
Rules:
|
| 209 |
+
- Validate action is a valid `ActionType` enum member. Return penalty reward if invalid.
|
| 210 |
+
- Check if episode is already done. Raise `RuntimeError("Episode already terminated. Call reset().")` if so.
|
| 211 |
+
- Pass action through `StateMachine.transition()` to get next state.
|
| 212 |
+
- Calculate reward via `RewardCalculator.calculate()`.
|
| 213 |
+
- Increment `step_count`.
|
| 214 |
+
- Append action to `action_history`.
|
| 215 |
+
- Check terminal conditions:
|
| 216 |
+
- `action.name == ActionType.RESOLVE_TICKET` → done, apply terminal reward.
|
| 217 |
+
- `action.name == ActionType.ESCALATE_TICKET` → done, apply escalation reward.
|
| 218 |
+
- `action.name == ActionType.CLOSE_WITHOUT_FIX` → done, apply heavy penalty.
|
| 219 |
+
- `step_count >= max_steps[task_level]` → done, apply budget-exceeded penalty.
|
| 220 |
+
- Update adversary policy tracker with this action.
|
| 221 |
+
- `info` dict must contain: `{"episode_id": ..., "step": ..., "root_cause": None, "reward_breakdown": {...}}`. Do NOT reveal `root_cause` until `done=True`.
|
| 222 |
+
- When `done=True`, `info["root_cause"]` = actual root cause string, `info["correct"]` = bool.
|
| 223 |
+
|
| 224 |
+
#### 4.1.3 `state()` Method
|
| 225 |
+
|
| 226 |
+
```python
|
| 227 |
+
async def state(self) -> EnvState:
|
| 228 |
+
```
|
| 229 |
+
|
| 230 |
+
Returns full `EnvState` including `hidden_state`. Used by graders only. Never expose to agent during active episode.
|
| 231 |
+
|
| 232 |
+
#### 4.1.4 Step Budgets
|
| 233 |
+
|
| 234 |
+
```python
|
| 235 |
+
MAX_STEPS = {
|
| 236 |
+
"easy": 10,
|
| 237 |
+
"medium": 16,
|
| 238 |
+
"hard": 24
|
| 239 |
+
}
|
| 240 |
+
```
|
| 241 |
+
|
| 242 |
+
#### 4.1.5 Escalation Unlock Logic
|
| 243 |
+
|
| 244 |
+
```python
|
| 245 |
+
ESCALATION_UNLOCK_AFTER = {
|
| 246 |
+
"easy": 3, # Must take 3+ diagnostic actions first
|
| 247 |
+
"medium": 4, # Must take 4+ diagnostic actions first
|
| 248 |
+
"hard": 5 # Must take 5+ diagnostic actions first
|
| 249 |
+
}
|
| 250 |
+
```
|
| 251 |
+
|
| 252 |
+
`escalation_allowed` flips to `True` in the observation after the unlock threshold is met. If agent tries to escalate before unlock, return penalty `-0.20` and do NOT terminate episode.
|
| 253 |
+
|
| 254 |
+
---
|
| 255 |
+
|
| 256 |
+
## 5. PYDANTIC MODELS & TYPE SYSTEM
|
| 257 |
+
|
| 258 |
+
### 5.1 `env/state.py` — Complete Model Definitions
|
| 259 |
+
|
| 260 |
+
Every model uses Pydantic v2. No exceptions.
|
| 261 |
+
|
| 262 |
+
```python
|
| 263 |
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
| 264 |
+
from enum import Enum
|
| 265 |
+
from datetime import datetime
|
| 266 |
+
import uuid
|
| 267 |
+
|
| 268 |
+
# ─── Enums ───────────────────────────────────────────────────
|
| 269 |
+
|
| 270 |
+
class NetworkStatus(str, Enum):
|
| 271 |
+
HEALTHY = "HEALTHY"
|
| 272 |
+
DEGRADED = "DEGRADED"
|
| 273 |
+
DOWN = "DOWN"
|
| 274 |
+
UNKNOWN = "UNKNOWN"
|
| 275 |
+
|
| 276 |
+
class VPNStatus(str, Enum):
|
| 277 |
+
CONNECTED = "CONNECTED"
|
| 278 |
+
FAILED = "FAILED"
|
| 279 |
+
TIMEOUT = "TIMEOUT"
|
| 280 |
+
NA = "N/A"
|
| 281 |
+
|
| 282 |
+
class AuthStatus(str, Enum):
|
| 283 |
+
OK = "OK"
|
| 284 |
+
LOCKED = "LOCKED"
|
| 285 |
+
EXPIRED = "EXPIRED"
|
| 286 |
+
MFA_FAIL = "MFA_FAIL"
|
| 287 |
+
|
| 288 |
+
class ServiceHealth(str, Enum):
|
| 289 |
+
HEALTHY = "HEALTHY"
|
| 290 |
+
DEGRADED = "DEGRADED"
|
| 291 |
+
DOWN = "DOWN"
|
| 292 |
+
UNKNOWN = "UNKNOWN"
|
| 293 |
+
|
| 294 |
+
class TaskLevel(str, Enum):
|
| 295 |
+
EASY = "easy"
|
| 296 |
+
MEDIUM = "medium"
|
| 297 |
+
HARD = "hard"
|
| 298 |
+
|
| 299 |
+
# ─── Sub-models ──────────────────────────────────────────────
|
| 300 |
+
|
| 301 |
+
class UserContext(BaseModel):
|
| 302 |
+
department: str
|
| 303 |
+
role: str
|
| 304 |
+
device_type: str
|
| 305 |
+
os_version: str
|
| 306 |
+
location: str # "onsite" | "remote"
|
| 307 |
+
|
| 308 |
+
class LogEntry(BaseModel):
|
| 309 |
+
timestamp: str
|
| 310 |
+
level: str # "INFO" | "WARN" | "ERROR" | "DEBUG"
|
| 311 |
+
service: str
|
| 312 |
+
message: str
|
| 313 |
+
is_misleading: bool = False # Hidden from agent, used by grader
|
| 314 |
+
|
| 315 |
+
class ActionRecord(BaseModel):
|
| 316 |
+
"""Record of a past action in action_history."""
|
| 317 |
+
step: int
|
| 318 |
+
action_name: str
|
| 319 |
+
rationale: str | None
|
| 320 |
+
reward_received: float
|
| 321 |
+
timestamp: str
|
| 322 |
+
|
| 323 |
+
# ─── Core Models ─────────────────────────────────────────────
|
| 324 |
+
|
| 325 |
+
class EnvObservation(BaseModel):
|
| 326 |
+
"""What the AGENT sees. No hidden info."""
|
| 327 |
+
ticket_id: str
|
| 328 |
+
ticket_summary: str
|
| 329 |
+
user_context: UserContext
|
| 330 |
+
network_status: NetworkStatus
|
| 331 |
+
vpn_status: VPNStatus
|
| 332 |
+
auth_status: AuthStatus
|
| 333 |
+
service_health: dict[str, ServiceHealth]
|
| 334 |
+
system_logs: list[LogEntry]
|
| 335 |
+
action_history: list[ActionRecord]
|
| 336 |
+
step_count: int
|
| 337 |
+
escalation_allowed: bool
|
| 338 |
+
confidence_score: float | None = None # Optional agent field
|
| 339 |
+
task_level: TaskLevel
|
| 340 |
+
steps_remaining: int
|
| 341 |
+
|
| 342 |
+
class HiddenState(BaseModel):
|
| 343 |
+
"""Internal ground truth. NEVER sent to agent."""
|
| 344 |
+
root_cause: str
|
| 345 |
+
root_cause_category: str # "network" | "auth" | "dns" | "vpn" | "sso" | "hardware"
|
| 346 |
+
correct_remediation: str # Which ActionType resolves this
|
| 347 |
+
correct_remediation_alts: list[str] # Acceptable alternates
|
| 348 |
+
misleading_log_index: int | None # Which log entry is the red herring
|
| 349 |
+
severity: str # "low" | "medium" | "high" | "critical"
|
| 350 |
+
affected_users_count: int # Side-effect blast radius
|
| 351 |
+
ticket_variant_id: str # For adversary tracking
|
| 352 |
+
|
| 353 |
+
class EnvState(BaseModel):
|
| 354 |
+
"""Full internal state. Graders only."""
|
| 355 |
+
episode_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
| 356 |
+
task_level: TaskLevel
|
| 357 |
+
observation: EnvObservation
|
| 358 |
+
hidden: HiddenState
|
| 359 |
+
cumulative_reward: float = 0.0
|
| 360 |
+
done: bool = False
|
| 361 |
+
created_at: str = Field(default_factory=lambda: datetime.utcnow().isoformat())
|
| 362 |
+
action_log: list[ActionRecord] = Field(default_factory=list)
|
| 363 |
+
diagnostic_steps_taken: int = 0 # Tracks unlock threshold
|
| 364 |
+
|
| 365 |
+
class StepResult(BaseModel):
|
| 366 |
+
observation: EnvObservation
|
| 367 |
+
reward: float
|
| 368 |
+
done: bool
|
| 369 |
+
info: dict
|
| 370 |
+
```
|
| 371 |
+
|
| 372 |
+
---
|
| 373 |
+
|
| 374 |
+
## 6. STATE MACHINE & TRANSITIONS
|
| 375 |
+
|
| 376 |
+
### 6.1 `env/actions.py`
|
| 377 |
+
|
| 378 |
+
Define all 15 actions with metadata:
|
| 379 |
+
|
| 380 |
+
```python
|
| 381 |
+
from enum import Enum
|
| 382 |
+
from dataclasses import dataclass
|
| 383 |
+
|
| 384 |
+
class ActionType(str, Enum):
|
| 385 |
+
INSPECT_NETWORK = "inspect_network"
|
| 386 |
+
INSPECT_LOGS = "inspect_logs"
|
| 387 |
+
CHECK_AUTHENTICATION = "check_authentication"
|
| 388 |
+
CHECK_PERMISSIONS = "check_permissions"
|
| 389 |
+
QUERY_DEVICE_STATUS = "query_device_status"
|
| 390 |
+
SEARCH_INTERNAL_KB = "search_internal_kb"
|
| 391 |
+
CONTACT_USER = "contact_user_for_info"
|
| 392 |
+
RUN_DIAGNOSTIC = "run_diagnostic_script"
|
| 393 |
+
FLUSH_DNS = "flush_dns"
|
| 394 |
+
RECONFIGURE_CLIENT = "reconfigure_client"
|
| 395 |
+
RESTART_SERVICE = "restart_service"
|
| 396 |
+
RESET_CREDENTIALS = "reset_credentials"
|
| 397 |
+
ESCALATE_TICKET = "escalate_ticket"
|
| 398 |
+
RESOLVE_TICKET = "resolve_ticket"
|
| 399 |
+
CLOSE_WITHOUT_FIX = "close_without_fix"
|
| 400 |
+
|
| 401 |
+
@dataclass
|
| 402 |
+
class ActionMetadata:
|
| 403 |
+
risk_level: str # "LOW" | "MED" | "HIGH" | "NONE"
|
| 404 |
+
info_yield: str # "HIGH" | "MED" | "LOW" | "NONE"
|
| 405 |
+
step_cost: int # 1 for all, kept for future
|
| 406 |
+
requires_prior_diagnostic: bool
|
| 407 |
+
can_disrupt_other_users: bool
|
| 408 |
+
is_terminal: bool
|
| 409 |
+
|
| 410 |
+
ACTION_METADATA: dict[ActionType, ActionMetadata] = {
|
| 411 |
+
ActionType.INSPECT_NETWORK: ActionMetadata("LOW", "HIGH", 1, False, False, False),
|
| 412 |
+
ActionType.INSPECT_LOGS: ActionMetadata("LOW", "HIGH", 1, False, False, False),
|
| 413 |
+
ActionType.CHECK_AUTHENTICATION: ActionMetadata("LOW", "HIGH", 1, False, False, False),
|
| 414 |
+
ActionType.CHECK_PERMISSIONS: ActionMetadata("LOW", "MED", 1, False, False, False),
|
| 415 |
+
ActionType.QUERY_DEVICE_STATUS: ActionMetadata("LOW", "MED", 1, False, False, False),
|
| 416 |
+
ActionType.SEARCH_INTERNAL_KB: ActionMetadata("LOW", "MED", 1, False, False, False),
|
| 417 |
+
ActionType.CONTACT_USER: ActionMetadata("LOW", "MED", 1, False, False, False),
|
| 418 |
+
ActionType.RUN_DIAGNOSTIC: ActionMetadata("MED", "HIGH", 1, True, False, False),
|
| 419 |
+
ActionType.FLUSH_DNS: ActionMetadata("MED", "LOW", 1, False, False, False),
|
| 420 |
+
ActionType.RECONFIGURE_CLIENT: ActionMetadata("MED", "LOW", 1, False, False, False),
|
| 421 |
+
ActionType.RESTART_SERVICE: ActionMetadata("HIGH", "LOW", 1, True, True, False),
|
| 422 |
+
ActionType.RESET_CREDENTIALS: ActionMetadata("HIGH", "LOW", 1, True, False, False),
|
| 423 |
+
ActionType.ESCALATE_TICKET: ActionMetadata("NONE", "NONE", 1, False, False, True),
|
| 424 |
+
ActionType.RESOLVE_TICKET: ActionMetadata("NONE", "NONE", 1, False, False, True),
|
| 425 |
+
ActionType.CLOSE_WITHOUT_FIX: ActionMetadata("NONE", "NONE", 1, False, False, True),
|
| 426 |
+
}
|
| 427 |
+
```
|
| 428 |
+
|
| 429 |
+
### 6.2 `env/transitions.py` — StateMachine
|
| 430 |
+
|
| 431 |
+
The state machine enforces action preconditions and generates next observations:
|
| 432 |
+
|
| 433 |
+
```python
|
| 434 |
+
class StateMachine:
|
| 435 |
+
async def transition(self, state: EnvState, action: Action) -> tuple[EnvState, dict]:
|
| 436 |
+
"""
|
| 437 |
+
Apply action to state. Returns (next_state, reward_events).
|
| 438 |
+
|
| 439 |
+
Precondition rules (enforce strictly):
|
| 440 |
+
1. If action requires_prior_diagnostic and diagnostic_steps_taken == 0:
|
| 441 |
+
→ Inject reward event: RISKY_WITHOUT_DIAGNOSTIC = -0.15
|
| 442 |
+
→ Still apply the action (don't block it), but penalize
|
| 443 |
+
|
| 444 |
+
2. If action is ESCALATE_TICKET and not escalation_allowed:
|
| 445 |
+
→ Inject reward event: PREMATURE_ESCALATION = -0.20
|
| 446 |
+
→ Do NOT terminate. Keep episode running.
|
| 447 |
+
|
| 448 |
+
3. If same action has been taken before (action.name in past action_history):
|
| 449 |
+
→ Inject reward event: REDUNDANT_ACTION = -0.05
|
| 450 |
+
|
| 451 |
+
Observation updates per action (what new info is revealed):
|
| 452 |
+
- inspect_network: Update network_status, vpn_status with true values (or noisy version)
|
| 453 |
+
- inspect_logs: Return next batch of log entries (some may be misleading)
|
| 454 |
+
- check_authentication: Update auth_status with true values
|
| 455 |
+
- check_permissions: Add permission info to service_health
|
| 456 |
+
- query_device_status: Update user_context.device details
|
| 457 |
+
- search_internal_kb: Append KB result as a special LogEntry
|
| 458 |
+
- contact_user: Append user response as LogEntry (costs 1 step)
|
| 459 |
+
- run_diagnostic_script: Return targeted diagnostic result (high accuracy)
|
| 460 |
+
- flush_dns: Side effect: network_status may briefly show DEGRADED
|
| 461 |
+
- reconfigure_client: Partial fix (may resolve DNS-related causes)
|
| 462 |
+
- restart_service: Updates service_health, but may trigger side effects
|
| 463 |
+
- reset_credentials: Forces auth_status to OK but logs the action
|
| 464 |
+
- escalate_ticket: Terminal if allowed
|
| 465 |
+
- resolve_ticket: Terminal always
|
| 466 |
+
- close_without_fix: Terminal always
|
| 467 |
+
|
| 468 |
+
For diagnostic actions that match root cause category:
|
| 469 |
+
→ Inject reward event: CORRECT_TARGETED_DIAGNOSTIC = +0.20
|
| 470 |
+
|
| 471 |
+
For any diagnostic action:
|
| 472 |
+
→ Increment state.diagnostic_steps_taken
|
| 473 |
+
→ Check if escalation_allowed should flip to True
|
| 474 |
+
→ Inject reward event: DIAGNOSTIC_ACTION = +0.10
|
| 475 |
+
"""
|
| 476 |
+
```
|
| 477 |
+
|
| 478 |
+
Noise model for `inspect_logs`:
|
| 479 |
+
- Easy task: 0% misleading entries
|
| 480 |
+
- Medium task: 1 misleading entry injected at random position
|
| 481 |
+
- Hard task: 1–2 misleading entries, plus one contradictory entry
|
| 482 |
+
|
| 483 |
+
---
|
| 484 |
+
|
| 485 |
+
## 7. REWARD FUNCTION
|
| 486 |
+
|
| 487 |
+
### 7.1 `env/rewards.py` — RewardCalculator
|
| 488 |
+
|
| 489 |
+
All reward events must be tracked individually in a `reward_breakdown` dict for the `info` return:
|
| 490 |
+
|
| 491 |
+
```python
|
| 492 |
+
REWARD_EVENTS = {
|
| 493 |
+
# Positive
|
| 494 |
+
"DIAGNOSTIC_ACTION": +0.10,
|
| 495 |
+
"CORRECT_TARGETED_DIAGNOSTIC": +0.20,
|
| 496 |
+
"EVIDENCE_BEFORE_RISKY": +0.05,
|
| 497 |
+
"CORRECT_REMEDIATION": +0.40,
|
| 498 |
+
"OPTIMAL_RESOLUTION": +0.20, # Only if steps_used <= min_required_steps
|
| 499 |
+
"JUSTIFIED_ESCALATION": +0.30,
|
| 500 |
+
"TERMINAL_CORRECT": +1.00,
|
| 501 |
+
|
| 502 |
+
# Negative
|
| 503 |
+
"REDUNDANT_ACTION": -0.05,
|
| 504 |
+
"RISKY_WITHOUT_DIAGNOSTIC": -0.15,
|
| 505 |
+
"INCORRECT_REMEDIATION": -0.25,
|
| 506 |
+
"HARMFUL_ACTION": -0.35,
|
| 507 |
+
"PREMATURE_CLOSURE": -0.40,
|
| 508 |
+
"BUDGET_EXCEEDED_PER_STEP": -0.02,
|
| 509 |
+
"PREMATURE_ESCALATION": -0.20, # Escalate before unlock
|
| 510 |
+
"UNJUSTIFIED_ESCALATION": -0.20, # Escalate without enough evidence
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
class RewardCalculator:
|
| 514 |
+
def calculate(self, events: list[str], state: EnvState, action: Action) -> tuple[float, dict]:
|
| 515 |
+
"""
|
| 516 |
+
Takes list of reward event keys, computes total reward.
|
| 517 |
+
Returns (total_reward, breakdown_dict).
|
| 518 |
+
|
| 519 |
+
Normalization:
|
| 520 |
+
max_possible_reward per task level (precomputed):
|
| 521 |
+
- easy: 2.05 (DIAGNOSTIC×3 + CORRECT_TARGETED + CORRECT_REMEDIATION + TERMINAL + OPTIMAL)
|
| 522 |
+
- medium: 2.35
|
| 523 |
+
- hard: 2.75
|
| 524 |
+
|
| 525 |
+
Episode score = clamp(cumulative_reward / max_possible[task], 0.0, 1.0)
|
| 526 |
+
"""
|
| 527 |
+
```
|
| 528 |
+
|
| 529 |
+
### 7.2 Minimum Steps for Optimal Resolution Bonus
|
| 530 |
+
|
| 531 |
+
```python
|
| 532 |
+
MIN_STEPS_FOR_OPTIMAL = {
|
| 533 |
+
"easy": 4, # Any solution in ≤4 steps gets the +0.20 bonus
|
| 534 |
+
"medium": 7,
|
| 535 |
+
"hard": 12
|
| 536 |
+
}
|
| 537 |
+
```
|
| 538 |
+
|
| 539 |
+
---
|
| 540 |
+
|
| 541 |
+
## 8. TASK DEFINITIONS
|
| 542 |
+
|
| 543 |
+
### 8.1 `env/scenarios.py` — TicketGenerator
|
| 544 |
+
|
| 545 |
+
#### Task 1 — Easy: Wi-Fi Connectivity Failure
|
| 546 |
+
|
| 547 |
+
```python
|
| 548 |
+
TASK_EASY_ROOT_CAUSES = [
|
| 549 |
+
{
|
| 550 |
+
"id": "wifi_adapter_disabled",
|
| 551 |
+
"description": "Network adapter disabled by OS update",
|
| 552 |
+
"category": "hardware",
|
| 553 |
+
"correct_remediation": ActionType.RECONFIGURE_CLIENT,
|
| 554 |
+
"diagnostic_path": [ActionType.QUERY_DEVICE_STATUS, ActionType.INSPECT_NETWORK],
|
| 555 |
+
"severity": "low",
|
| 556 |
+
"affected_users": 1
|
| 557 |
+
},
|
| 558 |
+
{
|
| 559 |
+
"id": "wifi_wrong_credentials",
|
| 560 |
+
"description": "Incorrect saved credentials for SSID",
|
| 561 |
+
"category": "auth",
|
| 562 |
+
"correct_remediation": ActionType.RESET_CREDENTIALS,
|
| 563 |
+
"diagnostic_path": [ActionType.CHECK_AUTHENTICATION, ActionType.INSPECT_LOGS],
|
| 564 |
+
"severity": "low",
|
| 565 |
+
"affected_users": 1
|
| 566 |
+
},
|
| 567 |
+
{
|
| 568 |
+
"id": "wifi_dns_corruption",
|
| 569 |
+
"description": "Local DNS cache corruption",
|
| 570 |
+
"category": "dns",
|
| 571 |
+
"correct_remediation": ActionType.FLUSH_DNS,
|
| 572 |
+
"diagnostic_path": [ActionType.INSPECT_NETWORK, ActionType.INSPECT_LOGS],
|
| 573 |
+
"severity": "low",
|
| 574 |
+
"affected_users": 1
|
| 575 |
+
},
|
| 576 |
+
{
|
| 577 |
+
"id": "wifi_airplane_mode",
|
| 578 |
+
"description": "Airplane mode enabled by accident",
|
| 579 |
+
"category": "hardware",
|
| 580 |
+
"correct_remediation": ActionType.RECONFIGURE_CLIENT,
|
| 581 |
+
"diagnostic_path": [ActionType.QUERY_DEVICE_STATUS],
|
| 582 |
+
"severity": "low",
|
| 583 |
+
"affected_users": 1
|
| 584 |
+
},
|
| 585 |
+
{
|
| 586 |
+
"id": "wifi_dhcp_exhausted",
|
| 587 |
+
"description": "DHCP lease pool exhausted",
|
| 588 |
+
"category": "network",
|
| 589 |
+
"correct_remediation": ActionType.RESTART_SERVICE,
|
| 590 |
+
"diagnostic_path": [ActionType.INSPECT_NETWORK, ActionType.RUN_DIAGNOSTIC],
|
| 591 |
+
"severity": "medium",
|
| 592 |
+
"affected_users": 15
|
| 593 |
+
},
|
| 594 |
+
{
|
| 595 |
+
"id": "wifi_wrong_ssid",
|
| 596 |
+
"description": "Client connected to wrong SSID (guest vs corporate)",
|
| 597 |
+
"category": "network",
|
| 598 |
+
"correct_remediation": ActionType.RECONFIGURE_CLIENT,
|
| 599 |
+
"diagnostic_path": [ActionType.QUERY_DEVICE_STATUS, ActionType.INSPECT_NETWORK],
|
| 600 |
+
"severity": "low",
|
| 601 |
+
"affected_users": 1
|
| 602 |
+
}
|
| 603 |
+
]
|
| 604 |
+
```
|
| 605 |
+
|
| 606 |
+
Ticket template for easy:
|
| 607 |
+
```
|
| 608 |
+
"ticket_summary": "Hi, I can't connect to Wi-Fi and need help urgently."
|
| 609 |
+
"user_context": randomized department/role/device/OS
|
| 610 |
+
"initial_logs": 3 generic log entries, 0 misleading
|
| 611 |
+
```
|
| 612 |
+
|
| 613 |
+
#### Task 2 — Medium: VPN Access Failure
|
| 614 |
+
|
| 615 |
+
Root causes (one OR two combined per episode):
|
| 616 |
+
|
| 617 |
+
```python
|
| 618 |
+
TASK_MEDIUM_ROOT_CAUSES = [
|
| 619 |
+
"expired_password", # auth_status: EXPIRED
|
| 620 |
+
"vpn_version_mismatch", # service_health["vpn_client"]: DEGRADED
|
| 621 |
+
"dns_split_tunnel_conflict", # network shows HEALTHY but vpn_status: FAILED
|
| 622 |
+
"mfa_token_desync", # auth_status: MFA_FAIL
|
| 623 |
+
"firewall_subnet_block", # network_status: DEGRADED, specific subnet
|
| 624 |
+
"certificate_expiry", # service_health["vpn_cert"]: DOWN
|
| 625 |
+
]
|
| 626 |
+
```
|
| 627 |
+
|
| 628 |
+
Key challenge implementation:
|
| 629 |
+
- Always inject exactly 1 misleading log entry at position `random.randint(1, len(logs)-1)`
|
| 630 |
+
- The misleading entry must point toward a plausible but incorrect root cause
|
| 631 |
+
- The agent must gather evidence from ≥3 sources before the grader gives full credit
|
| 632 |
+
|
| 633 |
+
Ticket template:
|
| 634 |
+
```
|
| 635 |
+
"ticket_summary": "I'm working from home and can't access any internal tools through VPN.
|
| 636 |
+
It was working fine yesterday."
|
| 637 |
+
"initial_logs": 5 entries, 1 is misleading (flagged in HiddenState.misleading_log_index)
|
| 638 |
+
```
|
| 639 |
+
|
| 640 |
+
#### Task 3 — Hard: Cross-System Access Failure with Shared Infrastructure
|
| 641 |
+
|
| 642 |
+
This task delivers **two sequential tickets** in the same episode. The agent must recognize they share a root cause.
|
| 643 |
+
|
| 644 |
+
Implementation:
|
| 645 |
+
```python
|
| 646 |
+
class HardTaskState(BaseModel):
|
| 647 |
+
ticket_a: EnvObservation # Email access failure
|
| 648 |
+
ticket_b: EnvObservation # Enterprise tools access failure
|
| 649 |
+
shared_root_cause: str # "sso_token_service_degradation"
|
| 650 |
+
cross_ticket_recognized: bool = False # Set to True if agent escalates with both ticket IDs
|
| 651 |
+
|
| 652 |
+
# Ticket A symptoms:
|
| 653 |
+
# - auth_status: EXPIRED (misleading — it's not expired, it's SSO backend failing)
|
| 654 |
+
# - service_health["mail"]: DEGRADED
|
| 655 |
+
# - vpn_status: N/A
|
| 656 |
+
|
| 657 |
+
# Ticket B symptoms:
|
| 658 |
+
# - auth_status: OK (misleading — cached credentials still valid)
|
| 659 |
+
# - service_health["sharepoint"]: DOWN
|
| 660 |
+
# - service_health["teams"]: DEGRADED
|
| 661 |
+
|
| 662 |
+
# True root cause: SSO token service degradation on datacenter-east
|
| 663 |
+
# Correct resolution: ESCALATE with summary mentioning both ticket IDs and SSO hypothesis
|
| 664 |
+
```
|
| 665 |
+
|
| 666 |
+
The adversary for hard task:
|
| 667 |
+
- Reads the agent's action history from last 5 episodes
|
| 668 |
+
- If agent always does inspect_logs → inject double-misleading-log variant
|
| 669 |
+
- If agent always escalates at step 5 → generate scenario where early escalation is penalized
|
| 670 |
+
- If agent scores > 0.85 on medium → unlock "contradictory evidence" hard variant
|
| 671 |
+
|
| 672 |
+
---
|
| 673 |
+
|
| 674 |
+
## 9. ADAPTIVE ADVERSARY MODULE
|
| 675 |
+
|
| 676 |
+
### 9.1 `adversary/policy_tracker.py`
|
| 677 |
+
|
| 678 |
+
```python
|
| 679 |
+
class PolicyTracker:
|
| 680 |
+
"""
|
| 681 |
+
Tracks agent behavior across episodes.
|
| 682 |
+
Maintains a sliding window of last N=20 episodes.
|
| 683 |
+
"""
|
| 684 |
+
|
| 685 |
+
def record_episode(self, episode_id: str, task_level: str, actions: list[ActionRecord], score: float):
|
| 686 |
+
"""Record completed episode into tracker."""
|
| 687 |
+
|
| 688 |
+
def get_action_frequencies(self, task_level: str) -> dict[str, float]:
|
| 689 |
+
"""Returns normalized frequency of each action type."""
|
| 690 |
+
|
| 691 |
+
def get_first_action_distribution(self) -> dict[str, float]:
|
| 692 |
+
"""What does the agent always do first?"""
|
| 693 |
+
|
| 694 |
+
def get_escalation_step_distribution(self) -> list[int]:
|
| 695 |
+
"""At which steps does agent escalate?"""
|
| 696 |
+
|
| 697 |
+
def get_score_by_root_cause_category(self) -> dict[str, float]:
|
| 698 |
+
"""Which root cause categories does agent struggle with?"""
|
| 699 |
+
```
|
| 700 |
+
|
| 701 |
+
### 9.2 `adversary/adversary.py`
|
| 702 |
+
|
| 703 |
+
```python
|
| 704 |
+
class AdaptiveAdversary:
|
| 705 |
+
"""
|
| 706 |
+
Analyzes policy_tracker and adjusts ticket sampling weights.
|
| 707 |
+
Stateless per-episode. Reads from policy_tracker at episode start.
|
| 708 |
+
"""
|
| 709 |
+
|
| 710 |
+
def get_sampling_weights(self, task_level: str) -> dict[str, float]:
|
| 711 |
+
"""
|
| 712 |
+
Returns weights for each root_cause_id.
|
| 713 |
+
|
| 714 |
+
Rules:
|
| 715 |
+
- If agent scores > 0.85 on dns_corruption: increase weight of mfa_token_desync
|
| 716 |
+
- If agent always runs inspect_logs first: enable misleading_log_boost=True
|
| 717 |
+
- If agent escalates at step < unlock_threshold + 1: increase
|
| 718 |
+
UNJUSTIFIED_ESCALATION scenario weight
|
| 719 |
+
- If agent uses restart_service > 40% of episodes: enable cascade_failure_mode=True
|
| 720 |
+
- If agent has < 0.40 score on any category: boost that category by 2x
|
| 721 |
+
|
| 722 |
+
Default: uniform weights across all root causes.
|
| 723 |
+
"""
|
| 724 |
+
|
| 725 |
+
def get_injection_config(self, agent_profile: dict) -> dict:
|
| 726 |
+
"""
|
| 727 |
+
Returns episode configuration overrides:
|
| 728 |
+
- misleading_log_count: 0 | 1 | 2
|
| 729 |
+
- contradictory_evidence: bool
|
| 730 |
+
- cascade_failure_enabled: bool
|
| 731 |
+
- variant_id: str (for tracking)
|
| 732 |
+
"""
|
| 733 |
+
```
|
| 734 |
+
|
| 735 |
+
---
|
| 736 |
+
|
| 737 |
+
## 10. GRADING SYSTEM
|
| 738 |
+
|
| 739 |
+
### 10.1 `graders/programmatic.py`
|
| 740 |
+
|
| 741 |
+
```python
|
| 742 |
+
class ProgrammaticGrader:
|
| 743 |
+
"""
|
| 744 |
+
Rule-based ground truth checker.
|
| 745 |
+
Used for all three tasks.
|
| 746 |
+
"""
|
| 747 |
+
|
| 748 |
+
async def grade(self, state: EnvState) -> GradeResult:
|
| 749 |
+
"""
|
| 750 |
+
Checks:
|
| 751 |
+
1. root_cause_identified: bool
|
| 752 |
+
→ Was the correct remediation applied (or correct escalation reason given)?
|
| 753 |
+
|
| 754 |
+
2. action_sequence_safe: bool
|
| 755 |
+
→ Were all HIGH-risk actions preceded by ≥1 diagnostic action?
|
| 756 |
+
|
| 757 |
+
3. step_budget_respected: bool
|
| 758 |
+
→ step_count <= MAX_STEPS[task_level]
|
| 759 |
+
|
| 760 |
+
4. no_harmful_actions: bool
|
| 761 |
+
→ No RESTART_SERVICE that triggered cascade?
|
| 762 |
+
|
| 763 |
+
5. evidence_breadth_score: float [0.0-1.0]
|
| 764 |
+
→ For medium/hard: # unique diagnostic categories used / required_categories
|
| 765 |
+
|
| 766 |
+
Returns GradeResult with score 0.0-1.0 and breakdown.
|
| 767 |
+
"""
|
| 768 |
+
|
| 769 |
+
class GradeResult(BaseModel):
|
| 770 |
+
score: float = Field(ge=0.0, le=1.0)
|
| 771 |
+
root_cause_identified: bool
|
| 772 |
+
action_sequence_safe: bool
|
| 773 |
+
step_budget_respected: bool
|
| 774 |
+
no_harmful_actions: bool
|
| 775 |
+
evidence_breadth_score: float
|
| 776 |
+
breakdown: dict[str, float]
|
| 777 |
+
```
|
| 778 |
+
|
| 779 |
+
### 10.2 `graders/llm_grader.py`
|
| 780 |
+
|
| 781 |
+
Used for medium and hard tasks. Grades the agent's `rationale` strings.
|
| 782 |
+
|
| 783 |
+
```python
|
| 784 |
+
class LLMGrader:
|
| 785 |
+
"""
|
| 786 |
+
LLM judge using OpenAI-compatible API.
|
| 787 |
+
Reads API_BASE_URL, MODEL_NAME, OPENAI_API_KEY from env.
|
| 788 |
+
"""
|
| 789 |
+
|
| 790 |
+
SYSTEM_PROMPT = """
|
| 791 |
+
You are an expert IT operations grader evaluating an AI agent's performance on an
|
| 792 |
+
incident triage task. You will be given:
|
| 793 |
+
1. The hidden root cause (ground truth)
|
| 794 |
+
2. The agent's action history with rationale strings
|
| 795 |
+
3. The final escalation summary (if escalated)
|
| 796 |
+
|
| 797 |
+
Grade the following dimensions on a scale of 0.0 to 1.0:
|
| 798 |
+
- diagnostic_coherence: Are the agent's rationales logically consistent with the observations?
|
| 799 |
+
- evidence_sufficiency: Did the agent gather enough evidence before committing to a fix?
|
| 800 |
+
- root_cause_narration: If escalated, does the escalation summary correctly identify the cause?
|
| 801 |
+
|
| 802 |
+
Return ONLY a JSON object with keys: diagnostic_coherence, evidence_sufficiency,
|
| 803 |
+
root_cause_narration, overall, reasoning (string).
|
| 804 |
+
Do not include any other text.
|
| 805 |
+
"""
|
| 806 |
+
|
| 807 |
+
async def grade(self, state: EnvState) -> LLMGradeResult:
|
| 808 |
+
"""
|
| 809 |
+
Calls LLM API with action history + rationales.
|
| 810 |
+
Parses JSON response.
|
| 811 |
+
Returns LLMGradeResult.
|
| 812 |
+
Falls back to score=0.5 if API call fails.
|
| 813 |
+
"""
|
| 814 |
+
```
|
| 815 |
+
|
| 816 |
+
### 10.3 `graders/adversarial_grader.py`
|
| 817 |
+
|
| 818 |
+
Used for hard task only. Tests policy robustness.
|
| 819 |
+
|
| 820 |
+
```python
|
| 821 |
+
class AdversarialGrader:
|
| 822 |
+
"""
|
| 823 |
+
After episode completion, generates a harder variant of the same ticket.
|
| 824 |
+
Checks if the agent's learned policy (reconstructed from action history)
|
| 825 |
+
would still succeed on the variant.
|
| 826 |
+
"""
|
| 827 |
+
|
| 828 |
+
async def grade(self, state: EnvState, adversary: AdaptiveAdversary) -> float:
|
| 829 |
+
"""
|
| 830 |
+
1. Generate variant ticket (same root cause, different surface presentation)
|
| 831 |
+
2. Replay agent's action policy (deterministic from action_history)
|
| 832 |
+
3. Score: would that same action sequence work on the variant?
|
| 833 |
+
Returns 0.0-1.0 robustness score.
|
| 834 |
+
"""
|
| 835 |
+
```
|
| 836 |
+
|
| 837 |
+
### 10.4 Score Aggregation
|
| 838 |
+
|
| 839 |
+
```python
|
| 840 |
+
def aggregate_scores(
|
| 841 |
+
programmatic: float,
|
| 842 |
+
llm: float | None,
|
| 843 |
+
adversarial: float | None,
|
| 844 |
+
task_level: str
|
| 845 |
+
) -> float:
|
| 846 |
+
if task_level == "easy":
|
| 847 |
+
return programmatic
|
| 848 |
+
elif task_level == "medium":
|
| 849 |
+
return 0.6 * programmatic + 0.4 * (llm or 0.5)
|
| 850 |
+
else: # hard
|
| 851 |
+
return 0.5 * programmatic + 0.3 * (llm or 0.5) + 0.2 * (adversarial or 0.5)
|
| 852 |
+
|
| 853 |
+
OVERALL_BENCHMARK_SCORE_WEIGHTS = {"easy": 0.2, "medium": 0.3, "hard": 0.5}
|
| 854 |
+
```
|
| 855 |
+
|
| 856 |
+
---
|
| 857 |
+
|
| 858 |
+
## 11. BASELINE AGENT
|
| 859 |
+
|
| 860 |
+
### 11.1 `baseline/baseline_agent.py`
|
| 861 |
+
|
| 862 |
+
This is a **scripted heuristic agent** — no ML model required. Must produce deterministic, reproducible scores.
|
| 863 |
+
|
| 864 |
+
```python
|
| 865 |
+
class BaselineAgent:
|
| 866 |
+
"""
|
| 867 |
+
Scripted heuristic baseline agent.
|
| 868 |
+
No API calls. No learned model. Fully deterministic given a seed.
|
| 869 |
+
|
| 870 |
+
Strategy:
|
| 871 |
+
Step 1: Always run inspect_logs + check_authentication (safe, high-yield)
|
| 872 |
+
Step 2: Based on observation fields, apply decision tree:
|
| 873 |
+
- If auth_status in [LOCKED, EXPIRED, MFA_FAIL] → check_permissions → reset_credentials
|
| 874 |
+
- If network_status in [DEGRADED, DOWN] → inspect_network → run_diagnostic_script
|
| 875 |
+
- If vpn_status in [FAILED, TIMEOUT] → reconfigure_client → flush_dns
|
| 876 |
+
- If service_health has DOWN services → run_diagnostic_script → restart_service
|
| 877 |
+
- Default: query_device_status → search_internal_kb
|
| 878 |
+
Step 3: Choose lowest-risk remediation matching most likely root cause
|
| 879 |
+
Step 4: If diagnostic_steps >= 60% of budget and uncertainty high → escalate_ticket
|
| 880 |
+
|
| 881 |
+
Expected scores:
|
| 882 |
+
- Easy: 0.65 – 0.75
|
| 883 |
+
- Medium: 0.40 – 0.55
|
| 884 |
+
- Hard: 0.20 – 0.35
|
| 885 |
+
"""
|
| 886 |
+
|
| 887 |
+
async def select_action(self, observation: EnvObservation) -> Action:
|
| 888 |
+
"""Pure function. Returns Action with a rationale string."""
|
| 889 |
+
|
| 890 |
+
async def run_episode(self, env: SupportOpsArena, task_level: str) -> float:
|
| 891 |
+
"""Run one full episode. Returns episode score."""
|
| 892 |
+
```
|
| 893 |
+
|
| 894 |
+
---
|
| 895 |
+
|
| 896 |
+
## 12. FASTAPI SERVER (OPENENV HTTP SPEC)
|
| 897 |
+
|
| 898 |
+
### 12.1 `app/server.py` — Complete Endpoint Specification
|
| 899 |
+
|
| 900 |
+
```python
|
| 901 |
+
from fastapi import FastAPI, HTTPException
|
| 902 |
+
from fastapi.staticfiles import StaticFiles
|
| 903 |
+
from fastapi.responses import HTMLResponse
|
| 904 |
+
|
| 905 |
+
app = FastAPI(
|
| 906 |
+
title="SupportOps Arena",
|
| 907 |
+
description="OpenEnv-compliant IT incident triage RL environment",
|
| 908 |
+
version="1.0.0"
|
| 909 |
+
)
|
| 910 |
+
```
|
| 911 |
+
|
| 912 |
+
#### Required Endpoints
|
| 913 |
+
|
| 914 |
+
| Method | Path | Description |
|
| 915 |
+
|--------|------|-------------|
|
| 916 |
+
| GET | `/` | Serve `index.html` (dashboard UI) |
|
| 917 |
+
| GET | `/health` | Returns `{"status": "ok", "version": "1.0.0"}` |
|
| 918 |
+
| POST | `/reset` | Reset environment, return initial observation |
|
| 919 |
+
| POST | `/step` | Take action, return observation + reward |
|
| 920 |
+
| GET | `/state` | Return full env state (grader use) |
|
| 921 |
+
| GET | `/scores` | Return scores for all 3 tasks from last baseline run |
|
| 922 |
+
| POST | `/grade` | Run grader on current episode |
|
| 923 |
+
| GET | `/openenv.yaml` | Serve openenv.yaml as text |
|
| 924 |
+
| GET | `/tasks` | List all 3 tasks with metadata |
|
| 925 |
+
| POST | `/baseline/run` | Run baseline agent on all 3 tasks |
|
| 926 |
+
| GET | `/docs` | FastAPI auto-docs (keep enabled) |
|
| 927 |
+
|
| 928 |
+
#### `/reset` Request/Response
|
| 929 |
+
|
| 930 |
+
```python
|
| 931 |
+
class ResetRequest(BaseModel):
|
| 932 |
+
task_level: str = "easy" # "easy" | "medium" | "hard"
|
| 933 |
+
seed: int | None = None
|
| 934 |
+
|
| 935 |
+
# Response: EnvObservation (full typed model)
|
| 936 |
+
```
|
| 937 |
+
|
| 938 |
+
#### `/step` Request/Response
|
| 939 |
+
|
| 940 |
+
```python
|
| 941 |
+
class StepRequest(BaseModel):
|
| 942 |
+
action_name: str # ActionType enum value
|
| 943 |
+
rationale: str | None = None
|
| 944 |
+
|
| 945 |
+
# Response: StepResult (observation, reward, done, info)
|
| 946 |
+
```
|
| 947 |
+
|
| 948 |
+
#### Session Management
|
| 949 |
+
|
| 950 |
+
Each HTTP session must maintain its own environment instance. Use a session ID (UUID) passed as a header `X-Session-ID`. If no session ID, auto-generate and return it in response headers. Store sessions in a dict with TTL of 30 minutes.
|
| 951 |
+
|
| 952 |
+
```python
|
| 953 |
+
sessions: dict[str, SupportOpsArena] = {}
|
| 954 |
+
SESSION_TTL_SECONDS = 1800
|
| 955 |
+
```
|
| 956 |
+
|
| 957 |
+
#### CORS Configuration
|
| 958 |
+
|
| 959 |
+
```python
|
| 960 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 961 |
+
app.add_middleware(
|
| 962 |
+
CORSMiddleware,
|
| 963 |
+
allow_origins=["*"],
|
| 964 |
+
allow_methods=["*"],
|
| 965 |
+
allow_headers=["*"],
|
| 966 |
+
)
|
| 967 |
+
```
|
| 968 |
+
|
| 969 |
+
---
|
| 970 |
+
|
| 971 |
+
## 13. OPENENV.YAML
|
| 972 |
+
|
| 973 |
+
The file at root level must validate against `openenv validate`:
|
| 974 |
+
|
| 975 |
+
```yaml
|
| 976 |
+
name: supportops-arena
|
| 977 |
+
version: 1.0.0
|
| 978 |
+
description: >
|
| 979 |
+
Enterprise IT incident triage RL environment. An AI agent acts as a
|
| 980 |
+
Level-1/2 IT support operator, triaging incidents under partial
|
| 981 |
+
observability with risk-weighted actions and an adaptive adversary.
|
| 982 |
+
author: SupportOps Arena Team
|
| 983 |
+
license: Apache-2.0
|
| 984 |
+
tags:
|
| 985 |
+
- openenv
|
| 986 |
+
- rl-environment
|
| 987 |
+
- enterprise
|
| 988 |
+
- it-support
|
| 989 |
+
- partial-observability
|
| 990 |
+
- multi-task
|
| 991 |
+
tasks:
|
| 992 |
+
- id: easy
|
| 993 |
+
name: Wi-Fi Connectivity Failure
|
| 994 |
+
description: Triage a single Wi-Fi failure ticket. 6 possible root causes.
|
| 995 |
+
max_steps: 10
|
| 996 |
+
score_range: [0.0, 1.0]
|
| 997 |
+
grader: programmatic
|
| 998 |
+
difficulty: easy
|
| 999 |
+
- id: medium
|
| 1000 |
+
name: VPN Access Failure
|
| 1001 |
+
description: Multi-cause VPN failure with one misleading log entry.
|
| 1002 |
+
max_steps: 16
|
| 1003 |
+
score_range: [0.0, 1.0]
|
| 1004 |
+
grader: programmatic+llm
|
| 1005 |
+
difficulty: medium
|
| 1006 |
+
- id: hard
|
| 1007 |
+
name: Cross-System Access Failure
|
| 1008 |
+
description: Two tickets share a hidden SSO infrastructure root cause.
|
| 1009 |
+
max_steps: 24
|
| 1010 |
+
score_range: [0.0, 1.0]
|
| 1011 |
+
grader: programmatic+llm+adversarial
|
| 1012 |
+
difficulty: hard
|
| 1013 |
+
observation_type: structured_json
|
| 1014 |
+
action_type: discrete_enum
|
| 1015 |
+
action_count: 15
|
| 1016 |
+
reward_type: dense_shaped
|
| 1017 |
+
reward_range: [-0.40, 1.00]
|
| 1018 |
+
grader: programmatic+llm+adversarial
|
| 1019 |
+
deployment:
|
| 1020 |
+
type: huggingface_spaces
|
| 1021 |
+
space_sdk: docker
|
| 1022 |
+
port: 7860
|
| 1023 |
+
docker: true
|
| 1024 |
+
baseline_agent: included
|
| 1025 |
+
api_server:
|
| 1026 |
+
type: fastapi
|
| 1027 |
+
base_url: /
|
| 1028 |
+
endpoints:
|
| 1029 |
+
reset: POST /reset
|
| 1030 |
+
step: POST /step
|
| 1031 |
+
state: GET /state
|
| 1032 |
+
health: GET /health
|
| 1033 |
+
```
|
| 1034 |
+
|
| 1035 |
+
---
|
| 1036 |
+
|
| 1037 |
+
## 14. DOCKERFILE & CONTAINERIZATION
|
| 1038 |
+
|
| 1039 |
+
### 14.1 `Dockerfile`
|
| 1040 |
+
|
| 1041 |
+
```dockerfile
|
| 1042 |
+
FROM python:3.11-slim
|
| 1043 |
+
|
| 1044 |
+
WORKDIR /app
|
| 1045 |
+
|
| 1046 |
+
# Install system deps
|
| 1047 |
+
RUN apt-get update && apt-get install -y \
|
| 1048 |
+
curl \
|
| 1049 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 1050 |
+
|
| 1051 |
+
# Copy and install Python deps first (layer caching)
|
| 1052 |
+
COPY requirements.txt .
|
| 1053 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 1054 |
+
|
| 1055 |
+
# Copy source
|
| 1056 |
+
COPY . .
|
| 1057 |
+
|
| 1058 |
+
# Create non-root user for security
|
| 1059 |
+
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
| 1060 |
+
USER appuser
|
| 1061 |
+
|
| 1062 |
+
# Expose port
|
| 1063 |
+
EXPOSE 7860
|
| 1064 |
+
|
| 1065 |
+
# Health check
|
| 1066 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
| 1067 |
+
CMD curl -f http://localhost:7860/health || exit 1
|
| 1068 |
+
|
| 1069 |
+
# Run
|
| 1070 |
+
CMD ["uvicorn", "app.server:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
|
| 1071 |
+
```
|
| 1072 |
+
|
| 1073 |
+
### 14.2 `requirements.txt`
|
| 1074 |
+
|
| 1075 |
+
```
|
| 1076 |
+
fastapi==0.110.0
|
| 1077 |
+
uvicorn[standard]==0.27.1
|
| 1078 |
+
pydantic==2.6.4
|
| 1079 |
+
openai==1.14.0
|
| 1080 |
+
pytest==8.1.0
|
| 1081 |
+
pytest-asyncio==0.23.5
|
| 1082 |
+
httpx==0.27.0
|
| 1083 |
+
pyyaml==6.0.1
|
| 1084 |
+
python-dotenv==1.0.1
|
| 1085 |
+
numpy==1.26.4
|
| 1086 |
+
```
|
| 1087 |
+
|
| 1088 |
+
### 14.3 Build & Run Validation
|
| 1089 |
+
|
| 1090 |
+
The container must:
|
| 1091 |
+
- Build cleanly: `docker build -t supportops-arena .`
|
| 1092 |
+
- Run cleanly: `docker run -p 7860:7860 supportops-arena`
|
| 1093 |
+
- Respond to: `curl http://localhost:7860/health` → `{"status": "ok"}`
|
| 1094 |
+
- Respond to: `curl -X POST http://localhost:7860/reset -H "Content-Type: application/json" -d '{"task_level": "easy"}'`
|
| 1095 |
+
|
| 1096 |
+
---
|
| 1097 |
+
|
| 1098 |
+
## 15. FRONTEND DASHBOARD UI
|
| 1099 |
+
|
| 1100 |
+
### 15.1 Overview
|
| 1101 |
+
|
| 1102 |
+
Build a stunning, interactive dashboard at `app/static/index.html`. This is served by FastAPI at `/`. It allows humans to:
|
| 1103 |
+
- Watch the environment in action
|
| 1104 |
+
- Step through episodes manually
|
| 1105 |
+
- View real-time reward signals
|
| 1106 |
+
- Run the baseline agent and watch it play
|
| 1107 |
+
- See scores across all 3 tasks
|
| 1108 |
+
|
| 1109 |
+
### 15.2 Design System
|
| 1110 |
+
|
| 1111 |
+
**Color Palette (STRICT — do not deviate):**
|
| 1112 |
+
|
| 1113 |
+
```css
|
| 1114 |
+
:root {
|
| 1115 |
+
/* Backgrounds */
|
| 1116 |
+
--bg-primary: #FFFFFF; /* Pure white */
|
| 1117 |
+
--bg-secondary: #F0F8FF; /* Alice blue (very light sky) */
|
| 1118 |
+
--bg-card: #FFFFFF; /* White cards */
|
| 1119 |
+
--bg-glass: rgba(255,255,255,0.8);/* Frosted glass */
|
| 1120 |
+
|
| 1121 |
+
/* Gradient (primary usage) */
|
| 1122 |
+
--gradient-main: linear-gradient(135deg, #FFFFFF 0%, #E0F4FF 40%, #B8E4FF 100%);
|
| 1123 |
+
--gradient-hero: linear-gradient(160deg, #FFFFFF 0%, #D6EEFF 50%, #87CEEB 100%);
|
| 1124 |
+
--gradient-card: linear-gradient(145deg, rgba(255,255,255,0.95), rgba(224,244,255,0.6));
|
| 1125 |
+
|
| 1126 |
+
/* Sky Blues */
|
| 1127 |
+
--sky-100: #E0F4FF;
|
| 1128 |
+
--sky-200: #B8E4FF;
|
| 1129 |
+
--sky-300: #87CEEB; /* Sky blue */
|
| 1130 |
+
--sky-400: #5BB8E8;
|
| 1131 |
+
--sky-500: #2196F3; /* Primary accent */
|
| 1132 |
+
--sky-600: #1976D2;
|
| 1133 |
+
--sky-700: #1565C0;
|
| 1134 |
+
|
| 1135 |
+
/* Text */
|
| 1136 |
+
--text-primary: #0D1B2A; /* Near-black, deep navy */
|
| 1137 |
+
--text-secondary: #3A5068; /* Muted navy */
|
| 1138 |
+
--text-muted: #7B9BB5; /* Light muted */
|
| 1139 |
+
--text-on-accent: #FFFFFF;
|
| 1140 |
+
|
| 1141 |
+
/* Status colors */
|
| 1142 |
+
--success: #10B981;
|
| 1143 |
+
--warning: #F59E0B;
|
| 1144 |
+
--danger: #EF4444;
|
| 1145 |
+
--info: #3B82F6;
|
| 1146 |
+
|
| 1147 |
+
/* Borders */
|
| 1148 |
+
--border-light: rgba(135, 206, 235, 0.3);
|
| 1149 |
+
--border-medium: rgba(91, 184, 232, 0.5);
|
| 1150 |
+
|
| 1151 |
+
/* Shadows */
|
| 1152 |
+
--shadow-sm: 0 2px 8px rgba(33, 150, 243, 0.08);
|
| 1153 |
+
--shadow-md: 0 4px 20px rgba(33, 150, 243, 0.12);
|
| 1154 |
+
--shadow-lg: 0 8px 40px rgba(33, 150, 243, 0.16);
|
| 1155 |
+
--shadow-glow: 0 0 30px rgba(135, 206, 235, 0.4);
|
| 1156 |
+
|
| 1157 |
+
/* Sizing */
|
| 1158 |
+
--radius-sm: 8px;
|
| 1159 |
+
--radius-md: 12px;
|
| 1160 |
+
--radius-lg: 20px;
|
| 1161 |
+
--radius-xl: 28px;
|
| 1162 |
+
|
| 1163 |
+
/* Transitions */
|
| 1164 |
+
--transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
| 1165 |
+
--transition-med: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 1166 |
+
--transition-slow: 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
| 1167 |
+
}
|
| 1168 |
+
```
|
| 1169 |
+
|
| 1170 |
+
**Typography:**
|
| 1171 |
+
|
| 1172 |
+
```css
|
| 1173 |
+
/* Import from Google Fonts */
|
| 1174 |
+
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=DM+Mono:wght@400;500&family=Sora:wght@600;700;800&display=swap');
|
| 1175 |
+
|
| 1176 |
+
body { font-family: 'DM Sans', sans-serif; }
|
| 1177 |
+
h1, h2, h3 { font-family: 'Sora', sans-serif; }
|
| 1178 |
+
code, .mono { font-family: 'DM Mono', monospace; }
|
| 1179 |
+
```
|
| 1180 |
+
|
| 1181 |
+
**Global Rules:**
|
| 1182 |
+
- Background is always the `--gradient-main` or pure white — NEVER dark
|
| 1183 |
+
- Cards use `--gradient-card` with `backdrop-filter: blur(12px)`
|
| 1184 |
+
- Borders are always light sky blue tinted — `--border-light`
|
| 1185 |
+
- All interactive elements have smooth hover transitions
|
| 1186 |
+
- Scrollbars are styled (thin, sky blue thumb)
|
| 1187 |
+
- No black backgrounds anywhere
|
| 1188 |
+
|
| 1189 |
+
### 15.3 Layout Structure
|
| 1190 |
+
|
| 1191 |
+
```
|
| 1192 |
+
┌─────────────────────────────────────────────────────────┐
|
| 1193 |
+
│ HEADER: Logo + "SupportOps Arena" + Status Badge │
|
| 1194 |
+
├──────────────────┬──────────────────────────────────────┤
|
| 1195 |
+
│ LEFT SIDEBAR │ MAIN CONTENT AREA │
|
| 1196 |
+
│ (280px fixed) │ │
|
| 1197 |
+
│ │ ┌──────────────────────────────┐ │
|
| 1198 |
+
│ Task Selector │ │ TICKET VIEWER │ │
|
| 1199 |
+
│ [easy/med/hard] │ │ Current ticket details │ │
|
| 1200 |
+
│ │ │ User context │ │
|
| 1201 |
+
│ Score Tracker │ │ Network/Auth status badges │ │
|
| 1202 |
+
│ Easy: [bar] │ └──────────────────────────────┘ │
|
| 1203 |
+
│ Med: [bar] │ │
|
| 1204 |
+
│ Hard: [bar] │ ┌──────────────────────────────┐ │
|
| 1205 |
+
│ │ │ ACTION PANEL │ │
|
| 1206 |
+
│ Episode Info │ │ 15 action buttons │ │
|
| 1207 |
+
│ Steps: 3/10 │ │ Rationale text input │ │
|
| 1208 |
+
│ Reward: +0.45 │ │ [Take Action] button │ │
|
| 1209 |
+
│ Status: Active │ └──────────────────────────────┘ │
|
| 1210 |
+
│ │ │
|
| 1211 |
+
│ [▶ Run Baseline]│ ┌──────────────────────────────┐ │
|
| 1212 |
+
│ [⟳ Reset] │ │ SYSTEM LOGS │ │
|
| 1213 |
+
│ │ │ Scrollable log viewer │ │
|
| 1214 |
+
│ │ │ Color-coded by level │ │
|
| 1215 |
+
└──────────────────┴──┴──────────────────────────────┘ │
|
| 1216 |
+
│ FOOTER: Reward History Graph (Chart.js sparkline) │
|
| 1217 |
+
└─────────────────────────────────────────────────────────┘
|
| 1218 |
+
```
|
| 1219 |
+
|
| 1220 |
+
### 15.4 Component Specifications
|
| 1221 |
+
|
| 1222 |
+
#### Header
|
| 1223 |
+
```css
|
| 1224 |
+
.header {
|
| 1225 |
+
background: rgba(255, 255, 255, 0.9);
|
| 1226 |
+
backdrop-filter: blur(20px);
|
| 1227 |
+
border-bottom: 1px solid var(--border-light);
|
| 1228 |
+
box-shadow: var(--shadow-sm);
|
| 1229 |
+
height: 64px;
|
| 1230 |
+
display: flex;
|
| 1231 |
+
align-items: center;
|
| 1232 |
+
padding: 0 24px;
|
| 1233 |
+
position: sticky;
|
| 1234 |
+
top: 0;
|
| 1235 |
+
z-index: 100;
|
| 1236 |
+
}
|
| 1237 |
+
```
|
| 1238 |
+
|
| 1239 |
+
Logo: SVG of a stylized checkmark shield in sky blue gradient.
|
| 1240 |
+
"SupportOps Arena" in Sora 600 weight, deep navy text.
|
| 1241 |
+
Status badge: pill showing "LIVE" with animated green dot.
|
| 1242 |
+
|
| 1243 |
+
#### Sidebar
|
| 1244 |
+
```css
|
| 1245 |
+
.sidebar {
|
| 1246 |
+
background: var(--gradient-card);
|
| 1247 |
+
backdrop-filter: blur(16px);
|
| 1248 |
+
border-right: 1px solid var(--border-light);
|
| 1249 |
+
box-shadow: var(--shadow-md);
|
| 1250 |
+
width: 280px;
|
| 1251 |
+
padding: 20px;
|
| 1252 |
+
overflow-y: auto;
|
| 1253 |
+
}
|
| 1254 |
+
```
|
| 1255 |
+
|
| 1256 |
+
Task selector: Three pill buttons (Easy / Medium / Hard). Active state has sky blue gradient fill + shadow glow.
|
| 1257 |
+
|
| 1258 |
+
Score bars: Custom CSS progress bars with gradient fill `linear-gradient(90deg, #87CEEB, #2196F3)`.
|
| 1259 |
+
|
| 1260 |
+
Episode info: Mini stat cards with white background, thin sky border, showing Steps/Reward/Status.
|
| 1261 |
+
|
| 1262 |
+
#### Ticket Viewer
|
| 1263 |
+
```css
|
| 1264 |
+
.ticket-card {
|
| 1265 |
+
background: white;
|
| 1266 |
+
border: 1px solid var(--border-light);
|
| 1267 |
+
border-radius: var(--radius-lg);
|
| 1268 |
+
box-shadow: var(--shadow-md);
|
| 1269 |
+
padding: 24px;
|
| 1270 |
+
margin-bottom: 16px;
|
| 1271 |
+
transition: var(--transition-med);
|
| 1272 |
+
}
|
| 1273 |
+
|
| 1274 |
+
.ticket-card:hover {
|
| 1275 |
+
box-shadow: var(--shadow-lg);
|
| 1276 |
+
transform: translateY(-2px);
|
| 1277 |
+
}
|
| 1278 |
+
```
|
| 1279 |
+
|
| 1280 |
+
Status badges (Network/Auth/VPN): Pill badges with color-coded background:
|
| 1281 |
+
- HEALTHY/OK/CONNECTED: `#D1FAE5` bg, `#065F46` text
|
| 1282 |
+
- DEGRADED/EXPIRED: `#FEF3C7` bg, `#92400E` text
|
| 1283 |
+
- DOWN/FAILED/LOCKED: `#FEE2E2` bg, `#991B1B` text
|
| 1284 |
+
- UNKNOWN/N/A: `#F1F5F9` bg, `#475569` text
|
| 1285 |
+
|
| 1286 |
+
#### Action Panel
|
| 1287 |
+
|
| 1288 |
+
15 action buttons in a 3×5 grid. Each button:
|
| 1289 |
+
```css
|
| 1290 |
+
.action-btn {
|
| 1291 |
+
background: white;
|
| 1292 |
+
border: 1px solid var(--border-light);
|
| 1293 |
+
border-radius: var(--radius-md);
|
| 1294 |
+
padding: 10px 14px;
|
| 1295 |
+
cursor: pointer;
|
| 1296 |
+
font-family: 'DM Mono', monospace;
|
| 1297 |
+
font-size: 12px;
|
| 1298 |
+
font-weight: 500;
|
| 1299 |
+
color: var(--text-secondary);
|
| 1300 |
+
transition: var(--transition-fast);
|
| 1301 |
+
text-align: left;
|
| 1302 |
+
}
|
| 1303 |
+
|
| 1304 |
+
.action-btn:hover {
|
| 1305 |
+
background: var(--gradient-card);
|
| 1306 |
+
border-color: var(--sky-300);
|
| 1307 |
+
color: var(--sky-600);
|
| 1308 |
+
box-shadow: var(--shadow-sm);
|
| 1309 |
+
transform: translateY(-1px);
|
| 1310 |
+
}
|
| 1311 |
+
|
| 1312 |
+
/* Risk level indicators */
|
| 1313 |
+
.action-btn[data-risk="HIGH"] { border-left: 3px solid var(--danger); }
|
| 1314 |
+
.action-btn[data-risk="MED"] { border-left: 3px solid var(--warning); }
|
| 1315 |
+
.action-btn[data-risk="LOW"] { border-left: 3px solid var(--success); }
|
| 1316 |
+
.action-btn[data-risk="NONE"] { border-left: 3px solid var(--sky-300); }
|
| 1317 |
+
```
|
| 1318 |
+
|
| 1319 |
+
Rationale textarea: Clean white, sky blue focus ring, DM Mono font.
|
| 1320 |
+
|
| 1321 |
+
Take Action button:
|
| 1322 |
+
```css
|
| 1323 |
+
.btn-primary {
|
| 1324 |
+
background: linear-gradient(135deg, #5BB8E8, #2196F3);
|
| 1325 |
+
color: white;
|
| 1326 |
+
border: none;
|
| 1327 |
+
border-radius: var(--radius-md);
|
| 1328 |
+
padding: 12px 28px;
|
| 1329 |
+
font-family: 'Sora', sans-serif;
|
| 1330 |
+
font-weight: 600;
|
| 1331 |
+
font-size: 14px;
|
| 1332 |
+
cursor: pointer;
|
| 1333 |
+
box-shadow: 0 4px 15px rgba(33, 150, 243, 0.3);
|
| 1334 |
+
transition: var(--transition-fast);
|
| 1335 |
+
}
|
| 1336 |
+
|
| 1337 |
+
.btn-primary:hover {
|
| 1338 |
+
transform: translateY(-2px);
|
| 1339 |
+
box-shadow: 0 6px 20px rgba(33, 150, 243, 0.4);
|
| 1340 |
+
}
|
| 1341 |
+
|
| 1342 |
+
.btn-primary:active {
|
| 1343 |
+
transform: translateY(0);
|
| 1344 |
+
}
|
| 1345 |
+
```
|
| 1346 |
+
|
| 1347 |
+
#### System Logs Viewer
|
| 1348 |
+
|
| 1349 |
+
```css
|
| 1350 |
+
.log-viewer {
|
| 1351 |
+
background: #FAFCFF;
|
| 1352 |
+
border: 1px solid var(--border-light);
|
| 1353 |
+
border-radius: var(--radius-lg);
|
| 1354 |
+
height: 240px;
|
| 1355 |
+
overflow-y: auto;
|
| 1356 |
+
padding: 16px;
|
| 1357 |
+
font-family: 'DM Mono', monospace;
|
| 1358 |
+
font-size: 12px;
|
| 1359 |
+
}
|
| 1360 |
+
|
| 1361 |
+
/* Custom scrollbar */
|
| 1362 |
+
.log-viewer::-webkit-scrollbar { width: 4px; }
|
| 1363 |
+
.log-viewer::-webkit-scrollbar-track { background: transparent; }
|
| 1364 |
+
.log-viewer::-webkit-scrollbar-thumb { background: var(--sky-300); border-radius: 2px; }
|
| 1365 |
+
|
| 1366 |
+
.log-entry { padding: 4px 0; border-bottom: 1px solid var(--bg-secondary); }
|
| 1367 |
+
.log-entry.ERROR { color: var(--danger); }
|
| 1368 |
+
.log-entry.WARN { color: var(--warning); }
|
| 1369 |
+
.log-entry.INFO { color: var(--text-secondary); }
|
| 1370 |
+
.log-entry.DEBUG { color: var(--text-muted); }
|
| 1371 |
+
```
|
| 1372 |
+
|
| 1373 |
+
#### Reward History Chart
|
| 1374 |
+
|
| 1375 |
+
Use Chart.js (CDN). Sparkline area chart at the bottom:
|
| 1376 |
+
- Line color: `#2196F3`
|
| 1377 |
+
- Fill: gradient from `rgba(33, 150, 243, 0.15)` to transparent
|
| 1378 |
+
- Grid lines: very faint sky blue
|
| 1379 |
+
- No x-axis labels (steps only)
|
| 1380 |
+
- White background card with shadow
|
| 1381 |
+
|
| 1382 |
+
#### Animations
|
| 1383 |
+
|
| 1384 |
+
Entry animations (use CSS keyframes):
|
| 1385 |
+
```css
|
| 1386 |
+
@keyframes slideIn {
|
| 1387 |
+
from { opacity: 0; transform: translateY(12px); }
|
| 1388 |
+
to { opacity: 1; transform: translateY(0); }
|
| 1389 |
+
}
|
| 1390 |
+
|
| 1391 |
+
@keyframes fadeIn {
|
| 1392 |
+
from { opacity: 0; }
|
| 1393 |
+
to { opacity: 1; }
|
| 1394 |
+
}
|
| 1395 |
+
|
| 1396 |
+
@keyframes pulse-glow {
|
| 1397 |
+
0%, 100% { box-shadow: 0 0 0 0 rgba(33, 150, 243, 0.3); }
|
| 1398 |
+
50% { box-shadow: 0 0 0 8px rgba(33, 150, 243, 0); }
|
| 1399 |
+
}
|
| 1400 |
+
|
| 1401 |
+
.ticket-card { animation: slideIn 0.3s ease forwards; }
|
| 1402 |
+
.log-entry { animation: fadeIn 0.2s ease forwards; }
|
| 1403 |
+
.live-badge { animation: pulse-glow 2s infinite; }
|
| 1404 |
+
```
|
| 1405 |
+
|
| 1406 |
+
Reward feedback flash: When a positive reward comes in, flash the reward display green for 500ms. Negative → flash red.
|
| 1407 |
+
|
| 1408 |
+
#### Baseline Run Mode
|
| 1409 |
+
|
| 1410 |
+
When user clicks "▶ Run Baseline":
|
| 1411 |
+
- Show a progress overlay (semi-transparent white with spinning sky-blue circle)
|
| 1412 |
+
- Stream step-by-step actions as log entries in real time (SSE or polling)
|
| 1413 |
+
- Show final scores in animated number counters that count up from 0
|
| 1414 |
+
|
| 1415 |
+
### 15.5 Responsive Behavior
|
| 1416 |
+
|
| 1417 |
+
- Desktop (>1200px): Full 3-column layout as shown
|
| 1418 |
+
- Tablet (768–1200px): Sidebar collapses to icon mode
|
| 1419 |
+
- Mobile (<768px): Single column, sidebar becomes bottom drawer
|
| 1420 |
+
|
| 1421 |
+
### 15.6 JavaScript Architecture
|
| 1422 |
+
|
| 1423 |
+
Use vanilla JS with a clean module pattern:
|
| 1424 |
+
|
| 1425 |
+
```javascript
|
| 1426 |
+
const API_BASE = window.location.origin;
|
| 1427 |
+
|
| 1428 |
+
const State = {
|
| 1429 |
+
sessionId: null,
|
| 1430 |
+
taskLevel: 'easy',
|
| 1431 |
+
currentObs: null,
|
| 1432 |
+
rewardHistory: [],
|
| 1433 |
+
episodeDone: false,
|
| 1434 |
+
};
|
| 1435 |
+
|
| 1436 |
+
const API = {
|
| 1437 |
+
async reset(taskLevel) { ... },
|
| 1438 |
+
async step(actionName, rationale) { ... },
|
| 1439 |
+
async getScores() { ... },
|
| 1440 |
+
async runBaseline() { ... },
|
| 1441 |
+
};
|
| 1442 |
+
|
| 1443 |
+
const UI = {
|
| 1444 |
+
renderObservation(obs) { ... },
|
| 1445 |
+
renderLogs(logs) { ... },
|
| 1446 |
+
renderActionHistory(history) { ... },
|
| 1447 |
+
updateScores(scores) { ... },
|
| 1448 |
+
flashReward(value) { ... },
|
| 1449 |
+
updateChart(rewardHistory) { ... },
|
| 1450 |
+
};
|
| 1451 |
+
```
|
| 1452 |
+
|
| 1453 |
+
---
|
| 1454 |
+
|
| 1455 |
+
## 16. INFERENCE.PY (BASELINE INFERENCE SCRIPT)
|
| 1456 |
+
|
| 1457 |
+
This file MUST be at the root level. It is named exactly `inference.py`. The hackathon automated system will look for it here.
|
| 1458 |
+
|
| 1459 |
+
### 16.1 Required stdout log format
|
| 1460 |
+
|
| 1461 |
+
Every log line must follow this EXACT format. Any deviation causes evaluation failure:
|
| 1462 |
+
|
| 1463 |
+
```
|
| 1464 |
+
[START] {"episode_id": "...", "task_level": "easy", "timestamp": "..."}
|
| 1465 |
+
[STEP] {"step": 1, "action": "inspect_logs", "rationale": "...", "reward": 0.10, "done": false}
|
| 1466 |
+
[STEP] {"step": 2, "action": "check_authentication", "rationale": "...", "reward": 0.20, "done": false}
|
| 1467 |
+
...
|
| 1468 |
+
[END] {"episode_id": "...", "task_level": "easy", "final_score": 0.72, "steps_used": 6}
|
| 1469 |
+
```
|
| 1470 |
+
|
| 1471 |
+
### 16.2 `inference.py` Full Specification
|
| 1472 |
+
|
| 1473 |
+
```python
|
| 1474 |
+
#!/usr/bin/env python3
|
| 1475 |
+
"""
|
| 1476 |
+
SupportOps Arena — Baseline Inference Script
|
| 1477 |
+
Runs baseline agent against all 3 task levels.
|
| 1478 |
+
Produces [START]/[STEP]/[END] logs to stdout.
|
| 1479 |
+
Uses OpenAI-compatible API (API_BASE_URL, MODEL_NAME, OPENAI_API_KEY from env).
|
| 1480 |
+
Must complete in < 20 minutes total.
|
| 1481 |
+
Must run on vcpu=2, memory=8gb.
|
| 1482 |
+
"""
|
| 1483 |
+
|
| 1484 |
+
import asyncio
|
| 1485 |
+
import json
|
| 1486 |
+
import os
|
| 1487 |
+
import sys
|
| 1488 |
+
import logging
|
| 1489 |
+
from datetime import datetime
|
| 1490 |
+
|
| 1491 |
+
# Must use OpenAI client
|
| 1492 |
+
from openai import AsyncOpenAI
|
| 1493 |
+
|
| 1494 |
+
# Read from environment
|
| 1495 |
+
API_BASE_URL = os.environ.get("API_BASE_URL", "http://localhost:7860")
|
| 1496 |
+
MODEL_NAME = os.environ.get("MODEL_NAME", "gpt-4o-mini")
|
| 1497 |
+
HF_TOKEN = os.environ.get("HF_TOKEN", "")
|
| 1498 |
+
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
| 1499 |
+
|
| 1500 |
+
# The inference script can use either:
|
| 1501 |
+
# 1. The local env directly (no HTTP), OR
|
| 1502 |
+
# 2. HTTP calls to the running server
|
| 1503 |
+
# Prefer option 1 for speed (import env directly)
|
| 1504 |
+
|
| 1505 |
+
async def run_task(task_level: str, env, agent) -> dict:
|
| 1506 |
+
"""Run one task level. Returns score dict."""
|
| 1507 |
+
obs = await env.reset(task_level=task_level)
|
| 1508 |
+
episode_id = obs.ticket_id # Use as episode ID
|
| 1509 |
+
|
| 1510 |
+
# [START] log
|
| 1511 |
+
print(json.dumps({"type": "[START]", "episode_id": episode_id,
|
| 1512 |
+
"task_level": task_level,
|
| 1513 |
+
"timestamp": datetime.utcnow().isoformat()}), flush=True)
|
| 1514 |
+
|
| 1515 |
+
total_reward = 0.0
|
| 1516 |
+
step = 0
|
| 1517 |
+
done = False
|
| 1518 |
+
|
| 1519 |
+
while not done:
|
| 1520 |
+
action = await agent.select_action(obs)
|
| 1521 |
+
obs, reward, done, info = await env.step(action)
|
| 1522 |
+
total_reward += reward
|
| 1523 |
+
step += 1
|
| 1524 |
+
|
| 1525 |
+
# [STEP] log — REQUIRED FORMAT
|
| 1526 |
+
print(json.dumps({
|
| 1527 |
+
"type": "[STEP]",
|
| 1528 |
+
"step": step,
|
| 1529 |
+
"action": action.name,
|
| 1530 |
+
"rationale": action.rationale or "",
|
| 1531 |
+
"reward": round(reward, 4),
|
| 1532 |
+
"cumulative_reward": round(total_reward, 4),
|
| 1533 |
+
"done": done
|
| 1534 |
+
}), flush=True)
|
| 1535 |
+
|
| 1536 |
+
final_score = info.get("episode_score", 0.0)
|
| 1537 |
+
|
| 1538 |
+
# [END] log
|
| 1539 |
+
print(json.dumps({
|
| 1540 |
+
"type": "[END]",
|
| 1541 |
+
"episode_id": episode_id,
|
| 1542 |
+
"task_level": task_level,
|
| 1543 |
+
"final_score": round(final_score, 4),
|
| 1544 |
+
"steps_used": step,
|
| 1545 |
+
"correct": info.get("correct", False)
|
| 1546 |
+
}), flush=True)
|
| 1547 |
+
|
| 1548 |
+
return {"task_level": task_level, "score": final_score}
|
| 1549 |
+
|
| 1550 |
+
async def main():
|
| 1551 |
+
from env.environment import SupportOpsArena
|
| 1552 |
+
from baseline.baseline_agent import BaselineAgent
|
| 1553 |
+
|
| 1554 |
+
env = SupportOpsArena()
|
| 1555 |
+
agent = BaselineAgent()
|
| 1556 |
+
|
| 1557 |
+
results = []
|
| 1558 |
+
for task_level in ["easy", "medium", "hard"]:
|
| 1559 |
+
result = await run_task(task_level, env, agent)
|
| 1560 |
+
results.append(result)
|
| 1561 |
+
|
| 1562 |
+
# Final summary
|
| 1563 |
+
overall = sum(r["score"] * w for r, w in
|
| 1564 |
+
zip(results, [0.2, 0.3, 0.5]))
|
| 1565 |
+
print(json.dumps({
|
| 1566 |
+
"type": "[SUMMARY]",
|
| 1567 |
+
"easy_score": results[0]["score"],
|
| 1568 |
+
"medium_score": results[1]["score"],
|
| 1569 |
+
"hard_score": results[2]["score"],
|
| 1570 |
+
"overall_score": round(overall, 4)
|
| 1571 |
+
}), flush=True)
|
| 1572 |
+
|
| 1573 |
+
if __name__ == "__main__":
|
| 1574 |
+
asyncio.run(main())
|
| 1575 |
+
```
|
| 1576 |
+
|
| 1577 |
+
---
|
| 1578 |
+
|
| 1579 |
+
## 17. TESTING SUITE
|
| 1580 |
+
|
| 1581 |
+
### 17.1 `tests/conftest.py`
|
| 1582 |
+
|
| 1583 |
+
```python
|
| 1584 |
+
import pytest
|
| 1585 |
+
import pytest_asyncio
|
| 1586 |
+
from env.environment import SupportOpsArena
|
| 1587 |
+
|
| 1588 |
+
@pytest_asyncio.fixture
|
| 1589 |
+
async def env():
|
| 1590 |
+
return SupportOpsArena()
|
| 1591 |
+
|
| 1592 |
+
@pytest_asyncio.fixture
|
| 1593 |
+
async def env_easy(env):
|
| 1594 |
+
await env.reset(task_level="easy", seed=42)
|
| 1595 |
+
return env
|
| 1596 |
+
|
| 1597 |
+
@pytest_asyncio.fixture
|
| 1598 |
+
async def env_medium(env):
|
| 1599 |
+
await env.reset(task_level="medium", seed=42)
|
| 1600 |
+
return env
|
| 1601 |
+
|
| 1602 |
+
@pytest_asyncio.fixture
|
| 1603 |
+
async def env_hard(env):
|
| 1604 |
+
await env.reset(task_level="hard", seed=42)
|
| 1605 |
+
return env
|
| 1606 |
+
```
|
| 1607 |
+
|
| 1608 |
+
### 17.2 `tests/test_env.py` — Required Tests
|
| 1609 |
+
|
| 1610 |
+
```python
|
| 1611 |
+
# Must all pass for submission
|
| 1612 |
+
|
| 1613 |
+
async def test_reset_returns_valid_observation(env):
|
| 1614 |
+
obs = await env.reset("easy")
|
| 1615 |
+
assert obs.ticket_id is not None
|
| 1616 |
+
assert obs.step_count == 0
|
| 1617 |
+
assert obs.task_level == "easy"
|
| 1618 |
+
assert obs.escalation_allowed == False
|
| 1619 |
+
assert len(obs.action_history) == 0
|
| 1620 |
+
|
| 1621 |
+
async def test_step_returns_valid_types(env_easy):
|
| 1622 |
+
from env.actions import Action, ActionType
|
| 1623 |
+
action = Action(name=ActionType.INSPECT_LOGS)
|
| 1624 |
+
result = await env_easy.step(action)
|
| 1625 |
+
assert 0.0 <= result.reward # may be positive
|
| 1626 |
+
assert isinstance(result.done, bool)
|
| 1627 |
+
assert result.observation.step_count == 1
|
| 1628 |
+
|
| 1629 |
+
async def test_reward_in_valid_range(env_easy):
|
| 1630 |
+
from env.actions import Action, ActionType
|
| 1631 |
+
for _ in range(5):
|
| 1632 |
+
action = Action(name=ActionType.INSPECT_LOGS)
|
| 1633 |
+
result = await env_easy.step(action)
|
| 1634 |
+
assert -1.0 <= result.reward <= 2.0
|
| 1635 |
+
|
| 1636 |
+
async def test_done_on_resolve(env_easy):
|
| 1637 |
+
from env.actions import Action, ActionType
|
| 1638 |
+
result = await env_easy.step(Action(name=ActionType.RESOLVE_TICKET))
|
| 1639 |
+
assert result.done == True
|
| 1640 |
+
|
| 1641 |
+
async def test_state_contains_hidden_info(env_easy):
|
| 1642 |
+
state = await env_easy.state()
|
| 1643 |
+
assert state.hidden.root_cause is not None
|
| 1644 |
+
assert state.hidden.correct_remediation is not None
|
| 1645 |
+
|
| 1646 |
+
async def test_escalation_not_allowed_early(env_easy):
|
| 1647 |
+
from env.actions import Action, ActionType
|
| 1648 |
+
result = await env_easy.step(Action(name=ActionType.ESCALATE_TICKET))
|
| 1649 |
+
# Should NOT terminate (not enough diagnostic steps)
|
| 1650 |
+
assert result.done == False
|
| 1651 |
+
assert result.reward <= 0 # Penalized
|
| 1652 |
+
|
| 1653 |
+
async def test_all_task_levels_reset(env):
|
| 1654 |
+
for level in ["easy", "medium", "hard"]:
|
| 1655 |
+
obs = await env.reset(task_level=level)
|
| 1656 |
+
assert obs.task_level == level
|
| 1657 |
+
|
| 1658 |
+
async def test_redundant_action_penalty(env_easy):
|
| 1659 |
+
from env.actions import Action, ActionType
|
| 1660 |
+
action = Action(name=ActionType.INSPECT_LOGS)
|
| 1661 |
+
r1 = await env_easy.step(action)
|
| 1662 |
+
r2 = await env_easy.step(action)
|
| 1663 |
+
assert r2.reward < r1.reward # Redundant action penalized
|
| 1664 |
+
|
| 1665 |
+
async def test_step_count_increments(env_easy):
|
| 1666 |
+
from env.actions import Action, ActionType
|
| 1667 |
+
for i in range(3):
|
| 1668 |
+
action = Action(name=ActionType.INSPECT_LOGS)
|
| 1669 |
+
result = await env_easy.step(action)
|
| 1670 |
+
assert result.observation.step_count == i + 1
|
| 1671 |
+
```
|
| 1672 |
+
|
| 1673 |
+
### 17.3 `tests/test_graders.py`
|
| 1674 |
+
|
| 1675 |
+
```python
|
| 1676 |
+
async def test_programmatic_grader_returns_valid_score(env_easy):
|
| 1677 |
+
from graders.programmatic import ProgrammaticGrader
|
| 1678 |
+
state = await env_easy.state()
|
| 1679 |
+
grader = ProgrammaticGrader()
|
| 1680 |
+
result = await grader.grade(state)
|
| 1681 |
+
assert 0.0 <= result.score <= 1.0
|
| 1682 |
+
|
| 1683 |
+
async def test_grade_result_has_all_fields(env_easy):
|
| 1684 |
+
from graders.programmatic import ProgrammaticGrader
|
| 1685 |
+
state = await env_easy.state()
|
| 1686 |
+
grader = ProgrammaticGrader()
|
| 1687 |
+
result = await grader.grade(state)
|
| 1688 |
+
assert hasattr(result, 'root_cause_identified')
|
| 1689 |
+
assert hasattr(result, 'action_sequence_safe')
|
| 1690 |
+
assert hasattr(result, 'step_budget_respected')
|
| 1691 |
+
```
|
| 1692 |
+
|
| 1693 |
+
### 17.4 Run Tests Command
|
| 1694 |
+
|
| 1695 |
+
```bash
|
| 1696 |
+
pytest tests/ -v --asyncio-mode=auto
|
| 1697 |
+
```
|
| 1698 |
+
|
| 1699 |
+
All tests must pass before submission.
|
| 1700 |
+
|
| 1701 |
+
---
|
| 1702 |
+
|
| 1703 |
+
## 18. README.MD REQUIREMENTS
|
| 1704 |
+
|
| 1705 |
+
The README must contain ALL of the following sections in order:
|
| 1706 |
+
|
| 1707 |
+
### Required Sections
|
| 1708 |
+
|
| 1709 |
+
1. **Project Title & Banner** — ASCII art or SVG badge of "SupportOps Arena"
|
| 1710 |
+
2. **Overview** — 3-4 sentences on what this environment does
|
| 1711 |
+
3. **Motivation** — Why IT support triage? What gap does this fill?
|
| 1712 |
+
4. **Task Descriptions**
|
| 1713 |
+
- Task 1 (Easy): Description, step budget, root causes, grader type
|
| 1714 |
+
- Task 2 (Medium): Description, step budget, challenge, grader type
|
| 1715 |
+
- Task 3 (Hard): Description, step budget, novel mechanics, grader type
|
| 1716 |
+
5. **Observation Space** — Table of all observation fields with types
|
| 1717 |
+
6. **Action Space** — Table of all 15 actions with risk/info yield
|
| 1718 |
+
7. **Reward Function** — Full reward table (positive + negative)
|
| 1719 |
+
8. **Setup Instructions**
|
| 1720 |
+
```bash
|
| 1721 |
+
git clone <repo>
|
| 1722 |
+
cd supportops-arena
|
| 1723 |
+
cp .env.example .env
|
| 1724 |
+
# Edit .env with your API keys
|
| 1725 |
+
docker build -t supportops-arena .
|
| 1726 |
+
docker run -p 7860:7860 --env-file .env supportops-arena
|
| 1727 |
+
```
|
| 1728 |
+
9. **Running Without Docker**
|
| 1729 |
+
```bash
|
| 1730 |
+
pip install -r requirements.txt
|
| 1731 |
+
uvicorn app.server:app --host 0.0.0.0 --port 7860
|
| 1732 |
+
```
|
| 1733 |
+
10. **Running Inference Script**
|
| 1734 |
+
```bash
|
| 1735 |
+
python inference.py
|
| 1736 |
+
```
|
| 1737 |
+
11. **Baseline Scores** — Table of expected baseline agent scores per task
|
| 1738 |
+
12. **Novel Mechanics** — Adaptive adversary, risk-weighted actions, escalation, cross-ticket memory
|
| 1739 |
+
13. **Environment Variables** — Table of all env vars with descriptions
|
| 1740 |
+
14. **API Reference** — Table of all HTTP endpoints
|
| 1741 |
+
15. **Testing** — How to run tests
|
| 1742 |
+
16. **Evaluation** — Scoring methodology
|
| 1743 |
+
|
| 1744 |
+
---
|
| 1745 |
+
|
| 1746 |
+
## 19. HUGGING FACE SPACES DEPLOYMENT
|
| 1747 |
+
|
| 1748 |
+
### 19.1 Space Configuration
|
| 1749 |
+
|
| 1750 |
+
Add `README.md` front matter at the very top:
|
| 1751 |
+
|
| 1752 |
+
```yaml
|
| 1753 |
+
---
|
| 1754 |
+
title: SupportOps Arena
|
| 1755 |
+
emoji: 🎯
|
| 1756 |
+
colorFrom: blue
|
| 1757 |
+
colorTo: sky
|
| 1758 |
+
sdk: docker
|
| 1759 |
+
app_port: 7860
|
| 1760 |
+
tags:
|
| 1761 |
+
- openenv
|
| 1762 |
+
- reinforcement-learning
|
| 1763 |
+
- it-support
|
| 1764 |
+
- environment
|
| 1765 |
+
- benchmark
|
| 1766 |
+
pinned: false
|
| 1767 |
+
---
|
| 1768 |
+
```
|
| 1769 |
+
|
| 1770 |
+
### 19.2 Space Requirements
|
| 1771 |
+
|
| 1772 |
+
- Must respond to `GET /health` with 200 OK
|
| 1773 |
+
- Must respond to `POST /reset` with valid EnvObservation JSON
|
| 1774 |
+
- Must have the UI accessible at `/`
|
| 1775 |
+
- Port must be 7860
|
| 1776 |
+
- Must start within 60 seconds of container boot
|
| 1777 |
+
|
| 1778 |
+
### 19.3 Secrets in HF Spaces
|
| 1779 |
+
|
| 1780 |
+
Set these in Space Settings → Repository Secrets:
|
| 1781 |
+
- `OPENAI_API_KEY`
|
| 1782 |
+
- `API_BASE_URL`
|
| 1783 |
+
- `MODEL_NAME`
|
| 1784 |
+
|
| 1785 |
+
---
|
| 1786 |
+
|
| 1787 |
+
## 20. PRE-SUBMISSION CHECKLIST COMPLIANCE
|
| 1788 |
+
|
| 1789 |
+
This section maps every checklist item to the implementation requirement:
|
| 1790 |
+
|
| 1791 |
+
| Checklist Item | Implementation Requirement | How to Verify |
|
| 1792 |
+
|----------------|--------------------------|---------------|
|
| 1793 |
+
| HF Space deploys | Dockerfile + port 7860 + health endpoint | `curl https://<space>/health` → 200 |
|
| 1794 |
+
| OpenEnv spec compliance | openenv.yaml + typed models + step/reset/state | `openenv validate openenv.yaml` |
|
| 1795 |
+
| Dockerfile builds | Single-stage Python 3.11 Dockerfile | `docker build . && docker run -p 7860:7860 .` |
|
| 1796 |
+
| Baseline reproduces | `inference.py` at root level | `python inference.py` → stdout logs |
|
| 1797 |
+
| 3+ tasks with graders | Easy/Medium/Hard in openenv.yaml + graders/ | `GET /tasks` → 3 tasks |
|
| 1798 |
+
| API_BASE_URL env var | Read in server.py + inference.py | `echo $API_BASE_URL` |
|
| 1799 |
+
| MODEL_NAME env var | Read in inference.py | `echo $MODEL_NAME` |
|
| 1800 |
+
| HF_TOKEN env var | Read in inference.py | `echo $HF_TOKEN` |
|
| 1801 |
+
| inference.py named correctly | File at root: `./inference.py` | `ls inference.py` |
|
| 1802 |
+
| OpenAI client used | `from openai import AsyncOpenAI` in inference.py | Code review |
|
| 1803 |
+
| [START]/[STEP]/[END] format | JSON with type field on every stdout line | Run inference.py and check |
|
| 1804 |
+
| Graders return 0.0-1.0 | `score: float = Field(ge=0.0, le=1.0)` | Test suite |
|
| 1805 |
+
| Runtime < 20 min | Baseline agent is scripted (fast) | `time python inference.py` |
|
| 1806 |
+
| vcpu=2, memory=8gb compatible | No GPU code, async only, no heavy ML | Docker run with `--cpus=2 --memory=8g` |
|
| 1807 |
+
|
| 1808 |
+
---
|
| 1809 |
+
|
| 1810 |
+
## 21. DESIGN SYSTEM & UI RULES
|
| 1811 |
+
|
| 1812 |
+
### 21.1 Absolute UI Rules
|
| 1813 |
+
|
| 1814 |
+
1. **Background is ALWAYS light** — pure white or light sky blue gradient. No exceptions.
|
| 1815 |
+
2. **Primary gradient** = `linear-gradient(135deg, #FFFFFF 0%, #E0F4FF 40%, #B8E4FF 100%)`
|
| 1816 |
+
3. **All cards have glass morphism** = `backdrop-filter: blur(12px)` + semi-transparent white bg
|
| 1817 |
+
4. **Typography hierarchy**: Sora for headings, DM Sans for body, DM Mono for code/logs
|
| 1818 |
+
5. **Sky blue is the only accent color**. No purples, no oranges, no reds (except error states)
|
| 1819 |
+
6. **All interactive elements have hover + active states**
|
| 1820 |
+
7. **Reward is color-coded**: green for positive, red for negative, with CSS transitions
|
| 1821 |
+
8. **Scrollbars are styled** — thin (4px), sky blue thumb
|
| 1822 |
+
9. **Loading states** use sky-blue spinning rings, never gray spinners
|
| 1823 |
+
10. **Error messages** appear in soft red pill badges, never raw text
|
| 1824 |
+
|
| 1825 |
+
### 21.2 Animation Principles
|
| 1826 |
+
|
| 1827 |
+
- Entry animations: 0.3s ease slide-up
|
| 1828 |
+
- Hover transitions: 0.15s cubic-bezier(0.4, 0, 0.2, 1)
|
| 1829 |
+
- Reward flash: 0.5s ease pulse (green or red glow on reward display)
|
| 1830 |
+
- Baseline run progress: indeterminate sky-blue progress bar
|
| 1831 |
+
- New log entries: 0.2s fade in from slightly below
|
| 1832 |
+
|
| 1833 |
+
---
|
| 1834 |
+
|
| 1835 |
+
## 22. COPILOT CLI PROMPT STRATEGY
|
| 1836 |
+
|
| 1837 |
+
### 22.1 How to Use This PRD with GitHub Copilot CLI
|
| 1838 |
+
|
| 1839 |
+
Open your terminal in the project root. Use this master prompt to kick off:
|
| 1840 |
+
|
| 1841 |
+
```bash
|
| 1842 |
+
gh copilot suggest "Build the SupportOps Arena OpenEnv environment following PRD.md exactly.
|
| 1843 |
+
Start with: env/state.py (all Pydantic v2 models), then env/actions.py (ActionType enum + metadata),
|
| 1844 |
+
then env/rewards.py (RewardCalculator), then env/transitions.py (StateMachine),
|
| 1845 |
+
then env/scenarios.py (TicketGenerator), then env/environment.py (SupportOpsArena main class).
|
| 1846 |
+
Use Python 3.11+, Pydantic v2, async everywhere. Follow every rule in PRD.md."
|
| 1847 |
+
```
|
| 1848 |
+
|
| 1849 |
+
### 22.2 File-by-File Prompt Sequence
|
| 1850 |
+
|
| 1851 |
+
Use these prompts in order. Complete each file before moving to the next:
|
| 1852 |
+
|
| 1853 |
+
**Phase 1: Core Models**
|
| 1854 |
+
```
|
| 1855 |
+
Build env/state.py for SupportOps Arena. Include: NetworkStatus, VPNStatus, AuthStatus,
|
| 1856 |
+
ServiceHealth, TaskLevel enums; UserContext, LogEntry, ActionRecord, EnvObservation,
|
| 1857 |
+
HiddenState, EnvState, StepResult Pydantic v2 models. EnvObservation must NOT contain
|
| 1858 |
+
HiddenState fields. All enums use str,Enum. Use field_validator and model_validator where needed.
|
| 1859 |
+
```
|
| 1860 |
+
|
| 1861 |
+
**Phase 2: Actions**
|
| 1862 |
+
```
|
| 1863 |
+
Build env/actions.py. Create ActionType enum with exactly 15 values. Create ActionMetadata
|
| 1864 |
+
dataclass with risk_level, info_yield, step_cost, requires_prior_diagnostic,
|
| 1865 |
+
can_disrupt_other_users, is_terminal. Create ACTION_METADATA dict mapping every ActionType
|
| 1866 |
+
to its metadata. Create Action Pydantic v2 model with name:ActionType and rationale:str|None.
|
| 1867 |
+
```
|
| 1868 |
+
|
| 1869 |
+
**Phase 3: Rewards**
|
| 1870 |
+
```
|
| 1871 |
+
Build env/rewards.py. Create REWARD_EVENTS dict with all 15 reward events (values from PRD).
|
| 1872 |
+
Create MAX_POSSIBLE_REWARD dict for easy/medium/hard. Create MIN_STEPS_FOR_OPTIMAL dict.
|
| 1873 |
+
Create RewardCalculator class with async calculate(events, state, action) method that returns
|
| 1874 |
+
(total_reward: float, breakdown: dict). Include episode score normalization with clamp to [0,1].
|
| 1875 |
+
```
|
| 1876 |
+
|
| 1877 |
+
**Phase 4: Scenarios**
|
| 1878 |
+
```
|
| 1879 |
+
Build env/scenarios.py. Create TASK_EASY_ROOT_CAUSES list with 6 root cause dicts.
|
| 1880 |
+
Create TASK_MEDIUM_ROOT_CAUSES list with 6 root causes (one or two combined per episode).
|
| 1881 |
+
Create TicketGenerator class with async generate(task_level, adversary_weights, seed) method.
|
| 1882 |
+
Must return (EnvObservation, HiddenState) tuple. Easy task: 0 misleading logs.
|
| 1883 |
+
Medium: 1 misleading log. Hard: two tickets, 1-2 misleading logs.
|
| 1884 |
+
```
|
| 1885 |
+
|
| 1886 |
+
**Phase 5: State Machine**
|
| 1887 |
+
```
|
| 1888 |
+
Build env/transitions.py. Create StateMachine class with async transition(state, action) method.
|
| 1889 |
+
Must: enforce requires_prior_diagnostic precondition, handle escalation_allowed unlock logic,
|
| 1890 |
+
detect redundant actions, update diagnostic_steps_taken counter, generate observation updates
|
| 1891 |
+
per action type (each action reveals different info), return (next_EnvState, list[reward_event_keys]).
|
| 1892 |
+
```
|
| 1893 |
+
|
| 1894 |
+
**Phase 6: Main Environment**
|
| 1895 |
+
```
|
| 1896 |
+
Build env/environment.py. Create SupportOpsArena class. Constructor takes no required args.
|
| 1897 |
+
Implement: async reset(task_level, seed) -> EnvObservation, async step(action) -> StepResult,
|
| 1898 |
+
async state() -> EnvState. Use TicketGenerator, StateMachine, RewardCalculator, AdaptiveAdversary.
|
| 1899 |
+
Track episode state internally. Raise RuntimeError on step after done. Log all episodes.
|
| 1900 |
+
```
|
| 1901 |
+
|
| 1902 |
+
**Phase 7: Adversary**
|
| 1903 |
+
```
|
| 1904 |
+
Build adversary/policy_tracker.py (PolicyTracker with sliding window of 20 episodes) and
|
| 1905 |
+
adversary/adversary.py (AdaptiveAdversary with get_sampling_weights and get_injection_config).
|
| 1906 |
+
Adversary is stateless per-episode. Reads from tracker. Adjusts ticket sampling weights based
|
| 1907 |
+
on agent's past action frequencies, first-action distribution, and score by root cause category.
|
| 1908 |
+
```
|
| 1909 |
+
|
| 1910 |
+
**Phase 8: Graders**
|
| 1911 |
+
```
|
| 1912 |
+
Build graders/programmatic.py (ProgrammaticGrader), graders/llm_grader.py (LLMGrader using
|
| 1913 |
+
AsyncOpenAI with API_BASE_URL/MODEL_NAME from env), graders/adversarial_grader.py
|
| 1914 |
+
(AdversarialGrader). All return GradeResult with score: float Field(ge=0.0, le=1.0).
|
| 1915 |
+
LLM grader falls back to 0.5 on API failure. Add aggregate_scores function.
|
| 1916 |
+
```
|
| 1917 |
+
|
| 1918 |
+
**Phase 9: Baseline Agent**
|
| 1919 |
+
```
|
| 1920 |
+
Build baseline/baseline_agent.py. BaselineAgent must be fully scripted (no ML).
|
| 1921 |
+
Decision tree: inspect_logs+check_authentication first, then branch on auth_status,
|
| 1922 |
+
network_status, vpn_status, service_health. Lowest-risk remediation per root cause category.
|
| 1923 |
+
Escalate if diagnostic_steps >= 60% of budget and done < 40% of budget remaining.
|
| 1924 |
+
Expected scores: easy 0.65-0.75, medium 0.40-0.55, hard 0.20-0.35.
|
| 1925 |
+
```
|
| 1926 |
+
|
| 1927 |
+
**Phase 10: FastAPI Server**
|
| 1928 |
+
```
|
| 1929 |
+
Build app/server.py. Mount static files at /static. Serve index.html at /. Endpoints:
|
| 1930 |
+
GET /health, POST /reset, POST /step, GET /state, GET /scores, POST /grade, GET /openenv.yaml,
|
| 1931 |
+
GET /tasks, POST /baseline/run. Session management with X-Session-ID header (UUID),
|
| 1932 |
+
dict storage with 30min TTL. CORS middleware with allow_origins=["*"].
|
| 1933 |
+
```
|
| 1934 |
+
|
| 1935 |
+
**Phase 11: Frontend**
|
| 1936 |
+
```
|
| 1937 |
+
Build app/static/index.html. Single file, vanilla HTML/CSS/JS. Design: pure white + light sky
|
| 1938 |
+
blue gradient (#FFFFFF → #E0F4FF → #B8E4FF). Fonts: Sora (headings), DM Sans (body), DM Mono (mono).
|
| 1939 |
+
Layout: sticky header, 280px sidebar (task selector + score bars + episode info), main area
|
| 1940 |
+
(ticket viewer + action panel + log viewer), bottom chart. 15 action buttons in 3x5 grid with
|
| 1941 |
+
risk-level color coding. Chart.js sparkline for reward history. Reward flash animation on step.
|
| 1942 |
+
Baseline run button with progress overlay.
|
| 1943 |
+
```
|
| 1944 |
+
|
| 1945 |
+
**Phase 12: Inference Script**
|
| 1946 |
+
```
|
| 1947 |
+
Build inference.py at root level. Runs baseline agent on all 3 tasks sequentially.
|
| 1948 |
+
Uses AsyncOpenAI client (API_BASE_URL, MODEL_NAME, OPENAI_API_KEY from env).
|
| 1949 |
+
Prints [START]/[STEP]/[END] logs as JSON to stdout. Final [SUMMARY] with all 3 scores
|
| 1950 |
+
and overall weighted score (easy×0.2 + medium×0.3 + hard×0.5).
|
| 1951 |
+
```
|
| 1952 |
+
|
| 1953 |
+
**Phase 13: Tests**
|
| 1954 |
+
```
|
| 1955 |
+
Build tests/conftest.py, tests/test_env.py, tests/test_graders.py, tests/test_transitions.py.
|
| 1956 |
+
All tests async with pytest-asyncio. test_env.py: test reset, step, reward range, terminal
|
| 1957 |
+
conditions, escalation unlock, redundant action penalty. test_graders.py: grader returns
|
| 1958 |
+
valid score, all fields present. pytest.ini with asyncio_mode=auto.
|
| 1959 |
+
```
|
| 1960 |
+
|
| 1961 |
+
**Phase 14: Config Files**
|
| 1962 |
+
```
|
| 1963 |
+
Build: openenv.yaml (exact format from PRD section 13), Dockerfile (Python 3.11-slim,
|
| 1964 |
+
port 7860, non-root user, healthcheck), requirements.txt (exact versions from PRD),
|
| 1965 |
+
.env.example (all env vars with placeholder values), pytest.ini (asyncio_mode=auto).
|
| 1966 |
+
```
|
| 1967 |
+
|
| 1968 |
+
**Phase 15: README**
|
| 1969 |
+
```
|
| 1970 |
+
Build README.md. Must include all 16 sections from PRD section 18. Include HF Spaces
|
| 1971 |
+
front matter at top. Include setup instructions, baseline scores table, action space table,
|
| 1972 |
+
observation space table, reward table, env vars table, API endpoints table.
|
| 1973 |
+
```
|
| 1974 |
+
|
| 1975 |
+
### 22.3 Verification Commands After Each Phase
|
| 1976 |
+
|
| 1977 |
+
```bash
|
| 1978 |
+
# After Phase 1-6 (env core):
|
| 1979 |
+
python -c "from env.environment import SupportOpsArena; import asyncio; \
|
| 1980 |
+
env = SupportOpsArena(); \
|
| 1981 |
+
obs = asyncio.run(env.reset('easy')); \
|
| 1982 |
+
print('Reset OK:', obs.ticket_id)"
|
| 1983 |
+
|
| 1984 |
+
# After Phase 7 (adversary):
|
| 1985 |
+
python -c "from adversary.adversary import AdaptiveAdversary; \
|
| 1986 |
+
a = AdaptiveAdversary(); \
|
| 1987 |
+
w = a.get_sampling_weights('easy'); print('Adversary OK:', w)"
|
| 1988 |
+
|
| 1989 |
+
# After Phase 8 (graders):
|
| 1990 |
+
python -c "from graders.programmatic import ProgrammaticGrader; print('Graders OK')"
|
| 1991 |
+
|
| 1992 |
+
# After Phase 10 (server):
|
| 1993 |
+
uvicorn app.server:app --host 0.0.0.0 --port 7860 &
|
| 1994 |
+
sleep 2
|
| 1995 |
+
curl http://localhost:7860/health
|
| 1996 |
+
|
| 1997 |
+
# After Phase 12 (inference):
|
| 1998 |
+
python inference.py 2>&1 | head -20
|
| 1999 |
+
|
| 2000 |
+
# After Phase 13 (tests):
|
| 2001 |
+
pytest tests/ -v --asyncio-mode=auto
|
| 2002 |
+
|
| 2003 |
+
# Final validation:
|
| 2004 |
+
docker build -t supportops-arena .
|
| 2005 |
+
docker run -p 7860:7860 supportops-arena &
|
| 2006 |
+
sleep 10
|
| 2007 |
+
curl http://localhost:7860/health
|
| 2008 |
+
curl -X POST http://localhost:7860/reset -H "Content-Type: application/json" \
|
| 2009 |
+
-d '{"task_level": "easy"}' | python -m json.tool
|
| 2010 |
+
```
|
| 2011 |
+
|
| 2012 |
+
---
|
| 2013 |
+
|
| 2014 |
+
## 23. ERROR HANDLING & EDGE CASES
|
| 2015 |
+
|
| 2016 |
+
### 23.1 Environment Error Handling
|
| 2017 |
+
|
| 2018 |
+
```python
|
| 2019 |
+
# In environment.py
|
| 2020 |
+
class SupportOpsArena:
|
| 2021 |
+
async def step(self, action: Action) -> StepResult:
|
| 2022 |
+
if self._state is None:
|
| 2023 |
+
raise RuntimeError("Environment not initialized. Call reset() first.")
|
| 2024 |
+
if self._state.done:
|
| 2025 |
+
raise RuntimeError("Episode is done. Call reset() to start a new episode.")
|
| 2026 |
+
|
| 2027 |
+
# Invalid action type
|
| 2028 |
+
if action.name not in [a.value for a in ActionType]:
|
| 2029 |
+
return StepResult(
|
| 2030 |
+
observation=self._state.observation,
|
| 2031 |
+
reward=-0.05, # Small penalty for invalid action
|
| 2032 |
+
done=False,
|
| 2033 |
+
info={"error": f"Invalid action: {action.name}"}
|
| 2034 |
+
)
|
| 2035 |
+
```
|
| 2036 |
+
|
| 2037 |
+
### 23.2 API Error Handling
|
| 2038 |
+
|
| 2039 |
+
```python
|
| 2040 |
+
# In server.py
|
| 2041 |
+
@app.exception_handler(RuntimeError)
|
| 2042 |
+
async def runtime_error_handler(request, exc):
|
| 2043 |
+
return JSONResponse(status_code=400, content={"error": str(exc)})
|
| 2044 |
+
|
| 2045 |
+
@app.exception_handler(ValueError)
|
| 2046 |
+
async def value_error_handler(request, exc):
|
| 2047 |
+
return JSONResponse(status_code=422, content={"error": str(exc)})
|
| 2048 |
+
```
|
| 2049 |
+
|
| 2050 |
+
### 23.3 LLM Grader Fallback
|
| 2051 |
+
|
| 2052 |
+
```python
|
| 2053 |
+
async def grade(self, state: EnvState) -> LLMGradeResult:
|
| 2054 |
+
try:
|
| 2055 |
+
# ... API call ...
|
| 2056 |
+
except Exception as e:
|
| 2057 |
+
logger.warning(f"LLM grader API call failed: {e}. Using fallback score.")
|
| 2058 |
+
return LLMGradeResult(
|
| 2059 |
+
score=0.5,
|
| 2060 |
+
diagnostic_coherence=0.5,
|
| 2061 |
+
evidence_sufficiency=0.5,
|
| 2062 |
+
root_cause_narration=0.5,
|
| 2063 |
+
reasoning="LLM grader unavailable — fallback score applied"
|
| 2064 |
+
)
|
| 2065 |
+
```
|
| 2066 |
+
|
| 2067 |
+
### 23.4 Session Cleanup
|
| 2068 |
+
|
| 2069 |
+
```python
|
| 2070 |
+
# In server.py
|
| 2071 |
+
import asyncio
|
| 2072 |
+
from datetime import datetime, timedelta
|
| 2073 |
+
|
| 2074 |
+
async def cleanup_stale_sessions():
|
| 2075 |
+
"""Run every 5 minutes. Remove sessions older than 30 minutes."""
|
| 2076 |
+
while True:
|
| 2077 |
+
await asyncio.sleep(300)
|
| 2078 |
+
cutoff = datetime.utcnow() - timedelta(seconds=SESSION_TTL_SECONDS)
|
| 2079 |
+
stale = [sid for sid, data in sessions.items()
|
| 2080 |
+
if data["created_at"] < cutoff]
|
| 2081 |
+
for sid in stale:
|
| 2082 |
+
del sessions[sid]
|
| 2083 |
+
if stale:
|
| 2084 |
+
logger.info(f"Cleaned up {len(stale)} stale sessions")
|
| 2085 |
+
|
| 2086 |
+
@app.on_event("startup")
|
| 2087 |
+
async def startup():
|
| 2088 |
+
asyncio.create_task(cleanup_stale_sessions())
|
| 2089 |
+
```
|
| 2090 |
+
|
| 2091 |
+
---
|
| 2092 |
+
|
| 2093 |
+
## 24. ABSOLUTE RULES & PROHIBITIONS
|
| 2094 |
+
|
| 2095 |
+
### 24.1 Rules That Cannot Be Broken
|
| 2096 |
+
|
| 2097 |
+
1. **`inference.py` must be at root level** — not in any subdirectory
|
| 2098 |
+
2. **[START]/[STEP]/[END] log format is EXACT** — JSON with `type` field, no extra text
|
| 2099 |
+
3. **All graders must return scores in [0.0, 1.0]** — validated by Pydantic Field constraint
|
| 2100 |
+
4. **Port is always 7860** — hardcoded in Dockerfile CMD
|
| 2101 |
+
5. **`openenv.yaml` must be at root level** — not inside any subdirectory
|
| 2102 |
+
6. **Pydantic v2 only** — no v1 syntax anywhere
|
| 2103 |
+
7. **No synchronous I/O in async paths** — use `asyncio`, `httpx`, `aiofiles`
|
| 2104 |
+
8. **Session ID header is `X-Session-ID`** — exact casing
|
| 2105 |
+
9. **Graders that always return the same score = DISQUALIFICATION** — reward must vary
|
| 2106 |
+
10. **No API keys in source code** — always from environment variables
|
| 2107 |
+
|
| 2108 |
+
### 24.2 Quality Gates Before Submission
|
| 2109 |
+
|
| 2110 |
+
Run this full checklist manually:
|
| 2111 |
+
|
| 2112 |
+
```bash
|
| 2113 |
+
# 1. Tests pass
|
| 2114 |
+
pytest tests/ -v --asyncio-mode=auto
|
| 2115 |
+
|
| 2116 |
+
# 2. Docker builds and runs
|
| 2117 |
+
docker build -t supportops-arena . && \
|
| 2118 |
+
docker run -d -p 7860:7860 --name test-arena supportops-arena && \
|
| 2119 |
+
sleep 10 && \
|
| 2120 |
+
curl -f http://localhost:7860/health && \
|
| 2121 |
+
docker stop test-arena && docker rm test-arena
|
| 2122 |
+
|
| 2123 |
+
# 3. Inference script runs and produces correct format
|
| 2124 |
+
python inference.py | grep -E "^\{" | python -c "
|
| 2125 |
+
import sys, json
|
| 2126 |
+
for line in sys.stdin:
|
| 2127 |
+
data = json.loads(line)
|
| 2128 |
+
assert 'type' in data, f'Missing type field: {data}'
|
| 2129 |
+
assert data['type'] in ['[START]', '[STEP]', '[END]', '[SUMMARY]']
|
| 2130 |
+
print('Log format: OK')
|
| 2131 |
+
"
|
| 2132 |
+
|
| 2133 |
+
# 4. openenv.yaml is valid YAML
|
| 2134 |
+
python -c "import yaml; yaml.safe_load(open('openenv.yaml')); print('YAML: OK')"
|
| 2135 |
+
|
| 2136 |
+
# 5. All scores in range
|
| 2137 |
+
python -c "
|
| 2138 |
+
import asyncio
|
| 2139 |
+
from env.environment import SupportOpsArena
|
| 2140 |
+
from baseline.baseline_agent import BaselineAgent
|
| 2141 |
+
async def check():
|
| 2142 |
+
env = SupportOpsArena()
|
| 2143 |
+
agent = BaselineAgent()
|
| 2144 |
+
for level in ['easy', 'medium', 'hard']:
|
| 2145 |
+
obs = await env.reset(level)
|
| 2146 |
+
done = False
|
| 2147 |
+
while not done:
|
| 2148 |
+
action = await agent.select_action(obs)
|
| 2149 |
+
obs, reward, done, info = await env.step(action)
|
| 2150 |
+
score = info.get('episode_score', 0)
|
| 2151 |
+
assert 0.0 <= score <= 1.0, f'Score out of range for {level}: {score}'
|
| 2152 |
+
print(f'{level}: {score:.3f} ✓')
|
| 2153 |
+
asyncio.run(check())
|
| 2154 |
+
"
|
| 2155 |
+
```
|
| 2156 |
+
|
| 2157 |
+
---
|
| 2158 |
+
|
| 2159 |
+
## APPENDIX A: Expected File Sizes
|
| 2160 |
+
|
| 2161 |
+
| File | Expected Lines | Note |
|
| 2162 |
+
|------|---------------|------|
|
| 2163 |
+
| env/state.py | 120–160 | All models |
|
| 2164 |
+
| env/actions.py | 60–80 | Enum + metadata |
|
| 2165 |
+
| env/rewards.py | 80–100 | Calculator class |
|
| 2166 |
+
| env/transitions.py | 150–200 | Full state machine |
|
| 2167 |
+
| env/scenarios.py | 200–280 | All root causes |
|
| 2168 |
+
| env/environment.py | 180–220 | Main class |
|
| 2169 |
+
| adversary/adversary.py | 100–140 | Adaptive adversary |
|
| 2170 |
+
| graders/programmatic.py | 100–130 | Rule-based grader |
|
| 2171 |
+
| graders/llm_grader.py | 80–100 | LLM judge |
|
| 2172 |
+
| app/server.py | 200–250 | FastAPI endpoints |
|
| 2173 |
+
| app/static/index.html | 600–900 | Full dashboard UI |
|
| 2174 |
+
| inference.py | 80–120 | Inference script |
|
| 2175 |
+
| tests/test_env.py | 100–140 | All env tests |
|
| 2176 |
+
|
| 2177 |
+
---
|
| 2178 |
+
|
| 2179 |
+
## APPENDIX B: Baseline Score Targets
|
| 2180 |
+
|
| 2181 |
+
| Task | Min Score | Max Score | Target |
|
| 2182 |
+
|------|-----------|-----------|--------|
|
| 2183 |
+
| Easy | 0.60 | 0.80 | 0.70 |
|
| 2184 |
+
| Medium | 0.35 | 0.55 | 0.45 |
|
| 2185 |
+
| Hard | 0.15 | 0.35 | 0.25 |
|
| 2186 |
+
| **Overall** | **0.30** | **0.50** | **0.40** |
|
| 2187 |
+
|
| 2188 |
+
If baseline scores are outside these ranges, the reward function or baseline agent has a bug.
|
| 2189 |
+
|
| 2190 |
+
---
|
| 2191 |
+
|
| 2192 |
+
*PRD Version 1.0 · SupportOps Arena · OpenEnv Hackathon 2026*
|
| 2193 |
+
*Build with Claude Sonnet 4.5 via GitHub Copilot CLI*
|
README.md
CHANGED
|
@@ -1,10 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
| 1 |
+
# 🎯 SupportOps Arena
|
| 2 |
+
|
| 3 |
+
**Enterprise IT Incident Triage Reinforcement Learning Environment**
|
| 4 |
+
|
| 5 |
+
[](https://openenv.ai)
|
| 6 |
+
[](https://www.python.org/downloads/)
|
| 7 |
+
[](https://opensource.org/licenses/Apache-2.0)
|
| 8 |
+
|
| 9 |
+
SupportOps Arena is a production-grade, OpenEnv-compliant RL benchmark that simulates enterprise IT support operations. AI agents act as Level-1/2 IT support operators, triaging incomplete and noisy incident tickets.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## 🚀 Quick Start
|
| 14 |
+
|
| 15 |
+
### Installation
|
| 16 |
+
|
| 17 |
+
```bash
|
| 18 |
+
pip install -r requirements.txt
|
| 19 |
+
cp .env.example .env
|
| 20 |
+
# Edit .env with your API keys
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
### Run Server
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
python app/server.py
|
| 27 |
+
# Access dashboard at http://localhost:7860
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
### Run Baseline Agent
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
python inference.py
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
### Docker
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
docker build -t supportops-arena .
|
| 40 |
+
docker run -p 7860:7860 supportops-arena
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
---
|
| 44 |
+
|
| 45 |
+
## 📖 Environment
|
| 46 |
+
|
| 47 |
+
### Tasks
|
| 48 |
+
- **Easy**: Wi-Fi connectivity (10 steps, 6 scenarios)
|
| 49 |
+
- **Medium**: VPN access (16 steps, 6 scenarios, misleading logs)
|
| 50 |
+
- **Hard**: Cross-system failures (24 steps, 6 scenarios, contradictory evidence)
|
| 51 |
+
|
| 52 |
+
### Actions (15 total)
|
| 53 |
+
- **Diagnostic**: inspect_network, inspect_logs, check_authentication, etc.
|
| 54 |
+
- **Remediation**: flush_dns, reconfigure_client, restart_service, reset_credentials
|
| 55 |
+
- **Terminal**: escalate_ticket, resolve_ticket, close_without_fix
|
| 56 |
+
|
| 57 |
+
### Rewards
|
| 58 |
+
- Positive: +0.10 to +1.00 for good actions
|
| 59 |
+
- Negative: -0.05 to -0.40 for bad actions
|
| 60 |
+
|
| 61 |
+
---
|
| 62 |
+
|
| 63 |
+
## 🔌 API
|
| 64 |
+
|
| 65 |
+
- `POST /reset` - Start new episode
|
| 66 |
+
- `POST /step` - Take action
|
| 67 |
+
- `GET /state` - Get full state
|
| 68 |
+
- `POST /baseline/run` - Run baseline agent
|
| 69 |
+
|
| 70 |
+
---
|
| 71 |
+
|
| 72 |
+
## 🧪 Testing
|
| 73 |
+
|
| 74 |
+
```bash
|
| 75 |
+
pytest
|
| 76 |
+
pytest --cov
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
---
|
| 80 |
+
|
| 81 |
+
## 📊 Performance
|
| 82 |
+
|
| 83 |
+
**Baseline Agent Scores**:
|
| 84 |
+
- Easy: 0.65-0.75
|
| 85 |
+
- Medium: 0.40-0.55
|
| 86 |
+
- Hard: 0.20-0.35
|
| 87 |
+
|
| 88 |
+
---
|
| 89 |
+
|
| 90 |
+
## 📝 License
|
| 91 |
+
|
| 92 |
+
Apache License 2.0
|
| 93 |
+
|
| 94 |
---
|
| 95 |
|
| 96 |
+
Built for OpenEnv Hackathon 2026
|
adversary/__init__.py
ADDED
|
File without changes
|
adversary/adversary.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Adaptive adversary system for SupportOps Arena.
|
| 3 |
+
Analyzes agent behavior and adjusts task difficulty dynamically.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Any
|
| 7 |
+
from collections import defaultdict
|
| 8 |
+
|
| 9 |
+
from adversary.policy_tracker import PolicyTracker
|
| 10 |
+
from env.state import TaskLevel, ActionRecord
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class AdaptiveAdversary:
|
| 14 |
+
"""
|
| 15 |
+
Analyzes agent behavior and adjusts ticket sampling weights.
|
| 16 |
+
Makes episodes harder in areas where agent is strong,
|
| 17 |
+
and tests weaknesses to drive learning.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
def __init__(self):
|
| 21 |
+
"""Initialize adaptive adversary."""
|
| 22 |
+
self.policy_tracker = PolicyTracker(window_size=20)
|
| 23 |
+
self._default_weights = {}
|
| 24 |
+
|
| 25 |
+
async def update_policy_tracker(
|
| 26 |
+
self,
|
| 27 |
+
ticket_variant_id: str,
|
| 28 |
+
actions: list[ActionRecord],
|
| 29 |
+
score: float
|
| 30 |
+
) -> None:
|
| 31 |
+
"""
|
| 32 |
+
Update policy tracker with completed episode.
|
| 33 |
+
|
| 34 |
+
Args:
|
| 35 |
+
ticket_variant_id: ID of the ticket scenario
|
| 36 |
+
actions: List of actions taken in episode
|
| 37 |
+
score: Episode score (0-1)
|
| 38 |
+
"""
|
| 39 |
+
# Extract metadata from ticket variant ID
|
| 40 |
+
# Format: category_description (e.g., "wifi_dns_corruption", "expired_password")
|
| 41 |
+
parts = ticket_variant_id.split("_")
|
| 42 |
+
|
| 43 |
+
# Infer category from ID
|
| 44 |
+
category = self._infer_category_from_id(ticket_variant_id)
|
| 45 |
+
task_level = "easy" # This should be passed in properly, simplified for now
|
| 46 |
+
|
| 47 |
+
self.policy_tracker.record_episode(
|
| 48 |
+
episode_id="", # Would need to be passed
|
| 49 |
+
task_level=task_level,
|
| 50 |
+
root_cause_category=category,
|
| 51 |
+
root_cause_id=ticket_variant_id,
|
| 52 |
+
actions=actions,
|
| 53 |
+
score=score
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
def get_sampling_weights(self, task_level: TaskLevel) -> dict[str, float]:
|
| 57 |
+
"""
|
| 58 |
+
Get sampling weights for ticket scenarios.
|
| 59 |
+
|
| 60 |
+
Adjusts weights based on agent's historical performance:
|
| 61 |
+
- Boost scenarios in weak categories (score < 0.40) by 2x
|
| 62 |
+
- Increase difficulty if agent is strong (score > 0.85) on certain types
|
| 63 |
+
- Default to uniform weights if insufficient data
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
task_level: Current task difficulty level
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
Dictionary mapping root_cause_id to sampling weight
|
| 70 |
+
"""
|
| 71 |
+
# Need at least 5 episodes for meaningful adaptation
|
| 72 |
+
if self.policy_tracker.get_episode_count() < 5:
|
| 73 |
+
return {} # Return empty dict for uniform sampling
|
| 74 |
+
|
| 75 |
+
weights = defaultdict(lambda: 1.0)
|
| 76 |
+
|
| 77 |
+
# Identify weak categories (< 0.40 average score)
|
| 78 |
+
weak_categories = self.policy_tracker.get_weakness_categories(threshold=0.40)
|
| 79 |
+
|
| 80 |
+
# Boost scenarios in weak categories by 2x
|
| 81 |
+
for category in weak_categories:
|
| 82 |
+
# This is simplified - in reality would map categories to specific IDs
|
| 83 |
+
weights[f"{category}_*"] = 2.0
|
| 84 |
+
|
| 85 |
+
# Check for strong performance on specific types
|
| 86 |
+
scores_by_category = self.policy_tracker.get_score_by_root_cause_category()
|
| 87 |
+
|
| 88 |
+
for category, avg_score in scores_by_category.items():
|
| 89 |
+
if avg_score > 0.85:
|
| 90 |
+
# Agent is strong here, introduce harder variants
|
| 91 |
+
if task_level == TaskLevel.EASY:
|
| 92 |
+
# Boost medium-difficulty variants of same category
|
| 93 |
+
weights[f"{category}_complex"] = 1.5
|
| 94 |
+
elif task_level == TaskLevel.MEDIUM:
|
| 95 |
+
# Enable contradictory evidence
|
| 96 |
+
weights[f"{category}_contradictory"] = 1.3
|
| 97 |
+
|
| 98 |
+
# Check action usage patterns
|
| 99 |
+
if self.policy_tracker.uses_action_frequently("restart_service", threshold=0.40):
|
| 100 |
+
# Agent over-uses restart_service, introduce cascade failure scenarios
|
| 101 |
+
weights["cascade_failure"] = 1.8
|
| 102 |
+
|
| 103 |
+
# Check first action patterns
|
| 104 |
+
first_action_dist = self.policy_tracker.get_first_action_distribution()
|
| 105 |
+
if first_action_dist.get("inspect_logs", 0.0) > 0.70:
|
| 106 |
+
# Agent always starts with logs, boost misleading log scenarios
|
| 107 |
+
weights["misleading_heavy"] = 1.5
|
| 108 |
+
|
| 109 |
+
# Check escalation patterns
|
| 110 |
+
escalation_steps = self.policy_tracker.get_escalation_step_distribution()
|
| 111 |
+
if escalation_steps:
|
| 112 |
+
avg_escalation_step = sum(escalation_steps) / len(escalation_steps)
|
| 113 |
+
if avg_escalation_step < 4:
|
| 114 |
+
# Agent escalates too early, penalize premature escalation
|
| 115 |
+
weights["requires_deep_diagnostics"] = 1.6
|
| 116 |
+
|
| 117 |
+
return dict(weights)
|
| 118 |
+
|
| 119 |
+
def get_injection_config(self, task_level: TaskLevel) -> dict[str, Any]:
|
| 120 |
+
"""
|
| 121 |
+
Get episode configuration overrides based on agent profile.
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
task_level: Current task difficulty
|
| 125 |
+
|
| 126 |
+
Returns:
|
| 127 |
+
Dictionary with configuration overrides:
|
| 128 |
+
- misleading_log_count: Number of misleading logs to inject
|
| 129 |
+
- contradictory_evidence: Whether to add contradictory evidence
|
| 130 |
+
- cascade_failure_enabled: Whether to enable cascade failures
|
| 131 |
+
"""
|
| 132 |
+
if self.policy_tracker.get_episode_count() < 5:
|
| 133 |
+
return {
|
| 134 |
+
"misleading_log_count": 0 if task_level == TaskLevel.EASY else 1,
|
| 135 |
+
"contradictory_evidence": False,
|
| 136 |
+
"cascade_failure_enabled": False,
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
config = {
|
| 140 |
+
"misleading_log_count": 0,
|
| 141 |
+
"contradictory_evidence": False,
|
| 142 |
+
"cascade_failure_enabled": False,
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
# Adjust based on task level and agent performance
|
| 146 |
+
avg_score = self.policy_tracker.get_average_score()
|
| 147 |
+
|
| 148 |
+
if task_level == TaskLevel.MEDIUM:
|
| 149 |
+
config["misleading_log_count"] = 1
|
| 150 |
+
if avg_score > 0.75:
|
| 151 |
+
config["misleading_log_count"] = 2
|
| 152 |
+
|
| 153 |
+
elif task_level == TaskLevel.HARD:
|
| 154 |
+
config["misleading_log_count"] = 2
|
| 155 |
+
config["contradictory_evidence"] = avg_score > 0.70
|
| 156 |
+
|
| 157 |
+
# Enable cascade failures if agent over-uses high-risk actions
|
| 158 |
+
if self.policy_tracker.uses_action_frequently("restart_service", 0.40):
|
| 159 |
+
config["cascade_failure_enabled"] = True
|
| 160 |
+
|
| 161 |
+
return config
|
| 162 |
+
|
| 163 |
+
def _infer_category_from_id(self, ticket_id: str) -> str:
|
| 164 |
+
"""
|
| 165 |
+
Infer root cause category from ticket variant ID.
|
| 166 |
+
|
| 167 |
+
Args:
|
| 168 |
+
ticket_id: Ticket variant ID
|
| 169 |
+
|
| 170 |
+
Returns:
|
| 171 |
+
Category name
|
| 172 |
+
"""
|
| 173 |
+
# Simple heuristic based on ID patterns
|
| 174 |
+
if "dns" in ticket_id:
|
| 175 |
+
return "dns"
|
| 176 |
+
elif "vpn" in ticket_id:
|
| 177 |
+
return "vpn"
|
| 178 |
+
elif "auth" in ticket_id or "password" in ticket_id or "mfa" in ticket_id:
|
| 179 |
+
return "auth"
|
| 180 |
+
elif "sso" in ticket_id:
|
| 181 |
+
return "sso"
|
| 182 |
+
elif "wifi" in ticket_id or "network" in ticket_id or "dhcp" in ticket_id:
|
| 183 |
+
return "network"
|
| 184 |
+
elif "adapter" in ticket_id or "device" in ticket_id or "airplane" in ticket_id:
|
| 185 |
+
return "hardware"
|
| 186 |
+
else:
|
| 187 |
+
return "unknown"
|
| 188 |
+
|
| 189 |
+
def reset_tracker(self) -> None:
|
| 190 |
+
"""Reset policy tracker (useful for testing)."""
|
| 191 |
+
self.policy_tracker = PolicyTracker(window_size=20)
|
| 192 |
+
|
| 193 |
+
def get_statistics(self) -> dict[str, Any]:
|
| 194 |
+
"""
|
| 195 |
+
Get adversary statistics for debugging/monitoring.
|
| 196 |
+
|
| 197 |
+
Returns:
|
| 198 |
+
Dictionary with statistics
|
| 199 |
+
"""
|
| 200 |
+
return {
|
| 201 |
+
"episodes_tracked": self.policy_tracker.get_episode_count(),
|
| 202 |
+
"average_score": self.policy_tracker.get_average_score(),
|
| 203 |
+
"action_frequencies": self.policy_tracker.get_action_frequencies(),
|
| 204 |
+
"first_action_dist": self.policy_tracker.get_first_action_distribution(),
|
| 205 |
+
"scores_by_category": self.policy_tracker.get_score_by_root_cause_category(),
|
| 206 |
+
"weak_categories": self.policy_tracker.get_weakness_categories(),
|
| 207 |
+
}
|
adversary/policy_tracker.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Policy tracker for adaptive adversary system.
|
| 3 |
+
Tracks agent behavior across episodes to identify patterns and weaknesses.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from collections import defaultdict, deque
|
| 7 |
+
from typing import Any
|
| 8 |
+
from env.state import ActionRecord
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class PolicyTracker:
|
| 12 |
+
"""
|
| 13 |
+
Tracks agent behavior across episodes.
|
| 14 |
+
Maintains a sliding window of last N episodes for analysis.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
def __init__(self, window_size: int = 20):
|
| 18 |
+
"""
|
| 19 |
+
Initialize policy tracker.
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
window_size: Number of recent episodes to track
|
| 23 |
+
"""
|
| 24 |
+
self.window_size = window_size
|
| 25 |
+
self.episodes = deque(maxlen=window_size)
|
| 26 |
+
self.action_counts = defaultdict(int)
|
| 27 |
+
self.total_actions = 0
|
| 28 |
+
self.root_cause_scores = defaultdict(list)
|
| 29 |
+
self.escalation_steps = []
|
| 30 |
+
self.first_actions = []
|
| 31 |
+
|
| 32 |
+
def record_episode(
|
| 33 |
+
self,
|
| 34 |
+
episode_id: str,
|
| 35 |
+
task_level: str,
|
| 36 |
+
root_cause_category: str,
|
| 37 |
+
root_cause_id: str,
|
| 38 |
+
actions: list[ActionRecord],
|
| 39 |
+
score: float
|
| 40 |
+
) -> None:
|
| 41 |
+
"""
|
| 42 |
+
Record completed episode into tracker.
|
| 43 |
+
|
| 44 |
+
Args:
|
| 45 |
+
episode_id: Unique episode identifier
|
| 46 |
+
task_level: Difficulty level
|
| 47 |
+
root_cause_category: Category of root cause
|
| 48 |
+
root_cause_id: Specific root cause ID
|
| 49 |
+
actions: List of actions taken
|
| 50 |
+
score: Episode score (0-1)
|
| 51 |
+
"""
|
| 52 |
+
episode_data = {
|
| 53 |
+
"episode_id": episode_id,
|
| 54 |
+
"task_level": task_level,
|
| 55 |
+
"root_cause_category": root_cause_category,
|
| 56 |
+
"root_cause_id": root_cause_id,
|
| 57 |
+
"actions": actions,
|
| 58 |
+
"score": score,
|
| 59 |
+
"num_actions": len(actions)
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
self.episodes.append(episode_data)
|
| 63 |
+
|
| 64 |
+
# Update action counts
|
| 65 |
+
for action in actions:
|
| 66 |
+
self.action_counts[action.action_name] += 1
|
| 67 |
+
self.total_actions += 1
|
| 68 |
+
|
| 69 |
+
# Track root cause performance
|
| 70 |
+
self.root_cause_scores[root_cause_category].append(score)
|
| 71 |
+
|
| 72 |
+
# Track escalation patterns
|
| 73 |
+
for i, action in enumerate(actions):
|
| 74 |
+
if action.action_name == "escalate_ticket":
|
| 75 |
+
self.escalation_steps.append(i + 1) # 1-indexed
|
| 76 |
+
break
|
| 77 |
+
|
| 78 |
+
# Track first action
|
| 79 |
+
if actions:
|
| 80 |
+
self.first_actions.append(actions[0].action_name)
|
| 81 |
+
|
| 82 |
+
def get_action_frequencies(self, task_level: str | None = None) -> dict[str, float]:
|
| 83 |
+
"""
|
| 84 |
+
Get normalized frequency of each action type.
|
| 85 |
+
|
| 86 |
+
Args:
|
| 87 |
+
task_level: Optional filter by task level
|
| 88 |
+
|
| 89 |
+
Returns:
|
| 90 |
+
Dictionary mapping action names to frequencies (0-1)
|
| 91 |
+
"""
|
| 92 |
+
if not self.total_actions:
|
| 93 |
+
return {}
|
| 94 |
+
|
| 95 |
+
# Filter episodes if task level specified
|
| 96 |
+
if task_level:
|
| 97 |
+
filtered_actions = defaultdict(int)
|
| 98 |
+
total = 0
|
| 99 |
+
for ep in self.episodes:
|
| 100 |
+
if ep["task_level"] == task_level:
|
| 101 |
+
for action in ep["actions"]:
|
| 102 |
+
filtered_actions[action.action_name] += 1
|
| 103 |
+
total += 1
|
| 104 |
+
|
| 105 |
+
if total == 0:
|
| 106 |
+
return {}
|
| 107 |
+
|
| 108 |
+
return {
|
| 109 |
+
action: count / total
|
| 110 |
+
for action, count in filtered_actions.items()
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
# Return overall frequencies
|
| 114 |
+
return {
|
| 115 |
+
action: count / self.total_actions
|
| 116 |
+
for action, count in self.action_counts.items()
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
def get_first_action_distribution(self) -> dict[str, float]:
|
| 120 |
+
"""
|
| 121 |
+
Analyze what agent does first.
|
| 122 |
+
|
| 123 |
+
Returns:
|
| 124 |
+
Dictionary mapping action names to probability of being first
|
| 125 |
+
"""
|
| 126 |
+
if not self.first_actions:
|
| 127 |
+
return {}
|
| 128 |
+
|
| 129 |
+
counts = defaultdict(int)
|
| 130 |
+
for action in self.first_actions:
|
| 131 |
+
counts[action] += 1
|
| 132 |
+
|
| 133 |
+
total = len(self.first_actions)
|
| 134 |
+
return {
|
| 135 |
+
action: count / total
|
| 136 |
+
for action, count in counts.items()
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
def get_escalation_step_distribution(self) -> list[int]:
|
| 140 |
+
"""
|
| 141 |
+
Get list of steps at which agent escalated.
|
| 142 |
+
|
| 143 |
+
Returns:
|
| 144 |
+
List of step numbers (useful for statistical analysis)
|
| 145 |
+
"""
|
| 146 |
+
return self.escalation_steps.copy()
|
| 147 |
+
|
| 148 |
+
def get_score_by_root_cause_category(self) -> dict[str, float]:
|
| 149 |
+
"""
|
| 150 |
+
Get average score per root cause category.
|
| 151 |
+
|
| 152 |
+
Returns:
|
| 153 |
+
Dictionary mapping categories to average scores
|
| 154 |
+
"""
|
| 155 |
+
return {
|
| 156 |
+
category: sum(scores) / len(scores) if scores else 0.0
|
| 157 |
+
for category, scores in self.root_cause_scores.items()
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
def get_average_score(self, task_level: str | None = None) -> float:
|
| 161 |
+
"""
|
| 162 |
+
Get average score across episodes.
|
| 163 |
+
|
| 164 |
+
Args:
|
| 165 |
+
task_level: Optional filter by task level
|
| 166 |
+
|
| 167 |
+
Returns:
|
| 168 |
+
Average score (0-1)
|
| 169 |
+
"""
|
| 170 |
+
if task_level:
|
| 171 |
+
scores = [
|
| 172 |
+
ep["score"] for ep in self.episodes
|
| 173 |
+
if ep["task_level"] == task_level
|
| 174 |
+
]
|
| 175 |
+
else:
|
| 176 |
+
scores = [ep["score"] for ep in self.episodes]
|
| 177 |
+
|
| 178 |
+
return sum(scores) / len(scores) if scores else 0.0
|
| 179 |
+
|
| 180 |
+
def get_episode_count(self) -> int:
|
| 181 |
+
"""Get number of recorded episodes."""
|
| 182 |
+
return len(self.episodes)
|
| 183 |
+
|
| 184 |
+
def get_weakness_categories(self, threshold: float = 0.40) -> list[str]:
|
| 185 |
+
"""
|
| 186 |
+
Identify root cause categories where agent performs poorly.
|
| 187 |
+
|
| 188 |
+
Args:
|
| 189 |
+
threshold: Score threshold below which category is "weak"
|
| 190 |
+
|
| 191 |
+
Returns:
|
| 192 |
+
List of category names with average score < threshold
|
| 193 |
+
"""
|
| 194 |
+
scores_by_category = self.get_score_by_root_cause_category()
|
| 195 |
+
return [
|
| 196 |
+
category for category, score in scores_by_category.items()
|
| 197 |
+
if score < threshold
|
| 198 |
+
]
|
| 199 |
+
|
| 200 |
+
def uses_action_frequently(self, action_name: str, threshold: float = 0.40) -> bool:
|
| 201 |
+
"""
|
| 202 |
+
Check if agent uses a specific action frequently.
|
| 203 |
+
|
| 204 |
+
Args:
|
| 205 |
+
action_name: Name of action to check
|
| 206 |
+
threshold: Frequency threshold (0-1)
|
| 207 |
+
|
| 208 |
+
Returns:
|
| 209 |
+
True if action frequency >= threshold
|
| 210 |
+
"""
|
| 211 |
+
frequencies = self.get_action_frequencies()
|
| 212 |
+
return frequencies.get(action_name, 0.0) >= threshold
|
app/__init__.py
ADDED
|
File without changes
|
app/server.py
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI server for SupportOps Arena.
|
| 3 |
+
Implements OpenEnv HTTP specification for remote environment access.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import uuid
|
| 8 |
+
import logging
|
| 9 |
+
from datetime import datetime, timedelta
|
| 10 |
+
from typing import Any, Dict
|
| 11 |
+
|
| 12 |
+
from fastapi import FastAPI, HTTPException, Header, Request
|
| 13 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 14 |
+
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
| 15 |
+
from fastapi.staticfiles import StaticFiles
|
| 16 |
+
from pydantic import BaseModel
|
| 17 |
+
|
| 18 |
+
from env.environment import SupportOpsArena
|
| 19 |
+
from env.state import EnvObservation, StepResult, TaskLevel
|
| 20 |
+
from env.actions import ActionType
|
| 21 |
+
from adversary.adversary import AdaptiveAdversary
|
| 22 |
+
from baseline.baseline_agent import BaselineAgent
|
| 23 |
+
from graders.programmatic import ProgrammaticGrader
|
| 24 |
+
from graders.llm_grader import LLMGrader
|
| 25 |
+
from graders.adversarial_grader import AdversarialGrader, aggregate_scores
|
| 26 |
+
|
| 27 |
+
# Setup logging
|
| 28 |
+
logging.basicConfig(
|
| 29 |
+
level=logging.INFO,
|
| 30 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 31 |
+
)
|
| 32 |
+
logger = logging.getLogger(__name__)
|
| 33 |
+
|
| 34 |
+
# Create FastAPI app
|
| 35 |
+
app = FastAPI(
|
| 36 |
+
title="SupportOps Arena",
|
| 37 |
+
description="OpenEnv-compliant IT incident triage RL environment",
|
| 38 |
+
version="1.0.0"
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# Add CORS middleware
|
| 42 |
+
app.add_middleware(
|
| 43 |
+
CORSMiddleware,
|
| 44 |
+
allow_origins=["*"],
|
| 45 |
+
allow_methods=["*"],
|
| 46 |
+
allow_headers=["*"],
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# Session management
|
| 50 |
+
sessions: Dict[str, Dict[str, Any]] = {}
|
| 51 |
+
SESSION_TTL_SECONDS = 1800 # 30 minutes
|
| 52 |
+
|
| 53 |
+
# Baseline scores cache
|
| 54 |
+
baseline_scores: Dict[str, float] = {}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ─── Request/Response Models ──────────────────────────────────
|
| 58 |
+
|
| 59 |
+
class ResetRequest(BaseModel):
|
| 60 |
+
"""Request to reset environment."""
|
| 61 |
+
task_level: str = "easy"
|
| 62 |
+
seed: int | None = None
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class StepRequest(BaseModel):
|
| 66 |
+
"""Request to take a step."""
|
| 67 |
+
action_name: str
|
| 68 |
+
rationale: str | None = None
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class GradeRequest(BaseModel):
|
| 72 |
+
"""Request to grade episode."""
|
| 73 |
+
use_llm: bool = True
|
| 74 |
+
use_adversarial: bool = False
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ─── Session Management ───────────────────────────────────────
|
| 78 |
+
|
| 79 |
+
def get_or_create_session(session_id: str | None = None) -> tuple[str, SupportOpsArena]:
|
| 80 |
+
"""Get existing session or create new one."""
|
| 81 |
+
# Clean up expired sessions
|
| 82 |
+
now = datetime.utcnow()
|
| 83 |
+
expired = [
|
| 84 |
+
sid for sid, data in sessions.items()
|
| 85 |
+
if now - data["created_at"] > timedelta(seconds=SESSION_TTL_SECONDS)
|
| 86 |
+
]
|
| 87 |
+
for sid in expired:
|
| 88 |
+
del sessions[sid]
|
| 89 |
+
logger.info(f"Expired session {sid}")
|
| 90 |
+
|
| 91 |
+
# Create new session if needed
|
| 92 |
+
if not session_id or session_id not in sessions:
|
| 93 |
+
session_id = str(uuid.uuid4())
|
| 94 |
+
adversary = AdaptiveAdversary()
|
| 95 |
+
env = SupportOpsArena(adversary=adversary)
|
| 96 |
+
|
| 97 |
+
sessions[session_id] = {
|
| 98 |
+
"env": env,
|
| 99 |
+
"adversary": adversary,
|
| 100 |
+
"created_at": now,
|
| 101 |
+
"last_accessed": now,
|
| 102 |
+
}
|
| 103 |
+
logger.info(f"Created new session {session_id}")
|
| 104 |
+
else:
|
| 105 |
+
sessions[session_id]["last_accessed"] = now
|
| 106 |
+
|
| 107 |
+
return session_id, sessions[session_id]["env"]
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ─── API Endpoints ────────────────────────────────────────────
|
| 111 |
+
|
| 112 |
+
@app.get("/", response_class=HTMLResponse)
|
| 113 |
+
async def root():
|
| 114 |
+
"""Serve dashboard UI."""
|
| 115 |
+
try:
|
| 116 |
+
with open("app/static/index.html", "r") as f:
|
| 117 |
+
return HTMLResponse(content=f.read())
|
| 118 |
+
except FileNotFoundError:
|
| 119 |
+
return HTMLResponse(
|
| 120 |
+
content="<h1>SupportOps Arena</h1><p>Dashboard coming soon...</p>"
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@app.get("/health")
|
| 125 |
+
async def health():
|
| 126 |
+
"""Health check endpoint."""
|
| 127 |
+
return {
|
| 128 |
+
"status": "ok",
|
| 129 |
+
"version": "1.0.0",
|
| 130 |
+
"active_sessions": len(sessions)
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@app.post("/reset")
|
| 135 |
+
async def reset(
|
| 136 |
+
request: ResetRequest,
|
| 137 |
+
x_session_id: str | None = Header(None)
|
| 138 |
+
) -> Dict[str, Any]:
|
| 139 |
+
"""
|
| 140 |
+
Reset environment and start new episode.
|
| 141 |
+
|
| 142 |
+
Returns:
|
| 143 |
+
Initial observation and session ID
|
| 144 |
+
"""
|
| 145 |
+
try:
|
| 146 |
+
session_id, env = get_or_create_session(x_session_id)
|
| 147 |
+
|
| 148 |
+
observation = await env.reset(
|
| 149 |
+
task_level=request.task_level,
|
| 150 |
+
seed=request.seed
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
logger.info(f"Reset session {session_id} to {request.task_level}")
|
| 154 |
+
|
| 155 |
+
return {
|
| 156 |
+
"session_id": session_id,
|
| 157 |
+
"observation": observation.model_dump()
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
except ValueError as e:
|
| 161 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 162 |
+
except Exception as e:
|
| 163 |
+
logger.error(f"Reset failed: {e}", exc_info=True)
|
| 164 |
+
raise HTTPException(status_code=500, detail=f"Reset failed: {str(e)}")
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
@app.post("/step")
|
| 168 |
+
async def step(
|
| 169 |
+
request: StepRequest,
|
| 170 |
+
x_session_id: str | None = Header(None)
|
| 171 |
+
) -> Dict[str, Any]:
|
| 172 |
+
"""
|
| 173 |
+
Take a step in the environment.
|
| 174 |
+
|
| 175 |
+
Returns:
|
| 176 |
+
Step result with observation, reward, done, info
|
| 177 |
+
"""
|
| 178 |
+
try:
|
| 179 |
+
if not x_session_id or x_session_id not in sessions:
|
| 180 |
+
raise HTTPException(
|
| 181 |
+
status_code=400,
|
| 182 |
+
detail="No active session. Call /reset first."
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
session_id, env = get_or_create_session(x_session_id)
|
| 186 |
+
|
| 187 |
+
# Parse action
|
| 188 |
+
try:
|
| 189 |
+
action = ActionType(request.action_name)
|
| 190 |
+
except ValueError:
|
| 191 |
+
raise HTTPException(
|
| 192 |
+
status_code=400,
|
| 193 |
+
detail=f"Invalid action: {request.action_name}"
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
# Take step
|
| 197 |
+
result = await env.step(action)
|
| 198 |
+
|
| 199 |
+
logger.debug(
|
| 200 |
+
f"Session {session_id} step: {action.value} -> "
|
| 201 |
+
f"reward={result.reward:.2f}, done={result.done}"
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
return {
|
| 205 |
+
"observation": result.observation.model_dump(),
|
| 206 |
+
"reward": result.reward,
|
| 207 |
+
"done": result.done,
|
| 208 |
+
"info": result.info
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
except HTTPException:
|
| 212 |
+
raise
|
| 213 |
+
except Exception as e:
|
| 214 |
+
logger.error(f"Step failed: {e}", exc_info=True)
|
| 215 |
+
raise HTTPException(status_code=500, detail=f"Step failed: {str(e)}")
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
@app.get("/state")
|
| 219 |
+
async def get_state(x_session_id: str | None = Header(None)) -> Dict[str, Any]:
|
| 220 |
+
"""
|
| 221 |
+
Get full environment state (for graders).
|
| 222 |
+
|
| 223 |
+
Returns:
|
| 224 |
+
Complete EnvState including hidden information
|
| 225 |
+
"""
|
| 226 |
+
try:
|
| 227 |
+
if not x_session_id or x_session_id not in sessions:
|
| 228 |
+
raise HTTPException(
|
| 229 |
+
status_code=400,
|
| 230 |
+
detail="No active session"
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
session_id, env = get_or_create_session(x_session_id)
|
| 234 |
+
state = await env.state()
|
| 235 |
+
|
| 236 |
+
return state.model_dump()
|
| 237 |
+
|
| 238 |
+
except HTTPException:
|
| 239 |
+
raise
|
| 240 |
+
except Exception as e:
|
| 241 |
+
logger.error(f"Get state failed: {e}", exc_info=True)
|
| 242 |
+
raise HTTPException(status_code=500, detail=f"Get state failed: {str(e)}")
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
@app.post("/grade")
|
| 246 |
+
async def grade_episode(
|
| 247 |
+
request: GradeRequest,
|
| 248 |
+
x_session_id: str | None = Header(None)
|
| 249 |
+
) -> Dict[str, Any]:
|
| 250 |
+
"""
|
| 251 |
+
Grade current episode.
|
| 252 |
+
|
| 253 |
+
Returns:
|
| 254 |
+
Grading results from requested graders
|
| 255 |
+
"""
|
| 256 |
+
try:
|
| 257 |
+
if not x_session_id or x_session_id not in sessions:
|
| 258 |
+
raise HTTPException(
|
| 259 |
+
status_code=400,
|
| 260 |
+
detail="No active session"
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
session_id, env = get_or_create_session(x_session_id)
|
| 264 |
+
state = await env.state()
|
| 265 |
+
|
| 266 |
+
if not state.done:
|
| 267 |
+
raise HTTPException(
|
| 268 |
+
status_code=400,
|
| 269 |
+
detail="Episode not complete. Cannot grade active episode."
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
# Run programmatic grader
|
| 273 |
+
prog_grader = ProgrammaticGrader()
|
| 274 |
+
prog_result = await prog_grader.grade(state)
|
| 275 |
+
|
| 276 |
+
results = {
|
| 277 |
+
"programmatic": prog_result.model_dump()
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
# Run LLM grader if requested
|
| 281 |
+
if request.use_llm:
|
| 282 |
+
llm_grader = LLMGrader()
|
| 283 |
+
llm_result = await llm_grader.grade(state)
|
| 284 |
+
results["llm"] = llm_result.model_dump()
|
| 285 |
+
|
| 286 |
+
# Run adversarial grader if requested
|
| 287 |
+
if request.use_adversarial:
|
| 288 |
+
adv_grader = AdversarialGrader()
|
| 289 |
+
adversary = sessions[session_id].get("adversary")
|
| 290 |
+
adv_score = await adv_grader.grade(state, adversary)
|
| 291 |
+
results["adversarial"] = {"score": adv_score}
|
| 292 |
+
|
| 293 |
+
# Calculate aggregate score
|
| 294 |
+
aggregate = aggregate_scores(
|
| 295 |
+
programmatic=prog_result.score,
|
| 296 |
+
llm=results.get("llm", {}).get("overall"),
|
| 297 |
+
adversarial=results.get("adversarial", {}).get("score"),
|
| 298 |
+
task_level=state.task_level.value
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
results["aggregate_score"] = aggregate
|
| 302 |
+
|
| 303 |
+
return results
|
| 304 |
+
|
| 305 |
+
except HTTPException:
|
| 306 |
+
raise
|
| 307 |
+
except Exception as e:
|
| 308 |
+
logger.error(f"Grading failed: {e}", exc_info=True)
|
| 309 |
+
raise HTTPException(status_code=500, detail=f"Grading failed: {str(e)}")
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
@app.get("/tasks")
|
| 313 |
+
async def list_tasks() -> Dict[str, Any]:
|
| 314 |
+
"""List all available tasks with metadata."""
|
| 315 |
+
return {
|
| 316 |
+
"tasks": [
|
| 317 |
+
{
|
| 318 |
+
"id": "easy",
|
| 319 |
+
"name": "Wi-Fi Connectivity Failure",
|
| 320 |
+
"description": "Basic network connectivity issues",
|
| 321 |
+
"max_steps": 10,
|
| 322 |
+
"scenarios": 6
|
| 323 |
+
},
|
| 324 |
+
{
|
| 325 |
+
"id": "medium",
|
| 326 |
+
"name": "VPN Access Failure",
|
| 327 |
+
"description": "Remote access and authentication issues",
|
| 328 |
+
"max_steps": 16,
|
| 329 |
+
"scenarios": 6
|
| 330 |
+
},
|
| 331 |
+
{
|
| 332 |
+
"id": "hard",
|
| 333 |
+
"name": "Cross-System Access Failure",
|
| 334 |
+
"description": "Complex multi-service failures with shared root causes",
|
| 335 |
+
"max_steps": 24,
|
| 336 |
+
"scenarios": 6
|
| 337 |
+
}
|
| 338 |
+
]
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
@app.post("/baseline/run")
|
| 343 |
+
async def run_baseline() -> Dict[str, Any]:
|
| 344 |
+
"""
|
| 345 |
+
Run baseline agent on all three task levels.
|
| 346 |
+
|
| 347 |
+
Returns:
|
| 348 |
+
Scores for each task level
|
| 349 |
+
"""
|
| 350 |
+
try:
|
| 351 |
+
logger.info("Starting baseline agent evaluation")
|
| 352 |
+
|
| 353 |
+
agent = BaselineAgent()
|
| 354 |
+
results = {}
|
| 355 |
+
|
| 356 |
+
for task_level in ["easy", "medium", "hard"]:
|
| 357 |
+
# Create fresh environment
|
| 358 |
+
env = SupportOpsArena()
|
| 359 |
+
|
| 360 |
+
# Run episode
|
| 361 |
+
episode_result = await agent.run_episode(env, task_level)
|
| 362 |
+
|
| 363 |
+
# Get final state for scoring
|
| 364 |
+
state = await env.state()
|
| 365 |
+
|
| 366 |
+
# Grade with programmatic grader
|
| 367 |
+
grader = ProgrammaticGrader()
|
| 368 |
+
grade_result = await grader.grade(state)
|
| 369 |
+
|
| 370 |
+
results[task_level] = {
|
| 371 |
+
"steps": episode_result["steps"],
|
| 372 |
+
"reward": episode_result["cumulative_reward"],
|
| 373 |
+
"grade_score": grade_result.score,
|
| 374 |
+
"root_cause_identified": grade_result.root_cause_identified
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
# Cache score
|
| 378 |
+
baseline_scores[task_level] = grade_result.score
|
| 379 |
+
|
| 380 |
+
logger.info(
|
| 381 |
+
f"Baseline {task_level}: "
|
| 382 |
+
f"steps={episode_result['steps']}, "
|
| 383 |
+
f"score={grade_result.score:.3f}"
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
return {
|
| 387 |
+
"results": results,
|
| 388 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
except Exception as e:
|
| 392 |
+
logger.error(f"Baseline run failed: {e}", exc_info=True)
|
| 393 |
+
raise HTTPException(
|
| 394 |
+
status_code=500,
|
| 395 |
+
detail=f"Baseline run failed: {str(e)}"
|
| 396 |
+
)
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
@app.get("/scores")
|
| 400 |
+
async def get_scores() -> Dict[str, Any]:
|
| 401 |
+
"""Get cached baseline scores."""
|
| 402 |
+
if not baseline_scores:
|
| 403 |
+
return {
|
| 404 |
+
"scores": {},
|
| 405 |
+
"message": "No baseline scores available. Run /baseline/run first."
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
return {
|
| 409 |
+
"scores": baseline_scores,
|
| 410 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
@app.get("/openenv.yaml")
|
| 415 |
+
async def get_openenv_spec():
|
| 416 |
+
"""Serve openenv.yaml specification file."""
|
| 417 |
+
try:
|
| 418 |
+
return FileResponse("openenv.yaml", media_type="text/yaml")
|
| 419 |
+
except FileNotFoundError:
|
| 420 |
+
raise HTTPException(status_code=404, detail="openenv.yaml not found")
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
# ─── Server Entry Point ───────────────────────────────────────
|
| 424 |
+
|
| 425 |
+
if __name__ == "__main__":
|
| 426 |
+
import uvicorn
|
| 427 |
+
|
| 428 |
+
host = os.environ.get("HOST", "0.0.0.0")
|
| 429 |
+
port = int(os.environ.get("PORT", "7860"))
|
| 430 |
+
|
| 431 |
+
logger.info(f"Starting SupportOps Arena server on {host}:{port}")
|
| 432 |
+
|
| 433 |
+
uvicorn.run(
|
| 434 |
+
"app.server:app",
|
| 435 |
+
host=host,
|
| 436 |
+
port=port,
|
| 437 |
+
log_level="info"
|
| 438 |
+
)
|
app/static/index.html
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>SupportOps Arena - IT Incident Triage RL Environment</title>
|
| 7 |
+
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=DM+Mono:wght@400;500&family=Sora:wght@600;700;800&display=swap" rel="stylesheet">
|
| 8 |
+
<style>
|
| 9 |
+
:root {
|
| 10 |
+
--bg-primary: #FFFFFF;
|
| 11 |
+
--bg-secondary: #F0F8FF;
|
| 12 |
+
--bg-card: #FFFFFF;
|
| 13 |
+
--bg-glass: rgba(255,255,255,0.8);
|
| 14 |
+
--gradient-main: linear-gradient(135deg, #FFFFFF 0%, #E0F4FF 40%, #B8E4FF 100%);
|
| 15 |
+
--gradient-card: linear-gradient(145deg, rgba(255,255,255,0.95), rgba(224,244,255,0.6));
|
| 16 |
+
--sky-300: #87CEEB;
|
| 17 |
+
--sky-500: #2196F3;
|
| 18 |
+
--sky-600: #1976D2;
|
| 19 |
+
--text-primary: #0D1B2A;
|
| 20 |
+
--text-secondary: #3A5068;
|
| 21 |
+
--text-muted: #7B9BB5;
|
| 22 |
+
--success: #10B981;
|
| 23 |
+
--warning: #F59E0B;
|
| 24 |
+
--danger: #EF4444;
|
| 25 |
+
--info: #3B82F6;
|
| 26 |
+
--border-light: rgba(135, 206, 235, 0.3);
|
| 27 |
+
--shadow-md: 0 4px 20px rgba(33, 150, 243, 0.12);
|
| 28 |
+
--radius-md: 12px;
|
| 29 |
+
--transition-med: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
* {
|
| 33 |
+
margin: 0;
|
| 34 |
+
padding: 0;
|
| 35 |
+
box-sizing: border-box;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
body {
|
| 39 |
+
font-family: 'DM Sans', sans-serif;
|
| 40 |
+
background: var(--gradient-main);
|
| 41 |
+
color: var(--text-primary);
|
| 42 |
+
min-height: 100vh;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
h1, h2, h3 {
|
| 46 |
+
font-family: 'Sora', sans-serif;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.container {
|
| 50 |
+
max-width: 1400px;
|
| 51 |
+
margin: 0 auto;
|
| 52 |
+
padding: 20px;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.header {
|
| 56 |
+
background: var(--gradient-card);
|
| 57 |
+
backdrop-filter: blur(12px);
|
| 58 |
+
padding: 20px 30px;
|
| 59 |
+
border-radius: var(--radius-md);
|
| 60 |
+
box-shadow: var(--shadow-md);
|
| 61 |
+
margin-bottom: 20px;
|
| 62 |
+
display: flex;
|
| 63 |
+
justify-content: space-between;
|
| 64 |
+
align-items: center;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.header h1 {
|
| 68 |
+
font-size: 28px;
|
| 69 |
+
color: var(--sky-600);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.status-badge {
|
| 73 |
+
padding: 8px 16px;
|
| 74 |
+
background: var(--success);
|
| 75 |
+
color: white;
|
| 76 |
+
border-radius: 20px;
|
| 77 |
+
font-weight: 600;
|
| 78 |
+
font-size: 14px;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.grid {
|
| 82 |
+
display: grid;
|
| 83 |
+
grid-template-columns: 1fr 1fr;
|
| 84 |
+
gap: 20px;
|
| 85 |
+
margin-bottom: 20px;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.card {
|
| 89 |
+
background: var(--gradient-card);
|
| 90 |
+
backdrop-filter: blur(12px);
|
| 91 |
+
border-radius: var(--radius-md);
|
| 92 |
+
padding: 20px;
|
| 93 |
+
box-shadow: var(--shadow-md);
|
| 94 |
+
border: 1px solid var(--border-light);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.card h2 {
|
| 98 |
+
font-size: 20px;
|
| 99 |
+
margin-bottom: 15px;
|
| 100 |
+
color: var(--sky-600);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.button {
|
| 104 |
+
padding: 10px 20px;
|
| 105 |
+
background: var(--sky-500);
|
| 106 |
+
color: white;
|
| 107 |
+
border: none;
|
| 108 |
+
border-radius: var(--radius-md);
|
| 109 |
+
font-family: 'DM Sans', sans-serif;
|
| 110 |
+
font-weight: 600;
|
| 111 |
+
cursor: pointer;
|
| 112 |
+
transition: var(--transition-med);
|
| 113 |
+
margin-right: 10px;
|
| 114 |
+
margin-bottom: 10px;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.button:hover {
|
| 118 |
+
background: var(--sky-600);
|
| 119 |
+
transform: translateY(-2px);
|
| 120 |
+
box-shadow: var(--shadow-md);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.button-secondary {
|
| 124 |
+
background: var(--text-secondary);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.button-secondary:hover {
|
| 128 |
+
background: var(--text-primary);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
select, input {
|
| 132 |
+
padding: 10px;
|
| 133 |
+
border: 2px solid var(--border-light);
|
| 134 |
+
border-radius: var(--radius-md);
|
| 135 |
+
font-family: 'DM Sans', sans-serif;
|
| 136 |
+
margin-right: 10px;
|
| 137 |
+
background: white;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.log-entry {
|
| 141 |
+
padding: 10px;
|
| 142 |
+
margin-bottom: 8px;
|
| 143 |
+
background: white;
|
| 144 |
+
border-left: 4px solid var(--info);
|
| 145 |
+
border-radius: 6px;
|
| 146 |
+
font-family: 'DM Mono', monospace;
|
| 147 |
+
font-size: 13px;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.log-entry.error {
|
| 151 |
+
border-left-color: var(--danger);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.log-entry.warn {
|
| 155 |
+
border-left-color: var(--warning);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
.log-entry.info {
|
| 159 |
+
border-left-color: var(--success);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.action-history {
|
| 163 |
+
max-height: 300px;
|
| 164 |
+
overflow-y: auto;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.action-item {
|
| 168 |
+
padding: 10px;
|
| 169 |
+
margin-bottom: 8px;
|
| 170 |
+
background: white;
|
| 171 |
+
border-radius: 6px;
|
| 172 |
+
display: flex;
|
| 173 |
+
justify-content: space-between;
|
| 174 |
+
align-items: center;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.reward {
|
| 178 |
+
font-weight: 600;
|
| 179 |
+
padding: 4px 8px;
|
| 180 |
+
border-radius: 4px;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.reward.positive {
|
| 184 |
+
background: var(--success);
|
| 185 |
+
color: white;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.reward.negative {
|
| 189 |
+
background: var(--danger);
|
| 190 |
+
color: white;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.metrics {
|
| 194 |
+
display: grid;
|
| 195 |
+
grid-template-columns: repeat(3, 1fr);
|
| 196 |
+
gap: 15px;
|
| 197 |
+
margin-top: 15px;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.metric {
|
| 201 |
+
text-align: center;
|
| 202 |
+
padding: 15px;
|
| 203 |
+
background: white;
|
| 204 |
+
border-radius: var(--radius-md);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.metric-value {
|
| 208 |
+
font-size: 32px;
|
| 209 |
+
font-weight: 700;
|
| 210 |
+
color: var(--sky-600);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
.metric-label {
|
| 214 |
+
font-size: 14px;
|
| 215 |
+
color: var(--text-muted);
|
| 216 |
+
margin-top: 5px;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.hidden {
|
| 220 |
+
display: none;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
.loading {
|
| 224 |
+
color: var(--text-muted);
|
| 225 |
+
font-style: italic;
|
| 226 |
+
}
|
| 227 |
+
</style>
|
| 228 |
+
</head>
|
| 229 |
+
<body>
|
| 230 |
+
<div class="container">
|
| 231 |
+
<div class="header">
|
| 232 |
+
<h1>🎯 SupportOps Arena</h1>
|
| 233 |
+
<div class="status-badge" id="statusBadge">Ready</div>
|
| 234 |
+
</div>
|
| 235 |
+
|
| 236 |
+
<div class="card">
|
| 237 |
+
<h2>Environment Controls</h2>
|
| 238 |
+
<div>
|
| 239 |
+
<select id="taskLevel">
|
| 240 |
+
<option value="easy">Easy - Wi-Fi Connectivity</option>
|
| 241 |
+
<option value="medium">Medium - VPN Access</option>
|
| 242 |
+
<option value="hard">Hard - Cross-System Failure</option>
|
| 243 |
+
</select>
|
| 244 |
+
<input type="number" id="seedInput" placeholder="Seed (optional)">
|
| 245 |
+
<button class="button" onclick="resetEnvironment()">Reset Environment</button>
|
| 246 |
+
<button class="button button-secondary" onclick="runBaseline()">Run Baseline Agent</button>
|
| 247 |
+
</div>
|
| 248 |
+
</div>
|
| 249 |
+
|
| 250 |
+
<div class="grid">
|
| 251 |
+
<div class="card">
|
| 252 |
+
<h2>Current Ticket</h2>
|
| 253 |
+
<div id="ticketInfo">
|
| 254 |
+
<p class="loading">Reset environment to start...</p>
|
| 255 |
+
</div>
|
| 256 |
+
</div>
|
| 257 |
+
|
| 258 |
+
<div class="card">
|
| 259 |
+
<h2>Observations</h2>
|
| 260 |
+
<div id="observations">
|
| 261 |
+
<p class="loading">No active episode</p>
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
|
| 266 |
+
<div class="card">
|
| 267 |
+
<h2>Manual Actions</h2>
|
| 268 |
+
<div id="actionButtons">
|
| 269 |
+
<!-- Action buttons will be populated -->
|
| 270 |
+
</div>
|
| 271 |
+
</div>
|
| 272 |
+
|
| 273 |
+
<div class="grid">
|
| 274 |
+
<div class="card">
|
| 275 |
+
<h2>System Logs</h2>
|
| 276 |
+
<div id="systemLogs" style="max-height: 300px; overflow-y: auto;">
|
| 277 |
+
<p class="loading">No logs yet</p>
|
| 278 |
+
</div>
|
| 279 |
+
</div>
|
| 280 |
+
|
| 281 |
+
<div class="card">
|
| 282 |
+
<h2>Action History</h2>
|
| 283 |
+
<div id="actionHistory" class="action-history">
|
| 284 |
+
<p class="loading">No actions yet</p>
|
| 285 |
+
</div>
|
| 286 |
+
</div>
|
| 287 |
+
</div>
|
| 288 |
+
|
| 289 |
+
<div class="card">
|
| 290 |
+
<h2>Episode Metrics</h2>
|
| 291 |
+
<div class="metrics" id="metrics">
|
| 292 |
+
<div class="metric">
|
| 293 |
+
<div class="metric-value" id="stepsValue">0</div>
|
| 294 |
+
<div class="metric-label">Steps</div>
|
| 295 |
+
</div>
|
| 296 |
+
<div class="metric">
|
| 297 |
+
<div class="metric-value" id="rewardValue">0.00</div>
|
| 298 |
+
<div class="metric-label">Cumulative Reward</div>
|
| 299 |
+
</div>
|
| 300 |
+
<div class="metric">
|
| 301 |
+
<div class="metric-value" id="diagnosticsValue">0</div>
|
| 302 |
+
<div class="metric-label">Diagnostics Taken</div>
|
| 303 |
+
</div>
|
| 304 |
+
</div>
|
| 305 |
+
</div>
|
| 306 |
+
</div>
|
| 307 |
+
|
| 308 |
+
<script>
|
| 309 |
+
let sessionId = null;
|
| 310 |
+
let currentObservation = null;
|
| 311 |
+
let cumulativeReward = 0;
|
| 312 |
+
|
| 313 |
+
const actions = [
|
| 314 |
+
'inspect_network', 'inspect_logs', 'check_authentication',
|
| 315 |
+
'check_permissions', 'query_device_status', 'search_internal_kb',
|
| 316 |
+
'contact_user_for_info', 'run_diagnostic_script', 'flush_dns',
|
| 317 |
+
'reconfigure_client', 'restart_service', 'reset_credentials',
|
| 318 |
+
'escalate_ticket', 'resolve_ticket', 'close_without_fix'
|
| 319 |
+
];
|
| 320 |
+
|
| 321 |
+
function updateStatus(text, type = 'success') {
|
| 322 |
+
const badge = document.getElementById('statusBadge');
|
| 323 |
+
badge.textContent = text;
|
| 324 |
+
badge.style.background = type === 'success' ? 'var(--success)' :
|
| 325 |
+
type === 'warning' ? 'var(--warning)' :
|
| 326 |
+
'var(--danger)';
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
async function resetEnvironment() {
|
| 330 |
+
const taskLevel = document.getElementById('taskLevel').value;
|
| 331 |
+
const seed = document.getElementById('seedInput').value || null;
|
| 332 |
+
|
| 333 |
+
updateStatus('Resetting...', 'warning');
|
| 334 |
+
|
| 335 |
+
try {
|
| 336 |
+
const response = await fetch('/reset', {
|
| 337 |
+
method: 'POST',
|
| 338 |
+
headers: {
|
| 339 |
+
'Content-Type': 'application/json',
|
| 340 |
+
...(sessionId ? { 'X-Session-ID': sessionId } : {})
|
| 341 |
+
},
|
| 342 |
+
body: JSON.stringify({ task_level: taskLevel, seed: seed ? parseInt(seed) : null })
|
| 343 |
+
});
|
| 344 |
+
|
| 345 |
+
const data = await response.json();
|
| 346 |
+
sessionId = data.session_id;
|
| 347 |
+
currentObservation = data.observation;
|
| 348 |
+
cumulativeReward = 0;
|
| 349 |
+
|
| 350 |
+
updateUI();
|
| 351 |
+
createActionButtons();
|
| 352 |
+
updateStatus('Episode Active', 'success');
|
| 353 |
+
} catch (error) {
|
| 354 |
+
console.error('Reset failed:', error);
|
| 355 |
+
updateStatus('Error', 'error');
|
| 356 |
+
}
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
async function takeAction(actionName) {
|
| 360 |
+
if (!sessionId) {
|
| 361 |
+
alert('Please reset environment first');
|
| 362 |
+
return;
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
try {
|
| 366 |
+
const response = await fetch('/step', {
|
| 367 |
+
method: 'POST',
|
| 368 |
+
headers: {
|
| 369 |
+
'Content-Type': 'application/json',
|
| 370 |
+
'X-Session-ID': sessionId
|
| 371 |
+
},
|
| 372 |
+
body: JSON.stringify({ action_name: actionName })
|
| 373 |
+
});
|
| 374 |
+
|
| 375 |
+
const data = await response.json();
|
| 376 |
+
currentObservation = data.observation;
|
| 377 |
+
cumulativeReward += data.reward;
|
| 378 |
+
|
| 379 |
+
updateUI();
|
| 380 |
+
|
| 381 |
+
if (data.done) {
|
| 382 |
+
updateStatus('Episode Complete', 'info');
|
| 383 |
+
alert(`Episode finished! Final reward: ${cumulativeReward.toFixed(2)}`);
|
| 384 |
+
}
|
| 385 |
+
} catch (error) {
|
| 386 |
+
console.error('Step failed:', error);
|
| 387 |
+
updateStatus('Error', 'error');
|
| 388 |
+
}
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
async function runBaseline() {
|
| 392 |
+
updateStatus('Running Baseline...', 'warning');
|
| 393 |
+
|
| 394 |
+
try {
|
| 395 |
+
const response = await fetch('/baseline/run', { method: 'POST' });
|
| 396 |
+
const data = await response.json();
|
| 397 |
+
|
| 398 |
+
let message = 'Baseline Complete!\n\n';
|
| 399 |
+
for (const [level, result] of Object.entries(data.results)) {
|
| 400 |
+
message += `${level}: score=${result.grade_score.toFixed(3)}, steps=${result.steps}\n`;
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
alert(message);
|
| 404 |
+
updateStatus('Ready', 'success');
|
| 405 |
+
} catch (error) {
|
| 406 |
+
console.error('Baseline failed:', error);
|
| 407 |
+
updateStatus('Error', 'error');
|
| 408 |
+
}
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
function updateUI() {
|
| 412 |
+
if (!currentObservation) return;
|
| 413 |
+
|
| 414 |
+
// Update ticket info
|
| 415 |
+
document.getElementById('ticketInfo').innerHTML = `
|
| 416 |
+
<p><strong>Ticket ID:</strong> ${currentObservation.ticket_id}</p>
|
| 417 |
+
<p><strong>Summary:</strong> ${currentObservation.ticket_summary}</p>
|
| 418 |
+
<p><strong>Task Level:</strong> ${currentObservation.task_level}</p>
|
| 419 |
+
`;
|
| 420 |
+
|
| 421 |
+
// Update observations
|
| 422 |
+
document.getElementById('observations').innerHTML = `
|
| 423 |
+
<p><strong>Network:</strong> ${currentObservation.network_status}</p>
|
| 424 |
+
<p><strong>VPN:</strong> ${currentObservation.vpn_status}</p>
|
| 425 |
+
<p><strong>Auth:</strong> ${currentObservation.auth_status}</p>
|
| 426 |
+
<p><strong>Escalation Allowed:</strong> ${currentObservation.escalation_allowed ? 'Yes' : 'No'}</p>
|
| 427 |
+
`;
|
| 428 |
+
|
| 429 |
+
// Update logs
|
| 430 |
+
const logsDiv = document.getElementById('systemLogs');
|
| 431 |
+
logsDiv.innerHTML = '';
|
| 432 |
+
currentObservation.system_logs.forEach(log => {
|
| 433 |
+
const entry = document.createElement('div');
|
| 434 |
+
entry.className = `log-entry ${log.level.toLowerCase()}`;
|
| 435 |
+
entry.textContent = `[${log.timestamp.split('T')[1].split('.')[0]}] ${log.level} ${log.service}: ${log.message}`;
|
| 436 |
+
logsDiv.appendChild(entry);
|
| 437 |
+
});
|
| 438 |
+
|
| 439 |
+
// Update action history
|
| 440 |
+
const historyDiv = document.getElementById('actionHistory');
|
| 441 |
+
historyDiv.innerHTML = '';
|
| 442 |
+
currentObservation.action_history.forEach(action => {
|
| 443 |
+
const item = document.createElement('div');
|
| 444 |
+
item.className = 'action-item';
|
| 445 |
+
item.innerHTML = `
|
| 446 |
+
<span>${action.step}. ${action.action_name}</span>
|
| 447 |
+
<span class="reward ${action.reward_received >= 0 ? 'positive' : 'negative'}">
|
| 448 |
+
${action.reward_received >= 0 ? '+' : ''}${action.reward_received.toFixed(2)}
|
| 449 |
+
</span>
|
| 450 |
+
`;
|
| 451 |
+
historyDiv.appendChild(item);
|
| 452 |
+
});
|
| 453 |
+
|
| 454 |
+
// Update metrics
|
| 455 |
+
document.getElementById('stepsValue').textContent = currentObservation.step_count;
|
| 456 |
+
document.getElementById('rewardValue').textContent = cumulativeReward.toFixed(2);
|
| 457 |
+
document.getElementById('diagnosticsValue').textContent = currentObservation.action_history.length;
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
function createActionButtons() {
|
| 461 |
+
const container = document.getElementById('actionButtons');
|
| 462 |
+
container.innerHTML = '';
|
| 463 |
+
|
| 464 |
+
actions.forEach(action => {
|
| 465 |
+
const btn = document.createElement('button');
|
| 466 |
+
btn.className = 'button button-secondary';
|
| 467 |
+
btn.textContent = action.replace(/_/g, ' ').toUpperCase();
|
| 468 |
+
btn.onclick = () => takeAction(action);
|
| 469 |
+
container.appendChild(btn);
|
| 470 |
+
});
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
// Initialize
|
| 474 |
+
updateStatus('Ready', 'success');
|
| 475 |
+
</script>
|
| 476 |
+
</body>
|
| 477 |
+
</html>
|
baseline/__init__.py
ADDED
|
File without changes
|
baseline/baseline_agent.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Baseline heuristic agent for SupportOps Arena.
|
| 3 |
+
Rule-based agent using decision tree logic (no ML required).
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from env.state import EnvObservation, NetworkStatus, VPNStatus, AuthStatus, ServiceHealth
|
| 10 |
+
from env.actions import ActionType
|
| 11 |
+
from env.environment import SupportOpsArena
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class BaselineAgent:
|
| 17 |
+
"""
|
| 18 |
+
Scripted heuristic baseline agent.
|
| 19 |
+
|
| 20 |
+
Uses a simple decision tree strategy:
|
| 21 |
+
1. Gather initial evidence (logs + auth)
|
| 22 |
+
2. Apply rule-based diagnostics based on observations
|
| 23 |
+
3. Choose lowest-risk remediation
|
| 24 |
+
4. Escalate if uncertain after gathering evidence
|
| 25 |
+
|
| 26 |
+
Expected performance:
|
| 27 |
+
- Easy: 0.65-0.75
|
| 28 |
+
- Medium: 0.40-0.55
|
| 29 |
+
- Hard: 0.20-0.35
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
def __init__(self, seed: int | None = None):
|
| 33 |
+
"""
|
| 34 |
+
Initialize baseline agent.
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
seed: Random seed for reproducibility (currently unused as agent is deterministic)
|
| 38 |
+
"""
|
| 39 |
+
self.seed = seed
|
| 40 |
+
self._action_history: list[str] = []
|
| 41 |
+
|
| 42 |
+
async def select_action(self, observation: EnvObservation) -> ActionType:
|
| 43 |
+
"""
|
| 44 |
+
Select next action based on current observation.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
observation: Current environment observation
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
ActionType to take
|
| 51 |
+
"""
|
| 52 |
+
step = observation.step_count
|
| 53 |
+
max_steps = observation.steps_remaining + step
|
| 54 |
+
|
| 55 |
+
# Phase 1: Initial diagnostic (steps 1-2)
|
| 56 |
+
if step == 0:
|
| 57 |
+
return ActionType.INSPECT_LOGS
|
| 58 |
+
|
| 59 |
+
if step == 1:
|
| 60 |
+
return ActionType.CHECK_AUTHENTICATION
|
| 61 |
+
|
| 62 |
+
# Phase 2: Targeted diagnostics (steps 2-4)
|
| 63 |
+
if step < 4:
|
| 64 |
+
# Check auth status
|
| 65 |
+
if observation.auth_status in [
|
| 66 |
+
AuthStatus.LOCKED,
|
| 67 |
+
AuthStatus.EXPIRED,
|
| 68 |
+
AuthStatus.MFA_FAIL
|
| 69 |
+
]:
|
| 70 |
+
if "check_permissions" not in self._action_history:
|
| 71 |
+
self._action_history.append("check_permissions")
|
| 72 |
+
return ActionType.CHECK_PERMISSIONS
|
| 73 |
+
|
| 74 |
+
# Check network status
|
| 75 |
+
if observation.network_status in [NetworkStatus.DEGRADED, NetworkStatus.DOWN]:
|
| 76 |
+
if "inspect_network" not in self._action_history:
|
| 77 |
+
self._action_history.append("inspect_network")
|
| 78 |
+
return ActionType.INSPECT_NETWORK
|
| 79 |
+
|
| 80 |
+
# Check VPN status
|
| 81 |
+
if observation.vpn_status in [VPNStatus.FAILED, VPNStatus.TIMEOUT]:
|
| 82 |
+
if "query_device_status" not in self._action_history:
|
| 83 |
+
self._action_history.append("query_device_status")
|
| 84 |
+
return ActionType.QUERY_DEVICE_STATUS
|
| 85 |
+
|
| 86 |
+
# Check for service issues
|
| 87 |
+
has_service_issues = any(
|
| 88 |
+
status in [ServiceHealth.DEGRADED, ServiceHealth.DOWN]
|
| 89 |
+
for status in observation.service_health.values()
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
if has_service_issues:
|
| 93 |
+
if "run_diagnostic_script" not in self._action_history:
|
| 94 |
+
self._action_history.append("run_diagnostic_script")
|
| 95 |
+
return ActionType.RUN_DIAGNOSTIC
|
| 96 |
+
|
| 97 |
+
# Default: query device
|
| 98 |
+
if "query_device_status" not in self._action_history:
|
| 99 |
+
self._action_history.append("query_device_status")
|
| 100 |
+
return ActionType.QUERY_DEVICE_STATUS
|
| 101 |
+
|
| 102 |
+
# Phase 3: Apply remediation (steps 4+)
|
| 103 |
+
# Determine remediation based on observations
|
| 104 |
+
remediation = self._determine_remediation(observation)
|
| 105 |
+
|
| 106 |
+
if remediation:
|
| 107 |
+
if remediation.value not in self._action_history:
|
| 108 |
+
self._action_history.append(remediation.value)
|
| 109 |
+
return remediation
|
| 110 |
+
|
| 111 |
+
# Phase 4: Resolve or escalate
|
| 112 |
+
# Check if we have enough evidence
|
| 113 |
+
diagnostic_count = len(self._action_history)
|
| 114 |
+
evidence_threshold = max_steps * 0.6
|
| 115 |
+
|
| 116 |
+
# If we've gathered enough evidence and applied a fix, resolve
|
| 117 |
+
if diagnostic_count >= 4 and len(self._action_history) >= 5:
|
| 118 |
+
# Check if escalation is available and we're uncertain
|
| 119 |
+
if observation.escalation_allowed and diagnostic_count < evidence_threshold * 0.8:
|
| 120 |
+
self._action_history.append("escalate_ticket")
|
| 121 |
+
return ActionType.ESCALATE_TICKET
|
| 122 |
+
|
| 123 |
+
# Otherwise resolve
|
| 124 |
+
self._action_history.append("resolve_ticket")
|
| 125 |
+
return ActionType.RESOLVE_TICKET
|
| 126 |
+
|
| 127 |
+
# Need more diagnostics or stuck - escalate if possible
|
| 128 |
+
if observation.escalation_allowed and diagnostic_count >= evidence_threshold:
|
| 129 |
+
self._action_history.append("escalate_ticket")
|
| 130 |
+
return ActionType.ESCALATE_TICKET
|
| 131 |
+
|
| 132 |
+
# Gather more evidence
|
| 133 |
+
if "search_internal_kb" not in self._action_history:
|
| 134 |
+
self._action_history.append("search_internal_kb")
|
| 135 |
+
return ActionType.SEARCH_INTERNAL_KB
|
| 136 |
+
|
| 137 |
+
# Last resort: resolve
|
| 138 |
+
self._action_history.append("resolve_ticket")
|
| 139 |
+
return ActionType.RESOLVE_TICKET
|
| 140 |
+
|
| 141 |
+
def _determine_remediation(self, observation: EnvObservation) -> ActionType | None:
|
| 142 |
+
"""
|
| 143 |
+
Determine appropriate remediation based on observations.
|
| 144 |
+
|
| 145 |
+
Returns:
|
| 146 |
+
Remediation action or None if more diagnostics needed
|
| 147 |
+
"""
|
| 148 |
+
# Auth issues → reset credentials
|
| 149 |
+
if observation.auth_status in [
|
| 150 |
+
AuthStatus.LOCKED,
|
| 151 |
+
AuthStatus.EXPIRED,
|
| 152 |
+
AuthStatus.MFA_FAIL
|
| 153 |
+
]:
|
| 154 |
+
if observation.diagnostic_steps_taken >= 2:
|
| 155 |
+
return ActionType.RESET_CREDENTIALS
|
| 156 |
+
|
| 157 |
+
# Network/DNS issues → flush DNS or reconfigure
|
| 158 |
+
if observation.network_status in [NetworkStatus.DEGRADED, NetworkStatus.DOWN]:
|
| 159 |
+
if observation.diagnostic_steps_taken >= 2:
|
| 160 |
+
# Check if it's likely DNS
|
| 161 |
+
dns_indicators = sum(
|
| 162 |
+
1 for log in observation.system_logs
|
| 163 |
+
if "dns" in log.message.lower()
|
| 164 |
+
)
|
| 165 |
+
if dns_indicators > 0:
|
| 166 |
+
return ActionType.FLUSH_DNS
|
| 167 |
+
else:
|
| 168 |
+
return ActionType.RECONFIGURE_CLIENT
|
| 169 |
+
|
| 170 |
+
# VPN issues → reconfigure client
|
| 171 |
+
if observation.vpn_status in [VPNStatus.FAILED, VPNStatus.TIMEOUT]:
|
| 172 |
+
if observation.diagnostic_steps_taken >= 2:
|
| 173 |
+
return ActionType.RECONFIGURE_CLIENT
|
| 174 |
+
|
| 175 |
+
# Service issues → restart service (high risk, so be cautious)
|
| 176 |
+
has_critical_service_down = any(
|
| 177 |
+
status == ServiceHealth.DOWN
|
| 178 |
+
for status in observation.service_health.values()
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
if has_critical_service_down:
|
| 182 |
+
if observation.diagnostic_steps_taken >= 3:
|
| 183 |
+
return ActionType.RESTART_SERVICE
|
| 184 |
+
|
| 185 |
+
# No clear remediation yet
|
| 186 |
+
return None
|
| 187 |
+
|
| 188 |
+
async def run_episode(
|
| 189 |
+
self,
|
| 190 |
+
env: SupportOpsArena,
|
| 191 |
+
task_level: str = "easy"
|
| 192 |
+
) -> dict[str, Any]:
|
| 193 |
+
"""
|
| 194 |
+
Run one complete episode.
|
| 195 |
+
|
| 196 |
+
Args:
|
| 197 |
+
env: Environment instance
|
| 198 |
+
task_level: Difficulty level
|
| 199 |
+
|
| 200 |
+
Returns:
|
| 201 |
+
Dictionary with episode results
|
| 202 |
+
"""
|
| 203 |
+
# Reset tracking
|
| 204 |
+
self._action_history = []
|
| 205 |
+
|
| 206 |
+
# Reset environment
|
| 207 |
+
observation = await env.reset(task_level=task_level, seed=self.seed)
|
| 208 |
+
|
| 209 |
+
total_reward = 0.0
|
| 210 |
+
done = False
|
| 211 |
+
steps = 0
|
| 212 |
+
|
| 213 |
+
logger.info(f"Starting baseline agent episode on {task_level}")
|
| 214 |
+
|
| 215 |
+
# Run episode
|
| 216 |
+
while not done and steps < 50: # Safety limit
|
| 217 |
+
# Select action
|
| 218 |
+
action = await self.select_action(observation)
|
| 219 |
+
|
| 220 |
+
# Take step
|
| 221 |
+
result = await env.step(action)
|
| 222 |
+
|
| 223 |
+
observation = result.observation
|
| 224 |
+
total_reward += result.reward
|
| 225 |
+
done = result.done
|
| 226 |
+
steps += 1
|
| 227 |
+
|
| 228 |
+
logger.debug(
|
| 229 |
+
f"Step {steps}: {action.value} -> reward={result.reward:.2f}, "
|
| 230 |
+
f"done={done}"
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
# Get final state
|
| 234 |
+
final_state = await env.state()
|
| 235 |
+
|
| 236 |
+
logger.info(
|
| 237 |
+
f"Episode completed: steps={steps}, "
|
| 238 |
+
f"reward={total_reward:.2f}, "
|
| 239 |
+
f"score={final_state.cumulative_reward:.2f}"
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
return {
|
| 243 |
+
"steps": steps,
|
| 244 |
+
"total_reward": total_reward,
|
| 245 |
+
"cumulative_reward": final_state.cumulative_reward,
|
| 246 |
+
"done": done,
|
| 247 |
+
"task_level": task_level,
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
def reset(self) -> None:
|
| 251 |
+
"""Reset agent state for new episode."""
|
| 252 |
+
self._action_history = []
|
env/__init__.py
ADDED
|
File without changes
|
env/actions.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Action definitions and metadata for SupportOps Arena.
|
| 3 |
+
Defines all 15 actions available to agents with their characteristics.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from enum import Enum
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class ActionType(str, Enum):
|
| 11 |
+
"""All available actions in the environment."""
|
| 12 |
+
# Diagnostic Actions (Information Gathering)
|
| 13 |
+
INSPECT_NETWORK = "inspect_network"
|
| 14 |
+
INSPECT_LOGS = "inspect_logs"
|
| 15 |
+
CHECK_AUTHENTICATION = "check_authentication"
|
| 16 |
+
CHECK_PERMISSIONS = "check_permissions"
|
| 17 |
+
QUERY_DEVICE_STATUS = "query_device_status"
|
| 18 |
+
SEARCH_INTERNAL_KB = "search_internal_kb"
|
| 19 |
+
CONTACT_USER = "contact_user_for_info"
|
| 20 |
+
RUN_DIAGNOSTIC = "run_diagnostic_script"
|
| 21 |
+
|
| 22 |
+
# Remediation Actions (Risk-bearing)
|
| 23 |
+
FLUSH_DNS = "flush_dns"
|
| 24 |
+
RECONFIGURE_CLIENT = "reconfigure_client"
|
| 25 |
+
RESTART_SERVICE = "restart_service"
|
| 26 |
+
RESET_CREDENTIALS = "reset_credentials"
|
| 27 |
+
|
| 28 |
+
# Terminal Actions
|
| 29 |
+
ESCALATE_TICKET = "escalate_ticket"
|
| 30 |
+
RESOLVE_TICKET = "resolve_ticket"
|
| 31 |
+
CLOSE_WITHOUT_FIX = "close_without_fix"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class ActionMetadata:
|
| 36 |
+
"""
|
| 37 |
+
Metadata for each action type.
|
| 38 |
+
|
| 39 |
+
Attributes:
|
| 40 |
+
risk_level: Impact risk - "LOW" | "MED" | "HIGH" | "NONE"
|
| 41 |
+
info_yield: Information value - "HIGH" | "MED" | "LOW" | "NONE"
|
| 42 |
+
step_cost: Step count cost (always 1 currently)
|
| 43 |
+
requires_prior_diagnostic: Must have diagnostic evidence first
|
| 44 |
+
can_disrupt_other_users: May affect other users
|
| 45 |
+
is_terminal: Ends the episode
|
| 46 |
+
"""
|
| 47 |
+
risk_level: str
|
| 48 |
+
info_yield: str
|
| 49 |
+
step_cost: int
|
| 50 |
+
requires_prior_diagnostic: bool
|
| 51 |
+
can_disrupt_other_users: bool
|
| 52 |
+
is_terminal: bool
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# Action metadata lookup table
|
| 56 |
+
ACTION_METADATA: dict[ActionType, ActionMetadata] = {
|
| 57 |
+
# Diagnostic actions - low risk, high information
|
| 58 |
+
ActionType.INSPECT_NETWORK: ActionMetadata(
|
| 59 |
+
risk_level="LOW",
|
| 60 |
+
info_yield="HIGH",
|
| 61 |
+
step_cost=1,
|
| 62 |
+
requires_prior_diagnostic=False,
|
| 63 |
+
can_disrupt_other_users=False,
|
| 64 |
+
is_terminal=False
|
| 65 |
+
),
|
| 66 |
+
ActionType.INSPECT_LOGS: ActionMetadata(
|
| 67 |
+
risk_level="LOW",
|
| 68 |
+
info_yield="HIGH",
|
| 69 |
+
step_cost=1,
|
| 70 |
+
requires_prior_diagnostic=False,
|
| 71 |
+
can_disrupt_other_users=False,
|
| 72 |
+
is_terminal=False
|
| 73 |
+
),
|
| 74 |
+
ActionType.CHECK_AUTHENTICATION: ActionMetadata(
|
| 75 |
+
risk_level="LOW",
|
| 76 |
+
info_yield="HIGH",
|
| 77 |
+
step_cost=1,
|
| 78 |
+
requires_prior_diagnostic=False,
|
| 79 |
+
can_disrupt_other_users=False,
|
| 80 |
+
is_terminal=False
|
| 81 |
+
),
|
| 82 |
+
ActionType.CHECK_PERMISSIONS: ActionMetadata(
|
| 83 |
+
risk_level="LOW",
|
| 84 |
+
info_yield="MED",
|
| 85 |
+
step_cost=1,
|
| 86 |
+
requires_prior_diagnostic=False,
|
| 87 |
+
can_disrupt_other_users=False,
|
| 88 |
+
is_terminal=False
|
| 89 |
+
),
|
| 90 |
+
ActionType.QUERY_DEVICE_STATUS: ActionMetadata(
|
| 91 |
+
risk_level="LOW",
|
| 92 |
+
info_yield="MED",
|
| 93 |
+
step_cost=1,
|
| 94 |
+
requires_prior_diagnostic=False,
|
| 95 |
+
can_disrupt_other_users=False,
|
| 96 |
+
is_terminal=False
|
| 97 |
+
),
|
| 98 |
+
ActionType.SEARCH_INTERNAL_KB: ActionMetadata(
|
| 99 |
+
risk_level="LOW",
|
| 100 |
+
info_yield="MED",
|
| 101 |
+
step_cost=1,
|
| 102 |
+
requires_prior_diagnostic=False,
|
| 103 |
+
can_disrupt_other_users=False,
|
| 104 |
+
is_terminal=False
|
| 105 |
+
),
|
| 106 |
+
ActionType.CONTACT_USER: ActionMetadata(
|
| 107 |
+
risk_level="LOW",
|
| 108 |
+
info_yield="MED",
|
| 109 |
+
step_cost=1,
|
| 110 |
+
requires_prior_diagnostic=False,
|
| 111 |
+
can_disrupt_other_users=False,
|
| 112 |
+
is_terminal=False
|
| 113 |
+
),
|
| 114 |
+
ActionType.RUN_DIAGNOSTIC: ActionMetadata(
|
| 115 |
+
risk_level="MED",
|
| 116 |
+
info_yield="HIGH",
|
| 117 |
+
step_cost=1,
|
| 118 |
+
requires_prior_diagnostic=True,
|
| 119 |
+
can_disrupt_other_users=False,
|
| 120 |
+
is_terminal=False
|
| 121 |
+
),
|
| 122 |
+
|
| 123 |
+
# Remediation actions - medium/high risk
|
| 124 |
+
ActionType.FLUSH_DNS: ActionMetadata(
|
| 125 |
+
risk_level="MED",
|
| 126 |
+
info_yield="LOW",
|
| 127 |
+
step_cost=1,
|
| 128 |
+
requires_prior_diagnostic=False,
|
| 129 |
+
can_disrupt_other_users=False,
|
| 130 |
+
is_terminal=False
|
| 131 |
+
),
|
| 132 |
+
ActionType.RECONFIGURE_CLIENT: ActionMetadata(
|
| 133 |
+
risk_level="MED",
|
| 134 |
+
info_yield="LOW",
|
| 135 |
+
step_cost=1,
|
| 136 |
+
requires_prior_diagnostic=False,
|
| 137 |
+
can_disrupt_other_users=False,
|
| 138 |
+
is_terminal=False
|
| 139 |
+
),
|
| 140 |
+
ActionType.RESTART_SERVICE: ActionMetadata(
|
| 141 |
+
risk_level="HIGH",
|
| 142 |
+
info_yield="LOW",
|
| 143 |
+
step_cost=1,
|
| 144 |
+
requires_prior_diagnostic=True,
|
| 145 |
+
can_disrupt_other_users=True,
|
| 146 |
+
is_terminal=False
|
| 147 |
+
),
|
| 148 |
+
ActionType.RESET_CREDENTIALS: ActionMetadata(
|
| 149 |
+
risk_level="HIGH",
|
| 150 |
+
info_yield="LOW",
|
| 151 |
+
step_cost=1,
|
| 152 |
+
requires_prior_diagnostic=True,
|
| 153 |
+
can_disrupt_other_users=False,
|
| 154 |
+
is_terminal=False
|
| 155 |
+
),
|
| 156 |
+
|
| 157 |
+
# Terminal actions
|
| 158 |
+
ActionType.ESCALATE_TICKET: ActionMetadata(
|
| 159 |
+
risk_level="NONE",
|
| 160 |
+
info_yield="NONE",
|
| 161 |
+
step_cost=1,
|
| 162 |
+
requires_prior_diagnostic=False,
|
| 163 |
+
can_disrupt_other_users=False,
|
| 164 |
+
is_terminal=True
|
| 165 |
+
),
|
| 166 |
+
ActionType.RESOLVE_TICKET: ActionMetadata(
|
| 167 |
+
risk_level="NONE",
|
| 168 |
+
info_yield="NONE",
|
| 169 |
+
step_cost=1,
|
| 170 |
+
requires_prior_diagnostic=False,
|
| 171 |
+
can_disrupt_other_users=False,
|
| 172 |
+
is_terminal=True
|
| 173 |
+
),
|
| 174 |
+
ActionType.CLOSE_WITHOUT_FIX: ActionMetadata(
|
| 175 |
+
risk_level="NONE",
|
| 176 |
+
info_yield="NONE",
|
| 177 |
+
step_cost=1,
|
| 178 |
+
requires_prior_diagnostic=False,
|
| 179 |
+
can_disrupt_other_users=False,
|
| 180 |
+
is_terminal=True
|
| 181 |
+
),
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def get_diagnostic_actions() -> list[ActionType]:
|
| 186 |
+
"""Return all diagnostic (information gathering) actions."""
|
| 187 |
+
return [
|
| 188 |
+
ActionType.INSPECT_NETWORK,
|
| 189 |
+
ActionType.INSPECT_LOGS,
|
| 190 |
+
ActionType.CHECK_AUTHENTICATION,
|
| 191 |
+
ActionType.CHECK_PERMISSIONS,
|
| 192 |
+
ActionType.QUERY_DEVICE_STATUS,
|
| 193 |
+
ActionType.SEARCH_INTERNAL_KB,
|
| 194 |
+
ActionType.CONTACT_USER,
|
| 195 |
+
ActionType.RUN_DIAGNOSTIC,
|
| 196 |
+
]
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def get_remediation_actions() -> list[ActionType]:
|
| 200 |
+
"""Return all remediation (fix) actions."""
|
| 201 |
+
return [
|
| 202 |
+
ActionType.FLUSH_DNS,
|
| 203 |
+
ActionType.RECONFIGURE_CLIENT,
|
| 204 |
+
ActionType.RESTART_SERVICE,
|
| 205 |
+
ActionType.RESET_CREDENTIALS,
|
| 206 |
+
]
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def get_terminal_actions() -> list[ActionType]:
|
| 210 |
+
"""Return all terminal actions."""
|
| 211 |
+
return [
|
| 212 |
+
ActionType.ESCALATE_TICKET,
|
| 213 |
+
ActionType.RESOLVE_TICKET,
|
| 214 |
+
ActionType.CLOSE_WITHOUT_FIX,
|
| 215 |
+
]
|
env/environment.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SupportOps Arena - Core OpenEnv-compliant environment.
|
| 3 |
+
Main environment class implementing the full OpenEnv interface.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import uuid
|
| 7 |
+
import logging
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from env.state import (
|
| 12 |
+
EnvState, EnvObservation, StepResult, HiddenState,
|
| 13 |
+
TaskLevel, ActionRecord, NetworkStatus, VPNStatus,
|
| 14 |
+
AuthStatus, ServiceHealth
|
| 15 |
+
)
|
| 16 |
+
from env.actions import ActionType, ACTION_METADATA
|
| 17 |
+
from env.scenarios import TicketGenerator
|
| 18 |
+
from env.transitions import StateMachine
|
| 19 |
+
from env.rewards import RewardCalculator
|
| 20 |
+
|
| 21 |
+
# Setup logging
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ─── Configuration ────────────────────────────────────────────
|
| 26 |
+
|
| 27 |
+
MAX_STEPS = {
|
| 28 |
+
TaskLevel.EASY: 10,
|
| 29 |
+
TaskLevel.MEDIUM: 16,
|
| 30 |
+
TaskLevel.HARD: 24,
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class SupportOpsArena:
|
| 35 |
+
"""
|
| 36 |
+
OpenEnv-compliant environment for enterprise IT incident triage.
|
| 37 |
+
|
| 38 |
+
An RL environment simulating IT support operations where agents must:
|
| 39 |
+
- Gather evidence through sequential tool use
|
| 40 |
+
- Reason about hidden root causes
|
| 41 |
+
- Apply risk-weighted remediation actions
|
| 42 |
+
- Escalate when autonomous resolution is unsafe
|
| 43 |
+
|
| 44 |
+
Implements full OpenEnv interface:
|
| 45 |
+
- async reset(task_level: str, seed: int | None) -> EnvObservation
|
| 46 |
+
- async step(action: ActionType) -> StepResult
|
| 47 |
+
- async state() -> EnvState
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
def __init__(self, adversary: Any | None = None):
|
| 51 |
+
"""
|
| 52 |
+
Initialize the environment.
|
| 53 |
+
|
| 54 |
+
Args:
|
| 55 |
+
adversary: Optional AdaptiveAdversary instance for difficulty adaptation
|
| 56 |
+
"""
|
| 57 |
+
self.ticket_generator = TicketGenerator()
|
| 58 |
+
self.state_machine = StateMachine()
|
| 59 |
+
self.reward_calculator = RewardCalculator()
|
| 60 |
+
self.adversary = adversary
|
| 61 |
+
|
| 62 |
+
self._current_state: EnvState | None = None
|
| 63 |
+
self._episode_active = False
|
| 64 |
+
|
| 65 |
+
logger.info("SupportOpsArena environment initialized")
|
| 66 |
+
|
| 67 |
+
async def reset(
|
| 68 |
+
self,
|
| 69 |
+
task_level: str = "easy",
|
| 70 |
+
seed: int | None = None
|
| 71 |
+
) -> EnvObservation:
|
| 72 |
+
"""
|
| 73 |
+
Reset environment and start new episode.
|
| 74 |
+
|
| 75 |
+
Args:
|
| 76 |
+
task_level: Difficulty level - "easy", "medium", or "hard"
|
| 77 |
+
seed: Random seed for reproducibility
|
| 78 |
+
|
| 79 |
+
Returns:
|
| 80 |
+
Initial observation for the episode
|
| 81 |
+
|
| 82 |
+
Raises:
|
| 83 |
+
ValueError: If task_level is not valid
|
| 84 |
+
"""
|
| 85 |
+
# Validate task level
|
| 86 |
+
if task_level not in ["easy", "medium", "hard"]:
|
| 87 |
+
raise ValueError(f"Invalid task_level: {task_level}. Must be 'easy', 'medium', or 'hard'")
|
| 88 |
+
|
| 89 |
+
task_level_enum = TaskLevel(task_level)
|
| 90 |
+
|
| 91 |
+
# Generate new episode ID
|
| 92 |
+
episode_id = str(uuid.uuid4())
|
| 93 |
+
|
| 94 |
+
logger.info(f"Starting episode {episode_id} at {task_level} difficulty (seed={seed})")
|
| 95 |
+
|
| 96 |
+
# Apply adversary sampling weights if available
|
| 97 |
+
if self.adversary:
|
| 98 |
+
weights = self.adversary.get_sampling_weights(task_level_enum)
|
| 99 |
+
self.ticket_generator.set_sampling_weights(weights)
|
| 100 |
+
|
| 101 |
+
# Generate ticket scenario
|
| 102 |
+
ticket_data = self.ticket_generator.generate_ticket(task_level_enum, seed)
|
| 103 |
+
root_cause_data = ticket_data["root_cause"]
|
| 104 |
+
obs_template = ticket_data["observation_template"]
|
| 105 |
+
misleading_idx = ticket_data.get("misleading_log_index")
|
| 106 |
+
|
| 107 |
+
# Create hidden state
|
| 108 |
+
hidden = HiddenState(
|
| 109 |
+
root_cause=root_cause_data["description"],
|
| 110 |
+
root_cause_category=root_cause_data["category"],
|
| 111 |
+
correct_remediation=root_cause_data["correct_remediation"].value,
|
| 112 |
+
correct_remediation_alts=[
|
| 113 |
+
alt.value if isinstance(alt, ActionType) else alt
|
| 114 |
+
for alt in root_cause_data.get("correct_remediation_alts", [])
|
| 115 |
+
],
|
| 116 |
+
misleading_log_index=misleading_idx,
|
| 117 |
+
severity=root_cause_data["severity"],
|
| 118 |
+
affected_users_count=root_cause_data["affected_users"],
|
| 119 |
+
ticket_variant_id=root_cause_data["id"]
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# Create initial observation
|
| 123 |
+
max_steps = MAX_STEPS[task_level_enum]
|
| 124 |
+
observation = EnvObservation(
|
| 125 |
+
ticket_id=f"INC-{episode_id[:8].upper()}",
|
| 126 |
+
ticket_summary=obs_template["ticket_summary"],
|
| 127 |
+
user_context=obs_template["user_context"],
|
| 128 |
+
network_status=obs_template.get("network_status", NetworkStatus.UNKNOWN),
|
| 129 |
+
vpn_status=obs_template.get("vpn_status", VPNStatus.NA),
|
| 130 |
+
auth_status=obs_template.get("auth_status", AuthStatus.OK),
|
| 131 |
+
service_health=obs_template.get("service_health", {}),
|
| 132 |
+
system_logs=obs_template["system_logs"],
|
| 133 |
+
action_history=[],
|
| 134 |
+
step_count=0,
|
| 135 |
+
escalation_allowed=False,
|
| 136 |
+
task_level=task_level_enum,
|
| 137 |
+
steps_remaining=max_steps
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
# Create full state
|
| 141 |
+
self._current_state = EnvState(
|
| 142 |
+
episode_id=episode_id,
|
| 143 |
+
task_level=task_level_enum,
|
| 144 |
+
observation=observation,
|
| 145 |
+
hidden=hidden,
|
| 146 |
+
cumulative_reward=0.0,
|
| 147 |
+
done=False,
|
| 148 |
+
created_at=datetime.utcnow().isoformat(),
|
| 149 |
+
action_log=[],
|
| 150 |
+
diagnostic_steps_taken=0
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
self._episode_active = True
|
| 154 |
+
|
| 155 |
+
logger.info(f"Episode {episode_id} initialized: {root_cause_data['id']}")
|
| 156 |
+
|
| 157 |
+
return observation
|
| 158 |
+
|
| 159 |
+
async def step(self, action: ActionType) -> StepResult:
|
| 160 |
+
"""
|
| 161 |
+
Execute one step in the environment.
|
| 162 |
+
|
| 163 |
+
Args:
|
| 164 |
+
action: Action to take (ActionType enum)
|
| 165 |
+
|
| 166 |
+
Returns:
|
| 167 |
+
StepResult containing (observation, reward, done, info)
|
| 168 |
+
|
| 169 |
+
Raises:
|
| 170 |
+
RuntimeError: If episode is not active or already terminated
|
| 171 |
+
ValueError: If action is invalid
|
| 172 |
+
"""
|
| 173 |
+
if not self._episode_active or self._current_state is None:
|
| 174 |
+
raise RuntimeError("Episode not active. Call reset() first.")
|
| 175 |
+
|
| 176 |
+
if self._current_state.done:
|
| 177 |
+
raise RuntimeError("Episode already terminated. Call reset() to start new episode.")
|
| 178 |
+
|
| 179 |
+
# Validate action
|
| 180 |
+
if not isinstance(action, ActionType):
|
| 181 |
+
logger.error(f"Invalid action type: {action}")
|
| 182 |
+
# Return penalty for invalid action
|
| 183 |
+
info = {
|
| 184 |
+
"episode_id": self._current_state.episode_id,
|
| 185 |
+
"step": self._current_state.observation.step_count,
|
| 186 |
+
"root_cause": None,
|
| 187 |
+
"reward_breakdown": {"HARMFUL_ACTION": -0.35},
|
| 188 |
+
"correct": False
|
| 189 |
+
}
|
| 190 |
+
return StepResult(
|
| 191 |
+
observation=self._current_state.observation,
|
| 192 |
+
reward=-0.35,
|
| 193 |
+
done=False,
|
| 194 |
+
info=info
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
logger.debug(f"Step {self._current_state.observation.step_count + 1}: {action.value}")
|
| 198 |
+
|
| 199 |
+
# Apply state transition
|
| 200 |
+
next_state, reward_events = await self.state_machine.transition(
|
| 201 |
+
self._current_state,
|
| 202 |
+
action
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
# Check terminal conditions and add terminal rewards
|
| 206 |
+
done, terminal_events = self._check_terminal_conditions(next_state, action)
|
| 207 |
+
reward_events.extend(terminal_events)
|
| 208 |
+
|
| 209 |
+
# Calculate reward
|
| 210 |
+
reward, reward_breakdown = self.reward_calculator.calculate(
|
| 211 |
+
reward_events,
|
| 212 |
+
next_state,
|
| 213 |
+
action
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
# Update state
|
| 217 |
+
next_state.cumulative_reward += reward
|
| 218 |
+
next_state.observation.step_count += 1
|
| 219 |
+
next_state.observation.steps_remaining -= 1
|
| 220 |
+
next_state.done = done
|
| 221 |
+
|
| 222 |
+
# Record action in history
|
| 223 |
+
action_record = ActionRecord(
|
| 224 |
+
step=next_state.observation.step_count,
|
| 225 |
+
action_name=action.value,
|
| 226 |
+
rationale=None,
|
| 227 |
+
reward_received=reward,
|
| 228 |
+
timestamp=datetime.utcnow().isoformat()
|
| 229 |
+
)
|
| 230 |
+
next_state.observation.action_history.append(action_record)
|
| 231 |
+
next_state.action_log.append(action_record)
|
| 232 |
+
|
| 233 |
+
# Update adversary if available
|
| 234 |
+
if self.adversary and done:
|
| 235 |
+
await self.adversary.update_policy_tracker(
|
| 236 |
+
next_state.hidden.ticket_variant_id,
|
| 237 |
+
next_state.action_log,
|
| 238 |
+
next_state.cumulative_reward
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
# Build info dict
|
| 242 |
+
info: dict[str, Any] = {
|
| 243 |
+
"episode_id": next_state.episode_id,
|
| 244 |
+
"step": next_state.observation.step_count,
|
| 245 |
+
"root_cause": None, # Hidden until episode ends
|
| 246 |
+
"reward_breakdown": reward_breakdown,
|
| 247 |
+
"correct": False
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
# Reveal ground truth at episode end
|
| 251 |
+
if done:
|
| 252 |
+
info["root_cause"] = next_state.hidden.root_cause
|
| 253 |
+
info["correct"] = self._check_correctness(next_state, action)
|
| 254 |
+
info["episode_score"] = self.reward_calculator.calculate_episode_score(
|
| 255 |
+
next_state.cumulative_reward,
|
| 256 |
+
next_state.task_level
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
logger.info(
|
| 260 |
+
f"Episode {next_state.episode_id} completed: "
|
| 261 |
+
f"score={info['episode_score']:.3f}, "
|
| 262 |
+
f"correct={info['correct']}, "
|
| 263 |
+
f"steps={next_state.observation.step_count}"
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
# Update current state
|
| 267 |
+
self._current_state = next_state
|
| 268 |
+
|
| 269 |
+
if done:
|
| 270 |
+
self._episode_active = False
|
| 271 |
+
|
| 272 |
+
return StepResult(
|
| 273 |
+
observation=next_state.observation,
|
| 274 |
+
reward=reward,
|
| 275 |
+
done=done,
|
| 276 |
+
info=info
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
async def state(self) -> EnvState:
|
| 280 |
+
"""
|
| 281 |
+
Return full internal state including hidden information.
|
| 282 |
+
|
| 283 |
+
This method is for graders only and should not be called by agents
|
| 284 |
+
during active episodes.
|
| 285 |
+
|
| 286 |
+
Returns:
|
| 287 |
+
Complete EnvState including hidden state
|
| 288 |
+
|
| 289 |
+
Raises:
|
| 290 |
+
RuntimeError: If no episode is active
|
| 291 |
+
"""
|
| 292 |
+
if self._current_state is None:
|
| 293 |
+
raise RuntimeError("No active episode. Call reset() first.")
|
| 294 |
+
|
| 295 |
+
return self._current_state
|
| 296 |
+
|
| 297 |
+
def _check_terminal_conditions(
|
| 298 |
+
self,
|
| 299 |
+
state: EnvState,
|
| 300 |
+
action: ActionType
|
| 301 |
+
) -> tuple[bool, list[str]]:
|
| 302 |
+
"""
|
| 303 |
+
Check if episode should terminate and return terminal reward events.
|
| 304 |
+
|
| 305 |
+
Returns:
|
| 306 |
+
Tuple of (done: bool, terminal_reward_events: list[str])
|
| 307 |
+
"""
|
| 308 |
+
events = []
|
| 309 |
+
|
| 310 |
+
# Check if action is terminal
|
| 311 |
+
action_meta = ACTION_METADATA.get(action)
|
| 312 |
+
if action_meta and action_meta.is_terminal:
|
| 313 |
+
if action == ActionType.ESCALATE_TICKET:
|
| 314 |
+
# Check if escalation was premature (already handled in transition)
|
| 315 |
+
if not state.observation.escalation_allowed:
|
| 316 |
+
# Don't terminate if premature
|
| 317 |
+
return False, events
|
| 318 |
+
|
| 319 |
+
# Check if escalation is justified
|
| 320 |
+
if state.diagnostic_steps_taken >= 3:
|
| 321 |
+
events.append("JUSTIFIED_ESCALATION")
|
| 322 |
+
else:
|
| 323 |
+
events.append("UNJUSTIFIED_ESCALATION")
|
| 324 |
+
|
| 325 |
+
# Escalation is always "correct" if allowed and justified
|
| 326 |
+
if state.diagnostic_steps_taken >= 3:
|
| 327 |
+
events.append("TERMINAL_CORRECT")
|
| 328 |
+
|
| 329 |
+
return True, events
|
| 330 |
+
|
| 331 |
+
elif action == ActionType.RESOLVE_TICKET:
|
| 332 |
+
# Check if correct remediation
|
| 333 |
+
correct_action = state.hidden.correct_remediation
|
| 334 |
+
alt_actions = state.hidden.correct_remediation_alts
|
| 335 |
+
|
| 336 |
+
# Check if we applied the correct fix earlier
|
| 337 |
+
past_actions = [a.action_name for a in state.observation.action_history]
|
| 338 |
+
|
| 339 |
+
if correct_action in past_actions:
|
| 340 |
+
events.append("CORRECT_REMEDIATION")
|
| 341 |
+
events.append("TERMINAL_CORRECT")
|
| 342 |
+
|
| 343 |
+
# Check for optimal resolution
|
| 344 |
+
if self.reward_calculator.is_optimal_resolution(
|
| 345 |
+
state.observation.step_count,
|
| 346 |
+
state.task_level
|
| 347 |
+
):
|
| 348 |
+
events.append("OPTIMAL_RESOLUTION")
|
| 349 |
+
else:
|
| 350 |
+
# Check alternatives
|
| 351 |
+
alt_found = any(alt in past_actions for alt in alt_actions)
|
| 352 |
+
if alt_found:
|
| 353 |
+
events.append("CORRECT_REMEDIATION")
|
| 354 |
+
events.append("TERMINAL_CORRECT")
|
| 355 |
+
else:
|
| 356 |
+
events.append("INCORRECT_REMEDIATION")
|
| 357 |
+
|
| 358 |
+
return True, events
|
| 359 |
+
|
| 360 |
+
elif action == ActionType.CLOSE_WITHOUT_FIX:
|
| 361 |
+
events.append("PREMATURE_CLOSURE")
|
| 362 |
+
return True, events
|
| 363 |
+
|
| 364 |
+
# Check step budget
|
| 365 |
+
max_steps = MAX_STEPS[state.task_level]
|
| 366 |
+
if state.observation.step_count >= max_steps:
|
| 367 |
+
# Budget exceeded
|
| 368 |
+
remaining_steps = max_steps - state.observation.step_count
|
| 369 |
+
if remaining_steps < 0:
|
| 370 |
+
for _ in range(abs(remaining_steps)):
|
| 371 |
+
events.append("BUDGET_EXCEEDED_PER_STEP")
|
| 372 |
+
|
| 373 |
+
return True, events
|
| 374 |
+
|
| 375 |
+
return False, events
|
| 376 |
+
|
| 377 |
+
def _check_correctness(self, state: EnvState, action: ActionType) -> bool:
|
| 378 |
+
"""Check if episode was resolved correctly."""
|
| 379 |
+
if action == ActionType.RESOLVE_TICKET:
|
| 380 |
+
correct_action = state.hidden.correct_remediation
|
| 381 |
+
alt_actions = state.hidden.correct_remediation_alts
|
| 382 |
+
past_actions = [a.action_name for a in state.observation.action_history]
|
| 383 |
+
|
| 384 |
+
return (
|
| 385 |
+
correct_action in past_actions or
|
| 386 |
+
any(alt in past_actions for alt in alt_actions)
|
| 387 |
+
)
|
| 388 |
+
elif action == ActionType.ESCALATE_TICKET:
|
| 389 |
+
# Escalation with sufficient evidence is considered correct
|
| 390 |
+
return state.diagnostic_steps_taken >= 3
|
| 391 |
+
else:
|
| 392 |
+
return False
|
env/rewards.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Reward calculation system for SupportOps Arena.
|
| 3 |
+
Implements dense reward shaping with detailed breakdown tracking.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Any
|
| 7 |
+
from env.state import EnvState, TaskLevel
|
| 8 |
+
from env.actions import ActionType
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# ─── Reward Events ────────────────────────────────────────────
|
| 12 |
+
|
| 13 |
+
REWARD_EVENTS = {
|
| 14 |
+
# Positive rewards
|
| 15 |
+
"DIAGNOSTIC_ACTION": +0.10,
|
| 16 |
+
"CORRECT_TARGETED_DIAGNOSTIC": +0.20,
|
| 17 |
+
"EVIDENCE_BEFORE_RISKY": +0.05,
|
| 18 |
+
"CORRECT_REMEDIATION": +0.40,
|
| 19 |
+
"OPTIMAL_RESOLUTION": +0.20, # Only if steps_used <= min_required_steps
|
| 20 |
+
"JUSTIFIED_ESCALATION": +0.30,
|
| 21 |
+
"TERMINAL_CORRECT": +1.00,
|
| 22 |
+
|
| 23 |
+
# Negative rewards
|
| 24 |
+
"REDUNDANT_ACTION": -0.05,
|
| 25 |
+
"RISKY_WITHOUT_DIAGNOSTIC": -0.15,
|
| 26 |
+
"INCORRECT_REMEDIATION": -0.25,
|
| 27 |
+
"HARMFUL_ACTION": -0.35,
|
| 28 |
+
"PREMATURE_CLOSURE": -0.40,
|
| 29 |
+
"BUDGET_EXCEEDED_PER_STEP": -0.02,
|
| 30 |
+
"PREMATURE_ESCALATION": -0.20, # Escalate before unlock
|
| 31 |
+
"UNJUSTIFIED_ESCALATION": -0.20, # Escalate without enough evidence
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ─── Configuration ────────────────────────────────────────────
|
| 36 |
+
|
| 37 |
+
MIN_STEPS_FOR_OPTIMAL = {
|
| 38 |
+
TaskLevel.EASY: 4,
|
| 39 |
+
TaskLevel.MEDIUM: 7,
|
| 40 |
+
TaskLevel.HARD: 12,
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
MAX_POSSIBLE_REWARD = {
|
| 44 |
+
TaskLevel.EASY: 2.05,
|
| 45 |
+
TaskLevel.MEDIUM: 2.35,
|
| 46 |
+
TaskLevel.HARD: 2.75,
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class RewardCalculator:
|
| 51 |
+
"""
|
| 52 |
+
Calculates rewards for agent actions with dense reward shaping.
|
| 53 |
+
Tracks individual reward events for interpretability.
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
def calculate(
|
| 57 |
+
self,
|
| 58 |
+
events: list[str],
|
| 59 |
+
state: EnvState,
|
| 60 |
+
action: ActionType
|
| 61 |
+
) -> tuple[float, dict[str, Any]]:
|
| 62 |
+
"""
|
| 63 |
+
Calculate total reward from a list of reward event keys.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
events: List of reward event keys (e.g., ["DIAGNOSTIC_ACTION", "CORRECT_TARGETED_DIAGNOSTIC"])
|
| 67 |
+
state: Current environment state
|
| 68 |
+
action: Action that was taken
|
| 69 |
+
|
| 70 |
+
Returns:
|
| 71 |
+
Tuple of (total_reward, breakdown_dict)
|
| 72 |
+
- total_reward: Sum of all event rewards
|
| 73 |
+
- breakdown_dict: Dictionary mapping event keys to their reward values
|
| 74 |
+
"""
|
| 75 |
+
breakdown = {}
|
| 76 |
+
total_reward = 0.0
|
| 77 |
+
|
| 78 |
+
for event_key in events:
|
| 79 |
+
if event_key not in REWARD_EVENTS:
|
| 80 |
+
raise ValueError(f"Unknown reward event: {event_key}")
|
| 81 |
+
|
| 82 |
+
reward_value = REWARD_EVENTS[event_key]
|
| 83 |
+
breakdown[event_key] = reward_value
|
| 84 |
+
total_reward += reward_value
|
| 85 |
+
|
| 86 |
+
return total_reward, breakdown
|
| 87 |
+
|
| 88 |
+
def calculate_episode_score(
|
| 89 |
+
self,
|
| 90 |
+
cumulative_reward: float,
|
| 91 |
+
task_level: TaskLevel
|
| 92 |
+
) -> float:
|
| 93 |
+
"""
|
| 94 |
+
Normalize cumulative reward to episode score in [0, 1].
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
cumulative_reward: Total reward accumulated during episode
|
| 98 |
+
task_level: Difficulty level of the task
|
| 99 |
+
|
| 100 |
+
Returns:
|
| 101 |
+
Normalized score clamped to [0, 1]
|
| 102 |
+
"""
|
| 103 |
+
max_reward = MAX_POSSIBLE_REWARD[task_level]
|
| 104 |
+
score = cumulative_reward / max_reward
|
| 105 |
+
return max(0.0, min(1.0, score))
|
| 106 |
+
|
| 107 |
+
def is_optimal_resolution(
|
| 108 |
+
self,
|
| 109 |
+
step_count: int,
|
| 110 |
+
task_level: TaskLevel
|
| 111 |
+
) -> bool:
|
| 112 |
+
"""
|
| 113 |
+
Check if resolution was achieved within optimal step count.
|
| 114 |
+
|
| 115 |
+
Args:
|
| 116 |
+
step_count: Number of steps taken
|
| 117 |
+
task_level: Difficulty level
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
True if step_count <= minimum optimal steps for this level
|
| 121 |
+
"""
|
| 122 |
+
return step_count <= MIN_STEPS_FOR_OPTIMAL[task_level]
|
| 123 |
+
|
| 124 |
+
def get_max_possible_reward(self, task_level: TaskLevel) -> float:
|
| 125 |
+
"""Get maximum possible reward for a task level."""
|
| 126 |
+
return MAX_POSSIBLE_REWARD[task_level]
|
| 127 |
+
|
| 128 |
+
def validate_events(self, events: list[str]) -> bool:
|
| 129 |
+
"""
|
| 130 |
+
Validate that all event keys are recognized.
|
| 131 |
+
|
| 132 |
+
Args:
|
| 133 |
+
events: List of event keys to validate
|
| 134 |
+
|
| 135 |
+
Returns:
|
| 136 |
+
True if all events are valid
|
| 137 |
+
|
| 138 |
+
Raises:
|
| 139 |
+
ValueError: If any event key is not recognized
|
| 140 |
+
"""
|
| 141 |
+
for event in events:
|
| 142 |
+
if event not in REWARD_EVENTS:
|
| 143 |
+
raise ValueError(f"Unknown reward event: {event}")
|
| 144 |
+
return True
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def get_reward_event_description(event_key: str) -> str:
|
| 148 |
+
"""Get human-readable description of a reward event."""
|
| 149 |
+
descriptions = {
|
| 150 |
+
"DIAGNOSTIC_ACTION": "Performed a diagnostic action to gather information",
|
| 151 |
+
"CORRECT_TARGETED_DIAGNOSTIC": "Performed diagnostic action that targets the actual root cause",
|
| 152 |
+
"EVIDENCE_BEFORE_RISKY": "Gathered evidence before taking risky action",
|
| 153 |
+
"CORRECT_REMEDIATION": "Applied the correct fix for the root cause",
|
| 154 |
+
"OPTIMAL_RESOLUTION": "Resolved issue within optimal step count",
|
| 155 |
+
"JUSTIFIED_ESCALATION": "Escalated with sufficient evidence",
|
| 156 |
+
"TERMINAL_CORRECT": "Episode terminated with correct resolution",
|
| 157 |
+
"REDUNDANT_ACTION": "Repeated an action already performed",
|
| 158 |
+
"RISKY_WITHOUT_DIAGNOSTIC": "Took risky action without prior diagnostics",
|
| 159 |
+
"INCORRECT_REMEDIATION": "Applied wrong fix for the root cause",
|
| 160 |
+
"HARMFUL_ACTION": "Action caused harm or disruption",
|
| 161 |
+
"PREMATURE_CLOSURE": "Closed ticket without proper resolution",
|
| 162 |
+
"BUDGET_EXCEEDED_PER_STEP": "Exceeded step budget (per step penalty)",
|
| 163 |
+
"PREMATURE_ESCALATION": "Escalated before gathering minimum evidence",
|
| 164 |
+
"UNJUSTIFIED_ESCALATION": "Escalated without sufficient diagnostic evidence",
|
| 165 |
+
}
|
| 166 |
+
return descriptions.get(event_key, f"Unknown event: {event_key}")
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def get_all_positive_events() -> list[str]:
|
| 170 |
+
"""Get list of all positive reward event keys."""
|
| 171 |
+
return [k for k, v in REWARD_EVENTS.items() if v > 0]
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def get_all_negative_events() -> list[str]:
|
| 175 |
+
"""Get list of all negative reward event keys."""
|
| 176 |
+
return [k for k, v in REWARD_EVENTS.items() if v < 0]
|
env/scenarios.py
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Ticket generation scenarios for SupportOps Arena.
|
| 3 |
+
Defines root causes and generates tickets for easy, medium, and hard difficulty levels.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import random
|
| 7 |
+
from datetime import datetime, timedelta
|
| 8 |
+
from typing import Any
|
| 9 |
+
from env.actions import ActionType
|
| 10 |
+
from env.state import (
|
| 11 |
+
UserContext, LogEntry, NetworkStatus, VPNStatus,
|
| 12 |
+
AuthStatus, ServiceHealth, TaskLevel
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# ─── Easy Task Root Causes ───────────────────────────────────
|
| 17 |
+
|
| 18 |
+
TASK_EASY_ROOT_CAUSES = [
|
| 19 |
+
{
|
| 20 |
+
"id": "wifi_adapter_disabled",
|
| 21 |
+
"description": "Network adapter disabled by OS update",
|
| 22 |
+
"category": "hardware",
|
| 23 |
+
"correct_remediation": ActionType.RECONFIGURE_CLIENT,
|
| 24 |
+
"correct_remediation_alts": [],
|
| 25 |
+
"diagnostic_path": [ActionType.QUERY_DEVICE_STATUS, ActionType.INSPECT_NETWORK],
|
| 26 |
+
"severity": "low",
|
| 27 |
+
"affected_users": 1
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"id": "wifi_wrong_credentials",
|
| 31 |
+
"description": "Incorrect saved credentials for SSID",
|
| 32 |
+
"category": "auth",
|
| 33 |
+
"correct_remediation": ActionType.RESET_CREDENTIALS,
|
| 34 |
+
"correct_remediation_alts": [],
|
| 35 |
+
"diagnostic_path": [ActionType.CHECK_AUTHENTICATION, ActionType.INSPECT_LOGS],
|
| 36 |
+
"severity": "low",
|
| 37 |
+
"affected_users": 1
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
"id": "wifi_dns_corruption",
|
| 41 |
+
"description": "Local DNS cache corruption",
|
| 42 |
+
"category": "dns",
|
| 43 |
+
"correct_remediation": ActionType.FLUSH_DNS,
|
| 44 |
+
"correct_remediation_alts": [ActionType.RECONFIGURE_CLIENT],
|
| 45 |
+
"diagnostic_path": [ActionType.INSPECT_NETWORK, ActionType.INSPECT_LOGS],
|
| 46 |
+
"severity": "low",
|
| 47 |
+
"affected_users": 1
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
"id": "wifi_airplane_mode",
|
| 51 |
+
"description": "Airplane mode enabled by accident",
|
| 52 |
+
"category": "hardware",
|
| 53 |
+
"correct_remediation": ActionType.RECONFIGURE_CLIENT,
|
| 54 |
+
"correct_remediation_alts": [],
|
| 55 |
+
"diagnostic_path": [ActionType.QUERY_DEVICE_STATUS],
|
| 56 |
+
"severity": "low",
|
| 57 |
+
"affected_users": 1
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"id": "wifi_dhcp_exhausted",
|
| 61 |
+
"description": "DHCP lease pool exhausted",
|
| 62 |
+
"category": "network",
|
| 63 |
+
"correct_remediation": ActionType.RESTART_SERVICE,
|
| 64 |
+
"correct_remediation_alts": [ActionType.ESCALATE_TICKET],
|
| 65 |
+
"diagnostic_path": [ActionType.INSPECT_NETWORK, ActionType.RUN_DIAGNOSTIC],
|
| 66 |
+
"severity": "medium",
|
| 67 |
+
"affected_users": 15
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
"id": "wifi_wrong_ssid",
|
| 71 |
+
"description": "Client connected to wrong SSID (guest vs corporate)",
|
| 72 |
+
"category": "network",
|
| 73 |
+
"correct_remediation": ActionType.RECONFIGURE_CLIENT,
|
| 74 |
+
"correct_remediation_alts": [],
|
| 75 |
+
"diagnostic_path": [ActionType.QUERY_DEVICE_STATUS, ActionType.INSPECT_NETWORK],
|
| 76 |
+
"severity": "low",
|
| 77 |
+
"affected_users": 1
|
| 78 |
+
},
|
| 79 |
+
]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# ─── Medium Task Root Causes ──────────────────────────────────
|
| 83 |
+
|
| 84 |
+
TASK_MEDIUM_ROOT_CAUSES = [
|
| 85 |
+
{
|
| 86 |
+
"id": "expired_password",
|
| 87 |
+
"description": "User password expired, blocking VPN authentication",
|
| 88 |
+
"category": "auth",
|
| 89 |
+
"correct_remediation": ActionType.RESET_CREDENTIALS,
|
| 90 |
+
"correct_remediation_alts": [ActionType.ESCALATE_TICKET],
|
| 91 |
+
"diagnostic_path": [ActionType.CHECK_AUTHENTICATION, ActionType.INSPECT_LOGS],
|
| 92 |
+
"severity": "medium",
|
| 93 |
+
"affected_users": 1
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
"id": "vpn_version_mismatch",
|
| 97 |
+
"description": "VPN client version incompatible with gateway",
|
| 98 |
+
"category": "vpn",
|
| 99 |
+
"correct_remediation": ActionType.RECONFIGURE_CLIENT,
|
| 100 |
+
"correct_remediation_alts": [ActionType.ESCALATE_TICKET],
|
| 101 |
+
"diagnostic_path": [ActionType.QUERY_DEVICE_STATUS, ActionType.SEARCH_INTERNAL_KB],
|
| 102 |
+
"severity": "medium",
|
| 103 |
+
"affected_users": 1
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
"id": "dns_split_tunnel_conflict",
|
| 107 |
+
"description": "Split-tunnel DNS configuration conflict",
|
| 108 |
+
"category": "dns",
|
| 109 |
+
"correct_remediation": ActionType.FLUSH_DNS,
|
| 110 |
+
"correct_remediation_alts": [ActionType.RECONFIGURE_CLIENT],
|
| 111 |
+
"diagnostic_path": [ActionType.INSPECT_NETWORK, ActionType.RUN_DIAGNOSTIC],
|
| 112 |
+
"severity": "medium",
|
| 113 |
+
"affected_users": 1
|
| 114 |
+
},
|
| 115 |
+
{
|
| 116 |
+
"id": "mfa_token_desync",
|
| 117 |
+
"description": "MFA token out of sync with authentication server",
|
| 118 |
+
"category": "auth",
|
| 119 |
+
"correct_remediation": ActionType.RESET_CREDENTIALS,
|
| 120 |
+
"correct_remediation_alts": [ActionType.ESCALATE_TICKET],
|
| 121 |
+
"diagnostic_path": [ActionType.CHECK_AUTHENTICATION, ActionType.CONTACT_USER],
|
| 122 |
+
"severity": "medium",
|
| 123 |
+
"affected_users": 1
|
| 124 |
+
},
|
| 125 |
+
{
|
| 126 |
+
"id": "firewall_subnet_block",
|
| 127 |
+
"description": "Firewall blocking specific subnet for remote users",
|
| 128 |
+
"category": "network",
|
| 129 |
+
"correct_remediation": ActionType.ESCALATE_TICKET,
|
| 130 |
+
"correct_remediation_alts": [],
|
| 131 |
+
"diagnostic_path": [ActionType.INSPECT_NETWORK, ActionType.RUN_DIAGNOSTIC],
|
| 132 |
+
"severity": "high",
|
| 133 |
+
"affected_users": 8
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"id": "certificate_expiry",
|
| 137 |
+
"description": "VPN SSL certificate expired on gateway",
|
| 138 |
+
"category": "vpn",
|
| 139 |
+
"correct_remediation": ActionType.ESCALATE_TICKET,
|
| 140 |
+
"correct_remediation_alts": [],
|
| 141 |
+
"diagnostic_path": [ActionType.INSPECT_LOGS, ActionType.SEARCH_INTERNAL_KB],
|
| 142 |
+
"severity": "high",
|
| 143 |
+
"affected_users": 12
|
| 144 |
+
},
|
| 145 |
+
]
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# ─── Hard Task Root Causes ────────────────────────────────────
|
| 149 |
+
|
| 150 |
+
TASK_HARD_ROOT_CAUSES = [
|
| 151 |
+
{
|
| 152 |
+
"id": "sso_token_service_degradation",
|
| 153 |
+
"description": "SSO token service degraded in datacenter-east, affecting multiple applications",
|
| 154 |
+
"category": "sso",
|
| 155 |
+
"correct_remediation": ActionType.ESCALATE_TICKET,
|
| 156 |
+
"correct_remediation_alts": [],
|
| 157 |
+
"diagnostic_path": [
|
| 158 |
+
ActionType.INSPECT_LOGS,
|
| 159 |
+
ActionType.CHECK_AUTHENTICATION,
|
| 160 |
+
ActionType.RUN_DIAGNOSTIC
|
| 161 |
+
],
|
| 162 |
+
"severity": "critical",
|
| 163 |
+
"affected_users": 150,
|
| 164 |
+
"multi_ticket": True
|
| 165 |
+
},
|
| 166 |
+
{
|
| 167 |
+
"id": "ldap_replication_lag",
|
| 168 |
+
"description": "LDAP replication lag causing inconsistent auth across regions",
|
| 169 |
+
"category": "auth",
|
| 170 |
+
"correct_remediation": ActionType.ESCALATE_TICKET,
|
| 171 |
+
"correct_remediation_alts": [],
|
| 172 |
+
"diagnostic_path": [
|
| 173 |
+
ActionType.CHECK_AUTHENTICATION,
|
| 174 |
+
ActionType.INSPECT_LOGS,
|
| 175 |
+
ActionType.SEARCH_INTERNAL_KB
|
| 176 |
+
],
|
| 177 |
+
"severity": "critical",
|
| 178 |
+
"affected_users": 200,
|
| 179 |
+
"multi_ticket": True
|
| 180 |
+
},
|
| 181 |
+
{
|
| 182 |
+
"id": "dns_zone_transfer_failure",
|
| 183 |
+
"description": "DNS zone transfer failure causing resolution inconsistencies",
|
| 184 |
+
"category": "dns",
|
| 185 |
+
"correct_remediation": ActionType.ESCALATE_TICKET,
|
| 186 |
+
"correct_remediation_alts": [],
|
| 187 |
+
"diagnostic_path": [
|
| 188 |
+
ActionType.INSPECT_NETWORK,
|
| 189 |
+
ActionType.INSPECT_LOGS,
|
| 190 |
+
ActionType.RUN_DIAGNOSTIC
|
| 191 |
+
],
|
| 192 |
+
"severity": "critical",
|
| 193 |
+
"affected_users": 300,
|
| 194 |
+
"multi_ticket": True
|
| 195 |
+
},
|
| 196 |
+
{
|
| 197 |
+
"id": "proxy_cascading_failure",
|
| 198 |
+
"description": "Web proxy cascading failure affecting multiple services",
|
| 199 |
+
"category": "network",
|
| 200 |
+
"correct_remediation": ActionType.ESCALATE_TICKET,
|
| 201 |
+
"correct_remediation_alts": [],
|
| 202 |
+
"diagnostic_path": [
|
| 203 |
+
ActionType.INSPECT_NETWORK,
|
| 204 |
+
ActionType.INSPECT_LOGS,
|
| 205 |
+
ActionType.SEARCH_INTERNAL_KB
|
| 206 |
+
],
|
| 207 |
+
"severity": "critical",
|
| 208 |
+
"affected_users": 250,
|
| 209 |
+
"multi_ticket": True
|
| 210 |
+
},
|
| 211 |
+
{
|
| 212 |
+
"id": "certificate_chain_break",
|
| 213 |
+
"description": "Intermediate certificate chain break affecting SSL validation",
|
| 214 |
+
"category": "network",
|
| 215 |
+
"correct_remediation": ActionType.ESCALATE_TICKET,
|
| 216 |
+
"correct_remediation_alts": [],
|
| 217 |
+
"diagnostic_path": [
|
| 218 |
+
ActionType.INSPECT_LOGS,
|
| 219 |
+
ActionType.RUN_DIAGNOSTIC,
|
| 220 |
+
ActionType.SEARCH_INTERNAL_KB
|
| 221 |
+
],
|
| 222 |
+
"severity": "critical",
|
| 223 |
+
"affected_users": 180,
|
| 224 |
+
"multi_ticket": True
|
| 225 |
+
},
|
| 226 |
+
{
|
| 227 |
+
"id": "storage_backend_degradation",
|
| 228 |
+
"description": "Shared storage backend degradation affecting file services",
|
| 229 |
+
"category": "hardware",
|
| 230 |
+
"correct_remediation": ActionType.ESCALATE_TICKET,
|
| 231 |
+
"correct_remediation_alts": [],
|
| 232 |
+
"diagnostic_path": [
|
| 233 |
+
ActionType.QUERY_DEVICE_STATUS,
|
| 234 |
+
ActionType.INSPECT_LOGS,
|
| 235 |
+
ActionType.RUN_DIAGNOSTIC
|
| 236 |
+
],
|
| 237 |
+
"severity": "critical",
|
| 238 |
+
"affected_users": 220,
|
| 239 |
+
"multi_ticket": True
|
| 240 |
+
},
|
| 241 |
+
]
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
class TicketGenerator:
|
| 245 |
+
"""
|
| 246 |
+
Generates realistic IT support tickets with configurable difficulty.
|
| 247 |
+
Supports adversarial sampling weights for adaptive difficulty.
|
| 248 |
+
"""
|
| 249 |
+
|
| 250 |
+
def __init__(self, seed: int | None = None):
|
| 251 |
+
self.rng = random.Random(seed)
|
| 252 |
+
self._sampling_weights: dict[str, float] = {}
|
| 253 |
+
|
| 254 |
+
def set_sampling_weights(self, weights: dict[str, float]) -> None:
|
| 255 |
+
"""Update sampling weights from adversary."""
|
| 256 |
+
self._sampling_weights = weights
|
| 257 |
+
|
| 258 |
+
def generate_ticket(
|
| 259 |
+
self,
|
| 260 |
+
task_level: TaskLevel,
|
| 261 |
+
seed: int | None = None
|
| 262 |
+
) -> dict[str, Any]:
|
| 263 |
+
"""
|
| 264 |
+
Generate a ticket scenario for the given difficulty level.
|
| 265 |
+
|
| 266 |
+
Returns:
|
| 267 |
+
Dictionary containing all ticket components:
|
| 268 |
+
- root_cause: The ground truth root cause info
|
| 269 |
+
- observation_template: Initial observation data
|
| 270 |
+
- misleading_log_index: Index of misleading log (if any)
|
| 271 |
+
"""
|
| 272 |
+
if seed is not None:
|
| 273 |
+
self.rng = random.Random(seed)
|
| 274 |
+
|
| 275 |
+
if task_level == TaskLevel.EASY:
|
| 276 |
+
return self._generate_easy_ticket()
|
| 277 |
+
elif task_level == TaskLevel.MEDIUM:
|
| 278 |
+
return self._generate_medium_ticket()
|
| 279 |
+
elif task_level == TaskLevel.HARD:
|
| 280 |
+
return self._generate_hard_ticket()
|
| 281 |
+
else:
|
| 282 |
+
raise ValueError(f"Invalid task level: {task_level}")
|
| 283 |
+
|
| 284 |
+
def _generate_easy_ticket(self) -> dict[str, Any]:
|
| 285 |
+
"""Generate easy difficulty ticket (Wi-Fi connectivity)."""
|
| 286 |
+
# Apply sampling weights if available
|
| 287 |
+
causes = TASK_EASY_ROOT_CAUSES.copy()
|
| 288 |
+
if self._sampling_weights:
|
| 289 |
+
weights = [self._sampling_weights.get(c["id"], 1.0) for c in causes]
|
| 290 |
+
root_cause = self.rng.choices(causes, weights=weights, k=1)[0]
|
| 291 |
+
else:
|
| 292 |
+
root_cause = self.rng.choice(causes)
|
| 293 |
+
|
| 294 |
+
# Generate user context
|
| 295 |
+
user_context = self._generate_user_context()
|
| 296 |
+
|
| 297 |
+
# Generate initial logs (no misleading entries for easy)
|
| 298 |
+
logs = self._generate_initial_logs_easy(root_cause)
|
| 299 |
+
|
| 300 |
+
# Create observation template
|
| 301 |
+
observation_template = {
|
| 302 |
+
"ticket_summary": "Hi, I can't connect to Wi-Fi and need help urgently.",
|
| 303 |
+
"user_context": user_context,
|
| 304 |
+
"network_status": NetworkStatus.UNKNOWN,
|
| 305 |
+
"vpn_status": VPNStatus.NA,
|
| 306 |
+
"auth_status": AuthStatus.OK,
|
| 307 |
+
"service_health": {
|
| 308 |
+
"dhcp": ServiceHealth.UNKNOWN,
|
| 309 |
+
"dns": ServiceHealth.UNKNOWN,
|
| 310 |
+
"wifi": ServiceHealth.UNKNOWN,
|
| 311 |
+
},
|
| 312 |
+
"system_logs": logs,
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
return {
|
| 316 |
+
"root_cause": root_cause,
|
| 317 |
+
"observation_template": observation_template,
|
| 318 |
+
"misleading_log_index": None,
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
def _generate_medium_ticket(self) -> dict[str, Any]:
|
| 322 |
+
"""Generate medium difficulty ticket (VPN access)."""
|
| 323 |
+
causes = TASK_MEDIUM_ROOT_CAUSES.copy()
|
| 324 |
+
if self._sampling_weights:
|
| 325 |
+
weights = [self._sampling_weights.get(c["id"], 1.0) for c in causes]
|
| 326 |
+
root_cause = self.rng.choices(causes, weights=weights, k=1)[0]
|
| 327 |
+
else:
|
| 328 |
+
root_cause = self.rng.choice(causes)
|
| 329 |
+
|
| 330 |
+
user_context = self._generate_user_context()
|
| 331 |
+
user_context.location = "remote"
|
| 332 |
+
|
| 333 |
+
# Generate logs with 1 misleading entry
|
| 334 |
+
logs, misleading_idx = self._generate_initial_logs_medium(root_cause)
|
| 335 |
+
|
| 336 |
+
observation_template = {
|
| 337 |
+
"ticket_summary": "I'm working from home and can't access any internal tools through VPN. It was working fine yesterday.",
|
| 338 |
+
"user_context": user_context,
|
| 339 |
+
"network_status": NetworkStatus.UNKNOWN,
|
| 340 |
+
"vpn_status": VPNStatus.NA,
|
| 341 |
+
"auth_status": AuthStatus.OK,
|
| 342 |
+
"service_health": {
|
| 343 |
+
"vpn_client": ServiceHealth.UNKNOWN,
|
| 344 |
+
"vpn_gateway": ServiceHealth.UNKNOWN,
|
| 345 |
+
"dns": ServiceHealth.UNKNOWN,
|
| 346 |
+
},
|
| 347 |
+
"system_logs": logs,
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
return {
|
| 351 |
+
"root_cause": root_cause,
|
| 352 |
+
"observation_template": observation_template,
|
| 353 |
+
"misleading_log_index": misleading_idx,
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
def _generate_hard_ticket(self) -> dict[str, Any]:
|
| 357 |
+
"""Generate hard difficulty ticket (multi-service failure)."""
|
| 358 |
+
causes = TASK_HARD_ROOT_CAUSES.copy()
|
| 359 |
+
if self._sampling_weights:
|
| 360 |
+
weights = [self._sampling_weights.get(c["id"], 1.0) for c in causes]
|
| 361 |
+
root_cause = self.rng.choices(causes, weights=weights, k=1)[0]
|
| 362 |
+
else:
|
| 363 |
+
root_cause = self.rng.choice(causes)
|
| 364 |
+
|
| 365 |
+
user_context = self._generate_user_context()
|
| 366 |
+
|
| 367 |
+
# Generate logs with multiple misleading entries
|
| 368 |
+
logs, misleading_idx = self._generate_initial_logs_hard(root_cause)
|
| 369 |
+
|
| 370 |
+
observation_template = {
|
| 371 |
+
"ticket_summary": "Multiple users reporting access issues across different systems. Email and collaboration tools are affected.",
|
| 372 |
+
"user_context": user_context,
|
| 373 |
+
"network_status": NetworkStatus.UNKNOWN,
|
| 374 |
+
"vpn_status": VPNStatus.NA,
|
| 375 |
+
"auth_status": AuthStatus.OK,
|
| 376 |
+
"service_health": {
|
| 377 |
+
"mail": ServiceHealth.UNKNOWN,
|
| 378 |
+
"sharepoint": ServiceHealth.UNKNOWN,
|
| 379 |
+
"teams": ServiceHealth.UNKNOWN,
|
| 380 |
+
"sso": ServiceHealth.UNKNOWN,
|
| 381 |
+
},
|
| 382 |
+
"system_logs": logs,
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
return {
|
| 386 |
+
"root_cause": root_cause,
|
| 387 |
+
"observation_template": observation_template,
|
| 388 |
+
"misleading_log_index": misleading_idx,
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
def _generate_user_context(self) -> UserContext:
|
| 392 |
+
"""Generate randomized user context."""
|
| 393 |
+
departments = ["Engineering", "Sales", "Finance", "HR", "Operations"]
|
| 394 |
+
roles = ["Manager", "Analyst", "Developer", "Coordinator", "Specialist"]
|
| 395 |
+
devices = ["Laptop", "Desktop", "Tablet"]
|
| 396 |
+
os_versions = ["Windows 11", "Windows 10", "macOS 14", "macOS 13"]
|
| 397 |
+
locations = ["onsite", "remote"]
|
| 398 |
+
|
| 399 |
+
return UserContext(
|
| 400 |
+
department=self.rng.choice(departments),
|
| 401 |
+
role=self.rng.choice(roles),
|
| 402 |
+
device_type=self.rng.choice(devices),
|
| 403 |
+
os_version=self.rng.choice(os_versions),
|
| 404 |
+
location=self.rng.choice(locations),
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
def _generate_initial_logs_easy(self, root_cause: dict) -> list[LogEntry]:
|
| 408 |
+
"""Generate initial logs for easy task (no misleading entries)."""
|
| 409 |
+
now = datetime.utcnow()
|
| 410 |
+
logs = []
|
| 411 |
+
|
| 412 |
+
# Add generic startup logs
|
| 413 |
+
logs.append(LogEntry(
|
| 414 |
+
timestamp=(now - timedelta(minutes=5)).isoformat(),
|
| 415 |
+
level="INFO",
|
| 416 |
+
service="NetworkManager",
|
| 417 |
+
message="Network service initialized",
|
| 418 |
+
is_misleading=False
|
| 419 |
+
))
|
| 420 |
+
|
| 421 |
+
# Add category-relevant log
|
| 422 |
+
category = root_cause["category"]
|
| 423 |
+
if category == "dns":
|
| 424 |
+
logs.append(LogEntry(
|
| 425 |
+
timestamp=(now - timedelta(minutes=3)).isoformat(),
|
| 426 |
+
level="WARN",
|
| 427 |
+
service="DNSResolver",
|
| 428 |
+
message="DNS resolution timeout for internal.company.com",
|
| 429 |
+
is_misleading=False
|
| 430 |
+
))
|
| 431 |
+
elif category == "auth":
|
| 432 |
+
logs.append(LogEntry(
|
| 433 |
+
timestamp=(now - timedelta(minutes=2)).isoformat(),
|
| 434 |
+
level="ERROR",
|
| 435 |
+
service="WiFiAuth",
|
| 436 |
+
message="Authentication failed: Invalid credentials",
|
| 437 |
+
is_misleading=False
|
| 438 |
+
))
|
| 439 |
+
elif category == "hardware":
|
| 440 |
+
logs.append(LogEntry(
|
| 441 |
+
timestamp=(now - timedelta(minutes=1)).isoformat(),
|
| 442 |
+
level="WARN",
|
| 443 |
+
service="DeviceManager",
|
| 444 |
+
message="Network adapter state changed",
|
| 445 |
+
is_misleading=False
|
| 446 |
+
))
|
| 447 |
+
|
| 448 |
+
logs.append(LogEntry(
|
| 449 |
+
timestamp=now.isoformat(),
|
| 450 |
+
level="ERROR",
|
| 451 |
+
service="NetworkManager",
|
| 452 |
+
message="Connection attempt failed",
|
| 453 |
+
is_misleading=False
|
| 454 |
+
))
|
| 455 |
+
|
| 456 |
+
return logs
|
| 457 |
+
|
| 458 |
+
def _generate_initial_logs_medium(
|
| 459 |
+
self, root_cause: dict
|
| 460 |
+
) -> tuple[list[LogEntry], int]:
|
| 461 |
+
"""Generate logs for medium task with 1 misleading entry."""
|
| 462 |
+
now = datetime.utcnow()
|
| 463 |
+
logs = []
|
| 464 |
+
|
| 465 |
+
# Add real logs
|
| 466 |
+
logs.append(LogEntry(
|
| 467 |
+
timestamp=(now - timedelta(minutes=10)).isoformat(),
|
| 468 |
+
level="INFO",
|
| 469 |
+
service="VPNClient",
|
| 470 |
+
message="VPN client started",
|
| 471 |
+
is_misleading=False
|
| 472 |
+
))
|
| 473 |
+
|
| 474 |
+
category = root_cause["category"]
|
| 475 |
+
if category == "auth":
|
| 476 |
+
logs.append(LogEntry(
|
| 477 |
+
timestamp=(now - timedelta(minutes=7)).isoformat(),
|
| 478 |
+
level="ERROR",
|
| 479 |
+
service="AuthService",
|
| 480 |
+
message=f"Authentication failed: {root_cause['description']}",
|
| 481 |
+
is_misleading=False
|
| 482 |
+
))
|
| 483 |
+
elif category == "vpn":
|
| 484 |
+
logs.append(LogEntry(
|
| 485 |
+
timestamp=(now - timedelta(minutes=6)).isoformat(),
|
| 486 |
+
level="WARN",
|
| 487 |
+
service="VPNGateway",
|
| 488 |
+
message=f"VPN handshake failed: {root_cause['description']}",
|
| 489 |
+
is_misleading=False
|
| 490 |
+
))
|
| 491 |
+
|
| 492 |
+
# Add misleading log (at random position)
|
| 493 |
+
misleading_messages = [
|
| 494 |
+
"Network latency spike detected (250ms avg)",
|
| 495 |
+
"DNS query timeout for vpn.company.com",
|
| 496 |
+
"Firewall rule evaluation took longer than expected",
|
| 497 |
+
]
|
| 498 |
+
misleading_log = LogEntry(
|
| 499 |
+
timestamp=(now - timedelta(minutes=4)).isoformat(),
|
| 500 |
+
level="WARN",
|
| 501 |
+
service="NetworkMonitor",
|
| 502 |
+
message=self.rng.choice(misleading_messages),
|
| 503 |
+
is_misleading=True
|
| 504 |
+
)
|
| 505 |
+
|
| 506 |
+
# Insert misleading log at random position (not first or last)
|
| 507 |
+
insert_pos = self.rng.randint(1, len(logs))
|
| 508 |
+
logs.insert(insert_pos, misleading_log)
|
| 509 |
+
|
| 510 |
+
logs.append(LogEntry(
|
| 511 |
+
timestamp=now.isoformat(),
|
| 512 |
+
level="ERROR",
|
| 513 |
+
service="VPNClient",
|
| 514 |
+
message="Connection failed after 3 retries",
|
| 515 |
+
is_misleading=False
|
| 516 |
+
))
|
| 517 |
+
|
| 518 |
+
return logs, insert_pos
|
| 519 |
+
|
| 520 |
+
def _generate_initial_logs_hard(
|
| 521 |
+
self, root_cause: dict
|
| 522 |
+
) -> tuple[list[LogEntry], int | None]:
|
| 523 |
+
"""Generate logs for hard task with multiple misleading entries."""
|
| 524 |
+
now = datetime.utcnow()
|
| 525 |
+
logs = []
|
| 526 |
+
|
| 527 |
+
# Add system logs with contradictory evidence
|
| 528 |
+
logs.append(LogEntry(
|
| 529 |
+
timestamp=(now - timedelta(minutes=15)).isoformat(),
|
| 530 |
+
level="INFO",
|
| 531 |
+
service="SystemMonitor",
|
| 532 |
+
message="All services nominal",
|
| 533 |
+
is_misleading=True # Contradicts the actual failures
|
| 534 |
+
))
|
| 535 |
+
|
| 536 |
+
logs.append(LogEntry(
|
| 537 |
+
timestamp=(now - timedelta(minutes=12)).isoformat(),
|
| 538 |
+
level="WARN",
|
| 539 |
+
service="LoadBalancer",
|
| 540 |
+
message="Backend pool health check degraded",
|
| 541 |
+
is_misleading=False
|
| 542 |
+
))
|
| 543 |
+
|
| 544 |
+
# Add a misleading authentication success
|
| 545 |
+
logs.append(LogEntry(
|
| 546 |
+
timestamp=(now - timedelta(minutes=10)).isoformat(),
|
| 547 |
+
level="INFO",
|
| 548 |
+
service="AuthService",
|
| 549 |
+
message="User authentication successful (cached token)",
|
| 550 |
+
is_misleading=True
|
| 551 |
+
))
|
| 552 |
+
|
| 553 |
+
category = root_cause["category"]
|
| 554 |
+
if category == "sso":
|
| 555 |
+
logs.append(LogEntry(
|
| 556 |
+
timestamp=(now - timedelta(minutes=8)).isoformat(),
|
| 557 |
+
level="ERROR",
|
| 558 |
+
service="SSOTokenService",
|
| 559 |
+
message="Token validation latency exceeding threshold (3000ms)",
|
| 560 |
+
is_misleading=False
|
| 561 |
+
))
|
| 562 |
+
elif category == "dns":
|
| 563 |
+
logs.append(LogEntry(
|
| 564 |
+
timestamp=(now - timedelta(minutes=7)).isoformat(),
|
| 565 |
+
level="ERROR",
|
| 566 |
+
service="DNSService",
|
| 567 |
+
message="Zone transfer failed from primary nameserver",
|
| 568 |
+
is_misleading=False
|
| 569 |
+
))
|
| 570 |
+
|
| 571 |
+
logs.append(LogEntry(
|
| 572 |
+
timestamp=(now - timedelta(minutes=5)).isoformat(),
|
| 573 |
+
level="WARN",
|
| 574 |
+
service="ServiceMesh",
|
| 575 |
+
message="Multiple service endpoints reporting degraded state",
|
| 576 |
+
is_misleading=False
|
| 577 |
+
))
|
| 578 |
+
|
| 579 |
+
logs.append(LogEntry(
|
| 580 |
+
timestamp=now.isoformat(),
|
| 581 |
+
level="ERROR",
|
| 582 |
+
service="IncidentManager",
|
| 583 |
+
message=f"Multiple concurrent failures detected: {root_cause['description']}",
|
| 584 |
+
is_misleading=False
|
| 585 |
+
))
|
| 586 |
+
|
| 587 |
+
# Return index of first misleading log
|
| 588 |
+
misleading_idx = 0 # First log is misleading
|
| 589 |
+
return logs, misleading_idx
|
env/state.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pydantic v2 models for SupportOps Arena environment state.
|
| 3 |
+
All models use Pydantic v2 syntax exclusively.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
| 7 |
+
from enum import Enum
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
import uuid
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# ─── Enums ───────────────────────────────────────────────────
|
| 14 |
+
|
| 15 |
+
class NetworkStatus(str, Enum):
|
| 16 |
+
"""Network connectivity status."""
|
| 17 |
+
HEALTHY = "HEALTHY"
|
| 18 |
+
DEGRADED = "DEGRADED"
|
| 19 |
+
DOWN = "DOWN"
|
| 20 |
+
UNKNOWN = "UNKNOWN"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class VPNStatus(str, Enum):
|
| 24 |
+
"""VPN connection status."""
|
| 25 |
+
CONNECTED = "CONNECTED"
|
| 26 |
+
FAILED = "FAILED"
|
| 27 |
+
TIMEOUT = "TIMEOUT"
|
| 28 |
+
NA = "N/A"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class AuthStatus(str, Enum):
|
| 32 |
+
"""Authentication system status."""
|
| 33 |
+
OK = "OK"
|
| 34 |
+
LOCKED = "LOCKED"
|
| 35 |
+
EXPIRED = "EXPIRED"
|
| 36 |
+
MFA_FAIL = "MFA_FAIL"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class ServiceHealth(str, Enum):
|
| 40 |
+
"""Service health status."""
|
| 41 |
+
HEALTHY = "HEALTHY"
|
| 42 |
+
DEGRADED = "DEGRADED"
|
| 43 |
+
DOWN = "DOWN"
|
| 44 |
+
UNKNOWN = "UNKNOWN"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class TaskLevel(str, Enum):
|
| 48 |
+
"""Task difficulty level."""
|
| 49 |
+
EASY = "easy"
|
| 50 |
+
MEDIUM = "medium"
|
| 51 |
+
HARD = "hard"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ─── Sub-models ──────────────────────────────────────────────
|
| 55 |
+
|
| 56 |
+
class UserContext(BaseModel):
|
| 57 |
+
"""User context information."""
|
| 58 |
+
department: str
|
| 59 |
+
role: str
|
| 60 |
+
device_type: str
|
| 61 |
+
os_version: str
|
| 62 |
+
location: str # "onsite" | "remote"
|
| 63 |
+
|
| 64 |
+
model_config = {"frozen": False}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class LogEntry(BaseModel):
|
| 68 |
+
"""System log entry."""
|
| 69 |
+
timestamp: str
|
| 70 |
+
level: str # "INFO" | "WARN" | "ERROR" | "DEBUG"
|
| 71 |
+
service: str
|
| 72 |
+
message: str
|
| 73 |
+
is_misleading: bool = False # Hidden from agent, used by grader
|
| 74 |
+
|
| 75 |
+
model_config = {"frozen": False}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class ActionRecord(BaseModel):
|
| 79 |
+
"""Record of a past action in action_history."""
|
| 80 |
+
step: int
|
| 81 |
+
action_name: str
|
| 82 |
+
rationale: str | None = None
|
| 83 |
+
reward_received: float
|
| 84 |
+
timestamp: str
|
| 85 |
+
|
| 86 |
+
model_config = {"frozen": False}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ─── Core Models ─────────────────────────────────────────────
|
| 90 |
+
|
| 91 |
+
class EnvObservation(BaseModel):
|
| 92 |
+
"""
|
| 93 |
+
What the AGENT sees. No hidden info.
|
| 94 |
+
This is the observation returned to the agent at each step.
|
| 95 |
+
"""
|
| 96 |
+
ticket_id: str
|
| 97 |
+
ticket_summary: str
|
| 98 |
+
user_context: UserContext
|
| 99 |
+
network_status: NetworkStatus
|
| 100 |
+
vpn_status: VPNStatus
|
| 101 |
+
auth_status: AuthStatus
|
| 102 |
+
service_health: dict[str, ServiceHealth]
|
| 103 |
+
system_logs: list[LogEntry]
|
| 104 |
+
action_history: list[ActionRecord]
|
| 105 |
+
step_count: int
|
| 106 |
+
escalation_allowed: bool
|
| 107 |
+
confidence_score: float | None = None # Optional agent field
|
| 108 |
+
task_level: TaskLevel
|
| 109 |
+
steps_remaining: int
|
| 110 |
+
|
| 111 |
+
model_config = {"frozen": False}
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class HiddenState(BaseModel):
|
| 115 |
+
"""
|
| 116 |
+
Internal ground truth. NEVER sent to agent.
|
| 117 |
+
Only accessible via state() method for graders.
|
| 118 |
+
"""
|
| 119 |
+
root_cause: str
|
| 120 |
+
root_cause_category: str # "network" | "auth" | "dns" | "vpn" | "sso" | "hardware"
|
| 121 |
+
correct_remediation: str # Which ActionType resolves this
|
| 122 |
+
correct_remediation_alts: list[str] = Field(default_factory=list) # Acceptable alternates
|
| 123 |
+
misleading_log_index: int | None = None # Which log entry is the red herring
|
| 124 |
+
severity: str # "low" | "medium" | "high" | "critical"
|
| 125 |
+
affected_users_count: int # Side-effect blast radius
|
| 126 |
+
ticket_variant_id: str # For adversary tracking
|
| 127 |
+
|
| 128 |
+
model_config = {"frozen": False}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class EnvState(BaseModel):
|
| 132 |
+
"""
|
| 133 |
+
Full internal state. Graders only.
|
| 134 |
+
Contains both observable and hidden state.
|
| 135 |
+
"""
|
| 136 |
+
episode_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
| 137 |
+
task_level: TaskLevel
|
| 138 |
+
observation: EnvObservation
|
| 139 |
+
hidden: HiddenState
|
| 140 |
+
cumulative_reward: float = 0.0
|
| 141 |
+
done: bool = False
|
| 142 |
+
created_at: str = Field(default_factory=lambda: datetime.utcnow().isoformat())
|
| 143 |
+
action_log: list[ActionRecord] = Field(default_factory=list)
|
| 144 |
+
diagnostic_steps_taken: int = 0 # Tracks unlock threshold
|
| 145 |
+
|
| 146 |
+
model_config = {"frozen": False}
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class StepResult(BaseModel):
|
| 150 |
+
"""
|
| 151 |
+
Result returned from environment step() method.
|
| 152 |
+
Standard RL tuple: (observation, reward, done, info).
|
| 153 |
+
"""
|
| 154 |
+
observation: EnvObservation
|
| 155 |
+
reward: float
|
| 156 |
+
done: bool
|
| 157 |
+
info: dict[str, Any]
|
| 158 |
+
|
| 159 |
+
model_config = {"frozen": False}
|
env/transitions.py
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
State machine and transition logic for SupportOps Arena.
|
| 3 |
+
Handles action preconditions, observation updates, and reward event generation.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import copy
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from typing import Any
|
| 9 |
+
from env.state import (
|
| 10 |
+
EnvState, EnvObservation, LogEntry, TaskLevel,
|
| 11 |
+
NetworkStatus, VPNStatus, AuthStatus, ServiceHealth
|
| 12 |
+
)
|
| 13 |
+
from env.actions import ActionType, ACTION_METADATA, get_diagnostic_actions
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# ─── Escalation Unlock Thresholds ────────────────────────────
|
| 17 |
+
|
| 18 |
+
ESCALATION_UNLOCK_AFTER = {
|
| 19 |
+
TaskLevel.EASY: 3,
|
| 20 |
+
TaskLevel.MEDIUM: 4,
|
| 21 |
+
TaskLevel.HARD: 5,
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class StateMachine:
|
| 26 |
+
"""
|
| 27 |
+
Manages state transitions and observation updates.
|
| 28 |
+
Enforces action preconditions and generates reward events.
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
def __init__(self):
|
| 32 |
+
self.diagnostic_actions = set(get_diagnostic_actions())
|
| 33 |
+
|
| 34 |
+
async def transition(
|
| 35 |
+
self,
|
| 36 |
+
state: EnvState,
|
| 37 |
+
action: ActionType
|
| 38 |
+
) -> tuple[EnvState, list[str]]:
|
| 39 |
+
"""
|
| 40 |
+
Apply action to state and return next state with reward events.
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
state: Current environment state
|
| 44 |
+
action: Action to apply
|
| 45 |
+
|
| 46 |
+
Returns:
|
| 47 |
+
Tuple of (next_state, reward_events_list)
|
| 48 |
+
"""
|
| 49 |
+
# Create a copy of state to modify
|
| 50 |
+
next_state = copy.deepcopy(state)
|
| 51 |
+
reward_events = []
|
| 52 |
+
|
| 53 |
+
# Get action metadata
|
| 54 |
+
action_meta = ACTION_METADATA.get(action)
|
| 55 |
+
if not action_meta:
|
| 56 |
+
reward_events.append("HARMFUL_ACTION")
|
| 57 |
+
return next_state, reward_events
|
| 58 |
+
|
| 59 |
+
# Check preconditions and add penalties
|
| 60 |
+
reward_events.extend(self._check_preconditions(next_state, action, action_meta))
|
| 61 |
+
|
| 62 |
+
# Update observation based on action type
|
| 63 |
+
self._update_observation(next_state, action)
|
| 64 |
+
|
| 65 |
+
# Handle diagnostic actions
|
| 66 |
+
if action in self.diagnostic_actions:
|
| 67 |
+
reward_events.append("DIAGNOSTIC_ACTION")
|
| 68 |
+
next_state.diagnostic_steps_taken += 1
|
| 69 |
+
|
| 70 |
+
# Check if diagnostic targets root cause category
|
| 71 |
+
if self._is_targeted_diagnostic(next_state, action):
|
| 72 |
+
reward_events.append("CORRECT_TARGETED_DIAGNOSTIC")
|
| 73 |
+
|
| 74 |
+
# Check if escalation should be unlocked
|
| 75 |
+
unlock_threshold = ESCALATION_UNLOCK_AFTER[next_state.task_level]
|
| 76 |
+
if next_state.diagnostic_steps_taken >= unlock_threshold:
|
| 77 |
+
next_state.observation.escalation_allowed = True
|
| 78 |
+
|
| 79 |
+
return next_state, reward_events
|
| 80 |
+
|
| 81 |
+
def _check_preconditions(
|
| 82 |
+
self,
|
| 83 |
+
state: EnvState,
|
| 84 |
+
action: ActionType,
|
| 85 |
+
action_meta: Any
|
| 86 |
+
) -> list[str]:
|
| 87 |
+
"""Check action preconditions and return penalty events."""
|
| 88 |
+
events = []
|
| 89 |
+
|
| 90 |
+
# Check if action requires prior diagnostic but none taken
|
| 91 |
+
if action_meta.requires_prior_diagnostic and state.diagnostic_steps_taken == 0:
|
| 92 |
+
events.append("RISKY_WITHOUT_DIAGNOSTIC")
|
| 93 |
+
|
| 94 |
+
# Check if escalating before allowed
|
| 95 |
+
if action == ActionType.ESCALATE_TICKET and not state.observation.escalation_allowed:
|
| 96 |
+
events.append("PREMATURE_ESCALATION")
|
| 97 |
+
|
| 98 |
+
# Check for redundant actions
|
| 99 |
+
past_actions = [record.action_name for record in state.observation.action_history]
|
| 100 |
+
if action.value in past_actions:
|
| 101 |
+
events.append("REDUNDANT_ACTION")
|
| 102 |
+
|
| 103 |
+
return events
|
| 104 |
+
|
| 105 |
+
def _update_observation(self, state: EnvState, action: ActionType) -> None:
|
| 106 |
+
"""Update observation based on action taken."""
|
| 107 |
+
obs = state.observation
|
| 108 |
+
hidden = state.hidden
|
| 109 |
+
|
| 110 |
+
if action == ActionType.INSPECT_NETWORK:
|
| 111 |
+
self._handle_inspect_network(obs, hidden, state.task_level)
|
| 112 |
+
|
| 113 |
+
elif action == ActionType.INSPECT_LOGS:
|
| 114 |
+
self._handle_inspect_logs(obs, hidden, state.task_level)
|
| 115 |
+
|
| 116 |
+
elif action == ActionType.CHECK_AUTHENTICATION:
|
| 117 |
+
self._handle_check_authentication(obs, hidden)
|
| 118 |
+
|
| 119 |
+
elif action == ActionType.CHECK_PERMISSIONS:
|
| 120 |
+
self._handle_check_permissions(obs, hidden)
|
| 121 |
+
|
| 122 |
+
elif action == ActionType.QUERY_DEVICE_STATUS:
|
| 123 |
+
self._handle_query_device_status(obs, hidden)
|
| 124 |
+
|
| 125 |
+
elif action == ActionType.SEARCH_INTERNAL_KB:
|
| 126 |
+
self._handle_search_kb(obs, hidden)
|
| 127 |
+
|
| 128 |
+
elif action == ActionType.CONTACT_USER:
|
| 129 |
+
self._handle_contact_user(obs, hidden)
|
| 130 |
+
|
| 131 |
+
elif action == ActionType.RUN_DIAGNOSTIC:
|
| 132 |
+
self._handle_run_diagnostic(obs, hidden)
|
| 133 |
+
|
| 134 |
+
elif action == ActionType.FLUSH_DNS:
|
| 135 |
+
self._handle_flush_dns(obs, hidden)
|
| 136 |
+
|
| 137 |
+
elif action == ActionType.RECONFIGURE_CLIENT:
|
| 138 |
+
self._handle_reconfigure_client(obs, hidden)
|
| 139 |
+
|
| 140 |
+
elif action == ActionType.RESTART_SERVICE:
|
| 141 |
+
self._handle_restart_service(obs, hidden)
|
| 142 |
+
|
| 143 |
+
elif action == ActionType.RESET_CREDENTIALS:
|
| 144 |
+
self._handle_reset_credentials(obs, hidden)
|
| 145 |
+
|
| 146 |
+
def _handle_inspect_network(
|
| 147 |
+
self,
|
| 148 |
+
obs: EnvObservation,
|
| 149 |
+
hidden: Any,
|
| 150 |
+
task_level: TaskLevel
|
| 151 |
+
) -> None:
|
| 152 |
+
"""Update network status information."""
|
| 153 |
+
category = hidden.root_cause_category
|
| 154 |
+
|
| 155 |
+
if category == "network":
|
| 156 |
+
obs.network_status = NetworkStatus.DEGRADED
|
| 157 |
+
elif category == "dns":
|
| 158 |
+
obs.network_status = NetworkStatus.HEALTHY
|
| 159 |
+
else:
|
| 160 |
+
obs.network_status = NetworkStatus.HEALTHY
|
| 161 |
+
|
| 162 |
+
# Update VPN status if relevant
|
| 163 |
+
if category == "vpn":
|
| 164 |
+
obs.vpn_status = VPNStatus.FAILED
|
| 165 |
+
elif "vpn" in obs.service_health:
|
| 166 |
+
obs.vpn_status = VPNStatus.NA
|
| 167 |
+
|
| 168 |
+
def _handle_inspect_logs(
|
| 169 |
+
self,
|
| 170 |
+
obs: EnvObservation,
|
| 171 |
+
hidden: Any,
|
| 172 |
+
task_level: TaskLevel
|
| 173 |
+
) -> None:
|
| 174 |
+
"""Add more log entries (may include misleading ones)."""
|
| 175 |
+
# Logs were already populated during reset
|
| 176 |
+
# This action reveals more details in existing logs
|
| 177 |
+
now = datetime.utcnow()
|
| 178 |
+
category = hidden.root_cause_category
|
| 179 |
+
|
| 180 |
+
new_log = LogEntry(
|
| 181 |
+
timestamp=now.isoformat(),
|
| 182 |
+
level="INFO",
|
| 183 |
+
service="LogAnalyzer",
|
| 184 |
+
message=f"Analyzed logs: {len(obs.system_logs)} entries found related to {category} subsystem",
|
| 185 |
+
is_misleading=False
|
| 186 |
+
)
|
| 187 |
+
obs.system_logs.append(new_log)
|
| 188 |
+
|
| 189 |
+
def _handle_check_authentication(
|
| 190 |
+
self,
|
| 191 |
+
obs: EnvObservation,
|
| 192 |
+
hidden: Any
|
| 193 |
+
) -> None:
|
| 194 |
+
"""Update authentication status."""
|
| 195 |
+
category = hidden.root_cause_category
|
| 196 |
+
|
| 197 |
+
if category == "auth":
|
| 198 |
+
if "expired" in hidden.root_cause.lower():
|
| 199 |
+
obs.auth_status = AuthStatus.EXPIRED
|
| 200 |
+
elif "mfa" in hidden.root_cause.lower():
|
| 201 |
+
obs.auth_status = AuthStatus.MFA_FAIL
|
| 202 |
+
elif "locked" in hidden.root_cause.lower():
|
| 203 |
+
obs.auth_status = AuthStatus.LOCKED
|
| 204 |
+
else:
|
| 205 |
+
obs.auth_status = AuthStatus.EXPIRED
|
| 206 |
+
else:
|
| 207 |
+
obs.auth_status = AuthStatus.OK
|
| 208 |
+
|
| 209 |
+
def _handle_check_permissions(
|
| 210 |
+
self,
|
| 211 |
+
obs: EnvObservation,
|
| 212 |
+
hidden: Any
|
| 213 |
+
) -> None:
|
| 214 |
+
"""Check permissions and update service health."""
|
| 215 |
+
now = datetime.utcnow()
|
| 216 |
+
log = LogEntry(
|
| 217 |
+
timestamp=now.isoformat(),
|
| 218 |
+
level="INFO",
|
| 219 |
+
service="PermissionChecker",
|
| 220 |
+
message="User permissions validated: Standard access level confirmed",
|
| 221 |
+
is_misleading=False
|
| 222 |
+
)
|
| 223 |
+
obs.system_logs.append(log)
|
| 224 |
+
|
| 225 |
+
def _handle_query_device_status(
|
| 226 |
+
self,
|
| 227 |
+
obs: EnvObservation,
|
| 228 |
+
hidden: Any
|
| 229 |
+
) -> None:
|
| 230 |
+
"""Query device status and reveal hardware info."""
|
| 231 |
+
category = hidden.root_cause_category
|
| 232 |
+
now = datetime.utcnow()
|
| 233 |
+
|
| 234 |
+
if category == "hardware":
|
| 235 |
+
log = LogEntry(
|
| 236 |
+
timestamp=now.isoformat(),
|
| 237 |
+
level="WARN",
|
| 238 |
+
service="DeviceManager",
|
| 239 |
+
message=f"Device check: {hidden.root_cause}",
|
| 240 |
+
is_misleading=False
|
| 241 |
+
)
|
| 242 |
+
else:
|
| 243 |
+
log = LogEntry(
|
| 244 |
+
timestamp=now.isoformat(),
|
| 245 |
+
level="INFO",
|
| 246 |
+
service="DeviceManager",
|
| 247 |
+
message="Device status: All hardware components operational",
|
| 248 |
+
is_misleading=False
|
| 249 |
+
)
|
| 250 |
+
obs.system_logs.append(log)
|
| 251 |
+
|
| 252 |
+
def _handle_search_kb(
|
| 253 |
+
self,
|
| 254 |
+
obs: EnvObservation,
|
| 255 |
+
hidden: Any
|
| 256 |
+
) -> None:
|
| 257 |
+
"""Search internal knowledge base."""
|
| 258 |
+
category = hidden.root_cause_category
|
| 259 |
+
now = datetime.utcnow()
|
| 260 |
+
|
| 261 |
+
# KB provides relevant but not complete information
|
| 262 |
+
kb_results = {
|
| 263 |
+
"network": "KB Article 2034: DHCP issues can cause intermittent connectivity",
|
| 264 |
+
"auth": "KB Article 1523: Password expiration policies and reset procedures",
|
| 265 |
+
"dns": "KB Article 3012: DNS cache corruption troubleshooting guide",
|
| 266 |
+
"vpn": "KB Article 4056: VPN client compatibility and configuration",
|
| 267 |
+
"sso": "KB Article 5078: SSO token service architecture and dependencies",
|
| 268 |
+
"hardware": "KB Article 1209: Hardware configuration and driver updates",
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
message = kb_results.get(category, "KB Article 9999: General troubleshooting steps")
|
| 272 |
+
|
| 273 |
+
log = LogEntry(
|
| 274 |
+
timestamp=now.isoformat(),
|
| 275 |
+
level="INFO",
|
| 276 |
+
service="KnowledgeBase",
|
| 277 |
+
message=f"Search result: {message}",
|
| 278 |
+
is_misleading=False
|
| 279 |
+
)
|
| 280 |
+
obs.system_logs.append(log)
|
| 281 |
+
|
| 282 |
+
def _handle_contact_user(
|
| 283 |
+
self,
|
| 284 |
+
obs: EnvObservation,
|
| 285 |
+
hidden: Any
|
| 286 |
+
) -> None:
|
| 287 |
+
"""Contact user for more information."""
|
| 288 |
+
now = datetime.utcnow()
|
| 289 |
+
|
| 290 |
+
# User provides additional context
|
| 291 |
+
user_responses = [
|
| 292 |
+
"User reports: Issue started after latest system update",
|
| 293 |
+
"User reports: Problem occurs only when connecting from home",
|
| 294 |
+
"User reports: Other colleagues experiencing similar issues",
|
| 295 |
+
"User reports: Was working fine until this morning",
|
| 296 |
+
]
|
| 297 |
+
|
| 298 |
+
import random
|
| 299 |
+
message = random.choice(user_responses)
|
| 300 |
+
|
| 301 |
+
log = LogEntry(
|
| 302 |
+
timestamp=now.isoformat(),
|
| 303 |
+
level="INFO",
|
| 304 |
+
service="UserContact",
|
| 305 |
+
message=message,
|
| 306 |
+
is_misleading=False
|
| 307 |
+
)
|
| 308 |
+
obs.system_logs.append(log)
|
| 309 |
+
|
| 310 |
+
def _handle_run_diagnostic(
|
| 311 |
+
self,
|
| 312 |
+
obs: EnvObservation,
|
| 313 |
+
hidden: Any
|
| 314 |
+
) -> None:
|
| 315 |
+
"""Run targeted diagnostic script."""
|
| 316 |
+
category = hidden.root_cause_category
|
| 317 |
+
now = datetime.utcnow()
|
| 318 |
+
|
| 319 |
+
# Diagnostic provides accurate targeted information
|
| 320 |
+
log = LogEntry(
|
| 321 |
+
timestamp=now.isoformat(),
|
| 322 |
+
level="WARN",
|
| 323 |
+
service="DiagnosticScript",
|
| 324 |
+
message=f"Diagnostic result: Detected issue in {category} subsystem - {hidden.root_cause}",
|
| 325 |
+
is_misleading=False
|
| 326 |
+
)
|
| 327 |
+
obs.system_logs.append(log)
|
| 328 |
+
|
| 329 |
+
def _handle_flush_dns(
|
| 330 |
+
self,
|
| 331 |
+
obs: EnvObservation,
|
| 332 |
+
hidden: Any
|
| 333 |
+
) -> None:
|
| 334 |
+
"""Flush DNS cache."""
|
| 335 |
+
now = datetime.utcnow()
|
| 336 |
+
|
| 337 |
+
# DNS flush may temporarily show degraded status
|
| 338 |
+
if hidden.root_cause_category == "dns":
|
| 339 |
+
obs.network_status = NetworkStatus.HEALTHY
|
| 340 |
+
|
| 341 |
+
log = LogEntry(
|
| 342 |
+
timestamp=now.isoformat(),
|
| 343 |
+
level="INFO",
|
| 344 |
+
service="DNSService",
|
| 345 |
+
message="DNS cache flushed successfully",
|
| 346 |
+
is_misleading=False
|
| 347 |
+
)
|
| 348 |
+
obs.system_logs.append(log)
|
| 349 |
+
|
| 350 |
+
def _handle_reconfigure_client(
|
| 351 |
+
self,
|
| 352 |
+
obs: EnvObservation,
|
| 353 |
+
hidden: Any
|
| 354 |
+
) -> None:
|
| 355 |
+
"""Reconfigure client settings."""
|
| 356 |
+
now = datetime.utcnow()
|
| 357 |
+
|
| 358 |
+
log = LogEntry(
|
| 359 |
+
timestamp=now.isoformat(),
|
| 360 |
+
level="INFO",
|
| 361 |
+
service="ConfigManager",
|
| 362 |
+
message="Client reconfiguration applied",
|
| 363 |
+
is_misleading=False
|
| 364 |
+
)
|
| 365 |
+
obs.system_logs.append(log)
|
| 366 |
+
|
| 367 |
+
def _handle_restart_service(
|
| 368 |
+
self,
|
| 369 |
+
obs: EnvObservation,
|
| 370 |
+
hidden: Any
|
| 371 |
+
) -> None:
|
| 372 |
+
"""Restart service (high risk action)."""
|
| 373 |
+
now = datetime.utcnow()
|
| 374 |
+
|
| 375 |
+
# Update service health
|
| 376 |
+
for service_name in obs.service_health:
|
| 377 |
+
if obs.service_health[service_name] == ServiceHealth.DEGRADED:
|
| 378 |
+
obs.service_health[service_name] = ServiceHealth.HEALTHY
|
| 379 |
+
|
| 380 |
+
log = LogEntry(
|
| 381 |
+
timestamp=now.isoformat(),
|
| 382 |
+
level="WARN",
|
| 383 |
+
service="ServiceManager",
|
| 384 |
+
message=f"Service restart completed. {hidden.affected_users_count} users may have been affected.",
|
| 385 |
+
is_misleading=False
|
| 386 |
+
)
|
| 387 |
+
obs.system_logs.append(log)
|
| 388 |
+
|
| 389 |
+
def _handle_reset_credentials(
|
| 390 |
+
self,
|
| 391 |
+
obs: EnvObservation,
|
| 392 |
+
hidden: Any
|
| 393 |
+
) -> None:
|
| 394 |
+
"""Reset user credentials."""
|
| 395 |
+
obs.auth_status = AuthStatus.OK
|
| 396 |
+
now = datetime.utcnow()
|
| 397 |
+
|
| 398 |
+
log = LogEntry(
|
| 399 |
+
timestamp=now.isoformat(),
|
| 400 |
+
level="INFO",
|
| 401 |
+
service="AuthService",
|
| 402 |
+
message="User credentials reset successfully",
|
| 403 |
+
is_misleading=False
|
| 404 |
+
)
|
| 405 |
+
obs.system_logs.append(log)
|
| 406 |
+
|
| 407 |
+
def _is_targeted_diagnostic(self, state: EnvState, action: ActionType) -> bool:
|
| 408 |
+
"""Check if diagnostic action targets the root cause category."""
|
| 409 |
+
category = state.hidden.root_cause_category
|
| 410 |
+
|
| 411 |
+
# Map actions to categories they target
|
| 412 |
+
action_category_map = {
|
| 413 |
+
ActionType.INSPECT_NETWORK: ["network", "dns", "vpn"],
|
| 414 |
+
ActionType.INSPECT_LOGS: ["auth", "network", "dns", "vpn", "sso", "hardware"],
|
| 415 |
+
ActionType.CHECK_AUTHENTICATION: ["auth", "sso"],
|
| 416 |
+
ActionType.CHECK_PERMISSIONS: ["auth"],
|
| 417 |
+
ActionType.QUERY_DEVICE_STATUS: ["hardware", "network"],
|
| 418 |
+
ActionType.SEARCH_INTERNAL_KB: ["auth", "network", "dns", "vpn", "sso", "hardware"],
|
| 419 |
+
ActionType.CONTACT_USER: ["auth", "network", "dns", "vpn", "hardware"],
|
| 420 |
+
ActionType.RUN_DIAGNOSTIC: ["network", "dns", "vpn", "sso", "hardware"],
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
targeted_categories = action_category_map.get(action, [])
|
| 424 |
+
return category in targeted_categories
|
graders/__init__.py
ADDED
|
File without changes
|
graders/adversarial_grader.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Adversarial grader for testing policy robustness.
|
| 3 |
+
Generates variant episodes and tests if agent's strategy would still work.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from env.state import EnvState
|
| 10 |
+
from adversary.adversary import AdaptiveAdversary
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class AdversarialGrader:
|
| 16 |
+
"""
|
| 17 |
+
Tests policy robustness by generating harder variants.
|
| 18 |
+
Used for hard task only.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
def __init__(self):
|
| 22 |
+
"""Initialize adversarial grader."""
|
| 23 |
+
pass
|
| 24 |
+
|
| 25 |
+
async def grade(
|
| 26 |
+
self,
|
| 27 |
+
state: EnvState,
|
| 28 |
+
adversary: AdaptiveAdversary | None = None
|
| 29 |
+
) -> float:
|
| 30 |
+
"""
|
| 31 |
+
Grade policy robustness by testing on variant episodes.
|
| 32 |
+
|
| 33 |
+
Strategy:
|
| 34 |
+
1. Generate a variant of the same ticket (different surface presentation)
|
| 35 |
+
2. Simulate if agent's action strategy would still work
|
| 36 |
+
3. Return robustness score (0.0-1.0)
|
| 37 |
+
|
| 38 |
+
Args:
|
| 39 |
+
state: Complete environment state after episode
|
| 40 |
+
adversary: Optional adaptive adversary for variant generation
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
Robustness score (0.0-1.0)
|
| 44 |
+
"""
|
| 45 |
+
# For now, use heuristics to estimate robustness
|
| 46 |
+
# A full implementation would actually run the variant episode
|
| 47 |
+
|
| 48 |
+
try:
|
| 49 |
+
robustness_score = self._estimate_robustness(state)
|
| 50 |
+
return robustness_score
|
| 51 |
+
|
| 52 |
+
except Exception as e:
|
| 53 |
+
logger.error(f"Adversarial grading failed: {e}", exc_info=True)
|
| 54 |
+
return 0.5 # Neutral fallback
|
| 55 |
+
|
| 56 |
+
def _estimate_robustness(self, state: EnvState) -> float:
|
| 57 |
+
"""
|
| 58 |
+
Estimate policy robustness using heuristics.
|
| 59 |
+
|
| 60 |
+
A robust policy:
|
| 61 |
+
- Uses diverse diagnostic actions (not always same pattern)
|
| 62 |
+
- Adapts to observations (doesn't follow fixed sequence)
|
| 63 |
+
- Gathers evidence before high-risk actions
|
| 64 |
+
- Makes decisions based on evidence, not just templates
|
| 65 |
+
"""
|
| 66 |
+
score_components = []
|
| 67 |
+
|
| 68 |
+
# 1. Action diversity score
|
| 69 |
+
unique_actions = len(set(a.action_name for a in state.action_log))
|
| 70 |
+
total_actions = len(state.action_log)
|
| 71 |
+
diversity_score = unique_actions / total_actions if total_actions > 0 else 0.0
|
| 72 |
+
score_components.append(diversity_score)
|
| 73 |
+
|
| 74 |
+
# 2. Diagnostic breadth score
|
| 75 |
+
diagnostic_actions = {
|
| 76 |
+
"inspect_network",
|
| 77 |
+
"inspect_logs",
|
| 78 |
+
"check_authentication",
|
| 79 |
+
"check_permissions",
|
| 80 |
+
"query_device_status",
|
| 81 |
+
"search_internal_kb",
|
| 82 |
+
"contact_user_for_info",
|
| 83 |
+
"run_diagnostic_script",
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
diagnostics_used = sum(
|
| 87 |
+
1 for a in state.action_log
|
| 88 |
+
if a.action_name in diagnostic_actions
|
| 89 |
+
)
|
| 90 |
+
unique_diagnostics_used = len(set(
|
| 91 |
+
a.action_name for a in state.action_log
|
| 92 |
+
if a.action_name in diagnostic_actions
|
| 93 |
+
))
|
| 94 |
+
|
| 95 |
+
diagnostic_breadth = (
|
| 96 |
+
unique_diagnostics_used / len(diagnostic_actions)
|
| 97 |
+
if diagnostic_actions else 0.0
|
| 98 |
+
)
|
| 99 |
+
score_components.append(diagnostic_breadth)
|
| 100 |
+
|
| 101 |
+
# 3. Evidence-before-action score
|
| 102 |
+
# Check if high-risk actions were preceded by diagnostics
|
| 103 |
+
diagnostic_count = 0
|
| 104 |
+
risky_without_diagnostic = 0
|
| 105 |
+
|
| 106 |
+
for action in state.action_log:
|
| 107 |
+
if action.action_name in diagnostic_actions:
|
| 108 |
+
diagnostic_count += 1
|
| 109 |
+
elif action.action_name in ["restart_service", "reset_credentials"]:
|
| 110 |
+
if diagnostic_count < 2:
|
| 111 |
+
risky_without_diagnostic += 1
|
| 112 |
+
|
| 113 |
+
if risky_without_diagnostic == 0:
|
| 114 |
+
evidence_score = 1.0
|
| 115 |
+
else:
|
| 116 |
+
evidence_score = max(0.0, 1.0 - (risky_without_diagnostic * 0.3))
|
| 117 |
+
|
| 118 |
+
score_components.append(evidence_score)
|
| 119 |
+
|
| 120 |
+
# 4. Adaptive behavior score
|
| 121 |
+
# Check if agent repeated same action multiple times (template behavior)
|
| 122 |
+
action_counts = {}
|
| 123 |
+
for action in state.action_log:
|
| 124 |
+
action_counts[action.action_name] = action_counts.get(action.action_name, 0) + 1
|
| 125 |
+
|
| 126 |
+
max_repeats = max(action_counts.values()) if action_counts else 1
|
| 127 |
+
if max_repeats >= 4:
|
| 128 |
+
adaptive_score = 0.5 # Too much repetition
|
| 129 |
+
elif max_repeats >= 3:
|
| 130 |
+
adaptive_score = 0.7
|
| 131 |
+
else:
|
| 132 |
+
adaptive_score = 1.0
|
| 133 |
+
|
| 134 |
+
score_components.append(adaptive_score)
|
| 135 |
+
|
| 136 |
+
# 5. Success under pressure score
|
| 137 |
+
# Check if agent succeeded despite complexity
|
| 138 |
+
task_multiplier = {
|
| 139 |
+
"easy": 0.8,
|
| 140 |
+
"medium": 1.0,
|
| 141 |
+
"hard": 1.2,
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
# If agent succeeded on hard task, boost robustness
|
| 145 |
+
if state.done and state.cumulative_reward > 0:
|
| 146 |
+
success_score = 1.0 * task_multiplier.get(state.task_level.value, 1.0)
|
| 147 |
+
else:
|
| 148 |
+
success_score = 0.5
|
| 149 |
+
|
| 150 |
+
score_components.append(min(1.0, success_score))
|
| 151 |
+
|
| 152 |
+
# Calculate weighted average
|
| 153 |
+
weights = [0.15, 0.20, 0.25, 0.20, 0.20]
|
| 154 |
+
robustness_score = sum(
|
| 155 |
+
score * weight
|
| 156 |
+
for score, weight in zip(score_components, weights)
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
return min(1.0, max(0.0, robustness_score))
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def aggregate_scores(
|
| 163 |
+
programmatic: float,
|
| 164 |
+
llm: float | None,
|
| 165 |
+
adversarial: float | None,
|
| 166 |
+
task_level: str
|
| 167 |
+
) -> float:
|
| 168 |
+
"""
|
| 169 |
+
Aggregate scores from multiple graders.
|
| 170 |
+
|
| 171 |
+
Args:
|
| 172 |
+
programmatic: Score from programmatic grader
|
| 173 |
+
llm: Score from LLM grader (optional)
|
| 174 |
+
adversarial: Score from adversarial grader (optional)
|
| 175 |
+
task_level: Task difficulty level
|
| 176 |
+
|
| 177 |
+
Returns:
|
| 178 |
+
Aggregated score (0.0-1.0)
|
| 179 |
+
"""
|
| 180 |
+
if task_level == "easy":
|
| 181 |
+
return programmatic
|
| 182 |
+
|
| 183 |
+
elif task_level == "medium":
|
| 184 |
+
llm_score = llm if llm is not None else 0.5
|
| 185 |
+
return 0.6 * programmatic + 0.4 * llm_score
|
| 186 |
+
|
| 187 |
+
else: # hard
|
| 188 |
+
llm_score = llm if llm is not None else 0.5
|
| 189 |
+
adv_score = adversarial if adversarial is not None else 0.5
|
| 190 |
+
return 0.5 * programmatic + 0.3 * llm_score + 0.2 * adv_score
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# Benchmark score weights by task level
|
| 194 |
+
OVERALL_BENCHMARK_SCORE_WEIGHTS = {
|
| 195 |
+
"easy": 0.2,
|
| 196 |
+
"medium": 0.3,
|
| 197 |
+
"hard": 0.5,
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def calculate_benchmark_score(scores_by_level: dict[str, float]) -> float:
|
| 202 |
+
"""
|
| 203 |
+
Calculate overall benchmark score across task levels.
|
| 204 |
+
|
| 205 |
+
Args:
|
| 206 |
+
scores_by_level: Dictionary mapping task level to score
|
| 207 |
+
|
| 208 |
+
Returns:
|
| 209 |
+
Weighted benchmark score
|
| 210 |
+
"""
|
| 211 |
+
return sum(
|
| 212 |
+
scores_by_level.get(level, 0.0) * weight
|
| 213 |
+
for level, weight in OVERALL_BENCHMARK_SCORE_WEIGHTS.items()
|
| 214 |
+
)
|
graders/llm_grader.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LLM-based grader for evaluating agent rationales and reasoning quality.
|
| 3 |
+
Uses OpenAI-compatible API to grade soft criteria.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
from typing import Any
|
| 10 |
+
from pydantic import BaseModel, Field
|
| 11 |
+
|
| 12 |
+
from openai import AsyncOpenAI
|
| 13 |
+
|
| 14 |
+
from env.state import EnvState
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class LLMGradeResult(BaseModel):
|
| 20 |
+
"""Result from LLM grading."""
|
| 21 |
+
diagnostic_coherence: float = Field(ge=0.0, le=1.0)
|
| 22 |
+
evidence_sufficiency: float = Field(ge=0.0, le=1.0)
|
| 23 |
+
root_cause_narration: float = Field(ge=0.0, le=1.0)
|
| 24 |
+
overall: float = Field(ge=0.0, le=1.0)
|
| 25 |
+
reasoning: str
|
| 26 |
+
|
| 27 |
+
model_config = {"frozen": False}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class LLMGrader:
|
| 31 |
+
"""
|
| 32 |
+
LLM judge using OpenAI-compatible API.
|
| 33 |
+
Evaluates agent reasoning and rationales.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
SYSTEM_PROMPT = """You are an expert IT operations grader evaluating an AI agent's performance on an incident triage task.
|
| 37 |
+
|
| 38 |
+
You will be given:
|
| 39 |
+
1. The hidden root cause (ground truth)
|
| 40 |
+
2. The agent's action history with observation updates
|
| 41 |
+
3. The final outcome (resolved/escalated/closed)
|
| 42 |
+
|
| 43 |
+
Grade the following dimensions on a scale of 0.0 to 1.0:
|
| 44 |
+
- diagnostic_coherence: Are the agent's actions logically consistent with the observations? Did it follow a coherent diagnostic strategy?
|
| 45 |
+
- evidence_sufficiency: Did the agent gather enough evidence before committing to a fix or escalation?
|
| 46 |
+
- root_cause_narration: Did the agent's actions demonstrate understanding of the actual root cause?
|
| 47 |
+
|
| 48 |
+
Return ONLY a valid JSON object with these exact keys:
|
| 49 |
+
{
|
| 50 |
+
"diagnostic_coherence": <float 0.0-1.0>,
|
| 51 |
+
"evidence_sufficiency": <float 0.0-1.0>,
|
| 52 |
+
"root_cause_narration": <float 0.0-1.0>,
|
| 53 |
+
"overall": <float 0.0-1.0>,
|
| 54 |
+
"reasoning": "<brief explanation>"
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
Do not include any other text or markdown formatting."""
|
| 58 |
+
|
| 59 |
+
def __init__(self):
|
| 60 |
+
"""Initialize LLM grader with API configuration."""
|
| 61 |
+
self.api_base_url = os.environ.get("API_BASE_URL", "https://api.openai.com/v1")
|
| 62 |
+
self.model_name = os.environ.get("MODEL_NAME", "gpt-4o-mini")
|
| 63 |
+
self.api_key = os.environ.get("OPENAI_API_KEY", "")
|
| 64 |
+
|
| 65 |
+
if not self.api_key:
|
| 66 |
+
logger.warning("OPENAI_API_KEY not set. LLM grading will use fallback scores.")
|
| 67 |
+
|
| 68 |
+
self.client = AsyncOpenAI(
|
| 69 |
+
api_key=self.api_key,
|
| 70 |
+
base_url=self.api_base_url
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
async def grade(self, state: EnvState) -> LLMGradeResult:
|
| 74 |
+
"""
|
| 75 |
+
Grade episode using LLM judge.
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
state: Complete environment state after episode
|
| 79 |
+
|
| 80 |
+
Returns:
|
| 81 |
+
LLMGradeResult with scores and reasoning
|
| 82 |
+
"""
|
| 83 |
+
if not self.api_key:
|
| 84 |
+
logger.warning("No API key available, returning fallback scores")
|
| 85 |
+
return self._fallback_grade()
|
| 86 |
+
|
| 87 |
+
try:
|
| 88 |
+
# Build user prompt
|
| 89 |
+
user_prompt = self._build_prompt(state)
|
| 90 |
+
|
| 91 |
+
# Call LLM API
|
| 92 |
+
response = await self.client.chat.completions.create(
|
| 93 |
+
model=self.model_name,
|
| 94 |
+
messages=[
|
| 95 |
+
{"role": "system", "content": self.SYSTEM_PROMPT},
|
| 96 |
+
{"role": "user", "content": user_prompt}
|
| 97 |
+
],
|
| 98 |
+
temperature=0.3,
|
| 99 |
+
max_tokens=500
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
# Parse response
|
| 103 |
+
content = response.choices[0].message.content
|
| 104 |
+
if not content:
|
| 105 |
+
logger.error("Empty response from LLM")
|
| 106 |
+
return self._fallback_grade()
|
| 107 |
+
|
| 108 |
+
# Try to parse JSON
|
| 109 |
+
try:
|
| 110 |
+
result_dict = json.loads(content)
|
| 111 |
+
except json.JSONDecodeError:
|
| 112 |
+
# Try to extract JSON from markdown code blocks
|
| 113 |
+
if "```json" in content:
|
| 114 |
+
content = content.split("```json")[1].split("```")[0].strip()
|
| 115 |
+
elif "```" in content:
|
| 116 |
+
content = content.split("```")[1].split("```")[0].strip()
|
| 117 |
+
result_dict = json.loads(content)
|
| 118 |
+
|
| 119 |
+
return LLMGradeResult(**result_dict)
|
| 120 |
+
|
| 121 |
+
except Exception as e:
|
| 122 |
+
logger.error(f"LLM grading failed: {e}", exc_info=True)
|
| 123 |
+
return self._fallback_grade()
|
| 124 |
+
|
| 125 |
+
def _build_prompt(self, state: EnvState) -> str:
|
| 126 |
+
"""Build grading prompt from episode state."""
|
| 127 |
+
# Extract key information
|
| 128 |
+
root_cause = state.hidden.root_cause
|
| 129 |
+
category = state.hidden.root_cause_category
|
| 130 |
+
severity = state.hidden.severity
|
| 131 |
+
|
| 132 |
+
# Summarize action sequence
|
| 133 |
+
action_summary = []
|
| 134 |
+
for i, action in enumerate(state.action_log, 1):
|
| 135 |
+
action_summary.append(
|
| 136 |
+
f"{i}. {action.action_name} (reward: {action.reward_received:.2f})"
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
actions_text = "\n".join(action_summary)
|
| 140 |
+
|
| 141 |
+
# Summarize observations
|
| 142 |
+
final_obs = state.observation
|
| 143 |
+
|
| 144 |
+
prompt = f"""## Episode Summary
|
| 145 |
+
|
| 146 |
+
**Ground Truth Root Cause:**
|
| 147 |
+
- Category: {category}
|
| 148 |
+
- Description: {root_cause}
|
| 149 |
+
- Severity: {severity}
|
| 150 |
+
- Affected Users: {state.hidden.affected_users_count}
|
| 151 |
+
|
| 152 |
+
**Correct Remediation:**
|
| 153 |
+
- Primary: {state.hidden.correct_remediation}
|
| 154 |
+
- Alternatives: {', '.join(state.hidden.correct_remediation_alts) if state.hidden.correct_remediation_alts else 'None'}
|
| 155 |
+
|
| 156 |
+
**Agent's Action Sequence:**
|
| 157 |
+
{actions_text}
|
| 158 |
+
|
| 159 |
+
**Final State:**
|
| 160 |
+
- Steps taken: {final_obs.step_count}
|
| 161 |
+
- Episode terminated: {state.done}
|
| 162 |
+
- Diagnostic steps: {state.diagnostic_steps_taken}
|
| 163 |
+
- Escalation allowed: {final_obs.escalation_allowed}
|
| 164 |
+
|
| 165 |
+
**Observations:**
|
| 166 |
+
- Network Status: {final_obs.network_status.value}
|
| 167 |
+
- VPN Status: {final_obs.vpn_status.value}
|
| 168 |
+
- Auth Status: {final_obs.auth_status.value}
|
| 169 |
+
- Service Health: {dict(final_obs.service_health)}
|
| 170 |
+
|
| 171 |
+
**Performance:**
|
| 172 |
+
- Cumulative Reward: {state.cumulative_reward:.2f}
|
| 173 |
+
- Task Level: {state.task_level.value}
|
| 174 |
+
|
| 175 |
+
Evaluate how well the agent diagnosed and resolved this incident."""
|
| 176 |
+
|
| 177 |
+
return prompt
|
| 178 |
+
|
| 179 |
+
def _fallback_grade(self) -> LLMGradeResult:
|
| 180 |
+
"""Return neutral fallback scores when LLM unavailable."""
|
| 181 |
+
return LLMGradeResult(
|
| 182 |
+
diagnostic_coherence=0.5,
|
| 183 |
+
evidence_sufficiency=0.5,
|
| 184 |
+
root_cause_narration=0.5,
|
| 185 |
+
overall=0.5,
|
| 186 |
+
reasoning="LLM grading unavailable, using fallback scores"
|
| 187 |
+
)
|
graders/programmatic.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Programmatic (rule-based) grader for SupportOps Arena.
|
| 3 |
+
Checks objective correctness criteria without LLM inference.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from env.state import EnvState, TaskLevel
|
| 10 |
+
from env.actions import ActionType, ACTION_METADATA
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class GradeResult(BaseModel):
|
| 14 |
+
"""Result from programmatic grading."""
|
| 15 |
+
score: float = Field(ge=0.0, le=1.0)
|
| 16 |
+
root_cause_identified: bool
|
| 17 |
+
action_sequence_safe: bool
|
| 18 |
+
step_budget_respected: bool
|
| 19 |
+
no_harmful_actions: bool
|
| 20 |
+
evidence_breadth_score: float = Field(ge=0.0, le=1.0)
|
| 21 |
+
breakdown: dict[str, float]
|
| 22 |
+
|
| 23 |
+
model_config = {"frozen": False}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class ProgrammaticGrader:
|
| 27 |
+
"""
|
| 28 |
+
Rule-based ground truth checker.
|
| 29 |
+
Evaluates episodes against objective criteria.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
def __init__(self):
|
| 33 |
+
"""Initialize programmatic grader."""
|
| 34 |
+
self.max_steps = {
|
| 35 |
+
TaskLevel.EASY: 10,
|
| 36 |
+
TaskLevel.MEDIUM: 16,
|
| 37 |
+
TaskLevel.HARD: 24,
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
# Required diagnostic categories by task level
|
| 41 |
+
self.required_diagnostic_categories = {
|
| 42 |
+
TaskLevel.EASY: 2,
|
| 43 |
+
TaskLevel.MEDIUM: 3,
|
| 44 |
+
TaskLevel.HARD: 4,
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
async def grade(self, state: EnvState) -> GradeResult:
|
| 48 |
+
"""
|
| 49 |
+
Grade episode performance based on objective criteria.
|
| 50 |
+
|
| 51 |
+
Args:
|
| 52 |
+
state: Complete environment state after episode
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
GradeResult with score and breakdown
|
| 56 |
+
"""
|
| 57 |
+
# Check root cause identification
|
| 58 |
+
root_cause_identified = self._check_root_cause_identified(state)
|
| 59 |
+
|
| 60 |
+
# Check action sequence safety
|
| 61 |
+
action_sequence_safe = self._check_action_sequence_safe(state)
|
| 62 |
+
|
| 63 |
+
# Check step budget
|
| 64 |
+
step_budget_respected = self._check_step_budget(state)
|
| 65 |
+
|
| 66 |
+
# Check for harmful actions
|
| 67 |
+
no_harmful_actions = self._check_no_harmful_actions(state)
|
| 68 |
+
|
| 69 |
+
# Calculate evidence breadth score
|
| 70 |
+
evidence_breadth_score = self._calculate_evidence_breadth(state)
|
| 71 |
+
|
| 72 |
+
# Calculate component scores
|
| 73 |
+
breakdown = {
|
| 74 |
+
"root_cause": 1.0 if root_cause_identified else 0.0,
|
| 75 |
+
"safety": 1.0 if action_sequence_safe else 0.0,
|
| 76 |
+
"budget": 1.0 if step_budget_respected else 0.0,
|
| 77 |
+
"no_harm": 1.0 if no_harmful_actions else 0.0,
|
| 78 |
+
"evidence": evidence_breadth_score,
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
# Calculate overall score (weighted average)
|
| 82 |
+
weights = {
|
| 83 |
+
"root_cause": 0.35,
|
| 84 |
+
"safety": 0.20,
|
| 85 |
+
"budget": 0.15,
|
| 86 |
+
"no_harm": 0.15,
|
| 87 |
+
"evidence": 0.15,
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
overall_score = sum(
|
| 91 |
+
breakdown[key] * weights[key]
|
| 92 |
+
for key in breakdown
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
return GradeResult(
|
| 96 |
+
score=overall_score,
|
| 97 |
+
root_cause_identified=root_cause_identified,
|
| 98 |
+
action_sequence_safe=action_sequence_safe,
|
| 99 |
+
step_budget_respected=step_budget_respected,
|
| 100 |
+
no_harmful_actions=no_harmful_actions,
|
| 101 |
+
evidence_breadth_score=evidence_breadth_score,
|
| 102 |
+
breakdown=breakdown,
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
def _check_root_cause_identified(self, state: EnvState) -> bool:
|
| 106 |
+
"""Check if agent correctly identified and fixed root cause."""
|
| 107 |
+
correct_remediation = state.hidden.correct_remediation
|
| 108 |
+
alt_remediations = state.hidden.correct_remediation_alts
|
| 109 |
+
|
| 110 |
+
# Get all actions taken
|
| 111 |
+
actions_taken = [action.action_name for action in state.action_log]
|
| 112 |
+
|
| 113 |
+
# Check if correct remediation was applied
|
| 114 |
+
if correct_remediation in actions_taken:
|
| 115 |
+
return True
|
| 116 |
+
|
| 117 |
+
# Check alternatives
|
| 118 |
+
if any(alt in actions_taken for alt in alt_remediations):
|
| 119 |
+
return True
|
| 120 |
+
|
| 121 |
+
# Check if escalation was appropriate (for critical issues)
|
| 122 |
+
if state.hidden.severity == "critical":
|
| 123 |
+
if "escalate_ticket" in actions_taken:
|
| 124 |
+
# Escalation with sufficient diagnostics is correct for critical issues
|
| 125 |
+
diagnostic_count = sum(
|
| 126 |
+
1 for action in state.action_log
|
| 127 |
+
if self._is_diagnostic_action(action.action_name)
|
| 128 |
+
)
|
| 129 |
+
return diagnostic_count >= 3
|
| 130 |
+
|
| 131 |
+
return False
|
| 132 |
+
|
| 133 |
+
def _check_action_sequence_safe(self, state: EnvState) -> bool:
|
| 134 |
+
"""Check if high-risk actions were preceded by diagnostics."""
|
| 135 |
+
diagnostic_taken = False
|
| 136 |
+
|
| 137 |
+
for action in state.action_log:
|
| 138 |
+
action_type = ActionType(action.action_name)
|
| 139 |
+
metadata = ACTION_METADATA.get(action_type)
|
| 140 |
+
|
| 141 |
+
if not metadata:
|
| 142 |
+
continue
|
| 143 |
+
|
| 144 |
+
# Track diagnostic actions
|
| 145 |
+
if self._is_diagnostic_action(action.action_name):
|
| 146 |
+
diagnostic_taken = True
|
| 147 |
+
|
| 148 |
+
# Check if high-risk action requires prior diagnostic
|
| 149 |
+
if metadata.requires_prior_diagnostic and not diagnostic_taken:
|
| 150 |
+
return False
|
| 151 |
+
|
| 152 |
+
return True
|
| 153 |
+
|
| 154 |
+
def _check_step_budget(self, state: EnvState) -> bool:
|
| 155 |
+
"""Check if step budget was respected."""
|
| 156 |
+
max_allowed = self.max_steps[state.task_level]
|
| 157 |
+
return state.observation.step_count <= max_allowed
|
| 158 |
+
|
| 159 |
+
def _check_no_harmful_actions(self, state: EnvState) -> bool:
|
| 160 |
+
"""Check that no actions caused harm."""
|
| 161 |
+
# Check for restart_service on services affecting many users
|
| 162 |
+
for action in state.action_log:
|
| 163 |
+
if action.action_name == "restart_service":
|
| 164 |
+
# If affected many users and wasn't necessary, it's harmful
|
| 165 |
+
if state.hidden.affected_users_count > 10:
|
| 166 |
+
# Was it the correct fix?
|
| 167 |
+
if state.hidden.correct_remediation != "restart_service":
|
| 168 |
+
return False
|
| 169 |
+
|
| 170 |
+
return True
|
| 171 |
+
|
| 172 |
+
def _calculate_evidence_breadth(self, state: EnvState) -> float:
|
| 173 |
+
"""Calculate evidence gathering breadth score."""
|
| 174 |
+
# Map actions to diagnostic categories
|
| 175 |
+
diagnostic_categories = set()
|
| 176 |
+
|
| 177 |
+
for action in state.action_log:
|
| 178 |
+
action_name = action.action_name
|
| 179 |
+
|
| 180 |
+
if action_name == "inspect_network":
|
| 181 |
+
diagnostic_categories.add("network")
|
| 182 |
+
elif action_name == "inspect_logs":
|
| 183 |
+
diagnostic_categories.add("logs")
|
| 184 |
+
elif action_name == "check_authentication":
|
| 185 |
+
diagnostic_categories.add("auth")
|
| 186 |
+
elif action_name == "check_permissions":
|
| 187 |
+
diagnostic_categories.add("permissions")
|
| 188 |
+
elif action_name == "query_device_status":
|
| 189 |
+
diagnostic_categories.add("device")
|
| 190 |
+
elif action_name == "search_internal_kb":
|
| 191 |
+
diagnostic_categories.add("knowledge")
|
| 192 |
+
elif action_name == "contact_user_for_info":
|
| 193 |
+
diagnostic_categories.add("user_contact")
|
| 194 |
+
elif action_name == "run_diagnostic_script":
|
| 195 |
+
diagnostic_categories.add("diagnostic_script")
|
| 196 |
+
|
| 197 |
+
# Calculate score based on breadth
|
| 198 |
+
required = self.required_diagnostic_categories[state.task_level]
|
| 199 |
+
actual = len(diagnostic_categories)
|
| 200 |
+
|
| 201 |
+
return min(1.0, actual / required)
|
| 202 |
+
|
| 203 |
+
def _is_diagnostic_action(self, action_name: str) -> bool:
|
| 204 |
+
"""Check if action is diagnostic (information gathering)."""
|
| 205 |
+
diagnostic_actions = {
|
| 206 |
+
"inspect_network",
|
| 207 |
+
"inspect_logs",
|
| 208 |
+
"check_authentication",
|
| 209 |
+
"check_permissions",
|
| 210 |
+
"query_device_status",
|
| 211 |
+
"search_internal_kb",
|
| 212 |
+
"contact_user_for_info",
|
| 213 |
+
"run_diagnostic_script",
|
| 214 |
+
}
|
| 215 |
+
return action_name in diagnostic_actions
|
inference.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
SupportOps Arena — Baseline Inference Script
|
| 4 |
+
Runs baseline agent against all 3 task levels.
|
| 5 |
+
Produces [START]/[STEP]/[END] logs to stdout in JSON format.
|
| 6 |
+
Must complete in < 20 minutes total.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import asyncio
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import sys
|
| 13 |
+
import logging
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
|
| 16 |
+
# Read environment variables
|
| 17 |
+
API_BASE_URL = os.environ.get("API_BASE_URL", "http://localhost:7860")
|
| 18 |
+
MODEL_NAME = os.environ.get("MODEL_NAME", "gpt-4o-mini")
|
| 19 |
+
HF_TOKEN = os.environ.get("HF_TOKEN", "")
|
| 20 |
+
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
| 21 |
+
|
| 22 |
+
# Setup logging
|
| 23 |
+
logging.basicConfig(
|
| 24 |
+
level=logging.WARNING, # Only warnings/errors to stderr
|
| 25 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 26 |
+
stream=sys.stderr
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
async def run_task(task_level: str, env, agent) -> dict:
|
| 31 |
+
"""
|
| 32 |
+
Run one task level with baseline agent.
|
| 33 |
+
|
| 34 |
+
Args:
|
| 35 |
+
task_level: "easy", "medium", or "hard"
|
| 36 |
+
env: SupportOpsArena environment instance
|
| 37 |
+
agent: BaselineAgent instance
|
| 38 |
+
|
| 39 |
+
Returns:
|
| 40 |
+
Dictionary with task_level and score
|
| 41 |
+
"""
|
| 42 |
+
# Reset environment
|
| 43 |
+
obs = await env.reset(task_level=task_level)
|
| 44 |
+
state = await env.state()
|
| 45 |
+
episode_id = state.episode_id
|
| 46 |
+
|
| 47 |
+
# [START] log
|
| 48 |
+
print(json.dumps({
|
| 49 |
+
"type": "[START]",
|
| 50 |
+
"episode_id": episode_id,
|
| 51 |
+
"task_level": task_level,
|
| 52 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 53 |
+
}), flush=True)
|
| 54 |
+
|
| 55 |
+
cumulative_reward = 0.0
|
| 56 |
+
step = 0
|
| 57 |
+
done = False
|
| 58 |
+
|
| 59 |
+
# Run episode
|
| 60 |
+
while not done and step < 50: # Safety limit
|
| 61 |
+
# Select action
|
| 62 |
+
action = await agent.select_action(obs)
|
| 63 |
+
|
| 64 |
+
# Take step
|
| 65 |
+
result = await env.step(action)
|
| 66 |
+
obs = result.observation
|
| 67 |
+
reward = result.reward
|
| 68 |
+
done = result.done
|
| 69 |
+
info = result.info
|
| 70 |
+
|
| 71 |
+
cumulative_reward += reward
|
| 72 |
+
step += 1
|
| 73 |
+
|
| 74 |
+
# [STEP] log
|
| 75 |
+
print(json.dumps({
|
| 76 |
+
"type": "[STEP]",
|
| 77 |
+
"step": step,
|
| 78 |
+
"action": action.value,
|
| 79 |
+
"rationale": "",
|
| 80 |
+
"reward": round(reward, 4),
|
| 81 |
+
"cumulative_reward": round(cumulative_reward, 4),
|
| 82 |
+
"done": done
|
| 83 |
+
}), flush=True)
|
| 84 |
+
|
| 85 |
+
# Get final state
|
| 86 |
+
final_state = await env.state()
|
| 87 |
+
|
| 88 |
+
# Calculate final score from programmatic grader
|
| 89 |
+
from graders.programmatic import ProgrammaticGrader
|
| 90 |
+
grader = ProgrammaticGrader()
|
| 91 |
+
grade_result = await grader.grade(final_state)
|
| 92 |
+
|
| 93 |
+
final_score = grade_result.score
|
| 94 |
+
correct = grade_result.root_cause_identified
|
| 95 |
+
|
| 96 |
+
# [END] log
|
| 97 |
+
print(json.dumps({
|
| 98 |
+
"type": "[END]",
|
| 99 |
+
"episode_id": episode_id,
|
| 100 |
+
"task_level": task_level,
|
| 101 |
+
"final_score": round(final_score, 4),
|
| 102 |
+
"steps_used": step,
|
| 103 |
+
"correct": correct
|
| 104 |
+
}), flush=True)
|
| 105 |
+
|
| 106 |
+
return {"task_level": task_level, "score": final_score}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
async def main():
|
| 110 |
+
"""Main entry point for inference script."""
|
| 111 |
+
try:
|
| 112 |
+
# Import environment and agent
|
| 113 |
+
from env.environment import SupportOpsArena
|
| 114 |
+
from baseline.baseline_agent import BaselineAgent
|
| 115 |
+
from adversary.adversary import AdaptiveAdversary
|
| 116 |
+
|
| 117 |
+
# Create environment with adversary
|
| 118 |
+
adversary = AdaptiveAdversary()
|
| 119 |
+
env = SupportOpsArena(adversary=adversary)
|
| 120 |
+
|
| 121 |
+
# Create baseline agent
|
| 122 |
+
agent = BaselineAgent(seed=42)
|
| 123 |
+
|
| 124 |
+
# Run all three task levels
|
| 125 |
+
results = []
|
| 126 |
+
for task_level in ["easy", "medium", "hard"]:
|
| 127 |
+
result = await run_task(task_level, env, agent)
|
| 128 |
+
results.append(result)
|
| 129 |
+
|
| 130 |
+
# Reset agent for next episode
|
| 131 |
+
agent.reset()
|
| 132 |
+
|
| 133 |
+
# Calculate overall benchmark score
|
| 134 |
+
# Weights: easy=0.2, medium=0.3, hard=0.5
|
| 135 |
+
weights = [0.2, 0.3, 0.5]
|
| 136 |
+
overall_score = sum(
|
| 137 |
+
r["score"] * w
|
| 138 |
+
for r, w in zip(results, weights)
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
# [SUMMARY] log
|
| 142 |
+
print(json.dumps({
|
| 143 |
+
"type": "[SUMMARY]",
|
| 144 |
+
"easy_score": round(results[0]["score"], 4),
|
| 145 |
+
"medium_score": round(results[1]["score"], 4),
|
| 146 |
+
"hard_score": round(results[2]["score"], 4),
|
| 147 |
+
"overall_score": round(overall_score, 4),
|
| 148 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 149 |
+
}), flush=True)
|
| 150 |
+
|
| 151 |
+
return 0
|
| 152 |
+
|
| 153 |
+
except Exception as e:
|
| 154 |
+
print(json.dumps({
|
| 155 |
+
"type": "[ERROR]",
|
| 156 |
+
"error": str(e),
|
| 157 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 158 |
+
}), file=sys.stderr, flush=True)
|
| 159 |
+
return 1
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
if __name__ == "__main__":
|
| 163 |
+
exit_code = asyncio.run(main())
|
| 164 |
+
sys.exit(exit_code)
|
openenv.yaml
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: supportops-arena
|
| 2 |
+
version: 1.0.0
|
| 3 |
+
description: >
|
| 4 |
+
Enterprise IT incident triage RL environment. An AI agent acts as a
|
| 5 |
+
Level-1/2 IT support operator, triaging incidents under partial
|
| 6 |
+
observability with risk-weighted actions and an adaptive adversary.
|
| 7 |
+
author: SupportOps Arena Team
|
| 8 |
+
license: Apache-2.0
|
| 9 |
+
tags:
|
| 10 |
+
- openenv
|
| 11 |
+
- rl-environment
|
| 12 |
+
- enterprise
|
| 13 |
+
- it-support
|
| 14 |
+
- partial-observability
|
| 15 |
+
- multi-task
|
| 16 |
+
tasks:
|
| 17 |
+
- id: easy
|
| 18 |
+
name: Wi-Fi Connectivity Failure
|
| 19 |
+
description: Triage a single Wi-Fi failure ticket. 6 possible root causes.
|
| 20 |
+
max_steps: 10
|
| 21 |
+
score_range: [0.0, 1.0]
|
| 22 |
+
grader: programmatic
|
| 23 |
+
difficulty: easy
|
| 24 |
+
- id: medium
|
| 25 |
+
name: VPN Access Failure
|
| 26 |
+
description: Multi-cause VPN failure with one misleading log entry.
|
| 27 |
+
max_steps: 16
|
| 28 |
+
score_range: [0.0, 1.0]
|
| 29 |
+
grader: programmatic+llm
|
| 30 |
+
difficulty: medium
|
| 31 |
+
- id: hard
|
| 32 |
+
name: Cross-System Access Failure
|
| 33 |
+
description: Two tickets share a hidden SSO infrastructure root cause.
|
| 34 |
+
max_steps: 24
|
| 35 |
+
score_range: [0.0, 1.0]
|
| 36 |
+
grader: programmatic+llm+adversarial
|
| 37 |
+
difficulty: hard
|
| 38 |
+
observation_type: structured_json
|
| 39 |
+
action_type: discrete_enum
|
| 40 |
+
action_count: 15
|
| 41 |
+
reward_type: dense_shaped
|
| 42 |
+
reward_range: [-0.40, 1.00]
|
| 43 |
+
grader: programmatic+llm+adversarial
|
| 44 |
+
deployment:
|
| 45 |
+
type: huggingface_spaces
|
| 46 |
+
space_sdk: docker
|
| 47 |
+
port: 7860
|
| 48 |
+
docker: true
|
| 49 |
+
baseline_agent: included
|
| 50 |
+
api_server:
|
| 51 |
+
type: fastapi
|
| 52 |
+
base_url: /
|
| 53 |
+
endpoints:
|
| 54 |
+
reset: POST /reset
|
| 55 |
+
step: POST /step
|
| 56 |
+
state: GET /state
|
| 57 |
+
health: GET /health
|
requirements.txt
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python 3.11+ required
|
| 2 |
+
|
| 3 |
+
# Core Framework
|
| 4 |
+
fastapi==0.110.0
|
| 5 |
+
uvicorn[standard]==0.27.1
|
| 6 |
+
pydantic==2.6.1
|
| 7 |
+
pydantic-settings==2.1.0
|
| 8 |
+
|
| 9 |
+
# LLM Integration
|
| 10 |
+
openai==1.12.0
|
| 11 |
+
|
| 12 |
+
# Async Support
|
| 13 |
+
httpx==0.26.0
|
| 14 |
+
aiofiles==23.2.1
|
| 15 |
+
|
| 16 |
+
# Testing
|
| 17 |
+
pytest==8.0.0
|
| 18 |
+
pytest-asyncio==0.23.5
|
| 19 |
+
pytest-cov==4.1.0
|
| 20 |
+
|
| 21 |
+
# Type Checking & Validation
|
| 22 |
+
typing-extensions==4.9.0
|
| 23 |
+
|
| 24 |
+
# Utilities
|
| 25 |
+
python-dotenv==1.0.1
|
| 26 |
+
numpy==1.26.4
|
task.md
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SupportOps Arena - Task Management
|
| 2 |
+
|
| 3 |
+
## Project Overview
|
| 4 |
+
Building an OpenEnv-compliant RL environment for enterprise IT support simulation.
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## Phase 1: Project Foundation & Structure
|
| 9 |
+
**Status**: ✅ Complete
|
| 10 |
+
|
| 11 |
+
### Tasks:
|
| 12 |
+
- [x] Create directory structure (env/, adversary/, graders/, baseline/, tests/, app/)
|
| 13 |
+
- [x] Setup requirements.txt with all dependencies
|
| 14 |
+
- [x] Create .env.example file
|
| 15 |
+
- [x] Setup basic __init__.py files
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## Phase 2: Core Pydantic Models
|
| 20 |
+
**Status**: ✅ Complete
|
| 21 |
+
|
| 22 |
+
### Tasks:
|
| 23 |
+
- [x] Create env/state.py with all Pydantic v2 models
|
| 24 |
+
- [x] Define enums (NetworkStatus, VPNStatus, AuthStatus, etc.)
|
| 25 |
+
- [x] Define sub-models (UserContext, LogEntry, ActionRecord)
|
| 26 |
+
- [x] Define core models (EnvObservation, HiddenState, EnvState, StepResult)
|
| 27 |
+
|
| 28 |
+
---
|
| 29 |
+
|
| 30 |
+
## Phase 3: Action System
|
| 31 |
+
**Status**: ⏳ Pending
|
| 32 |
+
|
| 33 |
+
### Tasks:
|
| 34 |
+
- [ ] Create env/actions.py with ActionType enum (all 15 actions)
|
| 35 |
+
- [ ] Define ActionMetadata dataclass
|
| 36 |
+
- [ ] Create ACTION_METADATA dictionary with all action specifications
|
| 37 |
+
|
| 38 |
+
---
|
| 39 |
+
|
| 40 |
+
## Phase 4: Scenarios & Task Definitions
|
| 41 |
+
**Status**: ⏳ Pending
|
| 42 |
+
|
| 43 |
+
### Tasks:
|
| 44 |
+
- [ ] Create env/scenarios.py with TicketGenerator class
|
| 45 |
+
- [ ] Define TASK_EASY_ROOT_CAUSES (6+ scenarios)
|
| 46 |
+
- [ ] Define TASK_MEDIUM_ROOT_CAUSES (6+ scenarios)
|
| 47 |
+
- [ ] Define TASK_HARD_ROOT_CAUSES (6+ scenarios)
|
| 48 |
+
- [ ] Implement ticket generation logic with noise/misleading entries
|
| 49 |
+
|
| 50 |
+
---
|
| 51 |
+
|
| 52 |
+
## Phase 5: Rewards System
|
| 53 |
+
**Status**: ⏳ Pending
|
| 54 |
+
|
| 55 |
+
### Tasks:
|
| 56 |
+
- [ ] Create env/rewards.py with REWARD_EVENTS dictionary
|
| 57 |
+
- [ ] Implement RewardCalculator class
|
| 58 |
+
- [ ] Implement reward calculation logic with breakdown
|
| 59 |
+
- [ ] Add normalization logic for episode scores
|
| 60 |
+
|
| 61 |
+
---
|
| 62 |
+
|
| 63 |
+
## Phase 6: State Machine & Transitions
|
| 64 |
+
**Status**: ⏳ Pending
|
| 65 |
+
|
| 66 |
+
### Tasks:
|
| 67 |
+
- [ ] Create env/transitions.py with StateMachine class
|
| 68 |
+
- [ ] Implement transition() method with all precondition rules
|
| 69 |
+
- [ ] Implement observation updates for each action type
|
| 70 |
+
- [ ] Add noise model for inspect_logs
|
| 71 |
+
- [ ] Add escalation unlock logic
|
| 72 |
+
|
| 73 |
+
---
|
| 74 |
+
|
| 75 |
+
## Phase 7: Core Environment
|
| 76 |
+
**Status**: ⏳ Pending
|
| 77 |
+
|
| 78 |
+
### Tasks:
|
| 79 |
+
- [ ] Create env/environment.py with SupportOpsArena class
|
| 80 |
+
- [ ] Implement async reset() method
|
| 81 |
+
- [ ] Implement async step() method with full logic
|
| 82 |
+
- [ ] Implement async state() method
|
| 83 |
+
- [ ] Add episode termination logic
|
| 84 |
+
- [ ] Integrate with all other modules
|
| 85 |
+
|
| 86 |
+
---
|
| 87 |
+
|
| 88 |
+
## Phase 8: Adaptive Adversary
|
| 89 |
+
**Status**: ⏳ Pending
|
| 90 |
+
|
| 91 |
+
### Tasks:
|
| 92 |
+
- [ ] Create adversary/policy_tracker.py with PolicyTracker class
|
| 93 |
+
- [ ] Create adversary/adversary.py with AdaptiveAdversary class
|
| 94 |
+
- [ ] Implement policy weak-point detection
|
| 95 |
+
- [ ] Implement sampling weight adjustment
|
| 96 |
+
- [ ] Add cross-episode tracking
|
| 97 |
+
|
| 98 |
+
---
|
| 99 |
+
|
| 100 |
+
## Phase 9: Grading System
|
| 101 |
+
**Status**: ⏳ Pending
|
| 102 |
+
|
| 103 |
+
### Tasks:
|
| 104 |
+
- [ ] Create graders/programmatic.py with ProgrammaticGrader
|
| 105 |
+
- [ ] Create graders/llm_grader.py with LLMGrader (async)
|
| 106 |
+
- [ ] Create graders/adversarial_grader.py with AdversarialGrader
|
| 107 |
+
- [ ] Implement grading logic for all three types
|
| 108 |
+
- [ ] Add LLM-based quality assessment
|
| 109 |
+
|
| 110 |
+
---
|
| 111 |
+
|
| 112 |
+
## Phase 10: Baseline Agent
|
| 113 |
+
**Status**: ⏳ Pending
|
| 114 |
+
|
| 115 |
+
### Tasks:
|
| 116 |
+
- [ ] Create baseline/baseline_agent.py with BaselineAgent class
|
| 117 |
+
- [ ] Implement rule-based heuristic logic
|
| 118 |
+
- [ ] Add support for all action types
|
| 119 |
+
- [ ] Implement diagnostic → remediation flow
|
| 120 |
+
|
| 121 |
+
---
|
| 122 |
+
|
| 123 |
+
## Phase 11: FastAPI Server
|
| 124 |
+
**Status**: ⏳ Pending
|
| 125 |
+
|
| 126 |
+
### Tasks:
|
| 127 |
+
- [ ] Create app/server.py with FastAPI app
|
| 128 |
+
- [ ] Implement /api/openenv/v1/env/reset endpoint
|
| 129 |
+
- [ ] Implement /api/openenv/v1/env/step endpoint
|
| 130 |
+
- [ ] Implement /api/openenv/v1/env/state endpoint (auth required)
|
| 131 |
+
- [ ] Implement /api/openenv/v1/metrics endpoint
|
| 132 |
+
- [ ] Add health check endpoint /health
|
| 133 |
+
- [ ] Add static file serving for frontend
|
| 134 |
+
|
| 135 |
+
---
|
| 136 |
+
|
| 137 |
+
## Phase 12: Frontend Dashboard
|
| 138 |
+
**Status**: ⏳ Pending
|
| 139 |
+
|
| 140 |
+
### Tasks:
|
| 141 |
+
- [ ] Create app/static/index.html
|
| 142 |
+
- [ ] Implement episode controls UI
|
| 143 |
+
- [ ] Implement live observation display
|
| 144 |
+
- [ ] Implement action history table
|
| 145 |
+
- [ ] Implement reward tracking chart
|
| 146 |
+
- [ ] Add metrics visualization
|
| 147 |
+
- [ ] Apply glassmorphism design system
|
| 148 |
+
|
| 149 |
+
---
|
| 150 |
+
|
| 151 |
+
## Phase 13: Configuration Files
|
| 152 |
+
**Status**: ⏳ Pending
|
| 153 |
+
|
| 154 |
+
### Tasks:
|
| 155 |
+
- [ ] Create openenv.yaml with full metadata
|
| 156 |
+
- [ ] Create Dockerfile (multi-stage if needed)
|
| 157 |
+
- [ ] Verify Docker build and run commands
|
| 158 |
+
|
| 159 |
+
---
|
| 160 |
+
|
| 161 |
+
## Phase 14: Inference Script
|
| 162 |
+
**Status**: ⏳ Pending
|
| 163 |
+
|
| 164 |
+
### Tasks:
|
| 165 |
+
- [ ] Create inference.py at root level
|
| 166 |
+
- [ ] Implement episode runner logic
|
| 167 |
+
- [ ] Add metric calculation and reporting
|
| 168 |
+
- [ ] Support multiple episodes with different seeds
|
| 169 |
+
|
| 170 |
+
---
|
| 171 |
+
|
| 172 |
+
## Phase 15: Testing Suite
|
| 173 |
+
**Status**: ⏳ Pending
|
| 174 |
+
|
| 175 |
+
### Tasks:
|
| 176 |
+
- [ ] Create tests/conftest.py with fixtures
|
| 177 |
+
- [ ] Create tests/test_env.py with environment tests
|
| 178 |
+
- [ ] Create tests/test_graders.py with grader tests
|
| 179 |
+
- [ ] Create tests/test_transitions.py with state machine tests
|
| 180 |
+
- [ ] Create tests/test_adversary.py with adversary tests
|
| 181 |
+
- [ ] Ensure all tests pass with pytest
|
| 182 |
+
|
| 183 |
+
---
|
| 184 |
+
|
| 185 |
+
## Phase 16: Documentation
|
| 186 |
+
**Status**: ⏳ Pending
|
| 187 |
+
|
| 188 |
+
### Tasks:
|
| 189 |
+
- [ ] Create comprehensive README.md
|
| 190 |
+
- [ ] Add quickstart guide
|
| 191 |
+
- [ ] Document API endpoints
|
| 192 |
+
- [ ] Add task examples with difficulty levels
|
| 193 |
+
- [ ] Include environment details and metrics
|
| 194 |
+
- [ ] Add troubleshooting section
|
| 195 |
+
|
| 196 |
+
---
|
| 197 |
+
|
| 198 |
+
## Phase 17: Final Validation
|
| 199 |
+
**Status**: ⏳ Pending
|
| 200 |
+
|
| 201 |
+
### Tasks:
|
| 202 |
+
- [ ] Run full test suite
|
| 203 |
+
- [ ] Test Docker build and deployment
|
| 204 |
+
- [ ] Verify all endpoints work
|
| 205 |
+
- [ ] Test baseline agent performance
|
| 206 |
+
- [ ] Validate OpenEnv compliance
|
| 207 |
+
- [ ] Check Hugging Face Spaces compatibility
|
| 208 |
+
- [ ] Complete pre-submission checklist
|
| 209 |
+
|
| 210 |
+
---
|
| 211 |
+
|
| 212 |
+
## Notes
|
| 213 |
+
- Follow PRD.md strictly for all specifications
|
| 214 |
+
- Use Pydantic v2 syntax exclusively
|
| 215 |
+
- All async code properly implemented
|
| 216 |
+
- No hardcoded credentials
|
| 217 |
+
- Proper error handling throughout
|
tests/__init__.py
ADDED
|
File without changes
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pytest fixtures and configuration for SupportOps Arena tests.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
import asyncio
|
| 7 |
+
|
| 8 |
+
from env.environment import SupportOpsArena
|
| 9 |
+
from adversary.adversary import AdaptiveAdversary
|
| 10 |
+
from baseline.baseline_agent import BaselineAgent
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@pytest.fixture
|
| 14 |
+
def event_loop():
|
| 15 |
+
"""Create event loop for async tests."""
|
| 16 |
+
loop = asyncio.get_event_loop_policy().new_event_loop()
|
| 17 |
+
yield loop
|
| 18 |
+
loop.close()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.fixture
|
| 22 |
+
async def env():
|
| 23 |
+
"""Create test environment instance."""
|
| 24 |
+
adversary = AdaptiveAdversary()
|
| 25 |
+
environment = SupportOpsArena(adversary=adversary)
|
| 26 |
+
return environment
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@pytest.fixture
|
| 30 |
+
def agent():
|
| 31 |
+
"""Create baseline agent instance."""
|
| 32 |
+
return BaselineAgent(seed=42)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@pytest.fixture
|
| 36 |
+
def task_levels():
|
| 37 |
+
"""Provide list of task levels."""
|
| 38 |
+
return ["easy", "medium", "hard"]
|
tests/test_env.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for SupportOpsArena environment core functionality.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from env.environment import SupportOpsArena
|
| 7 |
+
from env.actions import ActionType
|
| 8 |
+
from env.state import TaskLevel
|
| 9 |
+
from adversary.adversary import AdaptiveAdversary
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class TestEnvironmentBasics:
|
| 13 |
+
"""Test basic environment functionality."""
|
| 14 |
+
|
| 15 |
+
@pytest.mark.asyncio
|
| 16 |
+
async def test_environment_initialization(self):
|
| 17 |
+
"""Test environment can be created."""
|
| 18 |
+
adversary = AdaptiveAdversary()
|
| 19 |
+
env = SupportOpsArena(adversary=adversary)
|
| 20 |
+
assert env is not None
|
| 21 |
+
|
| 22 |
+
@pytest.mark.asyncio
|
| 23 |
+
async def test_reset_easy(self):
|
| 24 |
+
"""Test reset for easy task."""
|
| 25 |
+
adversary = AdaptiveAdversary()
|
| 26 |
+
env = SupportOpsArena(adversary=adversary)
|
| 27 |
+
obs = await env.reset(task_level="easy", seed=42)
|
| 28 |
+
|
| 29 |
+
assert obs.task_level == TaskLevel.EASY
|
| 30 |
+
assert obs.step_count == 0
|
| 31 |
+
assert len(obs.system_logs) > 0
|
| 32 |
+
assert obs.escalation_allowed == False
|
| 33 |
+
|
| 34 |
+
@pytest.mark.asyncio
|
| 35 |
+
async def test_reset_medium(self):
|
| 36 |
+
"""Test reset for medium task."""
|
| 37 |
+
adversary = AdaptiveAdversary()
|
| 38 |
+
env = SupportOpsArena(adversary=adversary)
|
| 39 |
+
obs = await env.reset(task_level="medium", seed=42)
|
| 40 |
+
|
| 41 |
+
assert obs.task_level == TaskLevel.MEDIUM
|
| 42 |
+
assert obs.step_count == 0
|
| 43 |
+
|
| 44 |
+
@pytest.mark.asyncio
|
| 45 |
+
async def test_reset_hard(self):
|
| 46 |
+
"""Test reset for hard task."""
|
| 47 |
+
adversary = AdaptiveAdversary()
|
| 48 |
+
env = SupportOpsArena(adversary=adversary)
|
| 49 |
+
obs = await env.reset(task_level="hard", seed=42)
|
| 50 |
+
|
| 51 |
+
assert obs.task_level == TaskLevel.HARD
|
| 52 |
+
assert obs.step_count == 0
|
| 53 |
+
|
| 54 |
+
@pytest.mark.asyncio
|
| 55 |
+
async def test_step_basic_action(self):
|
| 56 |
+
"""Test taking a basic step."""
|
| 57 |
+
adversary = AdaptiveAdversary()
|
| 58 |
+
env = SupportOpsArena(adversary=adversary)
|
| 59 |
+
obs = await env.reset(task_level="easy", seed=42)
|
| 60 |
+
|
| 61 |
+
result = await env.step(ActionType.INSPECT_LOGS)
|
| 62 |
+
|
| 63 |
+
assert result.observation.step_count == 1
|
| 64 |
+
assert result.reward != 0.0 # Should get diagnostic reward
|
| 65 |
+
assert result.done == False
|
| 66 |
+
|
| 67 |
+
@pytest.mark.asyncio
|
| 68 |
+
async def test_invalid_task_level(self):
|
| 69 |
+
"""Test reset with invalid task level raises error."""
|
| 70 |
+
adversary = AdaptiveAdversary()
|
| 71 |
+
env = SupportOpsArena(adversary=adversary)
|
| 72 |
+
|
| 73 |
+
with pytest.raises(ValueError):
|
| 74 |
+
await env.reset(task_level="invalid")
|
| 75 |
+
|
| 76 |
+
@pytest.mark.asyncio
|
| 77 |
+
async def test_episode_done_on_resolve(self):
|
| 78 |
+
"""Test episode ends when ticket is resolved."""
|
| 79 |
+
adversary = AdaptiveAdversary()
|
| 80 |
+
env = SupportOpsArena(adversary=adversary)
|
| 81 |
+
await env.reset(task_level="easy", seed=42)
|
| 82 |
+
|
| 83 |
+
# Take a few diagnostic actions
|
| 84 |
+
await env.step(ActionType.INSPECT_LOGS)
|
| 85 |
+
await env.step(ActionType.CHECK_AUTHENTICATION)
|
| 86 |
+
await env.step(ActionType.INSPECT_NETWORK)
|
| 87 |
+
|
| 88 |
+
# Resolve ticket
|
| 89 |
+
result = await env.step(ActionType.RESOLVE_TICKET)
|
| 90 |
+
|
| 91 |
+
assert result.done == True
|
| 92 |
+
assert "root_cause" in result.info
|
| 93 |
+
|
| 94 |
+
@pytest.mark.asyncio
|
| 95 |
+
async def test_state_method(self):
|
| 96 |
+
"""Test state() method returns full state."""
|
| 97 |
+
adversary = AdaptiveAdversary()
|
| 98 |
+
env = SupportOpsArena(adversary=adversary)
|
| 99 |
+
await env.reset(task_level="easy", seed=42)
|
| 100 |
+
|
| 101 |
+
state = await env.state()
|
| 102 |
+
|
| 103 |
+
assert state.observation is not None
|
| 104 |
+
assert state.hidden is not None
|
| 105 |
+
assert state.episode_id is not None
|
| 106 |
+
|
| 107 |
+
@pytest.mark.asyncio
|
| 108 |
+
async def test_escalation_unlock(self):
|
| 109 |
+
"""Test escalation is unlocked after enough diagnostics."""
|
| 110 |
+
adversary = AdaptiveAdversary()
|
| 111 |
+
env = SupportOpsArena(adversary=adversary)
|
| 112 |
+
obs = await env.reset(task_level="easy", seed=42)
|
| 113 |
+
|
| 114 |
+
assert obs.escalation_allowed == False
|
| 115 |
+
|
| 116 |
+
# Take 3 diagnostic actions (unlock threshold for easy)
|
| 117 |
+
result1 = await env.step(ActionType.INSPECT_LOGS)
|
| 118 |
+
result2 = await env.step(ActionType.CHECK_AUTHENTICATION)
|
| 119 |
+
result3 = await env.step(ActionType.INSPECT_NETWORK)
|
| 120 |
+
|
| 121 |
+
# Should now be unlocked
|
| 122 |
+
assert result3.observation.escalation_allowed == True
|
| 123 |
+
|
| 124 |
+
@pytest.mark.asyncio
|
| 125 |
+
async def test_step_without_reset_raises_error(self):
|
| 126 |
+
"""Test that step without reset raises error."""
|
| 127 |
+
adversary = AdaptiveAdversary()
|
| 128 |
+
env = SupportOpsArena(adversary=adversary)
|
| 129 |
+
|
| 130 |
+
with pytest.raises(RuntimeError):
|
| 131 |
+
await env.step(ActionType.INSPECT_LOGS)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class TestRewardSystem:
|
| 135 |
+
"""Test reward calculation."""
|
| 136 |
+
|
| 137 |
+
@pytest.mark.asyncio
|
| 138 |
+
async def test_diagnostic_action_reward(self):
|
| 139 |
+
"""Test diagnostic actions give positive reward."""
|
| 140 |
+
adversary = AdaptiveAdversary()
|
| 141 |
+
env = SupportOpsArena(adversary=adversary)
|
| 142 |
+
await env.reset(task_level="easy", seed=42)
|
| 143 |
+
|
| 144 |
+
result = await env.step(ActionType.INSPECT_LOGS)
|
| 145 |
+
|
| 146 |
+
assert result.reward > 0 # Should get diagnostic bonus
|
| 147 |
+
assert "DIAGNOSTIC_ACTION" in str(result.info.get("reward_breakdown", {}))
|
| 148 |
+
|
| 149 |
+
@pytest.mark.asyncio
|
| 150 |
+
async def test_redundant_action_penalty(self):
|
| 151 |
+
"""Test redundant actions are penalized."""
|
| 152 |
+
adversary = AdaptiveAdversary()
|
| 153 |
+
env = SupportOpsArena(adversary=adversary)
|
| 154 |
+
await env.reset(task_level="easy", seed=42)
|
| 155 |
+
|
| 156 |
+
# Take same action twice
|
| 157 |
+
result1 = await env.step(ActionType.INSPECT_LOGS)
|
| 158 |
+
result2 = await env.step(ActionType.INSPECT_LOGS)
|
| 159 |
+
|
| 160 |
+
# Second time should have penalty
|
| 161 |
+
assert "REDUNDANT_ACTION" in str(result2.info.get("reward_breakdown", {}))
|
tests/test_graders.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for grading system.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from env.environment import SupportOpsArena
|
| 7 |
+
from env.actions import ActionType
|
| 8 |
+
from adversary.adversary import AdaptiveAdversary
|
| 9 |
+
from graders.programmatic import ProgrammaticGrader
|
| 10 |
+
from graders.adversarial_grader import aggregate_scores
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class TestProgrammaticGrader:
|
| 14 |
+
"""Test programmatic grader functionality."""
|
| 15 |
+
|
| 16 |
+
@pytest.mark.asyncio
|
| 17 |
+
async def test_grader_initialization(self):
|
| 18 |
+
"""Test grader can be created."""
|
| 19 |
+
grader = ProgrammaticGrader()
|
| 20 |
+
assert grader is not None
|
| 21 |
+
|
| 22 |
+
@pytest.mark.asyncio
|
| 23 |
+
async def test_grade_complete_episode(self):
|
| 24 |
+
"""Test grading a complete episode."""
|
| 25 |
+
adversary = AdaptiveAdversary()
|
| 26 |
+
env = SupportOpsArena(adversary=adversary)
|
| 27 |
+
await env.reset(task_level="easy", seed=42)
|
| 28 |
+
|
| 29 |
+
# Run a simple episode
|
| 30 |
+
await env.step(ActionType.INSPECT_LOGS)
|
| 31 |
+
await env.step(ActionType.CHECK_AUTHENTICATION)
|
| 32 |
+
await env.step(ActionType.INSPECT_NETWORK)
|
| 33 |
+
await env.step(ActionType.RESOLVE_TICKET)
|
| 34 |
+
|
| 35 |
+
# Grade episode
|
| 36 |
+
state = await env.state()
|
| 37 |
+
grader = ProgrammaticGrader()
|
| 38 |
+
result = await grader.grade(state)
|
| 39 |
+
|
| 40 |
+
assert result.score >= 0.0
|
| 41 |
+
assert result.score <= 1.0
|
| 42 |
+
assert result.action_sequence_safe is not None
|
| 43 |
+
assert result.step_budget_respected is not None
|
| 44 |
+
|
| 45 |
+
@pytest.mark.asyncio
|
| 46 |
+
async def test_grade_score_range(self):
|
| 47 |
+
"""Test grade score is in valid range."""
|
| 48 |
+
adversary = AdaptiveAdversary()
|
| 49 |
+
env = SupportOpsArena(adversary=adversary)
|
| 50 |
+
await env.reset(task_level="easy", seed=42)
|
| 51 |
+
|
| 52 |
+
# Take minimal actions
|
| 53 |
+
await env.step(ActionType.INSPECT_LOGS)
|
| 54 |
+
await env.step(ActionType.RESOLVE_TICKET)
|
| 55 |
+
|
| 56 |
+
state = await env.state()
|
| 57 |
+
grader = ProgrammaticGrader()
|
| 58 |
+
result = await grader.grade(state)
|
| 59 |
+
|
| 60 |
+
assert 0.0 <= result.score <= 1.0
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class TestScoreAggregation:
|
| 64 |
+
"""Test score aggregation logic."""
|
| 65 |
+
|
| 66 |
+
def test_aggregate_easy(self):
|
| 67 |
+
"""Test aggregation for easy task (programmatic only)."""
|
| 68 |
+
score = aggregate_scores(0.75, None, None, "easy")
|
| 69 |
+
assert score == 0.75
|
| 70 |
+
|
| 71 |
+
def test_aggregate_medium(self):
|
| 72 |
+
"""Test aggregation for medium task (programmatic + LLM)."""
|
| 73 |
+
score = aggregate_scores(0.75, 0.60, None, "medium")
|
| 74 |
+
assert 0.65 < score < 0.75 # Weighted average
|
| 75 |
+
|
| 76 |
+
def test_aggregate_hard(self):
|
| 77 |
+
"""Test aggregation for hard task (all graders)."""
|
| 78 |
+
score = aggregate_scores(0.75, 0.60, 0.50, "hard")
|
| 79 |
+
assert 0.5 < score < 0.75 # Weighted average
|