mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-11 12:54:29 +00:00
287 lines
9.3 KiB
TypeScript
287 lines
9.3 KiB
TypeScript
/**
|
|
* 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.debug?.(`[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).
|
|
|