Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions docs/en/guides/01-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -771,16 +771,21 @@ 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"
}
}
```

| 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

Expand Down
9 changes: 7 additions & 2 deletions docs/zh/guides/01-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -748,16 +748,21 @@ openviking-server --config /path/to/ov.conf
```json
{
"memory": {
"agent_scope_mode": "user+agent"
"agent_scope_mode": "user+agent",
"scope_mode": "default"
}
}
```

| 字段 | 说明 | 默认值 |
|------|------|--------|
| `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

Expand Down
40 changes: 40 additions & 0 deletions examples/openclaw-plugin/__tests__/strict-agent-isolation.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
9 changes: 9 additions & 0 deletions examples/openclaw-plugin/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -144,6 +146,7 @@ export const memoryOpenVikingConfigSchema = {
"ingestReplyAssistMinChars",
"emitStandardDiagnostics",
"logFindRequests",
"strictAgentIsolation",
],
"openviking config",
);
Expand Down Expand Up @@ -236,6 +239,7 @@ export const memoryOpenVikingConfigSchema = {
cfg.logFindRequests === true ||
envFlag("OPENVIKING_LOG_ROUTING") ||
envFlag("OPENVIKING_DEBUG"),
strictAgentIsolation: cfg.strictAgentIsolation === true,
};
},
uiHints: {
Expand Down Expand Up @@ -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,
},
},
};

Expand Down
67 changes: 44 additions & 23 deletions examples/openclaw-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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)
);
Expand Down
8 changes: 8 additions & 0 deletions examples/openclaw-plugin/openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -196,6 +201,9 @@
},
"logFindRequests": {
"type": "boolean"
},
"strictAgentIsolation": {
"type": "boolean"
}
}
}
Expand Down
13 changes: 11 additions & 2 deletions openviking/session/compressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
51 changes: 39 additions & 12 deletions openviking/session/memory_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -444,25 +452,36 @@ 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()
if not viking_fs:
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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 ""
Expand Down
Loading