185 lines
6.5 KiB
JavaScript
185 lines
6.5 KiB
JavaScript
const { settings } = require("../config");
|
||
const { workspace } = require("../workspace");
|
||
const { broadcast } = require("../broadcast");
|
||
const { setPendingInterrupt } = require("../interrupt");
|
||
|
||
// ─── State ─────────────────────────────────────────────────────────────────
|
||
|
||
const LOOP_DETECT = {
|
||
recentTexts: [], // { text, shingles }[] — sliding window for text repetition
|
||
recentToolCalls: [], // string[] — "name:argsHash" signatures
|
||
turnsSinceToolCall: 0, // consecutive message turns with no tool use
|
||
alertedAt: 0, // timestamp of last alert (60s cooldown)
|
||
|
||
MAX_NO_TOOL_TURNS: 15,
|
||
MAX_TRACKED_TEXTS: 30,
|
||
MAX_TRACKED_TOOLS: 30,
|
||
SIMILARITY_THRESHOLD: 0.85,
|
||
MIN_REPEATS: 8,
|
||
MIN_TOOL_REPEATS: 4,
|
||
};
|
||
|
||
// ─── Text similarity helpers ───────────────────────────────────────────────
|
||
|
||
function textToShingles(text, k = 3) {
|
||
const normalized = text.toLowerCase().replace(/[^a-z0-9 ]/g, "").trim();
|
||
const words = normalized.split(/\s+/);
|
||
const shingles = new Set();
|
||
for (let i = 0; i <= words.length - k; i++) {
|
||
shingles.add(words.slice(i, i + k).join(" "));
|
||
}
|
||
return shingles;
|
||
}
|
||
|
||
function jaccardSimilarity(a, b) {
|
||
if (a.size === 0 && b.size === 0) return 1;
|
||
let intersection = 0;
|
||
for (const s of a) {
|
||
if (b.has(s)) intersection++;
|
||
}
|
||
return intersection / (a.size + b.size - intersection);
|
||
}
|
||
|
||
// ─── Detection ────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Returns true if the new text segment is suspiciously similar to recent
|
||
* segments, or if the agent has gone too many turns without calling a tool.
|
||
*/
|
||
function checkForLoop(newText) {
|
||
if (!newText || newText.length < 20) return false;
|
||
if (LOOP_DETECT.alertedAt && Date.now() - LOOP_DETECT.alertedAt < 60_000)
|
||
return false;
|
||
|
||
const shingles = textToShingles(newText);
|
||
LOOP_DETECT.recentTexts.push({ text: newText, shingles });
|
||
if (LOOP_DETECT.recentTexts.length > LOOP_DETECT.MAX_TRACKED_TEXTS)
|
||
LOOP_DETECT.recentTexts.shift();
|
||
|
||
let similarCount = 0;
|
||
for (let i = 0; i < LOOP_DETECT.recentTexts.length - 1; i++) {
|
||
if (
|
||
jaccardSimilarity(shingles, LOOP_DETECT.recentTexts[i].shingles) >=
|
||
LOOP_DETECT.SIMILARITY_THRESHOLD
|
||
)
|
||
similarCount++;
|
||
}
|
||
|
||
if (similarCount >= LOOP_DETECT.MIN_REPEATS) return true;
|
||
if (LOOP_DETECT.turnsSinceToolCall >= LOOP_DETECT.MAX_NO_TOOL_TURNS) return true;
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Returns a description string (truthy) if the agent is repeating the same
|
||
* tool-call cycle (length 1–3), or false if no loop is detected.
|
||
*/
|
||
function checkForToolLoop(toolName, argsJson) {
|
||
if (LOOP_DETECT.alertedAt && Date.now() - LOOP_DETECT.alertedAt < 60_000)
|
||
return false;
|
||
|
||
const sig = `${toolName}:${argsJson || ""}`;
|
||
LOOP_DETECT.recentToolCalls.push(sig);
|
||
if (LOOP_DETECT.recentToolCalls.length > LOOP_DETECT.MAX_TRACKED_TOOLS)
|
||
LOOP_DETECT.recentToolCalls.shift();
|
||
|
||
const calls = LOOP_DETECT.recentToolCalls;
|
||
for (let cycleLen = 1; cycleLen <= 3; cycleLen++) {
|
||
if (calls.length < cycleLen * LOOP_DETECT.MIN_TOOL_REPEATS) continue;
|
||
const tail = calls.slice(-cycleLen);
|
||
let repeats = 0;
|
||
for (let i = calls.length - cycleLen; i >= 0; i -= cycleLen) {
|
||
const chunk = calls.slice(i, i + cycleLen);
|
||
if (chunk.length < cycleLen) break;
|
||
if (chunk.every((v, j) => v === tail[j])) repeats++;
|
||
else break;
|
||
}
|
||
if (repeats >= LOOP_DETECT.MIN_TOOL_REPEATS) {
|
||
const cycleDesc = tail.map((s) => s.split(":")[0]).join(" → ");
|
||
return `tool cycle [${cycleDesc}] repeated ${repeats}x`;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ─── State management ─────────────────────────────────────────────────────
|
||
|
||
function resetLoopDetector() {
|
||
LOOP_DETECT.recentTexts = [];
|
||
LOOP_DETECT.recentToolCalls = [];
|
||
LOOP_DETECT.turnsSinceToolCall = 0;
|
||
LOOP_DETECT.alertedAt = 0;
|
||
}
|
||
|
||
/** Call whenever a tool execution starts to reset the no-tool-turn counter. */
|
||
function onToolCall() {
|
||
LOOP_DETECT.turnsSinceToolCall = 0;
|
||
}
|
||
|
||
/** Call whenever a text message turn arrives (increments no-tool-turn counter). */
|
||
function onTextMessage() {
|
||
LOOP_DETECT.turnsSinceToolCall++;
|
||
}
|
||
|
||
// ─── Intervention ─────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Injects a stop nudge into the LLM proxy interrupt queue and, if the proxy
|
||
* is not in use, also writes directly to the pi RPC channel. Surfaces an
|
||
* ask_for_help event so the user can intervene if the nudge fails.
|
||
*/
|
||
function alertLoopingAgent(reason) {
|
||
if (!workspace.piProcess) return;
|
||
if (workspace.pendingHelp) return;
|
||
|
||
console.warn(`[loop-detect] Suspected loop: ${reason}`);
|
||
LOOP_DETECT.alertedAt = Date.now();
|
||
|
||
const nudge =
|
||
`STOP. You are stuck in a loop: ${reason}. ` +
|
||
`The same tool calls are returning identical results. ` +
|
||
`Do NOT repeat the same action. Try a completely different approach: ` +
|
||
`use a different tool, navigate to a different URL, use curl instead of the browser, ` +
|
||
`or move on to the next step of the task.`;
|
||
|
||
if (settings.LLM_PROXY_TARGET) {
|
||
setPendingInterrupt(nudge);
|
||
}
|
||
|
||
if (workspace.piRpc) {
|
||
workspace.piRpc.write(
|
||
JSON.stringify({
|
||
id: `loop-nudge-${Date.now()}`,
|
||
type: "prompt",
|
||
message: nudge,
|
||
streamingBehavior: "followUp",
|
||
}) + "\n",
|
||
);
|
||
}
|
||
|
||
console.log(`[loop-detect] Auto-interrupted: ${reason}`);
|
||
broadcast({ type: "agent_log", content: `[loop-detect] Auto-interrupted: ${reason}` });
|
||
|
||
const fakeId = `loop-${Date.now()}`;
|
||
workspace.pendingHelp = { id: fakeId, method: "input" };
|
||
broadcast({
|
||
type: "ask_for_help",
|
||
requestId: fakeId,
|
||
method: "input",
|
||
content:
|
||
`The agent was stuck in a loop (${reason}). ` +
|
||
`An automatic nudge was sent to break it out. ` +
|
||
`If it keeps looping, you can abort or send a manual instruction.`,
|
||
title: "Agent Loop Detected — Auto-Interrupted",
|
||
});
|
||
}
|
||
|
||
module.exports = {
|
||
checkForLoop,
|
||
checkForToolLoop,
|
||
resetLoopDetector,
|
||
onToolCall,
|
||
onTextMessage,
|
||
alertLoopingAgent,
|
||
};
|