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
60 changes: 60 additions & 0 deletions docs/multi-agent-isolation-guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# 📘 Multi-Agent Memory Isolation Guide

## Overview
This guide explains how to use the **Multi-Agent Isolation** feature introduced in PR #96. It solves the data mixing issue in multi-profile setups (e.g., default, xiaoxiao, zhi) by implementing logical isolation in a single SQLite database.

## 💡 Core Design

### Shared vs. Private Memory
The system uses a `shared` area for global context and `private` areas for agent-specific context.

| Memory Type | Isolation Strategy | Description |
| :--- | :--- | :--- |
| **instruction** | 🟢 **Shared** | Rules, preferences, and commands. Visible to all profiles. |
| **persona** | 🟢 **Shared** | User profiles and bio info. Visible to all profiles. |
| **episodic** | 🔴 **Private** | Conversation history and workflow logs. Isolated by agent. |

### Why Single DB?
Unlike multi-database solutions, this approach uses a single `vectors.db` with an `agent_id` column.
* **Zero Overhead**: Easy backup and migration.
* **Clean Logic**: Solves "share persona vs isolate context" dilemma efficiently.

---

## 🛠️ Integration Guide

### For Hermes Users ✅
**Zero Configuration Required.**
* Simply install the updated `memory-tencentdb` plugin.
* The plugin automatically handles session keys (`agent:{profile}:...`).
* Isolation works immediately upon restart.

### For OpenClaw Users 🔌
The core storage logic is generic, but your Host Adapter needs to generate the correct session key format.
* **Requirement**: Ensure `sessionKey` follows the pattern: `agent:{profile_id}:{session_id}`.
* **Example**:
* `agent:default:session-123`
* `agent:coding-bot:session-456`

---

## 🔄 Migration from Legacy Versions

If upgrading from a version before this PR, your existing data will lack `agent_id` tags.
* **Default Behavior**: Untagged data is treated as belonging to the `default` profile.
* **Recommended Action**: Run the provided migration script to categorize old data.

```bash
# Example command (may vary by release)
python3 scripts/migrate_multi_agent.py
```

---

## ❓ FAQ

**Q: Will I lose my old memories?**
A: No. They will simply be grouped under the `default` profile until you migrate them.

**Q: Does this affect search performance?**
A: Negligible. SQLite indexes on `agent_id` ensure fast retrieval.
20 changes: 19 additions & 1 deletion hermes-plugin/memory/memory_tencentdb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,19 @@ def initialize(self, session_id: str, **kwargs) -> None:
Gateway is ready.
"""
self._session_id = session_id
self._user_id = kwargs.get("user_id", "default")
user_id = kwargs.get("user_id", "default")
# Multi-profile isolation: prepend profile name to user_id so that
# different Hermes profiles (default, xiaoling, xiaoxi, etc.) get
# isolated memories even when talking from the same platform user.
# Hermes passes agent_identity=profile_name but the original TDAI
# plugin ignored it — this fix ensures per-profile memory separation.
agent_identity = str(kwargs.get("agent_identity") or "").strip()
if agent_identity:
self._user_id = f"{agent_identity}:{user_id}"
else:
self._user_id = user_id

self._agent_identity = agent_identity or "default"

host = _resolve_gateway_host()
port = _resolve_gateway_port()
Expand Down Expand Up @@ -798,6 +810,9 @@ def prefetch(self, query: str, *, session_id: str = "") -> str:
return ""

effective_session = session_id or self._session_id
# Add agent_identity prefix for multi-agent isolation: "agent:{profile}:{session_id}"
if self._agent_identity:
effective_session = f"agent:{self._agent_identity}:{effective_session}"
try:
result = self._client.recall(
query=query,
Expand Down Expand Up @@ -846,6 +861,9 @@ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: st
return

effective_session = session_id or self._session_id
# Add agent_identity prefix for multi-agent isolation
if self._agent_identity:
effective_session = f"agent:{self._agent_identity}:{effective_session}"
client = self._client

def _sync():
Expand Down
41 changes: 33 additions & 8 deletions src/core/hooks/auto-recall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ async function performAutoRecallInner(params: {
vectorStore?: IMemoryStore;
embeddingService?: EmbeddingService;
}): Promise<RecallResult | undefined> {
const { userText, cfg, pluginDataDir, logger, vectorStore, embeddingService } = params;
const { userText, cfg, pluginDataDir, logger, vectorStore, embeddingService, sessionKey } = params;
const tRecallStart = performance.now();

// Search relevant memories (L1 layer) — skip only when userText is empty/undefined
Expand All @@ -124,7 +124,7 @@ async function performAutoRecallInner(params: {
logger?.debug?.(`${TAG} User text empty/undefined, skipping memory search (persona/scene still injected)`);
} else {
effectiveStrategy = cfg.recall.strategy ?? "hybrid";
const searchResult = await searchMemories(userText, pluginDataDir, cfg, logger, effectiveStrategy as "keyword" | "embedding" | "hybrid", vectorStore, embeddingService);
const searchResult = await searchMemories(userText, pluginDataDir, cfg, logger, effectiveStrategy as "keyword" | "embedding" | "hybrid", vectorStore, embeddingService, sessionKey);
memoryLines = searchResult.lines;
searchTiming = searchResult.timing;

Expand Down Expand Up @@ -278,8 +278,9 @@ async function searchMemoriesWithDetails(
strategy: "keyword" | "embedding" | "hybrid",
vectorStore?: IMemoryStore,
embeddingService?: EmbeddingService,
sessionKey?: string,
): Promise<{ lines: string[]; memories: RecalledMemory[]; timing: SearchTiming }> {
const result = await searchMemories(userText, pluginDataDir, cfg, logger, strategy, vectorStore, embeddingService);
const result = await searchMemories(userText, pluginDataDir, cfg, logger, strategy, vectorStore, embeddingService, sessionKey);

// Extract structured data from formatted memory lines.
// Format: "- [type|scene] content (活动时间: ...)" or "- [type] content"
Expand Down Expand Up @@ -314,6 +315,7 @@ async function searchMemories(
strategy: "keyword" | "embedding" | "hybrid",
vectorStore?: IMemoryStore,
embeddingService?: EmbeddingService,
sessionKey?: string,
): Promise<SearchResult> {
const emptyResult: SearchResult = { lines: [], timing: { ftsMs: 0, embeddingMs: 0, ftsHits: 0, embeddingHits: 0 } };
// Strip gateway-injected inbound metadata (Sender, timestamps, media markers,
Expand Down Expand Up @@ -362,7 +364,7 @@ async function searchMemories(
try {
if (effectiveStrategy === "keyword") {
const tFts = performance.now();
const lines = await searchByKeyword(cleanText, pluginDataDir, maxResults, threshold, logger, vectorStore);
const lines = await searchByKeyword(cleanText, pluginDataDir, maxResults, threshold, logger, vectorStore, sessionKey);
return { lines, timing: { ftsMs: performance.now() - tFts, embeddingMs: 0, ftsHits: lines.length, embeddingHits: 0 } };
}

Expand Down Expand Up @@ -403,13 +405,19 @@ async function searchByKeyword(
threshold: number,
logger?: Logger,
vectorStore?: IMemoryStore,
sessionKey?: string,
): Promise<string[]> {
// Prefer FTS5 if available
if (vectorStore?.isFtsAvailable()) {
const ftsQuery = buildFtsQuery(userText);
if (ftsQuery) {
logger?.debug?.(`${TAG} [keyword-fts] Using FTS5 BM25 search: query="${ftsQuery}"`);
const ftsResults = await vectorStore.searchL1Fts(ftsQuery, maxResults * 2);
// Extract agentId from sessionKey for multi-agent isolation
let agentId = "default";
if (sessionKey?.startsWith("agent:")) {
agentId = sessionKey.split(":")[1] || "default";
}
const ftsResults = await vectorStore.searchL1Fts(ftsQuery, maxResults * 2, agentId);
if (ftsResults.length > 0) {
logger?.debug?.(
`${TAG} [keyword-fts] FTS5 raw results (${ftsResults.length}): ` +
Expand Down Expand Up @@ -456,6 +464,7 @@ async function searchByEmbedding(
embeddingService: EmbeddingService,
logger?: Logger,
embeddingCallOpts?: EmbeddingCallOptions,
sessionKey?: string,
): Promise<string[]> {
logger?.debug?.(
`${TAG} [embedding-search] START query="${userText.slice(0, 80)}...", maxResults=${maxResults}, threshold=${threshold}`,
Expand All @@ -467,7 +476,12 @@ async function searchByEmbedding(
`searching top-${maxResults * 2}...`,
);
// Retrieve more candidates for subsequent filtering
const vecResults: L1SearchResult[] = await vectorStore.searchL1Vector(queryEmbedding, maxResults * 2);
// Extract agentId from sessionKey for multi-agent isolation
let agentId = "default";
if (sessionKey?.startsWith("agent:")) {
agentId = sessionKey.split(":")[1] || "default";
}
const vecResults: L1SearchResult[] = await vectorStore.searchL1Vector(queryEmbedding, maxResults * 2, agentId);

if (vecResults.length === 0) {
logger?.debug?.(`${TAG} [embedding-search] Returned 0 results`);
Expand Down Expand Up @@ -518,6 +532,7 @@ async function searchHybrid(
embeddingService: EmbeddingService,
logger?: Logger,
embeddingCallOpts?: EmbeddingCallOptions,
sessionKey?: string,
): Promise<SearchResult> {
// Run keyword and embedding searches in parallel
const candidateK = maxResults * 3; // retrieve more for merging
Expand All @@ -531,7 +546,12 @@ async function searchHybrid(
if (vectorStore.isFtsAvailable()) {
const ftsQuery = buildFtsQuery(userText);
if (ftsQuery) {
const ftsResults = await vectorStore.searchL1Fts(ftsQuery, candidateK);
// Extract agentId for multi-agent isolation
let agentId = "default";
if (sessionKey?.startsWith("agent:")) {
agentId = sessionKey.split(":")[1] || "default";
}
const ftsResults = await vectorStore.searchL1Fts(ftsQuery, candidateK, agentId);
if (ftsResults.length > 0) {
logger?.debug?.(`${TAG} [hybrid-keyword-fts] FTS5 found ${ftsResults.length} candidates`);
// Convert FtsSearchResult to ScoredRecord for RRF merge
Expand Down Expand Up @@ -573,7 +593,12 @@ async function searchHybrid(
logger?.debug?.(
`${TAG} [hybrid-embedding] Embedding OK, dims=${queryEmbedding.length}, searching top-${candidateK}...`,
);
const results = await vectorStore.searchL1Vector(queryEmbedding, candidateK, userText);
// Extract agentId for multi-agent isolation
let agentId = "default";
if (sessionKey?.startsWith("agent:")) {
agentId = sessionKey.split(":")[1] || "default";
}
const results = await vectorStore.searchL1Vector(queryEmbedding, candidateK, agentId);
logger?.debug?.(`${TAG} [hybrid-embedding] Got ${results.length} candidates`);
return { results, ms: performance.now() - tStart };
} catch (err) {
Expand Down
46 changes: 34 additions & 12 deletions src/core/store/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,12 +607,15 @@ export class VectorStore implements IMemoryStore {
}

// Prepare statements for reuse
// Multi-agent isolation: add agent_id column
try { this.db.exec("ALTER TABLE l1_records ADD COLUMN agent_id TEXT DEFAULT ''"); } catch { /* column exists */ }

this.stmtUpsertMeta = this.db.prepare(`
INSERT INTO l1_records (
record_id, content, type, priority, scene_name, session_key, session_id,
timestamp_str, timestamp_start, timestamp_end,
created_time, updated_time, metadata_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
created_time, updated_time, metadata_json, agent_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(record_id) DO UPDATE SET
content=excluded.content,
type=excluded.type,
Expand All @@ -622,7 +625,8 @@ export class VectorStore implements IMemoryStore {
timestamp_start=excluded.timestamp_start,
timestamp_end=excluded.timestamp_end,
updated_time=excluded.updated_time,
metadata_json=excluded.metadata_json
metadata_json=excluded.metadata_json,
agent_id=excluded.agent_id
`);

if (this.dimensions > 0) {
Expand All @@ -633,7 +637,7 @@ export class VectorStore implements IMemoryStore {

this.stmtGetMeta = this.db.prepare(`
SELECT content, type, priority, scene_name, session_key, session_id,
timestamp_str, timestamp_start, timestamp_end, metadata_json
timestamp_str, timestamp_start, timestamp_end, metadata_json, agent_id
FROM l1_records WHERE record_id = ?
`);

Expand Down Expand Up @@ -800,12 +804,13 @@ export class VectorStore implements IMemoryStore {
this.stmtL1FtsDelete = this.db.prepare("DELETE FROM l1_fts WHERE record_id = ?");

this.stmtL1FtsSearch = this.db.prepare(`
SELECT record_id, content_original AS content, type, priority, scene_name,
session_key, session_id, timestamp_str, timestamp_start, timestamp_end,
metadata_json,
SELECT f.record_id, f.content_original AS content, f.type, f.priority, f.scene_name,
f.session_key, f.session_id, f.timestamp_str, f.timestamp_start, f.timestamp_end,
f.metadata_json,
bm25(l1_fts) AS rank
FROM l1_fts
WHERE l1_fts MATCH ?
FROM l1_fts f
JOIN l1_records r ON f.record_id = r.record_id
WHERE f MATCH ? AND r.agent_id IN ('shared', ?)
ORDER BY rank ASC
LIMIT ?
`);
Expand Down Expand Up @@ -1032,6 +1037,16 @@ export class VectorStore implements IMemoryStore {

this.db.exec("BEGIN");
try {
// Multi-agent isolation: compute agent_id
let agentId = "default";
if (record.sessionKey.startsWith("agent:")) {
agentId = record.sessionKey.split(":")[1] || "default";
}
// Shared memory: instruction and persona are global
if (record.type === "instruction" || record.type === "persona") {
agentId = "shared";
}

// Upsert metadata (INSERT OR UPDATE)
this.stmtUpsertMeta.run(
recordId,
Expand All @@ -1047,6 +1062,7 @@ export class VectorStore implements IMemoryStore {
record.createdAt,
record.updatedAt,
JSON.stringify(record.metadata),
agentId,
);

if (!skipVec) {
Expand Down Expand Up @@ -1109,7 +1125,7 @@ export class VectorStore implements IMemoryStore {
* **Fault-tolerant**: returns an empty array on any error (e.g. dimension
* mismatch, corrupted DB) so callers can fall back to keyword search.
*/
searchL1Vector(queryEmbedding: Float32Array, topK = 5): VectorSearchResult[] {
searchL1Vector(queryEmbedding: Float32Array, topK = 5, agentIdentity = "default"): VectorSearchResult[] {
if (this.degraded || !this.vecTablesReady) {
if (this.degraded) this.logger?.warn(`${TAG} [L1-search] SKIPPED (degraded mode)`);
return [];
Expand Down Expand Up @@ -1172,6 +1188,12 @@ export class VectorStore implements IMemoryStore {
continue;
}

// Multi-agent isolation: check agent_id
const metaAgentId = (meta as any).agent_id || "default";
if (metaAgentId !== "shared" && metaAgentId !== agentIdentity) {
continue;
}

const score = 1.0 - distance;
this.logger?.debug?.(
`${TAG} [L1-search] HIT id=${record_id}, distance=${distance.toFixed(4)}, score=${score.toFixed(4)}, ` +
Expand Down Expand Up @@ -2026,10 +2048,10 @@ export class VectorStore implements IMemoryStore {
*
* **Fault-tolerant**: returns an empty array on any error.
*/
searchL1Fts(ftsQuery: string, limit = 20): FtsSearchResult[] {
searchL1Fts(ftsQuery: string, limit = 20, agentIdentity = "default"): FtsSearchResult[] {
if (this.degraded || !this.ftsAvailable) return [];
try {
const rows = this.stmtL1FtsSearch.all(ftsQuery, limit) as Array<{
const rows = this.stmtL1FtsSearch.all(ftsQuery, agentIdentity, limit) as Array<{
record_id: string;
content: string;
type: string;
Expand Down
2 changes: 1 addition & 1 deletion src/core/store/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ export interface IMemoryStore {
// ── L1 Search ────────────────────────────────────────────

searchL1Vector(queryEmbedding: Float32Array, topK?: number, queryText?: string): MaybePromise<L1SearchResult[]>;
searchL1Fts(ftsQuery: string, limit?: number): MaybePromise<L1FtsResult[]>;
searchL1Fts(ftsQuery: string, limit?: number, agentIdentity?: string): MaybePromise<L1FtsResult[]>;
searchL1Hybrid?(params: {
query?: string;
queryEmbedding?: Float32Array;
Expand Down
3 changes: 2 additions & 1 deletion src/gateway/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,8 @@ export class TdaiGateway {
this.logger.info(`Recall completed in ${elapsed}ms: context=${(result.appendSystemContext?.length ?? 0)} chars`);

const response: RecallResponse = {
context: result.appendSystemContext ?? "",
// Multi-agent fix: Include prependContext (L1 memories) in the response
context: [result.prependContext, result.appendSystemContext].filter(Boolean).join("\n\n"),
strategy: result.recallStrategy,
memory_count: result.recalledL1Memories?.length ?? 0,
};
Expand Down