mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-02 08:33:16 +00:00
* feat: tune reviewer for precision — web/wiki tools + recalibrated prompt Reviewer agent now has web_search, fetch_url, and http_request alongside the finding tools, so it can verify library semantics and consult the DeepWiki auto-generated wiki for public repos (https://deepwiki.com/<owner>/<repo>) before flagging cross-file or architectural concerns. Prompt rewritten to push precision over recall: - explicit severity ladder pushing reviews toward bimodal high/low instead of defaulting to medium - ≤200-char description target (gold set averages ~186 chars; we were at ~436) - mandatory docs / wiki / code lookup before flagging concurrency, security, or perf — the three categories that dominated false positives - "do not flag" list covering compiler/linter-catchable nits, speculative claims without a concrete attacker/interleaving/scale, style preferences the codebase doesn't share, and test-quality nits on non-test diffs - smart file-selection guidance for large PRs (deprioritize generated / vendored / pure-rename hunks) Eval config switched to openai:gpt-5.5 + high reasoning effort for the next benchmark run. * trim prompt * subagent prompting * confidence ratings * added medium * enforce confidence threshold * . * reviewer: precision-tuned prompt + drop confidence gate Rewrites the reviewer system prompt around a defensibility bar (anchor + failure mode + maintainer wouldn't say "not a bug"), an explicit do-not-file list (style nits, speculation, scope-policing, same-bug fan-out), and a checklist of 10 bug archetypes drawn from a per-PR audit of the eval golden set. The audit showed 145 FPs in the last eval split ~28% speculative, ~26% style-nit, ~31% real-but-unscored (mostly same-archetype fan-out); the new prompt targets each class directly. Confidence is still recorded on every finding for post-hoc calibration but no longer gates publication — the audit showed the gate was a no-op (agent self-rated 65% of findings "high" regardless), and the prompt's defensibility bar is the actual discipline. Drops CONFIDENCE_ORDER, CONFIDENCE_THRESHOLD, the confidence_threshold kwarg on filter_findings_for_publish, the confidence_filtered score_mode, and the min_confidence kwarg on the eval target's _extract_comments — all dead once the gate is gone. Also removes the "informational" severity tier from the Severity enum, SEVERITY_ORDER, and all validators / tests / docstrings. It was reserved for FYI observations the dataset never rewards. * benchmax * adding google provider * slight steering * tuning * more tuning * fix * cleanup * reducing overfitting * Add per-repo review style profiles and inject them into the reviewer. Dashboard users can analyze historical PR review feedback per repository, edit the resulting style guide, and have it loaded from LangGraph Store at reviewer runtime (including Martian eval runs) keyed by owner/name. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix review style job errors leaking exception details to clients. Return generic dashboard messages while logging full stack traces server-side. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
321 lines
9.9 KiB
Python
321 lines
9.9 KiB
Python
"""Findings storage for the reviewer agent.
|
|
|
|
Findings live in LangGraph thread metadata under the canonical reviewer thread
|
|
for a PR. This file owns the Finding schema and the read/write helpers that
|
|
the reviewer's tools and webhook handlers go through.
|
|
|
|
Why thread metadata: it survives sandbox eviction, is queryable cross-thread
|
|
via the langgraph SDK (a future UI lists all reviewer threads by filtering on
|
|
``metadata.kind == "reviewer"``), and matches existing patterns the codebase
|
|
already uses for ``sandbox_id``, ``github_token_encrypted``, etc.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import uuid
|
|
from typing import Any, Literal, TypedDict, cast
|
|
|
|
from langgraph.config import get_config
|
|
from langgraph_sdk import get_client
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
REVIEWER_THREAD_KIND = "reviewer"
|
|
|
|
# Suggestions are only useful when the reader can scan them at a glance and
|
|
# accept with one click. Anything longer reads as the reviewer rewriting the
|
|
# code for the author and clutters the comment. We cap at 4 lines and drop
|
|
# longer suggestions; the description still gets posted on its own.
|
|
MAX_SUGGESTION_LINES = 4
|
|
|
|
|
|
def clip_suggestion(suggestion: str | None) -> tuple[str | None, bool]:
|
|
"""Return (suggestion_or_none, was_dropped). Drops if over the line cap."""
|
|
if not suggestion:
|
|
return suggestion, False
|
|
if suggestion.count("\n") + 1 > MAX_SUGGESTION_LINES:
|
|
return None, True
|
|
return suggestion, False
|
|
|
|
|
|
Severity = Literal["low", "medium", "high", "critical"]
|
|
Confidence = Literal["low", "medium", "high"]
|
|
FindingStatus = Literal["open", "resolved", "dismissed"]
|
|
DiffSide = Literal["LEFT", "RIGHT"]
|
|
|
|
SEVERITY_ORDER: dict[Severity, int] = {
|
|
"low": 0,
|
|
"medium": 1,
|
|
"high": 2,
|
|
"critical": 3,
|
|
}
|
|
|
|
# Confidence is recorded on every finding for post-hoc calibration analysis
|
|
# but does not gate publication — the system prompt's defensibility bar is
|
|
# the discipline.
|
|
|
|
|
|
class Finding(TypedDict, total=False):
|
|
"""A single review finding.
|
|
|
|
All fields are optional at the TypedDict level so partial updates are
|
|
representable, but ``new_finding`` always returns a fully populated dict.
|
|
"""
|
|
|
|
id: str
|
|
severity: Severity
|
|
confidence: Confidence
|
|
category: str
|
|
file: str
|
|
start_line: int | None
|
|
end_line: int | None
|
|
side: DiffSide
|
|
description: str
|
|
suggestion: str | None
|
|
status: FindingStatus
|
|
first_seen_sha: str
|
|
last_confirmed_sha: str
|
|
github_review_comment_id: int | None
|
|
diff_hunk: str | None
|
|
|
|
|
|
class ReviewerPRMeta(TypedDict, total=False):
|
|
"""PR identity stored on reviewer thread metadata, used by the UI."""
|
|
|
|
owner: str
|
|
name: str
|
|
number: int
|
|
url: str
|
|
title: str
|
|
head_ref: str
|
|
base_ref: str
|
|
|
|
|
|
class ReviewerSlackThread(TypedDict, total=False):
|
|
"""Slack thread that initiated this review — used to post a completion reply."""
|
|
|
|
channel_id: str
|
|
thread_ts: str
|
|
|
|
|
|
def new_finding_id() -> str:
|
|
"""Return a stable, short, URL-friendly finding id (``f_<hex>``)."""
|
|
return f"f_{uuid.uuid4().hex[:10]}"
|
|
|
|
|
|
def new_finding(
|
|
*,
|
|
severity: Severity,
|
|
category: str,
|
|
file: str,
|
|
start_line: int | None,
|
|
end_line: int | None,
|
|
description: str,
|
|
sha: str,
|
|
confidence: Confidence = "medium",
|
|
side: DiffSide = "RIGHT",
|
|
suggestion: str | None = None,
|
|
diff_hunk: str | None = None,
|
|
finding_id: str | None = None,
|
|
) -> Finding:
|
|
"""Construct a fully-populated ``Finding`` ready to persist."""
|
|
return {
|
|
"id": finding_id or new_finding_id(),
|
|
"severity": severity,
|
|
"confidence": confidence,
|
|
"category": category,
|
|
"file": file,
|
|
"start_line": start_line,
|
|
"end_line": end_line,
|
|
"side": side,
|
|
"description": description,
|
|
"suggestion": suggestion,
|
|
"status": "open",
|
|
"first_seen_sha": sha,
|
|
"last_confirmed_sha": sha,
|
|
"github_review_comment_id": None,
|
|
"diff_hunk": diff_hunk,
|
|
}
|
|
|
|
|
|
def _coerce_finding(value: Any) -> Finding | None:
|
|
if not isinstance(value, dict):
|
|
return None
|
|
if "id" not in value or not isinstance(value["id"], str):
|
|
return None
|
|
return cast(Finding, value)
|
|
|
|
|
|
def _coerce_findings_list(value: Any) -> list[Finding]:
|
|
if not isinstance(value, list):
|
|
return []
|
|
out: list[Finding] = []
|
|
for entry in value:
|
|
finding = _coerce_finding(entry)
|
|
if finding is not None:
|
|
out.append(finding)
|
|
return out
|
|
|
|
|
|
def get_thread_id_from_runtime() -> str:
|
|
"""Return the thread id from the current LangGraph runnable config."""
|
|
config = get_config()
|
|
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
|
|
thread_id = configurable.get("thread_id") if isinstance(configurable, dict) else None
|
|
if not isinstance(thread_id, str) or not thread_id:
|
|
msg = "No thread_id available in runtime config"
|
|
raise RuntimeError(msg)
|
|
return thread_id
|
|
|
|
|
|
async def get_thread_metadata(thread_id: str) -> dict[str, Any]:
|
|
"""Fetch the current metadata for a thread. Returns ``{}`` on miss."""
|
|
client = get_client()
|
|
try:
|
|
thread = await client.threads.get(thread_id)
|
|
except Exception: # noqa: BLE001
|
|
logger.exception("Failed to fetch thread metadata for %s", thread_id)
|
|
return {}
|
|
metadata = thread.get("metadata") if isinstance(thread, dict) else None
|
|
return metadata if isinstance(metadata, dict) else {}
|
|
|
|
|
|
async def list_findings(thread_id: str) -> list[Finding]:
|
|
"""Return all findings persisted on the reviewer thread."""
|
|
metadata = await get_thread_metadata(thread_id)
|
|
return _coerce_findings_list(metadata.get("findings"))
|
|
|
|
|
|
async def get_finding(thread_id: str, finding_id: str) -> Finding | None:
|
|
"""Return one finding by id, or ``None`` if not present."""
|
|
findings = await list_findings(thread_id)
|
|
for finding in findings:
|
|
if finding.get("id") == finding_id:
|
|
return finding
|
|
return None
|
|
|
|
|
|
async def replace_findings(thread_id: str, findings: list[Finding]) -> None:
|
|
"""Overwrite the findings list on a thread's metadata."""
|
|
client = get_client()
|
|
await client.threads.update(thread_id=thread_id, metadata={"findings": findings})
|
|
|
|
|
|
async def append_finding(thread_id: str, finding: Finding) -> Finding:
|
|
"""Append a finding and persist the new list."""
|
|
findings = await list_findings(thread_id)
|
|
findings.append(finding)
|
|
await replace_findings(thread_id, findings)
|
|
return finding
|
|
|
|
|
|
async def update_finding_fields(
|
|
thread_id: str,
|
|
finding_id: str,
|
|
updates: dict[str, Any],
|
|
) -> Finding | None:
|
|
"""Apply field updates to one finding by id and persist."""
|
|
findings = await list_findings(thread_id)
|
|
updated: Finding | None = None
|
|
for finding in findings:
|
|
if finding.get("id") == finding_id:
|
|
finding.update(updates)
|
|
updated = finding
|
|
break
|
|
if updated is None:
|
|
return None
|
|
await replace_findings(thread_id, findings)
|
|
return updated
|
|
|
|
|
|
async def set_reviewer_thread_metadata(
|
|
thread_id: str,
|
|
*,
|
|
pr: ReviewerPRMeta | None = None,
|
|
last_reviewed_sha: str | None = None,
|
|
watch: bool | None = None,
|
|
findings: list[Finding] | None = None,
|
|
slack_thread: ReviewerSlackThread | None = None,
|
|
extra: dict[str, Any] | None = None,
|
|
) -> None:
|
|
"""Persist reviewer-thread-level metadata.
|
|
|
|
Always sets ``kind=reviewer`` so the future UI can list reviewer threads by
|
|
filtering on metadata. Only includes the fields the caller passed in
|
|
(langgraph metadata updates merge rather than overwrite).
|
|
"""
|
|
client = get_client()
|
|
metadata: dict[str, Any] = {"kind": REVIEWER_THREAD_KIND}
|
|
if pr is not None:
|
|
metadata["pr"] = pr
|
|
if last_reviewed_sha is not None:
|
|
metadata["last_reviewed_sha"] = last_reviewed_sha
|
|
if watch is not None:
|
|
metadata["watch"] = watch
|
|
if findings is not None:
|
|
metadata["findings"] = findings
|
|
if slack_thread is not None:
|
|
metadata["slack_thread"] = slack_thread
|
|
if extra:
|
|
metadata.update(extra)
|
|
await client.threads.update(thread_id=thread_id, metadata=metadata)
|
|
|
|
|
|
def get_thread_watch_flag(metadata: dict[str, Any]) -> bool:
|
|
return bool(metadata.get("watch"))
|
|
|
|
|
|
def get_thread_last_reviewed_sha(metadata: dict[str, Any]) -> str | None:
|
|
value = metadata.get("last_reviewed_sha")
|
|
return value if isinstance(value, str) and value else None
|
|
|
|
|
|
def get_thread_pr_meta(metadata: dict[str, Any]) -> ReviewerPRMeta | None:
|
|
pr = metadata.get("pr")
|
|
if not isinstance(pr, dict):
|
|
return None
|
|
return cast(ReviewerPRMeta, pr)
|
|
|
|
|
|
def get_thread_slack_ref(metadata: dict[str, Any]) -> ReviewerSlackThread | None:
|
|
slack_thread = metadata.get("slack_thread")
|
|
if not isinstance(slack_thread, dict):
|
|
return None
|
|
channel_id = slack_thread.get("channel_id")
|
|
thread_ts = slack_thread.get("thread_ts")
|
|
if not isinstance(channel_id, str) or not isinstance(thread_ts, str):
|
|
return None
|
|
if not channel_id or not thread_ts:
|
|
return None
|
|
return cast(ReviewerSlackThread, slack_thread)
|
|
|
|
|
|
def filter_findings_for_publish(
|
|
findings: list[Finding],
|
|
*,
|
|
severity_threshold: Severity = "medium",
|
|
cap: int = 4,
|
|
) -> list[Finding]:
|
|
"""Return findings to surface to GitHub.
|
|
|
|
- status must be ``open``
|
|
- severity must be at or above ``severity_threshold``
|
|
- sorted by severity descending, then file/start_line for stable ordering
|
|
- capped at ``cap`` to avoid review spam
|
|
"""
|
|
severity_rank = SEVERITY_ORDER[severity_threshold]
|
|
eligible = [
|
|
finding
|
|
for finding in findings
|
|
if finding.get("status", "open") == "open"
|
|
and SEVERITY_ORDER.get(finding.get("severity", "low"), 0) >= severity_rank
|
|
]
|
|
eligible.sort(
|
|
key=lambda f: (
|
|
-SEVERITY_ORDER.get(f.get("severity", "low"), 0),
|
|
f.get("file", ""),
|
|
f.get("start_line") or 0,
|
|
)
|
|
)
|
|
return eligible[:cap]
|