Repository navigation
Description
Summary
Gemini 2.5 Flash occasionally enters an infinite token-repetition loop during structured-output extraction, truncating the response mid-JSON at max_output_tokens. This is an upstream model bug — documented by Google since Oct 2025, with ~30% hit rate on some reporters' workloads. Persists on both Flash and Pro as of April 2026, no Google fix or ETA.
This issue documents the failure signature, the client-side mitigations we've shipped, and deferred follow-ups. Keep open until either (a) Google fixes upstream, or (b) all deferred items below ship.
Observed failure signatures
Two distinct failures that hit the same code path in nlp-service/services/llm_client.py :: extract_source():
1. Repetition loop → JSON truncation:
extract_llm_parse_failed: 1 validation error for LLMExtractionResponse
Invalid JSON: EOF while parsing a value at line 24 column 64119
[type=json_invalid, input_value='{"entities":[..."AI","AI","AI","AI",', ...]
Config that triggers it: temperature=0.0, max_output_tokens=32768, response_mime_type=application/json, response_schema=LLMExtractionResponse, thinking_budget=512.
2. Transient connection drops (unrelated root cause, same code path):
extract_llm_call_failed: Server disconnected without sending a response.
httpx RemoteProtocolError bubbling out of google-genai with no retry logic.
Root cause
Repetition loop: Upstream sampler pathology on Gemini 2.5 Flash/Pro at temperature=0.0 with structured output. Well-documented by Google users; no Google fix:
- https://discuss.ai.google.dev/t/gemini-2-5-flash-repeats-tokens-until-max-tokens-reached-in-structured-output/107176
- https://discuss.ai.google.dev/t/gemini-2-5-pro-enters-infinite-b-repetition-loop-65k-tokens-billed-same-behavior-as-flash/115368
- https://discuss.ai.google.dev/t/truncated-response-issue-with-gemini-2-5-flash-preview/81258
Transient drops: google-genai==1.12.1 (our pinned version) has zero retry logic. HttpRetryOptions only landed in ≥1.65 — a 60-version jump we're not taking mid-debug.
Evaluated and confirmed NOT to work
frequency_penalty/presence_penalty— NOT supported on 2.5 models; returnsINVALID_ARGUMENT- Pydantic
max_length/max_itemsconstraints — silently ignored - Anti-repetition prompt hints alone — no reports of them working
- Lower
top_p— no corroboration - Streaming mid-body abort — streaming on 2.5 models has its own bugs (empty chunks, 400 errors)
thinking_budgettuning — often ignored outright on 2.5 models
Applied workarounds
Shipped in two commits:
-
2234bcc— fix(api): observability overhaul for compile pipeline failures- Shared
throwOnErrorhelper incompile/run/route.tsreplaces 8 inline status-only throws. Reads the sub-route response body (3 s race-capped), embeds first 2 000 chars in the thrown Error sofailCompileProgressstores the real cause incompile_progress.error(visible in UI banner + Activity feed). timed()wrapper logs[compile:<sid>] <step> <ms>on success — makes cold-start vs steady-state latency visible.- Top-level
.catchnowconsole.error's rawerr(stack trace), not just the stringified message. - Each of
compile/{extract,resolve,match,plan,crossref,draft,schema}now wraps its POST body in a top-level try/catch that logs the stack before returning 500. Next.js 16 production doesn't surface uncaught handler errors without explicit logging. - Side effect: UUIDs no longer leak into activity-feed error strings (they were baked into the old
extract failed for {uuid}: 500message). api/health: bumped expectedschema_version15 → 16 (the v16 migration had been silently failing the healthcheck, leaving Docker flapping).
- Shared
-
3ebb275— feat(nlp-service): Gemini retry wrapper + truncation salvage + usage logging_with_retryhelper: 3 attempts, exponential backoff (0.5/1/2 s), catches httpx connect-class errors + google-genaiAPIErrorwith status in {408, 429, 500, 502, 503, 504}. Re-acquires the pyrate-limiter token per attempt so retries count against the 800 RPM cap (not bypass it).- Applied to 7 read-only analyzers:
extract,lint_scan,disambiguate,crossref,triage,generate_schema,select_pages. Skipped ondraft_page,synthesize_answer,generate_digest— those commit downstream state and can't be safely re-executed after a partial response. _log_usagehelper: one stderr line per Gemini call withfinish_reason+prompt_token_count/candidates_token_count/thoughts_token_count. Ground truth for distinguishingMAX_TOKENStruncation from other failure modes.extract_sourcefallback chain on parse failure:json-repairsalvage (new dep,json-repair>=0.59.4) — recovers the valid JSON prefix from a repetition-loop truncation for free; ~90% of loops have extractable content before the stuck tail- If
finish_reason == MAX_TOKENSand salvage failed → retry once with halved input; different prompt path usually escapes the loop - On all-fail → chain both errors in
extract_llm_parse_failed_after_fallback: first=...; second=...socompile_progress.errorshows what each attempt hit
- Anti-repetition hint added to
_EXTRACTION_SYSTEM_PROMPT("Stop as soon as the source is covered — do NOT repeat names or pad the output"). Mixed evidence it helps; costs ~10 input tokens, kept as belt-and-suspenders.
Deferred follow-ups
- Prompt reorder for implicit caching — Gemini 2.5 has implicit caching enabled (75% off cached input tokens on prefix match) but requires ≥1024 cached tokens. Our stable prefix is ~375 tokens — below the threshold. Padding it with substantive stable content (schema description, few-shot examples) would unlock the discount.
- Streaming + mid-stream repetition detection —
generate_content_streamIS supported withresponse_schemain 1.12.1. A rolling-window detector (same ≥15-char slice repeated ≥4× in last ~100 tokens) +breakthe iterator could abort at ~2-4k tokens instead of 32k. ~94% output-token savings on detection. ~1 day of work. - Env-flagged model-swap fallback to
gemini-2.5-flash-preview-09-2025after two consecutive MAX_TOKENS failures. Forum reports it's more stable for the repetition bug specifically; explicitly non-GA. -
google-genaiSDK upgrade to ≥1.65 for nativeHttpRetryOptions(our own_with_retrybecomes redundant). 60-version jump from 1.12.1 — needs its own investigation cycle. - Batch API migration for non-interactive compiles — 50% discount on input + output tokens, 24h SLA. Session-compile is already fire-and-forget via n8n, matches the latency budget.
- Disambiguate result cache — Layer 3 entity disambiguation re-runs on every compile retry even when prior aliases were persisted. Cache by (session_id, sorted-pair-hash) to avoid re-spend.
References
- python-genai cookbook issue #1091 — retry.if_transient_error doesn't catch google-genai errors
- python-genai #782 — thinking_budget often ignored on 2.5 models
- python-genai #1984 — hallucinations/endless JSON (closed stale, no fix)
- Vertex AI Retry Strategy docs
- Gemini 2.5 implicit caching announcement
- Gemini Batch API docs
- json-repair library
Activity
tuirk commentedon Apr 18, 2026
Scope reduction — crossref no longer calls Gemini
Commit
fad98d6replaced the LLM-backed crossref with deterministic title-matching (wikilink-injector.ts + rewritten crossref route). Effect on this issue:Motivation wasn't purely the repetition loop — the trigger was a separate
HeadersTimeoutErrorat 12m50s on an 8-source compile. But once we looked at scale (real user with 1 860 bookmarks → ~500+ pages), LLM crossref wasn't viable regardless of which upstream bug hit it: ~20 sequential Gemini calls, ~$0.06 per session, wall time 10-20 min, fragile under any timeout layer. Research across MediaWiki LinkTitles, Obsidian Automatic Linker, Logseq, Roam, LangChain KG pipelines, Karpathy's LLM-wiki write-up — every production wiki uses deterministic title-matching for this problem. Aho-Corasick / regex-alternation is the right tool.Contradiction detection (the other half of the old crossref) dropped to 0-findings-per-session in committed runs, so it's deferred. Can be reintroduced as a targeted Gemini job on entity-overlap pairs if needed.
tuirk commentedon May 4, 2026
Status check 2026-05-04 — bug migrated upstream, salvage rate below original estimate
Today's failure (single 2-source compile, session 7170feee)
step=extract finish=STOP in=16303 out=2545)extract+extract-fallback, bothfinish=MAX_TOKENS, bothout=32K)Both attempts on source 2 hit the 32K output cap inside the repetition loop.
json-repairsalvage returnedNonefor both — the loop entered before any complete entity was emitted, so there was no valid prefix to recover. The halved-input fallback (50K → 25K) did not escape the loop. Net: source 2 dropped, no entity/concept pages produced (planner had only source 1 contributing → no cross-source signal → only the unconditionalsource-summarypages were created).Revising the salvage rate
The original commit (
3ebb275) claimed~90% of repetition-loop responsesrecover viajson-repair. Today's evidence shows 0/2 recovery on a content-dense source (95K chars, technical/structural document). The 90% figure looks accurate for short repetition tails on otherwise valid output, but does not hold for documents where Gemini enters the loop early in the entity list. Suggest revising the claim to "salvages tail-truncated responses; recovery rate drops sharply when the loop starts before the first complete entity."Upstream status — got worse
[unclear]tokens untilmax_output_tokens. (forum thread)google/langextractlibrary acknowledges the bug in their structured-extraction reference impl with no fix. No public ETA from Google for either family.New mitigation candidates (proposed additions to deferred follow-ups)
"1/2 sources extracted"whether the failed source was a 200-char tweet or a 95K-char article. Should read"1/2 sources extracted — failed: <title>". UX-only, ~1 line inapp/src/app/api/compile/run/route.ts.max_output_tokens32768 → ~12000 for the extract call. Caps the bleed when the loop hits — output cost drops by ~63% per failure. Aligned with Google's own guidance to setmax_output_tokenslow for character-limited applications. Tradeoff: legitimate dense extractions truncate sooner, relying onjson-repairsalvage. Need a baseline of typical successfulout=values to set the cap right (current data: ~2.5K on a 33K-char source — 12K leaves ~5× headroom).source_id, markextract-blockedin DB and skip on future retries until the user removes/re-uploads/edits. Prevents repeat burns on the same doomed source.thinking_budget=0for the extract call specifically. Issue Gemini 2.5 Flash structured-output repetition loop — upstream bug + client-side workaround #7 already notesthinking_budgetis "often ignored" on 2.5 models. But one report showed disabling thinking entirely changed output behavior dramatically (37 → 670 tokens) for a different failure mode. Worth a controlled A/B on extract calls before/after.Reprioritization
tuirk commentedon May 5, 2026
Status check 2026-05-05 — streaming detector failed Stage C precision
The previous comment moved "streaming + mid-stream repetition detection" from deferred to prioritized as the only structural fix. We built it, ran a multi-stage validation pipeline against a real corpus, and the result was negative — the detector cannot ship on Gemini in its current form.
What we built
nlp-service/services/repetition_detector.py— pure detector function (window=200, min_substr=12, min_repeats=4)nlp-service/tests/test_repetition_detector.py— 19 synthetic testsnlp-service/streaming_validation/harness.py— control (non-streaming) vs treatment (streamed + detector) on a 6-source corpusnlp-service/streaming_validation/debug_one_stream.py— chunk-level instrumentationStage A — passed
19/19 synthetic tests pass. Recall = 100% on known loop patterns (
"AI","AI","AI"...,\b\b\b\b..., delimiter-only loops, entity-shaped loops, valid-prefix-then-loop hybrids). False-positive rate = 0% on real entity-JSON samples extracted from prior successful production calls.Stage B — partial
The live run produced two intrinsic detector failures that re-tuning cannot fix:
Failure 1 — false positive on legitimate enumeration JSON (initial run):
The detector fired at chunk 2 (~220 chars) on the Vilnius corpus source. Cause: legitimate
mentions: ["Vilnius", "Vilnius Cathedral", "Vilnius Old Town", ...]arrays look exactly like a loop to the detector — same substring repeats the same number of times in close succession. Mitigation attempt: addedMIN_DETECTION_LENGTH=2000gate to skip detection on the very-early stream.Failure 2 — same root cause, larger window (after gate):
With the 2000-char gate, the Vilnius source no longer trips. But on the "Introduction to agent skills - Claude Code" source the detector fires at chunk 12 (~2400 chars) on
"Claude Code"repeating four times across legitimateentitiesandmentionsarrays. The pattern is the same: dense JSON that legitimately enumerates a small set of canonical names looks indistinguishable from a sampler loop at the detector's level.Stage C precision = 0% (target: ≥99%). The two failure modes above don't have a knob fix. Tightening
min_substrupward from 12 reduces synthetic recall (the actual"AI","AI","AI"loop has only ~6-char cycles); looseningmin_repeatsdownward worsens precision; lengtheningMIN_DETECTION_LENGTHdoesn't help once we're already past 2K chars and the legit enumeration is still going.Bonus finding — Gemini streaming has its own bug (separate from this)
The Vilnius source produces ~3,000 output tokens reliably under the non-streaming endpoint. Under
generate_content_streamwith the same prompt and config, the same source consistently truncates at ~1,485 output chars withfinish_reason=STOP. This is unrelated to the repetition loop and unrelated to the detector —streaming + response_schemaon 2.5-flash drops content before the model would naturally stop. Reproducible. Adds to the live-bug list (alongside livekit#4307 empty-final-chunks, #4706 empty-stream completions, the 400-INVALID_ARGUMENT thread).Reprioritization
New direction under investigation — provider migration
Tested DeepSeek V4 Pro side-by-side on the same corpus, including the 95K-char "Open Standards for AI Skills" source that Gemini cannot extract from. Results:
Investigation now sized at ~3-4 days of work to add DeepSeek as a second provider behind a settings toggle (Gemini stays the default). Plan in progress; not yet committed.
Updated deferred list
Streaming + mid-stream repetition detection— failed validation, dropped from prioritized listmax_output_tokens32768 → ~12000 for extract — bleed-cap mitigation, still relevant if we keep Geminigoogle-genaiSDK upgrade ≥1.65 — independenttuirk commentedon May 5, 2026
The pattern is not Gemini-specific
Cross-provider check (May 2026) confirms structured-output reliability bugs span the industry. The Gemini repetition loop documented here has direct counterparts elsewhere:
Same family — runaway output tokens until the budget cap:
\n\n\n\npadding via the Responses API. Reports show >80% of output tokens being padding on affected calls. community.openai.com/t/1295284Different mechanism, same reliability concern (silent schema breakage):
Reasoning: []text leaking into the JSON envelope, breaking strict parsers. promptfoo#5283Clean as of May 2026 — no documented repetition or schema-drop bug:
Implication for the workarounds in this issue: the salvage-and-retry layer in
_with_retryandextract_sourceis currently structured around Gemini's specific failure shape. Once the provider abstraction lands (#55 presupposes it), this should generalize into a per-provider robustness layer — each adapter declares which failure classes it's prone to (repetition loop, schema drop, padding bloat) and which salvage strategies apply. Locking the salvage code to Gemini's shape would force a parallel rewrite for every new provider.chore(docs): add 2026-05-05 multi-provider LLM plan + phase 1 spec
feat(llm): multi-provider abstraction + DeepSeek backend (Phases 1-5) (…
feat(settings): retire thinking_budgets — Gemini SDK ignores it (issue …
feat(nlp-service): YouTube direct-ingest via transcript-api + Data AP…
feat(ui): compile-model advisory + estimate range + onboarding polish
fix(compile): stranded-source recovery + YouTube direct-ingest + comp…
feat(llm): multi-provider abstraction + DeepSeek backend (Phases 1-5) (…
feat(settings): retire thinking_budgets — Gemini SDK ignores it (issue …
fix(compile): stranded-source recovery + YouTube direct-ingest + comp…
tuirk commentedon Oct 26, 2026
Closing. Since v0.3 the default compile model is DeepSeek V4.1 Flash, and installs that still run Gemini keep the client-side mitigations described above (retry with halved input, JSON salvage, connection retries). The daily cost cap is also now checked before every call, so a looping call can no longer keep spending past the cap. If you hit this on Gemini, switching the compile model in Settings avoids it. Feel free to reopen with a new trace.