Skip to content

feat(agent): layered loop output detection with a graduated intervention ladder - #3390

Open
YodonTan wants to merge 20 commits into
GCWing:mainfrom
YodonTan:feat/agent-loop-detection
Open

YodonTan wants to merge 20 commits into
GCWing:mainfrom
YodonTan:feat/agent-loop-detection

Conversation

@YodonTan

Copy link
Copy Markdown
Contributor

What

Detects looping/degenerate model output — the chanting, near-repetition, stuck-connector, periodic-sequence, predictable-drift, thinking-stagnation and reasoning-runaway patterns that burn tokens invisibly (thinking loops are folded in the UI) — and intervenes on a graduated ladder instead of hard-blocking.

  • L1 heuristics (pure, no IO): rolling-hash exact blocks, multi-scale n-gram Jaccard, trailing-period detection (k≤5), an LZ77-style compression-redundancy proxy, a predictable-drift classifier, connector phrase-stuck counting, adjacent thinking-block similarity, and a reasoning-token usage bypass for encrypted thinking. Every threshold is a config field (47 in the detector + 12 user-facing overrides + a profile×model-tier scale where weaker models get MORE conservative thresholds). Structural exclusions (fenced code, tables, lists, headings, quotes, hr) keep legitimate repetition out of the signals.
  • Intervention ladder: ai.loop_detection.mode = Off | Telemetry | Enforce, default Telemetry (report-only calibration first). Enforce steers first — a root-cause-oriented OutputLoopRecovery/ThinkingLoopRecovery internal reminder that never echoes the looped text — and hard-stops only after 3 unheeded steers (finalization_reason="loop_detected", same shape as the existing repeated_tool_failures). Capped kinds (predictable drift, reasoning runaway) never hard-stop on heuristics alone. Per-session escape hatch included.
  • L2 judge (arbitration): when heuristics are uncertain, a fast-model arbiter (ai.default_models.fast, fallback primary) rules on summary-only evidence (≤3×80-char excerpts — never full text), with self-arbitration avoidance, ≤2 verdicts/turn, 30s timeout, and a confidence gate (judge_verdict_confidence_min, default 0.9; an answer without usable confidence never escalates). Judge tokens land in the global usage ledger.
  • Surfaces: AgenticEvent::LoopDetectionNoted (kind/confidence/escalation/judge_arbitrated) → web notice (steered/stopped only) + specific loop_detected completion notice, CLI status lines (chat + exec, parent task title for subagents), relay control record for remote controllers. ContextHealthSnapshot carries the telemetry.
  • Philosophy: follows the repo's "Agent loop behavior" clause — no default blocking by string/count, thresholds are configuration not constants, telemetry-first, and the feature doc maps every design choice to the clause: docs/features/agent-loop-detection.md.

Verification

  • Engine-level: 8 new tests drive REAL rounds through RetryTestServer into the production drive_loop_detection — steer reminder lands in messages AND persists, exactly one event per decision, turn continues while steered, 3 strikes → loop_detected completion (success=true), thinking-loop variant, Telemetry reports without injecting, Off never feeds detectors, session hatch restarts detection. Focused suite agentic::execution:: → 250 passed (loop_detection 52 + turn tests 8 + loop_arbiter 26 + model_arbiter 10 + pre-existing).
  • Detector core: 135 agent-stream tests (67 loop_detection: 7 kinds × positives, 13 negatives incl. every structural exclusion, determinism/chunking invariants). Performance: ingestion ~0.6µs/64-char chunk, full 16KB-window analysis 0.57–0.70ms, stride-throttled at 4KB; detection runs ONLY at round boundaries (never per-delta).
  • Judge: 26 tests (model chain, avoidance incl. same-provider-model step-down, budget, malformed-reply tolerance, timeout/cancellation). Config/policy: 103 + 24 (legacy tolerance, three-mode enum with unknown→Telemetry degradation). Events: 33; relay control record: 4; CLI: 33; web: 76 (type-check clean).
  • Independent review: pass on philosophy/hot-path/exclusions/judge-safety/upgrade-compat after 4 findings fixed (engine e2e coverage added; two doc contradictions corrected; deviation list completed). check:core-boundaries green; rustfmt clean; 0 merge conflicts vs main at review time.

Not verified / known gaps (documented in the feature doc's 12 deviations)

  • Detection is round-quantized: a running round cannot be preempted mid-flight; steer lands at the next round boundary.
  • DITTO guard is prompt-level: looped reasoning is still replayed in subsequent requests (the reminder never echoes it).
  • The reasoning-token bypass only fires where providers populate reasoning_token_count — today that is Gemini only; OpenAI/Anthropic adapters don't map it (follow-up).
  • Judge usage reaches the global ledger but not the turn's live TokenUsageUpdated event; mobile-web/bots render a loop-stopped turn as normally completed (success=true, same as max_rounds); web subagent bubbling is log-level.
  • Calibration itself is a runtime activity: with Telemetry as the default, real sessions produce the verdict statistics the doc's calibration plan describes; flipping the Enforce default is a separate decision after that data exists.

Tant added 20 commits October 15, 2026 02:41
Add ai.loop_detection with the three-mode ladder Off/Telemetry/Enforce,
optional per-signal threshold overrides, judge settings, and the
per-session detection escape hatch.

The default stays Telemetry, the report-only calibration mode, so loop
detection earns the right to intervene with measured false-positive
rates before Enforce becomes a default; Off remains user-selectable as
the escape hatch. Every field carries a tolerant default, an unknown
mode string from a newer build degrades to Telemetry instead of failing
configuration deserialization, and the default section is omitted from
the persisted document so existing config files are unchanged.
Extend ContextProfilePolicy with a loop-detection tier next to the
existing threshold table: a threshold scale per (profile x model
capability) plus the canonical structural exclusion list (code blocks,
tables, lists, headings, quotes, horizontal rules).

Weak models are strictly more conservative in this tier (higher
thresholds, fewer false positives), the opposite direction from the
context-compaction knobs, and no tier may drop an exclusion. The policy
stays pure data, so the detector keeps owning its baselines and its own
configuration type while wiring scales them and passes the exclusions
through. Numeric- and alpha-sequence predictability stays with its own
classifier, and tool-level repetition stays out of the text exclusion
set.
Implement the detector core of the agent loop-detection plan as a new
provider-neutral module in openbitfun-agent-stream.

- LoopDetectorConfig carries every threshold as a field, with the plan's
  initial values as defaults and a sanitizing pass for policy misconfiguration.
- OutputLoopDetector: rolling-hash exact-block index, multi-scale character
  n-gram window Jaccard, short-period detection, connector-phrase stalling and
  a dependency-free LZ77 compression proxy. All state is feedable and cloneable
  through LoopDetectionState; feed() is strided and analyze() forces a pass.
- ThinkingLoopDetector: adjacent-block similarity, connector stalling inside
  thinking blocks and the usage bypass for opaque reasoning, whose confidence is
  capped so it can only steer.
- Markdown structure exclusion list (fenced code, tables, lists, headings,
  quotes, thematic breaks) is applied by dropping excluded lines during
  normalization, so legitimate repetition inside them cannot form blocks,
  n-grams, connector runs or compression evidence.
- Predictable enumerations (numeric and letter series) are classified through a
  counter-progression classifier and reported as their own low-confidence kind
  instead of a loop.

No IO, no await, no globals, no clocks: token deltas and chunk boundaries are
inputs. The compression proxy deviates from the plan's "zlib ratio" wording
because adding flate2 to this dependency-light stream crate is not worth it; the
proxy is deterministic and documented as conservative.
…ests

Cover the detector core with a deterministic synthetic corpus.

Positives: irregular verbatim phrase repetition, inline chanting, near
repetition with controlled variation, a ~936-token block repeated four times,
connector stalling (single token and alternating n-gram), periods of one to
five units, numeric and letter counter series, adjacent thinking stagnation,
identical thinking blocks, connector stalling inside thinking, and the
reasoning-token usage bypass.

Negatives: every entry of the plan's false-positive exclusion list (repeated
code lines in fenced blocks, table rows, numeric tables, list labels, headings,
quotes, thematic breaks) plus batch labels with a constant counter, legitimate
polling status lines, ordinary prose with a repeated sentence, and short or
empty input.

Invariants: identical verdicts for repeated runs and across chunk boundaries,
kind-scoped dedup, reset semantics, detection after window pruning, stride
throttling of analysis passes, state snapshot/resume, sanitized misconfiguration
and compression redundancy availability.
…tion

Product-side wiring for the agent loop-detection plan.

- loop_detection::policy translates the agent-runtime tier table plus the
  configured threshold overrides into the detector's own configuration. Ratio
  thresholds scale in the tolerance domain: multiplying a 0.85 similarity by
  the Conversation tier scale would leave 0.0..=1.0 and mute the signal.
- loop_detection::ladder is a pure intervention state machine (L0 telemetry,
  L1 steer, L2 hard stop after three unheeded steers) with the plan's
  asymmetric kind policy: capped kinds may nudge but never abort on heuristics
  alone. Steer copy cites the signal and never echoes the repeated text.
- loop_detection::arbiter defines the L2 arbitration port and the default
  no-arbiter implementation; runtime.rs holds the installed port and the
  per-session clear-detection escape hatch.
- InternalReminderKind gains ThinkingLoopRecovery / OutputLoopRecovery and
  drops them during compaction like their recovery siblings.
Add the standalone L2 arbitration layer of the agent loop-detection plan as
`agentic::execution::loop_arbiter`: a fast-model judge over ambiguous L1
evidence, intentionally not wired into the engine.

- Judge model chain follows `ai.default_models.fast` then `primary` through the
  runtime's own model-resolution helpers, and skips any candidate that is the
  monitored turn's own model, including a second configuration entry for the
  same provider model.
- The prompt carries only bounded summary evidence (signal kind, confidence,
  statistics, at most three 80-character excerpts); the reply contract is a
  small JSON verdict parsed defensively.
- One provider attempt per arbitration, capped by a timeout and the dialog
  turn's cancellation token; every unusable path returns `Indeterminate` so the
  heuristic verdict stands and the judge can never break the L1 path.
- Per-session current-turn budget from
  `ai.loop_detection.judge.max_verdicts_per_turn`; a disabled judge never
  consults the model.
- Arbitration usage is reported through the existing auxiliary-call token usage
  path and returned to the caller for live turn accounting.

26 focused tests cover model resolution, avoidance, prompt purity, reply
parsing, budget accounting, usage reporting, cancellation, and timeout.
Observation and intervention wiring for the agent loop-detection plan.

- ExecutionEngineConfig reads ai.loop_detection into LoopDetectionSettings, so
  the mode, session default, judge budget, and threshold overrides reach the
  engine through the existing global-config assembly path.
- Each dialog turn owns one LoopInterventionLadder: its detector windows,
  thinking-block memory, and attempt counters survive the turn's rounds without
  leaking into the next turn. Thresholds come from the context-profile tier for
  this agent and model.
- After every round the engine feeds the round's visible text, its completed
  thinking block, and its usage deltas (reasoning growth versus visible
  completion), consults the arbitration port when the ladder asks for it, and
  applies the result: a steer becomes a persisted internal reminder at the round
  boundary (LoopRecovery precedent), a hard stop ends the turn with
  finalization_reason=loop_detected and asks for a final answer.
- ContextHealthSnapshot carries loop-detection telemetry and always logs a
  verdict, which is what the report-only calibration default needs.
- ExecutionEngine exposes install_loop_arbiter and the session-scoped
  clear-detection escape hatch; mode off and a closed escape hatch skip the
  detectors entirely.
The execution guide now names the focused loop-detection suite and the
ladder's invariants: observation-first logging, boundary steers, the
three-attempt hard fallback with finalization_reason=loop_detected, the
high-confidence-only hard stop, and turn-owned detector state.
…turn

The engine re-reads the per-session clear-detection flag every round, so a
mid-turn clear stops detection immediately and drops the detector state instead
of waiting for the next turn.
Loop detection reported only through context-health logs, so no product surface could react to a verdict. Add AgenticEvent::LoopDetectionNoted, emitted once per ladder decision at the round boundary with the detector kind, confidence, escalation level (noted/steered/stopped) and whether the L2 judge arbitrated. The escalation level tolerates an unknown value from a newer host by degrading to the least invasive reading.
Give finish_reason=loop_detected its own turn completion notice instead of the generic abnormal notice, and consume the new event: a non-blocking toast when the runtime steers or stops a turn, silence for report-only calibration decisions, and a debug log with the resolved parent session when the signal came from a hidden subagent. Adds the flow-chat locale keys for en-US, zh-CN and zh-TW.
Exec mode prints one status line for a steer or hard stop (turn-scoped, so another turn's note is ignored) and keeps report-only decisions out of the terminal. Interactive chat renders one line per intervention: a steer from the event, a hard stop from the loop_detected finalization reason so a stopped turn never prints twice. A subagent's loop bubbles to the parent Task card through the existing child-event forwarding.
A loop detection note is a live status signal, not transcript content: remote controllers still receive it, but it no longer rebuilds the same turn record on every looped round during a report-only calibration run.
The round-boundary chain (observe, steer, hard stop) existed only as production
code: no test ever ran ExecutionEngine::drive_loop_detection. Add engine-level
tests that drive the production mounting point with real scripted rounds served
through the round executor's provider fixture, a real session manager, a real
context store, and a real event queue. They assert that a repeating round
injects the OutputLoopRecovery reminder into the round's messages, persists it in
the session transcript, emits exactly one LoopDetectionNoted{Steered} per
decision, keeps the turn alive, and that after three unheeded steers the turn
ends with finalization_reason=loop_detected. Further cases cover the thinking
source (feed_block -> ThinkingLoopRecovery), telemetry (report without
injecting), off mode (detectors never fed), and the session escape hatch
mid-turn.

To make the turn-level decisions assertable without the process-global AI client
factory, config service, agent registry, and tool manager a full turn needs,
extract the behavior-preserving helpers the loop already performed inline:
loop_ladder_for_turn, finalize_reminder_for, turn_success, and
LoopRoundDecision::turn_effect (a steer keeps the turn alive, a hard stop
finalizes it with the loop-detection reason).

Also widen the round_executor test fixture test_round_context to
pub(in crate::agentic::execution), matching test_round_executor and
RetryTestServer, so engine-level tests can reuse it.
The module docs claimed a bare counter series with no repetition candidate is not reported. The detector does report it as predictable_drift when the trailing window holds a progression run, precisely so a caller can read a repeating-looking window as an enumeration instead of guessing why nothing fired. Describe both paths: the progression that explains an otherwise-repetitive region (the repetition candidates stay silent for that analysis) and the bare series that is reported on its own at the deliberately low predictable_drift_confidence. No behavior change.
The feature page understated the compression lever: inside the ambiguous
0.85-0.90 near-repetition band, compression corroboration
(compression_min_redundancy, override window_compression_ratio_min) is the only
detector-level factor that promotes a verdict into the strong band (0.88
confidence, no arbitration, hard-stop eligible without a judge). Record it in
the L1 signal section and in the tuning list, so the calibration phase treats
the knob as high-leverage instead of as a supporting statistic.

Also record the upgrade-compatibility property of the two loop-steer reminder
kinds: they persist into session history, and InternalReminderKind has no
catch-all variant, so a build that predates them fails to deserialize that field
of such a session. That is the enum's existing property, kept as-is, and a
tolerant catch-all would be a separate contract change.
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