From 4f41780053ae00163b5d04aac3696a7da738ea56 Mon Sep 17 00:00:00 2001 From: Xudong Huang Date: Sat, 14 Mar 2026 13:36:52 +0800 Subject: [PATCH 1/2] feat(openclaw-memory-plugin): support per-agent memory isolation Use the host-provided agent ID to namespace memories so that each agent on the same OpenClaw instance gets its own isolated memory space. The default primary agent "main" maps to "default" for backward compatibility with existing memories. - Pass agentId from ctx to all OpenViking API requests - Update agentId config help text and placeholder - Add "Multi-Agent Memory Isolation" section to README --- examples/openclaw-memory-plugin/README.md | 24 ++++ examples/openclaw-memory-plugin/client.ts | 106 ++++++++---------- examples/openclaw-memory-plugin/config.ts | 11 +- examples/openclaw-memory-plugin/index.ts | 84 +++++++------- .../openclaw.plugin.json | 4 +- 5 files changed, 120 insertions(+), 109 deletions(-) diff --git a/examples/openclaw-memory-plugin/README.md b/examples/openclaw-memory-plugin/README.md index da84b392..2755ae65 100644 --- a/examples/openclaw-memory-plugin/README.md +++ b/examples/openclaw-memory-plugin/README.md @@ -36,6 +36,7 @@ Use [OpenViking](https://github.com/volcengine/OpenViking) as the long-term memo - [Configuration Reference](#configuration-reference) - [Daily Usage](#daily-usage) - [Web Console (Visualization)](#web-console-visualization) +- [Multi-Agent Memory Isolation](#multi-agent-memory-isolation) - [Troubleshooting](#troubleshooting) - [Uninstallation](#uninstallation) @@ -411,6 +412,29 @@ Open http://127.0.0.1:8020 in your browser. --- +## Multi-Agent Memory Isolation + +Previously, all agents on the same OpenClaw instance shared a single memory namespace — memories stored by one agent were visible to every other agent. The plugin now supports **per-agent memory isolation**: each agent's memories are automatically namespaced by its agent ID, so agents no longer see each other's memories. + +**This is enabled by default.** No extra configuration is needed — simply leave the `agentId` config empty and the plugin will use the agent ID provided by the OpenClaw host. + +| `agentId` config | Behavior | +|---|---| +| **Not set** (default, recommended) | Each agent gets its own isolated memory namespace. The plugin reads the agent ID from the OpenClaw host automatically. | +| **Set to a fixed value** (e.g. `"default"`) | All agents using this value share the same memory namespace (the old behavior). | + +> **Backward compatibility:** OpenClaw's default primary agent ID is `main`. For compatibility with previous versions (where all memories were stored under `default`), the plugin maps `main` to the `default` namespace — so existing memories remain accessible after upgrading. Other agents get their own isolated namespace based on their agent ID. + +### Reverting to Shared Memory + +If you need all agents to share the same memories (the previous behavior), set a fixed `agentId`: + +```bash +openclaw config set plugins.entries.memory-openviking.config.agentId "default" +``` + +--- + ## Troubleshooting ### Common Issues diff --git a/examples/openclaw-memory-plugin/client.ts b/examples/openclaw-memory-plugin/client.ts index edc11f48..fc3e1070 100644 --- a/examples/openclaw-memory-plugin/client.ts +++ b/examples/openclaw-memory-plugin/client.ts @@ -58,37 +58,16 @@ export function isMemoryUri(uri: string): boolean { } export class OpenVikingClient { - private resolvedSpaceByScope: Partial> = {}; - private runtimeIdentity: RuntimeIdentity | null = null; + private readonly resolvedSpaceCache = new Map(); + private userId: string | null = null; constructor( private readonly baseUrl: string, private readonly apiKey: string, - private agentId: string, private readonly timeoutMs: number, ) {} - /** - * Dynamically switch the agent identity for multi-agent memory isolation. - * When a shared client serves multiple agents (e.g. in OpenClaw multi-agent - * gateway), call this before each agent's recall/capture to route memories - * to the correct agent_space = md5(user_id + agent_id)[:12]. - * Clears cached space resolution so the next request re-derives agent_space. - */ - setAgentId(newAgentId: string): void { - if (newAgentId && newAgentId !== this.agentId) { - this.agentId = newAgentId; - // Clear cached identity and spaces — they depend on agentId - this.runtimeIdentity = null; - this.resolvedSpaceByScope = {}; - } - } - - getAgentId(): string { - return this.agentId; - } - - private async request(path: string, init: RequestInit = {}): Promise { + private async request(path: string, init: RequestInit = {}, agentId?: string): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.timeoutMs); try { @@ -96,8 +75,8 @@ export class OpenVikingClient { if (this.apiKey) { headers.set("X-API-Key", this.apiKey); } - if (this.agentId) { - headers.set("X-OpenViking-Agent", this.agentId); + if (agentId) { + headers.set("X-OpenViking-Agent", agentId); } if (init.body && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); @@ -131,36 +110,41 @@ export class OpenVikingClient { await this.request<{ status: string }>("/health"); } - private async ls(uri: string): Promise>> { + private async ls(uri: string, agentId: string): Promise>> { return this.request>>( `/api/v1/fs/ls?uri=${encodeURIComponent(uri)}&output=original`, + {}, + agentId ); } - private async getRuntimeIdentity(): Promise { - if (this.runtimeIdentity) { - return this.runtimeIdentity; + private async getUserId(): Promise { + if (this.userId) { + return this.userId; } - const fallback: RuntimeIdentity = { userId: "default", agentId: this.agentId || "default" }; try { const status = await this.request<{ user?: unknown }>("/api/v1/system/status"); - const userId = + this.userId = typeof status.user === "string" && status.user.trim() ? status.user.trim() : "default"; - this.runtimeIdentity = { userId, agentId: this.agentId || "default" }; - return this.runtimeIdentity; } catch { - this.runtimeIdentity = fallback; - return fallback; + this.userId = "default"; } + return this.userId; + } + + private async getRuntimeIdentity(agentId: string): Promise { + const userId = await this.getUserId(); + return { userId, agentId: agentId || "default" }; } - private async resolveScopeSpace(scope: ScopeName): Promise { - const cached = this.resolvedSpaceByScope[scope]; + private async resolveScopeSpace(scope: ScopeName, agentId: string): Promise { + const cacheKey = `${scope}:${agentId}`; + const cached = this.resolvedSpaceCache.get(cacheKey); if (cached) { return cached; } - const identity = await this.getRuntimeIdentity(); + const identity = await this.getRuntimeIdentity(agentId); const fallbackSpace = scope === "user" ? identity.userId : md5Short(`${identity.userId}:${identity.agentId}`); const reservedDirs = scope === "user" ? USER_STRUCTURE_DIRS : AGENT_STRUCTURE_DIRS; @@ -168,7 +152,7 @@ export class OpenVikingClient { scope === "user" ? identity.userId : md5Short(`${identity.userId}:${identity.agentId}`); try { - const entries = await this.ls(`viking://${scope}`); + const entries = await this.ls(`viking://${scope}`, agentId); const spaces = entries .filter((entry) => entry?.isDir === true) .map((entry) => (typeof entry.name === "string" ? entry.name.trim() : "")) @@ -176,15 +160,15 @@ export class OpenVikingClient { if (spaces.length > 0) { if (spaces.includes(preferredSpace)) { - this.resolvedSpaceByScope[scope] = preferredSpace; + this.resolvedSpaceCache.set(cacheKey, preferredSpace); return preferredSpace; } if (scope === "user" && spaces.includes("default")) { - this.resolvedSpaceByScope[scope] = "default"; + this.resolvedSpaceCache.set(cacheKey, "default"); return "default"; } if (spaces.length === 1) { - this.resolvedSpaceByScope[scope] = spaces[0]!; + this.resolvedSpaceCache.set(cacheKey, spaces[0]!); return spaces[0]!; } } @@ -192,11 +176,11 @@ export class OpenVikingClient { // Fall back to identity-derived space when listing fails. } - this.resolvedSpaceByScope[scope] = fallbackSpace; + this.resolvedSpaceCache.set(cacheKey, fallbackSpace); return fallbackSpace; } - private async normalizeTargetUri(targetUri: string): Promise { + private async normalizeTargetUri(targetUri: string, agentId: string): Promise { const trimmed = targetUri.trim().replace(/\/+$/, ""); const match = trimmed.match(/^viking:\/\/(user|agent)(?:\/(.*))?$/); if (!match) { @@ -217,7 +201,7 @@ export class OpenVikingClient { return trimmed; } - const space = await this.resolveScopeSpace(scope); + const space = await this.resolveScopeSpace(scope, agentId); return `viking://${scope}/${space}/${parts.join("/")}`; } @@ -227,9 +211,10 @@ export class OpenVikingClient { targetUri: string; limit: number; scoreThreshold?: number; + agentId: string; }, ): Promise { - const normalizedTargetUri = await this.normalizeTargetUri(options.targetUri); + const normalizedTargetUri = await this.normalizeTargetUri(options.targetUri, options.agentId); const body = { query, target_uri: normalizedTargetUri, @@ -239,55 +224,60 @@ export class OpenVikingClient { return this.request("/api/v1/search/find", { method: "POST", body: JSON.stringify(body), - }); + }, options.agentId); } - async read(uri: string): Promise { + async read(uri: string, agentId: string): Promise { return this.request( `/api/v1/content/read?uri=${encodeURIComponent(uri)}`, + {}, + agentId, ); } - async createSession(): Promise { + async createSession(agentId: string): Promise { const result = await this.request<{ session_id: string }>("/api/v1/sessions", { method: "POST", body: JSON.stringify({}), - }); + }, agentId); return result.session_id; } - async addSessionMessage(sessionId: string, role: string, content: string): Promise { + async addSessionMessage(sessionId: string, role: string, content: string, agentId: string): Promise { await this.request<{ session_id: string }>( `/api/v1/sessions/${encodeURIComponent(sessionId)}/messages`, { method: "POST", body: JSON.stringify({ role, content }), }, + agentId, ); } /** GET session so server loads messages from storage before extract (workaround for AGFS visibility). */ - async getSession(sessionId: string): Promise<{ message_count?: number }> { + async getSession(sessionId: string, agentId: string): Promise<{ message_count?: number }> { return this.request<{ message_count?: number }>( `/api/v1/sessions/${encodeURIComponent(sessionId)}`, { method: "GET" }, + agentId, ); } - async extractSessionMemories(sessionId: string): Promise>> { + async extractSessionMemories(sessionId: string, agentId: string): Promise>> { return this.request>>( `/api/v1/sessions/${encodeURIComponent(sessionId)}/extract`, { method: "POST", body: JSON.stringify({}) }, + agentId, ); } - async deleteSession(sessionId: string): Promise { - await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" }); + async deleteSession(sessionId: string, agentId: string): Promise { + await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" }, agentId); } - async deleteUri(uri: string): Promise { + async deleteUri(uri: string, agentId: string): Promise { await this.request(`/api/v1/fs?uri=${encodeURIComponent(uri)}&recursive=false`, { method: "DELETE", - }); + }, agentId); } } diff --git a/examples/openclaw-memory-plugin/config.ts b/examples/openclaw-memory-plugin/config.ts index 3fccd12e..890f0827 100644 --- a/examples/openclaw-memory-plugin/config.ts +++ b/examples/openclaw-memory-plugin/config.ts @@ -38,13 +38,10 @@ const DEFAULT_INGEST_REPLY_ASSIST_MIN_SPEAKER_TURNS = 2; const DEFAULT_INGEST_REPLY_ASSIST_MIN_CHARS = 120; const DEFAULT_LOCAL_CONFIG_PATH = join(homedir(), ".openviking", "ov.conf"); -const DEFAULT_AGENT_ID = "default"; - -function resolveAgentId(configured: unknown): string { +function resolveAgentId(configured: unknown): string | undefined { if (typeof configured === "string" && configured.trim()) { return configured.trim(); } - return DEFAULT_AGENT_ID; } function resolveEnvVars(value: string): string { @@ -87,7 +84,7 @@ function resolveDefaultBaseUrl(): string { } export const memoryOpenVikingConfigSchema = { - parse(value: unknown): Required { + parse(value: unknown): Required> & Pick { if (!value || typeof value !== "object" || Array.isArray(value)) { value = {}; } @@ -208,8 +205,8 @@ export const memoryOpenVikingConfigSchema = { }, agentId: { label: "Agent ID", - placeholder: "auto-generated", - help: "Identifies this agent to OpenViking (sent as X-OpenViking-Agent header). Defaults to \"default\" if not set.", + placeholder: "default", + help: "Leave empty for per-agent memory isolation (recommended). The host-provided agent ID is used to namespace memories; \"main\" maps to \"default\" for backward compatibility. Set a fixed value (e.g. \"default\") to share one namespace across all agents.", }, apiKey: { label: "OpenViking API Key", diff --git a/examples/openclaw-memory-plugin/index.ts b/examples/openclaw-memory-plugin/index.ts index f4f82f9a..15890e46 100644 --- a/examples/openclaw-memory-plugin/index.ts +++ b/examples/openclaw-memory-plugin/index.ts @@ -90,13 +90,17 @@ const memoryPlugin = { } } } else { - clientPromise = Promise.resolve(new OpenVikingClient(cfg.baseUrl, cfg.apiKey, cfg.agentId, cfg.timeoutMs)); + clientPromise = Promise.resolve(new OpenVikingClient(cfg.baseUrl, cfg.apiKey, cfg.timeoutMs)); } const getClient = (): Promise => clientPromise; + const getAgentId = (agentId?: string): string => { + return cfg.agentId ?? (agentId === "main" ? "default" : agentId) ?? "default"; + }; + api.registerTool( - { + (ctx) => ({ name: "memory_recall", label: "Memory Recall (OpenViking)", description: @@ -114,6 +118,7 @@ const memoryPlugin = { ), }), async execute(_toolCallId: string, params: Record) { + const agentId = getAgentId(ctx.agentId); const { query } = params as { query: string }; const limit = typeof (params as { limit?: number }).limit === "number" @@ -136,6 +141,7 @@ const memoryPlugin = { targetUri, limit: requestLimit, scoreThreshold: 0, + agentId, }); } else { // 默认同时检索 user 和 agent 两个位置的记忆 @@ -144,11 +150,13 @@ const memoryPlugin = { targetUri: "viking://user/memories", limit: requestLimit, scoreThreshold: 0, + agentId, }), (await getClient()).find(query, { targetUri: "viking://agent/memories", limit: requestLimit, scoreThreshold: 0, + agentId, }), ]); const userResult = userSettled.status === "fulfilled" ? userSettled.value : { memories: [] }; @@ -191,12 +199,12 @@ const memoryPlugin = { }, }; }, - }, + }), { name: "memory_recall" }, ); api.registerTool( - { + (ctx) => ({ name: "memory_store", label: "Memory Store (OpenViking)", description: @@ -207,6 +215,7 @@ const memoryPlugin = { sessionId: Type.Optional(Type.String({ description: "Existing OpenViking session ID" })), }), async execute(_toolCallId: string, params: Record) { + const agentId = getAgentId(ctx.agentId); const { text } = params as { text: string }; const role = typeof (params as { role?: string }).role === "string" @@ -223,11 +232,11 @@ const memoryPlugin = { try { const c = await getClient(); if (!sessionId) { - sessionId = await c.createSession(); + sessionId = await c.createSession(agentId); createdTempSession = true; } - await c.addSessionMessage(sessionId, role, text); - const extracted = await c.extractSessionMemories(sessionId); + await c.addSessionMessage(sessionId, role, text, agentId); + const extracted = await c.extractSessionMemories(sessionId, agentId); if (extracted.length === 0) { api.logger.warn( `memory-openviking: memory_store completed but extract returned 0 memories (sessionId=${sessionId}). ` + @@ -251,16 +260,16 @@ const memoryPlugin = { } finally { if (createdTempSession && sessionId) { const c = await getClient().catch(() => null); - if (c) await c.deleteSession(sessionId!).catch(() => {}); + if (c) await c.deleteSession(sessionId!, agentId).catch(() => {}); } } }, - }, + }), { name: "memory_store" }, ); api.registerTool( - { + (ctx) => ({ name: "memory_forget", label: "Memory Forget (OpenViking)", description: @@ -277,6 +286,7 @@ const memoryPlugin = { ), }), async execute(_toolCallId: string, params: Record) { + const agentId = getAgentId(ctx.agentId); const uri = (params as { uri?: string }).uri; if (uri) { if (!isMemoryUri(uri)) { @@ -285,7 +295,7 @@ const memoryPlugin = { details: { action: "rejected", uri }, }; } - await (await getClient()).deleteUri(uri); + await (await getClient()).deleteUri(uri, agentId); return { content: [{ type: "text", text: `Forgotten: ${uri}` }], details: { action: "deleted", uri }, @@ -318,6 +328,7 @@ const memoryPlugin = { targetUri, limit: requestLimit, scoreThreshold: 0, + agentId, }); const candidates = postProcessMemories(result.memories ?? [], { limit: requestLimit, @@ -337,7 +348,7 @@ const memoryPlugin = { } const top = candidates[0]; if (candidates.length === 1 && clampScore(top.score) >= 0.85) { - await (await getClient()).deleteUri(top.uri); + await (await getClient()).deleteUri(top.uri, agentId); return { content: [{ type: "text", text: `Forgotten: ${top.uri}` }], details: { action: "deleted", uri: top.uri, score: top.score ?? 0 }, @@ -358,21 +369,13 @@ const memoryPlugin = { details: { action: "candidates", candidates, scoreThreshold, requestLimit }, }; }, - }, + }), { name: "memory_forget" }, ); if (cfg.autoRecall || cfg.ingestReplyAssist) { - api.on("before_agent_start", async (event: { messages?: unknown[]; prompt: string }, ctx?: { agentId?: string }) => { - // Dynamically switch agent identity for multi-agent memory isolation. - // In multi-agent gateway deployments, the hook context carries the current - // agent's ID so we route memory operations to the correct agent_space. - const hookAgentId = ctx?.agentId; - if (hookAgentId) { - const client = await getClient(); - client.setAgentId(hookAgentId); - api.logger.info?.(`memory-openviking: switched to agentId=${hookAgentId} for recall`); - } + api.on("before_agent_start", async (event: { messages?: unknown[]; prompt: string }, ctx) => { + const agentId = getAgentId(ctx.agentId); const queryText = extractLatestUserText(event.messages) || event.prompt.trim(); if (!queryText) { return; @@ -398,6 +401,7 @@ const memoryPlugin = { targetUri: "viking://user/memories", limit: candidateLimit, scoreThreshold: 0, + agentId, }), ), getClient().then((client) => @@ -405,6 +409,7 @@ const memoryPlugin = { targetUri: "viking://agent/memories", limit: candidateLimit, scoreThreshold: 0, + agentId, }), ), ]); @@ -436,7 +441,7 @@ const memoryPlugin = { memories.map(async (item: FindResultItem) => { if (item.level === 2) { try { - const content = await client.read(item.uri); + const content = await client.read(item.uri, agentId); if (content && typeof content === "string" && content.trim()) { return `- [${item.category ?? "memory"}] ${content.trim()}`; } @@ -499,16 +504,9 @@ const memoryPlugin = { } if (cfg.autoCapture) { - let lastProcessedMsgCount = 0; - - api.on("agent_end", async (event: { success?: boolean; messages?: unknown[] }, ctx?: { agentId?: string }) => { - // Dynamically switch agent identity for multi-agent memory isolation - const hookAgentId = ctx?.agentId; - if (hookAgentId) { - const client = await getClient(); - client.setAgentId(hookAgentId); - api.logger.info?.(`memory-openviking: switched to agentId=${hookAgentId} for capture`); - } + const lastProcessedMsgCountByAgent = new Map(); + + api.on("agent_end", async (event: { success?: boolean; messages?: unknown[] }, ctx) => { if (!event.success || !event.messages || event.messages.length === 0) { api.logger.info( `memory-openviking: auto-capture skipped (success=${String(event.success)}, messages=${event.messages?.length ?? 0})`, @@ -516,9 +514,11 @@ const memoryPlugin = { return; } try { + const agentId = getAgentId(ctx.agentId); const messages = event.messages; - const { texts: newTexts, newCount } = extractNewTurnTexts(messages, lastProcessedMsgCount); - lastProcessedMsgCount = messages.length; + const lastCount = lastProcessedMsgCountByAgent.get(agentId) ?? 0; + const { texts: newTexts, newCount } = extractNewTurnTexts(messages, lastCount); + lastProcessedMsgCountByAgent.set(agentId, messages.length); if (newTexts.length === 0) { api.logger.info("memory-openviking: auto-capture skipped (no new user/assistant messages)"); @@ -540,12 +540,12 @@ const memoryPlugin = { } const c = await getClient(); - const sessionId = await c.createSession(); + const sessionId = await c.createSession(agentId); try { - await c.addSessionMessage(sessionId, "user", decision.normalizedText); + await c.addSessionMessage(sessionId, "user", decision.normalizedText, agentId); // Force server to read session so storage (e.g. AGFS) sees the written messages before extract - await c.getSession(sessionId).catch(() => ({})); - const extracted = await c.extractSessionMemories(sessionId); + await c.getSession(sessionId, agentId).catch(() => ({})); + const extracted = await c.extractSessionMemories(sessionId, agentId); api.logger.info( `memory-openviking: auto-captured ${newCount} new messages, extracted ${extracted.length} memories`, ); @@ -563,7 +563,7 @@ const memoryPlugin = { ); } } finally { - await c.deleteSession(sessionId).catch(() => {}); + await c.deleteSession(sessionId, agentId).catch(() => {}); } } catch (err) { api.logger.warn(`memory-openviking: auto-capture failed: ${String(err)}`); @@ -659,7 +659,7 @@ const memoryPlugin = { }); try { await waitForHealth(baseUrl, timeoutMs, intervalMs); - const client = new OpenVikingClient(baseUrl, cfg.apiKey, cfg.agentId, cfg.timeoutMs); + const client = new OpenVikingClient(baseUrl, cfg.apiKey, cfg.timeoutMs); localClientCache.set(localCacheKey, { client, process: child }); resolveLocalClient(client); rejectLocalClient = null; diff --git a/examples/openclaw-memory-plugin/openclaw.plugin.json b/examples/openclaw-memory-plugin/openclaw.plugin.json index 85e18ac8..1ca27434 100644 --- a/examples/openclaw-memory-plugin/openclaw.plugin.json +++ b/examples/openclaw-memory-plugin/openclaw.plugin.json @@ -24,8 +24,8 @@ }, "agentId": { "label": "Agent ID", - "placeholder": "random unique ID", - "help": "Identifies this agent to OpenViking. A random unique ID is generated if not set." + "placeholder": "default", + "help": "Leave empty for per-agent memory isolation (recommended). The host-provided agent ID is used to namespace memories; \"main\" maps to \"default\" for backward compatibility. Set a fixed value (e.g. \"default\") to share one namespace across all agents." }, "apiKey": { "label": "OpenViking API Key", From 672ee0922c3c98bef2c8c095cf762ed560aeccbb Mon Sep 17 00:00:00 2001 From: Xudong Huang Date: Tue, 17 Mar 2026 22:26:19 +0800 Subject: [PATCH 2/2] fix: add non-null assertion for `resolveLocalClient` --- examples/openclaw-memory-plugin/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/openclaw-memory-plugin/index.ts b/examples/openclaw-memory-plugin/index.ts index 15890e46..7aee3e6a 100644 --- a/examples/openclaw-memory-plugin/index.ts +++ b/examples/openclaw-memory-plugin/index.ts @@ -661,7 +661,7 @@ const memoryPlugin = { await waitForHealth(baseUrl, timeoutMs, intervalMs); const client = new OpenVikingClient(baseUrl, cfg.apiKey, cfg.timeoutMs); localClientCache.set(localCacheKey, { client, process: child }); - resolveLocalClient(client); + resolveLocalClient!(client); rejectLocalClient = null; localClientPendingPromises.delete(localCacheKey); api.logger.info(