fix(openclaw): dream gate correctness — cheap-first, session-keyed, verified
Three fixes to the auto-dream gate mechanism: 1. Cheap gates first: checkCheapGates() (local file reads for time + session count) runs BEFORE any API call. provider.getAll() only executes if both cheap gates pass. Previously every turn downloaded the entire memory set just to compute memCount. 2. Session-keyed dream tracking: dreamSessionId replaces the process-global dreamJustRan boolean. Only the session that triggered dream can complete it. Prevents cross-session false completion. 3. Verified completion: agent_end checks event.messages for actual memory tool calls (memory_store, memory_update, memory_forget, memory_list). If the model ignored the dream prompt and just answered the user, the lock releases but completion is NOT recorded. Gates will re-trigger on the next eligible turn. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
30f242dc4c
commit
67afcd90d9
+27
-22
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+48
-21
@@ -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,8 +1343,9 @@ 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;
|
if (!event.success) return;
|
||||||
@@ -1345,13 +1353,32 @@ function registerHooks(
|
|||||||
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, check if it actually ran
|
||||||
const stateDir = session.getStateDir();
|
const stateDir = session.getStateDir();
|
||||||
if (dreamJustRan && stateDir) {
|
if (dreamSessionId && dreamSessionId === sessionId && stateDir) {
|
||||||
releaseDreamLock(stateDir);
|
// Verify the model actually called memory tools (not false completion).
|
||||||
recordDreamCompletion(stateDir);
|
// Check event.messages for memory_store, memory_update, memory_forget tool calls.
|
||||||
api.logger.info("openclaw-mem0: auto-dream completed, lock released");
|
const messages = event.messages ?? [];
|
||||||
dreamJustRan = false;
|
const dreamToolsUsed = messages.some((m: any) => {
|
||||||
|
if (m.role !== "assistant") return false;
|
||||||
|
const content = Array.isArray(m.content) ? m.content : [];
|
||||||
|
return content.some((block: any) =>
|
||||||
|
block.type === "tool_use" &&
|
||||||
|
["memory_store", "memory_update", "memory_forget", "memory_delete_all", "memory_list"].includes(block.name)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dreamToolsUsed) {
|
||||||
|
releaseDreamLock(stateDir);
|
||||||
|
recordDreamCompletion(stateDir);
|
||||||
|
api.logger.info("openclaw-mem0: auto-dream completed (verified tool usage), lock released");
|
||||||
|
} else {
|
||||||
|
// Model did not run dream tools. Release lock but do NOT record completion.
|
||||||
|
// Gates will re-trigger on the next eligible turn.
|
||||||
|
releaseDreamLock(stateDir);
|
||||||
|
api.logger.warn("openclaw-mem0: auto-dream injected but model did not execute consolidation. Lock released, will retry.");
|
||||||
|
}
|
||||||
|
dreamSessionId = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user