fix: thread gateway user_id to memory plugins for per-user scoping (#5895)

Memory plugins (Mem0, Honcho) used static identifiers ('hermes-user',
config peerName) meaning all gateway users shared the same memory bucket.

Changes:
- AIAgent.__init__: add user_id parameter, store as self._user_id
- run_agent.py: include user_id in _init_kwargs passed to memory providers
- gateway/run.py: pass source.user_id to AIAgent in primary + background paths
- Mem0 plugin: prefer kwargs user_id over config default
- Honcho plugin: override cfg.peer_name with gateway user_id when present

CLI sessions (user_id=None) preserve existing defaults. Only gateway
sessions with a real platform user_id get per-user memory scoping.

Reported by plev333.
This commit is contained in:
Teknium 2026-04-07 11:14:12 -07:00 committed by GitHub
parent e49c8bbbbb
commit 69c753c19b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 305 additions and 1 deletions

View file

@ -4551,6 +4551,7 @@ class GatewayRunner:
provider_data_collection=pr.get("data_collection"),
session_id=task_id,
platform=platform_key,
user_id=source.user_id,
session_db=self._session_db,
fallback_model=self._fallback_model,
)
@ -6645,6 +6646,7 @@ class GatewayRunner:
provider_data_collection=pr.get("data_collection"),
session_id=session_id,
platform=platform_key,
user_id=source.user_id,
session_db=self._session_db,
fallback_model=self._fallback_model,
)

View file

@ -216,6 +216,12 @@ class HonchoMemoryProvider(MemoryProvider):
logger.debug("Honcho not configured — plugin inactive")
return
# Override peer_name with gateway user_id for per-user memory scoping.
# CLI sessions won't have user_id, so the config default is preserved.
_gw_user_id = kwargs.get("user_id")
if _gw_user_id:
cfg.peer_name = _gw_user_id
self._config = cfg
# ----- B1: recall_mode from config -----

View file

@ -202,7 +202,9 @@ class Mem0MemoryProvider(MemoryProvider):
def initialize(self, session_id: str, **kwargs) -> None:
self._config = _load_config()
self._api_key = self._config.get("api_key", "")
self._user_id = self._config.get("user_id", "hermes-user")
# Prefer gateway-provided user_id for per-user memory scoping;
# fall back to config/env default for CLI (single-user) sessions.
self._user_id = kwargs.get("user_id") or self._config.get("user_id", "hermes-user")
self._agent_id = self._config.get("agent_id", "hermes")
self._rerank = self._config.get("rerank", True)

View file

@ -526,6 +526,7 @@ class AIAgent:
reasoning_config: Dict[str, Any] = None,
prefill_messages: List[Dict[str, Any]] = None,
platform: str = None,
user_id: str = None,
skip_context_files: bool = False,
skip_memory: bool = False,
session_db=None,
@ -590,6 +591,7 @@ class AIAgent:
self.quiet_mode = quiet_mode
self.ephemeral_system_prompt = ephemeral_system_prompt
self.platform = platform # "cli", "telegram", "discord", "whatsapp", etc.
self._user_id = user_id # Platform user identifier (gateway sessions)
# Pluggable print function — CLI replaces this with _cprint so that
# raw ANSI status lines are routed through prompt_toolkit's renderer
# instead of going directly to stdout where patch_stdout's StdoutProxy
@ -1092,6 +1094,9 @@ class AIAgent:
"hermes_home": str(_ghh()),
"agent_context": "primary",
}
# Thread gateway user identity for per-user memory scoping
if self._user_id:
_init_kwargs["user_id"] = self._user_id
# Profile identity for per-profile provider scoping
try:
from hermes_cli.profiles import get_active_profile_name

View file

@ -0,0 +1,289 @@
"""Tests for per-user memory scoping via user_id threading.
Verifies that gateway user_id flows from AIAgent -> MemoryManager -> plugins,
so each gateway user gets their own memory bucket instead of sharing a static one.
"""
import json
import os
import pytest
from unittest.mock import MagicMock, patch
from agent.memory_provider import MemoryProvider
from agent.memory_manager import MemoryManager
# ---------------------------------------------------------------------------
# Concrete test provider that records init kwargs
# ---------------------------------------------------------------------------
class RecordingProvider(MemoryProvider):
"""Minimal provider that records what initialize() receives."""
def __init__(self, name="recording"):
self._name = name
self._init_kwargs = {}
self._init_session_id = None
@property
def name(self) -> str:
return self._name
def is_available(self) -> bool:
return True
def initialize(self, session_id: str, **kwargs) -> None:
self._init_session_id = session_id
self._init_kwargs = dict(kwargs)
def system_prompt_block(self) -> str:
return ""
def prefetch(self, query: str, *, session_id: str = "") -> str:
return ""
def sync_turn(self, user_content, assistant_content, *, session_id=""):
pass
def get_tool_schemas(self):
return []
def handle_tool_call(self, tool_name, args, **kwargs):
return json.dumps({})
def shutdown(self):
pass
# ---------------------------------------------------------------------------
# MemoryManager user_id threading tests
# ---------------------------------------------------------------------------
class TestMemoryManagerUserIdThreading:
"""Verify user_id reaches providers via initialize_all."""
def test_user_id_forwarded_to_provider(self):
mgr = MemoryManager()
p = RecordingProvider()
mgr.add_provider(p)
mgr.initialize_all(
session_id="sess-123",
platform="telegram",
user_id="tg_user_42",
)
assert p._init_kwargs.get("user_id") == "tg_user_42"
assert p._init_kwargs.get("platform") == "telegram"
assert p._init_session_id == "sess-123"
def test_no_user_id_when_cli(self):
"""CLI sessions should not have user_id in kwargs."""
mgr = MemoryManager()
p = RecordingProvider()
mgr.add_provider(p)
mgr.initialize_all(
session_id="sess-456",
platform="cli",
)
assert "user_id" not in p._init_kwargs
assert p._init_kwargs.get("platform") == "cli"
def test_user_id_none_not_forwarded(self):
"""Explicit None user_id should not appear in kwargs."""
mgr = MemoryManager()
p = RecordingProvider()
mgr.add_provider(p)
# Simulates what happens when AIAgent passes user_id=None
# (the agent code only adds user_id to kwargs when it's truthy)
mgr.initialize_all(
session_id="sess-789",
platform="discord",
)
assert "user_id" not in p._init_kwargs
def test_multiple_providers_all_receive_user_id(self):
from agent.builtin_memory_provider import BuiltinMemoryProvider
mgr = MemoryManager()
# Use builtin + one external (MemoryManager only allows one external)
builtin = BuiltinMemoryProvider()
ext = RecordingProvider("external")
mgr.add_provider(builtin)
mgr.add_provider(ext)
mgr.initialize_all(
session_id="sess-multi",
platform="slack",
user_id="slack_U12345",
)
assert ext._init_kwargs.get("user_id") == "slack_U12345"
assert ext._init_kwargs.get("platform") == "slack"
# ---------------------------------------------------------------------------
# Mem0 provider user_id tests
# ---------------------------------------------------------------------------
class TestMem0UserIdScoping:
"""Verify Mem0 plugin uses gateway user_id when provided."""
def test_gateway_user_id_overrides_default(self):
"""When user_id is passed via kwargs, it should override the config default."""
from plugins.memory.mem0 import Mem0MemoryProvider
provider = Mem0MemoryProvider()
# Mock _load_config to return a config with default user_id
with patch("plugins.memory.mem0._load_config", return_value={
"api_key": "test-key",
"user_id": "hermes-user",
"agent_id": "hermes",
"rerank": True,
}):
provider.initialize(session_id="test-sess", user_id="tg_user_99")
assert provider._user_id == "tg_user_99"
def test_no_user_id_falls_back_to_config(self):
"""Without user_id in kwargs, should use config default."""
from plugins.memory.mem0 import Mem0MemoryProvider
provider = Mem0MemoryProvider()
with patch("plugins.memory.mem0._load_config", return_value={
"api_key": "test-key",
"user_id": "custom-default",
"agent_id": "hermes",
"rerank": True,
}):
provider.initialize(session_id="test-sess")
assert provider._user_id == "custom-default"
def test_no_user_id_no_config_uses_hermes_user(self):
"""Without user_id or config override, should default to 'hermes-user'."""
from plugins.memory.mem0 import Mem0MemoryProvider
provider = Mem0MemoryProvider()
with patch("plugins.memory.mem0._load_config", return_value={
"api_key": "test-key",
"agent_id": "hermes",
"rerank": True,
}):
provider.initialize(session_id="test-sess")
assert provider._user_id == "hermes-user"
def test_different_users_get_different_ids(self):
"""Two providers initialized with different user_ids should be scoped differently."""
from plugins.memory.mem0 import Mem0MemoryProvider
p1 = Mem0MemoryProvider()
p2 = Mem0MemoryProvider()
with patch("plugins.memory.mem0._load_config", return_value={
"api_key": "test-key",
"user_id": "hermes-user",
"agent_id": "hermes",
"rerank": True,
}):
p1.initialize(session_id="sess-1", user_id="alice_123")
p2.initialize(session_id="sess-2", user_id="bob_456")
assert p1._user_id == "alice_123"
assert p2._user_id == "bob_456"
assert p1._user_id != p2._user_id
# ---------------------------------------------------------------------------
# Honcho provider user_id tests
# ---------------------------------------------------------------------------
class TestHonchoUserIdScoping:
"""Verify Honcho plugin uses gateway user_id for peer_name when provided."""
def test_gateway_user_id_overrides_peer_name(self):
"""When user_id is in kwargs, cfg.peer_name should be overridden."""
from plugins.memory.honcho import HonchoMemoryProvider
provider = HonchoMemoryProvider()
# Create a mock config with a static peer_name
mock_cfg = MagicMock()
mock_cfg.enabled = True
mock_cfg.api_key = "test-key"
mock_cfg.base_url = None
mock_cfg.peer_name = "static-user"
mock_cfg.recall_mode = "tools" # Use tools mode to defer session init
with patch(
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
return_value=mock_cfg,
):
provider.initialize(
session_id="test-sess",
user_id="discord_user_789",
platform="discord",
)
# The config's peer_name should have been overridden with the user_id
assert mock_cfg.peer_name == "discord_user_789"
def test_no_user_id_preserves_config_peer_name(self):
"""Without user_id, the config peer_name should be preserved."""
from plugins.memory.honcho import HonchoMemoryProvider
provider = HonchoMemoryProvider()
mock_cfg = MagicMock()
mock_cfg.enabled = True
mock_cfg.api_key = "test-key"
mock_cfg.base_url = None
mock_cfg.peer_name = "my-custom-peer"
mock_cfg.recall_mode = "tools"
with patch(
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
return_value=mock_cfg,
):
provider.initialize(
session_id="test-sess",
platform="cli",
)
# peer_name should not have been overridden
assert mock_cfg.peer_name == "my-custom-peer"
# ---------------------------------------------------------------------------
# AIAgent user_id propagation test
# ---------------------------------------------------------------------------
class TestAIAgentUserIdPropagation:
"""Verify AIAgent stores user_id and passes it to memory init kwargs."""
def test_user_id_stored_on_agent(self):
"""AIAgent should store user_id as instance attribute."""
with patch.dict(os.environ, {"HERMES_HOME": "/tmp/test_hermes"}):
from run_agent import AIAgent
agent = object.__new__(AIAgent)
# Manually set the attribute as __init__ does
agent._user_id = "test_user_42"
assert agent._user_id == "test_user_42"
def test_user_id_none_by_default(self):
"""AIAgent should have None user_id when not provided (CLI mode)."""
with patch.dict(os.environ, {"HERMES_HOME": "/tmp/test_hermes"}):
from run_agent import AIAgent
agent = object.__new__(AIAgent)
agent._user_id = None
assert agent._user_id is None