CoolFace
Apppublic

deveshshetty/sysadmin-game

sourceHugging Facemitupdated 5mo agoView on Hugging Face
0likes
app.py846 linesDownload Raw Back to root
1"""Gradio app for Sysadmin Game GRPO training on HF Spaces."""2 3import os4import re5import json6import threading7import time8from dataclasses import dataclass, field9from typing import Optional10from collections import defaultdict11 12import gradio as gr13import torch14import matplotlib.pyplot as plt15from transformers import AutoModelForCausalLM, AutoTokenizer16 17 18# ============== Configuration ==============19 20@dataclass21class Config:22    model_name: str = "Qwen/Qwen2.5-Coder-3B-Instruct"23    num_steps: int = 5024    episodes_per_step: int = 425    group_size: int = 226    max_turns: int = 827    learning_rate: float = 1e-528    temperature: float = 0.729    max_seq_length: int = 204830 31 32SYSTEM_PROMPT = """You are a Linux sysadmin agent. You diagnose and fix system issues by running shell commands.33 34RULES:35- Run exactly ONE command per response36- Wrap your command in <bash> and </bash> tags37- You may optionally think first in <think> tags38- Do NOT explain, just run the command39 40Example response:41<think>Check what's using port 80</think>42<bash>ss -tlnp | grep :80</bash>43 44Another example:45<bash>systemctl status nginx</bash>46 47ALWAYS use <bash>command</bash> format. Never use markdown code blocks."""48 49 50# ============== Training State ==============51 52class TrainingState:53    def __init__(self):54        self.is_training = False55        self.current_step = 056        self.total_steps = 057        self.history = {"steps": [], "rewards": [], "fix_rates": [], "losses": []}58        self.logs = []59        self.model = None60        self.tokenizer = None61 62    def log(self, msg: str):63        self.logs.append(f"[{time.strftime('%H:%M:%S')}] {msg}")64        if len(self.logs) > 500:65            self.logs = self.logs[-500:]66 67state = TrainingState()68 69 70# ============== Model Loading ==============71 72def load_model(model_name: str, progress=gr.Progress()):73    """Load model for training."""74    try:75        progress(0.1, desc="Loading tokenizer...")76        state.log(f"Loading model: {model_name}")77 78        tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)79        if tokenizer.pad_token is None:80            tokenizer.pad_token = tokenizer.eos_token81        tokenizer.padding_side = "left"82        state.log(f"Tokenizer loaded, vocab size: {len(tokenizer)}")83 84        progress(0.3, desc="Loading model...")85 86        device = "cuda" if torch.cuda.is_available() else "cpu"87        # Use bfloat16 for training stability (no GradScaler needed unlike fp16)88        if device == "cuda" and torch.cuda.is_bf16_supported():89            dtype = torch.bfloat1690        elif device == "cuda":91            dtype = torch.float32  # fall back to fp32 if bf16 not supported92        else:93            dtype = torch.float3294        state.log(f"Using device: {device}, dtype: {dtype}")95 96        model = AutoModelForCausalLM.from_pretrained(97            model_name,98            torch_dtype=dtype,99            device_map="auto" if device == "cuda" else None,100            low_cpu_mem_usage=True,101            trust_remote_code=True,102        )103 104        # Don't resize embeddings — Qwen models already match tokenizer size105        state.log(f"Model vocab: {model.config.vocab_size}, tokenizer vocab: {len(tokenizer)}")106 107        # Enable gradient checkpointing to reduce VRAM usage during training108        model.gradient_checkpointing_enable()109        state.log("Gradient checkpointing enabled (saves ~40% VRAM during training)")110 111        if device == "cpu":112            model = model.to(device)113 114        state.model = model115        state.tokenizer = tokenizer116        state.log(f"Model loaded successfully on {device}")117 118        progress(1.0, desc="Done!")119        return f"✅ Model loaded: {model_name} on {device}"120 121    except Exception as e:122        import traceback123        err_msg = f"{type(e).__name__}: {str(e)}"124        state.log(f"Model loading FAILED: {err_msg}")125        state.log(f"Traceback: {traceback.format_exc()[-500:]}")126        return f"❌ Failed to load model: {err_msg}"127 128 129# ============== Real HTTP Environment ==============130 131class RealEnvHTTP:132    """HTTP client for the real Sysadmin Game environment server.133 134    Mirrors the SimulatedEnv dict-based interface so run_episode works135    unchanged whether we're talking to a real Docker sandbox or the fake sim.136    """137 138    def __init__(self, base_url: str):139        self.base_url = base_url.rstrip("/")140        self.episode_id: Optional[str] = None141 142    def _post(self, endpoint: str, payload: dict) -> dict:143        from urllib import request as urlrequest144        from urllib.error import URLError, HTTPError145        import traceback146 147        url = f"{self.base_url}{endpoint}"148        data = json.dumps(payload).encode("utf-8")149        headers = {"Content-Type": "application/json", "Accept": "application/json"}150        req = urlrequest.Request(url, data=data, headers=headers, method="POST")151 152        try:153            with urlrequest.urlopen(req, timeout=90.0) as resp:154                return json.loads(resp.read().decode("utf-8"))155        except HTTPError as e:156            # Read error body for details157            error_body = ""158            try:159                error_body = e.read().decode("utf-8")160            except:161                pass162            state.log(f"HTTP {e.code} from {endpoint}: {error_body[:200]}")163            raise RuntimeError(f"HTTP {e.code} from {endpoint}: {error_body[:100]}") from e164        except URLError as e:165            state.log(f"Connection failed to {url}: {e.reason}")166            raise RuntimeError(f"Cannot connect to {url}: {e.reason}") from e167        except json.JSONDecodeError as e:168            state.log(f"Invalid JSON from {endpoint}: {str(e)}")169            raise RuntimeError(f"Invalid JSON response from {endpoint}") from e170        except Exception as e:171            state.log(f"Request failed {endpoint}: {type(e).__name__}: {str(e)}")172            raise173 174    def reset(self, scenario_id: Optional[str] = None) -> dict:175        try:176            resp = self._post("/reset", {"scenario_id": scenario_id})177            self.episode_id = resp["metadata"]["episode_id"]178            return {179                "output": resp["output"],180                "done": resp["done"],181                "reward": resp["reward"],182                "metadata": resp["metadata"],183            }184        except Exception as e:185            state.log(f"Reset failed: {type(e).__name__}: {str(e)[:100]}")186            raise187 188    def step(self, command: str) -> dict:189        try:190            resp = self._post("/step", {"command": command, "episode_id": self.episode_id})191            return {192                "output": resp["output"],193                "done": resp["done"],194                "reward": resp["reward"],195                "metadata": resp["metadata"],196            }197        except Exception as e:198            state.log(f"Step failed for '{command[:30]}': {type(e).__name__}: {str(e)[:80]}")199            raise200 201 202def check_env_server(env_url: str) -> str:203    """Ping the env server and return a status string."""204    if not env_url.strip():205        return "⚠️ No server URL — will use simulated environment"206    try:207        from urllib import request as urlrequest208        url = env_url.rstrip("/") + "/health"209        with urlrequest.urlopen(url, timeout=5.0) as resp:210            data = json.loads(resp.read().decode("utf-8"))211        scenarios_url = env_url.rstrip("/") + "/scenarios"212        with urlrequest.urlopen(scenarios_url, timeout=5.0) as resp:213            sc = json.loads(resp.read().decode("utf-8"))214        return (215            f"✅ Connected to real environment server\n"216            f"Status: {data.get('status')}  Active episodes: {data.get('active_episodes', 0)}\n"217            f"Train scenarios: {', '.join(sc.get('train', []))}"218        )219    except Exception as e:220        return f"❌ Cannot reach server at {env_url}\nError: {e}"221 222 223# ============== Simulated Environment (fallback) ==============224 225SIMULATED_SCENARIOS = [226    {227        "id": "nginx_syntax",228        "complaint": "nginx won't start, getting config errors",229        "solution_pattern": r"nginx -t|vim.*nginx|nano.*nginx|systemctl restart nginx",230        "diagnostics": ["systemctl status nginx", "nginx -t", "cat /etc/nginx"],231    },232    {233        "id": "disk_full",234        "complaint": "Can't save files, disk full errors everywhere",235        "solution_pattern": r"rm |find.*-delete|truncate|df -h",236        "diagnostics": ["df -h", "du -sh", "ls -la /var/log"],237    },238    {239        "id": "port_bound",240        "complaint": "nginx says address already in use on port 80",241        "solution_pattern": r"kill|systemctl stop|fuser -k",242        "diagnostics": ["ss -tlnp", "netstat -tlnp", "lsof -i :80"],243    },244]245 246 247class SimulatedEnv:248    """Fallback when no real server URL is provided."""249 250    def __init__(self):251        self.scenario = None252        self.commands_run = []253        self.fixed = False254 255    def reset(self, scenario_id=None) -> dict:256        import random257        if scenario_id:258            self.scenario = next(259                (s for s in SIMULATED_SCENARIOS if s["id"] == scenario_id),260                random.choice(SIMULATED_SCENARIOS),261            )262        else:263            self.scenario = random.choice(SIMULATED_SCENARIOS)264        self.commands_run = []265        self.fixed = False266        return {267            "output": self.scenario["complaint"],268            "done": False,269            "reward": 0.0,270            "metadata": {"scenario_id": self.scenario["id"]},271        }272 273    def step(self, command: str) -> dict:274        self.commands_run.append(command)275        reward = -0.01276 277        for diag in self.scenario["diagnostics"]:278            if diag.split()[0] in command:279                reward += 0.1280                break281 282        if re.search(self.scenario["solution_pattern"], command, re.IGNORECASE):283            self.fixed = True284            reward += 1.0285 286        done = self.fixed or len(self.commands_run) >= 15287        return {288            "output": f"[Simulated output for: {command}]",289            "done": done,290            "reward": reward,291            "metadata": {"scenario_id": self.scenario["id"], "fixed": self.fixed},292        }293 294 295# ============== Episode Runner ==============296 297def parse_response(response: str) -> Optional[str]:298    """Extract command from model response. Handles multiple formats."""299    # Try <bash>...</bash> tags first (preferred)300    bash_match = re.search(r"<bash>(.*?)</bash>", response, re.DOTALL)301    if bash_match:302        return bash_match.group(1).strip()303 304    # Try ```bash or ```sh code blocks305    code_match = re.search(r"```(?:bash|sh|shell)?\n?(.*?)```", response, re.DOTALL)306    if code_match:307        cmd = code_match.group(1).strip()308        # Take only first line if multiple commands309        return cmd.split("\n")[0].strip()310 311    # Try single backtick `command`312    tick_match = re.search(r"`([^`]+)`", response)313    if tick_match:314        cmd = tick_match.group(1).strip()315        # Only accept if it looks like a command (starts with common commands)316        cmd_starters = ("ls", "cat", "grep", "find", "ps", "ss", "netstat", "df",317                        "du", "systemctl", "service", "nginx", "kill", "rm", "mv",318                        "cp", "chmod", "chown", "apt", "yum", "pip", "docker",319                        "journalctl", "tail", "head", "less", "more", "lsof",320                        "free", "top", "htop", "mount", "umount", "fdisk",321                        "curl", "wget", "ssh", "scp", "tar", "gzip", "fuser",322                        "truncate", "echo", "sudo", "id", "whoami", "stat")323        if any(cmd.startswith(s) for s in cmd_starters):324            return cmd325 326    # Last resort: look for lines starting with $ or # (shell prompts)327    prompt_match = re.search(r"^[\$#]\s*(.+)$", response, re.MULTILINE)328    if prompt_match:329        return prompt_match.group(1).strip()330 331    return None332 333 334def run_episode(env, model, tokenizer, config: Config, episode_num: int = 0) -> dict:335    """Run single episode against env (real or simulated)."""336    import traceback337 338    model.eval()  # Disable gradient checkpointing for faster generation339 340    try:341        obs = env.reset()342    except Exception as e:343        state.log(f"  Episode {episode_num}: RESET FAILED - {type(e).__name__}: {str(e)[:100]}")344        raise RuntimeError(f"Episode reset failed: {e}") from e345 346    scenario_id = obs["metadata"]["scenario_id"]347    state.log(f"  Episode {episode_num}: scenario={scenario_id}")348 349    messages = [350        {"role": "system", "content": SYSTEM_PROMPT},351        {"role": "user", "content": obs["output"]},352    ]353 354    trajectory = {355        "scenario_id": scenario_id,356        "prompts": [],357        "responses": [],358        "rewards": [],359        "commands": [],360    }361 362    total_reward = 0.0363 364    for turn in range(config.max_turns):365        try:366            prompt = tokenizer.apply_chat_template(367                messages, tokenize=False, add_generation_prompt=True368            )369            inputs = tokenizer(370                prompt,371                return_tensors="pt",372                truncation=True,373                max_length=config.max_seq_length - 200,374            ).to(model.device)375 376            with torch.no_grad():377                outputs = model.generate(378                    **inputs,379                    max_new_tokens=200,380                    temperature=config.temperature,381                    do_sample=True,382                    pad_token_id=tokenizer.pad_token_id,383                    eos_token_id=tokenizer.eos_token_id,384                )385 386            response = tokenizer.decode(387                outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True388            )389            command = parse_response(response)390 391            trajectory["prompts"].append(prompt)392            trajectory["responses"].append(response)393            trajectory["commands"].append(command)394 395            if not command:396                state.log(f"    Turn {turn}: no command extracted, penalty -0.1")397                trajectory["rewards"].append(-0.1)398                total_reward -= 0.1399                break400 401            obs = env.step(command)402            reward = obs["reward"]403            trajectory["rewards"].append(reward)404            total_reward += reward405 406            # Log command and reward407            cmd_short = command[:40] + "..." if len(command) > 40 else command408            state.log(f"    Turn {turn}: `{cmd_short}` → reward={reward:+.2f}")409 410            messages.append({"role": "assistant", "content": response})411            messages.append(412                {"role": "tool", "content": f"<output>\n{obs['output']}\n</output>"}413            )414 415            if obs["done"]:416                fixed = obs["metadata"].get("fixed", False)417                reason = obs["metadata"].get("termination_reason", "unknown")418                state.log(f"    Done: fixed={fixed}, reason={reason}, total={total_reward:+.2f}")419                break420 421        except Exception as e:422            import traceback423            err_type = type(e).__name__424            err_msg = str(e)[:100]425            state.log(f"    Turn {turn} ERROR: {err_type}: {err_msg}")426            # Log full traceback for debugging427            tb_lines = traceback.format_exc().split('\n')[-4:-1]428            for line in tb_lines:429                if line.strip():430                    state.log(f"      {line.strip()}")431            trajectory["rewards"].append(-0.1)432            total_reward -= 0.1433            break434 435    trajectory["total_reward"] = total_reward436    trajectory["fixed"] = obs["metadata"].get("fixed", False)437    trajectory["num_commands"] = len([c for c in trajectory["commands"] if c])438    return trajectory439 440 441# ============== GRPO Training ==============442 443def compute_advantages(trajectories: list, group_size: int) -> list:444    """Compute GRPO advantages (normalize within group)."""445    processed = []446    for i in range(0, len(trajectories) - group_size + 1, group_size):447        group = trajectories[i : i + group_size]448        rewards = [t["total_reward"] for t in group]449        mean_r = sum(rewards) / len(rewards)450        std_r = max((sum((r - mean_r) ** 2 for r in rewards) / len(rewards)) ** 0.5, 1e-6)451 452        for traj in group:453            advantage = (traj["total_reward"] - mean_r) / std_r454            for prompt, response, reward in zip(455                traj["prompts"], traj["responses"], traj["rewards"]456            ):457                processed.append(458                    {"prompt": prompt, "response": response, "advantage": advantage}459                )460    return processed461 462 463def _check_model_health(model) -> bool:464    """Return True if model weights are healthy (no NaN/Inf)."""465    for name, param in model.named_parameters():466        if torch.isnan(param).any() or torch.isinf(param).any():467            state.log(f"  ⚠️ NaN/Inf detected in {name} — model is corrupted!")468            return False469    return True470 471 472def train_step(model, tokenizer, env, config: Config, optimizer, step_num: int = 0) -> tuple:473    """Single GRPO training step."""474    state.log(f"Step {step_num}: Collecting {config.episodes_per_step} episodes...")475 476    if torch.cuda.is_available():477        torch.cuda.empty_cache()478 479    # Check model health before running episodes480    if not _check_model_health(model):481        state.log(f"Step {step_num}: SKIPPING — model weights are corrupted (NaN/Inf). Stop training and reload model.")482        return None, None, None483 484    trajectories = []485    consecutive_failures = 0486    for ep_idx in range(config.episodes_per_step):487        try:488            traj = run_episode(env, model, tokenizer, config, episode_num=ep_idx)489            trajectories.append(traj)490            consecutive_failures = 0  # Reset on success491        except Exception as e:492            consecutive_failures += 1493            err_type = type(e).__name__494            state.log(f"  Episode {ep_idx} FAILED: {err_type}: {str(e)[:100]}")495            if torch.cuda.is_available():496                torch.cuda.empty_cache()497            # If too many consecutive failures, likely server is down498            if consecutive_failures >= 3:499                state.log(f"  ⚠️ {consecutive_failures} consecutive failures - check environment server!")500                break501 502    if not trajectories:503        state.log(f"Step {step_num}: No trajectories collected!")504        return None, None, None505 506    # Summarize episodes507    rewards = [t["total_reward"] for t in trajectories]508    fix_count = sum(1 for t in trajectories if t["fixed"])509    fix_rate = fix_count / len(trajectories)510    avg_reward = sum(rewards) / len(rewards)511    total_cmds = sum(t.get("num_commands", 0) for t in trajectories)512 513    state.log(f"Step {step_num}: Episodes done - avg_reward={avg_reward:.3f}, fixed={fix_count}/{len(trajectories)}, commands={total_cmds}")514 515    training_data = compute_advantages(trajectories, config.group_size)516    if not training_data:517        state.log(f"Step {step_num}: No training data after advantage computation")518        return avg_reward, fix_rate, 0.0519 520    state.log(f"Step {step_num}: Training on {len(training_data)} examples...")521 522    # Free inference VRAM before training523    if torch.cuda.is_available():524        torch.cuda.empty_cache()525 526    model.train()527    total_loss = 0.0528    valid_examples = 0529    skipped_nan = 0530    skipped_error = 0531 532    # Process one example at a time: forward → backward → step → zero533    # This avoids holding multiple computation graphs in VRAM534    for idx, ex in enumerate(training_data):535        try:536            inputs = tokenizer(537                ex["prompt"] + ex["response"],538                return_tensors="pt",539                truncation=True,540                max_length=config.max_seq_length,541            ).to(model.device)542 543            outputs = model(**inputs, labels=inputs.input_ids)544            loss = outputs.loss * ex["advantage"]545 546            if torch.isnan(loss) or torch.isinf(loss):547                skipped_nan += 1548                del inputs, outputs, loss549                continue550 551            loss.backward()552            total_loss += loss.item()553            valid_examples += 1554 555            # Step after each example to free graph memory immediately556            grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)557            if not (torch.isnan(grad_norm) or torch.isinf(grad_norm)):558                optimizer.step()559            optimizer.zero_grad()560 561            # Free memory562            del inputs, outputs, loss563            if torch.cuda.is_available():564                torch.cuda.empty_cache()565 566        except Exception as e:567            skipped_error += 1568            if skipped_error <= 2:569                state.log(f"  Training example {idx} error: {type(e).__name__}: {str(e)[:60]}")570            optimizer.zero_grad()571            if torch.cuda.is_available():572                torch.cuda.empty_cache()573 574 575    avg_loss = total_loss / max(valid_examples, 1)576 577    # Log summary with any issues578    issues = []579    if skipped_nan > 0:580        issues.append(f"{skipped_nan} NaN")581    if skipped_error > 0:582        issues.append(f"{skipped_error} errors")583    issue_str = f" (skipped: {', '.join(issues)})" if issues else ""584 585    state.log(f"Step {step_num}: Loss={avg_loss:.4f} (from {valid_examples} examples){issue_str}")586 587    return avg_reward, fix_rate, avg_loss588 589 590def run_training(591    num_steps: int,592    episodes_per_step: int,593    learning_rate: float,594    env_url: str,595):596    """Main training loop — yields live updates after each step.597 598    Uses real HTTP env if env_url is set, else simulated.599    This is a generator so Gradio streams status/plot/logs in real-time.600    """601    if state.model is None:602        yield "❌ Load a model first!", None, ""603        return604 605    config = Config(606        num_steps=int(num_steps),607        episodes_per_step=int(episodes_per_step),608        learning_rate=float(learning_rate),609    )610 611    state.is_training = True612    state.total_steps = config.num_steps613    state.history = {"steps": [], "rewards": [], "fix_rates": [], "losses": []}614 615    # Pick environment: real if URL provided, fake otherwise616    if env_url.strip():617        env = RealEnvHTTP(env_url.strip())618        env_label = f"Real server: {env_url.strip()}"619    else:620        env = SimulatedEnv()621        env_label = "Simulated (no server URL)"622 623    optimizer = torch.optim.AdamW(state.model.parameters(), lr=config.learning_rate)624 625    state.log(f"Starting training: {config.num_steps} steps, {config.episodes_per_step} eps/step")626    state.log(f"Environment: {env_label}")627 628    # Yield initial state so user sees logs immediately629    yield f"⏳ Starting training...", None, "\n".join(state.logs[-50:])630 631    try:632        for step in range(config.num_steps):633            if not state.is_training:634                state.log("Training stopped by user")635                break636 637            state.current_step = step + 1638 639            try:640                reward, fix_rate, loss = train_step(641                    state.model, state.tokenizer, env, config, optimizer, step_num=step + 1642                )643 644                if reward is not None:645                    state.history["steps"].append(step + 1)646                    state.history["rewards"].append(reward)647                    state.history["fix_rates"].append(fix_rate)648                    state.history["losses"].append(loss)649                    state.log(650                        f"═══ Step {step+1} Summary: reward={reward:.3f}, fix={fix_rate:.1%}, loss={loss:.4f} ═══"651                    )652            except Exception as e:653                import traceback654                state.log(f"Step {step+1} FAILED: {type(e).__name__}: {str(e)[:100]}")655                state.log(f"  Traceback: {traceback.format_exc()[-300:]}")656                # Continue to next step instead of crashing657 658            # Yield after every step so UI updates live659            fig = create_training_plot()660            yield (661                f"⏳ Step {step+1}/{config.num_steps}",662                fig,663                "\n".join(state.logs[-50:]),664            )665            plt.close(fig) if fig else None666 667        state.is_training = False668        state.log("Training complete!")669        fig = create_training_plot()670        final_reward = state.history["rewards"][-1] if state.history["rewards"] else 0.0671        yield f"✅ Training complete! Final reward: {final_reward:.3f}", fig, "\n".join(state.logs[-50:])672 673    except Exception as e:674        import traceback675        state.is_training = False676        err_msg = f"{type(e).__name__}: {str(e)}"677        state.log(f"Training CRASHED: {err_msg}")678        state.log(f"Full traceback:\n{traceback.format_exc()}")679        fig = create_training_plot()680        yield f"❌ Training failed: {err_msg}", fig, "\n".join(state.logs[-50:])681 682 683def stop_training():684    state.is_training = False685    state.log("Training stopped by user")686    return "Training stopped"687 688 689def create_training_plot():690    """Create training curves plot."""691    if not state.history["steps"]:692        return None693 694    fig, axes = plt.subplots(1, 3, figsize=(15, 4))695 696    axes[0].plot(state.history["steps"], state.history["rewards"], "b-", lw=2)697    axes[0].set_xlabel("Step")698    axes[0].set_ylabel("Reward")699    axes[0].set_title("Average Reward")700    axes[0].grid(True, alpha=0.3)701 702    axes[1].plot(state.history["steps"], state.history["fix_rates"], "g-", lw=2)703    axes[1].set_xlabel("Step")704    axes[1].set_ylabel("Fix Rate")705    axes[1].set_title("Success Rate")706    axes[1].set_ylim(0, 1)707    axes[1].grid(True, alpha=0.3)708 709    axes[2].plot(state.history["steps"], state.history["losses"], "r-", lw=2)710    axes[2].set_xlabel("Step")711    axes[2].set_ylabel("Loss")712    axes[2].set_title("Policy Loss")713    axes[2].grid(True, alpha=0.3)714 715    plt.tight_layout()716    return fig717 718 719# ============== Demo Tab ==============720 721def run_demo(complaint: str):722    """Run a single demo episode."""723    if state.model is None:724        return "Load a model first!"725 726    messages = [727        {"role": "system", "content": SYSTEM_PROMPT},728        {"role": "user", "content": complaint},729    ]730 731    prompt = state.tokenizer.apply_chat_template(732        messages, tokenize=False, add_generation_prompt=True733    )734    inputs = state.tokenizer(prompt, return_tensors="pt").to(state.model.device)735 736    with torch.no_grad():737        outputs = state.model.generate(738            **inputs,739            max_new_tokens=300,740            temperature=0.7,741            do_sample=True,742            pad_token_id=state.tokenizer.eos_token_id,743        )744 745    response = state.tokenizer.decode(746        outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True747    )748    return response749 750 751# ============== Gradio UI ==============752 753with gr.Blocks(title="Sysadmin Game - GRPO Training", theme=gr.themes.Soft()) as demo:754    gr.Markdown("# 🛠️ Sysadmin Game: GRPO Training")755    gr.Markdown(756        "Train LLMs to diagnose and fix Linux systems using reinforcement learning.\n\n"757        "**With real environment:** Start your environment server locally, expose it via "758        "ngrok, paste the URL below. **Without:** falls back to a simulated environment."759    )760 761    with gr.Tabs():762        # ── Setup Tab ──────────────────────────────────────────────────────────763        with gr.Tab("1. Setup"):764            gr.Markdown("### Load Model")765            with gr.Row():766                model_dropdown = gr.Dropdown(767                    choices=[768                        "Qwen/Qwen2.5-Coder-0.5B-Instruct",769                        "Qwen/Qwen2.5-Coder-1.5B-Instruct",770                        "Qwen/Qwen2.5-Coder-3B-Instruct",771                    ],772                    value="Qwen/Qwen2.5-Coder-0.5B-Instruct",773                    label="Model",774                )775                load_btn = gr.Button("Load Model", variant="primary")776            load_status = gr.Textbox(label="Status", interactive=False)777 778            gr.Markdown("### Environment Server (optional — for real Docker sandbox)")779            with gr.Row():780                env_url_input = gr.Textbox(781                    label="Environment Server URL",782                    placeholder="https://abc123.ngrok-free.app  (leave empty for simulated env)",783                    scale=4,784                )785                check_btn = gr.Button("Check Connection", scale=1)786            env_status = gr.Textbox(label="Environment Status", interactive=False, lines=3)787 788            load_btn.click(load_model, inputs=[model_dropdown], outputs=[load_status])789            check_btn.click(check_env_server, inputs=[env_url_input], outputs=[env_status])790 791        # ── Training Tab ───────────────────────────────────────────────────────792        with gr.Tab("2. Training"):793            gr.Markdown("### GRPO Training Configuration")794            with gr.Row():795                num_steps = gr.Slider(10, 200, value=50, step=10, label="Training Steps")796                episodes = gr.Slider(2, 16, value=4, step=2, label="Episodes per Step")797                lr = gr.Number(value=1e-5, label="Learning Rate")798 799            env_url_train = gr.Textbox(800                label="Environment Server URL (copy from Setup tab)",801                placeholder="https://abc123.ngrok-free.app  or leave empty for simulated",802            )803 804            with gr.Row():805                train_btn = gr.Button("Start Training", variant="primary")806                stop_btn = gr.Button("Stop", variant="stop")807 808            train_status = gr.Textbox(label="Status", interactive=False)809            with gr.Row():810                train_logs = gr.Textbox(811                    label="Logs", lines=15, interactive=False,812                    autoscroll=True, scale=3,813                )814                train_plot = gr.Plot(label="Training Curves", scale=2)815 816            train_btn.click(817                run_training,818                inputs=[num_steps, episodes, lr, env_url_train],819                outputs=[train_status, train_plot, train_logs],820            )821            stop_btn.click(stop_training, outputs=[train_status])822 823        # ── Demo Tab ───────────────────────────────────────────────────────────824        with gr.Tab("3. Demo"):825            gr.Markdown("### Test the Model")826            complaint_input = gr.Textbox(827                label="User Complaint",828                placeholder="e.g., nginx won't start, getting config errors",829                lines=2,830            )831            demo_btn = gr.Button("Get Diagnosis", variant="primary")832            demo_output = gr.Textbox(label="Model Response", lines=10)833 834            demo_btn.click(run_demo, inputs=[complaint_input], outputs=[demo_output])835 836            gr.Examples(837                examples=[838                    ["nginx won't start, says something about address already in use"],839                    ["Can't write any files, getting 'No space left on device' errors"],840                    ["Getting permission denied when trying to read /var/log/syslog"],841                ],842                inputs=[complaint_input],843            )844 845demo.launch(server_name="0.0.0.0", server_port=7860)846