diff --git a/docs/en/guides/01-configuration.md b/docs/en/guides/01-configuration.md index 4a71fc4d7..ad2829ca3 100644 --- a/docs/en/guides/01-configuration.md +++ b/docs/en/guides/01-configuration.md @@ -771,7 +771,8 @@ For memory-related settings, add a `memory` section in `ov.conf`: ```json { "memory": { - "agent_scope_mode": "user+agent" + "agent_scope_mode": "user+agent", + "scope_mode": "default" } } ``` @@ -779,8 +780,12 @@ For memory-related settings, add a `memory` section in `ov.conf`: | Field | Description | Default | |-------|-------------|---------| | `agent_scope_mode` | Agent memory namespace mode: `"user+agent"` isolates by `(user_id, agent_id)`, while `"agent"` isolates only by `agent_id` and shares agent memories across users of the same agent | `"user+agent"` | +| `scope_mode` | Memory category routing mode: `"default"` routes user-level categories (profile/preferences/entities/events) to shared user space, `"isolated"` routes ALL categories to agent space for full inter-agent isolation | `"default"` | -`agent_scope_mode` only affects agent-level namespaces such as `viking://agent/{agent_space}/memories/...`. User memories under `viking://user/{user_space}/memories/...` are not affected. +`agent_scope_mode` controls how the agent space hash is computed. `scope_mode` controls which memory categories are routed to agent vs user scope. These are independent settings: + +- `agent_scope_mode` only affects agent-level namespaces such as `viking://agent/{agent_space}/memories/...`. User memories under `viking://user/{user_space}/memories/...` are not affected. +- `scope_mode="isolated"` moves all categories (including profile and preferences) into `viking://agent/{agent_space}/memories/...`, achieving full inter-agent memory isolation. ### ovcli.conf diff --git a/docs/zh/guides/01-configuration.md b/docs/zh/guides/01-configuration.md index 65b6040ba..8f62b3a38 100644 --- a/docs/zh/guides/01-configuration.md +++ b/docs/zh/guides/01-configuration.md @@ -748,7 +748,8 @@ openviking-server --config /path/to/ov.conf ```json { "memory": { - "agent_scope_mode": "user+agent" + "agent_scope_mode": "user+agent", + "scope_mode": "default" } } ``` @@ -756,8 +757,12 @@ openviking-server --config /path/to/ov.conf | 字段 | 说明 | 默认值 | |------|------|--------| | `agent_scope_mode` | Agent memory 命名空间模式:`"user+agent"` 按 `(user_id, agent_id)` 隔离;`"agent"` 仅按 `agent_id` 隔离,同一 agent 的不同用户共享 agent memory | `"user+agent"` | +| `scope_mode` | 记忆类别路由模式:`"default"` 将用户级类别(profile/preferences/entities/events)路由到共享的 user space;`"isolated"` 将所有类别路由到 agent space,实现 agent 间完全记忆隔离 | `"default"` | -`agent_scope_mode` 只影响 `viking://agent/{agent_space}/memories/...` 这类 agent 级命名空间,不影响 `viking://user/{user_space}/memories/...` 下的 user memory。 +`agent_scope_mode` 控制 agent space hash 的计算方式,`scope_mode` 控制记忆类别写入哪个 scope。两者独立配合: + +- `agent_scope_mode` 只影响 `viking://agent/{agent_space}/memories/...` 这类 agent 级命名空间,不影响 `viking://user/{user_space}/memories/...` 下的 user memory。 +- `scope_mode="isolated"` 将所有类别(包括 profile 和 preferences)移入 `viking://agent/{agent_space}/memories/...`,实现 agent 间完全记忆隔离。 ### ovcli.conf diff --git a/examples/openclaw-plugin/__tests__/strict-agent-isolation.test.ts b/examples/openclaw-plugin/__tests__/strict-agent-isolation.test.ts new file mode 100644 index 000000000..3c695959d --- /dev/null +++ b/examples/openclaw-plugin/__tests__/strict-agent-isolation.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { memoryOpenVikingConfigSchema } from "../config.js"; + +describe("strictAgentIsolation config", () => { + it("defaults to false when not specified", () => { + const cfg = memoryOpenVikingConfigSchema.parse({}); + expect(cfg.strictAgentIsolation).toBe(false); + }); + + it("defaults to false when explicitly set to false", () => { + const cfg = memoryOpenVikingConfigSchema.parse({ strictAgentIsolation: false }); + expect(cfg.strictAgentIsolation).toBe(false); + }); + + it("resolves to true when explicitly set to true", () => { + const cfg = memoryOpenVikingConfigSchema.parse({ strictAgentIsolation: true }); + expect(cfg.strictAgentIsolation).toBe(true); + }); + + it("treats non-boolean truthy values as false (strict boolean check)", () => { + const cfg = memoryOpenVikingConfigSchema.parse({ strictAgentIsolation: "yes" }); + expect(cfg.strictAgentIsolation).toBe(false); + }); + + it("is accepted in allowedKeys without throwing", () => { + expect(() => + memoryOpenVikingConfigSchema.parse({ strictAgentIsolation: true }), + ).not.toThrow(); + }); + + it("still rejects unknown keys alongside strictAgentIsolation", () => { + expect(() => + memoryOpenVikingConfigSchema.parse({ + strictAgentIsolation: true, + bogusKey: 42, + }), + ).toThrow(/unknown keys.*bogusKey/i); + }); +}); diff --git a/examples/openclaw-plugin/config.ts b/examples/openclaw-plugin/config.ts index a25037bdc..813942730 100644 --- a/examples/openclaw-plugin/config.ts +++ b/examples/openclaw-plugin/config.ts @@ -34,6 +34,8 @@ export type MemoryOpenVikingConfig = { emitStandardDiagnostics?: boolean; /** When true, log tenant routing for semantic find and session writes (messages/commit) to the plugin logger. */ logFindRequests?: boolean; + /** When true, recall only searches agent-scoped memory (viking://agent/memories), ensuring full isolation between different agents on the same user account. */ + strictAgentIsolation?: boolean; }; const DEFAULT_BASE_URL = "http://127.0.0.1:1933"; @@ -144,6 +146,7 @@ export const memoryOpenVikingConfigSchema = { "ingestReplyAssistMinChars", "emitStandardDiagnostics", "logFindRequests", + "strictAgentIsolation", ], "openviking config", ); @@ -236,6 +239,7 @@ export const memoryOpenVikingConfigSchema = { cfg.logFindRequests === true || envFlag("OPENVIKING_LOG_ROUTING") || envFlag("OPENVIKING_DEBUG"), + strictAgentIsolation: cfg.strictAgentIsolation === true, }; }, uiHints: { @@ -362,6 +366,11 @@ export const memoryOpenVikingConfigSchema = { "Or set env OPENVIKING_LOG_ROUTING=1 or OPENVIKING_DEBUG=1 (no JSON edit). When on, local-mode OpenViking subprocess stderr is also logged at info.", advanced: true, }, + strictAgentIsolation: { + label: "Strict Agent Isolation", + help: "When true, recall only searches agent-scoped memory (viking://agent/memories), ensuring full isolation between different agents on the same user account.", + advanced: true, + }, }, }; diff --git a/examples/openclaw-plugin/index.ts b/examples/openclaw-plugin/index.ts index 06d4b058c..84955a035 100644 --- a/examples/openclaw-plugin/index.ts +++ b/examples/openclaw-plugin/index.ts @@ -414,8 +414,24 @@ const contextEnginePlugin = { }, agentId, ); + } else if (cfg.strictAgentIsolation) { + // Strict agent isolation: only search agent-scoped memory + const agentResult = await recallClient.find( + query, + { + targetUri: "viking://agent/memories", + limit: requestLimit, + scoreThreshold: 0, + }, + agentId, + ); + const leafOnly = (agentResult.memories ?? []).filter((m) => m.level === 2); + result = { + memories: leafOnly, + total: leafOnly.length, + }; } else { - // 默认同时检索 user 和 agent 两个位置的记忆 + // Default: search both user and agent scopes const [userSettled, agentSettled] = await Promise.allSettled([ recallClient.find( query, @@ -438,7 +454,6 @@ const contextEnginePlugin = { ]); const userResult = userSettled.status === "fulfilled" ? userSettled.value : { memories: [] }; const agentResult = agentSettled.status === "fulfilled" ? agentSettled.value : { memories: [] }; - // 合并两个位置的结果,去重 const allMemories = [...(userResult.memories ?? []), ...(agentResult.memories ?? [])]; const uniqueMemories = allMemories.filter((memory, index, self) => index === self.findIndex((m) => m.uri === memory.uri) @@ -858,29 +873,35 @@ const contextEnginePlugin = { await withTimeout( (async () => { const candidateLimit = Math.max(cfg.recallLimit * 4, 20); - const [userSettled, agentSettled] = await Promise.allSettled([ - client.find(queryText, { - targetUri: "viking://user/memories", - limit: candidateLimit, - scoreThreshold: 0, - }, agentId), - client.find(queryText, { - targetUri: "viking://agent/memories", - limit: candidateLimit, - scoreThreshold: 0, - }, agentId), - ]); - - const userResult = userSettled.status === "fulfilled" ? userSettled.value : { memories: [] }; - const agentResult = agentSettled.status === "fulfilled" ? agentSettled.value : { memories: [] }; - if (userSettled.status === "rejected") { - api.logger.warn(`openviking: user memories search failed: ${String(userSettled.reason)}`); - } - if (agentSettled.status === "rejected") { - api.logger.warn(`openviking: agent memories search failed: ${String(agentSettled.reason)}`); + + // Determine search targets based on strictAgentIsolation config + const searchTargets: Array<{ targetUri: string; label: string }> = cfg.strictAgentIsolation + ? [{ targetUri: "viking://agent/memories", label: "agent" }] + : [ + { targetUri: "viking://user/memories", label: "user" }, + { targetUri: "viking://agent/memories", label: "agent" }, + ]; + + const settled = await Promise.allSettled( + searchTargets.map((t) => + client.find(queryText, { + targetUri: t.targetUri, + limit: candidateLimit, + scoreThreshold: 0, + }, agentId), + ), + ); + + const allMemories: FindResultItem[] = []; + for (let i = 0; i < settled.length; i++) { + const s = settled[i]; + if (s.status === "fulfilled") { + allMemories.push(...(s.value.memories ?? [])); + } else { + api.logger.warn(`openviking: ${searchTargets[i].label} memories search failed: ${String(s.reason)}`); + } } - const allMemories = [...(userResult.memories ?? []), ...(agentResult.memories ?? [])]; const uniqueMemories = allMemories.filter((memory, index, self) => index === self.findIndex((m) => m.uri === memory.uri) ); diff --git a/examples/openclaw-plugin/openclaw.plugin.json b/examples/openclaw-plugin/openclaw.plugin.json index cd007ce29..ae3b507b9 100644 --- a/examples/openclaw-plugin/openclaw.plugin.json +++ b/examples/openclaw-plugin/openclaw.plugin.json @@ -122,6 +122,11 @@ "label": "Log find requests", "help": "Log tenant routing: /search/find + session messages/commit (X-OpenViking-*; not apiKey). Or set env OPENVIKING_LOG_ROUTING=1 or OPENVIKING_DEBUG=1. Local mode: subprocess stderr at info when enabled.", "advanced": true + }, + "strictAgentIsolation": { + "label": "Strict Agent Isolation", + "help": "When true, recall only searches agent-scoped memory (viking://agent/memories), ensuring full isolation between different agents on the same user account.", + "advanced": true } }, "configSchema": { @@ -196,6 +201,9 @@ }, "logFindRequests": { "type": "boolean" + }, + "strictAgentIsolation": { + "type": "boolean" } } } diff --git a/openviking/session/compressor.py b/openviking/session/compressor.py index e2e90be17..080873500 100644 --- a/openviking/session/compressor.py +++ b/openviking/session/compressor.py @@ -292,6 +292,13 @@ async def extract_long_term_memories( if not ctx: return [] + # Read scope_mode from config to control memory category routing + try: + from openviking_cli.utils.config import get_openviking_config + scope_mode = get_openviking_config().memory.scope_mode + except Exception: + scope_mode = "default" + self._pending_semantic_changes.clear() telemetry = get_current_telemetry() telemetry.set("memory.extract.candidates.total", 0) @@ -341,7 +348,8 @@ async def extract_long_term_memories( if candidate.category in ALWAYS_MERGE_CATEGORIES: with telemetry.measure("memory.extract.stage.profile_create"): memory = await self.extractor.create_memory( - candidate, user, session_id, ctx=ctx + candidate, user, session_id, ctx=ctx, + scope_mode=scope_mode, ) if memory: memories.append(memory) @@ -506,7 +514,8 @@ async def extract_long_term_memories( with telemetry.measure("memory.extract.stage.create_memory"): memory = await self.extractor.create_memory( - candidate, user, session_id, ctx=ctx + candidate, user, session_id, ctx=ctx, + scope_mode=scope_mode, ) if memory: memories.append(memory) diff --git a/openviking/session/memory_extractor.py b/openviking/session/memory_extractor.py index 820111638..36738a99c 100644 --- a/openviking/session/memory_extractor.py +++ b/openviking/session/memory_extractor.py @@ -134,12 +134,20 @@ def __init__(self): self._tool_desc_cache_ready: bool = False @staticmethod - def _get_owner_space(category: MemoryCategory, ctx: RequestContext) -> str: - """Derive owner_space from memory category. + def _get_owner_space( + category: MemoryCategory, ctx: RequestContext, scope_mode: str = "default" + ) -> str: + """Derive owner_space from memory category and scope_mode. + + When scope_mode is "default" (the default): + PROFILE / PREFERENCES / ENTITIES / EVENTS → user_space (shared across agents) + CASES / PATTERNS → agent_space (isolated per agent) - PROFILE / PREFERENCES / ENTITIES / EVENTS → user_space - CASES / PATTERNS → agent_space + When scope_mode is "isolated": + ALL categories → agent_space (full inter-agent isolation) """ + if scope_mode == "isolated": + return ctx.user.agent_space_name() if category in MemoryExtractor._USER_CATEGORIES: return ctx.user.user_space_name() return ctx.user.agent_space_name() @@ -444,6 +452,7 @@ async def create_memory( user: str, session_id: str, ctx: RequestContext, + scope_mode: str = "default", ) -> Optional[Context]: """Create Context object from candidate and persist to AGFS as .md file.""" viking_fs = get_viking_fs() @@ -451,18 +460,28 @@ async def create_memory( logger.warning("VikingFS not available, skipping memory creation") return None - owner_space = self._get_owner_space(candidate.category, ctx) + owner_space = self._get_owner_space(candidate.category, ctx, scope_mode) # Special handling for profile: append to profile.md if candidate.category == MemoryCategory.PROFILE: - payload = await self._append_to_profile(candidate, viking_fs, ctx=ctx) + payload = await self._append_to_profile( + candidate, viking_fs, ctx=ctx, scope_mode=scope_mode + ) if not payload: return None - user_space = ctx.user.user_space_name() - memory_uri = f"viking://user/{user_space}/memories/profile.md" + # In isolated mode, profile goes to agent scope + use_agent_scope = scope_mode == "isolated" + if use_agent_scope: + space = ctx.user.agent_space_name() + memory_uri = f"viking://agent/{space}/memories/profile.md" + parent = f"viking://agent/{space}/memories" + else: + space = ctx.user.user_space_name() + memory_uri = f"viking://user/{space}/memories/profile.md" + parent = f"viking://user/{space}/memories" memory = Context( uri=memory_uri, - parent_uri=f"viking://user/{user_space}/memories", + parent_uri=parent, is_leaf=True, abstract=payload.abstract, context_type=ContextType.MEMORY.value, @@ -476,9 +495,12 @@ async def create_memory( memory.set_vectorize(Vectorize(text=payload.content)) return memory - # Determine parent URI based on category + # Determine parent URI based on category and scope_mode cat_dir = self.CATEGORY_DIRS[candidate.category] - if candidate.category in [ + if scope_mode == "isolated": + # All categories route to agent scope + parent_uri = f"viking://agent/{ctx.user.agent_space_name()}/{cat_dir}" + elif candidate.category in [ MemoryCategory.PREFERENCES, MemoryCategory.ENTITIES, MemoryCategory.EVENTS, @@ -521,9 +543,14 @@ async def _append_to_profile( candidate: CandidateMemory, viking_fs, ctx: RequestContext, + scope_mode: str = "default", ) -> Optional[MergedMemoryPayload]: """Update user profile - always merge with existing content.""" - uri = f"viking://user/{ctx.user.user_space_name()}/memories/profile.md" + # In isolated mode, profile is stored in agent scope + if scope_mode == "isolated": + uri = f"viking://agent/{ctx.user.agent_space_name()}/memories/profile.md" + else: + uri = f"viking://user/{ctx.user.user_space_name()}/memories/profile.md" existing = "" try: existing = await viking_fs.read_file(uri, ctx=ctx) or "" diff --git a/openviking_cli/utils/config/memory_config.py b/openviking_cli/utils/config/memory_config.py index b6889684f..90ad62917 100644 --- a/openviking_cli/utils/config/memory_config.py +++ b/openviking_cli/utils/config/memory_config.py @@ -1,6 +1,6 @@ # Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. # SPDX-License-Identifier: AGPL-3.0 -from typing import Any, Dict +from typing import Any, Dict, Literal from pydantic import BaseModel, Field, field_validator @@ -20,6 +20,16 @@ class MemoryConfig(BaseModel): ), ) + scope_mode: Literal["default", "isolated"] = Field( + default="default", + description=( + "Memory scope routing mode. 'default' routes user-level categories " + "(profile/preferences/entities/events) to shared user space and agent-level " + "categories (cases/patterns) to isolated agent space. 'isolated' routes ALL " + "categories to agent space for full inter-agent memory isolation." + ), + ) + custom_templates_dir: str = Field( default="", description="Custom memory templates directory. If set, templates from this directory will be loaded in addition to built-in templates", @@ -34,6 +44,13 @@ def validate_agent_scope_mode(cls, value: str) -> str: raise ValueError("memory.agent_scope_mode must be 'user+agent' or 'agent'") return value + @field_validator("scope_mode") + @classmethod + def validate_scope_mode(cls, value: str) -> str: + if value not in {"default", "isolated"}: + raise ValueError("memory.scope_mode must be 'default' or 'isolated'") + return value + @classmethod def from_dict(cls, config: Dict[str, Any]) -> "MemoryConfig": """Create configuration from dictionary.""" diff --git a/tests/session/test_memory_scope_mode.py b/tests/session/test_memory_scope_mode.py new file mode 100644 index 000000000..929c6f737 --- /dev/null +++ b/tests/session/test_memory_scope_mode.py @@ -0,0 +1,101 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: AGPL-3.0 +"""Tests for memory.scope_mode configuration and owner_space routing.""" + +import pytest +from unittest.mock import MagicMock + +from openviking.session.memory_extractor import MemoryExtractor, MemoryCategory +from openviking_cli.utils.config.memory_config import MemoryConfig + + +class TestGetOwnerSpace: + """Test _get_owner_space routing with scope_mode.""" + + def _make_ctx(self, user_space="user123", agent_space="agent456"): + ctx = MagicMock() + ctx.user.user_space_name.return_value = user_space + ctx.user.agent_space_name.return_value = agent_space + return ctx + + def test_default_mode_profile_goes_to_user_space(self): + ctx = self._make_ctx() + result = MemoryExtractor._get_owner_space( + MemoryCategory.PROFILE, ctx, scope_mode="default" + ) + assert result == "user123" + + def test_default_mode_preferences_goes_to_user_space(self): + ctx = self._make_ctx() + result = MemoryExtractor._get_owner_space( + MemoryCategory.PREFERENCES, ctx, scope_mode="default" + ) + assert result == "user123" + + def test_default_mode_events_goes_to_user_space(self): + ctx = self._make_ctx() + result = MemoryExtractor._get_owner_space( + MemoryCategory.EVENTS, ctx, scope_mode="default" + ) + assert result == "user123" + + def test_default_mode_cases_goes_to_agent_space(self): + ctx = self._make_ctx() + result = MemoryExtractor._get_owner_space( + MemoryCategory.CASES, ctx, scope_mode="default" + ) + assert result == "agent456" + + def test_default_mode_patterns_goes_to_agent_space(self): + ctx = self._make_ctx() + result = MemoryExtractor._get_owner_space( + MemoryCategory.PATTERNS, ctx, scope_mode="default" + ) + assert result == "agent456" + + def test_isolated_mode_profile_goes_to_agent_space(self): + ctx = self._make_ctx() + result = MemoryExtractor._get_owner_space( + MemoryCategory.PROFILE, ctx, scope_mode="isolated" + ) + assert result == "agent456" + + def test_isolated_mode_preferences_goes_to_agent_space(self): + ctx = self._make_ctx() + result = MemoryExtractor._get_owner_space( + MemoryCategory.PREFERENCES, ctx, scope_mode="isolated" + ) + assert result == "agent456" + + def test_isolated_mode_cases_still_goes_to_agent_space(self): + ctx = self._make_ctx() + result = MemoryExtractor._get_owner_space( + MemoryCategory.CASES, ctx, scope_mode="isolated" + ) + assert result == "agent456" + + def test_no_scope_mode_defaults_to_default_behavior(self): + """When scope_mode is omitted, should behave like 'default'.""" + ctx = self._make_ctx() + result = MemoryExtractor._get_owner_space(MemoryCategory.PROFILE, ctx) + assert result == "user123" + + +class TestMemoryConfigScopeMode: + """Test MemoryConfig scope_mode validation.""" + + def test_default_scope_mode(self): + config = MemoryConfig() + assert config.scope_mode == "default" + + def test_valid_scope_mode_default(self): + config = MemoryConfig(scope_mode="default") + assert config.scope_mode == "default" + + def test_valid_scope_mode_isolated(self): + config = MemoryConfig(scope_mode="isolated") + assert config.scope_mode == "isolated" + + def test_invalid_scope_mode_raises(self): + with pytest.raises(ValueError, match="scope_mode"): + MemoryConfig(scope_mode="invalid")