mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(memory): recall prior conversation after session reset (#122051)
* fix(memory): recall archived session generations after reset * test(memory): prove private recall across reset * fix(memory): keep deleted transcripts outside reset recall * fix(memory): reject deleted archives from reset recall * fix(memory): isolate recall across sqlite resets * test(memory): split reset recall coverage * fix(memory): keep reset recall metadata private * fix(memory): keep reset authority scoped --------- Co-authored-by: TheAngryPit <16145902+TheAngryPit@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
11fe5abaa9
commit
a9ee6618fe
@@ -17,6 +17,8 @@ import {
|
||||
hashText,
|
||||
INVALID_PROJECT_ANNOTATION_KEY,
|
||||
MEMORY_EMBEDDING_CACHE_TABLE,
|
||||
MEMORY_INDEX_CHUNK_PROVENANCE_TABLE,
|
||||
MEMORY_INDEX_CHUNK_RECALL_METADATA_TABLE,
|
||||
MEMORY_INDEX_FTS_TABLE,
|
||||
MEMORY_INDEX_VECTOR_TABLE,
|
||||
remapChunkLines,
|
||||
@@ -24,14 +26,13 @@ import {
|
||||
runWithConcurrency,
|
||||
stripMemoryAnnotationCarriers,
|
||||
type MemoryChunk,
|
||||
type MemorySource,
|
||||
type MemoryEntryProvenance,
|
||||
MEMORY_INDEX_CHUNK_PROVENANCE_TABLE,
|
||||
MEMORY_INDEX_CHUNK_RECALL_METADATA_TABLE,
|
||||
type MemorySource,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
import { MAX_TIMER_TIMEOUT_MS, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { runSqliteImmediateTransactionSync } from "openclaw/plugin-sdk/sqlite-runtime";
|
||||
import { readSessionResetRecallCutoffMetadata } from "../session-reset-recall-metadata.js";
|
||||
import type { EmbeddingProvider } from "./embeddings.js";
|
||||
import {
|
||||
MEMORY_BATCH_FAILURE_LIMIT,
|
||||
@@ -59,6 +60,7 @@ import {
|
||||
resolveMemoryIndexProviderIdentities,
|
||||
type MemoryIndexProviderIdentity,
|
||||
} from "./manager-reindex-state.js";
|
||||
import { chunkSessionContentAtResetBoundary } from "./manager-reset-chunk-boundary.js";
|
||||
import {
|
||||
MemoryManagerSyncOps,
|
||||
type MemoryIndexWorkItem,
|
||||
@@ -1121,11 +1123,19 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps {
|
||||
(normalizedEntryPath === "MEMORY.md" || normalizedEntryPath === "USER.md");
|
||||
const indexingContent =
|
||||
options.source === "memory" ? stripMemoryAnnotationCarriers(content) : content;
|
||||
const chunkOptions = { ...this.settings.chunking, perEntry };
|
||||
const baseChunks = filterNonEmptyMemoryChunks(
|
||||
chunkMarkdown(indexingContent, {
|
||||
...this.settings.chunking,
|
||||
perEntry,
|
||||
}),
|
||||
options.source === "sessions"
|
||||
? chunkSessionContentAtResetBoundary({
|
||||
content: indexingContent,
|
||||
cutoffLine: (() => {
|
||||
const cutoff = readSessionResetRecallCutoffMetadata(entry);
|
||||
return cutoff.state === "valid" ? cutoff.cutoffLine : undefined;
|
||||
})(),
|
||||
lineMap: entry.lineMap,
|
||||
chunking: chunkOptions,
|
||||
})
|
||||
: chunkMarkdown(indexingContent, chunkOptions),
|
||||
);
|
||||
for (const chunk of baseChunks) {
|
||||
chunk.provenance = this.resolveChunkProvenance(
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { chunkSessionContentAtResetBoundary } from "./manager-reset-chunk-boundary.js";
|
||||
|
||||
describe("chunkSessionContentAtResetBoundary", () => {
|
||||
it("never overlaps a pre-reset chunk into the current generation", () => {
|
||||
const chunks = chunkSessionContentAtResetBoundary({
|
||||
content: "old one\nold two\nkept live\ncurrent turn",
|
||||
cutoffLine: 7,
|
||||
lineMap: [2, 4, 7, 9],
|
||||
chunking: { tokens: 100, overlap: 50 },
|
||||
});
|
||||
|
||||
expect(chunks.map((chunk) => [chunk.startLine, chunk.endLine, chunk.text])).toEqual([
|
||||
[1, 2, "old one\nold two"],
|
||||
[3, 4, "kept live\ncurrent turn"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
chunkMarkdown,
|
||||
type MemoryChunk,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
|
||||
export function chunkSessionContentAtResetBoundary(params: {
|
||||
content: string;
|
||||
cutoffLine?: number;
|
||||
lineMap?: readonly number[];
|
||||
chunking: { tokens: number; overlap: number; perEntry?: boolean };
|
||||
}): MemoryChunk[] {
|
||||
const cutoffIndex =
|
||||
params.cutoffLine !== undefined && params.lineMap
|
||||
? params.lineMap.findIndex((line) => line >= params.cutoffLine!)
|
||||
: -1;
|
||||
if (cutoffIndex <= 0) {
|
||||
return chunkMarkdown(params.content, params.chunking);
|
||||
}
|
||||
const lines = params.content.split("\n");
|
||||
const chunkPartition = (content: string, lineOffset: number) => {
|
||||
const chunks = chunkMarkdown(content, params.chunking);
|
||||
for (const chunk of chunks) {
|
||||
chunk.startLine += lineOffset;
|
||||
chunk.endLine += lineOffset;
|
||||
}
|
||||
return chunks;
|
||||
};
|
||||
return [
|
||||
...chunkPartition(lines.slice(0, cutoffIndex).join("\n"), 0),
|
||||
...chunkPartition(lines.slice(cutoffIndex).join("\n"), cutoffIndex),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readSessionArchiveReasonFromHitPath } from "./session-reset-recall-metadata.js";
|
||||
|
||||
describe("readSessionArchiveReasonFromHitPath", () => {
|
||||
it.each([
|
||||
["sessions/a.jsonl.reset.2026-08-11T08-00-00Z", "reset"],
|
||||
["sessions/a.jsonl.reset.2026-08-11T08-00-00.000Z.zst", "reset"],
|
||||
["sessions/a.jsonl.deleted.2026-08-11T08-00-00Z", "deleted"],
|
||||
["sessions\\a.jsonl.deleted.2026-08-11T08-00-00.000Z.zst", "deleted"],
|
||||
["sessions/a.jsonl.reset.2026-08-11T08:00:00Z", undefined],
|
||||
["sessions/a.jsonl.reset.2026-08-11T08-00-00Z.gz", undefined],
|
||||
["sessions/a.jsonl.deleted.2026-08-11T08-00-00Z.extra", undefined],
|
||||
["sessions/a.jsonl.RESET.2026-08-11T08-00-00Z", undefined],
|
||||
])("classifies %s", (path, expected) => {
|
||||
expect(readSessionArchiveReasonFromHitPath(path)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
export type SessionResetRecallCutoff =
|
||||
| { state: "absent" }
|
||||
| { state: "invalid" }
|
||||
| { cutoffLine: number; state: "valid" };
|
||||
|
||||
const RESET_RECALL_CUTOFF = Symbol.for("openclaw.memory.sessionResetRecallCutoff");
|
||||
|
||||
export function readSessionResetRecallCutoffMetadata(value: unknown): SessionResetRecallCutoff {
|
||||
if (!value || typeof value !== "object") {
|
||||
return { state: "invalid" };
|
||||
}
|
||||
const cutoff = (value as Record<PropertyKey, unknown>)[RESET_RECALL_CUTOFF];
|
||||
if (!cutoff || typeof cutoff !== "object") {
|
||||
return { state: "invalid" };
|
||||
}
|
||||
const state = (cutoff as { state?: unknown }).state;
|
||||
if (state === "absent" || state === "invalid") {
|
||||
return { state };
|
||||
}
|
||||
const cutoffLine = (cutoff as { cutoffLine?: unknown }).cutoffLine;
|
||||
return state === "valid" && typeof cutoffLine === "number" && Number.isInteger(cutoffLine)
|
||||
? { state, cutoffLine }
|
||||
: { state: "invalid" };
|
||||
}
|
||||
|
||||
export function readSessionArchiveReasonFromHitPath(
|
||||
hitPath: string,
|
||||
): "reset" | "deleted" | undefined {
|
||||
const match =
|
||||
/\.jsonl\.(reset|deleted)\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(?:\.\d{3})?Z(?:\.zst)?$/.exec(
|
||||
hitPath,
|
||||
);
|
||||
const reason = match?.[1];
|
||||
return reason === "reset" || reason === "deleted" ? reason : undefined;
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import * as engineSessions from "openclaw/plugin-sdk/memory-core-host-engine-sessions";
|
||||
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
|
||||
import * as sessionTranscriptHit from "openclaw/plugin-sdk/session-transcript-hit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { filterMemorySearchHitsBySessionVisibility } from "./session-search-visibility.js";
|
||||
import { asOpenClawConfig } from "./tools.test-helpers.js";
|
||||
|
||||
type TestSessionEntry = {
|
||||
sessionId: string;
|
||||
updatedAt: number;
|
||||
sessionFile: string;
|
||||
chatType?: "direct" | "group" | "channel";
|
||||
};
|
||||
|
||||
let combinedSessionStore: Record<string, TestSessionEntry> = {};
|
||||
|
||||
function entryWithCutoff(cutoff: unknown) {
|
||||
const entry = {};
|
||||
Object.defineProperty(entry, Symbol.for("openclaw.memory.sessionResetRecallCutoff"), {
|
||||
enumerable: false,
|
||||
value: cutoff,
|
||||
});
|
||||
return entry;
|
||||
}
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/memory-core-host-engine-sessions", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("openclaw/plugin-sdk/memory-core-host-engine-sessions")>();
|
||||
return {
|
||||
...actual,
|
||||
buildSessionEntry: vi.fn(async () => entryWithCutoff({ state: "absent" })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/session-transcript-hit", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("openclaw/plugin-sdk/session-transcript-hit")>();
|
||||
return {
|
||||
...actual,
|
||||
loadCombinedSessionStoreForGateway: vi.fn(() => ({
|
||||
storePath: "(test)",
|
||||
store: combinedSessionStore,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe("reset-generation session search visibility", () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(sessionTranscriptHit.loadCombinedSessionStoreForGateway).mockClear();
|
||||
vi.mocked(engineSessions.buildSessionEntry).mockReset();
|
||||
vi.mocked(engineSessions.buildSessionEntry).mockResolvedValue(
|
||||
entryWithCutoff({ state: "absent" }) as never,
|
||||
);
|
||||
combinedSessionStore = {};
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "pre-reset", range: [2, 3], cutoff: { state: "valid", cutoffLine: 4 }, kept: true },
|
||||
{ name: "crossing", range: [3, 4], cutoff: { state: "valid", cutoffLine: 4 }, kept: false },
|
||||
{ name: "current", range: [4, 5], cutoff: { state: "valid", cutoffLine: 4 }, kept: false },
|
||||
{ name: "missing", range: [1, 2], cutoff: { state: "absent" }, kept: false },
|
||||
{ name: "missing-contract", range: [1, 2], cutoff: undefined, kept: false },
|
||||
{ name: "malformed", range: [1, 2], cutoff: { state: "invalid" }, kept: false },
|
||||
] as const)(
|
||||
"handles a $name live SQLite reset-generation hit",
|
||||
async ({ range, cutoff, kept }) => {
|
||||
const anchorSessionKey = "agent:main:telegram:direct:owner";
|
||||
combinedSessionStore = {
|
||||
[anchorSessionKey]: {
|
||||
sessionId: "current",
|
||||
updatedAt: 2,
|
||||
sessionFile: "/tmp/sessions/current.jsonl",
|
||||
chatType: "direct",
|
||||
},
|
||||
};
|
||||
vi.mocked(engineSessions.buildSessionEntry).mockResolvedValue(
|
||||
(cutoff === undefined ? {} : entryWithCutoff(cutoff)) as never,
|
||||
);
|
||||
const hit: MemorySearchResult = {
|
||||
path: "sessions/main/current.jsonl",
|
||||
source: "sessions",
|
||||
score: 1,
|
||||
snippet: "short fact",
|
||||
startLine: range[0],
|
||||
endLine: range[1],
|
||||
};
|
||||
|
||||
const filtered = await filterMemorySearchHitsBySessionVisibility({
|
||||
cfg: asOpenClawConfig({ tools: { sessions: { visibility: "self" } } }),
|
||||
agentId: "main",
|
||||
requesterSessionKey: `${anchorSessionKey}:active-memory:123456abcdef`,
|
||||
sandboxed: false,
|
||||
hits: [hit],
|
||||
conversationRecall: { anchorSessionKey, scope: "same-agent-private", corpus: "sessions" },
|
||||
});
|
||||
|
||||
expect(filtered).toEqual(kept ? [hit] : []);
|
||||
},
|
||||
);
|
||||
|
||||
it("resolves the live anchor reset cutoff once per filter pass", async () => {
|
||||
const anchorSessionKey = "agent:main:telegram:direct:owner";
|
||||
combinedSessionStore = {
|
||||
[anchorSessionKey]: {
|
||||
sessionId: "current",
|
||||
updatedAt: 2,
|
||||
sessionFile: "/tmp/sessions/current.jsonl",
|
||||
chatType: "direct",
|
||||
},
|
||||
};
|
||||
vi.mocked(engineSessions.buildSessionEntry).mockResolvedValue(
|
||||
entryWithCutoff({ state: "valid", cutoffLine: 5 }) as never,
|
||||
);
|
||||
const hits: MemorySearchResult[] = [
|
||||
{
|
||||
path: "sessions/main/current.jsonl",
|
||||
source: "sessions",
|
||||
score: 1,
|
||||
snippet: "first pre-reset chunk",
|
||||
startLine: 1,
|
||||
endLine: 2,
|
||||
},
|
||||
{
|
||||
path: "sessions/main/current.jsonl",
|
||||
source: "sessions",
|
||||
score: 0.9,
|
||||
snippet: "second pre-reset chunk",
|
||||
startLine: 3,
|
||||
endLine: 4,
|
||||
},
|
||||
];
|
||||
|
||||
const filtered = await filterMemorySearchHitsBySessionVisibility({
|
||||
cfg: asOpenClawConfig({ tools: { sessions: { visibility: "self" } } }),
|
||||
agentId: "main",
|
||||
requesterSessionKey: `${anchorSessionKey}:active-memory:123456abcdef`,
|
||||
sandboxed: false,
|
||||
hits,
|
||||
conversationRecall: { anchorSessionKey, scope: "same-agent-private", corpus: "sessions" },
|
||||
});
|
||||
|
||||
expect(filtered).toEqual(hits);
|
||||
expect(engineSessions.buildSessionEntry).toHaveBeenCalledTimes(1);
|
||||
expect(engineSessions.buildSessionEntry).toHaveBeenCalledWith("current.jsonl", {
|
||||
agentId: "main",
|
||||
sessionId: "current",
|
||||
sessionKey: anchorSessionKey,
|
||||
storePath: "(test)",
|
||||
updatedAtMs: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["", ".zst"])(
|
||||
"allows an archived reset generation of the private anchor conversation%s",
|
||||
async (compressionSuffix) => {
|
||||
const anchorSessionKey = "agent:main:telegram:direct:owner";
|
||||
combinedSessionStore = {
|
||||
[anchorSessionKey]: {
|
||||
sessionId: "current",
|
||||
updatedAt: 2,
|
||||
sessionFile: "/tmp/sessions/current.jsonl",
|
||||
chatType: "direct",
|
||||
},
|
||||
};
|
||||
const hit: MemorySearchResult = {
|
||||
path: `sessions/main/current.jsonl.reset.2026-08-11T08-00-00.000Z${compressionSuffix}`,
|
||||
source: "sessions",
|
||||
score: 1,
|
||||
snippet: "prior conversation context",
|
||||
startLine: 1,
|
||||
endLine: 2,
|
||||
};
|
||||
|
||||
const filtered = await filterMemorySearchHitsBySessionVisibility({
|
||||
cfg: asOpenClawConfig({ tools: { sessions: { visibility: "self" } } }),
|
||||
agentId: "main",
|
||||
requesterSessionKey: `${anchorSessionKey}:active-memory:123456abcdef`,
|
||||
sandboxed: false,
|
||||
hits: [hit],
|
||||
conversationRecall: { anchorSessionKey, scope: "same-agent-private", corpus: "sessions" },
|
||||
});
|
||||
|
||||
expect(filtered).toEqual([hit]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "the private anchor conversation",
|
||||
path: "sessions/main/current.jsonl.deleted.2026-08-11T08-00-00.000Z",
|
||||
snippet: "explicitly deleted private context",
|
||||
includeDeletedSource: false,
|
||||
},
|
||||
{
|
||||
name: "the compressed private anchor conversation",
|
||||
path: "sessions/main/current.jsonl.deleted.2026-08-11T08-00-00.000Z.zst",
|
||||
snippet: "explicitly deleted compressed private context",
|
||||
includeDeletedSource: false,
|
||||
},
|
||||
{
|
||||
name: "another private conversation",
|
||||
path: "sessions/main/deleted-source.jsonl.deleted.2026-08-11T08-00-00.000Z",
|
||||
snippet: "intentionally deleted private context",
|
||||
includeDeletedSource: true,
|
||||
},
|
||||
{
|
||||
name: "another compressed private conversation",
|
||||
path: "sessions/main/deleted-source.jsonl.deleted.2026-08-11T08-00-00.000Z.zst",
|
||||
snippet: "intentionally deleted compressed private context",
|
||||
includeDeletedSource: true,
|
||||
},
|
||||
])(
|
||||
"denies an archived deleted generation from $name",
|
||||
async ({ path, snippet, includeDeletedSource }) => {
|
||||
const anchorSessionKey = "agent:main:telegram:direct:owner";
|
||||
combinedSessionStore = {
|
||||
[anchorSessionKey]: {
|
||||
sessionId: "current",
|
||||
updatedAt: 2,
|
||||
sessionFile: "/tmp/sessions/current.jsonl",
|
||||
chatType: "direct",
|
||||
},
|
||||
...(includeDeletedSource
|
||||
? {
|
||||
"agent:main:telegram:direct:deleted-source": {
|
||||
sessionId: "deleted-source",
|
||||
updatedAt: 1,
|
||||
sessionFile: "/tmp/sessions/deleted-source.jsonl",
|
||||
chatType: "direct" as const,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const hit: MemorySearchResult = {
|
||||
path,
|
||||
source: "sessions",
|
||||
score: 1,
|
||||
snippet,
|
||||
startLine: 1,
|
||||
endLine: 2,
|
||||
};
|
||||
|
||||
const filtered = await filterMemorySearchHitsBySessionVisibility({
|
||||
cfg: asOpenClawConfig({ tools: { sessions: { visibility: "self" } } }),
|
||||
agentId: "main",
|
||||
requesterSessionKey: `${anchorSessionKey}:active-memory:123456abcdef`,
|
||||
sandboxed: false,
|
||||
hits: [hit],
|
||||
conversationRecall: { anchorSessionKey, scope: "same-agent-private", corpus: "sessions" },
|
||||
});
|
||||
|
||||
expect(filtered).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -282,10 +282,10 @@ describe("filterMemorySearchHitsBySessionVisibility", () => {
|
||||
},
|
||||
};
|
||||
const hit: MemorySearchResult = {
|
||||
path: "sessions/other-private.jsonl",
|
||||
path: "sessions/main/current.jsonl.reset.2026-08-11T08-00-00.000Z",
|
||||
source: "sessions",
|
||||
score: 1,
|
||||
snippet: "private context",
|
||||
snippet: "prior private context",
|
||||
startLine: 1,
|
||||
endLine: 2,
|
||||
};
|
||||
@@ -431,45 +431,54 @@ describe("filterMemorySearchHitsBySessionVisibility", () => {
|
||||
expect(filtered).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("denies another agent's private transcript during trusted conversation recall", async () => {
|
||||
combinedSessionStore = {
|
||||
"agent:main:telegram:direct:owner": {
|
||||
sessionId: "current",
|
||||
updatedAt: 2,
|
||||
sessionFile: "/tmp/sessions/current.jsonl",
|
||||
chatType: "direct",
|
||||
},
|
||||
"agent:peer:telegram:direct:owner": {
|
||||
sessionId: "peer-private",
|
||||
updatedAt: 1,
|
||||
sessionFile: "/tmp/sessions/peer-private.jsonl",
|
||||
chatType: "direct",
|
||||
},
|
||||
};
|
||||
const hit: MemorySearchResult = {
|
||||
path: "sessions/peer-private.jsonl",
|
||||
source: "sessions",
|
||||
score: 1,
|
||||
snippet: "other agent context",
|
||||
startLine: 1,
|
||||
endLine: 2,
|
||||
};
|
||||
const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "all" } } });
|
||||
it.each([
|
||||
{ name: "live", path: "sessions/peer-private.jsonl" },
|
||||
{
|
||||
name: "archived",
|
||||
path: "sessions/peer/peer-private.jsonl.reset.2026-08-11T08-00-00.000Z",
|
||||
},
|
||||
])(
|
||||
"denies another agent's $name private transcript during trusted conversation recall",
|
||||
async ({ path }) => {
|
||||
combinedSessionStore = {
|
||||
"agent:main:telegram:direct:owner": {
|
||||
sessionId: "current",
|
||||
updatedAt: 2,
|
||||
sessionFile: "/tmp/sessions/current.jsonl",
|
||||
chatType: "direct",
|
||||
},
|
||||
"agent:peer:telegram:direct:owner": {
|
||||
sessionId: "peer-private",
|
||||
updatedAt: 1,
|
||||
sessionFile: "/tmp/sessions/peer-private.jsonl",
|
||||
chatType: "direct",
|
||||
},
|
||||
};
|
||||
const hit: MemorySearchResult = {
|
||||
path,
|
||||
source: "sessions",
|
||||
score: 1,
|
||||
snippet: "other agent context",
|
||||
startLine: 1,
|
||||
endLine: 2,
|
||||
};
|
||||
const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "all" } } });
|
||||
|
||||
const filtered = await filterMemorySearchHitsBySessionVisibility({
|
||||
cfg,
|
||||
requesterSessionKey: "agent:main:telegram:direct:owner",
|
||||
sandboxed: false,
|
||||
hits: [hit],
|
||||
conversationRecall: {
|
||||
anchorSessionKey: "agent:main:telegram:direct:owner",
|
||||
scope: "same-agent-private",
|
||||
corpus: "sessions",
|
||||
},
|
||||
});
|
||||
const filtered = await filterMemorySearchHitsBySessionVisibility({
|
||||
cfg,
|
||||
requesterSessionKey: "agent:main:telegram:direct:owner",
|
||||
sandboxed: false,
|
||||
hits: [hit],
|
||||
conversationRecall: {
|
||||
anchorSessionKey: "agent:main:telegram:direct:owner",
|
||||
scope: "same-agent-private",
|
||||
corpus: "sessions",
|
||||
},
|
||||
});
|
||||
|
||||
expect(filtered).toStrictEqual([]);
|
||||
});
|
||||
expect(filtered).toStrictEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("denies persisted Active Memory helper transcripts under explicit sessions", async () => {
|
||||
combinedSessionStore = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Memory Core plugin module implements session search visibility behavior.
|
||||
import { buildSessionEntry } from "openclaw/plugin-sdk/memory-core-host-engine-sessions";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
|
||||
import { resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core";
|
||||
@@ -14,6 +15,11 @@ import {
|
||||
createSessionVisibilityGuard,
|
||||
resolveEffectiveSessionToolsVisibility,
|
||||
} from "openclaw/plugin-sdk/session-visibility";
|
||||
import {
|
||||
readSessionArchiveReasonFromHitPath,
|
||||
readSessionResetRecallCutoffMetadata,
|
||||
type SessionResetRecallCutoff,
|
||||
} from "./session-reset-recall-metadata.js";
|
||||
|
||||
function normalizeAgentIdForCompare(value: string | undefined): string | undefined {
|
||||
return value?.trim().toLowerCase() || undefined;
|
||||
@@ -186,7 +192,7 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
|
||||
})
|
||||
: null;
|
||||
|
||||
const { store: combinedSessionStore } = loadCombinedSessionStoreForGateway(
|
||||
const { store: combinedSessionStore, storePath } = loadCombinedSessionStoreForGateway(
|
||||
params.cfg,
|
||||
scopedAgentId ? { agentId: scopedAgentId } : {},
|
||||
);
|
||||
@@ -200,6 +206,26 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
|
||||
? resolveSessionAgentId({ sessionKey: anchorSessionKey, config: params.cfg })
|
||||
: undefined;
|
||||
const anchorEntry = anchorSessionKey ? combinedSessionStore[anchorSessionKey] : undefined;
|
||||
let anchorResetCutoffPromise: Promise<SessionResetRecallCutoff> | undefined;
|
||||
const resolveAnchorResetCutoff = () => {
|
||||
if (anchorResetCutoffPromise) {
|
||||
return anchorResetCutoffPromise;
|
||||
}
|
||||
const sessionId = anchorEntry?.sessionId?.trim();
|
||||
if (!recallAgentId || !sessionId || !anchorSessionKey) {
|
||||
return Promise.resolve<SessionResetRecallCutoff>({ state: "invalid" });
|
||||
}
|
||||
anchorResetCutoffPromise = buildSessionEntry(`${sessionId}.jsonl`, {
|
||||
agentId: recallAgentId,
|
||||
sessionId,
|
||||
sessionKey: anchorSessionKey,
|
||||
storePath,
|
||||
updatedAtMs: anchorEntry?.updatedAt,
|
||||
})
|
||||
.then(readSessionResetRecallCutoffMetadata)
|
||||
.catch(() => ({ state: "invalid" }));
|
||||
return anchorResetCutoffPromise;
|
||||
};
|
||||
const recallAuthorized = Boolean(
|
||||
conversationRecall &&
|
||||
!params.sandboxed &&
|
||||
@@ -230,7 +256,7 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
|
||||
: [];
|
||||
}
|
||||
|
||||
const isSessionKeyAllowed = (key: string): boolean => {
|
||||
const isSessionKeyAllowed = (key: string, allowAnchorTranscript = false): boolean => {
|
||||
if (!conversationRecall || !anchorSessionKey || !recallAgentId) {
|
||||
// A bare global key is local to the selected agent store. Reattach that
|
||||
// owner before applying visibility or non-default agents look cross-agent.
|
||||
@@ -242,8 +268,11 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
|
||||
}
|
||||
const candidateEntry = combinedSessionStore[key];
|
||||
// Canonical and legacy alias keys can identify one transcript. Exclude the
|
||||
// anchor by transcript identity so an alias cannot re-inject current context.
|
||||
if (key === anchorSessionKey || isSameStoredTranscript(anchorEntry, candidateEntry)) {
|
||||
// live anchor, but let prior archived generations pass the privacy checks below.
|
||||
if (
|
||||
!allowAnchorTranscript &&
|
||||
(key === anchorSessionKey || isSameStoredTranscript(anchorEntry, candidateEntry))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const candidateAgentId = resolveSessionAgentId({ sessionKey: key, config: params.cfg });
|
||||
@@ -277,12 +306,12 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
|
||||
return [...expanded];
|
||||
};
|
||||
|
||||
const areSessionKeysAllowed = (keys: string[]): boolean => {
|
||||
const areSessionKeysAllowed = (keys: string[], allowAnchorTranscript = false): boolean => {
|
||||
// Product recall fails closed when aliases disagree about privacy. Ordinary
|
||||
// session-tool visibility keeps its existing any-visible-alias behavior.
|
||||
return conversationRecall
|
||||
? expandRecallAliasKeys(keys).every(isSessionKeyAllowed)
|
||||
: keys.some(isSessionKeyAllowed);
|
||||
? expandRecallAliasKeys(keys).every((key) => isSessionKeyAllowed(key, allowAnchorTranscript))
|
||||
: keys.some((key) => isSessionKeyAllowed(key));
|
||||
};
|
||||
|
||||
const next: MemorySearchResult[] = [];
|
||||
@@ -300,6 +329,10 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
|
||||
if (!identity) {
|
||||
continue;
|
||||
}
|
||||
const archiveReason = readSessionArchiveReasonFromHitPath(hit.path);
|
||||
if (conversationRecall && archiveReason === "deleted") {
|
||||
continue;
|
||||
}
|
||||
const normalizedScopedAgentId = normalizeAgentIdForCompare(scopedAgentId);
|
||||
const normalizedOwnerAgentId = normalizeAgentIdForCompare(identity.ownerAgentId);
|
||||
if (
|
||||
@@ -342,7 +375,20 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const allowed = areSessionKeysAllowed(keys);
|
||||
let allowResetAnchor = false;
|
||||
const anchorSessionId = anchorEntry?.sessionId?.trim();
|
||||
if (
|
||||
conversationRecall &&
|
||||
!identity.archived &&
|
||||
recallAgentId &&
|
||||
anchorSessionId &&
|
||||
identity.stem === anchorSessionId &&
|
||||
normalizedOwnerAgentId === normalizeAgentIdForCompare(recallAgentId)
|
||||
) {
|
||||
const cutoff = await resolveAnchorResetCutoff();
|
||||
allowResetAnchor = cutoff?.state === "valid" && hit.endLine < cutoff.cutoffLine;
|
||||
}
|
||||
const allowed = areSessionKeysAllowed(keys, archiveReason === "reset" || allowResetAnchor);
|
||||
if (!allowed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -246,6 +246,7 @@ describe("qa scenario catalog", () => {
|
||||
"matrix-restart-resume",
|
||||
"qa-channel-reconnect-dedupe",
|
||||
"remember-across-conversations",
|
||||
"remember-across-reset-private",
|
||||
"slack-restart-resume",
|
||||
"subagent-stale-child-links",
|
||||
"telegram-repeated-command-authorization",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import path from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { buildSessionEntry } from "openclaw/plugin-sdk/memory-core-host-engine-sessions";
|
||||
import {
|
||||
listSessionEntries,
|
||||
loadTranscriptEventsSync,
|
||||
@@ -44,6 +45,7 @@ type QaSessionTranscriptSeedParams = {
|
||||
const SESSION_STORE_FTS_SETTLE_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const;
|
||||
const MAX_COMPACTION_SUMMARIES = 16;
|
||||
const MAX_SUCCESSFUL_TOOL_CALL_EVENTS = 64;
|
||||
const SESSION_RESET_RECALL_CUTOFF = Symbol.for("openclaw.memory.sessionResetRecallCutoff");
|
||||
|
||||
type QaSessionTranscriptSummary = {
|
||||
assistantMirrors?: Array<{ identity: string; text: string }>;
|
||||
@@ -61,11 +63,14 @@ type QaSessionTranscriptSummary = {
|
||||
lastAssistantStopReason?: string;
|
||||
lastAssistantToolNames?: string[];
|
||||
lastMessageRole?: string;
|
||||
resetRecallCutoffLine?: number;
|
||||
probeTextEndLine?: number;
|
||||
};
|
||||
|
||||
type QaSessionTranscriptSummaryOptions = {
|
||||
afterEventCursor?: number;
|
||||
allowEmpty?: boolean;
|
||||
probeText?: string;
|
||||
};
|
||||
|
||||
function isSessionStoreFtsSettleRace(error: unknown) {
|
||||
@@ -427,7 +432,34 @@ async function readSessionTranscriptSummary(
|
||||
if (selectedEvents.length === 0 && options.allowEmpty === true) {
|
||||
return emptySessionTranscriptSummary(events.length);
|
||||
}
|
||||
return summarizeSessionTranscriptEvents(selectedEvents, normalizedSessionKey, events.length);
|
||||
const summary = summarizeSessionTranscriptEvents(
|
||||
selectedEvents,
|
||||
normalizedSessionKey,
|
||||
events.length,
|
||||
);
|
||||
const probeText = options.probeText?.trim();
|
||||
let cutoff: unknown;
|
||||
if (probeText) {
|
||||
const runtimeEnv = qaSessionRuntimeEnv(env.gateway.tempRoot);
|
||||
const storePath = resolveStorePath(undefined, { agentId: "qa", env: runtimeEnv });
|
||||
const transcriptEntry = await buildSessionEntry(
|
||||
path.join(env.gateway.tempRoot, "state", "agents", "qa", "sessions", `${sessionId}.jsonl`),
|
||||
{ agentId: "qa", sessionId, sessionKey: normalizedSessionKey, storePath },
|
||||
);
|
||||
cutoff = transcriptEntry
|
||||
? (transcriptEntry as unknown as Record<PropertyKey, unknown>)[SESSION_RESET_RECALL_CUTOFF]
|
||||
: undefined;
|
||||
}
|
||||
const probeTextEndLine = probeText
|
||||
? events.findLastIndex((event) => JSON.stringify(event).includes(probeText)) + 1
|
||||
: 0;
|
||||
return {
|
||||
...summary,
|
||||
...(isRecord(cutoff) && cutoff.state === "valid" && typeof cutoff.cutoffLine === "number"
|
||||
? { resetRecallCutoffLine: cutoff.cutoffLine }
|
||||
: {}),
|
||||
...(probeTextEndLine > 0 ? { probeTextEndLine } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import fsSync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
clearConfigCache,
|
||||
clearRuntimeConfigSnapshot,
|
||||
} from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
persistSessionTranscriptTurn,
|
||||
resetSessionEntryLifecycle,
|
||||
upsertSessionEntryCore,
|
||||
} from "../../../../src/config/sessions/session-accessor.js";
|
||||
import { closeOpenClawAgentDatabasesForTest } from "../../../../src/state/openclaw-agent-db.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../../../../src/state/openclaw-state-db.js";
|
||||
import { buildSessionEntry, type SessionFileEntry } from "./session-files.js";
|
||||
|
||||
function requireSessionEntry(entry: SessionFileEntry | null): SessionFileEntry {
|
||||
if (!entry) {
|
||||
throw new Error("expected session entry");
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
let tmpDir: string;
|
||||
let previousStateDir: string | undefined;
|
||||
let previousConfigPath: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "session-reset-revision-test-"));
|
||||
previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
previousConfigPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
Reflect.set(process.env, "OPENCLAW_STATE_DIR", tmpDir);
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearConfigCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
if (previousStateDir === undefined) {
|
||||
Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR");
|
||||
} else {
|
||||
Reflect.set(process.env, "OPENCLAW_STATE_DIR", previousStateDir);
|
||||
}
|
||||
if (previousConfigPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, "OPENCLAW_CONFIG_PATH");
|
||||
} else {
|
||||
Reflect.set(process.env, "OPENCLAW_CONFIG_PATH", previousConfigPath);
|
||||
}
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearConfigCache();
|
||||
fsSync.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("SQLite session reset content revision", () => {
|
||||
it("invalidates a session hash when a reset boundary changes its generation", async () => {
|
||||
const sessionsDir = path.join(tmpDir, "agents", "main", "sessions");
|
||||
const storePath = path.join(sessionsDir, "sessions.json");
|
||||
const sessionKey = "agent:main:chat:reset-revision";
|
||||
const sessionId = "reset-revision";
|
||||
fsSync.mkdirSync(sessionsDir, { recursive: true });
|
||||
await upsertSessionEntryCore(
|
||||
{ agentId: "main", sessionKey, storePath },
|
||||
{ sessionId, updatedAt: 1 },
|
||||
);
|
||||
await persistSessionTranscriptTurn(
|
||||
{ agentId: "main", sessionId, sessionKey, storePath },
|
||||
{
|
||||
messages: [{ message: { role: "user", content: "unchanged exported text" } }],
|
||||
touchSessionEntry: true,
|
||||
updateMode: "none",
|
||||
},
|
||||
);
|
||||
const buildOptions = {
|
||||
agentId: "main",
|
||||
sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
updatedAtMs: 1,
|
||||
};
|
||||
const before = requireSessionEntry(await buildSessionEntry(sessionKey, buildOptions));
|
||||
|
||||
await resetSessionEntryLifecycle({
|
||||
agentId: "main",
|
||||
buildNextEntry: ({ currentEntry }) => ({
|
||||
...currentEntry,
|
||||
sessionId,
|
||||
updatedAt: 2,
|
||||
}),
|
||||
resetBoundaryReason: "reset",
|
||||
storePath,
|
||||
target: { canonicalKey: sessionKey, storeKeys: [sessionKey] },
|
||||
});
|
||||
|
||||
const after = requireSessionEntry(await buildSessionEntry(sessionKey, buildOptions));
|
||||
expect(after.content).toBe(before.content);
|
||||
expect(after.lineMap).toEqual(before.lineMap);
|
||||
const cutoffSymbol = Symbol.for("openclaw.memory.sessionResetRecallCutoff");
|
||||
expect(Object.getOwnPropertyDescriptor(after, cutoffSymbol)).toMatchObject({
|
||||
enumerable: false,
|
||||
value: { state: "valid", cutoffLine: expect.any(Number) },
|
||||
});
|
||||
expect(Object.keys(after)).not.toContain(cutoffSymbol.description);
|
||||
expect(after.hash).not.toBe(before.hash);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
stripInternalRuntimeContext,
|
||||
} from "./openclaw-runtime-session.js";
|
||||
import { retryTransientMemoryRead } from "./read-retry.js";
|
||||
import { resolveSessionResetRecallCutoff } from "./session-reset-recall.js";
|
||||
import {
|
||||
listSessionTranscriptCorpusEntriesForAgent,
|
||||
listSessionTranscriptCorpusEntriesForAgentSync,
|
||||
@@ -775,11 +776,13 @@ export async function buildSessionEntry(
|
||||
const records = loadTranscriptEventsSync({
|
||||
...sqliteIdentity,
|
||||
});
|
||||
const resetRecallCutoff = resolveSessionResetRecallCutoff(records);
|
||||
const raw = serializeTranscriptEvents(records);
|
||||
return {
|
||||
mtimeMs: opts.updatedAtMs ?? stats.maxSeq,
|
||||
path: sessionPathForSessionIdentity(sqliteIdentity.agentId, sqliteIdentity.sessionId),
|
||||
raw,
|
||||
resetRecallCutoff,
|
||||
size: stats.sizeBytes,
|
||||
};
|
||||
})()
|
||||
@@ -959,7 +962,7 @@ export async function buildSessionEntry(
|
||||
lineProvenance.push(...renderedLines.map(() => memoryProvenance));
|
||||
}
|
||||
const content = collected.join("\n");
|
||||
return {
|
||||
const entry: SessionFileEntry = {
|
||||
path: memoryPath,
|
||||
absPath,
|
||||
mtimeMs,
|
||||
@@ -971,7 +974,9 @@ export async function buildSessionEntry(
|
||||
"\n" +
|
||||
messageTimestampsMs.join(",") +
|
||||
"\n" +
|
||||
JSON.stringify(lineProvenance),
|
||||
JSON.stringify(lineProvenance) +
|
||||
"\n" +
|
||||
JSON.stringify(rawSource?.resetRecallCutoff ?? { state: "absent" }),
|
||||
),
|
||||
content,
|
||||
lineMap,
|
||||
@@ -981,6 +986,13 @@ export async function buildSessionEntry(
|
||||
...(generatedByDreamingNarrative ? { generatedByDreamingNarrative: true } : {}),
|
||||
...(generatedByCronRun ? { generatedByCronRun: true } : {}),
|
||||
};
|
||||
Object.defineProperty(entry, Symbol.for("openclaw.memory.sessionResetRecallCutoff"), {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
value: rawSource?.resetRecallCutoff ?? { state: "absent" },
|
||||
writable: false,
|
||||
});
|
||||
return entry;
|
||||
} catch (err) {
|
||||
void logSessionFileReadFailure(absPath, err);
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSessionResetRecallCutoff } from "./session-reset-recall.js";
|
||||
|
||||
describe("resolveSessionResetRecallCutoff", () => {
|
||||
it("uses the first kept entry before the latest reset as the live cutoff", () => {
|
||||
expect(
|
||||
resolveSessionResetRecallCutoff([
|
||||
{ type: "message", id: "old" },
|
||||
{ type: "reset", id: "first" },
|
||||
{ type: "message", id: "kept" },
|
||||
{ type: "message", id: "newer" },
|
||||
{ type: "reset", id: "latest", firstKeptEntryId: "kept" },
|
||||
]),
|
||||
).toEqual({ state: "valid", cutoffLine: 3 });
|
||||
});
|
||||
|
||||
it("uses the latest reset line when it keeps no earlier entries", () => {
|
||||
expect(
|
||||
resolveSessionResetRecallCutoff([
|
||||
{ type: "message", id: "old" },
|
||||
{ type: "reset", id: "latest" },
|
||||
{ type: "message", id: "current" },
|
||||
]),
|
||||
).toEqual({ state: "valid", cutoffLine: 2 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[[{ type: "message", id: "only" }]],
|
||||
[[{ type: "reset", id: "latest", firstKeptEntryId: "missing" }]],
|
||||
[[{ type: "reset", id: "latest", firstKeptEntryId: 42 }]],
|
||||
[
|
||||
[
|
||||
{ type: "reset", id: "latest", firstKeptEntryId: "after" },
|
||||
{ type: "message", id: "after" },
|
||||
],
|
||||
],
|
||||
])("fails closed for absent or invalid reset lineage", (events) => {
|
||||
expect(resolveSessionResetRecallCutoff(events).state).not.toBe("valid");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
type SessionResetRecallCutoff =
|
||||
| { state: "absent" }
|
||||
| { state: "invalid" }
|
||||
| { cutoffLine: number; state: "valid" };
|
||||
|
||||
function eventId(event: unknown): string | undefined {
|
||||
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
||||
return undefined;
|
||||
}
|
||||
const id = (event as { id?: unknown }).id;
|
||||
return typeof id === "string" && id.trim() ? id : undefined;
|
||||
}
|
||||
|
||||
/** Resolves the first raw transcript line owned by the current reset generation. */
|
||||
export function resolveSessionResetRecallCutoff(
|
||||
events: readonly unknown[],
|
||||
): SessionResetRecallCutoff {
|
||||
const resetIndex = events.findLastIndex(
|
||||
(event) =>
|
||||
event !== null &&
|
||||
typeof event === "object" &&
|
||||
!Array.isArray(event) &&
|
||||
(event as { type?: unknown }).type === "reset",
|
||||
);
|
||||
if (resetIndex < 0) {
|
||||
return { state: "absent" };
|
||||
}
|
||||
const reset = events[resetIndex] as { firstKeptEntryId?: unknown };
|
||||
if (reset.firstKeptEntryId === undefined) {
|
||||
return { state: "valid", cutoffLine: resetIndex + 1 };
|
||||
}
|
||||
if (typeof reset.firstKeptEntryId !== "string" || !reset.firstKeptEntryId.trim()) {
|
||||
return { state: "invalid" };
|
||||
}
|
||||
const keptIndex = events.findIndex(
|
||||
(event, index) => index < resetIndex && eventId(event) === reset.firstKeptEntryId,
|
||||
);
|
||||
return keptIndex < 0 ? { state: "invalid" } : { state: "valid", cutoffLine: keptIndex + 1 };
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
title: Remember across a private session reset
|
||||
|
||||
scenario:
|
||||
id: remember-across-reset-private
|
||||
surface: session-memory
|
||||
risk: high
|
||||
coverage:
|
||||
primary:
|
||||
- session-memory.active-memory-active-recall
|
||||
- session-memory.active-memory-recall
|
||||
secondary:
|
||||
- session-memory.active-memory-qa-channel
|
||||
- channels.channel-native-commands
|
||||
objective: Verify SQLite reset continuity and pre-reset generation recall without admitting deleted, shared, or other-agent transcripts.
|
||||
plugins:
|
||||
- active-memory
|
||||
gatewayConfigPatch:
|
||||
session:
|
||||
dmScope: per-channel-peer
|
||||
memory:
|
||||
search:
|
||||
rememberAcrossConversations: true
|
||||
agents:
|
||||
entries:
|
||||
peer:
|
||||
identity:
|
||||
name: QA Peer
|
||||
plugins:
|
||||
entries:
|
||||
active-memory:
|
||||
enabled: true
|
||||
config:
|
||||
enabled: true
|
||||
mode: always
|
||||
agents: []
|
||||
toolsAllow:
|
||||
- memory_search
|
||||
logging: true
|
||||
persistTranscripts: true
|
||||
transcriptDir: qa-remember-across-reset-private
|
||||
queryMode: message
|
||||
maxSummaryChars: 220
|
||||
successCriteria:
|
||||
- A real Gateway session reset changes the private lifecycle revision while retaining its durable session id.
|
||||
- The pre-reset fact is indexed under the canonical SQLite session identity before the current generation cutoff.
|
||||
- Active Memory recalls the pre-reset private fact in the same conversation after the current reset.
|
||||
- An indexed deleted private marker, shared marker, and independently indexed peer-agent marker are absent from accepted recall evidence.
|
||||
docsRefs:
|
||||
- docs/concepts/active-memory.md
|
||||
- docs/reference/memory-config.md
|
||||
- docs/reference/session-management-compaction.md
|
||||
codeRefs:
|
||||
- extensions/active-memory/index.ts
|
||||
- extensions/memory-core/src/session-search-visibility.ts
|
||||
- src/auto-reply/reply/session-reset-command.ts
|
||||
- extensions/qa-lab/src/suite-runtime-flow.ts
|
||||
- extensions/qa-lab/src/providers/mock-openai/server.ts
|
||||
execution:
|
||||
kind: flow
|
||||
channel: qa-channel
|
||||
providerMode: mock-openai
|
||||
retryCount: 0
|
||||
suiteIsolation: isolated
|
||||
isolationReason: Resets and indexes isolated QA and peer-agent transcripts while running an ephemeral Gateway child.
|
||||
summary: Reset one private QA conversation, index its canonical SQLite transcript generations, and prove private-only Active Memory recall in that same conversation.
|
||||
config:
|
||||
requiredProviderMode: mock-openai
|
||||
conversationId: remember-reset-private
|
||||
deletedConversationId: remember-reset-deleted
|
||||
groupConversationId: remember-reset-group
|
||||
peerConversationId: remember-reset-peer
|
||||
privateFact: lemon pepper wings with blue cheese
|
||||
deletedFact: DELETED-RESET-ONLY cinnamon popcorn with chili salt
|
||||
groupFact: GROUP-RESET-ONLY loaded nachos with black olives
|
||||
peerFact: PEER-RESET-ONLY smoked tofu skewers
|
||||
seedMarker: QA-REMEMBER-RESET-SOURCE-SEEDED
|
||||
recallPrompt: "Remember across conversations QA check: what snack do I usually want for QA movie night? Reply in one short sentence."
|
||||
expectedNeedle: lemon pepper wings with blue cheese
|
||||
transcriptDir: qa-remember-across-reset-private
|
||||
|
||||
flow:
|
||||
steps:
|
||||
- name: recalls the pre-reset private generation without crossing ownership boundaries
|
||||
actions:
|
||||
- assert:
|
||||
expr: "env.providerMode === config.requiredProviderMode"
|
||||
message: this deterministic reset-and-recall proof requires mock-openai
|
||||
- call: waitForGatewayHealthy
|
||||
args:
|
||||
- ref: env
|
||||
- 60000
|
||||
- call: waitForQaChannelReady
|
||||
args:
|
||||
- ref: env
|
||||
- 60000
|
||||
- resetTransport: true
|
||||
- call: fs.rm
|
||||
args:
|
||||
- expr: "path.join(env.gateway.workspaceDir, 'MEMORY.md')"
|
||||
- force: true
|
||||
- call: fs.rm
|
||||
args:
|
||||
- expr: "path.join(env.gateway.workspaceDir, 'memory', `${formatMemoryDreamingDay(Date.now())}.md`)"
|
||||
- force: true
|
||||
- set: sourceDelivery
|
||||
value:
|
||||
expr: "transport.buildAgentDelivery({ target: `dm:${config.conversationId}` })"
|
||||
- set: groupDelivery
|
||||
value:
|
||||
expr: "transport.buildAgentDelivery({ target: `channel:${config.groupConversationId}` })"
|
||||
- set: deletedDelivery
|
||||
value:
|
||||
expr: "transport.buildAgentDelivery({ target: `dm:${config.deletedConversationId}` })"
|
||||
- set: peerDelivery
|
||||
value:
|
||||
expr: "transport.buildAgentDelivery({ target: `dm:${config.peerConversationId}` })"
|
||||
- set: sourceSessionKey
|
||||
value:
|
||||
expr: "buildAgentSessionKey({ agentId: 'qa', channel: sourceDelivery.channel, accountId: transport.accountId, peer: { kind: 'direct', id: sourceDelivery.replyTo }, dmScope: env.cfg.session?.dmScope, identityLinks: env.cfg.session?.identityLinks })"
|
||||
- set: groupSessionKey
|
||||
value:
|
||||
expr: "buildAgentSessionKey({ agentId: 'qa', channel: groupDelivery.channel, accountId: transport.accountId, peer: { kind: 'channel', id: groupDelivery.replyTo } })"
|
||||
- set: deletedSessionKey
|
||||
value:
|
||||
expr: "buildAgentSessionKey({ agentId: 'qa', channel: deletedDelivery.channel, accountId: transport.accountId, peer: { kind: 'direct', id: deletedDelivery.replyTo }, dmScope: env.cfg.session?.dmScope, identityLinks: env.cfg.session?.identityLinks })"
|
||||
- set: peerSessionKey
|
||||
value:
|
||||
expr: "buildAgentSessionKey({ agentId: 'peer', channel: peerDelivery.channel, accountId: transport.accountId, peer: { kind: 'direct', id: peerDelivery.replyTo }, dmScope: env.cfg.session?.dmScope, identityLinks: env.cfg.session?.identityLinks })"
|
||||
- set: transcriptRoot
|
||||
value:
|
||||
expr: "path.join(env.gateway.tempRoot, 'state', 'plugins', 'active-memory', 'transcripts', 'agents', 'qa', config.transcriptDir)"
|
||||
- call: fs.rm
|
||||
args:
|
||||
- ref: transcriptRoot
|
||||
- recursive: true
|
||||
force: true
|
||||
- sendInbound:
|
||||
conversation:
|
||||
id:
|
||||
ref: config.deletedConversationId
|
||||
kind: direct
|
||||
senderId:
|
||||
ref: config.deletedConversationId
|
||||
senderName: Remember Reset Deleted Source
|
||||
text:
|
||||
expr: "`Stable QA movie night usual favorite snack preference: ${config.deletedFact}. This source will be deleted. Acknowledge briefly.`"
|
||||
- waitForOutbound:
|
||||
conversation:
|
||||
id:
|
||||
ref: config.deletedConversationId
|
||||
kind: direct
|
||||
timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 60000)
|
||||
- sendInbound:
|
||||
conversation:
|
||||
id:
|
||||
ref: config.conversationId
|
||||
kind: direct
|
||||
senderId:
|
||||
ref: config.conversationId
|
||||
senderName: Remember Reset Source
|
||||
text:
|
||||
expr: "`Stable QA movie night usual favorite snack preference: ${config.privateFact}. Reply exactly: ${config.seedMarker}.`"
|
||||
- waitForOutbound:
|
||||
conversation:
|
||||
id:
|
||||
ref: config.conversationId
|
||||
kind: direct
|
||||
textIncludes:
|
||||
ref: config.seedMarker
|
||||
timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 60000)
|
||||
- sendInbound:
|
||||
conversation: { id: { ref: config.conversationId }, kind: direct }
|
||||
senderId: { ref: config.conversationId }
|
||||
senderName: Remember Reset Source
|
||||
text: "Filler turn one after the saved fact. Reply briefly."
|
||||
- waitForOutbound:
|
||||
conversation: { id: { ref: config.conversationId }, kind: direct }
|
||||
timeoutMs: { expr: "liveTurnTimeoutMs(env, 60000)" }
|
||||
- sendInbound:
|
||||
conversation: { id: { ref: config.conversationId }, kind: direct }
|
||||
senderId: { ref: config.conversationId }
|
||||
senderName: Remember Reset Source
|
||||
text: "Filler turn two after the saved fact. Reply briefly."
|
||||
- waitForOutbound:
|
||||
conversation: { id: { ref: config.conversationId }, kind: direct }
|
||||
timeoutMs: { expr: "liveTurnTimeoutMs(env, 60000)" }
|
||||
- sendInbound:
|
||||
conversation: { id: { ref: config.conversationId }, kind: direct }
|
||||
senderId: { ref: config.conversationId }
|
||||
senderName: Remember Reset Source
|
||||
text: "Filler turn three after the saved fact. Reply briefly."
|
||||
- waitForOutbound:
|
||||
conversation: { id: { ref: config.conversationId }, kind: direct }
|
||||
timeoutMs: { expr: "liveTurnTimeoutMs(env, 60000)" }
|
||||
- sendInbound:
|
||||
conversation: { id: { ref: config.conversationId }, kind: direct }
|
||||
senderId: { ref: config.conversationId }
|
||||
senderName: Remember Reset Source
|
||||
text: "Filler turn four after the saved fact. Reply briefly."
|
||||
- waitForOutbound:
|
||||
conversation: { id: { ref: config.conversationId }, kind: direct }
|
||||
timeoutMs: { expr: "liveTurnTimeoutMs(env, 60000)" }
|
||||
- sendInbound:
|
||||
conversation:
|
||||
id:
|
||||
ref: config.groupConversationId
|
||||
kind: channel
|
||||
title: Remember Reset Group
|
||||
senderId: remember-reset-group-member
|
||||
senderName: Remember Reset Group Member
|
||||
text:
|
||||
expr: "`@openclaw Stable QA movie night usual favorite snack preference: ${config.groupFact}. This applies only inside this group. Acknowledge briefly.`"
|
||||
- waitForOutbound:
|
||||
conversation:
|
||||
id:
|
||||
ref: config.groupConversationId
|
||||
kind: channel
|
||||
timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 60000)
|
||||
- call: env.gateway.call
|
||||
saveAs: peerStarted
|
||||
args:
|
||||
- agent
|
||||
- agentId: peer
|
||||
sessionKey:
|
||||
ref: peerSessionKey
|
||||
idempotencyKey:
|
||||
expr: randomUUID()
|
||||
message:
|
||||
expr: "`Stable QA movie night usual favorite snack preference: ${config.peerFact}. This belongs only to the peer agent. Acknowledge briefly.`"
|
||||
deliver: false
|
||||
channel:
|
||||
expr: peerDelivery.channel
|
||||
to:
|
||||
expr: "peerDelivery.to ?? `dm:${config.peerConversationId}`"
|
||||
- timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 180000)
|
||||
- assert:
|
||||
expr: "Boolean(peerStarted?.runId)"
|
||||
message:
|
||||
expr: "`peer agent run did not start: ${JSON.stringify(peerStarted)}`"
|
||||
- call: env.gateway.call
|
||||
saveAs: peerWaited
|
||||
args:
|
||||
- agent.wait
|
||||
- runId:
|
||||
expr: peerStarted.runId
|
||||
timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 180000)
|
||||
- timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 185000)
|
||||
- assert:
|
||||
expr: "['ok', 'completed', 'succeeded'].includes(peerWaited?.status) || (peerWaited?.status === 'error' && String(peerWaited?.error ?? '').trim().toLowerCase() === 'completed')"
|
||||
message:
|
||||
expr: "`peer agent run did not complete: ${JSON.stringify(peerWaited)}`"
|
||||
- call: readRawQaSessionStore
|
||||
saveAs: seededStore
|
||||
args:
|
||||
- ref: env
|
||||
- call: readRawQaSessionStore
|
||||
saveAs: peerStore
|
||||
args:
|
||||
- ref: env
|
||||
- agentId: peer
|
||||
- set: seedSession
|
||||
value:
|
||||
expr: seededStore[sourceSessionKey]
|
||||
- set: groupSession
|
||||
value:
|
||||
expr: seededStore[groupSessionKey]
|
||||
- set: deletedSession
|
||||
value:
|
||||
expr: seededStore[deletedSessionKey]
|
||||
- set: peerSession
|
||||
value:
|
||||
expr: peerStore[peerSessionKey]
|
||||
- assert:
|
||||
expr: "Boolean(seedSession?.sessionId) && Boolean(deletedSession?.sessionId) && Boolean(groupSession?.sessionId) && Boolean(peerSession?.sessionId)"
|
||||
message:
|
||||
expr: "`seeded transcript identities missing: ${JSON.stringify({ source: seedSession, group: groupSession, peer: peerSession })}`"
|
||||
- call: env.gateway.call
|
||||
saveAs: resetResult
|
||||
args:
|
||||
- sessions.reset
|
||||
- key:
|
||||
ref: sourceSessionKey
|
||||
reason: reset
|
||||
- timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 60000)
|
||||
- set: resetSession
|
||||
value:
|
||||
expr: resetResult.entry
|
||||
- assert:
|
||||
expr: "resetResult?.ok === true && Boolean(resetSession?.lifecycleRevision) && resetSession.lifecycleRevision !== seedSession.lifecycleRevision"
|
||||
message:
|
||||
expr: "`gateway session reset did not advance lifecycle: ${JSON.stringify({ result: resetResult, before: seedSession?.lifecycleRevision })}`"
|
||||
- assert:
|
||||
expr: resetSession?.sessionId === seedSession.sessionId
|
||||
message:
|
||||
expr: "`durable session id changed across reset: ${JSON.stringify({ before: seedSession?.sessionId, after: resetSession?.sessionId })}`"
|
||||
- call: readSessionTranscriptSummary
|
||||
saveAs: resetTranscript
|
||||
args:
|
||||
- ref: env
|
||||
- ref: sourceSessionKey
|
||||
- probeText:
|
||||
ref: config.privateFact
|
||||
- assert:
|
||||
expr: "Number.isInteger(resetTranscript.probeTextEndLine) && Number.isInteger(resetTranscript.resetRecallCutoffLine) && resetTranscript.probeTextEndLine < resetTranscript.resetRecallCutoffLine"
|
||||
message:
|
||||
expr: "`seed fact was not strictly before the effective SQLite reset cutoff: ${JSON.stringify(resetTranscript)}`"
|
||||
- set: canonicalSessionPath
|
||||
value:
|
||||
expr: "`sessions/qa/${seedSession.sessionId}.jsonl`"
|
||||
- call: env.gateway.call
|
||||
saveAs: deletedResult
|
||||
args:
|
||||
- sessions.delete
|
||||
- key:
|
||||
ref: deletedSessionKey
|
||||
expectedSessionId:
|
||||
expr: deletedSession.sessionId
|
||||
- timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 60000)
|
||||
- assert:
|
||||
expr: "deletedResult?.deleted === true && deletedResult.archived?.some((entry) => String(entry).includes(`${deletedSession.sessionId}.jsonl.deleted.`))"
|
||||
message:
|
||||
expr: "`same-agent private deletion did not produce its archive: ${JSON.stringify(deletedResult)}`"
|
||||
- set: deletedArchiveName
|
||||
value:
|
||||
expr: "path.basename(deletedResult.archived.find((entry) => String(entry).includes(`${deletedSession.sessionId}.jsonl.deleted.`)))"
|
||||
- call: readConfigSnapshot
|
||||
saveAs: preconditionConfig
|
||||
args:
|
||||
- ref: env
|
||||
- set: originalMemorySearch
|
||||
value:
|
||||
expr: "preconditionConfig.config.memory && typeof preconditionConfig.config.memory === 'object' ? structuredClone(preconditionConfig.config.memory.search) : undefined"
|
||||
- call: patchConfig
|
||||
args:
|
||||
- env:
|
||||
ref: env
|
||||
patch:
|
||||
memory:
|
||||
search:
|
||||
expr: "{ ...structuredClone(originalMemorySearch ?? {}), sources: ['memory', 'sessions'] }"
|
||||
- call: waitForGatewayHealthy
|
||||
args:
|
||||
- ref: env
|
||||
- 60000
|
||||
- call: waitForQaChannelReady
|
||||
args:
|
||||
- ref: env
|
||||
- 60000
|
||||
- call: runQaCli
|
||||
args:
|
||||
- ref: env
|
||||
- - memory
|
||||
- index
|
||||
- --agent
|
||||
- peer
|
||||
- --force
|
||||
- timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 60000)
|
||||
- call: runQaCli
|
||||
saveAs: peerSearch
|
||||
args:
|
||||
- ref: env
|
||||
- - memory
|
||||
- search
|
||||
- --agent
|
||||
- peer
|
||||
- --json
|
||||
- --query
|
||||
- expr: config.peerFact
|
||||
- --max-results
|
||||
- "50"
|
||||
- --min-score
|
||||
- "0"
|
||||
- timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 60000)
|
||||
json: true
|
||||
- assert:
|
||||
expr: "JSON.stringify(peerSearch).includes(config.peerFact) && JSON.stringify(peerSearch).includes(peerSession.sessionId)"
|
||||
message:
|
||||
expr: "`peer agent index did not contain its marker: ${JSON.stringify(peerSearch)}`"
|
||||
- call: runQaCli
|
||||
args:
|
||||
- ref: env
|
||||
- - memory
|
||||
- index
|
||||
- --agent
|
||||
- qa
|
||||
- --force
|
||||
- timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 60000)
|
||||
- call: runQaCli
|
||||
saveAs: qaSearch
|
||||
args:
|
||||
- ref: env
|
||||
- - memory
|
||||
- search
|
||||
- --agent
|
||||
- qa
|
||||
- --json
|
||||
- --query
|
||||
- expr: config.privateFact
|
||||
- --max-results
|
||||
- "50"
|
||||
- --min-score
|
||||
- "0"
|
||||
- timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 60000)
|
||||
json: true
|
||||
- set: qaSearchText
|
||||
value:
|
||||
expr: JSON.stringify(qaSearch)
|
||||
- call: runQaCli
|
||||
saveAs: groupSearch
|
||||
args:
|
||||
- ref: env
|
||||
- - memory
|
||||
- search
|
||||
- --agent
|
||||
- qa
|
||||
- --json
|
||||
- --query
|
||||
- expr: config.groupFact
|
||||
- --max-results
|
||||
- "50"
|
||||
- --min-score
|
||||
- "0"
|
||||
- timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 60000)
|
||||
json: true
|
||||
- call: runQaCli
|
||||
saveAs: deletedSearch
|
||||
args:
|
||||
- ref: env
|
||||
- - memory
|
||||
- search
|
||||
- --agent
|
||||
- qa
|
||||
- --json
|
||||
- --query
|
||||
- expr: config.deletedFact
|
||||
- --max-results
|
||||
- "50"
|
||||
- --min-score
|
||||
- "0"
|
||||
- timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 60000)
|
||||
json: true
|
||||
- set: deletedSearchText
|
||||
value:
|
||||
expr: JSON.stringify(deletedSearch)
|
||||
- set: groupSearchText
|
||||
value:
|
||||
expr: JSON.stringify(groupSearch)
|
||||
- assert:
|
||||
expr: "groupSearchText.includes(config.groupFact) && groupSearchText.includes(groupSession.sessionId)"
|
||||
message:
|
||||
expr: "`QA index did not contain the seeded group transcript: ${groupSearchText}`"
|
||||
- assert:
|
||||
expr: "deletedSearchText.includes(config.deletedFact) && deletedSearchText.includes(deletedArchiveName)"
|
||||
message:
|
||||
expr: "`QA index did not contain the deleted private control: ${deletedSearchText}`"
|
||||
- assert:
|
||||
expr: "qaSearchText.includes(config.privateFact) && qaSearchText.includes(canonicalSessionPath)"
|
||||
message:
|
||||
expr: "`QA index did not return the pre-reset private fact at its canonical SQLite path: ${qaSearchText}`"
|
||||
- call: patchConfig
|
||||
args:
|
||||
- env:
|
||||
ref: env
|
||||
patch:
|
||||
memory:
|
||||
search:
|
||||
expr: "originalMemorySearch === undefined ? null : structuredClone(originalMemorySearch)"
|
||||
- call: waitForGatewayHealthy
|
||||
args:
|
||||
- ref: env
|
||||
- 60000
|
||||
- call: waitForQaChannelReady
|
||||
args:
|
||||
- ref: env
|
||||
- 60000
|
||||
- call: env.gateway.restartAfterStateMutation
|
||||
args:
|
||||
- lambda:
|
||||
async: true
|
||||
expr: await Promise.resolve()
|
||||
- call: fs.rm
|
||||
args:
|
||||
- ref: transcriptRoot
|
||||
- recursive: true
|
||||
force: true
|
||||
- set: requestCursorBeforeRecall
|
||||
value:
|
||||
expr: "(await fetchJson(`${env.mock.baseUrl}/debug/request-cursor`)).cursor"
|
||||
- set: recallOutboundIndex
|
||||
value:
|
||||
expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound').length"
|
||||
- sendInbound:
|
||||
conversation:
|
||||
id:
|
||||
ref: config.conversationId
|
||||
kind: direct
|
||||
senderId:
|
||||
ref: config.conversationId
|
||||
senderName: Remember Reset Source
|
||||
text:
|
||||
ref: config.recallPrompt
|
||||
- call: waitForOutboundMessage
|
||||
saveAs: recallOutbound
|
||||
args:
|
||||
- ref: state
|
||||
- lambda:
|
||||
params: [candidate]
|
||||
expr: "candidate.direction === 'outbound' && candidate.conversation.id === config.conversationId"
|
||||
- expr: liveTurnTimeoutMs(env, 60000)
|
||||
- sinceIndex:
|
||||
ref: recallOutboundIndex
|
||||
- call: waitForCondition
|
||||
saveAs: helperTranscriptPath
|
||||
args:
|
||||
- lambda:
|
||||
async: true
|
||||
expr: "await (async () => { const entries = (await fs.readdir(transcriptRoot).catch(() => [])).filter((entry) => entry.endsWith('.jsonl')).toSorted(); return entries.length > 0 ? path.join(transcriptRoot, entries.at(-1)) : undefined; })()"
|
||||
- expr: liveTurnTimeoutMs(env, 30000)
|
||||
- 250
|
||||
- call: fs.readFile
|
||||
saveAs: helperTranscriptText
|
||||
args:
|
||||
- ref: helperTranscriptPath
|
||||
- utf8
|
||||
- set: recallRequests
|
||||
value:
|
||||
expr: "await fetchJson(`${env.mock.baseUrl}/debug/requests?after=${requestCursorBeforeRecall}`)"
|
||||
- assert:
|
||||
expr: "normalizeLowercaseStringOrEmpty(recallOutbound.text).includes(normalizeLowercaseStringOrEmpty(config.expectedNeedle))"
|
||||
message:
|
||||
expr: "`same-conversation post-reset reply missed the pre-reset private fact: ${recallOutbound.text}`"
|
||||
- assert:
|
||||
expr: "helperTranscriptText.includes('memory_search') && helperTranscriptText.includes(config.privateFact) && helperTranscriptText.includes(seedSession.sessionId)"
|
||||
message:
|
||||
expr: "`Active Memory helper did not accept the canonical pre-reset private fact: ${helperTranscriptText}`"
|
||||
- assert:
|
||||
expr: "!helperTranscriptText.includes(config.deletedFact) && !helperTranscriptText.includes(deletedArchiveName) && !helperTranscriptText.includes(config.groupFact) && !helperTranscriptText.includes(groupSession.sessionId) && !helperTranscriptText.includes(config.peerFact) && !helperTranscriptText.includes(peerSession.sessionId)"
|
||||
message:
|
||||
expr: "`Active Memory helper crossed a shared or peer-agent boundary: ${helperTranscriptText}`"
|
||||
- assert:
|
||||
expr: "recallRequests.some((request) => String(request.allInputText ?? '').includes('Remember across conversations QA check') && request.plannedToolName === 'memory_search')"
|
||||
message: deterministic post-reset recall did not issue memory_search
|
||||
detailsExpr: "JSON.stringify({ verdict: 'PASS', scenario: 'remember-across-reset-private', channel: 'qa-channel', provider: env.providerMode, gateway: 'ephemeral-child', lifecycleReset: resetSession.lifecycleRevision !== seedSession.lifecycleRevision, durableSessionRetained: resetSession.sessionId === seedSession.sessionId, canonicalResetBoundary: true, preResetHitIndexed: qaSearchText.includes(canonicalSessionPath), preResetHitAccepted: helperTranscriptText.includes(seedSession.sessionId), deletedTranscriptIndexed: deletedSearchText.includes(deletedArchiveName), deletedTranscriptExcluded: !helperTranscriptText.includes(deletedArchiveName), groupTranscriptIndexed: groupSearchText.includes(groupSession.sessionId), peerTranscriptIndexed: JSON.stringify(peerSearch).includes(peerSession.sessionId), privateRecallDelivered: normalizeLowercaseStringOrEmpty(recallOutbound.text).includes(normalizeLowercaseStringOrEmpty(config.expectedNeedle)), sharedExcluded: !helperTranscriptText.includes(groupSession.sessionId), peerAgentExcluded: !helperTranscriptText.includes(peerSession.sessionId), reply: recallOutbound.text })"
|
||||
Reference in New Issue
Block a user