Skip to content
Merged
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
2 changes: 2 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -4544,6 +4544,7 @@ def run_sync():
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,
)
Expand Down Expand Up @@ -6632,6 +6633,7 @@ def run_sync():
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,
)
Expand Down
6 changes: 6 additions & 0 deletions plugins/memory/honcho/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,12 @@ def initialize(self, session_id: str, **kwargs) -> None:
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 -----
Expand Down
4 changes: 3 additions & 1 deletion plugins/memory/mem0/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,9 @@ def _record_failure(self):
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)

Expand Down
5 changes: 5 additions & 0 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,7 @@ def __init__(
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,
Expand Down Expand Up @@ -592,6 +593,7 @@ def __init__(
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
Expand Down Expand Up @@ -1094,6 +1096,9 @@ def __init__(
"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
Expand Down
289 changes: 289 additions & 0 deletions tests/agent/test_memory_user_id.py
Original file line number Diff line number Diff line change
@@ -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
Loading