fix(noema): reject a completion the provider declares truncated - #1930
fix(noema): reject a completion the provider declares truncated#1930seonghobae wants to merge 4 commits into
Conversation
noema_review_gate.py never read finish_reason -- the field where an
OpenAI-compatible provider declares it stopped at the output-token budget
rather than because the model finished. Verified on main@f25063882:
`grep -c finish_reason` was 0.
That is only sometimes caught downstream. Driving main's own parser:
'{"decision":"approve",...,"findings":[{"severity":"high"'
-> NoemaModelOutputError (unbalanced, fails closed)
'{"decision":"approve","summary":"reviewed","findings":[]}'
-> parsed as a valid verdict (truncation lands on a closed object)
The second case is the dangerous one, and it is not a corner: findings is
emitted last, so the likeliest parseable truncation is an approval with an
empty or short findings list -- a review cut off mid-thought, accepted as
a genuine APPROVE on a required gate. The local repair is deliberately
lossless (trailing commas only) so it does not manufacture this; the
provider does, and the gate simply never checked where the provider says
so.
reject_truncated_completion() reads choices[0].finish_reason on the
decoded body and raises NoemaModelOutputError only for the unambiguous
"length". Missing, empty, or any other value passes untouched, so a
provider reporting a vocabulary this gate does not model cannot be failed
spuriously; malformed envelopes keep being classified by
extract_llm_message_content, which reports their real cause.
Deliberately narrow: no retry, no new exception type, no change to who
owns repair. The gateway keeps that, per the caller attempts=1 contract.
This is the portable part of #1606, which cannot merge as written because
it also adds caller-side retry against that contract.
Same failure family as #1921 -- a reviewer that could not see everything
returning APPROVE with nothing in the output saying so.
Tests assert the payload really would have parsed before the guard, that
every other finish_reason is allowed, that shape errors stay deferred to
the content parser, and that the guard runs ahead of extraction.
Full suite 2905 passed, 1 skipped, 21 subtests; noema_review_gate.py 100%
coverage over 864 statements / 388 branches; interrogate 100%.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4
|
Warning Review limit reachedNext included review available in 34 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (2)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
CI status note — standing down on the two red checks on head
Not re-running it manually: a re-run dispatches again and enlarges the queue that is causing the failure ( Generated by Claude Code |
…letion Rebinds workflow_sha so the required review runs pick up #1939's round-robin catalog fill; the previous head's Strix and Noema runs failed on the single-upstream gateway stall that #1939 removes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4
|
CI note and push: head Two new red checks on the previous head, both the single-upstream gateway stall class and neither in this diff: Local gate on Generated by Claude Code |
|
CI note on head Same class as #1938's failure 20 minutes earlier, read per host 1's source trace on that thread: the gateway did fail over; Corrected tally, since the one I posted on #1938 was wrong: of the post-#1939 Action: not re-running yet. #1938's attempt-2 re-run (queued 23:49Z, same pre-#1944 workflow) is the measurement of whether the rate has moved; at 1 in 4 a re-run here would spend another ~25 minutes of a runner slot in a 234-deep queue at the same odds. I will issue this PR's single re-run once that result is in, or fold it into the next base-merge push, which also binds the artifact-producing workflow. The two CodeQL compatibility failures remain the designed pending state noted earlier. Generated by Claude Code |
|
CI note on head
So at 23:48–00:12Z the free pool was rate-limited on every account at once — OpenRouter at preflight, the primary NVIDIA account at preflight, the sub account on request. #1939's interleave is working (4+4+4 selected); there was simply no free capacity to interleave. The same window produced #1938's and this PR's Action: no re-run now — with one ready route that 429s on contact, a re-run is 25 more minutes of slot at near-zero odds. This PR's single sanctioned re-run stays gated on #1938's queued attempt 2 (or the next base-merge push), same as the Noema check. The lever for the condition itself (a pool with ≤1 ready route that rate-limits on first contact should not cost 25 minutes) sits in the orchestrator's retry policy and the Strix replay policy, neither in this diff; I have handed the artifact to the observability lane. Generated by Claude Code |
|
CI note on head Nothing in this diff is involved, and no push or re-run from this side changes the outcome — a re-run re-dispatches into the same rejected gate, and the dispatch workflow re-runs this exact job by itself once a verdict is published. Standing down on this check until #1929's variable is reconciled; the Strix, Noema, and CodeQL notes above stand. Generated by Claude Code |
…letion Binds the sidecar pin bump to contextual-orchestrator@414f2297 (contextual-orchestrator#1081's retry-stacking fix, .github efb8926) and the #1943/#1944/#1945 workflows to this head's required runs; the previous head's Noema/Strix runs failed on the stalled-route loop that fix removes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4
|
CI note and push: head Merges Local gate on Generated by Claude Code |
|
CI note on head Generated by Claude Code |
|
CI note on head
Not this PR's, and nothing to push. This PR's sanctioned re-run is unspent; I am holding it, because the failure is deterministic while that first-ranked route stalls and a re-run costs ~90 runner-minutes for a coin flip. The two sibling scans (#1916, #1946) are still running on the same pool; their outcomes go to #1948, not here, unless they differ in kind. Generated by Claude Code |
…-truncated-completion
|
CI note and push: head Merges The five red checks on the previous head were all attributed and none was this diff's: Gate on the pushed tree: 2943 passed, 1 skipped, 21 subtests; coverage 100 % (13,197 statements / 5,334 branches, 0 missed); Generated by Claude Code |
There was a problem hiding this comment.
Noema LLM review
The PR adds a fail-closed guard reject_truncated_completion() that raises NoemaModelOutputError when the provider's first choice reports finish_reason=="length", preventing a budget-truncated completion from being parsed as a genuine APPROVE (the most likely parseable truncation, since findings is emitted last). The guard is invoked in call_llm() before extract_llm_message_content(), runs only on the decoded body, and only rejects the unambiguous "length" value, deferring all shape/malformed-envelope errors to the existing content parser. Concrete regression hypotheses were falsified at the guard's shape guards, JSON error handling, and call-site ordering. Tests cover the parseable-but-truncated APPROVE case, allow-list of other finish reasons, deferral of shape errors, and the guard's position ahead of content extraction.
Reviewed changed lines
scripts/ci/noema_review_gate.py:1212 (RIGHT): Guard returns without raising for non-list or emptychoices, deferring shape errors toextract_llm_message_content; no spurious truncation rejection.scripts/ci/noema_review_gate.py:1213 (RIGHT): Non-dict first choice returns before anyfinish_reasonaccess, avoiding AttributeError and preserving the content parser's precise error reporting.scripts/ci/noema_review_gate.py:1237 (RIGHT):json.JSONDecodeErroris caught and returns, so malformed bodies are not misclassified as truncated.scripts/ci/noema_review_gate.py:1641 (RIGHT): Guard is called immediately after_extract_served_modeland beforeextract_llm_message_content, so the truncation signal is honored before any content parsing.
Adversarial validation
scripts/ci/noema_review_gate.py:1212 (RIGHT)falsified: A response whose choices is not a list or is empty is rejected as truncated, causing a false REQUEST_CHANGES for a well-formed provider response. —reject_truncated_completionchecksif not isinstance(choices, list) or not choices: returnand returns without raising, allowingextract_llm_message_contentto handle the shape error.scripts/ci/noema_review_gate.py:1213 (RIGHT)falsified: A first choice that is not a dict (e.g., a string) crashes with an AttributeError instead of deferring to the content parser. — The guard checksif not isinstance(first_choice, dict): returnimmediately after indexing, so a non-dict value returnsNoneandextract_llm_message_contentraises its precise shape error.scripts/ci/noema_review_gate.py:1237 (RIGHT)falsified: The guard rejects a response withfinish_reason: "length"even when JSON parsing fails, causing a spurious NoemaModelOutputError for malformed bodies. —reject_truncated_completioncatchesjson.JSONDecodeErrorand returns immediately, so unparseable bodies are not raised here but by the later content extraction path.scripts/ci/noema_review_gate.py:1641 (RIGHT)falsified: The guard runs after_extract_served_model, so a truncation signal in the model field could interfere with guard ordering. —reject_truncated_completion(raw)is called beforecontent = extract_llm_message_content(raw), and_extract_served_modeldoes not depend on content, so the ordering is correct for the truncation check.- Residual risk: The guard only examines the first choice's
finish_reasonand only the literal"length"value; a provider reporting a non-standard truncation signal (e.g., a custom value infinish_reasonor truncation indicated in another field) would not be caught. This is an acceptable residual limitation given the explicit fail-open choice for unmapped vocabularies.
Findings
- No blocking findings.
- Result: APPROVE
- Head SHA:
e13bd337e0fc2bb6163c606d6b03bc95804f3ba6 - Reviewer credential:
noema-review-github-app-refresh - Actor:
cwl-noema-review[bot]
|
First Noema APPROVE on any of my heads this session, and it landed on the head the base merge produced. That closes the loop this PR's own subject opened: a truncated completion is now rejected before it can be parsed as an APPROVE, and the reviewer that says so reached a verdict through the same gateway the change protects. Remaining on this head: both Generated by Claude Code |
… the shared route `#1187` `541cadd1` `noema-review` (run 34036172068, job 101502686002, failed 15:38:45Z) returned the same four fields as the `#1884` sample 65 seconds earlier: HTTP Error 502: Bad Gateway; caller attempts=1, duration=1215.2s, phase=response_error, served_model=deepseek-ai/deepseek-v4-flash-0731 Two pull requests, two heads, 1424.1 s and 1215.2 s, both with a ready route, the same model served, and a classified 502 rather than a timeout. Residual (iv) is a class, not an incident. The shared detail is the model. `deepseek-ai/deepseek-v4-flash-0731` is the same first-ranked route `contextual-orchestrator#1082`'s evidence names as the candidate that stalls and is re-selected — 44 of the 48 timeouts in its `#1930` sample. So (ii) and (iv) may be one unhealthy upstream route observed through two request shapes: on the tool-bearing passthrough walk it expires a socket at 90 s and leaks a raw 500; on the orchestrated walk it is served, held for twenty minutes or more, and classified. Recorded as a hypothesis these logs support but do not establish. Confirming it needs the gateway's internal attempt records from the `noema-sidecar-evidence` artifacts (9992218398, 9992230612), which are not read here. Verification: full suite passed; `git diff --check` clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX
|
Failing check on Not this PR's, and it predates the relevant fix. The preflight report carries no Classified by the verdict line, not by a grep — and the two disagree here. The artifact does contain Not The runner cost, which is the part worth escalating. The No fix to port; not re-running. Nothing in this PR's diff can affect gateway capacity, and a re-run of a six-hour job that ends in provider exhaustion would take another slot from the pool that caused it. I am folding the classification rule and this occupancy measurement into Head unchanged at Generated by Claude Code |
…rections Section 0 gains the CI-run counterpart of the stale-checkout rule: a pull_request_target run executes the base branch's scripts as of the run's created_at, not its started_at, and on 2026-09-06 those were routinely 90 minutes apart. #1916's 12:00Z preflight looked like #1957 failing 23 minutes after it merged; the run was created at 10:24Z. Classify by created_at, or by the report's own version marker (postponed_probed_count). Section 3 records what #1957 changed, measured on this repository's own heads (ready 0 -> 1, probed 6 -> 16), and three things the earlier text got wrong or did not have: - caller attempts=1 counts sidecar->gateway calls, not routing. One such attempt carried ~20 provider_attempt lines across four agents, so it is not evidence that failover did not happen. - The ~90 s attempt is now measured: 90.090 s and 90.115 s TimeoutError probes of google/gemma-4-31b-it on both NVIDIA keys. - A third reason the breaker cannot exclude a bad route, stronger than the 30 s reset: _record_success pops the circuit state entirely, so any single success zeroes the count and an alternating route never reaches the threshold. The sandbox split is refined: is_caido_bootstrap_timing_error() inspects only the last attempt, so grepping an artifact for loginAsGuest is not sufficient. Four artifacts from one day show every combination, including #1930's run 34027404208, which carries the line but ends in gateway exhaustion. Classify on the verdict line; the check annotation is generic by design. Section 7 gains the occupancy half of the queue shortage: #1930's strix job held a runner for 351.8 minutes and produced no verdict, which is execution rather than queue wait -- its sibling jobs finished in tenths of a minute. Capping long jobs is a different remedy from adding capacity. Section 9 gains the DISPATCH_OUTCOME/VERDICT_STATE discriminator that separates it from signature 1, plus the 34-of-34-queued measurement. Gate: 2956 passed, 1 skipped, 21 subtests, coverage 100%, interrogate 100%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4
Lane: jan | session:session_01BV96rXhqoR3tYZ9AeAVur4
The hole
scripts/ci/noema_review_gate.pynever readsfinish_reason— the field where an OpenAI-compatible provider declares it stopped at the output-token budget rather than because the model finished. Onmain@f25063882,grep -c finish_reasonreturns 0.Truncation is only sometimes caught downstream. Driving
main's own parser directly:The second case is not a corner case.
findingsis emitted last, so the likeliest parseable truncation is an approval carrying an empty or short findings list — a review cut off mid-thought, arriving as a genuine-looking APPROVE on a required gate, with nothing anywhere saying it was incomplete.main's local repair is deliberately lossless (_strip_trailing_commas_outside_stringsonly), so it does not manufacture this. The provider does, and the gate simply never looked at the field where the provider says so.This is the same failure family as #1921: a reviewer that could not see everything returns APPROVE, silently. There the cause is allowed-locations starvation; here it is output-budget truncation.
The fix
reject_truncated_completion(raw)readschoices[0].finish_reasonon the decoded body and raisesNoemaModelOutputErrorfor the unambiguous"length". It runs incall_llmimmediately afterdecode_llm_response_body, ahead of content extraction.Deliberately narrow, in three ways:
"length"is rejected. Missing, empty,"stop","tool_calls","end_turn","content_filter"— all pass untouched. A provider reporting a vocabulary this gate does not model must not be failed spuriously, and this gate fronts every repository in the organization.extract_llm_message_content, which reports their actual cause with precise messages. Duplicating that here would attribute the wrong reason to a body this function cannot classify.caller attempts=1 (gateway owns repair/failover)contract already onmain.Relationship to #1606
This is the portable half of #1606. That PR cannot merge as written: alongside this check it adds caller-side retry (
is_retry,truncated_after_retry, a stale-head check before retry) built onextract_llm_completion, whichmainreplaced withextract_llm_message_contentwhen it moved repair ownership to the gateway. Resolving its conflict would mean silently reversing that architectural decision. Detail in #1606.So the defect is landed here in
main's own idiom and its own error type, and #1606's remaining parts — the bounded-field validators andNOEMA_MAX_*constants — stay available as their own change if wanted.Verification
pytest tests/test_noema_review_gate.py -k "truncated_completion or call_llm_consults"→ 15 passedcoverage run -m pytest tests -q→ 2905 passed, 1 skipped, 21 subtests passedcoverage report→scripts/ci/noema_review_gate.py100% (864 statements / 388 branches, 0 missing); TOTAL 100%interrogate→ PASSED (100.0%)git diff --checkcleanorigin/main@f25063882in a fresh worktreeThe first test asserts the truncated payload really would have parsed before the guard — otherwise the test would pass for the wrong reason and prove nothing about the hole being closed. A separate test pins that the guard runs ahead of content extraction, so a future reorder cannot silently move it after the parse.
Developer experience
A truncated review now fails with a message naming the cause (
finish_reason=length) instead of either succeeding wrongly or failing later with a misleading JSON-shape error. The message embeds no part of the untrusted response body, so it stays safe in the publicpull_request_targetjob log.User experience
An APPROVE from
noema-reviewcan no longer be produced by a completion the provider itself declared incomplete. This closes a path by which a required gate could pass a change nobody finished reviewing.🤖 Generated with Claude Code
https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4
Generated by Claude Code