omp packages

pi-antiloop1.8.1

Antiloop: detect reasoning loops and force a break (warn → force → abort) across text, tool, thinking, structural, degenerate-repetition, block/narration-repetition, and no-progress outcome patterns. Degenerate (one message stuck repeating a word hundreds

GitHub Actions Published 25 days ago MIT ~962 downloads/mo

Untested on omp

This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.

Did it work for you?

Result

One report per person. A new report replaces your old one.

Reports 0

No reports yet.

Readme

Antiloop banner

Antiloop — Loop Detection and Break for pi

Antiloop watches every assistant message, tool call and thinking block, and forces the model out of reasoning loops before they eat your context and your patience. Seven simultaneous detection strategies (text similarity, tool-call sequences, thinking content, structural openings, degenerate repetition, block/narration repetition, no-progress outcome runs) find loops that humans miss — and progressive intervention (warning → force break → abort) tells the model to take a different approach, without you having to babysit it.


Features

  • Seven detection strategies — text repetition (trigram Jaccard + Levenshtein), tool-call sequences (name + near-identical arguments + same outcome — result-aware, so retries that make progress don't false-positive), thinking blocks, structural opening-phrase patterns, degenerate repetition (a single message/call stuck repeating one word hundreds of times — the lorem ×5145 meltdown — caught at message_end with no repeated peer needed, and degenerate bash commands blocked before they execute), block repetition (ONE message replaying whole sentences/phrases — the narration loop: Let me start by checking the environment… ×5 — same message_end timing and strong turn weight), and no-progress outcome runs (the NFS test A…QQQ class: dozens of near-identical re-runs of the same experiment, every one ending in the same failing outcome — args mutate so tool-loop can't see it; the repeated failure signature can)
  • Stays alive in long sessions — tracked messages carry a monotonic sequence number, so trimming the sliding window can never make the dedupe guard skip later messages (a bug that silently blinded antiloop after ~15 tracked messages)
  • Task-stream recognition (batch work) — when another extension (e.g. punched appending lines to pi.md, or plan adding tasks) makes the model call the same tool many times with different content, antiloop recognizes it as N distinct tasks of one type and stays silent — no warning, no force break. A genuine loop (the same call repeated verbatim) is still caught
  • Snapshot/read-only tool exemption — a coordinator closing N agents that already finished calls trimegisto_harvest once per agent and gets the same settled board every time; identical reads of a status snapshot are an idempotent serial close, not a loop, so snapshotTools (configurable) is exempt from tool-loop and no-progress detection — real bash loops next to it are still caught
  • Progressive intervention — warning reminds the model to vary its approach; force break steers a real break message into the running agent before its next LLM call; abort stops the run entirely
  • Configurable thresholds — independent dials for similarity cutoff, warning/force-break/abort counts, detection window, and which strategies are on
  • Sliding window — only the last N messages are compared, so detection is O(N) in the window size, not in the full session
  • Live footer indicator — 🔄 antiloop(on|off) in the footer, per spec, with the current level (⚠️/🛑/🚨) and consecutive count; an interactive TUI footer adds a keyboard toggle (esc+a by default, configurable/off, honored only while idle) and preserves the built-in footer's pwd/branch/context/model info
  • Detection log — timestamped history with similarity scores, filterable through the native pi menu
  • Self-test — /antiloop test runs built-in cases to verify the similarity engine is calibrated
  • User input softens detection — each new user message decays the consecutive counter so a fresh prompt can resolve the loop without manual reset
  • Bilingual-friendly — no language assumptions in the comparison (only whitespace + punctuation normalization)

Install

Antiloop is a pi package: one extension (src/index.ts) declared in package.json.

# From GitHub
pi install git:github.com/noguerol/antiloop

# Pin a tag/commit
pi install git:github.com/noguerol/antiloop@v1.0.0

# From npm
pi install npm:pi-antiloop

# Local checkout (development)
pi install /path/to/antiloop

# Try it for one run only
pi -e git:github.com/noguerol/antiloop
pi list                    # show installed packages
pi remove npm:pi-antiloop

Security: pi packages run with full system access — extensions execute arbitrary code. Install only packages you trust and review the source.

Requirements: a working pi installation. Works with every model — including small local ones that get stuck easily. Zero external dependencies.

Quick Start

/antiloop            # toggle on (enabled by default)
/antiloop status     # check state, configuration, recent detections
/antiloop config     # adjust thresholds to taste

That's it. Antiloop is on by default. If the model ever starts repeating itself, you'll see a ⚠️ warning; if it keeps looping past the force-break threshold, antiloop steers a break message into the running agent before its next tool call — and if the model ignores it and keeps repeating verbatim, antiloop aborts the run. Loops always terminate.

Commands

Command Description
/antiloop Toggle on/off
/antiloop enable / /antiloop disable Explicit enable/disable
/antiloop status Show current state, configuration, and recent detections
/antiloop config Open interactive configuration menu
/antiloop log Show detection history (last 30, newest first)
/antiloop reset Clear all counters and history
/antiloop test Run a self-test of the similarity engine

/antiloop status

Example output:

State: ✅ ENABLED
Current level: warning
Consecutive detections: 2
Total detections: 5
Messages tracked: 8
In forced break: no

Configuration:
  Warning threshold: 2 similar messages
  Force break threshold: 3 similar messages
  Abort threshold: disabled
  Similarity threshold: 75%
  Detection window: 10 messages

Detection strategies:
  Text loops: ✅
  Tool loops: ✅
  Thinking loops: ✅
  Degenerate: ✅
  Block repetition: ✅
  Outcome (no-progress): ✅

Recent detections:
  [text] Text similarity 85% with message 3 (2m ago)
  [tool] repeated 3x: bash (5m ago)
  [degenerate] bash command: degenerate repetition — "lorem" ×5145 (5m ago)
  [block] message text: block repetition — 100% of 450 tokens replay repeated 5-word phrases (5m ago)
  [outcome] no progress: 9 near-identical bash attempts with the same failing outcome (5m ago)

/antiloop config

Grouped interactive menu showing the current value in each option:

🎛️ General

  • 🟢/🔴 enable/disable — turn detection on or off
  • ⏳ window — 5 / 10 / 15 / 20 — how many recent messages are analyzed (default 10)
  • 🔔 notifications — on/off — show a warning when a loop is detected
  • 📊 interactive footer — on/off — experimental: replaces pi's footer and captures keystrokes for the toggle shortcut (leave it off if it misbehaves)
  • ⌨️ shortcut — esc+a or off — key press to toggle without typing a command

🎯 Detection

  • ⚠️ warn threshold — 1 / 2 / 3 / 5 — repetitions before antiloop warns you (default 2)
  • 🛑 force break threshold — 2 / 3 / 5 / 8 — repetitions before forcing a change of approach (default 3)
  • 🚨 abort threshold — off / 5 / 8 / 10 / 15 — repetitions before aborting (0 = disabled)
  • 📏 text similarity — 50 / 60 / 70 / 75 / 80 / 90% — how similar two messages must be to count as a loop (default 75%)
  • 🔧 call similarity — 99 / 95 / 90 / 80% — how identical tool-call arguments must be to count as the same call (default 95%: only near-identical repeats loop)
  • 🔁 call repeats — 1 / 2 / 3 — how many times the same call must repeat before it flags (default 2)
  • 🧾 result similarity — 95 / 80 / 60% — how similar captured results must be to count as the same outcome; a repeated command that starts producing a different result is progress, not a loop (default 80%)
  • 🌀 degenerate run — 8 / 16 / 24 / 48 — identical words in a row inside ONE message/call before it counts as stuck generation (default 16; the lorem ×5145 class)
  • 🔁 block repetition — on/off — detect ONE message replaying whole sentences/phrases (the narration loop: Let me start by checking the environment… ×5) with no repeated peer needed
  • 🔁 block share — 70 / 85 / 95% — share of replayed 5-word phrases in one message before it counts as a replay (default 85%)
  • ⛔ block repetitive bash — on/off — refuse a degenerate or replayed bash command before it executes, feeding the reason back to the model (default on)
  • 📉 no-progress after — 4 / 6 / 8 / 12 — same failing outcome repeated this many times (near-identical args) before flagging (default 8; the NFS test A…QQQ class)

📋 Task streams — batch work (punched_log, plan_manager, …) is N tasks of one type, not a loop

  • 📋 task streams — on/off — recognize that batch work and stay silent
  • 📋 stream min calls — 2 / 3 / 4 / 5 — same-tool calls required before a batch is recognized (default 3)
  • 📋 twin threshold — 99 / 95 / 90% — calls more similar than this count as the same task repeated; one twin invalidates the batch and normal detection resumes (default 99%)

📡 Snapshot tools — read-only status boards (trimegisto_harvest, …) read serially are not a loop

  • 📡 snapshot tools — trimegisto_harvest / off — repeated identical reads of a settled board stay silent all the way through (default on; add any other status/poll tool in antiloop.json)

🔍 Detectors

  • 📝 text — on/off — detect repeated text messages
  • 🔧 tools — on/off — detect repeated tool calls
  • 🧠 thinking — on/off — detect repeated internal reasoning
  • 🌀 degenerate — on/off — detect one message stuck repeating a single word/token (no repeated peer needed)
  • 🔁 block — on/off — detect one message replaying whole sentences/phrases (narration loop, no repeated peer needed)
  • 📉 outcome — on/off — detect many near-identical attempts all ending in the same failing outcome (no progress)

🧹 reset state — clear all counters and history

/antiloop log

Shows the most recent 30 detections with similarity scores and timestamps, newest first.

/antiloop test

Runs the real detection engine (not a copy) — text similarity plus tool-call regression cases:

text identical      → 100% (exp 100%) ✅
text near-identical → 91% (exp ≥ 80%) ✅
text unrelated      → 19% (exp < 50%) ✅
tool identical cmd  → match (exp match) ✅
tool sweep (flags)  → no match @95% (exp no match) ✅   ← regression: 0.8–0.94 overlap is NOT a loop
tool sweep (old 80%)→ match @80% (exp match — was the false positive) ✅
tool different tool → no match (exp no match) ✅
tool empty lists    → no match (exp no match) ✅
result same outcome  → match (exp match — PID noise ok) ✅
result diff outcome  → no match (exp no match — error→success is progress) ✅
batch stream detected  → punched_log×3 (exp punched_log×3) ✅   ← task streams: N tasks of one type
batch no detections    → silent (exp silent — 98.9% args would match without gate) ✅
loop still detected     → tool (exp tool — identical repeats are NOT a stream) ✅
loop survives batch gate→ tool (exp tool — bash repeats are real) ✅
stream needs ≥3 calls   → no stream (exp no stream at 2 calls) ✅
degenerate first sight   → degenerate (bash command: "lorem" ×402 …) ✅   ← v1.6: ONE meltdown message, no peer
degenerate legit cmd      → ok (exp ok) ✅
degenerate interleaved    → flag (lorem ×150) (exp flag — freq/share clause) ✅
degenerate glued token    → flag (lorem ×300) (exp flag — perfect power) ✅
degenerate turn weight    → degenerate 2, text 1 (exp 2, 1) ✅
block S0 narration loop  → block (message text: 100% of 450 tokens replay repeated 5-word phrases) ✅   ← v1.7: ONE message replaying sentences, no peer
block 3 cycles / 2 cycles→ flag (100%) / no (exp flag / no — needs ≥3 repeats) ✅
block legit prose/logs   → ok (exp ok) ✅
outcome fires on 9th      → outcome (9 near-identical bash attempts, same failing outcome) ✅   ← v1.6.1: NFS test-A…QQQ class
outcome needs 8 prior     → silent (exp silent at 5 attempts) ✅
outcome converging sweep  → silent (exp silent — outcomes differ = progress) ✅
outcome diff failures     → silent (exp silent — error changed = progress) ✅
outcome identical OKs     → silent (exp silent — success repeats ≠ loop) ✅

How It Works

Detection pipeline

After every assistant message_end event, antiloop extracts the new content (text, thinking, tool calls — including their ids) and pushes it onto a sliding window of the last detectionWindow + 5 messages. Detection itself runs at turn_end, once the tool results are known: results are fingerprinted and attached to the tracked calls, then the active detection strategies run against the window. The exceptions are the intra-message strategies — degenerate and block. A single stuck message needs no peer and no tool result, so both are evaluated right at message_end — the only point before the message's own tool calls execute — and repetitive bash calls are also blocked at the tool_call hook.

Strategy What it compares Algorithm
Text Full assistant message text n-gram Jaccard (≥ 100 chars) or Levenshtein (shorter)
Tool Tool name + arguments (+ captured result) Sequence match + near-identical args (≥ toolSimilarityThreshold, default 95%) and ≥ minToolRepeatCount prior recurrences. Result veto: if both runs captured a result and the outcomes differ, it's progress, not a loop
Thinking Internal reasoning/thinking blocks Same as text
Structural First 10 words of each message Opening-phrase similarity ≥ 90% across ≥ 3 messages
Degenerate One single message/call (no peer needed) Run-length + frequency of identical words inside the payload: ≥ degenerateMaxRun (default 16) consecutive identical words, or one word ≥ degenerateMaxFreq× at ≥ degenerateMaxShare of all tokens; plus a perfect-power check for glued no-space tokens. Scanned at message_end — before the tool calls execute — and on every bash tool_call (blocking gate)
Block One single message (no peer needed) Sliding blockNgram-word n-grams over the normalized payload: a payload of ≥ blockMinTokens (120) words is a replay when ≥ blockRepeatShare (default 85%) of the n-gram positions recur AND the most repeated n-gram appears ≥ blockMinRepeats (3) times. Catches a message replaying whole sentences/phrases — the narration loop (Let me start by checking the environment… ×5) that every cross-message detector misses because there is no peer. Scanned at message_end and on every bash tool_call (blocking gate)
Outcome Single tool calls across the window, after the last user input ≥ outcomeMinRepeats (default 8) PRIOR attempts with args ≥ outcomeArgSimilarity (0.85) similar AND the same failing outcome (failure signatures compared at ≥ outcomeSigThreshold, 0.7; identical OK results never count — they're the norm for batches). Catches mutated re-run loops the tool detector can't see (labels/permutations change every turn)
Task stream Same tool, many calls When a tool appears ≥ taskStreamMinCalls times (default 3) in the window and no two calls are near-identical (taskStreamTwinThreshold, default 99%), the tool is an active batch: N different tasks of one type (e.g. punched_log appends, plan_manager task adds). Those calls are exempt from tool-loop detection, and text/thinking/structural patterns that only involve those batch messages are suppressed too. If even one call pair is a twin (the same task repeated), the tool is not a stream and detection proceeds normally

Each detected pair becomes a LoopDetection { type, similarity, messageIndices, description } and the consecutive counter increases (a degenerate or block turn counts degenerateTurnWeight, default 2 — warning on first sight).

Intervention levels

Level Trigger Behavior
0 (no loop) — Silent — passes the message through
1 (warning) consecutiveDetections >= warningThreshold Notifies the user (⚠️) — no message is injected into the conversation, so the model's generation is never interrupted by the warning itself
2 (force break) consecutiveDetections >= forceBreakThreshold Steers a real break message into the running agent (pi.sendUserMessage, delivered right before its next LLM call) telling it to stop repeating and change approach
3 (abort) consecutiveDetections >= abortThreshold (Disabled by default) Stops the run outright via ctx.abort()

The level never de-escalates during an active loop; user input decays the consecutive counter naturally so a fresh prompt can break the cycle.

Degenerate and block turns are handled earlier than the ladder: they are detected at message_end (the same moment the text/tool-call payload is complete, before pi preflights and executes its tools). A single such turn already adds degenerateTurnWeight (2) consecutive points → warning on first sight; the second consecutive one → force break steer; after the steer, further degenerate/blocked output counts against ignoredSteerLimit → hard stop. Repetitive bash calls are additionally refused by the tool_call gate (blockDegenerateBash) — the command never runs, and the block reason is fed back to the model as the tool error so it can still change approach.

Similarity scoring

For short texts (< 100 chars):  Levenshtein distance
  "Hello world" vs "Hello World!" → 95% (1 char edit on 11 chars)

For longer texts:  Character trigram Jaccard
  "I will read the file first to understand the structure..."
  "I will read the file first to understand the codebase..."
  → ~85% (many shared 3-grams)

For tool calls:  sequence + per-call argument similarity ≥ 95% (default)
  [bash("setsid ./llama-server -m … -b 2048 -ctk q8_0 …")]
  [bash("setsid ./llama-server -m … -b 2048 -ctk q8_0 …")]      → matched (identical)

  …but a parameter sweep is NOT a loop, even at 80–94% similarity:
  [bash("… -b 2048 -ctk q8_0 -ctv turbo4 > /tmp/sweep-turbo4.log …")]
  [bash("… -b 8192 -ctk f16  -ctv f16  > /tmp/sweep-b8192.log …")]  → not matched

  Long bash commands share scaffolding (env setup, model path, most flags),
  so 80% overlap is normal for *different* sequential operations. Only
  near-identical repeats — the same call set seen `minToolRepeatCount` times
  inside the window — count as a tool loop.

Result veto (tool loops):  detection runs at `turn_end`, where the tool
results are known. Each captured result becomes a normalized tail fingerprint
("err|" / "ok|" prefix + last 400 chars, so PID/timestamp noise is tolerated).
If the same command produced a *different* outcome, the pair is progress:

  [bash("...")] → err|error: invalid argument: GPU0        (attempt 1)
  [bash("...")] → ok|model loaded / listening on :8080      (attempt 2)
  → NOT a loop — the retry fixed the problem

  [bash("...")] → err|failed to create context …            (attempt 1)
  [bash("...")] → err|failed to create context …            (attempt 2)
  → loop signal (same command, same outcome, repeated)

Results only veto; they never trigger on their own, and calls without a
captured result fall back to argument matching alone.

Task streams: N tasks of one type ≠ a loop

Extensions push homogeneous work into the model's hands. punched appends lines to pi.md one at a time; plan adds tasks one at a time; obsidian_* writes/edits notes file by file. Each call is a different task — "add line 1", "add line 2", "add line 3" — of the same type, and the narration around it sounds similar ("now appending the next entry…"). That is not a reasoning loop; flagging it would interrupt legitimate multi-step work.

Antiloop recognizes this as a task stream: if the same tool name appears at least taskStreamMinCalls times (default 3) inside the detection window and no two calls are "twins" (arguments ≥ taskStreamTwinThreshold similar, default 99% — any real content difference counts as a distinct task), the tool is treated as an active batch:

  • tool-loop detection skips messages whose calls are all stream tools;
  • text / thinking / structural detections that only involve those batch messages are suppressed (identical narration while adding N entries is expected, not looping);
  • the footer shows it: 🔄 antiloop(on) · batch: punched_log×4.

Crucially, the exemption requires no twins: the moment the same call repeats verbatim (or near-verbatim), the stream is invalid and normal loop detection takes over — so a model stuck re-logging the same line still gets caught. The check is name-agnostic: any extension tool used as a batch is covered, no allow-list needed. If a genuine loop coexists with a batch (e.g. the same bash command re-run while appending different notes), the loop is still flagged.

Tunables: detectTaskStreams (master switch), taskStreamMinCalls (batch size needed before recognition), taskStreamTwinThreshold (how similar args must be to count as the same task — lower it to treat near-duplicate entries as loops again).

Snapshot tools: the serial close of settled agents ≠ a loop

A coordinator that closes N agents which have already finished calls the snapshot tool once per agent. When the agents are all settled, every one of those calls returns the same serialized board — same arguments ({}), same result. To the tool detector that is indistinguishable from a verbatim loop: same args + same outcome repeated N times, so it force-broke a run that was simply collecting finished results.

Re-reading a state snapshot is an idempotent read, not a stuck generation, so tools listed in snapshotTools (default ["trimegisto_harvest"]) are exempt from tool-loop and no-progress outcome detection. The exemption is deliberately narrow:

  • it only applies when every call in the message is a snapshot tool — a real bash loop re-run alongside a harvest is still flagged;
  • it is name-based and configurable, so antiloop.json can list any other status/poll tool ("snapshotTools": ["trimegisto_harvest", "my_status"]);
  • clearing it ("snapshotTools": [] or 📡 off) restores normal detection, where the very same 5× harvest payload is a genuine verbatim tool loop.

Degenerate repetition: ONE message stuck on a word ≠ a reasoning loop

All strategies above need at least two similar messages — they detect a model repeating itself across turns. There is a different, equally destructive failure mode they cannot see: the model's decoder anchors on a token and stops producing new output, repeating the same word hundreds of times inside a single message or tool call. Real case (session 2026-09-09T15-43, a local llama.cpp server): one 46 KB bash call whose SSH username wordlist repeated lorem 5145 times (a run of 5140 — 99% of the payload). Every cross-message detector stayed silent (nothing to compare against — it happened exactly once), and only a manual ESC stopped it.

Antiloop v1.6 detects this degenerate repetition directly, on the first occurrence, with no peer message:

  • Run-length — a payload of ≥ degenerateMinTokens (50) normalized words containing ≥ degenerateMaxRun (16) consecutive identical words is a meltdown.
  • Frequency share — one word appearing ≥ degenerateMaxFreq (60) times with ≥ degenerateMaxShare (40%) of all tokens catches interleaved meltdowns (A B A B A B…) that have no long run.
  • Perfect power — a single giant token with no separators at all (loremlorem…) is checked for periodicity.

Tokenization uses runs of letters (unicode), so JSON stringification noise (escaped \n in stored tool args, punctuation, digits, code symbols) never fragments the repeated word — and legit payloads full of numbers or code symbols don't false-positive. 1-letter tokens are ignored as candidates ({"a":1} JSON keys can't trigger).

Because the signal is conclusive, it acts before the tools run: the meltdown is caught at message_end (pi emits it once the assistant message is complete, before tool preflight), escalates immediately (one degenerate turn = degenerateTurnWeight = 2 consecutive points → warning on first sight, force steer on the second consecutive meltdown), and any degenerate bash command is blocked in the tool_call hook (blockDegenerateBash) — the 46 KB brute-force style command never executes. The block reason is returned to the model as the tool error, so the next LLM call can still change approach; only if it keeps melting does antiloop steer and then hard-stop the run.

Legit commands are safe: real scripts never repeat one word 16+ times in a row inside a ≥ 50-token payload (verified against the actual sequential bash sweeps that motivated the tool-loop threshold). Only bash gets the blocking gate — writing a repetitive file (e.g. a user-requested padding fixture) is alerted and escalated, not refused.

Block repetition: ONE message replaying its own sentences

The degenerate detector catches a single word repeated hundreds of times. There is a second intra-message failure mode, just as obvious to a human and invisible to every cross-message detector: the model replays whole sentences/phrases inside one generation. Real case (a coding session's S0 scaffold): ONE assistant message cycled ~5 times through

Let me start by checking the environment and the current state of the repository, then set up a plan for the S0 slice and begin building. I'll run several independent checks in parallel. Let me begin the S0 development. First, reconnaissance of the environment and current repo state.

…never emitting a tool call. Text/tool/thinking/structural detection all compare across messages and had nothing to compare against; the degenerate scan only watches for one word repeated, and no single word repeated 16×.

Antiloop v1.7 detects block repetition on the message itself:

  • the payload is read as a stream of lowercase alphanumeric words;
  • a sliding window of blockNgram (default 5) words is built over it;
  • if at least blockRepeatShare (default 85%) of those n-gram positions recur somewhere else, the most repeated n-gram appears at least blockMinRepeats (default 3) times, and the payload has at least blockMinTokens (default 120) words, the generation is replaying itself.

The thresholds are deliberately far apart from normal content: ordinary prose — even long, structured documents — scores ≤ 11% coverage, while 3+ replays of a narration block reach 98–100%. Templated payloads with evolving data (log lines, generated code) are not flagged because their n-grams carry the varying digits and differ. A single restatement (2 cycles) does not trip it — one echo is a summary, three are stuck generation.

Because the signal is conclusive it is handled like the degenerate one: caught at message_end (before the message's tools execute), weighted degenerateTurnWeight (2) so the first occurrence warns, and a replayed bash command is refused by the same blockDegenerateBash gate. After a force-break steer, another replayed block escalates to the hard stop.

Tunables: detectBlockRepeats, blockRepeatShare (lower = earlier), blockMinRepeats, blockNgram, blockMinTokens.

No-progress outcome runs: mutated re-runs, same wall

A subtler meltdown than the degenerate one: the model re-runs the SAME experiment over and over, mutating a cosmetic label or permutation each time so no call ever repeats verbatim — while the outcome stays the SAME FAILURE. Real case (same session, rows 95–249): ~90 ssh exportfs/mount tests, labels test A … test QQQ, targets alternating data-a/data-b — every one ending access denied / rc=32, with fresh journalctl noise per attempt. The tool-loop detector is blind to it BY DESIGN (args mutate every turn: mean adjacent trigram similarity 0.93, but the label always changes, so the same call never recurs minToolRepeatCount times) and results only veto tool loops today — nothing used "same outcome repeated" as a positive signal.

Antiloop v1.6.1's outcome detector closes exactly that gap, conservatively:

  • the LAST turn's single tool call must have a captured result that is a FAILURE (fingerprints carry an error signature — ok|fail|sig|… — extracted around the first failure marker over the FULL output, because a tool run can fail with isError=false: the ssh pipeline exits 0 while rc=32 lives inside the text);
  • at least outcomeMinRepeats (default 8) PRIOR single-call turns (all after the last real user message) must share BOTH args ≥ outcomeArgSimilarity (0.85 — the same experiment reshuffled) AND the same failure signature (digit-stripped signatures compared at outcomeSigThreshold, 0.7 — mount targets that legitimately vary between attempts survive, journalctl noise doesn't).

Guarantees preserved: converging sweeps change outcome → silent; different failures = evolving diagnosis → silent; identical OKs (task-stream batches, file writes, idempotent verifications) never count as failures → silent; user-steered turns don't count → only the autonomous stretch is judged. Because the signal is proven no-progress, one such turn adds degenerateTurnWeight (2) points → warning on the crossing turn, force-break steer on the next, hard stop shortly after if the same wall persists. On the real session this fires at test MM (warn) → NN (steer) → PP (abort) — ~50 wasted experiment turns cut.

Tunables: detectOutcomeLoops, outcomeMinRepeats (lower = earlier cutoff), outcomeArgSimilarity, outcomeSigThreshold.

Sliding window

Only the last detectionWindow messages participate in comparisons, so detection cost stays bounded: O(N × W) where N is the window size and W is the message size. The window is trimmed to detectionWindow + 5 to keep a small buffer past the analysis range, avoiding edge artifacts.

Configuration

Persisted as JSON at ~/.pi/agent/antiloop.json:

{
  "enabled": true,
  "warningThreshold": 2,
  "forceBreakThreshold": 3,
  "abortThreshold": 0,
  "similarityThreshold": 0.75,
  "toolSimilarityThreshold": 0.95,
  "minToolRepeatCount": 2,
  "resultSimilarityThreshold": 0.8,
  "detectDegenerate": true,
  "degenerateMinTokens": 50,
  "degenerateMaxRun": 16,
  "degenerateMaxFreq": 60,
  "degenerateMaxShare": 0.4,
  "degenerateTurnWeight": 2,
  "blockDegenerateBash": true,
  "detectBlockRepeats": true,
  "blockMinTokens": 120,
  "blockNgram": 5,
  "blockMinRepeats": 3,
  "blockRepeatShare": 0.85,
  "detectOutcomeLoops": true,
  "outcomeMinRepeats": 8,
  "outcomeArgSimilarity": 0.85,
  "outcomeSigThreshold": 0.7,
  "detectTaskStreams": true,
  "taskStreamMinCalls": 3,
  "taskStreamTwinThreshold": 0.99,
  "detectToolLoops": true,
  "detectThinkingLoops": true,
  "detectTextLoops": true,
  "notifyOnDetection": true,
  "maxHistoryEntries": 100,
  "detectionWindow": 10,
  "interactiveFooter": true,
  "toggleShortcut": "esc+a"
}
Field Default Description
enabled true Master switch
warningThreshold 2 Consecutive detections before warning
forceBreakThreshold 3 Consecutive detections before force break
abortThreshold 0 Consecutive detections before abort (0 = disabled)
similarityThreshold 0.75 Minimum similarity (0.0–1.0) to count a text/thinking pair as looping
toolSimilarityThreshold 0.95 How close tool-call arguments must be (0.0–1.0) to count as the same call — see tool loops
minToolRepeatCount 2 Prior occurrences of a near-identical call set required before a tool loop is flagged (2 = same call seen 3×)
resultSimilarityThreshold 0.8 Minimum similarity between captured result tails to still count as the same outcome; below this, a repeated command is treated as progress, not a loop
detectDegenerate true Detect intra-message degenerate repetition — one message/call stuck repeating a single word (the lorem ×5145 class). Needs no repeated peer message; scanned at message_end and on every bash tool_call
degenerateMinTokens 50 Minimum normalized tokens in a payload before it is scanned for degenerate repetition (shorter payloads aren't conclusive)
degenerateMaxRun 16 Consecutive identical words inside one payload that flag it as degenerate
degenerateMaxFreq 60 One word's total occurrences (with degenerateMaxShare of the payload) that flags interleaved meltdowns
degenerateMaxShare 0.4 Frequency share (freq/total tokens) required together with degenerateMaxFreq
degenerateTurnWeight 2 Consecutive-detection points added by one degenerate turn (2 = warning on first sight)
blockDegenerateBash true Block a degenerate or block-repetition bash command in the tool_call hook before it executes; the reason is fed back to the model as the tool error
detectBlockRepeats true Detect intra-message block/narration repetition — ONE message replaying whole sentences/phrases (the Let me start by checking the environment… ×5 class). Needs no repeated peer; scanned at message_end and on every bash tool_call
blockMinTokens 120 Minimum normalized words in a payload before it is scanned for block repetition (shorter payloads aren't conclusive)
blockNgram 5 Word window of the n-grams whose recurrence is measured
blockMinRepeats 3 The most repeated n-gram must occur at least this many times to flag
blockRepeatShare 0.85 Share of n-gram positions that must recur for the payload to count as a replay
detectOutcomeLoops true No-progress outcome runs: ≥ outcomeMinRepeats near-identical attempts (args ≥ outcomeArgSimilarity) all ending in the same failing outcome — the NFS test A…QQQ class
outcomeMinRepeats 8 Prior same-failure attempts (inside the window, after the last user input) required before the outcome detector fires
outcomeArgSimilarity 0.85 How similar args must be to count as the same experiment reshuffled (mutations of labels/permutations stay under it — distinct tasks don't)
outcomeSigThreshold 0.7 Minimum similarity between digit-stripped failure signatures to count as the same failure
detectTaskStreams true Recognize homogeneous batch work (same tool called with distinct content — e.g. punched/plan/obsidian extensions) and stay silent; see task streams
taskStreamMinCalls 3 Same-tool calls required inside the window before a task stream is recognized
taskStreamTwinThreshold 0.99 Arguments this similar (or identical) count as the same task — a twin invalidates the stream and re-enables normal loop detection
snapshotTools ["trimegisto_harvest"] Read-only snapshot/status tools: repeated identical reads (the serial close of N settled agents) never count as a tool loop or no-progress run. Add any other status/poll tool by name
detectTextLoops true Detect full-text repetition
detectToolLoops true Detect tool-call sequence + argument repetition
detectThinkingLoops true Detect repeated thinking/reasoning content
notifyOnDetection true Show a notification on every detection
maxHistoryEntries 100 Max detection history entries
detectionWindow 10 Number of recent messages to analyze
interactiveFooter true TUI footer replaces the built-in one with an antiloop indicator + toggle shortcut (set false to keep the built-in footer and only the setStatus line)
toggleShortcut esc+a Key sequence that toggles antiloop from the footer (esc+a or off). Honored only while pi is idle (ESC is also pi's interrupt key — typing a right after cancelling a stuck run must not silently switch antiloop off). The input is never consumed, so typing is unaffected

Best Practices

  1. Start with defaults — warning=2 / force-break=3 / similarity=75% / tool-sim=95% works well for most models.
  2. Adjust sensitivity to the model — small/local models loop more, so lower warningThreshold and similarityThreshold to catch them early. Big cloud models rarely loop, so you can raise them to avoid false positives.
  3. Tool loops are strict on purpose — a long bash command with env setup + flags scores 80–94% similar to the next, different command. Antiloop only flags tool calls that are near-identical (≥ 95%) and repeated ≥ minToolRepeatCount times, and — when results are captured — produced the same outcome. If you still see false positives on sequential operations, raise toolSimilarityThreshold (or minToolRepeatCount, or resultSimilarityThreshold) via /antiloop config — don't disable the detector.
  4. Per-strategy toggles — if the model's reasoning legitimately repeats (e.g. it's working through a checklist), disable thinking detection and leave text/tool on.
  5. Watch the log — /antiloop log shows what's actually triggering. If you see false positives, raise similarityThreshold instead of disabling the strategy entirely.
  6. Let user input clear state — each user message decays the consecutive counter by 2, so a fresh prompt naturally resets without /antiloop reset.
  7. Degenerate detector needs no tuning for most setups — a run of ≥ 16 identical words (or one word ≥ 40% of a ≥ 50-token payload) inside a single message is conclusive stuck generation; the 46 KB lorem ×5145 SSH-wordlist meltdown is caught on first sight (warning), its bash never executes (blockDegenerateBash), and a second consecutive meltdown gets the force-break steer. If a model legitimately writes repetitive payloads, raise degenerateMaxRun / degenerateMaxFreq via /antiloop config — don't disable the detector.
  8. Outcome detector catches mutated re-run loops — a model that re-issues the same experiment with cosmetic changes (labels, permutations) while every attempt fails identically gets a warning after outcomeMinRepeats (8) same-failure attempts, a steer on the next, and a hard stop shortly after. Converging sweeps, evolving failures and repeated successes stay silent by design. Lower outcomeMinRepeats if you want earlier cutoffs.
  9. Block detector catches narration loops — a single message replaying whole sentences (the Let me start by checking the environment… ×5 class) warns on first sight and escalates like a meltdown. It only fires when ≥ 85% of the message's 5-word phrases recur, so ordinary prose and templated logs/code stay silent. Raise blockRepeatShare (or blockMinRepeats) if you ever see a false positive.
  10. /antiloop test — runs the real detection engine (text + tool-call + task-stream + degenerate + block + outcome regression cases) to verify calibration after any change.

Architecture

antiloop/
├── package.json        # pi package manifest (pi-package)
├── LICENSE             # MIT
├── README.md
├── docs/
│   ├── banner.jpeg      # wide README header
│   └── preview.jpeg     # npm pi.dev preview card
└── src/
    ├── index.ts        # hooks + intervention pipeline
    ├── detect.ts       # similarity engine, detection strategies, self-test
    ├── commands.ts     # /antiloop command handlers + config menu
    ├── config.ts       # config load/save
    ├── types.ts        # shared types
    └── ui.ts           # UI helpers (select, duration)

Modular extension with zero external dependencies (only pi's bundled @earendil-works/pi-coding-agent + Node built-ins):

  • Levenshtein + trigram Jaccard hybrid — small texts use edit distance, large texts use n-gram overlap (each is O(N) in text length)
  • Sliding window — only the last detectionWindow messages participate, capping memory at O(W × message_size)
  • Early bail — short messages and empty tool calls skip similarity computation entirely; the degenerate and block scans are single linear passes
  • TUI integration — uses ctx.ui.select for the config menu and the log viewer; ctx.ui.notify for state notifications; ctx.ui.setStatus + a custom ctx.ui.setFooter component for the persistent footer indicator, live level info, and the esc+a keyboard toggle (ctx.ui.onTerminalInput, never consumes input)
  • Hooks — message_end (track messages + tool call ids with a monotonic sequence so the sliding-window trim can never collide turn indices, and pre-handle degenerate/block repetition — the message is complete but its tools haven't executed yet), tool_call (block degenerate or replayed bash commands before they run), turn_end (attach result fingerprints with failure signatures, detect — including no-progress outcome runs — and intervene: steer the force break / abort the run), input (decay on real user messages only), session_start (load config + install footer + reset), session_shutdown (restore built-in footer)
  • Intervention runs on the turn loop, not on user prompts — escalation is decided at turn_end (and at message_end for the self-contained intra-message signals), the break is steered into the running agent before its next LLM call, and the guaranteed hard stop aborts the run (ctx.abort, fire-and-forget — never awaited, so the hook can't deadlock). No custom-role messages are injected into the conversation at any level (steering a real user message + aborting are the only levers; custom-role injections were removed because a model can stall on an unexpected injected message)

License

MIT © antiloop contributors