| """GRPO training for OpsGuard via TRL's environment_factory integration. |
| |
| Mirrors the official TRL openenv wordle/sudoku pattern: |
| - one env class per generation |
| - environment_factory=<class> on GRPOTrainer |
| - reward_funcs reads env.reward set during the rollout |
| |
| Our env exposes a single tool method `triage(...)` that takes the structured |
| fields of OpsguardAction. We deliberately use ONE method (not one-per-action) |
| because action_type is a tagged-union — splitting it would require 9 nearly |
| identical methods that all just dispatch to the same env.step(). |
| |
| This script supports two modes: |
| 1. Plain TRL + vLLM (matches the wordle.py reference) — most reliable. |
| 2. Unsloth FastLanguageModel preload — faster 4-bit/LoRA path. Note that |
| Unsloth's GRPO support sometimes lags TRL's environment_factory API; if |
| it fails, fall back to mode 1. |
| |
| Run on HF Jobs: |
| hf jobs uv run --flavor a100-large \\ |
| --with "trl>=0.18,unsloth,openenv-core,peft,bitsandbytes,vllm" \\ |
| --secrets HF_TOKEN \\ |
| scripts/train_grpo.py \\ |
| --model unsloth/Qwen2.5-7B-Instruct-bnb-4bit \\ |
| --env-url http://0.0.0.0:8001 \\ |
| --hub-repo me/opsguard-grpo \\ |
| --num-steps 200 --num-generations 4 |
| |
| Run locally (single GPU, vLLM colocate): |
| # 1. start the env server |
| uv run --with openenv-core uvicorn server.app:app --host 0.0.0.0 --port 8001 |
| # 2. run training |
| python scripts/train_grpo.py --env-url http://0.0.0.0:8001 |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import inspect |
| import os |
| import sys |
| from pathlib import Path |
| from typing import Any |
|
|
| |
| _PROJECT_ROOT = Path(__file__).resolve().parent.parent |
| if str(_PROJECT_ROOT) not in sys.path: |
| sys.path.insert(0, str(_PROJECT_ROOT)) |
|
|
| |
| from models import ActionType, OpsguardAction |
| try: |
| from scripts.system_prompt import SYSTEM_PROMPT, format_observation |
| except ImportError: |
| sys.path.insert(0, str(_PROJECT_ROOT / "scripts")) |
| from system_prompt import SYSTEM_PROMPT, format_observation |
|
|
|
|
| def _safe_kwargs(cls_or_callable, kwargs: dict) -> dict: |
| """Drop kwargs not accepted by the target signature. |
| |
| TRL's GRPOConfig / GRPOTrainer signatures drift between releases; this is |
| the same trick used in handoff §6 — instead of pinning a TRL version, we |
| introspect at runtime and silently drop unknown kwargs (after warning). |
| """ |
| try: |
| sig = inspect.signature(cls_or_callable) |
| params = sig.parameters |
| except (TypeError, ValueError): |
| return kwargs |
| has_var_kw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()) |
| if has_var_kw: |
| return kwargs |
| valid = set(params.keys()) |
| out = {} |
| dropped = [] |
| for k, v in kwargs.items(): |
| if k in valid: |
| out[k] = v |
| else: |
| dropped.append(k) |
| if dropped: |
| print(f" [WARN] dropping unsupported kwargs for {getattr(cls_or_callable, '__name__', cls_or_callable)}: {dropped}", |
| flush=True) |
| return out |
|
|
|
|
| |
| |
| |
| def make_opsguard_env_class(env_url: str, max_steps: int): |
| """Factory that builds a class, capturing env_url/max_steps in closure. |
| |
| TRL invokes `environment_factory()` per generation; the factory must be |
| callable with no args. Capturing config in the enclosing scope (the |
| pattern recommended by the TRL OpenEnv docs) is how we inject env_url. |
| """ |
| |
| from client import OpsguardEnv |
|
|
| class OpsguardToolEnv: |
| """One instance per parallel generation. Wraps the HTTP env client.""" |
|
|
| def __init__(self): |
| |
| |
| self._client: OpsguardEnv | None = None |
| self.reward = 0.0 |
| self.cumulative_reward = 0.0 |
| self.done = False |
| self._steps = 0 |
| self._initial_obs: str | None = None |
|
|
| def _ensure_client(self): |
| if self._client is None: |
| self._client = OpsguardEnv(base_url=env_url) |
| return self._client |
|
|
| def _close_client(self): |
| if self._client is not None: |
| try: |
| close = getattr(self._client, "close", None) |
| if callable(close): |
| close() |
| except Exception: |
| pass |
| self._client = None |
|
|
| def reset(self, **kwargs) -> str | None: |
| """Called by TRL at episode start. Returns initial observation string.""" |
| self._close_client() |
| self.reward = 0.0 |
| self.cumulative_reward = 0.0 |
| self.done = False |
| self._steps = 0 |
| client = self._ensure_client() |
|
|
| scenario_id = kwargs.get("scenario_id") |
| seed = kwargs.get("seed") |
| try: |
| if scenario_id is not None or seed is not None: |
| result = client.reset(scenario_id=scenario_id, seed=seed) |
| else: |
| result = client.reset() |
| except TypeError: |
| |
| result = client.reset() |
|
|
| obs = result.observation if hasattr(result, "observation") else result |
| self._initial_obs = format_observation(obs) |
| return self._initial_obs |
|
|
| def triage( |
| self, |
| action_type: str, |
| target_issue_id: int = 0, |
| label: str = "", |
| duplicate_of_id: int = 0, |
| assignee_login: str = "", |
| comment_body: str = "", |
| query: str = "", |
| reasoning: str = "", |
| ) -> str: |
| """ |
| Take ONE triage action against the current issue in the queue and |
| advance to the next. |
| |
| Args: |
| action_type: One of 'label', 'close_spam', 'request_info', |
| 'link_duplicate', 'assign', 'comment', 'merge_pr', |
| 'query_history', 'wait'. |
| target_issue_id: The issue_id of the current issue (0 to use current). |
| label: Label name (only for 'label' action). |
| duplicate_of_id: Existing issue id (only for 'link_duplicate'). |
| assignee_login: Maintainer login (only for 'assign'). |
| comment_body: Comment text (only for 'comment'/'request_info'). |
| query: Free-text search query (only for 'query_history'). |
| reasoning: Short rationale shown to the grader. |
| |
| Returns: |
| The next observation rendered as JSON, or 'Episode done.' on terminal. |
| """ |
| if self.done: |
| raise ValueError("Episode is over. Stop calling tools.") |
|
|
| try: |
| at = ActionType(action_type.lower().strip()) |
| except (ValueError, AttributeError): |
| at = ActionType.WAIT |
| reasoning = f"unknown action_type={action_type!r}; defaulting to wait" |
|
|
| action = OpsguardAction( |
| action_type=at, |
| target_issue_id=target_issue_id or None, |
| label=label or None, |
| duplicate_of_id=duplicate_of_id or None, |
| assignee_login=assignee_login or None, |
| comment_body=comment_body or None, |
| query=query or None, |
| reasoning=reasoning or None, |
| ) |
|
|
| client = self._ensure_client() |
| result = client.step(action) |
|
|
| obs = result.observation if hasattr(result, "observation") else result |
| step_reward = float(result.reward or 0.0) if hasattr(result, "reward") else 0.0 |
| self.reward = step_reward |
| self.cumulative_reward += step_reward |
| self._steps += 1 |
|
|
| self.done = bool(getattr(result, "done", False)) or self._steps >= max_steps |
| if self.done: |
| |
| return (f"Episode done. cumulative_reward={self.cumulative_reward:.3f} " |
| f"steps={self._steps}.") |
| return format_observation(obs) |
|
|
| return OpsguardToolEnv |
|
|
|
|
| |
| |
| |
| def make_reward_func(reduce: str = "cumulative"): |
| """Return a TRL-compatible reward function. |
| |
| `reduce`: |
| - 'cumulative' : sum of all step rewards in the episode |
| - 'last' : reward of the final step only |
| - 'mean' : cumulative / steps |
| """ |
| def reward_func(environments, **kwargs): |
| out = [] |
| for env in environments: |
| cum = float(getattr(env, "cumulative_reward", 0.0)) |
| steps = max(1, int(getattr(env, "_steps", 1))) |
| last = float(getattr(env, "reward", 0.0)) |
| if reduce == "last": |
| out.append(last) |
| elif reduce == "mean": |
| out.append(cum / steps) |
| else: |
| out.append(cum) |
| return out |
| return reward_func |
|
|
|
|
| |
| |
| |
| def build_dataset(n_samples: int, scenarios: list[str]) -> Any: |
| """One row per episode. The user message is just the system prompt body — |
| the *actual* observation comes from env.reset() being returned as the |
| first turn (TRL appends it as a tool/observation message).""" |
| from datasets import Dataset |
|
|
| user_kickoff = ( |
| "You are about to start a triage session. Call the `triage` tool with " |
| "exactly one action per turn, based on the observation that the " |
| "environment provides after each call. Stop when you see 'Episode done.'" |
| ) |
|
|
| rows = [] |
| for i in range(n_samples): |
| scen = scenarios[i % len(scenarios)] |
| rows.append({ |
| "prompt": [ |
| {"role": "system", "content": SYSTEM_PROMPT}, |
| {"role": "user", "content": user_kickoff}, |
| ], |
| "scenario_id": scen, |
| "seed": i, |
| }) |
| return Dataset.from_list(rows) |
|
|
|
|
| |
| |
| |
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--model", type=str, default="unsloth/Qwen2.5-7B-Instruct-bnb-4bit") |
| ap.add_argument("--env-url", type=str, default="http://0.0.0.0:8001", |
| help="OpsGuard env server base URL") |
| ap.add_argument("--hub-repo", type=str, default=None, |
| help="HF repo id (e.g. user/opsguard-grpo) to push final LoRA") |
| ap.add_argument("--num-steps", type=int, default=200, |
| help="GRPO training steps (max_steps)") |
| ap.add_argument("--num-generations", type=int, default=4, |
| help="GRPO group size — completions per prompt") |
| ap.add_argument("--max-steps-per-episode", type=int, default=30, |
| help="Cap on env steps per episode (matches E0/E1 budgets)") |
| ap.add_argument("--max-completion-length", type=int, default=4096) |
| ap.add_argument("--per-device-batch-size", type=int, default=1) |
| ap.add_argument("--grad-accum", type=int, default=8) |
| ap.add_argument("--lr", type=float, default=5e-6) |
| ap.add_argument("--scenarios", nargs="+", |
| default=["E0_quiet_day", "E1_release_week"]) |
| ap.add_argument("--n-prompts", type=int, default=1024, |
| help="Size of the synthetic prompt dataset (one row = one episode)") |
| ap.add_argument("--output-dir", type=str, |
| default=str(_PROJECT_ROOT / "checkpoints" / "opsguard-grpo")) |
| ap.add_argument("--sft-adapter", type=str, default=None, |
| help="Path to SFT-warmstart LoRA to load before GRPO") |
| ap.add_argument("--use-vllm", action="store_true", default=True) |
| ap.add_argument("--no-vllm", dest="use_vllm", action="store_false") |
| ap.add_argument("--vllm-mode", type=str, default="colocate", |
| choices=["colocate", "server"]) |
| ap.add_argument("--vllm-server-url", type=str, default=None) |
| ap.add_argument("--reward-reduce", type=str, default="cumulative", |
| choices=["cumulative", "mean", "last"]) |
| ap.add_argument("--use-unsloth", action="store_true", |
| help="Preload model via Unsloth (4-bit + LoRA)") |
| args = ap.parse_args() |
|
|
| print(f"=== OpsGuard GRPO training ===", flush=True) |
| print(f" model: {args.model}", flush=True) |
| print(f" env_url: {args.env_url}", flush=True) |
| print(f" num_steps: {args.num_steps}", flush=True) |
| print(f" num_gens: {args.num_generations}", flush=True) |
| print(f" max_steps_ep: {args.max_steps_per_episode}", flush=True) |
| print(f" output_dir: {args.output_dir}", flush=True) |
| print(f" sft_adapter: {args.sft_adapter}", flush=True) |
| print(f" vllm: {args.use_vllm} ({args.vllm_mode})", flush=True) |
|
|
| |
| from trl import GRPOConfig, GRPOTrainer |
|
|
| OpsguardToolEnv = make_opsguard_env_class( |
| env_url=args.env_url, |
| max_steps=args.max_steps_per_episode, |
| ) |
|
|
| dataset = build_dataset(args.n_prompts, args.scenarios) |
|
|
| |
| model_or_name: Any = args.model |
| tokenizer = None |
| if args.use_unsloth: |
| print("preloading via Unsloth...", flush=True) |
| from unsloth import FastLanguageModel |
| model, tokenizer = FastLanguageModel.from_pretrained( |
| model_name=args.model, |
| max_seq_length=args.max_completion_length + 2048, |
| load_in_4bit=True, |
| dtype=None, |
| ) |
| model = FastLanguageModel.get_peft_model( |
| model, |
| r=32, |
| lora_alpha=64, |
| lora_dropout=0.0, |
| bias="none", |
| target_modules=["q_proj", "k_proj", "v_proj", "o_proj", |
| "gate_proj", "up_proj", "down_proj"], |
| use_gradient_checkpointing="unsloth", |
| ) |
| if args.sft_adapter: |
| print(f"loading SFT adapter from {args.sft_adapter}...", flush=True) |
| try: |
| model.load_adapter(args.sft_adapter, adapter_name="default") |
| except Exception as e: |
| print(f" [WARN] could not load adapter: {e}", flush=True) |
| model_or_name = model |
|
|
| |
| grpo_kwargs = dict( |
| output_dir=args.output_dir, |
| per_device_train_batch_size=args.per_device_batch_size, |
| gradient_accumulation_steps=args.grad_accum, |
| learning_rate=args.lr, |
| max_steps=args.num_steps, |
| num_generations=args.num_generations, |
| max_completion_length=args.max_completion_length, |
| logging_steps=1, |
| save_steps=max(20, args.num_steps // 5), |
| save_total_limit=2, |
| bf16=True, |
| report_to="none", |
| log_completions=True, |
| push_to_hub=bool(args.hub_repo), |
| hub_model_id=args.hub_repo, |
| chat_template_kwargs={"enable_thinking": False}, |
| use_vllm=args.use_vllm, |
| vllm_mode=args.vllm_mode, |
| vllm_server_url=args.vllm_server_url, |
| ) |
| grpo_config = GRPOConfig(**_safe_kwargs(GRPOConfig, grpo_kwargs)) |
|
|
| reward_func = make_reward_func(args.reward_reduce) |
|
|
| trainer_kwargs = dict( |
| model=model_or_name, |
| args=grpo_config, |
| train_dataset=dataset, |
| reward_funcs=reward_func, |
| environment_factory=OpsguardToolEnv, |
| ) |
| if tokenizer is not None: |
| trainer_kwargs["processing_class"] = tokenizer |
| trainer = GRPOTrainer(**_safe_kwargs(GRPOTrainer, trainer_kwargs)) |
|
|
| print("starting trainer.train()...", flush=True) |
| trainer.train() |
|
|
| print(f"saving final adapter to {args.output_dir}...", flush=True) |
| try: |
| trainer.save_model(args.output_dir) |
| except Exception as e: |
| print(f" [WARN] save_model failed: {e}", flush=True) |
|
|
| if args.hub_repo: |
| try: |
| trainer.push_to_hub() |
| print(f"pushed to https://huggingface.co/{args.hub_repo}", flush=True) |
| except Exception as e: |
| print(f" [WARN] push_to_hub failed: {e}", flush=True) |
|
|
| print("DONE.", flush=True) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|