pi-mega-compact 0.7.7 → 0.7.8
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.test.js +221 -22
- package/dist/extensions/mega-config.js +10 -0
- package/dist/extensions/mega-events.js +64 -17
- package/dist/extensions/mega-runtime.js +19 -4
- package/extensions/mega-compact.test.ts +296 -35
- package/extensions/mega-config.ts +22 -0
- package/extensions/mega-events.ts +66 -18
- package/extensions/mega-runtime.ts +21 -4
- package/package.json +1 -1
|
@@ -44,6 +44,7 @@ function harness(opts = {}) {
|
|
|
44
44
|
let statusText;
|
|
45
45
|
const notifies = [];
|
|
46
46
|
const compactCalls = [];
|
|
47
|
+
const sendUserMessages = [];
|
|
47
48
|
// Minimal AgentMessage factory for the session we project into the extension.
|
|
48
49
|
function msg(role, text, toolName) {
|
|
49
50
|
if (role === "assistant" && toolName) {
|
|
@@ -178,7 +179,7 @@ function harness(opts = {}) {
|
|
|
178
179
|
registerMessageRenderer: () => { },
|
|
179
180
|
registerEntryRenderer: () => { },
|
|
180
181
|
sendMessage: (_m) => { },
|
|
181
|
-
sendUserMessage: () => { },
|
|
182
|
+
sendUserMessage: (m) => { sendUserMessages.push(m); },
|
|
182
183
|
appendEntry: (t, d) => appended.push({ t, d }),
|
|
183
184
|
setSessionName: () => { },
|
|
184
185
|
getSessionName: () => undefined,
|
|
@@ -205,6 +206,7 @@ function harness(opts = {}) {
|
|
|
205
206
|
},
|
|
206
207
|
notifies,
|
|
207
208
|
compactCalls,
|
|
209
|
+
sendUserMessages,
|
|
208
210
|
fire: (ev, event, ctx) => handlers[ev](event, ctx),
|
|
209
211
|
ctx: makeCtx,
|
|
210
212
|
session,
|
|
@@ -733,27 +735,6 @@ test("/dashboard skips server spawn when already running", async () => {
|
|
|
733
735
|
await new Promise((r) => server.close(() => r()));
|
|
734
736
|
delete process.env.MEGACOMPACT_DASHBOARD_PORT;
|
|
735
737
|
});
|
|
736
|
-
test("/dashboard-status reports running after dashboard start", async () => {
|
|
737
|
-
// Private dashboard port base for this harness — never collides with the
|
|
738
|
-
// parallel dashboard-server.test.js (9320 family) or a leftover server.
|
|
739
|
-
process.env.MEGACOMPACT_DASHBOARD_PORT = "39320";
|
|
740
|
-
const h = harness();
|
|
741
|
-
const livPort = 39320;
|
|
742
|
-
const { createServer } = await import("node:http");
|
|
743
|
-
const { join: j } = await import("node:path");
|
|
744
|
-
const { writeFileSync: wf } = await import("node:fs");
|
|
745
|
-
const server = createServer((_req, res) => {
|
|
746
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
747
|
-
res.end(JSON.stringify({ updatedAt: new Date().toISOString(), tier: "test" }));
|
|
748
|
-
});
|
|
749
|
-
await new Promise((r) => server.listen(livPort, "127.0.0.1", r));
|
|
750
|
-
wf(j(h.stateDir, "port.pid"), JSON.stringify({ port: livPort, pid: process.pid }));
|
|
751
|
-
const ctx = h.ctx();
|
|
752
|
-
await h.commands["mega-dashboard-status"].handler("", ctx);
|
|
753
|
-
assert.ok(h.notifies.some((n) => n.includes("running") && n.includes(String(livPort))), "reports running with port");
|
|
754
|
-
await new Promise((r) => server.close(() => r()));
|
|
755
|
-
delete process.env.MEGACOMPACT_DASHBOARD_PORT;
|
|
756
|
-
});
|
|
757
738
|
test("state snapshot writes dashboard.json after compaction", async () => {
|
|
758
739
|
const h = harness();
|
|
759
740
|
const ctx = h.ctx({
|
|
@@ -806,6 +787,224 @@ test("events.log receives compaction events", async () => {
|
|
|
806
787
|
assert.ok(ex(j(h.stateDir, "dashboard.json")), "dashboard.json proves post-compact ran");
|
|
807
788
|
}
|
|
808
789
|
});
|
|
790
|
+
test("S28: length-stop auto-continue nudges once, no ctx.compact on low-pressure length path", async () => {
|
|
791
|
+
const h = harness();
|
|
792
|
+
// Force a low-pressure context so the durable-trim branch (which calls
|
|
793
|
+
// ctx.compact()) is NOT taken; only the length-stop nudge should fire.
|
|
794
|
+
const lowPressureCtx = h.ctx({
|
|
795
|
+
isIdle: () => true,
|
|
796
|
+
hasPendingMessages: () => false,
|
|
797
|
+
getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }),
|
|
798
|
+
});
|
|
799
|
+
// 1) Normal stop: no length flag armed → no nudge.
|
|
800
|
+
await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message: { role: "assistant", stopReason: "stop" } }, lowPressureCtx);
|
|
801
|
+
await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
|
|
802
|
+
assert.equal(h.sendUserMessages.length, 0, "normal stop: no nudge");
|
|
803
|
+
assert.equal(h.compactCalls.length, 0, "normal stop: no ctx.compact");
|
|
804
|
+
// 2) Length stop: arms the flag, agent_end fires exactly one continue nudge
|
|
805
|
+
// that references the output-token truncation (not a compaction).
|
|
806
|
+
await h.fire("turn_end", { type: "turn_end", turnIndex: 2, message: { role: "assistant", stopReason: "length" } }, lowPressureCtx);
|
|
807
|
+
await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
|
|
808
|
+
assert.equal(h.sendUserMessages.length, 1, "length stop: exactly one nudge");
|
|
809
|
+
assert.match(h.sendUserMessages[0], /output-token cap/, "length stop: nudge references the output-token truncation");
|
|
810
|
+
assert.equal(h.compactCalls.length, 0, "length path: ctx.compact() NOT called (low pressure)");
|
|
811
|
+
// 3) One-shot: a second agent_end without a new length stop must NOT re-nudge.
|
|
812
|
+
await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
|
|
813
|
+
assert.equal(h.sendUserMessages.length, 1, "one-shot: no second nudge without a new length stop");
|
|
814
|
+
});
|
|
815
|
+
test("S28: length-stop auto-continue fires even when config.auto === false (autoContinueLengthStop is the sole gate)", async () => {
|
|
816
|
+
// Disable auto (durable-trim + queued-resume) but keep the length-stop flag on.
|
|
817
|
+
// Set BEFORE harness() loads the compiled extension so loadConfig() picks it up.
|
|
818
|
+
const prevAuto = process.env.MEGACOMPACT_AUTO;
|
|
819
|
+
process.env.MEGACOMPACT_AUTO = "false";
|
|
820
|
+
try {
|
|
821
|
+
// Re-load the extension with the new env so config.auto is false but
|
|
822
|
+
// autoContinueLengthStop stays true (default).
|
|
823
|
+
const h2 = harness();
|
|
824
|
+
const lowPressureCtx = h2.ctx({
|
|
825
|
+
isIdle: () => true,
|
|
826
|
+
hasPendingMessages: () => false,
|
|
827
|
+
getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }),
|
|
828
|
+
});
|
|
829
|
+
// Length stop arms the flag; agent_end must still nudge despite auto=false.
|
|
830
|
+
await h2.fire("turn_end", { type: "turn_end", turnIndex: 1, message: { role: "assistant", stopReason: "length" } }, lowPressureCtx);
|
|
831
|
+
await h2.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
|
|
832
|
+
assert.equal(h2.sendUserMessages.length, 1, "auto=false: length stop still nudges");
|
|
833
|
+
assert.match(h2.sendUserMessages[0], /output-token cap/, "auto=false: nudge references the output-token truncation");
|
|
834
|
+
assert.equal(h2.compactCalls.length, 0, "auto=false: ctx.compact() NOT called (auto gates durable-trim)");
|
|
835
|
+
}
|
|
836
|
+
finally {
|
|
837
|
+
if (prevAuto === undefined)
|
|
838
|
+
delete process.env.MEGACOMPACT_AUTO;
|
|
839
|
+
else
|
|
840
|
+
process.env.MEGACOMPACT_AUTO = prevAuto;
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
// Helper: read <stateDir>/events.log JSONL and return the list of event `type`s.
|
|
844
|
+
// Dashboard.event (extensions/mega-dashboard.ts) appends `{ ts, type, ...data }`
|
|
845
|
+
// per line. Used to assert the S28 length_stop / length_stop_continue dashboard
|
|
846
|
+
// events fire on the right paths (spec acceptance #7; OPEN issue #3).
|
|
847
|
+
function eventTypes(stateDir) {
|
|
848
|
+
const { readFileSync: rf, existsSync: ex } = require("node:fs");
|
|
849
|
+
const { join: j } = require("node:path");
|
|
850
|
+
const logPath = j(stateDir, "events.log");
|
|
851
|
+
if (!ex(logPath))
|
|
852
|
+
return [];
|
|
853
|
+
const content = rf(logPath, "utf-8").trim();
|
|
854
|
+
if (content.length === 0)
|
|
855
|
+
return [];
|
|
856
|
+
return content
|
|
857
|
+
.split("\n")
|
|
858
|
+
.map((line) => {
|
|
859
|
+
try {
|
|
860
|
+
return JSON.parse(line).type;
|
|
861
|
+
}
|
|
862
|
+
catch {
|
|
863
|
+
return undefined;
|
|
864
|
+
}
|
|
865
|
+
})
|
|
866
|
+
.filter((t) => typeof t === "string");
|
|
867
|
+
}
|
|
868
|
+
test("S28: length_stop + length_stop_continue dashboard events fire on the right paths", async () => {
|
|
869
|
+
const h = harness();
|
|
870
|
+
const lowPressureCtx = h.ctx({
|
|
871
|
+
isIdle: () => true,
|
|
872
|
+
hasPendingMessages: () => false,
|
|
873
|
+
getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }),
|
|
874
|
+
});
|
|
875
|
+
// Normal stop: no length_stop event, no nudge, no length_stop_continue.
|
|
876
|
+
await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message: { role: "assistant", stopReason: "stop" } }, lowPressureCtx);
|
|
877
|
+
await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
|
|
878
|
+
const afterNormal = eventTypes(h.stateDir);
|
|
879
|
+
assert.ok(!afterNormal.includes("length_stop"), "normal stop: no length_stop dashboard event");
|
|
880
|
+
assert.ok(!afterNormal.includes("length_stop_continue"), "normal stop: no length_stop_continue dashboard event");
|
|
881
|
+
assert.equal(h.sendUserMessages.length, 0, "normal stop: no nudge");
|
|
882
|
+
// Length stop: length_stop fires on turn_end, length_stop_continue on agent_end.
|
|
883
|
+
await h.fire("turn_end", { type: "turn_end", turnIndex: 2, message: { role: "assistant", stopReason: "length" } }, lowPressureCtx);
|
|
884
|
+
const afterTurnEnd = eventTypes(h.stateDir);
|
|
885
|
+
assert.ok(afterTurnEnd.includes("length_stop"), "length stop: length_stop dashboard event fired on turn_end");
|
|
886
|
+
await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
|
|
887
|
+
const afterAgentEnd = eventTypes(h.stateDir);
|
|
888
|
+
assert.ok(afterAgentEnd.includes("length_stop_continue"), "length stop: length_stop_continue dashboard event fired on agent_end");
|
|
889
|
+
assert.equal(h.sendUserMessages.length, 1, "length stop: exactly one nudge");
|
|
890
|
+
});
|
|
891
|
+
test("S28: non-length stopReasons do not arm the flag (no nudge, no length_stop event)", async () => {
|
|
892
|
+
const h = harness();
|
|
893
|
+
const lowPressureCtx = h.ctx({
|
|
894
|
+
isIdle: () => true,
|
|
895
|
+
hasPendingMessages: () => false,
|
|
896
|
+
getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }),
|
|
897
|
+
});
|
|
898
|
+
// Every other pi-ai StopReason must leave the flag unset → no nudge + no event.
|
|
899
|
+
for (const stopReason of ["tool_use", "error", "aborted"]) {
|
|
900
|
+
await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message: { role: "assistant", stopReason } }, lowPressureCtx);
|
|
901
|
+
await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
|
|
902
|
+
}
|
|
903
|
+
assert.equal(h.sendUserMessages.length, 0, "non-length stopReasons: no nudge");
|
|
904
|
+
assert.ok(!eventTypes(h.stateDir).includes("length_stop"), "non-length stopReasons: no length_stop dashboard event");
|
|
905
|
+
});
|
|
906
|
+
// ---- S29: percent-based auto-compact trigger (gate on context %, not tokens) -
|
|
907
|
+
// The context-handler gate now fires on pct/100 >= (autoPctTrigger ?? tierPct)
|
|
908
|
+
// for tiered configs, with a token FALLBACK when pct is null. `custom` keeps the
|
|
909
|
+
// absolute token gate. These are the first tests to drive a `context` event
|
|
910
|
+
// on a tiered config (the default harness forces custom via THRESHOLD_TOKENS=50).
|
|
911
|
+
/** S29 tiered-config helper: tiered (not custom), low tier (tierPct 0.5), with
|
|
912
|
+
* the legacy durable-trim flag off + anchor floor lowered so the live trim
|
|
913
|
+
* returns a trimmed view (mirrors the S16 live-trim test setup at ~line 329). */
|
|
914
|
+
function s29TieredCtx(h, usage) {
|
|
915
|
+
delete process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM;
|
|
916
|
+
delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
|
|
917
|
+
process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES = "1";
|
|
918
|
+
return h.ctx({
|
|
919
|
+
isIdle: () => true,
|
|
920
|
+
hasPendingMessages: () => false,
|
|
921
|
+
getContextUsage: () => usage,
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
test("S29: percent gate fires when tokens under-report (tiered low, percent 55, tokens 10)", async () => {
|
|
925
|
+
process.env.MEGACOMPACT_TIER = "low";
|
|
926
|
+
delete process.env.MEGACOMPACT_THRESHOLD_TOKENS;
|
|
927
|
+
delete process.env.MEGACOMPACT_AUTO_PCT_TRIGGER;
|
|
928
|
+
try {
|
|
929
|
+
const h = harness({ keepTier: true, keepThreshold: true });
|
|
930
|
+
// tokens=10 (under the 0.5×10000=5000 token gate), percent=55 (>= 0.5).
|
|
931
|
+
// The OLD token-only gate would return (10 < 5000) → no trim. The S29
|
|
932
|
+
// percent gate (0.55 >= 0.5) fires → live trim returns a trimmed view.
|
|
933
|
+
const ctx = s29TieredCtx(h, { tokens: 10, contextWindow: 10000, percent: 55 });
|
|
934
|
+
const res = await h.fire("context", { type: "context", messages: h.session }, ctx);
|
|
935
|
+
assert.ok(res && typeof res === "object", "percent gate: live trim returned a result object");
|
|
936
|
+
assert.ok(Array.isArray(res.messages), "percent gate: result has a trimmed messages array");
|
|
937
|
+
assert.ok(res.messages.length < h.session.length, "percent gate: trimmed view is shorter than the full session");
|
|
938
|
+
assert.equal(h.compactCalls.length, 0, "percent gate: live trim, no ctx.compact()");
|
|
939
|
+
// Control: percent 40 (< 0.5) → no trim, even with the same under-reported tokens.
|
|
940
|
+
const h2 = harness({ keepTier: true, keepThreshold: true });
|
|
941
|
+
const ctx2 = s29TieredCtx(h2, { tokens: 10, contextWindow: 10000, percent: 40 });
|
|
942
|
+
const res2 = await h2.fire("context", { type: "context", messages: h2.session }, ctx2);
|
|
943
|
+
assert.ok(!(res2 && typeof res2 === "object" && Array.isArray(res2.messages)), "percent below fire point: no trim (token count 10 is also below the token gate)");
|
|
944
|
+
}
|
|
945
|
+
finally {
|
|
946
|
+
delete process.env.MEGACOMPACT_TIER;
|
|
947
|
+
delete process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
test("S29: MEGACOMPACT_AUTO_PCT_TRIGGER overrides the tier fire point (0.85)", async () => {
|
|
951
|
+
process.env.MEGACOMPACT_TIER = "low"; // tierPct 0.5
|
|
952
|
+
process.env.MEGACOMPACT_AUTO_PCT_TRIGGER = "0.85";
|
|
953
|
+
delete process.env.MEGACOMPACT_THRESHOLD_TOKENS;
|
|
954
|
+
try {
|
|
955
|
+
// percent 80 < 0.85 → no trim.
|
|
956
|
+
const h = harness({ keepTier: true, keepThreshold: true });
|
|
957
|
+
const ctx80 = s29TieredCtx(h, { tokens: 10, contextWindow: 10000, percent: 80 });
|
|
958
|
+
const res80 = await h.fire("context", { type: "context", messages: h.session }, ctx80);
|
|
959
|
+
assert.ok(!(res80 && typeof res80 === "object" && Array.isArray(res80.messages)), "override 0.85: percent 80 does NOT trim (below the override fire point)");
|
|
960
|
+
// percent 90 >= 0.85 → trim fires (despite the tier's own 0.5 fire point).
|
|
961
|
+
const h2 = harness({ keepTier: true, keepThreshold: true });
|
|
962
|
+
const ctx90 = s29TieredCtx(h2, { tokens: 10, contextWindow: 10000, percent: 90 });
|
|
963
|
+
const res90 = await h2.fire("context", { type: "context", messages: h2.session }, ctx90);
|
|
964
|
+
assert.ok(res90 && Array.isArray(res90.messages) && res90.messages.length < h2.session.length, "override 0.85: percent 90 DOES trim (above the override fire point)");
|
|
965
|
+
}
|
|
966
|
+
finally {
|
|
967
|
+
delete process.env.MEGACOMPACT_TIER;
|
|
968
|
+
delete process.env.MEGACOMPACT_AUTO_PCT_TRIGGER;
|
|
969
|
+
delete process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
test("S29: custom tier keeps the absolute token gate (percent 40 but tokens 100 >= 50)", async () => {
|
|
973
|
+
// MEGACOMPACT_THRESHOLD_TOKENS → custom (tierPct null) → token gate, percent ignored.
|
|
974
|
+
process.env.MEGACOMPACT_THRESHOLD_TOKENS = "50";
|
|
975
|
+
delete process.env.MEGACOMPACT_TIER;
|
|
976
|
+
delete process.env.MEGACOMPACT_AUTO_PCT_TRIGGER;
|
|
977
|
+
try {
|
|
978
|
+
const h = harness({ keepTier: true, keepThreshold: true });
|
|
979
|
+
// percent 40 (low) BUT tokens 100 >= 50 threshold → custom token gate fires.
|
|
980
|
+
const ctx = s29TieredCtx(h, { tokens: 100, contextWindow: 10000, percent: 40 });
|
|
981
|
+
const res = await h.fire("context", { type: "context", messages: h.session }, ctx);
|
|
982
|
+
assert.ok(res && Array.isArray(res.messages) && res.messages.length < h.session.length, "custom tier: token gate fires (tokens 100 >= 50) despite low percent 40");
|
|
983
|
+
}
|
|
984
|
+
finally {
|
|
985
|
+
delete process.env.MEGACOMPACT_THRESHOLD_TOKENS;
|
|
986
|
+
delete process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
|
|
987
|
+
}
|
|
988
|
+
});
|
|
989
|
+
test("S29: tiered config with pct==null falls back to the token gate (not skipped)", async () => {
|
|
990
|
+
// The regression guard for the audit finding: a percent-ONLY gate would skip
|
|
991
|
+
// compaction when percent is unreported. S29 falls back to the token gate
|
|
992
|
+
// (S27 boot-fallback guarantee). tiered low: effectiveThreshold = 0.5×10000 = 5000;
|
|
993
|
+
// tokens 6000 >= 5000 → token fallback fires.
|
|
994
|
+
process.env.MEGACOMPACT_TIER = "low";
|
|
995
|
+
delete process.env.MEGACOMPACT_THRESHOLD_TOKENS;
|
|
996
|
+
delete process.env.MEGACOMPACT_AUTO_PCT_TRIGGER;
|
|
997
|
+
try {
|
|
998
|
+
const h = harness({ keepTier: true, keepThreshold: true });
|
|
999
|
+
const ctx = s29TieredCtx(h, { tokens: 6000, contextWindow: 10000, percent: null });
|
|
1000
|
+
const res = await h.fire("context", { type: "context", messages: h.session }, ctx);
|
|
1001
|
+
assert.ok(res && Array.isArray(res.messages) && res.messages.length < h.session.length, "pct==null on tiered: token fallback fires (NOT skipped) — S27 boot-fallback preserved");
|
|
1002
|
+
}
|
|
1003
|
+
finally {
|
|
1004
|
+
delete process.env.MEGACOMPACT_TIER;
|
|
1005
|
+
delete process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
|
|
1006
|
+
}
|
|
1007
|
+
});
|
|
809
1008
|
test("cleanup", async () => {
|
|
810
1009
|
// Terminate the global PGlite cross-repo index (WASM worker thread) so the
|
|
811
1010
|
// test process can exit. Without this, node --test never returns even though
|
|
@@ -118,6 +118,14 @@ export { pressureFromPct, preserveRecentForPressure, pressureRatio, pressureBand
|
|
|
118
118
|
/** Build the resolved config from env + defaults. */
|
|
119
119
|
export function loadConfig() {
|
|
120
120
|
const { tier, tierPct, thresholdTokens } = resolveThreshold();
|
|
121
|
+
// S29: optional percent-based fire-point override for tiered configs.
|
|
122
|
+
// null = inherit tierPct (default; preserves existing fire points). Clamped
|
|
123
|
+
// to [0.1, 1] so a bogus env can't disable or invert the gate. Ignored by
|
|
124
|
+
// the `custom` tier (tierPct null) which keeps the absolute token gate.
|
|
125
|
+
const aptRaw = process.env.MEGACOMPACT_AUTO_PCT_TRIGGER;
|
|
126
|
+
const autoPctTrigger = aptRaw && aptRaw !== "" && Number.isFinite(Number(aptRaw))
|
|
127
|
+
? Math.min(1, Math.max(0.1, Number(aptRaw)))
|
|
128
|
+
: null;
|
|
121
129
|
return {
|
|
122
130
|
tier,
|
|
123
131
|
tierPct,
|
|
@@ -131,6 +139,8 @@ export function loadConfig() {
|
|
|
131
139
|
preserveRecentMin: envFlag("MEGACOMPACT_PRESERVE_RECENT_MIN", 2),
|
|
132
140
|
auto: envBool("MEGACOMPACT_AUTO", true),
|
|
133
141
|
autoInline: envBool("MEGACOMPACT_AUTO_INLINE", true),
|
|
142
|
+
autoContinueLengthStop: envBool("MEGACOMPACT_AUTO_CONTINUE_LENGTH_STOP", true),
|
|
143
|
+
autoPctTrigger,
|
|
134
144
|
autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
|
|
135
145
|
dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
|
|
136
146
|
raptorEnabled: envBool("MEGACOMPACT_RAPTOR_ENABLED", true),
|
|
@@ -16,7 +16,7 @@ import { runCompact, doRecall, doRecallAsync, piCompactWouldNoop, runMemoryRevie
|
|
|
16
16
|
import { recallMemoriesAndInline } from "../src/recall.js";
|
|
17
17
|
import { driveNativeCompaction, } from "./mega-compact-driver.js";
|
|
18
18
|
import { computeLiveTrimCut, liveTrimSummaryMessage } from "./mega-trim.js";
|
|
19
|
-
import { pressureFromPct, memoryReviewCadence, } from "./mega-config.js";
|
|
19
|
+
import { pressureFromPct, pressureRatio, memoryReviewCadence, } from "./mega-config.js";
|
|
20
20
|
import { createHash } from "node:crypto";
|
|
21
21
|
/**
|
|
22
22
|
* Convert a pi AgentMessage to a RawTranscriptRow for the DB mirror.
|
|
@@ -217,7 +217,7 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
217
217
|
// compaction AND there is queued work AND we haven't nudged recently, nudge
|
|
218
218
|
// once so the agent continues (the live trim should make this rare). Guarded
|
|
219
219
|
// to never busy-loop: one nudge per 30s, only when truly idle + queued.
|
|
220
|
-
if (config.auto && runtime.activeAgents === 0) {
|
|
220
|
+
if ((config.auto || config.autoContinueLengthStop) && runtime.activeAgents === 0) {
|
|
221
221
|
try {
|
|
222
222
|
const idle = ctx.isIdle?.() ?? true;
|
|
223
223
|
const queued = ctx.hasPendingMessages?.() ?? false;
|
|
@@ -264,7 +264,7 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
264
264
|
// most. Instead we DECOUPLE the nudge from `queued`: after a durable
|
|
265
265
|
// trim we ALWAYS nudge so the agent reliably restarts. Debounced 30s.
|
|
266
266
|
let didDurableTrim = false;
|
|
267
|
-
if (idle && overThreshold && now >= runtime.debounceUntil) {
|
|
267
|
+
if (config.auto && idle && overThreshold && now >= runtime.debounceUntil) {
|
|
268
268
|
// COMPACT-DEDUP FIX: skip the manual durable-trim trigger when pi's
|
|
269
269
|
// NATIVE auto-compaction just fired (or is in-flight). pi emits
|
|
270
270
|
// agent_end BEFORE its own _checkCompaction (per its docstring:
|
|
@@ -295,11 +295,26 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
295
295
|
// Restart the agent after a mid-run durable trim (which stopped it), or
|
|
296
296
|
// when it settled idle with queued work. Decoupled from `queued` for the
|
|
297
297
|
// durable-trim case — see FIX note above. Debounced 30s; never blocks.
|
|
298
|
+
const lengthStop = config.autoContinueLengthStop && runtime.rt.lengthStopPending;
|
|
298
299
|
if (idle &&
|
|
299
300
|
now >= runtime.resumeNudgeUntil &&
|
|
300
|
-
(didDurableTrim || queued)) {
|
|
301
|
+
((config.auto && (didDurableTrim || queued)) || lengthStop)) {
|
|
301
302
|
runtime.resumeNudgeUntil = now + 30_000;
|
|
302
|
-
|
|
303
|
+
if (runtime.rt.lengthStopPending) {
|
|
304
|
+
runtime.rt.lengthStopPending = false; // one-shot: never re-fire for same stop
|
|
305
|
+
runtime.dashboard.event("length_stop_continue", { turnIndex: runtime.currentTurn });
|
|
306
|
+
runtime.logger.info("length_stop_continue", {
|
|
307
|
+
sessionId: runtime.rt.sessionId,
|
|
308
|
+
didDurableTrim,
|
|
309
|
+
queued,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
// S28: when a length-stop (max-output-token truncation) fired WITHOUT a durable trim, do NOT claim a compaction happened
|
|
313
|
+
// (nothing was compacted on the low-pressure length path). Branch the message so the nudge matches reality.
|
|
314
|
+
const nudgeMsg = lengthStop && !didDurableTrim
|
|
315
|
+
? "[mega-compact] the last response hit the output-token cap; continue from where it stopped."
|
|
316
|
+
: "[mega-compact] continue from the compacted context above.";
|
|
317
|
+
pi.sendUserMessage(nudgeMsg);
|
|
303
318
|
}
|
|
304
319
|
}
|
|
305
320
|
catch {
|
|
@@ -310,6 +325,7 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
310
325
|
});
|
|
311
326
|
pi.on("turn_start", async (event, ctx) => {
|
|
312
327
|
runtime.currentTurn = event.turnIndex;
|
|
328
|
+
runtime.rt.lengthStopPending = false; // S28: re-arm defensively each user turn
|
|
313
329
|
runtime.dashboard.event("turn_start", { turnIndex: event.turnIndex });
|
|
314
330
|
runtime.snapshot(ctx);
|
|
315
331
|
});
|
|
@@ -333,6 +349,15 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
333
349
|
await runMemoryReview(runtime, view, "turn");
|
|
334
350
|
}
|
|
335
351
|
}
|
|
352
|
+
// S28: detect max-output-token truncation. event.message.stopReason is the
|
|
353
|
+
// pi-ai StopReason union; 'length' == generation hit max_tokens OUTPUT cap
|
|
354
|
+
// (INPUT-orthogonal to context-window overflow). Arm the agent_end nudge.
|
|
355
|
+
if (config.autoContinueLengthStop &&
|
|
356
|
+
event.message.role === "assistant" &&
|
|
357
|
+
event.message.stopReason === "length") {
|
|
358
|
+
runtime.rt.lengthStopPending = true;
|
|
359
|
+
runtime.dashboard.event("length_stop", { turnIndex: event.turnIndex });
|
|
360
|
+
}
|
|
336
361
|
});
|
|
337
362
|
// ---- Auto-trigger: live trim (compact and continue) + native durable ----
|
|
338
363
|
// S16 redesign: we NO LONGER call ctx.compact() from the auto-trigger by
|
|
@@ -358,13 +383,11 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
358
383
|
runtime.lastCtxPercent = pct ?? null;
|
|
359
384
|
runtime.lastCtxWindow = usage?.contextWindow ?? 0;
|
|
360
385
|
runtime.snapshot(ctx);
|
|
361
|
-
if (pct == null)
|
|
362
|
-
return;
|
|
363
386
|
const messages = event.messages;
|
|
364
387
|
const view = runtime.engineView(messages);
|
|
365
388
|
const currentTokens = usage?.tokens ??
|
|
366
389
|
estimateSessionTokens(view) ??
|
|
367
|
-
Math.round((pct / 100) * (usage?.contextWindow ?? 0));
|
|
390
|
+
Math.round(((pct ?? 0) / 100) * (usage?.contextWindow ?? 0));
|
|
368
391
|
// S27 DB-mirror: append ALL incoming messages to raw_transcript.
|
|
369
392
|
// Runs BEFORE fast-gate so every message is captured, even if we
|
|
370
393
|
// don't compact this turn. Append is idempotent (content_hash PK).
|
|
@@ -382,14 +405,36 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
382
405
|
runtime.logger.warn("db-mirror-append-fail", { error: String(e) });
|
|
383
406
|
}
|
|
384
407
|
}
|
|
385
|
-
// FAST GATE:
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
408
|
+
// S29 FAST GATE: drive the auto-trigger off the context % (the number the
|
|
409
|
+
// menu bar shows), NOT the token count — the model under-reports tokens,
|
|
410
|
+
// so a token-only gate misses the overshoot that causes max-output-token
|
|
411
|
+
// truncation. The fire point is the tier's percent threshold (tierPct)
|
|
412
|
+
// unless overridden by MEGACOMPACT_AUTO_PCT_TRIGGER. `custom` (absolute
|
|
413
|
+
// MEGACOMPACT_THRESHOLD_TOKENS, tierPct null) is an explicit opt-out of
|
|
414
|
+
// percent scaling — it keeps the token gate. When pct is unavailable
|
|
415
|
+
// (window unknown / a model that doesn't report percent) a tiered config
|
|
416
|
+
// falls back to the token gate (S27 boot-fallback guarantee) instead of
|
|
417
|
+
// skipping compaction — a percent-only gate would regress that.
|
|
418
|
+
let gatePassed = false;
|
|
419
|
+
if (config.tierPct != null && pct != null) {
|
|
420
|
+
const firePct = config.autoPctTrigger ?? config.tierPct;
|
|
421
|
+
gatePassed = pct / 100 >= firePct;
|
|
389
422
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
runtime.
|
|
423
|
+
else {
|
|
424
|
+
// custom tier OR tiered-but-pct-unavailable → token gate (S27 fallback).
|
|
425
|
+
if (currentTokens < runtime.effectiveThreshold) {
|
|
426
|
+
runtime.diagCtxFastGate++;
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const check = autoCompactCheck(currentTokens, runtime.effectiveThreshold); // SERVER-STYLE CONFIRM (local)
|
|
430
|
+
if (!check.shouldCompact) {
|
|
431
|
+
runtime.diagCtxNoCompact++;
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
gatePassed = true;
|
|
435
|
+
}
|
|
436
|
+
if (!gatePassed) {
|
|
437
|
+
runtime.diagCtxFastGate++;
|
|
393
438
|
return;
|
|
394
439
|
}
|
|
395
440
|
// Debounce so we don't fire on every context event past threshold.
|
|
@@ -400,8 +445,10 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
400
445
|
}
|
|
401
446
|
runtime.debounceUntil = now + 2000;
|
|
402
447
|
// Adaptive compression (Fix E): scale compression strength + keepFrom depth
|
|
403
|
-
// with how close we are to the model context limit.
|
|
404
|
-
|
|
448
|
+
// with how close we are to the model context limit. Null-safe: when the
|
|
449
|
+
// token-fallback path ran (pct unavailable) use the token-basis pressure
|
|
450
|
+
// (the same basis the runtime `pressure` getter uses for custom/no-window).
|
|
451
|
+
const pressure = pct != null ? pressureFromPct(pct) : pressureRatio(currentTokens, runtime.effectiveThreshold);
|
|
405
452
|
const ran = runCompact(pi, runtime, config, ctx, messages, {
|
|
406
453
|
compressionPressure: pressure,
|
|
407
454
|
});
|
|
@@ -199,6 +199,7 @@ export class MegaRuntime {
|
|
|
199
199
|
compactCount: 0,
|
|
200
200
|
recallInjections: 0,
|
|
201
201
|
cacheHitTokens: 0,
|
|
202
|
+
lengthStopPending: false,
|
|
202
203
|
};
|
|
203
204
|
debounceUntil = 0;
|
|
204
205
|
// S16: debounce for the agent_end resume nudge (avoid busy-loops).
|
|
@@ -415,15 +416,26 @@ export class MegaRuntime {
|
|
|
415
416
|
}
|
|
416
417
|
: undefined;
|
|
417
418
|
// effectiveThresholdPct: the live fire point as a % of the window (null for
|
|
418
|
-
// `custom`, which has no tierPct).
|
|
419
|
-
|
|
419
|
+
// `custom`, which has no tierPct). S29: honors MEGACOMPACT_AUTO_PCT_TRIGGER
|
|
420
|
+
// override so the dashboard's armed/ready match the context-handler gate
|
|
421
|
+
// (which fires on this same %). Used by armed/ready + the dashboard.
|
|
422
|
+
const effectiveThresholdPct = this.config.tierPct != null
|
|
423
|
+
? (this.config.autoPctTrigger ?? this.config.tierPct) * 100
|
|
424
|
+
: null;
|
|
420
425
|
// armed lights at/above the REAL fire point: max(effectiveThresholdPct,
|
|
421
426
|
// fastGatePct). fastGatePct already equals tierPct*100 by default, but a
|
|
422
427
|
// MEGACOMPACT_FAST_GATE_PCT override can raise it, so we take the max.
|
|
423
428
|
const armed = this.lastCtxPercent != null &&
|
|
424
429
|
this.lastCtxPercent >=
|
|
425
430
|
Math.max(effectiveThresholdPct ?? 0, this.config.fastGatePct);
|
|
426
|
-
|
|
431
|
+
// S29: ready mirrors the context-handler gate's basis — percent for tiered
|
|
432
|
+
// (the gate fires on pct), tokens for custom (the gate fires on tokens).
|
|
433
|
+
// Previously this always required tokens, so the dashboard could show
|
|
434
|
+
// "armed" (percent high) but never "ready" when tokens were under-reported
|
|
435
|
+
// — the same inconsistency the S29 gate fix removes.
|
|
436
|
+
const ready = this.config.tierPct != null
|
|
437
|
+
? armed && (this.lastCtxPercent ?? 0) >= (effectiveThresholdPct ?? 0)
|
|
438
|
+
: armed && (this.lastCtxTokens ?? 0) >= this.effectiveThreshold;
|
|
427
439
|
this.dashboard.snapshot({
|
|
428
440
|
version: 1,
|
|
429
441
|
updatedAt: new Date().toISOString(),
|
|
@@ -544,7 +556,9 @@ export class MegaRuntime {
|
|
|
544
556
|
? `${Math.round(this.lastCtxWindow / 1000)}k`
|
|
545
557
|
: "?";
|
|
546
558
|
const pctStr = this.lastCtxPercent != null
|
|
547
|
-
?
|
|
559
|
+
? this.lastCtxPercent > 100
|
|
560
|
+
? `>100%` // S29: overshoot warning, not a raw "250%" — the percent trigger now compacts before 100%, so this is the residual case where it can't keep up.
|
|
561
|
+
: `${Math.round(this.lastCtxPercent * 10) / 10}%`
|
|
548
562
|
: "?%";
|
|
549
563
|
// S24: the tier label is the LIVE pressure band (low/medium/high/ultra/
|
|
550
564
|
// mega), not the static env preset. It climbs as context fills.
|
|
@@ -752,6 +766,7 @@ export class MegaRuntime {
|
|
|
752
766
|
compactCount: 0,
|
|
753
767
|
recallInjections: 0,
|
|
754
768
|
cacheHitTokens: 0,
|
|
769
|
+
lengthStopPending: false,
|
|
755
770
|
};
|
|
756
771
|
this.statusKey = undefined;
|
|
757
772
|
this.activeAgents = 0;
|
|
@@ -50,6 +50,7 @@ function harness(opts: { keepTier?: boolean; keepThreshold?: boolean } = {}) {
|
|
|
50
50
|
let statusText: string | undefined;
|
|
51
51
|
const notifies: string[] = [];
|
|
52
52
|
const compactCalls: any[] = [];
|
|
53
|
+
const sendUserMessages: string[] = [];
|
|
53
54
|
|
|
54
55
|
// Minimal AgentMessage factory for the session we project into the extension.
|
|
55
56
|
function msg(role: string, text: string, toolName?: string): AgentMessage {
|
|
@@ -192,7 +193,7 @@ function harness(opts: { keepTier?: boolean; keepThreshold?: boolean } = {}) {
|
|
|
192
193
|
registerMessageRenderer: () => {},
|
|
193
194
|
registerEntryRenderer: () => {},
|
|
194
195
|
sendMessage: (_m: any) => {},
|
|
195
|
-
sendUserMessage: () => {},
|
|
196
|
+
sendUserMessage: (m: string) => { sendUserMessages.push(m); },
|
|
196
197
|
appendEntry: (t: string, d: any) => appended.push({ t, d }),
|
|
197
198
|
setSessionName: () => {},
|
|
198
199
|
getSessionName: () => undefined,
|
|
@@ -221,6 +222,7 @@ function harness(opts: { keepTier?: boolean; keepThreshold?: boolean } = {}) {
|
|
|
221
222
|
},
|
|
222
223
|
notifies,
|
|
223
224
|
compactCalls,
|
|
225
|
+
sendUserMessages,
|
|
224
226
|
fire: (ev: string, event: any, ctx: any) => handlers[ev](event, ctx),
|
|
225
227
|
ctx: makeCtx,
|
|
226
228
|
session,
|
|
@@ -957,40 +959,6 @@ test("/dashboard skips server spawn when already running", async () => {
|
|
|
957
959
|
delete process.env.MEGACOMPACT_DASHBOARD_PORT;
|
|
958
960
|
});
|
|
959
961
|
|
|
960
|
-
test("/dashboard-status reports running after dashboard start", async () => {
|
|
961
|
-
// Private dashboard port base for this harness — never collides with the
|
|
962
|
-
// parallel dashboard-server.test.js (9320 family) or a leftover server.
|
|
963
|
-
process.env.MEGACOMPACT_DASHBOARD_PORT = "39320";
|
|
964
|
-
const h = harness();
|
|
965
|
-
const livPort = 39320;
|
|
966
|
-
const { createServer } = await import("node:http");
|
|
967
|
-
const { join: j } = await import("node:path");
|
|
968
|
-
const { writeFileSync: wf } = await import("node:fs");
|
|
969
|
-
const server = createServer((_req, res) => {
|
|
970
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
971
|
-
res.end(
|
|
972
|
-
JSON.stringify({ updatedAt: new Date().toISOString(), tier: "test" }),
|
|
973
|
-
);
|
|
974
|
-
});
|
|
975
|
-
await new Promise<void>((r) => server.listen(livPort, "127.0.0.1", r));
|
|
976
|
-
wf(
|
|
977
|
-
j(h.stateDir, "port.pid"),
|
|
978
|
-
JSON.stringify({ port: livPort, pid: process.pid }),
|
|
979
|
-
);
|
|
980
|
-
|
|
981
|
-
const ctx = h.ctx();
|
|
982
|
-
await h.commands["mega-dashboard-status"].handler("", ctx);
|
|
983
|
-
assert.ok(
|
|
984
|
-
h.notifies.some(
|
|
985
|
-
(n) => n.includes("running") && n.includes(String(livPort)),
|
|
986
|
-
),
|
|
987
|
-
"reports running with port",
|
|
988
|
-
);
|
|
989
|
-
|
|
990
|
-
await new Promise<void>((r) => server.close(() => r()));
|
|
991
|
-
delete process.env.MEGACOMPACT_DASHBOARD_PORT;
|
|
992
|
-
});
|
|
993
|
-
|
|
994
962
|
test("state snapshot writes dashboard.json after compaction", async () => {
|
|
995
963
|
const h = harness();
|
|
996
964
|
const ctx = h.ctx({
|
|
@@ -1056,6 +1024,299 @@ test("events.log receives compaction events", async () => {
|
|
|
1056
1024
|
}
|
|
1057
1025
|
});
|
|
1058
1026
|
|
|
1027
|
+
test("S28: length-stop auto-continue nudges once, no ctx.compact on low-pressure length path", async () => {
|
|
1028
|
+
const h = harness();
|
|
1029
|
+
// Force a low-pressure context so the durable-trim branch (which calls
|
|
1030
|
+
// ctx.compact()) is NOT taken; only the length-stop nudge should fire.
|
|
1031
|
+
const lowPressureCtx = h.ctx({
|
|
1032
|
+
isIdle: () => true,
|
|
1033
|
+
hasPendingMessages: () => false,
|
|
1034
|
+
getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }),
|
|
1035
|
+
});
|
|
1036
|
+
// 1) Normal stop: no length flag armed → no nudge.
|
|
1037
|
+
await h.fire(
|
|
1038
|
+
"turn_end",
|
|
1039
|
+
{ type: "turn_end", turnIndex: 1, message: { role: "assistant", stopReason: "stop" } },
|
|
1040
|
+
lowPressureCtx,
|
|
1041
|
+
);
|
|
1042
|
+
await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
|
|
1043
|
+
assert.equal(h.sendUserMessages.length, 0, "normal stop: no nudge");
|
|
1044
|
+
assert.equal(h.compactCalls.length, 0, "normal stop: no ctx.compact");
|
|
1045
|
+
|
|
1046
|
+
// 2) Length stop: arms the flag, agent_end fires exactly one continue nudge
|
|
1047
|
+
// that references the output-token truncation (not a compaction).
|
|
1048
|
+
await h.fire(
|
|
1049
|
+
"turn_end",
|
|
1050
|
+
{ type: "turn_end", turnIndex: 2, message: { role: "assistant", stopReason: "length" } },
|
|
1051
|
+
lowPressureCtx,
|
|
1052
|
+
);
|
|
1053
|
+
await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
|
|
1054
|
+
assert.equal(h.sendUserMessages.length, 1, "length stop: exactly one nudge");
|
|
1055
|
+
assert.match(
|
|
1056
|
+
h.sendUserMessages[0],
|
|
1057
|
+
/output-token cap/,
|
|
1058
|
+
"length stop: nudge references the output-token truncation",
|
|
1059
|
+
);
|
|
1060
|
+
assert.equal(h.compactCalls.length, 0, "length path: ctx.compact() NOT called (low pressure)");
|
|
1061
|
+
|
|
1062
|
+
// 3) One-shot: a second agent_end without a new length stop must NOT re-nudge.
|
|
1063
|
+
await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
|
|
1064
|
+
assert.equal(h.sendUserMessages.length, 1, "one-shot: no second nudge without a new length stop");
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
test("S28: length-stop auto-continue fires even when config.auto === false (autoContinueLengthStop is the sole gate)", async () => {
|
|
1068
|
+
// Disable auto (durable-trim + queued-resume) but keep the length-stop flag on.
|
|
1069
|
+
// Set BEFORE harness() loads the compiled extension so loadConfig() picks it up.
|
|
1070
|
+
const prevAuto = process.env.MEGACOMPACT_AUTO;
|
|
1071
|
+
process.env.MEGACOMPACT_AUTO = "false";
|
|
1072
|
+
try {
|
|
1073
|
+
// Re-load the extension with the new env so config.auto is false but
|
|
1074
|
+
// autoContinueLengthStop stays true (default).
|
|
1075
|
+
const h2 = harness();
|
|
1076
|
+
const lowPressureCtx = h2.ctx({
|
|
1077
|
+
isIdle: () => true,
|
|
1078
|
+
hasPendingMessages: () => false,
|
|
1079
|
+
getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }),
|
|
1080
|
+
});
|
|
1081
|
+
// Length stop arms the flag; agent_end must still nudge despite auto=false.
|
|
1082
|
+
await h2.fire(
|
|
1083
|
+
"turn_end",
|
|
1084
|
+
{ type: "turn_end", turnIndex: 1, message: { role: "assistant", stopReason: "length" } },
|
|
1085
|
+
lowPressureCtx,
|
|
1086
|
+
);
|
|
1087
|
+
await h2.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
|
|
1088
|
+
assert.equal(h2.sendUserMessages.length, 1, "auto=false: length stop still nudges");
|
|
1089
|
+
assert.match(
|
|
1090
|
+
h2.sendUserMessages[0],
|
|
1091
|
+
/output-token cap/,
|
|
1092
|
+
"auto=false: nudge references the output-token truncation",
|
|
1093
|
+
);
|
|
1094
|
+
assert.equal(h2.compactCalls.length, 0, "auto=false: ctx.compact() NOT called (auto gates durable-trim)");
|
|
1095
|
+
} finally {
|
|
1096
|
+
if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
|
|
1097
|
+
else process.env.MEGACOMPACT_AUTO = prevAuto;
|
|
1098
|
+
}
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
// Helper: read <stateDir>/events.log JSONL and return the list of event `type`s.
|
|
1102
|
+
// Dashboard.event (extensions/mega-dashboard.ts) appends `{ ts, type, ...data }`
|
|
1103
|
+
// per line. Used to assert the S28 length_stop / length_stop_continue dashboard
|
|
1104
|
+
// events fire on the right paths (spec acceptance #7; OPEN issue #3).
|
|
1105
|
+
function eventTypes(stateDir: string): string[] {
|
|
1106
|
+
const { readFileSync: rf, existsSync: ex } = require("node:fs") as typeof import("node:fs");
|
|
1107
|
+
const { join: j } = require("node:path") as typeof import("node:path");
|
|
1108
|
+
const logPath = j(stateDir, "events.log");
|
|
1109
|
+
if (!ex(logPath)) return [];
|
|
1110
|
+
const content = rf(logPath, "utf-8").trim();
|
|
1111
|
+
if (content.length === 0) return [];
|
|
1112
|
+
return content
|
|
1113
|
+
.split("\n")
|
|
1114
|
+
.map((line) => {
|
|
1115
|
+
try {
|
|
1116
|
+
return JSON.parse(line).type;
|
|
1117
|
+
} catch {
|
|
1118
|
+
return undefined;
|
|
1119
|
+
}
|
|
1120
|
+
})
|
|
1121
|
+
.filter((t): t is string => typeof t === "string");
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
test("S28: length_stop + length_stop_continue dashboard events fire on the right paths", async () => {
|
|
1125
|
+
const h = harness();
|
|
1126
|
+
const lowPressureCtx = h.ctx({
|
|
1127
|
+
isIdle: () => true,
|
|
1128
|
+
hasPendingMessages: () => false,
|
|
1129
|
+
getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }),
|
|
1130
|
+
});
|
|
1131
|
+
// Normal stop: no length_stop event, no nudge, no length_stop_continue.
|
|
1132
|
+
await h.fire(
|
|
1133
|
+
"turn_end",
|
|
1134
|
+
{ type: "turn_end", turnIndex: 1, message: { role: "assistant", stopReason: "stop" } },
|
|
1135
|
+
lowPressureCtx,
|
|
1136
|
+
);
|
|
1137
|
+
await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
|
|
1138
|
+
const afterNormal = eventTypes(h.stateDir);
|
|
1139
|
+
assert.ok(
|
|
1140
|
+
!afterNormal.includes("length_stop"),
|
|
1141
|
+
"normal stop: no length_stop dashboard event",
|
|
1142
|
+
);
|
|
1143
|
+
assert.ok(
|
|
1144
|
+
!afterNormal.includes("length_stop_continue"),
|
|
1145
|
+
"normal stop: no length_stop_continue dashboard event",
|
|
1146
|
+
);
|
|
1147
|
+
assert.equal(h.sendUserMessages.length, 0, "normal stop: no nudge");
|
|
1148
|
+
|
|
1149
|
+
// Length stop: length_stop fires on turn_end, length_stop_continue on agent_end.
|
|
1150
|
+
await h.fire(
|
|
1151
|
+
"turn_end",
|
|
1152
|
+
{ type: "turn_end", turnIndex: 2, message: { role: "assistant", stopReason: "length" } },
|
|
1153
|
+
lowPressureCtx,
|
|
1154
|
+
);
|
|
1155
|
+
const afterTurnEnd = eventTypes(h.stateDir);
|
|
1156
|
+
assert.ok(
|
|
1157
|
+
afterTurnEnd.includes("length_stop"),
|
|
1158
|
+
"length stop: length_stop dashboard event fired on turn_end",
|
|
1159
|
+
);
|
|
1160
|
+
await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
|
|
1161
|
+
const afterAgentEnd = eventTypes(h.stateDir);
|
|
1162
|
+
assert.ok(
|
|
1163
|
+
afterAgentEnd.includes("length_stop_continue"),
|
|
1164
|
+
"length stop: length_stop_continue dashboard event fired on agent_end",
|
|
1165
|
+
);
|
|
1166
|
+
assert.equal(h.sendUserMessages.length, 1, "length stop: exactly one nudge");
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
test("S28: non-length stopReasons do not arm the flag (no nudge, no length_stop event)", async () => {
|
|
1170
|
+
const h = harness();
|
|
1171
|
+
const lowPressureCtx = h.ctx({
|
|
1172
|
+
isIdle: () => true,
|
|
1173
|
+
hasPendingMessages: () => false,
|
|
1174
|
+
getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }),
|
|
1175
|
+
});
|
|
1176
|
+
// Every other pi-ai StopReason must leave the flag unset → no nudge + no event.
|
|
1177
|
+
for (const stopReason of ["tool_use", "error", "aborted"] as const) {
|
|
1178
|
+
await h.fire(
|
|
1179
|
+
"turn_end",
|
|
1180
|
+
{ type: "turn_end", turnIndex: 1, message: { role: "assistant", stopReason } },
|
|
1181
|
+
lowPressureCtx,
|
|
1182
|
+
);
|
|
1183
|
+
await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
|
|
1184
|
+
}
|
|
1185
|
+
assert.equal(
|
|
1186
|
+
h.sendUserMessages.length,
|
|
1187
|
+
0,
|
|
1188
|
+
"non-length stopReasons: no nudge",
|
|
1189
|
+
);
|
|
1190
|
+
assert.ok(
|
|
1191
|
+
!eventTypes(h.stateDir).includes("length_stop"),
|
|
1192
|
+
"non-length stopReasons: no length_stop dashboard event",
|
|
1193
|
+
);
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
// ---- S29: percent-based auto-compact trigger (gate on context %, not tokens) -
|
|
1197
|
+
// The context-handler gate now fires on pct/100 >= (autoPctTrigger ?? tierPct)
|
|
1198
|
+
// for tiered configs, with a token FALLBACK when pct is null. `custom` keeps the
|
|
1199
|
+
// absolute token gate. These are the first tests to drive a `context` event
|
|
1200
|
+
// on a tiered config (the default harness forces custom via THRESHOLD_TOKENS=50).
|
|
1201
|
+
|
|
1202
|
+
/** S29 tiered-config helper: tiered (not custom), low tier (tierPct 0.5), with
|
|
1203
|
+
* the legacy durable-trim flag off + anchor floor lowered so the live trim
|
|
1204
|
+
* returns a trimmed view (mirrors the S16 live-trim test setup at ~line 329). */
|
|
1205
|
+
function s29TieredCtx(h: ReturnType<typeof harness>, usage: { tokens: number; contextWindow: number; percent: number | null }) {
|
|
1206
|
+
delete process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM;
|
|
1207
|
+
delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
|
|
1208
|
+
process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES = "1";
|
|
1209
|
+
return h.ctx({
|
|
1210
|
+
isIdle: () => true,
|
|
1211
|
+
hasPendingMessages: () => false,
|
|
1212
|
+
getContextUsage: () => usage as any,
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
test("S29: percent gate fires when tokens under-report (tiered low, percent 55, tokens 10)", async () => {
|
|
1217
|
+
process.env.MEGACOMPACT_TIER = "low";
|
|
1218
|
+
delete process.env.MEGACOMPACT_THRESHOLD_TOKENS;
|
|
1219
|
+
delete process.env.MEGACOMPACT_AUTO_PCT_TRIGGER;
|
|
1220
|
+
try {
|
|
1221
|
+
const h = harness({ keepTier: true, keepThreshold: true });
|
|
1222
|
+
// tokens=10 (under the 0.5×10000=5000 token gate), percent=55 (>= 0.5).
|
|
1223
|
+
// The OLD token-only gate would return (10 < 5000) → no trim. The S29
|
|
1224
|
+
// percent gate (0.55 >= 0.5) fires → live trim returns a trimmed view.
|
|
1225
|
+
const ctx = s29TieredCtx(h, { tokens: 10, contextWindow: 10000, percent: 55 });
|
|
1226
|
+
const res = await h.fire("context", { type: "context", messages: h.session }, ctx);
|
|
1227
|
+
assert.ok(res && typeof res === "object", "percent gate: live trim returned a result object");
|
|
1228
|
+
assert.ok(Array.isArray((res as any).messages), "percent gate: result has a trimmed messages array");
|
|
1229
|
+
assert.ok(
|
|
1230
|
+
(res as any).messages.length < h.session.length,
|
|
1231
|
+
"percent gate: trimmed view is shorter than the full session",
|
|
1232
|
+
);
|
|
1233
|
+
assert.equal(h.compactCalls.length, 0, "percent gate: live trim, no ctx.compact()");
|
|
1234
|
+
|
|
1235
|
+
// Control: percent 40 (< 0.5) → no trim, even with the same under-reported tokens.
|
|
1236
|
+
const h2 = harness({ keepTier: true, keepThreshold: true });
|
|
1237
|
+
const ctx2 = s29TieredCtx(h2, { tokens: 10, contextWindow: 10000, percent: 40 });
|
|
1238
|
+
const res2 = await h2.fire("context", { type: "context", messages: h2.session }, ctx2);
|
|
1239
|
+
assert.ok(
|
|
1240
|
+
!(res2 && typeof res2 === "object" && Array.isArray((res2 as any).messages)),
|
|
1241
|
+
"percent below fire point: no trim (token count 10 is also below the token gate)",
|
|
1242
|
+
);
|
|
1243
|
+
} finally {
|
|
1244
|
+
delete process.env.MEGACOMPACT_TIER;
|
|
1245
|
+
delete process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
|
|
1246
|
+
}
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
test("S29: MEGACOMPACT_AUTO_PCT_TRIGGER overrides the tier fire point (0.85)", async () => {
|
|
1250
|
+
process.env.MEGACOMPACT_TIER = "low"; // tierPct 0.5
|
|
1251
|
+
process.env.MEGACOMPACT_AUTO_PCT_TRIGGER = "0.85";
|
|
1252
|
+
delete process.env.MEGACOMPACT_THRESHOLD_TOKENS;
|
|
1253
|
+
try {
|
|
1254
|
+
// percent 80 < 0.85 → no trim.
|
|
1255
|
+
const h = harness({ keepTier: true, keepThreshold: true });
|
|
1256
|
+
const ctx80 = s29TieredCtx(h, { tokens: 10, contextWindow: 10000, percent: 80 });
|
|
1257
|
+
const res80 = await h.fire("context", { type: "context", messages: h.session }, ctx80);
|
|
1258
|
+
assert.ok(
|
|
1259
|
+
!(res80 && typeof res80 === "object" && Array.isArray((res80 as any).messages)),
|
|
1260
|
+
"override 0.85: percent 80 does NOT trim (below the override fire point)",
|
|
1261
|
+
);
|
|
1262
|
+
|
|
1263
|
+
// percent 90 >= 0.85 → trim fires (despite the tier's own 0.5 fire point).
|
|
1264
|
+
const h2 = harness({ keepTier: true, keepThreshold: true });
|
|
1265
|
+
const ctx90 = s29TieredCtx(h2, { tokens: 10, contextWindow: 10000, percent: 90 });
|
|
1266
|
+
const res90 = await h2.fire("context", { type: "context", messages: h2.session }, ctx90);
|
|
1267
|
+
assert.ok(
|
|
1268
|
+
res90 && Array.isArray((res90 as any).messages) && (res90 as any).messages.length < h2.session.length,
|
|
1269
|
+
"override 0.85: percent 90 DOES trim (above the override fire point)",
|
|
1270
|
+
);
|
|
1271
|
+
} finally {
|
|
1272
|
+
delete process.env.MEGACOMPACT_TIER;
|
|
1273
|
+
delete process.env.MEGACOMPACT_AUTO_PCT_TRIGGER;
|
|
1274
|
+
delete process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
|
|
1275
|
+
}
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
test("S29: custom tier keeps the absolute token gate (percent 40 but tokens 100 >= 50)", async () => {
|
|
1279
|
+
// MEGACOMPACT_THRESHOLD_TOKENS → custom (tierPct null) → token gate, percent ignored.
|
|
1280
|
+
process.env.MEGACOMPACT_THRESHOLD_TOKENS = "50";
|
|
1281
|
+
delete process.env.MEGACOMPACT_TIER;
|
|
1282
|
+
delete process.env.MEGACOMPACT_AUTO_PCT_TRIGGER;
|
|
1283
|
+
try {
|
|
1284
|
+
const h = harness({ keepTier: true, keepThreshold: true });
|
|
1285
|
+
// percent 40 (low) BUT tokens 100 >= 50 threshold → custom token gate fires.
|
|
1286
|
+
const ctx = s29TieredCtx(h, { tokens: 100, contextWindow: 10000, percent: 40 });
|
|
1287
|
+
const res = await h.fire("context", { type: "context", messages: h.session }, ctx);
|
|
1288
|
+
assert.ok(
|
|
1289
|
+
res && Array.isArray((res as any).messages) && (res as any).messages.length < h.session.length,
|
|
1290
|
+
"custom tier: token gate fires (tokens 100 >= 50) despite low percent 40",
|
|
1291
|
+
);
|
|
1292
|
+
} finally {
|
|
1293
|
+
delete process.env.MEGACOMPACT_THRESHOLD_TOKENS;
|
|
1294
|
+
delete process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
|
|
1295
|
+
}
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
test("S29: tiered config with pct==null falls back to the token gate (not skipped)", async () => {
|
|
1299
|
+
// The regression guard for the audit finding: a percent-ONLY gate would skip
|
|
1300
|
+
// compaction when percent is unreported. S29 falls back to the token gate
|
|
1301
|
+
// (S27 boot-fallback guarantee). tiered low: effectiveThreshold = 0.5×10000 = 5000;
|
|
1302
|
+
// tokens 6000 >= 5000 → token fallback fires.
|
|
1303
|
+
process.env.MEGACOMPACT_TIER = "low";
|
|
1304
|
+
delete process.env.MEGACOMPACT_THRESHOLD_TOKENS;
|
|
1305
|
+
delete process.env.MEGACOMPACT_AUTO_PCT_TRIGGER;
|
|
1306
|
+
try {
|
|
1307
|
+
const h = harness({ keepTier: true, keepThreshold: true });
|
|
1308
|
+
const ctx = s29TieredCtx(h, { tokens: 6000, contextWindow: 10000, percent: null });
|
|
1309
|
+
const res = await h.fire("context", { type: "context", messages: h.session }, ctx);
|
|
1310
|
+
assert.ok(
|
|
1311
|
+
res && Array.isArray((res as any).messages) && (res as any).messages.length < h.session.length,
|
|
1312
|
+
"pct==null on tiered: token fallback fires (NOT skipped) — S27 boot-fallback preserved",
|
|
1313
|
+
);
|
|
1314
|
+
} finally {
|
|
1315
|
+
delete process.env.MEGACOMPACT_TIER;
|
|
1316
|
+
delete process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
|
|
1317
|
+
}
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1059
1320
|
test("cleanup", async () => {
|
|
1060
1321
|
// Terminate the global PGlite cross-repo index (WASM worker thread) so the
|
|
1061
1322
|
// test process can exit. Without this, node --test never returns even though
|
|
@@ -66,6 +66,17 @@ export interface MegaConfig {
|
|
|
66
66
|
auto: boolean;
|
|
67
67
|
autoInline: boolean;
|
|
68
68
|
autoInlineK: number;
|
|
69
|
+
/** S28: auto-continue the agent after a max-output-token length stop by
|
|
70
|
+
* reusing the existing S16 resume-nudge. Default true. Off = silent (the
|
|
71
|
+
* prior behavior). PREVENT-PI-003: restart via user-role sendUserMessage. */
|
|
72
|
+
autoContinueLengthStop: boolean;
|
|
73
|
+
/** S29: override the auto-compact fire point for tiered configs, as a
|
|
74
|
+
* fraction of the context window (e.g. 0.85). null = inherit the tier's
|
|
75
|
+
* tierPct (default; preserves existing fire points). The context-handler
|
|
76
|
+
* gate fires on context % (reliable), not token count (under-reported),
|
|
77
|
+
* so it catches the overshoot that causes max-output-token truncation.
|
|
78
|
+
* `custom` (tierPct null) ignores this — it keeps the absolute token gate. */
|
|
79
|
+
autoPctTrigger: number | null;
|
|
69
80
|
dedupSim: number;
|
|
70
81
|
/** RAPTOR hierarchical recall enabled (Fix D). Drives both live recall and
|
|
71
82
|
* the durable-trim summary source (root summary). */
|
|
@@ -204,6 +215,15 @@ export {
|
|
|
204
215
|
/** Build the resolved config from env + defaults. */
|
|
205
216
|
export function loadConfig(): MegaConfig {
|
|
206
217
|
const { tier, tierPct, thresholdTokens } = resolveThreshold();
|
|
218
|
+
// S29: optional percent-based fire-point override for tiered configs.
|
|
219
|
+
// null = inherit tierPct (default; preserves existing fire points). Clamped
|
|
220
|
+
// to [0.1, 1] so a bogus env can't disable or invert the gate. Ignored by
|
|
221
|
+
// the `custom` tier (tierPct null) which keeps the absolute token gate.
|
|
222
|
+
const aptRaw = process.env.MEGACOMPACT_AUTO_PCT_TRIGGER;
|
|
223
|
+
const autoPctTrigger =
|
|
224
|
+
aptRaw && aptRaw !== "" && Number.isFinite(Number(aptRaw))
|
|
225
|
+
? Math.min(1, Math.max(0.1, Number(aptRaw)))
|
|
226
|
+
: null;
|
|
207
227
|
return {
|
|
208
228
|
tier,
|
|
209
229
|
tierPct,
|
|
@@ -217,6 +237,8 @@ export function loadConfig(): MegaConfig {
|
|
|
217
237
|
preserveRecentMin: envFlag("MEGACOMPACT_PRESERVE_RECENT_MIN", 2),
|
|
218
238
|
auto: envBool("MEGACOMPACT_AUTO", true),
|
|
219
239
|
autoInline: envBool("MEGACOMPACT_AUTO_INLINE", true),
|
|
240
|
+
autoContinueLengthStop: envBool("MEGACOMPACT_AUTO_CONTINUE_LENGTH_STOP", true),
|
|
241
|
+
autoPctTrigger,
|
|
220
242
|
autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
|
|
221
243
|
dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
|
|
222
244
|
raptorEnabled: envBool("MEGACOMPACT_RAPTOR_ENABLED", true),
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
import { computeLiveTrimCut, liveTrimSummaryMessage } from "./mega-trim.js";
|
|
41
41
|
import {
|
|
42
42
|
pressureFromPct,
|
|
43
|
+
pressureRatio,
|
|
43
44
|
memoryReviewCadence,
|
|
44
45
|
type MegaConfig,
|
|
45
46
|
} from "./mega-config.js";
|
|
@@ -267,7 +268,7 @@ export function registerEventHandlers(
|
|
|
267
268
|
// compaction AND there is queued work AND we haven't nudged recently, nudge
|
|
268
269
|
// once so the agent continues (the live trim should make this rare). Guarded
|
|
269
270
|
// to never busy-loop: one nudge per 30s, only when truly idle + queued.
|
|
270
|
-
if (config.auto && runtime.activeAgents === 0) {
|
|
271
|
+
if ((config.auto || config.autoContinueLengthStop) && runtime.activeAgents === 0) {
|
|
271
272
|
try {
|
|
272
273
|
const idle = ctx.isIdle?.() ?? true;
|
|
273
274
|
const queued = ctx.hasPendingMessages?.() ?? false;
|
|
@@ -316,7 +317,7 @@ export function registerEventHandlers(
|
|
|
316
317
|
// most. Instead we DECOUPLE the nudge from `queued`: after a durable
|
|
317
318
|
// trim we ALWAYS nudge so the agent reliably restarts. Debounced 30s.
|
|
318
319
|
let didDurableTrim = false;
|
|
319
|
-
if (idle && overThreshold && now >= runtime.debounceUntil) {
|
|
320
|
+
if (config.auto && idle && overThreshold && now >= runtime.debounceUntil) {
|
|
320
321
|
// COMPACT-DEDUP FIX: skip the manual durable-trim trigger when pi's
|
|
321
322
|
// NATIVE auto-compaction just fired (or is in-flight). pi emits
|
|
322
323
|
// agent_end BEFORE its own _checkCompaction (per its docstring:
|
|
@@ -346,15 +347,28 @@ export function registerEventHandlers(
|
|
|
346
347
|
// Restart the agent after a mid-run durable trim (which stopped it), or
|
|
347
348
|
// when it settled idle with queued work. Decoupled from `queued` for the
|
|
348
349
|
// durable-trim case — see FIX note above. Debounced 30s; never blocks.
|
|
350
|
+
const lengthStop = config.autoContinueLengthStop && runtime.rt.lengthStopPending;
|
|
349
351
|
if (
|
|
350
352
|
idle &&
|
|
351
353
|
now >= runtime.resumeNudgeUntil &&
|
|
352
|
-
(didDurableTrim || queued)
|
|
354
|
+
((config.auto && (didDurableTrim || queued)) || lengthStop)
|
|
353
355
|
) {
|
|
354
356
|
runtime.resumeNudgeUntil = now + 30_000;
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
357
|
+
if (runtime.rt.lengthStopPending) {
|
|
358
|
+
runtime.rt.lengthStopPending = false; // one-shot: never re-fire for same stop
|
|
359
|
+
runtime.dashboard.event("length_stop_continue", { turnIndex: runtime.currentTurn });
|
|
360
|
+
runtime.logger.info("length_stop_continue", {
|
|
361
|
+
sessionId: runtime.rt.sessionId,
|
|
362
|
+
didDurableTrim,
|
|
363
|
+
queued,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
// S28: when a length-stop (max-output-token truncation) fired WITHOUT a durable trim, do NOT claim a compaction happened
|
|
367
|
+
// (nothing was compacted on the low-pressure length path). Branch the message so the nudge matches reality.
|
|
368
|
+
const nudgeMsg = lengthStop && !didDurableTrim
|
|
369
|
+
? "[mega-compact] the last response hit the output-token cap; continue from where it stopped."
|
|
370
|
+
: "[mega-compact] continue from the compacted context above.";
|
|
371
|
+
pi.sendUserMessage(nudgeMsg);
|
|
358
372
|
}
|
|
359
373
|
} catch {
|
|
360
374
|
/* non-fatal: a failed nudge never blocks */
|
|
@@ -365,6 +379,7 @@ export function registerEventHandlers(
|
|
|
365
379
|
|
|
366
380
|
pi.on("turn_start", async (event, ctx) => {
|
|
367
381
|
runtime.currentTurn = event.turnIndex;
|
|
382
|
+
runtime.rt.lengthStopPending = false; // S28: re-arm defensively each user turn
|
|
368
383
|
runtime.dashboard.event("turn_start", { turnIndex: event.turnIndex });
|
|
369
384
|
runtime.snapshot(ctx);
|
|
370
385
|
});
|
|
@@ -395,6 +410,18 @@ export function registerEventHandlers(
|
|
|
395
410
|
await runMemoryReview(runtime, view, "turn");
|
|
396
411
|
}
|
|
397
412
|
}
|
|
413
|
+
|
|
414
|
+
// S28: detect max-output-token truncation. event.message.stopReason is the
|
|
415
|
+
// pi-ai StopReason union; 'length' == generation hit max_tokens OUTPUT cap
|
|
416
|
+
// (INPUT-orthogonal to context-window overflow). Arm the agent_end nudge.
|
|
417
|
+
if (
|
|
418
|
+
config.autoContinueLengthStop &&
|
|
419
|
+
event.message.role === "assistant" &&
|
|
420
|
+
event.message.stopReason === "length"
|
|
421
|
+
) {
|
|
422
|
+
runtime.rt.lengthStopPending = true;
|
|
423
|
+
runtime.dashboard.event("length_stop", { turnIndex: event.turnIndex });
|
|
424
|
+
}
|
|
398
425
|
});
|
|
399
426
|
|
|
400
427
|
// ---- Auto-trigger: live trim (compact and continue) + native durable ----
|
|
@@ -420,14 +447,13 @@ export function registerEventHandlers(
|
|
|
420
447
|
runtime.lastCtxPercent = pct ?? null;
|
|
421
448
|
runtime.lastCtxWindow = usage?.contextWindow ?? 0;
|
|
422
449
|
runtime.snapshot(ctx);
|
|
423
|
-
if (pct == null) return;
|
|
424
450
|
|
|
425
451
|
const messages = event.messages;
|
|
426
452
|
const view = runtime.engineView(messages);
|
|
427
453
|
const currentTokens =
|
|
428
454
|
usage?.tokens ??
|
|
429
455
|
estimateSessionTokens(view) ??
|
|
430
|
-
Math.round((pct / 100) * (usage?.contextWindow ?? 0));
|
|
456
|
+
Math.round(((pct ?? 0) / 100) * (usage?.contextWindow ?? 0));
|
|
431
457
|
|
|
432
458
|
// S27 DB-mirror: append ALL incoming messages to raw_transcript.
|
|
433
459
|
// Runs BEFORE fast-gate so every message is captured, even if we
|
|
@@ -445,15 +471,35 @@ export function registerEventHandlers(
|
|
|
445
471
|
}
|
|
446
472
|
}
|
|
447
473
|
|
|
448
|
-
// FAST GATE:
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
474
|
+
// S29 FAST GATE: drive the auto-trigger off the context % (the number the
|
|
475
|
+
// menu bar shows), NOT the token count — the model under-reports tokens,
|
|
476
|
+
// so a token-only gate misses the overshoot that causes max-output-token
|
|
477
|
+
// truncation. The fire point is the tier's percent threshold (tierPct)
|
|
478
|
+
// unless overridden by MEGACOMPACT_AUTO_PCT_TRIGGER. `custom` (absolute
|
|
479
|
+
// MEGACOMPACT_THRESHOLD_TOKENS, tierPct null) is an explicit opt-out of
|
|
480
|
+
// percent scaling — it keeps the token gate. When pct is unavailable
|
|
481
|
+
// (window unknown / a model that doesn't report percent) a tiered config
|
|
482
|
+
// falls back to the token gate (S27 boot-fallback guarantee) instead of
|
|
483
|
+
// skipping compaction — a percent-only gate would regress that.
|
|
484
|
+
let gatePassed = false;
|
|
485
|
+
if (config.tierPct != null && pct != null) {
|
|
486
|
+
const firePct = config.autoPctTrigger ?? config.tierPct;
|
|
487
|
+
gatePassed = pct / 100 >= firePct;
|
|
488
|
+
} else {
|
|
489
|
+
// custom tier OR tiered-but-pct-unavailable → token gate (S27 fallback).
|
|
490
|
+
if (currentTokens < runtime.effectiveThreshold) {
|
|
491
|
+
runtime.diagCtxFastGate++;
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const check = autoCompactCheck(currentTokens, runtime.effectiveThreshold); // SERVER-STYLE CONFIRM (local)
|
|
495
|
+
if (!check.shouldCompact) {
|
|
496
|
+
runtime.diagCtxNoCompact++;
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
gatePassed = true;
|
|
452
500
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
if (!check.shouldCompact) {
|
|
456
|
-
runtime.diagCtxNoCompact++;
|
|
501
|
+
if (!gatePassed) {
|
|
502
|
+
runtime.diagCtxFastGate++;
|
|
457
503
|
return;
|
|
458
504
|
}
|
|
459
505
|
|
|
@@ -466,8 +512,10 @@ export function registerEventHandlers(
|
|
|
466
512
|
runtime.debounceUntil = now + 2000;
|
|
467
513
|
|
|
468
514
|
// Adaptive compression (Fix E): scale compression strength + keepFrom depth
|
|
469
|
-
// with how close we are to the model context limit.
|
|
470
|
-
|
|
515
|
+
// with how close we are to the model context limit. Null-safe: when the
|
|
516
|
+
// token-fallback path ran (pct unavailable) use the token-basis pressure
|
|
517
|
+
// (the same basis the runtime `pressure` getter uses for custom/no-window).
|
|
518
|
+
const pressure = pct != null ? pressureFromPct(pct) : pressureRatio(currentTokens, runtime.effectiveThreshold);
|
|
471
519
|
const ran = runCompact(pi, runtime, config, ctx, messages, {
|
|
472
520
|
compressionPressure: pressure,
|
|
473
521
|
});
|
|
@@ -81,6 +81,7 @@ interface SessionRuntime {
|
|
|
81
81
|
compactCount: number; // compactions performed this session-instance
|
|
82
82
|
recallInjections: number; // recall blocks injected this session-instance
|
|
83
83
|
cacheHitTokens: number; // tokens saved via cache hits (dedup + recall) this session
|
|
84
|
+
lengthStopPending: boolean; // S28: set on turn_end when stopReason==='length'
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
/** ANSI palette for the toolbar. The pi TUI's Text component preserves ANSI
|
|
@@ -290,6 +291,7 @@ export class MegaRuntime {
|
|
|
290
291
|
compactCount: 0,
|
|
291
292
|
recallInjections: 0,
|
|
292
293
|
cacheHitTokens: 0,
|
|
294
|
+
lengthStopPending: false,
|
|
293
295
|
};
|
|
294
296
|
debounceUntil = 0;
|
|
295
297
|
// S16: debounce for the agent_end resume nudge (avoid busy-loops).
|
|
@@ -519,9 +521,13 @@ export class MegaRuntime {
|
|
|
519
521
|
}
|
|
520
522
|
: undefined;
|
|
521
523
|
// effectiveThresholdPct: the live fire point as a % of the window (null for
|
|
522
|
-
// `custom`, which has no tierPct).
|
|
524
|
+
// `custom`, which has no tierPct). S29: honors MEGACOMPACT_AUTO_PCT_TRIGGER
|
|
525
|
+
// override so the dashboard's armed/ready match the context-handler gate
|
|
526
|
+
// (which fires on this same %). Used by armed/ready + the dashboard.
|
|
523
527
|
const effectiveThresholdPct =
|
|
524
|
-
this.config.tierPct != null
|
|
528
|
+
this.config.tierPct != null
|
|
529
|
+
? (this.config.autoPctTrigger ?? this.config.tierPct) * 100
|
|
530
|
+
: null;
|
|
525
531
|
// armed lights at/above the REAL fire point: max(effectiveThresholdPct,
|
|
526
532
|
// fastGatePct). fastGatePct already equals tierPct*100 by default, but a
|
|
527
533
|
// MEGACOMPACT_FAST_GATE_PCT override can raise it, so we take the max.
|
|
@@ -529,7 +535,15 @@ export class MegaRuntime {
|
|
|
529
535
|
this.lastCtxPercent != null &&
|
|
530
536
|
this.lastCtxPercent >=
|
|
531
537
|
Math.max(effectiveThresholdPct ?? 0, this.config.fastGatePct);
|
|
532
|
-
|
|
538
|
+
// S29: ready mirrors the context-handler gate's basis — percent for tiered
|
|
539
|
+
// (the gate fires on pct), tokens for custom (the gate fires on tokens).
|
|
540
|
+
// Previously this always required tokens, so the dashboard could show
|
|
541
|
+
// "armed" (percent high) but never "ready" when tokens were under-reported
|
|
542
|
+
// — the same inconsistency the S29 gate fix removes.
|
|
543
|
+
const ready =
|
|
544
|
+
this.config.tierPct != null
|
|
545
|
+
? armed && (this.lastCtxPercent ?? 0) >= (effectiveThresholdPct ?? 0)
|
|
546
|
+
: armed && (this.lastCtxTokens ?? 0) >= this.effectiveThreshold;
|
|
533
547
|
this.dashboard.snapshot({
|
|
534
548
|
version: 1,
|
|
535
549
|
updatedAt: new Date().toISOString(),
|
|
@@ -656,7 +670,9 @@ export class MegaRuntime {
|
|
|
656
670
|
: "?";
|
|
657
671
|
const pctStr =
|
|
658
672
|
this.lastCtxPercent != null
|
|
659
|
-
?
|
|
673
|
+
? this.lastCtxPercent > 100
|
|
674
|
+
? `>100%` // S29: overshoot warning, not a raw "250%" — the percent trigger now compacts before 100%, so this is the residual case where it can't keep up.
|
|
675
|
+
: `${Math.round(this.lastCtxPercent * 10) / 10}%`
|
|
660
676
|
: "?%";
|
|
661
677
|
// S24: the tier label is the LIVE pressure band (low/medium/high/ultra/
|
|
662
678
|
// mega), not the static env preset. It climbs as context fills.
|
|
@@ -888,6 +904,7 @@ export class MegaRuntime {
|
|
|
888
904
|
compactCount: 0,
|
|
889
905
|
recallInjections: 0,
|
|
890
906
|
cacheHitTokens: 0,
|
|
907
|
+
lengthStopPending: false,
|
|
891
908
|
};
|
|
892
909
|
this.statusKey = undefined;
|
|
893
910
|
this.activeAgents = 0;
|
package/package.json
CHANGED