diff --git a/README.md b/README.md index 2caa290..6dded1b 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,11 @@ overridden deployment-wide with `PRAGENT_MAX_REVIEW_STEPS`, `PRAGENT_REVIEW_TIMEOUT`, or per repository in the trusted base-branch `.pr-review.json`; repository values win. +For repositories with broad diffs, the pilot automatically raises headroom to +40/400K, 60/800K, or 80/1.2M steps/tokens as changed lines cross 200, 800, or +2,000. Explicit repository budgets always take precedence, and the global +hard ceilings remain in force. + Two measured reviews of a ~1100-line PR in this repo: 28 and 31 agent steps, ~2.1M input tokens each, **zero cache reads or writes**. The demo repo's PR, same tier: 126K tokens. diff --git a/pilot/README-webhook.md b/pilot/README-webhook.md index 2e946af..9749fe1 100644 --- a/pilot/README-webhook.md +++ b/pilot/README-webhook.md @@ -306,6 +306,11 @@ limit, preserves any output already emitted, and records the cap reason in the review body and Langfuse metadata. Environment variables provide deployment-wide defaults; repository budget values override them. +Without an explicit repository budget, changed diffs receive adaptive headroom: +the default 20-step/120K-token budget grows to 40/400K, 60/800K, or 80/1.2M for +diffs over 200, 800, or 2,000 changed lines. This keeps focused PRs inexpensive +while allowing broad TypeScript/Go reviews to finish. Hard ceilings still apply. + **Cross-lens dedup:** synthesiser drops duplicates by `sha256[:16](path|line|severity|problem[:80])` (matches the feedback DB's `posthash`), then promotes multi-lens agreement by one severity step diff --git a/pilot/review/budget.py b/pilot/review/budget.py index 709e93e..59b1f82 100644 --- a/pilot/review/budget.py +++ b/pilot/review/budget.py @@ -15,6 +15,13 @@ DEFAULTS = { "max_duration_seconds": 480, } +PROFILES = ( + # (changed lines threshold, steps, total tokens, output tokens, seconds) + (2_000, 80, 1_200_000, 80_000, 1_800), + (800, 60, 800_000, 60_000, 1_200), + (200, 40, 400_000, 40_000, 900), +) + @dataclass(frozen=True) class Budget: @@ -63,6 +70,34 @@ class Budget: price_target=price_target, ) + @classmethod + def for_review(cls, config: dict | None, diff: str) -> "Budget": + """Choose safe headroom from diff size, unless config is explicit.""" + if isinstance((config or {}).get("budget"), dict): + return cls.from_config(config) + changed_lines = _changed_line_count(diff) + for threshold, steps, tokens, output, seconds in PROFILES: + if changed_lines >= threshold: + return cls.from_config({ + **(config or {}), + "budget": { + "max_steps": steps, + "max_total_tokens": tokens, + "max_output_tokens": output, + "max_duration_seconds": seconds, + }, + }) + return cls.from_config(config) + + +def _changed_line_count(diff: str) -> int: + """Count changed lines without treating hunk headers as additions.""" + return sum( + 1 for line in diff.splitlines() + if (line.startswith("+") and not line.startswith("+++")) + or (line.startswith("-") and not line.startswith("---")) + ) + def _env_int(name: str, default: int) -> int: try: diff --git a/pilot/review/opencode.py b/pilot/review/opencode.py index 7c54dce..ce6f5cf 100644 --- a/pilot/review/opencode.py +++ b/pilot/review/opencode.py @@ -440,7 +440,7 @@ def run_lenses_review( always has: prose + a final ```json fence with the legacy schema). """ os.makedirs(WORK_ROOT, exist_ok=True) - budget = budget or Budget.from_config(config) + budget = budget or Budget.for_review(config, diff) budget_state = budget_state or BudgetState(budget) workdir = tempfile.mkdtemp(prefix=f"{repo.replace('/', '_')}-{sha[:8]}-", dir=WORK_ROOT) keep = bool(os.environ.get("PRAGENT_KEEP_WORK")) @@ -687,7 +687,7 @@ def run( additional_context=additional_context, ) - budget = Budget.from_config(config) + budget = Budget.for_review(config, diff) budget_state = BudgetState(budget) os.makedirs(WORK_ROOT, exist_ok=True) workdir = tempfile.mkdtemp(prefix=f"{repo.replace('/', '_')}-{sha[:8]}-", dir=WORK_ROOT) diff --git a/tests/pilot/review_tests/budget_test.py b/tests/pilot/review_tests/budget_test.py index 58bb5d1..bb5e6da 100644 --- a/tests/pilot/review_tests/budget_test.py +++ b/tests/pilot/review_tests/budget_test.py @@ -18,6 +18,19 @@ def test_budget_reads_config_over_environment(monkeypatch): assert budget.max_steps == 7 +def test_budget_scales_for_broad_diff(): + diff = "".join("+changed\n" for _ in range(850)) + budget = Budget.for_review({}, diff) + assert budget.max_steps == 60 + assert budget.max_total_tokens == 800_000 + + +def test_explicit_budget_wins_over_diff_profile(): + diff = "".join("+changed\n" for _ in range(2_100)) + budget = Budget.for_review({"budget": {"max_steps": 9}}, diff) + assert budget.max_steps == 9 + + def test_budget_state_stops_at_token_limit(): state = BudgetState(Budget(max_steps=20, max_total_tokens=100)) assert state.record({"steps": 1, "total": 60, "output": 10}) == ""