prathvi9607 commited on
Commit
9c1645f
·
1 Parent(s): 825c00b

done phase 1

Browse files
.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
- title: SupportOps
3
- emoji: 🐢
4
- colorFrom: blue
5
- colorTo: indigo
6
- sdk: docker
7
- pinned: false
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
+ # 🎯 SupportOps Arena
2
+
3
+ **Enterprise IT Incident Triage Reinforcement Learning Environment**
4
+
5
+ [![OpenEnv](https://img.shields.io/badge/OpenEnv-compliant-blue)](https://openenv.ai)
6
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
7
+ [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-green.svg)](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