@solknight48/pi-loop-guard
v0.1.0
Published
Bounds LLM degeneration loops in pi: collapses duplicate tool-call batches, blocks stuck repeated calls, aborts runaway repeated text, and rewrites token-limit truncation errors so they stop re-arming the loop.
Readme
pi-loop-guard
A pi extension that bounds the blast radius of LLM degeneration loops.
Why
A pi session (0.84.0) degenerated into a repetition loop: the model emitted one assistant message containing several hundred byte-identical tool calls — all of which were executed, with every full result appended to context. When the response hit the output token limit mid-batch, the injected error said "Re-issue the tool call with complete arguments", which told the degenerating model to emit the same batch again. Each round-trip further poisoned the context until the user killed the session.
Model-side degeneration can't be prevented by a harness — but it can be contained. This package contains it.
The four guards
- Intra-turn duplicate collapse — identical
(tool, args)calls within one turn run once; the rest are blocked with a short stub reason. A degenerate batch costs one execution instead of hundreds, and the model gets a clear "already in this batch, do not repeat" signal. - Cross-turn stuck detection — the same exact call issued in every one of the last K turns is blocked with a "you are stuck, stop and ask the user" reason. Catches the slow one-call-per-turn loop. Two precision rules keep it off legitimate workflows: the window is scoped to a single agent run (a user re-asking the same thing across prompts never trips it), and a call whose recent results differ is treated as observing changing state (polling, re-reading an edited file) and allowed — the block engages only when the last K−1 executions returned identical content, or when no execution evidence exists at all.
- Stream degeneration abort — assistant output that ends with the same chunk (≥24 chars) repeated ≥8 times consecutively aborts the turn. Whitespace-only chunks are ignored.
- Truncation re-arm neutralizer — the error results pi injects for a batch cut off by the output token limit ("… Re-issue the tool call with complete arguments.") are rewritten so they no longer instruct the model to re-emit the failed batch (re-issuing the single truncated call is still allowed). This guard hooks
message_end, nottool_result: in pi 0.84.0 the truncation path builds those results without going through the tool pipeline, so thetool_resultextension event never fires for them; themessage_endreplacement is mutated into the live run's context before the next LLM call.
Install
pi install npm:@solknight48/pi-loop-guard
pi install git:github.com/solknight48/pi-loop-guard # or straight from git
pi install /path/to/pi-loop-guard # or from a local cloneOr try it for one run without installing:
pi -e /path/to/pi-loop-guardUsage
Once loaded it works silently; a 🛡 loop-guard status appears in the footer.
/loop-guard show status and per-session counters
/loop-guard on|off toggle all guards at runtime
/loop-guard reset zero the counters and clear the stuck-call windowreset is the escape hatch when guard 2 blocks a call you deliberately want to retry. The footer status shows 🛡 loop-guard OFF while disabled.
Configuration
Environment variables (read at extension load):
| Variable | Default | Meaning |
|---|---|---|
| PI_LOOP_GUARD_MAX_DUP_PER_TURN | 1 | Identical calls allowed per turn; the rest are blocked |
| PI_LOOP_GUARD_MAX_CONSEC_TURNS | 4 | Same call in this many consecutive turns ⇒ blocked as stuck |
| PI_LOOP_GUARD_TEXT_GUARD | 1 | Set 0 to disable the stream-abort guard |
| PI_LOOP_GUARD_MIN_UNIT | 24 | Minimum repeated-chunk length (chars) for the stream guard |
| PI_LOOP_GUARD_MIN_REPEATS | 8 | Consecutive repeats that trigger the stream guard |
False-positive notes
- Polling that actually observes change (a growing log, a file being edited) does not trip guard 2 — differing results suspend the block. Polling whose output is byte-identical 4 turns running does trip it, deliberately: the call is gaining nothing, and the block reason tells the model to add a sleep or vary the command. Use
/loop-guard resetto retry a blocked call, or/loop-guard offif a workflow needs raw repetition. - The flip side: a degenerate loop whose output contains noise (timestamps, counters) won't be caught by guard 2 — results differ every time. Guards 1 and 3 still bound it within a turn.
- The stream guard only fires on long repeated chunks (≥24 chars, ≥8 consecutive copies), so repeated short tokens (log separators, table rows) do not trigger it. Known limits: repetition units longer than 400 chars are not detected, and the final <512 chars of a message go unchecked (the scan is throttled to every 512 streamed chars).
Development
npm install # installs jiti (dev-only; the extension itself has zero runtime deps)
npm test # unit tests via a mock ExtensionAPIWithout a local npm install, the test runner falls back to the jiti bundled with a globally installed pi (or set PI_PACKAGE_DIR to a pi package directory). The mock encodes assumptions about pi's real event flow; the facts it depends on are listed at the top of test/run-tests.cjs — re-verify them when upgrading pi.
The extension is a single file: extensions/loop-guard.ts.
License
MIT
