Skip to content

Commit 80d8691

Browse files
committed
feat(agent): improve reviewer resilience, auth mapping, and shell reporting
1 parent 8250b07 commit 80d8691

42 files changed

Lines changed: 1492 additions & 127 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ subagents, skills, web/visualization UIs, and multi-provider LLM authentication.
3232
and agent spec semantics need tests/docs when changed.
3333
- **Do not modify git config, skip hooks, force-push, reset hard, or delete branches/worktrees**
3434
unless the user explicitly asks and confirms the destructive action.
35+
- **Always check the CodeRabbit review before merging a PR.** Before merging (`gh pr merge` or the
36+
GitHub UI), confirm CodeRabbit has finished reviewing the PR's head commit — its `CodeRabbit`
37+
commit status is `success`, not `pending`/`failure` or absent — and read the review summary and
38+
any "Actionable comments posted: N" findings. Do not merge while CodeRabbit is still reviewing or
39+
on an unreviewed commit; surface unresolved actionable findings instead of merging past them.
3540

3641
## Quick commands
3742

packages/pythinker-review/src/pythinker_review/reviewers/common.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,29 @@
1212
from pythinker_review.reviewers.schema import RawFinding, ReviewerOutput
1313
from pythinker_review.store.models import ChunkFailureReason
1414

15-
_RETRY_SUFFIX = (
16-
"\n\nIMPORTANT: Your previous response was not valid JSON for the given schema. "
17-
"Reply with strict JSON only, no prose, no markdown fences."
18-
)
15+
_RETRY_ERROR_BUDGET = 600
16+
17+
18+
def _retry_suffix(last_error: str) -> str:
19+
"""Build the retry instruction, surfacing the concrete validation error.
20+
21+
The first version only said "reply with valid JSON", which is useless when
22+
the failure is a *content* violation (e.g. a title over the length cap) on
23+
otherwise-valid JSON — the model has no signal about what to change. We now
24+
relay the actual parser/validation error so the model can self-correct.
25+
"""
26+
suffix = (
27+
"\n\nIMPORTANT: Your previous response could not be parsed into the required "
28+
"schema. Reply with strict JSON only — no prose, no markdown fences — and make "
29+
"every field satisfy the schema (in particular keep each finding 'title' to 80 "
30+
"characters or fewer)."
31+
)
32+
detail = " ".join(last_error.split())
33+
if detail:
34+
if len(detail) > _RETRY_ERROR_BUDGET:
35+
detail = f"{detail[:_RETRY_ERROR_BUDGET]} …"
36+
suffix += f"\n\nValidation error from your previous attempt: {detail}"
37+
return suffix
1938

2039

2140
@dataclass(frozen=True, slots=True)
@@ -89,7 +108,7 @@ async def complete_typed_json[T: BaseModel](
89108
return TypedReviewerResult(
90109
False, failure_reason="malformed_output", failure_message=last_error
91110
)
92-
prompt = prompt + _RETRY_SUFFIX
111+
prompt = user + _retry_suffix(last_error)
93112
return TypedReviewerResult(False, failure_reason="malformed_output")
94113

95114

packages/pythinker-review/src/pythinker_review/reviewers/prompts/code_review.system.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ Schema:
4949
"start_line": 1,
5050
"end_line": 1,
5151
"confidence": 0.0,
52-
"evidence_snippet": "<code excerpt visible in the diff/context>",
52+
"evidence_snippet": "<code copied VERBATIM from the diff/context — must match character-for-character; do not paraphrase, reformat, or add ellipses>",
5353
"confidence_reason": "<why this confidence is justified>",
5454
"test_analysis": "<optional coverage assessment for the changed behavior>",
5555
"suggested_regression_test": "<optional focused test to add>",

packages/pythinker-review/src/pythinker_review/reviewers/prompts/debug_review.system.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Schema:
2525
"start_line": 1,
2626
"end_line": 1,
2727
"confidence": 0.0,
28-
"evidence_snippet": "<optional excerpt>",
28+
"evidence_snippet": "<optional; if given, copy code VERBATIM from the diff/context — character-for-character, no paraphrase or ellipses>",
2929
"confidence_reason": "<optional why this is likely the root cause>",
3030
"reproduction": "<optional command/log evidence>",
3131
"test_analysis": "<optional failing/passing test interpretation>",

packages/pythinker-review/src/pythinker_review/reviewers/prompts/deslopify_review.system.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@ Rules:
1010
- Output strict JSON only.
1111

1212
Schema:
13-
{"findings":[{"rule_id":"deslopify.<dotted id>","title":"<≤80 chars>","rationale":"<markdown>","category":"readability|performance|test_coverage|api_design|correctness","severity":"medium|low|info","file":"<repo-relative POSIX path>","start_line":1,"end_line":1,"confidence":0.0,"evidence_snippet":"<optional code excerpt>","minimum_fix_scope":"<smallest deletion/consolidation/reuse scope>","test_analysis":"<why tests preserve or should cover this>","suggestion":{"summary":"<one sentence>","patch":"<optional unified diff>"}}]}
13+
{"findings":[{"rule_id":"deslopify.<dotted id>","title":"<≤80 chars>","rationale":"<markdown>","category":"readability|performance|test_coverage|api_design|correctness","severity":"medium|low|info","file":"<repo-relative POSIX path>","start_line":1,"end_line":1,"confidence":0.0,"evidence_snippet":"<optional; if given, copy code VERBATIM from the diff/context — character-for-character, no paraphrase or ellipses>","minimum_fix_scope":"<smallest deletion/consolidation/reuse scope>","test_analysis":"<why tests preserve or should cover this>","suggestion":{"summary":"<one sentence>","patch":"<optional unified diff>"}}]}
1414

1515
If you find no issues, return {"findings": []}. Output JSON only, no prose.

packages/pythinker-review/src/pythinker_review/reviewers/prompts/security_review.system.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Schema:
3232
"start_line": 1,
3333
"end_line": 1,
3434
"confidence": 0.0,
35-
"evidence_snippet": "<optional code excerpt>",
35+
"evidence_snippet": "<optional; if given, copy code VERBATIM from the diff/context — character-for-character, no paraphrase or ellipses>",
3636
"confidence_reason": "<optional validation reasoning>",
3737
"exploitability": "<optional attacker path and preconditions>",
3838
"minimum_fix_scope": "<optional smallest safe mitigation scope>",

packages/pythinker-review/src/pythinker_review/reviewers/schema.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,18 @@
44

55
from typing import Self
66

7-
from pydantic import BaseModel, ConfigDict, Field, model_validator
7+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
88

99
from pythinker_review.store.models import Category, Severity, Suggestion
1010

11+
_MAX_TITLE_LEN = 80
12+
1113

1214
class RawFinding(BaseModel):
1315
model_config = ConfigDict(extra="forbid")
1416

1517
rule_id: str
16-
title: str = Field(max_length=80)
18+
title: str
1719
rationale: str
1820
category: Category
1921
severity: Severity
@@ -30,6 +32,16 @@ class RawFinding(BaseModel):
3032
suggested_regression_test: str | None = None
3133
minimum_fix_scope: str | None = None
3234

35+
@field_validator("title", mode="before")
36+
@classmethod
37+
def _truncate_title(cls, value: object) -> object:
38+
# Models (especially smaller ones) routinely exceed the title budget.
39+
# Truncate rather than hard-fail: a length violation used to fail the
40+
# whole ReviewerOutput parse, discarding *every* finding in the chunk.
41+
if isinstance(value, str) and len(value) > _MAX_TITLE_LEN:
42+
return value[: _MAX_TITLE_LEN - 1].rstrip() + "…"
43+
return value
44+
3345
@model_validator(mode="after")
3446
def validate_range(self) -> Self:
3547
if self.end_line < self.start_line:

packages/pythinker-review/tests/unit/test_reviewers.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,48 @@ async def test_security_review_retries_once_on_malformed_then_succeeds() -> None
8787
assert len(llm.calls) == 2
8888

8989

90+
@pytest.mark.asyncio
91+
async def test_retry_prompt_surfaces_previous_validation_error() -> None:
92+
# The retry must relay the concrete parser error so the model can
93+
# self-correct, not just repeat a generic "reply with valid JSON".
94+
llm = FakeReviewLLM(scripted=["not valid json at all", '{"findings": []}'])
95+
result = await run_code_review_pass(chunk=_chunk(), llm=llm, timeout_s=10.0)
96+
assert result.ok
97+
assert len(llm.calls) == 2
98+
retry_prompt = llm.calls[1][1]
99+
assert "Validation error from your previous attempt" in retry_prompt
100+
assert retry_prompt != llm.calls[0][1]
101+
102+
103+
@pytest.mark.asyncio
104+
async def test_overlong_title_is_truncated_not_dropped() -> None:
105+
# A single finding with an over-long title used to fail the whole chunk.
106+
# It must now survive (truncated) rather than discard sibling findings.
107+
payload = json.dumps(
108+
{
109+
"findings": [
110+
{
111+
"rule_id": "review.x",
112+
"title": "T" * 200,
113+
"rationale": "...",
114+
"category": "correctness",
115+
"severity": "low",
116+
"file": "x.py",
117+
"start_line": 1,
118+
"end_line": 1,
119+
"confidence": 0.6,
120+
}
121+
]
122+
}
123+
)
124+
llm = FakeReviewLLM(scripted=[payload])
125+
result = await run_code_review_pass(chunk=_chunk(), llm=llm, timeout_s=10.0)
126+
assert result.ok
127+
assert len(result.findings) == 1
128+
assert len(result.findings[0].title) == 80
129+
assert len(llm.calls) == 1 # parsed on the first attempt, no retry needed
130+
131+
90132
@pytest.mark.asyncio
91133
async def test_reviewer_accepts_json_inside_markdown_fence() -> None:
92134
llm = FakeReviewLLM(scripted=['```json\n{"findings": []}\n```'])

packages/pythinker-review/tests/unit/test_schema.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,46 @@ def test_reviewer_output_parses_minimal_payload() -> None:
2828
assert out.findings[0].severity is Severity.medium
2929

3030

31+
def test_reviewer_output_truncates_overlong_title() -> None:
32+
# An over-long title must not fail the whole parse (which would drop every
33+
# finding in the chunk); it is truncated to the budget instead.
34+
out = ReviewerOutput.model_validate(
35+
{
36+
"findings": [
37+
{
38+
"rule_id": "r",
39+
"title": "T" * 200,
40+
"rationale": "...",
41+
"category": "correctness",
42+
"severity": "low",
43+
"file": "a.py",
44+
"start_line": 1,
45+
"end_line": 1,
46+
"confidence": 0.5,
47+
}
48+
]
49+
}
50+
)
51+
title = out.findings[0].title
52+
assert len(title) == 80
53+
assert title.endswith("…")
54+
55+
56+
def test_reviewer_output_keeps_short_title_unchanged() -> None:
57+
finding = RawFinding(
58+
rule_id="r",
59+
title="Short title",
60+
rationale="r",
61+
category=Category.correctness,
62+
severity=Severity.low,
63+
file="a",
64+
start_line=1,
65+
end_line=1,
66+
confidence=0.5,
67+
)
68+
assert finding.title == "Short title"
69+
70+
3171
def test_reviewer_output_rejects_lines_under_one() -> None:
3272
with pytest.raises(ValidationError):
3373
RawFinding(

src/pythinker_code/agents/default/code_reviewer.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ agent:
5151
Use to run a read-only diff-focused code review or code-reviewr-derived PR artifact workflow on the current branch.
5252
allowed_tools:
5353
- "pythinker_code.tools.shell:Shell"
54+
- "pythinker_code.tools.todo:SetTodoList"
5455
- "pythinker_code.tools.file:ReadFile"
5556
- "pythinker_code.tools.file:Grep"
5657
- "pythinker_code.tools.skill:ReadSkill"

0 commit comments

Comments
 (0)