# pi-death-loop-guard

[English](./README.md) | [中文](./README.zh-CN.md)

<p align="center">
<strong>Loop-Guard</strong> for <a href="https://pi.dev">Pi</a>
<br />
The model cannot loop forever — it gets <em>warned</em>, <em>blocked</em>, and finally <em>aborted</em>.
</p>

---

<p align="center">
<a href="https://www.npmjs.com/package/pi-death-loop-guard"><img src="https://img.shields.io/npm/v/pi-death-loop-guard?style=flat-square" alt="npm version"></a>
<a href="https://github.com/Tyan66666/pi-death-loop-guard/blob/main/LICENSE"><img src="https://img.shields.io/github/license/Tyan66666/pi-death-loop-guard?style=flat-square" alt="license"></a>
<a href="https://github.com/Tyan66666/pi-death-loop-guard"><img src="https://img.shields.io/badge/GitHub-Tyan66666%2Fpi--death--loop--guard-181717?style=flat-square&logo=github" alt="GitHub"></a>
</p>

<p align="center">
<code>pi install npm:pi-death-loop-guard</code>
</p>

---

## Why?

When a small model gets stuck, it sometimes calls the **same tool with the same arguments** over and over — re-reading the same file, re-running the same command, re-updating the same todo. Each repetition costs tokens, pollutes the conversation, and the results never change. Pi has no built-in loop detection ([#6158](https://github.com/earendil-works/pi-mono/issues/6158), closed no-action): the agent loop just keeps going.

**pi-death-loop-guard** watches for this exact signature — repeated identical tool calls with unchanged results — and interrupts with an escalating response:

1. **Warn** — injects a steering message telling the model to change strategy or wrap up
2. **Block** — refuses the tool call; the block reason becomes an error message fed back to the model
3. **Abort** — terminates the whole user request via `ctx.abort()`

Legitimate behavior is never touched: calls with different arguments, or identical calls whose results keep changing (polling), are not loops.

## Install

```bash
pi install npm:pi-death-loop-guard
```

That's it. The extension auto-loads on next Pi startup. No configuration needed — the defaults (warn at 3, block at 5, abort after 3 blocks) work out of the box.

To pin a version:

```bash
pi install npm:pi-death-loop-guard@0.1.0
```

Install from GitHub instead:

```bash
pi install git:github.com/Tyan66666/pi-death-loop-guard
```
To pin the GitHub version:

```bash
pi install git:github.com/Tyan66666/pi-death-loop-guard@v0.1.0
```

## How it works

pi-death-loop-guard listens to `tool_call` / `tool_result` events and maintains a streak counter. A loop is only declared when **all three** match:

1. **Same tool name**
2. **Same argument fingerprint** — recursively key-sorted JSON; field order is irrelevant
3. **Same result fingerprint** — streaming SHA-256 over text content (images count only, not content)

Escalation (defaults):

| Stage | Trigger | Action |
|-------|---------|--------|
| Warn | 3 consecutive matches | Inject a steer message: "change strategy or give a final answer" |
| Block | 5 consecutive matches (4 allowed, 5th refused) | Return `{ block: true, reason }`; reason becomes an error the model sees |
| Abort | 3 blocks within the same user request | `ctx.abort()` ends the request |

Key details:

- **Cross-argument calls never count** — `read` of different files never triggers, even with identical content. Result comparison only applies to repeated same-argument calls.
- **Legitimate polling is safe** — identical calls whose results change don't advance the streak.
- **Parallel batches don't advance the streak** — parallel calls inside a single LLM response are not counted; only the last result of a batch compares against the previous serial result.
- **After a block, same-argument retries are blocked every time** (the streak is not reset), until the request is aborted or the model changes arguments.
- **`terminate` needs pi ≥ 0.84.1** (PR #7715) and is off by default (`LOOP_GUARD_USE_TERMINATE`); the plugin core runs safely on older versions.

## Configuration

All configuration is via environment variables. No config file needed.

| Variable | Default | Description |
|----------|---------|-------------|
| `LOOP_GUARD_ENABLED` | `true` | Master switch |
| `LOOP_GUARD_WARN_THRESHOLD` | `3` | Consecutive matches before warning |
| `LOOP_GUARD_BLOCK_THRESHOLD` | `5` | Consecutive matches before blocking (must be > warn) |
| `LOOP_GUARD_ABORT_THRESHOLD` | `3` | Blocks within one user request before `ctx.abort()` |
| `LOOP_GUARD_IGNORE_TOOLS` | *(empty)* | Comma-separated tool allowlist, e.g. `todo,bash` |
| `LOOP_GUARD_IGNORE_ERRORS` | `false` | Skip `isError=true` results (error retries don't count) |
| `LOOP_GUARD_USE_TERMINATE` | `false` | Also return `terminate: true` on block (needs pi ≥ 0.84.1) |

Example:

```bash
LOOP_GUARD_WARN_THRESHOLD=5 LOOP_GUARD_BLOCK_THRESHOLD=8 pi
```

If `warnThreshold >= blockThreshold`, the plugin disables itself and logs an error instead of misbehaving.

## Plugin compatibility

pi-death-loop-guard only intercepts `tool_call` (to block) and `tool_result` (to fingerprint results). It does **not** touch the `context` event, so it coexists with context-compression plugins (e.g. billion-context-pi) and any other extension.

One thing to know: when multiple extensions block the same `tool_call`, pi honors the first `{ block: true }` result in load order. If you run several blocking extensions, whichever loads first has priority. This is a Pi extension-model limitation, not specific to pi-death-loop-guard.

## Development

```bash
npm install            # typescript / @types/node / tsx (--ignore-scripts)
npm run check          # tsc type check
npm test               # unit tests with a mocked ExtensionAPI, no real pi needed
```

Tests cover: warning trigger, block timing (4 allowed, 5th blocked), safe polling, cross-argument reset, abort threshold, `before_agent_start` reset, `ignoreErrors`, parallel batches, `useTerminate`, `ignoreTools`, and config invariants.

## Known limitations

- **Argument-switching loops** (e.g. `read` of a rotating set of files) are not detected — usually they make progress, so harm is limited.
- **Interleaved loops** (`read(A) → bash(cmd) → read(A) → bash(cmd)`) are out of scope.
- **Images count, not content** — two different images plus identical text can false-positive.

## License

MIT.
