Repository navigation
Review pull requests with an agent in CI - #611
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Archive scanning can exhaust the post job’s memory or CPU before limits apply, and failed agent runs can be marked as reviewed, causing unfinished work to be skipped.
5 open issues
Bound archive scanning before buffering members · New Reject failed agent runs before marking them reviewed · New Update the skill to match the current runtime contract · New Align test guidance with the required pytest style · New Use wire-form dictionaries for composed and observed resources · New
What changed in this PR
This PR adds shared, automated pull request reviews to Modelplane’s CI, supplementing maintainer review with repository-owned agents and review guidance.
Changes:
- Adds automatic reviews for organization members, a permission-gated
/reviewcommand, and retries for unavailable KVM. - Runs reviewers in a Firecracker VM and validates their output before posting a comment-only review, with incremental re-review support.
- Adds review skills, specialized agent prompts, and Vale checks for their prose.
| File | Description |
|---|---|
nix/docs.nix |
Exposes shared Vale styles. |
nix/checks.nix |
Adds linting for skills and agent prompts. |
hack/review-vm/flake.nix |
Defines the isolated review VM and agent runtime. |
hack/review-vm/flake.lock |
Pins VM dependencies. |
hack/post_review.py |
Validates, scopes, and posts review output. |
design/pr-review-agent.md |
Describes the review flow and security model. |
.opencode/vale/.vale.ini |
Configures review-prose linting. |
.opencode/vale/.gitignore |
Ignores downloaded Vale styles. |
.opencode/agents/review.md |
Coordinates reviewers and reconciles findings. |
.opencode/agents/review-prose.md |
Defines prose review responsibilities. |
.opencode/agents/review-design.md |
Defines design review responsibilities. |
.opencode/agents/review-craft.md |
Defines code-craft review responsibilities. |
.opencode/agents/review-correctness.md |
Defines correctness verification responsibilities. |
.github/workflows/review.yml |
Orchestrates authorization, VM execution, and posting. |
.github/workflows/review-retry.yml |
Retries runs that fail during KVM setup. |
.github/workflows/commands.yml |
Adds permission-gated /review handling. |
.agents/skills/writing-style/SKILL.md |
Adds project writing guidance. |
.agents/skills/writing-style/references/voice.md |
Provides voice and structure examples. |
.agents/skills/writing-style/references/de-slop-review.md |
Defines a focused prose-editing pass. |
.agents/skills/reviewing-pull-requests/SKILL.md |
Sets finding standards and feedback conventions. |
.agents/skills/reviewing-pull-requests/references/calibration.md |
Provides examples for judging findings. |
.agents/skills/reviewing-designs/SKILL.md |
Defines design assessment criteria. |
.agents/skills/reviewing-designs/references/calibration.md |
Provides design-review examples. |
.agents/skills/python-code-factoring/SKILL.md |
Adds Python structure and testing guidance. |
.agents/skills/python-code-factoring/references/example.md |
Illustrates Python factoring patterns. |
.agents/skills/nix-tools/SKILL.md |
Explains running CLI tools through Nix. |
.agents/skills/nix-flake/SKILL.md |
Explains flake-based development commands. |
.agents/skills/krm-api-design/SKILL.md |
Adds Kubernetes API design guidance. |
.agents/skills/design-docs/SKILL.md |
Sets design-document standards. |
.agents/skills/crossplane-python-functions/SKILL.md |
Adds composition-function guidance. |
.agents/skills/crossplane-python-functions/references/example.py |
Supplies a composition-function reference example. |
.agents/skills/before-writing-code/SKILL.md |
Adds shared engineering and testing principles. |
Review effort: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
The posting path can publish partial reviews after agent failure and process unbounded compressed archives, risking skipped unfinished work and resource exhaustion in the privileged runner.
6 open issues
Require a successful agent exit before posting a review · New Bound archive scanning before buffering members Reject failed agent runs before marking them reviewed Use wire-form dictionaries for composed and observed resources Align test guidance with the required pytest style Update the skill to match the current runtime contract
Review effort: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
The review poster does not validate successful agent completion, allowing partial reviews from failed runs to be published with commit markers that cause later re-reviews to skip unfinished work.
7 open issues
Reject failed agent runs before posting review markers · New Require a successful agent exit before posting a review Bound archive scanning before buffering members Reject failed agent runs before marking them reviewed Use wire-form dictionaries for composed and observed resources Align test guidance with the required pytest style Update the skill to match the current runtime contract
Review effort: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
The parser ignores missing or nonzero agent exit status, allowing unfinished reviews to be marked complete, and reviewer skills conflict with the documented function and testing conventions.
9 open issues
Reject reviews when the agent exit status is missing or unsuccessful · New Reject failed agent runs before posting review markers Require a successful agent exit before posting a review Bound archive scanning before buffering members Align test guidance with the project's pytest convention · New Reject failed agent runs before marking them reviewed Use wire-form dictionaries for composed and observed resources Align test guidance with the required pytest style Update the skill to match the current runtime contract
Review effort: Balanced
All of Modelplane's code is written by agents, and each maintainer's agent loads its own personal skills. The conventions we expect, such as how to factor a composition function, shape an XRD or write a design doc, live in those personal skills rather than in the repo, so each agent knows a different subset. This adds my skills for writing code, Python composition functions, KRM APIs, design docs, prose and Nix to .agents/skills, where opencode and other agent harnesses load them for anyone working in the repo. They're adapted to Modelplane: the composition function skill describes how Modelplane builds and tests functions, the testing advice defers to CONTRIBUTING.md, the XRD skill covers YAML schemas rather than Go types, and the writing skills describe the project's style rather than mine. Agents imitate the prose they're given, so a new agents-vale flake check lints the skills with the same ai-tells rules the docs use. Signed-off-by: Nic Cope <nicc@rk0n.org>
0fceca4 to
ee4327d
Compare
Agent reviewers find problems indefinitely, and the author's agent addresses each one. Most of what maintainers decline falls into a few kinds: backward compatibility for pre-1.0 software, guards for states the schema rules out, and confident claims about external tools that turn out to be false. reviewing-pull-requests sets a bar a finding must pass before it's posted, what not to raise, and how to phrase what is: correctness and design feedback as a question, an explanation and a suggestion, style feedback directly. reviewing-designs holds the questions maintainers ask of a proposal, such as what the user gets and whether the next engine would need a new Modelplane release. Both are distilled from my review sessions and the comments maintainers posted on Modelplane PRs, and carry real findings from this repo for calibration. Signed-off-by: Nic Cope <nicc@rk0n.org>
4117106 to
66daa42
Compare
The review agent dispatches four reviewers with fresh context, for design, prose, craft and correctness, then checks their findings against the reviewing-pull-requests bar and writes what passes as markdown for a workflow to post. A re-review covers only what git range-diff shows changed since the last one. Any maintainer can run it locally with `opencode run --agent review`. The agents-vale check now lints the agents' prompts too. Signed-off-by: Nic Cope <nicc@rk0n.org>
haarchri
left a comment
There was a problem hiding this comment.
lets try it out and iterate over it - please check my comments around how some files gets created - do we need this information inside?!
dennis-upbound
left a comment
There was a problem hiding this comment.
The agent runs in a microVM, the key stays in a runner proxy, and a model-free job posts the review. That's how I'd want pull_request_target to handle untrusted code. I checked that the review job's token is read-only (contents/pull-requests/issues: read) with a 60-minute timeout.
- A budget per review (a token or turn cap in the opencode config) as well as the workspace limit, so one runaway or malicious PR can't use up the month and stop reviews for everyone.
- A small eval before we tune the skills, since this PR leaves that to follow-ups. Without a baseline, a follow-up that "improves" the skills can't show it did. You've already replayed #556 and #514 by hand, so this is mostly writing that down. Fine as a follow-up, but I'd like it landed before the first skill-tuning PR. Here's a version in Inspect AI:
Dataset: evals/review/cases.jsonl, 10 to 20 merged PRs. Most should be PRs where our review found something real, plus 3 or 4 clean ones that shouldn't draw findings. Each case pins the commit the first review saw, and lists what maintainers raised, taken from our review comments:
{"id": "pr-556", "pr": 556, "head": "<sha the first review saw>",
"findings": [
{"kind": "correctness", "file": "functions/compose-model-replica/function/fn.py",
"what": "a single-copy engine deadlocks its rollout when no GPU is idle"},
{"kind": "prose", "file": "docs/...", "what": "..."}]}The eval in evals/review/review.py. The solver runs the same agent the workflow runs, and the scorers compare its findings with ours:
import asyncio, json
from inspect_ai import Task, task
from inspect_ai.dataset import Sample
from inspect_ai.model import get_model
from inspect_ai.scorer import Score, mean, scorer
from inspect_ai.solver import solver
JUDGE = "anthropic/claude-sonnet-5-5" # cheaper than the reviewer; check it against hand labels once
def cases():
for line in open("evals/review/cases.jsonl"):
c = json.loads(line)
yield Sample(id=c["id"], input=f"Review PR #{c['pr']} at {c['head']}.",
target=json.dumps(c["findings"]), metadata=c)
@solver
def run_review():
async def solve(state, generate):
# the workflow's agent and skills, from main, against the pinned head; findings as JSON
p = await asyncio.create_subprocess_exec(
"opencode", "run", "--agent", "review", "--format", "json", state.input_text,
stdout=asyncio.subprocess.PIPE)
out, _ = await p.communicate()
state.output.completion = out.decode()
return state
return solve
async def matches(found: list, expected: list) -> list[bool]:
"""For each expected finding, does any agent finding raise the same problem? One judge call per case."""
prompt = ("For each EXPECTED finding, answer true if any FOUND finding raises the same problem "
"(same file or change, same defect), else false. Reply with a JSON list of booleans only.\n"
f"EXPECTED: {json.dumps(expected)}\nFOUND: {json.dumps(found)}")
return json.loads((await get_model(JUDGE).generate(prompt)).completion)
@scorer(metrics=[mean()])
def recall():
async def score(state, target):
expected, found = json.loads(target.text), json.loads(state.output.completion).get("findings", [])
if not expected: # a clean PR: score 1 when the agent raised nothing
return Score(value=float(not found), explanation=f"{len(found)} findings on a clean PR")
hit = await matches(found, expected)
return Score(value=sum(hit) / len(expected), explanation=f"{sum(hit)}/{len(expected)} found")
return score
@scorer(metrics=[mean()])
def grounded():
"""Deterministic: every finding names a file the PR touched. Catches invented findings without a judge."""
async def score(state, target):
found = json.loads(state.output.completion).get("findings", [])
touched = set(state.metadata.get("files", [])) # fill from `gh pr diff --name-only` when building cases
ok = [f for f in found if not touched or f.get("file") in touched]
return Score(value=len(ok) / len(found) if found else 1.0)
return score
@task
def review():
return Task(dataset=list(cases()), solver=run_review(), scorer=[recall(), grounded()])Run it with inspect eval evals/review/review.py, and compare runs with inspect view. Each skill change should report recall of what we raised, grounded, and the finding count on the clean PRs. A follow-up can add a precision scorer from our 👍/👎 on bot comments. Those reactions are the cheapest label source we'll get, so I'd turn them on from day one.
Before trusting the numbers:
- Check the judge once: label 2 or 3 cases by hand and confirm the judge agrees, before trusting its numbers.
- Keep a few cases out of tuning (a held-out set), so we don't fit the skills to the cases we look at.
The review agent runs the PR's code, so it needs a sandbox, and a container shares the runner's kernel and can't run Docker for a kind cluster. This flake builds a microVM with opencode, Vale, gh, Nix and Docker. It shares no files with the host: its job arrives as a tar on a read-only disk, and it writes its output as a tar to another. It's a flake of its own so the project's flake doesn't depend on microvm.nix. Signed-off-by: Nic Cope <nicc@rk0n.org>
PRs from org members are reviewed when they're opened or marked ready, and a /review comment from anyone with write access reviews any PR, or re-reviews it after new pushes. Every PR comes from a fork, so the workflow runs on pull_request_target, and the agent runs the PR's code in the microVM. A Caddy proxy on the runner adds the Anthropic key to its model requests and forwards only POST /v1/messages, and a firewall limits the VM to the proxy and the internet. The VM gets only the job's read-only token. A second job with no model reads the review from the VM's output tar, validates it with post_review.py and posts it as a GitHub App. On a re-review, the script only posts comments on lines that changed since the last one. GitHub doesn't officially support KVM on its runners, so review-retry.yml re-runs a review that landed on a runner without it. Signed-off-by: Nic Cope <nicc@rk0n.org>
Signed-off-by: Nic Cope <nicc@rk0n.org>
The docs had a Vale config under docs/utils/vale, and the review agent and the agents-vale check had their own under .opencode/vale. They pinned the same style packages separately, and nothing pointed a reader from one to the other. There's now one config, .vale.ini at the repo root, with the local styles and vocabulary under .vale/styles. Markdown in general gets the ai-tells rules and four Google ones, and docs/content gets the docs' full set on top. The docs lint exactly as before, under both the Vale the flake checks use and the current release. The Hugo token and block ignores move to the general section, because Vale 3.14 doesn't apply them from a path-scoped one. The section globs match paths relative to the repo root, so the Vale checks now lint from there, and live in nix/vale.nix rather than nix/docs.nix. The review workflow takes the config from main, like the rest of the reviewer's setup, so a PR can't change how its review is linted. Signed-off-by: Nic Cope <nicc@rk0n.org>
The skill and the design reviewer both gave the review agent verdict sentences, and the agent copied them into nearly every summary. The other reviewers also dropped findings they judged weak, a filter Anthropic's guidance says recent models apply too literally. Now the design reviewer reports a level, the skill describes the scale without sentences to copy, and each reviewer reports every finding with a confidence for the review agent to weigh. The skills' examples also came from the PRs used to test the agent, which flattered those tests, so they now come from other PRs. Signed-off-by: Nic Cope <nicc@rk0n.org>
The review skills repeated each other and in places disagreed. Every reviewer loaded reviewing-pull-requests and its checklist of what to look for, though each reviewer checks one category, and writing-style mixed general prose rules with design doc rules that the review agent had to override. This commit keeps one copy of each rule, where the agents that need it load it. Each reviewer's checklist is in its agent, the Crossplane failure modes are in crossplane-python-functions, and the design doc rules are in design-docs. The pre-1.0 rule is in AGENTS.md, since every agent reads it. reviewing-pull-requests keeps what the review agent needs to post: the bar, phrasing, where comments go and the summary. Signed-off-by: Nic Cope <nicc@rk0n.org>
The workflow dropped any comment with more than 600 characters of prose, and a smoke test lost half a review that way, including its most important finding. Now the workflow posts a long comment and logs it, and the review agent measures its comments and summary before it finishes, and trims them by cutting words rather than findings. Signed-off-by: Nic Cope <nicc@rk0n.org>
The skills said they were distilled from my review comments and documents, and quoted my corrections to agents as evidence for their rules. Who a rule came from doesn't help an agent follow it, and it makes shared skills read as one maintainer's preferences. Each rule now stands on its own, and the calibration examples name the role rather than the person. Signed-off-by: Nic Cope <nicc@rk0n.org>
Nothing stopped a runaway review except the workflow's timeout, and a review that ran to it could spend most of an hour's tokens on one PR. Each agent now has a step cap of about three times the most steps a review has needed. At the cap opencode asks the agent to wrap up rather than failing it, so a long review still posts. Signed-off-by: Nic Cope <nicc@rk0n.org>
Reactions and replies to review comments are the cheapest signal of whether a skill change helped, but nothing recorded which version of the skills and agents wrote a comment. Each review now ends with a footnote naming the commit the reviewer came from, and asking readers to react to each comment. Signed-off-by: Nic Cope <nicc@rk0n.org>



Description of your changes
Each of us points our own agent at a PR, with our own harness and skills. None of us has special skills for finding correctness bugs, so a shared agent could find them on every PR for all of us. On style and taste, each agent gives feedback from its owner's conventions, so moving those skills into the repo would let us agree on them. This doesn't replace maintainer review. The aim is that we stop spending review on what the agent already caught, and spend it on what only we can judge.
This PR adds an agent that reviews PRs automatically. It runs when an org member opens a PR or marks one ready for review. Anyone with write access can comment
/reviewto review any PR, or to re-review after new pushes. A re-review only covers whatgit range-diffshows changed since the last review.The review agent runs four reviewers: design, prose, craft and correctness. It drops any finding that fails the bar set in the
reviewing-pull-requestsskill, then posts the rest as a single comment review from a GitHub App. The bot never approves or requests changes.The skills live in
.agents/skills: some I've adapted from my own, and some are distilled from the reviews we've posted on this repo. I'd prefer to treat them as a starting point we can iterate on, rather than spending too much time honing them in this PR. i.e. I'd prefer us to align on the mechanism in this PR, and hone the skills in follow up PRs.The agent runs the PR's code, and every PR comes from a fork, so it runs in a Firecracker microVM that has no credentials that could do harm.
design/pr-review-agent.mdexplains why I think that's secure enough. The review I'd most like is on that section.I've run it end to end on my fork:
The replays' base branches stop the agent reading the upstream PR it replays, so it can't see how the real review went. The skills and agents are the ones in this PR.
To make it work after this merges:
modelplaneai, with no webhook. It needs read and write on pull requests, read on contents, and read on organization members. Install it on this repo.REVIEW_APP_IDand the secretREVIEW_APP_KEYfrom the App.REVIEW_MODEL_API_KEY. The review uses Anthropic's API and Claude Opus 5.5 by default. To use another endpoint, set the variablesREVIEW_MODEL_API(anthropicoropenai),REVIEW_MODEL_URLandREVIEW_MODEL.Until
REVIEW_APP_IDis set, none of the review jobs run.I have:
nix flake check(or./nix.sh flake check) and made sure it passes.Added or updated tests covering any composition function changes.git commit -s.