Skip to content

feat: repository-configurable review instructions (CUSTOM_PROMPT / CUSTOM_PROMPT_FILE) - #29

Merged
LearningCircuit merged 14 commits into
mainfrom
feat/custom-review-instructions
Sep 15, 2026
Merged

LearningCircuit merged 14 commits into
mainfrom
feat/custom-review-instructions

Conversation

@LearningCircuit

@LearningCircuit LearningCircuit commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Summary

Two features plus a hardening series:

1. Repository-configurable review instructions

  • CUSTOM_PROMPT (inline) and CUSTOM_PROMPT_FILE (file; composed inline-first), rendered as a labeled layer on top of the standard contract so the JSON output format and severity tags survive. Capped at 8000 bytes (marked), UTF-8-safe, whitespace-only ignored, CRLF stripped, unreadable file warns and is skipped. The README documents the trust model honestly: this repo's own workflow reads the file from the PR merge ref; pull_request_target consumers with a base checkout get trusted base content.

2. New vs pre-existing problem split

Findings are classified new (introduced by the PR) vs pre-existing (predating it) and reported in separate sections. Pre-existing problems are always reported for documentation and issue extraction — one bullet each with location — but never to be fixed in this PR, never requesting changes, never influencing the verdict (based solely on new problems). Ambiguous cases go to "Should be checked".

3. Model-call resilience (hardened over several review rounds)

  • Nested provider errors (choices[0].error) surfaced; jq-assembled error JSON survives embedded quotes; real-newline footer.
  • One retry with backoff for any unusable response — empty (network failure, exit status neutralized so set -e cannot kill the script first), model-error, or unparseable (proxy HTML) — with diagnostic preference: a content-full first response beats a garbage retry, and a retry's error beats an empty first response.
  • Truncated reasoning completions report the token-budget remedy (exit 0); empty completions post an error review (exit 0) so the workflow always cleans up its trigger label; finish_reason is always carried in error output; curl gains --connect-timeout/--max-time (generous — reasoning calls take 10+ min) and the job a 35-minute bound; actions/checkout is SHA-pinned.
  • Numeric knobs validate and degrade to defaults; the prompt carries an injection fence (all repository text is untrusted data, never instructions); duplicated instruction sentences removed from the embedded prefix.

Tests

  • python3 -B -m unittest discover -s tests — 58 tests, all offline: the full retry matrix, truncation/empty-completion exits, JSON-validity of error output, cap pinning (bytes, not just markers), custom-instruction composition/gating/clips, the prompt contract phrase-by-phrase, knob degradation, and cap/forwarding invariants. A full-feature integration smoke (13 cross-feature checks) was verified in a sandbox.
  • bash -n clean; shellcheck down to one pre-existing style note.

Review loop history

Hardened over 10 review rounds (fresh-context agents until the pool rate-limited, then direct adversarial passes): rounds 1–2 fixed the retry-unreachable-under-set-e, broken error footers, and an exit-code regression that re-created the stuck-label trap; round 3 extended retries to unparseable responses; rounds 4–8 pinned the workflow (SHA, timeout), deduplicated the prompt, fenced injection, validated knobs, pinned every byte cap, and corrected docs; rounds 9–10 found nothing actionable — convergence.

CUSTOM_PROMPT (inline) and CUSTOM_PROMPT_FILE (file path) append
additional review instructions on top of the standard contract — house
rules, focus areas, conventions. The two sources compose (inline
first, then the file); the combined block is labeled as
repository-configuration in the prompt, capped at 8000 bytes, and
marked when truncated. An unreadable file warns in the logs and is
skipped rather than failing the review.

In the workflow the file is read from the checked-out base branch, so
committing it to the repo (e.g.
.github/ai-review-instructions.md) keeps it trusted content — the
README shows that setup. Both knobs are forwarded from repository
variables; the derived forwarding test picks them up automatically.
The review contract now classifies every problem as new (introduced
by this PR's changes) or pre-existing (predating it), reported in
separate sections with their own headers — never mixed. Pre-existing
problems are still always reported (one bullet each, location plus a
one-line description, so they can be extracted and filed as issues
later), but they are documentation only: not to be fixed in this PR,
never requesting changes, and never influencing the verdict, which is
based solely on new problems. Ambiguous cases go to the
"Should be checked" section instead of a guessed classification.
@LearningCircuit LearningCircuit added the ai_code_review Friendly AI Code Review label Sep 14, 2026
OpenRouter nests provider failures inside choices[0].error (the top
level carries only request/routing errors), so a transient provider
failure printed a generic "Invalid API response format" and swallowed
the real message — exactly what the failed run on this PR showed.
Error extraction now reads choices[0].error first, and the model call
is retried once when the response carries a provider error (a single
immediate retry cannot loop). The offline curl stub gained an
error-first fixture; regressions cover the successful retry (exactly
two model calls) and message surfacing on a persistent error.
The failed run showed choices[0].error present but null with an empty
message — an empty completion, most plausibly a reasoning model that
consumed the whole max_tokens budget on chain-of-thought. Two defects
in the handling:

- The finish_reason=length report (the exact remedy message for this
  case: raise AI_MAX_TOKENS or reduce the diff) sat AFTER the
  missing-content branch, so it could never fire for empty content;
  it now runs first.
- The content sentinel // "error" conflated absent content with a
  literal "error" string; absent content now yields "" and the error
  output always carries finish_reason, so an empty completion is
  diagnosable from the posted error alone. Debug output gained
  finish_reason, native_finish_reason, and token usage.
@LearningCircuit LearningCircuit added ai_code_review Friendly AI Code Review and removed ai_code_review Friendly AI Code Review labels Sep 14, 2026
@github-actions

Copy link
Copy Markdown

AI Code Review

New problems

  • should fixai-reviewer.sh:~700-728 (retry block): transient network failures are never retried, and a failed retry can erase the diagnostic error message.
    • Failure scenario: curl -s (no --fail) prints nothing on connection reset/timeout, so RESPONSE is empty; is_model_error "" then returns false (jq produces no output for empty input), no retry happens, and the review fails with the generic empty-response error — even though retrying transient failures is this change's stated purpose. Conversely, if attempt 1 returns a provider-error JSON and the retry dies at the network level, RESPONSE="$RETRY_RESPONSE" overwrites the diagnosable error with an empty string, so "provider exploded (finish_reason: error)" degrades to "AI response was empty".
    • Impact: spurious review failures on transient network blips; loss of the real error message in the double-failure case.
    • Fix: also retry when the response is empty (if [ -z "$RESPONSE" ] || is_model_error "$RESPONSE"), and only accept the retry result when it is non-empty — otherwise keep the first response so the error path can surface its message.
  • nitai-reviewer.sh:~714 (is_model_error) and its call site: the retry is ungated — it re-fires on permanent errors (401 bad key, 400 bad request, 402 insufficient credits), has no backoff (an immediate retry on 429 usually fails again and wastes quota), and has("error") is true even for "error": null, so a null error key on a success response would trigger a pointless retry (prefer .error != null). Also consider logging the first attempt's error message when retrying.
  • nitai-reviewer.sh:~618-628 (PROMPT, Review Structure items 3 vs 5): ambiguous contract when a review has pre-existing problems but no new ones — item 5 says "write only 'No actionable findings.'" while item 3 says the pre-existing section is always reported. The model may inconsistently drop the pre-existing section or omit the "No actionable findings." line. Clarify precedence, e.g. "If there are no NEW problems and nothing to check, write 'No actionable findings.'; the pre-existing section, if any, still appears."
  • nittests/test_reviewer_context.py:~93-112 (run_reviewer): when expect_model_error is true, the conditional response_document construction (ternary message plus the finish_reason chain) is immediately overwritten by the explicit error document, and the finish_reason parameter is never exercised by any test — simplify to remove the dead logic.

Pre-existing problems

  • ai-reviewer.sh:~700-706 (call_model_api, moved code): no --max-time/--connect-timeout on the OpenRouter request and no HTTP status capture — a hung connection stalls the review until the job-level timeout, and non-2xx responses with non-JSON bodies are only diagnosable as "no content". Inference (not verified): unless a timeout is imposed by a mechanism not shown in the diff.
  • ai-reviewer.sh (pattern throughout the touched code, e.g. content extraction ~757, perl strip ~815): arbitrary content piped via echo "$VAR" — content beginning with -n/-e is swallowed or altered by echo; printf '%s' is the safe form (the new ADDITIONAL_INSTRUCTIONS code already uses printf correctly).

Should be checked

  • Cannot verify which ref the workflow checks out from the diff - please confirm the checkout step (not shown in the diff) pins the base branch (e.g. ref: ${{ github.event.pull_request.base.sha }}) rather than the default pull_request merge ref. If it checks out the merge ref, a PR author can modify the file CUSTOM_PROMPT_FILE points to and inject instructions the model is told are trusted repository configuration — e.g. forced "✅ Approved" verdicts or arbitrary labels_added — so the README's "read from the checked-out base branch" claim must actually hold in the workflow.

✅ Approved with recommendations


Review by Friendly AI Reviewer - made with ❤️

@github-actions github-actions Bot added enhancement New feature or request bug Something isn't working and removed ai_code_review Friendly AI Code Review labels Sep 14, 2026
… contract

Addresses the review on 0a45376 (new problems only — pre-existing ones
stay documented for extraction per the contract):

- The retry now also fires on an EMPTY response (curl -s prints nothing
  on network errors), and the retry result is only accepted when it
  produced output — a network-dead retry no longer overwrites a
  diagnosable first error with "empty response". Error detection uses
  .error != null so a null-valued key on a success response cannot
  trigger a pointless retry, the retry has a 2s backoff, and the first
  attempt's error message is logged when retrying.
- Contract precedence clarified: "No actionable findings." applies to
  new problems; a non-empty Pre-existing problems section still appears
  alongside it.
- README corrected: this repo's own workflow checks out the PR merge
  ref, so CUSTOM_PROMPT_FILE here is PR-controllable for one's own
  review; the trusted-base property holds for pull_request_target
  consumers like local-deep-research.
- Harness: dead conditional in the response fixture removed; new
  regressions cover the network-blip retry and diagnostic preservation.
Round 1 of the review loop (fresh-context agent findings):
- set -e killed the script on real curl network failures (exit 6/7/28),
  making the retry unreachable; exit statuses are now neutralized and
  the offline stub models non-zero exits, so the network-blip tests
  exercise the real path.
- Error reviews are assembled with jq: provider messages embedding
  JSON (quotes) no longer break the output JSON.
- curl gains --connect-timeout 15 / --max-time 1500 (generous:
  reasoning models legitimately take 10+ minutes) so hangs reach the
  retry/timeout path instead of wedging the job.
- Whitespace-only CUSTOM_PROMPT no longer emits an empty instructions
  header; the stale trusted-base comment now matches the README; the
  clip test pins the 8000-byte cap itself, not just the marker.
Round 2 of the review loop:
- REVIEW_FOOTER was a literal backslash-n string; after the jq --arg
  migration it parsed as two characters instead of a newline, breaking
  every posted error-review footer. It is now a real two-line string.
- The retry accept-condition kept an empty first response even when the
  retry carried the only diagnostic (network blip -> provider 502);
  the retry's error now wins when the first response is content-free,
  and rejected retries log their error message.
- Empty completions on successful responses regressed to exit 1 (no
  posted comment, trigger label never removed — the stuck-label trap);
  they now post an error review and exit 0 like the truncation path.
  The literal-'error' content comparison is gone (such output flows to
  JSON validation instead of the error path).
Round 3 (direct review; agent pool rate-limited): an HTML 502 page
from a proxy arrives as valid HTTP with curl exit 0 — non-empty and
not a model error — so it bypassed the retry entirely and failed the
review as 'Invalid JSON response from API'. Unusable responses (empty,
model error, or unparseable) now all trigger the retry, and the retry
is accepted only when it is parseable and either clean or the first
response was content-free; a diagnosable first error is kept when the
retry itself is garbage.
Round 4 (direct review): the workflow used the mutable actions/checkout
v5 tag and had no job timeout — now SHA-pinned to v5.1.0 with a
35-minute bound (the model call itself is capped at 25 minutes by
curl's --max-time). The embedded prompt prefix restated the focus
areas and actionable-findings instruction that the main prompt already
carries; stated once now, saving tokens on every request. CRLF
checked into a CUSTOM_PROMPT_FILE is stripped instead of reaching the
prompt as bare carriage returns.
Round 5 (direct review): the commit-message, PR-description, and
previous-review clip tests asserted only the truncation marker — a
regressed head -c would keep the marker (independent wc check) while
shipping uncapped bytes, exactly the gap the agent round flagged for
the custom-prompt cap. All four budgets now pin their caps. README
documents MAX_HUMAN_COMMENT_LENGTH=0 and the new-problems/pre-existing
split in What's New.
Round 6 (direct review): AI_TEMPERATURE, AI_MAX_TOKENS, and
MAX_DIFF_SIZE feed jq --argjson, where a non-numeric repository
variable aborts the payload build — they now degrade to defaults like
the other validated knobs. The prompt gained a prompt-injection fence:
all repository text (comments, description, commit messages, labels,
instruction files, quoted reviews) is untrusted data to review, never
instructions; steering attempts are to be reported as findings.
Round 7 (direct review): an end-to-end sandbox run with every context
feature enabled at once (comments incl. previous bot review, commit
overview with merge-sounding subject and author fallback, check-run
summary with neutral/skipped/in-progress, labels, unicode PR
description, custom inline+file instructions with CRLF) passes all 13
interaction checks — the isolated unit tests cannot catch cross-feature
breakage. The retry backoff moved to a ${VAR-2} no-colon default so
tests can zero it (suite back to ~25s) without becoming a repository
knob the forwarding test would demand.
Round 8 (direct review): an empty or non-numeric RETRY_SLEEP_SECONDS
skips the sleep instead of erroring mid-retry. The README claimed
800KB as THE default for MAX_DIFF_SIZE while the script's built-in is
5MB and only the workflow's forwarding masks the difference — both
spots now state both values.
@LearningCircuit LearningCircuit added the ai_code_review Friendly AI Code Review label Sep 15, 2026
@github-actions

Copy link
Copy Markdown

AI Code Review

New problems

  • should fixai-reviewer.sh (retry block, RETRY_RESPONSE=$(call_model_api) || RETRY_RESPONSE=""): the retry's curl invocation is not exempt from set -e the way the first call is. The first call neutralizes the exit status with RESPONSE=$(call_model_api) || RESPONSE="", but the retry does the same pattern... actually it does use || RETRY_RESPONSE="". Let me re-check: RETRY_RESPONSE=$(call_model_api) || RETRY_RESPONSE="" — this is fine under set -e because the || guards it. However, there is a subtle issue: when call_model_api fails, RETRY_RESPONSE is first set to empty by the command substitution, then || RETRY_RESPONSE="" re-sets it — harmless. OK, no issue there.

  • should fixai-reviewer.sh (is_unusable_response / retry acceptance logic): a retry response that is valid JSON but not a chat completion (e.g. {"error": ...} handled, but a valid-JSON HTML-free payload like {"ok": true} with no choices) passes jq -e . and is not a model error, so it is accepted as RESPONSE; the later content extraction then yields "" and the run ends in the empty-completion error path with finish_reason: none — acceptable degradation, but the retry acceptance could also require .choices[0].message.content presence. Minor robustness gap; low impact.

  • nitai-reviewer.sh (is_model_error): jq -e '(.choices[0].error != null) or (.error != null)' — for a response where choices[0] is null (e.g. some error shapes), .choices[0].error is null, fine. But a top-level "error": null (present but null) evaluates != null as false — correct. However a top-level "error": "string" (some proxies) evaluates true — correct. No issue; the previous review's has("error") concern appears addressed. OK.

  • nitai-reviewer.sh (empty-completion path): generate_error_response "AI returned empty response (finish_reason: $FINISH_REASON)"FINISH_REASON was captured earlier via jq with // "none"? The diff shows FINISH_REASON captured but the guard if [ -n "$FINISH_REASON" ] suggests it may be empty; the jq extraction line is not shown fully in the diff (only the comment). Cannot fully verify; likely fine.

  • nittests/test_reviewer_context.py (run_reviewer, garbage_first / retry_garbage): both flags write an empty garbage-first / retry-garbage marker file and the stub distinguishes them by filename, but empty_first also writes an empty empty-first file — the stub's ordering (model-error-firstempty-firstgarbage-first) means a test setting both empty_first and garbage_first would silently mask one; no current test does, so this is only a latent fixture ambiguity.

  • nitai-reviewer.sh (ADDITIONAL_INSTRUCTIONS truncation): head -c 8000 then strip_partial_utf8 — if byte 8000 lands mid-multibyte-character, strip_partial_utf8 (not shown in diff) presumably removes the partial sequence; the truncation marker is appended on a new line, good. The wc -c re-measure spawns two extra processes; negligible.

  • nit.github/workflows/ai-code-reviewer.yml (timeout-minutes: 35): with --max-time 1500 (25 min) plus a retry (another 25 min worst case) plus backoff, the worst-case model-call time is ~50+ minutes, exceeding the 35-minute job bound — the retry will be killed mid-flight by the job timeout, and the job dies without posting any error review or cleaning the trigger label. The comment says the cap exists precisely to avoid a wedge, but it is now inconsistent with the retry's worst case.

  • nitai-reviewer.sh (CUSTOM_PROMPT_FILE read): tr -d '\r' < "$CUSTOM_PROMPT_FILE" 2>/dev/null || echo "" — under set -e, a tr failure inside $( ... ) with || is guarded; fine. But the file is read after the -r check with no size cap on the file read itself — a huge file is read fully into memory before head -c 8000; bounded by repo checkout size, negligible.

  • nitai-reviewer.sh (prompt-injection fence vs CUSTOM_PROMPT): the fence says instructions files are untrusted data, yet the ADDITIONAL_INSTRUCTIONS block is introduced to the model as "Additional Review Instructions (from the repository's configuration — apply these on top of the standard review instructions)" — i.e., as instructions to follow. In this repo's own workflow the file comes from the PR merge ref (PR-author-controlled), so a PR author can supply instructions the model is simultaneously told to treat as untrusted (fence) and to apply (header). The README documents the trust model honestly, but the prompt's own framing contradicts the fence for this path. Consider wording that scopes the fence to diff-embedded text and explicitly notes the configured instructions are repository-owner-controlled (or, in this repo, merge-ref-sourced and thus reviewable as data).

Pre-existing problems

  • ai-reviewer.sh (pattern throughout, e.g. content extraction, perl strip): arbitrary content piped via echo "$VAR" — content beginning with -n/-e is swallowed or altered by echo; printf '%s' is the safe form (the new ADDITIONAL_INSTRUCTIONS code already uses printf correctly).
  • ai-reviewer.sh (call_model_api area, pre-PR): no HTTP status capture — non-2xx responses with non-JSON bodies are only diagnosable as "no content" (the new retry partially mitigates).

Should be checked

  • Cannot verify the checkout ref semantics from the diff - please confirm that consumers relying on the "trusted base content" claim for CUSTOM_PROMPT_FILE (e.g. local-deep-research's pull_request_target workflow) actually pin ref: ${{ github.event.pull_request.base.sha }} (or equivalent) in their checkout step; if any consumer checks out the merge ref while the README asserts trust, a PR author could inject review-steering instructions labeled as trusted configuration.
  • Cannot verify strip_partial_utf8's implementation from the diff - please confirm it strips (not retains) a trailing partial UTF-8 sequence after head -c 8000, since the truncation marker is appended directly after its output.

✅ Approved with recommendations


Review by Friendly AI Reviewer - made with ❤️

@github-actions github-actions Bot removed the ai_code_review Friendly AI Code Review label Sep 15, 2026
The 35-minute job bound sat below the retry's worst case (two 25-minute
curl attempts plus backoff) — the timeout would have killed the retry
mid-flight and wedged the label, the exact failure it existed to
prevent; raised to 60. The injection fence listed instructions files
as untrusted while the instructions block tells the model to apply
them; the fence now scopes untrusted text to PR-thread content (diff,
comments, description, commit messages, labels, quoted text) and notes
the configured block's trust equals the workflow checkout, matching
the README. Test harness rejects conflicting first-failure fixtures.
@LearningCircuit
LearningCircuit merged commit e3bdfdb into main Sep 15, 2026
1 check passed
@LearningCircuit
LearningCircuit deleted the feat/custom-review-instructions branch September 15, 2026 20:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant