mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-11 21:04:29 +00:00
feat: release v0.3.3 — Hermes adapter, context offload, core refactor
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* OffloadStateManager: In-memory state + persistent state.json coordination.
|
||||
* Manages pendingToolPairs buffer, active MMD tracking, and processed IDs.
|
||||
*
|
||||
* Each instance is bound to a single session via StorageContext.
|
||||
* No global mutable state — all I/O goes through the frozen ctx.
|
||||
*/
|
||||
import {
|
||||
readStateFile,
|
||||
writeStateFile,
|
||||
ensureDirs,
|
||||
createStorageContext,
|
||||
parseSessionKey,
|
||||
readOffloadEntries,
|
||||
extractConfirmedIdsFromEntries,
|
||||
extractDeletedIdsFromEntries,
|
||||
registerSession,
|
||||
listMmds,
|
||||
} from "./storage.js";
|
||||
import type { StorageContext } from "./storage.js";
|
||||
import type { ToolPair, PluginState, OffloadEntry, L15Boundary } from "./types.js";
|
||||
|
||||
const DEFAULT_STATE: PluginState & { estimatedSystemOverhead: number | null } = {
|
||||
activeMmdFile: null,
|
||||
activeMmdId: null,
|
||||
mmdCounter: 0,
|
||||
lastSessionKey: null,
|
||||
lastOffloadedToolCallId: null,
|
||||
lastL2TriggerTime: null,
|
||||
estimatedSystemOverhead: null,
|
||||
};
|
||||
|
||||
export class OffloadStateManager {
|
||||
/** Immutable storage path context — set by init() or switchSession() */
|
||||
private _ctx: StorageContext | null = null;
|
||||
|
||||
/** Buffered tool pairs waiting to be processed by L1 */
|
||||
pendingToolPairs: Array<ToolPair & { _sessionId?: string | null }> = [];
|
||||
/** Set of already-processed tool call IDs to prevent duplicates */
|
||||
processedToolCallIds = new Set<string>();
|
||||
/** Persistent state (synced with state.json) */
|
||||
private state: PluginState & { estimatedSystemOverhead: number | null } = { ...DEFAULT_STATE };
|
||||
/** Whether state has been loaded from disk */
|
||||
private loaded = false;
|
||||
/** Mutex for L1 pipeline to prevent concurrent runs */
|
||||
private l1Lock: Promise<unknown> = Promise.resolve();
|
||||
|
||||
// ─── Runtime-only flags (not persisted) ──────────────────────────────────
|
||||
private mmdInjectionReady = false;
|
||||
private injectedMmdVersions: Record<string, string> = {};
|
||||
|
||||
/** Whether L1.5 has successfully executed for the current session/prompt.
|
||||
* L2 must wait for this to be true before triggering. */
|
||||
l15Settled = false;
|
||||
/** Unique instance ID for debugging (each new OffloadStateManager gets a new id). */
|
||||
readonly _instanceId = ++OffloadStateManager._instanceCounter;
|
||||
private static _instanceCounter = 0;
|
||||
|
||||
/** Set of toolCallIds confirmed offloaded in previous rounds. */
|
||||
confirmedOffloadIds = new Set<string>();
|
||||
/** Set of toolCallIds that were aggressively DELETED. */
|
||||
deletedOffloadIds = new Set<string>();
|
||||
/** Reconciliation retry counter */
|
||||
_reconcileRetries = new Map<string, number>();
|
||||
/** Cached offload entries map */
|
||||
_cachedOffloadMap: Map<string, OffloadEntry> | null = null;
|
||||
/** Monotonic version counter */
|
||||
_offloadMapVersion = 0;
|
||||
/** Last MMD injection token count */
|
||||
lastMmdInjectedTokens = 0;
|
||||
/** Cached system prompt from last llm_input */
|
||||
cachedSystemPrompt: string | null = null;
|
||||
/** Cached user prompt from last llm_input */
|
||||
cachedUserPrompt: string | null = null;
|
||||
/** Cached latest turn messages for L2 */
|
||||
cachedLatestTurnMessages: string | null = null;
|
||||
/** Cached recent history for L2 background triggers */
|
||||
cachedRecentHistory: string | null = null;
|
||||
/** Cached system prompt token count */
|
||||
cachedSystemPromptTokens: number | null = null;
|
||||
/** Cached user prompt token count */
|
||||
cachedUserPromptTokens: number | null = null;
|
||||
/** Force emergency compression on next L3 entry */
|
||||
_forceEmergencyNext = false;
|
||||
/** Last known total token count from precise tiktoken calculation (P1 quick-skip) */
|
||||
lastKnownTotalTokens = 0;
|
||||
/** Message count at last precise tiktoken calculation (P1 quick-skip) */
|
||||
lastKnownMessageCount = 0;
|
||||
/** Consecutive QUICK-SKIP count; reset to 0 on each precise calculation */
|
||||
consecutiveQuickSkips = 0;
|
||||
/** 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 */
|
||||
lastL15PromptHash: number | null = null;
|
||||
|
||||
// ─── Fault tolerance fields ─────────────────────────────────────────────
|
||||
/** Per-chunk consecutive L1 failure count. Key = first toolCallId of the chunk. */
|
||||
_l1ChunkFailCounts = new Map<string, number>();
|
||||
/** Consecutive L1.5 all-null response count. Reset to 0 on successful judgment. */
|
||||
l15ConsecutiveNullCount = 0;
|
||||
|
||||
// ─── L1.5 Boundary (runtime-only, per-session) ────────────────────────
|
||||
/** Global entry counter, incremented after each appendOffloadEntries. */
|
||||
entryCounter = 0;
|
||||
/** Settled boundaries (ascending by startIndex). */
|
||||
l15Boundaries: L15Boundary[] = [];
|
||||
|
||||
// ─── StorageContext accessor ─────────────────────────────────────────────
|
||||
|
||||
/** Get the current session's StorageContext. Throws if not initialized. */
|
||||
get ctx(): StorageContext {
|
||||
if (!this._ctx) {
|
||||
throw new Error("OffloadStateManager: ctx not initialized, call init() or switchSession() first");
|
||||
}
|
||||
return this._ctx;
|
||||
}
|
||||
|
||||
/** Get agent name from ctx (null if not initialized) */
|
||||
get agentName(): string | null {
|
||||
return this._ctx?.agentName ?? null;
|
||||
}
|
||||
|
||||
/** Get session id from ctx (null if not initialized) */
|
||||
get sessionId(): string | null {
|
||||
return this._ctx?.sessionId ?? null;
|
||||
}
|
||||
|
||||
// ─── Initialization ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initialize the manager for a specific agent + session.
|
||||
* Creates StorageContext, ensures directories, and loads persistent state.
|
||||
*/
|
||||
async init(dataRoot: string, agentName: string, sessionId: string): Promise<void> {
|
||||
this._ctx = createStorageContext(dataRoot, agentName, sessionId);
|
||||
await ensureDirs(this._ctx);
|
||||
const loadedState = await readStateFile(this._ctx, DEFAULT_STATE);
|
||||
this.state = { ...DEFAULT_STATE, ...loadedState };
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
await writeStateFile(this.ctx, this.state);
|
||||
}
|
||||
|
||||
// ─── Tool Pair Buffer ────────────────────────────────────────────────────
|
||||
addToolPair(pair: ToolPair): void {
|
||||
if (this.processedToolCallIds.has(pair.toolCallId)) return;
|
||||
(pair as ToolPair & { _sessionId?: string | null })._sessionId = this._ctx?.sessionId ?? null;
|
||||
this.pendingToolPairs.push(pair as ToolPair & { _sessionId?: string | null });
|
||||
}
|
||||
|
||||
getPendingCount(): number {
|
||||
return this.pendingToolPairs.length;
|
||||
}
|
||||
|
||||
hasPending(): boolean {
|
||||
return this.pendingToolPairs.length > 0;
|
||||
}
|
||||
|
||||
takePending(max: number): Array<ToolPair & { _sessionId?: string | null }> {
|
||||
const taken = this.pendingToolPairs.splice(0, max);
|
||||
for (const pair of taken) {
|
||||
this.processedToolCallIds.add(pair.toolCallId);
|
||||
}
|
||||
return taken;
|
||||
}
|
||||
|
||||
isProcessed(toolCallId: string): boolean {
|
||||
return this.processedToolCallIds.has(toolCallId);
|
||||
}
|
||||
|
||||
// ─── Active MMD ──────────────────────────────────────────────────────────
|
||||
getActiveMmdFile(): string | null {
|
||||
return this.state.activeMmdFile;
|
||||
}
|
||||
|
||||
getActiveMmdId(): string | null {
|
||||
return this.state.activeMmdId;
|
||||
}
|
||||
|
||||
setActiveMmd(file: string | null, id: string | null): void {
|
||||
this.state.activeMmdFile = file;
|
||||
this.state.activeMmdId = id;
|
||||
}
|
||||
|
||||
async nextMmdNumber(): Promise<number> {
|
||||
try {
|
||||
const existingFiles = await listMmds(this.ctx);
|
||||
let maxOnDisk = 0;
|
||||
for (const f of existingFiles) {
|
||||
const m = f.match(/^(\d+)-/);
|
||||
if (m) {
|
||||
const num = parseInt(m[1], 10);
|
||||
if (num > maxOnDisk) maxOnDisk = num;
|
||||
}
|
||||
}
|
||||
if (maxOnDisk >= this.state.mmdCounter) {
|
||||
this.state.mmdCounter = maxOnDisk;
|
||||
}
|
||||
} catch {
|
||||
/* If listing fails, fall through with in-memory counter */
|
||||
}
|
||||
this.state.mmdCounter += 1;
|
||||
return this.state.mmdCounter;
|
||||
}
|
||||
|
||||
getMmdCounter(): number {
|
||||
return this.state.mmdCounter;
|
||||
}
|
||||
|
||||
// ─── Session / Multi-Agent ──────────────────────────────────────────────
|
||||
getLastSessionKey(): string | null {
|
||||
return this.state.lastSessionKey;
|
||||
}
|
||||
|
||||
setLastSessionKey(key: string | null): void {
|
||||
this.state.lastSessionKey = key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a new session. Rebuilds StorageContext and reloads state.
|
||||
* @param sessionKey - Full session key (e.g. "agent:main:session-123")
|
||||
* @param dataRoot - Storage root directory
|
||||
* @param realSessionId - Optional override for the parsed sessionId
|
||||
*/
|
||||
async switchSession(
|
||||
sessionKey: string,
|
||||
dataRoot: string,
|
||||
realSessionId?: string,
|
||||
): Promise<boolean> {
|
||||
const parsed = parseSessionKey(sessionKey);
|
||||
if (!parsed) return false;
|
||||
const prevAgent = this._ctx?.agentName;
|
||||
const effectiveSessionId = realSessionId || parsed.sessionId;
|
||||
|
||||
// Create new immutable StorageContext
|
||||
this._ctx = createStorageContext(dataRoot, parsed.agentName, effectiveSessionId);
|
||||
await ensureDirs(this._ctx);
|
||||
if (realSessionId) {
|
||||
await registerSession(this._ctx, sessionKey, realSessionId).catch(() => {});
|
||||
}
|
||||
if (prevAgent !== parsed.agentName) {
|
||||
const loadedState = await readStateFile(this._ctx, DEFAULT_STATE);
|
||||
this.state = { ...DEFAULT_STATE, ...loadedState };
|
||||
}
|
||||
try {
|
||||
const entries = await readOffloadEntries(this._ctx);
|
||||
this.confirmedOffloadIds = extractConfirmedIdsFromEntries(
|
||||
entries as Array<OffloadEntry & { offloaded?: unknown }>,
|
||||
);
|
||||
this.deletedOffloadIds = extractDeletedIdsFromEntries(
|
||||
entries as Array<OffloadEntry & { offloaded?: unknown }>,
|
||||
);
|
||||
this.processedToolCallIds = new Set<string>();
|
||||
for (const e of entries) {
|
||||
if (e.tool_call_id) {
|
||||
this.processedToolCallIds.add(e.tool_call_id);
|
||||
const norm = e.tool_call_id.replace(/_/g, "");
|
||||
if (norm !== e.tool_call_id) {
|
||||
this.processedToolCallIds.add(norm);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.pendingToolPairs = [];
|
||||
this.injectedMmdVersions = {};
|
||||
this.mmdInjectionReady = false;
|
||||
this.l15Settled = false;
|
||||
this.lastMmdInjectedTokens = 0;
|
||||
this.cachedUserPrompt = null;
|
||||
this.lastL15PromptHash = null;
|
||||
// Restore entryCounter from persisted entries; reset boundaries
|
||||
this.entryCounter = entries.length;
|
||||
this.l15Boundaries = [];
|
||||
// Reset P1 quick-skip state
|
||||
this.lastKnownTotalTokens = 0;
|
||||
this.lastKnownMessageCount = 0;
|
||||
this.consecutiveQuickSkips = 0;
|
||||
this._forceEmergencyNext = false;
|
||||
// Keep cachedSystemPrompt/Tokens across switchSession within the same agent
|
||||
if (prevAgent !== parsed.agentName) {
|
||||
this.cachedSystemPrompt = null;
|
||||
this.cachedSystemPromptTokens = null;
|
||||
this.cachedUserPromptTokens = null;
|
||||
}
|
||||
this._cachedOffloadMap = null;
|
||||
this._offloadMapVersion++;
|
||||
this.cachedLatestTurnMessages = null;
|
||||
this.cachedRecentHistory = null;
|
||||
this._reconcileRetries = new Map();
|
||||
this._pendingParams = new Map();
|
||||
this._l1ChunkFailCounts = new Map();
|
||||
this.l15ConsecutiveNullCount = 0;
|
||||
} catch {
|
||||
this.confirmedOffloadIds = new Set();
|
||||
this.deletedOffloadIds = new Set();
|
||||
this.processedToolCallIds = new Set();
|
||||
this.pendingToolPairs = [];
|
||||
}
|
||||
this.state.lastSessionKey = sessionKey;
|
||||
await this.save();
|
||||
return true;
|
||||
}
|
||||
|
||||
getLastOffloadedToolCallId(): string | null {
|
||||
return this.state.lastOffloadedToolCallId;
|
||||
}
|
||||
|
||||
setLastOffloadedToolCallId(toolCallId: string | null): void {
|
||||
this.state.lastOffloadedToolCallId = toolCallId;
|
||||
}
|
||||
|
||||
// ─── L1 Mutex ────────────────────────────────────────────────────────────
|
||||
acquireL1Lock(): Promise<() => void> {
|
||||
let release!: () => void;
|
||||
const prev = this.l1Lock;
|
||||
this.l1Lock = new Promise<void>((resolve) => {
|
||||
release = () => resolve();
|
||||
});
|
||||
return prev.then(() => release);
|
||||
}
|
||||
|
||||
// ─── L2 Trigger Tracking ───────────────────────────────────────────────
|
||||
getLastL2TriggerTime(): string | null {
|
||||
return this.state.lastL2TriggerTime;
|
||||
}
|
||||
|
||||
setLastL2TriggerTime(time: string | null): void {
|
||||
this.state.lastL2TriggerTime = time;
|
||||
}
|
||||
|
||||
// ─── Full State Access ───────────────────────────────────────────────────
|
||||
getState(): Readonly<PluginState> {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
isLoaded(): boolean {
|
||||
return this.loaded;
|
||||
}
|
||||
|
||||
// ─── MMD Injection Control ──────────────────────────────────────────────
|
||||
setMmdInjectionReady(ready: boolean): void {
|
||||
this.mmdInjectionReady = ready;
|
||||
}
|
||||
|
||||
isMmdInjectionReady(): boolean {
|
||||
return this.mmdInjectionReady;
|
||||
}
|
||||
|
||||
// ─── Injected MMD Version Tracking ──────────────────────────────────────
|
||||
setInjectedMmdVersion(filename: string, fingerprint: string): void {
|
||||
this.injectedMmdVersions[filename] = fingerprint;
|
||||
}
|
||||
|
||||
getInjectedMmdVersion(filename: string): string | null {
|
||||
return this.injectedMmdVersions[filename] ?? null;
|
||||
}
|
||||
|
||||
removeInjectedMmdVersion(filename: string): void {
|
||||
delete this.injectedMmdVersions[filename];
|
||||
}
|
||||
|
||||
getAllInjectedMmdVersions(): Record<string, string> {
|
||||
return { ...this.injectedMmdVersions };
|
||||
}
|
||||
|
||||
clearInjectedMmdVersions(): void {
|
||||
this.injectedMmdVersions = {};
|
||||
}
|
||||
|
||||
// ─── Token Tracking ─────────────────────────────────────────────────────
|
||||
setEstimatedSystemOverhead(tokens: number): void {
|
||||
(this.state as unknown as Record<string, unknown>).estimatedSystemOverhead = tokens;
|
||||
}
|
||||
|
||||
getEstimatedSystemOverhead(): number | null {
|
||||
return (this.state as unknown as Record<string, unknown>).estimatedSystemOverhead as number | null;
|
||||
}
|
||||
|
||||
// ─── Offload Map Cache ──────────────────────────────────────────────────
|
||||
invalidateOffloadMapCache(): void {
|
||||
this._cachedOffloadMap = null;
|
||||
this._offloadMapVersion++;
|
||||
}
|
||||
|
||||
getCachedOffloadMap(): Map<string, OffloadEntry> | null {
|
||||
return this._cachedOffloadMap;
|
||||
}
|
||||
|
||||
setCachedOffloadMap(map: Map<string, OffloadEntry>): void {
|
||||
this._cachedOffloadMap = map;
|
||||
}
|
||||
|
||||
getOffloadMapVersion(): number {
|
||||
return this._offloadMapVersion;
|
||||
}
|
||||
|
||||
// ─── Before Tool Call Params Cache ───────────────────────────────────────
|
||||
cacheToolParams(toolCallId: string, params: Record<string, unknown>): void {
|
||||
this._pendingParams.set(toolCallId, params);
|
||||
if (this._pendingParams.size > 100) {
|
||||
const oldest = this._pendingParams.keys().next().value;
|
||||
if (oldest !== undefined) this._pendingParams.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
consumeToolParams(toolCallId: string): Record<string, unknown> | null {
|
||||
const params = this._pendingParams.get(toolCallId);
|
||||
if (params !== undefined) {
|
||||
this._pendingParams.delete(toolCallId);
|
||||
}
|
||||
return params ?? null;
|
||||
}
|
||||
|
||||
// ─── L1.5 Boundary Helpers ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Append a new boundary (must be in ascending startIndex order).
|
||||
* If the last boundary has the same startIndex, overwrite it instead of
|
||||
* appending — this happens during fast task switching when no tool calls
|
||||
* (and thus no L1 entries) are produced between consecutive L1.5 judgments.
|
||||
*/
|
||||
pushBoundary(boundary: L15Boundary): void {
|
||||
const last = this.l15Boundaries.at(-1);
|
||||
if (last && last.startIndex === boundary.startIndex) {
|
||||
this.l15Boundaries[this.l15Boundaries.length - 1] = boundary;
|
||||
} else {
|
||||
this.l15Boundaries.push(boundary);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the boundary that covers the given entry index.
|
||||
* Returns the last boundary whose startIndex <= entryIndex,
|
||||
* or null if no boundary covers it (entry predates all boundaries).
|
||||
*/
|
||||
resolveEntryBoundary(entryIndex: number): L15Boundary | null {
|
||||
let matched: L15Boundary | null = null;
|
||||
for (const b of this.l15Boundaries) {
|
||||
if (b.startIndex <= entryIndex) {
|
||||
matched = b;
|
||||
} else {
|
||||
break; // boundaries are ascending by startIndex
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user