Skip to content

fix(llm): close the truncated-response silent-FN family (adapters + enhancer + verifier) - #207

Merged
gadievron merged 5 commits into
masterfrom
fix/verifier-stop-reason-gating
Aug 2, 2026
Merged

fix(llm): close the truncated-response silent-FN family (adapters + enhancer + verifier)#207
gadievron merged 5 commits into
masterfrom
fix/verifier-stop-reason-gating

Conversation

@gadievron

Copy link
Copy Markdown
Collaborator

What

Gate the Stage-2 verifier's finish-acceptance on stop_reason, so a truncated (max_tokens) finish call is not read as a completed verdict. Stacked on #206.

Why

#206 makes the OpenAI adapter honestly report abnormal/truncated terminations as stop_reason="max_tokens" (BUG-2 responses path, BUG-7 chat path). But finding_verifier harvested a finish ToolUseBlock regardless of stop_reason, so a max_tokens-truncated finish(agree=false, "safe") was parsed as a completed verdict and silently downgraded a Stage-1 vulnerable — the verify-stage tail of the same silent-false-negative family. BUG-7 slightly widens this path (an unknown finish_reason now reaches it instead of the end_turn fail-safe), so this ships promptly alongside.

How

  • C(b): gate finish-acceptance on stop_reason != "max_tokens"; a truncated finish → verification-incomplete (agree=False, Stage-1 verdict preserved for triage), mirroring the enhancer's degenerate-exit handling. A malformed-mid-JSON finish was already caught (input_dict={} → agree missing → incomplete); this closes the narrower fully-formed-finish-under-truncation window (proxy / trailing-tokens / length).
  • C(a): the end_turn+unparseable-text fail-safe returned without record_call unlike its three sibling degenerate exits — added the missing record_call (per-unit cost undercount).

Tests

+2 (truncated finish → incomplete; normal finish still accepted). 82/82 verifier-subsystem tests green; full module suite green. Each fix has a /rebugz git-history receipt (RED on base → GREEN on this commit).

Compatibility

No breaking change; scoped to utilities/finding_verifier.py (+ tests). No core/parsers/reachability change.

Depends-on: #206

Surfaced by an independent auditor + expert + judge review of the silent-FN family.

@gadievron gadievron changed the title fix(verifier): treat a truncated (max_tokens) finish call as incomplete, not a verdict fix(llm): close the truncated-response silent-FN family (adapters + enhancer + verifier) Aug 1, 2026
gadievron and others added 5 commits August 2, 2026 04:54
…t a verdict

Stacked follow-up to the openai-adapter PR (#206). The adapter now honestly reports
an abnormal/truncated termination as stop_reason="max_tokens" (BUG-2 responses path,
BUG-7 chat path). But finding_verifier's block loop harvested a finish ToolUseBlock
regardless of stop_reason (:438-476), so a max_tokens-truncated reply carrying a
well-formed finish(agree=False, correct_finding="safe") was parsed as a COMPLETE
verdict (incomplete=False) and silently downgraded a Stage-1 vulnerable to safe — the
verify-stage tail of the same silent-false-negative family, and one that BUG-7 slightly
widens (an unknown finish_reason now reaches this path instead of the end_turn fail-safe).

C(b): gate finish-acceptance on stop_reason != "max_tokens"; a truncated finish is
treated as verification-incomplete (agree=False, Stage-1 verdict preserved for triage),
mirroring the enhancer's degenerate-exit handling. A malformed-mid-JSON finish was
already caught (input_dict={} -> agree missing -> incomplete); this closes the narrower
fully-formed-finish-under-truncation window (proxy / trailing-tokens / length).

C(a): the end_turn+unparseable-text fail-safe (:425) returned without record_call,
unlike its three sibling degenerate exits (finish/no-tool-calls/max-iterations) — a
per-unit cost undercount. Added the missing record_call.

Scoped to utilities/finding_verifier.py (+ tests); no core/parsers/reachability change
(82/82 verifier-subsystem tests green; full module suite green in CI).

Surfaced by an independent auditor + expert + judge review of the silent-FN family.

Tests: +2 (truncated finish -> incomplete; normal finish still accepted).
  grep -c "def test_" tests/test_verifier_max_tokens_finish_incomplete.py -> 2
  verifier-subsystem suite -> 82 passed, 2470 deselected, 1 warning in 0.67s

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ancer

Round-2 re-hunt of the verifier fix surfaced three siblings of the same family
(a truncated/abnormal response read as a clean/complete result), in code the
verifier fix did not touch. The family closes iff two invariants hold everywhere:
(1) every adapter emits stop_reason="max_tokens" for truncation, never masking it;
(2) every consumer gates finish-acceptance on max_tokens.

- R2-A (google.py): a MAX_TOKENS candidate carrying a function_call surfaced as
  stop_reason="tool_use" (has_tool_use checked before finish_reason), masking the
  truncation so the verifier/enhancer gate could never fire on Gemini. Now
  truncation wins over tool_use, mirroring the OpenAI responses path.
- R2-B (agentic_enhancer/agent.py): the enhancer accepted a finish call regardless
  of stop_reason; a truncated finish defaulting security_classification to "neutral"
  silently drops a unit from the analysed set (a coverage/recall loss). Now a
  max_tokens finish is INCOMPLETE, mirroring the verifier + this agent's own
  degenerate-exit handling.
- R2-C (google.py, anthropic.py): an unknown/abnormal finish/stop reason defaulted
  to "end_turn" (the un-ported BUG-7 sibling); now defaults to "max_tokens".

Adapter/verify/enhance layers only — no core/parsers/reachability change; the
enhancer's reachability fields (is_entry_point/reachable_from_entry/entry_point_path)
are passed through unchanged. Capability balance: each trades a sliver of
completeness for silent-FN safety; R2-B is coverage-POSITIVE (stops dropping units).

Tests: +4 (google adapter family) + 2 (enhancer max_tokens-finish) + updated the
anthropic unknown-stop test to the new contract.
  full module suite -> 2529 passed, 28 skipped (2 pre-existing local zig-subprocess reds)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…omplete survey

Round-3 re-hunt exhaustion pass found the third and final invariant-2 consumer gap.
explore_repository returned the finish tool payload the moment a finish block appeared,
ignoring response.stop_reason (:285). A finish emitted on a turn truncated at the token
cap (stop_reason=="max_tokens", partial arguments) was accepted as a complete survey and
written into the application-context / threat-model document every later scan trusts —
an under-scoped attack surface = silent coverage loss.

Skip a max_tokens finish (getattr-guarded so mock/odd adapters without stop_reason are
unaffected) so the loop nudges for a complete answer; if it never completes, the existing
MAX_TURNS RuntimeError fires — a visible failure, not a silent partial. Mirrors the
verifier (finding_verifier.py) and enhancer (agentic_enhancer/agent.py) max_tokens gates,
completing invariant-2 across all three finish-consumers.

context survey layer only — no core/parsers/reachability change (repo_explorer references
zero reachability code). Capability: coverage-POSITIVE (stops silently trusting a partial
threat model).

Tests: +1 (truncated finish skipped, later complete finish used); 19/19 repo_explorer
tests green; full module suite 2530 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sequence 400)

Round-4 re-hunt found that the R3-A gate (30a1e62) introduced a Messages-API
sequence violation: on a max_tokens-truncated finish it did `continue`, skipping
the finish block WITHOUT emitting a tool_result — but the assistant turn (carrying
that finish tool_use) is still appended, so the NEXT complete() sends an unanswered
tool_use and real Anthropic/OpenAI reject it with 400. The intended graceful
retry / MAX_TURNS exhaustion never happened against a real provider. Not a silent FN
(it fails loud), but it defeated the fix's own purpose.

Answer the truncated finish's tool_use with a retry-nudge tool_result before
`continue`, so the sequence stays valid: the model is asked for a complete, more
concise finish, and repeated truncation still exhausts MAX_TURNS -> RuntimeError.

The R3-A test passed only because its fake adapter didn't validate tool_use/
tool_result pairing. Strengthened it with a _PairingFakeAdapter that enforces the
real Messages-API rule (RED on 30a1e62 -> GREEN here, via /rebugz worktree).

context survey layer only; no core/parsers/reachability change. full suite 2530 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nt-1 completeness)

Round-5 dry-round confirmation noted a consistency gap in the R2-A/R2-C google fix:
an UNKNOWN/abnormal finish_reason carrying a function_call hit `elif has_tool_use ->
tool_use` BEFORE the R2-C `else -> max_tokens` default, so an unknown reason with a
tool call would surface as a clean tool_use — a hole in invariant-1 (any abnormal/
unknown termination must be max_tokens). Future-value-only today (no currently-emitted
Gemini truncation reason is unmapped-and-non-refusal), so speculative, not a live
silent-FN — but it is an inconsistency in the fix's own invariant.

Reorder: check `mapped is None` (unknown; refusals already raised above) BEFORE
has_tool_use, so unknown -> max_tokens regardless of a tool call. Known non-truncation
reasons still map to tool_use (with a tool) or their mapping. Re-derived table:
STOP+tool->tool_use, STOP->end_turn, MAX_TOKENS(+/-tool)->max_tokens,
UNKNOWN(+/-tool)->max_tokens. Only the unknown+tool cell changed.

adapter layer only; no core/parsers/reachability change. full suite 2531 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gadievron
gadievron force-pushed the fix/verifier-stop-reason-gating branch from 29a5320 to 15d45aa Compare August 2, 2026 01:55
@gadievron
gadievron changed the base branch from feat/openai-gpt5-responses to master August 2, 2026 01:55
@gadievron
gadievron merged commit fad4b01 into master Aug 2, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant