Skip to content

fix(agent): stop degenerate output repetition during streaming - #78551

Open
enzo-adami wants to merge 3 commits into
NousResearch:mainfrom
enzo-adami:agent/stream-output-repetition-guard
Open

enzo-adami wants to merge 3 commits into
NousResearch:mainfrom
enzo-adami:agent/stream-output-repetition-guard

Conversation

@enzo-adami

Copy link
Copy Markdown
Contributor

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 main has 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

  • StreamOutputRepetitionGuard is provider/model neutral and has no MLX-specific wiring.
  • A sliding line window fires only when one of at most six distinct lines repeats at least eight times after 1,200 characters. Recurring status lines interleaved with varied report/log content remain below the density gate.
  • An exact periodic-tail check covers streams that never emit newlines. It runs only on unterminated text and at 256-character intervals.
  • Content and reasoning channels use independent guard instances.
  • The guard is wired into both Chat Completions and native Anthropic Messages streaming paths.
  • A guard termination reuses the existing partial-stream response shape, tags the response, truncates the repeated tail before persistence, removes reasoning/tool calls, and ends the turn without a synthetic continuation request.
  • No new environment variables or user-facing configuration surface.

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 main before implementation:

  • the 100-chunk stream was consumed and returned as an ordinary partial-stream stub;
  • no repetition module or terminal continuation branch existed.

Green after implementation:

  • repeated line-block detection;
  • split/periodic no-newline detection;
  • spaced legitimate repetition no-fire;
  • tail truncation keeps one copy and a visible cut marker;
  • Chat Completions content loop termination;
  • Chat Completions reasoning-only loop termination;
  • native Anthropic content loop termination;
  • conversation-loop proof that the tagged partial response makes exactly one API call and does not append a length-continuation prompt;
  • normal length continuation remains unchanged.

280 passed across:

tests/agent/test_stream_output_repetition_guard.py
tests/run_agent/test_streaming.py
tests/run_agent/test_run_agent.py

Ruff, py_compile, and git diff --check pass.

The repository test runner selected an older shared environment without the optional anthropic package and exposed three Anthropic test failures. The same three failures reproduce unchanged on a detached origin/main worktree. Running with the repository .venv (which includes anthropic) 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

  • Current-main premise reproduced before implementation
  • Existing sibling guards and open PRs audited
  • Behavior tests cover detection, false positives, persistence, and retry suppression
  • No model-specific code or new environment configuration
  • Tested on macOS Apple Silicon

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint provider/openai OpenAI / Codex Responses API provider/anthropic Anthropic native Messages API needs-decision Awaiting maintainer decision before any implementation labels Aug 4, 2026
@enzo-adami
enzo-adami force-pushed the agent/stream-output-repetition-guard branch from 4e4711d to 675fc11 Compare August 7, 2026 17:03
@enzo-adami

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (675fc112f); the branch is now zero commits behind.

Conflict resolved, both behaviours kept. main added separate_glued_reasoning_blocks() on the reasoning-delta path in agent/chat_completion_helpers.py, which is where this PR feeds the guard. The two changes are complementary, not competing — the guard is now fed the separated text rather than the raw delta, so it measures what actually accumulates into the stream:

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 main. I checked before re-proposing: there is no dedicated output-repetition guard under agent/ on current main, and the recent streaming work (f73457803, 458ce7b2b) targets a different failure — dropped/empty tool-call arguments on stream close, not degenerate output loops.

Tests, via scripts/run_tests.sh on the rebased branch: tests/agent + tests/run_agent = 521 files, 5337 passed, 1 failed. The single failure is tests/agent/test_credential_pool_routing.py::test_unmatched_key_does_not_retry_only_pool_entry, which is unrelated to this PR — it is the known isolation leak already tracked in #75230 with a fix pending in #75265, and it reproduces on pristine main on any machine that has a populated ~/.claude/.credentials.json.

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):

sample distinct n-gram ratio
one sentence repeated 0.085
three clauses cycling 0.126
bullet list, distinct items 0.319
source code 0.991
prose / numeric table 1.000

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.

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)
@enzo-adami
enzo-adami force-pushed the agent/stream-output-repetition-guard branch from c981f8c to 86d4799 Compare August 19, 2026 18:03
@enzo-adami

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (13ce0c5) — zero behind again.

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

@enzo-adami

Copy link
Copy Markdown
Contributor Author

Scope reduced (2026-09-13): this PR previously also carried agent/local_mlx_activity.py, a
cross-process FIFO queue for a local single-lane inference server. That file was local
infrastructure for my own machine, not something this project should carry, so it has been removed
from the branch history entirely. Nothing here imports it — the streaming guard never referenced it.

What remains is the streaming repetition guard alone: agent/stream_repetition_guard.py plus its
wiring in chat_completion_helpers.py / conversation_loop.py / context_compressor.py, and two
test files. 6 files, +1075/-23, and tests/test_stream_repetition_guard.py +
tests/run_agent/test_partial_stream_finish_reason.py pass (49 tests).

Note on overlap, for your triage: main already has agent/repetition_guard.py, which detects
repetition at the line level (is_repetition_dominated, MIN_FRAGMENT_LENGTH = 400). This PR
works on the stream as it arrives, to cut a degenerate generation before it burns the whole
budget. Adjacent concerns rather than the same one, but if you'd rather extend the existing module
than add a second one, say so and I'll rework it that way.

This branch is still behind main and will need a rebase before it can merge.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have provider/anthropic Anthropic native Messages API provider/openai OpenAI / Codex Responses API type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants