mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-10 20:34:30 +00:00
feat: release v0.3.6
This commit is contained in:
@@ -135,13 +135,13 @@ export class BackendClient {
|
||||
/** L1 Summarize — synchronous await (used by assemble flush + force trigger) */
|
||||
async l1Summarize(req: L1Request): Promise<L1Response> {
|
||||
const pairNames = req.toolPairs.map((p) => `${p.toolName}(${p.toolCallId})`).join(", ");
|
||||
this.logger.info(`[context-offload] L1 >>> summarize ${req.toolPairs.length} pairs: [${pairNames}]`);
|
||||
this.logger.debug?.(`[context-offload] L1 >>> summarize ${req.toolPairs.length} pairs: [${pairNames}]`);
|
||||
const startMs = Date.now();
|
||||
const resp = await this.post<L1Response>("/offload/v1/l1/summarize", req, BackendClient.TIMEOUT_MS);
|
||||
const durationMs = Date.now() - startMs;
|
||||
const entryCount = resp.entries?.length ?? 0;
|
||||
const scores = resp.entries?.map((e) => `${e.tool_call_id}:score=${e.score}`).join(", ") ?? "";
|
||||
this.logger.info(`[context-offload] L1 <<< ${entryCount} entries [${scores}]`);
|
||||
this.logger.debug?.(`[context-offload] L1 <<< ${entryCount} entries [${scores}]`);
|
||||
traceOffloadModelIo({
|
||||
sessionKey: this.sessionKeyFn(),
|
||||
stage: "L1.backend",
|
||||
@@ -161,13 +161,13 @@ export class BackendClient {
|
||||
|
||||
/** L1.5 Task Judgment — synchronous await, uses unified timeout */
|
||||
async l15Judge(req: L15Request): Promise<L15Response> {
|
||||
this.logger.info(
|
||||
this.logger.debug?.(
|
||||
`[context-offload] L1.5 >>> judge: currentMmd=${req.currentMmd?.filename ?? "null"}, availableMmds=${req.availableMmdMetas.length}, recentMessages=${req.recentMessages.length} chars`,
|
||||
);
|
||||
const startMs = Date.now();
|
||||
const resp = await this.post<L15Response>("/offload/v1/l15/judge", req, BackendClient.TIMEOUT_MS);
|
||||
const durationMs = Date.now() - startMs;
|
||||
this.logger.info(
|
||||
this.logger.debug?.(
|
||||
`[context-offload] L1.5 <<< completed=${resp.taskCompleted}, continuation=${resp.isContinuation}, continuationFile=${resp.continuationMmdFile ?? "null"}, newLabel=${resp.newTaskLabel ?? "null"}, longTask=${resp.isLongTask}`,
|
||||
);
|
||||
traceOffloadModelIo({
|
||||
@@ -189,7 +189,7 @@ export class BackendClient {
|
||||
/** L2 MMD Generation — async background, uses unified timeout */
|
||||
async l2Generate(req: L2Request): Promise<L2Response> {
|
||||
const entryIds = req.newEntries.map((e) => e.tool_call_id).join(", ");
|
||||
this.logger.info(
|
||||
this.logger.debug?.(
|
||||
`[context-offload] L2 >>> generate: task=${req.taskLabel}, prefix=${req.mmdPrefix}, entries=${req.newEntries.length} [${entryIds}], existingMmd=${req.existingMmd ? `${req.mmdCharCount} chars` : "null (new)"}`,
|
||||
);
|
||||
const startMs = Date.now();
|
||||
@@ -197,7 +197,7 @@ export class BackendClient {
|
||||
const durationMs = Date.now() - startMs;
|
||||
const mappingCount = Object.keys(resp.nodeMapping ?? {}).length;
|
||||
const mappingStr = Object.entries(resp.nodeMapping ?? {}).map(([k, v]) => `${k}->${v}`).join(", ");
|
||||
this.logger.info(
|
||||
this.logger.debug?.(
|
||||
`[context-offload] L2 <<< action=${resp.fileAction}, mmdContent=${resp.mmdContent ? `${resp.mmdContent.length} chars` : "null"}, replaceBlocks=${resp.replaceBlocks?.length ?? 0}, nodeMapping=${mappingCount} [${mappingStr}]`,
|
||||
);
|
||||
traceOffloadModelIo({
|
||||
@@ -218,13 +218,13 @@ export class BackendClient {
|
||||
|
||||
/** L4 Skill Generation — synchronous await, uses unified timeout */
|
||||
async l4Generate(req: L4Request): Promise<L4Response> {
|
||||
this.logger.info(
|
||||
this.logger.debug?.(
|
||||
`[context-offload] L4 >>> generate: mmd=${req.mmdFilename}, entries=${req.offloadEntries.length}, skillFocus=${req.skillFocus ?? "null"}`,
|
||||
);
|
||||
const startMs = Date.now();
|
||||
const resp = await this.post<L4Response>("/offload/v1/l4/generate", req, BackendClient.TIMEOUT_MS);
|
||||
const durationMs = Date.now() - startMs;
|
||||
this.logger.info(
|
||||
this.logger.debug?.(
|
||||
`[context-offload] L4 <<< skill="${resp.skillName}", content=${resp.skillContent?.length ?? 0} chars`,
|
||||
);
|
||||
traceOffloadModelIo({
|
||||
@@ -255,7 +255,7 @@ export class BackendClient {
|
||||
try {
|
||||
const resp = await this.post<StoreStateResponse>("/offload/v1/store", payload, timeoutMs);
|
||||
const durationMs = Date.now() - startMs;
|
||||
this.logger.info(
|
||||
this.logger.debug?.(
|
||||
`[context-offload] store <<< insertedId=${resp.insertedId ?? "?"} (${durationMs}ms)`,
|
||||
);
|
||||
return resp;
|
||||
@@ -273,7 +273,7 @@ export class BackendClient {
|
||||
const startMs = Date.now();
|
||||
|
||||
const bodyStr = JSON.stringify(body);
|
||||
this.logger.info(`[context-offload] HTTP >>> POST ${url} (${bodyStr.length} bytes, timeout=${timeoutMs}ms)`);
|
||||
this.logger.debug?.(`[context-offload] HTTP >>> POST ${url} (${bodyStr.length} bytes, timeout=${timeoutMs}ms)`);
|
||||
|
||||
const reqHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
@@ -330,7 +330,7 @@ export class BackendClient {
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data) as T;
|
||||
this.logger.info(
|
||||
this.logger.debug?.(
|
||||
`[context-offload] HTTP <<< ${path}: ${res.statusCode} (${durationMs}ms, ${data.length} bytes)`,
|
||||
);
|
||||
resolve(parsed);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Benchmark: fastEstimateTokens vs tiktoken cl100k_base
|
||||
*/
|
||||
import { fastEstimateTokens } from "../src/offload/fast-token-estimate.ts";
|
||||
import { getEncoding } from "js-tiktoken";
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const enc = getEncoding("cl100k_base");
|
||||
|
||||
function tiktokenCount(text: string): number {
|
||||
return enc.encode(text).length;
|
||||
}
|
||||
|
||||
// Load corpus
|
||||
const corpusDir = join(__dirname, "../token_count/corpus");
|
||||
const testTexts: { name: string; text: string }[] = [];
|
||||
|
||||
if (existsSync(corpusDir)) {
|
||||
const files = ["en_pride.txt", "en_arxiv.txt", "cn_hlm.txt", "cn_sgy.txt", "fr_french.txt",
|
||||
"ru_russian.txt", "ja_japanese.txt", "ko_korean.txt", "ar_arabic.txt",
|
||||
"de_german.txt", "es_spanish.txt", "pt_portuguese.txt"];
|
||||
for (const f of files) {
|
||||
const fp = join(corpusDir, f);
|
||||
if (existsSync(fp)) {
|
||||
testTexts.push({ name: f.replace(".txt", ""), text: readFileSync(fp, "utf-8").slice(0, 100_000) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add typical agent scenarios
|
||||
testTexts.push({ name: "json_messages", text: JSON.stringify([
|
||||
{ role: "user", content: "Hello world" },
|
||||
{ role: "assistant", content: "Hi! How can I help?" },
|
||||
{ role: "user", content: "Run ls -la" },
|
||||
{ role: "toolResult", toolCallId: "call_123", content: "total 48\ndrwxr-xr-x 5 user user 4096 May 18 10:00 .\n-rw-r--r-- 1 user user 1234 May 18 09:30 package.json\n" },
|
||||
]).repeat(50) });
|
||||
testTexts.push({ name: "mixed_code_zh", text: "// 这是一个测试函数\nfunction hello(name: string): string {\n return `你好 ${name}!`;\n}\n".repeat(1000) });
|
||||
|
||||
console.log("\n══════════════════════════════════════════════════════════════════");
|
||||
console.log(" fastEstimateTokens vs tiktoken cl100k_base");
|
||||
console.log("══════════════════════════════════════════════════════════════════\n");
|
||||
|
||||
const header = [
|
||||
"文本".padEnd(18), "chars".padStart(8), "tiktoken".padStart(9),
|
||||
"estimate".padStart(9), "error".padStart(7), "tk_ms".padStart(7), "est_ms".padStart(7), "speedup".padStart(8),
|
||||
];
|
||||
console.log(header.join(" │ "));
|
||||
console.log("─".repeat(85));
|
||||
|
||||
let totalTk = 0, totalEst = 0, totalTkMs = 0, totalEstMs = 0;
|
||||
|
||||
for (const { name, text } of testTexts) {
|
||||
const t0 = performance.now();
|
||||
const tk = tiktokenCount(text);
|
||||
const tkMs = performance.now() - t0;
|
||||
|
||||
const t1 = performance.now();
|
||||
const est = fastEstimateTokens(text);
|
||||
const estMs = performance.now() - t1;
|
||||
|
||||
const err = ((est - tk) / tk * 100).toFixed(1);
|
||||
const speedup = (tkMs / Math.max(estMs, 0.01)).toFixed(0);
|
||||
const mark = Math.abs(est - tk) / tk <= 0.10 ? "✅" : "❌";
|
||||
|
||||
totalTk += tk; totalEst += est; totalTkMs += tkMs; totalEstMs += estMs;
|
||||
|
||||
console.log([
|
||||
name.padEnd(18), text.length.toLocaleString().padStart(8),
|
||||
tk.toLocaleString().padStart(9), est.toLocaleString().padStart(9),
|
||||
`${err}%`.padStart(7), tkMs.toFixed(1).padStart(7), estMs.toFixed(1).padStart(7),
|
||||
`${speedup}x`.padStart(8),
|
||||
].join(" │ ") + ` ${mark}`);
|
||||
}
|
||||
|
||||
console.log("─".repeat(85));
|
||||
const totalErr = ((totalEst - totalTk) / totalTk * 100).toFixed(1);
|
||||
console.log([
|
||||
"TOTAL".padEnd(18), "".padStart(8),
|
||||
totalTk.toLocaleString().padStart(9), totalEst.toLocaleString().padStart(9),
|
||||
`${totalErr}%`.padStart(7), totalTkMs.toFixed(0).padStart(7), totalEstMs.toFixed(0).padStart(7),
|
||||
`${(totalTkMs / totalEstMs).toFixed(0)}x`.padStart(8),
|
||||
].join(" │ "));
|
||||
|
||||
console.log(`\n 精度: 平均误差 ${totalErr}%`);
|
||||
console.log(` 速度: tiktoken ${totalTkMs.toFixed(0)}ms vs estimate ${totalEstMs.toFixed(0)}ms (${(totalTkMs / totalEstMs).toFixed(0)}x faster)`);
|
||||
console.log();
|
||||
@@ -70,12 +70,14 @@ export interface ContextSnapshot {
|
||||
}
|
||||
|
||||
// Internal metadata keys that should NOT be counted as tokens.
|
||||
// These are plugin-internal markers that the LLM never sees.
|
||||
// These are plugin-internal markers or framework-internal fields that the LLM never sees.
|
||||
// Note: "details" is stripped by OpenClaw's normalizeMessagesForLlmBoundary before sending to LLM.
|
||||
const INTERNAL_KEYS = new Set([
|
||||
"_offloaded",
|
||||
"_mmdContextMessage",
|
||||
"_mmdInjection",
|
||||
"_contextOffloadProcessed",
|
||||
"details",
|
||||
]);
|
||||
|
||||
/** JSON replacer that strips internal metadata keys from serialization. */
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Fast token estimator — TypeScript port of token_count/fast_token_estimate.py
|
||||
* Targets cl100k_base encoding (GPT-4, Claude, DeepSeek, GLM, MiniMax).
|
||||
*
|
||||
* Precision: ~2-7% error for most languages (tested vs tiktoken cl100k_base).
|
||||
* Speed: ~5ms per 100K chars (vs tiktoken ~3-10s).
|
||||
*
|
||||
* Algorithm: single-pass character classification with per-category coefficients.
|
||||
* No BPE encoding, no regex splitting — pure arithmetic on codepoints.
|
||||
*/
|
||||
import { readFileSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// ─── CJK Lookup Table ──────────────────────────────────────────────────────
|
||||
// Each byte = token cost × 255 for one CJK character (U+4E00..U+9FFF).
|
||||
// Pre-computed from tiktoken cl100k_base actual encoding.
|
||||
const CJK_START = 0x4E00;
|
||||
const CJK_END = 0x9FFF;
|
||||
let _cjkTable: Uint8Array | null = null;
|
||||
|
||||
function loadCjkTable(): Uint8Array | null {
|
||||
if (_cjkTable) return _cjkTable;
|
||||
try {
|
||||
// Try multiple paths for the CJK table
|
||||
const paths = [
|
||||
join(dirname(fileURLToPath(import.meta.url)), "../../token_count/cjk_token_table.bin"),
|
||||
join(dirname(fileURLToPath(import.meta.url)), "../../../token_count/cjk_token_table.bin"),
|
||||
];
|
||||
for (const p of paths) {
|
||||
try {
|
||||
const buf = readFileSync(p);
|
||||
if (buf.length === CJK_END - CJK_START + 1) {
|
||||
_cjkTable = new Uint8Array(buf.buffer, buf.byteOffset, buf.length);
|
||||
return _cjkTable;
|
||||
}
|
||||
} catch { /* try next */ }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Character Classification ──────────────────────────────────────────────
|
||||
|
||||
function isLatinLetter(cp: number): boolean {
|
||||
return (
|
||||
(cp >= 0x41 && cp <= 0x5A) || (cp >= 0x61 && cp <= 0x7A) ||
|
||||
(cp >= 0x00C0 && cp <= 0x00FF && cp !== 0x00D7 && cp !== 0x00F7) ||
|
||||
(cp >= 0x0100 && cp <= 0x024F)
|
||||
);
|
||||
}
|
||||
|
||||
function isCjkHan(cp: number): boolean {
|
||||
return (
|
||||
(cp >= 0x4E00 && cp <= 0x9FFF) ||
|
||||
(cp >= 0x3400 && cp <= 0x4DBF) ||
|
||||
(cp >= 0xF900 && cp <= 0xFAFF)
|
||||
);
|
||||
}
|
||||
|
||||
function isKana(cp: number): boolean {
|
||||
return (cp >= 0x3040 && cp <= 0x309F) || (cp >= 0x30A0 && cp <= 0x30FF);
|
||||
}
|
||||
|
||||
function isHangul(cp: number): boolean {
|
||||
return (cp >= 0xAC00 && cp <= 0xD7AF) || (cp >= 0x1100 && cp <= 0x11FF) || (cp >= 0x3130 && cp <= 0x318F);
|
||||
}
|
||||
|
||||
function isCyrillic(cp: number): boolean {
|
||||
return (cp >= 0x0400 && cp <= 0x04FF) || (cp >= 0x0500 && cp <= 0x052F);
|
||||
}
|
||||
|
||||
function isArabic(cp: number): boolean {
|
||||
return (
|
||||
(cp >= 0x0600 && cp <= 0x06FF) || (cp >= 0x0750 && cp <= 0x077F) ||
|
||||
(cp >= 0x08A0 && cp <= 0x08FF) || (cp >= 0xFB50 && cp <= 0xFDFF) ||
|
||||
(cp >= 0xFE70 && cp <= 0xFEFF)
|
||||
);
|
||||
}
|
||||
|
||||
function isGreek(cp: number): boolean {
|
||||
return (cp >= 0x0370 && cp <= 0x03FF) || (cp >= 0x1F00 && cp <= 0x1FFF);
|
||||
}
|
||||
|
||||
// ─── Main Estimator ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Estimate token count for a string without doing BPE encoding.
|
||||
* Targets cl100k_base (GPT-4/Claude/DeepSeek/GLM/MiniMax).
|
||||
* Error typically <5% for code/English, <10% for CJK/mixed.
|
||||
*/
|
||||
export function fastEstimateTokens(text: string): number {
|
||||
if (!text) return 0;
|
||||
|
||||
const n = text.length;
|
||||
const cjkTable = loadCjkTable();
|
||||
let tokens = 0.0;
|
||||
let i = 0;
|
||||
|
||||
// Pre-scan: detect if text is non-English Latin (French, Spanish, etc.)
|
||||
let accentCount = 0;
|
||||
let latinCount = 0;
|
||||
const sampleEnd = Math.min(n, 50000);
|
||||
for (let s = 0; s < sampleEnd; s++) {
|
||||
const cp = text.charCodeAt(s);
|
||||
if (cp >= 0x80 && cp <= 0x024F &&
|
||||
((cp >= 0x00C0 && cp <= 0x00FF && cp !== 0x00D7 && cp !== 0x00F7) || (cp >= 0x0100 && cp <= 0x024F))) {
|
||||
accentCount++;
|
||||
}
|
||||
if ((cp >= 0x41 && cp <= 0x5A) || (cp >= 0x61 && cp <= 0x7A)) {
|
||||
latinCount++;
|
||||
}
|
||||
}
|
||||
const isNonEnglishLatin = latinCount > 100 && accentCount > latinCount * 0.005;
|
||||
|
||||
while (i < n) {
|
||||
const cp = text.charCodeAt(i);
|
||||
|
||||
// ── Latin word ──
|
||||
if (isLatinLetter(cp)) {
|
||||
let j = i + 1;
|
||||
while (j < n) {
|
||||
const c = text.charCodeAt(j);
|
||||
if (isLatinLetter(c)) { j++; }
|
||||
else if (c === 0x27 && j + 1 < n && isLatinLetter(text.charCodeAt(j + 1))) { j += 2; }
|
||||
else { break; }
|
||||
}
|
||||
const wl = j - i;
|
||||
|
||||
// Check if this word contains accented characters
|
||||
let hasAccent = false;
|
||||
if (isNonEnglishLatin) {
|
||||
for (let k = i; k < j; k++) {
|
||||
if (text.charCodeAt(k) >= 0x80) { hasAccent = true; break; }
|
||||
}
|
||||
if (!hasAccent) {
|
||||
// Check nearby window
|
||||
const lo = Math.max(0, i - 100);
|
||||
const hi = Math.min(n, j + 100);
|
||||
for (let k = lo; k < hi; k++) {
|
||||
const cc = text.charCodeAt(k);
|
||||
if (cc >= 0x00C0 && cc <= 0x024F && cc !== 0x00D7 && cc !== 0x00F7) {
|
||||
hasAccent = true; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAccent) {
|
||||
// Non-English Latin words are longer in tokens
|
||||
if (wl <= 3) tokens += 1.0;
|
||||
else if (wl <= 5) tokens += 1.35;
|
||||
else if (wl <= 7) tokens += 1.85;
|
||||
else if (wl <= 9) tokens += 2.5;
|
||||
else if (wl <= 12) tokens += 3.2;
|
||||
else tokens += 3.2 + (wl - 12) * 0.32;
|
||||
} else {
|
||||
// English word
|
||||
if (wl <= 4) tokens += 1.0;
|
||||
else if (wl <= 8) tokens += 1.1;
|
||||
else if (wl <= 13) tokens += 1.5;
|
||||
else tokens += 1.5 + (wl - 13) * 0.3;
|
||||
}
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── CJK Han characters ──
|
||||
if (isCjkHan(cp)) {
|
||||
let j = i + 1;
|
||||
let segTokens = 0.0;
|
||||
if (cjkTable && cp >= CJK_START && cp <= CJK_END) {
|
||||
segTokens += cjkTable[cp - CJK_START]; // table values are direct token counts (1-3)
|
||||
} else {
|
||||
segTokens += 1.3;
|
||||
}
|
||||
while (j < n && isCjkHan(text.charCodeAt(j))) {
|
||||
const cp2 = text.charCodeAt(j);
|
||||
if (cjkTable && cp2 >= CJK_START && cp2 <= CJK_END) {
|
||||
segTokens += cjkTable[cp2 - CJK_START];
|
||||
} else {
|
||||
segTokens += 1.3;
|
||||
}
|
||||
j++;
|
||||
}
|
||||
const run = j - i;
|
||||
// BPE merges adjacent CJK characters. Longer segments get more merges.
|
||||
if (run >= 4) segTokens *= 0.94;
|
||||
else if (run >= 2) segTokens *= 0.97;
|
||||
tokens += segTokens;
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Japanese Kana ──
|
||||
if (isKana(cp)) {
|
||||
let j = i + 1;
|
||||
while (j < n && isKana(text.charCodeAt(j))) j++;
|
||||
const run = j - i;
|
||||
if (run === 1) tokens += 1.0;
|
||||
else if (run === 2) tokens += 1.6;
|
||||
else if (run === 3) tokens += 2.65;
|
||||
else if (run === 4) tokens += 3.7;
|
||||
else if (run <= 6) tokens += run * 0.93;
|
||||
else tokens += run * 0.95;
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Korean Hangul ──
|
||||
if (isHangul(cp)) {
|
||||
tokens += 1.4;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Cyrillic (Russian etc.) ──
|
||||
if (isCyrillic(cp)) {
|
||||
let j = i + 1;
|
||||
while (j < n && isCyrillic(text.charCodeAt(j))) j++;
|
||||
tokens += (j - i) * 0.55;
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Arabic ──
|
||||
if (isArabic(cp)) {
|
||||
let j = i + 1;
|
||||
while (j < n && isArabic(text.charCodeAt(j))) j++;
|
||||
tokens += (j - i) * 0.82;
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Greek ──
|
||||
if (isGreek(cp)) {
|
||||
let j = i + 1;
|
||||
while (j < n && isGreek(text.charCodeAt(j))) j++;
|
||||
tokens += (j - i) * 0.85;
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Digits (with commas, dots) ──
|
||||
if (cp >= 0x30 && cp <= 0x39) {
|
||||
let j = i + 1;
|
||||
let digits = 1;
|
||||
let commas = 0;
|
||||
let dots = 0;
|
||||
while (j < n) {
|
||||
const c = text.charCodeAt(j);
|
||||
if (c >= 0x30 && c <= 0x39) { digits++; j++; }
|
||||
else if (c === 0x2C && j + 1 < n && text.charCodeAt(j + 1) >= 0x30 && text.charCodeAt(j + 1) <= 0x39) {
|
||||
commas++; j += 2; digits++;
|
||||
}
|
||||
else if (c === 0x2E && j + 1 < n && text.charCodeAt(j + 1) >= 0x30 && text.charCodeAt(j + 1) <= 0x39) {
|
||||
dots++; j += 2; digits++;
|
||||
}
|
||||
else { break; }
|
||||
}
|
||||
if (digits <= 3 && commas === 0 && dots === 0) tokens += 1.0;
|
||||
else if (commas > 0) tokens += commas * 2 + 1.0;
|
||||
else if (dots > 0) tokens += Math.max(2.0, digits / 3.0 + dots * 1.5);
|
||||
else tokens += Math.max(1.0, digits / 2.5);
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Whitespace (space, tab) ──
|
||||
if (cp === 0x20 || cp === 0x09) { i++; continue; }
|
||||
|
||||
// ── Newline ──
|
||||
if (cp === 0x0A || cp === 0x0D) { tokens += 1.0; i++; continue; }
|
||||
|
||||
// ── Fullwidth punctuation ──
|
||||
if ((cp >= 0x3000 && cp <= 0x303F) || (cp >= 0xFF00 && cp <= 0xFFEF) ||
|
||||
cp === 0x2018 || cp === 0x2019 || cp === 0x201C || cp === 0x201D ||
|
||||
cp === 0x2014 || cp === 0x2026 || cp === 0x2013) {
|
||||
tokens += 1.0; i++; continue;
|
||||
}
|
||||
|
||||
// ── ASCII punctuation ──
|
||||
if (cp >= 0x21 && cp <= 0x7E) { tokens += 0.6; i++; continue; }
|
||||
|
||||
// ── Other Unicode (emoji etc.) ──
|
||||
if (cp > 0x7F) { tokens += 2.5; i++; continue; }
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return Math.max(1, Math.round(tokens));
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate tokens for an array of messages (same as buildTiktokenContextSnapshot
|
||||
* but using fast estimation instead of tiktoken).
|
||||
*/
|
||||
export function fastEstimateMessages(messages: any[], jsonReplacer?: (key: string, value: unknown) => unknown): number {
|
||||
let total = 0;
|
||||
for (const msg of messages) {
|
||||
const str = JSON.stringify(msg, jsonReplacer as any);
|
||||
total += fastEstimateTokens(str);
|
||||
}
|
||||
// JSON array overhead
|
||||
total += Math.ceil(messages.length * 0.5);
|
||||
return total;
|
||||
}
|
||||
@@ -112,7 +112,7 @@ export function createAfterToolCallHandler(
|
||||
const hasMsgsKey = "messages" in (event ?? {});
|
||||
const msgsValue = event?.messages;
|
||||
const hasMsgs = msgsValue && Array.isArray(msgsValue);
|
||||
logger.info(`[context-offload] after_tool_call event keys=[${eventKeys.join(",")}], hasMsgsKey=${hasMsgsKey}, msgsType=${typeof msgsValue}, isArray=${Array.isArray(msgsValue)}, len=${hasMsgs ? msgsValue.length : "N/A"}`);
|
||||
logger.debug?.(`[context-offload] after_tool_call event keys=[${eventKeys.join(",")}], hasMsgsKey=${hasMsgsKey}, msgsType=${typeof msgsValue}, isArray=${Array.isArray(msgsValue)}, len=${hasMsgs ? msgsValue.length : "N/A"}`);
|
||||
|
||||
// ── Patch-effectiveness detection ──
|
||||
// The upstream runtime patch is expected to populate event.messages with
|
||||
@@ -168,7 +168,7 @@ export function createAfterToolCallHandler(
|
||||
// tool results that happen to contain "Approval required" in their text.
|
||||
const isApprovalPending = event.result?.details?.status === "approval-pending";
|
||||
if (isApprovalPending) {
|
||||
logger.info(`[context-offload] after_tool_call: SKIP approval-pending tool ${event.toolName} (${toolCallId})`);
|
||||
logger.debug?.(`[context-offload] after_tool_call: SKIP approval-pending tool ${event.toolName} (${toolCallId})`);
|
||||
stateManager.processedToolCallIds.add(toolCallId);
|
||||
return;
|
||||
}
|
||||
@@ -183,7 +183,7 @@ export function createAfterToolCallHandler(
|
||||
durationMs: event.durationMs,
|
||||
};
|
||||
stateManager.addToolPair(pair);
|
||||
logger.info(`[context-offload] after_tool_call: buffered ${event.toolName} (${toolCallId}), pending=${stateManager.getPendingCount()}, duration=${event.durationMs ?? "N/A"}ms`);
|
||||
logger.debug?.(`[context-offload] after_tool_call: buffered ${event.toolName} (${toolCallId}), pending=${stateManager.getPendingCount()}, duration=${event.durationMs ?? "N/A"}ms`);
|
||||
|
||||
// Cache latest user context for L2
|
||||
if (event.messages && Array.isArray(event.messages) && event.messages.length > 0 && !stateManager.cachedLatestTurnMessages) {
|
||||
@@ -200,9 +200,9 @@ export function createAfterToolCallHandler(
|
||||
const l15Settled = stateManager.l15Settled;
|
||||
const activeMmdFile = stateManager.getActiveMmdFile();
|
||||
if (!l15Settled) {
|
||||
logger.info(`[context-offload] after_tool_call MMD: SKIP (L1.5 not settled yet)`);
|
||||
logger.debug?.(`[context-offload] after_tool_call MMD: SKIP (L1.5 not settled yet)`);
|
||||
} else if (!activeMmdFile) {
|
||||
logger.info(`[context-offload] after_tool_call MMD: SKIP (no active MMD file)`);
|
||||
logger.debug?.(`[context-offload] after_tool_call MMD: SKIP (no active MMD file)`);
|
||||
} else {
|
||||
const mmdContent = await readMmd(stateManager.ctx, activeMmdFile);
|
||||
if (mmdContent) {
|
||||
@@ -231,19 +231,19 @@ export function createAfterToolCallHandler(
|
||||
const contentChanged = !oldContent.includes(activeMmdFile) || oldContent !== mmdText;
|
||||
if (contentChanged) {
|
||||
event.messages[existingIdx] = newMsg;
|
||||
logger.info(`[context-offload] after_tool_call MMD: UPDATED at [${existingIdx}], file=${activeMmdFile}, contentChanged=true`);
|
||||
logger.debug?.(`[context-offload] after_tool_call MMD: UPDATED at [${existingIdx}], file=${activeMmdFile}, contentChanged=true`);
|
||||
_dumpMessagesAfterMmd(event.messages, "UPDATED", logger);
|
||||
} else {
|
||||
logger.info(`[context-offload] after_tool_call MMD: unchanged, skip update`);
|
||||
logger.debug?.(`[context-offload] after_tool_call MMD: unchanged, skip update`);
|
||||
}
|
||||
} else {
|
||||
const insertIdx = findActiveMmdInsertionPoint(event.messages);
|
||||
event.messages.splice(insertIdx, 0, newMsg);
|
||||
logger.info(`[context-offload] after_tool_call MMD: INJECTED at [${insertIdx}], file=${activeMmdFile}, msgs=${event.messages.length}`);
|
||||
logger.debug?.(`[context-offload] after_tool_call MMD: INJECTED at [${insertIdx}], file=${activeMmdFile}, msgs=${event.messages.length}`);
|
||||
_dumpMessagesAfterMmd(event.messages, "INJECTED", logger);
|
||||
}
|
||||
} else {
|
||||
logger.info(`[context-offload] after_tool_call MMD: file=${activeMmdFile} content is null`);
|
||||
logger.debug?.(`[context-offload] after_tool_call MMD: file=${activeMmdFile} content is null`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -264,7 +264,7 @@ export function createAfterToolCallHandler(
|
||||
const _compResult = await checkAndCompressAfterToolCall(event, stateManager, logger, pluginConfig, getContextWindow);
|
||||
const _compDuration = Date.now() - _compStart;
|
||||
const _msgsAfter = event.messages?.length ?? 0;
|
||||
logger.info(`[context-offload] after_tool_call L3 check completed: ${_compDuration}ms`);
|
||||
logger.debug?.(`[context-offload] after_tool_call L3 check completed: ${_compDuration}ms`);
|
||||
|
||||
// QUICK-SKIP: no snapshots, skip trace
|
||||
if (_compResult) {
|
||||
@@ -419,7 +419,7 @@ async function checkAndCompressAfterToolCall(
|
||||
const quickEst = quickTokenEstimate(messages, stateManager);
|
||||
if (quickEst < mildThreshold * 0.85 && stateManager.consecutiveQuickSkips < MAX_CONSECUTIVE_QUICK_SKIPS) {
|
||||
stateManager.consecutiveQuickSkips++;
|
||||
logger.info(`[context-offload] L3(after_tool_call) QUICK-SKIP: est≈${quickEst} < ${Math.floor(mildThreshold * 0.85)} (85% mild), streak=${stateManager.consecutiveQuickSkips}/${MAX_CONSECUTIVE_QUICK_SKIPS}`);
|
||||
logger.debug?.(`[context-offload] L3(after_tool_call) QUICK-SKIP: est≈${quickEst} < ${Math.floor(mildThreshold * 0.85)} (85% mild), streak=${stateManager.consecutiveQuickSkips}/${MAX_CONSECUTIVE_QUICK_SKIPS}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -435,7 +435,7 @@ async function checkAndCompressAfterToolCall(
|
||||
const utilisation = snap.totalTokens / contextWindow;
|
||||
const aboveMild = snap.totalTokens >= mildThreshold;
|
||||
const aboveAggressive = snap.totalTokens >= aggressiveThreshold;
|
||||
logger.info(
|
||||
logger.debug?.(
|
||||
`[context-offload] L3(after_tool_call) token snapshot: tool=${event.toolName} total=${snap.totalTokens} ` +
|
||||
`msgCount=${messages.length} utilisation=${(utilisation * 100).toFixed(1)}% ` +
|
||||
`${aboveAggressive ? "⚠ ABOVE_AGGRESSIVE" : aboveMild ? "⚠ ABOVE_MILD" : "✓ OK"}`,
|
||||
@@ -456,14 +456,19 @@ async function checkAndCompressAfterToolCall(
|
||||
let _aggDeletedCount = 0;
|
||||
// Aggressive
|
||||
if (workingTokens >= aggressiveThreshold) {
|
||||
logger.info(`[context-offload] L3(after_tool_call) AGGRESSIVE: tokens≈${workingTokens} >= ${aggressiveThreshold}`);
|
||||
logger.debug?.(`[context-offload] L3(after_tool_call) AGGRESSIVE: tokens≈${workingTokens} >= ${aggressiveThreshold}`);
|
||||
const _atcAggStart = Date.now();
|
||||
const result = await aggressiveCompressUntilBelowThreshold(
|
||||
messages, offloadMap, currentTaskNodeIds, aggressiveDeleteRatio,
|
||||
stateManager, logger, aggressiveThreshold, countTokens, sysPrompt, null,
|
||||
);
|
||||
workingTokens = result.remainingTokens;
|
||||
_aggDeletedCount = result.deletedCount ?? result.allDeletedToolCallIds.length;
|
||||
logger.info(`[context-offload] L3(after_tool_call) AGGRESSIVE done: rounds=${result.rounds ?? "?"}, deleted=${result.allDeletedToolCallIds.length}, remaining≈${workingTokens}, stalledByUserMsg=${result.stalledByUserMsg ?? false}`);
|
||||
const _atcAggDuration = Date.now() - _atcAggStart;
|
||||
logger.debug?.(`[context-offload] L3(after_tool_call) AGGRESSIVE done: rounds=${result.rounds ?? "?"}, deleted=${result.allDeletedToolCallIds.length}, remaining≈${workingTokens}, stalledByUserMsg=${result.stalledByUserMsg ?? false}, duration=${_atcAggDuration}ms`);
|
||||
if (_atcAggDuration > 10_000) {
|
||||
logger.warn(`[context-offload] L3(after_tool_call) AGGRESSIVE SLOW: ${_atcAggDuration}ms (rounds=${result.rounds ?? "?"}, deleted=${result.allDeletedToolCallIds.length}, remaining≈${workingTokens})`);
|
||||
}
|
||||
dumpMessagesSnapshot("atc-after-aggressive", messages, logger);
|
||||
if (result.allDeletedToolCallIds.length > 0) {
|
||||
const statusUpdates = new Map<string, string | boolean>();
|
||||
@@ -496,10 +501,10 @@ async function checkAndCompressAfterToolCall(
|
||||
// Mild
|
||||
let _mildResult: { mildReplacedCount: number; mildReplacedDetails: Array<{ toolCallId: string; score: number; summaryPreview: string; originalLength?: number; summaryLength?: number }> } = { mildReplacedCount: 0, mildReplacedDetails: [] };
|
||||
if (workingTokens >= mildThreshold) {
|
||||
logger.info(`[context-offload] L3(after_tool_call) MILD: tokens≈${workingTokens} >= ${mildThreshold}`);
|
||||
logger.debug?.(`[context-offload] L3(after_tool_call) MILD: tokens≈${workingTokens} >= ${mildThreshold}`);
|
||||
const cascadeResult = compressByScoreCascade(messages, offloadMap, currentTaskNodeIds, mildScanRatio, logger);
|
||||
const detailStr = cascadeResult.replacedDetails.map((d) => `${d.toolCallId}(score=${d.score}): "${d.summaryPreview}"`).join(" | ");
|
||||
logger.info(`[context-offload] L3(after_tool_call) MILD done: replaced=${cascadeResult.replacedCount}, threshold=${cascadeResult.finalThreshold}${detailStr ? `, details=[${detailStr}]` : ""}`);
|
||||
logger.debug?.(`[context-offload] L3(after_tool_call) MILD done: replaced=${cascadeResult.replacedCount}, threshold=${cascadeResult.finalThreshold}${detailStr ? `, details=[${detailStr}]` : ""}`);
|
||||
_mildResult = { mildReplacedCount: cascadeResult.replacedCount, mildReplacedDetails: cascadeResult.replacedDetails };
|
||||
if (cascadeResult.replacedCount > 0) {
|
||||
for (const id of cascadeResult.replacedToolCallIds) {
|
||||
@@ -527,8 +532,13 @@ async function checkAndCompressAfterToolCall(
|
||||
let _emergencyDeletedCount = 0;
|
||||
if ((workingTokens >= emergencyThreshold || forceEmergency) && messages.length > EMERGENCY_MIN_MESSAGES_TO_KEEP) {
|
||||
_emergencyTriggered = true;
|
||||
const _atcEmStart = Date.now();
|
||||
const emergencyResult = emergencyCompress(messages, emergencyTarget, countTokens, sysPrompt, null, logger);
|
||||
const _atcEmDuration = Date.now() - _atcEmStart;
|
||||
_emergencyDeletedCount = emergencyResult.deletedCount;
|
||||
if (_atcEmDuration > 10_000) {
|
||||
logger.warn(`[context-offload] L3(after_tool_call) EMERGENCY SLOW: ${_atcEmDuration}ms (deleted=${emergencyResult.deletedCount}, remaining≈${emergencyResult.remainingTokens})`);
|
||||
}
|
||||
if (emergencyResult.deletedToolCallIds.length > 0) {
|
||||
const statusUpdates = new Map<string, string | boolean>();
|
||||
for (const id of emergencyResult.deletedToolCallIds) {
|
||||
@@ -580,5 +590,5 @@ function _extractText(msg: any): string {
|
||||
function _dumpMessagesAfterMmd(messages: any[], action: string, logger: PluginLogger): void {
|
||||
const mmdCount = messages.filter((m: any) => m._mmdContextMessage || m._mmdInjection).length;
|
||||
const offloadedCount = messages.filter((m: any) => m._offloaded).length;
|
||||
logger.info(`[context-offload] POST-MMD-${action} (after_tool_call): ${messages.length} msgs, mmd=${mmdCount}, offloaded=${offloadedCount}`);
|
||||
logger.debug?.(`[context-offload] POST-MMD-${action} (after_tool_call): ${messages.length} msgs, mmd=${mmdCount}, offloaded=${offloadedCount}`);
|
||||
}
|
||||
|
||||
@@ -69,16 +69,16 @@ export async function handleTaskTransition(
|
||||
const num = await stateManager.nextMmdNumber();
|
||||
const paddedNum = String(num).padStart(3, "0");
|
||||
const filename = `${paddedNum}-${label}.mmd`;
|
||||
logger.info(`[context-offload] L1.5: Creating new MMD: ${filename} (replacing ${currentMmd ?? "(none)"})`);
|
||||
logger.debug?.(`[context-offload] L1.5: Creating new MMD: ${filename} (replacing ${currentMmd ?? "(none)"})`);
|
||||
await cleanupIfEmptyShell(currentMmd);
|
||||
stateManager.setActiveMmd(filename, label);
|
||||
const initialMmd = `flowchart TD\n ${paddedNum}-N1["${label}"]\n`;
|
||||
await writeMmd(ctx, filename, initialMmd);
|
||||
logger.info(`[context-offload] L1.5: New MMD created and activated: ${filename}`);
|
||||
logger.debug?.(`[context-offload] L1.5: New MMD created and activated: ${filename}`);
|
||||
};
|
||||
|
||||
const reactivateMmd = async (contFile: string) => {
|
||||
logger.info(`[context-offload] L1.5: Reactivating MMD: ${contFile} (current=${currentMmd ?? "(none)"})`);
|
||||
logger.debug?.(`[context-offload] L1.5: Reactivating MMD: ${contFile} (current=${currentMmd ?? "(none)"})`);
|
||||
if (currentMmd && currentMmd !== contFile) {
|
||||
await cleanupIfEmptyShell(currentMmd);
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export async function handleTaskTransition(
|
||||
};
|
||||
|
||||
if (judgment.taskCompleted) {
|
||||
logger.info(`[context-offload] L1.5: Task COMPLETED — continuation=${judgment.isContinuation}, longTask=${judgment.isLongTask}, contFile=${judgment.continuationMmdFile ?? "N/A"}, newLabel=${judgment.newTaskLabel ?? "N/A"}`);
|
||||
logger.debug?.(`[context-offload] L1.5: Task COMPLETED — continuation=${judgment.isContinuation}, longTask=${judgment.isLongTask}, contFile=${judgment.continuationMmdFile ?? "N/A"}, newLabel=${judgment.newTaskLabel ?? "N/A"}`);
|
||||
if (judgment.isContinuation && judgment.continuationMmdFile) {
|
||||
await reactivateMmd(judgment.continuationMmdFile);
|
||||
} else if (judgment.isLongTask && judgment.newTaskLabel) {
|
||||
@@ -110,11 +110,11 @@ export async function handleTaskTransition(
|
||||
stateManager.setActiveMmd(null, null);
|
||||
}
|
||||
} else {
|
||||
logger.info("[context-offload] L1.5: No MMD needed (casual/short), clearing active MMD");
|
||||
logger.debug?.("[context-offload] L1.5: No MMD needed (casual/short), clearing active MMD");
|
||||
stateManager.setActiveMmd(null, null);
|
||||
}
|
||||
} else {
|
||||
logger.info(`[context-offload] L1.5: Task NOT completed — continuation=${judgment.isContinuation}, longTask=${judgment.isLongTask}, current=${currentMmd ?? "(none)"}`);
|
||||
logger.debug?.(`[context-offload] L1.5: Task NOT completed — continuation=${judgment.isContinuation}, longTask=${judgment.isLongTask}, current=${currentMmd ?? "(none)"}`);
|
||||
if (judgment.isContinuation) {
|
||||
if (!currentMmd && judgment.continuationMmdFile) {
|
||||
await reactivateMmd(judgment.continuationMmdFile);
|
||||
|
||||
@@ -51,7 +51,7 @@ export function createBeforePromptBuildHandler(
|
||||
const _sk = stateManager.getLastSessionKey() ?? _ctx?.sessionKey;
|
||||
if (typeof _sk === "string" && /memory-.*-session-\d+/.test(_sk)) return;
|
||||
|
||||
logger.info(`[context-offload] before_prompt_build CALLED, msgs=${event?.messages?.length ?? "?"}`);
|
||||
logger.debug?.(`[context-offload] before_prompt_build CALLED, msgs=${event?.messages?.length ?? "?"}`);
|
||||
try {
|
||||
const messages = event.messages;
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) return;
|
||||
@@ -158,11 +158,16 @@ export function createBeforePromptBuildHandler(
|
||||
const countTokens = createL3TokenCounter(pluginConfig, logger);
|
||||
const aggressiveDeleteRatio = (pluginConfig as any)?.aggressiveDeleteRatio ?? PLUGIN_DEFAULTS.aggressiveDeleteRatio;
|
||||
const currentTaskNodeIds = await getCurrentTaskNodeIds(stateManager);
|
||||
const _bpbAggStart = Date.now();
|
||||
const result = await aggressiveCompressUntilBelowThreshold(
|
||||
messages, offloadMap, currentTaskNodeIds, aggressiveDeleteRatio,
|
||||
stateManager, logger, aggressiveThreshold, countTokens, null, null,
|
||||
);
|
||||
workingTokens = result.remainingTokens;
|
||||
const _bpbAggDuration = Date.now() - _bpbAggStart;
|
||||
if (_bpbAggDuration > 10_000) {
|
||||
logger.warn(`[context-offload] L3(before_prompt_build) AGGRESSIVE SLOW: ${_bpbAggDuration}ms (rounds=${result.rounds}, deleted=${result.deletedCount}, remaining≈${workingTokens})`);
|
||||
}
|
||||
dumpMessagesSnapshot("bpb-after-aggressive", messages, logger);
|
||||
if (result.allDeletedToolCallIds.length > 0) {
|
||||
const statusUpdates = new Map<string, string | boolean>();
|
||||
@@ -221,8 +226,13 @@ export function createBeforePromptBuildHandler(
|
||||
if (forceEmergency) stateManager._forceEmergencyNext = false;
|
||||
if ((workingTokens >= emergencyThreshold || forceEmergency) && messages.length > EMERGENCY_MIN_MESSAGES_TO_KEEP) {
|
||||
const countTokensBpb = createL3TokenCounter(pluginConfig, logger);
|
||||
const _bpbEmStart = Date.now();
|
||||
const emergencyResult = emergencyCompress(messages, emergencyTarget, countTokensBpb, null, null, logger);
|
||||
workingTokens = emergencyResult.remainingTokens;
|
||||
const _bpbEmDuration = Date.now() - _bpbEmStart;
|
||||
if (_bpbEmDuration > 10_000) {
|
||||
logger.warn(`[context-offload] L3(before_prompt_build) EMERGENCY SLOW: ${_bpbEmDuration}ms (deleted=${emergencyResult.deletedCount}, remaining≈${workingTokens})`);
|
||||
}
|
||||
if (emergencyResult.deletedToolCallIds.length > 0) {
|
||||
const emergencyStatusUpdates = new Map<string, string | boolean>();
|
||||
for (const id of emergencyResult.deletedToolCallIds) {
|
||||
|
||||
+339
-117
@@ -8,7 +8,7 @@ import { readOffloadEntries, readMmd, listMmds, markOffloadStatus } from "../sto
|
||||
import { traceOffloadDecision } from "../opik-tracer.js";
|
||||
import { createL3TokenCounter } from "../l3-token-counter.js";
|
||||
import { injectMmdIntoMessages, findHistoryMmdInsertionPoint, findActiveMmdInsertionPoint } from "../mmd-injector.js";
|
||||
import { buildTiktokenContextSnapshot, tiktokenCount, jsonReplacer } from "../context-token-tracker.js";
|
||||
import { buildTiktokenContextSnapshot, tiktokenCount, jsonReplacer, invalidateTokenCache } from "../context-token-tracker.js";
|
||||
import {
|
||||
normalizeToolCallIdForLookup,
|
||||
getOffloadEntry,
|
||||
@@ -114,7 +114,11 @@ export const MILD_CASCADE_MIN_COUNT = 10;
|
||||
export const MILD_CASCADE_INITIAL_SCORE = 7;
|
||||
export const MILD_CASCADE_FLOOR_SCORE = 1;
|
||||
export const AGGRESSIVE_MIN_MESSAGES_TO_KEEP = 2;
|
||||
export const EMERGENCY_MIN_MESSAGES_TO_KEEP = 4;
|
||||
export const EMERGENCY_MIN_MESSAGES_TO_KEEP = 2;
|
||||
|
||||
// Maximum content length (chars) to keep when truncating an oversized message in-place.
|
||||
// ~2K chars ≈ ~500 tokens — enough to preserve tool_call_id and a snippet of context.
|
||||
const EMERGENCY_TRUNCATE_MAX_CHARS = 2000;
|
||||
|
||||
// ─── Message dump helper ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -169,7 +173,7 @@ export function createLlmInputL3Handler(
|
||||
const _sk = stateManager.getLastSessionKey();
|
||||
if (typeof _sk === "string" && /memory-.*-session-\d+/.test(_sk)) return;
|
||||
|
||||
logger.info(`[context-offload] llm_input_l3 CALLED, historyMsgs=${event?.historyMessages?.length ?? "?"}, prompt=${typeof event?.prompt === "string" ? event.prompt.slice(0, 50) : "?"}`);
|
||||
logger.debug?.(`[context-offload] llm_input_l3 CALLED, historyMsgs=${event?.historyMessages?.length ?? "?"}, prompt=${typeof event?.prompt === "string" ? event.prompt.slice(0, 50) : "?"}`);
|
||||
let _aggDeleted = 0;
|
||||
let _mildReplaced = 0;
|
||||
let _emergencyTriggered = false;
|
||||
@@ -213,7 +217,7 @@ export function createLlmInputL3Handler(
|
||||
const aggressiveThreshold = Math.floor(contextWindow * aggressiveRatio);
|
||||
|
||||
const utilisation = snap.totalTokens / contextWindow;
|
||||
logger.info(
|
||||
logger.debug?.(
|
||||
`[context-offload] L3(llm_input) token snapshot: total=${snap.totalTokens} ` +
|
||||
`(system=${snap.systemTokens}, messages=${snap.messagesTokens}, user=${snap.userPromptTokens}) ` +
|
||||
`msgCount=${historyMessages.length} utilisation=${(utilisation * 100).toFixed(1)}% ` +
|
||||
@@ -222,7 +226,7 @@ export function createLlmInputL3Handler(
|
||||
|
||||
if (historyMessages.length === 0) return;
|
||||
if (snap.totalTokens < mildThreshold) {
|
||||
logger.info(`[context-offload] L3(llm_input): ${snap.totalTokens} < mild@${mildThreshold} → no compression needed`);
|
||||
logger.debug?.(`[context-offload] L3(llm_input): ${snap.totalTokens} < mild@${mildThreshold} → no compression needed`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -237,14 +241,19 @@ export function createLlmInputL3Handler(
|
||||
|
||||
// Aggressive
|
||||
if (workingTokens >= aggressiveThreshold) {
|
||||
logger.info(`[context-offload] L3(llm_input) AGGRESSIVE: tokens≈${workingTokens} >= ${aggressiveThreshold}, starting deletion`);
|
||||
logger.debug?.(`[context-offload] L3(llm_input) AGGRESSIVE: tokens≈${workingTokens} >= ${aggressiveThreshold}, starting deletion`);
|
||||
const _llmAggStart = Date.now();
|
||||
const result = await aggressiveCompressUntilBelowThreshold(
|
||||
historyMessages, offloadMap, currentTaskNodeIds, aggressiveDeleteRatio,
|
||||
stateManager, logger, aggressiveThreshold, countTokens, sysPrompt, promptText,
|
||||
);
|
||||
workingTokens = result.remainingTokens;
|
||||
_aggDeleted = result.deletedCount ?? result.allDeletedToolCallIds.length;
|
||||
logger.info(`[context-offload] L3(llm_input) AGGRESSIVE done: rounds=${result.rounds}, deleted=${result.deletedCount}, remaining≈${workingTokens}, deletedIds=${result.allDeletedToolCallIds.length}, stalledByUserMsg=${result.stalledByUserMsg ?? false}`);
|
||||
const _llmAggDuration = Date.now() - _llmAggStart;
|
||||
logger.debug?.(`[context-offload] L3(llm_input) AGGRESSIVE done: rounds=${result.rounds}, deleted=${result.deletedCount}, remaining≈${workingTokens}, deletedIds=${result.allDeletedToolCallIds.length}, stalledByUserMsg=${result.stalledByUserMsg ?? false}, duration=${_llmAggDuration}ms`);
|
||||
if (_llmAggDuration > 10_000) {
|
||||
logger.warn(`[context-offload] L3(llm_input) AGGRESSIVE SLOW: ${_llmAggDuration}ms (rounds=${result.rounds}, deleted=${result.deletedCount}, remaining≈${workingTokens})`);
|
||||
}
|
||||
dumpMessagesSnapshot("after-aggressive", historyMessages, logger);
|
||||
if (result.allDeletedToolCallIds.length > 0) {
|
||||
const statusUpdates = new Map<string, string | boolean>();
|
||||
@@ -263,7 +272,7 @@ export function createLlmInputL3Handler(
|
||||
const histInsertIdx = findHistoryMmdInsertionPoint(historyMessages);
|
||||
historyMessages.splice(histInsertIdx, 0, ...mmdInjection.injectedMessages);
|
||||
workingTokens += mmdInjection.totalMmdTokens;
|
||||
logger.info(`[context-offload] L3(llm_input) AGGRESSIVE: injected ${mmdInjection.injectedMessages.length} history MMD msgs at [${histInsertIdx}] (${mmdInjection.totalMmdTokens} tokens, files=${mmdInjection.mmdFiles.join(",")})`);
|
||||
logger.debug?.(`[context-offload] L3(llm_input) AGGRESSIVE: injected ${mmdInjection.injectedMessages.length} history MMD msgs at [${histInsertIdx}] (${mmdInjection.totalMmdTokens} tokens, files=${mmdInjection.mmdFiles.join(",")})`);
|
||||
dumpMessagesSnapshot("after-aggressive-mmd-injection", historyMessages, logger);
|
||||
}
|
||||
}
|
||||
@@ -276,10 +285,10 @@ export function createLlmInputL3Handler(
|
||||
|
||||
// Mild
|
||||
if (workingTokens >= mildThreshold) {
|
||||
logger.info(`[context-offload] L3(llm_input) MILD: tokens≈${workingTokens} >= ${mildThreshold}, starting cascade`);
|
||||
logger.debug?.(`[context-offload] L3(llm_input) MILD: tokens≈${workingTokens} >= ${mildThreshold}, starting cascade`);
|
||||
const cascadeResult = compressByScoreCascade(historyMessages, offloadMap, currentTaskNodeIds, mildScanRatio, logger);
|
||||
_mildReplaced = cascadeResult.replacedCount;
|
||||
logger.info(`[context-offload] L3(llm_input) MILD done: replaced=${cascadeResult.replacedCount}, finalThreshold=${cascadeResult.finalThreshold}, ids=[${cascadeResult.replacedToolCallIds.slice(0,5).join(",")}${cascadeResult.replacedToolCallIds.length > 5 ? "..." : ""}]`);
|
||||
logger.debug?.(`[context-offload] L3(llm_input) MILD done: replaced=${cascadeResult.replacedCount}, finalThreshold=${cascadeResult.finalThreshold}, ids=[${cascadeResult.replacedToolCallIds.slice(0,5).join(",")}${cascadeResult.replacedToolCallIds.length > 5 ? "..." : ""}]`);
|
||||
if (cascadeResult.replacedCount > 0) {
|
||||
for (const id of cascadeResult.replacedToolCallIds) {
|
||||
stateManager.confirmedOffloadIds.add(id);
|
||||
@@ -305,7 +314,7 @@ export function createLlmInputL3Handler(
|
||||
|
||||
if ((workingTokens >= emergencyThreshold || forceEmergency) && historyMessages.length > EMERGENCY_MIN_MESSAGES_TO_KEEP) {
|
||||
_emergencyTriggered = true;
|
||||
logger.warn(`[context-offload] L3(llm_input) ⚠ EMERGENCY: tokens≈${workingTokens} >= ${emergencyThreshold} (force=${forceEmergency}), target=${emergencyTarget}`);
|
||||
logger.warn(`[context-offload] L3(llm_input) EMERGENCY: tokens≈${workingTokens} >= ${emergencyThreshold} (force=${forceEmergency}), target=${emergencyTarget}`);
|
||||
const emergencyResult = emergencyCompress(historyMessages, emergencyTarget, countTokens, sysPrompt, promptText, logger);
|
||||
_emergencyDeleted = emergencyResult.deletedCount;
|
||||
logger.warn(`[context-offload] L3(llm_input) EMERGENCY done: deleted=${emergencyResult.deletedCount}, remaining≈${emergencyResult.remainingTokens}, deletedIds=${emergencyResult.deletedToolCallIds.length}`);
|
||||
@@ -327,7 +336,7 @@ export function createLlmInputL3Handler(
|
||||
const finalSnap = buildTiktokenContextSnapshot("llm_input_l3_final", historyMessages, sysPrompt, promptText);
|
||||
const totalSaved = snap.totalTokens - finalSnap.totalTokens;
|
||||
if (totalSaved > 0) {
|
||||
logger.info(`[context-offload] L3(llm_input) SUMMARY: ${snap.totalTokens}→${finalSnap.totalTokens} (saved≈${totalSaved} tokens), msgs=${historyMessages.length}`);
|
||||
logger.debug?.(`[context-offload] L3(llm_input) SUMMARY: ${snap.totalTokens}→${finalSnap.totalTokens} (saved≈${totalSaved} tokens), msgs=${historyMessages.length}`);
|
||||
}
|
||||
|
||||
traceOffloadDecision({
|
||||
@@ -437,7 +446,7 @@ export function compressByScoreCascade(
|
||||
candidates.push({ msgIndex: i, toolCallId, offloadEntry, score: offloadEntry.score ?? 5 });
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
logger.info(`[context-offload] L3-MILD: 0 candidates in scan range (0..${scanEnd}/${totalMessages}), offloadMap=${offloadMap.size} entries`);
|
||||
logger.debug?.(`[context-offload] L3-MILD: 0 candidates in scan range (0..${scanEnd}/${totalMessages}), offloadMap=${offloadMap.size} entries`);
|
||||
return { replacedCount: 0, lastOffloadedId: null, finalThreshold: initialScore, replacedToolCallIds: [], replacedDetails: [] };
|
||||
}
|
||||
candidates.sort((a: any, b: any) => b.score - a.score);
|
||||
@@ -449,7 +458,7 @@ export function compressByScoreCascade(
|
||||
scoreDist.set(s, (scoreDist.get(s) ?? 0) + 1);
|
||||
}
|
||||
const scoreDistStr = [...scoreDist.entries()].sort((a, b) => b[0] - a[0]).map(([s, n]) => `score=${s}:${n}`).join(", ");
|
||||
logger.info(`[context-offload] L3-MILD: ${candidates.length} candidates (scan 0..${scanEnd}/${totalMessages}), distribution=[${scoreDistStr}], offloadMap=${offloadMap.size}`);
|
||||
logger.debug?.(`[context-offload] L3-MILD: ${candidates.length} candidates (scan 0..${scanEnd}/${totalMessages}), distribution=[${scoreDistStr}], offloadMap=${offloadMap.size}`);
|
||||
|
||||
const toolCallIdToResultIdx = new Map<string, number>();
|
||||
const toolCallIdToAssistantIdx = new Map<string, number>();
|
||||
@@ -512,14 +521,14 @@ export function compressByScoreCascade(
|
||||
}
|
||||
} else {
|
||||
const replInfo = replaceWithSummary(msg, c.offloadEntry);
|
||||
logger.info(
|
||||
logger.debug?.(
|
||||
`[context-offload] L3-MILD replace: [${c.msgIndex}] ${c.toolCallId} score=${c.score}, ` +
|
||||
`original=${replInfo.originalLength}→summary=${replInfo.summaryLength} (delta=${replInfo.summaryLength - replInfo.originalLength}), ` +
|
||||
`tool=${(c.offloadEntry.tool_call ?? "").slice(0, 80)}, ` +
|
||||
`summary="${(c.offloadEntry.summary ?? "").slice(0, 100)}"`,
|
||||
);
|
||||
if (replInfo.summaryLength > replInfo.originalLength) {
|
||||
logger.info(`[context-offload] L3-MILD: SKIPPING replacement for ${c.toolCallId} — summary larger than original (${replInfo.originalLength} → ${replInfo.summaryLength}, delta=+${replInfo.summaryLength - replInfo.originalLength}), reverting`);
|
||||
logger.debug?.(`[context-offload] L3-MILD: SKIPPING replacement for ${c.toolCallId} — summary larger than original (${replInfo.originalLength} → ${replInfo.summaryLength}, delta=+${replInfo.summaryLength - replInfo.originalLength}), reverting`);
|
||||
// Revert: the message was already mutated by replaceWithSummary,
|
||||
// but we mark it as _offloaded anyway to avoid re-processing.
|
||||
// The net effect is minimal since the size barely increased.
|
||||
@@ -611,36 +620,33 @@ function capDeleteCountForUserMessage(messages: any[], deleteCount: number): num
|
||||
// ─── Aggressive Compression ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute how many messages to delete from the head of the array.
|
||||
* Compute how many messages to delete from the head to bring total tokens
|
||||
* below threshold. One-shot: accumulate per-message token costs from the
|
||||
* head until enough tokens have been removed.
|
||||
*
|
||||
* Strategy: accumulate tokens from the oldest messages until reaching
|
||||
* `totalMsgTokens * deleteRatio`. This preferentially deletes the oldest
|
||||
* (typically already-offloaded / compressed) messages.
|
||||
*
|
||||
* IMPORTANT: When many messages are already offloaded (small summaries),
|
||||
* the head region may contain very few tokens. To prevent "delete 0" stalls,
|
||||
* we guarantee a minimum delete count proportional to the message count
|
||||
* when above threshold — this ensures progress even when token distribution
|
||||
* is heavily tail-weighted.
|
||||
* @param messages - messages array (MMD already extracted)
|
||||
* @param remainingTokens - current total tokens (may include sys/prompt overhead)
|
||||
* @param aggressiveThreshold - target total tokens to reach
|
||||
* @param countTokens - tiktoken counter
|
||||
* @param maxDeletable - max messages allowed to delete (preserves MIN_KEEP)
|
||||
*/
|
||||
function computeAggressiveDeleteCount(messages: any[], deleteRatio: number, countTokens: (t: string) => number, maxDeletable: number): number {
|
||||
function computeAggressiveDeleteCount(messages: any[], remainingTokens: number, aggressiveThreshold: number, countTokens: (t: string) => number, maxDeletable: number): number {
|
||||
if (messages.length === 0 || maxDeletable <= 0) return 0;
|
||||
if (remainingTokens <= aggressiveThreshold) return 0; // already below target
|
||||
// Need to remove (remainingTokens - aggressiveThreshold) tokens from messages
|
||||
const tokensToDelete = remainingTokens - aggressiveThreshold;
|
||||
const perMsg = messages.map((m: any) => countTokens(JSON.stringify(m)));
|
||||
const totalMsgTokens = perMsg.reduce((a: number, b: number) => a + b, 0);
|
||||
if (totalMsgTokens <= 0) return Math.min(maxDeletable, Math.ceil(messages.length * deleteRatio));
|
||||
const targetTokens = totalMsgTokens * deleteRatio;
|
||||
let acc = 0;
|
||||
let deleteCount = 0;
|
||||
for (let i = 0; i < messages.length && deleteCount < maxDeletable; i++) {
|
||||
acc += perMsg[i];
|
||||
deleteCount = i + 1;
|
||||
if (acc >= targetTokens) break;
|
||||
if (acc >= tokensToDelete) break;
|
||||
}
|
||||
// Minimum progress guarantee: when we couldn't reach targetTokens
|
||||
// (head messages are tiny offloaded summaries), ensure at least
|
||||
// deleteRatio of MESSAGE COUNT is deleted to make forward progress.
|
||||
if (acc < targetTokens && deleteCount > 0) {
|
||||
const minByCount = Math.max(1, Math.ceil(messages.length * deleteRatio * 0.5));
|
||||
// Minimum progress guarantee: if head messages are tiny (offloaded summaries)
|
||||
// and we couldn't reach tokensToDelete, ensure at least 20% of messages are deleted.
|
||||
if (acc < tokensToDelete && deleteCount > 0) {
|
||||
const minByCount = Math.max(1, Math.ceil(messages.length * 0.2));
|
||||
deleteCount = Math.max(deleteCount, Math.min(minByCount, maxDeletable));
|
||||
}
|
||||
return deleteCount;
|
||||
@@ -653,64 +659,11 @@ function adjustDeleteCountForToolPairing(messages: any[], initialDeleteCount: nu
|
||||
return count;
|
||||
}
|
||||
|
||||
async function aggressiveCompress(
|
||||
messages: any[],
|
||||
offloadMap: Map<string, OffloadEntry>,
|
||||
deleteRatio: number,
|
||||
stateManager: OffloadStateManager,
|
||||
logger: PluginLogger,
|
||||
countTokens: (t: string) => number,
|
||||
): Promise<{ deletedCount: number; deletedToolCallIds: string[]; deletedTokens: number }> {
|
||||
const mmdMsgs: { msg: any }[] = [];
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i]._mmdContextMessage || messages[i]._mmdInjection) {
|
||||
mmdMsgs.unshift({ msg: messages.splice(i, 1)[0] });
|
||||
}
|
||||
}
|
||||
|
||||
const totalMessages = messages.length;
|
||||
const maxDeletable = Math.max(0, totalMessages - AGGRESSIVE_MIN_MESSAGES_TO_KEEP);
|
||||
let deleteCount = computeAggressiveDeleteCount(messages, deleteRatio, countTokens, maxDeletable);
|
||||
deleteCount = adjustDeleteCountForToolPairing(messages, deleteCount);
|
||||
const preCapCount = deleteCount;
|
||||
deleteCount = capDeleteCountForUserMessage(messages, deleteCount);
|
||||
if (deleteCount < preCapCount) {
|
||||
logger.info(`[context-offload] L3-AGGRESSIVE capDeleteCountForUserMessage: ${preCapCount} → ${deleteCount} (lastUserIdx=${findLastUserMessageIndex(messages)})`);
|
||||
}
|
||||
|
||||
// Calculate token cost of messages to delete BEFORE splicing (for incremental subtraction)
|
||||
const deletedTokens = tiktokenCount(JSON.stringify(messages.slice(0, deleteCount), jsonReplacer));
|
||||
|
||||
const toDelete = messages.splice(0, deleteCount);
|
||||
const deletedToolCallIds: string[] = [];
|
||||
|
||||
// Collect tool call IDs and log aggregated summary (was per-message, now single line)
|
||||
for (const msg of toDelete) {
|
||||
const toolCallId = extractToolCallId(msg) ?? extractToolUseIdFromAssistant(msg);
|
||||
if ((isToolResultMessage(msg) || isToolUseInAssistant(msg)) && toolCallId && deletedToolCallIds.length < 50) {
|
||||
deletedToolCallIds.push(toolCallId);
|
||||
}
|
||||
}
|
||||
logger.info(
|
||||
`[context-offload] L3-AGGRESSIVE deleted ${toDelete.length} msgs, toolCallIds=[${deletedToolCallIds.slice(0, 5).join(",")}${deletedToolCallIds.length > 5 ? `...+${deletedToolCallIds.length - 5}` : ""}]`,
|
||||
);
|
||||
|
||||
// Restore MMD context messages (including _mmdInjection)
|
||||
for (const { msg } of mmdMsgs) {
|
||||
if (msg._mmdContextMessage === "history" || msg._mmdInjection) {
|
||||
const restoreIdx = findHistoryMmdInsertionPoint(messages);
|
||||
messages.splice(restoreIdx, 0, msg);
|
||||
} else {
|
||||
// Active MMD: use the same insertion logic as mmd-injector to avoid
|
||||
// breaking tool_call/tool_result pairing or user→assistant alternation.
|
||||
const insertIdx = findActiveMmdInsertionPoint(messages);
|
||||
messages.splice(insertIdx, 0, msg);
|
||||
}
|
||||
}
|
||||
|
||||
return { deletedCount: toDelete.length, deletedToolCallIds, deletedTokens };
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot aggressive compression. Computes the exact cut point to bring
|
||||
* tokens below threshold in a single pass, then splices once.
|
||||
* No multi-round while loop — O(N) tiktoken + O(1) splice.
|
||||
*/
|
||||
export async function aggressiveCompressUntilBelowThreshold(
|
||||
messages: any[],
|
||||
offloadMap: Map<string, OffloadEntry>,
|
||||
@@ -723,31 +676,78 @@ export async function aggressiveCompressUntilBelowThreshold(
|
||||
sysPrompt: string | null,
|
||||
promptText: string | null,
|
||||
): Promise<{ deletedCount: number; rounds: number; remainingTokens: number; allDeletedToolCallIds: string[]; stalledByUserMsg?: boolean }> {
|
||||
let deletedTotal = 0;
|
||||
let rounds = 0;
|
||||
const allDeletedToolCallIds: string[] = [];
|
||||
let remainingTokens = buildTiktokenContextSnapshot("l3_aggressive_est", messages, sysPrompt, promptText).totalTokens;
|
||||
let stalledByUserMsg = false;
|
||||
|
||||
logger.info(`[context-offload] L3-aggressive entry: msgs=${messages.length}, remainingTokens=${remainingTokens}, threshold=${aggressiveThreshold}, minKeep=${AGGRESSIVE_MIN_MESSAGES_TO_KEEP}, willLoop=${remainingTokens >= aggressiveThreshold && messages.length > AGGRESSIVE_MIN_MESSAGES_TO_KEEP}`);
|
||||
logger.debug?.(`[context-offload] L3-aggressive entry: msgs=${messages.length}, remainingTokens=${remainingTokens}, threshold=${aggressiveThreshold}, minKeep=${AGGRESSIVE_MIN_MESSAGES_TO_KEEP}`);
|
||||
|
||||
while (remainingTokens >= aggressiveThreshold && messages.length > AGGRESSIVE_MIN_MESSAGES_TO_KEEP) {
|
||||
rounds++;
|
||||
const oneRound = await aggressiveCompress(messages, offloadMap, deleteRatio, stateManager, logger, countTokens);
|
||||
if (oneRound.deletedCount <= 0) {
|
||||
// Aggressive stalled — likely because capDeleteCountForUserMessage blocked deletion.
|
||||
// Signal the caller so it can escalate to emergency compression.
|
||||
stalledByUserMsg = true;
|
||||
logger.warn(`[context-offload] L3-aggressive STALLED at round ${rounds}: deleted=0 (user msg at head?), remaining≈${remainingTokens}, msgs=${messages.length}`);
|
||||
break;
|
||||
}
|
||||
deletedTotal += oneRound.deletedCount;
|
||||
allDeletedToolCallIds.push(...oneRound.deletedToolCallIds);
|
||||
// Incremental subtraction instead of full tiktoken re-encode
|
||||
remainingTokens -= oneRound.deletedTokens;
|
||||
logger.info(`[context-offload] L3-aggressive round ${rounds}: deleted=${oneRound.deletedCount}, remaining≈${remainingTokens}, msgsLeft=${messages.length}`);
|
||||
if (remainingTokens < aggressiveThreshold || messages.length <= AGGRESSIVE_MIN_MESSAGES_TO_KEEP) {
|
||||
return { deletedCount: 0, rounds: 0, remainingTokens, allDeletedToolCallIds, stalledByUserMsg };
|
||||
}
|
||||
return { deletedCount: deletedTotal, rounds, remainingTokens, allDeletedToolCallIds, stalledByUserMsg };
|
||||
|
||||
// ── Extract MMD messages before computing delete count ──
|
||||
const mmdMsgs: { msg: any }[] = [];
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i]._mmdContextMessage || messages[i]._mmdInjection) {
|
||||
mmdMsgs.unshift({ msg: messages.splice(i, 1)[0] });
|
||||
}
|
||||
}
|
||||
|
||||
// ── One-shot: compute exactly how many to delete to reach threshold ──
|
||||
const maxDeletable = Math.max(0, messages.length - AGGRESSIVE_MIN_MESSAGES_TO_KEEP);
|
||||
let deleteCount = computeAggressiveDeleteCount(messages, remainingTokens, aggressiveThreshold, countTokens, maxDeletable);
|
||||
deleteCount = adjustDeleteCountForToolPairing(messages, deleteCount);
|
||||
const preCapCount = deleteCount;
|
||||
deleteCount = capDeleteCountForUserMessage(messages, deleteCount);
|
||||
if (deleteCount < preCapCount) {
|
||||
logger.debug?.(`[context-offload] L3-AGGRESSIVE capDeleteCountForUserMessage: ${preCapCount} → ${deleteCount} (lastUserIdx=${findLastUserMessageIndex(messages)})`);
|
||||
}
|
||||
|
||||
if (deleteCount <= 0) {
|
||||
stalledByUserMsg = true;
|
||||
logger.warn(`[context-offload] L3-aggressive STALLED: deleteCount=0 (user msg at head?), remaining≈${remainingTokens}, msgs=${messages.length}`);
|
||||
// Restore MMD messages
|
||||
for (const { msg } of mmdMsgs) {
|
||||
if (msg._mmdContextMessage === "history" || msg._mmdInjection) {
|
||||
messages.splice(findHistoryMmdInsertionPoint(messages), 0, msg);
|
||||
} else {
|
||||
messages.splice(findActiveMmdInsertionPoint(messages), 0, msg);
|
||||
}
|
||||
}
|
||||
return { deletedCount: 0, rounds: 1, remainingTokens, allDeletedToolCallIds, stalledByUserMsg };
|
||||
}
|
||||
|
||||
// ── Calculate deleted token cost and splice ──
|
||||
const deletedTokens = tiktokenCount(JSON.stringify(messages.slice(0, deleteCount), jsonReplacer));
|
||||
const toDelete = messages.splice(0, deleteCount);
|
||||
|
||||
// Collect tool call IDs
|
||||
for (const msg of toDelete) {
|
||||
const toolCallId = extractToolCallId(msg) ?? extractToolUseIdFromAssistant(msg);
|
||||
if ((isToolResultMessage(msg) || isToolUseInAssistant(msg)) && toolCallId && allDeletedToolCallIds.length < 200) {
|
||||
allDeletedToolCallIds.push(toolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
remainingTokens -= deletedTokens;
|
||||
logger.debug?.(
|
||||
`[context-offload] L3-AGGRESSIVE one-shot: deleted=${toDelete.length} msgs, remaining≈${remainingTokens}, msgsLeft=${messages.length}, ` +
|
||||
`toolCallIds=[${allDeletedToolCallIds.slice(0, 5).join(",")}${allDeletedToolCallIds.length > 5 ? `...+${allDeletedToolCallIds.length - 5}` : ""}]`,
|
||||
);
|
||||
|
||||
// ── Restore MMD context messages ──
|
||||
for (const { msg } of mmdMsgs) {
|
||||
if (msg._mmdContextMessage === "history" || msg._mmdInjection) {
|
||||
const restoreIdx = findHistoryMmdInsertionPoint(messages);
|
||||
messages.splice(restoreIdx, 0, msg);
|
||||
} else {
|
||||
const insertIdx = findActiveMmdInsertionPoint(messages);
|
||||
messages.splice(insertIdx, 0, msg);
|
||||
}
|
||||
}
|
||||
|
||||
return { deletedCount: toDelete.length, rounds: 1, remainingTokens, allDeletedToolCallIds, stalledByUserMsg };
|
||||
}
|
||||
|
||||
// ─── Emergency Compression ───────────────────────────────────────────────────
|
||||
@@ -791,7 +791,13 @@ export function emergencyCompress(
|
||||
const tailDeleted = _emergencyTailDelete(messages, targetTokens, currentTokens, deletedToolCallIds, logger);
|
||||
deletedCount += tailDeleted.count;
|
||||
currentTokens -= tailDeleted.tokens;
|
||||
if (tailDeleted.count <= 0) break; // truly nothing left to delete
|
||||
if (tailDeleted.count <= 0) {
|
||||
// Both head-delete and tail-delete are stuck.
|
||||
// Last-resort: truncate the LARGEST message content in-place.
|
||||
const truncResult = _emergencyTruncateOversized(messages, targetTokens, currentTokens, deletedToolCallIds, logger);
|
||||
currentTokens -= truncResult.tokensSaved;
|
||||
if (truncResult.tokensSaved <= 0) break; // truly nothing left to do
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Calculate deleted tokens before splicing (incremental subtraction)
|
||||
@@ -938,7 +944,7 @@ function _emergencyTailDelete(
|
||||
}
|
||||
totalDeleted += best.indices.length;
|
||||
totalTokensDeleted += best.tokens;
|
||||
logger.info(
|
||||
logger.debug?.(
|
||||
`[context-offload] EMERGENCY tail-delete: removed ${best.indices.length} msgs (group tokens=${best.tokens}, ids=[${best.toolCallIds.slice(0, 3).join(",")}${best.toolCallIds.length > 3 ? "..." : ""}]), remaining≈${currentTokens - totalTokensDeleted}`,
|
||||
);
|
||||
}
|
||||
@@ -946,6 +952,222 @@ function _emergencyTailDelete(
|
||||
return { count: totalDeleted, tokens: totalTokensDeleted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Emergency truncate: when both head-delete and tail-delete are blocked
|
||||
* (e.g. only MIN_KEEP messages remain but one is 142K tokens), truncate
|
||||
* the LARGEST message content in-place to break the deadlock.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Find the largest non-user message by token count.
|
||||
* 2. If it's a tool result, replace content with a truncated stub.
|
||||
* 3. If truncation fails or message is protected, try deleting it entirely
|
||||
* (ignoring MIN_KEEP for this single critical operation).
|
||||
*
|
||||
* This ensures emergency ALWAYS makes progress regardless of MIN_KEEP constraints.
|
||||
*/
|
||||
function _emergencyTruncateOversized(
|
||||
messages: any[],
|
||||
targetTokens: number,
|
||||
currentTokens: number,
|
||||
deletedToolCallIds: string[],
|
||||
logger: PluginLogger,
|
||||
): { tokensSaved: number } {
|
||||
const lastUserIdx = findLastUserMessageIndex(messages);
|
||||
let bestIdx = -1;
|
||||
let bestTokens = 0;
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (i === lastUserIdx) continue; // protect last user message
|
||||
const msg = messages[i];
|
||||
if (msg._mmdContextMessage || msg._mmdInjection) continue;
|
||||
const tokens = tiktokenCount(JSON.stringify(msg, jsonReplacer));
|
||||
if (tokens > bestTokens) {
|
||||
bestTokens = tokens;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIdx < 0 || bestTokens <= 0) return { tokensSaved: 0 };
|
||||
|
||||
// Skip if the largest message is already small enough — truncation would
|
||||
// make it LARGER (stub text overhead > original content). ~600 tokens is
|
||||
// the approximate size of the stub + preview.
|
||||
if (bestTokens < 600) return { tokensSaved: 0 };
|
||||
|
||||
const msg = messages[bestIdx];
|
||||
const role = msg.role ?? msg.message?.role ?? msg.type;
|
||||
const isAssistantTU = isAssistantMessageWithToolUse(msg);
|
||||
const toolCallId = extractToolCallId(msg) ?? extractToolUseIdFromAssistant(msg);
|
||||
|
||||
|
||||
// Try truncation first: replace content with a short stub
|
||||
try {
|
||||
if (isAssistantTU) {
|
||||
// Assistant with tool_use: preserve tool_use block structure (id, name, type)
|
||||
// but replace input/arguments with a compact stub to maintain tool pairing.
|
||||
_truncateAssistantToolUseContent(msg, bestTokens, logger);
|
||||
} else {
|
||||
// toolResult / plain assistant / other: safe to replace entire content
|
||||
const stubText =
|
||||
`[Tool output truncated for context management. Original ~${bestTokens} tokens, role=${role}${toolCallId ? `, id=${toolCallId}` : ""}]`;
|
||||
_setMessageContent(msg, stubText);
|
||||
// Also strip any other large fields that may exist on the message
|
||||
// (OpenClaw tool results can have output/result/data fields outside content)
|
||||
_stripLargeFields(msg);
|
||||
}
|
||||
// Invalidate WeakMap token cache so buildTiktokenContextSnapshot sees the new size
|
||||
invalidateTokenCache(msg);
|
||||
// Also clean up any legacy per-message cache markers
|
||||
if (msg._cachedTokens !== undefined) delete msg._cachedTokens;
|
||||
if (msg._tokenCount !== undefined) delete msg._tokenCount;
|
||||
|
||||
const afterTokens = tiktokenCount(JSON.stringify(msg, jsonReplacer));
|
||||
const saved = bestTokens - afterTokens;
|
||||
|
||||
if (toolCallId) deletedToolCallIds.push(toolCallId);
|
||||
|
||||
logger.warn(
|
||||
`[context-offload] EMERGENCY truncate-in-place: idx=${bestIdx}, role=${role}, isToolUse=${isAssistantTU}, ` +
|
||||
`${bestTokens}→${afterTokens} tokens (saved=${saved}), id=${toolCallId ?? "N/A"}`,
|
||||
);
|
||||
return { tokensSaved: saved };
|
||||
} catch (truncErr) {
|
||||
// Truncation failed — force-delete the message regardless of MIN_KEEP.
|
||||
// If it's an assistant with tool_use, also remove its paired toolResult
|
||||
// messages to avoid orphaned tool results (Anthropic 400 error).
|
||||
logger.warn(`[context-offload] EMERGENCY truncate failed (${truncErr}), force-deleting msg idx=${bestIdx}`);
|
||||
let totalSaved = bestTokens;
|
||||
const tuIds = isAssistantTU ? new Set(extractAllToolUseIds(msg)) : null;
|
||||
messages.splice(bestIdx, 1);
|
||||
if (toolCallId) deletedToolCallIds.push(toolCallId);
|
||||
|
||||
// Clean up orphaned toolResult messages for the deleted tool_use IDs
|
||||
if (tuIds && tuIds.size > 0) {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (!isToolResultMessage(messages[i])) continue;
|
||||
const tid = extractToolCallId(messages[i]);
|
||||
if (tid && tuIds.has(tid)) {
|
||||
totalSaved += tiktokenCount(JSON.stringify(messages[i], jsonReplacer));
|
||||
messages.splice(i, 1);
|
||||
deletedToolCallIds.push(tid);
|
||||
tuIds.delete(tid);
|
||||
if (tuIds.size === 0) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { tokensSaved: totalSaved };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate an assistant message with tool_use blocks while preserving
|
||||
* tool_use structure (type, id, name) to maintain tool pairing.
|
||||
* Replaces text blocks with a stub and tool_use input with a compact marker.
|
||||
*/
|
||||
function _truncateAssistantToolUseContent(msg: any, originalTokens: number, logger: PluginLogger): void {
|
||||
const content = msg.content ?? msg.message?.content;
|
||||
if (!Array.isArray(content)) {
|
||||
// Not array content — fall back to simple text replacement
|
||||
_setMessageContent(msg, `[Assistant tool_use message truncated for context management. Original ~${originalTokens} tokens. Tool call arguments removed.]`);
|
||||
return;
|
||||
}
|
||||
// Insert a truncation notice as the first text block
|
||||
content.unshift({
|
||||
type: "text",
|
||||
text: `[Assistant message truncated for context management. Original ~${originalTokens} tokens. Tool call arguments below replaced with stubs.]`,
|
||||
});
|
||||
for (let i = 1; i < content.length; i++) {
|
||||
const block = content[i] as any;
|
||||
if (block.type === "tool_use" || block.type === "toolCall") {
|
||||
// Preserve id/name/type, replace input with compact stub
|
||||
if (block.input !== undefined) {
|
||||
block.input = { _truncated: true, _original_tokens: originalTokens };
|
||||
}
|
||||
if (block.arguments !== undefined) {
|
||||
block.arguments = { _truncated: true, _original_tokens: originalTokens };
|
||||
}
|
||||
} else if (block.type === "text") {
|
||||
// Truncate text blocks
|
||||
block.text = typeof block.text === "string"
|
||||
? block.text.slice(0, 200) + (block.text.length > 200 ? "…[truncated]" : "")
|
||||
: "[truncated]";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract a preview of message content (first N chars) */
|
||||
function _extractContentPreview(msg: any, maxChars: number): string {
|
||||
const content = msg.content ?? msg.message?.content;
|
||||
if (typeof content === "string") {
|
||||
return content.slice(0, maxChars);
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
let result = "";
|
||||
for (const block of content) {
|
||||
const text = typeof block === "string" ? block : (block.text ?? "");
|
||||
result += text;
|
||||
if (result.length >= maxChars) break;
|
||||
}
|
||||
return result.slice(0, maxChars);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Set message content (handles both direct and transcript wrapper format) */
|
||||
function _setMessageContent(msg: any, text: string): void {
|
||||
if (msg.type === "message" && msg.message) {
|
||||
if (Array.isArray(msg.message.content)) {
|
||||
msg.message.content = [{ type: "text", text }];
|
||||
} else {
|
||||
msg.message.content = text;
|
||||
}
|
||||
} else {
|
||||
if (Array.isArray(msg.content)) {
|
||||
msg.content = [{ type: "text", text }];
|
||||
} else {
|
||||
msg.content = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip large non-essential fields from a message after content truncation.
|
||||
* OpenClaw tool result messages may store the raw output in fields like
|
||||
* `output`, `result`, `data`, `rawContent`, `response`, etc. that are
|
||||
* outside of `content` but still get serialized and counted as tokens.
|
||||
*
|
||||
* Preserves structural fields (role, type, id, toolCallId, name, tool_call_id).
|
||||
*/
|
||||
function _stripLargeFields(msg: any): void {
|
||||
const PRESERVE_KEYS = new Set([
|
||||
"role", "type", "name", "id", "toolCallId", "tool_call_id",
|
||||
"content", "message", "status",
|
||||
// internal plugin markers
|
||||
"_offloaded", "_mmdContextMessage", "_mmdInjection", "_contextOffloadProcessed",
|
||||
"_cachedTokens", "_tokenCount",
|
||||
]);
|
||||
const LARGE_THRESHOLD = 500; // chars — delete any field value > 500 chars serialized
|
||||
|
||||
const stripObj = (obj: any) => {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (PRESERVE_KEYS.has(key)) continue;
|
||||
const val = obj[key];
|
||||
if (val === null || val === undefined) continue;
|
||||
const serialized = typeof val === "string" ? val : JSON.stringify(val);
|
||||
if (serialized && serialized.length > LARGE_THRESHOLD) {
|
||||
delete obj[key];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
stripObj(msg);
|
||||
// Also strip inside the transcript wrapper
|
||||
if (msg.type === "message" && msg.message && typeof msg.message === "object") {
|
||||
stripObj(msg.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── History MMD Injection ───────────────────────────────────────────────────
|
||||
|
||||
export function removeExistingMmdInjections(messages: any[]): number {
|
||||
@@ -1011,7 +1233,7 @@ export async function buildHistoryMmdInjection(
|
||||
const metaText = buildHistoryMmdMetaText(filename, mmdContent);
|
||||
const metaTokens = countTokens(metaText);
|
||||
if (totalMmdTokens + metaTokens <= mmdTokenBudget) {
|
||||
logger.info(`[context-offload] History MMD ${filename}: full=${fullTokens} tokens exceeds budget, injecting meta-only (${metaTokens} tokens)`);
|
||||
logger.debug?.(`[context-offload] History MMD ${filename}: full=${fullTokens} tokens exceeds budget, injecting meta-only (${metaTokens} tokens)`);
|
||||
injectedMessages.push({ role: "user", content: [{ type: "text", text: metaText }], _mmdInjection: true });
|
||||
totalMmdTokens += metaTokens;
|
||||
mmdFiles.push(`${filename}(meta)`);
|
||||
@@ -1019,7 +1241,7 @@ export async function buildHistoryMmdInjection(
|
||||
}
|
||||
|
||||
// Even meta exceeds budget — skip entirely
|
||||
logger.info(`[context-offload] History MMD ${filename}: skipped (full=${fullTokens}, meta=${metaTokens}, remaining budget=${mmdTokenBudget - totalMmdTokens})`);
|
||||
logger.debug?.(`[context-offload] History MMD ${filename}: skipped (full=${fullTokens}, meta=${metaTokens}, remaining budget=${mmdTokenBudget - totalMmdTokens})`);
|
||||
}
|
||||
|
||||
// Reverse back so oldest appears first in messages (chronological order for LLM)
|
||||
|
||||
+492
-165
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,7 @@ export async function injectMmdIntoMessages(
|
||||
// When waitForL15 is set (assemble path), skip injection entirely if L1.5 hasn't settled yet.
|
||||
// This preserves any previously injected MMD messages without removing or replacing them.
|
||||
if (options?.waitForL15 && !stateManager.l15Settled) {
|
||||
logger.info(
|
||||
logger.debug?.(
|
||||
`[context-offload] mmd-injector inject: SKIPPED — L1.5 not settled yet (waitForL15=true), msgs=${messages.length}`,
|
||||
);
|
||||
return { mmdTokens: stateManager.lastMmdInjectedTokens };
|
||||
@@ -49,7 +49,7 @@ export async function injectMmdIntoMessages(
|
||||
|
||||
const injReady = stateManager.isMmdInjectionReady();
|
||||
const actFile = stateManager.getActiveMmdFile();
|
||||
logger.info(
|
||||
logger.debug?.(
|
||||
`[context-offload] mmd-injector inject: injectionReady=${injReady}, activeMmdFile=${actFile ?? "null"}, msgs=${messages.length}`,
|
||||
);
|
||||
if (!injReady) {
|
||||
@@ -67,7 +67,7 @@ export async function injectMmdIntoMessages(
|
||||
const countTokens = createL3TokenCounter(pluginConfig, logger);
|
||||
|
||||
const activeMmdText = await buildActiveMmdText(stateManager, logger);
|
||||
logger.info(
|
||||
logger.debug?.(
|
||||
`[context-offload] mmd-injector inject: activeMmdText=${activeMmdText ? `${activeMmdText.length} chars` : "null"}, contextWindow=${contextWindow}`,
|
||||
);
|
||||
removeMmdMessages(messages);
|
||||
@@ -88,7 +88,7 @@ export async function injectMmdIntoMessages(
|
||||
stateManager.lastMmdInjectedTokens = totalMmdTokens;
|
||||
|
||||
const activeMmd = stateManager.getActiveMmdFile();
|
||||
logger.info(
|
||||
logger.debug?.(
|
||||
`[context-offload] mmd-injector: injected active MMD into messages (${totalMmdTokens} tokens, file=${activeMmd})`,
|
||||
);
|
||||
|
||||
@@ -96,7 +96,7 @@ export async function injectMmdIntoMessages(
|
||||
if (totalMmdTokens > 0) {
|
||||
const mmdCount = messages.filter((m: any) => m[MMD_MESSAGE_MARKER] === "active" || m._mmdInjection).length;
|
||||
const offloadedCount = messages.filter((m: any) => m._offloaded).length;
|
||||
logger.info(`[context-offload] POST-ACTIVE-MMD-INJECT: ${messages.length} msgs, mmd=${mmdCount}, offloaded=${offloadedCount}`);
|
||||
logger.debug?.(`[context-offload] POST-ACTIVE-MMD-INJECT: ${messages.length} msgs, mmd=${mmdCount}, offloaded=${offloadedCount}`);
|
||||
}
|
||||
|
||||
traceOffloadDecision({
|
||||
@@ -133,7 +133,7 @@ export async function maybeUpdateMmdInMessages(
|
||||
): Promise<boolean> {
|
||||
const injectionReady = stateManager.isMmdInjectionReady();
|
||||
const activeMmdFile = stateManager.getActiveMmdFile();
|
||||
logger.info(
|
||||
logger.debug?.(
|
||||
`[context-offload] mmd-injector maybeUpdate: injectionReady=${injectionReady}, activeMmdFile=${activeMmdFile ?? "null"}, msgs=${messages.length}`,
|
||||
);
|
||||
if (!injectionReady) return false;
|
||||
@@ -142,11 +142,11 @@ export async function maybeUpdateMmdInMessages(
|
||||
let mmdContent: string | null;
|
||||
try {
|
||||
mmdContent = await readMmd(stateManager.ctx, activeMmdFile);
|
||||
logger.info(
|
||||
logger.debug?.(
|
||||
`[context-offload] mmd-injector maybeUpdate: readMmd result=${mmdContent ? `${mmdContent.length} chars` : "null"}`,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.info(`[context-offload] mmd-injector maybeUpdate: readMmd error=${e}`);
|
||||
logger.debug?.(`[context-offload] mmd-injector maybeUpdate: readMmd error=${e}`);
|
||||
return false;
|
||||
}
|
||||
if (!mmdContent) return false;
|
||||
@@ -155,7 +155,7 @@ export async function maybeUpdateMmdInMessages(
|
||||
const lastFp = stateManager.getInjectedMmdVersion(activeMmdFile);
|
||||
if (newFp === lastFp) return false;
|
||||
|
||||
logger.info(
|
||||
logger.debug?.(
|
||||
`[context-offload] mmd-injector: MMD updated (${activeMmdFile}), refreshing in-loop`,
|
||||
);
|
||||
await injectMmdIntoMessages(
|
||||
|
||||
@@ -116,7 +116,7 @@ export function initOffloadOpikTracer(
|
||||
} catch (err) {
|
||||
tracerEnabled = false;
|
||||
client = null;
|
||||
logger.warn(`[context-offload] Opik tracer init failed: ${String(err)}`);
|
||||
logger.debug?.(`[context-offload] Opik tracer init failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -262,7 +262,7 @@ export async function backfillNodeIds(
|
||||
if (changed) {
|
||||
await rewriteAllOffloadEntries(ctx, allEntries);
|
||||
}
|
||||
logger.info(`[context-offload] L2 backfill: mapped=${mappedCount}, fallback=${fallbackCount} (to ${effectiveFallback ?? "N/A"}), skipped=${skippedCount}, total=${waitIds.size}`);
|
||||
logger.debug?.(`[context-offload] L2 backfill: mapped=${mappedCount}, fallback=${fallbackCount} (to ${effectiveFallback ?? "N/A"}), skipped=${skippedCount}, total=${waitIds.size}`);
|
||||
}
|
||||
|
||||
function getMostFrequent(arr: string[]): string | null {
|
||||
|
||||
@@ -73,12 +73,12 @@ export async function reclaimOffloadData(
|
||||
};
|
||||
|
||||
if (config.retentionDays < 3) {
|
||||
logger.info(`${TAG} Skipped: retentionDays=${config.retentionDays} (min effective: 3)`);
|
||||
logger.debug?.(`${TAG} Skipped: retentionDays=${config.retentionDays} (min effective: 3)`);
|
||||
return stats;
|
||||
}
|
||||
|
||||
if (!existsSync(dataRoot)) {
|
||||
logger.info(`${TAG} Skipped: dataRoot does not exist: ${dataRoot}`);
|
||||
logger.debug?.(`${TAG} Skipped: dataRoot does not exist: ${dataRoot}`);
|
||||
return stats;
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ async function deleteExpiredJsonlInDir(
|
||||
if (s.mtimeMs < cutoffMs) {
|
||||
await unlink(filePath);
|
||||
deleted++;
|
||||
logger.info(`${TAG} Step 1: deleted expired JSONL: ${filePath} (mtime=${new Date(s.mtimeMs).toISOString()})`);
|
||||
logger.debug?.(`${TAG} Step 1: deleted expired JSONL: ${filePath} (mtime=${new Date(s.mtimeMs).toISOString()})`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`${TAG} Step 1: failed to process ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
@@ -258,7 +258,7 @@ async function reclaimOrphanRefs(
|
||||
if (s.mtimeMs < cutoffMs) {
|
||||
await unlink(filePath);
|
||||
deleted++;
|
||||
logger.info(`${TAG} Step 2: deleted orphan ref: ${filePath}`);
|
||||
logger.debug?.(`${TAG} Step 2: deleted orphan ref: ${filePath}`);
|
||||
}
|
||||
} catch {
|
||||
/* skip individual file errors */
|
||||
@@ -362,7 +362,7 @@ async function reclaimExpiredMmds(
|
||||
await unlink(filePath);
|
||||
deleted++;
|
||||
remaining--;
|
||||
logger.info(`${TAG} Step 3: deleted expired MMD: ${filePath}`);
|
||||
logger.debug?.(`${TAG} Step 3: deleted expired MMD: ${filePath}`);
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
@@ -422,7 +422,7 @@ async function rotateDebugLogs(
|
||||
await truncate(file.path, 0);
|
||||
totalSize -= file.size;
|
||||
truncated++;
|
||||
logger.info(`${TAG} Step 4: truncated log: ${file.path} (was ${file.size} bytes)`);
|
||||
logger.debug?.(`${TAG} Step 4: truncated log: ${file.path} (was ${file.size} bytes)`);
|
||||
} catch (err) {
|
||||
logger.warn(`${TAG} Step 4: failed to truncate ${file.path}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
@@ -465,7 +465,7 @@ async function pruneRegistries(
|
||||
const removedCount = originalCount - Object.keys(registry).length;
|
||||
pruned += removedCount;
|
||||
await atomicWriteJson(registryPath, registry);
|
||||
logger.info(`${TAG} Step 5: pruned ${removedCount} expired entries from ${registryPath}`);
|
||||
logger.debug?.(`${TAG} Step 5: pruned ${removedCount} expired entries from ${registryPath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`${TAG} Step 5: failed to prune ${registryPath}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
|
||||
@@ -88,6 +88,17 @@ export class OffloadStateManager {
|
||||
lastKnownMessageCount = 0;
|
||||
/** Consecutive QUICK-SKIP count; reset to 0 on each precise calculation */
|
||||
consecutiveQuickSkips = 0;
|
||||
/** Boundary info from last aggressive deletion — enables O(1) head-delete on replay.
|
||||
* originalIndex: position of the first kept message in the original input array.
|
||||
* fingerprint: hash of that message for verification.
|
||||
* keptMsgCount: number of messages kept after aggressive.
|
||||
* remainingTokens: total tokens (incl sys) after aggressive compression. */
|
||||
_lastAggressiveBoundary: {
|
||||
originalIndex: number;
|
||||
fingerprint: number;
|
||||
keptMsgCount: number;
|
||||
remainingTokens: number;
|
||||
} | null = null;
|
||||
/** Cached tool params from before_tool_call hook */
|
||||
_pendingParams = new Map<string, Record<string, unknown>>();
|
||||
/** Last L1.5 prompt hash — per-session to avoid cross-session re-trigger skip */
|
||||
@@ -277,6 +288,7 @@ export class OffloadStateManager {
|
||||
this.lastKnownMessageCount = 0;
|
||||
this.consecutiveQuickSkips = 0;
|
||||
this._forceEmergencyNext = false;
|
||||
this._lastAggressiveBoundary = null;
|
||||
// Keep cachedSystemPrompt/Tokens across switchSession within the same agent
|
||||
if (prevAgent !== parsed.agentName) {
|
||||
this.cachedSystemPrompt = null;
|
||||
|
||||
@@ -333,7 +333,7 @@ export function reportL3Trigger(
|
||||
backendClient
|
||||
.storeState(report as unknown as StoreStatePayload)
|
||||
.then(() => {
|
||||
logger.info(
|
||||
logger.debug?.(
|
||||
`[context-offload] state-report OK: stage=${report.stage} reason=${report.triggerReason} ` +
|
||||
`recentSaved=${report.recent.tokensSaved} cumSaved=${report.cumulative.totalTokensSaved} ` +
|
||||
`toolCalls=${report.cumulative.totalToolCalls} patch=${report.patch.status}`,
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { sanitizeText } from "./storage.js";
|
||||
|
||||
describe("sanitizeText", () => {
|
||||
it("preserves plain ASCII", () => {
|
||||
expect(sanitizeText("hello world")).toBe("hello world");
|
||||
});
|
||||
|
||||
it("preserves emoji and other non-BMP code points", () => {
|
||||
// 🎉 = U+1F389, 𠮷 = U+20BB7 (CJK Extension B), 𝐀 = U+1D400 (math bold A).
|
||||
// Each is a surrogate pair in UTF-16. Without the `u` flag, the
|
||||
// [\uD800-\uDFFF] range in UNSAFE_CHAR_RE would strip each half
|
||||
// independently and silently destroy these characters.
|
||||
expect(sanitizeText("emoji \u{1F389} here")).toBe("emoji \u{1F389} here");
|
||||
expect(sanitizeText("CJK ext-B \u{20BB7} here")).toBe(
|
||||
"CJK ext-B \u{20BB7} here",
|
||||
);
|
||||
expect(sanitizeText("math bold \u{1D400} here")).toBe(
|
||||
"math bold \u{1D400} here",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips lone (malformed) surrogates", () => {
|
||||
expect(sanitizeText("lone \uD800 surrogate")).toBe("lone surrogate");
|
||||
expect(sanitizeText("lone \uDC00 surrogate")).toBe("lone surrogate");
|
||||
});
|
||||
|
||||
it("strips C0 and C1 control characters", () => {
|
||||
expect(sanitizeText("ctrlhere")).toBe("ctrlhere");
|
||||
expect(sanitizeText("c1
here")).toBe("c1here");
|
||||
});
|
||||
|
||||
it("strips zero-width characters and BOM", () => {
|
||||
expect(sanitizeText("ab")).toBe("ab");
|
||||
expect(sanitizeText("ab")).toBe("ab");
|
||||
});
|
||||
|
||||
it("returns non-string input unchanged", () => {
|
||||
// Matches the existing typeof guard in sanitizeText.
|
||||
expect(sanitizeText(42 as unknown as string)).toBe(42);
|
||||
});
|
||||
});
|
||||
@@ -247,6 +247,6 @@ export const PLUGIN_DEFAULTS = {
|
||||
emergencyTargetRatio: 0.6,
|
||||
mmdMaxTokenRatio: 0.2,
|
||||
l3TokenCountMode: "tiktoken" as const,
|
||||
l3TiktokenEncoding: "o200k_base" as const,
|
||||
l3TiktokenEncoding: "cl100k_base" as const,
|
||||
defaultSystemOverheadRatio: 0.12,
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user