@capdiem/pi-repetition-guard 0.2.1 → 0.3.1
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 +38 -6
- package/index.min.js +4 -4
- package/index.min.js.map +4 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,9 +58,33 @@ the final `text` stream (whichever hits the threshold first).
|
|
|
58
58
|
next action without doing it" (e.g. repeating "let me check git status"
|
|
59
59
|
without ever committing), so the steer tells it to stop describing and do the
|
|
60
60
|
operation.
|
|
61
|
-
- **Retry budget**: max **
|
|
62
|
-
escalating wording (1 → 2
|
|
63
|
-
fallback on the final retry.
|
|
61
|
+
- **Retry budget**: max **2** steer retries per logical user turn, with
|
|
62
|
+
escalating wording (1 → 2) and a clear "give up or state the blocker"
|
|
63
|
+
fallback on the final retry.
|
|
64
|
+
- **Auto-compact and continue**: when the retry budget is exhausted (still
|
|
65
|
+
looping after 2 steers), the guard compacts the context and retries **once**
|
|
66
|
+
more (`MAX_COMPACTION_RETRIES = 1`) with a fresh budget, then gives up. This
|
|
67
|
+
attacks the long-context degradation that feeds the loop (a model stuck in a
|
|
68
|
+
loop is usually also drowning in its own garbage; shrinking the context gives
|
|
69
|
+
it a clean shot at the original task). Still hard-capped — no infinite loop.
|
|
70
|
+
|
|
71
|
+
> **Why compact instead of just giving up (measured root cause):** the infinite
|
|
72
|
+
> loops we observe are not a sampling-level "repetition bug" — they are a
|
|
73
|
+
> **long-context tracking failure**. In practice the loop appears almost
|
|
74
|
+
> exclusively on **deepseek-flash** (a fast/cheap tier model with weaker
|
|
75
|
+
> long-context coherence) and mostly once the context has grown to roughly
|
|
76
|
+
> **30% of the 1M window (~300K tokens)**. At that size the model's attention
|
|
77
|
+
> dilutes past its trained effective range: it loses "what did I just do" and
|
|
78
|
+
> starts re-issuing the same action or re-stating the same intent. Two
|
|
79
|
+
> consequences matter for the guard:
|
|
80
|
+
> 1. The loop is *context-driven*, so shrinking the context is the direct
|
|
81
|
+
> countermeasure — compaction puts the model back in the regime where it can
|
|
82
|
+
> converge, which is why we retry after compacting rather than giving up.
|
|
83
|
+
> 2. The guard's own abort + steer *appends* the junk and the steer to the
|
|
84
|
+
> context (no cleanup, ADR 0002), which feeds the very degradation that
|
|
85
|
+
> caused the loop. Auto-compacting breaks that self-reinforcing feedback.
|
|
86
|
+
> (This is also the argument for *not* raising `MAX_COMPACTION_RETRIES`
|
|
87
|
+
> casually: every extra cycle costs a compaction and loses detail.)
|
|
64
88
|
- **User-only control**: `/runaway on|off` slash command, default **on**. The
|
|
65
89
|
guard cannot be disabled by the LLM itself (there is no LLM-callable toggle).
|
|
66
90
|
|
|
@@ -96,8 +120,9 @@ Nothing to configure — it is on by default and runs silently in the background
|
|
|
96
120
|
per the settled design; see ADR 0002.)
|
|
97
121
|
- **Tool-call loop detected:** the repetitive tool call is blocked (a
|
|
98
122
|
"Repetition guard: tool-call loop" reason), the run terminates, and a steer
|
|
99
|
-
naming the tool + repeated input is sent. If it loops again,
|
|
100
|
-
|
|
123
|
+
naming the tool + repeated input is sent. If it loops again, the second steer
|
|
124
|
+
escalates; after the final retry, if it still loops, the context is
|
|
125
|
+
auto-compacted and the task retried once more before giving up.
|
|
101
126
|
- **Disable / re-enable:**
|
|
102
127
|
```
|
|
103
128
|
/runaway off
|
|
@@ -109,7 +134,14 @@ Nothing to configure — it is on by default and runs silently in the background
|
|
|
109
134
|
## Behavior details
|
|
110
135
|
|
|
111
136
|
- **Budget reset:** a new logical user turn (a user message that is *not* our
|
|
112
|
-
own steer) resets the retry budget to 2.
|
|
137
|
+
own steer) resets the retry budget to 2 (and the per-turn auto-compact counter).
|
|
138
|
+
- **Auto-compact-and-continue flow:** after the 2nd steer fails, the guard queues
|
|
139
|
+
a compaction, fires it at the next settle point (`message_end` for text loops,
|
|
140
|
+
`agent_end` for tool loops), and on completion resets the retry budget and
|
|
141
|
+
sends a "continue" steer telling the model to finish the original task from
|
|
142
|
+
the compacted context. If compaction fails, the guard gives up (no worse than
|
|
143
|
+
before). Per turn this happens at most `MAX_COMPACTION_RETRIES` times (1), so
|
|
144
|
+
the guard remains hard-capped — it can never loop forever.
|
|
113
145
|
- **Scope:** only the current assistant message's own text is compared — the
|
|
114
146
|
detector does not compare against the user's message or prior assistant
|
|
115
147
|
messages, so restating the user's words never false-triggers.
|
package/index.min.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
var
|
|
2
|
-
`)}class
|
|
1
|
+
var D=2,w=1;function _(j,V=2,Q=30,q=3000){let W=Math.min(q,Math.floor(j.length/V));for(let B=Q;B<=W;B++){let G=j.length-Math.floor(V*B),F=!0;for(let J=G+B;J<j.length;J++)if(j[J]!==j[J-B]){F=!1;break}if(F)return B}return null}function N(j){if(j.length<200)return{fraction:0,sample:""};let V=j.split(/\n+/).map((J)=>J.trim()).filter((J)=>J.length>15&&J.length<=100),Q=V.length;if(Q<3)return{fraction:0,sample:""};let q=[],W=[];for(let J of V){let L=!1;for(let $=0;$<q.length;$++)if(Math.abs(q[$].length-J.length)<=12&&E(q[$],J)>=0.7){W[$]+=1,L=!0;break}if(!L)q.push(J),W.push(1)}let B=1-W.filter((J)=>J===1).length/Q,G=-1,F=1;for(let J=0;J<W.length;J++)if(W[J]>F)F=W[J],G=J;return{fraction:B,sample:G>=0?q[G]:""}}function E(j,V){let q=new Set,W=j.toLowerCase();for(let J=0;J+5<=W.length;J++)q.add(W.slice(J,J+5));let B=V.toLowerCase(),G=0,F=0;for(let J=0;J+5<=B.length;J++){if(q.has(B.slice(J,J+5)))G++;F++}return F>0?G/F:0}class z{text="";lastChecked=0;lastResult={suspect:!1,hard:!1,sample:""};reset(){this.text="",this.lastChecked=0,this.lastResult={suspect:!1,hard:!1,sample:""}}ingest(j){if(this.text=j,this.text.length-this.lastChecked>=200)this.lastChecked=this.text.length,this.lastResult=this.scan();return this.lastResult}scan(){let j=_(this.text);if(j!==null){let Q=this.text.slice(this.text.length-j);return{suspect:!0,hard:!0,sample:Q.length>200?`${Q.slice(0,200)}…`:Q}}let V=N(this.text);if(V.fraction>=0.9)return{suspect:!0,hard:!0,sample:V.sample.length>200?`${V.sample.slice(0,200)}…`:V.sample};return{suspect:!1,hard:!1,sample:""}}}function M(j){if(!Array.isArray(j))return"";let V=[];for(let Q of j){if(!Q||typeof Q!=="object")continue;let q=Q;if(q.type==="text"&&typeof q.text==="string")V.push(q.text);else if(q.type==="thinking"){if(typeof q.thinking==="string")V.push(q.thinking);else if(typeof q.text==="string")V.push(q.text)}}return V.join(`
|
|
2
|
+
`)}class O{calls=[];counts=new Map;reset(){this.calls=[],this.counts.clear()}record(j,V){let Q=`${j}:${V}`;if(this.calls.push(Q),this.counts.set(Q,(this.counts.get(Q)??0)+1),this.calls.length>8){let q=this.calls.shift(),W=this.counts.get(q);if(W<=1)this.counts.delete(q);else this.counts.set(q,W-1)}return(this.counts.get(Q)??0)>=4}}function C(j,V,Q){if(j>=3)return"[自动护栏] 这是第 3 次工具循环。请立即停止调用工具,以最短篇幅完成任务目标,或明确声明"+"无法完成并说明原因。";if(j>=2)return"[自动护栏] 这是第 2 次工具循环。请停止调用 `"+V+"`,直接基于已有信息完成目标并给出简短结果;如果无法完成,明确说明卡在哪里。";let q=Q.length>80?`${Q.slice(0,80)}…`:Q;return"[自动护栏] 检测到你陷入了工具调用循环:连续多次调用 `"+V+"`(参数 "+(q||"无参数")+")却迟迟不完成任务。请停止重复调用同一工具,直接基于已有信息完成目标操作并给出结果,不要重复已执行过的操作。"}class f{max;remaining;sentSteers=new Set;constructor(j){this.max=j;this.remaining=j}consume(){return this.remaining-=1,{retryNum:this.max-this.remaining,allowSteer:this.remaining>=0}}recordSentSteer(j){this.sentSteers.add(j)}onUserMessage(j){if(j&&this.sentSteers.has(j))return this.sentSteers.delete(j),!1;return this.reset(),!0}reset(){this.remaining=this.max}get remainingCount(){return this.remaining}}function I(j,V){if(j>=3)return"[自动护栏] 这是第 3 次。你一直循环未完成目标。请立即以最短篇幅完成任务目标,或明确声明"+"无法完成并说明原因。不要再重复任何已说过的内容。";if(j>=2)return"[自动护栏] 这是第 2 次检测到循环。你仍在重复却没有执行。请立即停止任何描述性文本,直接完成"+"当前任务的目标操作并给出简短结果;如果无法完成,明确说明卡在哪里。";return"[自动护栏] 检测到你陷入了循环:反复复述同一个动作/内容却始终没有真正执行下一步。请立即停止"+`复述,直接执行你要做的操作并给出结果,不要描述你将要做什么。
|
|
3
3
|
|
|
4
4
|
`+`刚才重复的片段(节选):
|
|
5
|
-
`+(
|
|
5
|
+
`+(V||"(无样例)")}function P(){return"[自动护栏] 前几次尝试陷入循环,已自动压缩上下文。请基于当前压缩后的上下文,直接完成原始任务目标"+"并给出最终结果;若仍无法完成,请明确说明卡在哪里,不要再重复尝试相同操作。"}function R(j){let V=new z,Q=new O,q=new f(D),W=!0,B,G,F=!1,J=0,L=!1,$=(Z)=>{console.log(`[pi-repetition-guard] ${Z}`)},A=(Z,Y)=>{let{retryNum:H,allowSteer:X}=q.consume();if(X)B=I(H,Y),$(`text runaway — aborting, will steer (retry ${H}/${D})`);else if(J<w)F=!0,$("text runaway — aborting, will compact + continue");else $("text runaway — aborting, retry budget exhausted (giving up)");Z.abort()},K=(Z)=>{if(!F)return;if(F=!1,J>=w){$("compaction budget exhausted — giving up");return}J+=1,$(`triggering auto-compact (${J}/${w})`),Z.compact({onComplete:()=>{q.reset();let Y=P();q.recordSentSteer(Y),$("compaction complete — sending continue steer"),j.sendUserMessage(Y,{deliverAs:"steer"})},onError:(Y)=>{$(`compaction failed (${Y.message}) — giving up`)}})};j.on("message_start",(Z)=>{let Y=Z.message?.role;if(Y==="assistant")V.reset(),L=!1;else if(Y==="user"){let H=S(Z.message.content);if(q.onUserMessage(H))J=0,$(`new user turn — budget reset to ${D}`)}}),j.on("agent_start",()=>{Q.reset()}),j.on("message_update",(Z,Y)=>{if(!W||L)return;if(Z.message.role!=="assistant")return;let H=V.ingest(M(Z.message.content));if(H.hard)L=!0,A(Y,H.sample)}),j.on("tool_call",(Z)=>{if(!W)return;let Y=k(Z.input);if(!Q.record(Z.toolName,Y))return;let{retryNum:X,allowSteer:h}=q.consume();if(h)G=C(X,Z.toolName,Y),$(`tool-call loop — blocking ${Z.toolName} (${Y.slice(0,60)}), will steer (retry ${X}/${D})`);else if(J<w)F=!0,$("tool-call loop — blocking, will compact + continue");else $("tool-call loop — budget exhausted, blocking without steer");return{block:!0,reason:"Repetition guard: tool-call loop (same tool+args repeated)",terminate:!0}}),j.on("message_end",(Z,Y)=>{if(Z.message.role!=="assistant")return;if(K(Y),!B)return;let H=B;B=void 0,q.recordSentSteer(H),$("sending text steer retry"),j.sendUserMessage(H,{deliverAs:"steer"})}),j.on("agent_end",(Z,Y)=>{if(K(Y),!G)return;let H=G;G=void 0,q.recordSentSteer(H),$("sending tool-loop steer retry"),j.sendUserMessage(H,{deliverAs:"steer"})}),j.registerCommand("runaway",{description:"Toggle the repetition-loop guard (on | off). Default: on.",handler:async(Z,Y)=>{let H=Z.trim().toLowerCase();if(H==="on")W=!0;else if(H==="off")W=!1;else W=!W;if(Y.hasUI)Y.ui.notify(`Repetition guard ${W?"ON":"OFF"}`,W?"info":"warning");if(Y.mode==="tui")Y.ui.setStatus("pi-repetition-guard",W?"guard:on":"guard:off")}})}function k(j){if(j===void 0||j===null)return"";if(typeof j==="string")return j;try{return U(j)}catch{return String(j)}}function S(j){if(!Array.isArray(j))return"";let V="";for(let Q of j)if(Q&&typeof Q==="object"){let q=Q.text;if(typeof q==="string")V+=q}return V}function U(j){if(Array.isArray(j))return`[${j.map(U).join(",")}]`;if(j&&typeof j==="object")return`{${Object.entries(j).sort(([Q],[q])=>Q<q?-1:Q>q?1:0).map(([Q,q])=>`${JSON.stringify(Q)}:${U(q)}`).join(",")}}`;return JSON.stringify(j)}export{R as default};
|
|
6
6
|
|
|
7
|
-
//# debugId=
|
|
7
|
+
//# debugId=9422E5D24536DBDA64756E2164756E21
|
|
8
8
|
//# sourceMappingURL=index.min.js.map
|
package/index.min.js.map
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../extensions/pi-repetition-guard/detector.ts", "../../extensions/pi-repetition-guard/index.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"// Pure detection logic for pi-repetition-guard — no pi extension APIs here,\n// so it is unit-testable in isolation (mirrors pi-todo's state.ts split).\n\n// ── Tunable defaults (fixed, not user-configurable) ─────────────────────────\nexport const GUARD_STEER_MAX_RETRIES = 3;\n/** Rescan cadence — we re-scan only after this many new chars accumulate. */\nconst CHECKPOINT_CHARS = 200;\n/** Smallest loop unit (chars) we treat as a runaway tape-loop. */\nconst MIN_PERIOD = 30;\n/** Largest loop unit to scan for; capped by message length at runtime. */\nconst MAX_PERIOD = 3000;\n/** A loop is \"proven\" when the last `copies × P` chars are P-periodic. */\nconst HARD_PERIOD_COPIES = 2;\n/** Repeated-block sample length for the steer message. */\nexport const SAMPLE_MAX_CHARS = 200;\n/** Tool-loop trigger: same tool + same normalized input this many times in a\n * sliding window of recent calls → blocked as a tool-call loop. */\nexport const TOOL_LOOP_MAX_REPEATS = 4;\n/** Sliding window of recent tool calls used for tool-loop counting. */\nconst TOOL_LOOP_WINDOW = 8;\n/** Two segments are \"near-duplicates\" when this fraction of their 5-gram\n * shingles overlap (tolerates reworded copies). */\nexport const NEAR_DUP_OVERLAP_THRESH = 0.7;\n/** A message is a variation-loop when this fraction of its SHORT segments are\n * near-duplicates of another segment (within-message dominance). Calibrated on\n * real data: clear degenerate loops 0.90-1.0, productive messages with a\n * repetitive tail ~0.88 (kept quiet), normal messages median ~0.00. 0.9 (not\n * 0.85) cleanly separates clear loops from the productive+tail gray zone. */\nexport const NEAR_DUP_HARD_FRACTION = 0.9;\n/** Only segments up to this length count toward variation-loop detection. This\n * is what separates degenerate loops (dominated by short \"Let me read X.\"\n * restatements) from legitimate long thinking (built of long paragraphs that\n * advance). Legit templated thinking has ~0 short segments → never fires. */\nconst NEAR_DUP_MAX_SEG_LEN = 100;\n/** Need at least this many short segments before the signal is meaningful. */\nconst NEAR_DUP_MIN_SHORT_SEGS = 3;\n/** Minimum message length to bother computing near-repetition dominance. */\nconst NEAR_DUP_MIN_CHARS = 200;\n\nexport interface DetectionResult {\n suspect: boolean;\n hard: boolean;\n /** Repeated unit, truncated to SAMPLE_MAX_CHARS, for the steer message. */\n sample: string;\n}\n\n/**\n * Find the smallest period P in [minP, cap] such that the trailing\n * `copies × P` characters of `text` are exactly P-periodic\n * (text[i] === text[i - P] for every i in the tail window).\n *\n * This is the tape-loop signature: a runaway re-emits the same block\n * contiguously, so the tail becomes a clean repetition of a fixed unit.\n *\n * Deliberately EXACT (no fuzzy tolerance): every measured \"similarity\" signal\n * (shingle novelty, block-repeat, fractional-period) was shown to false-trigger\n * on legitimate long structured thinking, while exact ≥2-copy contiguity never\n * does. A loop with minor noise may be missed — acceptable, since false\n * positives are the far worse failure here.\n *\n * Returns the period, or null when no period exists.\n */\nexport function findPeriod(\n text: string,\n copies = HARD_PERIOD_COPIES,\n minP = MIN_PERIOD,\n maxP = MAX_PERIOD,\n): number | null {\n const cap = Math.min(maxP, Math.floor(text.length / copies));\n for (let P = minP; P <= cap; P++) {\n const start = text.length - Math.floor(copies * P);\n let ok = true;\n for (let i = start + P; i < text.length; i++) {\n if (text[i] !== text[i - P]) {\n ok = false;\n break;\n }\n }\n if (ok) return P;\n }\n return null;\n}\n\n/**\n * Within-message SHORT-segment near-repetition dominance: the fraction of a\n * message's SHORT segments (≤ NEAR_DUP_MAX_SEG_LEN chars) that are\n * near-duplicates (5-gram shingle overlap ≥ NEAR_DUP_OVERLAP_THRESH) of another\n * SHORT segment in the SAME message. Requires ≥ NEAR_DUP_MIN_SHORT_SEGS short\n * segments to be meaningful.\n *\n * Catches the \"variation loop\" class that exact tail periodicity misses: the\n * model rewording the same intent over and over (\"Let me read L1000-1035\" /\n * \"Let me read the signature end\" / ...) without advancing. Such loops are\n * DOMINATED by SHORT near-identical restatements (real data: 0.90-1.0), while\n * genuine messages — including legitimately long, templated thinking — are\n * built of long paragraphs and have ~0 short near-duplicates (median ~0.00).\n * Restricting to SHORT segments is what keeps this from false-positiving on\n * legit structured thinking. Strictly within-message; never cross-message.\n */\nexport function nearDupDominance(text: string): { fraction: number; sample: string } {\n if (text.length < NEAR_DUP_MIN_CHARS) return { fraction: 0, sample: \"\" };\n const segs = text.split(/\\n+/)\n .map((s) => s.trim())\n .filter((s) => s.length > 15 && s.length <= NEAR_DUP_MAX_SEG_LEN);\n const n = segs.length;\n if (n < NEAR_DUP_MIN_SHORT_SEGS) return { fraction: 0, sample: \"\" };\n // Greedy near-duplicate clustering by 5-gram shingle overlap.\n const reps: string[] = [];\n const sizes: number[] = [];\n for (const seg of segs) {\n let placed = false;\n for (let k = 0; k < reps.length; k++) {\n if (Math.abs(reps[k].length - seg.length) <= 12 && shingleOverlap(reps[k], seg) >= NEAR_DUP_OVERLAP_THRESH) {\n sizes[k] += 1;\n placed = true;\n break;\n }\n }\n if (!placed) {\n reps.push(seg);\n sizes.push(1);\n }\n }\n const fraction = 1 - sizes.filter((v) => v === 1).length / n;\n let bestIdx = -1;\n let bestSize = 1;\n for (let k = 0; k < sizes.length; k++) {\n if (sizes[k] > bestSize) {\n bestSize = sizes[k];\n bestIdx = k;\n }\n }\n return { fraction, sample: bestIdx >= 0 ? reps[bestIdx] : \"\" };\n}\n\n/** Fraction of b's 5-char shingles that also appear in a (lowercased). */\nfunction shingleOverlap(a: string, b: string): number {\n const n = 5;\n const set = new Set<string>();\n const ta = a.toLowerCase();\n for (let i = 0; i + n <= ta.length; i++) set.add(ta.slice(i, i + n));\n const tb = b.toLowerCase();\n let hit = 0;\n let total = 0;\n for (let i = 0; i + n <= tb.length; i++) {\n if (set.has(tb.slice(i, i + n))) hit++;\n total++;\n }\n return total > 0 ? hit / total : 0;\n}\n\n/**\n * Repetition-loop detector over the accumulated text of one assistant message\n * (thinking + final blocks concatenated).\n *\n * v5: TWO within-message signals, OR'd:\n * 1. Exact tail periodicity (≥2 exact contiguous copies) — catches tape-loops.\n * 2. Short-segment near-repetition dominance (≥90% of the message's short\n * segments are near-duplicates) — catches variation-loops (reworded copies).\n * Both are strictly within one message; no cross-message comparison.\n * `suspect` mirrors `hard` for API compatibility.\n */\nexport class RepetitionDetector {\n private text = \"\";\n private lastChecked = 0;\n private lastResult: DetectionResult = { suspect: false, hard: false, sample: \"\" };\n\n /** Start a fresh message: call on assistant message_start. */\n reset(): void {\n this.text = \"\";\n this.lastChecked = 0;\n this.lastResult = { suspect: false, hard: false, sample: \"\" };\n }\n\n /** Feed the latest full accumulated text; returns the current verdict. */\n ingest(newText: string): DetectionResult {\n this.text = newText;\n if (this.text.length - this.lastChecked >= CHECKPOINT_CHARS) {\n this.lastChecked = this.text.length;\n this.lastResult = this.scan();\n }\n return this.lastResult;\n }\n\n private scan(): DetectionResult {\n // 1) Exact tail periodicity (tape-loop).\n const period = findPeriod(this.text);\n if (period !== null) {\n const sample = this.text.slice(this.text.length - period);\n return {\n suspect: true,\n hard: true,\n sample: sample.length > SAMPLE_MAX_CHARS\n ? `${sample.slice(0, SAMPLE_MAX_CHARS)}…`\n : sample,\n };\n }\n // 2) Within-message short-segment near-repetition dominance (variation-loop).\n const nd = nearDupDominance(this.text);\n if (nd.fraction >= NEAR_DUP_HARD_FRACTION) {\n return {\n suspect: true,\n hard: true,\n sample: nd.sample.length > SAMPLE_MAX_CHARS\n ? `${nd.sample.slice(0, SAMPLE_MAX_CHARS)}…`\n : nd.sample,\n };\n }\n return { suspect: false, hard: false, sample: \"\" };\n }\n}\n\n/** Concatenate text + thinking blocks of an assistant message for detection. */\nexport function extractText(content: unknown): string {\n if (!Array.isArray(content)) return \"\";\n const parts: string[] = [];\n for (const item of content) {\n if (!item || typeof item !== \"object\") continue;\n const block = item as { type?: unknown; text?: unknown; thinking?: unknown };\n if (block.type === \"text\" && typeof block.text === \"string\") {\n parts.push(block.text);\n } else if (block.type === \"thinking\") {\n if (typeof block.thinking === \"string\") parts.push(block.thinking);\n else if (typeof block.text === \"string\") parts.push(block.text);\n }\n }\n return parts.join(\"\\n\");\n}\n\n/**\n * Tool-call-loop detector over one agent run (reset on agent_start).\n *\n * The real-world failure the user hit (and that pure text signals miss): the\n * model repeatedly invokes the SAME tool with the SAME input — e.g. checking\n * `git status` / reading CHANGES.md over and over — and never settles. The text\n * it emits is near-identical *variations*, not an exact periodic loop, so text\n * periodicity does not fire. Counting identical (toolName, inputKey) calls in a\n * sliding window catches it directly.\n */\nexport class ToolLoopTracker {\n private calls: string[] = [];\n private counts = new Map<string, number>();\n\n /** Start a fresh agent run: call on agent_start. */\n reset(): void {\n this.calls = [];\n this.counts.clear();\n }\n\n /**\n * Record a tool call; returns true when the same (toolName, inputKey) has\n * been seen `TOOL_LOOP_MAX_REPEATS` times within the recent window.\n */\n record(toolName: string, inputKey: string): boolean {\n const key = `${toolName}:${inputKey}`;\n this.calls.push(key);\n this.counts.set(key, (this.counts.get(key) ?? 0) + 1);\n if (this.calls.length > TOOL_LOOP_WINDOW) {\n const dropped = this.calls.shift()!;\n const c = this.counts.get(dropped)!;\n if (c <= 1) this.counts.delete(dropped);\n else this.counts.set(dropped, c - 1);\n }\n return (this.counts.get(key) ?? 0) >= TOOL_LOOP_MAX_REPEATS;\n }\n}\n\n/** Build the tool-loop steer message; retryNum is 1-based (1 = first retry).\n * Action-oriented: stop re-invoking, finish the goal from existing info.\n * Escalates over 3 retries. */\nexport function buildToolLoopSteer(retryNum: number, toolName: string, inputKey: string): string {\n if (retryNum >= 3) {\n return (\n \"[自动护栏] 这是第 3 次工具循环。请立即停止调用工具,以最短篇幅完成任务目标,或明确声明\" +\n \"无法完成并说明原因。\"\n );\n }\n if (retryNum >= 2) {\n return (\n \"[自动护栏] 这是第 2 次工具循环。请停止调用 `\" + toolName +\n \"`,直接基于已有信息完成目标并给出简短结果;如果无法完成,明确说明卡在哪里。\"\n );\n }\n const sample = inputKey.length > 80 ? `${inputKey.slice(0, 80)}…` : inputKey;\n return (\n \"[自动护栏] 检测到你陷入了工具调用循环:连续多次调用 `\" + toolName + \"`(参数 \" +\n (sample || \"无参数\") +\n \")却迟迟不完成任务。请停止重复调用同一工具,直接基于已有信息完成目标操作并给出结果,不要重复已执行过的操作。\"\n );\n}\n\n/**\n * Retry budget shared by the text-loop and tool-loop paths, with a\n * deterministic \"real user turn\" reset.\n *\n * The budget must span one logical user turn (the original request + any number\n * of steer retries) and reset on the NEXT real user message. We identify our\n * own steer messages by their exact content (Pi delivers a queued steer as a\n * user message whose text matches what we sent — this is how Pi itself clears\n * its steering queue). Using a boolean \"steer in flight\" flag is fragile: if a\n * steer's message_start is missed in an abort/auto-continue race, the flag\n * sticks and the budget never resets — the bug reported as \"after 3 retries the\n * count never resets\".\n */\nexport class RetryBudget {\n private remaining: number;\n private sentSteers = new Set<string>();\n\n constructor(private readonly max: number) {\n this.remaining = max;\n }\n\n /** Consume one retry slot; 1-based retry number and whether a steer is still\n * allowed. Shared by the text and tool-loop paths. */\n consume(): { retryNum: number; allowSteer: boolean } {\n this.remaining -= 1;\n return { retryNum: this.max - this.remaining, allowSteer: this.remaining >= 0 };\n }\n\n /** Record the exact text of a steer we sent, so its user-message delivery can\n * be recognized and NOT treated as a new user turn. */\n recordSentSteer(steerText: string): void {\n this.sentSteers.add(steerText);\n }\n\n /**\n * Handle a user message_start. Returns true when it was a REAL user turn\n * (budget reset to max); false when it was our own steer (budget kept).\n */\n onUserMessage(text: string): boolean {\n if (text && this.sentSteers.has(text)) {\n this.sentSteers.delete(text);\n return false;\n }\n this.reset();\n return true;\n }\n\n reset(): void {\n this.remaining = this.max;\n }\n\n get remainingCount(): number {\n return this.remaining;\n }\n}\n\n/** Build the corrective steer message; retryNum is 1-based (1 = first retry).\n *\n * Action-oriented wording (v4): the real-world loop this guard catches is often\n * \"rehearsing the next action without doing it\" (e.g. repeating \"let me check\n * git status\" without ever committing). Telling a stuck model to \"give a final\n * answer\" does not break that — it must be told to EXECUTE. Escalates over 3\n * retries. */\nexport function buildSteer(retryNum: number, sample: string): string {\n if (retryNum >= 3) {\n return (\n \"[自动护栏] 这是第 3 次。你一直循环未完成目标。请立即以最短篇幅完成任务目标,或明确声明\" +\n \"无法完成并说明原因。不要再重复任何已说过的内容。\"\n );\n }\n if (retryNum >= 2) {\n return (\n \"[自动护栏] 这是第 2 次检测到循环。你仍在重复却没有执行。请立即停止任何描述性文本,直接完成\" +\n \"当前任务的目标操作并给出简短结果;如果无法完成,明确说明卡在哪里。\"\n );\n }\n return (\n \"[自动护栏] 检测到你陷入了循环:反复复述同一个动作/内容却始终没有真正执行下一步。请立即停止\" +\n \"复述,直接执行你要做的操作并给出结果,不要描述你将要做什么。\\n\\n\" +\n \"刚才重复的片段(节选):\\n\" +\n (sample || \"(无样例)\")\n );\n}\n",
|
|
6
|
-
"import type { ExtensionAPI, ExtensionContext } from \"@earendil-works/pi-coding-agent\";\nimport {\n GUARD_STEER_MAX_RETRIES,\n RepetitionDetector,\n RetryBudget,\n ToolLoopTracker,\n buildSteer,\n buildToolLoopSteer,\n extractText,\n} from \"./detector.ts\";\n\n/**\n * pi-repetition-guard — a repetition-loop guard for the Pi coding agent.\n *\n * Detects two distinct runaway failure modes and intervenes with\n * abort/re-steer (ADR 0002: active intervention, beyond ADR 0001's ask_user\n * scope):\n *\n * 1. Text repetition loop (thinking-runaway / 万字复读): the model repeats the\n * same text contiguously during long thinking or output. Detected via tail\n * periodicity on `message_update`; intervened with ctx.abort() + steer.\n * 2. Tool-call loop: the model repeatedly invokes the SAME tool with the SAME\n * input and never settles (e.g. checking git status over and over). Detected\n * on `tool_call`; intervened by blocking the repetitive call (block +\n * terminate) and steering.\n *\n * Shared control: `/runaway on|off` (user-only), retry budget of
|
|
5
|
+
"// Pure detection logic for pi-repetition-guard — no pi extension APIs here,\n// so it is unit-testable in isolation (mirrors pi-todo's state.ts split).\n\n// ── Tunable defaults (fixed, not user-configurable) ─────────────────────────\nexport const GUARD_STEER_MAX_RETRIES = 2;\n/** Max auto-compact-and-continue cycles per logical user turn. After the retry\n * budget is exhausted we compact the context and retry once more (cap=1), then\n * give up. Hard-capped so the guard itself can never loop forever. */\nexport const MAX_COMPACTION_RETRIES = 1;\n/** Rescan cadence — we re-scan only after this many new chars accumulate. */\nconst CHECKPOINT_CHARS = 200;\n/** Smallest loop unit (chars) we treat as a runaway tape-loop. */\nconst MIN_PERIOD = 30;\n/** Largest loop unit to scan for; capped by message length at runtime. */\nconst MAX_PERIOD = 3000;\n/** A loop is \"proven\" when the last `copies × P` chars are P-periodic. */\nconst HARD_PERIOD_COPIES = 2;\n/** Repeated-block sample length for the steer message. */\nexport const SAMPLE_MAX_CHARS = 200;\n/** Tool-loop trigger: same tool + same normalized input this many times in a\n * sliding window of recent calls → blocked as a tool-call loop. */\nexport const TOOL_LOOP_MAX_REPEATS = 4;\n/** Sliding window of recent tool calls used for tool-loop counting. */\nconst TOOL_LOOP_WINDOW = 8;\n/** Two segments are \"near-duplicates\" when this fraction of their 5-gram\n * shingles overlap (tolerates reworded copies). */\nexport const NEAR_DUP_OVERLAP_THRESH = 0.7;\n/** A message is a variation-loop when this fraction of its SHORT segments are\n * near-duplicates of another segment (within-message dominance). Calibrated on\n * real data: clear degenerate loops 0.90-1.0, productive messages with a\n * repetitive tail ~0.88 (kept quiet), normal messages median ~0.00. 0.9 (not\n * 0.85) cleanly separates clear loops from the productive+tail gray zone. */\nexport const NEAR_DUP_HARD_FRACTION = 0.9;\n/** Only segments up to this length count toward variation-loop detection. This\n * is what separates degenerate loops (dominated by short \"Let me read X.\"\n * restatements) from legitimate long thinking (built of long paragraphs that\n * advance). Legit templated thinking has ~0 short segments → never fires. */\nconst NEAR_DUP_MAX_SEG_LEN = 100;\n/** Need at least this many short segments before the signal is meaningful. */\nconst NEAR_DUP_MIN_SHORT_SEGS = 3;\n/** Minimum message length to bother computing near-repetition dominance. */\nconst NEAR_DUP_MIN_CHARS = 200;\n\nexport interface DetectionResult {\n suspect: boolean;\n hard: boolean;\n /** Repeated unit, truncated to SAMPLE_MAX_CHARS, for the steer message. */\n sample: string;\n}\n\n/**\n * Find the smallest period P in [minP, cap] such that the trailing\n * `copies × P` characters of `text` are exactly P-periodic\n * (text[i] === text[i - P] for every i in the tail window).\n *\n * This is the tape-loop signature: a runaway re-emits the same block\n * contiguously, so the tail becomes a clean repetition of a fixed unit.\n *\n * Deliberately EXACT (no fuzzy tolerance): every measured \"similarity\" signal\n * (shingle novelty, block-repeat, fractional-period) was shown to false-trigger\n * on legitimate long structured thinking, while exact ≥2-copy contiguity never\n * does. A loop with minor noise may be missed — acceptable, since false\n * positives are the far worse failure here.\n *\n * Returns the period, or null when no period exists.\n */\nexport function findPeriod(\n text: string,\n copies = HARD_PERIOD_COPIES,\n minP = MIN_PERIOD,\n maxP = MAX_PERIOD,\n): number | null {\n const cap = Math.min(maxP, Math.floor(text.length / copies));\n for (let P = minP; P <= cap; P++) {\n const start = text.length - Math.floor(copies * P);\n let ok = true;\n for (let i = start + P; i < text.length; i++) {\n if (text[i] !== text[i - P]) {\n ok = false;\n break;\n }\n }\n if (ok) return P;\n }\n return null;\n}\n\n/**\n * Within-message SHORT-segment near-repetition dominance: the fraction of a\n * message's SHORT segments (≤ NEAR_DUP_MAX_SEG_LEN chars) that are\n * near-duplicates (5-gram shingle overlap ≥ NEAR_DUP_OVERLAP_THRESH) of another\n * SHORT segment in the SAME message. Requires ≥ NEAR_DUP_MIN_SHORT_SEGS short\n * segments to be meaningful.\n *\n * Catches the \"variation loop\" class that exact tail periodicity misses: the\n * model rewording the same intent over and over (\"Let me read L1000-1035\" /\n * \"Let me read the signature end\" / ...) without advancing. Such loops are\n * DOMINATED by SHORT near-identical restatements (real data: 0.90-1.0), while\n * genuine messages — including legitimately long, templated thinking — are\n * built of long paragraphs and have ~0 short near-duplicates (median ~0.00).\n * Restricting to SHORT segments is what keeps this from false-positiving on\n * legit structured thinking. Strictly within-message; never cross-message.\n */\nexport function nearDupDominance(text: string): { fraction: number; sample: string } {\n if (text.length < NEAR_DUP_MIN_CHARS) return { fraction: 0, sample: \"\" };\n const segs = text.split(/\\n+/)\n .map((s) => s.trim())\n .filter((s) => s.length > 15 && s.length <= NEAR_DUP_MAX_SEG_LEN);\n const n = segs.length;\n if (n < NEAR_DUP_MIN_SHORT_SEGS) return { fraction: 0, sample: \"\" };\n // Greedy near-duplicate clustering by 5-gram shingle overlap.\n const reps: string[] = [];\n const sizes: number[] = [];\n for (const seg of segs) {\n let placed = false;\n for (let k = 0; k < reps.length; k++) {\n if (Math.abs(reps[k].length - seg.length) <= 12 && shingleOverlap(reps[k], seg) >= NEAR_DUP_OVERLAP_THRESH) {\n sizes[k] += 1;\n placed = true;\n break;\n }\n }\n if (!placed) {\n reps.push(seg);\n sizes.push(1);\n }\n }\n const fraction = 1 - sizes.filter((v) => v === 1).length / n;\n let bestIdx = -1;\n let bestSize = 1;\n for (let k = 0; k < sizes.length; k++) {\n if (sizes[k] > bestSize) {\n bestSize = sizes[k];\n bestIdx = k;\n }\n }\n return { fraction, sample: bestIdx >= 0 ? reps[bestIdx] : \"\" };\n}\n\n/** Fraction of b's 5-char shingles that also appear in a (lowercased). */\nfunction shingleOverlap(a: string, b: string): number {\n const n = 5;\n const set = new Set<string>();\n const ta = a.toLowerCase();\n for (let i = 0; i + n <= ta.length; i++) set.add(ta.slice(i, i + n));\n const tb = b.toLowerCase();\n let hit = 0;\n let total = 0;\n for (let i = 0; i + n <= tb.length; i++) {\n if (set.has(tb.slice(i, i + n))) hit++;\n total++;\n }\n return total > 0 ? hit / total : 0;\n}\n\n/**\n * Repetition-loop detector over the accumulated text of one assistant message\n * (thinking + final blocks concatenated).\n *\n * v5: TWO within-message signals, OR'd:\n * 1. Exact tail periodicity (≥2 exact contiguous copies) — catches tape-loops.\n * 2. Short-segment near-repetition dominance (≥90% of the message's short\n * segments are near-duplicates) — catches variation-loops (reworded copies).\n * Both are strictly within one message; no cross-message comparison.\n * `suspect` mirrors `hard` for API compatibility.\n */\nexport class RepetitionDetector {\n private text = \"\";\n private lastChecked = 0;\n private lastResult: DetectionResult = { suspect: false, hard: false, sample: \"\" };\n\n /** Start a fresh message: call on assistant message_start. */\n reset(): void {\n this.text = \"\";\n this.lastChecked = 0;\n this.lastResult = { suspect: false, hard: false, sample: \"\" };\n }\n\n /** Feed the latest full accumulated text; returns the current verdict. */\n ingest(newText: string): DetectionResult {\n this.text = newText;\n if (this.text.length - this.lastChecked >= CHECKPOINT_CHARS) {\n this.lastChecked = this.text.length;\n this.lastResult = this.scan();\n }\n return this.lastResult;\n }\n\n private scan(): DetectionResult {\n // 1) Exact tail periodicity (tape-loop).\n const period = findPeriod(this.text);\n if (period !== null) {\n const sample = this.text.slice(this.text.length - period);\n return {\n suspect: true,\n hard: true,\n sample: sample.length > SAMPLE_MAX_CHARS\n ? `${sample.slice(0, SAMPLE_MAX_CHARS)}…`\n : sample,\n };\n }\n // 2) Within-message short-segment near-repetition dominance (variation-loop).\n const nd = nearDupDominance(this.text);\n if (nd.fraction >= NEAR_DUP_HARD_FRACTION) {\n return {\n suspect: true,\n hard: true,\n sample: nd.sample.length > SAMPLE_MAX_CHARS\n ? `${nd.sample.slice(0, SAMPLE_MAX_CHARS)}…`\n : nd.sample,\n };\n }\n return { suspect: false, hard: false, sample: \"\" };\n }\n}\n\n/** Concatenate text + thinking blocks of an assistant message for detection. */\nexport function extractText(content: unknown): string {\n if (!Array.isArray(content)) return \"\";\n const parts: string[] = [];\n for (const item of content) {\n if (!item || typeof item !== \"object\") continue;\n const block = item as { type?: unknown; text?: unknown; thinking?: unknown };\n if (block.type === \"text\" && typeof block.text === \"string\") {\n parts.push(block.text);\n } else if (block.type === \"thinking\") {\n if (typeof block.thinking === \"string\") parts.push(block.thinking);\n else if (typeof block.text === \"string\") parts.push(block.text);\n }\n }\n return parts.join(\"\\n\");\n}\n\n/**\n * Tool-call-loop detector over one agent run (reset on agent_start).\n *\n * The real-world failure the user hit (and that pure text signals miss): the\n * model repeatedly invokes the SAME tool with the SAME input — e.g. checking\n * `git status` / reading CHANGES.md over and over — and never settles. The text\n * it emits is near-identical *variations*, not an exact periodic loop, so text\n * periodicity does not fire. Counting identical (toolName, inputKey) calls in a\n * sliding window catches it directly.\n */\nexport class ToolLoopTracker {\n private calls: string[] = [];\n private counts = new Map<string, number>();\n\n /** Start a fresh agent run: call on agent_start. */\n reset(): void {\n this.calls = [];\n this.counts.clear();\n }\n\n /**\n * Record a tool call; returns true when the same (toolName, inputKey) has\n * been seen `TOOL_LOOP_MAX_REPEATS` times within the recent window.\n */\n record(toolName: string, inputKey: string): boolean {\n const key = `${toolName}:${inputKey}`;\n this.calls.push(key);\n this.counts.set(key, (this.counts.get(key) ?? 0) + 1);\n if (this.calls.length > TOOL_LOOP_WINDOW) {\n const dropped = this.calls.shift()!;\n const c = this.counts.get(dropped)!;\n if (c <= 1) this.counts.delete(dropped);\n else this.counts.set(dropped, c - 1);\n }\n return (this.counts.get(key) ?? 0) >= TOOL_LOOP_MAX_REPEATS;\n }\n}\n\n/** Build the tool-loop steer message; retryNum is 1-based (1 = first retry).\n * Action-oriented: stop re-invoking, finish the goal from existing info.\n * Escalates per retry number. */\nexport function buildToolLoopSteer(retryNum: number, toolName: string, inputKey: string): string {\n if (retryNum >= 3) {\n return (\n \"[自动护栏] 这是第 3 次工具循环。请立即停止调用工具,以最短篇幅完成任务目标,或明确声明\" +\n \"无法完成并说明原因。\"\n );\n }\n if (retryNum >= 2) {\n return (\n \"[自动护栏] 这是第 2 次工具循环。请停止调用 `\" + toolName +\n \"`,直接基于已有信息完成目标并给出简短结果;如果无法完成,明确说明卡在哪里。\"\n );\n }\n const sample = inputKey.length > 80 ? `${inputKey.slice(0, 80)}…` : inputKey;\n return (\n \"[自动护栏] 检测到你陷入了工具调用循环:连续多次调用 `\" + toolName + \"`(参数 \" +\n (sample || \"无参数\") +\n \")却迟迟不完成任务。请停止重复调用同一工具,直接基于已有信息完成目标操作并给出结果,不要重复已执行过的操作。\"\n );\n}\n\n/**\n * Retry budget shared by the text-loop and tool-loop paths, with a\n * deterministic \"real user turn\" reset.\n *\n * The budget must span one logical user turn (the original request + any number\n * of steer retries) and reset on the NEXT real user message. We identify our\n * own steer messages by their exact content (Pi delivers a queued steer as a\n * user message whose text matches what we sent — this is how Pi itself clears\n * its steering queue). Using a boolean \"steer in flight\" flag is fragile: if a\n * steer's message_start is missed in an abort/auto-continue race, the flag\n * sticks and the budget never resets — the bug reported as \"after 3 retries the\n * count never resets\".\n */\nexport class RetryBudget {\n private remaining: number;\n private sentSteers = new Set<string>();\n\n constructor(private readonly max: number) {\n this.remaining = max;\n }\n\n /** Consume one retry slot; 1-based retry number and whether a steer is still\n * allowed. Shared by the text and tool-loop paths. */\n consume(): { retryNum: number; allowSteer: boolean } {\n this.remaining -= 1;\n return { retryNum: this.max - this.remaining, allowSteer: this.remaining >= 0 };\n }\n\n /** Record the exact text of a steer we sent, so its user-message delivery can\n * be recognized and NOT treated as a new user turn. */\n recordSentSteer(steerText: string): void {\n this.sentSteers.add(steerText);\n }\n\n /**\n * Handle a user message_start. Returns true when it was a REAL user turn\n * (budget reset to max); false when it was our own steer (budget kept).\n */\n onUserMessage(text: string): boolean {\n if (text && this.sentSteers.has(text)) {\n this.sentSteers.delete(text);\n return false;\n }\n this.reset();\n return true;\n }\n\n reset(): void {\n this.remaining = this.max;\n }\n\n get remainingCount(): number {\n return this.remaining;\n }\n}\n\n/** Build the corrective steer message; retryNum is 1-based (1 = first retry).\n *\n * Action-oriented wording (v4): the real-world loop this guard catches is often\n * \"rehearsing the next action without doing it\" (e.g. repeating \"let me check\n * git status\" without ever committing). Telling a stuck model to \"give a final\n * answer\" does not break that — it must be told to EXECUTE. Escalates per\n * retry number. */\nexport function buildSteer(retryNum: number, sample: string): string {\n if (retryNum >= 3) {\n return (\n \"[自动护栏] 这是第 3 次。你一直循环未完成目标。请立即以最短篇幅完成任务目标,或明确声明\" +\n \"无法完成并说明原因。不要再重复任何已说过的内容。\"\n );\n }\n if (retryNum >= 2) {\n return (\n \"[自动护栏] 这是第 2 次检测到循环。你仍在重复却没有执行。请立即停止任何描述性文本,直接完成\" +\n \"当前任务的目标操作并给出简短结果;如果无法完成,明确说明卡在哪里。\"\n );\n }\n return (\n \"[自动护栏] 检测到你陷入了循环:反复复述同一个动作/内容却始终没有真正执行下一步。请立即停止\" +\n \"复述,直接执行你要做的操作并给出结果,不要描述你将要做什么。\\n\\n\" +\n \"刚才重复的片段(节选):\\n\" +\n (sample || \"(无样例)\")\n );\n}\n\n/** Build the post-compaction \"continue\" steer. Sent after the retry budget was\n * exhausted and the context was auto-compacted: the conversation was shrunk,\n * so the model gets a clean shot at finishing the original task instead of\n * re-reading its own garbage. Action-oriented, same philosophy as buildSteer. */\nexport function buildPostCompactSteer(): string {\n return (\n \"[自动护栏] 前几次尝试陷入循环,已自动压缩上下文。请基于当前压缩后的上下文,直接完成原始任务目标\" +\n \"并给出最终结果;若仍无法完成,请明确说明卡在哪里,不要再重复尝试相同操作。\"\n );\n}\n",
|
|
6
|
+
"import type { ExtensionAPI, ExtensionContext } from \"@earendil-works/pi-coding-agent\";\nimport {\n GUARD_STEER_MAX_RETRIES,\n MAX_COMPACTION_RETRIES,\n RepetitionDetector,\n RetryBudget,\n ToolLoopTracker,\n buildPostCompactSteer,\n buildSteer,\n buildToolLoopSteer,\n extractText,\n} from \"./detector.ts\";\n\n/**\n * pi-repetition-guard — a repetition-loop guard for the Pi coding agent.\n *\n * Detects two distinct runaway failure modes and intervenes with\n * abort/re-steer (ADR 0002: active intervention, beyond ADR 0001's ask_user\n * scope):\n *\n * 1. Text repetition loop (thinking-runaway / 万字复读): the model repeats the\n * same text contiguously during long thinking or output. Detected via tail\n * periodicity on `message_update`; intervened with ctx.abort() + steer.\n * 2. Tool-call loop: the model repeatedly invokes the SAME tool with the SAME\n * input and never settles (e.g. checking git status over and over). Detected\n * on `tool_call`; intervened by blocking the repetitive call (block +\n * terminate) and steering.\n *\n * Shared control: `/runaway on|off` (user-only), retry budget of 2 steers per\n * logical user turn (escalated wording 1→2). When the budget is exhausted we\n * auto-compact the context and retry once more (`MAX_COMPACTION_RETRIES`), then\n * give up — attacking the long-context degradation that feeds the loop. Still\n * hard-capped; the guard itself can never loop forever.\n */\nexport default function repetitionGuardExtension(pi: ExtensionAPI): void {\n const detector = new RepetitionDetector();\n const toolLoopTracker = new ToolLoopTracker();\n const budget = new RetryBudget(GUARD_STEER_MAX_RETRIES);\n let enabled = true;\n let pendingSteer: string | undefined;\n let pendingToolSteer: string | undefined;\n let pendingCompact = false;\n let compactionsUsed = 0;\n let hardTriggered = false;\n\n const diag = (message: string): void => {\n console.log(`[pi-repetition-guard] ${message}`);\n };\n\n const onHardTrigger = (ctx: ExtensionContext, sample: string): void => {\n const { retryNum, allowSteer } = budget.consume();\n if (allowSteer) {\n pendingSteer = buildSteer(retryNum, sample);\n diag(`text runaway — aborting, will steer (retry ${retryNum}/${GUARD_STEER_MAX_RETRIES})`);\n } else if (compactionsUsed < MAX_COMPACTION_RETRIES) {\n pendingCompact = true;\n diag(\"text runaway — aborting, will compact + continue\");\n } else {\n diag(\"text runaway — aborting, retry budget exhausted (giving up)\");\n }\n ctx.abort();\n };\n\n /**\n * Fire the queued compact-and-continue at a run settle point (`message_end`\n * for text loops, `agent_end` for tool loops) — same race-free discipline as\n * the steer delivery. On completion the retry budget resets and a \"continue\"\n * steer re-runs the task against the shrunk context. On failure we keep the\n * existing give-up behavior.\n */\n const maybeFireCompact = (ctx: ExtensionContext): void => {\n if (!pendingCompact) return;\n pendingCompact = false;\n if (compactionsUsed >= MAX_COMPACTION_RETRIES) {\n diag(\"compaction budget exhausted — giving up\");\n return;\n }\n compactionsUsed += 1;\n diag(`triggering auto-compact (${compactionsUsed}/${MAX_COMPACTION_RETRIES})`);\n ctx.compact({\n onComplete: () => {\n budget.reset();\n const steer = buildPostCompactSteer();\n budget.recordSentSteer(steer);\n diag(\"compaction complete — sending continue steer\");\n pi.sendUserMessage(steer, { deliverAs: \"steer\" });\n },\n onError: (error) => {\n diag(`compaction failed (${error.message}) — giving up`);\n },\n });\n };\n\n /** Track per-message state and the retry budget across logical turns. */\n pi.on(\"message_start\", (event) => {\n const role = event.message?.role;\n if (role === \"assistant\") {\n detector.reset();\n hardTriggered = false;\n } else if (role === \"user\") {\n const text = contentText(event.message.content);\n if (budget.onUserMessage(text)) {\n compactionsUsed = 0;\n diag(`new user turn — budget reset to ${GUARD_STEER_MAX_RETRIES}`);\n }\n }\n });\n\n pi.on(\"agent_start\", () => {\n toolLoopTracker.reset();\n });\n\n // Text runaway detection: the one real-time observation point, stream\n // updates with the accumulated full message snapshot (thinking + final text).\n pi.on(\"message_update\", (event, ctx) => {\n if (!enabled || hardTriggered) return;\n if (event.message.role !== \"assistant\") return;\n const result = detector.ingest(extractText(event.message.content));\n if (result.hard) {\n hardTriggered = true;\n onHardTrigger(ctx, result.sample);\n }\n });\n\n // Tool-call-loop detection: same tool + same input repeated in a window.\n // Block the repetitive call and queue a tool-loop steer.\n pi.on(\"tool_call\", (event) => {\n if (!enabled) return;\n const inputKey = normalizeInput(event.input);\n const isLoop = toolLoopTracker.record(event.toolName, inputKey);\n if (!isLoop) return;\n const { retryNum, allowSteer } = budget.consume();\n if (allowSteer) {\n pendingToolSteer = buildToolLoopSteer(retryNum, event.toolName, inputKey);\n diag(\n `tool-call loop — blocking ${event.toolName} (${inputKey.slice(0, 60)}), will steer ` +\n `(retry ${retryNum}/${GUARD_STEER_MAX_RETRIES})`,\n );\n } else if (compactionsUsed < MAX_COMPACTION_RETRIES) {\n pendingCompact = true;\n diag(\"tool-call loop — blocking, will compact + continue\");\n } else {\n diag(\"tool-call loop — budget exhausted, blocking without steer\");\n }\n return {\n block: true,\n reason: \"Repetition guard: tool-call loop (same tool+args repeated)\",\n terminate: true,\n };\n });\n\n // Send the text steer once the aborted message is finalized (race-free).\n pi.on(\"message_end\", (event, ctx) => {\n if (event.message.role !== \"assistant\") return;\n maybeFireCompact(ctx);\n if (!pendingSteer) return;\n const steer = pendingSteer;\n pendingSteer = undefined;\n budget.recordSentSteer(steer);\n diag(\"sending text steer retry\");\n pi.sendUserMessage(steer, { deliverAs: \"steer\" });\n });\n\n // Send the tool-loop steer after the (terminated) run settles.\n pi.on(\"agent_end\", (event, ctx) => {\n maybeFireCompact(ctx);\n if (!pendingToolSteer) return;\n const steer = pendingToolSteer;\n pendingToolSteer = undefined;\n budget.recordSentSteer(steer);\n diag(\"sending tool-loop steer retry\");\n pi.sendUserMessage(steer, { deliverAs: \"steer\" });\n });\n\n // User-only control surface: the guard cannot be disabled by the LLM.\n pi.registerCommand(\"runaway\", {\n description: \"Toggle the repetition-loop guard (on | off). Default: on.\",\n handler: async (args, ctx) => {\n const arg = args.trim().toLowerCase();\n if (arg === \"on\") enabled = true;\n else if (arg === \"off\") enabled = false;\n else enabled = !enabled;\n if (ctx.hasUI) {\n ctx.ui.notify(`Repetition guard ${enabled ? \"ON\" : \"OFF\"}`, enabled ? \"info\" : \"warning\");\n }\n if (ctx.mode === \"tui\") {\n ctx.ui.setStatus(\"pi-repetition-guard\", enabled ? \"guard:on\" : \"guard:off\");\n }\n },\n });\n}\n\n/** Stable, order-insensitive string key for a tool input. */\nfunction normalizeInput(input: unknown): string {\n if (input === undefined || input === null) return \"\";\n if (typeof input === \"string\") return input;\n try {\n return stableStringify(input);\n } catch {\n return String(input);\n }\n}\n\n/** Concatenate the text of a user message, for matching against our steers. */\nfunction contentText(content: unknown): string {\n if (!Array.isArray(content)) return \"\";\n let text = \"\";\n for (const item of content) {\n if (item && typeof item === \"object\") {\n const t = (item as { text?: unknown }).text;\n if (typeof t === \"string\") text += t;\n }\n }\n return text;\n}\n\nfunction stableStringify(value: unknown): string {\n if (Array.isArray(value)) {\n return `[${value.map(stableStringify).join(\",\")}]`;\n }\n if (value && typeof value === \"object\") {\n const entries = Object.entries(value as Record<string, unknown>)\n .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n .map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`);\n return `{${entries.join(\",\")}}`;\n }\n return JSON.stringify(value);\n}\n"
|
|
7
7
|
],
|
|
8
|
-
"mappings": "AAIO,IAAM,EAA0B,
|
|
9
|
-
"debugId": "
|
|
8
|
+
"mappings": "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",
|
|
9
|
+
"debugId": "9422E5D24536DBDA64756E2164756E21",
|
|
10
10
|
"names": []
|
|
11
11
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capdiem/pi-repetition-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "A repetition-loop guard for the Pi coding agent: detects thinking-runaway / 万字复读 (the model repeating the same text in a loop) in streamed output via message_update, then aborts and re-steers the model with a corrective steer message",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"pi": {
|