@capdiem/pi-repetition-guard 0.2.0 → 0.3.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
@@ -22,26 +22,80 @@ the final `text` stream (whichever hits the threshold first).
22
22
 
23
23
  ## Features
24
24
 
25
- - **Hybrid detection** over the accumulated streamed text (`message_update`):
26
- - *Block-repeat* (primary, low false-positive): a long block (line) repeated
27
- suspect, hard.
28
- - *Shingle novelty* (confirmatory, earlier): the trailing ~40×40-char window
29
- mostly re-emitting already-seen content suspect at ≥80%, hard at ≥95%.
30
- - **Two-stage trigger**: stage-1 "suspect" is **record-only** (a log line); only
31
- stage-2 "hard" aborts. This gives normal long-form content (code blocks,
32
- lists, prose) room before anything is interrupted.
33
- - **Abort + re-steer** on hard trigger:
34
- - `ctx.abort()` stops the runaway generation in `message_update`.
35
- - A corrective `steer` message is sent from `message_end` (race-free the
36
- aborted message is finalized by then): *"you are in a repetition loop, stop
37
- repeating, give a concise final answer"* plus a **truncated sample of the
38
- repeated block** so the model can see exactly what it was repeating.
25
+ - **Two runaway detectors**, both model-agnostic and strictly within-message:
26
+ - **Text repetition loop** (thinking-runaway / 万字复读) the model re-emits
27
+ the same text contiguously during long thinking or output. Detected via
28
+ **tail periodicity** on `message_update`: the trailing `2×P` characters
29
+ being an exact repetition of a fixed unit `P` (a "tape loop").
30
+ - **Variation loop** (v5) the model rewording the same intent over and over
31
+ ("Let me read L1000-1035" / "Let me read the signature end" / ...) without
32
+ advancing. Detected via **short-segment near-repetition dominance**: if
33
+ ≥90% of a message's short segments (≤100 chars) are near-duplicates of each
34
+ other (5-gram shingle overlap ≥0.7), the message is a loop. Restricting to
35
+ short segments is what keeps it from false-positiving on legitimately long,
36
+ templated thinking (which is built of long paragraphs).
37
+ - **Tool-call loop** the model repeatedly invokes the **same tool with the
38
+ same input** and never settles (e.g. checking `git status` / reading a file
39
+ over and over). Detected on `tool_call` by counting identical
40
+ (tool, normalized-input) calls in a sliding window.
41
+ - The text signals are OR'd: a message triggers if either exact tail
42
+ periodicity OR short-segment near-repetition dominance fires.
43
+ - **Zero false positives on legitimate content** — long self-verifying thinking,
44
+ recaps, code, and templated lists interleave new material, so their tails are
45
+ never periodic; varied tool usage (distinct calls) never trips the tool-loop
46
+ counter. (v1 used a trailing-window shingle-novelty signal that
47
+ false-triggered on legitimate long thinking — the bug this v2/v3 line fixes.
48
+ See [ADR 0002](#design-notes-adr-0002) and [CONTEXT.md](../../CONTEXT.md)
49
+ 触发策略.)
50
+ - **Intervention**, per failure mode:
51
+ - *Text loop*: `ctx.abort()` stops the generation, then a corrective `steer`
52
+ is sent from `message_end` with a **truncated sample of the repeated unit**.
53
+ - *Tool loop*: the repetitive tool call is **blocked** (`tool_call` →
54
+ `{ block, terminate }`), then a steer naming the tool + repeated input is
55
+ sent after the run settles.
56
+ - **Action-oriented steering (v4)**: steer messages redirect the model to
57
+ *execute*, not to "give an answer" — a stuck loop is usually "rehearsing the
58
+ next action without doing it" (e.g. repeating "let me check git status"
59
+ without ever committing), so the steer tells it to stop describing and do the
60
+ operation.
39
61
  - **Retry budget**: max **2** steer retries per logical user turn, with
40
- escalated wording on the 2nd; if it still runaways, it aborts without another
41
- steer (no infinite abort↔steer loop).
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.)
42
88
  - **User-only control**: `/runaway on|off` slash command, default **on**. The
43
89
  guard cannot be disabled by the LLM itself (there is no LLM-callable toggle).
44
90
 
91
+ > **Why is there no "stage-1 / two-stage" trigger anymore?** The original design
92
+ > had a two-stage trigger (record a "suspect" early, abort only on a harder
93
+ > threshold). Measured against realistic content, *every* early-warning signal
94
+ > (shingle novelty, block-repeat, fractional-period) false-triggers on
95
+ > legitimate long structured thinking, while exact ≥2-copy contiguity never
96
+ > does. So the two-stage was collapsed into the single clean signal. See
97
+ > CONTEXT.md 触发策略 for the record.
98
+
45
99
  ## Install
46
100
 
47
101
  ```bash
@@ -58,12 +112,17 @@ pi -e ./extensions/pi-repetition-guard/index.ts
58
112
 
59
113
  Nothing to configure — it is on by default and runs silently in the background.
60
114
 
61
- - **Normal operation:** you see nothing. Stage-1 "suspect" writes a
62
- `[pi-repetition-guard]` log line only.
63
- - **Runaway detected:** the generation is aborted and a `steer` retry runs
115
+ - **Normal operation:** you see nothing. The guard only acts on an unambiguous
116
+ contiguous tape-loop or a repeated identical tool call.
117
+ - **Text runaway detected:** the generation is aborted and a `steer` retry runs
64
118
  automatically. In the TUI you'll see the aborted message, then the corrective
65
119
  message, then a fresh answer. (The aborted junk stays in history — no cleanup,
66
120
  per the settled design; see ADR 0002.)
121
+ - **Tool-call loop detected:** the repetitive tool call is blocked (a
122
+ "Repetition guard: tool-call loop" reason), the run terminates, and a steer
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.
67
126
  - **Disable / re-enable:**
68
127
  ```
69
128
  /runaway off
@@ -75,22 +134,40 @@ Nothing to configure — it is on by default and runs silently in the background
75
134
  ## Behavior details
76
135
 
77
136
  - **Budget reset:** a new logical user turn (a user message that is *not* our
78
- 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.
79
145
  - **Scope:** only the current assistant message's own text is compared — the
80
146
  detector does not compare against the user's message or prior assistant
81
147
  messages, so restating the user's words never false-triggers.
82
- - **Wholesale repetition is always flagged:** re-emitting any content verbatim
83
- (even a "checklist") is, by definition, the runaway signature.
148
+ - **Trigger signal (text):** either exact tail periodicity (≥2 exact copies of a
149
+ ≥30-char unit at the tail) OR short-segment near-repetition dominance (≥90% of
150
+ the message's short ≤100-char segments are near-duplicates). Both strictly
151
+ within one message. Wholesale contiguous repetition is, by definition, the
152
+ runaway signature; content that merely *echoes* earlier material while still
153
+ advancing is not.
154
+ - **Trigger signal (tool):** the same tool called with the same normalized input
155
+ ≥4 times within the last 8 calls. Varied legitimate tool usage never trips it.
156
+ - **Known limitation:** the text signals are deliberately conservative — a
157
+ message is only flagged when either exactly periodic OR dominated by short
158
+ near-duplicate restatements. A loop that is neither (long-form repetition with
159
+ meaningful variation between copies) may slip through. This is the accepted
160
+ trade-off to keep false positives at zero. See ADR 0002.
84
161
  - **Sampling parameters are untouched:** Pi exposes no built-in
85
162
  `repetition_penalty` / `no_repeat_ngram_size`; this guard detects and
86
163
  intervenes client-side instead of relying on provider-side sampling knobs.
87
164
 
88
165
  ## Diagnostics
89
166
 
90
- Detection events (stage-1 suspect, stage-2 hard trigger, retry count, budget
91
- exhaustion) are logged with a `[pi-repetition-guard]` prefix. These records
92
- support tuning the fixed two-stage thresholds over time — consistent with the
93
- repo's diagnostics discipline (ADR 0001).
167
+ Detection events (hard trigger, retry count, budget exhaustion) are logged with
168
+ a `[pi-repetition-guard]` prefix. These records support tuning the fixed
169
+ detection thresholds over time — consistent with the repo's diagnostics
170
+ discipline (ADR 0001).
94
171
 
95
172
  ## License
96
173
 
package/index.min.js CHANGED
@@ -1,8 +1,8 @@
1
- var $=2;class f{text="";lastChecked=0;seenShingles=new Set;lastResult={suspect:!1,hard:!1,sample:""};reset(){this.text="",this.lastChecked=0,this.seenShingles.clear(),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=!1,O=!1,z="",K=new Map,Q=0,Z="";for(let Y of this.text.split(/\n+/)){let j=Y.trim();if(j.length<60)continue;let q=(K.get(j)??0)+1;if(K.set(j,q),q>Q)Q=q,Z=j}if(Q>=3)O=!0,z=Z;else if(Q>=2)J=!0,z=Z;let P=[];for(let Y=0;Y+40<=this.text.length;Y++){let j=this.text.slice(Y,Y+40),q=this.seenShingles.has(j);if(P.length<40)P.push(q);else P.shift(),P.push(q);this.seenShingles.add(j)}let H=P.filter(Boolean).length,W=P.length>0?H/P.length:0;if(W>=0.95)O=!0;else if(W>=0.8)J=!0;return{suspect:J,hard:O,sample:z.length>200?`${z.slice(0,200)}…`:z}}}function w(J){if(!Array.isArray(J))return"";let O=[];for(let z of J){if(!z||typeof z!=="object")continue;let K=z;if(K.type==="text"&&typeof K.text==="string")O.push(K.text);else if(K.type==="thinking"){if(typeof K.thinking==="string")O.push(K.thinking);else if(typeof K.text==="string")O.push(K.text)}}return O.join(`
2
- `)}function F(J,O){if(J>=2)return"[自动护栏] 你刚才再次陷入了复读循环,这是第 2 次。请立即停止重复,直接给出简洁、收敛的"+"最终回答。不要输出思考过程,不要复述任何已说过的内容,直接回答。";return"[自动护栏] 检测到你陷入了复读循环(重复输出相同内容)。请立即停止重复,直接给出简洁、收敛的"+`最终回答,不要复述或重复任何已说过的内容。
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
- `+(O||"(无样例)")}function G(J){let O=new f,z=!0,K=$,Q,Z=!1,P=!1,H=!1,W=(j)=>{console.log(`[pi-repetition-guard] ${j}`)},Y=(j,q)=>{K-=1;let V=$-K;if(K>=0)Q=F(V,q),W(`hard trigger — aborting, will steer (retry ${V}/${$})`);else W("hard trigger — aborting, retry budget exhausted (giving up)");j.abort()};J.on("message_start",(j)=>{let q=j.message?.role;if(q==="assistant")O.reset(),P=!1,H=!1;else if(q==="user")if(Z)Z=!1;else K=$,W(`new user turn — budget reset to ${$}`)}),J.on("message_update",(j,q)=>{if(!z||H)return;if(j.message.role!=="assistant")return;let V=O.ingest(w(j.message.content));if(V.hard)H=!0,Y(q,V.sample);else if(V.suspect&&!P)P=!0,W("suspect (stage-1): possible repetition loop, recording only")}),J.on("message_end",(j)=>{if(!Q)return;if(j.message.role!=="assistant")return;let q=Q;Q=void 0,Z=!0,W("sending steer retry"),J.sendUserMessage(q,{deliverAs:"steer"})}),J.registerCommand("runaway",{description:"Toggle the repetition-loop guard (on | off). Default: on.",handler:async(j,q)=>{let V=j.trim().toLowerCase();if(V==="on")z=!0;else if(V==="off")z=!1;else z=!z;if(q.hasUI)q.ui.notify(`Repetition guard ${z?"ON":"OFF"}`,z?"info":"warning");if(q.mode==="tui")q.ui.setStatus("pi-repetition-guard",z?"guard:on":"guard:off")}})}export{G as default};
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=E00E93383848526F64756E2164756E21
7
+ //# debugId=9422E5D24536DBDA64756E2164756E21
8
8
  //# sourceMappingURL=index.min.js.map
package/index.min.js.map CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["..\\..\\extensions\\pi-repetition-guard\\detector.ts", "..\\..\\extensions\\pi-repetition-guard\\index.ts"],
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 (two-stage trigger: fixed, not user-configurable) ──────\nexport const GUARD_STEER_MAX_RETRIES = 2;\n/** Rescan cadence — we re-scan only after this many new chars accumulate. */\nconst CHECKPOINT_CHARS = 200;\n/** A block must be at least this long (chars) to count as a repeatable block. */\nconst MIN_BLOCK_CHARS = 60;\n/** Block seen this many times → stage-1 suspect. */\nconst SUSPECT_BLOCK_REPEATS = 2;\n/** Block seen this many times → stage-2 hard trigger. */\nconst HARD_BLOCK_REPEATS = 3;\n/** Shingle length (chars) for the n-gram novelty signal. */\nconst SHINGLE_SIZE = 40;\n/** \"Recent\" window = this many trailing shingles (~RECENT_SHINGLES × 40 chars). */\nconst RECENT_SHINGLES = 40;\n/** Recent-window overlap ratio at or above this → stage-1 suspect. */\nconst SUSPECT_RECENT_RATIO = 0.8;\n/** Recent-window overlap ratio at or above this → stage-2 hard trigger. */\nconst HARD_RECENT_RATIO = 0.95;\n/** Repeated-block sample length for the steer message. */\nexport const SAMPLE_MAX_CHARS = 200;\n\nexport interface DetectionResult {\n suspect: boolean;\n hard: boolean;\n /** Most-repeated block, truncated to SAMPLE_MAX_CHARS, for the steer message. */\n sample: string;\n}\n\n/**\n * Hybrid repetition-loop detector over the accumulated text of one assistant\n * message (thinking + final blocks concatenated).\n *\n * Two signals:\n * 1. Block-repeat (primary, low false-positive): a long block (line) repeated\n * 2× → suspect, 3× → hard.\n * 2. Shingle novelty (confirmatory, earlier): over a trailing window of 40-char\n * shingles, the fraction that already appeared earlier in the message. ≥80%\n * → suspect, ≥95% → hard.\n */\nexport class RepetitionDetector {\n private text = \"\";\n private lastChecked = 0;\n private seenShingles = new Set<string>();\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.seenShingles.clear();\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 let suspect = false;\n let hard = false;\n let sample = \"\";\n\n // 1) Block-repeat (primary signal).\n const blockCounts = new Map<string, number>();\n let maxCount = 0;\n let mostRepeated = \"\";\n for (const rawBlock of this.text.split(/\\n+/)) {\n const block = rawBlock.trim();\n if (block.length < MIN_BLOCK_CHARS) continue;\n const count = (blockCounts.get(block) ?? 0) + 1;\n blockCounts.set(block, count);\n if (count > maxCount) {\n maxCount = count;\n mostRepeated = block;\n }\n }\n if (maxCount >= HARD_BLOCK_REPEATS) {\n hard = true;\n sample = mostRepeated;\n } else if (maxCount >= SUSPECT_BLOCK_REPEATS) {\n suspect = true;\n sample = mostRepeated;\n }\n\n // 2) Shingle novelty (confirmatory signal). The seen-set persists across\n // scans, so trailing shingles that repeat earlier content get flagged.\n const recent: boolean[] = [];\n for (let i = 0; i + SHINGLE_SIZE <= this.text.length; i++) {\n const shingle = this.text.slice(i, i + SHINGLE_SIZE);\n const isRepeat = this.seenShingles.has(shingle);\n if (recent.length < RECENT_SHINGLES) {\n recent.push(isRepeat);\n } else {\n recent.shift();\n recent.push(isRepeat);\n }\n this.seenShingles.add(shingle);\n }\n const recentRepeats = recent.filter(Boolean).length;\n const recentRatio = recent.length > 0 ? recentRepeats / recent.length : 0;\n if (recentRatio >= HARD_RECENT_RATIO) hard = true;\n else if (recentRatio >= SUSPECT_RECENT_RATIO) suspect = true;\n\n return {\n suspect,\n hard,\n sample: sample.length > SAMPLE_MAX_CHARS ? `${sample.slice(0, SAMPLE_MAX_CHARS)}…` : sample,\n };\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/** Build the corrective steer message; retryNum is 1-based (1 = first retry). */\nexport function buildSteer(retryNum: number, sample: string): string {\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 buildSteer,\n extractText,\n} from \"./detector.ts\";\n\n/**\n * pi-repetition-guard — a repetition-loop guard for the Pi coding agent.\n *\n * Detects thinking-runaway / 万字复读 (the model repeating the same text in a\n * loop during long thinking or output) by observing `message_update` stream\n * events, then aborts the generation and re-steers the model with a corrective\n * steer message (ADR 0002: active abort, beyond ADR 0001's ask_user scope).\n *\n * Design (settled in grilling, see docs/adr/0002):\n * - Hybrid detection: block-repeat primary + n-gram shingle novelty confirmatory.\n * - Two-stage trigger: stage-1 \"suspect\" is record-only; stage-2 \"hard\" aborts.\n * - Intervention pipeline: ctx.abort() in message_update, then send the steer\n * from message_end (race-free: the aborted message is finalized by then).\n * - Retry budget: max 2 steer retries per logical user turn, escalated wording\n * on the 2nd, then give up (abort without steer).\n * - Control: `/runaway on|off` slash command, default on.\n */\nexport default function repetitionGuardExtension(pi: ExtensionAPI): void {\n const detector = new RepetitionDetector();\n let enabled = true;\n let steerBudget = GUARD_STEER_MAX_RETRIES;\n let pendingSteer: string | undefined;\n let steerInFlight = false;\n let suspectLogged = false;\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 steerBudget -= 1;\n const retryNum = GUARD_STEER_MAX_RETRIES - steerBudget; // 1-based: 1 or 2\n if (steerBudget >= 0) {\n pendingSteer = buildSteer(retryNum, sample);\n diag(`hard trigger — aborting, will steer (retry ${retryNum}/${GUARD_STEER_MAX_RETRIES})`);\n } else {\n diag(\"hard trigger — aborting, retry budget exhausted (giving up)\");\n }\n ctx.abort();\n };\n\n // Track per-message state and the retry budget across logical user turns.\n pi.on(\"message_start\", (event) => {\n const role = event.message?.role;\n if (role === \"assistant\") {\n detector.reset();\n suspectLogged = false;\n hardTriggered = false;\n } else if (role === \"user\") {\n if (steerInFlight) {\n steerInFlight = false; // our own steer — keep the budget\n } else {\n steerBudget = GUARD_STEER_MAX_RETRIES; // real user message — new turn\n diag(`new user turn — budget reset to ${GUARD_STEER_MAX_RETRIES}`);\n }\n }\n });\n\n // The one real-time observation point: stream updates with the accumulated\n // 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 } else if (result.suspect && !suspectLogged) {\n suspectLogged = true;\n diag(\"suspect (stage-1): possible repetition loop, recording only\");\n }\n });\n\n // Send the steer once the aborted message is finalized (avoids racing the\n // abort; message_end replacement is not used ADR 0002, no cleanup).\n pi.on(\"message_end\", (event) => {\n if (!pendingSteer) return;\n if (event.message.role !== \"assistant\") return;\n const steer = pendingSteer;\n pendingSteer = undefined;\n steerInFlight = true;\n diag(\"sending 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"
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,EAsChC,MAAM,CAAmB,CACtB,KAAO,GACP,YAAc,EACd,aAAe,IAAI,IACnB,WAA8B,CAAE,QAAS,GAAO,KAAM,GAAO,OAAQ,EAAG,EAGhF,KAAK,EAAS,CACZ,KAAK,KAAO,GACZ,KAAK,YAAc,EACnB,KAAK,aAAa,MAAM,EACxB,KAAK,WAAa,CAAE,QAAS,GAAO,KAAM,GAAO,OAAQ,EAAG,EAI9D,MAAM,CAAC,EAAkC,CAEvC,GADA,KAAK,KAAO,EACR,KAAK,KAAK,OAAS,KAAK,aArDP,IAsDnB,KAAK,YAAc,KAAK,KAAK,OAC7B,KAAK,WAAa,KAAK,KAAK,EAE9B,OAAO,KAAK,WAGN,IAAI,EAAoB,CAC9B,IAAI,EAAU,GACV,EAAO,GACP,EAAS,GAGP,EAAc,IAAI,IACpB,EAAW,EACX,EAAe,GACnB,QAAW,KAAY,KAAK,KAAK,MAAM,KAAK,EAAG,CAC7C,IAAM,EAAQ,EAAS,KAAK,EAC5B,GAAI,EAAM,OArEQ,GAqEkB,SACpC,IAAM,GAAS,EAAY,IAAI,CAAK,GAAK,GAAK,EAE9C,GADA,EAAY,IAAI,EAAO,CAAK,EACxB,EAAQ,EACV,EAAW,EACX,EAAe,EAGnB,GAAI,GAzEmB,EA0ErB,EAAO,GACP,EAAS,EACJ,QAAI,GA9Ee,EA+ExB,EAAU,GACV,EAAS,EAKX,IAAM,EAAoB,CAAC,EAC3B,QAAS,EAAI,EAAG,EAlFC,IAkFmB,KAAK,KAAK,OAAQ,IAAK,CACzD,IAAM,EAAU,KAAK,KAAK,MAAM,EAAG,EAnFpB,EAmFoC,EAC7C,EAAW,KAAK,aAAa,IAAI,CAAO,EAC9C,GAAI,EAAO,OAnFO,GAoFhB,EAAO,KAAK,CAAQ,EAEpB,OAAO,MAAM,EACb,EAAO,KAAK,CAAQ,EAEtB,KAAK,aAAa,IAAI,CAAO,EAE/B,IAAM,EAAgB,EAAO,OAAO,OAAO,EAAE,OACvC,EAAc,EAAO,OAAS,EAAI,EAAgB,EAAO,OAAS,EACxE,GAAI,GAzFkB,KAyFgB,EAAO,GACxC,QAAI,GA5FgB,IA4FqB,EAAU,GAExD,MAAO,CACL,UACA,OACA,OAAQ,EAAO,OA7FW,IA6FiB,GAAG,EAAO,MAAM,EA7FjC,GA6FoD,KAAM,CACtF,EAEJ,CAGO,SAAS,CAAW,CAAC,EAA0B,CACpD,GAAI,CAAC,MAAM,QAAQ,CAAO,EAAG,MAAO,GACpC,IAAM,EAAkB,CAAC,EACzB,QAAW,KAAQ,EAAS,CAC1B,GAAI,CAAC,GAAQ,OAAO,IAAS,SAAU,SACvC,IAAM,EAAQ,EACd,GAAI,EAAM,OAAS,QAAU,OAAO,EAAM,OAAS,SACjD,EAAM,KAAK,EAAM,IAAI,EAChB,QAAI,EAAM,OAAS,YACxB,GAAI,OAAO,EAAM,WAAa,SAAU,EAAM,KAAK,EAAM,QAAQ,EAC5D,QAAI,OAAO,EAAM,OAAS,SAAU,EAAM,KAAK,EAAM,IAAI,GAGlE,OAAO,EAAM,KAAK;AAAA,CAAI,EAIjB,SAAS,CAAU,CAAC,EAAkB,EAAwB,CACnE,GAAI,GAAY,EACd,MACE,iDACA,mCAGJ,MACE,kDACA;AAAA;AAAA,EACA;AAAA,GACC,GAAU,SC5Hf,SAAwB,CAAwB,CAAC,EAAwB,CACvE,IAAM,EAAW,IAAI,EACjB,EAAU,GACV,EAAc,EACd,EACA,EAAgB,GAChB,EAAgB,GAChB,EAAgB,GAEd,EAAO,CAAC,IAA0B,CACtC,QAAQ,IAAI,yBAAyB,GAAS,GAG1C,EAAgB,CAAC,EAAuB,IAAyB,CACrE,GAAe,EACf,IAAM,EAAW,EAA0B,EAC3C,GAAI,GAAe,EACjB,EAAe,EAAW,EAAU,CAAM,EAC1C,EAAK,8CAA6C,KAAY,IAA0B,EAExF,OAAK,6DAA4D,EAEnE,EAAI,MAAM,GAIZ,EAAG,GAAG,gBAAiB,CAAC,IAAU,CAChC,IAAM,EAAO,EAAM,SAAS,KAC5B,GAAI,IAAS,YACX,EAAS,MAAM,EACf,EAAgB,GAChB,EAAgB,GACX,QAAI,IAAS,OAClB,GAAI,EACF,EAAgB,GAEhB,OAAc,EACd,EAAK,mCAAkC,GAAyB,EAGrE,EAID,EAAG,GAAG,iBAAkB,CAAC,EAAO,IAAQ,CACtC,GAAI,CAAC,GAAW,EAAe,OAC/B,GAAI,EAAM,QAAQ,OAAS,YAAa,OACxC,IAAM,EAAS,EAAS,OAAO,EAAY,EAAM,QAAQ,OAAO,CAAC,EACjE,GAAI,EAAO,KACT,EAAgB,GAChB,EAAc,EAAK,EAAO,MAAM,EAC3B,QAAI,EAAO,SAAW,CAAC,EAC5B,EAAgB,GAChB,EAAK,6DAA6D,EAErE,EAID,EAAG,GAAG,cAAe,CAAC,IAAU,CAC9B,GAAI,CAAC,EAAc,OACnB,GAAI,EAAM,QAAQ,OAAS,YAAa,OACxC,IAAM,EAAQ,EACd,EAAe,OACf,EAAgB,GAChB,EAAK,qBAAqB,EAC1B,EAAG,gBAAgB,EAAO,CAAE,UAAW,OAAQ,CAAC,EACjD,EAGD,EAAG,gBAAgB,UAAW,CAC5B,YAAa,4DACb,QAAS,MAAO,EAAM,IAAQ,CAC5B,IAAM,EAAM,EAAK,KAAK,EAAE,YAAY,EACpC,GAAI,IAAQ,KAAM,EAAU,GACvB,QAAI,IAAQ,MAAO,EAAU,GAC7B,OAAU,CAAC,EAChB,GAAI,EAAI,MACN,EAAI,GAAG,OAAO,oBAAoB,EAAU,KAAO,QAAS,EAAU,OAAS,SAAS,EAE1F,GAAI,EAAI,OAAS,MACf,EAAI,GAAG,UAAU,sBAAuB,EAAU,WAAa,WAAW,EAGhF,CAAC",
9
- "debugId": "E00E93383848526F64756E2164756E21",
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.2.0",
3
+ "version": "0.3.0",
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": {