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 +22 -10
- package/death-loop-guard.ts +407 -81
- package/index.ts +7 -4
- package/package.json +1 -1
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
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
174
|
-
before resuming — otherwise the model would just re-read its
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
| `
|
|
185
|
-
| `
|
|
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
|
package/death-loop-guard.ts
CHANGED
|
@@ -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
|
|
6
|
-
* (
|
|
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
|
|
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
|
|
42
|
-
* 40 is far above anything normal prose or code produces
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
|
64
|
-
*
|
|
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
|
|
68
|
-
let
|
|
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
|
-
/**
|
|
74
|
-
|
|
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-
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
|
|
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) ===
|
|
200
|
+
while (i >= 0 && delta.charCodeAt(i) === lastCode) i--;
|
|
201
|
+
const lastChar = delta[len - 1];
|
|
94
202
|
const trailingInDelta = len - 1 - i;
|
|
95
|
-
|
|
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
|
|
118
|
-
export function
|
|
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) ===
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
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
|
-
|
|
466
|
+
resetDetection();
|
|
171
467
|
_tripped = false;
|
|
172
|
-
|
|
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
|
|
177
|
-
//
|
|
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
|
-
|
|
180
|
-
_trailingBangs = 0;
|
|
476
|
+
resetDetection();
|
|
181
477
|
_tripped = false;
|
|
182
|
-
|
|
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
|
-
|
|
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) —
|
|
197
|
-
|
|
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
|
-
|
|
206
|
-
|
|
508
|
+
const run = nextTrailingRun(
|
|
509
|
+
{ char: _trailingRunChar, len: _trailingRunLen },
|
|
510
|
+
ame.delta,
|
|
511
|
+
);
|
|
512
|
+
_trailingRunChar = run.char;
|
|
513
|
+
_trailingRunLen = run.len;
|
|
207
514
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
528
|
+
_weAborted = true;
|
|
220
529
|
const agent = _agent;
|
|
221
530
|
if (!agent) {
|
|
222
531
|
ctx.ui.notify(
|
|
223
|
-
"Makora death-loop guard: runaway
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
)
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
|
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.
|
|
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",
|