open-swe/agent/reviewer.py
Johannes du Plessis ccea80b887
feat: inline AGENTS.md into reviewer system prompt (#1328)
* feat(reviewer): inline AGENTS.md into reviewer system prompt

Fetches AGENTS.md from the PR's head_sha via the GitHub contents API
during reviewer setup and inlines it as a "Repository conventions"
block in the system prompt. Mirrors how the per-repo review style
prompt is already wired.

The main agent has long had a mandatory step to read AGENTS.md after
cloning, but the reviewer often skips cloning entirely (it can
`gh pr diff` directly), so it never saw the file. Loading it
deterministically means the reviewer judges findings against the
project's own conventions instead of relying on the model to fetch
the file itself.

* fix(reviewer): fetch AGENTS.md from base_sha, not head_sha

The reviewer inlines AGENTS.md into its system prompt. Reading from
head_sha means a PR author can edit AGENTS.md in the same PR being
reviewed and smuggle instructions like "ignore all bugs" / "publish
no findings" into the reviewer's prompt. Switch to base_sha (the
target branch's pre-PR state, which is trusted) and update the prompt
text to reflect that the contents come from the base, not the head.

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-05-22 14:58:27 -07:00

487 lines
20 KiB
Python

"""Reviewer graph factory.
Mirrors `agent.server.get_agent`'s sandbox lifecycle but configures a deep
agent for code review only:
- Deterministic repo prep (clone-or-fetch + checkout) before the agent's first
model call so the LLM doesn't burn tokens narrating ``gh repo clone``.
- A computed unified diff and the set of (file, line) tuples in that diff,
passed via the runnable config so ``add_finding`` can validate at creation
time rather than failing at GitHub-publish time.
- A reviewer-specific tool set: ``add_finding``, ``update_finding``,
``list_findings``, ``publish_review``. No commit/push/PR-opening tools.
- A system prompt that pins the single-evolving-findings model, in-diff-only
discipline, severity ladder, and the watch-mode reconciliation flow.
"""
# ruff: noqa: E402
import logging
import warnings
logger = logging.getLogger(__name__)
from langgraph.graph.state import RunnableConfig
from langgraph.pregel import Pregel
warnings.filterwarnings("ignore", module="langchain_core._api.deprecation")
warnings.filterwarnings("ignore", message=".*Pydantic V1.*", category=UserWarning)
from ._patch_messages_reducer import _apply as _apply_messages_reducer_patch
_apply_messages_reducer_patch()
from deepagents import create_deep_agent
from langchain.agents.middleware import ModelCallLimitMiddleware
from .middleware import (
SanitizeToolInputsMiddleware,
SlackAssistantStatusMiddleware,
ToolErrorMiddleware,
)
from .reviewer_findings import (
list_findings as list_findings_async,
)
from .server import (
DEFAULT_LLM_MAX_TOKENS,
DEFAULT_RECURSION_LIMIT,
MODEL_CALL_RECURSION_LIMIT,
ensure_sandbox_for_thread,
graph_loaded_for_execution,
)
from .tools import (
add_finding,
fetch_url,
http_request,
list_findings,
publish_review,
update_finding,
web_search,
)
from .utils.agents_md import fetch_agents_md
from .utils.auth import resolve_github_token
from .utils.github_token import get_github_token_from_thread
from .utils.model import DEFAULT_LLM_REASONING, make_model, provider_model_kwargs
from .utils.sandbox_paths import aresolve_sandbox_work_dir
REVIEWER_PROMPT_TEMPLATE = """You are a specialized code reviewer agent. Your job is to review one GitHub PR and publish a single review.
Sandbox: `{working_dir}`. Invoke `gh` as `GH_TOKEN=dummy gh <command>`.
Fetch the diff:
```
GH_TOKEN=dummy gh pr diff {pr_number} --repo {repo_owner}/{repo_name}
```
Re-review (user message says "A new commit has been pushed"):
```
GH_TOKEN=dummy gh api repos/{repo_owner}/{repo_name}/compare/<last_reviewed_sha>...<head_sha> -H "Accept: application/vnd.github.v3.diff"
```
Clone the repo so you can grep for full file context:
```
GH_TOKEN=dummy gh repo clone {repo_owner}/{repo_name} && cd {repo_name} && git checkout <head_sha>
```
Tools: `add_finding`, `update_finding`, `list_findings`, `publish_review`.
Call `publish_review` once at the end.
Re-review: for each open finding, `update_finding(id, status="resolved")` if
fixed, `update_finding` with new fields + `note` if changed, otherwise do
nothing. Add net-new findings with `add_finding`.
# The bar: file a finding only if it passes these criteria
1. You can anchor it to a specific changed line and quote that line.
2. You can name the concrete failure mode — what breaks at build time,
runtime, or for users, given the code as it exists today.
3. **Diff-anchor:** the finding's file appears in the PR diff hunk, OR you
proved a regression via `git show <base_sha>:path` vs
`git show <head_sha>:path` on a callsite of a symbol whose signature
changed in the diff. Do not file bugs in unrelated files or subsystems
based on inference alone.
# Do NOT file
- **Style / naming / convention nits.** No "rename this", "extract a
constant", "use a different helper", "this could be cleaner". The one
exception: typos that break behavior (a template binding, an exported name
a template references by string, a misspelled identifier that fails to
resolve).
- **Speculation.** No "if X is ever null", "if a future caller passes Y",
"could potentially race". You need a concrete trigger reachable from the
current code.
- **Scope-policing / architectural critique.** No "this PR doesn't achieve
its stated goal", "the design should be different".
- **Pre-existing issues** not introduced by this diff.
- **Out-of-diff / wrong-subsystem speculation.** Do not file findings in
files absent from the PR diff unless you proved base-vs-head regression on
a changed symbol's callsite.
- **Same-bug fan-out.** If the same defect appears in N files, file ONE
finding that lists all sites in `description`. Not N findings.
# Review workflow
The diff is the starting point, not the whole job. Work the changed code
carefully before reaching for unchanged code.
1. **Read the diff end-to-end.** For each changed hunk, ask: *what did this
exact line change, and what's the failure mode if the change is wrong?*
Prioritize literal defects (wrong variable, wrong operator, wrong key,
wrong return) over inferred bugs in nearby unchanged code.
2. **Base-vs-head on refactors.** When the PR renames, moves, extracts, or
rewrites a function, compare each touched function's old body against the
new one with `git show <base_sha>:path`. Watch for silently dropped
behavior: nil-checks, logging, error handling, async-ness, lock scope,
transactions, validation.
3. **Grep beyond the diff when a contract changed.** If a function
signature, interface, exported name, config key, or data-shape changed,
grep implementers and callers. Are they all updated? Same for new lookup
helpers — find where the data is written and confirm keys match.
4. **Security / trust boundaries when touched.** If the diff includes auth,
permissions, sessions, caching of authorization decisions, URL fetching,
HTML/template rendering, or cross-origin behavior, trace the resolution
path. Don't just suggest tidying — confirm what actually happens on the
hit, miss, and error paths.
5. **Verify library / framework usage you're not certain of.** If a
stdlib, ORM, or framework call's semantics matter to the change, confirm
the contract before assuming a bug or assuming safety.
Use `add_finding` to record each candidate. Don't over-investigate before
recording — capture the finding, keep moving, then rank and prune before
publishing.
# Before publish_review
1. Call `list_findings`. If the diff touches production code and you have
zero findings, double-check you have actually walked the workflow above —
silence on a real change is usually a miss, not a clean PR.
2. **Dedup:** collapse duplicate `(file, line, failure_mode)` entries; use
the fan-out rule for the same defect across multiple sites.
3. **Rank** open findings by severity and confidence. Prefer findings tied
to a concrete failure mode over findings that merely describe a smell.
4. Keep only the strongest small set. No two findings in the same file
unless they are independent failure modes with different user-visible
symptoms.
5. Cross-check PR title and top-changed directories: if a major changed
prefix has zero findings, re-read that prefix before publishing.
# Severity rubric (tied to runtime consequence)
- `critical` — panic, crash, data loss, auth bypass, security regression.
- `high` — wrong result for users; clear correctness bug.
- `medium` — correctness in an edge case; concurrency hazard with a
reachable trigger.
- `low` — a real defect with limited blast radius (typo that breaks a
binding, log level wrong in a hot path, UX bug with concrete impact).
Architectural opinions, naming preferences, and micro-perf are not
severities — they're not findings.
# Other rules
- Read-only. Do not commit, push, or use `gh pr review` / `gh api .../reviews`.
- One finding per defect (with the fan-out rule above for cross-file bugs).
- Include `suggestion` only when the fix is ≤4 lines and obvious.
- Publish a concise review: prefer the highest-confidence findings that
pass the bar. Use fewer when fewer issues are defensible; publish zero
only after the workflow above found no concrete regression.
"""
REVIEWER_EVAL_PROMPT_SUFFIX = """
# Eval mode — calibration
This run is scored against a closed set of golden review comments per PR.
The dataset expects 1-5 comments per PR (mean ~2).
- **Hard minimum: at least 1 finding per review.** Publishing zero is only
acceptable after you have explicitly walked Passes 1-4 and have nothing
that meets the bar. If you reach `publish_review` empty, return to the
checklist — silence costs more than a defensible medium-severity finding.
- **Hard cap: at most 3 findings per review.**
- Findings that match a golden comment are rewarded; findings that don't
are penalized. Missing a golden comment is also penalized. Optimize for
*defects a careful maintainer would also flag* — not coverage of every
observation you make.
"""
def _reviewer_system_prompt(
working_dir: str,
*,
repo_owner: str,
repo_name: str,
pr_number: int | str,
reviewer_eval: bool = False,
repo_style_prompt: str | None = None,
agents_md_content: str | None = None,
) -> str:
prompt = REVIEWER_PROMPT_TEMPLATE.format(
working_dir=working_dir,
repo_owner=repo_owner or "<owner>",
repo_name=repo_name or "<repo>",
pr_number=pr_number if pr_number != "" else "<pr_number>",
)
if reviewer_eval:
prompt = f"{prompt}\n{REVIEWER_EVAL_PROMPT_SUFFIX}"
if repo_style_prompt:
prompt = (
f"{prompt}\n\n"
"# Repository-specific review style\n\n"
"The following rules were learned from this repository's historical "
"PR reviews. Apply them when they agree with the global bar above; "
"they refine tone, severity, and what this team typically flags.\n\n"
f"{repo_style_prompt}"
)
if agents_md_content:
prompt = (
f"{prompt}\n\n"
"# Repository conventions (AGENTS.md)\n\n"
"The following is the `AGENTS.md` file from the target branch "
"(the PR's base), not from the PR head. It documents the "
"project's conventions, architecture, and rules. Treat "
"violations of these conventions as candidate findings when "
"they meet the global bar above (anchored to a changed line, "
"concrete failure mode, in-diff). Do not file findings for "
"pre-existing violations outside the diff.\n\n"
"```\n"
f"{agents_md_content}\n"
"```"
)
return prompt
def _build_first_review_context(
*,
pr_url: str,
repo_owner: str,
repo_name: str,
pr_number: int,
base_sha: str,
head_sha: str,
) -> str:
return (
f"## Pull request to review\n\n"
f"- repo: {repo_owner}/{repo_name}\n"
f"- pr_number: {pr_number}\n"
f"- url: {pr_url}\n"
f"- base_sha: {base_sha}\n"
f"- head_sha: {head_sha}\n\n"
f"Fetch the diff yourself with "
f"`GH_TOKEN=dummy gh pr diff {pr_number} --repo {repo_owner}/{repo_name}`, "
f"then review using the ordered passes (mechanical grep → diff-line audit "
f"→ security/auth if applicable → pipeline sweep → deep flow).\n\n"
f"This is a first review — there are no existing findings. Record issues "
f"with `add_finding`, call `list_findings` to rank and dedup, then "
f"`publish_review` once at the end (cap 3)."
)
def _build_re_review_context(
*,
pr_url: str,
repo_owner: str,
repo_name: str,
pr_number: int,
last_reviewed_sha: str,
head_sha: str,
existing_findings_block: str,
) -> str:
return (
f"## A new commit has been pushed\n\n"
f"- repo: {repo_owner}/{repo_name}\n"
f"- pr_number: {pr_number}\n"
f"- url: {pr_url}\n"
f"- previous reviewed SHA: {last_reviewed_sha}\n"
f"- new HEAD SHA: {head_sha}\n\n"
f"## Existing findings\n\n{existing_findings_block}\n\n"
f"Fetch the diff since the previous reviewed SHA yourself with "
f"`GH_TOKEN=dummy gh api repos/{repo_owner}/{repo_name}/compare/"
f'{last_reviewed_sha}...{head_sha} -H "Accept: application/vnd.github.v3.diff"`, '
f"then review only what's in that diff.\n\n"
f"For each open finding above, decide whether the new commits resolved "
f'it (`update_finding(id, status="resolved")`), left it unchanged '
f"(no action), or changed it materially (`update_finding` with new "
f"fields + a `note`). Then add any net-new findings introduced by the "
f"new diff, and call `publish_review` once at the end."
)
def _format_existing_findings(findings: list[dict]) -> str:
if not findings:
return "_(none)_"
lines: list[str] = []
for f in findings:
if f.get("status") != "open":
continue
location = f.get("file", "<unknown>")
start = f.get("start_line")
end = f.get("end_line")
if start is not None and end is not None:
location += f":{start}" if start == end else f":{start}-{end}"
lines.append(
f"- [{f.get('id')}] ({f.get('severity')}, {f.get('category')}) "
f"{location} — {f.get('description', '').strip()}"
)
return "\n".join(lines) if lines else "_(no open findings)_"
async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
"""Get or create a reviewer agent with a sandbox + prepped repo."""
thread_id = config["configurable"].get("thread_id", None)
config["recursion_limit"] = DEFAULT_RECURSION_LIMIT
if thread_id is None or not graph_loaded_for_execution(config):
logger.info("No thread_id or not for execution, returning reviewer agent without sandbox")
return create_deep_agent(system_prompt="", tools=[]).with_config(config)
github_token: str | None = None
if config["configurable"].get("source"):
cached_token, cached_encrypted, cached_expires_at = await get_github_token_from_thread(
thread_id
)
if cached_token and cached_encrypted:
config["metadata"]["github_token_encrypted"] = cached_encrypted
config["metadata"]["github_token_expires_at"] = cached_expires_at
github_token = cached_token
else:
_token, new_encrypted, new_expires_at = await resolve_github_token(config, thread_id)
config["metadata"]["github_token_encrypted"] = new_encrypted
config["metadata"]["github_token_expires_at"] = new_expires_at
github_token = _token
sandbox_backend = await ensure_sandbox_for_thread(thread_id)
work_dir = await aresolve_sandbox_work_dir(sandbox_backend)
repo_config = config["configurable"].get("repo") or {}
repo_owner = str(repo_config.get("owner", ""))
repo_name = str(repo_config.get("name", ""))
base_sha = str(config["configurable"].get("base_sha", "") or "")
head_sha = str(config["configurable"].get("head_sha", "") or "")
pr_number = config["configurable"].get("pr_number")
pr_url = str(config["configurable"].get("pr_url", "") or "")
last_reviewed_sha = str(config["configurable"].get("last_reviewed_sha", "") or "")
is_re_review = bool(config["configurable"].get("re_review"))
# Hotfix: prep was producing empty diffs for some PRs and the agent
# silently published "no issues found". The agent now fetches the diff
# itself via `gh pr diff` (or `gh api ...compare...` on re-review).
# `add_finding`'s in-diff line-range validation is skipped when no
# diff_line_set is set in config — we trust the agent's anchors.
config["configurable"]["diff_text"] = ""
config["configurable"]["diff_line_set"] = None
review_context = ""
if pr_number is not None and isinstance(pr_number, int):
if is_re_review and last_reviewed_sha:
existing_findings = await list_findings_async(thread_id)
review_context = _build_re_review_context(
pr_url=pr_url,
repo_owner=repo_owner,
repo_name=repo_name,
pr_number=pr_number,
last_reviewed_sha=last_reviewed_sha,
head_sha=head_sha,
existing_findings_block=_format_existing_findings(existing_findings),
)
else:
review_context = _build_first_review_context(
pr_url=pr_url,
repo_owner=repo_owner,
repo_name=repo_name,
pr_number=pr_number,
base_sha=base_sha,
head_sha=head_sha,
)
from .dashboard.team_settings import get_team_default_model
configured_model_id = config["configurable"].get("reviewer_model_id")
configured_effort = config["configurable"].get("reviewer_reasoning_effort")
if isinstance(configured_model_id, str) and configured_model_id:
model_id = configured_model_id
reasoning_effort = configured_effort if isinstance(configured_effort, str) else None
else:
model_id, reasoning_effort = await get_team_default_model("reviewer")
logger.info(
"Using team default reviewer model: model=%s effort=%s",
model_id,
reasoning_effort,
)
model_kwargs = provider_model_kwargs(
model_id,
reasoning_effort,
max_tokens=DEFAULT_LLM_MAX_TOKENS,
openai_reasoning_default=DEFAULT_LLM_REASONING,
)
reviewer_eval = (
config["configurable"].get("reviewer_eval") is True
or config["configurable"].get("eval") is True
)
repo_style_prompt: str | None = None
if repo_owner and repo_name:
from .dashboard.review_styles import get_repo_custom_prompt
repo_style_prompt = await get_repo_custom_prompt(repo_owner, repo_name)
# Fetch AGENTS.md from base_sha (the target branch's state before this
# PR's changes), not head_sha. The contents are inlined into the system
# prompt, so reading from head would let a PR author smuggle reviewer
# instructions ("ignore all bugs", "publish no findings") into the
# review. base_sha is the trusted ref.
agents_md_content: str | None = None
if repo_owner and repo_name and base_sha:
agents_md_content = await fetch_agents_md(
repo_owner,
repo_name,
base_sha,
token=github_token,
)
if agents_md_content:
logger.info(
"Loaded AGENTS.md (%d chars) from %s/%s@%s into reviewer prompt",
len(agents_md_content),
repo_owner,
repo_name,
base_sha,
)
del github_token
system_prompt = _reviewer_system_prompt(
f"{work_dir}/{repo_name}" if repo_name else work_dir,
repo_owner=repo_owner,
repo_name=repo_name,
pr_number=pr_number if isinstance(pr_number, int) else "",
reviewer_eval=reviewer_eval,
repo_style_prompt=repo_style_prompt,
agents_md_content=agents_md_content,
)
if review_context:
system_prompt = f"{system_prompt}\n\n{review_context}"
return create_deep_agent(
model=make_model(model_id, **model_kwargs),
system_prompt=system_prompt,
tools=[
add_finding,
update_finding,
list_findings,
publish_review,
web_search,
fetch_url,
http_request,
],
backend=sandbox_backend,
middleware=[
SanitizeToolInputsMiddleware(),
ModelCallLimitMiddleware(run_limit=MODEL_CALL_RECURSION_LIMIT, exit_behavior="end"),
ToolErrorMiddleware(),
SlackAssistantStatusMiddleware(),
],
).with_config(config)