mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-28 05:54:09 +08:00
fix: exclude incognito turns from automatic memory capture (#155594)
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
Reference in New Issue
Block a user