Skip to content
Open
40 changes: 40 additions & 0 deletions docs/docs/tools/improve.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,14 @@ Note: Chunking is primarily relevant for large PRs. For most PRs (up to 600 line
<td><b>persistent_comment</b></td>
<td>If set to true, the improve comment will be persistent, meaning that every new improve request will edit the previous one. Default is true.</td>
</tr>
<tr>
<td><b>persistent_inline_comments</b></td>
<td>Controls how inline suggestions are deduplicated across re-runs on the same PR/MR. <code>"update"</code> (default) edits the matching existing inline comment in place; <code>"skip"</code> leaves the existing one untouched; <code>"off"</code> always posts a new inline comment (legacy behavior). The dedup key is the hash of the proposed edit (file + normalized <code>improved_code</code>), with a fallback to a prose-based hash only when no <code>improved_code</code> is available. Line numbers are intentionally excluded so dedup stays stable across upstream pushes that drift the target line. See <a href="#how-inline-comment-deduplication-works">How inline-comment deduplication works</a> below for details.</td>
</tr>
<tr>
<td><b>resolve_outdated_inline_comments</b></td>
<td>When dedup is enabled (<code>persistent_inline_comments != "off"</code>), automatically resolve inline-comment threads whose suggestion was not re-emitted on the latest run; the thread body gets a short auto-resolve note. Default is true. Has no effect when <code>persistent_inline_comments = "off"</code>. Reviewers can manually unresolve an auto-resolved thread to opt it out of future auto-resolution &mdash; the bot detects the prior resolution marker in the body and respects it.</td>
</tr>
<tr>
<td><b>suggestions_score_threshold</b></td>
<td> Any suggestion with importance score less than this threshold will be removed. Default is 0. Highly recommend not to set this value above 7-8, since above it may clip relevant suggestions that can be useful. </td>
Expand Down Expand Up @@ -344,3 +352,35 @@ Note: Chunking is primarily relevant for large PRs. For most PRs (up to 600 line
- **Hierarchy:** Presenting the suggestions in a structured hierarchical table enables the user to _quickly_ understand them, and to decide which ones are relevant and which are not.
- **Customization:** To guide the model to suggestions that are more relevant to the specific needs of your project, we recommend using the [`extra_instructions`](./improve.md#extra-instructions-and-best-practices) and [`best practices`](./improve.md#best-practices) fields.
- **Model Selection:** For specific programming languages or use cases, some models may perform better than others.

## How inline-comment deduplication works

When `persistent_inline_comments` is enabled (the default is `"update"`), re-running `/improve` on the same PR/MR will recognize and update — instead of duplicating — inline comments that were already posted for the same suggestion on a previous run. This uses a hidden marker (an HTML comment of the form `<!-- pr-agent-inline-id:XXXX -->`) embedded in each inline-comment body.

### Identity rule

Two inline comments are considered the same suggestion when the marker matches. The marker is a short hash computed as follows:

- **Structured (preferred):** When the suggestion has an `improved_code` field (i.e., the model proposed replacement code), the hash covers `(file, normalized improved_code)`. Wording changes in the suggestion's prose do **not** affect the key; `label` is **not** part of the key either — the edit itself is the identity.
- **Prose fallback:** When no `improved_code` is present, the hash falls back to `(file, label, normalized prose prefix)`.

Normalization of `improved_code` expands tabs, strips trailing whitespace, drops leading/trailing blank lines, removes the longest common leading indent, and collapses internal whitespace runs — so reindentation of the same proposed edit does not split comments.

### Strict behaviour

This is intentionally a strict rule: when a suggestion has `improved_code`, its prose is never consulted for dedup. Two suggestions at the same spot with identical prose but **different** proposed edits are treated as distinct and remain as two separate inline comments. We'd rather under-merge (and show two comments) than over-merge two genuinely different fixes into one.

### What's invariant across runs

- Prose paraphrase of the same finding (same proposed edit) — does **not** split.
- Reindentation or whitespace variation in the proposed edit — does **not** split.
- Upstream commits that push the target line up or down in the file — do **not** split. Line numbers are not part of the key.

### What still splits

- A genuinely different proposed edit at the same spot — by design.
- A different `label` when the prose fallback is in use (e.g., the same prose-only suggestion now tagged "best practice" vs "possible issue").

### Future work

A fuzzy near-miss signal (e.g., shingle/Jaccard similarity) may be added later if users report recurring duplicates that this deterministic scheme doesn't catch. For now the behaviour is strictly deterministic, with no similarity threshold to tune.
11 changes: 10 additions & 1 deletion pr_agent/algo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,16 @@

CLAUDE_EXTENDED_THINKING_MODELS = [
"anthropic/claude-3-7-sonnet-20250219",
"claude-3-7-sonnet-20250219"
"claude-3-7-sonnet-20250219",
"anthropic/claude-sonnet-4-6",
"claude-sonnet-4-6",
"vertex_ai/claude-sonnet-4-6",
"bedrock/anthropic.claude-sonnet-4-6",
"bedrock/us.anthropic.claude-sonnet-4-6",
"bedrock/au.anthropic.claude-sonnet-4-6",
"bedrock/eu.anthropic.claude-sonnet-4-6",
"bedrock/jp.anthropic.claude-sonnet-4-6",
"bedrock/global.anthropic.claude-sonnet-4-6",
]

# Models that require streaming mode
Expand Down
18 changes: 16 additions & 2 deletions pr_agent/algo/ai_handlers/litellm_ai_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,22 @@ def __init__(self):
# Models that support reasoning effort
self.support_reasoning_models = SUPPORT_REASONING_EFFORT_MODELS

# Models that support extended thinking
self.claude_extended_thinking_models = CLAUDE_EXTENDED_THINKING_MODELS
# Models that support extended thinking (config override replaces the built-in list when non-empty)
override = get_settings().config.get("claude_extended_thinking_models_override", []) or []
if override and not isinstance(override, list):
get_logger().warning(
"Invalid claude_extended_thinking_models_override in config; expected a list of model names. "
"Falling back to the built-in Claude extended-thinking model list."
)
override = []
elif override and not all(isinstance(model, str) and model.strip() for model in override):
get_logger().warning(
"Invalid claude_extended_thinking_models_override in config; "
"expected a list of model name strings. "
"Falling back to the built-in Claude extended-thinking model list."
)
override = []
self.claude_extended_thinking_models = list(override) if override else CLAUDE_EXTENDED_THINKING_MODELS
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.

# Models that require streaming
self.streaming_required_models = STREAMING_REQUIRED_MODELS
Expand Down
210 changes: 210 additions & 0 deletions pr_agent/algo/inline_comments_dedup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
"""
Stable-marker deduplication for inline PR comments.

When PR-Agent re-runs /improve or /add_docs on the same PR, each run would
otherwise post fresh inline comments for suggestions that were already posted.
This module generates a hidden, content-derived marker that providers embed
in inline comment bodies so that subsequent runs can recognize and update
(or skip) the prior comment instead of creating a duplicate.
"""

from __future__ import annotations

import hashlib
import re
import textwrap
from typing import Any, Optional

MARKER_PREFIX = "<!-- pr-agent-inline-id:"
MARKER_SUFFIX = " -->"

# Constants used by the resolve-outdated-inline-comments feature.
# RESOLVED_BODY_MARKER is appended (with RESOLVED_NOTE) to the body of an
# inline comment whose suggestion was not re-emitted on the current run.
# It also serves as an idempotency signal: if a user manually unresolves a
# thread we previously auto-resolved, the marker remains in the body and
# tells us not to re-resolve on subsequent runs.
RESOLVED_NOTE = "Resolved automatically: this suggestion was not re-emitted on the latest run."
RESOLVED_BODY_MARKER = "<!-- pr-agent-inline-resolved -->"

PERSISTENT_MODE_OFF = "off"
PERSISTENT_MODE_UPDATE = "update"
PERSISTENT_MODE_SKIP = "skip"
VALID_PERSISTENT_MODES = {PERSISTENT_MODE_OFF, PERSISTENT_MODE_UPDATE, PERSISTENT_MODE_SKIP}

_HASH_LEN = 12
_CONTENT_PREFIX_LEN = 128
_MARKER_RE = re.compile(
re.escape(MARKER_PREFIX) + r"([0-9a-f]{" + str(_HASH_LEN) + r"})" + re.escape(MARKER_SUFFIX)
)
_WHITESPACE_RE = re.compile(r"\s+")

_SEP = "\x00"
_HASH_VERSION_STRUCTURED = "v2s"
_HASH_VERSION_PROSE = "v2p"


def _pick_content(suggestion: dict) -> Optional[str]:
for key in ("suggestion_content", "suggestion_summary", "content"):
val = suggestion.get(key)
if val:
return str(val)
return None


def _normalize(text: str) -> str:
return _WHITESPACE_RE.sub(" ", text).strip()


def normalize_code(text: Optional[str]) -> str:
"""Normalize a proposed-edit code snippet for stable hashing.

Expands tabs, strips trailing whitespace per line, drops leading and
trailing fully-blank lines, and removes the longest common leading
whitespace across remaining lines (textwrap.dedent).
"""
if not text:
return ""
expanded = text.expandtabs()
lines = [line.rstrip() for line in expanded.split("\n")]
while lines and not lines[0]:
lines.pop(0)
while lines and not lines[-1]:
lines.pop()
if not lines:
return ""
return textwrap.dedent("\n".join(lines))


# Dedup identity is structured-first, prose-fallback:
# - If a suggestion has `improved_code`, the hash covers
# (version_tag + file + normalized improved_code). Prose wording never
# affects the key, and label is intentionally excluded — the edit
# itself is the identity.
# - Otherwise we fall back to (version_tag + file + label + prose prefix).
#
# This is a strict (a) design: prose is NEVER consulted when a structured
# edit exists. Two suggestions at the same spot with the same prose but
# different edits intentionally remain separate comments — we'd rather
# under-merge than over-merge genuinely distinct fixes.
#
# Line-range is deliberately NOT in the key so dedup stays stable across
# upstream pushes that drift the target line (a property the user-facing
# docs explicitly promise).
#
# The version tag (v2s / v2p) lives INSIDE the hashed signature, making
# the two namespaces preimage-distinct and preventing accidental cross-
# namespace collisions. The marker grammar is unchanged, so pre-existing
# v1 markers on live PRs self-heal via the outdated-pass auto-resolve
# (resolve_outdated_inline_comments) on the first re-run after deployment.
#
# A fuzzy near-miss signal (shingle / Jaccard) was considered and deferred;
# see docs/docs/tools/improve.md and the Serena memory
# `future_fuzzy_inline_dedup`.
def generate_marker(suggestion: dict) -> Optional[str]:
"""Return a stable marker for this suggestion, or None if required fields are missing."""
file = suggestion.get("relevant_file")
if not file:
return None
file = str(file).strip()
if not file:
return None

improved_code = suggestion.get("improved_code")
if isinstance(improved_code, str) and improved_code.strip():
sig = _SEP.join([_HASH_VERSION_STRUCTURED, file, normalize_code(improved_code)])
else:
label = suggestion.get("label")
content = _pick_content(suggestion)
if not label or not content:
return None
sig = _SEP.join([
_HASH_VERSION_PROSE,
file,
str(label).strip(),
_normalize(content)[:_CONTENT_PREFIX_LEN],
])

digest = hashlib.sha256(sig.encode("utf-8")).hexdigest()[:_HASH_LEN]
return f"{MARKER_PREFIX}{digest}{MARKER_SUFFIX}"


def extract_marker(body: str) -> Optional[str]:
"""Return the last marker hash found in `body`, or None."""
if not body:
return None
matches = _MARKER_RE.findall(body)
if not matches:
return None
return matches[-1]


def append_marker(body: str, marker: str) -> str:
"""Append `marker` to `body` if not already present; idempotent."""
if not marker:
return body
if marker in body:
return body
sep = "" if body.endswith("\n") else "\n\n"
return f"{body}{sep}{marker}"


def build_marker_index(comments: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
"""Index comments by marker hash, preserving all collisions under the same hash."""
index: dict[str, list[dict[str, Any]]] = {}
for c in comments or []:
body = c.get("body") or ""
h = extract_marker(body)
if h:
index.setdefault(h, []).append(c)
return index


def find_comment_by_location(
candidates: list[dict[str, Any]],
relevant_file: str,
relevant_lines_start: int,
relevant_lines_end: int,
) -> Optional[dict[str, Any]]:
"""Return the newest candidate whose stored inline coordinates match this suggestion."""
if not candidates:
return None
expected_path = (relevant_file or "").strip()
expected_line = relevant_lines_end if relevant_lines_end > relevant_lines_start else relevant_lines_start
expected_start = relevant_lines_start if relevant_lines_end > relevant_lines_start else None

for candidate in reversed(candidates):
candidate_path = str(candidate.get("path") or "").strip()
candidate_line = candidate.get("line")
candidate_start = candidate.get("start_line")
if candidate_path != expected_path:
continue
if candidate_line != expected_line:
continue
if candidate_start != expected_start:
continue
return candidate
return None


def format_resolved_body(original_body: str) -> str:
"""Append the auto-resolved note and idempotency marker to ``original_body``.

Shared by every provider's outdated pass so the on-screen format stays
identical and the body marker check (RESOLVED_BODY_MARKER in body) keeps
working across providers.
"""
return (
(original_body or "").rstrip()
+ f"\n\n---\n_{RESOLVED_NOTE}_\n{RESOLVED_BODY_MARKER}"
)


def normalize_persistent_mode(raw: Any) -> str:
"""Coerce config input to one of the valid modes. Unknown values fall back to 'off'."""
if raw is None:
return PERSISTENT_MODE_OFF
candidate = str(raw).strip().lower()
if candidate in VALID_PERSISTENT_MODES:
return candidate
return PERSISTENT_MODE_OFF
51 changes: 49 additions & 2 deletions pr_agent/git_providers/git_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,9 @@ def get_user_description(self) -> str:
start_position = description_lowercase.find(user_description_header) + len(user_description_header)
end_position = len(description)
for header in possible_headers: # try to clip at the next header
if header != user_description_header and header in description_lowercase:
end_position = min(end_position, description_lowercase.find(header))
next_header_position = description_lowercase.find(header, start_position)
if header != user_description_header and next_header_position != -1:
end_position = min(end_position, next_header_position)
if end_position != len(description) and end_position > start_position:
original_user_description = description[start_position:end_position].strip()
if original_user_description.endswith("___"):
Expand Down Expand Up @@ -338,6 +339,52 @@ def create_inline_comment(self, body: str, relevant_file: str, relevant_line_in_
def publish_inline_comments(self, comments: list[dict]):
pass

def get_bot_review_comments(self) -> list[dict]:
"""
Return the bot's existing inline (review) comments on the current PR.

Each dict must contain at least:
- 'id': provider-specific comment id (used by edit_review_comment)
- 'body': full comment body (used for marker extraction)

Default: return []. Providers that support inline-comment dedup should override.
"""
return []

def edit_review_comment(self, comment_id, body: str) -> bool:
"""
Edit an existing inline (review) comment in place.

Returns True on success, False otherwise. Default: return False (unsupported),
which causes persistent-inline-comment dedup to fall back to the create-new path.
"""
return False

def resolve_review_thread(self, comment: dict) -> bool:
"""
Mark the review thread containing `comment` as resolved.

`comment` is one of the dicts returned by get_bot_review_comments();
providers extract whichever id (thread_id, discussion_id, etc.) they need.

Returns True on success, False otherwise. Default: return False (unsupported),
which causes the resolve-outdated pass to skip this comment.

Providers wiring this in must also override get_bot_review_comments to
include is_resolved on each dict and wire the outdated pass into their
publish_code_suggestions; without all three the feature silently no-ops.
"""
return False

def unresolve_review_thread(self, comment: dict) -> bool:
"""
Mark the review thread containing `comment` as unresolved.

Used when a previously auto-resolved suggestion is re-emitted on a later run.
Returns True on success, False otherwise. Default: return False (unsupported).
"""
return False

@abstractmethod
def remove_initial_comment(self):
pass
Expand Down
Loading