fix (memory/qmd): isolate managed collections per agent

This commit is contained in:
Vignesh Natarajan
2026-02-15 20:14:37 -08:00
parent 5d436f48b2
commit b32ae6fa0c
4 changed files with 104 additions and 29 deletions

View File

@@ -31,6 +31,10 @@ describe("resolveMemoryBackendConfig", () => {
expect(resolved.qmd?.update.commandTimeoutMs).toBe(30_000); expect(resolved.qmd?.update.commandTimeoutMs).toBe(30_000);
expect(resolved.qmd?.update.updateTimeoutMs).toBe(120_000); expect(resolved.qmd?.update.updateTimeoutMs).toBe(120_000);
expect(resolved.qmd?.update.embedTimeoutMs).toBe(120_000); expect(resolved.qmd?.update.embedTimeoutMs).toBe(120_000);
const names = new Set((resolved.qmd?.collections ?? []).map((collection) => collection.name));
expect(names.has("memory-root-main")).toBe(true);
expect(names.has("memory-alt-main")).toBe(true);
expect(names.has("memory-dir-main")).toBe(true);
}); });
it("parses quoted qmd command paths", () => { it("parses quoted qmd command paths", () => {
@@ -73,6 +77,33 @@ describe("resolveMemoryBackendConfig", () => {
expect(custom?.path).toBe(path.resolve(workspaceRoot, "notes")); expect(custom?.path).toBe(path.resolve(workspaceRoot, "notes"));
}); });
it("scopes qmd collection names per agent", () => {
const cfg = {
agents: {
defaults: { workspace: "/workspace/root" },
list: [
{ id: "main", default: true, workspace: "/workspace/root" },
{ id: "dev", workspace: "/workspace/dev" },
],
},
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: true,
paths: [{ path: "notes", name: "workspace", pattern: "**/*.md" }],
},
},
} as OpenClawConfig;
const mainResolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
const devResolved = resolveMemoryBackendConfig({ cfg, agentId: "dev" });
const mainNames = new Set((mainResolved.qmd?.collections ?? []).map((collection) => collection.name));
const devNames = new Set((devResolved.qmd?.collections ?? []).map((collection) => collection.name));
expect(mainNames.has("memory-dir-main")).toBe(true);
expect(devNames.has("memory-dir-dev")).toBe(true);
expect(mainNames.has("workspace-main")).toBe(true);
expect(devNames.has("workspace-dev")).toBe(true);
});
it("resolves qmd update timeout overrides", () => { it("resolves qmd update timeout overrides", () => {
const cfg = { const cfg = {
agents: { defaults: { workspace: "/tmp/memory-test" } }, agents: { defaults: { workspace: "/tmp/memory-test" } },

View File

@@ -95,6 +95,10 @@ function sanitizeName(input: string): string {
return trimmed || "collection"; return trimmed || "collection";
} }
function scopeCollectionBase(base: string, agentId: string): string {
return `${base}-${sanitizeName(agentId)}`;
}
function ensureUniqueName(base: string, existing: Set<string>): string { function ensureUniqueName(base: string, existing: Set<string>): string {
let name = sanitizeName(base); let name = sanitizeName(base);
if (!existing.has(name)) { if (!existing.has(name)) {
@@ -203,6 +207,7 @@ function resolveCustomPaths(
rawPaths: MemoryQmdIndexPath[] | undefined, rawPaths: MemoryQmdIndexPath[] | undefined,
workspaceDir: string, workspaceDir: string,
existing: Set<string>, existing: Set<string>,
agentId: string,
): ResolvedQmdCollection[] { ): ResolvedQmdCollection[] {
if (!rawPaths?.length) { if (!rawPaths?.length) {
return []; return [];
@@ -220,7 +225,7 @@ function resolveCustomPaths(
return; return;
} }
const pattern = entry.pattern?.trim() || "**/*.md"; const pattern = entry.pattern?.trim() || "**/*.md";
const baseName = entry.name?.trim() || `custom-${index + 1}`; const baseName = scopeCollectionBase(entry.name?.trim() || `custom-${index + 1}`, agentId);
const name = ensureUniqueName(baseName, existing); const name = ensureUniqueName(baseName, existing);
collections.push({ collections.push({
name, name,
@@ -236,6 +241,7 @@ function resolveDefaultCollections(
include: boolean, include: boolean,
workspaceDir: string, workspaceDir: string,
existing: Set<string>, existing: Set<string>,
agentId: string,
): ResolvedQmdCollection[] { ): ResolvedQmdCollection[] {
if (!include) { if (!include) {
return []; return [];
@@ -246,7 +252,7 @@ function resolveDefaultCollections(
{ path: path.join(workspaceDir, "memory"), pattern: "**/*.md", base: "memory-dir" }, { path: path.join(workspaceDir, "memory"), pattern: "**/*.md", base: "memory-dir" },
]; ];
return entries.map((entry) => ({ return entries.map((entry) => ({
name: ensureUniqueName(entry.base, existing), name: ensureUniqueName(scopeCollectionBase(entry.base, agentId), existing),
path: entry.path, path: entry.path,
pattern: entry.pattern, pattern: entry.pattern,
kind: "memory", kind: "memory",
@@ -268,8 +274,8 @@ export function resolveMemoryBackendConfig(params: {
const includeDefaultMemory = qmdCfg?.includeDefaultMemory !== false; const includeDefaultMemory = qmdCfg?.includeDefaultMemory !== false;
const nameSet = new Set<string>(); const nameSet = new Set<string>();
const collections = [ const collections = [
...resolveDefaultCollections(includeDefaultMemory, workspaceDir, nameSet), ...resolveDefaultCollections(includeDefaultMemory, workspaceDir, nameSet, params.agentId),
...resolveCustomPaths(qmdCfg?.paths, workspaceDir, nameSet), ...resolveCustomPaths(qmdCfg?.paths, workspaceDir, nameSet, params.agentId),
]; ];
const rawCommand = qmdCfg?.command?.trim() || "qmd"; const rawCommand = qmdCfg?.command?.trim() || "qmd";

View File

@@ -313,6 +313,7 @@ describe("QmdMemoryManager", () => {
}, },
} as OpenClawConfig; } as OpenClawConfig;
const sessionCollectionName = `sessions-${devAgentId}`;
const wrongSessionsPath = path.join(stateDir, "agents", agentId, "qmd", "sessions"); const wrongSessionsPath = path.join(stateDir, "agents", agentId, "qmd", "sessions");
spawnMock.mockImplementation((_cmd: string, args: string[]) => { spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "collection" && args[1] === "list") { if (args[0] === "collection" && args[1] === "list") {
@@ -320,7 +321,7 @@ describe("QmdMemoryManager", () => {
emitAndClose( emitAndClose(
child, child,
"stdout", "stdout",
JSON.stringify([{ name: "sessions", path: wrongSessionsPath, mask: "**/*.md" }]), JSON.stringify([{ name: sessionCollectionName, path: wrongSessionsPath, mask: "**/*.md" }]),
); );
return child; return child;
} }
@@ -339,7 +340,7 @@ describe("QmdMemoryManager", () => {
const commands = spawnMock.mock.calls.map((call) => call[1] as string[]); const commands = spawnMock.mock.calls.map((call) => call[1] as string[]);
const removeSessions = commands.find( const removeSessions = commands.find(
(args) => args[0] === "collection" && args[1] === "remove" && args[2] === "sessions", (args) => args[0] === "collection" && args[1] === "remove" && args[2] === sessionCollectionName,
); );
expect(removeSessions).toBeDefined(); expect(removeSessions).toBeDefined();
@@ -348,7 +349,7 @@ describe("QmdMemoryManager", () => {
return false; return false;
} }
const nameIdx = args.indexOf("--name"); const nameIdx = args.indexOf("--name");
return nameIdx >= 0 && args[nameIdx + 1] === "sessions"; return nameIdx >= 0 && args[nameIdx + 1] === sessionCollectionName;
}); });
expect(addSessions).toBeDefined(); expect(addSessions).toBeDefined();
expect(addSessions?.[2]).toBe(path.join(stateDir, "agents", devAgentId, "qmd", "sessions")); expect(addSessions?.[2]).toBe(path.join(stateDir, "agents", devAgentId, "qmd", "sessions"));
@@ -368,10 +369,11 @@ describe("QmdMemoryManager", () => {
}, },
} as OpenClawConfig; } as OpenClawConfig;
const sessionCollectionName = `sessions-${agentId}`;
spawnMock.mockImplementation((_cmd: string, args: string[]) => { spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "collection" && args[1] === "list") { if (args[0] === "collection" && args[1] === "list") {
const child = createMockChild({ autoClose: false }); const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", JSON.stringify(["workspace", "sessions"])); emitAndClose(child, "stdout", JSON.stringify([`workspace-${agentId}`, sessionCollectionName]));
return child; return child;
} }
return createMockChild(); return createMockChild();
@@ -382,7 +384,7 @@ describe("QmdMemoryManager", () => {
const commands = spawnMock.mock.calls.map((call) => call[1] as string[]); const commands = spawnMock.mock.calls.map((call) => call[1] as string[]);
const removeSessions = commands.find( const removeSessions = commands.find(
(args) => args[0] === "collection" && args[1] === "remove" && args[2] === "sessions", (args) => args[0] === "collection" && args[1] === "remove" && args[2] === sessionCollectionName,
); );
expect(removeSessions).toBeDefined(); expect(removeSessions).toBeDefined();
@@ -391,7 +393,7 @@ describe("QmdMemoryManager", () => {
return false; return false;
} }
const nameIdx = args.indexOf("--name"); const nameIdx = args.indexOf("--name");
return nameIdx >= 0 && args[nameIdx + 1] === "sessions"; return nameIdx >= 0 && args[nameIdx + 1] === sessionCollectionName;
}); });
expect(addSessions).toBeDefined(); expect(addSessions).toBeDefined();
}); });
@@ -484,8 +486,8 @@ describe("QmdMemoryManager", () => {
.map((args) => args[args.indexOf("--name") + 1]); .map((args) => args[args.indexOf("--name") + 1]);
expect(updateCalls).toBe(2); expect(updateCalls).toBe(2);
expect(removeCalls).toEqual(["memory-root", "memory-alt", "memory-dir"]); expect(removeCalls).toEqual(["memory-root-main", "memory-alt-main", "memory-dir-main"]);
expect(addCalls).toEqual(["memory-root", "memory-alt", "memory-dir"]); expect(addCalls).toEqual(["memory-root-main", "memory-alt-main", "memory-dir-main"]);
expect(logWarnMock).toHaveBeenCalledWith( expect(logWarnMock).toHaveBeenCalledWith(
expect.stringContaining("suspected null-byte collection metadata"), expect.stringContaining("suspected null-byte collection metadata"),
); );
@@ -573,7 +575,7 @@ describe("QmdMemoryManager", () => {
"-n", "-n",
String(resolved.qmd?.limits.maxResults), String(resolved.qmd?.limits.maxResults),
"-c", "-c",
"workspace", "workspace-main",
]); ]);
expect(spawnMock.mock.calls.some((call) => call[1]?.[0] === "query")).toBe(false); expect(spawnMock.mock.calls.some((call) => call[1]?.[0] === "query")).toBe(false);
expect(maxResults).toBeGreaterThan(0); expect(maxResults).toBeGreaterThan(0);
@@ -623,8 +625,8 @@ describe("QmdMemoryManager", () => {
(args): args is string[] => Array.isArray(args) && ["search", "query"].includes(args[0]), (args): args is string[] => Array.isArray(args) && ["search", "query"].includes(args[0]),
); );
expect(searchAndQueryCalls).toEqual([ expect(searchAndQueryCalls).toEqual([
["search", "test", "--json", "-n", String(maxResults), "-c", "workspace"], ["search", "test", "--json", "-n", String(maxResults), "-c", "workspace-main"],
["query", "test", "--json", "-n", String(maxResults), "-c", "workspace"], ["query", "test", "--json", "-n", String(maxResults), "-c", "workspace-main"],
]); ]);
await manager.close(); await manager.close();
}); });
@@ -789,9 +791,9 @@ describe("QmdMemoryManager", () => {
"-n", "-n",
String(maxResults), String(maxResults),
"-c", "-c",
"workspace", "workspace-main",
"-c", "-c",
"notes", "notes-main",
]); ]);
await manager.close(); await manager.close();
}); });
@@ -836,8 +838,8 @@ describe("QmdMemoryManager", () => {
.map((call) => call[1] as string[]) .map((call) => call[1] as string[])
.filter((args) => args[0] === "query"); .filter((args) => args[0] === "query");
expect(queryCalls).toEqual([ expect(queryCalls).toEqual([
["query", "test", "--json", "-n", String(maxResults), "-c", "workspace"], ["query", "test", "--json", "-n", String(maxResults), "-c", "workspace-main"],
["query", "test", "--json", "-n", String(maxResults), "-c", "notes"], ["query", "test", "--json", "-n", String(maxResults), "-c", "notes-main"],
]); ]);
await manager.close(); await manager.close();
}); });
@@ -887,9 +889,19 @@ describe("QmdMemoryManager", () => {
.map((call) => call[1] as string[]) .map((call) => call[1] as string[])
.filter((args) => args[0] === "search" || args[0] === "query"); .filter((args) => args[0] === "search" || args[0] === "query");
expect(searchAndQueryCalls).toEqual([ expect(searchAndQueryCalls).toEqual([
["search", "test", "--json", "-n", String(maxResults), "-c", "workspace", "-c", "notes"], [
["query", "test", "--json", "-n", String(maxResults), "-c", "workspace"], "search",
["query", "test", "--json", "-n", String(maxResults), "-c", "notes"], "test",
"--json",
"-n",
String(maxResults),
"-c",
"workspace-main",
"-c",
"notes-main",
],
["query", "test", "--json", "-n", String(maxResults), "-c", "workspace-main"],
["query", "test", "--json", "-n", String(maxResults), "-c", "notes-main"],
]); ]);
await manager.close(); await manager.close();
}); });
@@ -1020,7 +1032,7 @@ describe("QmdMemoryManager", () => {
const textPath = path.join(workspaceDir, "secret.txt"); const textPath = path.join(workspaceDir, "secret.txt");
await fs.writeFile(textPath, "nope", "utf-8"); await fs.writeFile(textPath, "nope", "utf-8");
await expect(manager.readFile({ relPath: "qmd/workspace/secret.txt" })).rejects.toThrow( await expect(manager.readFile({ relPath: "qmd/workspace-main/secret.txt" })).rejects.toThrow(
"path required", "path required",
); );
@@ -1028,7 +1040,7 @@ describe("QmdMemoryManager", () => {
await fs.writeFile(target, "ok", "utf-8"); await fs.writeFile(target, "ok", "utf-8");
const link = path.join(workspaceDir, "link.md"); const link = path.join(workspaceDir, "link.md");
await fs.symlink(target, link); await fs.symlink(target, link);
await expect(manager.readFile({ relPath: "qmd/workspace/link.md" })).rejects.toThrow( await expect(manager.readFile({ relPath: "qmd/workspace-main/link.md" })).rejects.toThrow(
"path required", "path required",
); );
@@ -1182,7 +1194,7 @@ describe("QmdMemoryManager", () => {
} }
if (query.includes("hash LIKE ?")) { if (query.includes("hash LIKE ?")) {
expect(arg).toBe(`${exactDocid}%`); expect(arg).toBe(`${exactDocid}%`);
return { collection: "workspace", path: "notes/welcome.md" }; return { collection: "workspace-main", path: "notes/welcome.md" };
} }
throw new Error(`unexpected sqlite query: ${query}`); throw new Error(`unexpected sqlite query: ${query}`);
}, },

View File

@@ -162,6 +162,9 @@ export class QmdMemoryManager implements MemorySearchManager {
await fs.mkdir(this.xdgConfigHome, { recursive: true }); await fs.mkdir(this.xdgConfigHome, { recursive: true });
await fs.mkdir(this.xdgCacheHome, { recursive: true }); await fs.mkdir(this.xdgCacheHome, { recursive: true });
await fs.mkdir(path.dirname(this.indexPath), { recursive: true }); await fs.mkdir(path.dirname(this.indexPath), { recursive: true });
if (this.sessionExporter) {
await fs.mkdir(this.sessionExporter.dir, { recursive: true });
}
// QMD stores its ML models under $XDG_CACHE_HOME/qmd/models/. Because we // QMD stores its ML models under $XDG_CACHE_HOME/qmd/models/. Because we
// override XDG_CACHE_HOME to isolate the index per-agent, qmd would not // override XDG_CACHE_HOME to isolate the index per-agent, qmd would not
@@ -257,6 +260,7 @@ export class QmdMemoryManager implements MemorySearchManager {
} }
} }
try { try {
await this.ensureCollectionPath(collection);
await this.addCollection(collection.path, collection.name, collection.pattern); await this.addCollection(collection.path, collection.name, collection.pattern);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
@@ -268,6 +272,21 @@ export class QmdMemoryManager implements MemorySearchManager {
} }
} }
private async ensureCollectionPath(collection: {
path: string;
pattern: string;
kind: "memory" | "custom" | "sessions";
}): Promise<void> {
if (!this.isDirectoryGlobPattern(collection.pattern)) {
return;
}
await fs.mkdir(collection.path, { recursive: true });
}
private isDirectoryGlobPattern(pattern: string): boolean {
return pattern.includes("*") || pattern.includes("?") || pattern.includes("[");
}
private isCollectionAlreadyExistsError(message: string): boolean { private isCollectionAlreadyExistsError(message: string): boolean {
const lower = message.toLowerCase(); const lower = message.toLowerCase();
return lower.includes("already exists") || lower.includes("exists"); return lower.includes("already exists") || lower.includes("exists");
@@ -843,18 +862,25 @@ export class QmdMemoryManager implements MemorySearchManager {
private pickSessionCollectionName(): string { private pickSessionCollectionName(): string {
const existing = new Set(this.qmd.collections.map((collection) => collection.name)); const existing = new Set(this.qmd.collections.map((collection) => collection.name));
if (!existing.has("sessions")) { const base = `sessions-${this.sanitizeCollectionNameSegment(this.agentId)}`;
return "sessions"; if (!existing.has(base)) {
return base;
} }
let counter = 2; let counter = 2;
let candidate = `sessions-${counter}`; let candidate = `${base}-${counter}`;
while (existing.has(candidate)) { while (existing.has(candidate)) {
counter += 1; counter += 1;
candidate = `sessions-${counter}`; candidate = `${base}-${counter}`;
} }
return candidate; return candidate;
} }
private sanitizeCollectionNameSegment(input: string): string {
const lower = input.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
const trimmed = lower.replace(/^-+|-+$/g, "");
return trimmed || "agent";
}
private async resolveDocLocation( private async resolveDocLocation(
docid?: string, docid?: string,
): Promise<{ rel: string; abs: string; source: MemorySource } | null> { ): Promise<{ rel: string; abs: string; source: MemorySource } | null> {