fix(openclaw): dream gate correctness — cheap-first ordering, session isolation, verified completion (#4666)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chaithanya Kumar
2026-04-02 13:29:49 +05:30
committed by GitHub
co-authored by Claude Opus 4.6
parent 30f242dc4c
commit beca7cc873
2 changed files with 99 additions and 50 deletions
+38 -28
View File
@@ -92,38 +92,43 @@ export function incrementSessionCount(stateDir: string, sessionId: string): void
// ============================================================================ // ============================================================================
/** /**
* Check all three gates. Returns true only if ALL pass. * Check cheap gates (time + sessions). These are local file reads only.
* Gates are checked cheapest-first (time, session, memory). * Call this BEFORE any API calls. If this fails, skip the expensive
* memory count check entirely.
*/ */
export async function shouldDream( export function checkCheapGates(
stateDir: string, stateDir: string,
config: { minHours?: number; minSessions?: number; minMemories?: number }, config: { minHours?: number; minSessions?: number },
memoryCount: number, ): { proceed: boolean; reason?: string } {
): Promise<{ pass: boolean; reason?: string }> { const minHours = config.minHours ?? DEFAULTS.minHours;
const cfg: DreamGateConfig = { const minSessions = config.minSessions ?? DEFAULTS.minSessions;
minHours: config.minHours ?? DEFAULTS.minHours,
minSessions: config.minSessions ?? DEFAULTS.minSessions,
minMemories: config.minMemories ?? DEFAULTS.minMemories,
};
const state = readState(stateDir); const state = readState(stateDir);
// Gate 1: Time // Gate 1: Time (one local file read)
const hoursSince = (Date.now() - state.lastConsolidatedAt) / 3_600_000; const hoursSince = (Date.now() - state.lastConsolidatedAt) / 3_600_000;
if (hoursSince < cfg.minHours) { if (hoursSince < minHours) {
return { pass: false, reason: `time: ${hoursSince.toFixed(1)}h < ${cfg.minHours}h` }; return { proceed: false, reason: `time: ${hoursSince.toFixed(1)}h < ${minHours}h` };
} }
// Gate 2: Sessions // Gate 2: Sessions (same file, already read)
if (state.sessionsSince < cfg.minSessions) { if (state.sessionsSince < minSessions) {
return { pass: false, reason: `sessions: ${state.sessionsSince} < ${cfg.minSessions}` }; return { proceed: false, reason: `sessions: ${state.sessionsSince} < ${minSessions}` };
} }
// Gate 3: Memory count return { proceed: true };
if (memoryCount < cfg.minMemories) { }
return { pass: false, reason: `memories: ${memoryCount} < ${cfg.minMemories}` };
}
/**
* Check expensive memory count gate. Only call AFTER checkCheapGates passes.
*/
export function checkMemoryGate(
memoryCount: number,
config: { minMemories?: number },
): { pass: boolean; reason?: string } {
const minMemories = config.minMemories ?? DEFAULTS.minMemories;
if (memoryCount < minMemories) {
return { pass: false, reason: `memories: ${memoryCount} < ${minMemories}` };
}
return { pass: true }; return { pass: true };
} }
@@ -145,18 +150,23 @@ export function acquireDreamLock(stateDir: string): boolean {
const lock = JSON.parse(raw) as DreamLock; const lock = JSON.parse(raw) as DreamLock;
const age = Date.now() - lock.startedAt; const age = Date.now() - lock.startedAt;
if (age < LOCK_STALE_MS) { if (age < LOCK_STALE_MS) {
// Lock is held and not stale return false; // Held and not stale
return false;
} }
// Stale lock, reclaim // Stale lock — remove it before attempting exclusive create
try { fs.unlinkSync(lp); } catch { /* race ok */ }
} catch { } catch {
// No lock file, proceed // No lock file, proceed
} }
// Write lock // Atomic create with exclusive flag (wx). If two processes race,
// only one succeeds. The other gets EEXIST.
const lock: DreamLock = { pid: process.pid, startedAt: Date.now() }; const lock: DreamLock = { pid: process.pid, startedAt: Date.now() };
fs.writeFileSync(lp, JSON.stringify(lock)); try {
return true; fs.writeFileSync(lp, JSON.stringify(lock), { flag: "wx" });
return true;
} catch {
return false; // Lost race
}
} }
/** /**
+61 -22
View File
@@ -48,7 +48,8 @@ import {
import { recall as skillRecall, sanitizeQuery } from "./recall.ts"; import { recall as skillRecall, sanitizeQuery } from "./recall.ts";
import { import {
incrementSessionCount, incrementSessionCount,
shouldDream, checkCheapGates,
checkMemoryGate,
acquireDreamLock, acquireDreamLock,
releaseDreamLock, releaseDreamLock,
recordDreamCompletion, recordDreamCompletion,
@@ -1306,23 +1307,29 @@ function registerHooks(
api.logger.info("openclaw-mem0: skills-mode recall strategy=manual, agent controls search"); api.logger.info("openclaw-mem0: skills-mode recall strategy=manual, agent controls search");
} }
// Auto-dream: check gates and inject consolidation protocol if due // Auto-dream: check CHEAP gates first (local file reads only).
// Only hit the API for memory count if time + session gates pass.
const stateDir = session.getStateDir(); const stateDir = session.getStateDir();
const dreamEnabled = cfg.skills?.dream?.enabled !== false && cfg.skills?.dream?.auto !== false; const dreamEnabled = cfg.skills?.dream?.enabled !== false && cfg.skills?.dream?.auto !== false;
let dreamContext = ""; let dreamContext = "";
if (dreamEnabled && stateDir && !isSubagent) { if (dreamEnabled && stateDir && !isSubagent) {
try { try {
const memories = await provider.getAll({ user_id: userId, source: "OPENCLAW" }); const cheapResult = checkCheapGates(stateDir, cfg.skills?.dream ?? {});
const memCount = Array.isArray(memories) ? memories.length : 0; if (cheapResult.proceed) {
const gateResult = await shouldDream(stateDir, cfg.skills?.dream ?? {}, memCount); // Cheap gates passed. Now do the expensive memory count check.
if (gateResult.pass && acquireDreamLock(stateDir)) { const memories = await provider.getAll({ user_id: userId, source: "OPENCLAW" });
const dreamPrompt = loadDreamPrompt(cfg.skills ?? {}); const memCount = Array.isArray(memories) ? memories.length : 0;
if (dreamPrompt) { const memResult = checkMemoryGate(memCount, cfg.skills?.dream ?? {});
dreamContext = "\n\n<auto-dream>\nIMPORTANT: Before responding to the user, run a memory consolidation pass. Follow the protocol below, then respond normally.\n\n" + dreamPrompt + "\n</auto-dream>"; if (memResult.pass && acquireDreamLock(stateDir)) {
dreamJustRan = true; const dreamPrompt = loadDreamPrompt(cfg.skills ?? {});
api.logger.info(`openclaw-mem0: auto-dream triggered (${memCount} memories, gate passed)`); if (dreamPrompt) {
} else { dreamContext = "\n\n<auto-dream>\nIMPORTANT: Before responding to the user, run a memory consolidation pass. Follow the protocol below, then respond normally.\n\n" + dreamPrompt + "\n</auto-dream>";
releaseDreamLock(stateDir); // Track which session triggered dream (session-keyed, not global)
dreamSessionId = sessionId;
api.logger.info(`openclaw-mem0: auto-dream triggered (${memCount} memories, gate passed)`);
} else {
releaseDreamLock(stateDir);
}
} }
} }
} catch (err) { } catch (err) {
@@ -1336,25 +1343,57 @@ function registerHooks(
}; };
}); });
// Skills mode: NO auto-capture. Track sessions for dream gating. // Session-keyed dream tracking. Only the session that triggered dream
let dreamJustRan = false; // can complete it. Prevents cross-session false completion.
let dreamSessionId: string | undefined;
api.on("agent_end", async (event: any, ctx: any) => { api.on("agent_end", async (event: any, ctx: any) => {
if (!event.success) return;
const sessionId = ctx?.sessionKey ?? undefined; const sessionId = ctx?.sessionKey ?? undefined;
const trigger = ctx?.trigger ?? undefined; const trigger = ctx?.trigger ?? undefined;
if (sessionId) session.setCurrentSessionId(sessionId); if (sessionId) session.setCurrentSessionId(sessionId);
// If dream just ran, release lock and record completion // If dream was triggered for THIS session, handle cleanup regardless
// of success/failure. A failed turn must still release the lock.
const stateDir = session.getStateDir(); const stateDir = session.getStateDir();
if (dreamJustRan && stateDir) { if (dreamSessionId && dreamSessionId === sessionId && stateDir) {
releaseDreamLock(stateDir); dreamSessionId = undefined;
recordDreamCompletion(stateDir);
api.logger.info("openclaw-mem0: auto-dream completed, lock released"); if (!event.success) {
dreamJustRan = false; // Turn failed/aborted after lock acquired. Release lock, do not
// record completion. Gates will re-trigger next eligible turn.
releaseDreamLock(stateDir);
api.logger.warn("openclaw-mem0: auto-dream turn failed, lock released, will retry");
return;
}
// Verify the model actually performed WRITE operations (not just reads).
// Only count memory_store, memory_update, memory_forget, memory_delete_all.
// Exclude memory_list and memory_search (read-only, orient-only pass).
// Scan only the LAST assistant message (this turn), not the full session
// snapshot, to avoid matching earlier tool calls from prior turns.
const WRITE_TOOLS = new Set(["memory_store", "memory_update", "memory_forget", "memory_delete_all"]);
const messages = event.messages ?? [];
// Find the last assistant message (this turn's output)
const lastAssistant = [...messages].reverse().find((m: any) => m.role === "assistant");
const writeToolUsed = lastAssistant && Array.isArray(lastAssistant.content)
? lastAssistant.content.some((block: any) =>
block.type === "tool_use" && WRITE_TOOLS.has(block.name)
)
: false;
if (writeToolUsed) {
releaseDreamLock(stateDir);
recordDreamCompletion(stateDir);
api.logger.info("openclaw-mem0: auto-dream completed (verified write tool usage), lock released");
} else {
releaseDreamLock(stateDir);
api.logger.warn("openclaw-mem0: auto-dream injected but no write tools executed. Lock released, will retry.");
}
return; return;
} }
if (!event.success) return;
// Track session for dream gating (interactive turns only) // Track session for dream gating (interactive turns only)
if (stateDir && sessionId && !isNonInteractiveTrigger(trigger, sessionId)) { if (stateDir && sessionId && !isNonInteractiveTrigger(trigger, sessionId)) {
incrementSessionCount(stateDir, sessionId); incrementSessionCount(stateDir, sessionId);