Skip to content

Review pull requests with an agent in CI - #611

Merged
negz merged 13 commits into
modelplaneai:mainfrom
negz:peer-pressure
Nov 2, 2026
Merged

negz merged 13 commits into
modelplaneai:mainfrom
negz:peer-pressure

Conversation

@negz

@negz negz commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

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 /review to review any PR, or to re-review after new pushes. A re-review only covers what git range-diff shows 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-requests skill, 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.md explains 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:

  1. Create a GitHub App owned by 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.
  2. On this repo, set the variable REVIEW_APP_ID and the secret REVIEW_APP_KEY from the App.
  3. Create a model API key of its own, with a spend limit. Set it as the secret REVIEW_MODEL_API_KEY. The review uses Anthropic's API and Claude Opus 5.5 by default. To use another endpoint, set the variables REVIEW_MODEL_API (anthropic or openai), REVIEW_MODEL_URL and REVIEW_MODEL.

Until REVIEW_APP_ID is set, none of the review jobs run.

I have:

  • Read and followed Modelplane's contribution process.
  • Run nix flake check (or ./nix.sh flake check) and made sure it passes.
  • Added or updated tests covering any composition function changes.
  • Signed off every commit with git commit -s.

Copilot AI balanced review requested due to automatic review settings November 1, 2026 06:14

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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
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 /review command, 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

Comment thread hack/post_review.py Resolved
Comment thread hack/post_review.py Resolved
Copilot AI balanced review requested due to automatic review settings November 1, 2026 06:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Review effort: Balanced

Comment thread hack/post_review.py Resolved
Copilot AI balanced review requested due to automatic review settings November 1, 2026 06:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Review effort: Balanced

Comment thread hack/post_review.py Resolved
Copilot AI balanced review requested due to automatic review settings November 1, 2026 06:54

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread hack/post_review.py Resolved
Copilot AI balanced review requested due to automatic review settings November 1, 2026 07:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

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>
@negz
negz force-pushed the peer-pressure branch 3 times, most recently from 0fceca4 to ee4327d Compare November 1, 2026 07:21
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>
@negz
negz force-pushed the peer-pressure branch 2 times, most recently from 4117106 to 66daa42 Compare November 1, 2026 09:51
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>
Comment thread .github/workflows/review.yml Resolved

@haarchri haarchri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 dennis-upbound left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

  1. 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.
  2. 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.

negz added 7 commits November 2, 2026 11:42
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>
negz added 3 commits November 2, 2026 11:53
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>
@negz
negz merged commit d7f2116 into modelplaneai:main Nov 2, 2026
6 checks passed
@negz
negz deleted the peer-pressure branch November 2, 2026 22:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants