feat(review): multi-lens orchestration — 5 parallel opencode subprocesses (security/docs/code-quality/tests/perf)

On by default, opt-out via "reviewers": []. 290 tests pass.

- pilot/opencode_review.py: ReviewerSpec dataclass, default_reviewers(),
  parse_reviewers_config(), parse_triage_config(), resolve_reviewers(),
  _normalize_lens_finding(), posthash() (matches feedback.py scheme),
  _agreement_hash() (severity-free for cross-lens promotion), _tone_strip(),
  synthesize() 7-stage (severity_floor → tone-strip → length cap → per-lens
  max → per-file cap → dedup by _posthash → cross-lens severity promote →
  per-PR cap), run_lenses() (ThreadPoolExecutor pool=4), triage(),
  _intersect_with_triage(), _filter_by_skip_if(), run_lenses_review().
  run() routes to fan-out when config.reviewers[] present or PRAGENT_REVIEWERS=1.
- pilot/ai_review.py: parse_repo_config learns reviewers[] and triage objects
  (id regex /^[a-z0-9][a-z0-9-]{0,31}$/, 8-entry cap, agent_file/model/
  severity_floor/max_findings/activation/skip_if_all_changed_paths/hotpath_globs).
  review_pr branches to opencode_review.run_lenses_review when configured.
  _render_collapsible_usage shows lenses: ... line when present.
- .opencode/agents/{docs,code-quality,triage}.md: 3 new lens subagents.
- .opencode/skills/lens-orchestration/SKILL.md: strict-JSON contract every
  lens subagent MUST honor.
- .opencode/agents/pragent.md: slim to coordinator; no more hardcoded
  @security/@tests/@perf delegation; loads lens-orchestration skill.
- .opencode/README.md: rewrite 'Add a review lens' recipe for multi-lens.
- pilot/README-webhook.md: new 'Multi-lens pipeline' section (diagram +
  default roster + config schema + env vars + cross-lens dedup contract).
- tests: 36 new tests (test_ai_review.py +12 reviewers/triage/usage,
  test_opencode_review.py +24 orchestration). posthash golden-vector matches
  feedback.py exactly across 5 severity × 2 line cases.
This commit is contained in:
pragent-bot
2026-08-20 22:16:21 +00:00
parent e8ebc54362
commit 78bcf6a9a0
11 changed files with 1847 additions and 51 deletions
+123 -8
View File
@@ -1129,9 +1129,17 @@ def _render_collapsible_usage(usage: dict | None, model: str, config: dict | Non
f"- **Est. cost on {eq_label}**: {eq_s}{eq_note}",
f"- **Actual**: {actual_s}{actual_note}",
f"- **Scope**: {scope}",
"",
"</details>",
]
# Multi-lens fan-out: surface the lens roster + summed steps so the user
# can see which lenses contributed (and that triage didn't drop them all).
lenses = usage.get("lenses")
if lenses:
ls = usage.get("lens_steps", usage.get("steps", 0))
lines.append(
f"- **Lenses**: {', '.join(f'`{x}`' for x in lenses)} "
f"({len(lenses)} parallel subprocesses, {ls} summed steps)"
)
lines += ["", "</details>"]
return "\n".join(lines)
@@ -1245,6 +1253,97 @@ def parse_repo_config(raw: str) -> dict:
# this just stops a 10k-entry file from making the config huge.
out["additional_context_urls"] = urls[:8]
# Multi-lens reviewers roster. Absent / empty list = the 5-lens default
# in pilot/opencode_review.py (security, docs, code-quality, tests, perf).
# This is the cheap trigger: once the config declares `reviewers[]`, the
# orchestrator spawns one opencode subprocess per lens in parallel. Set
# to `[]` to opt out (single-primary fallback). Capped at 8.
rev = _parse_reviewers_array(data.get("reviewers"))
if rev is not None:
out["reviewers"] = rev
# Triage (cheap pre-filter that picks a subset of lenses). Off by default
# to keep the parse deterministic; the orchestrator's own default is
# to enable it when `reviewers[]` is present.
tr = _parse_triage_object(data.get("triage"))
if tr is not None:
out["triage"] = tr
return out
def _parse_reviewers_array(raw) -> list[dict] | None:
"""Sanitize `.pr-review.json:reviewers[]` to a list of dicts.
Hard caps: 8 entries (default-reviewers.xml-bound), 200 chars per string
field. Untyped / non-list → None (caller keeps the default). Fields we
don't know about are dropped (no schema drift allowed).
"""
if not isinstance(raw, list):
return None
cap = 8
out: list[dict] = []
for entry in raw[:cap]:
if not isinstance(entry, dict):
continue
spec: dict = {}
rid = entry.get("id")
if isinstance(rid, str) and rid.strip():
cand = rid.strip()[:CONFIG_MAX_ITEM_CHARS]
# Same id shape required by opencode_review.parse_reviewers_config:
# kebab-case so it maps 1:1 to .opencode/agents/<id>.md
import re as _re
if _re.match(r"^[a-z0-9][a-z0-9-]{0,31}$", cand):
spec["id"] = cand
if not spec.get("id"):
continue
for sk in ("agent_file", "model"):
sv = entry.get(sk)
if isinstance(sv, str) and sv.strip():
spec[sk] = sv.strip()[:CONFIG_MAX_ITEM_CHARS]
sf = entry.get("severity_floor")
if isinstance(sf, str) and sf.strip().lower() in SEVERITY_VALUES:
spec["severity_floor"] = sf.strip().lower()
mf = entry.get("max_findings")
if isinstance(mf, int) and not isinstance(mf, bool) and 1 <= mf <= CONFIG_MAX_FINDINGS:
spec["max_findings"] = mf
act = entry.get("activation")
if isinstance(act, str) and act.strip().lower() in ("auto", "always", "off"):
spec["activation"] = act.strip().lower()
skip = entry.get("skip_if_all_changed_paths")
if isinstance(skip, str) and skip.strip():
spec["skip_if_all_changed_paths"] = skip.strip()[:CONFIG_MAX_ITEM_CHARS]
globs = entry.get("hotpath_globs")
if isinstance(globs, list):
cleaned = [g for g in globs if isinstance(g, str) and g.strip()]
if cleaned:
spec["hotpath_globs"] = [
g.strip()[:CONFIG_MAX_ITEM_CHARS]
for g in cleaned[:CONFIG_MAX_LIST_ITEMS]
]
out.append(spec)
return out
def _parse_triage_object(raw) -> dict | None:
"""Sanitize `.pr-review.json:triage` to a dict.
Returns `None` when absent. When the value is malformed (not an object),
returns `{"enabled": False}` so a typo disables triage rather than
silently making the orchestrator error.
"""
if raw is None:
return None
if not isinstance(raw, dict):
return {"enabled": False}
out: dict = {}
if isinstance(raw.get("enabled"), bool):
out["enabled"] = raw["enabled"]
if isinstance(raw.get("model"), str) and raw["model"].strip():
out["model"] = raw["model"].strip()[:CONFIG_MAX_ITEM_CHARS]
ml = raw.get("max_lenses")
if isinstance(ml, int) and not isinstance(ml, bool) and 1 <= ml <= 8:
out["max_lenses"] = ml
return out
@@ -1814,13 +1913,29 @@ def review_pr(
# `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}"
stdout, usage = opencode_review.run(
api=api, repo=repo, index=index, sha=sha, token=token,
title=title, body=body, diff=diff, config=config,
prior_reviews=prior, model=oc_model,
compression_note=compression_note,
additional_context=additional_context,
# 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
# single-primary path when neither is set.
use_lenses = bool((config or {}).get("reviewers")) or bool(
os.environ.get("PRAGENT_REVIEWERS")
)
if use_lenses and hasattr(opencode_review, "run_lenses_review"):
stdout, usage = opencode_review.run_lenses_review(
api=api, repo=repo, index=index, sha=sha, token=token,
title=title, body=body, diff=diff, config=config,
prior_reviews=prior, model=oc_model,
compression_note=compression_note,
additional_context=additional_context,
)
else:
stdout, usage = opencode_review.run(
api=api, repo=repo, index=index, sha=sha, token=token,
title=title, body=body, diff=diff, config=config,
prior_reviews=prior, model=oc_model,
compression_note=compression_note,
additional_context=additional_context,
)
review_summary, findings, summary_changes, risks = parse_review_output(stdout)
if not findings and not review_summary:
# The findings JSON was missing or malformed. Don't discard the