open-swe/evals/reviewer/target.py
Johannes du Plessis 82852f9eda
feat: tune reviewer for precision — web/wiki tools + recalibrated prompt (#1312)
* 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>
2026-05-20 18:35:00 +00:00

218 lines
7.2 KiB
Python

"""Target function for the reviewer eval.
Spawns the reviewer graph over `langgraph_sdk` for one PR, waits for
completion, and returns every `add_finding` tool call the agent made as the
structured output for the eval. Findings are normalized into the legacy
``{file, line, body, severity}`` shape so the judge prompt can stay the
verbatim form martian published.
"""
from __future__ import annotations
import os
import threading
from typing import Any, Literal, cast
from dotenv import load_dotenv
from langgraph_sdk import get_client
from agent.reviewer_findings import Finding, Severity, filter_findings_for_publish
load_dotenv()
DEFAULT_REVIEWER_ASSISTANT_ID = "reviewer"
DEFAULT_LANGGRAPH_URL = "http://localhost:2024"
ScoreMode = Literal["all_findings", "surfaced_findings"]
_VALID_SCORE_MODES: set[ScoreMode] = {"all_findings", "surfaced_findings"}
_VALID_SEVERITIES: set[Severity] = {"low", "medium", "high", "critical"}
_THREAD_IDS: set[str] = set()
_THREAD_IDS_LOCK = threading.Lock()
def _record_thread_id(thread_id: str) -> None:
with _THREAD_IDS_LOCK:
_THREAD_IDS.add(thread_id)
def drain_thread_ids() -> set[str]:
"""Return and clear thread IDs created by ``review_pr`` so far.
Used by ``run_eval`` to delete threads after the experiment finishes.
Underlying provider sandboxes time out via their own TTL — deleting the
LangGraph thread frees the checkpoint/metadata records, not the sandbox.
"""
with _THREAD_IDS_LOCK:
snapshot = set(_THREAD_IDS)
_THREAD_IDS.clear()
return snapshot
def get_langgraph_url() -> str:
return os.getenv("LANGGRAPH_URL", DEFAULT_LANGGRAPH_URL)
def get_reviewer_assistant_id() -> str:
return os.getenv("REVIEWER_ASSISTANT_ID", DEFAULT_REVIEWER_ASSISTANT_ID)
def get_score_mode() -> ScoreMode:
value = os.getenv("REVIEWER_EVAL_SCORE_MODE", "all_findings")
if value in _VALID_SCORE_MODES:
return cast(ScoreMode, value)
return "all_findings"
def get_reviewer_model_id() -> str | None:
value = os.getenv("REVIEWER_EVAL_MODEL_ID")
return value if value else None
def get_reviewer_reasoning_effort() -> str | None:
value = os.getenv("REVIEWER_EVAL_REASONING_EFFORT")
return value if value else None
def _build_user_message(inputs: dict[str, Any]) -> str:
return (
f"Review pull request {inputs['pr_url']}.\n\n"
f"- repo: {inputs['repo']}\n"
f"- pr_number: {inputs['pr_number']}\n"
f"- title: {inputs.get('pr_title', '')}\n"
f"- base_sha: {inputs['base_sha']}\n"
f"- head_sha: {inputs['head_sha']}\n"
f"- base_ref: {inputs.get('base_ref', '')}\n"
f"- head_ref: {inputs.get('head_ref', '')}\n\n"
f"Record each issue you find with the `add_finding` tool, then call "
f"`publish_review` once at the end."
)
def _build_configurable(inputs: dict[str, Any]) -> dict[str, Any]:
repo = inputs.get("repo", "")
owner, _, name = repo.partition("/") if isinstance(repo, str) else ("", "", "")
configurable: dict[str, Any] = {
"__is_for_execution__": True,
"reviewer_eval": True,
"eval": True,
"repo": {"owner": owner, "name": name},
"pr_number": inputs.get("pr_number"),
"pr_url": inputs.get("pr_url", ""),
"base_sha": inputs.get("base_sha", ""),
"head_sha": inputs.get("head_sha", ""),
"branch_name": inputs.get("head_ref", ""),
}
model_id = get_reviewer_model_id()
if model_id:
configurable["reviewer_model_id"] = model_id
reasoning_effort = get_reviewer_reasoning_effort()
if reasoning_effort:
configurable["reviewer_reasoning_effort"] = reasoning_effort
return configurable
async def review_pr(inputs: dict[str, Any]) -> dict[str, Any]:
"""LangSmith target: run the reviewer agent on one PR."""
client = get_client(url=get_langgraph_url())
thread = await client.threads.create()
thread_id: str = thread["thread_id"]
_record_thread_id(thread_id)
result = await client.runs.wait(
thread_id,
assistant_id=get_reviewer_assistant_id(),
input={"messages": [{"role": "user", "content": _build_user_message(inputs)}]},
config={"configurable": _build_configurable(inputs)},
)
if get_score_mode() == "surfaced_findings":
return {"comments": await _extract_surfaced_comments(client, thread_id)}
return {"comments": _extract_comments(result)}
def _extract_comments(result: Any) -> list[dict[str, Any]]:
"""Collect every ``add_finding`` tool call from the run's message stream.
Normalizes the new finding shape (``start_line``/``end_line``/``description``)
into the legacy ``{file, line, body, severity}`` shape the judge prompt
consumes verbatim from martian's benchmark.
"""
if not isinstance(result, dict):
return []
comments: list[dict[str, Any]] = []
for msg in result.get("messages") or []:
if not isinstance(msg, dict):
continue
for tc in msg.get("tool_calls") or []:
if tc.get("name") != "add_finding":
continue
args = tc.get("args") or {}
file = args.get("file")
severity = args.get("severity")
description = args.get("description") or args.get("body") or ""
line = args.get("end_line")
if line is None:
line = args.get("start_line")
if not file or not severity:
continue
comments.append(
{
"file": file,
"line": line,
"body": description,
"severity": severity,
}
)
return comments
async def _extract_surfaced_comments(client: Any, thread_id: str) -> list[dict[str, Any]]:
thread = await client.threads.get(thread_id)
metadata = thread.get("metadata") if isinstance(thread, dict) else None
findings_value = metadata.get("findings") if isinstance(metadata, dict) else None
findings = _coerce_findings(findings_value)
surfaced = filter_findings_for_publish(
findings,
severity_threshold=_score_severity_threshold(),
cap=_score_cap(),
)
return [_normalize_finding(finding) for finding in surfaced]
def _coerce_findings(value: Any) -> list[Finding]:
if not isinstance(value, list):
return []
findings: list[Finding] = []
for item in value:
if not isinstance(item, dict):
continue
if not isinstance(item.get("id"), str):
continue
findings.append(cast(Finding, item))
return findings
def _normalize_finding(finding: Finding) -> dict[str, Any]:
line = finding.get("end_line")
if line is None:
line = finding.get("start_line")
return {
"file": finding.get("file"),
"line": line,
"body": finding.get("description", ""),
"severity": finding.get("severity"),
}
def _score_severity_threshold() -> Severity:
value = os.getenv("REVIEWER_EVAL_SEVERITY_THRESHOLD", "medium")
if value in _VALID_SEVERITIES:
return cast(Severity, value)
return "medium"
def _score_cap() -> int:
raw = os.getenv("REVIEWER_EVAL_CAP", "4")
try:
cap = int(raw)
except ValueError:
return 4
return max(cap, 0)