pi-mega-compact 0.21.7 → 0.21.9

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.
Files changed (26) hide show
  1. package/dist/extensions/dashboard-server/routes-rag-settings-compaction.js +42 -0
  2. package/dist/extensions/dashboard-server/routes-rag-settings-helpers.js +5 -8
  3. package/dist/extensions/mega-config.js +12 -0
  4. package/dist/extensions/mega-events/context-handler/gateCheck.js +51 -1
  5. package/dist/extensions/mega-events/context-handler/headroom.js +128 -0
  6. package/dist/extensions/mega-events/context-handler/liveTrim.js +31 -49
  7. package/dist/extensions/mega-events/context-handler/pipelineRun.js +14 -1
  8. package/dist/extensions/mega-events/context-handler.js +34 -3
  9. package/dist/extensions/mega-runtime/dashboard-snapshot.js +1 -0
  10. package/dist/extensions/mega-runtime/runtime-instrumentation.js +1 -0
  11. package/dist/extensions/mega-runtime/runtime-snapshot.js +1 -0
  12. package/extensions/dashboard-server/api-contracts/endpoints/types.ts +2 -0
  13. package/extensions/dashboard-server/routes-rag-settings-compaction.ts +91 -0
  14. package/extensions/dashboard-server/routes-rag-settings-helpers.ts +5 -27
  15. package/extensions/mega-config-types.ts +25 -0
  16. package/extensions/mega-config.ts +12 -0
  17. package/extensions/mega-dashboard.ts +4 -1
  18. package/extensions/mega-events/context-handler/gateCheck.ts +67 -0
  19. package/extensions/mega-events/context-handler/headroom.ts +190 -0
  20. package/extensions/mega-events/context-handler/liveTrim.ts +31 -55
  21. package/extensions/mega-events/context-handler/pipelineRun.ts +14 -1
  22. package/extensions/mega-events/context-handler.ts +34 -3
  23. package/extensions/mega-runtime/dashboard-snapshot.ts +3 -0
  24. package/extensions/mega-runtime/runtime-instrumentation.ts +4 -0
  25. package/extensions/mega-runtime/runtime-snapshot.ts +2 -0
  26. package/package.json +1 -1
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import type { SettingSpec, SettingGroup } from "./routes-rag-settings-types.js";
11
11
  import { VECTOR_CORTEX_SETTINGS } from "./routes-rag-settings-vector-cortex.js";
12
+ import { COMPACTION_SETTINGS } from "./routes-rag-settings-compaction.js";
12
13
 
13
14
  export type { SettingSpec } from "./routes-rag-settings-types.js";
14
15
 
@@ -275,33 +276,10 @@ export const SETTINGS: ReadonlyArray<SettingGroup> = [
275
276
  num("MEGACOMPACT_EMBEDDING_CHARS_PER_TOKEN", "Embedding Chars per Token", "Estimated characters per token used for embedder chunking size", 4, 1, 32),
276
277
  ],
277
278
  },
278
- {
279
- name: "Compaction",
280
- settings: [
281
- num(
282
- "MEGACOMPACT_THRESHOLD_PCT",
283
- "Compaction Threshold",
284
- "Fraction of the actual model context window at which compaction fires — 0.80 fires at 80% used (leaves 20% free). Applies to any model size; a per-model Model Thresholds row overrides it",
285
- 0.8,
286
- 0.1,
287
- 0.95,
288
- ),
289
- num(
290
- "MEGACOMPACT_THRASH_REARM_PCT",
291
- "Thrash Re-arm %",
292
- "After an ineffective compaction (live window did not shrink), refuse to re-fire until the live window grows by this fraction of the effective threshold. Default 0.10 (10%)",
293
- 0.1,
294
- 0.01,
295
- 0.5,
296
- ),
297
- boolDirect(
298
- "MEGACOMPACT_OUTPUT_ERROR_COMPACT",
299
- "Output-Error Compact",
300
- "When a model response is truncated mid-output (stopReason: 'length'), trip a one-shot forced compaction to free input headroom. Closes the small-context deadlock where the model truncates below the input threshold.",
301
- true,
302
- ),
303
- ],
304
- },
279
+ // v0.21.9: compaction group extracted to keep this file under the
280
+ // extensions/ soft limit (delegate-shell split); carries the overflow-
281
+ // headroom + output-reserve flags alongside the pre-existing trio.
282
+ COMPACTION_SETTINGS,
305
283
  {
306
284
  name: "Three-Way Failback",
307
285
  settings: [
@@ -141,6 +141,31 @@ export interface MegaConfig {
141
141
  * gate never fires → "compact never" → every subsequent response truncates
142
142
  * too). Default ON; OFF (=0/`=false`) = byte-identical pre-H. */
143
143
  outputErrorCompact: boolean;
144
+ /** v0.21.9: output-headroom gate. Fire compaction BEFORE the request
145
+ * overflows the model window — when
146
+ * `currentTokens + outputReserve + safetyMargin >= contextWindow` —
147
+ * instead of waiting for the percent/token fire point (which judges only
148
+ * INPUT and never trips on small-window models whose output reserve is a
149
+ * large fraction of the window: a 32k window with a 20k maxTokens
150
+ * overflows at ~37% INPUT). Percent-based by construction: the reserve is
151
+ * a fraction of the model's own window, so the math is identical at every
152
+ * window size (32k, 64k, 200k, 1M, 5M). Default ON; OFF (=0/`=false`)
153
+ * disables ONLY the pre-fire gate check (the gate reverts to the
154
+ * input-only pre-v0.21.9 judgment). NOTE: the shared tail-cap hardenings
155
+ * this fix introduced — pair-safe front-drop (the pre-v0.21.9 cap could
156
+ * split a toolCall/toolResult pair, PREVENT-PI-002) and the budget floor
157
+ * (the old cap silently disabled itself when the reserve exceeded the
158
+ * window) — are UNCONDITIONAL guardrail fixes and apply regardless of
159
+ * this flag. Headroom-triggered fires are EXEMPT from the ThrashGuard
160
+ * (an overflowed session is unrecoverable). */
161
+ overflowHeadroom: boolean;
162
+ /** v0.21.9: fallback output reserve as a fraction of the context window
163
+ * when the model's declared maxTokens is absent or implausible (0, or the
164
+ * models.json sentinels 1e9/1e38, or >= the window). Clamped [0.1, 0.95];
165
+ * default 0.30 (30% of the window). When maxTokens is plausible the
166
+ * declared value wins — vLLM-style backends reserve the FULL declared
167
+ * maxTokens against the context window, so the reserve must match it. */
168
+ outputReservePct: number;
144
169
  /** Inline-dedupe recalled checkpoints against the live window (Fix C): drop
145
170
  * a hit whose summary is ≥ dedupSim similar to a live message — "dedupe on
146
171
  * inline/read" so we never re-inject context already resident. */
@@ -253,6 +253,18 @@ export function loadConfig(): MegaConfig {
253
253
  // Phase H: output-error catch — trip compaction on a truncated model output
254
254
  // (S28 stopReason==='length'). Default ON; OFF byte-identical pre-H.
255
255
  outputErrorCompact: envBool("MEGACOMPACT_OUTPUT_ERROR_COMPACT", true),
256
+ // v0.21.9 OUTPUT-HEADROOM GATE: fire compaction BEFORE the request
257
+ // overflows the model window (input + output reserve + margin >= window),
258
+ // not after. Percent-based: the reserve scales with the model's own window
259
+ // so the math holds at every window size (32k…5M). Default ON;
260
+ // OFF = byte-identical pre-v0.21.9 (2026-08-19 32k incident fix).
261
+ overflowHeadroom: envBool("MEGACOMPACT_OVERFLOW_HEADROOM", true),
262
+ // v0.21.9: fallback OUTPUT reserve as a FRACTION of the context window,
263
+ // used when the model's declared maxTokens is absent or implausible
264
+ // (0 / sentinel 1e9/1e38 / >= window). Clamped [0.1, 0.95]; default 0.30.
265
+ // When maxTokens IS plausible the declared value wins (vLLM reserves the
266
+ // full maxTokens) — this fraction is only the fallback.
267
+ outputReservePct: clamp(envFlag("MEGACOMPACT_OUTPUT_RESERVE_PCT", 0.3), 0.1, 0.95),
256
268
  windowDedupe: envBool("MEGACOMPACT_WINDOW_DEDUPE", true),
257
269
  recallTailInject: envBool("MEGACOMPACT_RECALL_TAIL_INJECT", true),
258
270
  // 3WF-1: TriggerGuard — guarantee a staged recall block on every context
@@ -142,11 +142,14 @@ export interface DashboardSnapshot {
142
142
  outputRate: number; // USD per output token (Model.cost)
143
143
  };
144
144
  /** v0.8.8 Perf dashboard: live diag counters (skip vs recompute vs replay)
145
- * for the Perf tab's "TUI lag proxy" cards. Optional for back-compat. */
145
+ * for the Perf tab's "TUI lag proxy" cards. Optional for back-compat.
146
+ * v0.21.9: headroomTrips added (output-headroom gate trips); readers that
147
+ * don't know it stay backward-compatible. */
146
148
  diag?: {
147
149
  ctxFastGate: number;
148
150
  liveTrimFires: number;
149
151
  liveTrimReplays: number;
152
+ headroomTrips?: number;
150
153
  };
151
154
  /** S38.8: error-retry state for dashboard "retries" tile.
152
155
  * R7 (retry redesign): sessionRetryCount / sessionMax / poisonedCount are
@@ -18,6 +18,7 @@ import { autoCompactCheck } from "../../../src/compact.js";
18
18
  import type { MegaRuntime } from "../../mega-runtime.js";
19
19
  import type { MegaConfig } from "../../mega-config.js";
20
20
  import { isThrashBlockedFor } from "./thrashGuard.js";
21
+ import { resolveOutputReserve } from "./headroom.js";
21
22
 
22
23
  /** Tail-injection closure shape produced by buildTailResult (tailResult.ts). */
23
24
  export type TailResultFn = (
@@ -30,6 +31,14 @@ export type GateOutcome =
30
31
  | {
31
32
  kind: "proceed";
32
33
  perModelThreshold: { safetyMarginPct: number; firePointPct: number };
34
+ /**
35
+ * v0.21.9: true when the proceed was forced by the output-headroom
36
+ * check (the request would overflow the model window before reaching
37
+ * the percent/token fire point). Consumed by thrashGuardBlocks so an
38
+ * overflow-bound fire is never refused by the thrash guard — an
39
+ * overflowed session is unrecoverable (2026-08-19 32k incident).
40
+ */
41
+ headroomExceeded?: boolean;
33
42
  };
34
43
 
35
44
  /**
@@ -55,9 +64,16 @@ export function thrashGuardBlocks(
55
64
  runtime: MegaRuntime,
56
65
  config: MegaConfig,
57
66
  currentTokens: number | null | undefined,
67
+ headroomExceeded?: boolean,
58
68
  ): boolean {
59
69
  if (!config.threeWayFailback) return false;
60
70
  if (currentTokens == null) return false;
71
+ // v0.21.9: an overflow-bound fire (headroomExceeded) is EXEMPT from the
72
+ // thrash guard. The guard exists to stop wasted re-compaction when the
73
+ // window refuses to shrink; but an overflowed request is not "wasted work"
74
+ // — it is the model about to 400. Blocking that fire reproduces the
75
+ // 2026-08-19 32k deadlock (compact never → request > window → error loop).
76
+ if (headroomExceeded) return false;
61
77
  return isThrashBlockedFor(runtime, currentTokens, runtime.currentStateDir);
62
78
  }
63
79
 
@@ -113,6 +129,57 @@ export function evaluateGate(
113
129
  return { kind: "proceed", perModelThreshold };
114
130
  }
115
131
 
132
+ // v0.21.9 OUTPUT-HEADROOM GATE (the root-cause fix for the 32k truncation
133
+ // loop). The percent/token fire points above judge only INPUT utilization
134
+ // (tier% of the window), but a request's budget is
135
+ // input tokens + the model's output budget + safety margin.
136
+ // On a small-window model with a large maxTokens (the user's 32k/20k
137
+ // GLM-4.7), the request overflows at ~32% INPUT (21.4k + 20k > 32.768k) —
138
+ // long before any percent gate fires → provider 400 "request exceeds the
139
+ // available context size" every turn → the poisoned-error loop. Phase H only
140
+ // reacts to stopReason 'length' (mid-output truncation); a pre-output 400
141
+ // never arms it, so "compact never". This check fires the compaction
142
+ // BEFORE the overflow instead of after.
143
+ //
144
+ // PERCENT-BASED (LTS invariant — must work at every window size: 32k, 64k,
145
+ // 200k, 1M, 5M): the reserve is a FRACTION of the model's own window via
146
+ // resolveOutputReserve (plausible declared maxTokens wins, else
147
+ // clamp(MEGACOMPACT_OUTPUT_RESERVE_PCT, 10–95%) × window). Same math, any
148
+ // size. window <= 0 (unknown) ⇒ deferred (never guess a window), matching
149
+ // the effectiveThresholdImpl Phase-C invariant. Gated on
150
+ // config.overflowHeadroom (default ON; OFF = byte-identical pre-v0.21.9).
151
+ // Thrash-guard exemption: headroomExceeded rides along on the proceed so the
152
+ // handler's thrash consult never refuses an overflow-bound fire (see
153
+ // thrashGuardBlocks above) — an overflowed session is unrecoverable, so a
154
+ // wasted re-fire is always the better outcome (2026-08-19 incident).
155
+ if (
156
+ config.overflowHeadroom &&
157
+ runtime.lastCtxWindow > 0 &&
158
+ Number.isFinite(currentTokens) &&
159
+ currentTokens > 0
160
+ ) {
161
+ const { reserveTokens, fallbackUsed } = resolveOutputReserve(
162
+ runtime.lastCtxWindow,
163
+ runtime.currentModel?.maxTokens ?? 0,
164
+ config.outputReservePct,
165
+ );
166
+ const headroomMargin = Math.ceil(
167
+ runtime.lastCtxWindow * (perModelThreshold.safetyMarginPct / 100),
168
+ );
169
+ if (currentTokens + reserveTokens + headroomMargin >= runtime.lastCtxWindow) {
170
+ runtime.diagCtxHeadroomTrip++;
171
+ runtime.logger.info("gate-headroom-trip", {
172
+ sessionId: runtime.rt.sessionId,
173
+ currentTokens,
174
+ ctxWindow: runtime.lastCtxWindow,
175
+ reserveTokens,
176
+ fallbackUsed,
177
+ marginPct: perModelThreshold.safetyMarginPct,
178
+ });
179
+ return { kind: "proceed", perModelThreshold, headroomExceeded: true };
180
+ }
181
+ }
182
+
116
183
  // S29 FAST GATE: `custom` (absolute MEGACOMPACT_THRESHOLD_TOKENS,
117
184
  // tierPct null) is an explicit opt-out of percent scaling — it keeps the
118
185
  // token gate. When pct is unavailable (window unknown / a model that
@@ -0,0 +1,190 @@
1
+ /**
2
+ * context-handler/headroom.ts — output-headroom reserve math + pair-safe tail cap.
3
+ *
4
+ * v0.21.9. Single source of truth for the output reserve used by BOTH the
5
+ * gate's pre-fire overflow check (gateCheck.ts) and the live-trim tail cap
6
+ * (liveTrim.ts + the D.2/D.3 replay paths). The pre-v0.21.9 code computed the
7
+ * reserve inline in liveTrim only and never in the gate — the two halves could
8
+ * drift, and the gate had no output awareness at all.
9
+ *
10
+ * PERCENT-BASED BY DESIGN (per the LTS invariant): every quantity is expressed
11
+ * as a fraction of the MODEL'S OWN context window, so the math is identical at
12
+ * any window size — 32k, 64k, 200k, 1M, 5M. The reserve is the model's
13
+ * declared max output tokens when plausible, else a clamped fraction of the
14
+ * window (MEGACOMPACT_OUTPUT_RESERVE_PCT, default 30%, clamped 10–95%).
15
+ *
16
+ * Pure functions, no runtime dependency — trivially unit-testable headlessly.
17
+ */
18
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
19
+ import { estimateBlockTokens, estimateMessageTokens } from "../../../src/tokens.js";
20
+ import { messageContentText } from "./messageText.js";
21
+
22
+ /**
23
+ * The model's declared maxTokens is only trusted as the output budget when it
24
+ * is plausible. models.json carries sentinel junk for some entries (1e9,
25
+ * 1e38, "unlimited"), and some providers report 0/absent. A declared budget
26
+ * above this FRACTION of the window is implausible — fall back to the
27
+ * configured fraction so a 200k/1e9 model doesn't compute a negative budget
28
+ * and silently disable the cap (the pre-v0.21.9 bug). Percent-based: holds at
29
+ * every window size.
30
+ *
31
+ * WHY 0.95 AND NOT LOWER: vLLM-style backends reject a request when
32
+ * `input + max_tokens > context window` — they reserve the model's FULL
33
+ * declared maxTokens, not a fraction of it. The user's own GLM-4.7 entry is
34
+ * 32000/20000 (62.5%); a 0.6 cutoff rejected that REAL config as
35
+ * "implausible" and fell back to a 30% reserve (9600) while the backend
36
+ * reserved the full 20000 — the gate would keep firing late and the
37
+ * post-compact tail would still overflow (2026-08-19 incident, attempt #6).
38
+ * A declared budget is plausible up to just below the WHOLE window; anything
39
+ * at/above the window (or the 1e9/1e38 sentinels) is junk.
40
+ */
41
+ export const MAX_OUTPUT_PLAUSIBLE_FRACTION = 0.95;
42
+
43
+ /** Bounds for the fallback reserve fraction (MEGACOMPACT_OUTPUT_RESERVE_PCT). */
44
+ export const OUTPUT_RESERVE_PCT_MIN = 0.1;
45
+ export const OUTPUT_RESERVE_PCT_MAX = 0.95;
46
+
47
+ /**
48
+ * Resolve the output reserve (tokens) for a model window.
49
+ *
50
+ * - window <= 0 (unknown) → { reserveTokens: 0, fallbackUsed: false }; every
51
+ * consumer is guarded on window > 0 and defers (never guesses a window).
52
+ * - maxTokens plausible (0 < maxTokens <= 95% of the window) → maxTokens —
53
+ * vLLM-style backends reserve the FULL declared maxTokens, so the reserve
54
+ * must equal it, not a fraction of it.
55
+ * - otherwise → clamp(outputReservePct, 0.1, 0.95) × window.
56
+ *
57
+ * `outputReservePct` is config.outputReservePct (already env-clamped at load,
58
+ * re-clamped here for defense against direct callers).
59
+ */
60
+ export function resolveOutputReserve(
61
+ ctxWindow: number,
62
+ maxTokens: number,
63
+ outputReservePct: number,
64
+ ): { reserveTokens: number; fallbackUsed: boolean } {
65
+ if (!Number.isFinite(ctxWindow) || ctxWindow <= 0) {
66
+ return { reserveTokens: 0, fallbackUsed: false };
67
+ }
68
+ const plausible =
69
+ Number.isFinite(maxTokens) &&
70
+ maxTokens > 0 &&
71
+ maxTokens <= ctxWindow * MAX_OUTPUT_PLAUSIBLE_FRACTION;
72
+ if (plausible) return { reserveTokens: Math.round(maxTokens), fallbackUsed: false };
73
+ const pct = Math.min(
74
+ OUTPUT_RESERVE_PCT_MAX,
75
+ Math.max(OUTPUT_RESERVE_PCT_MIN, Number.isFinite(outputReservePct) ? outputReservePct : 0.3),
76
+ );
77
+ return { reserveTokens: Math.ceil(ctxWindow * pct), fallbackUsed: true };
78
+ }
79
+
80
+ /**
81
+ * Pair-safe front-drop for the live-trim tail cap. Drops OLDEST messages from
82
+ * the front of `recentRaw` until the remaining tail fits
83
+ * `ctxWindow − outputReserve − safetyMargin − summaryTokens`, then advances
84
+ * the start index past any leading toolResult messages so the preserved tail
85
+ * never begins on an orphaned toolResult (PREVENT-PI-002: a toolCall/toolResult
86
+ * pair must not be split). Never returns an empty tail — the final message is
87
+ * always kept so the agent can respond.
88
+ *
89
+ * v0.21.9 hardenings over the pre-v0.21.9 inline cap in liveTrim.ts:
90
+ * 1. BUDGET FLOOR — when the reserve exceeds the window (implausible maxTokens
91
+ * made budget <= 0) the old block silently skipped the cap entirely and an
92
+ * oversized tail sailed past the window. Now the reserve is clamped (via
93
+ * resolveOutputReserve) to a fraction of the window, so a floor budget
94
+ * always exists. If even ONE message exceeds the floor budget we keep only
95
+ * the final message — the agent's last turn is the one thing the model
96
+ * must always see.
97
+ * 2. TOOL-PAIR SAFETY — the old front-drop could land between a toolCall and
98
+ * its toolResult, splitting the pair.
99
+ *
100
+ * Pure: returns { recent, dropped } without touching the input array.
101
+ */
102
+ export function applyTailCap(opts: {
103
+ recentRaw: readonly AgentMessage[];
104
+ summaryTokens: number;
105
+ ctxWindow: number;
106
+ maxOutputTokens: number;
107
+ outputReservePct: number;
108
+ safetyMarginPct: number;
109
+ /**
110
+ * Optional token-count override for the tail messages. When present, the
111
+ * token sum is counted from this array (index-aligned with `recentRaw`);
112
+ * otherwise each message's tokens are estimated from its text content.
113
+ */
114
+ messageTokens?: readonly number[];
115
+ }): { recent: AgentMessage[]; dropped: number } {
116
+ const { recentRaw, summaryTokens, ctxWindow, outputReservePct } = opts;
117
+ if (ctxWindow <= 0 || recentRaw.length <= 1) {
118
+ return { recent: [...recentRaw], dropped: 0 };
119
+ }
120
+ const msgTokens =
121
+ opts.messageTokens && opts.messageTokens.length === recentRaw.length
122
+ ? opts.messageTokens
123
+ : null;
124
+ const { reserveTokens } = resolveOutputReserve(
125
+ ctxWindow,
126
+ opts.maxOutputTokens,
127
+ outputReservePct,
128
+ );
129
+ const safetyMargin = Math.ceil(
130
+ ctxWindow * (Math.max(0, opts.safetyMarginPct) / 100),
131
+ );
132
+ // Budget floor: never negative. An implausible reserve (clamped above to
133
+ // <= 95% of the window) plus margin + summary can still exceed the window
134
+ // on tiny summaries-free edges; the floor keeps the cap alive with a small
135
+ // positive budget instead of disabling it (pre-v0.21.9 behavior).
136
+ const budget = Math.max(
137
+ 1,
138
+ ctxWindow - reserveTokens - safetyMargin - Math.max(0, summaryTokens),
139
+ );
140
+ let start = 0;
141
+ let tailTokens = 0;
142
+ for (let i = recentRaw.length - 1; i >= 0; i--) {
143
+ tailTokens +=
144
+ msgTokens != null
145
+ ? Math.max(0, msgTokens[i])
146
+ : estimateMessageTokens({ text: messageContentText(recentRaw[i]) });
147
+ if (tailTokens > budget) {
148
+ // Keep from i+1 onward; never drop below the FINAL message.
149
+ start = Math.min(i + 1, recentRaw.length - 1);
150
+ break;
151
+ }
152
+ }
153
+ // PREVENT-PI-002: never begin the preserved tail on an orphaned toolResult —
154
+ // its toolCall was dropped by the front-cut above. Advance past consecutive
155
+ // toolResults; the pair stays intact or drops whole.
156
+ while (
157
+ start < recentRaw.length - 1 &&
158
+ (recentRaw[start] as { role?: string }).role === "toolResult"
159
+ ) {
160
+ start++;
161
+ }
162
+ return { recent: recentRaw.slice(start), dropped: start };
163
+ }
164
+
165
+ /**
166
+ * v0.21.9: re-cap a REPLAYED trim tail (D.2 in context-handler.ts, D.3 in
167
+ * pipelineRun.ts). The replay paths return the cached trim view verbatim —
168
+ * which bypasses the fire-time tail cap. A model switch mid-epoch can shrink
169
+ * the window, leaving a replayed tail that fit the OLD window overflowing the
170
+ * NEW one. Re-runs applyTailCap against the CURRENT window with the margin
171
+ * stored at fire time (trimCache.safetyMarginPct), so the replayed view never
172
+ * exceeds what the gate would allow. Pure — no runtime dependency.
173
+ */
174
+ export function recapReplayedTail(opts: {
175
+ recentRaw: readonly AgentMessage[];
176
+ summaryAgentMsg: AgentMessage;
177
+ ctxWindow: number;
178
+ maxOutputTokens: number;
179
+ outputReservePct: number;
180
+ safetyMarginPct: number;
181
+ }): { recent: AgentMessage[]; dropped: number } {
182
+ return applyTailCap({
183
+ recentRaw: opts.recentRaw,
184
+ summaryTokens: estimateBlockTokens(messageContentText(opts.summaryAgentMsg)),
185
+ ctxWindow: opts.ctxWindow,
186
+ maxOutputTokens: opts.maxOutputTokens,
187
+ outputReservePct: opts.outputReservePct,
188
+ safetyMarginPct: opts.safetyMarginPct,
189
+ });
190
+ }
@@ -13,12 +13,9 @@
13
13
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
14
14
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
15
15
  import type { EngineMessage } from "../../../src/types.js";
16
- import {
17
- estimateBlockTokens,
18
- estimateMessageTokens,
19
- } from "../../../src/tokens.js";
16
+ import { estimateBlockTokens } from "../../../src/tokens.js";
20
17
  import { computeLiveTrimCut, liveTrimSummaryMessage } from "../../mega-trim.js";
21
- import { messageContentText } from "./messageText.js";
18
+ import { applyTailCap } from "./headroom.js";
22
19
  import type { MegaRuntime } from "../../mega-runtime.js";
23
20
  import type { MegaConfig } from "../../mega-config.js";
24
21
  import type { TailResultFn } from "./gateCheck.js";
@@ -136,59 +133,33 @@ export function buildLiveTrimView(
136
133
  // but has NO token cap, so a 2-message tail of two 80K bash outputs sails
137
134
  // right past the window.
138
135
  //
139
- // Cap: when the model context window is known, reserve room for the
140
- // summary + the model's max output tokens + a 10% safety margin, then
141
- // drop oldest preserved messages from the front of `recentRaw` until the
142
- // tail fits. Never drops below the FINAL message (always keep the latest
143
- // turn so the agent can respond). This is a last-resort HARD cap it
144
- // only fires when the preserved tail alone is oversized, which is rare.
136
+ // v0.21.9: the reserve + front-drop now lives in headroom.ts (single
137
+ // source shared with the gate's pre-fire headroom check and the D.2/D.3
138
+ // replay paths): (a) percent-based reserve plausible declared maxTokens
139
+ // wins, else clamp(MEGACOMPACT_OUTPUT_RESERVE_PCT, 10–95%) × window so
140
+ // the math is identical at any window size and a sentinel maxTokens
141
+ // (1e9/1e38) can no longer drive the budget negative and silently
142
+ // disable the cap; (b) budget floor (max(1, …)) so the cap stays active
143
+ // on every window; (c) pair-safe front-drop — the preserved tail never
144
+ // begins on an orphaned toolResult (PREVENT-PI-002).
145
145
  const ctxWindow = runtime.lastCtxWindow;
146
146
  // Reuse the per-model threshold resolved at the gate (single lookup).
147
147
  const modelThreshold = perModelThreshold;
148
- // Reserve room for output tokens. Use the model's reported max output
149
- // when known; fall back to 10% of the window (scales with any model —
150
- // 20K for a 200K window, 100K for a 1M window) so we never let the
151
- // preserved tail eat the model's output budget when maxTokens is unknown.
152
- const maxOutput =
153
- runtime.currentModel?.maxTokens && runtime.currentModel.maxTokens > 0
154
- ? runtime.currentModel.maxTokens
155
- : Math.ceil(ctxWindow * 0.1);
156
- let recent = recentRaw;
157
- if (ctxWindow > 0 && recentRaw.length > 1) {
158
- const summaryTokens = estimateBlockTokens(summaryMsg.text);
159
- // Reserve: summary + max output + per-model safety margin (0-20%).
160
- const safetyMargin = Math.ceil(
161
- ctxWindow * (modelThreshold.safetyMarginPct / 100),
162
- );
163
- const budget = ctxWindow - maxOutput - safetyMargin - summaryTokens;
164
- if (budget > 0) {
165
- // Walk recent from the front, dropping oldest first until the
166
- // remaining tail fits. Use the AgentMessage→engine-text estimate via
167
- // messageContentText (already imported) + estimateMessageTokens.
168
- let tailTokens = 0;
169
- for (let i = recentRaw.length - 1; i >= 0; i--) {
170
- const m = recentRaw[i];
171
- tailTokens += estimateMessageTokens({
172
- text: messageContentText(m),
173
- });
174
- if (tailTokens > budget) {
175
- // Keep from i+1 onward; but never fewer than the final message.
176
- const startIdx = Math.min(i + 1, recentRaw.length - 1);
177
- if (startIdx > 0) {
178
- recent = recentRaw.slice(startIdx);
179
- runtime.logger.warn("live-trim-tail-cap", {
180
- sessionId: runtime.rt.sessionId,
181
- dropped: startIdx,
182
- tailTokens,
183
- safetyMarginPct: modelThreshold.safetyMarginPct,
184
- budget,
185
- ctxWindow,
186
- });
187
- }
188
- break;
189
- }
190
- }
191
- }
148
+ const { recent, dropped } = applyTailCap({
149
+ recentRaw,
150
+ summaryTokens: estimateBlockTokens(summaryMsg.text),
151
+ ctxWindow,
152
+ maxOutputTokens: runtime.currentModel?.maxTokens ?? 0,
153
+ outputReservePct: config.outputReservePct,
154
+ safetyMarginPct: modelThreshold.safetyMarginPct,
155
+ });
156
+ if (dropped > 0) {
157
+ runtime.logger.warn("live-trim-tail-cap", {
158
+ sessionId: runtime.rt.sessionId,
159
+ dropped,
160
+ safetyMarginPct: modelThreshold.safetyMarginPct,
161
+ ctxWindow,
162
+ });
192
163
  }
193
164
 
194
165
  // v0.8.6: cache the trim view so subsequent gated calls in this epoch
@@ -214,6 +185,11 @@ export function buildLiveTrimView(
214
185
  summaryAgentMsg,
215
186
  ctxPct: pct ?? null,
216
187
  ctxTokens: currentTokens,
188
+ // v0.21.9: the D.2/D.3 replay paths re-cap the replayed tail against
189
+ // the CURRENT window (a model switch can change it mid-epoch). The
190
+ // margin % used at fire time is stored alongside so the replay uses
191
+ // the same reserve math as the fire that built the view.
192
+ safetyMarginPct: modelThreshold.safetyMarginPct,
217
193
  };
218
194
  runtime.snapshot(ctx);
219
195
  // DIAG (team-run relief): confirm the live trim actually fires + how big
@@ -21,6 +21,7 @@ import type { MegaConfig } from "../../mega-config.js";
21
21
  import type { TailResultFn } from "./gateCheck.js";
22
22
  import { recordCompactLatency } from "../../mega-runtime/vc-observer.js";
23
23
  import { decideLivePath } from "../../mega-runtime/vector-cortex-live.js";
24
+ import { recapReplayedTail } from "./headroom.js";
24
25
  import { defaultClock, type RolloutEvidence } from "../../../src/vector-cortex/rollout/gate.js";
25
26
  import { VC5C_ENABLED } from "../../../src/config/vector-cortex.js";
26
27
 
@@ -120,7 +121,19 @@ export function invokePipeline(
120
121
  runtime.trimCache.checkpointId === runtime.rt.lastCheckpointId &&
121
122
  runtime.trimCache.cut <= opts.messages.length
122
123
  ) {
123
- const recent = opts.messages.slice(runtime.trimCache.cut); // guardrails-allow PREVENT-PI-002: cached `cut` was sanitized by computeLiveTrimCut (src/boundary.ts); replayed verbatim, transcript only grows within an epoch.
124
+ const recentRaw = opts.messages.slice(runtime.trimCache.cut); // guardrails-allow PREVENT-PI-002: cached `cut` was sanitized by computeLiveTrimCut (src/boundary.ts); replayed verbatim, transcript only grows within an epoch.
125
+ // v0.21.9: RE-CAP the replayed tail against the CURRENT window —
126
+ // the D.3 skip-replay bypasses the fire-time tail cap exactly like
127
+ // D.2; a model switch mid-epoch can shrink the window below what
128
+ // the cached view was built for. No-op when the tail already fits.
129
+ const { recent } = recapReplayedTail({
130
+ recentRaw,
131
+ summaryAgentMsg: runtime.trimCache.summaryAgentMsg,
132
+ ctxWindow: runtime.lastCtxWindow,
133
+ maxOutputTokens: runtime.currentModel?.maxTokens ?? 0,
134
+ outputReservePct: config.outputReservePct,
135
+ safetyMarginPct: runtime.trimCache.safetyMarginPct,
136
+ });
124
137
  runtime.diagLiveTrimFires++;
125
138
  runtime.diagLiveTrimReplays++;
126
139
  runtime.snapshot(ctx);
@@ -34,6 +34,7 @@ import {
34
34
  } from "./context-handler/thrashGuard.js";
35
35
  import { invokePipeline } from "./context-handler/pipelineRun.js";
36
36
  import { buildLiveTrimView } from "./context-handler/liveTrim.js";
37
+ import { recapReplayedTail } from "./context-handler/headroom.js";
37
38
 
38
39
  /** Register the context event handler (live-trim auto-trigger). */
39
40
  export function registerContextHandler(
@@ -116,7 +117,21 @@ export function registerContextHandler(
116
117
  /* non-fatal */
117
118
  }
118
119
  runtime.lastCtxPercent = pct ?? null;
119
- runtime.lastCtxWindow = usage?.contextWindow ?? 0;
120
+ // Resolve the model window used by the token gate + live-trim tail-cap.
121
+ // Prefer the provider-reported usage window (authoritative when present);
122
+ // fall back to the captured model snapshot's contextWindow (populated from
123
+ // models.json / pi's model registry) when the provider does not report it
124
+ // via getContextUsage(). plexus (OpenAI-compatible) omits contextWindow in
125
+ // usage, so without this fallback lastCtxWindow is 0 for those providers —
126
+ // which silently disables the live-trim tail-cap (guarded on ctxWindow>0)
127
+ // and the token-gate window math, so mega-compact never reserves output
128
+ // headroom and a 32k model's own output overflows the window each turn.
129
+ // Mirrors the gate's existing pct fallback (gateCheck.ts S27).
130
+ const reportedWindow = usage?.contextWindow ?? 0;
131
+ runtime.lastCtxWindow =
132
+ reportedWindow > 0
133
+ ? reportedWindow
134
+ : (runtime.currentModel?.contextWindow ?? 0);
120
135
  runtime.snapshot(ctx);
121
136
  if (!config.auto) {
122
137
  const tailed = tailResult();
@@ -159,7 +174,23 @@ export function registerContextHandler(
159
174
  : currentTokens - (runtime.trimCache.ctxTokens ?? 0) >=
160
175
  runtime.effectiveThreshold * 0.5;
161
176
  if (!grewEnough) {
162
- const recent = messages.slice(runtime.trimCache.cut); // guardrails-allow PREVENT-PI-002: cached `cut` was sanitized once by computeLiveTrimCut (src/boundary.ts) and replayed verbatim; the transcript only grows within an epoch (cache is cleared on durable truncation), so the preserved run still starts on a toolPair-safe index.
177
+ const recentRaw = messages.slice(runtime.trimCache.cut); // guardrails-allow PREVENT-PI-002: cached `cut` was sanitized once by computeLiveTrimCut (src/boundary.ts) and replayed verbatim; the transcript only grows within an epoch (cache is cleared on durable truncation), so the preserved run still starts on a toolPair-safe index.
178
+ // v0.21.9: RE-CAP the replayed tail against the CURRENT window.
179
+ // Replay returns the cached view verbatim, which bypasses the
180
+ // fire-time tail cap — a model switch mid-epoch can shrink the
181
+ // window and leave a replayed tail that fit the OLD window
182
+ // overflowing the NEW one. Same reserve math as the fire
183
+ // (margin % stored in the cache at fire time); no-op when the
184
+ // tail already fits. Pair-safe (applyTailCap advances past any
185
+ // leading toolResult its front-drop exposes).
186
+ const { recent } = recapReplayedTail({
187
+ recentRaw,
188
+ summaryAgentMsg: runtime.trimCache.summaryAgentMsg,
189
+ ctxWindow: runtime.lastCtxWindow,
190
+ maxOutputTokens: runtime.currentModel?.maxTokens ?? 0,
191
+ outputReservePct: config.outputReservePct,
192
+ safetyMarginPct: runtime.trimCache.safetyMarginPct,
193
+ });
163
194
  runtime.diagLiveTrimFires++; // trim view returned this call (replay counts as a fire)
164
195
  runtime.diagLiveTrimReplays++;
165
196
  runtime.snapshot(ctx);
@@ -180,7 +211,7 @@ export function registerContextHandler(
180
211
  // invokePipeline (the real fire point), so it covers the percent + token
181
212
  // gate paths alike. Umbrella OFF ⇒ never blocks (byte-identical). Returns
182
213
  // the tailed view so a staged recall block still rides along.
183
- if (thrashGuardBlocks(runtime, config, currentTokens)) {
214
+ if (thrashGuardBlocks(runtime, config, currentTokens, gate.headroomExceeded)) {
184
215
  runtime.diagCtxFastGate++;
185
216
  runtime.snapshot(ctx);
186
217
  return tailResult() ?? undefined;
@@ -49,6 +49,8 @@ export interface SnapshotBuildContext {
49
49
  readonly diagCtxFastGate: number;
50
50
  readonly diagLiveTrimFires: number;
51
51
  readonly diagLiveTrimReplays: number;
52
+ /** v0.21.9: output-headroom gate trips (pre-overflow compaction fires). */
53
+ readonly diagCtxHeadroomTrip: number;
52
54
  readonly errorRetryCount: number;
53
55
  readonly consecutiveErrors: number;
54
56
  readonly ERROR_RETRY_MAX_CONSECUTIVE: number;
@@ -190,6 +192,7 @@ export function buildDashboardSnapshot(ctx: SnapshotBuildContext): DashboardSnap
190
192
  ctxFastGate: ctx.diagCtxFastGate,
191
193
  liveTrimFires: ctx.diagLiveTrimFires,
192
194
  liveTrimReplays: ctx.diagLiveTrimReplays,
195
+ headroomTrips: ctx.diagCtxHeadroomTrip,
193
196
  },
194
197
  retries: {
195
198
  errorRetryCount: ctx.errorRetryCount,
@@ -39,6 +39,7 @@ export class RuntimeInstrumentation {
39
39
  diagCtxCutNull = 0; // computeLiveTrimCut returned null (anchor/boundary)
40
40
  diagCtxThrown = 0; // live-trim try threw (caught)
41
41
  diagCtxOutputErrorTrip = 0; // Phase H: output-error catch tripped a forced compaction
42
+ diagCtxHeadroomTrip = 0; // v0.21.9: output-headroom gate tripped a pre-overflow compaction
42
43
 
43
44
  // Context health instrumentation (v0.12): rolling ring buffers for
44
45
  // drift detection + cache poison Layer 1 hash baseline.
@@ -79,6 +80,9 @@ export class RuntimeInstrumentation {
79
80
  summaryAgentMsg: AgentMessage;
80
81
  ctxPct: number | null;
81
82
  ctxTokens: number | null;
83
+ /** v0.21.9: safety margin % recorded at fire time so the D.2/D.3 replay
84
+ * paths can re-cap the replayed tail with the same reserve math. */
85
+ safetyMarginPct: number;
82
86
  } | null = null;
83
87
  debounceUntil = 0;
84
88
  // S16: debounce for the agent_end resume nudge (avoid busy-loops).