pi-makora-provider 1.6.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,19 +160,21 @@ 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. Recovery retries **indefinitely with exponential backoff (2s→60s,
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,
176
178
  2×)**, like [pi-retry](https://github.com/monotykamary/pi-retry) — long-horizon
177
179
  agent work can trip the loop many times in a session, so a retry cap would
178
180
  strand the agent mid-task. The loop exits only on a clean turn, a user abort
@@ -186,7 +188,10 @@ constants at the top of [`death-loop-guard.ts`](./death-loop-guard.ts):
186
188
  | Constant | Default | Meaning |
187
189
  |---|---|---|
188
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. |
189
- | `BANG_THRESHOLD` | `40` | Consecutive `!` characters that trip the guard. 40 is far above anything normal prose/code produces. |
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. |
190
195
  | `BACKOFF_BASE_MS` | `2000` | Initial recovery backoff delay. |
191
196
  | `BACKOFF_MAX_MS` | `60000` | Maximum backoff delay (cap). |
192
197
  | `BACKOFF_MULTIPLIER` | `2` | Backoff growth factor per retry. |
@@ -2,8 +2,10 @@
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
@@ -55,9 +57,45 @@ export const GUARDED_MODEL_IDS = new Set<string>([
55
57
  "zai-org/GLM-5.2-FP8",
56
58
  ]);
57
59
 
58
- /** Trip after this many consecutive '!' characters in the streamed answer.
59
- * 40 is far above anything normal prose or code produces. */
60
- 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;
61
99
 
62
100
  /** Exponential backoff for recovery retries. Mirrors pi-retry's defaults:
63
101
  * 2s base, 60s cap, 2× multiplier. Tunable via these constants. */
@@ -97,8 +135,22 @@ let _agent: GuardedAgent | null = null;
97
135
  * only the loop driver re-issues prompt([]). */
98
136
  let _recovering = false;
99
137
 
100
- /** Trailing '!' run length in the current text block. */
101
- 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
+ }
102
154
 
103
155
  /** Latch: already tripped for the current assistant message. */
104
156
  let _tripped = false;
@@ -131,17 +183,35 @@ export function isGuardedModel(
131
183
  return model.id != null && GUARDED_MODEL_IDS.has(model.id);
132
184
  }
133
185
 
134
- /** Update the trailing-'!' run length given a new text delta. O(len(delta)).
135
- * Trailing run depends only on the delta's suffix: if the delta contains any
136
- * non-'!' char, the prior run is cut off at that char; if the delta is all
137
- * '!', it extends the prior run. */
138
- 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 {
139
196
  const len = delta.length;
140
197
  if (len === 0) return prev;
198
+ const lastCode = delta.charCodeAt(len - 1);
141
199
  let i = len - 1;
142
- while (i >= 0 && delta.charCodeAt(i) === 0x21) i--; // '!' === 0x21
200
+ while (i >= 0 && delta.charCodeAt(i) === lastCode) i--;
201
+ const lastChar = delta[len - 1];
143
202
  const trailingInDelta = len - 1 - i;
144
- 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;
145
215
  }
146
216
 
147
217
  export function extractText(content: unknown): string {
@@ -163,12 +233,93 @@ export function extractText(content: unknown): string {
163
233
  return "";
164
234
  }
165
235
 
166
- /** Trailing '!' run length of a finalized message's text content. */
167
- 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 {
168
238
  const text = extractText(msg.content);
239
+ if (text.length === 0) return { char: "", len: 0 };
240
+ const lastCode = text.charCodeAt(text.length - 1);
169
241
  let i = text.length - 1;
170
- while (i >= 0 && text.charCodeAt(i) === 0x21) i--;
171
- 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;
271
+ }
272
+
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 !== "";
172
323
  }
173
324
 
174
325
  /** Exponential backoff delay, capped at maxDelayMs. Mirrors pi-retry. */
@@ -219,7 +370,8 @@ function trimAbortedDeathLoop(agent: GuardedAgent): void {
219
370
  last &&
220
371
  last.role === "assistant" &&
221
372
  (last.stopReason === "aborted" ||
222
- messageTrailingBangs(last) >= BANG_THRESHOLD)
373
+ isDegenerateRun(messageTrailingRun(last)) ||
374
+ isDegenerateTokenRun(trailingTokenRun(extractText(last.content))))
223
375
  ) {
224
376
  agent.state.messages = msgs.slice(0, -1);
225
377
  }
@@ -252,8 +404,8 @@ async function triggerRecovery(): Promise<void> {
252
404
  const delay = calculateDelay(attempt);
253
405
  if (_notifyFn) {
254
406
  _notifyFn(
255
- `Makora death-loop guard: resuming after runaway '!' output ` +
256
- `(retry ${attempt}, backoff ${formatDuration(delay)})...`,
407
+ `Makora death-loop guard: resuming after runaway repeated-character ` +
408
+ `output (retry ${attempt}, backoff ${formatDuration(delay)})...`,
257
409
  "warning",
258
410
  );
259
411
  }
@@ -311,7 +463,7 @@ export function registerDeathLoopGuard(pi: ExtensionAPI): void {
311
463
  // exits within 100ms (during backoff) or right after prompt([]) returns.
312
464
  _sessionGeneration++;
313
465
  _recovering = false;
314
- _trailingBangs = 0;
466
+ resetDetection();
315
467
  _tripped = false;
316
468
  _weAborted = false;
317
469
  _userAborted = false;
@@ -321,7 +473,7 @@ export function registerDeathLoopGuard(pi: ExtensionAPI): void {
321
473
  // not for the recovery's direct agent.prompt([]), so these resets bound
322
474
  // detection to the current user prompt without clearing mid-recovery.
323
475
  pi.on("before_agent_start", () => {
324
- _trailingBangs = 0;
476
+ resetDetection();
325
477
  _tripped = false;
326
478
  _weAborted = false;
327
479
  // A new user prompt is fresh activity — clear a stale user-abort flag.
@@ -330,7 +482,7 @@ export function registerDeathLoopGuard(pi: ExtensionAPI): void {
330
482
 
331
483
  pi.on("message_start", (event) => {
332
484
  if (event.message?.role === "assistant") {
333
- _trailingBangs = 0;
485
+ resetDetection();
334
486
  _tripped = false;
335
487
  _weAborted = false;
336
488
  }
@@ -344,8 +496,8 @@ export function registerDeathLoopGuard(pi: ExtensionAPI): void {
344
496
  if (_recovering || _tripped) return;
345
497
  const ame = event.assistantMessageEvent;
346
498
  if (ame.type === "text_start" || ame.type === "thinking_start") {
347
- // New content block (answer or reasoning) — trailing run starts fresh.
348
- _trailingBangs = 0;
499
+ // New content block (answer or reasoning) — detection starts fresh.
500
+ resetDetection();
349
501
  return;
350
502
  }
351
503
  // Watch both the visible answer and the reasoning trace: GLM 5.2 is a
@@ -353,8 +505,22 @@ export function registerDeathLoopGuard(pi: ExtensionAPI): void {
353
505
  if (ame.type !== "text_delta" && ame.type !== "thinking_delta") return;
354
506
  if (!isGuardedModel(ctx.model)) return;
355
507
 
356
- _trailingBangs = nextTrailingBangs(_trailingBangs, ame.delta);
357
- 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;
514
+
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;
358
524
 
359
525
  // Trip: abort the runaway stream and kick off the recovery loop (once,
360
526
  // mutex-gated). The loop driver handles re-detection on resumed turns.
@@ -363,8 +529,8 @@ export function registerDeathLoopGuard(pi: ExtensionAPI): void {
363
529
  const agent = _agent;
364
530
  if (!agent) {
365
531
  ctx.ui.notify(
366
- "Makora death-loop guard: runaway '!' output detected but the Agent " +
367
- "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.",
368
534
  "warning",
369
535
  );
370
536
  return;
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.6.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",