pi-makora-provider 1.4.0 → 1.5.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
@@ -20,6 +20,7 @@ _DeepSeek V4, Kimi K2.7 Code, GLM 5.2, Qwen 3.6 for [pi](https://github.com/eare
20
20
  |-------|----|-----------|-------|
21
21
  | DeepSeek V4 Flash | `deepseek-ai/DeepSeek-V4-Flash` | Yes | returns `reasoning` field |
22
22
  | DeepSeek V4 Pro | `deepseek-ai/DeepSeek-V4-Pro` | Yes | returns `reasoning` field |
23
+ | Gemma 4 26B A4B | `google/gemma-4-26B-A4B` | No | |
23
24
  | GLM 5.2 FP8 | `zai-org/GLM-5.2-FP8` | Yes | `enable_thinking` via `qwen-chat-template`; effort via `reasoning_effort` (only `high`/`max` distinct, per vLLM GLM-5.2 recipe); thinking levels aligned with neuralwatt GLM 5.2; returns `reasoning` field |
24
25
  | GLM 5.2 NVFP4 | `zai-org/GLM-5.2-NVFP4` | Yes | `enable_thinking` via `qwen-chat-template`; effort via `reasoning_effort` (only `high`/`max` distinct, per vLLM GLM-5.2 recipe); returns `reasoning` field |
25
26
  | Kimi K2.7 Code | `moonshotai/Kimi-K2.7-Code` | Yes | Reasoning on by default (thinking-only model); `preserve_thinking` via `chatTemplateKwargs` for multi-turn continuity; returns `reasoning` field |
@@ -156,3 +157,33 @@ Do **not** edit `models.json` directly — it is auto-generated from the API. To
156
157
  - The API is OpenAI-compatible (chat completions format)
157
158
  - All models are hosted on vLLM
158
159
  - The `developer` role is not supported (prompts are silently dropped); `supportsDeveloperRole` is set to `false` for all models
160
+
161
+ ## Death-Loop Guard
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
167
+ the runaway generation and resumes the agentic loop invisibly** — no new user
168
+ message is injected, using the
169
+ [pi-invisible-continue](https://github.com/monotykamary/pi-invisible-continue)
170
+ pattern (`agent.prompt([])`).
171
+
172
+ 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.
177
+
178
+ The guard is scoped to the Makora GLM 5.2 family by default and is tunable via
179
+ constants at the top of [`death-loop-guard.ts`](./death-loop-guard.ts):
180
+
181
+ | Constant | Default | Meaning |
182
+ |---|---|---|
183
+ | `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. |
186
+
187
+ It is only active for the `makora` provider, so it never interferes when you
188
+ switch to another provider. If you also run `pi-invisible-continue`, the two
189
+ coexist — both chain the `Agent.prototype.subscribe` patch.
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Death-loop guard for Makora reasoning models.
3
+ *
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
7
+ * streamed assistant output (both the visible answer and the reasoning
8
+ * trace); when that run is detected it aborts the runaway generation, drops
9
+ * the partial (toxic) assistant message from the transcript, and resumes the
10
+ * agentic loop invisibly via agent.prompt([]) — the same pattern
11
+ * pi-invisible-continue uses, so no new user message pollutes the context.
12
+ *
13
+ * Why trim the aborted message: on abort, pi finalizes the in-flight
14
+ * assistant message WITH its accumulated '!!!' content and stopReason
15
+ * "aborted" into the transcript. Resuming from that context would re-feed
16
+ * the toxic text to the model and likely re-trigger the loop. Dropping the
17
+ * last (aborted) assistant message leaves the context ending at the prior
18
+ * user/toolResult message — a clean continuation point.
19
+ *
20
+ * Module resolution: @earendil-works/pi-agent-core is a devDependency only
21
+ * (types + test resolution). At runtime pi's extension loader aliases that
22
+ * specifier to its bundled copy, so the Agent class patched below is the
23
+ * SAME class AgentSession uses. A static import is required — jiti's alias
24
+ * applies to static imports (which it rewrites to its own resolver) but not
25
+ * to native dynamic import() calls.
26
+ */
27
+
28
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
29
+ import { Agent } from "@earendil-works/pi-agent-core";
30
+
31
+ const PROVIDER_ID = "makora";
32
+
33
+ /** Makora model IDs to guard. Add ids to widen coverage, or include "*"
34
+ * to guard every Makora model. Defaults to the GLM 5.2 family (the known
35
+ * offender). Kept exported so tests and downstream forks can introspect. */
36
+ export const GUARDED_MODEL_IDS = new Set<string>([
37
+ "zai-org/GLM-5.2-NVFP4",
38
+ "zai-org/GLM-5.2-FP8",
39
+ ]);
40
+
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;
44
+
45
+ /** Max invisible recoveries per user prompt, to bound abort/continue thrash. */
46
+ export const MAX_RECOVERS_PER_RUN = 3;
47
+
48
+ export interface GuardedMessage {
49
+ role: string;
50
+ stopReason?: string;
51
+ content?: unknown;
52
+ }
53
+
54
+ export interface GuardedAgent {
55
+ abort(): void;
56
+ waitForIdle(): Promise<void>;
57
+ prompt(input: unknown[] | string): Promise<void>;
58
+ state: { messages: GuardedMessage[] };
59
+ }
60
+
61
+ let _agent: GuardedAgent | null = null;
62
+
63
+ /** Mutex held only across the abort+trim critical section — NOT across the
64
+ * resumed prompt([]) run, so the resumed stream stays monitored. */
65
+ let _recovering = false;
66
+
67
+ /** Trailing '!' run length in the current text block. */
68
+ let _trailingBangs = 0;
69
+
70
+ /** Latch: already tripped for the current assistant message. */
71
+ let _tripped = false;
72
+
73
+ /** Recoveries performed in the current user-initiated agent run. */
74
+ let _recoversThisRun = 0;
75
+
76
+ export function isGuardedModel(
77
+ model: { provider?: string; id?: string } | undefined | null,
78
+ ): boolean {
79
+ if (!model) return false;
80
+ if (model.provider !== PROVIDER_ID) return false;
81
+ if (GUARDED_MODEL_IDS.has("*")) return true;
82
+ return model.id != null && GUARDED_MODEL_IDS.has(model.id);
83
+ }
84
+
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 {
90
+ const len = delta.length;
91
+ if (len === 0) return prev;
92
+ let i = len - 1;
93
+ while (i >= 0 && delta.charCodeAt(i) === 0x21) i--; // '!' === 0x21
94
+ const trailingInDelta = len - 1 - i;
95
+ return i >= 0 ? trailingInDelta : prev + len;
96
+ }
97
+
98
+ export function extractText(content: unknown): string {
99
+ if (typeof content === "string") return content;
100
+ if (Array.isArray(content)) {
101
+ let out = "";
102
+ for (const block of content) {
103
+ if (
104
+ block &&
105
+ typeof block === "object" &&
106
+ (block as { type?: string }).type === "text" &&
107
+ typeof (block as { text?: unknown }).text === "string"
108
+ ) {
109
+ out += (block as { text: string }).text;
110
+ }
111
+ }
112
+ return out;
113
+ }
114
+ return "";
115
+ }
116
+
117
+ /** Trailing '!' run length of a finalized message's text content. */
118
+ export function messageTrailingBangs(msg: GuardedMessage): number {
119
+ const text = extractText(msg.content);
120
+ let i = text.length - 1;
121
+ while (i >= 0 && text.charCodeAt(i) === 0x21) i--;
122
+ return text.length - 1 - i;
123
+ }
124
+
125
+ async function recover(agent: GuardedAgent): Promise<void> {
126
+ _recovering = true;
127
+ 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);
141
+ }
142
+ } 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.
151
+ }
152
+ }
153
+
154
+ export function registerDeathLoopGuard(pi: ExtensionAPI): void {
155
+ // Capture the live Agent instance by chaining Agent.prototype.subscribe.
156
+ // subscribe() fires when AgentSession attaches — on every fresh session
157
+ // 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.
159
+ const proto = Agent.prototype as unknown as {
160
+ subscribe: (this: GuardedAgent, ...args: unknown[]) => unknown;
161
+ };
162
+ const origSubscribe = proto.subscribe;
163
+ proto.subscribe = function (this: GuardedAgent, ...args: unknown[]) {
164
+ _agent = this;
165
+ return origSubscribe.apply(this, args);
166
+ };
167
+
168
+ pi.on("session_start", () => {
169
+ _recovering = false;
170
+ _trailingBangs = 0;
171
+ _tripped = false;
172
+ _recoversThisRun = 0;
173
+ });
174
+
175
+ // 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.
178
+ pi.on("before_agent_start", () => {
179
+ _recovering = false;
180
+ _trailingBangs = 0;
181
+ _tripped = false;
182
+ _recoversThisRun = 0;
183
+ });
184
+
185
+ pi.on("message_start", (event) => {
186
+ if (event.message?.role === "assistant") {
187
+ _trailingBangs = 0;
188
+ _tripped = false;
189
+ }
190
+ });
191
+
192
+ pi.on("message_update", (event, ctx) => {
193
+ if (_recovering || _tripped) return;
194
+ const ame = event.assistantMessageEvent;
195
+ if (ame.type === "text_start" || ame.type === "thinking_start") {
196
+ // New content block (answer or reasoning) — trailing run starts fresh.
197
+ _trailingBangs = 0;
198
+ return;
199
+ }
200
+ // Watch both the visible answer and the reasoning trace: GLM 5.2 is a
201
+ // reasoning model, and the loop can surface in either.
202
+ if (ame.type !== "text_delta" && ame.type !== "thinking_delta") return;
203
+ if (!isGuardedModel(ctx.model)) return;
204
+
205
+ _trailingBangs = nextTrailingBangs(_trailingBangs, ame.delta);
206
+ if (_trailingBangs < BANG_THRESHOLD) return;
207
+
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
+ }
218
+ _tripped = true;
219
+ _recoversThisRun++;
220
+ const agent = _agent;
221
+ if (!agent) {
222
+ ctx.ui.notify(
223
+ "Makora death-loop guard: runaway '!' output detected but the Agent " +
224
+ "instance was not captured; cannot recover automatically.",
225
+ "warning",
226
+ );
227
+ return;
228
+ }
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);
237
+ });
238
+ }
package/index.ts CHANGED
@@ -20,6 +20,12 @@
20
20
  * - Qwen 3.6 models: returns `reasoning` field.
21
21
  * - Llama 3.3 70B: not a reasoning model.
22
22
  *
23
+ * A death-loop guard (see ./death-loop-guard.ts) is registered alongside the
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.
28
+ *
23
29
  * Developer role is NOT supported by any of the chat templates on Makora's
24
30
  * vLLM deployment (prompts with role: "developer" are silently dropped).
25
31
  * supportsDeveloperRole is set to false for all models.
@@ -42,6 +48,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
42
48
  import modelsData from "./models.json" with { type: "json" };
43
49
  import customModelsData from "./custom-models.json" with { type: "json" };
44
50
  import patchData from "./patch.json" with { type: "json" };
51
+ import { registerDeathLoopGuard } from "./death-loop-guard.js";
45
52
 
46
53
  // Types
47
54
 
@@ -205,4 +212,8 @@ export default function (pi: ExtensionAPI) {
205
212
  api: "openai-completions",
206
213
  models,
207
214
  });
215
+
216
+ // Abort runaway '!' repetition loops on the GLM 5.2 family and resume the
217
+ // agentic loop invisibly (no new user message). See ./death-loop-guard.ts.
218
+ registerDeathLoopGuard(pi);
208
219
  }
package/models.json CHANGED
@@ -41,6 +41,27 @@
41
41
  "maxTokensField": "max_completion_tokens"
42
42
  }
43
43
  },
44
+ {
45
+ "id": "google/gemma-4-26B-A4B",
46
+ "name": "Gemma 4 26B A4B",
47
+ "reasoning": false,
48
+ "input": [
49
+ "text"
50
+ ],
51
+ "cost": {
52
+ "input": 0,
53
+ "output": 0,
54
+ "cacheRead": 0,
55
+ "cacheWrite": 0
56
+ },
57
+ "contextWindow": 262144,
58
+ "maxTokens": 0,
59
+ "compat": {
60
+ "supportsDeveloperRole": false,
61
+ "supportsStore": false,
62
+ "maxTokensField": "max_completion_tokens"
63
+ }
64
+ },
44
65
  {
45
66
  "id": "meta-llama/Llama-3.3-70B-Instruct",
46
67
  "name": "Llama 3.3 70B Instruct",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-makora-provider",
3
- "version": "1.4.0",
3
+ "version": "1.5.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",
@@ -21,6 +21,7 @@
21
21
  "license": "MIT",
22
22
  "files": [
23
23
  "index.ts",
24
+ "death-loop-guard.ts",
24
25
  "models.json",
25
26
  "custom-models.json",
26
27
  "patch.json",
@@ -33,6 +34,8 @@
33
34
  ]
34
35
  },
35
36
  "devDependencies": {
37
+ "@earendil-works/pi-agent-core": "^0.80.2",
38
+ "@earendil-works/pi-coding-agent": "^0.80.2",
36
39
  "vitest": "^4.1.9"
37
40
  },
38
41
  "scripts": {