fix(ai): stop retrying a response truncated at the length cap - #495
Conversation
A model that stops on finish_reason=length returns a body cut mid-JSON. Nothing read the finish reason, so the truncation surfaced only as a parse failure — indistinguishable from a malformed response, and therefore routed into the transient-failure retry path. That path retries max-ai-retries times (default 5) with exponential backoff, and FindingPipeline then retried the batch, entering the same loop again. So one over-cap batch cost roughly ten full-price input calls, every one of them knowably futile — same prompt, same cap, same cut — before the batch was dropped and its files disclosed as unreviewed. The knob that exists to bound output spend multiplied it instead. Read finishReason from the ChatResponse and raise a distinct AiResponseTruncatedException. Both retry layers now decline it: the service rethrows without retrying or broadcasting a retry event, and the pipeline goes straight to the existing disclosure instead of paying for a second guaranteed truncation. The soft-fail contract is otherwise unchanged — successful batches keep their findings and the truncated batch's files are still disclosed. The exception extends AiReviewException so it survives asAiReviewException with its identity intact, and the message names max-output-tokens so an operator can see which knob caused it. Refs #492
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
The review service encountered an error. Please reply with |
…g self-cycles The cause walk carried a `cause.getCause() == cause` guard that was both incomplete and untested: it caught a Throwable that causes itself, but not the ordinary two-hop cycle A caused-by B caused-by A, which would spin the walk forever on the review thread. Replace it with a depth bound, which terminates on any cycle regardless of shape and is simpler to read. The bound is 16 against a real chain of 2 (CompletionException over the service exception), so no genuine truncation can be missed for depth. Add a test that drives a two-hop cycle through the batch path under a preemptive timeout — the case the old guard let through — and asserts the batch falls back to the ordinary soft-fail path. Refs #492
🤖 ThrillhouseBot PR SummaryWhat this PR doesDetects responses truncated at the model's max-output-tokens cap via finish_reason=length, raises a distinct AiResponseTruncatedException, and makes both retry layers (AiReviewService and FindingPipeline) decline to re-run the deterministic failing call, disclosing the affected batch's files as not reviewed once instead of paying roughly ten futile billed calls. Control-Flow Diagram🔀 Show diagramflowchart TD
A["AiReviewService: streaming onCompleteResponse fires"] --> B{"finishReason == LENGTH?"}
B -- "no" --> C["parse JSON and complete normally"]
B -- "yes" --> D["completeExceptionally(AiResponseTruncatedException)"]
D --> E["runWithRetries catches truncation; logs; rethrows, no retry, no broadcast"]
E --> F["reviewBatch future fails with truncation in cause chain"]
F --> G{"FindingPipeline: isResponseTruncated walks cause chain?"}
G -- "yes" --> H["recordUncoveredFiles; skip batch retry; disclose files as not reviewed"]
G -- "no" --> I["failedIndices; batch retry path"]
Changes Overview
Changed Files
Risk Assessment
Things to double-check3 lower-confidence findings
Automated review by ThrillhouseBot. Reply with |
There was a problem hiding this comment.
ThrillhouseBot noted 3 lower-confidence item(s) under Things to double-check in the PR summary (not posted as inline threads):
- MEDIUM: Cycle-guard test fixture is not actually cyclic (
src/test/java/dev/thiagogonzaga/thrillhousebot/review/FindingPipelineTest.java:264)
multiCallSurvivesACyclicCauseChainOnAFailedBatch claims to test the MAX_CAUSE_DEPTH protection with a cycle 'A caused-by B caused-by A', but the fixture builds a linear chain. The anonymous 'outer' returns tail; the anonymous tail returns head; head is a plainnew RuntimeException("head")with no cause, so head.getCause() is null. The chain outer -> tail -> head -> null terminates in 3 steps, so isResponseTruncated returns false without ever reaching the depth bound. The test would pass identically if MAX_CAUSE_DEPTH and the whole bound were deleted, so it provides no protection for the cycle guard this PR highlights as a design detail. Make the fixture genuinely cyclic (e.g. tail.getCause() returnsthis) so the guard is actually exercised. - MEDIUM: Verify OpenAI streaming populates finishReason on ChatResponse (
src/main/java/dev/thiagogonzaga/thrillhousebot/review/ai/AiReviewService.java:267)
The service-level detection depends entirely onresponse.finishReason() == FinishReason.LENGTHfiring when the real model hits the cap. The new tests prove only the handling code: TruncatedTokenStream fabricates the trigger withChatResponse.builder().finishReason(FinishReason.LENGTH), so the production path is exercised only against a fake. If the langchain4j/OpenAI streaming integration does not populate finishReason on the ChatResponse delivered to onCompleteResponse (e.g. it arrives null), this changed line never executes under the real trigger: the cut body still fails to parse and still lands in the transient-retry path, making the fix a silent no-op. This is a verification request: run the real OpenAiStreamingChatModel with a capped max_tokens and confirm the completion handler receives finishReason == LENGTH, or add an integration test that reproduces the provider response without fabricating it. - LOW: Verify AiResponseTruncatedException survives reviewBatch propagation (
src/main/java/dev/thiagogonzaga/thrillhousebot/review/FindingPipeline.java:202)
The pipeline guard only changes behavior when the CompletionException cause chain contains AiResponseTruncatedException. The batch-layer test fabricates this by stubbingaiReviewService.reviewBatch(...)with.thenThrow(new AiResponseTruncatedException(...)), so the real propagation path — service.review up through AiReviewService.reviewBatch and into the CompletableFuture/join() — is never exercised. The PR's design note covers asAiReviewException preserving an AiReviewException cause inside the service, but not whether reviewBatch itself rethrows the exception with its cause chain intact; if reviewBatch replaces it with a new exception that discards the original cause, isResponseTruncated returns false and the truncated batch still receives a futile batch-level retry. Verification request: confirm reviewBatch propagates the truncation with its cause chain intact, or add a test that goes through the real reviewBatch instead of mocking it.
The fixture built a -> b -> c where c was a plain exception, so the chain ended
at null after three hops. The walk terminated on `cause != null` and the depth
bound was never reached: the test asserted termination that would have happened
without the bound, and passed against the very defect it was written for.
Build a true two-node cycle through a holder so the chain never reaches null.
With the bound removed the test now fails as it should:
FindingPipelineTest.multiCallSurvivesACyclicCauseChainOnAFailedBatch:252
execution timed out after 10000 ms
Codecov found this, not review: the missed branch was `depth < MAX_CAUSE_DEPTH`
being false, which is exactly the case the fixture could not produce.
Refs #492
|
…cise named model (#507) ## What type of PR is this? - [x] ✨ Feature > **Stacked on #506.** Base is `fix/505-audit2-drift`, not `release/v0.6.0` — purely to keep the stack ordered; #506 is docs/log-only and nothing here depends on it functionally. GitHub will retarget the base to `release/v0.6.0` automatically when #506 merges — but this repo squash-merges, so after that retarget this branch still carries #506's original commit and needs `git rebase --onto release/v0.6.0 a7b7fa6` before its own merge. **Review #506 first.** ## Description Fixes #498. `max-output-tokens` was applied once, on the model builders, so every AI call shared one response cap. That is the wrong granularity: a **batch review** response scales with finding count and is the call that legitimately needs a large allowance; the **summary** returns one fixed-shape object plus `previous_findings_status`; the **verifier** returns a verdict per finding; a **maintainer reply** is short prose. Sizing the shared cap for the batch call licensed every other call to run orders of magnitude longer than it should — e.g. the shipped `deepseek-v4-flash` entry sets `max-output-tokens=384000`, and until now the summary call carried all of it. ### Shape: one named model, `concise` The summary, verifier, and reply calls now bind to a `concise` named model: - **`PrSummarizer`** (new interface, `@RegisterAiService(modelName = "concise")`) — `summarizeStream` moved verbatim from `PrReviewer` (same `PrReviewPrompts.SUMMARY_*` constants, same `@V` slots), because one `@RegisterAiService` interface is one model binding. - **`FindingVerifier`** and **`ReplyAssistant`** — rebound with `modelName = "concise"`; no signature change, so their callers are untouched. - **`quarkus.langchain4j.openai.concise.*`** aliases the **same** `AI_*` env vars (api-key, base-url, model-name, timeout, log flags) — named configs inherit nothing from the default block (`OpenAiRecorder.correspondingOpenAiConfig` is `isDefault(name) ? defaultConfig() : namedConfig().get(name)`, no fallback), so the aliases are what keep operators configuring one provider. Note the namespace is provider-first: a first boot attempt with `quarkus.langchain4j.concise.openai.*` failed with `SRCFG00014: The config property quarkus.langchain4j.openai.concise.api-key is required`. - **`REVIEW_CONCISE_MAX_OUTPUT_TOKENS`** (default **8192**) feeds `quarkus.langchain4j.openai.concise.chat-model.max-tokens`. 8192 is roomy for these calls — a 50-finding review verifies in well under 4k tokens, a summary is one object, a reply a few paragraphs — while an order of magnitude under batch-sized caps. Empty drops the cap (provider default). **Deliberately NOT moved:** the review call and the command generators (`/describe`, `/changelog`, `/add-docs`, `/improve`, `/generate-tests`, including the describe/changelog reduce steps). Their outputs scale with the diff — a large PR legitimately produces a long description, changelog, docstring set, or test file — so capping them at a fixed "concise" size would trade one wrong granularity for another. They stay on the default model's `max-output-tokens`. ### The customizer-binding question, settled with evidence Whether the existing `ChatModelCustomizers` would reach (and stomp) the named model was verified against the deployed 1.12.2 jars (`javap -c` on `ModelBuilderCustomizer` and the `OpenAiRecorder` apply-functions): - `applyCustomizers(instance, builder, name)` selects customizer beans **by CDI qualifier**: `Default.Literal` for the default model, `ModelName.Literal.of(name)` for a named one. **No inheritance in either direction.** - The recorder runs customizers **after** the config properties, immediately before `build()` — a customizer wins over config. - In 1.12.2 both the blocking **and** streaming recorders apply `ChatModelConfig.maxTokens()`, so the named block's `chat-model.max-tokens` reaches both concise beans (the 1.11.2 blocking-only gap that motivated the customizer route does not bite here). Consequences implemented: the unqualified customizers **cannot** stomp the concise cap (they never run for the named model), but the named model would silently lose reasoning-effort/temperature/top-p/penalties/seed. So `ChatModelCustomizers` gains a `@ModelName("concise")` pair that applies every shared parameter **except** `maxTokens` — applying the active model's `max-output-tokens` there would overwrite the concise cap, since customizers run last. `ChatModelWiringTest` pins all of it end-to-end. ### Boot-time validation and log `StartupConfigValidator` rejects `REVIEW_CONCISE_MAX_OUTPUT_TOKENS < 1` at boot (fail-fast, naming the env var, like the other budget keys; empty = uncapped is allowed) and logs the concise cap next to the per-model settings line, so the boot an operator debugs states which cap the summary/verifier/reply calls run under. ## Related Issues Fixes #498 Stacked on #506 (#505). Earlier in the stack: #495 (#492), #504 (#497). Next: #499, then #500. ## How Has This Been Tested? - [x] Unit tests **Red/green.** All test changes were written against the structural move only (interfaces split and rebound, named block aliased — no cap property, no concise customizers, no validation), then the behavior was added. Red, verbatim: ``` [ERROR] Failures: [ERROR] StartupConfigValidatorTest.failsFastWhenConciseResponseCapBelowOne:300->assertFailsValidation:213 Expected dev.thiagogonzaga.thrillhousebot.config.ConfigValidationException to be thrown, but nothing was thrown. [ERROR] ChatModelDefaultOffTest.conciseModelsCarryOnlyTheirResponseCapByDefault:75 the concise default cap must apply ==> expected: <8192> but was: <null> [ERROR] ChatModelWiringTest.conciseBlockingModelCarriesTheConciseCapAndTheSharedTuning:91 the concise response cap must apply, not the active model's max-output-tokens ==> expected: <8192> but was: <null> [ERROR] ChatModelWiringTest.conciseStreamingModelCarriesTheConciseCapAndTheSharedTuning:110 the concise response cap must apply, not the active model's max-output-tokens ==> expected: <8192> but was: <null> [ERROR] Tests run: 72, Failures: 4, Errors: 0, Skipped: 0 ``` Green with the cap property + concise customizers + validator rule in place. The wiring tests also assert the default models still carry the per-model `max-output-tokens` (4096 in the profile) while the concise beans show 8192 plus the shared `reasoning_effort`/temperature/top-p — "summary/verifier/reply carry the concise cap while batch review keeps the model cap" pinned in one class. **Truncation detection survives the rebinding** (`ConciseModelTruncationTest`, new; green-only guard — detection itself is #495/#504 behavior and was never red here): drives the real `AiReviewService.summarize`, `FindingVerifier.verify`, and `ReplyAssistant.reply` against `@InjectMock @ModelName("concise")` model beans returning `finish_reason: length`, and asserts `AiResponseTruncatedException` naming the knob. Because the mocks are the **concise-qualified** beans, these tests double as proof the services really bind to the named model — a silent fallback to the default model would leave the stubs unhit and fail. **Existing test files touched, and why** (every prior assertion preserved; nothing disabled or deleted): - `AiReviewServiceTest` — `AiReviewService` gained the `PrSummarizer` dependency: new `@Mock`, the summarize test stubs/verifies `prSummarizer` instead of `prReviewer`, and the two explicit `new AiReviewService(...)` constructions gained the argument. Mechanical. - `AiServicePromptRenderingTest` — reply/verify/summary rendering is now captured off `@InjectMock @ModelName("concise")` model mocks (the default-model mocks no longer see those calls); the capture helpers gained a model parameter, with the old single-argument overloads delegating for the unchanged services. Assertions untouched. - `AiServiceUserMessagePlacementTest` — added the `PrSummarizer` structural check (same guard every other AI service has). - `ChatModelWiringTest` / `ChatModelDefaultOffTest` — extended with the concise-bean assertions above (the red phase); the existing default-model assertions are unchanged. - `ChatModelCustomizersTest` — new cases for the concise customizer pair: shared tuning applied, `maxTokens` never called (`verifyNoMoreInteractions` with a per-model `max-output-tokens=384000` present in settings). - `StartupConfigValidatorTest` — `ConfigBuilder` passes the new constructor argument (default `Optional.of(8192)`); new red/green case for `< 1` rejection plus an empty-allowed case. Gates on the final tree: - `./mvnw -B spotless:apply` / `spotless:check` — clean - `./mvnw -B clean compile spotbugs:check` — `BugInstance size is 0`, BUILD SUCCESS - `./mvnw -B clean test` — **Tests run: 2466, Failures: 0, Errors: 0, Skipped: 0** (baseline 2454 + 12 new) - Coverage: `jacoco.xml` intersected with this PR's added `src/main/java` lines — **no uncovered added lines** ## Checklist - [x] My code follows the project's coding standards - [x] I have performed a self-review of my own code - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the documentation accordingly - [x] My changes generate no new warnings or errors ## Additional Notes - **Behavior change for every deployment:** the summary/verifier/reply calls now send `max_tokens: 8192` even where nothing was configured before (previously they were uncapped unless `max-output-tokens` was set). That is the point of the issue — the cap exists for the runaway case — and 8192 is far above what these calls produce; if it is ever hit it surfaces loudly as the #492-style truncation error naming `REVIEW_CONCISE_MAX_OUTPUT_TOKENS`, and setting the variable empty restores the uncapped behavior. - The issue sketched per-request parameters as one possible mechanism. #504 already established that #497's `Result` plumbing and per-request parameters don't fall out of one change; the named-model route gets per-call-class caps entirely in configuration and bindings (no signature churn through the assistants), at the cost of one extra model bean pair — and the config block documents the no-inheritance trap it steps around. - The issue asked for "the per-model `max-output-tokens` as the default when a call-specific value is not set". With a named model that exact fallback would mean conditionally re-applying the active model's cap in the concise customizer when the env var is unset — reintroducing the stomping hazard for the configured case. The shipped shape instead gives the concise cap its own always-present default (8192) and a documented empty-to-uncap escape; the operator-visible contract (batch cap never leaks onto the summary; no hardcoded per-call numbers in code) is preserved, and the boot log states the effective value. - `website/` versioned docs (0.4.0) deliberately untouched; README, `.env.example`, and `application.properties` are the living config surface.



What type of PR is this?
Description
Fixes #492.
A model that stops on
finish_reason: lengthreturns a body cut mid-JSON. Nothing read the finish reason, so a truncation surfaced only as"Model response is not valid review JSON"— indistinguishable from a genuinely malformed response, and therefore routed into the transient-failure retry path.It is not transient. The same prompt against the same cap is cut at the same point every time. So:
AiReviewService.runWithRetriesretried itmax-ai-retriestimes (default 5) with exponential backoffFindingPipelinethen treated the exhausted-retries failure as a batch failure and re-ranprocessBatch, entering that loop againOne over-cap batch therefore cost roughly ten full-price input calls, every one knowably futile, before the batch was dropped and its files disclosed as unreviewed. The only knob that bounds output spend multiplied it instead — and none of those retries counted against
REVIEW_MAX_AI_CALLS, which caps only planned calls.The change
Read
finishReasonoff theChatResponseand raise a distinctAiResponseTruncatedException. Both retry layers now decline it:The soft-fail contract is otherwise untouched: successful batches keep their findings, the truncated batch's files are still recorded as uncovered so the verdict holds and the summary discloses the gap.
Two details worth noting for review:
AiResponseTruncatedException extends AiReviewExceptiondeliberately.asAiReviewExceptionreturns anAiReviewExceptioncause as-is, so the subclass survives theCompletableFutureunwrapping with its identity intact. ExtendingRuntimeExceptioninstead would have been silently re-wrapped and the guard would never fire.isResponseTruncatedwalks the cause chain (the failure arrives asCompletionExceptionover the service's exception) with a self-reference guard so a malformed chain cannot loop.The message names
max-output-tokensexplicitly, so an operator reading the log can see which knob caused it and that leaving it unset falls back to the provider default.Related Issues
Fixes #492
Companions, not included here: #493 (
output-buffer-tokenssubtracted from input on separate-budget models) and #494 (the startup rule built on the same shared-window assumption). This one is independent of both — it bites on any model, shared-window or not, for anyone who setsmax-output-tokensat all.How Has This Been Tested?
New fake
TruncatedTokenStreamemits a body cut mid-JSON withFinishReason.LENGTH— what a provider actually returns — and counts its ownstart()calls so a test can assert what a truncation cost.Service layer,
AiReviewServiceTest. Red phase with only the no-retry guard reverted (detection left in place), so the failure isolates the guard:That generic
AiReviewExceptionis the defect visible in one line: the truncation was retried to exhaustion and reported as "AI review failed after N attempts". Green with the guard restored. The first test assertsstarts.get() == 1— exactly one call — and that the parser is never reached.Batch layer,
FindingPipelineTest.multiCallDoesNotRetryABatchTruncatedAtTheModelsLengthCap. Red with the branch disabled:The second, futile call caught directly. The test also pins the parts that must not change — the successful batch keeps its finding,
plan.runtimeUncoveredFiles()still lists the truncated batch's file, and the summary still saysa.java (not reviewed.Gates on the final tree:
./mvnw -B spotless:apply/spotless:check— clean./mvnw -B clean compile spotbugs:check—BugInstance size is 0, BUILD SUCCESS./mvnw -B clean test— Tests run: 2429, Failures: 0, Errors: 0, Skipped: 0Checklist
Additional Notes
Scope, stated plainly. This makes a truncation cheap and legible instead of expensive and mute. It does not yet recover the findings — a truncated batch is still disclosed as unreviewed rather than re-run split smaller or salvaged for whatever parsed. That is a deliberate follow-up: batches are planned up front by
DiffBudgetPlannerwith token accounting, so re-splitting one mid-flight duplicates that logic and deserves its own change. The cost amplification was the urgent half and it is self-contained.No behaviour changes for any deployment that does not set
max-output-tokens, since nomax_tokensis sent and no length stop can occur.