pi-mega-compact 0.11.3 → 0.11.4
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/dist/extensions/mega-compact-s38.test.js +577 -14
- package/dist/extensions/mega-config.js +1 -0
- package/dist/extensions/mega-events/agent-handlers.js +22 -2
- package/dist/extensions/mega-events/error-classifier.js +106 -127
- package/dist/extensions/mega-events/outage-advisor.js +53 -0
- package/dist/extensions/mega-runtime/reset-runtime.js +1 -0
- package/dist/extensions/mega-runtime/runtime.js +1 -0
- package/extensions/mega-compact-s38.test.ts +575 -14
- package/extensions/mega-config.ts +8 -0
- package/extensions/mega-events/agent-handlers.ts +24 -1
- package/extensions/mega-events/error-classifier.ts +145 -124
- package/extensions/mega-events/outage-advisor.ts +73 -0
- package/extensions/mega-runtime/helpers.ts +2 -0
- package/extensions/mega-runtime/reset-runtime.ts +1 -0
- package/extensions/mega-runtime/runtime.ts +1 -0
- package/package.json +1 -1
|
@@ -107,6 +107,10 @@ export interface MegaConfig {
|
|
|
107
107
|
* classification is upgraded to 'poisoned-context' (the stateful repeat
|
|
108
108
|
* signal). Default 3. Raise to make the upgrade less aggressive. */
|
|
109
109
|
poisonedContextRepeatThreshold: number;
|
|
110
|
+
/** R10: consecutive transient errors at which a calm "provider outage"
|
|
111
|
+
* advisory is sent to the user (distinct from the poisoned /clear advise).
|
|
112
|
+
* Default 3. `0` disables the advisory entirely. */
|
|
113
|
+
providerOutageAdviseThreshold: number;
|
|
110
114
|
/** S29: override the auto-compact fire point for tiered configs, as a
|
|
111
115
|
* fraction of the context window (e.g. 0.85). null = inherit the tier's
|
|
112
116
|
* tierPct (default; preserves existing fire points). The context-handler
|
|
@@ -298,6 +302,10 @@ export function loadConfig(): MegaConfig {
|
|
|
298
302
|
"MEGACOMPACT_POISONED_REPEAT_THRESHOLD",
|
|
299
303
|
3,
|
|
300
304
|
),
|
|
305
|
+
providerOutageAdviseThreshold: envFlag(
|
|
306
|
+
"MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD",
|
|
307
|
+
3,
|
|
308
|
+
),
|
|
301
309
|
autoPctTrigger,
|
|
302
310
|
autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
|
|
303
311
|
dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
|
|
@@ -18,11 +18,13 @@ import { isMegaCache } from "../../src/game/scoring.js";
|
|
|
18
18
|
import { resolveRepoRoot } from "../mega-config.js";
|
|
19
19
|
import {
|
|
20
20
|
classifyError,
|
|
21
|
+
classifyErrorDetailed,
|
|
21
22
|
errorRetryBackoffMs,
|
|
22
23
|
extractErrorSignature,
|
|
23
24
|
isKnownRetryableTransient,
|
|
24
25
|
} from "./error-classifier.js";
|
|
25
26
|
import { safeSendUserMessage } from "./send-safe.js";
|
|
27
|
+
import { maybeSendProviderOutageAdvisory } from "./outage-advisor.js";
|
|
26
28
|
import { vectorStats } from "../../src/vectorStore.js";
|
|
27
29
|
|
|
28
30
|
/** Register agent/turn tracking event handlers. */
|
|
@@ -393,6 +395,7 @@ export function registerAgentHandlers(
|
|
|
393
395
|
// S28 handles; nothing for S38 to do here.
|
|
394
396
|
} else {
|
|
395
397
|
const category = classifyError(event.message);
|
|
398
|
+
const detail = classifyErrorDetailed(event.message);
|
|
396
399
|
// R3: stateful poisoned-context signal — repeated identical error text
|
|
397
400
|
// across consecutive turns. The classifier is stateless, so this
|
|
398
401
|
// upgrade happens here. A 'transient' (or 'poisoned-context') turn
|
|
@@ -430,6 +433,12 @@ export function registerAgentHandlers(
|
|
|
430
433
|
// (error-classifier.ts) so the two never drift.
|
|
431
434
|
if (!isKnownRetryableTransient(errSig)) {
|
|
432
435
|
effectiveCategory = "poisoned-context";
|
|
436
|
+
} else {
|
|
437
|
+
runtime.logger.info("repeat-upgrade-declined", {
|
|
438
|
+
sessionId: runtime.rt.sessionId,
|
|
439
|
+
signature: (errSig || '').slice(0, 500),
|
|
440
|
+
repeatCount: runtime.rt.errorTextRepeatCount,
|
|
441
|
+
});
|
|
433
442
|
}
|
|
434
443
|
}
|
|
435
444
|
}
|
|
@@ -445,6 +454,9 @@ export function registerAgentHandlers(
|
|
|
445
454
|
runtime.rt.consecutiveErrors = 0; // S38.6: circuit-breaker reset on success
|
|
446
455
|
// R4: a successful assistant turn consumes any queued nudge.
|
|
447
456
|
runtime.rt.retryNudgePending = false;
|
|
457
|
+
// R10: reset outage advisory so a recovered-then-flapping
|
|
458
|
+
// provider re-advises once per episode.
|
|
459
|
+
runtime.rt.providerOutageAdvised = false;
|
|
448
460
|
} else if (effectiveCategory === "compaction-noop") {
|
|
449
461
|
// (4) pi race / manual compact catch — NOT retryable. The compaction
|
|
450
462
|
// already succeeded via pi's native path; retrying would race again
|
|
@@ -540,18 +552,24 @@ export function registerAgentHandlers(
|
|
|
540
552
|
runtime.rt.consecutiveErrors++; // still counts toward the circuit breaker
|
|
541
553
|
runtime.rt.poisonedCount++; // R7: dashboard counter
|
|
542
554
|
const sig = errSig || "unknown";
|
|
543
|
-
// (a) dashboard + log
|
|
555
|
+
// (a) dashboard + log — R11: include signal + rawText for forensics
|
|
544
556
|
runtime.dashboard.event("poisoned_context", {
|
|
545
557
|
signature: sig,
|
|
546
558
|
repeatCount: runtime.rt.errorTextRepeatCount,
|
|
547
559
|
turnIndex: event.turnIndex,
|
|
548
560
|
sessionId: runtime.rt.sessionId,
|
|
561
|
+
signal: detail.signal,
|
|
562
|
+
rawText: (errSig || '').slice(0, 500),
|
|
563
|
+
...(detail.httpStatus !== undefined ? { httpStatus: detail.httpStatus } : {}),
|
|
549
564
|
});
|
|
550
565
|
runtime.logger.warn("poisoned-context", {
|
|
551
566
|
sessionId: runtime.rt.sessionId,
|
|
552
567
|
turnIndex: event.turnIndex,
|
|
553
568
|
signature: sig,
|
|
554
569
|
repeatCount: runtime.rt.errorTextRepeatCount,
|
|
570
|
+
signal: detail.signal,
|
|
571
|
+
rawText: (errSig || '').slice(0, 500),
|
|
572
|
+
...(detail.httpStatus !== undefined ? { httpStatus: detail.httpStatus } : {}),
|
|
555
573
|
});
|
|
556
574
|
// (b) one-per-session advise message (throttled by poisonedAdviseSent)
|
|
557
575
|
if (!runtime.rt.poisonedAdviseSent) {
|
|
@@ -607,6 +625,11 @@ export function registerAgentHandlers(
|
|
|
607
625
|
}
|
|
608
626
|
// S38.6: circuit-breaker — stop retrying after too many consecutive errors.
|
|
609
627
|
runtime.rt.consecutiveErrors++;
|
|
628
|
+
// R10: send calm "provider outage" advisory once per episode.
|
|
629
|
+
await maybeSendProviderOutageAdvisory(
|
|
630
|
+
effectiveCategory, runtime, pi, config,
|
|
631
|
+
{ signal: detail.signal, rawText: (errSig || '').slice(0, 500) },
|
|
632
|
+
);
|
|
610
633
|
if (runtime.rt.consecutiveErrors > config.maxConsecutiveErrors) {
|
|
611
634
|
runtime.dashboard.event("error_retry_circuit_open", {
|
|
612
635
|
consecutive: runtime.rt.consecutiveErrors,
|
|
@@ -24,9 +24,11 @@
|
|
|
24
24
|
* - `etimedout` is Node's timeout errno lowercased — does NOT contain the
|
|
25
25
|
* substring "timeout".
|
|
26
26
|
* - `socket hang up` is Node's ECONNRESET *message*; the errno lives in
|
|
27
|
-
* error.code, which extractErrorSignature never sees.
|
|
27
|
+
* error.code, which extractErrorSignature never sees.
|
|
28
|
+
* - router phrasings (all targets failed / no healthy target / too many
|
|
29
|
+
* concurrent) -- pi's status prefix is console-only (2026-07-30 incident). */
|
|
28
30
|
export const KNOWN_RETRYABLE_TRANSIENT_PATTERN =
|
|
29
|
-
/max(imum)? output token|rate[\s.-]?limit|429|too many requests|overloaded|5\d\d|internal server|bad gateway|service unavailable|network|time(?:d[\s-]?out|out)|etimedout|econnreset|econnrefused|epipe|eai_again|socket
|
|
31
|
+
/max(imum)? output token|rate[\s.-]?limit|429|too many requests|too many concurrent|overloaded|5\d\d|internal server|bad gateway|service unavailable|all targets failed|no healthy target|fetch failed|network|time(?:d[\s-]?out|out)|etimedout|econnreset|econnrefused|epipe|eai_again|socket|closed unexpectedly|premature close|other side closed|connection (lost|refused|reset|aborted?|was closed)|stream (interrupted|closed|ended|failed)|disconnected/;
|
|
30
32
|
|
|
31
33
|
/** R7: true when the error text carries a known-retryable transient marker.
|
|
32
34
|
* Defensively lowercases so callers can pass raw or normalized text. */
|
|
@@ -34,32 +36,60 @@ export function isKnownRetryableTransient(text: string): boolean {
|
|
|
34
36
|
return KNOWN_RETRYABLE_TRANSIENT_PATTERN.test(text.toLowerCase());
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
/** R8: best-effort extraction of HTTP status from pi AgentMessage shapes. */
|
|
40
|
+
function extractHttpStatus(m: Record<string, unknown>): number | undefined {
|
|
41
|
+
for (const outer of [m.error, m] as Array<Record<string, unknown> | undefined>) {
|
|
42
|
+
if (!outer || typeof outer !== 'object') continue;
|
|
43
|
+
for (const key of ['status', 'statusCode', 'code']) {
|
|
44
|
+
const v = (outer as Record<string, unknown>)[key];
|
|
45
|
+
if (typeof v === 'number' && v >= 100 && v <= 599) return v;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** R11: signal tag identifying which classification rule fired. */
|
|
52
|
+
export type ErrorSignal =
|
|
53
|
+
| 'length-guard'
|
|
54
|
+
| 'cancelled'
|
|
55
|
+
| 'success'
|
|
56
|
+
| 'stream-death-no-stopreason'
|
|
57
|
+
| 'compaction-noop'
|
|
58
|
+
| 'context-overflow'
|
|
59
|
+
| 'transient-marker'
|
|
60
|
+
| 'transient-status'
|
|
61
|
+
| 'permanent-status'
|
|
62
|
+
| 'poisoned-invalid-request'
|
|
63
|
+
| 'poisoned-request-failed'
|
|
64
|
+
| 'bare-0-token'
|
|
65
|
+
| 'generic-error'
|
|
66
|
+
| 'permanent-auth'
|
|
67
|
+
| 'unknown';
|
|
68
|
+
|
|
69
|
+
export type ErrorCategory =
|
|
46
70
|
| 'transient'
|
|
47
71
|
| 'permanent'
|
|
48
72
|
| 'compaction-noop'
|
|
49
73
|
| 'context-overflow'
|
|
50
74
|
| 'cancelled'
|
|
51
75
|
| 'poisoned-context'
|
|
52
|
-
| null
|
|
53
|
-
|
|
76
|
+
| null;
|
|
77
|
+
|
|
78
|
+
/** R11: detailed classification result with the signal tag and optional httpStatus. */
|
|
79
|
+
export interface ClassifyErrorDetail {
|
|
80
|
+
category: ErrorCategory;
|
|
81
|
+
signal: ErrorSignal;
|
|
82
|
+
httpStatus?: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** R11: classify a turn-end error/stop signal into a retry category with a
|
|
86
|
+
* diagnostic signal tag naming the rule that fired, plus the raw httpStatus
|
|
87
|
+
* when extractHttpStatus found one. */
|
|
88
|
+
export function classifyErrorDetailed(message: unknown): ClassifyErrorDetail {
|
|
54
89
|
let text = '';
|
|
55
|
-
// R3: usage-token signal. Only when `usage` is explicitly present with
|
|
56
|
-
// inputTokens=0 AND outputTokens=0 do we know the turn never reached the
|
|
57
|
-
// model. An ABSENT usage field means "unknown" (the event didn't carry
|
|
58
|
-
// token counts) — we do NOT treat that as the 0-token poisoned signal,
|
|
59
|
-
// because mid-response stream deaths and partial-content turns routinely
|
|
60
|
-
// omit usage while still being transient (preserves all pre-R3 tests).
|
|
61
90
|
let usagePresent = false;
|
|
62
91
|
let totalTokens = 0;
|
|
92
|
+
let httpStatus: number | undefined;
|
|
63
93
|
if (typeof message === 'string') {
|
|
64
94
|
text = message;
|
|
65
95
|
} else if (message && typeof message === 'object') {
|
|
@@ -70,14 +100,9 @@ export function classifyError(message: unknown):
|
|
|
70
100
|
usage?: { inputTokens?: number; outputTokens?: number } | undefined;
|
|
71
101
|
};
|
|
72
102
|
const sr = typeof m.stopReason === 'string' ? m.stopReason : '';
|
|
73
|
-
|
|
74
|
-
if (sr === '
|
|
75
|
-
|
|
76
|
-
// nudging would restart a task the user explicitly stopped.
|
|
77
|
-
if (sr === 'aborted') return 'cancelled';
|
|
78
|
-
// Success / normal tool flow — not an error, nothing to retry.
|
|
79
|
-
if (sr === 'stop' || sr === 'toolUse' || sr === 'tool_use') return null;
|
|
80
|
-
// R3: extract usage tokens for the 0-token poisoned-context signal.
|
|
103
|
+
if (sr === 'length') return { category: null, signal: 'length-guard' };
|
|
104
|
+
if (sr === 'aborted') return { category: 'cancelled', signal: 'cancelled' };
|
|
105
|
+
if (sr === 'stop' || sr === 'toolUse' || sr === 'tool_use') return { category: null, signal: 'success' };
|
|
81
106
|
const usage = m.usage;
|
|
82
107
|
usagePresent = usage != null && typeof usage === 'object';
|
|
83
108
|
if (usagePresent) {
|
|
@@ -86,6 +111,7 @@ export function classifyError(message: unknown):
|
|
|
86
111
|
(typeof u.inputTokens === 'number' ? u.inputTokens : 0) +
|
|
87
112
|
(typeof u.outputTokens === 'number' ? u.outputTokens : 0);
|
|
88
113
|
}
|
|
114
|
+
httpStatus = extractHttpStatus(m as Record<string, unknown>);
|
|
89
115
|
const parts: string[] = [];
|
|
90
116
|
if (sr) parts.push(sr);
|
|
91
117
|
const c = m.content;
|
|
@@ -98,119 +124,77 @@ export function classifyError(message: unknown):
|
|
|
98
124
|
}
|
|
99
125
|
}
|
|
100
126
|
if (m.error) {
|
|
101
|
-
// Extract message from error objects for pattern matching.
|
|
102
127
|
const err = m.error;
|
|
103
|
-
if (typeof err === 'string')
|
|
104
|
-
|
|
105
|
-
} else if (err && typeof err === 'object') {
|
|
128
|
+
if (typeof err === 'string') parts.push(err);
|
|
129
|
+
else if (err && typeof err === 'object') {
|
|
106
130
|
const errObj = err as Record<string, unknown>;
|
|
107
|
-
if (typeof errObj.message === 'string')
|
|
108
|
-
|
|
109
|
-
} else {
|
|
110
|
-
parts.push(JSON.stringify(err));
|
|
111
|
-
}
|
|
131
|
+
if (typeof errObj.message === 'string') parts.push(errObj.message);
|
|
132
|
+
else parts.push(JSON.stringify(err));
|
|
112
133
|
}
|
|
113
134
|
}
|
|
114
|
-
// S38: detect mid-response errors where the stream died without a
|
|
115
|
-
// proper stopReason (empty/undefined) — this catches provider failures
|
|
116
|
-
// that cut off the response mid-stream, INCLUDING the case where the
|
|
117
|
-
// provider emitted partial content before dying (a truncated response
|
|
118
|
-
// with no stop reason is a mid-stream death, not a success).
|
|
119
|
-
// A genuine success ALWAYS carries stop/tool_use/toolUse (which
|
|
120
|
-
// short-circuit to null at the top), so any message reaching here with a
|
|
121
|
-
// falsy stopReason is a stream failure → retryable.
|
|
122
135
|
if (!sr) {
|
|
123
|
-
|
|
136
|
+
const r: ClassifyErrorDetail = { category: 'transient', signal: 'stream-death-no-stopreason' };
|
|
137
|
+
if (httpStatus !== undefined) r.httpStatus = httpStatus;
|
|
138
|
+
return r;
|
|
124
139
|
}
|
|
125
140
|
text = parts.join(' ');
|
|
126
141
|
}
|
|
127
|
-
if (!text) return null;
|
|
142
|
+
if (!text) return { category: null, signal: 'unknown' };
|
|
128
143
|
const s = text.toLowerCase();
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (/already compacted/.test(s)) return 'compaction-noop';
|
|
133
|
-
if (/compaction failed/.test(s)) return 'compaction-noop';
|
|
134
|
-
if (/nothing to compact/.test(s)) return 'compaction-noop';
|
|
135
|
-
if (/auto[\s-]?compaction failed/.test(s)) return 'compaction-noop';
|
|
136
|
-
// --- context-overflow (ORDER BEFORE generic transient!) ---
|
|
137
|
-
// A 400 from the model meaning "the prompt is bigger than the context window."
|
|
138
|
-
// Catches BOTH provider phrasings so the classification does not depend on
|
|
139
|
-
// which backend the router landed on:
|
|
140
|
-
// - pi/Anthropic wrapper: "too long for this model's context window even
|
|
141
|
-
// after compaction. Reduce the conversation length..." ->
|
|
142
|
-
// too long | context window | even after compaction | reduce the conversation
|
|
143
|
-
// - OpenAI/OpenRouter provider-side (often wrapped in "All targets failed:
|
|
144
|
-
// <model>. Last error: ..."): "This model's maximum context length is N
|
|
145
|
-
// tokens. Your request requires at least M tokens. Please reduce your
|
|
146
|
-
// input or max_tokens." -> maximum context length | context length
|
|
147
|
-
// exceeded | requires at least N tokens | reduce your input
|
|
148
|
-
// All of these carry `invalid_request_error` (UNDERSCORE, not 'invalid
|
|
149
|
-
// request' space) and would otherwise fall through to the generic
|
|
150
|
-
// `s.includes('error')` transient branch below, misclassifying as
|
|
151
|
-
// 'transient' and firing up to 5 blind retry nudges that re-submit the same
|
|
152
|
-
// oversized prompt -> re-400 -> busy-loop. 'context-overflow' instead forces
|
|
153
|
-
// ONE deferred re-compact (debounce-bypassed, race-guarded) and fires NO
|
|
154
|
-
// blind retry nudge. The forced re-compact is shaped by the existing
|
|
155
|
-
// session_before_compact durable trim (it cannot lower pi's firstKeptEntryId).
|
|
144
|
+
if (/already compacted|compaction failed|nothing to compact|auto[\s-]?compaction failed/.test(s)) {
|
|
145
|
+
return { category: 'compaction-noop', signal: 'compaction-noop' };
|
|
146
|
+
}
|
|
156
147
|
if (/too long|context window|maximum context length|context length exceeded|requires at least \d+ tokens|even after compaction|reduce the conversation|reduce your input/.test(s)) {
|
|
157
|
-
return 'context-overflow';
|
|
148
|
+
return { category: 'context-overflow', signal: 'context-overflow' };
|
|
149
|
+
}
|
|
150
|
+
if (isKnownRetryableTransient(s)) {
|
|
151
|
+
const r: ClassifyErrorDetail = { category: 'transient', signal: 'transient-marker' };
|
|
152
|
+
if (httpStatus !== undefined) r.httpStatus = httpStatus;
|
|
153
|
+
return r;
|
|
154
|
+
}
|
|
155
|
+
if (httpStatus === 429 || (httpStatus !== undefined && httpStatus >= 500)) {
|
|
156
|
+
return { category: 'transient', signal: 'transient-status', httpStatus };
|
|
157
|
+
}
|
|
158
|
+
if (httpStatus === 401 || httpStatus === 403) {
|
|
159
|
+
return { category: 'permanent', signal: 'permanent-status', httpStatus };
|
|
158
160
|
}
|
|
159
|
-
// --- transient (known-retryable markers FIRST — these override poisoned signals) ---
|
|
160
|
-
// R3: network/throughput failures (timeout, ECONNRESET, 5xx, 429) MUST stay
|
|
161
|
-
// transient even when usage is 0 tokens, because they are retryable and
|
|
162
|
-
// /clear cannot fix them. 'connection aborted' is a network failure
|
|
163
|
-
// (ECONNABORTED), NOT a user ESC (stopReason 'aborted', early-return above).
|
|
164
|
-
// The marker set is shared with the agent-handlers R3 repeat-upgrade guard
|
|
165
|
-
// (isKnownRetryableTransient) so the two never drift.
|
|
166
|
-
if (isKnownRetryableTransient(s)) return 'transient';
|
|
167
|
-
// --- poisoned-context (R3: ORDER AFTER specific transient markers, BEFORE
|
|
168
|
-
// the generic 'error' transient fallthrough) ---
|
|
169
|
-
// A DETERMINISTIC request-rejection that retrying cannot fix. Re-submitting
|
|
170
|
-
// the same prompt re-triggers the same rejection. The 2026-07-28 incident
|
|
171
|
-
// was a provider returning 0-token "Request failed — please retry." turns
|
|
172
|
-
// for every request; classifyError mapped stopReason 'error' → 'transient'
|
|
173
|
-
// and the session emitted ~9 nudge bursts (~60 context-bloating messages)
|
|
174
|
-
// before stopping.
|
|
175
|
-
//
|
|
176
|
-
// Signal 1: provider request-validation 400 that is NOT context-overflow
|
|
177
|
-
// (context-overflow already returned above). Orphaned tool result / malformed
|
|
178
|
-
// message structure / unexpected role ordering / empty content. These were
|
|
179
|
-
// previously classified 'permanent' (max 1 retry) — but a single retry
|
|
180
|
-
// re-submits the SAME malformed structure and re-400s, so they are poisoned,
|
|
181
|
-
// not permanent. Auth/permission 400s stay permanent (separate check below).
|
|
182
161
|
if (/invalid_request_error|invalid request|malformed|bad request|orphaned tool|tool (result )?(without|for )|unexpected role|role (ordering|sequence)|empty (content|message|request)/.test(s)) {
|
|
183
|
-
return 'poisoned-context';
|
|
162
|
+
return { category: 'poisoned-context', signal: 'poisoned-invalid-request' };
|
|
184
163
|
}
|
|
185
|
-
// Signal 2: generic catch-all "request failed" / "request error" with no
|
|
186
|
-
// specific transient marker (transient markers already returned above).
|
|
187
|
-
// This is the exact phrasing the 2026-07-28 incident used. Without a
|
|
188
|
-
// specific retryable cause, treat the rejection as deterministic.
|
|
189
164
|
if (/request failed|request error/.test(s)) {
|
|
190
|
-
return 'poisoned-context';
|
|
165
|
+
return { category: 'poisoned-context', signal: 'poisoned-request-failed' };
|
|
191
166
|
}
|
|
192
|
-
// Signal 3: stopReason 'error' + usage explicitly 0 tokens (the turn never
|
|
193
|
-
// reached the model). Conservative: only when `usage` is PRESENT and 0 — an
|
|
194
|
-
// absent usage field means "unknown" and stays transient (mid-response
|
|
195
|
-
// deaths / partial-content turns often omit usage). The transient markers
|
|
196
|
-
// above already returned, so reaching here with 'error' text and 0 tokens
|
|
197
|
-
// means the request was rejected before any model work.
|
|
198
167
|
if (usagePresent && totalTokens === 0 && /error/.test(s)) {
|
|
199
|
-
return '
|
|
168
|
+
return { category: 'transient', signal: 'bare-0-token' };
|
|
200
169
|
}
|
|
201
|
-
// --- transient (generic 'error' stopReason fallthrough) ---
|
|
202
|
-
// Reaches here for a generic stopReason 'error' with no specific marker and
|
|
203
|
-
// no 0-token signal (usage absent or > 0). Stays transient — conservative.
|
|
204
170
|
if (s.includes('error') && !/\b(auth|unauthorized|invalid (api )?key|permission)\b/.test(s)) {
|
|
205
|
-
return 'transient'
|
|
171
|
+
return { category: 'transient', signal: 'generic-error' };
|
|
172
|
+
}
|
|
173
|
+
if (/auth|unauthorized|invalid (api )?key|permission/.test(s)) {
|
|
174
|
+
return { category: 'permanent', signal: 'permanent-auth' };
|
|
206
175
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
176
|
+
return { category: null, signal: 'unknown' };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** S38.2: classify a turn-end error/stop signal into a retry category.
|
|
180
|
+
*
|
|
181
|
+
* `length` is returned as null — S28 owns the max-output-token length stopReason
|
|
182
|
+
* exclusively (its agent_end nudge path is separate and must not be doubled).
|
|
183
|
+
*
|
|
184
|
+
* Thin wrapper around classifyErrorDetailed — returns only the category.
|
|
185
|
+
*
|
|
186
|
+
* @param message the event.message (a pi AgentMessage) or an error string
|
|
187
|
+
* @returns 'transient' | 'permanent' | 'compaction-noop' | 'context-overflow' | 'cancelled' | 'poisoned-context' | null (success/unknown)
|
|
188
|
+
*/
|
|
189
|
+
export function classifyError(message: unknown):
|
|
190
|
+
| 'transient'
|
|
191
|
+
| 'permanent'
|
|
192
|
+
| 'compaction-noop'
|
|
193
|
+
| 'context-overflow'
|
|
194
|
+
| 'cancelled'
|
|
195
|
+
| 'poisoned-context'
|
|
196
|
+
| null {
|
|
197
|
+
return classifyErrorDetailed(message).category;
|
|
214
198
|
}
|
|
215
199
|
|
|
216
200
|
/** S38.2: exponential backoff for error-retry nudges.
|
|
@@ -233,13 +217,39 @@ export function errorRetryBackoffMs(count: number, baseMs = 5000): number {
|
|
|
233
217
|
* 'error' for retryable turns) so two turns with the same error content share
|
|
234
218
|
* a signature. Empty string when there is no error text (e.g. a bare
|
|
235
219
|
* stopReason 'error' with no content) — the caller skips repeat detection in
|
|
236
|
-
* that case (the stateless 0-token signal handles bare errors).
|
|
220
|
+
* that case (the stateless 0-token signal handles bare errors).
|
|
221
|
+
*
|
|
222
|
+
* R9: when the joined text is empty AND stopReason is 'error' AND usage is
|
|
223
|
+
* present with 0 total tokens, returns the constant "bare-0-token-error" so
|
|
224
|
+
* the repeat detector can corroborate bare 0-token errors (first turn is
|
|
225
|
+
* transient; repeated occurrences upgrade to poisoned at threshold). Bare
|
|
226
|
+
* 'error' WITHOUT usage keeps returning '' (absent usage = unknown; mid-
|
|
227
|
+
* response deaths stay out of repeat tracking). */
|
|
228
|
+
/** R12: normalize volatile tokens in an already-lowercased error string so that
|
|
229
|
+
* equivalent errors with varied model aliases, IPs, hex request-ids, retry
|
|
230
|
+
* counts, and long numeric ids produce the same signature. Order matters.
|
|
231
|
+
* 3-digit HTTP status codes (429/500/502/etc.) survive — the long-number rule
|
|
232
|
+
* only covers 4+ digits. Invariant: normalizeVolatileTokens never empties a
|
|
233
|
+
* non-empty string (every regex replaces with a non-empty literal token). */
|
|
234
|
+
function normalizeVolatileTokens(s: string): string {
|
|
235
|
+
let n = s;
|
|
236
|
+
n = n.replace(/\b\d{1,3}(\.\d{1,3}){3}(:\d+)?\b/g, '<ip>'); // 1. IP:port / bare IPv4
|
|
237
|
+
n = n.replace(/\b[\w-]+(\/[\w.:+-]+)+/g, '<model>'); // 2. slash-separated paths
|
|
238
|
+
n = n.replace(/\b[0-9a-f]{8,}\b/g, '<hex>'); // 3. hex ids (8+ chars)
|
|
239
|
+
n = n.replace(/\b\d{4,}\b/g, '<n>'); // 4. long numbers (4+ digits)
|
|
240
|
+
n = n.replace(/\bafter \d+ attempts?\b/g, 'after <n> attempts'); // 5. attempt phrasing
|
|
241
|
+
n = n.replace(/\s+/g, ' ').trim(); // 6. collapse whitespace
|
|
242
|
+
return n;
|
|
243
|
+
}
|
|
244
|
+
|
|
237
245
|
export function extractErrorSignature(message: unknown): string {
|
|
238
|
-
if (typeof message === 'string') return message.toLowerCase().trim();
|
|
246
|
+
if (typeof message === 'string') return normalizeVolatileTokens(message.toLowerCase().trim());
|
|
239
247
|
if (!message || typeof message !== 'object') return '';
|
|
240
248
|
const m = message as {
|
|
241
249
|
content?: unknown;
|
|
242
250
|
error?: unknown;
|
|
251
|
+
stopReason?: string;
|
|
252
|
+
usage?: { inputTokens?: number; outputTokens?: number };
|
|
243
253
|
};
|
|
244
254
|
const parts: string[] = [];
|
|
245
255
|
const c = m.content;
|
|
@@ -260,5 +270,16 @@ export function extractErrorSignature(message: unknown): string {
|
|
|
260
270
|
else parts.push(JSON.stringify(err));
|
|
261
271
|
}
|
|
262
272
|
}
|
|
263
|
-
|
|
273
|
+
const joined = parts.join(' ').toLowerCase().trim();
|
|
274
|
+
const normalized = normalizeVolatileTokens(joined);
|
|
275
|
+
if (
|
|
276
|
+
normalized === '' &&
|
|
277
|
+
m.stopReason === 'error' &&
|
|
278
|
+
m.usage &&
|
|
279
|
+
typeof m.usage === 'object' &&
|
|
280
|
+
(m.usage.inputTokens ?? 0) + (m.usage.outputTokens ?? 0) === 0
|
|
281
|
+
) {
|
|
282
|
+
return 'bare-0-token-error';
|
|
283
|
+
}
|
|
284
|
+
return normalized;
|
|
264
285
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* outage-advisor.ts — R10: calm "provider outage" advisory.
|
|
3
|
+
*
|
|
4
|
+
* When a provider/router flaps (transient errors: timeouts, 5xx, 429s),
|
|
5
|
+
* the extension retries silently and eventually goes quiet after caps. This
|
|
6
|
+
* module sends ONE user-facing advisory per outage episode so the user knows
|
|
7
|
+
* to wait rather than running /clear.
|
|
8
|
+
*
|
|
9
|
+
* Distinct from the poisoned-context /clear advise: the outage advisory is
|
|
10
|
+
* for transient errors where the user's context is fine.
|
|
11
|
+
*
|
|
12
|
+
* PREVENT-PI-003: sends via safeSendUserMessage (user-role only).
|
|
13
|
+
* PREVENT-PI-004: local ctx call, no network.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { MegaRuntime } from "../mega-runtime.js";
|
|
17
|
+
import { safeSendUserMessage } from "./send-safe.js";
|
|
18
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
|
|
20
|
+
/** R11: optional diagnostic detail passed from the classifier. */
|
|
21
|
+
export interface OutageDetail {
|
|
22
|
+
signal?: string;
|
|
23
|
+
rawText?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check the provider-outage advisory condition and fire once per episode.
|
|
28
|
+
*
|
|
29
|
+
* Called from the transient/permanent retry branch of the turn_end handler,
|
|
30
|
+
* AFTER consecutiveErrors is incremented.
|
|
31
|
+
*
|
|
32
|
+
* @param effectiveCategory — must be "transient" for the advisory to fire.
|
|
33
|
+
* @param runtime — the live MegaRuntime (mutated on advisory fire).
|
|
34
|
+
* @param pi — pi ExtensionAPI for safeSendUserMessage.
|
|
35
|
+
* @param config — providerOutageAdviseThreshold config.
|
|
36
|
+
* @param detail — R11: optional signal + rawText for forensics.
|
|
37
|
+
*/
|
|
38
|
+
export async function maybeSendProviderOutageAdvisory(
|
|
39
|
+
effectiveCategory: string,
|
|
40
|
+
runtime: MegaRuntime,
|
|
41
|
+
pi: ExtensionAPI,
|
|
42
|
+
config: { providerOutageAdviseThreshold: number },
|
|
43
|
+
detail?: OutageDetail,
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
if (effectiveCategory !== "transient") return;
|
|
46
|
+
if (config.providerOutageAdviseThreshold <= 0) return;
|
|
47
|
+
if (runtime.rt.providerOutageAdvised) return;
|
|
48
|
+
if (runtime.rt.consecutiveErrors < config.providerOutageAdviseThreshold)
|
|
49
|
+
return;
|
|
50
|
+
|
|
51
|
+
runtime.rt.providerOutageAdvised = true;
|
|
52
|
+
|
|
53
|
+
runtime.dashboard.event("provider_outage_advised", {
|
|
54
|
+
consecutiveErrors: runtime.rt.consecutiveErrors,
|
|
55
|
+
turnIndex: runtime.currentTurn,
|
|
56
|
+
sessionId: runtime.rt.sessionId,
|
|
57
|
+
...(detail?.signal !== undefined ? { signal: detail.signal } : {}),
|
|
58
|
+
...(detail?.rawText !== undefined ? { rawText: detail.rawText } : {}),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
runtime.logger.info("provider-outage-advised", {
|
|
62
|
+
sessionId: runtime.rt.sessionId,
|
|
63
|
+
consecutiveErrors: runtime.rt.consecutiveErrors,
|
|
64
|
+
turnIndex: runtime.currentTurn,
|
|
65
|
+
...(detail?.signal !== undefined ? { signal: detail.signal } : {}),
|
|
66
|
+
...(detail?.rawText !== undefined ? { rawText: detail.rawText } : {}),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await safeSendUserMessage(
|
|
70
|
+
pi,
|
|
71
|
+
`[mega-compact] the provider is having issues (${runtime.rt.consecutiveErrors} consecutive failures — timeouts/5xx/rate-limits). Retries are bounded and continue automatically; your context is fine — do NOT clear or reset it. Work resumes as soon as the provider recovers.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -67,6 +67,8 @@ export interface SessionRuntime {
|
|
|
67
67
|
errorTextRepeatCount: number; // consecutive count of identical error signatures
|
|
68
68
|
// R3b: one-per-session /clear advise message throttle.
|
|
69
69
|
poisonedAdviseSent: boolean; // true after the advise message fires once
|
|
70
|
+
// R10: provider-outage advisory throttle (one per flapping episode).
|
|
71
|
+
providerOutageAdvised: boolean; // true after the outage advise message fires once
|
|
70
72
|
// R3c: one guarded compact per error signature (avoids re-compacting the
|
|
71
73
|
// same poisoned region repeatedly).
|
|
72
74
|
poisonedCompactSignatures: Set<string>; // signatures already attempted a compact for
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mega-compact",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.4",
|
|
4
4
|
"description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "BSD-3-Clause",
|