pi-makora-provider 1.5.0 → 1.7.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
@@ -160,20 +160,27 @@ Do **not** edit `models.json` directly — it is auto-generated from the API. To
160
160
 
161
161
  ## Death-Loop Guard
162
162
 
163
- GLM 5.2 (NVFP4 / FP8) occasionally degenerates into an unbroken `!` repetition
164
- loop (`!!!!...`) that eats the whole response. This extension ships a guard
165
- that watches the streamed assistant output (both the visible answer and the
166
- reasoning trace) and, when it detects a long run of `!` characters, **aborts
163
+ GLM 5.2 (NVFP4 / FP8) occasionally degenerates into a repetition loop — an
164
+ unbroken run of a single character or token, spaced or not (observed: `!` →
165
+ `!!!!...`, `0` → `0000...`, and `0` → `0 0 0 ...`) — that eats the whole
166
+ response. This extension ships a guard that watches the streamed assistant
167
+ output (both the visible answer and the reasoning trace) and, when it detects a
168
+ long run of any one non-whitespace character **or token**, **aborts
167
169
  the runaway generation and resumes the agentic loop invisibly** — no new user
168
170
  message is injected, using the
169
171
  [pi-invisible-continue](https://github.com/monotykamary/pi-invisible-continue)
170
172
  pattern (`agent.prompt([])`).
171
173
 
172
174
  On abort, pi finalizes the in-flight assistant message **with its accumulated
173
- `!!!` content** into the transcript, so the guard drops that partial message
174
- before resuming — otherwise the model would just re-read its own `!!!` and loop
175
- again. The recovery is bounded per prompt (default 3 attempts) to avoid
176
- abort/continue thrash.
175
+ repeated-character content** into the transcript, so the guard drops that
176
+ partial message before resuming — otherwise the model would just re-read its
177
+ own repeated text and loop again. Recovery retries **indefinitely with exponential backoff (2s→60s,
178
+ 2×)**, like [pi-retry](https://github.com/monotykamary/pi-retry) — long-horizon
179
+ agent work can trip the loop many times in a session, so a retry cap would
180
+ strand the agent mid-task. The loop exits only on a clean turn, a user abort
181
+ (Esc), or a session change (`/new`, `/resume`). Backoff is interruptible
182
+ (polls every 100ms) so Esc and `/new` take effect within 100ms instead of
183
+ waiting out the full delay.
177
184
 
178
185
  The guard is scoped to the Makora GLM 5.2 family by default and is tunable via
179
186
  constants at the top of [`death-loop-guard.ts`](./death-loop-guard.ts):
@@ -181,8 +188,13 @@ constants at the top of [`death-loop-guard.ts`](./death-loop-guard.ts):
181
188
  | Constant | Default | Meaning |
182
189
  |---|---|---|
183
190
  | `GUARDED_MODEL_IDS` | `zai-org/GLM-5.2-NVFP4`, `zai-org/GLM-5.2-FP8` | Which model IDs to guard. Add `'*'` to guard every Makora model. |
184
- | `BANG_THRESHOLD` | `40` | Consecutive `!` characters that trip the guard. 40 is far above anything normal prose/code produces. |
185
- | `MAX_RECOVERS_PER_RUN` | `3` | Max invisible recoveries per user prompt before the guard stops intervening. |
191
+ | `REPEAT_THRESHOLD` | `40` | Consecutive identical characters that trip the guard. Applies to any non-whitespace character (whitespace can legitimately repeat). 40 is far above anything normal prose/code produces. (`BANG_THRESHOLD` is kept as a deprecated alias.) |
192
+ | `IGNORED_REPEAT_CHARS` | `space, tab, LF, CR, FF, VT` | Characters whose repetition never trips the guard. |
193
+ | `TOKEN_REPEAT_THRESHOLD` | `40` | Consecutive identical whitespace-delimited tokens that trip the guard. Catches spaced loops like `0 0 0` that the character run can't see. |
194
+ | `TOKEN_REPEAT_BUFFER_CHARS` | `1024` | Tail length kept for token-run detection. Must hold ≥ `TOKEN_REPEAT_THRESHOLD` copies of the repeated unit. |
195
+ | `BACKOFF_BASE_MS` | `2000` | Initial recovery backoff delay. |
196
+ | `BACKOFF_MAX_MS` | `60000` | Maximum backoff delay (cap). |
197
+ | `BACKOFF_MULTIPLIER` | `2` | Backoff growth factor per retry. |
186
198
 
187
199
  It is only active for the `makora` provider, so it never interferes when you
188
200
  switch to another provider. If you also run `pi-invisible-continue`, the two
@@ -2,13 +2,24 @@
2
2
  * Death-loop guard for Makora reasoning models.
3
3
  *
4
4
  * Some Makora models (notably GLM 5.2 NVFP4 / FP8) occasionally fall into a
5
- * degenerate repetition loop, emitting an unbroken run of '!' characters
6
- * (e.g. "!!!!...") that consumes the whole response. This guard watches the
5
+ * degenerate repetition loop, emitting an unbroken run of a single character
6
+ * (observed: '!' -> "!!!!..." and '0' -> "0000...") or a repeated token
7
+ * (observed: '0' -> "0 0 0 ...") that consumes the whole response. This guard
8
+ * watches the
7
9
  * streamed assistant output (both the visible answer and the reasoning
8
10
  * trace); when that run is detected it aborts the runaway generation, drops
9
11
  * the partial (toxic) assistant message from the transcript, and resumes the
10
12
  * agentic loop invisibly via agent.prompt([]) — the same pattern
11
- * pi-invisible-continue uses, so no new user message pollutes the context.
13
+ * pi-invisible-continue / pi-retry uses, so no new user message pollutes the
14
+ * context.
15
+ *
16
+ * Infinite retries with exponential backoff. Long-horizon agent work can trip
17
+ * the loop many times in one session; capping retries would strand the agent
18
+ * mid-task. Instead the recovery loops indefinitely (like pi-retry) until the
19
+ * model produces a clean turn, the user aborts (Esc), or the session changes
20
+ * (/new, /resume). Backoff (2s→60s, 2×) paces retries and gives the user a
21
+ * window to intervene; interruptible sleep polls every 100ms so Esc and /new
22
+ * take effect within 100ms instead of waiting out the full delay.
12
23
  *
13
24
  * Why trim the aborted message: on abort, pi finalizes the in-flight
14
25
  * assistant message WITH its accumulated '!!!' content and stopReason
@@ -17,6 +28,14 @@
17
28
  * last (aborted) assistant message leaves the context ending at the prior
18
29
  * user/toolResult message — a clean continuation point.
19
30
  *
31
+ * Distinguishing our abort from a user Esc: both call agent.abort(), so both
32
+ * surface as stopReason "aborted" at turn_end. The handler sets _weAborted
33
+ * before aborting; turn_end only treats an abort as user-initiated (setting
34
+ * _userAborted to exit the loop) when _weAborted is false. Caveat: if the
35
+ * user hits Esc in the ~ms between our trip and the abort completing, the
36
+ * abort is mis-attributed to us and one unwanted retry fires. The backoff
37
+ * window lets the user Esc again, which then works normally.
38
+ *
20
39
  * Module resolution: @earendil-works/pi-agent-core is a devDependency only
21
40
  * (types + test resolution). At runtime pi's extension loader aliases that
22
41
  * specifier to its bundled copy, so the Agent class patched below is the
@@ -38,12 +57,63 @@ export const GUARDED_MODEL_IDS = new Set<string>([
38
57
  "zai-org/GLM-5.2-FP8",
39
58
  ]);
40
59
 
41
- /** Trip after this many consecutive '!' characters in the streamed answer.
42
- * 40 is far above anything normal prose or code produces. */
43
- export const BANG_THRESHOLD = 40;
60
+ /** Trip after this many consecutive identical characters in the streamed
61
+ * answer. 40 is far above anything normal prose or code produces for any
62
+ * single non-whitespace character. */
63
+ export const REPEAT_THRESHOLD = 40;
64
+
65
+ /** @deprecated alias kept for downstream forks; prefer REPEAT_THRESHOLD.
66
+ * The guard now catches runs of any non-whitespace character, not just '!'. */
67
+ export const BANG_THRESHOLD = REPEAT_THRESHOLD;
68
+
69
+ /** Characters whose repetition we never trip on. Whitespace can legitimately
70
+ * repeat (code indentation, blank lines, markdown padding), so a long
71
+ * whitespace run is not treated as a degenerate loop. Every other character
72
+ * is a candidate. Exported for tests/introspection. */
73
+ export const IGNORED_REPEAT_CHARS = new Set<string>([
74
+ " ",
75
+ "\t",
76
+ "\n",
77
+ "\r",
78
+ "\f",
79
+ "\v",
80
+ ]);
81
+
82
+ /** A trailing run of one repeated character in streamed text. `char` is ""
83
+ * when `len` === 0. */
84
+ export interface TrailingRun {
85
+ char: string;
86
+ len: number;
87
+ }
88
+
89
+ /** Trip after this many consecutive identical whitespace-delimited tokens.
90
+ * Catches spaced repetition ("0 0 0 ...", "!!!! !!!! ...") that the
91
+ * single-character run can't see (the separator resets it). 40 mirrors
92
+ * REPEAT_THRESHOLD. */
93
+ export const TOKEN_REPEAT_THRESHOLD = 40;
94
+
95
+ /** Bounded tail kept for token-run detection. Must hold >=
96
+ * TOKEN_REPEAT_THRESHOLD copies of the repeated unit; 1024 comfortably fits 40
97
+ * copies of tokens up to ~25 chars. */
98
+ export const TOKEN_REPEAT_BUFFER_CHARS = 1024;
99
+
100
+ /** Exponential backoff for recovery retries. Mirrors pi-retry's defaults:
101
+ * 2s base, 60s cap, 2× multiplier. Tunable via these constants. */
102
+ export const BACKOFF_BASE_MS = 2000;
103
+ export const BACKOFF_MAX_MS = 60_000;
104
+ export const BACKOFF_MULTIPLIER = 2;
105
+
106
+ export interface BackoffConfig {
107
+ baseDelayMs: number;
108
+ maxDelayMs: number;
109
+ multiplier: number;
110
+ }
44
111
 
45
- /** Max invisible recoveries per user prompt, to bound abort/continue thrash. */
46
- export const MAX_RECOVERS_PER_RUN = 3;
112
+ export const DEFAULT_BACKOFF_CONFIG: BackoffConfig = {
113
+ baseDelayMs: BACKOFF_BASE_MS,
114
+ maxDelayMs: BACKOFF_MAX_MS,
115
+ multiplier: BACKOFF_MULTIPLIER,
116
+ };
47
117
 
48
118
  export interface GuardedMessage {
49
119
  role: string;
@@ -60,18 +130,49 @@ export interface GuardedAgent {
60
130
 
61
131
  let _agent: GuardedAgent | null = null;
62
132
 
63
- /** Mutex held only across the abort+trim critical section — NOT across the
64
- * resumed prompt([]) run, so the resumed stream stays monitored. */
133
+ /** Mutex: only one recovery loop may be in-flight at a time. The
134
+ * message_update handler still aborts mid-stream while this is held, but
135
+ * only the loop driver re-issues prompt([]). */
65
136
  let _recovering = false;
66
137
 
67
- /** Trailing '!' run length in the current text block. */
68
- let _trailingBangs = 0;
138
+ /** Trailing run of one repeated character in the current text block. */
139
+ let _trailingRunChar = "";
140
+ let _trailingRunLen = 0;
141
+
142
+ /** Bounded trailing buffer for token-level repetition detection (e.g.
143
+ * "0 0 0 ...", "!!!! !!!! ..."). Single-char runs don't see spaced
144
+ * repetition, so we also tokenize the recent tail and count consecutive
145
+ * identical tokens. Capped to keep memory/cost bounded on long streams. */
146
+ let _tokenRepeatBuffer = "";
147
+
148
+ /** Reset all per-turn/per-block detection state. */
149
+ function resetDetection(): void {
150
+ _trailingRunChar = "";
151
+ _trailingRunLen = 0;
152
+ _tokenRepeatBuffer = "";
153
+ }
69
154
 
70
155
  /** Latch: already tripped for the current assistant message. */
71
156
  let _tripped = false;
72
157
 
73
- /** Recoveries performed in the current user-initiated agent run. */
74
- let _recoversThisRun = 0;
158
+ /** True when WE aborted the current turn (death-loop), false on user Esc.
159
+ * Reset on message_start of each new assistant turn. */
160
+ let _weAborted = false;
161
+
162
+ /** True when the user cancelled (Esc). Set in turn_end, cleared on
163
+ * session_start and fresh successful turns. Stops the recovery loop. */
164
+ let _userAborted = false;
165
+
166
+ /** Session generation counter: incremented on every session_start. The
167
+ * recovery loop captures it on entry and exits when it changes (/new,
168
+ * /resume), so a stale loop never drives a new session. */
169
+ let _sessionGeneration = 0;
170
+
171
+ /** notify() captured fresh from the most recent event ctx, since
172
+ * ctx.ui.notify isn't available inside the loop driver. Mirrors pi-retry. */
173
+ let _notifyFn:
174
+ | ((message: string, level: "info" | "warning" | "error") => void)
175
+ | null = null;
75
176
 
76
177
  export function isGuardedModel(
77
178
  model: { provider?: string; id?: string } | undefined | null,
@@ -82,17 +183,35 @@ export function isGuardedModel(
82
183
  return model.id != null && GUARDED_MODEL_IDS.has(model.id);
83
184
  }
84
185
 
85
- /** Update the trailing-'!' run length given a new text delta. O(len(delta)).
86
- * Trailing run depends only on the delta's suffix: if the delta contains any
87
- * non-'!' char, the prior run is cut off at that char; if the delta is all
88
- * '!', it extends the prior run. */
89
- export function nextTrailingBangs(prev: number, delta: string): number {
186
+ /** Update the trailing single-character run given a new text delta.
187
+ * O(len(delta)). Tracks runs of any one character (so the guard catches a
188
+ * degenerate loop regardless of which character the model fixates on),
189
+ * including whitespace -- the trip decision in isDegenerateRun filters those.
190
+ *
191
+ * Trailing run depends only on the delta's suffix: if the delta ends with a
192
+ * different character than the prior run, the prior run is cut off and the
193
+ * new run is the delta's trailing run of its last character; if the delta is
194
+ * entirely the prior run's character, it extends the prior run. */
195
+ export function nextTrailingRun(prev: TrailingRun, delta: string): TrailingRun {
90
196
  const len = delta.length;
91
197
  if (len === 0) return prev;
198
+ const lastCode = delta.charCodeAt(len - 1);
92
199
  let i = len - 1;
93
- while (i >= 0 && delta.charCodeAt(i) === 0x21) i--; // '!' === 0x21
200
+ while (i >= 0 && delta.charCodeAt(i) === lastCode) i--;
201
+ const lastChar = delta[len - 1];
94
202
  const trailingInDelta = len - 1 - i;
95
- return i >= 0 ? trailingInDelta : prev + len;
203
+ // Delta is entirely one char and it matches the prior run: extend it.
204
+ if (i < 0 && prev.len > 0 && prev.char === lastChar) {
205
+ return { char: prev.char, len: prev.len + len };
206
+ }
207
+ return { char: lastChar, len: trailingInDelta };
208
+ }
209
+
210
+ /** @deprecated '!'-only alias kept for downstream forks. Returns the trailing
211
+ * '!' run length (0 once the run switches to any other character). */
212
+ export function nextTrailingBangs(prev: number, delta: string): number {
213
+ const run = nextTrailingRun({ char: "!", len: prev }, delta);
214
+ return run.char === "!" ? run.len : 0;
96
215
  }
97
216
 
98
217
  export function extractText(content: unknown): string {
@@ -114,40 +233,214 @@ export function extractText(content: unknown): string {
114
233
  return "";
115
234
  }
116
235
 
117
- /** Trailing '!' run length of a finalized message's text content. */
118
- export function messageTrailingBangs(msg: GuardedMessage): number {
236
+ /** Trailing single-character run of a finalized message's text content. */
237
+ export function messageTrailingRun(msg: GuardedMessage): TrailingRun {
119
238
  const text = extractText(msg.content);
239
+ if (text.length === 0) return { char: "", len: 0 };
240
+ const lastCode = text.charCodeAt(text.length - 1);
120
241
  let i = text.length - 1;
121
- while (i >= 0 && text.charCodeAt(i) === 0x21) i--;
122
- return text.length - 1 - i;
242
+ while (i >= 0 && text.charCodeAt(i) === lastCode) i--;
243
+ return { char: text[text.length - 1], len: text.length - 1 - i };
244
+ }
245
+
246
+ /** @deprecated '!'-only alias kept for downstream forks. */
247
+ export function messageTrailingBangs(msg: GuardedMessage): number {
248
+ const run = messageTrailingRun(msg);
249
+ return run.char === "!" ? run.len : 0;
250
+ }
251
+
252
+ /** True when a trailing run is long enough to be a degenerate loop. Whitespace
253
+ * runs never count (see IGNORED_REPEAT_CHARS). Exported so the trip decision
254
+ * is unit-testable independently of the streaming handler. */
255
+ export function isDegenerateRun(
256
+ run: TrailingRun,
257
+ threshold: number = REPEAT_THRESHOLD,
258
+ ): boolean {
259
+ return (
260
+ run.len >= threshold &&
261
+ run.char !== "" &&
262
+ !IGNORED_REPEAT_CHARS.has(run.char)
263
+ );
264
+ }
265
+
266
+ /** A trailing run of one repeated whitespace-delimited token. `token` is ""
267
+ * when `count` === 0. */
268
+ export interface TokenRun {
269
+ token: string;
270
+ count: number;
123
271
  }
124
272
 
125
- async function recover(agent: GuardedAgent): Promise<void> {
273
+ /** Is `code` a whitespace code unit we split tokens on? Mirrors
274
+ * IGNORED_REPEAT_CHARS (space, tab, LF, CR, FF, VT). */
275
+ function isWhitespaceChar(code: number): boolean {
276
+ switch (code) {
277
+ case 0x20: case 0x09: case 0x0a: case 0x0d: case 0x0c: case 0x0b:
278
+ return true;
279
+ default:
280
+ return false;
281
+ }
282
+ }
283
+
284
+ /** Trailing run of one repeated whitespace-delimited token in `text`. Walks
285
+ * backward from the end: skips trailing whitespace, captures the last token,
286
+ * then counts consecutive preceding copies separated only by whitespace.
287
+ * O(run length * token length). Used on a bounded tail buffer, so cost stays
288
+ * bounded regardless of total stream length. */
289
+ export function trailingTokenRun(text: string): TokenRun {
290
+ const len = text.length;
291
+ let end = len;
292
+ while (end > 0 && isWhitespaceChar(text.charCodeAt(end - 1))) end--;
293
+ if (end === 0) return { token: "", count: 0 };
294
+ let start = end;
295
+ while (start > 0 && !isWhitespaceChar(text.charCodeAt(start - 1))) start--;
296
+ const token = text.slice(start, end);
297
+ let count = 1;
298
+ let i = start;
299
+ while (i > 0) {
300
+ let j = i;
301
+ while (j > 0 && isWhitespaceChar(text.charCodeAt(j - 1))) j--;
302
+ if (j === 0) break;
303
+ let prevEnd = j;
304
+ let prevStart = prevEnd;
305
+ while (prevStart > 0 && !isWhitespaceChar(text.charCodeAt(prevStart - 1))) {
306
+ prevStart--;
307
+ }
308
+ if (text.slice(prevStart, prevEnd) !== token) break;
309
+ count++;
310
+ i = prevStart;
311
+ }
312
+ return { token, count };
313
+ }
314
+
315
+ /** True when a trailing token run is long enough to be a degenerate loop.
316
+ * Tokens are non-whitespace by construction, so no whitespace exclusion is
317
+ * needed (unlike isDegenerateRun). Exported for unit testing. */
318
+ export function isDegenerateTokenRun(
319
+ run: TokenRun,
320
+ threshold: number = TOKEN_REPEAT_THRESHOLD,
321
+ ): boolean {
322
+ return run.count >= threshold && run.token !== "";
323
+ }
324
+
325
+ /** Exponential backoff delay, capped at maxDelayMs. Mirrors pi-retry. */
326
+ export function calculateDelay(
327
+ attempt: number,
328
+ config: BackoffConfig = DEFAULT_BACKOFF_CONFIG,
329
+ ): number {
330
+ const delay = config.baseDelayMs * Math.pow(config.multiplier, attempt - 1);
331
+ return Math.min(delay, config.maxDelayMs);
332
+ }
333
+
334
+ /** Format a duration for user-facing messages. Mirrors pi-retry. */
335
+ export function formatDuration(ms: number): string {
336
+ if (ms < 1000) return `${ms}ms`;
337
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
338
+ const minutes = Math.floor(ms / 60000);
339
+ const seconds = ((ms % 60000) / 1000).toFixed(0);
340
+ return `${minutes}m ${seconds}s`;
341
+ }
342
+
343
+ /** Interruptible sleep: polls _userAborted and _sessionGeneration every
344
+ * 100ms. Returns true if interrupted (Esc or /new), false if the full delay
345
+ * elapsed. Mirrors pi-retry. */
346
+ function interruptibleSleep(ms: number, generation: number): Promise<boolean> {
347
+ if (ms <= 0) return Promise.resolve(false);
348
+ return new Promise((resolve) => {
349
+ const checkInterval = 100;
350
+ let elapsed = 0;
351
+ const timer = setInterval(() => {
352
+ elapsed += checkInterval;
353
+ if (_userAborted || _sessionGeneration !== generation) {
354
+ clearInterval(timer);
355
+ resolve(true);
356
+ } else if (elapsed >= ms) {
357
+ clearInterval(timer);
358
+ resolve(false);
359
+ }
360
+ }, checkInterval);
361
+ });
362
+ }
363
+
364
+ /** Remove a trailing aborted/death-loop assistant message so the next
365
+ * prompt([]) sends a clean context ending at the prior user/toolResult. */
366
+ function trimAbortedDeathLoop(agent: GuardedAgent): void {
367
+ const msgs = agent.state.messages;
368
+ const last = msgs[msgs.length - 1];
369
+ if (
370
+ last &&
371
+ last.role === "assistant" &&
372
+ (last.stopReason === "aborted" ||
373
+ isDegenerateRun(messageTrailingRun(last)) ||
374
+ isDegenerateTokenRun(trailingTokenRun(extractText(last.content))))
375
+ ) {
376
+ agent.state.messages = msgs.slice(0, -1);
377
+ }
378
+ }
379
+
380
+ /** Recovery loop driver — the core. Loops until a clean turn, user abort,
381
+ * or session change. Backoff sleep happens AFTER each prompt([]) settles,
382
+ * so it does not block the agent; Esc and /new take effect within 100ms. */
383
+ async function triggerRecovery(): Promise<void> {
384
+ if (!_agent) return;
385
+ if (_userAborted) return;
386
+ if (_recovering) return;
126
387
  _recovering = true;
388
+
389
+ const myGeneration = _sessionGeneration;
390
+
127
391
  try {
128
- agent.abort();
129
- await agent.waitForIdle();
130
- const msgs = agent.state.messages;
131
- const last = msgs[msgs.length - 1];
132
- if (
133
- last &&
134
- last.role === "assistant" &&
135
- (last.stopReason === "aborted" || messageTrailingBangs(last) >= BANG_THRESHOLD)
136
- ) {
137
- // Drop the toxic partial so it isn't re-fed to the model on resume.
138
- // The state setter copies the array, leaving a clean transcript that
139
- // ends at the prior user/toolResult message.
140
- agent.state.messages = msgs.slice(0, -1);
392
+ await _agent.waitForIdle();
393
+ if (_userAborted || _sessionGeneration !== myGeneration) return;
394
+
395
+ // Trim the aborted !!! message from the initial trip.
396
+ trimAbortedDeathLoop(_agent);
397
+
398
+ let attempt = 0;
399
+ // Loop until success, user abort, or session change.
400
+ while (true) {
401
+ if (_userAborted || _sessionGeneration !== myGeneration) return;
402
+
403
+ attempt++;
404
+ const delay = calculateDelay(attempt);
405
+ if (_notifyFn) {
406
+ _notifyFn(
407
+ `Makora death-loop guard: resuming after runaway repeated-character ` +
408
+ `output (retry ${attempt}, backoff ${formatDuration(delay)})...`,
409
+ "warning",
410
+ );
411
+ }
412
+
413
+ // Interruptible sleep with backoff BEFORE the retry. Lets Esc and
414
+ // /new take effect within 100ms instead of waiting the full delay.
415
+ const interrupted = await interruptibleSleep(delay, myGeneration);
416
+ if (interrupted) return;
417
+ if (_userAborted || _sessionGeneration !== myGeneration) return;
418
+
419
+ // _weAborted is also reset on message_start, but reset here too in
420
+ // case message_start already fired before we reached this point.
421
+ _weAborted = false;
422
+ try {
423
+ await _agent.prompt([]);
424
+ } catch {
425
+ // "Agent is already processing" or other transient error — bail.
426
+ return;
427
+ }
428
+ if (_userAborted || _sessionGeneration !== myGeneration) return;
429
+
430
+ // Did the resumed turn death-loop again? If we aborted it, trim and
431
+ // loop; otherwise it completed cleanly (or the user aborted) — exit.
432
+ if (_weAborted) {
433
+ trimAbortedDeathLoop(_agent);
434
+ continue;
435
+ }
436
+ return;
141
437
  }
142
438
  } finally {
143
- // Release before resuming so the resumed stream stays monitored.
144
- _recovering = false;
145
- }
146
- try {
147
- // Invisible continue: fresh agent loop, no new message injected.
148
- await agent.prompt([]);
149
- } catch {
150
- // "Agent is already processing" or other transient error — best effort.
439
+ // Only release the mutex if the session hasn't changed. If /new fired,
440
+ // a new recovery loop may already own it — resetting here would clobber.
441
+ if (_sessionGeneration === myGeneration) {
442
+ _recovering = false;
443
+ }
151
444
  }
152
445
  }
153
446
 
@@ -155,7 +448,7 @@ export function registerDeathLoopGuard(pi: ExtensionAPI): void {
155
448
  // Capture the live Agent instance by chaining Agent.prototype.subscribe.
156
449
  // subscribe() fires when AgentSession attaches — on every fresh session
157
450
  // and every resume — so _agent always points at the active Agent. Chain
158
- // any prior patch (e.g. pi-invisible-continue) so both extensions coexist.
451
+ // any prior patch (e.g. pi-invisible-continue, pi-retry) so all coexist.
159
452
  const proto = Agent.prototype as unknown as {
160
453
  subscribe: (this: GuardedAgent, ...args: unknown[]) => unknown;
161
454
  };
@@ -166,35 +459,45 @@ export function registerDeathLoopGuard(pi: ExtensionAPI): void {
166
459
  };
167
460
 
168
461
  pi.on("session_start", () => {
462
+ // Bump generation so any in-flight recovery loop from a previous session
463
+ // exits within 100ms (during backoff) or right after prompt([]) returns.
464
+ _sessionGeneration++;
169
465
  _recovering = false;
170
- _trailingBangs = 0;
466
+ resetDetection();
171
467
  _tripped = false;
172
- _recoversThisRun = 0;
468
+ _weAborted = false;
469
+ _userAborted = false;
173
470
  });
174
471
 
175
472
  // before_agent_start fires only for user prompts (the AgentSession path),
176
- // not for the recovery's direct agent.prompt([]), so the recovery cap is
177
- // bounded per user prompt instead of reset on every recovery continuation.
473
+ // not for the recovery's direct agent.prompt([]), so these resets bound
474
+ // detection to the current user prompt without clearing mid-recovery.
178
475
  pi.on("before_agent_start", () => {
179
- _recovering = false;
180
- _trailingBangs = 0;
476
+ resetDetection();
181
477
  _tripped = false;
182
- _recoversThisRun = 0;
478
+ _weAborted = false;
479
+ // A new user prompt is fresh activity — clear a stale user-abort flag.
480
+ _userAborted = false;
183
481
  });
184
482
 
185
483
  pi.on("message_start", (event) => {
186
484
  if (event.message?.role === "assistant") {
187
- _trailingBangs = 0;
485
+ resetDetection();
188
486
  _tripped = false;
487
+ _weAborted = false;
189
488
  }
190
489
  });
191
490
 
192
491
  pi.on("message_update", (event, ctx) => {
492
+ // Refresh notify on every handler so it stays current after session
493
+ // switches (a stale ctx goes invalid). Same approach as pi-retry.
494
+ _notifyFn = (message, level) => ctx.ui.notify(message, level);
495
+
193
496
  if (_recovering || _tripped) return;
194
497
  const ame = event.assistantMessageEvent;
195
498
  if (ame.type === "text_start" || ame.type === "thinking_start") {
196
- // New content block (answer or reasoning) — trailing run starts fresh.
197
- _trailingBangs = 0;
499
+ // New content block (answer or reasoning) — detection starts fresh.
500
+ resetDetection();
198
501
  return;
199
502
  }
200
503
  // Watch both the visible answer and the reasoning trace: GLM 5.2 is a
@@ -202,37 +505,60 @@ export function registerDeathLoopGuard(pi: ExtensionAPI): void {
202
505
  if (ame.type !== "text_delta" && ame.type !== "thinking_delta") return;
203
506
  if (!isGuardedModel(ctx.model)) return;
204
507
 
205
- _trailingBangs = nextTrailingBangs(_trailingBangs, ame.delta);
206
- if (_trailingBangs < BANG_THRESHOLD) return;
508
+ const run = nextTrailingRun(
509
+ { char: _trailingRunChar, len: _trailingRunLen },
510
+ ame.delta,
511
+ );
512
+ _trailingRunChar = run.char;
513
+ _trailingRunLen = run.len;
207
514
 
208
- if (_recoversThisRun >= MAX_RECOVERS_PER_RUN) {
209
- _tripped = true;
210
- ctx.ui.notify(
211
- "Makora death-loop guard: runaway '!' output detected, but the " +
212
- "recovery limit for this prompt was reached — stopping " +
213
- "intervention. Try /continue or rephrase.",
214
- "warning",
215
- );
216
- return;
217
- }
515
+ // Token-level repetition: a spaced loop like "0 0 0 ..." or "!!!! !!!!"
516
+ // never builds a single-char run (the separator resets it), so track the
517
+ // recent tail as whitespace-delimited tokens and count consecutive copies.
518
+ _tokenRepeatBuffer = (_tokenRepeatBuffer + ame.delta).slice(
519
+ -TOKEN_REPEAT_BUFFER_CHARS,
520
+ );
521
+ const tokenRun = trailingTokenRun(_tokenRepeatBuffer);
522
+
523
+ if (!isDegenerateRun(run) && !isDegenerateTokenRun(tokenRun)) return;
524
+
525
+ // Trip: abort the runaway stream and kick off the recovery loop (once,
526
+ // mutex-gated). The loop driver handles re-detection on resumed turns.
218
527
  _tripped = true;
219
- _recoversThisRun++;
528
+ _weAborted = true;
220
529
  const agent = _agent;
221
530
  if (!agent) {
222
531
  ctx.ui.notify(
223
- "Makora death-loop guard: runaway '!' output detected but the Agent " +
224
- "instance was not captured; cannot recover automatically.",
532
+ "Makora death-loop guard: runaway repeated-character output detected " +
533
+ "but the Agent instance was not captured; cannot recover automatically.",
225
534
  "warning",
226
535
  );
227
536
  return;
228
537
  }
229
- ctx.ui.notify(
230
- `Makora death-loop guard: aborting runaway '!' output and resuming ` +
231
- `(${_recoversThisRun}/${MAX_RECOVERS_PER_RUN}).`,
232
- "warning",
233
- );
234
- // Detach: the handler must return so the run can unwind to idle before
235
- // recover() awaits waitForIdle() and calls prompt([]).
236
- void recover(agent);
538
+ agent.abort();
539
+ // Detach: the handler must return so the run unwinds to idle before the
540
+ // loop driver awaits waitForIdle(). Only kick off the loop if it isn't
541
+ // already running; otherwise the running loop catches this abort.
542
+ if (!_recovering) {
543
+ void triggerRecovery();
544
+ }
545
+ });
546
+
547
+ // Detect user aborts via turn_end. Our own death-loop abort also surfaces
548
+ // as stopReason "aborted", so _weAborted gates this: only a non-we-aborted
549
+ // aborted turn is treated as a user Esc and stops the recovery loop.
550
+ pi.on("turn_end", (event) => {
551
+ const msg = event.message as GuardedMessage | undefined;
552
+ if (msg?.role === "assistant" && msg.stopReason === "aborted" && !_weAborted) {
553
+ _userAborted = true;
554
+ }
555
+ });
556
+
557
+ // Also refresh notify on turn_end so the loop driver has a fresh fn even
558
+ // if message_update hasn't fired yet this session.
559
+ pi.on("turn_end", (_event, ctx) => {
560
+ if (!_notifyFn) {
561
+ _notifyFn = (message, level) => ctx.ui.notify(message, level);
562
+ }
237
563
  });
238
564
  }
package/index.ts CHANGED
@@ -22,9 +22,11 @@
22
22
  *
23
23
  * A death-loop guard (see ./death-loop-guard.ts) is registered alongside the
24
24
  * provider. It watches the assistant text stream on the GLM 5.2 family and,
25
- * if the model falls into an unbroken '!' repetition loop, aborts the runaway
26
- * generation and resumes the agentic loop invisibly via agent.prompt([]) (the
27
- * pi-invisible-continue pattern) — no new user message is injected.
25
+ * if the model falls into a degenerate repetition loop — an unbroken run of a
26
+ * single character or token, spaced or not (observed: `!!!!`, `0000`, `0 0 0`)
27
+ * — it aborts the runaway generation and resumes the agentic loop invisibly
28
+ * via agent.prompt([]) (the pi-invisible-continue pattern); no new user
29
+ * message is injected.
28
30
  *
29
31
  * Developer role is NOT supported by any of the chat templates on Makora's
30
32
  * vLLM deployment (prompts with role: "developer" are silently dropped).
@@ -213,7 +215,8 @@ export default function (pi: ExtensionAPI) {
213
215
  models,
214
216
  });
215
217
 
216
- // Abort runaway '!' repetition loops on the GLM 5.2 family and resume the
218
+ // Abort runaway repetition loops — a single character or token, spaced or
219
+ // not (e.g. '!!!!', '0000', '0 0 0') — on the GLM 5.2 family and resume the
217
220
  // agentic loop invisibly (no new user message). See ./death-loop-guard.ts.
218
221
  registerDeathLoopGuard(pi);
219
222
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-makora-provider",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Makora provider extension for pi - Access DeepSeek V4, GLM 5.2, Kimi K2.7 Code, Llama 3.3, Qwen 3.6, and more through the Makora inference API",
5
5
  "type": "module",
6
6
  "main": "index.ts",