mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-11 12:54:29 +00:00
feat: release v0.3.3 — Hermes adapter, context offload, core refactor
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* L2 Mermaid Generation Pipeline (Independent Trigger):
|
||||
*
|
||||
* L2 is NO LONGER triggered directly from L1. Instead it runs independently:
|
||||
* - Trigger condition A: offload.jsonl has >= l2NullThreshold entries with node_id=null
|
||||
* - Trigger condition B: time since last L2 trigger exceeds l2TimeoutSeconds
|
||||
*/
|
||||
import { PLUGIN_DEFAULTS, type OffloadEntry, type PluginConfig, type PluginLogger } from "../types.js";
|
||||
import {
|
||||
readAllOffloadEntries,
|
||||
rewriteAllOffloadEntries,
|
||||
type StorageContext,
|
||||
} from "../storage.js";
|
||||
import type { OffloadStateManager } from "../state-manager.js";
|
||||
|
||||
function isHeartbeatEntry(entry: OffloadEntry): boolean {
|
||||
try {
|
||||
const tc = entry.tool_call ?? "";
|
||||
return tc.includes("HEARTBEAT.md");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasNullEntryAfterLastL2(
|
||||
nullEntries: OffloadEntry[],
|
||||
lastL2Iso: string,
|
||||
): boolean {
|
||||
const lastMs = new Date(lastL2Iso).getTime();
|
||||
if (Number.isNaN(lastMs)) return true;
|
||||
return nullEntries.some((e) => {
|
||||
if (!e.timestamp) return true;
|
||||
const ts = new Date(e.timestamp).getTime();
|
||||
if (Number.isNaN(ts)) return true;
|
||||
return ts > lastMs;
|
||||
});
|
||||
}
|
||||
|
||||
const MMD_NODE_ID_RE = /\b(\d{3}-N\d+)\b/g;
|
||||
|
||||
function normalizeNodeMapping(raw: any): Record<string, string> {
|
||||
const out: Record<string, string> = Object.create(null);
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
if (typeof k !== "string" || !k) continue;
|
||||
const s = typeof v === "string" ? v.trim() : v != null ? String(v).trim() : "";
|
||||
if (s) out[k] = s;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractMmdNodeIdsFromText(text: string | null | undefined): string[] {
|
||||
if (text == null || typeof text !== "string") return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
MMD_NODE_ID_RE.lastIndex = 0;
|
||||
while ((m = MMD_NODE_ID_RE.exec(text)) !== null) {
|
||||
const id = m[1];
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickMmdDerivedFallbackNodeId(
|
||||
mmdText: string,
|
||||
mmdPrefix: string,
|
||||
): string | null {
|
||||
const ids = extractMmdNodeIdsFromText(mmdText);
|
||||
if (ids.length === 0) return null;
|
||||
const prefix =
|
||||
typeof mmdPrefix === "string" && /^\d{3}$/.test(mmdPrefix)
|
||||
? `${mmdPrefix}-`
|
||||
: null;
|
||||
const pool = prefix ? ids.filter((id) => id.startsWith(prefix)) : ids;
|
||||
const candidates = pool.length > 0 ? pool : ids;
|
||||
let best: string | null = null;
|
||||
let bestN = -1;
|
||||
for (const id of candidates) {
|
||||
const mm = id.match(/^(\d{3})-N(\d+)$/);
|
||||
if (!mm) continue;
|
||||
const n = Number(mm[2]);
|
||||
if (Number.isFinite(n) && n > bestN) {
|
||||
bestN = n;
|
||||
best = id;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// ─── L2 Independent Trigger Check ─────────────────────────────────────────────
|
||||
|
||||
export async function checkL2Trigger(
|
||||
stateManager: OffloadStateManager,
|
||||
pluginConfig: Partial<PluginConfig> | undefined,
|
||||
logger: PluginLogger,
|
||||
): Promise<{
|
||||
shouldTrigger: boolean;
|
||||
reason: string;
|
||||
entriesByMmd: Map<string, OffloadEntry[]>;
|
||||
}> {
|
||||
const nullThreshold =
|
||||
pluginConfig?.l2NullThreshold ?? PLUGIN_DEFAULTS.l2NullThreshold;
|
||||
const timeoutSeconds =
|
||||
pluginConfig?.l2TimeoutSeconds ?? PLUGIN_DEFAULTS.l2TimeoutSeconds;
|
||||
const timeNeedsNewOffload =
|
||||
(pluginConfig as any)?.l2TimeTriggerRequiresNewOffload ??
|
||||
PLUGIN_DEFAULTS.l2TimeTriggerRequiresNewOffload;
|
||||
const waitRetrySeconds =
|
||||
(pluginConfig as any)?.l2WaitRetrySeconds ??
|
||||
PLUGIN_DEFAULTS.l2WaitRetrySeconds;
|
||||
|
||||
const emptyResult = { shouldTrigger: false as const, reason: "", entriesByMmd: new Map<string, OffloadEntry[]>() };
|
||||
|
||||
const allEntries = await readAllOffloadEntries(stateManager.ctx);
|
||||
const nowMs = Date.now();
|
||||
|
||||
// Collect eligible null entries using boundary-based grouping
|
||||
const entriesByMmd = new Map<string, OffloadEntry[]>();
|
||||
let eligibleNullCount = 0;
|
||||
|
||||
for (let i = 0; i < allEntries.length; i++) {
|
||||
const entry = allEntries[i];
|
||||
if (isHeartbeatEntry(entry)) continue;
|
||||
if (entry.node_id !== null && entry.node_id !== "wait") continue;
|
||||
|
||||
// For "wait" entries, only include if they exceeded retry timeout
|
||||
if (entry.node_id === "wait") {
|
||||
const tsIso = entry.timestamp;
|
||||
if (tsIso) {
|
||||
const tsMs = new Date(tsIso).getTime();
|
||||
if (!Number.isNaN(tsMs) && (nowMs - tsMs) / 1000 < waitRetrySeconds) continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Use boundary to determine which mmd this entry belongs to
|
||||
const boundary = stateManager.resolveEntryBoundary(i);
|
||||
if (!boundary) continue; // no boundary coverage → skip
|
||||
if (boundary.result !== "long") continue; // short task → skip
|
||||
if (!boundary.targetMmd) continue; // no target mmd → skip
|
||||
|
||||
if (entry.node_id === null) eligibleNullCount++;
|
||||
|
||||
const mmd = boundary.targetMmd;
|
||||
let bucket = entriesByMmd.get(mmd);
|
||||
if (!bucket) { bucket = []; entriesByMmd.set(mmd, bucket); }
|
||||
// Dedup by tool_call_id within the same bucket
|
||||
if (entry.tool_call_id && bucket.some((e) => e.tool_call_id === entry.tool_call_id)) continue;
|
||||
bucket.push(entry);
|
||||
}
|
||||
|
||||
const totalEligible = Array.from(entriesByMmd.values()).reduce((sum, arr) => sum + arr.length, 0);
|
||||
|
||||
if (totalEligible === 0) {
|
||||
return { ...emptyResult, reason: "no eligible entries (boundary-filtered)" };
|
||||
}
|
||||
|
||||
// Condition A: null count threshold
|
||||
if (eligibleNullCount >= nullThreshold) {
|
||||
return {
|
||||
shouldTrigger: true,
|
||||
reason: `null_count=${eligibleNullCount} >= threshold=${nullThreshold} (${entriesByMmd.size} mmd(s))`,
|
||||
entriesByMmd,
|
||||
};
|
||||
}
|
||||
|
||||
// Condition B: timeout
|
||||
const lastL2Time = stateManager.getLastL2TriggerTime();
|
||||
if (lastL2Time) {
|
||||
const elapsed = (Date.now() - new Date(lastL2Time).getTime()) / 1000;
|
||||
if (elapsed >= timeoutSeconds) {
|
||||
if (timeNeedsNewOffload) {
|
||||
// Check if any null entry is newer than last L2
|
||||
const nullEntries = allEntries.filter((e) => e.node_id === null && !isHeartbeatEntry(e));
|
||||
if (!hasNullEntryAfterLastL2(nullEntries, lastL2Time) && totalEligible === eligibleNullCount) {
|
||||
return { ...emptyResult, reason: "timeout but no new offload rows" };
|
||||
}
|
||||
}
|
||||
return {
|
||||
shouldTrigger: true,
|
||||
reason: `timeout: ${elapsed.toFixed(0)}s >= ${timeoutSeconds}s (${entriesByMmd.size} mmd(s))`,
|
||||
entriesByMmd,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// No prior L2: check retry-wait entries or oldest null age
|
||||
const hasRetryWait = totalEligible > eligibleNullCount;
|
||||
if (hasRetryWait) {
|
||||
return {
|
||||
shouldTrigger: true,
|
||||
reason: `no prior L2 + retry-wait entries (${entriesByMmd.size} mmd(s))`,
|
||||
entriesByMmd,
|
||||
};
|
||||
}
|
||||
const nullEntries = allEntries.filter((e) => e.node_id === null && !isHeartbeatEntry(e));
|
||||
if (nullEntries.length > 0) {
|
||||
const oldestTs = nullEntries[0]?.timestamp;
|
||||
if (oldestTs) {
|
||||
const elapsed = (Date.now() - new Date(oldestTs).getTime()) / 1000;
|
||||
if (elapsed >= timeoutSeconds) {
|
||||
return {
|
||||
shouldTrigger: true,
|
||||
reason: `no prior L2 + oldest null entry age=${elapsed.toFixed(0)}s`,
|
||||
entriesByMmd,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...emptyResult,
|
||||
reason: `null_count=${eligibleNullCount} < ${nullThreshold}, timeout not reached`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function backfillNodeIds(
|
||||
ctx: StorageContext,
|
||||
nodeMapping: Record<string, string>,
|
||||
waitIds: Set<string>,
|
||||
logger: PluginLogger,
|
||||
options?: { mmdFallbackText?: string | null; mmdPrefix?: string },
|
||||
): Promise<void> {
|
||||
const mapping = normalizeNodeMapping(nodeMapping);
|
||||
const mmdFallbackText = options?.mmdFallbackText ?? null;
|
||||
const mmdPrefix = options?.mmdPrefix ?? "000";
|
||||
const allEntries = await readAllOffloadEntries(ctx);
|
||||
let changed = false;
|
||||
const mappedNodeIds = Object.values(mapping);
|
||||
const fallbackFromMapping = getMostFrequent(mappedNodeIds);
|
||||
const fallbackFromMmd = pickMmdDerivedFallbackNodeId(
|
||||
mmdFallbackText ?? "",
|
||||
mmdPrefix,
|
||||
);
|
||||
const effectiveFallback = fallbackFromMapping || fallbackFromMmd;
|
||||
|
||||
let mappedCount = 0;
|
||||
let fallbackCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const entry of allEntries) {
|
||||
const mapped = mapping[entry.tool_call_id];
|
||||
if (mapped) {
|
||||
entry.node_id = mapped;
|
||||
changed = true;
|
||||
mappedCount++;
|
||||
continue;
|
||||
}
|
||||
if (entry.node_id === "wait" && waitIds.has(entry.tool_call_id)) {
|
||||
if (effectiveFallback) {
|
||||
entry.node_id = effectiveFallback;
|
||||
changed = true;
|
||||
fallbackCount++;
|
||||
} else {
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
|
||||
function getMostFrequent(arr: string[]): string | null {
|
||||
if (arr.length === 0) return null;
|
||||
const freq = new Map<string, number>();
|
||||
for (const v of arr) {
|
||||
freq.set(v, (freq.get(v) ?? 0) + 1);
|
||||
}
|
||||
let maxKey = arr[0];
|
||||
let maxCount = 0;
|
||||
for (const [key, count] of freq) {
|
||||
if (count > maxCount) {
|
||||
maxCount = count;
|
||||
maxKey = key;
|
||||
}
|
||||
}
|
||||
return maxKey;
|
||||
}
|
||||
|
||||
// Local runL2Pipeline removed — all L2 processing goes through backend (index.ts → backendClient.l2Generate).
|
||||
|
||||
Reference in New Issue
Block a user