Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/docs/tools/review.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,18 @@ for the authoritative default values.
after chunking are still listed in the coverage footer. Every chunk is a separate model call,
so a chunked review costs roughly `max_number_of_calls` times a normal one.

If a chunk fails or returns malformed output, successful chunks are retained and fallback
models retry only the pending work. Pending chunks can be split for a smaller model, within
the call limit; a larger model can also include previously omitted files. Chunks that still
exceed the model's budget are not sent to it.

If every fallback is exhausted, successful chunks are published as a partial review with a
failed-chunk coverage warning, even when `publish_output_no_suggestions = false`. Incomplete
reviews cannot resolve absent persistent findings. If no chunk succeeds, the review fails.
With `config.propagate_tool_errors = true`, an exhausted fallback chain still signals failure
to the caller after publishing the partial review and removing the progress comment.
Optional run details list all models that contributed to the merged review.

Each chunk answers the same questions about a different part of the PR, so the answers are
merged field by field:

Expand Down
2 changes: 2 additions & 0 deletions pr_agent/algo/run_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ class RunDetails:
# took over. Stays None when no prediction succeeded, which the renderer reads as
# "nothing worth showing".
model_used: Optional[str] = None
# Retain every model contributing to a merged review; keep scalar output for other tools.
models_used: list[str] = field(default_factory=list)
# Sticky: once a fallback has won, a later success on the primary model must not
# clear this, or the comment would hide that a fallback ran at all.
fallback_used: bool = False
Expand Down
5 changes: 4 additions & 1 deletion pr_agent/algo/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1901,7 +1901,10 @@ def show_run_details(gfm_supported: bool) -> str:
return ""

title = "⚙️ Agent run details"
lines = [f"- Model: {details.model_used}{' (fallback)' if details.fallback_used else ''}"]
if len(details.models_used) > 1:
lines = [f"- Models: {', '.join(details.models_used)}{' (includes fallback)' if details.fallback_used else ''}"]
else:
lines = [f"- Model: {details.model_used}{' (fallback)' if details.fallback_used else ''}"]
if details.has_token_usage:
# A counter still at zero after a successful call means the provider never
# reported that component, so drop it instead of claiming it was zero.
Expand Down
189 changes: 154 additions & 35 deletions pr_agent/tools/pr_reviewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
)
from pr_agent.algo.output_models import PRReview
from pr_agent.algo.pr_processing import (
OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD,
PreparedPRDiff,
add_ai_metadata_to_diff_files,
get_pr_diff,
Expand All @@ -34,7 +35,7 @@
reconcile_review_findings,
)
from pr_agent.algo.review_merge import merge_review_chunks
from pr_agent.algo.run_details import get_run_details, init_run_details
from pr_agent.algo.run_details import get_run_details, init_run_details, record_model_used
from pr_agent.algo.skills_loader import get_skills_context
from pr_agent.algo.token_handler import TokenHandler
from pr_agent.algo.utils import (
Expand All @@ -43,6 +44,7 @@
PRReviewIdentity,
add_pr_review_identity,
convert_to_markdown_v2,
get_max_tokens,
get_pr_review_comment_identifiers,
github_action_output,
hidden_marker_forms,
Expand Down Expand Up @@ -261,7 +263,11 @@ def parse_incremental(self, args: List[str]):

async def run(self) -> None:
init_run_details()
for name in ("_chunked_patches_diff_list", "_chunked_remaining_files_list", "_chunked_results",
"_chunked_primary_model"):
self.__dict__.pop(name, None)
progress_response = None
partial_review_error = None
review_error = None
review_failed = False
persistent_write_failed = False
Expand Down Expand Up @@ -307,8 +313,14 @@ async def run(self) -> None:
if get_settings().config.publish_output and not get_settings().config.get('is_auto_command', False):
progress_response = self.git_provider.publish_comment("Preparing review...", is_temporary=True)

await retry_with_fallback_models(self._prepare_prediction, model_type=ModelType.REGULAR,
git_provider=self.git_provider)
try:
await retry_with_fallback_models(self._prepare_prediction, model_type=ModelType.REGULAR,
git_provider=self.git_provider)
except Exception as error:
if not self._merge_cached_review_chunks():
raise
partial_review_error = error
get_logger().warning("Fallback models exhausted; publishing successful review chunks")
if not self.prediction:
return None

Expand All @@ -325,6 +337,7 @@ async def run(self) -> None:
self._should_publish_review_no_suggestions(pr_review)
or state_changed
or state_blocked
or self.review_failed_chunk_count > 0
)
if not should_publish:
reason = "Review output is not published"
Expand Down Expand Up @@ -455,6 +468,9 @@ async def run(self) -> None:
self.git_provider.publish_comment(_review_failure_comment(review_error))
except Exception as e:
get_logger().exception(f"Failed to publish review failure result, error: {e}")
if (partial_review_error is not None and not review_failed
and get_settings().config.get("propagate_tool_errors", False)):
raise partial_review_error

def _review_finding_state_enabled(self) -> bool:
settings = get_settings()
Expand Down Expand Up @@ -795,8 +811,10 @@ async def _prepare_prediction(self, model: str) -> None:
self.patches_diff = output
self.remaining_files_list = []

# a non-empty remaining_files_list means the token budget truncated the diff
if self.remaining_files_list and chunking_enabled:
# Resume an incomplete chunk plan even when a fallback model can fit the full diff.
# Otherwise the single-call path would bypass cached successful chunks.
has_incomplete_chunk_plan = hasattr(self, "_chunked_patches_diff_list")
if chunking_enabled and (self.remaining_files_list or has_incomplete_chunk_plan):
Comment on lines +816 to +817

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.

Action required

1. Larger fallbacks skip reviewable files 🐞 Bug ≡ Correctness

_prepare_prediction enters the cached plan whenever _chunked_patches_diff_list exists, so
_prepare_chunked_prediction ignores a larger fallback's newly prepared full diff and retries only
indices from the primary plan. When the primary plan exhausted max_number_of_calls and left files
in _chunked_remaining_files_list, _merge_cached_review_chunks restores that old omission list
even though the fallback could have reviewed those files.
Agent Prompt
## Issue description
A larger-context fallback can fit files omitted by the primary chunk plan, but the cached-plan path ignores the freshly prepared fallback diff and permanently preserves the primary omission list.

## Fix Focus Areas
- pr_agent/tools/pr_reviewer.py[786-889]
- pr_agent/algo/pr_processing.py[215-282]
- tests/unittest/test_review_large_diff_chunking.py[341-357]

## Recommended Fix
Retain successful cached chunk results, but when the current fallback can cover files in the cached remaining-files list, prepare additional work containing only those omitted files. Append that work to the pending chunk plan, update the cached remaining-files list from the fallback result, and test that already successful chunks are not requested again while newly coverable files are reviewed.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

prepared_diff = output if isinstance(output, PreparedPRDiff) else None
if await self._prepare_chunked_prediction(model, prepared_diff):
return
Expand All @@ -816,56 +834,153 @@ async def _prepare_chunked_prediction(self, model: str,

Returns False when chunking does not apply, leaving the single-call flow in place.
"""
multi_diff_kwargs = {
"max_calls": get_settings().pr_reviewer.get("max_number_of_calls", 3),
"add_line_numbers": True,
"return_remaining_files": True,
}
output_token_reserve = getattr(
getattr(self, "ai_handler", None), "get_output_token_reserve", None
)
if callable(output_token_reserve):
multi_diff_kwargs["output_token_reserve"] = output_token_reserve
if prepared_diff is not None:
multi_diff_kwargs["prepared_diff"] = prepared_diff
patches_diff_list, remaining_files_list = get_pr_multi_diffs(
self.git_provider,
self.token_handler,
model,
**multi_diff_kwargs)
patches_diff_list = getattr(self, "_chunked_patches_diff_list", None)
if patches_diff_list is not None:
self._resize_pending_review_chunks(model)
patches_diff_list = self._chunked_patches_diff_list
if patches_diff_list is not None and self._chunked_remaining_files_list:
self._include_newly_reviewable_files(model)
if patches_diff_list is None:
Comment on lines +837 to +843

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.

Action required

1. Smaller fallback models reject chunks 🐞 Bug ☼ Reliability

_prepare_chunked_prediction reuses _chunked_patches_diff_list without checking whether its
chunks fit the current fallback model's tokenizer and context limit. When the primary model creates
a chunk larger than a fallback accepts, the pending request exceeds that fallback's budget and can
exhaust the fallback chain even though regenerating its chunks would allow the review to complete.
Agent Prompt
## Issue description
Cached chunks are generated using the primary model's token handler and context limit, then reused for fallback models without compatibility validation. A smaller fallback can therefore receive an oversized pending chunk and fail unnecessarily.

## Fix Focus Areas
- pr_agent/tools/pr_reviewer.py[810-856]
- pr_agent/algo/pr_processing.py[215-282]
- tests/unittest/test_review_large_diff_chunking.py[191-262]

## Recommended Fix
Associate the cached chunk plan with the model and token-budget assumptions used to create it. Before reusing it for a fallback, verify every pending chunk against the fallback model's token handler and maximum-token limit; regenerate or split incompatible pending chunks while preserving successful results and their merge order. Add a regression test where the fallback has a smaller context limit than the primary model.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

multi_diff_kwargs = {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
"max_calls": get_settings().pr_reviewer.get("max_number_of_calls", 3),
"add_line_numbers": True,
"return_remaining_files": True,
}
output_token_reserve = getattr(
getattr(self, "ai_handler", None), "get_output_token_reserve", None
)
if callable(output_token_reserve):
multi_diff_kwargs["output_token_reserve"] = output_token_reserve
if prepared_diff is not None:
multi_diff_kwargs["prepared_diff"] = prepared_diff
patches_diff_list, remaining_files_list = get_pr_multi_diffs(
self.git_provider,
self.token_handler,
model,
**multi_diff_kwargs)
self._chunked_patches_diff_list = patches_diff_list
self._chunked_remaining_files_list = remaining_files_list
self._chunked_primary_model = model
if len(patches_diff_list) < 2:
get_logger().info("Large-diff chunking produced a single chunk, reviewing the PR in one call")
return False

get_logger().info(f"Number of PR chunk calls: {len(patches_diff_list)}")
get_logger().debug("PR diff chunks", artifact=patches_diff_list)
chunk_results = getattr(self, "_chunked_results", {})
pending_indices = [index for index in range(len(patches_diff_list)) if index not in chunk_results]
predictions = await asyncio.gather(
*[self._get_prediction(model, patches_diff) for patches_diff in patches_diff_list],
*[self._get_prediction(model, patches_diff_list[index]) for index in pending_indices],
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
return_exceptions=True)

raw_predictions, chunk_outputs, chunk_errors = [], [], []
for chunk_index, prediction in enumerate(predictions):
chunk_errors = []
for chunk_index, prediction in zip(pending_indices, predictions, strict=True):
if isinstance(prediction, Exception):
chunk_errors.append(prediction)
get_logger().warning(f"Failed to review chunk {chunk_index + 1}; retaining successful chunks",
artifact={"error": prediction})
continue
if isinstance(prediction, BaseException):
raise prediction
data = self._load_valid_review_yaml(prediction, source=f"review chunk {chunk_index + 1}")
try:
data = self._load_valid_review_yaml(prediction, source=f"review chunk {chunk_index + 1}")
except Exception as error:
chunk_errors.append(error)
get_logger().warning(f"Failed to parse review chunk {chunk_index + 1}; retrying it with fallback",
artifact={"error": error})
continue
self._validate_review_schema(data)
raw_predictions.append(prediction)
chunk_outputs.append(data)

if not chunk_outputs:
raise chunk_errors[0]
chunk_results[chunk_index] = (prediction, data, model)
Comment on lines +885 to +893

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Schema failures stayed warn-only in #3372 and still are in _prepare_pr_review; raising here drops a chunk whose YAML reads no as a boolean. The parse stays the retry trigger.

Suggested change
try:
data = self._load_valid_review_yaml(prediction, source=f"review chunk {chunk_index + 1}")
if not self._validate_review_schema(data):
raise ValueError(f"review chunk {chunk_index + 1} failed schema validation")
except Exception as error:
chunk_errors.append(error)
get_logger().warning(f"Failed to parse review chunk {chunk_index + 1}; retrying it with fallback",
artifact={"error": error})
continue
chunk_results[chunk_index] = (prediction, data, model)
try:
data = self._load_valid_review_yaml(prediction, source=f"review chunk {chunk_index + 1}")
except Exception as error:
chunk_errors.append(error)
get_logger().warning(f"Failed to parse review chunk {chunk_index + 1}; retrying it with fallback",
artifact={"error": error})
continue
self._validate_review_schema(data)
chunk_results[chunk_index] = (prediction, data, model)

self._chunked_results = chunk_results

if len(chunk_results) < len(patches_diff_list):
if chunk_errors:
raise chunk_errors[0]
raise ValueError("No valid review output was produced for one or more chunks")
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.

return self._merge_cached_review_chunks()

def _resize_pending_review_chunks(self, model: str) -> None:
"""Split oversized pending chunks at file boundaries while preserving result order."""
chunks = self._chunked_patches_diff_list
results = getattr(self, "_chunked_results", {})
budget = get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD - self.token_handler.prompt_tokens

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.

Action required

1. Reserved output makes retries overflow 🐞 Bug ☼ Reliability

_resize_pending_review_chunks, _include_newly_reviewable_files, and _get_prediction calculate
input capacity by subtracting the fixed 1,500-token OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD instead of
the active AI handler’s resolved output reserve. When configured completion limits, Claude
extended-thinking allowances, or OpenRouter reasoning reserves exceed that threshold, cached
fallback chunks pass resizing and preflight checks without enough context headroom, causing rejected
fallback requests or avoidable partial reviews even though reserve-aware packing would split them
further.
Agent Prompt
## Issue description
Cached fallback retries calculate available input capacity using a fixed 1,500-token output allowance, while normal multi-diff preparation uses the AI handler’s model-specific resolved output reserve. This can admit chunks that do not fit alongside the configured completion limit, Claude extended-thinking allowance, or OpenRouter reasoning reserve.

## Fix Focus Areas
- pr_agent/tools/pr_reviewer.py[904-930]
- pr_agent/tools/pr_reviewer.py[938-961]
- pr_agent/tools/pr_reviewer.py[987-1002]
- pr_agent/algo/token_budget.py[17-91]

## Recommended Fix
Create or reuse one shared `AttemptTokenBudget` for the current model via `AttemptTokenBudget.for_attempt`, passing `ai_handler.get_output_token_reserve` when available and preserving the existing minimum reserve. Use that budget’s available-token calculation and model-bound tokenizer consistently for cached-chunk resizing, omitted-file inclusion, and the final pre-send chunk guard so these paths apply the same reserve policy as `get_pr_multi_diffs()`. Add coverage for resolved output reserves greater than 1,500 tokens.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

max_calls = get_settings().pr_reviewer.get("max_number_of_calls", 3)
resized, retained = [], {}
for index, chunk in enumerate(chunks):
if index in results:
retained[len(resized)] = results[index]
resized.append(chunk)
continue
if self.token_handler.count_tokens(chunk) <= budget:
resized.append(chunk)
continue
sections = re.split(r"(?=^## File: ')", chunk, flags=re.MULTILINE)
parts, current = [], ""
for section in sections:
if current and self.token_handler.count_tokens(current + section) > budget:
parts.append(current)
current = ""
current += section
if current:
parts.append(current)
reserved = len(chunks) - index - 1
if (parts and len(resized) + len(parts) + reserved <= max_calls
and all(self.token_handler.count_tokens(part) <= budget for part in parts)):
resized.extend(parts)
else:
# Retain unsplittable work for a later model and report it as failed if none can fit it.
resized.append(chunk)
self._chunked_patches_diff_list = resized
self._chunked_results = retained

def _include_newly_reviewable_files(self, model: str) -> None:
"""Add newly fitting files to pending work without resending successful chunks."""
chunks = self._chunked_patches_diff_list
results = getattr(self, "_chunked_results", {})
remaining = self._chunked_remaining_files_list
pending = [index for index in range(len(chunks)) if index not in results]
budget = get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD - self.token_handler.prompt_tokens
max_calls = get_settings().pr_reviewer.get("max_number_of_calls", 3)
included = set()
for section in re.split(r"(?=^## File: ')", self.patches_diff or "", flags=re.MULTILINE):
match = re.match(r"## File: '(.*)'\n", section)
if not match or match[1] not in remaining or match[1] in included:
continue
for index in pending:
combined = chunks[index] + "\n\n" + section
if self.token_handler.count_tokens(combined) <= budget:
chunks[index] = combined
included.add(match[1])
break
else:
if len(chunks) < max_calls and self.token_handler.count_tokens(section) <= budget:
pending.append(len(chunks))
chunks.append(section)
included.add(match[1])
self._chunked_remaining_files_list = [name for name in remaining if name not in included]

def _merge_cached_review_chunks(self) -> bool:
"""Merge successful chunks in order, retaining incomplete coverage after exhausted retries."""
chunk_results = getattr(self, "_chunked_results", {})
if not chunk_results:
return False

# the raw text is kept for logging only; the merged verdict is in self.prediction_data
# Keep raw text for logging only; use the merged verdict from self.prediction_data.
indices = sorted(chunk_results)
raw_predictions = [chunk_results[index][0] for index in indices]
chunk_outputs = [chunk_results[index][1] for index in indices]
self.prediction = "\n".join(raw_predictions)
self.prediction_data = merge_review_chunks(chunk_outputs)
self.review_chunk_count = len(patches_diff_list)
self.review_failed_chunk_count = len(patches_diff_list) - len(chunk_outputs)
self.remaining_files_list = remaining_files_list
self.review_chunk_count = len(self._chunked_patches_diff_list)
self.review_failed_chunk_count = self.review_chunk_count - len(chunk_results)
self.remaining_files_list = self._chunked_remaining_files_list
models = list(dict.fromkeys(chunk_results[index][2] for index in indices))
details = get_run_details()
if details is not None:
details.models_used = models
for model in models:
record_model_used(model, is_fallback=model != self._chunked_primary_model)
return True

async def _get_prediction(self, model: str, patches_diff: Optional[str] = None) -> str:
Expand All @@ -880,6 +995,10 @@ async def _get_prediction(self, model: str, patches_diff: Optional[str] = None)
Returns:
A string representing the AI prediction for the pull request review.
"""
if patches_diff is not None:
budget = get_max_tokens(model) - OUTPUT_BUFFER_TOKENS_SOFT_THRESHOLD - self.token_handler.prompt_tokens
if self.token_handler.count_tokens(patches_diff) > budget:
raise ValueError("Review chunk exceeds the current model token budget")
variables = copy.deepcopy(self.vars)
variables["diff"] = self.patches_diff if patches_diff is None else patches_diff # update diff

Expand Down
Loading
Loading