fix(exaforce): suppress LLM decode runaways with frequency_penalty=0.1 - #14
Merged
Merged
Conversation
Under strict json_schema structured output, nemotron-super-3-120b
intermittently falls into a degenerate decode state and emits whitespace
until it hits max_completion_tokens -- JSON permits unlimited whitespace
between tokens, so the grammar never forces a stop. Captured samples are
98.5-100% whitespace; the worst emitted "{" followed by 646k spaces.
The call then raises LengthFinishReasonError, arun_batches drops the batch
with no retry, and agentguard discards the whole scan's LLM findings on the
first such warning. Measured ~0.8% per call, which at ~3xfiles calls per
unit loses roughly a third of units.
A 5,400-call paired sweep over a prod-shaped corpus put the runaway rate at
0.78% (none) vs 0.00% (0.1); pooled any-penalty vs none is 0.78% -> 0.11%,
Fisher p = 0.0013. 0.1 is the smallest value reaching zero runaways with no
observed structured-output damage -- at >=0.3 the penalty starts mangling
rule_id, and that corruption scales with findings per response.
Lands as a guarded runtime patch so upstream-tracked files stay at parity.
Override with SKILLSPECTOR_FREQUENCY_PENALTY; 0 disables.
pupapaik
approved these changes
Aug 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
frequency_penalty=0.1to OpenAI-compatible chat models, eliminating the intermittent decode runaway that was costing whole scans their LLM findingswrap_module_callableprimitive to_patchlibso fork behavior can wrap upstream callables without forking themSKILLSPECTOR_FREQUENCY_PENALTY(0disables); no upstream-tracked file changesDetails
The bug. Under strict
json_schemastructured output,nemotron-super-3-120bintermittently enters a degenerate decode state and emits whitespace until it hitsmax_completion_tokens. JSON permits unlimited whitespace between tokens, so the grammar never forces a stop. All 7 captured samples were 98.5–100% whitespace; the worst emitted{followed by 646,475 spaces and 3,601 newlines, burning ~98s (a healthy call is 0.4s).The call raises
LengthFinishReasonError,arun_batchesdrops the batch with no retry, and agentguard's_llm_degradeddiscards the entire scan's LLM findings on the first such warning and re-runs--no-llm. Measured at ~0.8%/call across four independent runs — which, at ~3 × filescalls per unit, loses roughly a third of units.Why this value. A 5,400-call paired sweep (6 penalties × 900, prod-shaped corpus):
rule_idPooled any-penalty vs none: 0.78% → 0.11%, Fisher p = 0.0013 (independently reproduced by a 3,000-call two-arm run, p = 0.0034). No single arm survives Bonferroni across 5 comparisons, so the data supports "turn it on" but does not finely resolve 0.05 vs 0.1 vs 0.2.
0.1 is the smallest value reaching zero runaways with no observed structured-output damage. Above ~0.3 the penalty mangles
rule_id('','SQ','SQP-') because it discounts already-emitted tokens and a findings response repeatsrule_id/severity/start_lineonce per finding — so corruption scales with findings per response (0% at 1–2 findings, ~3% at 4+), i.e. it is worst on the files with the most to report.Rejected alternatives, all measured: any system prompt (
detailed thinking offis flat at 1/500 vs 1/500; instructing the model not to pad is flat-to-worse);function_callinginstead ofjson_schema(8.9% schema-validation failures, 58× output tokens, same yield); stop sequences on whitespace runs (aborted 0 of 500).Placement. Implemented as a guarded runtime patch under
src/skillspector/exaforce/so upstream-tracked files stay at parity. It wrapsget_chat_modelin bothllm_utilsandllm_analyzer_base— the latter binds the symbol by value at import, so patchingllm_utilsalone would miss every analyzer. Models that don't declarefrequency_penalty(ChatAnthropic, ChatBedrockConverse, the agent-CLI adapter) are skipped rather than sent an unsupported parameter, an explicit caller value always wins, and upstream renaming or resignaturingget_chat_modelraisesPatchDriftErrorat import.Verification.
pytest: 1380 passed, 4 failed — exactly the pre-existing exaforce-by-design failures (test_to_finding,test_model_dump,test_intent_validation,test_malicious_skill_findings_preserve_metadata), no new breakage. 24 full-graph scans against bedrock-mantle: zeroLengthFinishReason, vs 5-in-12 and 9-in-15 on baseline; degraded-scan rate 67% → 21%.Not yet benchmark-validated. The 900-unit run in
benchmark.db(c8c9b0d384f6) started ~5 hours before this patch existed and is described as a recheck of the baseline config, so it measures 2.3.11 → 2.4.2, not this change. A clean one-change-per-run comparison should usec8c9b0d384f6as the base. Worth watching there: findings/call rose 13–37% in the probes, and whether that is recall or false positives can only be settled against labelled ground truth.No structured plan — this came out of a measurement-driven investigation; probes and full writeup live in
tmp/mantle-llm-repro/(gitignored).Benchmark
Ver 2.3.11recheck of a1d8b9cd9a4ba1d8b9cd9a4bc8c9b0d384f6Outcomes
Every scanned unit is exactly one of these, so the rows conserve to the total —
reducing one bucket (e.g. eliminating timeouts) just moves those units into the
others. Read the two bold group rows for the verdict; the indented rows break
them down (a single sub-row moving isn't independently good or bad).
Timing
Per-scan
run_timestats. Total scan time is the sum (total work across allscans) — resume-safe, unlike
finished_at − started_at. ~est. wall dividesthat by the run's worker count to approximate elapsed time; it's an estimate
(ignores ramp-up, the under-saturated tail, and auth pauses, so it runs low).