From c99b5d0419574e545dfa92314a131f18ff9fa82f Mon Sep 17 00:00:00 2001 From: HasHome Date: Wed, 27 May 2026 03:20:42 +0800 Subject: [PATCH 1/2] feat: Single-DB Multi-Agent Isolation with Shared Memory Support - Add 'agent_id' column to SQLite store for logical isolation. - Logic: instruction/persona types -> 'shared' (global), episodic -> '{profile}' (private). - Update Hermes Plugin to prefix session keys with 'agent:{profile}:'. - Update Gateway Recall to include 'prependContext' (L1 memories) in response. - Implement agent identity filtering in FTS and Vector search. - Includes automatic migration logic for legacy data. --- .../memory/memory_tencentdb/__init__.py | 20 +++++++- src/core/hooks/auto-recall.ts | 41 +++++++++++++---- src/core/store/sqlite.ts | 46 ++++++++++++++----- src/core/store/types.ts | 2 +- src/gateway/server.ts | 3 +- 5 files changed, 89 insertions(+), 23 deletions(-) diff --git a/hermes-plugin/memory/memory_tencentdb/__init__.py b/hermes-plugin/memory/memory_tencentdb/__init__.py index 2be6c29..fd6a62c 100644 --- a/hermes-plugin/memory/memory_tencentdb/__init__.py +++ b/hermes-plugin/memory/memory_tencentdb/__init__.py @@ -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() @@ -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, @@ -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(): diff --git a/src/core/hooks/auto-recall.ts b/src/core/hooks/auto-recall.ts index cccb864..622b289 100644 --- a/src/core/hooks/auto-recall.ts +++ b/src/core/hooks/auto-recall.ts @@ -111,7 +111,7 @@ async function performAutoRecallInner(params: { vectorStore?: IMemoryStore; embeddingService?: EmbeddingService; }): Promise { - 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 @@ -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; @@ -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" @@ -314,6 +315,7 @@ async function searchMemories( strategy: "keyword" | "embedding" | "hybrid", vectorStore?: IMemoryStore, embeddingService?: EmbeddingService, + sessionKey?: string, ): Promise { const emptyResult: SearchResult = { lines: [], timing: { ftsMs: 0, embeddingMs: 0, ftsHits: 0, embeddingHits: 0 } }; // Strip gateway-injected inbound metadata (Sender, timestamps, media markers, @@ -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 } }; } @@ -403,13 +405,19 @@ async function searchByKeyword( threshold: number, logger?: Logger, vectorStore?: IMemoryStore, + sessionKey?: string, ): Promise { // 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}): ` + @@ -456,6 +464,7 @@ async function searchByEmbedding( embeddingService: EmbeddingService, logger?: Logger, embeddingCallOpts?: EmbeddingCallOptions, + sessionKey?: string, ): Promise { logger?.debug?.( `${TAG} [embedding-search] START query="${userText.slice(0, 80)}...", maxResults=${maxResults}, threshold=${threshold}`, @@ -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`); @@ -518,6 +532,7 @@ async function searchHybrid( embeddingService: EmbeddingService, logger?: Logger, embeddingCallOpts?: EmbeddingCallOptions, + sessionKey?: string, ): Promise { // Run keyword and embedding searches in parallel const candidateK = maxResults * 3; // retrieve more for merging @@ -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 @@ -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) { diff --git a/src/core/store/sqlite.ts b/src/core/store/sqlite.ts index 6252419..6e64dd0 100644 --- a/src/core/store/sqlite.ts +++ b/src/core/store/sqlite.ts @@ -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, @@ -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) { @@ -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 = ? `); @@ -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 ? `); @@ -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, @@ -1047,6 +1062,7 @@ export class VectorStore implements IMemoryStore { record.createdAt, record.updatedAt, JSON.stringify(record.metadata), + agentId, ); if (!skipVec) { @@ -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 []; @@ -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)}, ` + @@ -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; diff --git a/src/core/store/types.ts b/src/core/store/types.ts index cfcb50a..c554aa6 100644 --- a/src/core/store/types.ts +++ b/src/core/store/types.ts @@ -271,7 +271,7 @@ export interface IMemoryStore { // ── L1 Search ──────────────────────────────────────────── searchL1Vector(queryEmbedding: Float32Array, topK?: number, queryText?: string): MaybePromise; - searchL1Fts(ftsQuery: string, limit?: number): MaybePromise; + searchL1Fts(ftsQuery: string, limit?: number, agentIdentity?: string): MaybePromise; searchL1Hybrid?(params: { query?: string; queryEmbedding?: Float32Array; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index bd7d0a0..1416ba1 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -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, }; From a3fc705454a8a8c97e8de6d29a71143041332a36 Mon Sep 17 00:00:00 2001 From: HasHome Date: Wed, 27 May 2026 04:28:53 +0800 Subject: [PATCH 2/2] docs: Add multi-agent isolation usage guide --- docs/multi-agent-isolation-guide.md | 60 +++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/multi-agent-isolation-guide.md diff --git a/docs/multi-agent-isolation-guide.md b/docs/multi-agent-isolation-guide.md new file mode 100644 index 0000000..50cfc70 --- /dev/null +++ b/docs/multi-agent-isolation-guide.md @@ -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.