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..7aee3e6a 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,9 +659,9 @@ 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); + resolveLocalClient!(client); rejectLocalClient = null; localClientPendingPromises.delete(localCacheKey); api.logger.info( 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",