Repository navigation
Conversation
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.
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
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.
ai.loop_detection.mode=Off | Telemetry | Enforce, defaultTelemetry(report-only calibration first). Enforce steers first — a root-cause-orientedOutputLoopRecovery/ThinkingLoopRecoveryinternal reminder that never echoes the looped text — and hard-stops only after 3 unheeded steers (finalization_reason="loop_detected", same shape as the existingrepeated_tool_failures). Capped kinds (predictable drift, reasoning runaway) never hard-stop on heuristics alone. Per-session escape hatch included.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.AgenticEvent::LoopDetectionNoted(kind/confidence/escalation/judge_arbitrated) → web notice (steered/stopped only) + specificloop_detectedcompletion notice, CLI status lines (chat + exec, parent task title for subagents), relay control record for remote controllers.ContextHealthSnapshotcarries the telemetry.docs/features/agent-loop-detection.md.Verification
RetryTestServerinto the productiondrive_loop_detection— steer reminder lands in messages AND persists, exactly one event per decision, turn continues while steered, 3 strikes →loop_detectedcompletion (success=true), thinking-loop variant, Telemetry reports without injecting, Off never feeds detectors, session hatch restarts detection. Focused suiteagentic::execution::→ 250 passed (loop_detection 52 + turn tests 8 + loop_arbiter 26 + model_arbiter 10 + pre-existing).check:core-boundariesgreen; rustfmt clean; 0 merge conflicts vs main at review time.Not verified / known gaps (documented in the feature doc's 12 deviations)
reasoning_token_count— today that is Gemini only; OpenAI/Anthropic adapters don't map it (follow-up).success=true, same asmax_rounds); web subagent bubbling is log-level.