mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-11 04:44:29 +00:00
feat: release v0.3.3 — Hermes adapter, context offload, core refactor
This commit is contained in:
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* after_tool_call hook handler.
|
||||
* Collects tool call + result pairs into the pending buffer.
|
||||
* Post-tool token snapshot via tiktoken + inline L3 compression.
|
||||
*/
|
||||
import { nowChinaISO } from "../time-utils.js";
|
||||
import { buildTiktokenContextSnapshot, type ContextSnapshot } from "../context-token-tracker.js";
|
||||
import { traceOffloadDecision, traceMessagesSnapshot } from "../opik-tracer.js";
|
||||
import { PLUGIN_DEFAULTS } from "../types.js";
|
||||
import { readOffloadEntries, markOffloadStatus, readMmd } from "../storage.js";
|
||||
import { createL3TokenCounter } from "../l3-token-counter.js";
|
||||
import {
|
||||
normalizeToolCallIdForLookup,
|
||||
populateOffloadLookupMap,
|
||||
getCurrentTaskNodeIds,
|
||||
extractToolCallId,
|
||||
isToolResultMessage,
|
||||
isToolUseInAssistant,
|
||||
extractToolUseIdFromAssistant,
|
||||
} from "../l3-helpers.js";
|
||||
import {
|
||||
compressByScoreCascade,
|
||||
aggressiveCompressUntilBelowThreshold,
|
||||
buildHistoryMmdInjection,
|
||||
removeExistingMmdInjections,
|
||||
emergencyCompress,
|
||||
EMERGENCY_MIN_MESSAGES_TO_KEEP,
|
||||
isTokenOverflowError,
|
||||
dumpMessagesSnapshot,
|
||||
} from "./llm-input-l3.js";
|
||||
import { MMD_MESSAGE_MARKER, findActiveMmdInsertionPoint, findHistoryMmdInsertionPoint } from "../mmd-injector.js";
|
||||
import type { OffloadStateManager } from "../state-manager.js";
|
||||
import type { PluginConfig, PluginLogger, ToolPair } from "../types.js";
|
||||
import type { BackendClient } from "../backend-client.js";
|
||||
import {
|
||||
buildL3TriggerReport,
|
||||
classifyPatchEffectiveness,
|
||||
reportL3Trigger,
|
||||
recordToolCall,
|
||||
REPORT_TYPE_L3,
|
||||
L3_FIXED_PATCH_COST_TOKENS,
|
||||
} from "../state-reporter.js";
|
||||
|
||||
function isHeartbeatToolCall(event: any, cachedParams: any): boolean {
|
||||
try {
|
||||
const params = event.params ?? cachedParams;
|
||||
if (!params) return false;
|
||||
const raw = typeof params === "string" ? params : JSON.stringify(params);
|
||||
return raw.includes("HEARTBEAT.md");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function _extractParamsFromMessages(messages: any[], toolCallId: string): any {
|
||||
if (!messages || !Array.isArray(messages) || !toolCallId) return null;
|
||||
const normId = toolCallId.replace(/_/g, "");
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
const role = msg.role ?? msg.message?.role ?? msg.type;
|
||||
if (role !== "assistant") continue;
|
||||
const content = msg.content ?? msg.message?.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (
|
||||
(block.type === "tool_use" || block.type === "toolCall") &&
|
||||
(block.id === toolCallId || block.id?.replace(/_/g, "") === normId)
|
||||
) {
|
||||
const input = block.input ?? _tryParseArgs(block.arguments);
|
||||
if (input && typeof input === "object" && (input as any)._offloaded) continue;
|
||||
return input ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
const toolCalls = msg.tool_calls ?? msg.message?.tool_calls;
|
||||
if (Array.isArray(toolCalls)) {
|
||||
for (const tc of toolCalls) {
|
||||
if (tc.id === toolCallId || tc.id?.replace(/_/g, "") === normId) {
|
||||
return _tryParseArgs(tc.function?.arguments) ?? tc.function?.parameters ?? tc.input ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _tryParseArgs(args: any): any {
|
||||
if (args == null) return null;
|
||||
if (typeof args === "object") return args;
|
||||
if (typeof args !== "string") return null;
|
||||
try { return JSON.parse(args); } catch { return null; }
|
||||
}
|
||||
|
||||
export function createAfterToolCallHandler(
|
||||
stateManager: OffloadStateManager,
|
||||
logger: PluginLogger,
|
||||
getContextWindow: (() => number) | undefined,
|
||||
pluginConfig: Partial<PluginConfig> | undefined,
|
||||
backendClient?: BackendClient | null,
|
||||
) {
|
||||
return async (event: any, ctx: any) => {
|
||||
// Skip internal memory-pipeline sessions
|
||||
const _sk = stateManager.getLastSessionKey() ?? ctx?.sessionKey;
|
||||
if (typeof _sk === "string" && /memory-.*-session-\d+/.test(_sk)) return;
|
||||
|
||||
// Count every observed tool call for cumulative reporting. Done before
|
||||
// any early-return branch so the counter reflects the real invocation
|
||||
// rate, not just the cases where L3 actually runs.
|
||||
recordToolCall();
|
||||
|
||||
const eventKeys = event ? Object.keys(event) : [];
|
||||
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"}`);
|
||||
|
||||
// ── Patch-effectiveness detection ──
|
||||
// The upstream runtime patch is expected to populate event.messages with
|
||||
// the current conversation. If it is missing/empty the patch is NOT in
|
||||
// effect and L3 compression cannot run from this hook. Report that
|
||||
// explicitly so operators can detect misconfigurations.
|
||||
const _patchStatus = classifyPatchEffectiveness(event, "after_tool_call");
|
||||
if (_patchStatus.status !== "effective") {
|
||||
logger.warn(
|
||||
`[context-offload] after_tool_call patch check: NOT EFFECTIVE (status=${_patchStatus.status}). ` +
|
||||
`event.messages is ${Array.isArray(msgsValue) ? "empty array" : typeof msgsValue}. ` +
|
||||
`L3 compression will be skipped this turn.`,
|
||||
);
|
||||
if (backendClient) {
|
||||
try {
|
||||
backendClient
|
||||
.storeState({
|
||||
reportType: REPORT_TYPE_L3,
|
||||
reportedAt: new Date().toISOString(),
|
||||
sessionKey: _sk ?? null,
|
||||
stage: "after_tool_call",
|
||||
triggerReason: "patch_not_effective",
|
||||
patch: _patchStatus,
|
||||
pluginState: {
|
||||
l15Settled: stateManager.l15Settled === true,
|
||||
pendingCount: stateManager.getPendingCount(),
|
||||
activeMmdFile: stateManager.getActiveMmdFile?.() ?? null,
|
||||
},
|
||||
fixedPatchCostTokens: L3_FIXED_PATCH_COST_TOKENS,
|
||||
})
|
||||
.catch((err) => logger.warn(`[context-offload] patch-miss report failed: ${err}`));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
const toolCallId = event.toolCallId ?? ctx.toolCallId ?? `auto-${Date.now()}`;
|
||||
const cachedParams = stateManager.consumeToolParams(toolCallId);
|
||||
const messagesParams =
|
||||
!event.params && !cachedParams
|
||||
? _extractParamsFromMessages(event.messages, toolCallId)
|
||||
: null;
|
||||
const resolvedParams = event.params ?? cachedParams ?? messagesParams ?? {};
|
||||
|
||||
if (stateManager.isProcessed(toolCallId)) return;
|
||||
if (isHeartbeatToolCall(event, resolvedParams)) {
|
||||
stateManager.processedToolCallIds.add(toolCallId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip tool calls that are stuck at approval-pending — they have no useful
|
||||
// result and would waste L1 LLM tokens generating meaningless summaries.
|
||||
// Only check the structured status field to avoid false positives from
|
||||
// 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})`);
|
||||
stateManager.processedToolCallIds.add(toolCallId);
|
||||
return;
|
||||
}
|
||||
|
||||
const pair: ToolPair = {
|
||||
toolName: event.toolName,
|
||||
toolCallId,
|
||||
params: resolvedParams,
|
||||
result: event.result,
|
||||
error: event.error,
|
||||
timestamp: nowChinaISO(),
|
||||
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`);
|
||||
|
||||
// Cache latest user context for L2
|
||||
if (event.messages && Array.isArray(event.messages) && event.messages.length > 0 && !stateManager.cachedLatestTurnMessages) {
|
||||
const turn = _extractLatestTurnFromMessages(event.messages);
|
||||
if (turn) stateManager.cachedLatestTurnMessages = turn;
|
||||
}
|
||||
|
||||
// In-loop active MMD injection / update.
|
||||
// Only inject after L1.5 has settled (task boundary determined, activeMmdFile set).
|
||||
// This also picks up L2 MMD content updates (L2 runs async and may patch the MMD
|
||||
// file between tool calls).
|
||||
if (event.messages && Array.isArray(event.messages)) {
|
||||
try {
|
||||
const l15Settled = stateManager.l15Settled;
|
||||
const activeMmdFile = stateManager.getActiveMmdFile();
|
||||
if (!l15Settled) {
|
||||
logger.info(`[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)`);
|
||||
} else {
|
||||
const mmdContent = await readMmd(stateManager.ctx, activeMmdFile);
|
||||
if (mmdContent) {
|
||||
let taskGoal = "";
|
||||
const metaMatch = mmdContent.match(/^%%\{\s*(.*?)\s*\}%%/);
|
||||
if (metaMatch) {
|
||||
try { const meta = JSON.parse(`{${metaMatch[1]}}`); taskGoal = meta.taskGoal || ""; } catch { /* */ }
|
||||
}
|
||||
const mmdText = [
|
||||
`<current_task_context>`,
|
||||
`【当前活跃任务的mermaid流程图】这是你最近正在执行的任务的阶段性记录(此条下方的tool use未被汇总,进程可能有延迟,仅供参考)。`,
|
||||
taskGoal ? `**任务目标:** ${taskGoal}` : "",
|
||||
`**任务文件:** ${activeMmdFile}`,
|
||||
"```mermaid", mmdContent, "```",
|
||||
`标记为 "doing" 的节点是近期焦点(注:可能有延迟,下方的tool use未被统计,仅供参考),"done" 的已完成。请参考此保持方向感,避免重复已完成的工作。`,
|
||||
`</current_task_context>`,
|
||||
].filter((line) => line !== "").join("\n");
|
||||
|
||||
const existingIdx = event.messages.findIndex((m: any) => m._mmdContextMessage === "active");
|
||||
const newMsg = { role: "user", content: [{ type: "text", text: mmdText }], _mmdContextMessage: "active" };
|
||||
if (existingIdx >= 0) {
|
||||
// Check if content changed (L1.5 switched file or L2 updated content)
|
||||
const oldContent = Array.isArray(event.messages[existingIdx].content)
|
||||
? event.messages[existingIdx].content.map((c: any) => c.text ?? "").join("")
|
||||
: (event.messages[existingIdx].content ?? "");
|
||||
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`);
|
||||
_dumpMessagesAfterMmd(event.messages, "UPDATED", logger);
|
||||
} else {
|
||||
logger.info(`[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}`);
|
||||
_dumpMessagesAfterMmd(event.messages, "INJECTED", logger);
|
||||
}
|
||||
} else {
|
||||
logger.info(`[context-offload] after_tool_call MMD: file=${activeMmdFile} content is null`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`[context-offload] after_tool_call MMD error: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Post-tool token snapshot + inline L3 compression
|
||||
const _compStart = Date.now();
|
||||
const _msgsBefore = event.messages?.length ?? 0;
|
||||
|
||||
const _contextWindow = typeof getContextWindow === "function" ? getContextWindow() : PLUGIN_DEFAULTS.defaultContextWindow;
|
||||
const _mildThreshold = Math.floor(_contextWindow * (pluginConfig?.mildOffloadRatio ?? PLUGIN_DEFAULTS.mildOffloadRatio));
|
||||
const _aggressiveThreshold = Math.floor(_contextWindow * (pluginConfig?.aggressiveCompressRatio ?? PLUGIN_DEFAULTS.aggressiveCompressRatio));
|
||||
|
||||
// P0.5: checkAndCompressAfterToolCall now returns snapBefore/snapAfter
|
||||
// so we no longer need separate buildTiktokenContextSnapshot calls here
|
||||
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`);
|
||||
|
||||
// QUICK-SKIP: no snapshots, skip trace
|
||||
if (_compResult) {
|
||||
const _snapBefore = _compResult.snapBefore ?? null;
|
||||
const _snapAfter = _compResult.snapAfter ?? null;
|
||||
const _tokensBefore = _snapBefore?.totalTokens ?? 0;
|
||||
const _tokensAfter = _snapAfter?.totalTokens ?? 0;
|
||||
const _tokensSaved = _tokensBefore - _tokensAfter;
|
||||
const _utilisation = _contextWindow > 0 ? _tokensAfter / _contextWindow : 0;
|
||||
|
||||
traceOffloadDecision({
|
||||
sessionKey: stateManager.getLastSessionKey(),
|
||||
stage: "L3.after_tool_call.completed",
|
||||
input: {
|
||||
toolName: event.toolName,
|
||||
toolCallId,
|
||||
messagesBefore: _msgsBefore,
|
||||
tokensBefore: _tokensBefore,
|
||||
durationMs: _compDuration,
|
||||
contextWindow: _contextWindow,
|
||||
mildThreshold: _mildThreshold,
|
||||
aggressiveThreshold: _aggressiveThreshold,
|
||||
},
|
||||
output: {
|
||||
messagesAfter: _msgsAfter,
|
||||
messagesRemoved: _msgsBefore - _msgsAfter,
|
||||
pendingCount: stateManager.getPendingCount(),
|
||||
tokensBefore: _tokensBefore,
|
||||
tokensAfter: _tokensAfter,
|
||||
tokensSaved: _tokensSaved,
|
||||
utilisation: `${(_utilisation * 100).toFixed(1)}%`,
|
||||
aboveMild: _tokensAfter >= _mildThreshold,
|
||||
aboveAggressive: _tokensAfter >= _aggressiveThreshold,
|
||||
offloadMapAvailable: stateManager.confirmedOffloadIds?.size ?? 0,
|
||||
mildReplacedCount: _compResult.mildReplacedCount ?? 0,
|
||||
mildReplacedDetails: _compResult.mildReplacedDetails ?? [],
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
// Upload plugin state + L3 token accounting to backend /store.
|
||||
// Only report when a real compression check happened (i.e. we have a snapshot).
|
||||
// Trigger reason is derived from the threshold that fired first.
|
||||
const _triggerReason = _tokensBefore >= _aggressiveThreshold
|
||||
? "above_aggressive"
|
||||
: _tokensBefore >= _mildThreshold
|
||||
? "above_mild"
|
||||
: "below_mild";
|
||||
try {
|
||||
const report = buildL3TriggerReport({
|
||||
stage: "after_tool_call",
|
||||
triggerReason: _triggerReason,
|
||||
stateManager,
|
||||
event,
|
||||
contextWindow: _contextWindow,
|
||||
mildThreshold: _mildThreshold,
|
||||
aggressiveThreshold: _aggressiveThreshold,
|
||||
tokensBefore: _tokensBefore,
|
||||
tokensAfter: _tokensAfter,
|
||||
messagesBefore: _msgsBefore,
|
||||
messagesAfter: _msgsAfter,
|
||||
durationMs: _compDuration,
|
||||
aboveMild: _tokensBefore >= _mildThreshold,
|
||||
aboveAggressive: _tokensBefore >= _aggressiveThreshold,
|
||||
mildReplacedCount: _compResult.mildReplacedCount ?? 0,
|
||||
aggressiveDeletedCount: _compResult.aggressiveDeletedCount ?? 0,
|
||||
emergencyTriggered: _compResult.emergencyTriggered ?? false,
|
||||
emergencyDeletedCount: _compResult.emergencyDeletedCount ?? 0,
|
||||
});
|
||||
reportL3Trigger(backendClient ?? null, report, logger);
|
||||
} catch (reportErr) {
|
||||
logger.warn(`[context-offload] build L3 report failed: ${reportErr}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Trace full messages snapshot at end of after_tool_call
|
||||
if (event.messages && Array.isArray(event.messages)) {
|
||||
traceMessagesSnapshot({
|
||||
sessionKey: stateManager.getLastSessionKey(),
|
||||
stage: "after_tool_call.end",
|
||||
messages: event.messages,
|
||||
label: `tool=${event.toolName}`,
|
||||
extra: {
|
||||
toolName: event.toolName,
|
||||
toolCallId,
|
||||
pendingCount: stateManager.getPendingCount(),
|
||||
activeMmdFile: stateManager.getActiveMmdFile() ?? null,
|
||||
l15Settled: stateManager.l15Settled,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** P1: Quick heuristic token estimate to skip full tiktoken when clearly below threshold. */
|
||||
function quickTokenEstimate(messages: any[], stateManager: OffloadStateManager): number {
|
||||
if (stateManager.lastKnownTotalTokens <= 0) return Infinity;
|
||||
const newMsgCount = messages.length - stateManager.lastKnownMessageCount;
|
||||
if (newMsgCount <= 0) return stateManager.lastKnownTotalTokens;
|
||||
let newTokensEst = 0;
|
||||
for (let i = messages.length - newMsgCount; i < messages.length; i++) {
|
||||
const c = messages[i]?.content ?? messages[i]?.message?.content;
|
||||
const text = typeof c === "string" ? c : Array.isArray(c) ? JSON.stringify(c) : "";
|
||||
newTokensEst += text ? _quickCountTokens(text) : 50;
|
||||
}
|
||||
return stateManager.lastKnownTotalTokens + newTokensEst;
|
||||
}
|
||||
|
||||
/** CJK-aware quick token estimate: CJK chars ~1.5 tok/char, rest ~0.25 tok/char. */
|
||||
function _quickCountTokens(text: string): number {
|
||||
let cjk = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text.charCodeAt(i);
|
||||
if ((c >= 0x4e00 && c <= 0x9fff) || (c >= 0x3400 && c <= 0x4dbf) || (c >= 0xf900 && c <= 0xfaff)) cjk++;
|
||||
}
|
||||
const rest = text.length - cjk;
|
||||
return Math.ceil(cjk * 1.5 + rest / 4);
|
||||
}
|
||||
|
||||
async function checkAndCompressAfterToolCall(
|
||||
event: any,
|
||||
stateManager: OffloadStateManager,
|
||||
logger: PluginLogger,
|
||||
pluginConfig: Partial<PluginConfig> | undefined,
|
||||
getContextWindow: (() => number) | undefined,
|
||||
): Promise<{
|
||||
mildReplacedCount: number;
|
||||
mildReplacedDetails: Array<{ toolCallId: string; score: number; summaryPreview: string; originalLength?: number; summaryLength?: number }>;
|
||||
aggressiveDeletedCount: number;
|
||||
emergencyTriggered: boolean;
|
||||
emergencyDeletedCount: number;
|
||||
snapBefore: ContextSnapshot | null;
|
||||
snapAfter: ContextSnapshot | null;
|
||||
} | null> {
|
||||
try {
|
||||
const messages = event.messages;
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) return null;
|
||||
|
||||
const sysPrompt = stateManager.cachedSystemPrompt ?? null;
|
||||
const precomputed = stateManager.cachedSystemPromptTokens != null
|
||||
? { systemTokens: stateManager.cachedSystemPromptTokens, userPromptTokens: 0 }
|
||||
: undefined;
|
||||
|
||||
const contextWindow = typeof getContextWindow === "function" ? getContextWindow() : PLUGIN_DEFAULTS.defaultContextWindow;
|
||||
const mildRatio = pluginConfig?.mildOffloadRatio ?? PLUGIN_DEFAULTS.mildOffloadRatio;
|
||||
const mildThreshold = Math.floor(contextWindow * mildRatio);
|
||||
|
||||
// P1: Quick heuristic skip — avoid full tiktoken when clearly below threshold
|
||||
// Every MAX_CONSECUTIVE_QUICK_SKIPS, force a precise calculation to prevent drift
|
||||
const MAX_CONSECUTIVE_QUICK_SKIPS = 5;
|
||||
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}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const snap = buildTiktokenContextSnapshot("after_tool_call", messages, sysPrompt, null, precomputed);
|
||||
// Update stateManager with precise values and reset skip counter
|
||||
stateManager.lastKnownTotalTokens = snap.totalTokens;
|
||||
stateManager.lastKnownMessageCount = messages.length;
|
||||
stateManager.consecutiveQuickSkips = 0;
|
||||
|
||||
const aggressiveRatio = pluginConfig?.aggressiveCompressRatio ?? PLUGIN_DEFAULTS.aggressiveCompressRatio;
|
||||
const aggressiveThreshold = Math.floor(contextWindow * aggressiveRatio);
|
||||
|
||||
const utilisation = snap.totalTokens / contextWindow;
|
||||
const aboveMild = snap.totalTokens >= mildThreshold;
|
||||
const aboveAggressive = snap.totalTokens >= aggressiveThreshold;
|
||||
logger.info(
|
||||
`[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"}`,
|
||||
);
|
||||
|
||||
if (snap.totalTokens < mildThreshold) return { mildReplacedCount: 0, mildReplacedDetails: [], aggressiveDeletedCount: 0, emergencyTriggered: false, emergencyDeletedCount: 0, snapBefore: snap, snapAfter: snap };
|
||||
|
||||
// L3 compression
|
||||
const offloadEntries = await readOffloadEntries(stateManager.ctx);
|
||||
const offloadMap = new Map();
|
||||
populateOffloadLookupMap(offloadMap, offloadEntries);
|
||||
const currentTaskNodeIds = await getCurrentTaskNodeIds(stateManager);
|
||||
const countTokens = createL3TokenCounter(pluginConfig, logger);
|
||||
const aggressiveDeleteRatio = (pluginConfig as any)?.aggressiveDeleteRatio ?? PLUGIN_DEFAULTS.aggressiveDeleteRatio;
|
||||
const mildScanRatio = (pluginConfig as any)?.mildOffloadScanRatio ?? PLUGIN_DEFAULTS.mildOffloadScanRatio;
|
||||
let workingTokens = snap.totalTokens;
|
||||
|
||||
let _aggDeletedCount = 0;
|
||||
// Aggressive
|
||||
if (workingTokens >= aggressiveThreshold) {
|
||||
logger.info(`[context-offload] L3(after_tool_call) AGGRESSIVE: tokens≈${workingTokens} >= ${aggressiveThreshold}`);
|
||||
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}`);
|
||||
dumpMessagesSnapshot("atc-after-aggressive", messages, logger);
|
||||
if (result.allDeletedToolCallIds.length > 0) {
|
||||
const statusUpdates = new Map<string, string | boolean>();
|
||||
for (const id of result.allDeletedToolCallIds) {
|
||||
statusUpdates.set(id, "deleted");
|
||||
stateManager.confirmedOffloadIds.add(id);
|
||||
stateManager.deletedOffloadIds.add(id);
|
||||
}
|
||||
markOffloadStatus(stateManager.ctx, statusUpdates).catch(() => {});
|
||||
const mmdInjection = await buildHistoryMmdInjection(
|
||||
result.allDeletedToolCallIds, offloadMap, offloadEntries,
|
||||
stateManager, logger, countTokens, contextWindow, pluginConfig,
|
||||
);
|
||||
if (mmdInjection.injectedMessages.length > 0) {
|
||||
removeExistingMmdInjections(messages);
|
||||
const histInsertIdx = findHistoryMmdInsertionPoint(messages);
|
||||
messages.splice(histInsertIdx, 0, ...mmdInjection.injectedMessages);
|
||||
workingTokens += mmdInjection.totalMmdTokens;
|
||||
dumpMessagesSnapshot("atc-after-aggressive-mmd-injection", messages, logger);
|
||||
}
|
||||
}
|
||||
// If aggressive stalled due to user message protection and still above threshold,
|
||||
// force emergency to make progress
|
||||
if (result.stalledByUserMsg && workingTokens >= aggressiveThreshold) {
|
||||
logger.warn(`[context-offload] L3(after_tool_call) AGGRESSIVE stalled, forcing emergency fallback`);
|
||||
stateManager._forceEmergencyNext = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
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}]` : ""}`);
|
||||
_mildResult = { mildReplacedCount: cascadeResult.replacedCount, mildReplacedDetails: cascadeResult.replacedDetails };
|
||||
if (cascadeResult.replacedCount > 0) {
|
||||
for (const id of cascadeResult.replacedToolCallIds) {
|
||||
stateManager.confirmedOffloadIds.add(id);
|
||||
}
|
||||
const mildStatusUpdates = new Map<string, string | boolean>();
|
||||
for (const id of cascadeResult.replacedToolCallIds) {
|
||||
mildStatusUpdates.set(id, true);
|
||||
}
|
||||
markOffloadStatus(stateManager.ctx, mildStatusUpdates).catch(() => {});
|
||||
}
|
||||
dumpMessagesSnapshot("atc-after-mild", messages, logger);
|
||||
}
|
||||
const emergencyRatio = pluginConfig?.emergencyCompressRatio ?? PLUGIN_DEFAULTS.emergencyCompressRatio;
|
||||
const emergencyTargetRatio = pluginConfig?.emergencyTargetRatio ?? PLUGIN_DEFAULTS.emergencyTargetRatio;
|
||||
const emergencyThreshold = Math.floor(contextWindow * emergencyRatio);
|
||||
const emergencyTarget = Math.floor(contextWindow * emergencyTargetRatio);
|
||||
|
||||
const preEmergencySnap = buildTiktokenContextSnapshot("after_tool_call_pre_emergency", messages, sysPrompt, null, precomputed);
|
||||
workingTokens = preEmergencySnap.totalTokens;
|
||||
|
||||
const forceEmergency = stateManager._forceEmergencyNext === true;
|
||||
if (forceEmergency) stateManager._forceEmergencyNext = false;
|
||||
let _emergencyTriggered = false;
|
||||
let _emergencyDeletedCount = 0;
|
||||
if ((workingTokens >= emergencyThreshold || forceEmergency) && messages.length > EMERGENCY_MIN_MESSAGES_TO_KEEP) {
|
||||
_emergencyTriggered = true;
|
||||
const emergencyResult = emergencyCompress(messages, emergencyTarget, countTokens, sysPrompt, null, logger);
|
||||
_emergencyDeletedCount = emergencyResult.deletedCount;
|
||||
if (emergencyResult.deletedToolCallIds.length > 0) {
|
||||
const statusUpdates = new Map<string, string | boolean>();
|
||||
for (const id of emergencyResult.deletedToolCallIds) {
|
||||
statusUpdates.set(id, "deleted");
|
||||
stateManager.confirmedOffloadIds.add(id);
|
||||
stateManager.deletedOffloadIds.add(id);
|
||||
}
|
||||
markOffloadStatus(stateManager.ctx, statusUpdates).catch(() => {});
|
||||
}
|
||||
dumpMessagesSnapshot("atc-after-emergency", messages, logger);
|
||||
}
|
||||
|
||||
if (stateManager.isLoaded()) await stateManager.save();
|
||||
|
||||
// Update stateManager with final token count for future quick estimates
|
||||
stateManager.lastKnownTotalTokens = preEmergencySnap.totalTokens;
|
||||
stateManager.lastKnownMessageCount = messages.length;
|
||||
|
||||
return { ..._mildResult, aggressiveDeletedCount: _aggDeletedCount, emergencyTriggered: _emergencyTriggered, emergencyDeletedCount: _emergencyDeletedCount, snapBefore: snap, snapAfter: preEmergencySnap };
|
||||
} catch (err) {
|
||||
logger.warn?.(`[context-offload] after_tool_call L3 error: ${String(err)}`);
|
||||
if (isTokenOverflowError(err)) stateManager._forceEmergencyNext = true;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function _extractLatestTurnFromMessages(messages: any[]): string | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg._mmdContextMessage || msg._mmdInjection) continue;
|
||||
const role = msg.role ?? msg.message?.role ?? msg.type;
|
||||
if (role !== "user") continue;
|
||||
const text = _extractText(msg);
|
||||
if (text && text.length > 10) return `[User]: ${text.slice(0, 500)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _extractText(msg: any): string {
|
||||
const content = msg.content ?? msg.message?.content;
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content.filter((c: any) => c.type === "text" && typeof c.text === "string").map((c: any) => c.text).join(" ");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Dump all messages after MMD injection for diagnostics (debug-level only). */
|
||||
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}`);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* before_agent_start hook handler.
|
||||
* Implements L1.5: Task completion judgment and active MMD management.
|
||||
*
|
||||
* Backend-only mode: local LLM judge has been removed.
|
||||
* Only normalizeJudgment and handleTaskTransition are exported for use by index.ts.
|
||||
*/
|
||||
import { readMmd, writeMmd, deleteMmd, type StorageContext } from "../storage.js";
|
||||
import type { OffloadStateManager } from "../state-manager.js";
|
||||
import type { PluginLogger, TaskJudgment } from "../types.js";
|
||||
|
||||
/**
|
||||
* Normalize a raw L1.5 judgment response (from backend)
|
||||
* into a safe TaskJudgment with guaranteed boolean fields.
|
||||
* Handles null/undefined values from backend fallback responses.
|
||||
*/
|
||||
export function normalizeJudgment(raw: Record<string, unknown>): TaskJudgment | null {
|
||||
// All-null response from backend means "LLM unavailable" — treat as no judgment
|
||||
if (raw.taskCompleted == null && raw.isContinuation == null && raw.isLongTask == null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
taskCompleted: Boolean(raw.taskCompleted),
|
||||
isContinuation: Boolean(raw.isContinuation),
|
||||
continuationMmdFile:
|
||||
typeof raw.continuationMmdFile === "string" ? raw.continuationMmdFile : undefined,
|
||||
newTaskLabel:
|
||||
typeof raw.newTaskLabel === "string" ? raw.newTaskLabel : undefined,
|
||||
isLongTask: Boolean(raw.isLongTask),
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleTaskTransition(
|
||||
stateManager: OffloadStateManager,
|
||||
judgment: TaskJudgment,
|
||||
logger: PluginLogger,
|
||||
): Promise<void> {
|
||||
const currentMmd = stateManager.getActiveMmdFile();
|
||||
|
||||
const ctx = stateManager.ctx;
|
||||
|
||||
const isEmptyShellMmd = async (filename: string | null): Promise<boolean> => {
|
||||
if (!filename) return false;
|
||||
try {
|
||||
const content = await readMmd(ctx, filename);
|
||||
if (!content) return false;
|
||||
const trimmed = content.trim();
|
||||
if (trimmed.includes("%%{")) return false;
|
||||
const lines = trimmed.split("\n").filter((l) => l.trim().length > 0);
|
||||
return lines.length <= 3;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupIfEmptyShell = async (oldFilename: string | null) => {
|
||||
if (!oldFilename) return;
|
||||
const isShell = await isEmptyShellMmd(oldFilename);
|
||||
if (isShell) {
|
||||
try {
|
||||
await deleteMmd(ctx, oldFilename);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createNewMmd = async (label: string) => {
|
||||
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)"})`);
|
||||
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}`);
|
||||
};
|
||||
|
||||
const reactivateMmd = async (contFile: string) => {
|
||||
logger.info(`[context-offload] L1.5: Reactivating MMD: ${contFile} (current=${currentMmd ?? "(none)"})`);
|
||||
if (currentMmd && currentMmd !== contFile) {
|
||||
await cleanupIfEmptyShell(currentMmd);
|
||||
}
|
||||
const mmdId = contFile.replace(/^\d+-/, "").replace(/\.mmd$/, "");
|
||||
stateManager.setActiveMmd(contFile, mmdId);
|
||||
const existing = await readMmd(ctx, contFile);
|
||||
if (existing === null) {
|
||||
const prefixMatch = contFile.match(/^(\d+)-/);
|
||||
const prefix = prefixMatch ? prefixMatch[1] : "000";
|
||||
const initialMmd = `flowchart TD\n ${prefix}-N1["${mmdId}"]\n`;
|
||||
await writeMmd(ctx, contFile, initialMmd);
|
||||
logger.warn(`[context-offload] L1.5: Reactivated MMD file was missing, wrote initial template: ${contFile}`);
|
||||
}
|
||||
};
|
||||
|
||||
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"}`);
|
||||
if (judgment.isContinuation && judgment.continuationMmdFile) {
|
||||
await reactivateMmd(judgment.continuationMmdFile);
|
||||
} else if (judgment.isLongTask && judgment.newTaskLabel) {
|
||||
const currentLabel = currentMmd
|
||||
? currentMmd.replace(/^\d+-/, "").replace(/\.mmd$/, "")
|
||||
: null;
|
||||
if (currentLabel !== judgment.newTaskLabel) {
|
||||
await createNewMmd(judgment.newTaskLabel);
|
||||
}
|
||||
} else if (judgment.isContinuation && !judgment.continuationMmdFile) {
|
||||
if (!currentMmd) {
|
||||
stateManager.setActiveMmd(null, null);
|
||||
}
|
||||
} else {
|
||||
logger.info("[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)"}`);
|
||||
if (judgment.isContinuation) {
|
||||
if (!currentMmd && judgment.continuationMmdFile) {
|
||||
await reactivateMmd(judgment.continuationMmdFile);
|
||||
}
|
||||
} else if (judgment.isLongTask && judgment.newTaskLabel) {
|
||||
const currentLabel = currentMmd
|
||||
? currentMmd.replace(/^\d+-/, "").replace(/\.mmd$/, "")
|
||||
: null;
|
||||
if (currentLabel !== judgment.newTaskLabel) {
|
||||
await createNewMmd(judgment.newTaskLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* before_prompt_build hook handler.
|
||||
*
|
||||
* Three-phase context cleanup before llm_input:
|
||||
* 1. Fast-path re-apply: re-offload confirmed mild replacements + delete aggressive-deleted messages
|
||||
* 2. Token guard: if still above thresholds, run full L3 (Aggressive + Mild) inline
|
||||
* 3. MMD injection: injects active/history MMD into messages
|
||||
*/
|
||||
import { PLUGIN_DEFAULTS } from "../types.js";
|
||||
import { readOffloadEntries, markOffloadStatus } from "../storage.js";
|
||||
import { buildTiktokenContextSnapshot } from "../context-token-tracker.js";
|
||||
import { traceOffloadDecision } from "../opik-tracer.js";
|
||||
import { injectMmdIntoMessages, findHistoryMmdInsertionPoint } from "../mmd-injector.js";
|
||||
import { createL3TokenCounter } from "../l3-token-counter.js";
|
||||
import {
|
||||
normalizeToolCallIdForLookup,
|
||||
getOffloadEntry,
|
||||
populateOffloadLookupMap,
|
||||
isToolResultMessage,
|
||||
extractToolCallId,
|
||||
isOnlyToolUseAssistant,
|
||||
extractAllToolUseIds,
|
||||
isAssistantMessageWithToolUse,
|
||||
replaceWithSummary,
|
||||
replaceAssistantToolUseWithSummary,
|
||||
compressNonCurrentToolUseBlocks,
|
||||
getCurrentTaskNodeIds,
|
||||
} from "../l3-helpers.js";
|
||||
import {
|
||||
compressByScoreCascade,
|
||||
aggressiveCompressUntilBelowThreshold,
|
||||
buildHistoryMmdInjection,
|
||||
removeExistingMmdInjections,
|
||||
emergencyCompress,
|
||||
EMERGENCY_MIN_MESSAGES_TO_KEEP,
|
||||
isTokenOverflowError,
|
||||
filterHeartbeatMessages,
|
||||
dumpMessagesSnapshot,
|
||||
} from "./llm-input-l3.js";
|
||||
import type { OffloadStateManager } from "../state-manager.js";
|
||||
import type { PluginConfig, PluginLogger } from "../types.js";
|
||||
|
||||
export function createBeforePromptBuildHandler(
|
||||
stateManager: OffloadStateManager,
|
||||
logger: PluginLogger,
|
||||
getContextWindow: (() => number) | undefined,
|
||||
pluginConfig: Partial<PluginConfig> | undefined,
|
||||
) {
|
||||
return async (event: any, _ctx: any) => {
|
||||
// Skip internal memory-pipeline sessions
|
||||
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 ?? "?"}`);
|
||||
try {
|
||||
const messages = event.messages;
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) return;
|
||||
|
||||
filterHeartbeatMessages(messages, logger);
|
||||
|
||||
const sessionKey = stateManager.getLastSessionKey();
|
||||
const hasConfirmed = stateManager.confirmedOffloadIds && stateManager.confirmedOffloadIds.size > 0;
|
||||
const hasDeleted = stateManager.deletedOffloadIds && stateManager.deletedOffloadIds.size > 0;
|
||||
|
||||
if (!hasConfirmed && !hasDeleted) {
|
||||
await injectMmdIntoMessages(messages, stateManager, logger, getContextWindow, pluginConfig, { waitForL15: true });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Phase 1: Fast-path
|
||||
const snapBefore = buildTiktokenContextSnapshot("before_prompt_pre", messages, null, null);
|
||||
const tokensBefore = snapBefore.totalTokens;
|
||||
|
||||
const offloadEntries = await readOffloadEntries(stateManager.ctx);
|
||||
const offloadMap = new Map();
|
||||
populateOffloadLookupMap(offloadMap, offloadEntries);
|
||||
stateManager.setCachedOffloadMap(offloadMap);
|
||||
|
||||
let fastReplaceApplied = 0;
|
||||
const indicesToDelete: number[] = [];
|
||||
const deletedToolCallIdsForMmd: string[] = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
const tid = extractToolCallId(msg);
|
||||
const tidNorm = tid ? normalizeToolCallIdForLookup(tid) : null;
|
||||
|
||||
if (tid && hasDeleted && (stateManager.deletedOffloadIds.has(tid) || (tidNorm && stateManager.deletedOffloadIds.has(tidNorm)))) {
|
||||
indicesToDelete.push(i);
|
||||
if (isToolResultMessage(msg)) deletedToolCallIdsForMmd.push(tid);
|
||||
continue;
|
||||
}
|
||||
if (hasDeleted && isOnlyToolUseAssistant(msg)) {
|
||||
const tuIds = extractAllToolUseIds(msg);
|
||||
const allDeleted = tuIds.length > 0 && tuIds.every((id) =>
|
||||
stateManager.deletedOffloadIds.has(id) || stateManager.deletedOffloadIds.has(normalizeToolCallIdForLookup(id)));
|
||||
if (allDeleted) { indicesToDelete.push(i); continue; }
|
||||
}
|
||||
// FIX: For mixed assistant messages (text + tool_use), strip deleted tool_use
|
||||
// blocks to prevent orphaned tool_use without matching tool_result (Anthropic 400).
|
||||
if (hasDeleted && isAssistantMessageWithToolUse(msg) && !isOnlyToolUseAssistant(msg)) {
|
||||
const content = msg.type === "message" ? msg.message?.content : msg.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (let j = content.length - 1; j >= 0; j--) {
|
||||
const block = content[j] as any;
|
||||
if ((block.type === "tool_use" || block.type === "toolCall") && block.id) {
|
||||
const blockIdNorm = normalizeToolCallIdForLookup(block.id);
|
||||
if (stateManager.deletedOffloadIds.has(block.id) || stateManager.deletedOffloadIds.has(blockIdNorm)) {
|
||||
content.splice(j, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (msg._offloaded) continue;
|
||||
if (tid && hasConfirmed && (stateManager.confirmedOffloadIds.has(tid) || (tidNorm && stateManager.confirmedOffloadIds.has(tidNorm)))) {
|
||||
const entry = getOffloadEntry(offloadMap, tid);
|
||||
if (entry && isToolResultMessage(msg)) {
|
||||
replaceWithSummary(msg, entry);
|
||||
msg._offloaded = true;
|
||||
fastReplaceApplied++;
|
||||
}
|
||||
}
|
||||
if (isOnlyToolUseAssistant(msg)) {
|
||||
const tuIds = extractAllToolUseIds(msg);
|
||||
const allConfirmed = tuIds.length > 0 && tuIds.every((id) =>
|
||||
stateManager.confirmedOffloadIds.has(id) || stateManager.confirmedOffloadIds.has(normalizeToolCallIdForLookup(id)));
|
||||
if (allConfirmed) {
|
||||
const tuEntries = tuIds.map((id) => getOffloadEntry(offloadMap, id)).filter(Boolean) as any[];
|
||||
if (tuEntries.length === tuIds.length) {
|
||||
replaceAssistantToolUseWithSummary(msg, tuEntries);
|
||||
msg._offloaded = true;
|
||||
fastReplaceApplied++;
|
||||
}
|
||||
}
|
||||
} else if (isAssistantMessageWithToolUse(msg)) {
|
||||
compressNonCurrentToolUseBlocks(msg, offloadMap, new Set(), stateManager.confirmedOffloadIds);
|
||||
}
|
||||
}
|
||||
|
||||
if (indicesToDelete.length > 0) {
|
||||
for (let k = indicesToDelete.length - 1; k >= 0; k--) {
|
||||
messages.splice(indicesToDelete[k], 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Token guard
|
||||
const contextWindow = typeof getContextWindow === "function" ? getContextWindow() : PLUGIN_DEFAULTS.defaultContextWindow;
|
||||
const mildRatio = pluginConfig?.mildOffloadRatio ?? PLUGIN_DEFAULTS.mildOffloadRatio;
|
||||
const aggressiveRatio = pluginConfig?.aggressiveCompressRatio ?? PLUGIN_DEFAULTS.aggressiveCompressRatio;
|
||||
const mildThreshold = Math.floor(contextWindow * mildRatio);
|
||||
const aggressiveThreshold = Math.floor(contextWindow * aggressiveRatio);
|
||||
|
||||
const snapGuard = buildTiktokenContextSnapshot("before_prompt_guard", messages, null, null);
|
||||
let workingTokens = snapGuard.totalTokens;
|
||||
|
||||
if (workingTokens >= aggressiveThreshold) {
|
||||
const countTokens = createL3TokenCounter(pluginConfig, logger);
|
||||
const aggressiveDeleteRatio = (pluginConfig as any)?.aggressiveDeleteRatio ?? PLUGIN_DEFAULTS.aggressiveDeleteRatio;
|
||||
const currentTaskNodeIds = await getCurrentTaskNodeIds(stateManager);
|
||||
const result = await aggressiveCompressUntilBelowThreshold(
|
||||
messages, offloadMap, currentTaskNodeIds, aggressiveDeleteRatio,
|
||||
stateManager, logger, aggressiveThreshold, countTokens, null, null,
|
||||
);
|
||||
workingTokens = result.remainingTokens;
|
||||
dumpMessagesSnapshot("bpb-after-aggressive", messages, logger);
|
||||
if (result.allDeletedToolCallIds.length > 0) {
|
||||
const statusUpdates = new Map<string, string | boolean>();
|
||||
for (const id of result.allDeletedToolCallIds) {
|
||||
statusUpdates.set(id, "deleted");
|
||||
statusUpdates.set(normalizeToolCallIdForLookup(id), "deleted");
|
||||
stateManager.confirmedOffloadIds.add(id);
|
||||
stateManager.deletedOffloadIds.add(id);
|
||||
}
|
||||
markOffloadStatus(stateManager.ctx, statusUpdates).catch((err: any) =>
|
||||
logger.error(`[context-offload] markOffloadStatus error: ${err}`));
|
||||
const mmdInjection = await buildHistoryMmdInjection(
|
||||
result.allDeletedToolCallIds, offloadMap, offloadEntries,
|
||||
stateManager, logger, countTokens, contextWindow, pluginConfig,
|
||||
);
|
||||
if (mmdInjection.injectedMessages.length > 0) {
|
||||
removeExistingMmdInjections(messages);
|
||||
const histInsertIdx = findHistoryMmdInsertionPoint(messages);
|
||||
messages.splice(histInsertIdx, 0, ...mmdInjection.injectedMessages);
|
||||
workingTokens += mmdInjection.totalMmdTokens;
|
||||
dumpMessagesSnapshot("bpb-after-aggressive-mmd-injection", messages, logger);
|
||||
}
|
||||
}
|
||||
// If aggressive stalled due to user message protection, force emergency
|
||||
if (result.stalledByUserMsg && workingTokens >= aggressiveThreshold) {
|
||||
logger.warn(`[context-offload] before_prompt_build AGGRESSIVE stalled, forcing emergency fallback`);
|
||||
stateManager._forceEmergencyNext = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (workingTokens >= mildThreshold) {
|
||||
const currentTaskNodeIds = await getCurrentTaskNodeIds(stateManager);
|
||||
const mildScanRatio = (pluginConfig as any)?.mildOffloadScanRatio ?? PLUGIN_DEFAULTS.mildOffloadScanRatio;
|
||||
const cascadeResult = compressByScoreCascade(messages, offloadMap, currentTaskNodeIds, mildScanRatio, logger);
|
||||
if (cascadeResult.replacedCount > 0) {
|
||||
for (const id of cascadeResult.replacedToolCallIds) {
|
||||
stateManager.confirmedOffloadIds.add(id);
|
||||
}
|
||||
const mildStatusUpdates = new Map<string, string | boolean>();
|
||||
for (const id of cascadeResult.replacedToolCallIds) {
|
||||
mildStatusUpdates.set(id, true);
|
||||
}
|
||||
markOffloadStatus(stateManager.ctx, mildStatusUpdates).catch((err: any) =>
|
||||
logger.error(`[context-offload] markOffloadStatus error: ${err}`));
|
||||
}
|
||||
dumpMessagesSnapshot("bpb-after-mild", messages, logger);
|
||||
}
|
||||
{
|
||||
const emergencyRatio = pluginConfig?.emergencyCompressRatio ?? PLUGIN_DEFAULTS.emergencyCompressRatio;
|
||||
const emergencyTargetRatio = pluginConfig?.emergencyTargetRatio ?? PLUGIN_DEFAULTS.emergencyTargetRatio;
|
||||
const emergencyThreshold = Math.floor(contextWindow * emergencyRatio);
|
||||
const emergencyTarget = Math.floor(contextWindow * emergencyTargetRatio);
|
||||
const preEmergencySnap = buildTiktokenContextSnapshot("before_prompt_pre_emergency", messages, null, null);
|
||||
workingTokens = preEmergencySnap.totalTokens;
|
||||
const forceEmergency = stateManager._forceEmergencyNext === true;
|
||||
if (forceEmergency) stateManager._forceEmergencyNext = false;
|
||||
if ((workingTokens >= emergencyThreshold || forceEmergency) && messages.length > EMERGENCY_MIN_MESSAGES_TO_KEEP) {
|
||||
const countTokensBpb = createL3TokenCounter(pluginConfig, logger);
|
||||
const emergencyResult = emergencyCompress(messages, emergencyTarget, countTokensBpb, null, null, logger);
|
||||
workingTokens = emergencyResult.remainingTokens;
|
||||
if (emergencyResult.deletedToolCallIds.length > 0) {
|
||||
const emergencyStatusUpdates = new Map<string, string | boolean>();
|
||||
for (const id of emergencyResult.deletedToolCallIds) {
|
||||
emergencyStatusUpdates.set(id, "deleted");
|
||||
stateManager.confirmedOffloadIds.add(id);
|
||||
stateManager.deletedOffloadIds.add(id);
|
||||
}
|
||||
markOffloadStatus(stateManager.ctx, emergencyStatusUpdates).catch((err: any) =>
|
||||
logger.error(`[context-offload] markOffloadStatus error: ${err}`));
|
||||
}
|
||||
dumpMessagesSnapshot("bpb-after-emergency", messages, logger);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: MMD Injection
|
||||
await injectMmdIntoMessages(messages, stateManager, logger, getContextWindow, pluginConfig, { waitForL15: true });
|
||||
|
||||
traceOffloadDecision({
|
||||
sessionKey: stateManager.getLastSessionKey(),
|
||||
stage: "L3.before_prompt_build.completed",
|
||||
input: {
|
||||
phase: "before_prompt_build",
|
||||
confirmedOffloadIds: stateManager.confirmedOffloadIds.size,
|
||||
deletedOffloadIds: stateManager.deletedOffloadIds.size,
|
||||
},
|
||||
output: {
|
||||
messagesAfter: messages.length,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
logger.error(`[context-offload] before_prompt_build error: ${err}`);
|
||||
if (isTokenOverflowError(err)) {
|
||||
stateManager._forceEmergencyNext = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* llm_output hook handler.
|
||||
* Detects when L1 should be force-triggered based on pending pair count.
|
||||
*
|
||||
* Backend-only mode: local LLM pipeline references removed.
|
||||
*/
|
||||
import type { OffloadStateManager } from "../state-manager.js";
|
||||
import type { PluginConfig } from "../types.js";
|
||||
|
||||
const DEFAULT_FORCE_TRIGGER_THRESHOLD = 4;
|
||||
|
||||
/**
|
||||
* Check if L1 should be force-triggered (called from after_tool_call when
|
||||
* pending count exceeds threshold).
|
||||
*/
|
||||
export function shouldForceL1(
|
||||
stateManager: OffloadStateManager,
|
||||
pluginConfig: Partial<PluginConfig> | undefined,
|
||||
): boolean {
|
||||
const threshold =
|
||||
pluginConfig?.forceTriggerThreshold ?? DEFAULT_FORCE_TRIGGER_THRESHOLD;
|
||||
return stateManager.getPendingCount() >= threshold;
|
||||
}
|
||||
Reference in New Issue
Block a user