llm-apology-loop-detector-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +44 -0
  2. package/index.js +311 -0
  3. package/mcpize.yaml +12 -0
  4. package/package.json +33 -0
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # llm-apology-loop-detector-mcp
2
+
3
+ Detect **apology loops**, **retry deadlocks**, and **"I'll try again"** failure modes in LLM transcripts. One MCP call gives you a health score and a ranked escalation plan.
4
+
5
+ ## Why
6
+ Production AI agents (Claude/GPT/Gemini) get stuck spiraling: "Sorry, you're right, let me try again" repeated 30 turns deep, burning $$$ in tokens and never converging. On-call engineers spot it eventually — this MCP server spots it in one call.
7
+
8
+ ## Tools
9
+
10
+ | Tool | What it does |
11
+ |------|--------------|
12
+ | `detect_apology_loop` | Phrase scan + sliding-window hot spots (density, severity, samples) |
13
+ | `detect_retry_loop` | Identical tool-call repeats + `A→B→A→B` sequence cycles |
14
+ | `loop_health_score` | 0-100 score: `healthy / watch / degraded / deadlocked` |
15
+ | `escalation_advisor` | Health + ranked actions: `human_handoff`, `model_swap`, `reset_context`, `raise_temperature`, `kill_session`, `memoize_tool_failure`, `break_sequence` |
16
+ | `diagnose_transcript` | One-shot on-call triage: headline + postmortem + first failure + everything above |
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install -g llm-apology-loop-detector-mcp
22
+ ```
23
+
24
+ ```jsonc
25
+ // claude_desktop_config.json
26
+ {
27
+ "mcpServers": {
28
+ "apology-loop": {
29
+ "command": "npx",
30
+ "args": ["-y", "llm-apology-loop-detector-mcp"]
31
+ }
32
+ }
33
+ }
34
+ ```
35
+
36
+ ## Input shape
37
+
38
+ Any of:
39
+ - JSON array of messages: `[{role, content, tool?, input?}, ...]`
40
+ - JSONL string (one message per line)
41
+ - `{messages: [...]}` object
42
+
43
+ ## License
44
+ MIT
package/index.js ADDED
@@ -0,0 +1,311 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * llm-apology-loop-detector-mcp
4
+ * ─────────────────────────────
5
+ * Detect apology loops, retry deadlocks, and "I'll try again" failure modes in LLM
6
+ * transcripts. Built for production agent ops teams who watch Claude/GPT spirals in
7
+ * dashboards and need a single MCP call to triage and escalate.
8
+ */
9
+
10
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
+ import { z } from "zod";
13
+
14
+ // ─── Phrase taxonomy ──────────────────────────────────────────────────────
15
+ const APOLOGY_PATTERNS = [
16
+ { rx: /\b(I\s?(?:am|'m)\s+sorry|my\s+apologies|apologies|I\s+apologi[sz]e)\b/i, w: 25, why: "explicit_apology" },
17
+ { rx: /\b(you\s+(?:are|'re)\s+(?:right|correct)|you\s+make\s+a\s+good\s+point)\b/i, w: 18, why: "concede_to_user" },
18
+ { rx: /\b(let\s+me\s+(?:try|do)\s+(?:that|this)\s+again|I'?ll\s+try\s+again|let\s+me\s+retry)\b/i, w: 30, why: "retry_intent" },
19
+ { rx: /\b(I\s+(?:made|see)\s+(?:an?\s+)?(?:mistake|error)|I\s+(?:was|am)\s+(?:wrong|incorrect))\b/i, w: 28, why: "self_correction" },
20
+ { rx: /\b(I\s+misunderstood|I\s+misread|I\s+wasn'?t\s+clear|I\s+wasn'?t\s+careful)\b/i, w: 22, why: "misunderstanding" },
21
+ { rx: /\b(let\s+me\s+(?:rethink|reconsider|reconsider this|approach this differently))\b/i, w: 24, why: "rethink_attempt" },
22
+ { rx: /\b(I\s+see\s+the\s+(?:issue|problem|confusion)\s+now)\b/i, w: 20, why: "insight_claim" },
23
+ { rx: /\b(thank\s+you\s+for\s+(?:catching|pointing\s+out|the\s+correction))\b/i, w: 18, why: "thanks_for_correction" },
24
+ ];
25
+
26
+ const FRUSTRATION_PATTERNS = [
27
+ { rx: /\b(actually|wait|hmm|hmmm|let\s+me\s+think)\b/i, w: 6, why: "hedge" },
28
+ { rx: /\b(unfortunately|however|but\s+actually|that\s+said)\b/i, w: 8, why: "soft_contradiction" },
29
+ { rx: /\b(I\s+cannot|I\s+can'?t|I'?m\s+unable\s+to)\b/i, w: 15, why: "capability_denial" },
30
+ ];
31
+
32
+ const TOOL_FAIL_HINTS = [
33
+ /\b(error|exception|failed|failure|crash|panic|timeout|timed?\s?out)\b/i,
34
+ /\b(403|404|429|500|502|503|504)\b/,
35
+ /\b(connection\s+(?:refused|reset)|enotfound|econnrefused)\b/i,
36
+ ];
37
+
38
+ const RETRY_TOOL_WHITELIST = new Set([
39
+ "Read","Write","Edit","Bash","Glob","Grep","fetch","http","curl","wrangler","node","npm","python",
40
+ ]);
41
+
42
+ // ─── Helpers ─────────────────────────────────────────────────────────────
43
+ function safeJson(s, fallback = null) { try { return JSON.parse(s); } catch { return fallback; } }
44
+
45
+ function normalizeMessages(raw) {
46
+ if (Array.isArray(raw)) return raw;
47
+ if (raw && typeof raw === "object" && Array.isArray(raw.messages)) return raw.messages;
48
+ if (typeof raw !== "string") return [];
49
+ const t = raw.trim();
50
+ if (t.startsWith("[")) { const p = safeJson(t); if (Array.isArray(p)) return p; }
51
+ if (t.startsWith("{")) { const p = safeJson(t); if (p && Array.isArray(p.messages)) return p.messages; }
52
+ return t.split(/\r?\n/).map((line) => safeJson(line.trim())).filter(Boolean);
53
+ }
54
+
55
+ function roleOf(msg) {
56
+ return (msg?.role || msg?.author || msg?.from || "assistant").toLowerCase();
57
+ }
58
+ function textOf(msg) {
59
+ if (typeof msg === "string") return msg;
60
+ if (typeof msg?.content === "string") return msg.content;
61
+ if (Array.isArray(msg?.content)) {
62
+ return msg.content.map((c) => typeof c === "string" ? c : (c?.text || c?.input || c?.output || "")).filter(Boolean).join("\n");
63
+ }
64
+ return msg?.text || msg?.message || "";
65
+ }
66
+ function toolOf(msg) {
67
+ return msg?.tool || msg?.name || msg?.function?.name || null;
68
+ }
69
+
70
+ function ngram(arr, n) {
71
+ const out = [];
72
+ for (let i = 0; i + n <= arr.length; i++) out.push(arr.slice(i, i + n).join("|"));
73
+ return out;
74
+ }
75
+
76
+ // ─── Apology loop detection ──────────────────────────────────────────────
77
+ function scoreApologyPhrases(text) {
78
+ let score = 0;
79
+ const reasons = [];
80
+ for (const p of APOLOGY_PATTERNS) {
81
+ if (p.rx.test(text)) { score += p.w; reasons.push(p.why); }
82
+ }
83
+ for (const p of FRUSTRATION_PATTERNS) {
84
+ if (p.rx.test(text)) { score += p.w; reasons.push(p.why); }
85
+ }
86
+ return { score, reasons };
87
+ }
88
+
89
+ function detectApologyLoop(raw, { window = 6, threshold = 2 } = {}) {
90
+ const messages = normalizeMessages(raw);
91
+ const assistantMessages = messages.map((m, i) => ({ i, role: roleOf(m), text: textOf(m), tool: toolOf(m) }))
92
+ .filter((m) => m.role === "assistant" && m.text);
93
+ const scored = assistantMessages.map((m) => {
94
+ const s = scoreApologyPhrases(m.text);
95
+ return { ...m, apology_score: s.score, reasons: s.reasons };
96
+ });
97
+ const apologyTurns = scored.filter((s) => s.apology_score >= 20);
98
+ // Sliding window: ≥threshold apologies inside any `window`-sized assistant span
99
+ const windows = [];
100
+ for (let i = 0; i + window <= scored.length; i++) {
101
+ const slice = scored.slice(i, i + window);
102
+ const hits = slice.filter((s) => s.apology_score >= 20);
103
+ if (hits.length >= threshold) {
104
+ windows.push({
105
+ start_msg_index: slice[0].i,
106
+ end_msg_index: slice[slice.length - 1].i,
107
+ apology_count: hits.length,
108
+ window_size: window,
109
+ severity: hits.length >= window * 0.6 ? "high" : hits.length >= window * 0.4 ? "medium" : "low",
110
+ });
111
+ }
112
+ }
113
+ return {
114
+ total_assistant_messages: scored.length,
115
+ total_apology_turns: apologyTurns.length,
116
+ apology_density: scored.length ? Math.round((apologyTurns.length / scored.length) * 1000) / 10 : 0,
117
+ apology_windows: windows.slice(0, 8),
118
+ sample_apology_turns: apologyTurns.slice(0, 5).map((a) => ({
119
+ msg_index: a.i,
120
+ reasons: a.reasons,
121
+ score: a.apology_score,
122
+ text_excerpt: a.text.slice(0, 200),
123
+ })),
124
+ };
125
+ }
126
+
127
+ // ─── Retry loop detection ────────────────────────────────────────────────
128
+ function detectRetryLoop(raw, { min_repeats = 3 } = {}) {
129
+ const messages = normalizeMessages(raw);
130
+ // Extract tool calls with their (tool,input) signature
131
+ const calls = [];
132
+ for (const m of messages) {
133
+ const tool = toolOf(m);
134
+ if (!tool) continue;
135
+ const input = m?.input || m?.arguments || m?.params || m?.payload || null;
136
+ const sig = `${tool}:${stableHash(input)}`;
137
+ calls.push({ tool, sig, hadError: messages.some((mm) => mm?.parent_id === m?.id && isError(mm)) || isError(m) });
138
+ }
139
+ const sigCount = {};
140
+ for (const c of calls) sigCount[c.sig] = (sigCount[c.sig] || 0) + 1;
141
+ const repeats = Object.entries(sigCount)
142
+ .filter(([, n]) => n >= min_repeats)
143
+ .map(([sig, n]) => {
144
+ const [tool, hash] = sig.split(":");
145
+ return {
146
+ signature: sig,
147
+ tool,
148
+ identical_calls: n,
149
+ likely_retry: RETRY_TOOL_WHITELIST.has(tool) || calls.find((c) => c.sig === sig && c.hadError) ? true : false,
150
+ };
151
+ });
152
+ // Also detect tool-sequence cycles (A→B→A→B)
153
+ const sigs = calls.map((c) => c.sig);
154
+ const cycles = [];
155
+ for (let len = 2; len <= 4; len++) {
156
+ for (let i = 0; i + len * min_repeats <= sigs.length; i++) {
157
+ const pattern = sigs.slice(i, i + len).join("→");
158
+ let reps = 1;
159
+ let cursor = i + len;
160
+ while (cursor + len <= sigs.length && sigs.slice(cursor, cursor + len).join("→") === pattern) {
161
+ reps++; cursor += len;
162
+ }
163
+ if (reps >= min_repeats) {
164
+ cycles.push({ pattern, length: len, repeats: reps, starts_at: i, ends_at: cursor - 1 });
165
+ i = cursor - 1;
166
+ }
167
+ }
168
+ }
169
+ return {
170
+ total_tool_calls: calls.length,
171
+ identical_call_repeats: repeats,
172
+ sequence_cycles: cycles.slice(0, 6),
173
+ has_retry_loop: repeats.some((r) => r.likely_retry) || cycles.length > 0,
174
+ };
175
+ }
176
+
177
+ function isError(msg) {
178
+ if (!msg) return false;
179
+ if (msg.error || msg.is_error || msg.failed === true) return true;
180
+ const t = textOf(msg);
181
+ return TOOL_FAIL_HINTS.some((rx) => rx.test(t));
182
+ }
183
+
184
+ function stableHash(v) {
185
+ if (v == null) return "∅";
186
+ const s = typeof v === "string" ? v : JSON.stringify(v);
187
+ let h = 0;
188
+ for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
189
+ return `h_${(h >>> 0).toString(36)}`;
190
+ }
191
+
192
+ // ─── Health score + advice ───────────────────────────────────────────────
193
+ function loopHealth(raw) {
194
+ const apology = detectApologyLoop(raw);
195
+ const retry = detectRetryLoop(raw);
196
+ let health = 100;
197
+ if (apology.apology_density >= 60) health -= 50;
198
+ else if (apology.apology_density >= 30) health -= 30;
199
+ else if (apology.apology_density >= 15) health -= 15;
200
+ if (apology.apology_windows.some((w) => w.severity === "high")) health -= 20;
201
+ if (retry.identical_call_repeats.some((r) => r.identical_calls >= 5)) health -= 25;
202
+ if (retry.sequence_cycles.length > 0) health -= 20;
203
+ health = Math.max(0, Math.min(100, health));
204
+ return {
205
+ health_score: health,
206
+ band: health >= 80 ? "healthy" : health >= 60 ? "watch" : health >= 40 ? "degraded" : "deadlocked",
207
+ apology,
208
+ retry,
209
+ };
210
+ }
211
+
212
+ function escalation(raw, options = {}) {
213
+ const h = loopHealth(raw);
214
+ const recs = [];
215
+ if (h.band === "deadlocked") {
216
+ recs.push({ action: "human_handoff", reason: "Health < 40 — escalate the conversation to a human operator immediately." });
217
+ recs.push({ action: "kill_session", reason: "Stop further tool calls to avoid burning tokens on a known dead loop." });
218
+ }
219
+ if (h.band === "degraded") {
220
+ recs.push({ action: "model_swap", to: options.fallback_model || "gpt-5", reason: "Same model is stuck — switch family (Claude→GPT or vice versa)." });
221
+ recs.push({ action: "raise_temperature", to: 0.9, reason: "Determinism trap; jiggle the sampler." });
222
+ }
223
+ if (h.apology.apology_density >= 30) {
224
+ recs.push({ action: "reset_context", reason: "Apology streak ≥30% — context contains poisoned correction loop; reset to system prompt + last user message." });
225
+ }
226
+ if (h.retry.identical_call_repeats.some((r) => r.identical_calls >= 4)) {
227
+ recs.push({ action: "memoize_tool_failure", reason: "Identical tool calls repeated; remember the failure and stop re-attempting in this session." });
228
+ }
229
+ if (h.retry.sequence_cycles.length > 0) {
230
+ recs.push({ action: "break_sequence", reason: `Detected A→B→A→B cycle (${h.retry.sequence_cycles[0].repeats}× repeats) — inject a stop tool or hand off.` });
231
+ }
232
+ if (recs.length === 0) {
233
+ recs.push({ action: "continue", reason: "No loop pathology detected — continue current run." });
234
+ }
235
+ return { ...h, recommendations: recs };
236
+ }
237
+
238
+ // ─── MCP wiring ──────────────────────────────────────────────────────────
239
+ const server = new McpServer({
240
+ name: "llm-apology-loop-detector-mcp",
241
+ version: "1.0.0",
242
+ description: "Detect apology loops, retry deadlocks, and 'I'll try again' agent failure modes — with escalation recommendations.",
243
+ });
244
+
245
+ const TranscriptInput = z.union([z.string(), z.array(z.any()), z.record(z.any())])
246
+ .describe("Transcript as JSON array of messages, JSONL string, or {messages:[...]} object. Each message: {role, content, tool?, input?}.");
247
+
248
+ server.tool(
249
+ "detect_apology_loop",
250
+ "Scan the transcript for apology / 'try again' / self-correction phrases. Returns density, sliding-window hot spots, and sample turns.",
251
+ { transcript: TranscriptInput, window: z.number().int().min(2).default(6), threshold: z.number().int().min(2).default(2) },
252
+ async ({ transcript, window, threshold }) => {
253
+ const out = detectApologyLoop(transcript, { window, threshold });
254
+ return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
255
+ }
256
+ );
257
+
258
+ server.tool(
259
+ "detect_retry_loop",
260
+ "Find identical tool-call repeats and A→B→A→B sequence cycles — the classic sign of an agent stuck retrying a broken action.",
261
+ { transcript: TranscriptInput, min_repeats: z.number().int().min(2).default(3) },
262
+ async ({ transcript, min_repeats }) => {
263
+ const out = detectRetryLoop(transcript, { min_repeats });
264
+ return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
265
+ }
266
+ );
267
+
268
+ server.tool(
269
+ "loop_health_score",
270
+ "Combine apology + retry signals into a 0-100 health score (healthy / watch / degraded / deadlocked).",
271
+ { transcript: TranscriptInput },
272
+ async ({ transcript }) => {
273
+ const out = loopHealth(transcript);
274
+ return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
275
+ }
276
+ );
277
+
278
+ server.tool(
279
+ "escalation_advisor",
280
+ "Health score + a ranked list of recommended actions (human_handoff, model_swap, reset_context, raise_temperature, kill_session, etc.).",
281
+ { transcript: TranscriptInput, fallback_model: z.string().optional() },
282
+ async ({ transcript, fallback_model }) => {
283
+ const out = escalation(transcript, { fallback_model });
284
+ return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
285
+ }
286
+ );
287
+
288
+ server.tool(
289
+ "diagnose_transcript",
290
+ "One-shot triage for an on-call: apology + retry + health + recommendations + first failing tool + a one-paragraph postmortem.",
291
+ { transcript: TranscriptInput },
292
+ async ({ transcript }) => {
293
+ const adv = escalation(transcript);
294
+ const messages = normalizeMessages(transcript);
295
+ const firstFail = messages.find((m) => isError(m));
296
+ const postmortem = [
297
+ `Health: ${adv.health_score}/100 (${adv.band}).`,
298
+ `Apology density: ${adv.apology.apology_density}% across ${adv.apology.total_assistant_messages} assistant turns.`,
299
+ `Retry signals: ${adv.retry.identical_call_repeats.length} repeated-call patterns, ${adv.retry.sequence_cycles.length} sequence cycles.`,
300
+ adv.recommendations[0] ? `Primary action: ${adv.recommendations[0].action} — ${adv.recommendations[0].reason}` : "",
301
+ ].filter(Boolean).join(" ");
302
+ return { content: [{ type: "text", text: JSON.stringify({
303
+ headline: `${adv.band.toUpperCase()} — health ${adv.health_score}/100`,
304
+ postmortem,
305
+ first_failure: firstFail ? { role: roleOf(firstFail), tool: toolOf(firstFail), excerpt: textOf(firstFail).slice(0, 240) } : null,
306
+ ...adv,
307
+ }, null, 2) }] };
308
+ }
309
+ );
310
+
311
+ await server.connect(new StdioServerTransport());
package/mcpize.yaml ADDED
@@ -0,0 +1,12 @@
1
+ name: llm-apology-loop-detector-mcp
2
+ version: 1.0.0
3
+ description: Detect apology loops, retry deadlocks, and "I'll try again" agent failure modes in LLM transcripts. Surfaces the loop, scores severity, and prescribes an escalation action.
4
+ license: MIT
5
+ runtime: node
6
+ entry: index.js
7
+ tools:
8
+ - detect_apology_loop
9
+ - detect_retry_loop
10
+ - loop_health_score
11
+ - escalation_advisor
12
+ - diagnose_transcript
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "llm-apology-loop-detector-mcp",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Detect apology loops, retry deadlocks, and 'I'll try again' agent failure modes in LLM transcripts. Surfaces the loop, scores severity, and prescribes an escalation action (temperature bump, model swap, human handoff).",
6
+ "bin": { "llm-apology-loop-detector-mcp": "index.js" },
7
+ "main": "index.js",
8
+ "mcp": {
9
+ "tools": [
10
+ "detect_apology_loop",
11
+ "detect_retry_loop",
12
+ "loop_health_score",
13
+ "escalation_advisor",
14
+ "diagnose_transcript"
15
+ ]
16
+ },
17
+ "keywords": [
18
+ "mcp",
19
+ "llm",
20
+ "agent",
21
+ "observability",
22
+ "debugging",
23
+ "apology",
24
+ "loop",
25
+ "deadlock"
26
+ ],
27
+ "license": "MIT",
28
+ "author": "lazymac2x",
29
+ "dependencies": {
30
+ "@modelcontextprotocol/sdk": "latest",
31
+ "zod": "^3.23.0"
32
+ }
33
+ }