feat(openclaw): add per-agent memory isolation for multi-agent setups (#4245)

This commit is contained in:
Utkarsh
2026-03-09 21:23:49 +05:30
committed by GitHub
parent 72e2c5c24b
commit 3ffe43f99f
4 changed files with 318 additions and 31 deletions
+30 -4
View File
@@ -30,6 +30,26 @@ The agent tools (`memory_search`, `memory_list`) accept a `scope` parameter (`"s
All new parameters are optional and backward-compatible — existing configurations work without changes.
### Per-agent memory isolation
In multi-agent setups, each agent automatically gets its own memory namespace. Session keys following the pattern `agent:<agentId>:<uuid>` are parsed to derive isolated namespaces (`${userId}:agent:${agentId}`). Single-agent deployments are unaffected — plain session keys and `agent:main:*` keys resolve to the configured `userId`.
**How it works:**
- The agent's session key is inspected on every recall/capture cycle
- If the key matches `agent:<name>:<uuid>`, memories are stored under `userId:agent:<name>`
- Different agents never see each other's memories unless explicitly queried
**Explicit cross-agent queries:**
All memory tools (`memory_search`, `memory_store`, `memory_list`, `memory_forget`) accept an optional `agentId` parameter to query another agent's namespace:
```
memory_search({ query: "user's tech stack", agentId: "researcher" })
```
Resolution priority: explicit `agentId` > explicit `userId` > session-derived > configured default.
## Setup
```bash
@@ -87,11 +107,11 @@ The agent gets five tools it can call during conversations:
| Tool | Description |
|------|-------------|
| `memory_search` | Search memories by natural language |
| `memory_list` | List all stored memories for a user |
| `memory_store` | Explicitly save a fact |
| `memory_search` | Search memories by natural language. Optional `agentId` to scope to a specific agent. |
| `memory_list` | List all stored memories for a user. Optional `agentId` to scope to a specific agent. |
| `memory_store` | Explicitly save a fact. Optional `agentId` to store under a specific agent's namespace. |
| `memory_get` | Retrieve a memory by ID |
| `memory_forget` | Delete by ID or by query |
| `memory_forget` | Delete by ID or by query. Optional `agentId` to scope deletion to a specific agent. |
## CLI
@@ -107,6 +127,12 @@ openclaw mem0 search "what languages does the user know" --scope session
# Stats
openclaw mem0 stats
# Search a specific agent's memories
openclaw mem0 search "user preferences" --agent researcher
# Stats for a specific agent
openclaw mem0 stats --agent researcher
```
## Options
+165
View File
@@ -0,0 +1,165 @@
/**
* Regression tests for per-agent memory isolation helpers.
*
* Addresses review feedback: targeted coverage for auth/session state,
* malformed input, and the resolveUserId priority chain.
*/
import { describe, it, expect } from "vitest";
import {
extractAgentId,
effectiveUserId,
agentUserId,
resolveUserId,
} from "./index.ts";
// ---------------------------------------------------------------------------
// extractAgentId
// ---------------------------------------------------------------------------
describe("extractAgentId", () => {
it("returns agentId from a well-formed session key", () => {
expect(extractAgentId("agent:researcher:550e8400-e29b")).toBe("researcher");
});
it("returns undefined for the 'main' sentinel", () => {
expect(extractAgentId("agent:main:abc-123")).toBeUndefined();
});
it("returns undefined for undefined/null/empty input", () => {
expect(extractAgentId(undefined)).toBeUndefined();
expect(extractAgentId("")).toBeUndefined();
});
it("returns undefined for non-agent session keys", () => {
expect(extractAgentId("user:alice:xyz")).toBeUndefined();
expect(extractAgentId("some-random-uuid")).toBeUndefined();
});
it("handles keys with extra colons after the UUID portion", () => {
expect(extractAgentId("agent:beta:uuid:extra:stuff")).toBe("beta");
});
it("returns undefined when agentId segment is empty", () => {
// pattern: agent::<uuid> — empty agentId
expect(extractAgentId("agent::some-uuid")).toBeUndefined();
});
it("returns undefined when key is only 'agent:' with no trailing colon", () => {
expect(extractAgentId("agent:")).toBeUndefined();
});
it("is case-sensitive (Agent != agent)", () => {
expect(extractAgentId("Agent:researcher:uuid")).toBeUndefined();
});
it("handles whitespace-only agentId as truthy string", () => {
// " " is a non-empty match — returned as-is (validation is caller's job)
expect(extractAgentId("agent: :uuid")).toBe(" ");
});
});
// ---------------------------------------------------------------------------
// effectiveUserId
// ---------------------------------------------------------------------------
describe("effectiveUserId", () => {
const base = "alice";
it("returns base userId when sessionKey is undefined", () => {
expect(effectiveUserId(base)).toBe("alice");
expect(effectiveUserId(base, undefined)).toBe("alice");
});
it("returns namespaced userId for agent session keys", () => {
expect(effectiveUserId(base, "agent:researcher:uuid-1")).toBe(
"alice:agent:researcher",
);
});
it("falls back to base for 'main' agent sessions", () => {
expect(effectiveUserId(base, "agent:main:uuid-2")).toBe("alice");
});
it("falls back to base for non-agent session keys", () => {
expect(effectiveUserId(base, "plain-session-id")).toBe("alice");
});
});
// ---------------------------------------------------------------------------
// agentUserId
// ---------------------------------------------------------------------------
describe("agentUserId", () => {
it("produces the correct namespaced format", () => {
expect(agentUserId("alice", "researcher")).toBe("alice:agent:researcher");
});
it("handles empty agentId (caller is responsible for validation)", () => {
expect(agentUserId("alice", "")).toBe("alice:agent:");
});
});
// ---------------------------------------------------------------------------
// resolveUserId — priority chain
// ---------------------------------------------------------------------------
describe("resolveUserId", () => {
const base = "alice";
it("prefers explicit agentId over everything else", () => {
expect(
resolveUserId(
base,
{ agentId: "researcher", userId: "bob" },
"agent:beta:uuid",
),
).toBe("alice:agent:researcher");
});
it("uses explicit userId when agentId is absent", () => {
expect(
resolveUserId(base, { userId: "bob" }, "agent:beta:uuid"),
).toBe("bob");
});
it("derives from session key when both agentId and userId are absent", () => {
expect(
resolveUserId(base, {}, "agent:gamma:uuid"),
).toBe("alice:agent:gamma");
});
it("falls back to base userId when nothing else is provided", () => {
expect(resolveUserId(base, {})).toBe("alice");
expect(resolveUserId(base, {}, undefined)).toBe("alice");
});
it("ignores empty-string agentId (falsy)", () => {
expect(resolveUserId(base, { agentId: "" })).toBe("alice");
});
it("ignores empty-string userId (falsy)", () => {
expect(resolveUserId(base, { userId: "" })).toBe("alice");
});
});
// ---------------------------------------------------------------------------
// Cross-agent isolation sanity checks
// ---------------------------------------------------------------------------
describe("multi-agent isolation", () => {
const base = "user-42";
it("different agents get different namespaces", () => {
const alphaId = effectiveUserId(base, "agent:alpha:uuid-a");
const betaId = effectiveUserId(base, "agent:beta:uuid-b");
expect(alphaId).not.toBe(betaId);
expect(alphaId).toBe("user-42:agent:alpha");
expect(betaId).toBe("user-42:agent:beta");
});
it("same agent across sessions yields the same namespace", () => {
const s1 = effectiveUserId(base, "agent:alpha:session-1");
const s2 = effectiveUserId(base, "agent:alpha:session-2");
expect(s1).toBe(s2);
});
it("main session shares the base namespace (no isolation)", () => {
const mainId = effectiveUserId(base, "agent:main:uuid-m");
expect(mainId).toBe(base);
});
});
+116 -26
View File
@@ -10,6 +10,8 @@
* - Short-term (session-scoped) and long-term (user-scoped) memory
* - Auto-recall: injects relevant memories (both scopes) before each agent turn
* - Auto-capture: stores key facts scoped to the current session after each agent turn
* - Per-agent isolation: multi-agent setups write/read from separate userId namespaces
* automatically via sessionKey routing (zero breaking changes for single-agent setups)
* - CLI: openclaw mem0 search, openclaw mem0 stats
* - Dual mode: platform or open-source (self-hosted)
*/
@@ -605,6 +607,50 @@ function categoriesToArray(
return Object.entries(cats).map(([key, value]) => ({ [key]: value }));
}
// ============================================================================
// Per-agent isolation helpers (exported for testability)
// ============================================================================
/**
* Parse an agent ID from a session key following the pattern `agent:<agentId>:<uuid>`.
* Returns undefined for non-agent sessions, the "main" sentinel, or malformed keys.
*/
export function extractAgentId(sessionKey: string | undefined): string | undefined {
if (!sessionKey) return undefined;
const match = sessionKey.match(/^agent:([^:]+):/);
const agentId = match?.[1];
// "main" is the primary session — fall back to configured userId
if (!agentId || agentId === "main") return undefined;
return agentId;
}
/**
* Derive the effective user_id from a session key, namespacing per-agent.
* Falls back to baseUserId when the session is not agent-scoped.
*/
export function effectiveUserId(baseUserId: string, sessionKey?: string): string {
const agentId = extractAgentId(sessionKey);
return agentId ? `${baseUserId}:agent:${agentId}` : baseUserId;
}
/** Build a user_id for an explicit agentId (e.g. from tool params). */
export function agentUserId(baseUserId: string, agentId: string): string {
return `${baseUserId}:agent:${agentId}`;
}
/**
* Resolve user_id with priority: explicit agentId > explicit userId > session-derived > configured.
*/
export function resolveUserId(
baseUserId: string,
opts: { agentId?: string; userId?: string },
currentSessionId?: string,
): string {
if (opts.agentId) return agentUserId(baseUserId, opts.agentId);
if (opts.userId) return opts.userId;
return effectiveUserId(baseUserId, currentSessionId);
}
// ============================================================================
// Plugin Definition
// ============================================================================
@@ -624,14 +670,23 @@ const memoryPlugin = {
// Track current session ID for tool-level session scoping
let currentSessionId: string | undefined;
// ========================================================================
// Per-agent isolation helpers (thin wrappers around exported functions)
// ========================================================================
const _effectiveUserId = (sessionKey?: string) =>
effectiveUserId(cfg.userId, sessionKey);
const _agentUserId = (id: string) => agentUserId(cfg.userId, id);
const _resolveUserId = (opts: { agentId?: string; userId?: string }) =>
resolveUserId(cfg.userId, opts, currentSessionId);
api.logger.info(
`openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, graph: ${cfg.enableGraph}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`,
);
// Helper: build add options
function buildAddOptions(userIdOverride?: string, runId?: string): AddOptions {
function buildAddOptions(userIdOverride?: string, runId?: string, sessionKey?: string): AddOptions {
const opts: AddOptions = {
user_id: userIdOverride || cfg.userId,
user_id: userIdOverride || _effectiveUserId(sessionKey),
source: "OPENCLAW",
};
if (runId) opts.run_id = runId;
@@ -649,9 +704,10 @@ const memoryPlugin = {
userIdOverride?: string,
limit?: number,
runId?: string,
sessionKey?: string,
): SearchOptions {
const opts: SearchOptions = {
user_id: userIdOverride || cfg.userId,
user_id: userIdOverride || _effectiveUserId(sessionKey),
top_k: limit ?? cfg.topK,
limit: limit ?? cfg.topK,
threshold: cfg.searchThreshold,
@@ -686,6 +742,12 @@ const memoryPlugin = {
"User ID to scope search (default: configured userId)",
}),
),
agentId: Type.Optional(
Type.String({
description:
"Agent ID to search memories for a specific agent (e.g. \"researcher\"). Overrides userId.",
}),
),
scope: Type.Optional(
Type.Union([
Type.Literal("session"),
@@ -698,39 +760,41 @@ const memoryPlugin = {
),
}),
async execute(_toolCallId, params) {
const { query, limit, userId, scope = "all" } = params as {
const { query, limit, userId, agentId, scope = "all" } = params as {
query: string;
limit?: number;
userId?: string;
agentId?: string;
scope?: "session" | "long-term" | "all";
};
try {
let results: MemoryItem[] = [];
const uid = _resolveUserId({ agentId, userId });
if (scope === "session") {
if (currentSessionId) {
results = await provider.search(
query,
buildSearchOptions(userId, limit, currentSessionId),
buildSearchOptions(uid, limit, currentSessionId),
);
}
} else if (scope === "long-term") {
results = await provider.search(
query,
buildSearchOptions(userId, limit),
buildSearchOptions(uid, limit),
);
} else {
// "all" — search both scopes and combine
const longTermResults = await provider.search(
query,
buildSearchOptions(userId, limit),
buildSearchOptions(uid, limit),
);
let sessionResults: MemoryItem[] = [];
if (currentSessionId) {
sessionResults = await provider.search(
query,
buildSearchOptions(userId, limit, currentSessionId),
buildSearchOptions(uid, limit, currentSessionId),
);
}
// Deduplicate by ID, preferring long-term
@@ -803,6 +867,12 @@ const memoryPlugin = {
description: "User ID to scope this memory",
}),
),
agentId: Type.Optional(
Type.String({
description:
"Agent ID to store memory under a specific agent's namespace (e.g. \"researcher\"). Overrides userId.",
}),
),
metadata: Type.Optional(
Type.Record(Type.String(), Type.Unknown(), {
description: "Optional metadata to attach to this memory",
@@ -816,18 +886,20 @@ const memoryPlugin = {
),
}),
async execute(_toolCallId, params) {
const { text, userId, longTerm = true } = params as {
const { text, userId, agentId, longTerm = true } = params as {
text: string;
userId?: string;
agentId?: string;
metadata?: Record<string, unknown>;
longTerm?: boolean;
};
try {
const uid = _resolveUserId({ agentId, userId });
const runId = !longTerm && currentSessionId ? currentSessionId : undefined;
const result = await provider.add(
[{ role: "user", content: text }],
buildAddOptions(userId, runId),
buildAddOptions(uid, runId, currentSessionId),
);
const added =
@@ -919,7 +991,7 @@ const memoryPlugin = {
name: "memory_list",
label: "Memory List",
description:
"List all stored memories for a user. Use this when you want to see everything that's been remembered, rather than searching for something specific.",
"List all stored memories for a user or agent. Use this when you want to see everything that's been remembered, rather than searching for something specific.",
parameters: Type.Object({
userId: Type.Optional(
Type.String({
@@ -927,6 +999,12 @@ const memoryPlugin = {
"User ID to list memories for (default: configured userId)",
}),
),
agentId: Type.Optional(
Type.String({
description:
"Agent ID to list memories for a specific agent (e.g. \"researcher\"). Overrides userId.",
}),
),
scope: Type.Optional(
Type.Union([
Type.Literal("session"),
@@ -939,11 +1017,11 @@ const memoryPlugin = {
),
}),
async execute(_toolCallId, params) {
const { userId, scope = "all" } = params as { userId?: string; scope?: "session" | "long-term" | "all" };
const { userId, agentId, scope = "all" } = params as { userId?: string; agentId?: string; scope?: "session" | "long-term" | "all" };
try {
let memories: MemoryItem[] = [];
const uid = userId || cfg.userId;
const uid = _resolveUserId({ agentId, userId });
if (scope === "session") {
if (currentSessionId) {
@@ -1026,7 +1104,7 @@ const memoryPlugin = {
name: "memory_forget",
label: "Memory Forget",
description:
"Delete memories from Mem0. Provide a specific memoryId to delete directly, or a query to search and delete matching memories. GDPR-compliant.",
"Delete memories from Mem0. Provide a specific memoryId to delete directly, or a query to search and delete matching memories. Supports agent-scoped deletion. GDPR-compliant.",
parameters: Type.Object({
query: Type.Optional(
Type.String({
@@ -1036,11 +1114,18 @@ const memoryPlugin = {
memoryId: Type.Optional(
Type.String({ description: "Specific memory ID to delete" }),
),
agentId: Type.Optional(
Type.String({
description:
"Agent ID to scope deletion to a specific agent's memories (e.g. \"researcher\").",
}),
),
}),
async execute(_toolCallId, params) {
const { query, memoryId } = params as {
const { query, memoryId, agentId } = params as {
query?: string;
memoryId?: string;
agentId?: string;
};
try {
@@ -1055,9 +1140,10 @@ const memoryPlugin = {
}
if (query) {
const uid = _resolveUserId({ agentId });
const results = await provider.search(
query,
buildSearchOptions(undefined, 5),
buildSearchOptions(uid, 5),
);
if (!results || results.length === 0) {
@@ -1148,10 +1234,12 @@ const memoryPlugin = {
.argument("<query>", "Search query")
.option("--limit <n>", "Max results", String(cfg.topK))
.option("--scope <scope>", 'Memory scope: "session", "long-term", or "all"', "all")
.action(async (query: string, opts: { limit: string; scope: string }) => {
.option("--agent <agentId>", "Search a specific agent's memory namespace")
.action(async (query: string, opts: { limit: string; scope: string; agent?: string }) => {
try {
const limit = parseInt(opts.limit, 10);
const scope = opts.scope as "session" | "long-term" | "all";
const uid = opts.agent ? _agentUserId(opts.agent) : _effectiveUserId(currentSessionId);
let allResults: MemoryItem[] = [];
@@ -1159,7 +1247,7 @@ const memoryPlugin = {
if (currentSessionId) {
const sessionResults = await provider.search(
query,
buildSearchOptions(undefined, limit, currentSessionId),
buildSearchOptions(uid, limit, currentSessionId),
);
if (sessionResults?.length) {
allResults.push(...sessionResults.map((r) => ({ ...r, _scope: "session" as const })));
@@ -1173,7 +1261,7 @@ const memoryPlugin = {
if (scope === "long-term" || scope === "all") {
const longTermResults = await provider.search(
query,
buildSearchOptions(undefined, limit),
buildSearchOptions(uid, limit),
);
if (longTermResults?.length) {
allResults.push(...longTermResults.map((r) => ({ ...r, _scope: "long-term" as const })));
@@ -1212,14 +1300,16 @@ const memoryPlugin = {
mem0
.command("stats")
.description("Show memory statistics from Mem0")
.action(async () => {
.option("--agent <agentId>", "Show stats for a specific agent")
.action(async (opts: { agent?: string }) => {
try {
const uid = opts.agent ? _agentUserId(opts.agent) : cfg.userId;
const memories = await provider.getAll({
user_id: cfg.userId,
user_id: uid,
source: "OPENCLAW",
});
console.log(`Mode: ${cfg.mode}`);
console.log(`User: ${cfg.userId}`);
console.log(`User: ${uid}${opts.agent ? ` (agent: ${opts.agent})` : ""}`);
console.log(
`Total memories: ${Array.isArray(memories) ? memories.length : "unknown"}`,
);
@@ -1249,10 +1339,10 @@ const memoryPlugin = {
if (sessionId) currentSessionId = sessionId;
try {
// Search long-term memories (user-scoped)
// Search long-term memories (user-scoped, isolated per agent)
const longTermResults = await provider.search(
event.prompt,
buildSearchOptions(),
buildSearchOptions(undefined, undefined, undefined, sessionId),
);
// Search session memories (session-scoped) if we have a session ID
@@ -1260,7 +1350,7 @@ const memoryPlugin = {
if (currentSessionId) {
sessionResults = await provider.search(
event.prompt,
buildSearchOptions(undefined, undefined, currentSessionId),
buildSearchOptions(undefined, undefined, currentSessionId, sessionId),
);
}
@@ -1365,7 +1455,7 @@ const memoryPlugin = {
if (formattedMessages.length === 0) return;
const addOpts = buildAddOptions(undefined, currentSessionId);
const addOpts = buildAddOptions(undefined, currentSessionId, sessionId);
const result = await provider.add(
formattedMessages,
addOpts,
+7 -1
View File
@@ -11,6 +11,9 @@
"mem0",
"long-term-memory"
],
"scripts": {
"test": "vitest run"
},
"dependencies": {
"@sinclair/typebox": "0.34.47",
"mem0ai": "^2.2.1"
@@ -19,5 +22,8 @@
"extensions": [
"./index.ts"
]
},
"devDependencies": {
"vitest": "^4.0.18"
}
}
}