DevOps_Debugger / rewards /engine.py
printf-sourav's picture
Initial commit
27cdb3e
Raw
History Blame Contribute Delete
10 kB
"""
Multi-Signal Reward Engine — Computes composite rewards for the DevOps RL agent.
Each action receives a multi-component reward based on success, progress,
efficiency, safety, and other signals. Returns both the total reward
and a detailed breakdown for logging and analysis.
"""
from __future__ import annotations
import re
from typing import Dict, List, Tuple
from executor.docker_executor import ExecutionResult
from fingerprint.classifier import ErrorFingerprinter
from scenarios.registry import Scenario
class RewardEngine:
"""Computes multi-signal rewards for agent actions.
Reward components:
- success: +10.0 when scenario success_condition is met
- correct_command: +3.0 when action matches a hint command
- progress: +1.0 when error log changes (shorter/different)
- efficiency_bonus: +2.0 when solved in ≤ len(hint_commands) steps
- invalid_command: -2.0 when command is not in the whitelist
- dangerous_command: -10.0 when command matches blocklist
- no_progress: -1.0 when error log is identical to previous
- timeout: -5.0 when command times out
- repeated_command: -1.5 when same command issued twice in episode
- step_cost: -0.2 per step (encourages efficiency)
Usage:
engine = RewardEngine()
total, breakdown = engine.compute_reward(
action="pip install flask",
result=execution_result,
scenario=scenario,
step_count=1,
command_history=["pip install flask"],
prev_error_log="ModuleNotFoundError...",
curr_error_log="Successfully installed flask",
)
"""
# Reward signal values (configurable)
REWARD_SUCCESS: float = 10.0
REWARD_CORRECT_COMMAND: float = 1.5
REWARD_PROGRESS: float = 1.0
REWARD_EFFICIENCY_BONUS: float = 2.0
PENALTY_INVALID_COMMAND: float = -2.0
PENALTY_DANGEROUS_COMMAND: float = -10.0
PENALTY_NO_PROGRESS: float = -1.0
PENALTY_TIMEOUT: float = -5.0
PENALTY_REPEATED_COMMAND: float = -1.5
PENALTY_STEP_COST: float = -0.2
def __init__(self) -> None:
"""Initialize reward helpers."""
self._fingerprinter = ErrorFingerprinter()
def compute_reward(
self,
action: str,
result: ExecutionResult,
scenario: Scenario,
step_count: int,
command_history: List[str],
prev_error_log: str,
curr_error_log: str,
) -> Tuple[float, Dict[str, float]]:
"""Compute the multi-signal reward for an action.
Args:
action: The shell command that was executed.
result: The execution result from the sandbox.
scenario: The current scenario being solved.
step_count: Current step number in the episode (1-indexed).
command_history: All commands issued so far (including current).
prev_error_log: Error log before this action.
curr_error_log: Error log after this action.
Returns:
Tuple of (total_reward, breakdown_dict) where breakdown_dict
maps signal names to their individual reward values.
"""
breakdown: Dict[str, float] = {}
action_stripped = action.strip()
# 1. Step cost (always applied)
breakdown["step_cost"] = self.PENALTY_STEP_COST
# 2. Check for blocked/dangerous command
if result.blocked:
if "dangerous" in result.block_reason.lower() or "blocklist" in result.block_reason.lower():
breakdown["dangerous_command"] = self.PENALTY_DANGEROUS_COMMAND
else:
breakdown["invalid_command"] = self.PENALTY_INVALID_COMMAND
total = sum(breakdown.values())
return total, breakdown
# 3. Check for timeout
if result.timed_out:
breakdown["timeout"] = self.PENALTY_TIMEOUT
total = sum(breakdown.values())
return total, breakdown
# 4. Check for repeated command
if self._is_repeated(action_stripped, command_history):
breakdown["repeated_command"] = self.PENALTY_REPEATED_COMMAND
# 5. Check for progress
made_progress = self._has_progress(prev_error_log, curr_error_log)
if made_progress:
breakdown["progress"] = self.REWARD_PROGRESS
elif prev_error_log and curr_error_log and self._logs_identical(prev_error_log, curr_error_log):
breakdown["no_progress"] = self.PENALTY_NO_PROGRESS
# 6. Check for success
combined_output = f"{result.stdout}\n{result.stderr}".strip()
solved = scenario.success_condition(combined_output)
if solved:
breakdown["success"] = self.REWARD_SUCCESS
# 7. Efficiency bonus
if step_count <= len(scenario.hint_commands):
breakdown["efficiency_bonus"] = self.REWARD_EFFICIENCY_BONUS
# 8. Hint reward is only useful when accompanied by real improvement.
if self._matches_hint(action_stripped, scenario.hint_commands) and (made_progress or solved):
breakdown["correct_command"] = self.REWARD_CORRECT_COMMAND
total = sum(breakdown.values())
return total, breakdown
def _is_repeated(self, action: str, command_history: List[str]) -> bool:
"""Check if the action was already issued in this episode.
Args:
action: Current action.
command_history: All previous commands (not including current).
Returns:
True if the command was previously issued.
"""
# command_history includes the current command, so check for >1 occurrence
normalized = action.strip().lower()
count = sum(1 for cmd in command_history if cmd.strip().lower() == normalized)
return count > 1
def _matches_hint(self, action: str, hint_commands: List[str]) -> bool:
"""Check if the action matches any hint command.
Uses flexible matching: strips whitespace, normalizes separators,
and checks for substring containment.
Args:
action: The command to check.
hint_commands: List of optimal commands from the scenario.
Returns:
True if the action matches a hint command.
"""
action_normalized = self._normalize_command(action)
for hint in hint_commands:
hint_normalized = self._normalize_command(hint)
if action_normalized == hint_normalized:
return True
# Check if the core command is present (e.g., "pip install flask" in
# "pip install flask==2.0")
if hint_normalized in action_normalized or action_normalized in hint_normalized:
return True
return False
def _normalize_command(self, cmd: str) -> str:
"""Normalize a command for comparison.
Args:
cmd: Command string to normalize.
Returns:
Normalized command string.
"""
# Strip, lowercase, collapse whitespace
normalized = cmd.strip().lower()
normalized = re.sub(r'\s+', ' ', normalized)
return normalized
def _has_progress(self, prev_log: str, curr_log: str) -> bool:
"""Check if there has been progress (error changed or reduced).
Args:
prev_log: Previous error log.
curr_log: Current error log.
Returns:
True if progress was made (error changed for the better).
"""
if not prev_log:
return False
if not curr_log:
return True # Error cleared entirely
prev_stripped = prev_log.strip()
curr_stripped = curr_log.strip()
curr_lower = curr_stripped.lower()
if prev_stripped == curr_stripped:
return False
success_keywords = ["success", "installed", "running", "ok", "complete"]
failure_keywords = ["traceback", "error", "exception", "failed", "not found", "cannot"]
if any(kw in curr_lower for kw in success_keywords) and not any(kw in curr_lower for kw in failure_keywords):
return True
prev_fp = self._fingerprinter.classify(prev_stripped)
curr_fp = self._fingerprinter.classify(curr_stripped)
# Severity reduction: fewer hard-failure tokens means better state.
if self._error_severity(curr_stripped) < self._error_severity(prev_stripped):
return True
# If the same error family remains, lower classifier confidence can indicate a weaker/fading failure signature.
if prev_fp.error_type == curr_fp.error_type and curr_fp.confidence < prev_fp.confidence:
return True
# Reduced output while staying in the same error family can indicate partial remediation.
if prev_fp.error_type == curr_fp.error_type and len(curr_stripped) < len(prev_stripped):
return True
# Resolved from known error to unknown/no-error-like output.
if prev_fp.error_type != "unknown" and curr_fp.error_type == "unknown":
if not any(kw in curr_lower for kw in failure_keywords):
return True
return False
def _error_severity(self, log: str) -> int:
"""Estimate error severity from high-signal failure markers."""
lowered = log.lower()
markers = ["traceback", "exception", "error", "failed", "fatal", "cannot", "not found"]
return sum(lowered.count(marker) for marker in markers)
def _logs_identical(self, prev_log: str, curr_log: str) -> bool:
"""Check if two error logs are essentially identical.
Args:
prev_log: Previous error log.
curr_log: Current error log.
Returns:
True if the logs are identical after normalization.
"""
return prev_log.strip() == curr_log.strip()