Guardrail retry: recover from pre-content degeneration with safe settings#1925
Open
mimeding wants to merge 5 commits into
Open
Guardrail retry: recover from pre-content degeneration with safe settings#1925mimeding wants to merge 5 commits into
mimeding wants to merge 5 commits into
Conversation
Two failure classes previously streamed unchecked until the token budget ran out: degeneration loops (character runs like "!!!!!…" and phrase loops like "idea idea idea…") and chat-template special tokens leaking into user-visible content. Both are now caught at generation time inside GenerationEventMapper. Degeneration (abort): StreamDegenerationDetector is an incremental port of the CLI gauntlet's batch-mode DegenerationDetector with identical thresholds (3–12-token n-gram repeated ≥8x consecutively, or a single character run ≥64) — the two sides must stay in sync. It observes both .tokens and .reasoning deltas (loops routinely start in the thinking channel) over a bounded ~4 KB tail (covers the worst-case 12-token × 8-repeat window for tokens averaging ~42 chars, so memory never grows with stream length) plus a cross-delta character-run counter. On trigger the mapped stream finishes with the typed StreamGuardrailError.degeneration(fragment:) and one log line, so callers surface a real failure instead of garbage forever. Abort-only by design: retry-with-safe-settings needs a caller-side policy and is a follow-up. Template leak (log-only): content deltas — not reasoning — are scanned for a fixed special-token list with a bounded carry so tokens split across delta boundaries still match. A leaked marker means the template fallback mismatched the family: data for the capability ledger, never something to hide by filtering, so output is never mutated. <think> is deliberately excluded (divergence from the CLI gauntlet's list) because some families legitimately emit it in content on the fallback path. Both guardrails are flagged (ai.osaurus.guardrails.degenerationDetection / .templateLeakDetection, UserDefaults Bool) and default ON: detection+abort is strictly better than unbounded garbage, and leak logging is free. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…el degeneration Caller-side follow-up to the live output guardrails: on StreamGuardrailError.degeneration, ModelRuntime.streamWithTools / respondWithTools now splice in ONE retry attempt (DegenerationRetry.withRetry) instead of surfacing the error — but only when the abort happened before any content-bearing event (.tokens / .toolInvocation / .toolCallProgress) reached the outer stream, i.e. the loop ran in the reasoning channel or entirely inside the first (never-yielded) content delta. Once loop text has reached the consumer, transparency is impossible and the error propagates unchanged. The retry re-runs with sampling forced on (temperature max(effective, 0.7), repetitionPenalty 1.1 when the request set none, samplingParametersAreImplicit false) and the draft strategy disabled for the attempt via a new generateEventStream(disableDraftStrategy:) knob. Prefill re-runs but the vmlx prefix cache makes that cheap. Flag: ai.osaurus.guardrails.degenerationRetry, default ON; off restores the plain abort path byte for byte. Telemetry: "[Osaurus] guardrail: retrying model=... after reasoning-channel degeneration (fragment=...)" plus retry succeeded/failed outcome lines. Tests: DegenerationRetry splice suite (mapper-level fake Generation streams: transparent retry, content-yielded no-retry, tool-yielded no-retry, second degeneration propagates, retry-start failure propagates, non-guardrail errors untouched, flag off, clean pass-through) + DegenerationRetry policy suite (safe-settings rebuild, flag resolve). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review findings on the default-ON live-output guardrail:
the char-run rule (>=64 of ANY character, whitespace included) aborted
markdown dividers, table padding, base64 blobs, and "print 100 !"
requests, and the n-gram rule's 8-repeat bar aborted a user-requested
"repeat this 10 times". Minimal fixes:
- Whitespace is exempt from the char-run rule entirely (indentation,
table padding, blank runs are always legitimate) and breaks any
tracked run.
- Per-class char-run thresholds: 64 stays for letters/digits, 256 for
punctuation/symbol characters (dividers and requested bursts live at
64-100; a real decode loop blows far past 256).
- A run only triggers when it has GROWN in at least 3 distinct
observe() calls AND exceeds its per-class threshold — a single-delta
paste of an arbitrarily long divider or base64 run is completed
content, not a stuck decoder, while the documented "!!!!..." loop
still fires as it dribbles out over many small deltas.
- minConsecutiveRepeats raised 8 -> 12: the documented failure loops
repeat hundreds of times, so detection stays fast, while ten
requested repetitions clear the bar with margin.
The "KEEP IN SYNC with the CLI gauntlet" wording is replaced: the
thresholds now intentionally DIVERGE from the gauntlet's batch detector
(aborting a live stream needs anti-false-positive margins; the gauntlet
judges whole transcripts after the fact).
Tests pin the legitimate shapes as non-aborting (80-char "-"/"="
divider in one delta, requested 100-"!" burst, 10x repeated sentence,
200-char base64-style run, long whitespace runs, sub-threshold
punctuation runs across deltas) and the true positives as still firing
("!" run growing across many small deltas past 256/300+, "idea" x50,
letter runs past 64 across deltas), at both the detector and the
GenerationEventMapper wiring level.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The guardrails hardening raised minConsecutiveRepeats 8->12, so a period-1 loop needs >=36 unigram repeats to trigger via 3-grams; the 30-repeat fixtures fell below the floor. 60 keeps comfortable margin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
What
Retry-once-with-safe-settings when a stream aborts on
StreamGuardrailError.degeneration(#1922's noted follow-up) — turning a detected repetition loop into a hiccup instead of an error, only when honesty permits:Retry fires iff: the flag is on (
ai.osaurus.guardrails.degenerationRetry, default true) AND it's the first attempt AND no content-bearing event has reached the consumer — content-bearing includes non-empty content deltas and tool events, so a retry can never double-dispatch a tool. Reasoning deltas and prefill progress don't block retry, so the dominant real case (thinking-channel loops like the documented!!!!!) stays eligible; and because the mapper checks the detector before yielding, a loop contained in the very first content delta is also caught with zero content leaked. Degeneration after content, second degenerations, and non-guardrail errors propagate unchanged — a "transparent" retry after the consumer saw output would be a lie, so we don't do it.Retry settings: temperature floored at 0.7 (greedy loops break under sampling; a hotter request is never cooled), repetition penalty 1.1 when none was set, draft strategy disabled for the attempt (new
disableDraftStrategyknob threaded to the adapter). The re-run's prefill is cheap via the prefix cache. Attempt 2 splices onto the same outer stream — consumers ofstreamWithTools/respondWithTools(chat, agents-run, plugin host) see one stream; the legacy/v1/completionsraw-text path is deliberately not wrapped in v1, with the honest "model started over" reasoning restart. Telemetry: retrying/succeeded/failed log lines.Stacked on #1922.
Why
The guardrail's abort (#1922) protects users from unbounded garbage but still surfaces an error for a failure the runtime can usually fix itself — the historical degeneration cases were greedy/near-greedy decode latching onto a loop, exactly what a sampled retry with a repetition penalty escapes. Retrying invisibly is only legitimate before any output reached the consumer; this PR draws that line explicitly and tests it.
Measurement
65 tests across 6 suites green (9 new splice tests: transparent retry, first-content-delta eligibility, content/tool-yielded no-retry, second-degeneration propagates, retry-start-failure propagates, non-guardrail errors untouched, flag off, clean pass-through byte-exactness; plus safe-settings policy tests). Build + changed-line lint clean.
Risk & rollback
Worst case is one extra attempt's latency on an already-failing request, bounded by retry-once; flag restores pure abort behavior; clean streams are pass-through (asserted). Revert = one commit.
🤖 Generated with Claude Code