Spaces:
Sleeping
Sleeping
| """ | |
| GRPO Training Loop β Fine-tunes the DevOps agent using Group Relative Policy Optimization. | |
| Uses TRL's GRPOTrainer with Unsloth for efficient LoRA fine-tuning. | |
| Integrates curriculum scheduler (rolling windows), replay buffer, | |
| anti-reward-hacking checks, and proper LoRA weight saving. | |
| Per the hackathon guide: | |
| - Build the environment FIRST. Do not touch the trainer until reset/step/rewards | |
| are locally verified and stable. | |
| - Actively guard against reward hacking. | |
| - Save LoRA/QLoRA weights correctly. Do NOT upcast 4-bit to 16-bit before merging. | |
| - Inspect actual generations during training β do not rely only on mean reward. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import inspect | |
| import time | |
| from collections import defaultdict | |
| from typing import Dict, List, Optional, Tuple | |
| from agent.baseline_agent import BaselineAgent | |
| from agent.prompts import format_chat_messages, format_prompt | |
| from devops_env.env import DevOpsEnv | |
| from replay.buffer import ReplayBuffer | |
| from scenarios.registry import ScenarioRegistry | |
| from training.curriculum import CurriculumScheduler | |
| class AntiHackingMonitor: | |
| """Monitors for reward hacking patterns during training. | |
| Checks: | |
| 1. Overall reward rising while success rate stays flat β likely hacking | |
| 2. Repeated commands across episodes (cached/memorized outputs) | |
| 3. Dangerous command reward firing more than once per run | |
| 4. Success column not moving despite total reward increase | |
| Usage: | |
| monitor = AntiHackingMonitor() | |
| monitor.record_episode(episode_data) | |
| alerts = monitor.check() | |
| """ | |
| def __init__(self, alert_threshold: int = 50) -> None: | |
| """Initialize the anti-hacking monitor. | |
| Args: | |
| alert_threshold: Check for hacking every N episodes. | |
| """ | |
| self.alert_threshold = alert_threshold | |
| self._reward_history: List[float] = [] | |
| self._success_history: List[bool] = [] | |
| self._dangerous_count: int = 0 | |
| self._generation_samples: List[Dict] = [] | |
| self._command_frequency: Dict[str, int] = defaultdict(int) | |
| def record_episode(self, episode_data: Dict) -> None: | |
| """Record an episode's data for monitoring. | |
| Args: | |
| episode_data: Dict with total_reward, solved, steps, etc. | |
| """ | |
| self._reward_history.append(episode_data.get("total_reward", 0.0)) | |
| self._success_history.append(episode_data.get("solved", False)) | |
| for step in episode_data.get("steps", []): | |
| action = step.get("action", "") | |
| self._command_frequency[action] += 1 | |
| breakdown = step.get("reward_breakdown", {}) | |
| if "dangerous_command" in breakdown: | |
| self._dangerous_count += 1 | |
| # Sample generation for inspection | |
| if len(self._reward_history) % self.alert_threshold == 0: | |
| self._generation_samples.append({ | |
| "episode": len(self._reward_history), | |
| "scenario": episode_data.get("scenario_id", ""), | |
| "commands": [s.get("action", "") for s in episode_data.get("steps", [])], | |
| "solved": episode_data.get("solved", False), | |
| "reward": episode_data.get("total_reward", 0.0), | |
| }) | |
| def check(self) -> List[str]: | |
| """Run all anti-hacking checks. | |
| Returns: | |
| List of alert messages. Empty list = no issues detected. | |
| """ | |
| alerts = [] | |
| # Check 1: Reward rising but success flat | |
| if len(self._reward_history) >= 100: | |
| recent_50_reward = sum(self._reward_history[-50:]) / 50 | |
| older_50_reward = sum(self._reward_history[-100:-50]) / 50 | |
| recent_50_success = sum(self._success_history[-50:]) / 50 | |
| older_50_success = sum(self._success_history[-100:-50]) / 50 | |
| reward_increase = recent_50_reward - older_50_reward | |
| success_change = recent_50_success - older_50_success | |
| if reward_increase > 2.0 and success_change < 0.05: | |
| alerts.append( | |
| f"β REWARD HACKING SUSPECTED: Mean reward increased by " | |
| f"{reward_increase:.1f} but success rate only changed by " | |
| f"{success_change:.1%}. Check for environment exploits." | |
| ) | |
| # Check 2: Dangerous commands firing too often | |
| if self._dangerous_count > 3: | |
| alerts.append( | |
| f"β DANGEROUS COMMANDS: {self._dangerous_count} dangerous command " | |
| f"penalties detected. Agent may be probing blocklist boundaries." | |
| ) | |
| # Check 3: Suspiciously repeated commands across episodes | |
| top_commands = sorted( | |
| self._command_frequency.items(), key=lambda x: x[1], reverse=True | |
| )[:5] | |
| total_commands = sum(self._command_frequency.values()) | |
| if total_commands > 50 and top_commands: | |
| top_freq = top_commands[0][1] / total_commands | |
| if top_freq > 0.5: | |
| alerts.append( | |
| f"β COMMAND REPETITION: '{top_commands[0][0]}' used in " | |
| f"{top_freq:.0%} of all commands. Possible memorization." | |
| ) | |
| return alerts | |
| def get_generation_samples(self) -> List[Dict]: | |
| """Get sampled generations for manual inspection. | |
| Returns: | |
| List of generation sample dicts. | |
| """ | |
| return self._generation_samples | |
| def print_sample_report(self) -> None: | |
| """Print the latest generation samples to console for inspection.""" | |
| if not self._generation_samples: | |
| return | |
| print("\n" + "=" * 60) | |
| print(" GENERATION INSPECTION SAMPLES") | |
| print("=" * 60) | |
| for sample in self._generation_samples[-3:]: | |
| solved_str = "β SOLVED" if sample["solved"] else "β FAILED" | |
| print(f"\n Episode {sample['episode']} | {sample['scenario']} | {solved_str}") | |
| print(f" Reward: {sample['reward']:+.1f}") | |
| for i, cmd in enumerate(sample["commands"], 1): | |
| print(f" Step {i}: {cmd}") | |
| print("=" * 60 + "\n") | |
| class GRPODevOpsTrainer: | |
| """GRPO training loop for the DevOps RL agent. | |
| Runs rollout episodes, collects (prompt, completion, reward) tuples, | |
| and trains the model using TRL's GRPO approach with grouped completions. | |
| Includes: | |
| - Curriculum learning with rolling 50-episode windows | |
| - Anti-reward-hacking monitoring | |
| - Generation sample inspection every 50 steps | |
| - Proper LoRA weight saving (no 4-bit β 16-bit upcast) | |
| Usage: | |
| trainer = GRPODevOpsTrainer(model_name="unsloth/llama-3.2-3b-instruct") | |
| trainer.train(num_episodes=500) | |
| """ | |
| def __init__( | |
| self, | |
| model_name: str = "unsloth/llama-3.2-3b-instruct", | |
| output_dir: str = "./checkpoints", | |
| db_url: str = "sqlite:///training_replay.db", | |
| num_generations: int = 4, | |
| max_new_tokens: int = 64, | |
| temperature: float = 0.8, | |
| learning_rate: float = 5e-5, | |
| batch_size: int = 4, | |
| gradient_accumulation_steps: int = 4, | |
| max_steps: int = 1000, | |
| save_steps: int = 100, | |
| logging_steps: int = 10, | |
| ) -> None: | |
| """Initialize the GRPO trainer. | |
| Args: | |
| model_name: HuggingFace model ID for the base model. | |
| output_dir: Directory for checkpoints. | |
| db_url: SQLAlchemy URL for the replay buffer. | |
| num_generations: Number of completions per prompt (GRPO needs groups). | |
| max_new_tokens: Max tokens per generation. | |
| temperature: Sampling temperature during rollouts. | |
| learning_rate: Learning rate for fine-tuning. | |
| batch_size: Per-device training batch size. | |
| gradient_accumulation_steps: Gradient accumulation factor. | |
| max_steps: Total training steps. | |
| save_steps: Save checkpoint every N steps. | |
| logging_steps: Log metrics every N steps. | |
| """ | |
| self.model_name = model_name | |
| self.output_dir = output_dir | |
| self.num_generations = num_generations | |
| self.max_new_tokens = max_new_tokens | |
| self.temperature = temperature | |
| self.learning_rate = learning_rate | |
| self.batch_size = batch_size | |
| self.gradient_accumulation_steps = gradient_accumulation_steps | |
| self.max_steps = max_steps | |
| self.save_steps = save_steps | |
| self.logging_steps = logging_steps | |
| # Components | |
| self.replay_buffer = ReplayBuffer(db_url) | |
| self.curriculum = CurriculumScheduler(unlock_threshold=0.8, window_size=50) | |
| self.anti_hacking = AntiHackingMonitor(alert_threshold=50) | |
| self.registry = ScenarioRegistry() | |
| self.registry.register_defaults() | |
| # Model state | |
| self._model = None | |
| self._tokenizer = None | |
| self._trainer = None | |
| # Reward breakdown tracking (log each column separately) | |
| self._reward_column_totals: Dict[str, List[float]] = defaultdict(list) | |
| def _setup_model(self) -> None: | |
| """Load and prepare the model with LoRA for GRPO training. | |
| WARNING: Uses Unsloth's native 4-bit loading. Do NOT upcast | |
| 4-bit to 16-bit before merging β this damages quality. | |
| """ | |
| try: | |
| from unsloth import FastLanguageModel | |
| model, tokenizer = FastLanguageModel.from_pretrained( | |
| model_name=self.model_name, | |
| max_seq_length=2048, | |
| load_in_4bit=True, | |
| dtype=None, | |
| ) | |
| model = FastLanguageModel.get_peft_model( | |
| model, | |
| r=16, | |
| target_modules=["q_proj", "k_proj", "v_proj", "o_proj", | |
| "gate_proj", "up_proj", "down_proj"], | |
| lora_alpha=16, | |
| lora_dropout=0, | |
| bias="none", | |
| use_gradient_checkpointing="unsloth", | |
| ) | |
| self._model = model | |
| self._tokenizer = tokenizer | |
| print(f"[Trainer] β Model loaded: {self.model_name}") | |
| print(f"[Trainer] LoRA rank=16, 4-bit quantization enabled") | |
| except ImportError: | |
| print("[Trainer] β Unsloth not available. Trying transformers fallback...") | |
| try: | |
| from transformers import AutoModelForCausalLM, AutoTokenizer | |
| self._tokenizer = AutoTokenizer.from_pretrained(self.model_name) | |
| self._model = AutoModelForCausalLM.from_pretrained( | |
| self.model_name, device_map="auto", | |
| ) | |
| print(f"[Trainer] β Model loaded via transformers: {self.model_name}") | |
| except Exception as e: | |
| print(f"[Trainer] β Model load failed: {e}") | |
| print(f"[Trainer] Will use baseline (rule-based) agent for rollouts") | |
| def _setup_grpo_trainer(self) -> None: | |
| """Configure the TRL GRPO trainer.""" | |
| from trl import GRPOTrainer, GRPOConfig | |
| config_kwargs = { | |
| "output_dir": self.output_dir, | |
| "num_generations": self.num_generations, | |
| "max_new_tokens": self.max_new_tokens, | |
| "learning_rate": self.learning_rate, | |
| "per_device_train_batch_size": self.batch_size, | |
| "gradient_accumulation_steps": self.gradient_accumulation_steps, | |
| "max_steps": self.max_steps, | |
| "save_steps": self.save_steps, | |
| "logging_steps": self.logging_steps, | |
| "report_to": "none", | |
| } | |
| # TRL API changed in >=0.12; pass generation temperature only when supported. | |
| params = inspect.signature(GRPOConfig.__init__).parameters | |
| if "temperature" in params: | |
| config_kwargs["temperature"] = self.temperature | |
| elif "generation_kwargs" in params: | |
| config_kwargs["generation_kwargs"] = {"temperature": self.temperature} | |
| config = GRPOConfig(**config_kwargs) | |
| self._trainer = GRPOTrainer( | |
| model=self._model, | |
| processing_class=self._tokenizer, | |
| config=config, | |
| reward_funcs=self._reward_function, | |
| ) | |
| def _reward_function(self, completions: List[str], **kwargs) -> List[float]: | |
| """Compute rewards for a batch of GRPO completions. | |
| Args: | |
| completions: List of generated shell commands. | |
| Returns: | |
| List of reward values. | |
| """ | |
| rewards = [] | |
| level = kwargs.get("level") | |
| if isinstance(level, list): | |
| level = level[0] if level else None | |
| if level is None: | |
| level = self.curriculum.sample_level() | |
| scenario_id = kwargs.get("scenario_id") | |
| if isinstance(scenario_id, list): | |
| scenario_id = scenario_id[0] if scenario_id else None | |
| if not scenario_id: | |
| scenario_id = self.registry.get_random(level=level).id | |
| for completion in completions: | |
| command = completion.strip() | |
| env = None | |
| try: | |
| # All completions in a group must be evaluated on the same scenario. | |
| env = DevOpsEnv( | |
| scenario_registry=self.registry, | |
| max_steps=1, | |
| target_level=level, | |
| target_scenario=scenario_id, | |
| ) | |
| env.reset(options={"scenario_id": scenario_id}) | |
| _, reward, _, _, _ = env.step(command) | |
| rewards.append(reward) | |
| except Exception: | |
| rewards.append(-1.0) | |
| finally: | |
| if env is not None: | |
| env.close() | |
| return rewards | |
| def run_rollout_episode(self, level: int | None = None) -> Dict: | |
| """Run a single rollout episode using the current agent. | |
| Uses the LLM agent if loaded, otherwise falls back to the | |
| rule-based baseline agent. | |
| Args: | |
| level: Difficulty level to use. If None, curriculum decides. | |
| Returns: | |
| Episode summary dict. | |
| """ | |
| # Use baseline agent if model not loaded | |
| if self._model is not None: | |
| from agent.devops_agent import DevOpsAgent | |
| agent = DevOpsAgent( | |
| model_name=self.model_name, | |
| max_new_tokens=self.max_new_tokens, | |
| temperature=self.temperature, | |
| model=self._model, | |
| tokenizer=self._tokenizer, | |
| auto_load=False, | |
| ) | |
| else: | |
| agent = BaselineAgent() | |
| selected_level = level if level is not None else self.curriculum.sample_level() | |
| env = DevOpsEnv( | |
| scenario_registry=self.registry, | |
| target_level=selected_level, | |
| ) | |
| obs, info = env.reset() | |
| total_reward = 0.0 | |
| steps = [] | |
| done = False | |
| while not done: | |
| action = agent.act(obs) | |
| obs, reward, terminated, truncated, step_info = env.step(action) | |
| total_reward += reward | |
| step_data = { | |
| "step": step_info.get("step_count", len(steps) + 1), | |
| "action": action, | |
| "reward": reward, | |
| "reward_breakdown": step_info.get("reward_breakdown", {}), | |
| "error_type": obs.get("error_type", "unknown"), | |
| "observation": { | |
| "error_log": obs.get("error_log", "")[:500], | |
| "command_history": obs.get("command_history", []), | |
| "step_count": obs.get("step_count", 0), | |
| }, | |
| "result": step_info.get("execution_result", {}), | |
| } | |
| steps.append(step_data) | |
| # Track individual reward columns | |
| for col, val in step_info.get("reward_breakdown", {}).items(): | |
| self._reward_column_totals[col].append(val) | |
| done = terminated or truncated | |
| summary = env.get_episode_summary() | |
| env.close() | |
| # Store in replay buffer | |
| episode_id = self.replay_buffer.store_episode( | |
| scenario_id=summary["scenario_id"], | |
| level=summary["level"], | |
| steps=steps, | |
| total_reward=total_reward, | |
| solved=summary["solved"], | |
| ) | |
| summary["episode_id"] = episode_id | |
| summary["steps"] = steps | |
| return summary | |
| def train(self, num_episodes: int = 500, use_grpo: bool = True) -> Dict: | |
| """Run the full training loop. | |
| Args: | |
| num_episodes: Total number of rollout episodes. | |
| use_grpo: Whether to use GRPO training (requires GPU + Unsloth). | |
| Returns: | |
| Training summary with metrics. | |
| """ | |
| print(f"\n{'='*60}") | |
| print(f" GRPO Training β DevOps RL Agent") | |
| print(f"{'='*60}") | |
| print(f" Model: {self.model_name}") | |
| print(f" Episodes: {num_episodes}") | |
| print(f" Curriculum: {self.curriculum.get_status()}") | |
| print(f"{'='*60}\n") | |
| if use_grpo and self._model is None: | |
| try: | |
| self._setup_model() | |
| self._setup_grpo_trainer() | |
| except Exception as e: | |
| print(f"[Trainer] β GRPO setup failed: {e}") | |
| print(f"[Trainer] Running rollouts with baseline agent only.") | |
| use_grpo = False | |
| metrics_history = [] | |
| for episode_num in range(num_episodes): | |
| level = self.curriculum.sample_level() | |
| summary = self.run_rollout_episode(level=level) | |
| # Record in curriculum (rolling window) | |
| self.curriculum.record_episode( | |
| level=summary["level"], | |
| solved=summary["solved"], | |
| ) | |
| # Record for anti-hacking monitoring | |
| self.anti_hacking.record_episode(summary) | |
| # Periodic logging | |
| if (episode_num + 1) % self.logging_steps == 0: | |
| metrics = self._compute_metrics(episode_num + 1, summary) | |
| metrics_history.append(metrics) | |
| self._print_progress(metrics) | |
| # Inspect actual generations every 50 episodes | |
| if (episode_num + 1) % 50 == 0: | |
| self.anti_hacking.print_sample_report() | |
| # Run anti-hacking checks | |
| alerts = self.anti_hacking.check() | |
| for alert in alerts: | |
| print(f"\n {alert}\n") | |
| # Log reward column breakdown | |
| self._print_reward_column_breakdown() | |
| # Save checkpoint periodically | |
| if use_grpo and self._model and (episode_num + 1) % self.save_steps == 0: | |
| self._save_checkpoint(episode_num + 1) | |
| # Test post-training inference immediately after save | |
| self._verify_checkpoint(episode_num + 1) | |
| final_stats = self.replay_buffer.get_stats() | |
| print(f"\n{'='*60}") | |
| print(f" TRAINING COMPLETE") | |
| print(f"{'='*60}") | |
| print(f" Total episodes: {num_episodes}") | |
| print(f" Curriculum status: {self.curriculum.get_status()}") | |
| print(json.dumps(final_stats, indent=2)) | |
| return { | |
| "total_episodes": num_episodes, | |
| "final_stats": final_stats, | |
| "metrics_history": metrics_history, | |
| "anti_hacking_alerts": self.anti_hacking.check(), | |
| } | |
| def _compute_metrics(self, episode_num: int, latest_summary: Dict) -> Dict: | |
| """Compute training metrics at a logging step.""" | |
| status = self.curriculum.get_status() | |
| return { | |
| "episode": episode_num, | |
| "scenario": latest_summary.get("scenario_id", ""), | |
| "solved": latest_summary.get("solved", False), | |
| "reward": latest_summary.get("total_reward", 0.0), | |
| "steps": latest_summary.get("total_steps", 0), | |
| "curriculum": status, | |
| "l1_solve_rate": status[1]["window_solve_rate"], | |
| "l2_solve_rate": status[2]["window_solve_rate"], | |
| "l3_solve_rate": status[3]["window_solve_rate"], | |
| } | |
| def _print_progress(self, metrics: Dict) -> None: | |
| """Print training progress to console.""" | |
| ep = metrics["episode"] | |
| solved = "β" if metrics["solved"] else "β" | |
| reward = metrics["reward"] | |
| scenario = metrics["scenario"] | |
| level_info = [] | |
| for lvl in [1, 2, 3]: | |
| status = metrics["curriculum"][lvl] | |
| if status["unlocked"]: | |
| rate = status["window_solve_rate"] | |
| eps = status["total_episodes"] | |
| level_info.append(f"L{lvl}:{rate:.0%}({eps})") | |
| print(f" [{ep:>4d}] {scenario:<22s} {solved} r={reward:>6.1f} | {' '.join(level_info)}") | |
| def _print_reward_column_breakdown(self) -> None: | |
| """Print per-column reward averages for hacking detection.""" | |
| if not self._reward_column_totals: | |
| return | |
| print("\n Reward Column Breakdown (last window):") | |
| for col, values in sorted(self._reward_column_totals.items()): | |
| recent = values[-50:] if len(values) >= 50 else values | |
| avg = sum(recent) / len(recent) if recent else 0 | |
| direction = "β" if avg > 0 else "β" if avg < 0 else "β" | |
| print(f" {col:<20s}: {avg:>+6.2f} {direction}") | |
| print() | |
| def _save_checkpoint(self, step: int) -> None: | |
| """Save LoRA adapter weights correctly. | |
| WARNING: Do NOT upcast 4-bit to 16-bit and naively merge. | |
| Save adapters directly using save_pretrained. | |
| """ | |
| ckpt_dir = os.path.join(self.output_dir, f"checkpoint-{step}") | |
| os.makedirs(ckpt_dir, exist_ok=True) | |
| try: | |
| if self._model is not None: | |
| # Save LoRA adapters directly β NOT merged with base model | |
| self._model.save_pretrained(ckpt_dir) | |
| if self._tokenizer is not None: | |
| self._tokenizer.save_pretrained(ckpt_dir) | |
| print(f"[Trainer] β Checkpoint saved: {ckpt_dir}") | |
| print(f"[Trainer] Saved as LoRA adapters (not merged)") | |
| except Exception as e: | |
| print(f"[Trainer] β Checkpoint save failed: {e}") | |
| def _verify_checkpoint(self, step: int) -> None: | |
| """Test post-training inference immediately after checkpoint save. | |
| Per the hackathon guide: "Test post-training inference immediately | |
| after export, not at the end." | |
| """ | |
| ckpt_dir = os.path.join(self.output_dir, f"checkpoint-{step}") | |
| try: | |
| from agent.devops_agent import DevOpsAgent | |
| if not os.path.isdir(ckpt_dir): | |
| print(f"[Trainer] β Post-save verification failed: missing {ckpt_dir}") | |
| return | |
| adapter_files = [ | |
| "adapter_config.json", | |
| "adapter_model.safetensors", | |
| "adapter_model.bin", | |
| ] | |
| if not any(os.path.exists(os.path.join(ckpt_dir, f)) for f in adapter_files): | |
| print(f"[Trainer] β Post-save verification failed: adapter files missing in {ckpt_dir}") | |
| return | |
| test_agent = DevOpsAgent( | |
| model_name=self.model_name, | |
| max_new_tokens=self.max_new_tokens, | |
| temperature=self.temperature, | |
| ) | |
| if test_agent.model_name == "rule-based" or not getattr(test_agent, "_is_loaded", False): | |
| print("[Trainer] β Post-save verification failed: could not load base model") | |
| return | |
| test_agent.load_checkpoint(ckpt_dir) | |
| test_obs = { | |
| "error_log": "ModuleNotFoundError: No module named 'flask'", | |
| "command_history": [], | |
| "step_count": 0, | |
| "scenario_id": "missing_flask", | |
| "error_type": "missing_package", | |
| } | |
| result = test_agent.act(test_obs) | |
| if result: | |
| print(f"[Trainer] β Post-save inference verified: '{result}'") | |
| else: | |
| print(f"[Trainer] β Post-save inference returned empty result") | |
| except Exception as e: | |
| print(f"[Trainer] β Post-save inference check failed: {e}") | |