Merge agent-B: cost-line fix + static_message + per-repo model override

# Conflicts:
#	pilot/ai_review.py
#	tests/pilot/test_ai_review.py
This commit is contained in:
Marcos
2026-08-22 15:01:38 +00:00
2 changed files with 223 additions and 12 deletions
+95 -12
View File
@@ -273,12 +273,18 @@ def format_review_body(
walkthrough: list[str] | None = None,
risk_verdict: str = "",
test_coverage: str = "",
static_message: str = "",
) -> str:
"""Format the posted review summary body.
Layout (per the operator's format guide):
* Header line (``🤖 AI Review …``) including the merge-confidence badge.
* Optional static banner (``> {static_message}``) — repo-wide call-out
from `.pr-review.json:static_message`, placed under the header so
every reviewer sees it on every review without scrolling.
* **Summary of Changes** — 24 bullets of what the PR introduces
(`summary_changes`); falls back to the opencode prose `summary` if
the agent didn't emit the list.
@@ -317,6 +323,11 @@ def format_review_body(
)
parts: list[str] = [header]
# Optional free-text banner. Rendered as a Markdown blockquote immediately
# after the header — front-of-mind for any maintainer scanning the review.
if static_message and static_message.strip():
parts.append(f"> {static_message.strip()}")
# --- Summary of Changes ---
sc = list(summary_changes or [])
if not sc and summary:
@@ -467,6 +478,32 @@ def _resolve_price_target(config: dict | None) -> tuple[str, str | None]:
return chosen, None
def _resolve_display_model(base_model: str, config: dict | None) -> str:
"""Resolve the *display* model for one review.
Precedence (highest first):
1. `OPENCODE_MODEL` env var — operator override, used as-is (already a
provider-prefixed opencode ref).
2. `.pr-review.json:model` — per-repo override. Already validated
against `cost_model.PRICES` by `parse_repo_config`, so a bare key
like `claude-sonnet-5` is safe to use as the opencode ref AND the
REVIEW_HEADER label.
3. Default — `f"headroom/{base_model}"` where `base_model` is the bare
`OLLAMA_MODEL` (e.g. `"MiniMax-M2.7""headroom/MiniMax-M2.7"`).
The same value flows to every consumer (opencode subprocess, REVIEW_HEADER,
cost-line parenthetical) so reviewers never see a mix of `glm-5.2:cloud`
and the routed model in one body.
"""
env = os.environ.get("OPENCODE_MODEL")
if env:
return env
cfg_model = (config or {}).get("model")
if isinstance(cfg_model, str) and cfg_model.strip():
return cfg_model.strip()
return f"headroom/{base_model}"
def equivalent_cost(usage: dict, price_key: str) -> float:
"""USD the measured usage would have billed on `price_key`'s provider.
@@ -1195,7 +1232,11 @@ def _render_collapsible_usage(usage: dict | None, model: str, config: dict | Non
would bill on mainstream paid APIs (configurable via `compare_against`,
defaulting to ``DEFAULT_COMPARE_AGAINST``). The row matching `cost_target`
is bolded so the price target stands out. The whole table is omitted when
every row would be $0 (no work done).
every row would be $0 (no work done). The `actual` parenthetical clause
reflects the *actually-routed* model (`model` arg, resolved by caller from
`OPENCODE_MODEL` env or `headroom/{OLLAMA_MODEL}`) — cost == 0 → "free
tier", nonzero → "billed".
"""
if not usage:
return ""
@@ -1203,7 +1244,7 @@ def _render_collapsible_usage(usage: dict | None, model: str, config: dict | Non
dur_s = f"{dur}s" if dur is not None else "?"
actual = usage.get("cost") or 0.0
actual_s = f"${actual:.4f}" if actual else "$0.00"
actual_note = " (headroom glm-5.2:cloud — free tier)" if not actual else ""
actual_note = f" ({model}{'free tier' if not actual else 'billed'})"
cost_target, price_err = _resolve_price_target(config)
if price_err:
# Surface config typos loudly but do not pollute the posted summary
@@ -1232,6 +1273,7 @@ def _render_collapsible_usage(usage: dict | None, model: str, config: dict | Non
cost_str = f"${c:.4f}" if c < 0.01 else f"${c:.2f}"
bold = "**" if key == cost_target else ""
eq_rows.append(f"| {bold}{label}{bold} | {cost_str} |")
in_tok = usage.get("input", 0)
out_tok = usage.get("output", 0)
reason_tok = usage.get("reasoning", 0)
@@ -1288,6 +1330,7 @@ CONFIG_MAX_ITEM_CHARS = 200
CONFIG_MAX_INSTRUCTIONS_CHARS = 4000
CONFIG_MAX_PATTERNS_ITEMS = 16 # allow + deny separately, total 32 entries
CONFIG_MAX_FINDINGS = 30
CONFIG_MAX_STATIC_MESSAGE_CHARS = 400 # free-text banner, mirror of instructions
STYLES = frozenset(STYLE_DEFAULTS)
SEVERITY_VALUES = frozenset(SEVERITIES)
@@ -1303,12 +1346,14 @@ def parse_repo_config(raw: str) -> dict:
Recognised keys (all optional):
focus, exclude_paths, languages, instructions — text steer
static_message ≤ CONFIG_MAX_STATIC_MESSAGE_CHARS — banner under header
style strict|balanced|lenient — default: balanced
severity_threshold low|medium|high|critical — default: per style
max_findings 1..CONFIG_MAX_FINDINGS — default: per style
exclude_tests bool — default: False
require_tests bool — default: False
patterns {allow:[…], deny:[…]} — post-filter globs
model <key of cost_model.PRICES> — per-repo override
cost_target <key of cost_model.PRICES> — see equivalent_cost
additional_context_urls list[str] (≤ 8) — see fetch_additional_context
"""
@@ -1336,6 +1381,10 @@ def parse_repo_config(raw: str) -> dict:
if isinstance(instr, str) and instr.strip():
out["instructions"] = instr.strip()[:CONFIG_MAX_INSTRUCTIONS_CHARS]
sm = data.get("static_message")
if isinstance(sm, str) and sm.strip():
out["static_message"] = sm.strip()[:CONFIG_MAX_STATIC_MESSAGE_CHARS]
style = data.get("style")
if isinstance(style, str) and style.strip().lower() in STYLES:
out["style"] = style.strip().lower()
@@ -1372,6 +1421,26 @@ def parse_repo_config(raw: str) -> dict:
if isinstance(ct, str) and ct.strip():
out["cost_target"] = ct.strip()
# Per-repo model override. Validated against cost_model.PRICES so the value
# is usable both as the opencode subprocess ref and as the REVIEW_HEADER
# label (see _resolve_display_model precedence). Unknown values are dropped
# with a stderr pointer to the valid set — silently ignoring would mask
# typos from repo admins.
raw_model = data.get("model")
if raw_model is not None:
if isinstance(raw_model, str) and raw_model.strip():
from cost_model import PRICES # lazy: ollama path dep-free
candidate = raw_model.strip()
if candidate in PRICES:
out["model"] = candidate
else:
print(
f"pragent: .pr-review.json:model={candidate!r} not in "
f"cost_model.PRICES (valid: {', '.join(sorted(PRICES))}); "
f"dropping",
file=sys.stderr, flush=True,
)
acu = data.get("additional_context_urls")
if isinstance(acu, list):
urls: list[str] = []
@@ -2023,6 +2092,13 @@ def review_pr(
Both the CI `run()` entry point and the central webhook server call this.
"""
try:
# Pre-compute a *fallback* display name for the early-exit paths
# (already-reviewed dedupe skip, no-diff-content). We re-resolve
# properly after `.pr-review.json` is loaded further down — that
# version honours `OPENCODE_MODEL` env > `.pr-review.json:model` >
# this fallback.
display_model = f"headroom/{model}"
reviews = fetch_existing_reviews(api, repo, index, token)
# Dedupe: already reviewed this exact commit -> nothing to do.
if sha and sha in reviewed_shas(reviews):
@@ -2031,12 +2107,18 @@ def review_pr(
raw_diff, _truncated, _orig = fetch_pr_diff(api, repo, index, token, max_chars)
if not raw_diff.strip():
post_review(api, repo, index, token, format_review_body("No diff content to review.", model, sha))
post_review(api, repo, index, token, format_review_body("No diff content to review.", display_model, sha))
return True
config = fetch_repo_config(api, repo, token, ref=base_ref)
prior = compact_prior_reviews(prior_review_bodies(reviews, sha))
# Re-resolve display_model now that .pr-review.json is available —
# per-repo override (`.pr-review.json:model`) takes precedence over
# the bare OLLAMA_MODEL fallback, with OPENCODE_MODEL env still
# winning above both (see `_resolve_display_model`).
display_model = _resolve_display_model(model, config)
# Trim the diff to +/- hunks plus a narrow context window. The agent
# resends the brief prefix every step, so a 25k-char diff becomes
# 25k × 30-step × cached-after-step-1 = hundreds of thousands of input
@@ -2070,10 +2152,9 @@ def review_pr(
# the brief, and the pragent agent factory; returns stdout with a
# summary + findings JSON. We parse + anchor + post here.
import opencode_review # local import keeps the ollama path dep-free
# opencode wants a provider-prefixed model ref (headroom/glm-5.2:cloud);
# `model` here is the bare id (OLLAMA_MODEL). OPENCODE_MODEL overrides
# with the full ref; otherwise we prefix the configured provider.
oc_model = os.environ.get("OPENCODE_MODEL") or f"headroom/{model}"
# Reuse the display_model resolved above for the subprocess — same
# provider-prefixed ref goes to the engine and into the review body.
oc_model = display_model
# Multi-lens fan-out: when the repo declared `reviewers[]` (or the
# operator pinned PRAGENT_REVIEWERS=1), spawn one opencode subprocess
# per lens in parallel and synthesize. Falls through to the legacy
@@ -2109,10 +2190,11 @@ def review_pr(
file=sys.stderr, flush=True,
)
salvaged = salvage_summary(stdout)
usage_section = _render_collapsible_usage(usage, model, config=config) if usage else ""
usage_section = _render_collapsible_usage(usage, display_model, config=config) if usage else ""
post_review(api, repo, index, token, format_review_body(
salvaged or "AI review produced no parseable output.",
model, sha, usage_section=usage_section))
display_model, sha, usage_section=usage_section,
static_message=(config or {}).get("static_message", "")))
return True
else:
user_prompt = build_user_prompt(title, body + compression_note, diff, config, prior, additional_context)
@@ -2157,7 +2239,7 @@ def review_pr(
# estimates. Only meaningful when we have measured usage.
if usage and usage.get("output"):
compute_attribution(findings, usage["output"])
usage_section = _render_collapsible_usage(usage, model, config=config) if usage else ""
usage_section = _render_collapsible_usage(usage, display_model, config=config) if usage else ""
# Anchor against the RAW diff, never the compressed one. Compression
# drops context lines, so a finding on a line that survived in the file
@@ -2184,7 +2266,7 @@ def review_pr(
# reach this call.
confidence = merge_confidence(findings, multi_lens_observed=multi_lens)
summary_body = format_review_body(
"\n\n".join(summary_parts), model, sha,
"\n\n".join(summary_parts), display_model, sha,
summary=review_summary,
usage_section=usage_section,
summary_changes=summary_changes,
@@ -2192,6 +2274,7 @@ def review_pr(
findings_for_table=findings,
inline_count=len(anchored),
confidence=confidence,
static_message=(config or {}).get("static_message", ""),
)
post_inline_review(api, repo, index, token, summary_body, anchored)
@@ -2203,7 +2286,7 @@ def review_pr(
return True
except Exception as e: # fail-open
try:
post_review(api, repo, index, token, format_review_body(f"⚠️ AI review failed: {e}", model, sha))
post_review(api, repo, index, token, format_review_body(f"⚠️ AI review failed: {e}", display_model, sha))
except Exception as e2:
print(f"pragent: could not post failure note: {e2}", file=sys.stderr)
print(f"pragent: review failed: {e}", file=sys.stderr)