Initial commit: pi-loop-guard extension
This commit is contained in:
@@ -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.
|
||||
```
|
||||
@@ -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": "*"
|
||||
}
|
||||
}
|
||||
@@ -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<LoopGuardSettings>;
|
||||
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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user