Spaces:
Sleeping
Sleeping
| """ | |
| GRPO Training Script — CloudSRE v2 Agent | |
| Follows the standard OpenEnv + TRL pattern (same as Kube SRE Gym's train.py). | |
| Improvements over theirs: | |
| 1. 5-reward decomposition (total, triage, investigation, fix, cascade) | |
| 2. Phase-aware system prompt with real SRE commands (not just kubectl) | |
| 3. Cascade-aware rollout — detects and tracks cascade events | |
| 4. Multi-panel reward visualization with phase breakdown | |
| 5. Unsloth support for 2x faster training on consumer GPUs | |
| 6. Eval mode — test a trained model without training | |
| 7. CSV + JSONL dual logging for richer post-training analysis | |
| Architecture: | |
| Terminal 1: OpenEnv server (port 7860) | |
| uv run server | |
| Terminal 2: GRPO training | |
| python train.py --model-id Qwen/Qwen3-0.6B --env-url http://localhost:7860 | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import csv | |
| import json | |
| import logging | |
| import os | |
| import re | |
| from datetime import datetime | |
| from pathlib import Path | |
| from typing import Optional | |
| # Help PyTorch reuse fragmented GPU memory (critical for TRL+vLLM colocate) | |
| os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") | |
| # Silence TRL experimental warning for rollout_func | |
| os.environ.setdefault("TRL_EXPERIMENTAL_SILENCE", "1") | |
| from datasets import Dataset | |
| from transformers import AutoTokenizer | |
| from peft import LoraConfig | |
| from trl import GRPOConfig, GRPOTrainer | |
| from trl.experimental.openenv import generate_rollout_completions | |
| try: | |
| from cloud_sre_v2 import CloudSREEnv, CloudSREAction | |
| except ImportError: | |
| import sys | |
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) | |
| from cloud_sre_v2 import CloudSREEnv, CloudSREAction | |
| # ---- TRL 0.29.0 / vLLM 0.11.x compatibility ---- | |
| # TRL 0.29.0 expects vLLM logprobs as list-of-lists (top-k per token), | |
| # but vLLM 0.11.x returns plain floats. Patch until TRL releases a fix. | |
| # See: https://github.com/huggingface/trl/issues/4159 | |
| _orig_vllm_gen = None | |
| def _patch_vllm_generate(trainer): | |
| """Wrap vLLM generate to ensure logprobs are in top-k list format.""" | |
| global _orig_vllm_gen | |
| if _orig_vllm_gen is not None or not hasattr(trainer, "vllm_generation"): | |
| return | |
| _orig_vllm_gen = trainer.vllm_generation.generate | |
| def _wrapped_generate(**kwargs): | |
| result = _orig_vllm_gen(**kwargs) | |
| prompt_ids, completion_ids, logprobs, *rest = result | |
| if logprobs and logprobs[0] and isinstance(logprobs[0][0], float): | |
| logprobs = [[[lp] for lp in seq] for seq in logprobs] | |
| return (prompt_ids, completion_ids, logprobs, *rest) | |
| trainer.vllm_generation.generate = _wrapped_generate | |
| def patch_trl_vllm_compat(): | |
| """Apply TRL/vLLM compatibility patches. Call before trainer.train().""" | |
| _orig_train = GRPOTrainer.train | |
| def _patched_train(self, *args, **kwargs): | |
| _patch_vllm_generate(self) | |
| return _orig_train(self, *args, **kwargs) | |
| GRPOTrainer.train = _patched_train | |
| if __name__ == "__main__": | |
| patch_trl_vllm_compat() | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") | |
| logger = logging.getLogger(__name__) | |
| # ============================================================ | |
| # System prompt — the heart of what the agent learns | |
| # ============================================================ | |
| SYSTEM_PROMPT = """You are a production SRE on-call. Diagnose and fix ALL broken services in this microservice mesh. | |
| Output ONE command per turn. No explanations, no markdown, no prefixes. Just the raw command. | |
| SERVICES: | |
| payment (port 8001) — processes payments, writes to SQLite DB | |
| auth (port 8002) — JWT authentication, token signing/verification | |
| worker (port 8003) — message queue consumer, processes background jobs | |
| frontend (port 8004) — reverse proxy, routes to payment + auth | |
| DIAGNOSTIC COMMANDS: | |
| curl http://localhost:<port>/healthz — check service health (START HERE) | |
| curl http://localhost:<port>/metrics — view error rates, latency, memory | |
| cat /var/log/<service>/error.log — read error logs (structured JSON) | |
| grep "ERROR" /var/log/<service>/error.log — search for errors | |
| sqlite3 /data/app.db 'SELECT count(*) FROM payments WHERE status="pending"' | |
| ps aux — list all service processes | |
| queue status — check message queue depth | |
| status — overview of ALL services | |
| FIX COMMANDS: | |
| restart_service <service> — restart a crashed service | |
| kill <service> — kill a hung process | |
| queue drain 10 — drain queue at safe rate (10/batch) | |
| config <service> key=value — change service config | |
| WORKFLOW: | |
| 1. Run `status` to see which services are broken | |
| 2. Check /healthz of broken services | |
| 3. Read error logs: cat /var/log/<service>/error.log | |
| 4. Check metrics if needed: curl http://localhost:<port>/metrics | |
| 5. Apply the fix (restart, drain, config change) | |
| 6. Verify with `status` again | |
| CRITICAL RULES: | |
| - If queue depth > 100, use `queue drain 10` (NOT `queue drain all` — that causes thundering herd!) | |
| - After fixing one service, CHECK OTHERS — cascading failures can trigger new problems | |
| - Cross-reference logs with metrics — some error logs are RED HERRINGS (misleading signals) | |
| - Do NOT repeat the same command more than once""" | |
| # ============================================================ | |
| # Args | |
| # ============================================================ | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser(description="GRPO training for CloudSRE agent") | |
| # Model | |
| parser.add_argument("--model-id", default="Qwen/Qwen3-0.6B", help="Agent model to fine-tune") | |
| parser.add_argument("--use-unsloth", action="store_true", help="Use Unsloth for 2x faster training") | |
| # Environment | |
| parser.add_argument("--env-url", default="http://localhost:7860", help="OpenEnv server URL") | |
| parser.add_argument("--task-id", default="warmup", | |
| choices=["warmup", "single_fault", "cascade", "multi_cascade", "adversarial"], | |
| help="Task tier to train on") | |
| # Training | |
| parser.add_argument("--dataset-size", type=int, default=50, help="Number of training episodes") | |
| parser.add_argument("--max-turns", type=int, default=15, help="Max commands per episode") | |
| parser.add_argument("--max-new-tokens", type=int, default=256, help="Max tokens per agent response") | |
| parser.add_argument("--num-generations", type=int, default=8, | |
| help="G for GRPO (8+ recommended for stable advantage estimation)") | |
| parser.add_argument("--learning-rate", type=float, default=2e-6) | |
| parser.add_argument("--gradient-accumulation-steps", type=int, default=4) | |
| parser.add_argument("--num-epochs", type=int, default=1) | |
| parser.add_argument("--max-steps", type=int, default=-1, help="Max training steps (-1 = auto)") | |
| parser.add_argument("--save-steps", type=int, default=10) | |
| parser.add_argument("--temperature", type=float, default=1.0, | |
| help="T=1.0 optimal for GRPO exploration") | |
| # vLLM | |
| parser.add_argument("--vllm-mode", choices=("colocate", "server"), default="colocate") | |
| parser.add_argument("--vllm-server-url", default="http://localhost:8080", | |
| help="vLLM server URL (server mode only)") | |
| parser.add_argument("--vllm-gpu-memory-utilization", type=float, default=0.5) | |
| # LoRA | |
| parser.add_argument("--lora-r", type=int, default=16, help="LoRA rank") | |
| parser.add_argument("--lora-alpha", type=int, default=32, help="LoRA alpha (2x rank)") | |
| parser.add_argument("--lora-dropout", type=float, default=0.05) | |
| # Output | |
| parser.add_argument("--output-dir", default=None) | |
| parser.add_argument("--push-to-hub", action="store_true") | |
| parser.add_argument("--hub-repo", default=None) | |
| parser.add_argument("--report-to", default="none", choices=("tensorboard", "wandb", "none")) | |
| parser.add_argument("--reward-log", default="reward_log.csv") | |
| parser.add_argument("--logging-steps", type=int, default=1) | |
| # Eval mode | |
| parser.add_argument("--eval-only", action="store_true", help="Run evaluation without training") | |
| parser.add_argument("--eval-episodes", type=int, default=10) | |
| return parser.parse_args() | |
| # ============================================================ | |
| # Helpers | |
| # ============================================================ | |
| def sanitize_name(name: str) -> str: | |
| return name.replace("/", "-") | |
| def format_observation(obs) -> str: | |
| """Format observation into agent-readable text.""" | |
| command_output = getattr(obs, "command_output", "") or "" | |
| feedback = getattr(obs, "feedback", "") or "" | |
| step = getattr(obs, "step_number", 0) | |
| max_steps = getattr(obs, "max_steps", 15) | |
| phase = getattr(obs, "phase", "triage") | |
| cascade = getattr(obs, "cascade_triggered", False) | |
| cascade_alert = getattr(obs, "cascade_alert", "") or "" | |
| # Service health summary | |
| health = getattr(obs, "service_health", {}) | |
| health_lines = [] | |
| for name, info in health.items(): | |
| status = info.get("status", "unknown") | |
| err_rate = info.get("error_rate", 0) | |
| health_lines.append(f" {name}: {status} (error_rate={err_rate:.1%})") | |
| health_text = "\n".join(health_lines) if health_lines else " (no health data)" | |
| text = f"""{command_output} | |
| SERVICE HEALTH: | |
| {health_text}""" | |
| if cascade and cascade_alert: | |
| text += f"\n\n{cascade_alert}" | |
| if feedback: | |
| text += f"\n\nFEEDBACK: {feedback}" | |
| text += f"\n\nStep {step}/{max_steps} | Phase: {phase}" | |
| return text | |
| def format_history(history: list[dict]) -> str: | |
| """Format conversation history for agent context.""" | |
| if not history: | |
| return "" | |
| lines = ["PREVIOUS COMMANDS AND RESULTS:"] | |
| for entry in history: | |
| cmd = entry["command"] | |
| output = entry["output"] | |
| reward = entry.get("reward", 0.0) | |
| feedback = entry.get("feedback", "") | |
| phase = entry.get("phase", "") | |
| if len(output) > 300: | |
| output = output[:300] + "... (truncated)" | |
| lines.append(f"$ {cmd}") | |
| lines.append(f" Output: {output}") | |
| if feedback: | |
| lines.append(f" Feedback: {feedback}") | |
| return "\n".join(lines) | |
| def parse_commands(text: str) -> list[str]: | |
| """Extract SRE commands from agent response. | |
| Supports: | |
| curl http://... , cat /var/log/... , sqlite3 ... , ps aux, | |
| kill ... , restart_service ... , queue ... , config ... , | |
| status, diagnose: ... , fix: ... , grep ... | |
| Returns at most 2 commands to prevent spam. | |
| """ | |
| valid_prefixes = ( | |
| "curl ", "cat ", "tail ", "head ", "grep ", | |
| "sqlite3 ", "kill ", "restart_service ", "python ", | |
| "ps ", "queue ", "drain ", "config ", "status", | |
| "diagnose:", "diagnosis:", "fix:", | |
| ) | |
| commands = [] | |
| seen = set() | |
| for line in text.strip().split("\n"): | |
| line = line.strip() | |
| # Strip common LLM formatting | |
| line = re.sub(r'^[\-\*\>•]\s*', '', line) | |
| line = re.sub(r'^```\w*\s*', '', line) | |
| line = re.sub(r'```$', '', line) | |
| line = line.strip() | |
| if any(line.startswith(p) for p in valid_prefixes): | |
| if line not in seen: | |
| commands.append(line) | |
| seen.add(line) | |
| if len(commands) >= 2: | |
| break | |
| return commands | |
| def apply_chat_template(tokenizer, messages): | |
| """Apply chat template with fallback if enable_thinking is not supported.""" | |
| try: | |
| return tokenizer.apply_chat_template( | |
| messages, add_generation_prompt=True, | |
| tokenize=False, enable_thinking=False, | |
| ) | |
| except TypeError: | |
| return tokenizer.apply_chat_template( | |
| messages, add_generation_prompt=True, tokenize=False, | |
| ) | |
| # ============================================================ | |
| # Rollout — one full SRE episode | |
| # ============================================================ | |
| def rollout_once( | |
| trainer: GRPOTrainer, | |
| env: CloudSREEnv, | |
| tokenizer: AutoTokenizer, | |
| system_prompt: str, | |
| max_turns: int, | |
| ) -> dict[str, list]: | |
| """Run one full CloudSRE incident episode. | |
| The agent builds conversation history across turns for multi-step | |
| diagnosis: triage → investigate → fix → (handle cascade) → verify. | |
| Token accumulation: prompt_ids and completion_ids extend across turns. | |
| This matches the TRL OpenEnv pattern — GRPO assigns episode-level | |
| reward to the full token sequence. | |
| """ | |
| result = env.reset() | |
| observation = result.observation | |
| prompt_ids: list[int] = [] | |
| completion_ids: list[int] = [] | |
| logprobs: list[float] = [] | |
| # Per-phase reward tracking (our advantage over theirs) | |
| step_rewards: list[float] = [] | |
| triage_rewards: list[float] = [] | |
| investigation_rewards: list[float] = [] | |
| fix_rewards: list[float] = [] | |
| cascade_rewards: list[float] = [] | |
| # Conversation history | |
| conversation_history: list[dict] = [] | |
| cascade_detected = False | |
| MAX_TOTAL_TOKENS = 4096 # OOM prevention | |
| for _turn in range(max_turns): | |
| if result.done: | |
| break | |
| if len(completion_ids) >= MAX_TOTAL_TOKENS: | |
| break | |
| # Build prompt with full history | |
| history_text = format_history(conversation_history) | |
| obs_text = format_observation(observation) | |
| if history_text: | |
| user_prompt = f"{history_text}\n\n---\n\nCURRENT OBSERVATION:\n{obs_text}" | |
| else: | |
| user_prompt = obs_text | |
| messages = [ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_prompt}, | |
| ] | |
| prompt_text = apply_chat_template(tokenizer, messages) | |
| # Generate with vLLM via TRL | |
| rollout_outputs = generate_rollout_completions(trainer, [prompt_text])[0] | |
| prompt_ids.extend(rollout_outputs["prompt_ids"]) | |
| completion_ids.extend(rollout_outputs["completion_ids"]) | |
| logprobs.extend(rollout_outputs["logprobs"]) | |
| completion_text = rollout_outputs.get("text") or tokenizer.decode( | |
| rollout_outputs["completion_ids"], skip_special_tokens=True | |
| ) | |
| # Parse and execute commands | |
| commands = parse_commands(completion_text) | |
| if not commands: | |
| step_rewards.append(-0.5) | |
| conversation_history.append({ | |
| "agent_text": completion_text[:500], | |
| "command": completion_text[:100].strip(), | |
| "output": "(no valid command parsed)", | |
| "reward": -0.5, | |
| "feedback": "Invalid output — expected a real SRE command.", | |
| "phase": "unknown", | |
| }) | |
| continue | |
| for cmd in commands: | |
| try: | |
| result = env.step(CloudSREAction(command=cmd)) | |
| reward = float(result.reward or 0.0) | |
| step_rewards.append(reward) | |
| observation = result.observation | |
| # Extract phase and cascade info from observation | |
| phase = getattr(observation, "phase", "triage") | |
| was_cascade = getattr(observation, "cascade_triggered", False) | |
| cmd_output = getattr(observation, "command_output", "") or "" | |
| hint = getattr(observation, "feedback", "") or "" | |
| # Track per-phase rewards | |
| if phase == "triage": | |
| triage_rewards.append(reward) | |
| elif phase == "investigation": | |
| investigation_rewards.append(reward) | |
| elif phase in ("fix", "mitigation"): | |
| fix_rewards.append(reward) | |
| # Track cascade handling | |
| if was_cascade and not cascade_detected: | |
| cascade_detected = True | |
| cascade_rewards.append(reward) | |
| elif was_cascade: | |
| cascade_rewards.append(reward) | |
| conversation_history.append({ | |
| "agent_text": completion_text[:500], | |
| "command": cmd, | |
| "output": cmd_output[:500], | |
| "reward": reward, | |
| "feedback": hint, | |
| "phase": phase, | |
| }) | |
| if result.done: | |
| break | |
| except Exception as e: | |
| logger.warning(f"Step error: {e}") | |
| step_rewards.append(-0.1) | |
| conversation_history.append({ | |
| "command": cmd, "output": f"ERROR: {e}", | |
| "reward": -0.1, "feedback": "", "phase": "unknown", | |
| }) | |
| break | |
| # Aggregate rewards | |
| total_reward = sum(step_rewards) if step_rewards else -1.0 | |
| triage_score = sum(triage_rewards) / max(len(triage_rewards), 1) if triage_rewards else 0.0 | |
| investigation_score = sum(investigation_rewards) / max(len(investigation_rewards), 1) if investigation_rewards else 0.0 | |
| fix_score = sum(fix_rewards) / max(len(fix_rewards), 1) if fix_rewards else 0.0 | |
| cascade_score = sum(cascade_rewards) / max(len(cascade_rewards), 1) if cascade_rewards else 0.0 | |
| # Save transcript | |
| try: | |
| transcript_path = os.environ.get("AGENT_TRANSCRIPT_LOG", "agent_transcripts.jsonl") | |
| transcript = { | |
| "total_reward": total_reward, | |
| "triage_reward": triage_score, | |
| "investigation_reward": investigation_score, | |
| "fix_reward": fix_score, | |
| "cascade_reward": cascade_score, | |
| "cascade_detected": cascade_detected, | |
| "num_steps": len(conversation_history), | |
| "resolved": result.done and total_reward > 0, | |
| "phases": [h.get("phase", "") for h in conversation_history], | |
| "conversation": conversation_history, | |
| } | |
| with open(transcript_path, "a") as f: | |
| f.write(json.dumps(transcript) + "\n") | |
| except Exception as e: | |
| logger.warning(f"Failed to save transcript: {e}") | |
| return { | |
| "prompt_ids": prompt_ids, | |
| "completion_ids": completion_ids, | |
| "logprobs": logprobs, | |
| "total_reward": total_reward, | |
| "triage_reward": triage_score, | |
| "investigation_reward": investigation_score, | |
| "fix_reward": fix_score, | |
| "cascade_reward": cascade_score, | |
| } | |
| # ============================================================ | |
| # Reward functions (TRL convention — 5 decomposed signals) | |
| # ============================================================ | |
| def reward_total(completions: list[str], **kwargs) -> list[float]: | |
| """Total episode reward — primary GRPO signal.""" | |
| rewards = kwargs.get("total_reward") if kwargs else None | |
| return [float(r) for r in rewards] if rewards else [0.0 for _ in completions] | |
| def reward_triage(completions: list[str], **kwargs) -> list[float]: | |
| """Triage phase reward — did the agent check the right services first?""" | |
| rewards = kwargs.get("triage_reward") if kwargs else None | |
| return [float(r) for r in rewards] if rewards else [0.0 for _ in completions] | |
| def reward_investigation(completions: list[str], **kwargs) -> list[float]: | |
| """Investigation reward — did the agent read logs and metrics?""" | |
| rewards = kwargs.get("investigation_reward") if kwargs else None | |
| return [float(r) for r in rewards] if rewards else [0.0 for _ in completions] | |
| def reward_fix(completions: list[str], **kwargs) -> list[float]: | |
| """Fix phase reward — was the fix correct?""" | |
| rewards = kwargs.get("fix_reward") if kwargs else None | |
| return [float(r) for r in rewards] if rewards else [0.0 for _ in completions] | |
| def reward_cascade(completions: list[str], **kwargs) -> list[float]: | |
| """Cascade handling reward — did the agent handle cascading failures?""" | |
| rewards = kwargs.get("cascade_reward") if kwargs else None | |
| return [float(r) for r in rewards] if rewards else [0.0 for _ in completions] | |
| # ============================================================ | |
| # Reward visualization (multi-panel, superior to theirs) | |
| # ============================================================ | |
| def plot_rewards(csv_path: Path, out_path: Path = None): | |
| """Plot multi-panel reward curves from CSV log. | |
| Panel 1: Total reward with rolling average + trend line | |
| Panel 2: Phase breakdown (triage, investigation, fix, cascade) | |
| """ | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| import numpy as np | |
| episodes, totals, triages, investigations, fixes, cascades = [], [], [], [], [], [] | |
| with open(csv_path) as f: | |
| reader = csv.reader(f) | |
| next(reader) # skip header | |
| for row in reader: | |
| episodes.append(int(row[0])) | |
| totals.append(float(row[1])) | |
| triages.append(float(row[2])) | |
| investigations.append(float(row[3])) | |
| fixes.append(float(row[4])) | |
| cascades.append(float(row[5])) | |
| if not episodes: | |
| logger.warning("No episodes to plot") | |
| return | |
| fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 10), gridspec_kw={"height_ratios": [2, 1]}) | |
| window = min(10, len(episodes)) | |
| def rolling_avg(vals): | |
| return [sum(vals[max(0, i - window):i + 1]) / min(i + 1, window) for i in range(len(vals))] | |
| # ---- Panel 1: Total Reward ---- | |
| rolling = rolling_avg(totals) | |
| ax1.plot(episodes, totals, alpha=0.2, color="#3b82f6", marker="o", markersize=2, label="Per episode") | |
| ax1.plot(episodes, rolling, color="#3b82f6", linewidth=2.5, label=f"Rolling avg ({window})") | |
| # Trend line | |
| z = np.polyfit(episodes, totals, 1) | |
| trend = np.poly1d(z) | |
| direction = "improving" if z[0] > 0 else "declining" | |
| ax1.plot(episodes, trend(episodes), color="#ef4444", linewidth=1.5, linestyle="--", | |
| label=f"Trend ({direction}: {z[0]:+.3f}/ep)") | |
| ax1.set_ylabel("Total Reward", fontsize=12) | |
| ax1.set_title("CloudSRE v2 — GRPO Training Reward Curve", fontsize=14, fontweight="bold") | |
| ax1.legend(fontsize=9) | |
| ax1.grid(True, alpha=0.3) | |
| ax1.axhline(y=0, color="gray", linestyle="--", alpha=0.5) | |
| # Stats annotation | |
| ax1.text(0.02, 0.02, | |
| f"Episodes: {len(episodes)} | Final avg: {rolling[-1]:.2f} | " | |
| f"Best: {max(totals):.2f} | Resolved: {sum(1 for t in totals if t > 0)}/{len(totals)}", | |
| transform=ax1.transAxes, fontsize=9, verticalalignment="bottom", | |
| bbox=dict(boxstyle="round", facecolor="#fef3c7", alpha=0.8)) | |
| # ---- Panel 2: Phase Breakdown ---- | |
| phase_colors = {"Triage": "#10b981", "Investigation": "#6366f1", | |
| "Fix": "#f59e0b", "Cascade": "#ef4444"} | |
| ax2.plot(episodes, rolling_avg(triages), color=phase_colors["Triage"], | |
| linewidth=2, label="Triage") | |
| ax2.plot(episodes, rolling_avg(investigations), color=phase_colors["Investigation"], | |
| linewidth=2, label="Investigation") | |
| ax2.plot(episodes, rolling_avg(fixes), color=phase_colors["Fix"], | |
| linewidth=2, label="Fix") | |
| if any(c != 0 for c in cascades): | |
| ax2.plot(episodes, rolling_avg(cascades), color=phase_colors["Cascade"], | |
| linewidth=2, label="Cascade") | |
| ax2.set_ylabel("Phase Reward (avg)", fontsize=12) | |
| ax2.set_xlabel("Episode", fontsize=12) | |
| ax2.set_title("Phase-Level Reward Decomposition", fontsize=12) | |
| ax2.legend(fontsize=9, ncol=4) | |
| ax2.grid(True, alpha=0.3) | |
| ax2.axhline(y=0, color="gray", linestyle="--", alpha=0.5) | |
| plt.tight_layout() | |
| save_path = out_path or csv_path.with_suffix(".png") | |
| plt.savefig(save_path, dpi=150, bbox_inches="tight") | |
| plt.close() | |
| logger.info(f"Reward plot saved to {save_path}") | |
| # ============================================================ | |
| # Main | |
| # ============================================================ | |
| def main() -> None: | |
| patch_trl_vllm_compat() | |
| args = parse_args() | |
| logger.info("=" * 60) | |
| logger.info("CloudSRE v2 — GRPO Training (OpenEnv + TRL)") | |
| logger.info("=" * 60) | |
| logger.info(f"Agent model: {args.model_id}") | |
| logger.info(f"Env URL: {args.env_url}") | |
| logger.info(f"Task tier: {args.task_id}") | |
| logger.info(f"Episodes: {args.dataset_size}") | |
| logger.info(f"Generations/G: {args.num_generations}") | |
| logger.info(f"vLLM mode: {args.vllm_mode}") | |
| logger.info(f"Unsloth: {args.use_unsloth}") | |
| # ---- Tokenizer ---- | |
| tokenizer = AutoTokenizer.from_pretrained(args.model_id) | |
| if tokenizer.pad_token is None: | |
| tokenizer.pad_token = tokenizer.eos_token | |
| # ---- Connect to OpenEnv server ---- | |
| env = CloudSREEnv(base_url=args.env_url) | |
| # ---- Dataset (each entry triggers one episode) ---- | |
| dataset_prompt = f"Diagnose and fix this production incident. Task: {args.task_id}" | |
| dataset = Dataset.from_dict({"prompt": [dataset_prompt] * args.dataset_size}) | |
| # ---- Output directory ---- | |
| timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") | |
| default_dir = Path("outputs") / f"cloudsre-grpo-{sanitize_name(args.model_id)}-{args.task_id}-{timestamp}" | |
| output_dir = Path(args.output_dir or default_dir) | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| # ---- GRPO Config ---- | |
| grpo_config = GRPOConfig( | |
| use_vllm=True, | |
| vllm_mode=args.vllm_mode, | |
| vllm_server_base_url=args.vllm_server_url if args.vllm_mode == "server" else None, | |
| vllm_gpu_memory_utilization=args.vllm_gpu_memory_utilization, | |
| output_dir=str(output_dir), | |
| max_steps=args.max_steps, | |
| num_train_epochs=args.num_epochs, | |
| learning_rate=args.learning_rate, | |
| lr_scheduler_type="cosine", | |
| warmup_steps=2, | |
| max_grad_norm=1.0, | |
| gradient_accumulation_steps=8, | |
| per_device_train_batch_size=1, | |
| generation_batch_size=args.num_generations, | |
| num_generations=args.num_generations, | |
| max_completion_length=args.max_new_tokens, | |
| logging_steps=args.logging_steps, | |
| save_strategy="steps", | |
| save_steps=args.save_steps, | |
| temperature=args.temperature, | |
| report_to=args.report_to, | |
| gradient_checkpointing=True, | |
| gradient_checkpointing_kwargs={"use_reentrant": False}, | |
| push_to_hub=args.push_to_hub, | |
| hub_model_id=args.hub_repo if args.push_to_hub else None, | |
| hub_strategy="every_save", | |
| save_total_limit=3, | |
| # DAPO improvements over vanilla GRPO | |
| loss_type="dapo", | |
| mask_truncated_completions=True, | |
| beta=0.01, | |
| ) | |
| # ---- Reward CSV logger ---- | |
| reward_log_path = output_dir / args.reward_log | |
| episode_counter = [0] | |
| all_rewards = [] | |
| with open(reward_log_path, "w", newline="") as f: | |
| writer = csv.writer(f) | |
| writer.writerow([ | |
| "episode", "total_reward", "triage_reward", "investigation_reward", | |
| "fix_reward", "cascade_reward", "timestamp", | |
| ]) | |
| def _log_episode(total_r, triage_r, inv_r, fix_r, cascade_r): | |
| episode_counter[0] += 1 | |
| all_rewards.append(total_r) | |
| with open(reward_log_path, "a", newline="") as f: | |
| writer = csv.writer(f) | |
| writer.writerow([ | |
| episode_counter[0], total_r, triage_r, inv_r, fix_r, cascade_r, | |
| datetime.now().isoformat(), | |
| ]) | |
| n = len(all_rewards) | |
| mean_all = sum(all_rewards) / n | |
| last10 = all_rewards[-10:] | |
| mean_10 = sum(last10) / len(last10) | |
| best = max(all_rewards) | |
| logger.info( | |
| f"Episode {episode_counter[0]}: reward={total_r:.2f} " | |
| f"(triage={triage_r:.2f}, inv={inv_r:.2f}, fix={fix_r:.2f}, cascade={cascade_r:.2f}) | " | |
| f"mean={mean_all:.2f}, mean(10)={mean_10:.2f}, best={best:.2f}" | |
| ) | |
| # ---- Rollout function (called by GRPOTrainer each step) ---- | |
| def rollout_func(prompts: list[str], trainer: GRPOTrainer) -> dict[str, list]: | |
| episode_prompt_ids = [] | |
| episode_completion_ids = [] | |
| episode_logprobs = [] | |
| total_rewards = [] | |
| triage_rewards = [] | |
| investigation_rewards = [] | |
| fix_rewards_list = [] | |
| cascade_rewards = [] | |
| for _ in prompts: | |
| episode = rollout_once( | |
| trainer=trainer, | |
| env=env, | |
| tokenizer=tokenizer, | |
| system_prompt=SYSTEM_PROMPT, | |
| max_turns=args.max_turns, | |
| ) | |
| episode_prompt_ids.append(episode["prompt_ids"]) | |
| episode_completion_ids.append(episode["completion_ids"]) | |
| episode_logprobs.append(episode["logprobs"]) | |
| total_rewards.append(episode["total_reward"]) | |
| triage_rewards.append(episode["triage_reward"]) | |
| investigation_rewards.append(episode["investigation_reward"]) | |
| fix_rewards_list.append(episode["fix_reward"]) | |
| cascade_rewards.append(episode["cascade_reward"]) | |
| _log_episode( | |
| episode["total_reward"], episode["triage_reward"], | |
| episode["investigation_reward"], episode["fix_reward"], | |
| episode["cascade_reward"], | |
| ) | |
| return { | |
| "prompt_ids": episode_prompt_ids, | |
| "completion_ids": episode_completion_ids, | |
| "logprobs": episode_logprobs, | |
| "total_reward": total_rewards, | |
| "triage_reward": triage_rewards, | |
| "investigation_reward": investigation_rewards, | |
| "fix_reward": fix_rewards_list, | |
| "cascade_reward": cascade_rewards, | |
| } | |
| # ---- LoRA config ---- | |
| peft_config = LoraConfig( | |
| r=args.lora_r, | |
| lora_alpha=args.lora_alpha, | |
| lora_dropout=args.lora_dropout, | |
| bias="none", | |
| task_type="CAUSAL_LM", | |
| target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], | |
| ) | |
| # ---- Trainer ---- | |
| trainer = GRPOTrainer( | |
| model=args.model_id, | |
| processing_class=tokenizer, | |
| reward_funcs=[ | |
| reward_total, | |
| reward_triage, | |
| reward_investigation, | |
| reward_fix, | |
| reward_cascade, | |
| ], | |
| train_dataset=dataset, | |
| args=grpo_config, | |
| rollout_func=rollout_func, | |
| peft_config=peft_config, | |
| ) | |
| # ---- Train ---- | |
| logger.info("Starting GRPO training...") | |
| logger.info(f"5 reward signals: total, triage, investigation, fix, cascade") | |
| logger.info(f"Task tier: {args.task_id}") | |
| try: | |
| trainer.train() | |
| finally: | |
| env.close() | |
| try: | |
| plot_rewards(reward_log_path, output_dir / "reward_plot.png") | |
| except Exception as e: | |
| logger.warning(f"Could not generate reward plot: {e}") | |
| # ---- Save ---- | |
| trainer.save_model(str(output_dir)) | |
| logger.info(f"Model saved to {output_dir}") | |
| logger.info(f"Reward log: {reward_log_path}") | |
| if args.push_to_hub and args.hub_repo: | |
| trainer.push_to_hub() | |
| logger.info(f"Model pushed to https://huggingface.co/{args.hub_repo}") | |
| logger.info("Done!") | |
| if __name__ == "__main__": | |
| main() | |