printf-sourav's picture
Initial commit
27cdb3e
Raw
History Blame Contribute Delete
10.4 kB
"""
DevOpsEnv — OpenEnv-style RL environment for terminal troubleshooting.
The agent observes broken Linux/Python environment states, issues shell commands,
and receives multi-signal rewards. Episodes are bounded by max steps, success,
or dangerous command detection.
"""
from __future__ import annotations
import random
from typing import Any, Dict, List, Optional, Tuple
from devops_env.state_manager import StateManager
from executor.docker_executor import DockerExecutor, ExecutionResult
from fingerprint.classifier import ErrorFingerprinter
from rewards.engine import RewardEngine
from scenarios.registry import Scenario, ScenarioRegistry
class DevOpsEnv:
"""OpenEnv-style environment for DevOps troubleshooting with RL.
The agent receives an error log and command history as observations,
outputs a shell command, and gets a reward based on whether the
command moved toward fixing the issue.
Attributes:
metadata: Environment metadata dict.
max_steps: Maximum steps per episode before truncation.
"""
metadata = {"render_modes": ["human"]}
def __init__(
self,
scenario_registry: ScenarioRegistry | None = None,
executor: DockerExecutor | None = None,
max_steps: int = 10,
render_mode: str | None = None,
target_level: int | None = None,
target_scenario: str | None = None,
) -> None:
"""Initialize the DevOps environment.
Args:
scenario_registry: Registry of available scenarios. Creates default if None.
executor: Docker executor for running commands. Creates default if None.
max_steps: Maximum steps per episode.
render_mode: Render mode.
target_level: If set, only sample scenarios from this level.
target_scenario: If set, always use this specific scenario.
"""
self.max_steps = max_steps
self.render_mode = render_mode
self.target_level = target_level
self.target_scenario = target_scenario
# Initialize components
if scenario_registry is None:
self.registry = ScenarioRegistry()
self.registry.register_defaults()
else:
self.registry = scenario_registry
self.executor = executor or DockerExecutor(use_local_fallback=True)
self.state_manager = StateManager()
self.reward_engine = RewardEngine()
self.fingerprinter = ErrorFingerprinter()
# Episode state
self._current_scenario: Optional[Scenario] = None
self._step_count: int = 0
self._episode_reward: float = 0.0
self._episode_steps: List[Dict] = []
self._done: bool = False
# OpenEnv schemas (documented shape constraints for API clients)
self.observation_schema: Dict[str, str] = {
"error_log": "str(max=2000)",
"command_history": "List[str](max_items=10)",
"step_count": f"int(0..{max_steps})",
"scenario_id": "str(max=100)",
"error_type": "str(max=50)",
"error_confidence": "float(0.0..1.0)",
"is_terminal": "bool",
"solved": "bool",
}
self.action_schema: Dict[str, str] = {
"command": "str(max=500)",
}
def reset(
self,
seed: int | None = None,
options: Dict[str, Any] | None = None,
) -> Tuple[Dict, Dict]:
"""Reset the environment for a new episode.
Loads a random scenario (or the target scenario), sets up the
Docker sandbox, and returns the initial observation.
Args:
seed: Random seed for reproducibility.
options: Additional options (e.g., {"scenario_id": "missing_flask"}).
Returns:
Tuple of (observation, info_dict).
"""
if seed is not None:
random.seed(seed)
# Select scenario
scenario_id = None
if options and "scenario_id" in options:
scenario_id = options["scenario_id"]
elif self.target_scenario:
scenario_id = self.target_scenario
if scenario_id:
self._current_scenario = self.registry.get(scenario_id)
else:
self._current_scenario = self.registry.get_random(level=self.target_level)
# Reset episode state
self._step_count = 0
self._episode_reward = 0.0
self._episode_steps = []
self._done = False
# Set up Docker sandbox
try:
self.executor.stop_container()
self.executor.start_container(self._current_scenario.setup_commands)
except Exception:
# Continue with local fallback
pass
# Initialize state with the scenario's error log
obs = self.state_manager.reset(
scenario_id=self._current_scenario.id,
initial_error_log=self._current_scenario.initial_error_log,
)
info = {
"scenario_id": self._current_scenario.id,
"level": self._current_scenario.level,
"description": self._current_scenario.description,
"error_type": obs["error_type"],
}
return obs, info
def step(self, action: str) -> Tuple[Dict, float, bool, bool, Dict]:
"""Execute one step in the environment.
Args:
action: Shell command to execute.
Returns:
Tuple of (observation, reward, terminated, truncated, info).
"""
if self._done:
raise RuntimeError("Episode is done. Call reset() first.")
assert self._current_scenario is not None
self._step_count += 1
action = action.strip()
# Execute command in sandbox
result = self.executor.execute(action)
# Build new error log from execution output
if result.blocked:
new_error_log = f"COMMAND BLOCKED: {result.block_reason}"
elif result.timed_out:
new_error_log = "COMMAND TIMED OUT after 30 seconds."
else:
new_error_log = ""
if result.stdout:
new_error_log += result.stdout
if result.stderr:
new_error_log += ("\n" if new_error_log else "") + result.stderr
if not new_error_log:
new_error_log = f"Command completed with exit code {result.exit_code}"
# Get previous error log for reward computation
prev_error_log = self.state_manager.get_prev_error_log()
# Compute reward
all_commands = list(self.state_manager.state.command_history) + [action]
reward, reward_breakdown = self.reward_engine.compute_reward(
action=action,
result=result,
scenario=self._current_scenario,
step_count=self._step_count,
command_history=all_commands,
prev_error_log=prev_error_log,
curr_error_log=new_error_log,
)
# Check termination conditions
combined_output = f"{result.stdout}\n{result.stderr}".strip()
solved = self._current_scenario.success_condition(combined_output)
is_dangerous_block = result.blocked and "dangerous" in result.block_reason.lower()
terminated = solved or is_dangerous_block
truncated = self._step_count >= self.max_steps
# Update state
obs = self.state_manager.update(
command=action,
new_error_log=new_error_log,
is_terminal=terminated or truncated,
solved=solved,
)
# Track episode
self._episode_reward += reward
self._episode_steps.append({
"step": self._step_count,
"action": action,
"result": {
"stdout": result.stdout[:1000],
"stderr": result.stderr[:1000],
"exit_code": result.exit_code,
"timed_out": result.timed_out,
"blocked": result.blocked,
},
"reward": reward,
"reward_breakdown": reward_breakdown,
"error_type": obs["error_type"],
"observation": {
"error_log": obs["error_log"][:500],
"command_history": obs["command_history"],
"step_count": obs["step_count"],
},
})
self._done = terminated or truncated
info = {
"scenario_id": self._current_scenario.id,
"level": self._current_scenario.level,
"solved": solved,
"step_count": obs["step_count"],
"episode_reward": self._episode_reward,
"reward_breakdown": reward_breakdown,
"error_type": obs["error_type"],
"execution_result": {
"exit_code": result.exit_code,
"blocked": result.blocked,
"timed_out": result.timed_out,
},
}
if self._done:
info["episode_steps"] = self._episode_steps
return obs, reward, terminated, truncated, info
def get_episode_summary(self) -> Dict:
"""Get a summary of the current/last episode.
Returns:
Dict with episode metadata and step details.
"""
return {
"scenario_id": self._current_scenario.id if self._current_scenario else None,
"level": self._current_scenario.level if self._current_scenario else None,
"steps": self._episode_steps,
"total_reward": self._episode_reward,
"solved": self.state_manager.state.solved,
"total_steps": self._step_count,
}
def render(self) -> None:
"""Render the current environment state (human-readable)."""
if self.render_mode != "human":
return
state = self.state_manager.state
print(f"\n{'='*60}")
print(f"Scenario: {state.scenario_id} | Step: {state.step_count}")
print(f"Error Type: {state.error_type}")
print(f"{'─'*60}")
print(f"Error Log:\n{state.error_log[:500]}")
print(f"{'─'*60}")
if state.command_history:
print(f"Commands: {state.command_history}")
print(f"{'='*60}\n")
def close(self) -> None:
"""Clean up resources."""
self.executor.stop_container()