| """Shared system prompt + observation/action serialization for OpsGuard GRPO training. |
| |
| The model emits a JSON object describing the action. We use a single tool/method |
| on the environment class (`triage`) rather than one method per action type because |
| the underlying OpsguardAction is a tagged-union (action_type discriminates). |
| This keeps the tool surface small and matches our env contract exactly. |
| |
| Schema accepted by `parse_action`: |
| { |
| "action_type": "label" | "close_spam" | "request_info" | "link_duplicate" |
| | "assign" | "comment" | "merge_pr" | "query_history" | "wait", |
| "target_issue_id": int | null, |
| "label": str | null, |
| "duplicate_of_id": int | null, |
| "assignee_login": str | null, |
| "comment_body": str | null, |
| "query": str | null, |
| "reasoning": str | null |
| } |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import re |
| from typing import Any |
|
|
| |
| try: |
| from ..models import ActionType, OpsguardAction, OpsguardObservation |
| except (ImportError, ValueError): |
| try: |
| from models import ActionType, OpsguardAction, OpsguardObservation |
| except ImportError: |
| |
| |
| import os |
| import sys |
| _root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| if _root not in sys.path: |
| sys.path.insert(0, _root) |
| from models import ActionType, OpsguardAction, OpsguardObservation |
|
|
|
|
| SYSTEM_PROMPT = """You are OpsGuard, an autonomous open-source repository maintainer. |
| |
| Your job: process an incoming queue of GitHub issues and pull requests for the |
| repo. Each step you observe ONE current issue plus optional memory hits from |
| prior history and your own recent actions. You must pick exactly ONE action. |
| |
| Available actions (action_type values): |
| - "label" : apply a topical label (set "label": "<name>") |
| - "close_spam" : close as spam / not-a-bug / invalid (only for actual spam!) |
| - "request_info" : ask the author for reproduction steps (set "comment_body") |
| - "link_duplicate" : mark as duplicate of an existing issue (set "duplicate_of_id") |
| - "assign" : assign to a maintainer (set "assignee_login") |
| - "comment" : leave a substantive comment (set "comment_body") |
| - "merge_pr" : merge an approved pull request (only when is_pr=true) |
| - "query_history" : search prior issues before deciding (set "query") |
| - "wait" : skip without action (rarely correct) |
| |
| Spam signals to watch for: paraphrased duplicates, fake urgency ("P0", |
| "URGENT", "production down"), sock-puppet consensus ("everyone agrees"), |
| vague low-content complaints, coordinated bumping floods, brand-new |
| authors with zero prior PRs. |
| |
| Output format: respond with a SINGLE JSON object. No prose, no code fences. |
| Example: {"action_type":"label","target_issue_id":123,"label":"bug","reasoning":"clear bug report"} |
| Example: {"action_type":"close_spam","target_issue_id":456,"reasoning":"vague urgency, new account, no repro"} |
| Example: {"action_type":"query_history","query":"memory leak in trainer","reasoning":"check for prior duplicates first"} |
| |
| If unsure, prefer "query_history" once, then act.""" |
|
|
|
|
| _ACTION_TYPES = {a.value for a in ActionType} |
|
|
|
|
| def format_observation(obs: OpsguardObservation) -> str: |
| """Render an OpsguardObservation as a compact JSON-ish prompt for the LLM.""" |
| if obs.current_issue is None: |
| body: dict[str, Any] = { |
| "scenario": obs.scenario_id, |
| "step": obs.step, |
| "step_budget": obs.step_budget, |
| "queue_position": obs.queue_position, |
| "queue_total": obs.queue_total, |
| "current_issue": None, |
| "feedback": obs.feedback, |
| "note": "queue empty or terminal — emit {\"action_type\":\"wait\"}", |
| } |
| return json.dumps(body, ensure_ascii=False, indent=2) |
|
|
| ci = obs.current_issue |
| body = { |
| "scenario": obs.scenario_id, |
| "step": obs.step, |
| "step_budget": obs.step_budget, |
| "queue_position": f"{obs.queue_position}/{obs.queue_total}", |
| "current_issue": { |
| "issue_id": ci.issue_id, |
| "number": ci.number, |
| "is_pr": ci.is_pr, |
| "title": ci.title[:200], |
| "body": (ci.body or "")[:800], |
| "author": ci.author_login, |
| "author_pr_count": ci.author_pr_count, |
| "author_account_age_days": ci.author_account_age_days, |
| "available_labels": ci.available_labels[:20], |
| "comments_preview": ci.comments_preview[:3], |
| }, |
| "memory_hits": obs.memory_hits[:5], |
| "recent_actions": obs.recent_actions[-5:], |
| "feedback_from_last_step": obs.feedback, |
| } |
| return json.dumps(body, ensure_ascii=False, indent=2) |
|
|
|
|
| |
| def _extract_first_json_object(text: str) -> str | None: |
| start = text.find("{") |
| if start < 0: |
| return None |
| depth = 0 |
| in_string = False |
| escape = False |
| for i in range(start, len(text)): |
| ch = text[i] |
| if in_string: |
| if escape: |
| escape = False |
| elif ch == "\\": |
| escape = True |
| elif ch == '"': |
| in_string = False |
| continue |
| if ch == '"': |
| in_string = True |
| elif ch == "{": |
| depth += 1 |
| elif ch == "}": |
| depth -= 1 |
| if depth == 0: |
| return text[start : i + 1] |
| return None |
|
|
|
|
| def _coerce_int(v: Any) -> int | None: |
| if v is None: |
| return None |
| if isinstance(v, bool): |
| return None |
| if isinstance(v, int): |
| return v |
| if isinstance(v, str): |
| m = re.search(r"-?\d+", v) |
| if m: |
| try: |
| return int(m.group(0)) |
| except ValueError: |
| return None |
| return None |
|
|
|
|
| def _coerce_str(v: Any) -> str | None: |
| if v is None: |
| return None |
| if isinstance(v, str): |
| return v.strip() or None |
| return str(v) |
|
|
|
|
| def parse_action(text: str) -> OpsguardAction: |
| """Parse model output into OpsguardAction. Returns WAIT on any failure. |
| |
| Tolerates: code fences, prose around the JSON, single quotes, missing |
| optional fields, integer-as-string for ids. |
| """ |
| if not text: |
| return OpsguardAction(action_type=ActionType.WAIT, reasoning="empty output") |
|
|
| |
| candidate = text.strip() |
| if candidate.startswith("```"): |
| |
| candidate = re.sub(r"^```[a-zA-Z]*\n?", "", candidate) |
| candidate = re.sub(r"\n?```\s*$", "", candidate) |
|
|
| json_str = _extract_first_json_object(candidate) |
| if json_str is None: |
| return OpsguardAction(action_type=ActionType.WAIT, reasoning="no JSON object found") |
|
|
| |
| try: |
| data = json.loads(json_str) |
| except json.JSONDecodeError: |
| try: |
| data = json.loads(json_str.replace("'", '"')) |
| except json.JSONDecodeError: |
| return OpsguardAction(action_type=ActionType.WAIT, reasoning="JSON parse error") |
|
|
| if not isinstance(data, dict): |
| return OpsguardAction(action_type=ActionType.WAIT, reasoning="JSON not an object") |
|
|
| raw_type = data.get("action_type") or data.get("action") or "wait" |
| if not isinstance(raw_type, str) or raw_type.lower() not in _ACTION_TYPES: |
| return OpsguardAction(action_type=ActionType.WAIT, reasoning=f"unknown action_type={raw_type!r}") |
| action_type = ActionType(raw_type.lower()) |
|
|
| return OpsguardAction( |
| action_type=action_type, |
| target_issue_id=_coerce_int(data.get("target_issue_id")), |
| label=_coerce_str(data.get("label")), |
| duplicate_of_id=_coerce_int(data.get("duplicate_of_id")), |
| assignee_login=_coerce_str(data.get("assignee_login")), |
| comment_body=_coerce_str(data.get("comment_body")), |
| query=_coerce_str(data.get("query")), |
| reasoning=_coerce_str(data.get("reasoning")), |
| ) |
|
|
|
|
| __all__ = ["SYSTEM_PROMPT", "format_observation", "parse_action"] |
|
|