pi-antiloop 1.7.0 → 1.8.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.
package/README.md CHANGED
@@ -15,6 +15,7 @@
15
15
  - **Seven detection strategies** — text repetition (trigram Jaccard + Levenshtein), tool-call sequences (name + near-identical arguments + same outcome — result-aware, so retries that make progress don't false-positive), thinking blocks, structural opening-phrase patterns, **degenerate repetition** (a single message/call stuck repeating one word hundreds of times — the `noguerol ×5145` meltdown — caught at `message_end` with no repeated peer needed, and degenerate bash commands blocked before they execute), **block repetition** (ONE message replaying whole sentences/phrases — the narration loop: `Let me start by checking the environment…` ×5 — same `message_end` timing and strong turn weight), and **no-progress outcome runs** (the NFS `test A…QQQ` class: dozens of near-identical re-runs of the same experiment, every one ending in the *same failing outcome* — args mutate so tool-loop can't see it; the repeated failure signature can)
16
16
  - **Stays alive in long sessions** — tracked messages carry a monotonic sequence number, so trimming the sliding window can never make the dedupe guard skip later messages (a bug that silently blinded antiloop after ~15 tracked messages)
17
17
  - **Task-stream recognition (batch work)** — when another extension (e.g. `punched` appending lines to pi.md, or `plan` adding tasks) makes the model call the *same* tool many times with *different* content, antiloop recognizes it as N distinct tasks of one type and stays silent — no warning, no force break. A genuine loop (the *same* call repeated verbatim) is still caught
18
+ - **Snapshot/read-only tool exemption** — a coordinator closing N agents that already finished calls `trimegisto_harvest` once per agent and gets the *same* settled board every time; identical reads of a status snapshot are an idempotent serial close, not a loop, so `snapshotTools` (configurable) is exempt from tool-loop and no-progress detection — real `bash` loops next to it are still caught
18
19
  - **Progressive intervention** — `warning` reminds the model to vary its approach; `force break` steers a real break message into the running agent before its next LLM call; `abort` stops the run entirely
19
20
  - **Configurable thresholds** — independent dials for similarity cutoff, warning/force-break/abort counts, detection window, and which strategies are on
20
21
  - **Sliding window** — only the last N messages are compared, so detection is O(N) in the window size, not in the full session
@@ -141,6 +142,9 @@ Grouped interactive menu showing the current value in each option:
141
142
  - **📋 stream min calls** — `2 / 3 / 4 / 5` — same-tool calls required before a batch is recognized (default 3)
142
143
  - **📋 twin threshold** — `99 / 95 / 90%` — calls more similar than this count as the *same task* repeated; one twin invalidates the batch and normal detection resumes (default 99%)
143
144
 
145
+ **📡 Snapshot tools** — read-only status boards (trimegisto_harvest, …) read serially are not a loop
146
+ - **📡 snapshot tools** — `trimegisto_harvest` / off — repeated identical reads of a settled board stay silent all the way through (default on; add any other status/poll tool in `antiloop.json`)
147
+
144
148
  **🔍 Detectors**
145
149
  - **📝 text** — on/off — detect repeated text messages
146
150
  - **🔧 tools** — on/off — detect repeated tool calls
@@ -297,6 +301,27 @@ size needed before recognition), `taskStreamTwinThreshold` (how similar args
297
301
  must be to count as *the same task* — lower it to treat near-duplicate
298
302
  entries as loops again).
299
303
 
304
+ ### Snapshot tools: the serial close of settled agents ≠ a loop
305
+
306
+ A coordinator that closes N agents which have already finished calls the
307
+ snapshot tool once per agent. When the agents are all settled, every one of
308
+ those calls returns the **same** serialized board — same arguments (`{}`),
309
+ same result. To the tool detector that is indistinguishable from a verbatim
310
+ loop: `same args + same outcome` repeated N times, so it force-broke a run
311
+ that was simply collecting finished results.
312
+
313
+ Re-reading a state snapshot is an *idempotent read*, not a stuck generation,
314
+ so tools listed in `snapshotTools` (default `["trimegisto_harvest"]`) are
315
+ exempt from tool-loop **and** no-progress outcome detection. The exemption is
316
+ deliberately narrow:
317
+
318
+ - it only applies when *every* call in the message is a snapshot tool — a
319
+ real `bash` loop re-run alongside a harvest is still flagged;
320
+ - it is name-based and configurable, so `antiloop.json` can list any other
321
+ status/poll tool (`"snapshotTools": ["trimegisto_harvest", "my_status"]`);
322
+ - clearing it (`"snapshotTools": []` or **📡 off**) restores normal detection,
323
+ where the very same 5× harvest payload is a genuine verbatim tool loop.
324
+
300
325
  ### Degenerate repetition: ONE message stuck on a word ≠ a reasoning loop
301
326
 
302
327
  All strategies above need at least two similar messages — they detect a model
@@ -503,6 +528,7 @@ Persisted as JSON at `~/.pi/agent/antiloop.json`:
503
528
  | `detectTaskStreams` | `true` | Recognize homogeneous batch work (same tool called with distinct content — e.g. punched/plan/obsidian extensions) and stay silent; see [task streams](#task-streams-n-tasks-of-one-type--a-loop) |
504
529
  | `taskStreamMinCalls` | `3` | Same-tool calls required inside the window before a task stream is recognized |
505
530
  | `taskStreamTwinThreshold` | `0.99` | Arguments this similar (or identical) count as *the same task* — a twin invalidates the stream and re-enables normal loop detection |
531
+ | `snapshotTools` | `["trimegisto_harvest"]` | Read-only snapshot/status tools: repeated identical reads (the serial close of N settled agents) never count as a tool loop or no-progress run. Add any other status/poll tool by name |
506
532
  | `detectTextLoops` | `true` | Detect full-text repetition |
507
533
  | `detectToolLoops` | `true` | Detect tool-call sequence + argument repetition |
508
534
  | `detectThinkingLoops` | `true` | Detect repeated thinking/reasoning content |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-antiloop",
3
- "version": "1.7.0",
4
- "description": "Antiloop: detect reasoning loops and force a break (warn → force → abort) across text, tool, thinking, structural, degenerate-repetition, block/narration-repetition, and no-progress outcome patterns. Degenerate (one message stuck repeating a word hundreds of times — noguerol ×5145) is caught at message_end and its bash blocked before executing. Block (ONE message replaying whole sentences/phrases — the 'Let me start by checking the environment…' ×5 narration loop, invisible to every cross-message detector because there is no peer) fires on the first such message with the same strong turn weight and blocks a replayed bash command. No-progress (the NFS test-A…QQQ class: ~90 mutated re-runs of the same experiment, every one failing identically) fires when the same failing outcome repeats ≥ outcomeMinRepeats times with near-identical args. Fixes antiloop going blind mid-session: tracked messages use a monotonic sequence so the trim of the sliding window can never collide turn indices. The force break is delivered mid-run (steer before the next LLM call) and if the model ignores it antiloop aborts the run — an autonomous tool loop always terminates. Result-aware tool-loop detection keeps sequential bash and converging sweeps quiet; task-stream recognition keeps punched/plan batch work quiet.",
3
+ "version": "1.8.0",
4
+ "description": "Antiloop: detect reasoning loops and force a break (warn → force → abort) across text, tool, thinking, structural, degenerate-repetition, block/narration-repetition, and no-progress outcome patterns. Degenerate (one message stuck repeating a word hundreds of times — noguerol ×5145) is caught at message_end and its bash blocked before executing. Block (ONE message replaying whole sentences/phrases — the 'Let me start by checking the environment…' ×5 narration loop, invisible to every cross-message detector because there is no peer) fires on the first such message with the same strong turn weight and blocks a replayed bash command. No-progress (the NFS test-A…QQQ class: ~90 mutated re-runs of the same experiment, every one failing identically) fires when the same failing outcome repeats ≥ outcomeMinRepeats times with near-identical args. Snapshot tools (trimegisto_harvest) are exempt: a coordinator closing N settled agents re-reads the SAME board serially, which is an idempotent read, not a loop — so isVerbatimRepeat never fires on it. Fixes antiloop going blind mid-session: tracked messages use a monotonic sequence so the trim of the sliding window can never collide turn indices. The force break is delivered mid-run (steer before the next LLM call) and if the model ignores it antiloop aborts the run — an autonomous tool loop always terminates. Result-aware tool-loop detection keeps sequential bash and converging sweeps quiet; task-stream recognition keeps punched/plan batch work quiet.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "antiloop",
package/src/commands.ts CHANGED
@@ -62,6 +62,7 @@ async function showStatus(ctx: ExtensionCommandContext, rt: Runtime): Promise<vo
62
62
  ` block repeats: ${yn(rt.config.detectBlockRepeats)} (≥ ${(rt.config.blockRepeatShare * 100).toFixed(0)}% of ≥ ${rt.config.blockMinTokens} tokens replay ${rt.config.blockNgram}-grams ×${rt.config.blockMinRepeats}+)`,
63
63
  ` outcome: same failing result ≥ ${rt.config.outcomeMinRepeats} attempts (args ≥ ${(rt.config.outcomeArgSimilarity * 100).toFixed(0)}% sim, sig ≥ ${(rt.config.outcomeSigThreshold * 100).toFixed(0)}%)`,
64
64
  ` task streams: ${yn(rt.config.detectTaskStreams)} (min ${rt.config.taskStreamMinCalls} calls, twins ≥ ${(rt.config.taskStreamTwinThreshold * 100).toFixed(0)}%)`,
65
+ ` snapshot tools: ${rt.config.snapshotTools?.length ? rt.config.snapshotTools.join(", ") : "off"} (repeated reads are not a loop)`,
65
66
  "",
66
67
  `detectors: text ${yn(rt.config.detectTextLoops)} · tool ${yn(rt.config.detectToolLoops)} · think ${yn(rt.config.detectThinkingLoops)} · degenerate ${yn(rt.config.detectDegenerate)} · block ${yn(rt.config.detectBlockRepeats)} · outcome ${yn(rt.config.detectOutcomeLoops)}`,
67
68
  `footer: interactive ${yn(rt.config.interactiveFooter)} · toggle: ${rt.config.toggleShortcut}`,
@@ -105,6 +106,7 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
105
106
  { value: "streams" as const, label: `📋 task streams: ${yn(c.detectTaskStreams)}`, description: "batch work (punched_log / plan_manager / …) is not a loop" },
106
107
  { value: "streamMin" as const, label: `📋 stream min calls: ${c.taskStreamMinCalls}`, description: "calls of the same tool before a batch is recognized" },
107
108
  { value: "streamTwin" as const, label: `📋 twin threshold: ${(c.taskStreamTwinThreshold * 100).toFixed(0)}%`, description: "calls more similar than this = the same task repeated, not a batch" },
109
+ { value: "snapshot" as const, label: `📡 snapshot tools: ${c.snapshotTools?.length ? c.snapshotTools.join(", ") : "off"}`, description: "read-only status tools (trimegisto_harvest): re-reading settled agents serially is not a loop" },
108
110
  // ── 🔍 Detectors ────────────────────────────────────────────
109
111
  { value: "text" as const, label: `📝 text: ${yn(c.detectTextLoops)}`, description: "detect repeated text messages" },
110
112
  { value: "tool" as const, label: `🔧 tools: ${yn(c.detectToolLoops)}`, description: "detect repeated tool calls" },
@@ -277,6 +279,18 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
277
279
  if (v !== undefined) { c.taskStreamTwinThreshold = v; saveConfig(c); ctx.ui.notify(`twin threshold: ${(v * 100).toFixed(0)}%`, "info"); }
278
280
  break;
279
281
  }
282
+ case "snapshot": {
283
+ const v = await selectFrom(ctx, "📡 snapshot / read-only tools (repeated identical reads are never a loop)", [
284
+ { value: "default" as const, label: "🎯 trimegisto_harvest (default)", description: "serial close of settled agents: same args + same snapshot ×N stays silent" },
285
+ { value: "off" as const, label: "🚫 off", description: "treat every tool identically (harvest can loop-flag again)" },
286
+ ]);
287
+ if (v !== undefined) {
288
+ c.snapshotTools = v === "off" ? [] : ["trimegisto_harvest"];
289
+ saveConfig(c);
290
+ ctx.ui.notify(`snapshot tools: ${c.snapshotTools.length ? c.snapshotTools.join(", ") : "off"}`, "info");
291
+ }
292
+ break;
293
+ }
280
294
  case "ignoredBreak": {
281
295
  const v = await selectFrom(ctx, "🛑 stop after ignored break (identical repeats after the force break)", [
282
296
  { value: 1, label: "⚡ 1 (sensitive — one identical repeat after the break stops the run)" },
package/src/config.ts CHANGED
@@ -36,6 +36,7 @@ export const DEFAULT_CONFIG: AntiloopConfig = {
36
36
  detectTaskStreams: true,
37
37
  taskStreamMinCalls: 3,
38
38
  taskStreamTwinThreshold: 0.99,
39
+ snapshotTools: ["trimegisto_harvest"],
39
40
  detectToolLoops: true,
40
41
  detectThinkingLoops: true,
41
42
  detectTextLoops: true,
package/src/detect.ts CHANGED
@@ -495,6 +495,28 @@ export function detectTaskStreams(
495
495
  return streams;
496
496
  }
497
497
 
498
+ // ---------------------------------------------------------------------------
499
+ // Snapshot / read-only tools (v1.8).
500
+ //
501
+ // A coordinator closing N agents that already finished calls the snapshot tool
502
+ // once per agent and gets the SAME serialized board every time (all settled).
503
+ // Same args + same result looks exactly like a verbatim tool loop to the
504
+ // detector, but re-reading a state snapshot is an idempotent read — the serial
505
+ // close of finished work, not a stuck generation. Tools listed in
506
+ // config.snapshotTools (default: trimegisto_harvest) are therefore exempt from
507
+ // tool-loop and outcome detection. Any other status/poll tool can be added.
508
+ // ---------------------------------------------------------------------------
509
+
510
+ /** Set of tool names whose repeated identical reads must never count as a loop. */
511
+ function snapshotToolSet(config: AntiloopConfig): Set<string> {
512
+ return new Set((config.snapshotTools ?? []).map((n) => n.trim()).filter(Boolean));
513
+ }
514
+
515
+ /** True when every call in the set is a snapshot/status read. */
516
+ function allSnapshotCalls(calls: TrackedToolCall[] | undefined, snapshots: Set<string>): boolean {
517
+ return !!calls && calls.length > 0 && calls.every((c) => snapshots.has(c.name));
518
+ }
519
+
498
520
  export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopDetection[] {
499
521
  const out: LoopDetection[] = [];
500
522
  const msgs = state.recentMessages;
@@ -543,6 +565,7 @@ export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopD
543
565
  // detections that only involve batch messages are suppressed too — the
544
566
  // model is doing N different tasks of the same type, not looping.
545
567
  const streams = detectTaskStreams(win, config);
568
+ const snapshots = snapshotToolSet(config);
546
569
  const batchAt = new Set<number>();
547
570
  win.forEach((m, idx) => {
548
571
  const calls = m.toolCalls;
@@ -596,7 +619,10 @@ export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopD
596
619
  const lastCalls = last.toolCalls;
597
620
  // A message whose calls are all task-stream tools is batch work — skip
598
621
  // it entirely (the stream gate already proved the calls are distinct).
599
- if (lastCalls && lastCalls.length && !batchAt.has(msgs.length - 1)) {
622
+ // Snapshot/status tools (trimegisto_harvest…) are also skipped: repeated
623
+ // identical reads of a settled board are the serial close of finished
624
+ // agents, not a loop.
625
+ if (lastCalls && lastCalls.length && !batchAt.has(msgs.length - 1) && !allSnapshotCalls(lastCalls, snapshots)) {
600
626
  const matched: number[] = [];
601
627
  for (let i = 0; i < win.length - 1; i++) {
602
628
  const prev = win[i].toolCalls;
@@ -654,7 +680,9 @@ export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopD
654
680
  // produce the SAME OK outcome every call — they must never count. And
655
681
  // batch messages must NOT be skipped here: a "bash ×N stream" with
656
682
  // identical failures is exactly the no-progress loop to catch.
657
- if (lc.result && isFailResult(lc.result)) {
683
+ // Snapshot/status reads are excluded outright: an identical harvest
684
+ // snapshot is a state read, not a repeated failing experiment.
685
+ if (lc.result && isFailResult(lc.result) && !snapshots.has(lc.name)) {
658
686
  let matches = 0;
659
687
  for (let i = 0; i < win.length - 1; i++) {
660
688
  const m = win[i];
@@ -802,6 +830,7 @@ export function runSelfTest(): string[] {
802
830
  detectTextLoops: true, notifyOnDetection: true, maxHistoryEntries: 100,
803
831
  detectionWindow: 10, interactiveFooter: true, toggleShortcut: "esc+a",
804
832
  detectTaskStreams: true, taskStreamMinCalls: 3, taskStreamTwinThreshold: 0.99,
833
+ snapshotTools: ["trimegisto_harvest"],
805
834
  detectDegenerate: true, degenerateMinTokens: 50, degenerateMaxRun: 16,
806
835
  degenerateMaxFreq: 60, degenerateMaxShare: 0.4, degenerateTurnWeight: 2, blockDegenerateBash: true,
807
836
  detectBlockRepeats: true, blockMinTokens: 120, blockNgram: 5, blockMinRepeats: 3, blockRepeatShare: 0.85,
@@ -1052,5 +1081,32 @@ export function runSelfTest(): string[] {
1052
1081
  out.push(`outcome identical OKs → ${okDet.some((d) => d.type === "outcome") ? "outcome" : "silent"} (exp silent — success repeats ≠ loop) ${!okDet.some((d) => d.type === "outcome") ? "✅" : "❌"}`);
1053
1082
  out.push(`isFailResult gate → err| → ${isFailResult("err|boom") ? "fail" : "ok"}, rc32 → ${isFailResult("ok|rc32 denied") ? "fail" : "ok"}, rc0/ok → ${isFailResult("ok|rc 0 12 passed") ? "fail" : "ok"} (exp fail, fail, ok) ${isFailResult("err|boom") && isFailResult("ok|rc32 denied") && !isFailResult("ok|rc 0 12 passed") ? "✅" : "❌"}`);
1054
1083
 
1084
+ // --- v1.8: snapshot/read-only tools are idempotent reads, not loops ---
1085
+ // Regression (real session /home/j 2026-09-23T21:35): a coordinator closed
1086
+ // N agents that had ALREADY settled by calling trimegisto_harvest five times
1087
+ // with `{}`; every call returned the SAME 2.3 KB cumulative snapshot (all
1088
+ // agents done). Same args + same result ×5 → the tool detector force-broke
1089
+ // the run. Re-reading a settled state board is the serial close of finished
1090
+ // agents, not a stuck generation: with the snapshot exemption it is silent.
1091
+ const harvestSnap = resultFingerprint(
1092
+ [{
1093
+ type: "text",
1094
+ text:
1095
+ "## Trimegisto harvest (instant snapshot)\n\n### ✅ t0a [Active] — done (101s)\nTask: Explora el sistema en busca de LM Studio y sus runtimes.\n\n```\n# Informe: LM Studio en el sistema\n...\n```\n*10 turns, ↑32825 ↓2429*\n\n_All agents settled._",
1096
+ }],
1097
+ false,
1098
+ )!;
1099
+ const harvestMsgs = [0, 1, 2, 3, 4].map(() =>
1100
+ mk("", [{ name: "trimegisto_harvest", args: "{}", result: harvestSnap }]),
1101
+ );
1102
+ const snapDet = detectLoops(asState(harvestMsgs), tcfg);
1103
+ out.push(`snapshot harvest ×5 → ${snapDet.length ? snapDet.map((d) => d.type).join(",") : "silent"} (exp silent — serial close of settled agents) ${snapDet.length === 0 ? "✅" : "❌"}`);
1104
+
1105
+ // Guard: the exemption is what silences it — the SAME payload with
1106
+ // snapshotTools off is a genuine verbatim tool loop.
1107
+ const noSnapCfg: AntiloopConfig = { ...tcfg, snapshotTools: [] };
1108
+ const snapLoud = detectLoops(asState(harvestMsgs), noSnapCfg);
1109
+ out.push(`snapshot off = loop → ${snapLoud.some((d) => d.type === "tool") ? "tool" : "no"} (exp tool — normal tools still loop) ${snapLoud.some((d) => d.type === "tool") ? "✅" : "❌"}`);
1110
+
1055
1111
  return out;
1056
1112
  }
package/src/types.ts CHANGED
@@ -109,6 +109,18 @@ export interface AntiloopConfig {
109
109
  detectTaskStreams: boolean;
110
110
  taskStreamMinCalls: number;
111
111
  taskStreamTwinThreshold: number;
112
+ /**
113
+ * Read-only snapshot/status tools (default: ["trimegisto_harvest"]). These
114
+ * return a view of changing state (a list of agents, a queue, a dashboard).
115
+ * Calling one again with the SAME arguments is an idempotent read — most
116
+ * strikingly when a coordinator closes N agents that already settled and the
117
+ * serialized snapshot is byte-identical every time. That is the serial close
118
+ * of finished agents, not a reasoning loop, so repeated calls to a snapshot
119
+ * tool never count as a tool loop (nor as a no-progress outcome run). Names
120
+ * are matched exactly against the tool name; add any other status/poll tool
121
+ * here to keep antiloop quiet while it is read repeatedly.
122
+ */
123
+ snapshotTools: string[];
112
124
  detectToolLoops: boolean;
113
125
  detectThinkingLoops: boolean;
114
126
  detectTextLoops: boolean;