Skip to content

Guardrail retry: recover from pre-content degeneration with safe settings#1925

Open
mimeding wants to merge 5 commits into
osaurus-ai:mainfrom
mimeding:mlx-perf/guardrail-retry
Open

Guardrail retry: recover from pre-content degeneration with safe settings#1925
mimeding wants to merge 5 commits into
osaurus-ai:mainfrom
mimeding:mlx-perf/guardrail-retry

Conversation

@mimeding

@mimeding mimeding commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

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 disableDraftStrategy knob threaded to the adapter). The re-run's prefill is cheap via the prefix cache. Attempt 2 splices onto the same outer stream — consumers of streamWithTools/respondWithTools (chat, agents-run, plugin host) see one stream; the legacy /v1/completions raw-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

mimeding and others added 5 commits July 7, 2026 13:14
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>
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