diff --git a/gateway/run.py b/gateway/run.py index 2eb745f92bd..5a19546ccb1 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5679,6 +5679,11 @@ def run_sync(): session_id=task_id, platform=platform_key, user_id=source.user_id, + user_name=source.user_name, + chat_id=source.chat_id, + chat_name=source.chat_name, + chat_type=source.chat_type, + thread_id=source.thread_id, session_db=self._session_db, fallback_model=self._fallback_model, ) @@ -7297,6 +7302,7 @@ def _clear_session_env(self, tokens: list) -> None: """Restore session context variables to their pre-handler values.""" from gateway.session_context import clear_session_vars clear_session_vars(tokens) + async def _enrich_message_with_vision( self, @@ -8489,6 +8495,11 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: session_id=session_id, platform=platform_key, user_id=source.user_id, + user_name=source.user_name, + chat_id=source.chat_id, + chat_name=source.chat_name, + chat_type=source.chat_type, + thread_id=source.thread_id, session_db=self._session_db, fallback_model=self._fallback_model, ) diff --git a/plugins/memory/hindsight/README.md b/plugins/memory/hindsight/README.md index 024a9930312..3fbdc2aba43 100644 --- a/plugins/memory/hindsight/README.md +++ b/plugins/memory/hindsight/README.md @@ -84,7 +84,10 @@ Config file: `~/.hermes/hindsight/config.json` | `retain_async` | `true` | Process retain asynchronously on the Hindsight server | | `retain_every_n_turns` | `1` | Retain every N turns (1 = every turn) | | `retain_context` | `conversation between Hermes Agent and the User` | Context label for retained memories | -| `tags` | — | Tags applied when storing memories | +| `retain_tags` | — | Default tags applied to retained memories; merged with per-call tool tags | +| `retain_source` | — | Optional `metadata.source` attached to retained memories | +| `retain_user_prefix` | `User` | Label used before user turns in auto-retained transcripts | +| `retain_assistant_prefix` | `Assistant` | Label used before assistant turns in auto-retained transcripts | ### Integration @@ -113,7 +116,7 @@ Available in `hybrid` and `tools` memory modes: | Tool | Description | |------|-------------| -| `hindsight_retain` | Store information with auto entity extraction | +| `hindsight_retain` | Store information with auto entity extraction; supports optional per-call `tags` | | `hindsight_recall` | Multi-strategy search (semantic + entity graph) | | `hindsight_reflect` | Cross-memory synthesis (LLM-powered) | diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index c39679b73c8..2b233e265ca 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -6,11 +6,15 @@ Original PR #1811 by benfrank241, adapted to MemoryProvider ABC. Config via environment variables: - HINDSIGHT_API_KEY — API key for Hindsight Cloud - HINDSIGHT_BANK_ID — memory bank identifier (default: hermes) - HINDSIGHT_BUDGET — recall budget: low/mid/high (default: mid) - HINDSIGHT_API_URL — API endpoint - HINDSIGHT_MODE — cloud or local (default: cloud) + HINDSIGHT_API_KEY — API key for Hindsight Cloud + HINDSIGHT_BANK_ID — memory bank identifier (default: hermes) + HINDSIGHT_BUDGET — recall budget: low/mid/high (default: mid) + HINDSIGHT_API_URL — API endpoint + HINDSIGHT_MODE — cloud or local (default: cloud) + HINDSIGHT_RETAIN_TAGS — comma-separated tags attached to retained memories + HINDSIGHT_RETAIN_SOURCE — metadata source value attached to retained memories + HINDSIGHT_RETAIN_USER_PREFIX — label used before user turns in retained transcripts + HINDSIGHT_RETAIN_ASSISTANT_PREFIX — label used before assistant turns in retained transcripts Or via $HERMES_HOME/hindsight/config.json (profile-scoped), falling back to ~/.hindsight/config.json (legacy, shared) for backward compatibility. @@ -24,7 +28,7 @@ import os import threading -from hermes_constants import get_hermes_home +from datetime import datetime, timezone from typing import Any, Dict, List from agent.memory_provider import MemoryProvider @@ -99,6 +103,11 @@ def _run_sync(coro, timeout: float = 120.0): "properties": { "content": {"type": "string", "description": "The information to store."}, "context": {"type": "string", "description": "Short label (e.g. 'user preference', 'project decision')."}, + "tags": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional per-call tags to merge with configured default retain tags.", + }, }, "required": ["content"], }, @@ -168,6 +177,10 @@ def _load_config() -> dict: return { "mode": os.environ.get("HINDSIGHT_MODE", "cloud"), "apiKey": os.environ.get("HINDSIGHT_API_KEY", ""), + "retain_tags": os.environ.get("HINDSIGHT_RETAIN_TAGS", ""), + "retain_source": os.environ.get("HINDSIGHT_RETAIN_SOURCE", ""), + "retain_user_prefix": os.environ.get("HINDSIGHT_RETAIN_USER_PREFIX", "User"), + "retain_assistant_prefix": os.environ.get("HINDSIGHT_RETAIN_ASSISTANT_PREFIX", "Assistant"), "banks": { "hermes": { "bankId": os.environ.get("HINDSIGHT_BANK_ID", "hermes"), @@ -178,6 +191,48 @@ def _load_config() -> dict: } +def _normalize_retain_tags(value: Any) -> List[str]: + """Normalize tag config/tool values to a deduplicated list of strings.""" + if value is None: + return [] + + raw_items: list[Any] + if isinstance(value, list): + raw_items = value + elif isinstance(value, str): + text = value.strip() + if not text: + return [] + if text.startswith("["): + try: + parsed = json.loads(text) + except Exception: + parsed = None + if isinstance(parsed, list): + raw_items = parsed + else: + raw_items = text.split(",") + else: + raw_items = text.split(",") + else: + raw_items = [value] + + normalized = [] + seen = set() + for item in raw_items: + tag = str(item).strip() + if not tag or tag in seen: + continue + seen.add(tag) + normalized.append(tag) + return normalized + + +def _utc_timestamp() -> str: + """Return current UTC timestamp in ISO-8601 with milliseconds and Z suffix.""" + return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + # --------------------------------------------------------------------------- # MemoryProvider implementation # --------------------------------------------------------------------------- @@ -195,6 +250,19 @@ def __init__(self): self._llm_base_url = "" self._memory_mode = "hybrid" # "context", "tools", or "hybrid" self._prefetch_method = "recall" # "recall" or "reflect" + self._retain_tags: List[str] = [] + self._retain_source = "" + self._retain_user_prefix = "User" + self._retain_assistant_prefix = "Assistant" + self._platform = "" + self._user_id = "" + self._user_name = "" + self._chat_id = "" + self._chat_name = "" + self._chat_type = "" + self._thread_id = "" + self._agent_identity = "" + self._turn_index = 0 self._client = None self._prefetch_result = "" self._prefetch_lock = threading.Lock() @@ -210,6 +278,7 @@ def __init__(self): # Retain controls self._auto_retain = True self._retain_every_n_turns = 1 + self._retain_async = True self._retain_context = "conversation between Hermes Agent and the User" self._turn_counter = 0 self._session_turns: list[str] = [] # accumulates ALL turns for the session @@ -224,7 +293,6 @@ def __init__(self): # Bank self._bank_mission = "" self._bank_retain_mission: str | None = None - self._retain_async = True @property def name(self) -> str: @@ -423,7 +491,10 @@ def get_config_schema(self): {"key": "recall_budget", "description": "Recall thoroughness", "default": "mid", "choices": ["low", "mid", "high"]}, {"key": "memory_mode", "description": "Memory integration mode", "default": "hybrid", "choices": ["hybrid", "context", "tools"]}, {"key": "recall_prefetch_method", "description": "Auto-recall method", "default": "recall", "choices": ["recall", "reflect"]}, - {"key": "tags", "description": "Tags applied when storing memories (comma-separated)", "default": ""}, + {"key": "retain_tags", "description": "Default tags applied to retained memories (comma-separated)", "default": ""}, + {"key": "retain_source", "description": "Metadata source value attached to retained memories", "default": ""}, + {"key": "retain_user_prefix", "description": "Label used before user turns in retained transcripts", "default": "User"}, + {"key": "retain_assistant_prefix", "description": "Label used before assistant turns in retained transcripts", "default": "Assistant"}, {"key": "recall_tags", "description": "Tags to filter when searching memories (comma-separated)", "default": ""}, {"key": "recall_tags_match", "description": "Tag matching mode for recall", "default": "any", "choices": ["any", "all", "any_strict", "all_strict"]}, {"key": "auto_recall", "description": "Automatically recall memories before each turn", "default": True}, @@ -467,7 +538,7 @@ def _get_client(self): return self._client def initialize(self, session_id: str, **kwargs) -> None: - self._session_id = session_id + self._session_id = str(session_id or "").strip() # Check client version and auto-upgrade if needed try: @@ -496,6 +567,16 @@ def initialize(self, session_id: str, **kwargs) -> None: pass # packaging not available or other issue — proceed anyway self._config = _load_config() + self._platform = str(kwargs.get("platform") or "").strip() + self._user_id = str(kwargs.get("user_id") or "").strip() + self._user_name = str(kwargs.get("user_name") or "").strip() + self._chat_id = str(kwargs.get("chat_id") or "").strip() + self._chat_name = str(kwargs.get("chat_name") or "").strip() + self._chat_type = str(kwargs.get("chat_type") or "").strip() + self._thread_id = str(kwargs.get("thread_id") or "").strip() + self._agent_identity = str(kwargs.get("agent_identity") or "").strip() + self._turn_index = 0 + self._session_turns = [] self._mode = self._config.get("mode", "cloud") # "local" is a legacy alias for "local_embedded" if self._mode == "local": @@ -513,7 +594,7 @@ def initialize(self, session_id: str, **kwargs) -> None: memory_mode = self._config.get("memory_mode", "hybrid") self._memory_mode = memory_mode if memory_mode in ("context", "tools", "hybrid") else "hybrid" - prefetch_method = self._config.get("recall_prefetch_method", "recall") + prefetch_method = self._config.get("recall_prefetch_method") or self._config.get("prefetch_method", "recall") self._prefetch_method = prefetch_method if prefetch_method in ("recall", "reflect") else "recall" # Bank options @@ -521,9 +602,22 @@ def initialize(self, session_id: str, **kwargs) -> None: self._bank_retain_mission = self._config.get("bank_retain_mission") or None # Tags - self._tags = self._config.get("tags") or None + self._retain_tags = _normalize_retain_tags( + self._config.get("retain_tags") + or os.environ.get("HINDSIGHT_RETAIN_TAGS", "") + ) + self._tags = self._retain_tags or None self._recall_tags = self._config.get("recall_tags") or None self._recall_tags_match = self._config.get("recall_tags_match", "any") + self._retain_source = str( + self._config.get("retain_source") or os.environ.get("HINDSIGHT_RETAIN_SOURCE", "") + ).strip() + self._retain_user_prefix = str( + self._config.get("retain_user_prefix") or os.environ.get("HINDSIGHT_RETAIN_USER_PREFIX", "User") + ).strip() or "User" + self._retain_assistant_prefix = str( + self._config.get("retain_assistant_prefix") or os.environ.get("HINDSIGHT_RETAIN_ASSISTANT_PREFIX", "Assistant") + ).strip() or "Assistant" # Retain controls self._auto_retain = self._config.get("auto_retain", True) @@ -547,11 +641,9 @@ def initialize(self, session_id: str, **kwargs) -> None: logger.info("Hindsight initialized: mode=%s, api_url=%s, bank=%s, budget=%s, memory_mode=%s, prefetch_method=%s, client=%s", self._mode, self._api_url, self._bank_id, self._budget, self._memory_mode, self._prefetch_method, _client_version) logger.debug("Hindsight config: auto_retain=%s, auto_recall=%s, retain_every_n=%d, " - "retain_async=%s, retain_context=%s, " - "recall_max_tokens=%d, recall_max_input_chars=%d, tags=%s, recall_tags=%s", + "retain_async=%s, retain_context=%s, recall_max_tokens=%d, recall_max_input_chars=%d, tags=%s, recall_tags=%s", self._auto_retain, self._auto_recall, self._retain_every_n_turns, - self._retain_async, self._retain_context, - self._recall_max_tokens, self._recall_max_input_chars, + self._retain_async, self._retain_context, self._recall_max_tokens, self._recall_max_input_chars, self._tags, self._recall_tags) # For local mode, start the embedded daemon in the background so it @@ -712,6 +804,78 @@ def _run(): self._prefetch_thread = threading.Thread(target=_run, daemon=True, name="hindsight-prefetch") self._prefetch_thread.start() + def _build_turn_messages(self, user_content: str, assistant_content: str) -> List[Dict[str, str]]: + now = datetime.now(timezone.utc).isoformat() + return [ + { + "role": "user", + "content": f"{self._retain_user_prefix}: {user_content}", + "timestamp": now, + }, + { + "role": "assistant", + "content": f"{self._retain_assistant_prefix}: {assistant_content}", + "timestamp": now, + }, + ] + + def _build_metadata(self, *, message_count: int, turn_index: int) -> Dict[str, str]: + metadata: Dict[str, str] = { + "retained_at": _utc_timestamp(), + "message_count": str(message_count), + "turn_index": str(turn_index), + } + if self._retain_source: + metadata["source"] = self._retain_source + if self._session_id: + metadata["session_id"] = self._session_id + if self._platform: + metadata["platform"] = self._platform + if self._user_id: + metadata["user_id"] = self._user_id + if self._user_name: + metadata["user_name"] = self._user_name + if self._chat_id: + metadata["chat_id"] = self._chat_id + if self._chat_name: + metadata["chat_name"] = self._chat_name + if self._chat_type: + metadata["chat_type"] = self._chat_type + if self._thread_id: + metadata["thread_id"] = self._thread_id + if self._agent_identity: + metadata["agent_identity"] = self._agent_identity + return metadata + + def _build_retain_kwargs( + self, + content: str, + *, + context: str | None = None, + document_id: str | None = None, + metadata: Dict[str, str] | None = None, + tags: List[str] | None = None, + retain_async: bool | None = None, + ) -> Dict[str, Any]: + kwargs: Dict[str, Any] = { + "bank_id": self._bank_id, + "content": content, + "metadata": metadata or self._build_metadata(message_count=1, turn_index=self._turn_index), + } + if context is not None: + kwargs["context"] = context + if document_id: + kwargs["document_id"] = document_id + if retain_async is not None: + kwargs["retain_async"] = retain_async + merged_tags = _normalize_retain_tags(self._retain_tags) + for tag in _normalize_retain_tags(tags): + if tag not in merged_tags: + merged_tags.append(tag) + if merged_tags: + kwargs["tags"] = merged_tags + return kwargs + def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None: """Retain conversation turn in background (non-blocking). @@ -721,19 +885,14 @@ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: st logger.debug("sync_turn: skipped (auto_retain disabled)") return - from datetime import datetime, timezone - now = datetime.now(timezone.utc).isoformat() + if session_id: + self._session_id = str(session_id).strip() - messages = [ - {"role": "user", "content": user_content, "timestamp": now}, - {"role": "assistant", "content": assistant_content, "timestamp": now}, - ] - - turn = json.dumps(messages) + turn = json.dumps(self._build_turn_messages(user_content, assistant_content)) self._session_turns.append(turn) self._turn_counter += 1 + self._turn_index = self._turn_counter - # Only retain every N turns if self._turn_counter % self._retain_every_n_turns != 0: logger.debug("sync_turn: buffered turn %d (will retain at turn %d)", self._turn_counter, self._turn_counter + (self._retain_every_n_turns - self._turn_counter % self._retain_every_n_turns)) @@ -741,19 +900,21 @@ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: st logger.debug("sync_turn: retaining %d turns, total session content %d chars", len(self._session_turns), sum(len(t) for t in self._session_turns)) - # Send the ENTIRE session as a single JSON array (document_id deduplicates). - # Each element in _session_turns is a JSON string of that turn's messages. content = "[" + ",".join(self._session_turns) + "]" def _sync(): try: client = self._get_client() - item: dict = { - "content": content, - "context": self._retain_context, - } - if self._tags: - item["tags"] = self._tags + item = self._build_retain_kwargs( + content, + context=self._retain_context, + metadata=self._build_metadata( + message_count=len(self._session_turns) * 2, + turn_index=self._turn_index, + ), + ) + item.pop("bank_id", None) + item.pop("retain_async", None) logger.debug("Hindsight retain: bank=%s, doc=%s, async=%s, content_len=%d, num_turns=%d", self._bank_id, self._session_id, self._retain_async, len(content), len(self._session_turns)) _run_sync(client.aretain_batch( @@ -789,11 +950,11 @@ def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str: return tool_error("Missing required parameter: content") context = args.get("context") try: - retain_kwargs: dict = { - "bank_id": self._bank_id, "content": content, "context": context, - } - if self._tags: - retain_kwargs["tags"] = self._tags + retain_kwargs = self._build_retain_kwargs( + content, + context=context, + tags=args.get("tags"), + ) logger.debug("Tool hindsight_retain: bank=%s, content_len=%d, context=%s", self._bank_id, len(content), context) _run_sync(client.aretain(**retain_kwargs)) diff --git a/run_agent.py b/run_agent.py index efaeba82945..d43678157d9 100644 --- a/run_agent.py +++ b/run_agent.py @@ -593,6 +593,11 @@ def __init__( prefill_messages: List[Dict[str, Any]] = None, platform: str = None, user_id: str = None, + user_name: str = None, + chat_id: str = None, + chat_name: str = None, + chat_type: str = None, + thread_id: str = None, skip_context_files: bool = False, skip_memory: bool = False, session_db=None, @@ -640,6 +645,12 @@ def __init__( Example: [{"role": "user", "content": "Hi!"}, {"role": "assistant", "content": "Hello!"}] platform (str): The interface platform the user is on (e.g. "cli", "telegram", "discord", "whatsapp"). Used to inject platform-specific formatting hints into the system prompt. + user_id (str): Platform user identifier for the current session, when available. + user_name (str): Human-readable sender name for the current session, when available. + chat_id (str): Platform chat/channel identifier for the current session, when available. + chat_name (str): Human-readable chat/channel name for the current session, when available. + chat_type (str): Session surface type like dm/group/channel/thread, when available. + thread_id (str): Platform thread/topic identifier for the current session, when available. skip_context_files (bool): If True, skip auto-injection of SOUL.md, AGENTS.md, and .cursorrules into the system prompt. Use this for batch processing and data generation to avoid polluting trajectories with user-specific persona or project instructions. @@ -657,7 +668,12 @@ 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) + self._user_id = user_id or os.getenv("HERMES_SESSION_USER_ID") # Platform user identifier (gateway sessions) + self._user_name = user_name or os.getenv("HERMES_SESSION_USER_NAME") + self._chat_id = chat_id or os.getenv("HERMES_SESSION_CHAT_ID") + self._chat_name = chat_name or os.getenv("HERMES_SESSION_CHAT_NAME") + self._chat_type = chat_type or os.getenv("HERMES_SESSION_CHAT_TYPE") + self._thread_id = thread_id or os.getenv("HERMES_SESSION_THREAD_ID") # 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 @@ -1203,9 +1219,21 @@ def __init__( "hermes_home": str(_ghh()), "agent_context": "primary", } + if self._parent_session_id: + _init_kwargs["parent_session_id"] = self._parent_session_id # Thread gateway user identity for per-user memory scoping if self._user_id: _init_kwargs["user_id"] = self._user_id + if self._user_name: + _init_kwargs["user_name"] = self._user_name + if self._chat_id: + _init_kwargs["chat_id"] = self._chat_id + if self._chat_name: + _init_kwargs["chat_name"] = self._chat_name + if self._chat_type: + _init_kwargs["chat_type"] = self._chat_type + if self._thread_id: + _init_kwargs["thread_id"] = self._thread_id # Profile identity for per-profile provider scoping try: from hermes_cli.profiles import get_active_profile_name diff --git a/tests/agent/test_memory_user_id.py b/tests/agent/test_memory_user_id.py index c1b82208d0e..e862e2c6457 100644 --- a/tests/agent/test_memory_user_id.py +++ b/tests/agent/test_memory_user_id.py @@ -79,6 +79,28 @@ def test_user_id_forwarded_to_provider(self): assert p._init_kwargs.get("platform") == "telegram" assert p._init_session_id == "sess-123" + def test_chat_context_forwarded_to_provider(self): + mgr = MemoryManager() + p = RecordingProvider() + mgr.add_provider(p) + + mgr.initialize_all( + session_id="sess-chat", + platform="discord", + user_id="discord_u_7", + user_name="fakeusername", + chat_id="1485316232612941897", + chat_name="fakeassistantname-forums", + chat_type="thread", + thread_id="1491249007475949698", + ) + + assert p._init_kwargs.get("user_name") == "fakeusername" + assert p._init_kwargs.get("chat_id") == "1485316232612941897" + assert p._init_kwargs.get("chat_name") == "fakeassistantname-forums" + assert p._init_kwargs.get("chat_type") == "thread" + assert p._init_kwargs.get("thread_id") == "1491249007475949698" + def test_no_user_id_when_cli(self): """CLI sessions should not have user_id in kwargs.""" mgr = MemoryManager() @@ -287,3 +309,15 @@ def test_user_id_none_by_default(self): agent = object.__new__(AIAgent) agent._user_id = None assert agent._user_id is None + + def test_user_id_can_fall_back_from_session_env(self, monkeypatch): + monkeypatch.setenv("HERMES_SESSION_USER_ID", "env_user_42") + + from run_agent import AIAgent + + agent = object.__new__(AIAgent) + agent._user_id = None + agent._user_id = None or os.getenv("HERMES_SESSION_USER_ID") + + assert agent._user_id == "env_user_42" + diff --git a/tests/plugins/memory/test_hindsight_provider.py b/tests/plugins/memory/test_hindsight_provider.py index 5548a29ad41..db86f7626fa 100644 --- a/tests/plugins/memory/test_hindsight_provider.py +++ b/tests/plugins/memory/test_hindsight_provider.py @@ -6,6 +6,7 @@ """ import json +import re import threading from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -18,6 +19,7 @@ REFLECT_SCHEMA, RETAIN_SCHEMA, _load_config, + _normalize_retain_tags, ) @@ -32,14 +34,30 @@ def _clean_env(monkeypatch): for key in ( "HINDSIGHT_API_KEY", "HINDSIGHT_API_URL", "HINDSIGHT_BANK_ID", "HINDSIGHT_BUDGET", "HINDSIGHT_MODE", "HINDSIGHT_LLM_API_KEY", + "HINDSIGHT_RETAIN_TAGS", "HINDSIGHT_RETAIN_SOURCE", + "HINDSIGHT_RETAIN_USER_PREFIX", "HINDSIGHT_RETAIN_ASSISTANT_PREFIX", ): monkeypatch.delenv(key, raising=False) def _make_mock_client(): """Create a mock Hindsight client with async methods.""" + async def _aretain( + bank_id, + content, + timestamp=None, + context=None, + document_id=None, + metadata=None, + entities=None, + tags=None, + update_mode=None, + retain_async=None, + ): + return SimpleNamespace(ok=True) + client = MagicMock() - client.aretain = AsyncMock() + client.aretain = AsyncMock(side_effect=_aretain) client.arecall = AsyncMock( return_value=SimpleNamespace( results=[ @@ -56,6 +74,14 @@ def _make_mock_client(): return client +class _FakeSessionDB: + def __init__(self, messages=None): + self._messages = list(messages or []) + + def get_messages_as_conversation(self, session_id): + return list(self._messages) + + @pytest.fixture() def provider(tmp_path, monkeypatch): """Create an initialized HindsightMemoryProvider with a mock client.""" @@ -109,6 +135,18 @@ def _make(**overrides): return _make +def test_normalize_retain_tags_accepts_csv_and_dedupes(): + assert _normalize_retain_tags("agent:fakeassistantname, source_system:hermes-agent, agent:fakeassistantname") == [ + "agent:fakeassistantname", + "source_system:hermes-agent", + ] + + +def test_normalize_retain_tags_accepts_json_array_string(): + value = json.dumps(["agent:fakeassistantname", "source_system:hermes-agent"]) + assert _normalize_retain_tags(value) == ["agent:fakeassistantname", "source_system:hermes-agent"] + + # --------------------------------------------------------------------------- # Schema tests # --------------------------------------------------------------------------- @@ -118,6 +156,7 @@ class TestSchemas: def test_retain_schema_has_content(self): assert RETAIN_SCHEMA["name"] == "hindsight_retain" assert "content" in RETAIN_SCHEMA["parameters"]["properties"] + assert "tags" in RETAIN_SCHEMA["parameters"]["properties"] assert "content" in RETAIN_SCHEMA["parameters"]["required"] def test_recall_schema_has_query(self): @@ -160,7 +199,10 @@ def test_default_values(self, provider): def test_custom_config_values(self, provider_with_config): p = provider_with_config( - tags=["tag1", "tag2"], + retain_tags=["tag1", "tag2"], + retain_source="hermes", + retain_user_prefix="User (fakeusername)", + retain_assistant_prefix="Assistant (fakeassistantname)", recall_tags=["recall-tag"], recall_tags_match="all", auto_retain=False, @@ -175,6 +217,10 @@ def test_custom_config_values(self, provider_with_config): bank_mission="Test agent mission", ) assert p._tags == ["tag1", "tag2"] + assert p._retain_tags == ["tag1", "tag2"] + assert p._retain_source == "hermes" + assert p._retain_user_prefix == "User (fakeusername)" + assert p._retain_assistant_prefix == "Assistant (fakeassistantname)" assert p._recall_tags == ["recall-tag"] assert p._recall_tags_match == "all" assert p._auto_retain is False @@ -222,11 +268,20 @@ def test_retain_success(self, provider): assert call_kwargs["content"] == "user likes dark mode" def test_retain_with_tags(self, provider_with_config): - p = provider_with_config(tags=["pref", "ui"]) + p = provider_with_config(retain_tags=["pref", "ui"]) p.handle_tool_call("hindsight_retain", {"content": "likes dark mode"}) call_kwargs = p._client.aretain.call_args.kwargs assert call_kwargs["tags"] == ["pref", "ui"] + def test_retain_merges_per_call_tags_with_config_tags(self, provider_with_config): + p = provider_with_config(retain_tags=["pref", "ui"]) + p.handle_tool_call( + "hindsight_retain", + {"content": "likes dark mode", "tags": ["client:x", "ui"]}, + ) + call_kwargs = p._client.aretain.call_args.kwargs + assert call_kwargs["tags"] == ["pref", "ui", "client:x"] + def test_retain_without_tags(self, provider): provider.handle_tool_call("hindsight_retain", {"content": "hello"}) call_kwargs = provider._client.aretain.call_args.kwargs @@ -389,132 +444,92 @@ def test_queue_prefetch_passes_recall_params(self, provider_with_config): class TestSyncTurn: - def _get_retain_kwargs(self, provider): - """Helper to get the kwargs from the aretain_batch call.""" - return provider._client.aretain_batch.call_args.kwargs - - def _get_retain_content(self, provider): - """Helper to get the raw content string from the first item.""" - kwargs = self._get_retain_kwargs(provider) - return kwargs["items"][0]["content"] - - def _get_retain_messages(self, provider): - """Helper to parse the first turn's messages from retained content. - - Content is a JSON array of turns: [[msgs...], [msgs...], ...] - For single-turn tests, returns the first turn's messages. - """ - content = self._get_retain_content(provider) - turns = json.loads(content) - return turns[0] if len(turns) == 1 else turns - - def test_sync_turn_retains(self, provider): - provider.sync_turn("hello", "hi there") - if provider._sync_thread: - provider._sync_thread.join(timeout=5.0) - provider._client.aretain_batch.assert_called_once() - messages = self._get_retain_messages(provider) - assert len(messages) == 2 - assert messages[0]["role"] == "user" - assert messages[0]["content"] == "hello" - assert "timestamp" in messages[0] - assert messages[1]["role"] == "assistant" - assert messages[1]["content"] == "hi there" - assert "timestamp" in messages[1] - - def test_sync_turn_skipped_when_auto_retain_off(self, provider_with_config): - p = provider_with_config(auto_retain=False) - p.sync_turn("hello", "hi") - assert p._sync_thread is None - p._client.aretain_batch.assert_not_called() + def test_sync_turn_retains_metadata_rich_turn(self, provider_with_config): + p = provider_with_config( + retain_tags=["conv", "session1"], + retain_source="hermes", + retain_user_prefix="User (fakeusername)", + retain_assistant_prefix="Assistant (fakeassistantname)", + ) + p.initialize( + session_id="session-1", + platform="discord", + user_id="fakeusername-123", + user_name="fakeusername", + chat_id="1485316232612941897", + chat_name="fakeassistantname-forums", + chat_type="thread", + thread_id="1491249007475949698", + agent_identity="fakeassistantname", + ) + p._client = _make_mock_client() - def test_sync_turn_with_tags(self, provider_with_config): - p = provider_with_config(tags=["conv", "session1"]) - p.sync_turn("hello", "hi") - if p._sync_thread: - p._sync_thread.join(timeout=5.0) - item = p._client.aretain_batch.call_args.kwargs["items"][0] - assert item["tags"] == ["conv", "session1"] + p.sync_turn("hello", "hi there") + p._sync_thread.join(timeout=5.0) - def test_sync_turn_uses_aretain_batch(self, provider): - """sync_turn should use aretain_batch with retain_async.""" - provider.sync_turn("hello", "hi") - if provider._sync_thread: - provider._sync_thread.join(timeout=5.0) - provider._client.aretain_batch.assert_called_once() - call_kwargs = provider._client.aretain_batch.call_args.kwargs - assert call_kwargs["document_id"] == "test-session" + p._client.aretain_batch.assert_called_once() + call_kwargs = p._client.aretain_batch.call_args.kwargs + assert call_kwargs["bank_id"] == "test-bank" + assert call_kwargs["document_id"] == "session-1" assert call_kwargs["retain_async"] is True assert len(call_kwargs["items"]) == 1 - assert call_kwargs["items"][0]["context"] == "conversation between Hermes Agent and the User" + item = call_kwargs["items"][0] + assert item["context"] == "conversation between Hermes Agent and the User" + assert item["tags"] == ["conv", "session1"] + content = json.loads(item["content"]) + assert len(content) == 1 + assert content[0][0]["role"] == "user" + assert content[0][0]["content"] == "User (fakeusername): hello" + assert content[0][1]["role"] == "assistant" + assert content[0][1]["content"] == "Assistant (fakeassistantname): hi there" + assert item["metadata"]["source"] == "hermes" + assert item["metadata"]["session_id"] == "session-1" + assert item["metadata"]["platform"] == "discord" + assert item["metadata"]["user_id"] == "fakeusername-123" + assert item["metadata"]["user_name"] == "fakeusername" + assert item["metadata"]["chat_id"] == "1485316232612941897" + assert item["metadata"]["chat_name"] == "fakeassistantname-forums" + assert item["metadata"]["chat_type"] == "thread" + assert item["metadata"]["thread_id"] == "1491249007475949698" + assert item["metadata"]["agent_identity"] == "fakeassistantname" + assert item["metadata"]["turn_index"] == "1" + assert item["metadata"]["message_count"] == "2" + assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?\+00:00", content[0][0]["timestamp"]) + assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z", item["metadata"]["retained_at"]) - def test_sync_turn_custom_context(self, provider_with_config): - p = provider_with_config(retain_context="my-agent") + def test_sync_turn_skipped_when_auto_retain_off(self, provider_with_config): + p = provider_with_config(auto_retain=False) p.sync_turn("hello", "hi") - if p._sync_thread: - p._sync_thread.join(timeout=5.0) - item = p._client.aretain_batch.call_args.kwargs["items"][0] - assert item["context"] == "my-agent" + assert p._sync_thread is None + p._client.aretain_batch.assert_not_called() def test_sync_turn_every_n_turns(self, provider_with_config): - """With retain_every_n_turns=3, only retains on every 3rd turn.""" - p = provider_with_config(retain_every_n_turns=3) - + p = provider_with_config(retain_every_n_turns=3, retain_async=False) p.sync_turn("turn1-user", "turn1-asst") - assert p._sync_thread is None # not retained yet - + assert p._sync_thread is None p.sync_turn("turn2-user", "turn2-asst") - assert p._sync_thread is None # not retained yet - + assert p._sync_thread is None p.sync_turn("turn3-user", "turn3-asst") - assert p._sync_thread is not None # retained! p._sync_thread.join(timeout=5.0) - p._client.aretain_batch.assert_called_once() - content = p._client.aretain_batch.call_args.kwargs["items"][0]["content"] - # Should contain all 3 turns - assert "turn1-user" in content - assert "turn2-user" in content - assert "turn3-user" in content - - def test_sync_turn_accumulates_full_session(self, provider_with_config): - """Each retain sends the ENTIRE session, not just the latest batch.""" - p = provider_with_config(retain_every_n_turns=2) - - p.sync_turn("turn1-user", "turn1-asst") - p.sync_turn("turn2-user", "turn2-asst") - if p._sync_thread: - p._sync_thread.join(timeout=5.0) - - p._client.aretain_batch.reset_mock() - - p.sync_turn("turn3-user", "turn3-asst") - p.sync_turn("turn4-user", "turn4-asst") - if p._sync_thread: - p._sync_thread.join(timeout=5.0) - - content = p._client.aretain_batch.call_args.kwargs["items"][0]["content"] - # Should contain ALL turns from the session - assert "turn1-user" in content - assert "turn2-user" in content - assert "turn3-user" in content - assert "turn4-user" in content - - def test_sync_turn_passes_document_id(self, provider): - """sync_turn should pass session_id as document_id for dedup.""" - provider.sync_turn("hello", "hi") - if provider._sync_thread: - provider._sync_thread.join(timeout=5.0) - call_kwargs = provider._client.aretain_batch.call_args.kwargs + call_kwargs = p._client.aretain_batch.call_args.kwargs assert call_kwargs["document_id"] == "test-session" + assert call_kwargs["retain_async"] is False + item = call_kwargs["items"][0] + content = json.loads(item["content"]) + assert len(content) == 3 + assert content[-1][0]["role"] == "user" + assert content[-1][0]["content"] == "User: turn3-user" + assert content[-1][1]["role"] == "assistant" + assert content[-1][1]["content"] == "Assistant: turn3-asst" + assert item["metadata"]["turn_index"] == "3" + assert item["metadata"]["message_count"] == "6" def test_sync_turn_error_does_not_raise(self, provider): - """Errors in sync_turn should be swallowed (non-blocking).""" provider._client.aretain_batch.side_effect = RuntimeError("network error") provider.sync_turn("hello", "hi") if provider._sync_thread: provider._sync_thread.join(timeout=5.0) - # Should not raise # --------------------------------------------------------------------------- @@ -555,10 +570,11 @@ def test_schema_has_all_new_fields(self, provider): "mode", "api_url", "api_key", "llm_provider", "llm_api_key", "llm_model", "bank_id", "bank_mission", "bank_retain_mission", "recall_budget", "memory_mode", "recall_prefetch_method", - "tags", "recall_tags", "recall_tags_match", + "retain_tags", "retain_source", + "retain_user_prefix", "retain_assistant_prefix", + "recall_tags", "recall_tags_match", "auto_recall", "auto_retain", - "retain_every_n_turns", "retain_async", - "retain_context", + "retain_every_n_turns", "retain_async", "retain_context", "recall_max_tokens", "recall_max_input_chars", "recall_prompt_preamble", } diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index f9db4ab5777..b607a7242e5 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -299,7 +299,11 @@ The setup wizard installs dependencies automatically and only installs what's ne | `auto_retain` | `true` | Automatically retain conversation turns | | `auto_recall` | `true` | Automatically recall memories before each turn | | `retain_async` | `true` | Process retain asynchronously on the server | -| `tags` | — | Tags applied when storing memories | +| `retain_context` | `conversation between Hermes Agent and the User` | Context label for retained memories | +| `retain_tags` | — | Default tags applied to retained memories; merged with per-call tool tags | +| `retain_source` | — | Optional `metadata.source` attached to retained memories | +| `retain_user_prefix` | `User` | Label used before user turns in auto-retained transcripts | +| `retain_assistant_prefix` | `Assistant` | Label used before assistant turns in auto-retained transcripts | | `recall_tags` | — | Tags to filter on recall | See [plugin README](https://github.com/NousResearch/hermes-agent/blob/main/plugins/memory/hindsight/README.md) for the full configuration reference.