<div align="center">

![Antiloop banner](https://raw.githubusercontent.com/noguerol/antiloop/main/docs/banner.jpeg)

</div>

# 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](https://pi.dev/packages): one extension (`src/index.ts`) declared in `package.json`.

```bash
# 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
```

```bash
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`:

```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](#how-it-works) |
| `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](#task-streams-n-tasks-of-one-type--a-loop) |
| `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](LICENSE) © antiloop contributors
