File size: 11,632 Bytes
922c4d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
"""
IncidentOps Core Environment.

Implements the OpenEnv Environment base class for the IncidentOps SRE
incident-response training environment.

Episode lifecycle:
  1. reset(task_name=...) β€” loads a scenario and returns the initial briefing
  2. step(action)         β€” executes a command, returns observation + reward
  3. state()              β€” returns current State (episode_id, step_count)

The environment terminates (done=True) when:
  - The agent calls 'resolve'
  - step_count reaches scenario.max_steps (episode timeout)
"""

from __future__ import annotations

import os
from datetime import datetime, timezone
from typing import Any, Optional
from uuid import uuid4

try:
    from openenv.core.env_server.interfaces import Environment
    from openenv.core.env_server.types import Action, Observation, State
except ImportError:
    from openenv.core.env_server.interfaces import Environment  # type: ignore[no-redef]
    from openenv.core.env_server.types import Action, Observation, State

try:
    from models import IncidentAction, IncidentObservation
    from server.graders import grade
    from server.scenarios import ALL_TASK_NAMES, Scenario, get_scenario
    from server.simulation import SimulationEngine
except ImportError:
    from ..models import IncidentAction, IncidentObservation
    from .graders import grade
    from .scenarios import ALL_TASK_NAMES, Scenario, get_scenario
    from .simulation import SimulationEngine


# Default task if none specified at reset
DEFAULT_TASK = os.getenv("INCIDENT_TASK", "service-restart")


# ---------------------------------------------------------
#  Strict open-interval clamp for ALL rewards/scores.
#  Duplicated here as a second defense layer.
# ---------------------------------------------------------

def _safe_reward(value: float) -> float:
    """
    Clamp reward to the OPEN interval (0.01, 0.99).

    The OpenEnv Phase 2 validator rejects scores that are exactly 0.0 or 1.0.
    We never allow a reward of 0 or 1 to leave this module.
    """
    v = float(value)
    if v <= 0.0:
        return 0.01
    if v >= 1.0:
        return 0.99
    if v < 0.01:
        return 0.01
    if v > 0.99:
        return 0.99
    return v


class IncidentOpsEnvironment(Environment):
    """
    IncidentOps: AI Incident Response Training Environment.

    The agent acts as an on-call SRE engineer, interacting with a simulated
    production system through a text-based terminal interface.

    Three tasks of increasing difficulty are supported:
      - service-restart   (easy)
      - config-drift      (medium)
      - cascading-failure (hard)

    Each task is self-contained and can be selected at reset time by passing
    task_name in the reset request body:
        {"task_name": "config-drift"}

    Attributes:
        SUPPORTS_CONCURRENT_SESSIONS: False β€” environment is stateful per session.
    """

    SUPPORTS_CONCURRENT_SESSIONS = False

    def __init__(self) -> None:
        self._state: State = State(episode_id=str(uuid4()), step_count=0)
        self._scenario: Optional[Scenario] = None
        self._engine: Optional[SimulationEngine] = None
        self._done: bool = False
        self._final_score: float = 0.5
        self._task_name: str = DEFAULT_TASK
        self._cumulative_reward: float = 0.0

    # --- Core API -------------------------------------------------

    def reset(
        self,
        seed: Optional[int] = None,
        episode_id: Optional[str] = None,
        task_name: Optional[str] = None,
        **kwargs: Any,
    ) -> IncidentObservation:
        """
        Reset the environment for a new episode.

        Args:
            seed:       Optional seed for reproducible scenario generation.
            episode_id: Optional custom episode identifier.
            task_name:  Which task to load. One of:
                        "service-restart" | "config-drift" | "cascading-failure"
                        Defaults to the INCIDENT_TASK env var or "service-restart".
            **kwargs:   Ignored extra keyword arguments.

        Returns:
            IncidentObservation with initial briefing text and alert counts.
        """
        self._task_name = task_name or DEFAULT_TASK

        if self._task_name not in ALL_TASK_NAMES:
            valid = ", ".join(ALL_TASK_NAMES)
            self._task_name = DEFAULT_TASK
            briefing = (
                f"  [WARNING] Unknown task_name. Valid options: {valid}\n"
                f"Loading default task: '{self._task_name}'.\n"
            )
        else:
            briefing = ""

        self._scenario = get_scenario(self._task_name, seed=seed)
        self._engine = SimulationEngine(self._scenario)
        self._done = False
        self._cumulative_reward = 0.0
        self._final_score = 0.5
        self._state = State(
            episode_id=episode_id or str(uuid4()),
            step_count=0,
        )

        briefing += self._build_briefing()

        # Reset reward must be returned as a valid open interval float.
        # The Phase 1 validator crashes if it receives null/None here.
        return IncidentObservation(
            output=briefing,
            timestamp=self._sim_time(),
            alert_count=len(self._engine.active_alerts),
            severity=self._engine.current_severity,
            affected_services=self._engine.affected_services,
            done=False,
            reward=_safe_reward(0.5),
            metadata={
                "task_name": self._task_name,
                "episode_id": self._state.episode_id,
                "difficulty": self._scenario.difficulty,
                "max_steps": self._scenario.max_steps,
            },
        )

    def step(
        self,
        action: Action,
        timeout_s: Optional[float] = None,
        **kwargs: Any,
    ) -> IncidentObservation:
        """
        Execute one agent action.

        Args:
            action:    An IncidentAction with a text command string.
            timeout_s: Ignored (commands are synchronous and fast).
            **kwargs:  Extra keyword arguments are ignored.

        Returns:
            IncidentObservation with command output, updated metrics, and reward.
        """
        if self._done:
            return self._terminal_observation("Episode already finished. Call reset() to start a new one.")

        if self._scenario is None or self._engine is None:
            return self._terminal_observation("Environment not initialised. Call reset() first.")

        # Extract command string from action
        if isinstance(action, IncidentAction):
            command = action.command
        elif isinstance(action, dict):
            command = action.get("command", "")
        else:
            command = str(getattr(action, "command", action))

        self._state.step_count += 1
        step_num = self._state.step_count

        # Execute command in simulation
        output, incremental_reward, done_by_resolve = self._engine.execute(command)

        # Check episode termination
        timeout_reached = step_num >= self._scenario.max_steps
        self._done = done_by_resolve or timeout_reached

        # Compute reward β€” always pass through _safe_reward
        if self._done:
            self._final_score = grade(
                scenario=self._scenario,
                engine=self._engine,
                step_count=step_num,
            )
            # _safe_reward is defense-in-depth (grader already clamps)
            reward = _safe_reward(self._final_score)
        else:
            # Incremental reward centred at 0.5, clamped to (0.01, 0.99)
            reward = _safe_reward(0.5 + incremental_reward)

        self._cumulative_reward += incremental_reward

        # Append timeout notice if applicable
        if timeout_reached and not done_by_resolve:
            output += (
                f"\n\n  [TIMEOUT] Episode timeout reached ({self._scenario.max_steps} steps).\n"
                f"      Final score computed based on actions taken so far.\n"
            )

        return IncidentObservation(
            output=output,
            timestamp=self._sim_time(offset_minutes=step_num),
            alert_count=len(self._engine.active_alerts),
            severity=self._engine.current_severity,
            affected_services=self._engine.affected_services,
            done=self._done,
            reward=reward,
            metadata={
                "task_name": self._task_name,
                "episode_id": self._state.episode_id,
                "step": step_num,
                "command": command,
                "final_score": _safe_reward(self._final_score) if self._done else None,
            },
        )

    @property
    def state(self) -> State:
        """Return the current episode state."""
        return self._state

    def close(self) -> None:
        """Release any resources (nothing to release in this implementation)."""
        pass

    # --- Helpers ---------------------------------------------

    def _build_briefing(self) -> str:
        """Build the initial incident briefing shown to the agent."""
        s = self._scenario
        e = self._engine

        lines = [
            "",
            "+------------------------------------------------------------------β•—",
            "|          IncidentOps β€” Incident Response Environment              |",
            "+------------------------------------------------------------------╝",
            "",
           f"  [Task]         {s.display_name}",
           f"  [Difficulty]   {s.difficulty.upper()}",
           f"  [Sim-Time]     {s.start_time}",
           f"  [Alerts]       {len(e.active_alerts)} active",
           f"  [Severity]     {e.current_severity.upper()}",
            "",
           f"  DESCRIPTION:",
        ]
        # Wrap description text
        import textwrap
        for line in textwrap.wrap(s.description, width=66):
            lines.append(f"  {line}")

        lines += [
            "",
            "  -------------------------------------------------------------",
            "  You are the on-call engineer. Triage the incident, find the",
            "  root cause, remediate it, and type 'resolve' when done.",
            "",
            "  Type 'help' to see all available commands.",
            "  Type 'alerts' to see active alerts.",
            "  Type 'status' for a system-wide dashboard.",
            "  -------------------------------------------------------------",
            "",
        ]
        return "\n".join(lines)

    def _sim_time(self, offset_minutes: int = 0) -> str:
        """Return a simulated ISO-8601 timestamp."""
        if self._scenario:
            base = self._scenario.start_time  # e.g. "2026-04-10T04:00:00Z"
            try:
                from datetime import timedelta
                dt = datetime.fromisoformat(base.replace("Z", "+00:00"))
                dt += timedelta(minutes=offset_minutes)
                return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
            except Exception:
                pass
        return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

    def _terminal_observation(self, message: str) -> IncidentObservation:
        """Return a terminal observation for error/edge cases."""
        return IncidentObservation(
            output=f"\n  {message}\n",
            timestamp=self._sim_time(),
            alert_count=0,
            severity="none",
            affected_services=[],
            done=True,
            reward=_safe_reward(0.5),   # Always use _safe_reward
            metadata={},
        )