31 steps, 190s, 2,213,077 in / 9,058 out, cache 0/0 — within 7% of the first
run's input on the same tier, which is the first evidence the recalibrated tier
defaults hold rather than fitting a single point.
Also weakens the step-cap hypothesis from the previous commit: this run used 31
steps (more than the 28 that succeeded, and more than the run that failed) and
parsed fine, so hitting `steps: 40` is not on its own what breaks the output
format. Leaving the cap alone until the stderr logging catches a real failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B11e8TZZxJyzHW7jj7KWUN
Found by running the reviewer against this branch. The second review of PR #7
ran a full agent loop — 330s of a 540s budget, no timeout — and ended without a
parseable ```json block. The code discarded the entire run and posted "AI review
produced no parseable output.", losing minutes of work and millions of tokens
for a message that tells the maintainer nothing and gives me nothing to debug.
Three changes on that path:
- salvage_summary() keeps the agent's prose (fenced blocks stripped, tail kept
because the conclusion is written last) and posts it under an explicit banner
saying it is unstructured and its line numbers were never validated against
the diff. A partial review honestly labelled beats no review.
- The raw output's length and last 600 chars go to stderr, so the next
occurrence is diagnosable from pod logs instead of invisible.
- The AI-USAGE section is still rendered. The label asked for it and the tokens
were spent either way; dropping the measurement on the failure path is how the
cost model stops getting calibration data exactly when it is most interesting.
Not fixed here: why the agent went off-format. The likely cause is the 40-step
cap in the agent definition being reached on a larger diff (the successful run
used 28), which wants either a higher cap or a step-budget warning in the
prompt. Needs the next occurrence's stderr to confirm rather than guess.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B11e8TZZxJyzHW7jj7KWUN
PR #7 ran under the AI-USAGE label and reported real numbers: 28 agent steps,
348s, 2,071,025 input / 17,303 output tokens, and zero cache reads or writes.
The model predicted ~$0.73 on Opus 5 for that tier. The measurement prices it
at $10.79 — the model was ~15x low.
Two wrong assumptions:
- Step count and per-step growth. `full` assumed 12 steps and 1,200 tokens per
tool result; the run did 28 steps averaging ~3,300. Cost is roughly quadratic
in steps, so this compounds. Tier defaults are re-derived from the measured
per-step growth rather than from guesses.
- Caching. The model defaulted to prompt caching on. The headroom/glm-5.2 path
reports 0 read / 0 write, so the stable prefix is paid at full input price on
every step. Budget with caching off until that column is nonzero.
Adds OBSERVED_RUNS as an append-only calibration anchor, an observed-runs
section in the report, and a regression test asserting the model stays within
2.5x of the measurement — so the next drift is caught by the suite rather than
by a surprising invoice.
Corrected blended figures at 350 PRs/month: ~$1,740 Opus 5, ~$1,755 GPT-5.6
Sol, ~$696 Sonnet 5, ~$348 Haiku 4.5, ~$70 GPT-5.6 Luna.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B11e8TZZxJyzHW7jj7KWUN
Skills — the primary now loads conditionally (each one is input tokens), per a
load table in pragent.md:
- attention-tiering: classify every PR trivial/lite/full/oversized BEFORE
reading anything, and cap file reads, linter runs and subagent fan-out per
tier. This is the cost governor; the other skills defer to its budget.
- linter-playbook: per-ecosystem detect-and-run commands scoped to changed
files, the never-install rule, and how to turn a diagnostic into a finding
instead of pasting tool output.
- security-lens: the inline security checklist for when @security isn't worth
delegating, built around a source -> sink test each finding must pass.
- malicious-change: hostile-PR detection — injection aimed at the reviewer,
install/CI-time hooks, obfuscated payloads, dependency confusion, logic
backdoors. Complements the runtime containment added in the previous commit:
that stops the agent being hijacked, this makes it report the attempt.
- comment-craft: how to write problem/fix/suggestion so a maintainer can act in
one read, and what to cut.
pilot/cost_model.py — prices a review against published Claude and OpenAI rates
(fetched 2026-08-18). Prompt sizes are measured from the factory files rather
than guessed; per-tier workloads come from the tiering budgets. The model is
explicit about the thing that actually dominates an agent loop: the whole
conversation is resent every step, so caching moves ~2.3x of the bill.
Blended over a 5/35/55/5 mix with caching on: ~$0.61/PR on Opus 5 or GPT-5.6
Sol, ~$0.24 on Sonnet 5 or Terra, ~$0.12 on Haiku 4.5, ~$0.02 on Luna. At 350
PRs/month that's ~$212 / ~$85 / ~$43 / ~$8.50.
Tests: 101 -> 122.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B11e8TZZxJyzHW7jj7KWUN
The reviewer runs an opencode agent with `bash: "*": allow` over a checkout of
the PR author's branch, and the pod holds a Gitea Write credential. Those two
facts had no wall between them.
Security
- _build_env now allow-lists the subprocess environment instead of inheriting
it, so PRAGENT_BOT_TOKEN and WEBHOOK_SECRET never reach the agent. This was
the live hole: a PR body or an AGENTS.md could ask the agent to `curl` the
token out, and it had both the value and the tool.
- sanitize_workdir deletes author-controlled agent-instruction files from the
checkout before opencode starts (AGENTS.md at any depth, CLAUDE.md,
.cursorrules, a repo opencode.json/.opencode, copilot-instructions.md).
opencode loads nested AGENTS.md as instructions, so a PR could otherwise ship
its own system prompt. They are still reviewed, as data.
- The brief fences PR title/body and diff in --- UNTRUSTED --- markers under a
trust-boundary preamble; the pragent agent, the three lens subagents and the
review-methodology skill now treat injection attempts as a critical finding
to report rather than an instruction to obey.
- .pr-review.json is read from the PR's base branch, not the head sha. Its
`instructions` field is spliced into the reviewer's prompt, so head-ref
reading let any author rewrite the reviewer's rules. Fields are length-capped.
- Untar rejects escaping symlinks, parent traversal, and writes through a
planted symlink (tar-slip).
- The image runs as uid 10001 instead of root.
Robustness
- Bounded review concurrency (PRAGENT_MAX_CONCURRENT_REVIEWS, default 2). Each
review forks an opencode process; a thread per delivery was a fork bomb on a
burst of labels or Gitea retries.
- An in-flight (repo, index, sha) claim closes the check-then-act race in the
sha-marker dedupe, where two deliveries a second apart both read "not yet
reviewed" and both posted.
- Request bodies are capped before being read into memory.
Correctness
- parse_diff_anchors counts a whitespace-stripped blank context line. Skipping
it desynced the new-line counter for the rest of the hunk and silently
misplaced every later inline comment in that file.
- post_inline_review's body-only fallback folds the anchored findings into the
body. It previously posted a summary saying "N inline comment(s) below" with
no comments and no findings — losing them all on the one path that matters.
- fetch_pr_diff's files-endpoint fallback emits real a// b/ prefixes (so
changed_files and the anchor parser work on it) and reports both HTTP statuses
in its error instead of the same one twice.
- The CI workflow template pins PRAGENT_ENGINE=ollama; review_pr defaults to
opencode, which does not exist on a Gitea Actions runner.
Tests: 68 -> 101, covering each of the above.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B11e8TZZxJyzHW7jj7KWUN