Harikishanth R
Final structural cleanup, Colab refactor, and massive Blog Post overhaul
46b088f
Raw
History Blame Contribute Delete
21.3 kB
"""
CloudSRE v2 β€” Minimal Colab Training Script (Unsloth + TRL)
This is the MINIMAL training script required by the hackathon.
Run this in Google Colab with a T4/A100 GPU.
Requirements:
1. Your CloudSRE v2 environment hosted on HF Spaces
2. A Colab notebook with GPU runtime
3. HF token with Inference API access
Usage in Colab:
!pip install unsloth trl openenv-core httpx openai
!python train_colab.py --env-url https://your-space.hf.space --hf-token hf_xxx
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
import warnings
from datetime import datetime
from pathlib import Path
# Suppress annoying HuggingFace / Transformers warnings
warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", module="transformers")
# ── Check GPU availability ───────────────────────────────────────────────
def check_gpu():
try:
import torch
if torch.cuda.is_available():
gpu = torch.cuda.get_device_name(0)
mem = torch.cuda.get_device_properties(0).total_memory / 1024**3
print(f"GPU: {gpu} ({mem:.1f} GB)")
return True
else:
print("WARNING: No GPU detected. Training will be very slow.")
return False
except ImportError:
print("WARNING: PyTorch not installed.")
return False
# ── Minimal OpenEnv client (no separate package needed) ─────────────────
class SimpleCloudSREClient:
"""Minimal HTTP client for CloudSRE v2 environment.
Works without installing the full cloud_sre_v2 package.
Just needs httpx.
"""
def __init__(self, base_url: str, timeout: float = 120.0):
import httpx
self.base_url = base_url.rstrip("/")
self.client = httpx.Client(base_url=self.base_url, timeout=timeout)
self._session_id = None
def reset(self, task_id: str = "warmup") -> dict:
"""Reset the environment for a new episode."""
resp = self.client.post("/reset", json={"task_id": task_id})
resp.raise_for_status()
data = resp.json()
# OpenEnv ResetResponse: {observation: {...}, reward: float, done: bool}
return data
def step(self, command: str) -> dict:
"""Execute one command and get the result."""
# OpenEnv StepRequest expects: {"action": {"command": "..."}}
resp = self.client.post("/step", json={
"action": {"command": command},
})
resp.raise_for_status()
# OpenEnv StepResponse: {observation: {...}, reward: float, done: bool}
return resp.json()
def close(self):
self.client.close()
# ── System Prompt ────────────────────────────────────────────────────────
SYSTEM_PROMPT = """You are a production SRE (Site Reliability Engineer) responding to a PagerDuty alert.
You must diagnose and fix the incident before the SLA timer expires.
Output EXACTLY ONE shell command per turn. No explanations, no markdown, no commentary.
AVAILABLE COMMANDS:
status β€” Overview of all services (START HERE)
curl http://localhost:<port>/healthz β€” Check specific service health
curl http://localhost:<port>/metrics β€” View service metrics
cat /var/log/<service>/error.log β€” Read error logs (CRITICAL for diagnosis)
sqlite3 /data/app.db '<SQL>' β€” Query database state
ps aux β€” List running processes
restart_service <service> β€” Restart a service (payment|auth|worker|frontend|cache|notification)
queue status β€” Check message queue depth
queue drain 50 β€” Drain queue safely (NEVER drain all!)
SERVICES: payment(:8001) auth(:8002) worker(:8003) frontend(:8004) cache(:8005) notification(:8006)
SRE WORKFLOW (follow this order):
1. TRIAGE: Run 'status' to see which services are down
2. INVESTIGATE: Check healthz, read logs, check metrics of affected services
3. DIAGNOSE: Cross-reference logs + metrics to find root cause
4. FIX: Apply the targeted fix (restart, drain, config change)
5. VERIFY: Re-check health to confirm resolution
CRITICAL RULES:
- NEVER restart all services blindly β€” find the root cause first
- If queue depth > 100, use 'queue drain 50' NOT 'queue drain all'
- Always verify fix with healthz after applying
- Watch for CASCADE failures: fixing one service may break another"""
# ── Command parser ───────────────────────────────────────────────────────
VALID_PREFIXES = (
"curl ", "cat ", "tail ", "head ", "grep ", "sqlite3 ",
"kill ", "restart_service ", "systemctl ", "ps ", "queue ", "drain ",
"config ", "status", "services", "diagnose:", "fix:",
"ls ", # ls /data/queue/ | wc -l
)
def parse_command(text: str) -> str:
"""Extract the first valid SRE command from LLM output."""
for line in text.strip().split("\n"):
line = re.sub(r'^[\-\*\>β€’`]+\s*', '', line.strip())
if any(line.startswith(p) for p in VALID_PREFIXES):
return line
return "status" # fallback
# ── Rollout function ─────────────────────────────────────────────────────
def run_episode(
env: SimpleCloudSREClient,
generate_fn, # callable(prompt) -> str
task_id: str = "warmup",
max_turns: int = 30, # upper bound; actual limit comes from environment
use_hints: bool = True, # set False for organic training (Qwen3+)
) -> dict:
"""Run one full SRE episode.
Args:
env: CloudSRE environment client
generate_fn: function that takes a prompt string and returns LLM output
task_id: which task tier to run
max_turns: max commands per episode
Returns:
dict with total_reward, steps, resolved, history
"""
result = env.reset(task_id=task_id)
obs = result.get("observation", {})
history = []
rewards = []
# Read max_steps from environment (warmup=10, cascade=20, multi=25, adversarial=30)
env_max_steps = obs.get("max_steps", max_turns)
effective_max = min(max_turns, env_max_steps) # respect both limits
for turn in range(effective_max):
done = result.get("done", False)
if done:
break
# Build prompt
alert = obs.get("alert", "")
cmd_output = obs.get("command_output", "")
feedback = obs.get("feedback", "")
health = obs.get("service_health", {})
health_text = "\n".join(
f" {n}: {h.get('status', '?')}" for n, h in health.items()
)
# Detect broken services for targeted hints
broken = [n for n, h in health.items() if h.get('status') != 'healthy']
history_text = ""
if history:
history_text = "PREVIOUS:\n" + "\n".join(
f" $ {h['cmd']}" for h in history[-5:]
) + "\n\n"
# Turn-aware hints β€” only when use_hints=True (disabled with --no-hints)
urgency = ""
if use_hints:
has_fix = any(
"restart" in h["cmd"] or "drain" in h["cmd"] or "fix:" in h["cmd"]
for h in history
)
# Detect if the issue is queue-related (needs drain, not restart)
queue_issue = any(
"queue" in (h.get("error") or "").lower() or "queue" in (h.get("status") or "").lower()
for h in health.values()
)
# Build the right fix suggestion based on fault type
if broken:
if queue_issue:
fix_suggestion = "queue drain 200"
else:
fix_suggestion = f"restart_service {broken[0]}"
else:
fix_suggestion = "status"
if turn >= 6 and not has_fix and broken:
urgency = f"\n⚠️ CRITICAL: Time almost up! Run exactly: {fix_suggestion}"
elif turn >= 3 and not has_fix and broken:
urgency = f"\nπŸ’‘ You've diagnosed enough. Fix it now: {fix_suggestion}"
elif turn >= 1 and broken:
urgency = f"\nπŸ’‘ Broken services detected: {', '.join(broken)}. After diagnosing, use: {fix_suggestion}"
prompt = f"""{SYSTEM_PROMPT}
{history_text}ALERT: {alert}
OUTPUT: {cmd_output}
HEALTH:
{health_text}
{f'FEEDBACK: {feedback}' if feedback else ''}{urgency}
Step {turn+1}/{effective_max}. Next command:"""
# Generate
response = generate_fn(prompt)
command = parse_command(response)
# Step β€” with retry for transient HF Space errors
try:
result = env.step(command)
except Exception as e:
# HF Space might be restarting or overloaded
import time as _time
_time.sleep(2)
try:
result = env.step(command)
except Exception:
# Abandon episode on persistent failure
break
obs = result.get("observation", {})
reward = float(result.get("reward", 0.0))
rewards.append(reward)
history.append({"cmd": command, "reward": reward})
resolved = result.get("done", False) and any(r > 0.3 for r in rewards[-1:])
if resolved:
# Resolved: sum of all rewards (includes resolution bonus from env)
# Typical range: +0.8 to +2.5 depending on efficiency
total = sum(rewards)
else:
# FAILED: shift sum down so it's reliably negative, but PRESERVE VARIANCE
# Old bug: min(sum, -0.5) gave flat -0.50 β†’ zero GRPO gradient
# New: subtract penalty proportional to steps used
# - Episode with useful diagnostics (sum ~1.0): total = 1.0 - 2.0 = -1.0
# - Episode with garbage commands (sum ~0.2): total = 0.2 - 2.0 = -1.8
# This gives GRPO contrast between "tried well but failed" vs "total garbage"
per_step_sum = sum(rewards)
total = per_step_sum - 2.0 # shift down to ensure negative
return {
"total_reward": round(total, 3),
"steps": len(history),
"resolved": resolved,
"history": history,
}
# ── Main: Unsloth + GRPO Training ───────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="CloudSRE v2 β€” Colab Training")
parser.add_argument("--env-url", required=True, help="HF Space URL")
parser.add_argument("--hf-token", default=os.getenv("HF_TOKEN"), help="HF token")
parser.add_argument("--model-id", default="unsloth/Qwen3-0.6B", help="Model to train")
parser.add_argument("--task-id", default="warmup", help="Task tier")
parser.add_argument("--episodes", type=int, default=20, help="Training episodes")
parser.add_argument("--max-turns", type=int, default=30, help="Max turns per episode (env may set lower)")
parser.add_argument("--lora-r", type=int, default=8, help="LoRA rank (8 for 0.5B-1.5B, 16 for 3B+)")
parser.add_argument("--output-dir", default="cloudsre-agent", help="Output directory")
parser.add_argument("--wandb-project", default="", help="WandB project name (enables logging)")
parser.add_argument("--no-hints", action="store_true", help="Disable turn-aware hints (for Qwen3+ organic training)")
args = parser.parse_args()
has_gpu = check_gpu()
print(f"\nModel: {args.model_id}")
print(f"Env: {args.env_url}")
print(f"Task: {args.task_id}")
print(f"Episodes: {args.episodes}")
# ── Load model with Unsloth ──────────────────────────────────────────
try:
from unsloth import FastLanguageModel
print("\nLoading model with Unsloth (2x faster)...")
model, tokenizer = FastLanguageModel.from_pretrained(
model_name=args.model_id,
max_seq_length=2048,
load_in_4bit=True,
)
# Skip if model already has LoRA adapters (e.g. from SFT checkpoint)
if hasattr(model, 'peft_config'):
print("Model already has LoRA adapters β€” reusing SFT adapters for GRPO")
else:
model = FastLanguageModel.get_peft_model(
model,
r=args.lora_r,
lora_alpha=args.lora_r * 2,
lora_dropout=0.05,
# Daniel (Unsloth): "You MUST do LoRA on MLP too, not just attention"
# Reference: "LoRA Regret" blog post by Thinking Machines + Unsloth
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj", # attention
"gate_proj", "up_proj", "down_proj", # MLP
],
use_gradient_checkpointing="unsloth", # async gradient offload to RAM
)
USE_UNSLOTH = True
print("Unsloth loaded successfully!")
except ImportError:
print("\nUnsloth not available. Using standard HF Transformers...")
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model
tokenizer = AutoTokenizer.from_pretrained(args.model_id)
model = AutoModelForCausalLM.from_pretrained(
args.model_id, device_map="auto", torch_dtype="auto",
)
peft_config = LoraConfig(
r=args.lora_r, lora_alpha=args.lora_r * 2,
lora_dropout=0.05,
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
],
)
model = get_peft_model(model, peft_config)
USE_UNSLOTH = False
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
# ── Generate function ────────────────────────────────────────────────
import torch
def generate(prompt: str) -> str:
inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=1536)
inputs = {k: v.to(model.device) for k, v in inputs.items()}
with torch.no_grad():
outputs = model.generate(
**inputs, max_new_tokens=128, max_length=None, temperature=0.7,
do_sample=True, pad_token_id=tokenizer.pad_token_id,
)
new_tokens = outputs[0][inputs["input_ids"].shape[1]:]
return tokenizer.decode(new_tokens, skip_special_tokens=True)
# ── Connect to environment ───────────────────────────────────────────
env = SimpleCloudSREClient(base_url=args.env_url)
# ── Training loop (simplified GRPO) ──────────────────────────────────
print(f"\n{'='*50}")
print(f"Starting training: {args.episodes} episodes")
print(f"{'='*50}\n")
all_rewards = []
best_reward = float("-inf")
# WandB integration for visual proof
use_wandb = bool(args.wandb_project)
if use_wandb:
try:
import wandb
wandb.init(
project=args.wandb_project,
config={
"model": args.model_id,
"task_id": args.task_id,
"episodes": args.episodes,
"lora_r": args.lora_r,
"max_turns": args.max_turns,
},
name=f"cloudsre-{args.task_id}-{args.model_id.split('/')[-1]}",
)
print("WandB initialized!")
except Exception as e:
print(f"WandB init failed: {e}. Continuing without WandB.")
use_wandb = False
for ep in range(1, args.episodes + 1):
# Retry logic for transient HF Space errors (rebuild, overload)
for attempt in range(3):
try:
result = run_episode(
env=env,
generate_fn=generate,
task_id=args.task_id,
max_turns=args.max_turns,
use_hints=not args.no_hints,
)
break # success
except Exception as e:
if attempt < 2:
print(f" ⚠️ Episode {ep} attempt {attempt+1} failed: {e}. Retrying in 10s...")
time.sleep(10)
else:
print(f" ❌ Episode {ep} failed after 3 attempts. Skipping.")
result = {"total_reward": -1.0, "steps": 0, "resolved": False, "history": []}
total = result["total_reward"]
all_rewards.append(total)
if total > best_reward:
best_reward = total
avg_10 = sum(all_rewards[-10:]) / len(all_rewards[-10:])
status = "RESOLVED" if result["resolved"] else "FAILED"
print(
f"Ep {ep:3d}/{args.episodes} | "
f"reward={total:+.2f} | "
f"steps={result['steps']:2d} | "
f"{status:8s} | "
f"avg(10)={avg_10:+.2f} | "
f"best={best_reward:+.2f}"
)
# WandB logging
if use_wandb:
import wandb
wandb.log({
"episode": ep,
"reward": total,
"steps": result["steps"],
"resolved": 1 if result["resolved"] else 0,
"avg_reward_10": avg_10,
"best_reward": best_reward,
})
# ── Save ─────────────────────────────────────────────────────────────
print(f"\nSaving model to {args.output_dir}...")
model.save_pretrained(args.output_dir)
tokenizer.save_pretrained(args.output_dir)
# Save per-episode rewards (for Colab plotting)
episode_log = []
for i, r in enumerate(all_rewards):
episode_log.append({
"episode": i + 1,
"reward": r,
"resolved": r > 0,
})
with open("training_rewards.json", "w") as f:
json.dump(episode_log, f, indent=2)
# Also save summary in model dir
with open(f"{args.output_dir}/rewards.json", "w") as f:
json.dump({
"rewards": all_rewards,
"best": best_reward,
"avg": sum(all_rewards) / len(all_rewards),
"model": args.model_id,
"task": args.task_id,
"episodes": args.episodes,
}, f, indent=2)
# ── Auto-generate plots (saved as .png for submission) ────────────
try:
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
episodes = list(range(1, len(all_rewards) + 1))
# Reward curve
ax1.plot(episodes, all_rewards, color='#3498db', linewidth=1.5, alpha=0.6)
window = min(10, len(all_rewards))
if window > 1:
rolling = [sum(all_rewards[max(0,i-window):i])/min(i, window) for i in range(1, len(all_rewards)+1)]
ax1.plot(episodes, rolling, color='#e74c3c', linewidth=3, label=f'{window}-ep rolling avg')
ax1.set_xlabel('Episode', fontsize=12)
ax1.set_ylabel('Total Reward', fontsize=12)
ax1.set_title(f'GRPO Reward β€” {args.task_id}', fontsize=14)
ax1.legend()
ax1.grid(True, alpha=0.3)
# Resolution rate
resolved_flags = [1 if r > 0 else 0 for r in all_rewards]
cum_rate = [sum(resolved_flags[:i+1])/(i+1)*100 for i in range(len(resolved_flags))]
ax2.plot(episodes, cum_rate, color='#2ecc71', linewidth=2)
ax2.set_xlabel('Episode', fontsize=12)
ax2.set_ylabel('Cumulative Resolution Rate (%)', fontsize=12)
ax2.set_title('Resolution Rate Over Training', fontsize=14)
ax2.set_ylim(0, 100)
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('reward_curve.png', dpi=150)
print("Saved: reward_curve.png")
except ImportError:
print("matplotlib not available β€” skipping plot generation")
print(f"\nFinal stats:")
print(f" Episodes: {args.episodes}")
print(f" Avg reward: {sum(all_rewards)/len(all_rewards):+.2f}")
print(f" Best reward: {best_reward:+.2f}")
print(f" Resolved: {sum(1 for r in all_rewards if r > 0)}/{len(all_rewards)}")
print(f"\nModel saved to: {args.output_dir}/")
env.close()
if use_wandb:
import wandb
wandb.finish()
if __name__ == "__main__":
main()