From 292253ff41400fbe043bdb291e46e029cb20eecb Mon Sep 17 00:00:00 2001 From: laptran Date: Sat, 30 May 2026 11:18:56 -0400 Subject: [PATCH] Initial commit: pi-loop-guard extension --- README.md | 54 ++++++++++++++++++++++++++++++ package.json | 13 ++++++++ src/index.ts | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 README.md create mode 100644 package.json create mode 100644 src/index.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..b509052 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# pi-loop-guard + +Prevents infinite tool call loops in pi by counting tool calls and turns, then aborting the agent when a configurable threshold is exceeded. + +## Why? + +When the LLM encounters repeated errors (database connection failures, missing API keys, etc.), it can get stuck in an infinite loop of tool calls that all return the same error. This extension detects and stops that pattern. + +## Installation + +### Via pi package (recommended) + +```bash +pi install git:unraid.local:3003/pi-dev/pi-loop-guard +``` + +### Manual + +Place in `~/.pi/agent/extensions/` or `.pi/extensions/`: + +```bash +cp -r pi-loop-guard ~/.pi/agent/extensions/pi-loop-guard +``` + +## Configuration + +Add to `.pi/settings.json` or `~/.pi/agent/settings.json`: + +```json +{ + "loopGuard": { + "maxToolCalls": 50, + "maxTurns": 20 + } +} +``` + +| Setting | Default | Description | +|---------|---------|-------------| +| `maxToolCalls` | 50 | Maximum total tool calls before abort | +| `maxTurns` | 20 | Maximum turns (LLM responses) before abort | + +## Behavior + +- Counts tool calls across all turns in a session +- Counts total turns (LLM responses) +- When either threshold is exceeded, calls `ctx.abort()` to stop the agent +- Displays an error notification with details + +## Example + +``` +Loop guard: stopped after 51 tool calls (threshold: 50). Possible infinite loop detected. +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..5ed1b6c --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "pi-loop-guard", + "version": "1.0.0", + "description": "Prevents infinite tool call loops in pi by counting tool calls and aborting the agent when a threshold is exceeded.", + "keywords": ["pi-package"], + "pi": { + "extensions": ["./src/index.ts"] + }, + "dependencies": {}, + "devDependencies": { + "@earendil-works/pi-coding-agent": "*" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9ab1723 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,93 @@ +/** + * pi-loop-guard — Infinite loop prevention extension + * + * Counts tool calls across turns and aborts the agent when a configurable + * threshold is exceeded. This prevents the agent from looping indefinitely + * when errors repeat (e.g., database connection failures, missing API keys). + * + * Usage: + * pi install git:unraid.local:3003/pi-dev/pi-loop-guard + * + * Configuration (in .pi/settings.json or ~/.pi/agent/settings.json): + * { + * "loopGuard": { + * "maxToolCalls": 50, + * "maxTurns": 20 + * } + * } + */ + +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; + +interface LoopGuardSettings { + maxToolCalls: number; + maxTurns: number; +} + +const DEFAULT_SETTINGS: LoopGuardSettings = { + maxToolCalls: 50, + maxTurns: 20, +}; + +function readSettings(ctx: ExtensionContext): LoopGuardSettings { + try { + const raw = ctx.sessionManager.getEntries(); + for (const entry of raw) { + if (entry.type === "custom" && entry.customType === "loopguard-settings") { + const s = entry.data as Partial; + return { + maxToolCalls: s.maxToolCalls ?? DEFAULT_SETTINGS.maxToolCalls, + maxTurns: s.maxTurns ?? DEFAULT_SETTINGS.maxTurns, + }; + } + } + } catch { + // Ignore errors reading settings + } + return { ...DEFAULT_SETTINGS }; +} + +export default function (pi: ExtensionAPI) { + let toolCallCount = 0; + let turnCount = 0; + let settings: LoopGuardSettings = { ...DEFAULT_SETTINGS }; + + // Load settings on session start + pi.on("session_start", async (_event, ctx) => { + settings = readSettings(ctx); + }); + + // Count tool calls and turns + pi.on("turn_end", async (_event, ctx) => { + const entries = ctx.sessionManager.getEntries(); + const lastEntry = entries[entries.length - 1]; + + if (lastEntry?.type === "message" && lastEntry.message?.role === "assistant") { + const content = lastEntry.message.content as Array<{ type: string }>; + const toolCallsInTurn = content.filter((c) => c.type === "toolCall").length; + toolCallCount += toolCallsInTurn; + } + + turnCount++; + + if (toolCallCount >= settings.maxToolCalls) { + ctx.abort(); + ctx.ui.notify( + `Loop guard: stopped after ${toolCallCount} tool calls (threshold: ${settings.maxToolCalls}). Possible infinite loop detected.`, + "error", + ); + } else if (turnCount >= settings.maxTurns) { + ctx.abort(); + ctx.ui.notify( + `Loop guard: stopped after ${turnCount} turns (threshold: ${settings.maxTurns}). Possible infinite loop detected.`, + "error", + ); + } + }); + + // Reset counters on session shutdown + pi.on("session_shutdown", async () => { + toolCallCount = 0; + turnCount = 0; + }); +}