fix: exclude incognito turns from automatic memory capture (#155594)

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Vincent Koc
2026-09-23 17:53:28 +08:00
committed by GitHub
parent e366076760
commit f34c36899c
12 changed files with 148 additions and 10 deletions
+2 -1
View File
@@ -133,7 +133,8 @@ command source or automatic reset reason.
The hook captures the departing conversation before a reset closes its active
window, then writes the snapshot in the background. Capture is bounded to
4,096 scanned messages and 8 MiB.
4,096 scanned messages and 8 MiB. Incognito sessions do not create memory
artifacts, including on manual or automatic reset.
Manual resets do not await the file write or optional slug-model call; automatic
reset dispatch also runs independently of the successor turn. Wait for
`Session context saved to ...` in logs before expecting the file.
+4
View File
@@ -253,6 +253,10 @@ entry, or one inheriting a disabled top-level search, also gets none of the `mem
or `memory_forget` tools and does not participate in automatic recall or
capture, even when the plugin-level `autoRecall`/`autoCapture` flags are on.
Incognito sessions skip automatic recall and capture. Their prompts are not
sent to the embedding provider for automatic recall, and `memory_store` refuses
to save them. Explicit tool calls still follow their normal data-handling rules.
## Commands
`memory-lancedb` registers the `ltm` CLI namespace whenever it is installed
+2 -2
View File
@@ -56,8 +56,8 @@ Experience review starts only when all of these conditions hold:
- the foreground turn completed or was interrupted, but did not end in a
provider or prompt error.
- the current turn used at least 10 model iterations.
- the run was an eligible foreground conversation, not cron, heartbeat, memory,
overflow, hook, subagent, or review work.
- the run was an eligible foreground conversation, not Incognito, cron,
heartbeat, memory, overflow, hook, subagent, or review work.
- the runtime reported the resolved provider, model, and actual availability of
`skill_workshop`.
- the system has been quiet for 30 seconds.
@@ -37,6 +37,7 @@ In `propose` and `auto` modes, OpenClaw can review one finished substantial turn
after the agent system becomes idle. It records the finished turn's boundary and
reads that turn's model context asynchronously with the same provider and model.
Review transcript and session metadata stay detached from foreground work.
Incognito turns are excluded from automatic experience review.
In `propose` mode, only `skill_workshop` executes and the reviewer can stage one
pending mutation. In `auto` mode, ordinary file tools can inspect, edit, and
verify several connected files in the Workshop directory. The review inherits
@@ -0,0 +1,44 @@
import { describe, expect, test, vi } from "vitest";
import { createAutoRecallHook } from "./auto-recall.js";
import { MemoryDB } from "./lancedb-store.js";
describe("automatic memory recall privacy", () => {
test.each([
{ sessionKey: "agent:main:dashboard:incognito-recall", expectedCalls: 0 },
{ sessionKey: "agent:main:dashboard:ordinary-recall", expectedCalls: 1 },
])("limits embedding and search for $sessionKey", async ({ sessionKey, expectedCalls }) => {
const db = new MemoryDB("unused-auto-recall-test-db", 3);
const search = vi.spyOn(db, "search").mockResolvedValue([]);
const embed = vi.fn(async () => [0.1, 0.2, 0.3]);
const hook = createAutoRecallHook({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
db,
embeddings: { embed },
resolveCurrentConfig: () => ({
embedding: { provider: "openai", model: "synthetic-test-embedding" },
autoRecall: true,
captureMaxChars: 500,
recallMaxChars: 1000,
}),
resolveEnabledAgentId: (agentId) => agentId,
readCooldown: () => undefined,
recordCooldown: vi.fn(),
});
await expect(
hook(
{ prompt: "SYNTHETIC_INCOGNITO_EMBEDDING_SENTINEL", messages: [] },
{
agentId: "main",
sessionKey,
toolAuthority: {
allows: (toolName) => toolName === "memory_recall",
assertActive: () => undefined,
},
},
),
).resolves.toBeUndefined();
expect(embed).toHaveBeenCalledTimes(expectedCalls);
expect(search).toHaveBeenCalledTimes(expectedCalls);
});
});
+5
View File
@@ -1,4 +1,5 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { isIncognitoSessionKey } from "openclaw/plugin-sdk/routing";
import type { OpenClawPluginApi } from "./api.js";
import type { MemoryConfig } from "./config.js";
import {
@@ -26,6 +27,7 @@ type AutoRecallToolAuthority = {
type AutoRecallHookContext = {
agentId?: string;
sessionKey?: string;
toolAuthority?: AutoRecallToolAuthority;
};
@@ -44,6 +46,9 @@ export function createAutoRecallHook(params: {
recordCooldown: (agentId: string, error: string) => void;
}) {
return async (event: AutoRecallHookEvent, ctx: AutoRecallHookContext) => {
if (isIncognitoSessionKey(ctx.sessionKey)) {
return undefined;
}
const currentCfg = params.resolveCurrentConfig();
const recallMaxChars = currentCfg.recallMaxChars;
if (!currentCfg.autoRecall) {
+2
View File
@@ -18,6 +18,8 @@ metadata:
Automatically saves session context to workspace memory on `/new`, `/reset`, daily reset, or idle expiry.
Incognito sessions are excluded and never create these memory files.
## What It Does
When a manual or automatic reset starts a fresh session:
@@ -17,7 +17,12 @@ import {
import { parseAgentSessionKey } from "../../../routing/session-key.js";
import { writeWorkspaceFile } from "../../../test-helpers/workspace.js";
import { withEnvAsync } from "../../../test-utils/env.js";
import { createInternalHookEvent as createHookEvent } from "../../internal-hooks.js";
import {
createInternalHookEvent as createHookEvent,
registerInternalHook,
triggerInternalHook,
unregisterInternalHook,
} from "../../internal-hooks.js";
import { generateSlugViaLLM } from "../../llm-slug-generator.js";
// Avoid calling the embedded OpenClaw agent (global command lane); keep this unit test deterministic.
@@ -241,6 +246,35 @@ async function expectPathMissing(targetPath: string): Promise<void> {
}
describe("session-memory hook", () => {
it.each([
{ type: "command", action: "new", sessionKey: "agent:main:dashboard:incognito-new" },
{ type: "command", action: "reset", sessionKey: "agent:main:dashboard:incognito-reset" },
{ type: "session", action: "auto-reset", sessionKey: "agent:main:dashboard:incognito-idle" },
{ type: "command", action: "reset", sessionKey: "agent:main:private", incognito: true },
] as const)("does not capture Incognito $type:$action memory ($sessionKey)", async (testCase) => {
const workspaceDir = await createCaseWorkspace("incognito");
const event = createHookEvent(testCase.type, testCase.action, testCase.sessionKey, {
agentId: "main",
workspaceDir,
sessionEntry: { sessionId: "private-session", incognito: "incognito" in testCase },
previousSessionMemory: {
status: "available",
content: "SYNTHETIC_INCOGNITO_MEMORY_SENTINEL",
originClass: "agent",
},
reason: "idle",
});
const eventKey = `${testCase.type}:${testCase.action}`;
registerInternalHook(eventKey, handler);
try {
await triggerInternalHook(event);
await flushSessionMemoryWritesForTest();
await expectPathMissing(path.join(workspaceDir, "memory"));
} finally {
unregisterInternalHook(eventKey, handler);
}
});
it("skips non-command events", async () => {
const tempDir = await createCaseWorkspace("workspace");
+12 -4
View File
@@ -22,7 +22,11 @@ import { isVitestRuntimeEnv } from "../../../infra/env.js";
import { root } from "../../../infra/fs-safe.js";
import { createSubsystemLogger } from "../../../logging/subsystem.js";
import { runWithGatewayIndependentRootWorkContinuation } from "../../../process/gateway-work-admission.js";
import { parseAgentSessionKey, toAgentStoreSessionKey } from "../../../routing/session-key.js";
import {
isIncognitoSessionKey,
parseAgentSessionKey,
toAgentStoreSessionKey,
} from "../../../routing/session-key.js";
import { shortenHomePath } from "../../../utils.js";
import { resolveHookConfig } from "../../config.js";
import type { HookHandler } from "../../hooks.js";
@@ -299,14 +303,18 @@ const saveSessionToMemory: HookHandler = (event) => {
if ((event.type !== "command" || !isResetCommand) && !isAutoReset) {
return undefined;
}
const agentId = requireSessionMemoryAgentId(event);
const context = event.context;
const sessionEntry = (
event.type === "command"
? (context.previousSessionEntry ?? context.sessionEntry)
: context.sessionEntry
) as { sessionId?: string } | undefined;
) as { sessionId?: string; incognito?: boolean } | undefined;
// Reset hooks run before the process-local session is retired. Never turn its
// live transcript or a previously captured excerpt into durable workspace memory.
if (isIncognitoSessionKey(event.sessionKey) || sessionEntry?.incognito === true) {
return undefined;
}
const agentId = requireSessionMemoryAgentId(event);
const cfg = context.cfg as OpenClawConfig | undefined;
// Gateway and soft-reset hooks already run before mutation; chat resets carry
// the snapshot captured by session initialization before closing the window.
@@ -5,6 +5,7 @@ import type { TranscriptEntryAnchor } from "../../config/sessions/transcript-ent
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { runOutsidePluginRuntimeGenerationScope } from "../../plugins/runtime/generation-scope.js";
import { isIncognitoSessionKey } from "../../routing/session-key.js";
import type { RunSkillUsage } from "../runtime/run-usage.js";
import { resolveSkillWorkshopConfig } from "./config.js";
import {
@@ -159,7 +160,11 @@ export function createSkillExperienceReviewScheduler(deps: ExperienceReviewSched
return {
schedule(params: SkillExperienceReviewParams): void {
const sessionKey = params.ctx.sessionKey?.trim();
if (!sessionKey) {
if (
!sessionKey ||
isIncognitoSessionKey(sessionKey) ||
isIncognitoSessionKey(params.source?.sessionKey)
) {
return;
}
// Unqualified keys such as global still belong to one foreground agent.
@@ -121,6 +121,29 @@ afterEach(() => {
});
describe("skill experience review scheduler", () => {
it.each(["context", "source"] as const)(
"does not retain or schedule Incognito %s evidence",
async (identity) => {
vi.useFakeTimers();
const runReview = vi.fn(async () => {});
const scheduler = createSkillExperienceReviewScheduler({
isSystemActive: () => false,
runReview,
});
const params = completedRun();
const sessionKey = "agent:main:dashboard:incognito-workshop";
if (identity === "context") {
params.ctx = { ...params.ctx, sessionKey };
} else {
params.source = { ...params.source!, sessionKey };
}
scheduler.schedule(params);
expect(vi.getTimerCount()).toBe(0);
await vi.runAllTimersAsync();
expect(runReview).not.toHaveBeenCalled();
},
);
it("runs detached review work outside the foreground prepared generation", async () => {
const generation: PreparedModelRuntimePluginGeneration = {
configuredCatalogEntries: [],
@@ -655,6 +678,13 @@ describe("skill experience review prompt", () => {
});
describe("skill experience review preparation", () => {
it("rejects Incognito evidence before preparing a queued review", async () => {
const params = completedRun({ sessionKey: "agent:main:dashboard:incognito-workshop" });
await expect(
prepareSkillExperienceReviewCandidate(captureCandidate(params), params.config),
).resolves.toBeUndefined();
});
it.each([
{ agentId: "direct", eligible: true },
{ agentId: "isolated", eligible: false },
+5 -1
View File
@@ -22,6 +22,7 @@ import {
getGatewayRestartDrainSignal,
runWithGatewayDetachedWorkAdmission,
} from "../../process/gateway-work-admission.js";
import { isIncognitoSessionKey } from "../../routing/session-key.js";
import { bumpSkillsSnapshotVersion } from "../runtime/refresh-state.js";
import { recordSkillExperienceReviewOutcome } from "./collection-review-state.js";
import { resolveSkillWorkshopConfig } from "./config.js";
@@ -37,7 +38,10 @@ export async function prepareSkillExperienceReviewCandidate(
candidate: ExperienceReviewCandidate,
config: OpenClawConfig,
): Promise<ExperienceReviewCandidate | undefined> {
if (resolveSkillWorkshopConfig(config).autonomous.mode === "off") {
if (
isIncognitoSessionKey(candidate.source.sessionKey) ||
resolveSkillWorkshopConfig(config).autonomous.mode === "off"
) {
return undefined;
}
const { resolveConversationCapabilityProfile } =