fix(agent): stop degenerate output repetition during streaming - #78551
enzo-adami wants to merge 3 commits into
Conversation
4e4711d to
675fc11
Compare
|
Rebased onto current Conflict resolved, both behaviours kept. reasoning_text = separate_glued_reasoning_blocks(
reasoning_parts[-1] if reasoning_parts else "",
reasoning_text,
)
# Feed the guard the separated text, not the raw delta: the
# guard measures what actually accumulates into the stream.
reasoning_repetition_guard.feed(reasoning_text)Still not covered by Tests, via One design note that may be useful to reviewers. While evaluating whether to extend the guard with a character-entropy signal, I measured entropy against real degenerate output and it does not discriminate: a single sentence repeated until it fills a 400-character window scores 3.72 bits, comfortably above any threshold that would leave ordinary prose alone. Distinct n-gram overlap separates the cases far better (same window, 12-char n-grams):
I did not add that check here: every synthetic case I could build with a ratio under a safe threshold was already caught by the existing periodic-tail check, so the extra surface bought no demonstrable coverage. Recording the measurement in case it saves someone the same experiment, and as evidence that the current two-signal design is the right scope. |
0a013ad to
c981f8c
Compare
Re-port after the 2026-08-04 updater reset dropped the local guard lineage. Module agent/stream_repetition_guard.py + tests restored verbatim from backup/pre-upstream-merge-20260715 (windowed dominated-line detection, env knobs HERMES_STREAM_REPETITION_*). Hooks re-anchored on the v0.20 stream path: content+reasoning channel feeds, StreamRepetitionLoopError → tagged partial stub (finish_reason=length) in the post-worker, and the degenerate-truncation gate in the length branch so auto-continue never re-runs a poisoned context (incident 2026-07-02 class). Cross-turn narrative detector NOT re-ported yet — tracked as follow-up. (cherry picked from commit 6f857e8a8e7319e534da9a352385a51fc5a230cd)
…tition-guard kills A stream killed by the repetition guard returned a PARTIAL_STREAM_STUB_ID stub with finish_reason=length, so the continuation machinery injected the network-error nudge — 'Continue exactly where you left off' — the one instruction a degenerating model must not receive. Observed on the telegram gateway (2026-08-16, Qwen3.6-35B fallback): every nudge resumed the same repeated tail, the guard killed the stream again, and turns burned all 4 continuation retries accumulating FAILED_REPETITION_LOOP markers in the persisted transcript. - chat_completion_helpers: stamp _repetition_terminated on the stub (same idiom as _content_filter_terminated). - conversation_loop: dedicated repetition pivot stub; second guard kill in the same turn keeps the partial instead of nudging again; stitched final/partial responses are stripped of guard markers so the poisoned tail never re-enters later context. - context_compressor: recognize the new stub as synthetic scaffolding. (cherry picked from commit 01dc3d3f1748dfa60bf81bb5ed2547d044775b62)
c981f8c to
86d4799
Compare
|
Rebased onto current Also folded in the natural companion fix: after a repetition-guard kill, the runtime used to inject the generic "network error / continue where you left off" stub, which re-triggered the exact loop the guard had just cut (observed 5 kill cycles in one session). The second commit pivots to a dedicated stub (capped at 2 kills/turn, then keep-partial) and strips FAILED_REPETITION_LOOP markers from persisted content. Guard + pivot ship together because the pivot is what makes the kill terminal instead of cyclical. 🤖 Generated with Claude Code |
a2eeeb6 to
9b7e32e
Compare
|
Scope reduced (2026-09-13): this PR previously also carried What remains is the streaming repetition guard alone: Note on overlap, for your triage: This branch is still behind |
9b7e32e to
55764e1
Compare
What does this PR do?
Stops a single assistant response when its token stream degenerates into a repeated tail, before the provider spends the full output budget and Hermes requests up to four continuations on the poisoned partial response.
This is distinct from #67538 (identical assistant turns) and #60087 (repeated tool results): both operate between completed turns or tool calls. This guard acts inside one in-flight model response.
A real unattended local-provider incident produced the same three-line block until the output cap, then repeated that behavior across automatic length continuations. Current
mainhas no stream-level content guard: an OpenAI-compatible stream with 100 repeated chunks is fully consumed, classified as a partial stream, and routed toward continuation.Design
StreamOutputRepetitionGuardis provider/model neutral and has no MLX-specific wiring.Why the default is conservative
The guard does not compare semantic similarity and does not count global occurrences. It requires a locally dominated tail or an exact repeated character period, after a minimum output size. Long diverse output with a recurring status line is covered as a no-fire regression case.
Tests
Red on current
mainbefore implementation:Green after implementation:
280 passedacross:Ruff,
py_compile, andgit diff --checkpass.The repository test runner selected an older shared environment without the optional
anthropicpackage and exposed three Anthropic test failures. The same three failures reproduce unchanged on a detachedorigin/mainworktree. Running with the repository.venv(which includesanthropic) gives the 280/280 result above.Hot-path microbenchmark on 10,000 diverse newline-terminated chunks: approximately 5-8 microseconds per chunk on Apple Silicon.
Checklist