OpenCode Anti-Loop Plugin
Detects and blocks infinite agent loops — duplicate tests, repeated commands, identical outputs, exploration sprawl, zombie steps, truncation, and more — through layered detection with configurable escalation.
Why Use It
AI coding agents can get stuck in loops: re-running the same test against unchanged code, re-executing failed commands, writing endless diagnostic scripts that investigate the same data, or cycling through the same edit→build→run sequence without progress. These loops waste tokens, time, and can run indefinitely.
This plugin intercepts the agent's tool lifecycle and blocks loops before they spiral. It detects:
- Exact repetition — same command, same output, same test against same code
- Semantic repetition — different script names investigating the same files
- Strategic loops — cycling through the same action sequence
- Exploration sprawl — many steps without producing any output
- Zombie loops — steps with zero reasoning tokens
- Timeout loops — commands that keep timing out
- Output truncation — steps that hit the model's output token limit without emitting a tool call
When a loop is detected, the plugin blocks the action with a descriptive error guiding the agent to change approach. If the agent keeps getting blocked, the plugin escalates to session compaction and (optionally) rollback.
Installation
npm install opencode-anti-loop
Quick Start
Add to your opencode.json:
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
"opencode-anti-loop"
]
}
To pass configuration options, use the tuple form:
{
"plugin": [
["opencode-anti-loop", { "maxRepeatedCommands": 5 }]
]
}
Table of Contents
- Why Use It
- Installation
- Quick Start
- How It Works
- Tool Coverage
- Setup Command Exemptions
- Configuration
- Limitations
- Programmatic Usage
- License
How It Works
The plugin hooks into three points of OpenCode's tool lifecycle (event, tool.execute.before, tool.execute.after) and runs ~11 independent detectors in parallel. Each targets a specific loop pathology.
Detection Modes
| Detector | What it catches | Default | Action |
|---|---|---|---|
| Duplicate test | Re-running tests against unchanged code | 2 runs | Block |
| Command streak | Same normalized command without file changes | 3 runs/epoch | Block |
| Identical output (per-command) | Same command producing identical output | 3 runs | Block |
| Identical output (global) | Any consecutive commands producing identical output | 5 streak | Block |
| Timeout loop | Same command timing out repeatedly | 2 timeouts | Block |
| Action cycle (exact) | Repeated sequence of identical actions | 6 repeats | Block (5 = advisory) |
| Action cycle (semantic) | Repeated conceptual investigation pattern | 8 repeats | Block (7 = advisory) |
| Zombie loop | Steps with zero reasoning tokens | 3 steps | Block |
| Hard loop | Consecutive failed bash commands | 3 failures | Block |
| Exploration sprawl | Steps without writing code | 8 (pre-first-write) / 20 (after) | Block |
| File investigation | Repeatedly probing the same data files | 12 per group / 24 global | Block |
| Subagent loop | Repeated subagent spawns with similar prompts | 3 spawns + <30% novelty | Block |
| Output truncation | Step ends with reason: "length" or output > 16K tokens |
— | Inject "act now" prompt |
Detection Details
Here is a detailed breakdown of each detection mode, including examples of what gets caught and why blocking it makes sense:
1. Duplicate Test
- What gets caught: Running
pytest tests/test_math.pytwice in a row without modifying any files in the workspace. - Why it makes sense: Running a test against unchanged code cannot produce new information. It wastes tokens and time. The agent must change the code or the test before running it again.
2. Command Streak
- What gets caught: Running
python run.py3 times in a row without any file writes or edits in between. - Why it makes sense: If the agent runs the exact same command repeatedly without changing any files, the output will be identical. This is a classic "stuck" loop where the agent is waiting for a different result without taking action.
3. Identical Output (Per-Command)
- What gets caught: Running
python run.py, then editing a file, then runningpython run.pyagain, and repeating this 3 times, where the output ofpython run.pyis exactly the same every time. - Why it makes sense: Even though the agent is editing files, the edits are having absolutely no effect on the command's output. The agent is stuck in a "guess and check" loop. It needs to stop guessing and write a diagnostic script or print variables to understand why its changes are inert.
4. Identical Output (Global)
- What gets caught: Running
python test1.py, thenpython test2.py, thenpython test3.py, where all of them outputError: database connection failed. - Why it makes sense: The agent is writing different scripts to test the same underlying issue. It already knows the database connection is failing; writing more diagnostic scripts won't fix it. It needs to stop diagnosing and write a fix.
5. Timeout Loop
- What gets caught: Running
gcc main.c && ./a.outtwice, and both times the command times out (takes >120s or is terminated). - Why it makes sense: The code likely has an infinite loop or a severe performance bug. Running it again blindly will just time out again, wasting tokens and killing the agent. The agent must add debug prints or reduce the scope of the run to find the hang.
6. Action Cycle (Exact)
- What gets caught: Repeating the sequence
[edit file.c, gcc file.c]6 times in a row, where the file content and compiler output are identical in each cycle. - Why it makes sense: The agent is stuck in a local maximum, repeating the exact same sequence of actions. It needs to pivot its strategy and try a fundamentally different approach.
7. Action Cycle (Semantic)
- What gets caught: Repeating the sequence
[write check_weights1.py, python check_weights1.py]then[write check_weights2.py, python check_weights2.py]with the same description "Check weights layout" 8 times. - Why it makes sense: Even though the script names are different, the conceptual investigation is identical. The agent is looping strategically. It needs to commit to the facts it has learned and move forward with implementation.
8. Zombie Loop
- What gets caught: The agent completes 3 consecutive steps where its reasoning token count is 0 (it just calls tools without any internal thought/reasoning).
- Why it makes sense: The agent is acting on autopilot without thinking. This often happens when it gets confused or stuck in a tool-execution loop. Forcing a block forces the agent to stop and reason.
9. Hard Loop
- What gets caught: Running 3 consecutive bash commands that all fail (exit code != 0 or error output).
- Why it makes sense: The agent is blindly retrying failing commands or guessing syntax. It needs to stop, read the error message, and use a systematic debugging approach instead of guessing.
10. Exploration Sprawl
- What gets caught: Running 9 read-only commands (like
cat,grep,ls) before writing any code, or running 21 read-only commands after the first write. - Why it makes sense: The agent is stuck in "analysis paralysis" or exploration sprawl — reading endless files and running diagnostics without actually producing any deliverables. It needs to stop reading and start writing.
11. File Investigation
- What gets caught: Writing 13 different scripts that all read and print parts of
gpt2-124M.ckpt. - Why it makes sense: The agent is repeatedly probing the same data file without making progress. It needs to commit to the facts it has already learned from the file and move on to writing the solution.
12. Subagent Loop
- What gets caught: Spawning a
fixersubagent 4 times with the prompt "Write GPT-2 in C" where the prompt novelty is very low. - Why it makes sense: The agent is repeatedly delegating the same task to subagents and failing. It needs to stop spawning subagents, write a new plan, and test a miniaturized version of the architecture itself.
13. Output Truncation
- What gets caught: A step finishes with
reason: "length"(the model hit its output token limit) or produced more than 16,000 output tokens. - Why it makes sense: When a model over-thinks a problem, it can spend its entire output budget on reasoning without ever emitting a tool call. The step ends with
reason: "length"and no file was written, no command was run — the agent produced nothing. The plugin detects this and injects a prompt telling the agent to make a tool call immediately, limiting reasoning to 3-5 lines. - How it works: On every
step-finishevent, the plugin checkspart.reasonandpart.tokens.output. Ifreason === "length"oroutput > 16000, it callsctx.client.session.prompt()to inject a message: " OUTPUT TRUNCATION DETECTED: Your previous response produced N tokens and was cut off. You MUST make a tool call (write, bash, or edit) in your next response. Limit reasoning to 3-5 lines. Act immediately." - Note: This detector fires even when
tokens.reasoningis absent (as is the case with models like GLM-5.2 that don't separate reasoning tokens). The zombie-loop detector alone would miss this case because it requirestypeof reasoning === "number".
Normalization & Progress Tracking
Commands are normalized before tracking — incrementing filenames (file1.c → file.c), heredoc bodies, and cosmetic variations collapse to one key, so the agent can't evade detection by renaming things. Output is similarly normalized (line numbers, timestamps, memory addresses stripped) before hashing. Genuine progress is not penalized: different content hashes or output hashes break cycles.
Phase Awareness
The plugin reads the AGENT_PHASE environment variable to adjust its behavior during different phases of an agent pipeline (e.g., FrankCode's main → verify → fix cycle).
| Phase prefix | What's skipped | What still runs |
|---|---|---|
verify_* |
Exploration sprawl, file-target investigation, global file investigation | Duplicate test, command streak, timeout loop, hard loop, action cycle, subagent loop, truncation detection |
fix_* |
(no exemptions — same as main) | All detectors active |
main or unset |
(no exemptions) | All detectors active |
Why: During verification phases, the agent's primary job is to read deliverables and test output — activities that would normally trigger exploration sprawl or file-investigation detectors. These detectors produce false positives during verification, blocking the agent from doing its job. The phase awareness feature ensures that only genuinely harmful loops (duplicate tests, repeated commands, etc.) are blocked during verification.
How to set the phase: The orchestrator (e.g., FrankCode) sets AGENT_PHASE in the environment before launching each opencode session. The value is typically main, verify_1, verify_2, fix_1, fix_2, etc.
AGENT_PHASE=verify_1 opencode run "verify the deliverables..."
Escalation Behavior
When a detector triggers, the plugin throws an error with a descriptive message guiding the agent to change approach. If the agent keeps getting blocked, the plugin escalates:
- Advisory (at N-1 threshold): A note is appended to the tool output suggesting the agent reconsider its approach. Does not block.
- Block (at N threshold): The action is blocked with a / error message.
- Compact + redirect (5+ consecutive blocks): The session is compacted and a "SYSTEM OVERRIDE" prompt is injected demanding the agent write code. Investigation counters are reset.
- Rollback (10+ consecutive blocks, if
allowRollback: true): The session is reverted to an earlier message viasession.revert, and the agent is prompted to try a fundamentally different approach. Max 1 rollback per session.
Rollback is aggressive. It reverts the agent's session state (conversation history) to an earlier point and injects a system prompt. Files written by the agent may still exist on disk after rollback — only the conversation context is rolled back. Only enable
allowRollbackif you understand this. Defaults tofalse.
Behavioral Side Effects
Besides blocking, the plugin modifies tool outputs and subagent prompts:
| Behavior | When | What happens |
|---|---|---|
| Subagent prompt injection | Every task call |
Prepends an <EXTREMELY_IMPORTANT> block mandating use of the skill tool and TDD |
| Harness notes | Failed/timed-out bash | Appends a note suggesting the systematic-debugging skill |
| Advisory notes | Approaching cycle limits | Appends a note to tool output |
| Session compaction | 5+ consecutive blocks | Calls session.summarize |
| System override injection | 5+ consecutive blocks | Injects a session.prompt demanding the agent write code |
| Truncation prompt injection | Step ends with reason: "length" or output > 16K |
Injects a session.prompt telling the agent to make a tool call immediately |
Subagent prompt injection cannot be disabled. Every subagent spawned via the
tasktool gets an<EXTREMELY_IMPORTANT>instruction prepended, mandating use of theskilltool and TDD. If you don't use the "superpowers" skill system, this injection will still appear in your subagent prompts.
Tool Coverage
| Tool | Monitored | How |
|---|---|---|
bash |
Yes | Command normalization, intent classification, streak tracking, output hashing, file-target extraction, test detection |
write |
Yes | File hash tracking, mutation epoch increment, action cycle detection |
edit |
Yes | Same as write |
read |
Partial | Tracked in action history for cycle detection; increments sprawl counter |
task |
Yes | Subagent frequency tracking, prompt novelty computation, prompt injection |
skill |
No | Explicitly exempt |
event (step-finish) |
Yes | Zombie loop detection, output truncation detection |
Setup Command Exemptions
The following commands are classified as "setup" intent and exempt from command streak detection (retrying them is often legitimate due to network/IO flakiness):
pip install,pip3 install,python3 -m pip installnpm install,npm add,npx install,yarn install/add,pnpm install/addapt-get install,apt install,apk add,yum install,dnf install,pacman installcargo install,rustup,go install,gem install,brew installcurl/wgetwithinstall,setup,build,bootstrap,configure, or.shin the command
Note: Setup commands are still subject to identical-output detection (if pip install produces the same output 3 times, it's blocked) and timeout detection (2 timeouts = blocked).
Configuration
Options
| Option | Type | Default | Description |
|---|---|---|---|
maxIdenticalTests |
number |
2 |
Max times a test can run against unchanged target code |
maxRepeatedCommands |
number |
3 |
Max consecutive runs of the same normalized command per mutation epoch |
maxConsecutiveIdenticalOutputs |
number |
5 |
Max consecutive commands (any command) producing identical output |
maxCyclicalActionRepeats |
number |
6 |
Max exact action-cycle repeats before blocking (advisory at N-1) |
maxSemanticCycleRepeats |
number |
8 |
Max semantic/conceptual cycle repeats before blocking (advisory at N-1) |
maxZombieSteps |
number |
3 |
Max consecutive steps with zero reasoning tokens |
maxHardLoops |
number |
3 |
Max consecutive failed bash commands |
maxSameFileInvestigations |
number |
12 |
Max investigations of the same file group (global cap is 2× this) |
maxStepsWithoutWrite |
number |
20 |
Max steps without a write, after the first write |
maxStepsWithoutFirstWrite |
number |
8 |
Max steps before the first write |
trackedFilePatterns |
string[] |
["*"] |
Glob patterns for files to track for hash-based detection |
testCommandPatterns |
string[] |
["pmars", "pytest", "npm test", "cargo test", "go test", "make test", "./test.sh"] |
Command substrings that identify test commands |
allowRollback |
boolean |
false |
Enable session rollback on persistent loops (see Escalation Behavior) |
Defaults are defined in
src/config.ts. If you change them in code, update this table to match.
Truncation threshold is hardcoded at 16,000 output tokens in
src/hooks.ts. It is not configurable via plugin options. To change it, modify theTRUNCATION_THRESHOLDconstant.
Examples
Less aggressive (raise thresholds):
{
"plugin": [
["opencode-anti-loop", {
"maxRepeatedCommands": 5,
"maxCyclicalActionRepeats": 10,
"maxStepsWithoutWrite": 30
}]
]
}
Track only Python files:
{
"plugin": [
["opencode-anti-loop", {
"trackedFilePatterns": ["**/*.py"],
"testCommandPatterns": ["pytest", "python -m pytest"]
}]
]
}
Enable rollback:
{
"plugin": [
["opencode-anti-loop", {
"allowRollback": true
}]
]
}
See Escalation Behavior before enabling rollback.
Limitations
- All detectors are always active. There is no per-detector disable. To effectively disable one, set its threshold to a very high number.
- No bypass or whitelist. If a detector triggers, the action is blocked. There is no "allow this once" flag.
- State is in-memory only. Tracking resets when OpenCode restarts. The agent gets a clean slate on each session.
- No logging or telemetry. The plugin is silent during normal operation. You'll only see output when a detector triggers or an advisory note is appended.
- Rollback reverts session state, not the filesystem. Files written by the agent may still exist on disk after rollback.
- Subagent prompt injection cannot be disabled. Every
taskcall gets an<EXTREMELY_IMPORTANT>instruction prepended. - Truncation threshold is not configurable. The 16K-token threshold is hardcoded. To change it, modify
src/hooks.ts. - Phase awareness requires
AGENT_PHASEenv var. Without it, all detectors run at full strength regardless of phase.
Programmatic Usage
import { antiLoop } from "opencode-anti-loop";
// Called by OpenCode's plugin loader with (ctx, options)
// Returns a Hooks object