feat(hindsight): richer session-scoped retain metadata

- Add configurable retain_tags / retain_source / retain_user_prefix /
  retain_assistant_prefix knobs for native Hindsight.
- Thread gateway session identity (user_name, chat_id, chat_name,
  chat_type, thread_id) through AIAgent and MemoryManager into
  MemoryProvider.initialize kwargs so providers can scope and tag
  retained memories.
- Hindsight attaches the new identity fields as retain metadata,
  merges per-call tool tags with configured default tags, and uses
  the configurable transcript labels for auto-retained turns.

Co-authored-by: Abner <abner.the.foreman@agentmail.to>
This commit is contained in:
Abner
2026-04-22 05:23:50 -07:00
committed by Teknium
parent b8663813b6
commit b66644f0ec
7 changed files with 387 additions and 150 deletions

View File

@@ -6475,6 +6475,11 @@ class GatewayRunner:
session_id=task_id, session_id=task_id,
platform=platform_key, platform=platform_key,
user_id=source.user_id, 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, session_db=self._session_db,
fallback_model=self._fallback_model, fallback_model=self._fallback_model,
) )
@@ -9718,6 +9723,11 @@ class GatewayRunner:
session_id=session_id, session_id=session_id,
platform=platform_key, platform=platform_key,
user_id=source.user_id, 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,
gateway_session_key=session_key, gateway_session_key=session_key,
session_db=self._session_db, session_db=self._session_db,
fallback_model=self._fallback_model, fallback_model=self._fallback_model,

View File

@@ -84,7 +84,10 @@ Config file: `~/.hermes/hindsight/config.json`
| `retain_async` | `true` | Process retain asynchronously on the Hindsight server | | `retain_async` | `true` | Process retain asynchronously on the Hindsight server |
| `retain_every_n_turns` | `1` | Retain every N turns (1 = every turn) | | `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 | | `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 ### Integration
@@ -113,7 +116,7 @@ Available in `hybrid` and `tools` memory modes:
| Tool | Description | | 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_recall` | Multi-strategy search (semantic + entity graph) |
| `hindsight_reflect` | Cross-memory synthesis (LLM-powered) | | `hindsight_reflect` | Cross-memory synthesis (LLM-powered) |

View File

@@ -6,11 +6,15 @@ retrieval. Supports cloud (API key) and local modes.
Original PR #1811 by benfrank241, adapted to MemoryProvider ABC. Original PR #1811 by benfrank241, adapted to MemoryProvider ABC.
Config via environment variables: Config via environment variables:
HINDSIGHT_API_KEY — API key for Hindsight Cloud HINDSIGHT_API_KEY — API key for Hindsight Cloud
HINDSIGHT_BANK_ID — memory bank identifier (default: hermes) HINDSIGHT_BANK_ID — memory bank identifier (default: hermes)
HINDSIGHT_BUDGET — recall budget: low/mid/high (default: mid) HINDSIGHT_BUDGET — recall budget: low/mid/high (default: mid)
HINDSIGHT_API_URL — API endpoint HINDSIGHT_API_URL — API endpoint
HINDSIGHT_MODE — cloud or local (default: cloud) 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 Or via $HERMES_HOME/hindsight/config.json (profile-scoped), falling back to
~/.hindsight/config.json (legacy, shared) for backward compatibility. ~/.hindsight/config.json (legacy, shared) for backward compatibility.
@@ -24,7 +28,7 @@ import logging
import os import os
import threading import threading
from hermes_constants import get_hermes_home from datetime import datetime, timezone
from typing import Any, Dict, List from typing import Any, Dict, List
from agent.memory_provider import MemoryProvider from agent.memory_provider import MemoryProvider
@@ -99,6 +103,11 @@ RETAIN_SCHEMA = {
"properties": { "properties": {
"content": {"type": "string", "description": "The information to store."}, "content": {"type": "string", "description": "The information to store."},
"context": {"type": "string", "description": "Short label (e.g. 'user preference', 'project decision')."}, "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"], "required": ["content"],
}, },
@@ -168,6 +177,10 @@ def _load_config() -> dict:
return { return {
"mode": os.environ.get("HINDSIGHT_MODE", "cloud"), "mode": os.environ.get("HINDSIGHT_MODE", "cloud"),
"apiKey": os.environ.get("HINDSIGHT_API_KEY", ""), "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": { "banks": {
"hermes": { "hermes": {
"bankId": os.environ.get("HINDSIGHT_BANK_ID", "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 # MemoryProvider implementation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -195,6 +250,19 @@ class HindsightMemoryProvider(MemoryProvider):
self._llm_base_url = "" self._llm_base_url = ""
self._memory_mode = "hybrid" # "context", "tools", or "hybrid" self._memory_mode = "hybrid" # "context", "tools", or "hybrid"
self._prefetch_method = "recall" # "recall" or "reflect" 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._client = None
self._prefetch_result = "" self._prefetch_result = ""
self._prefetch_lock = threading.Lock() self._prefetch_lock = threading.Lock()
@@ -210,6 +278,7 @@ class HindsightMemoryProvider(MemoryProvider):
# Retain controls # Retain controls
self._auto_retain = True self._auto_retain = True
self._retain_every_n_turns = 1 self._retain_every_n_turns = 1
self._retain_async = True
self._retain_context = "conversation between Hermes Agent and the User" self._retain_context = "conversation between Hermes Agent and the User"
self._turn_counter = 0 self._turn_counter = 0
self._session_turns: list[str] = [] # accumulates ALL turns for the session self._session_turns: list[str] = [] # accumulates ALL turns for the session
@@ -224,7 +293,6 @@ class HindsightMemoryProvider(MemoryProvider):
# Bank # Bank
self._bank_mission = "" self._bank_mission = ""
self._bank_retain_mission: str | None = None self._bank_retain_mission: str | None = None
self._retain_async = True
@property @property
def name(self) -> str: def name(self) -> str:
@@ -423,7 +491,10 @@ class HindsightMemoryProvider(MemoryProvider):
{"key": "recall_budget", "description": "Recall thoroughness", "default": "mid", "choices": ["low", "mid", "high"]}, {"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": "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": "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", "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": "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}, {"key": "auto_recall", "description": "Automatically recall memories before each turn", "default": True},
@@ -467,7 +538,7 @@ class HindsightMemoryProvider(MemoryProvider):
return self._client return self._client
def initialize(self, session_id: str, **kwargs) -> None: 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 # Check client version and auto-upgrade if needed
try: try:
@@ -496,6 +567,16 @@ class HindsightMemoryProvider(MemoryProvider):
pass # packaging not available or other issue — proceed anyway pass # packaging not available or other issue — proceed anyway
self._config = _load_config() 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") self._mode = self._config.get("mode", "cloud")
# "local" is a legacy alias for "local_embedded" # "local" is a legacy alias for "local_embedded"
if self._mode == "local": if self._mode == "local":
@@ -513,7 +594,7 @@ class HindsightMemoryProvider(MemoryProvider):
memory_mode = self._config.get("memory_mode", "hybrid") memory_mode = self._config.get("memory_mode", "hybrid")
self._memory_mode = memory_mode if memory_mode in ("context", "tools", "hybrid") else "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" self._prefetch_method = prefetch_method if prefetch_method in ("recall", "reflect") else "recall"
# Bank options # Bank options
@@ -521,9 +602,22 @@ class HindsightMemoryProvider(MemoryProvider):
self._bank_retain_mission = self._config.get("bank_retain_mission") or None self._bank_retain_mission = self._config.get("bank_retain_mission") or None
# Tags # 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 = self._config.get("recall_tags") or None
self._recall_tags_match = self._config.get("recall_tags_match", "any") 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 # Retain controls
self._auto_retain = self._config.get("auto_retain", True) self._auto_retain = self._config.get("auto_retain", True)
@@ -547,11 +641,9 @@ class HindsightMemoryProvider(MemoryProvider):
logger.info("Hindsight initialized: mode=%s, api_url=%s, bank=%s, budget=%s, memory_mode=%s, prefetch_method=%s, client=%s", 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) 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, " logger.debug("Hindsight config: auto_retain=%s, auto_recall=%s, retain_every_n=%d, "
"retain_async=%s, retain_context=%s, " "retain_async=%s, retain_context=%s, recall_max_tokens=%d, recall_max_input_chars=%d, tags=%s, recall_tags=%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._auto_retain, self._auto_recall, self._retain_every_n_turns,
self._retain_async, self._retain_context, self._retain_async, self._retain_context, self._recall_max_tokens, self._recall_max_input_chars,
self._recall_max_tokens, self._recall_max_input_chars,
self._tags, self._recall_tags) self._tags, self._recall_tags)
# For local mode, start the embedded daemon in the background so it # For local mode, start the embedded daemon in the background so it
@@ -712,6 +804,78 @@ class HindsightMemoryProvider(MemoryProvider):
self._prefetch_thread = threading.Thread(target=_run, daemon=True, name="hindsight-prefetch") self._prefetch_thread = threading.Thread(target=_run, daemon=True, name="hindsight-prefetch")
self._prefetch_thread.start() 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: def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
"""Retain conversation turn in background (non-blocking). """Retain conversation turn in background (non-blocking).
@@ -721,19 +885,14 @@ class HindsightMemoryProvider(MemoryProvider):
logger.debug("sync_turn: skipped (auto_retain disabled)") logger.debug("sync_turn: skipped (auto_retain disabled)")
return return
from datetime import datetime, timezone if session_id:
now = datetime.now(timezone.utc).isoformat() self._session_id = str(session_id).strip()
messages = [ turn = json.dumps(self._build_turn_messages(user_content, assistant_content))
{"role": "user", "content": user_content, "timestamp": now},
{"role": "assistant", "content": assistant_content, "timestamp": now},
]
turn = json.dumps(messages)
self._session_turns.append(turn) self._session_turns.append(turn)
self._turn_counter += 1 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: if self._turn_counter % self._retain_every_n_turns != 0:
logger.debug("sync_turn: buffered turn %d (will retain at turn %d)", 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)) self._turn_counter, self._turn_counter + (self._retain_every_n_turns - self._turn_counter % self._retain_every_n_turns))
@@ -741,19 +900,21 @@ class HindsightMemoryProvider(MemoryProvider):
logger.debug("sync_turn: retaining %d turns, total session content %d chars", 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)) 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) + "]" content = "[" + ",".join(self._session_turns) + "]"
def _sync(): def _sync():
try: try:
client = self._get_client() client = self._get_client()
item: dict = { item = self._build_retain_kwargs(
"content": content, content,
"context": self._retain_context, context=self._retain_context,
} metadata=self._build_metadata(
if self._tags: message_count=len(self._session_turns) * 2,
item["tags"] = self._tags 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", 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)) self._bank_id, self._session_id, self._retain_async, len(content), len(self._session_turns))
_run_sync(client.aretain_batch( _run_sync(client.aretain_batch(
@@ -789,11 +950,11 @@ class HindsightMemoryProvider(MemoryProvider):
return tool_error("Missing required parameter: content") return tool_error("Missing required parameter: content")
context = args.get("context") context = args.get("context")
try: try:
retain_kwargs: dict = { retain_kwargs = self._build_retain_kwargs(
"bank_id": self._bank_id, "content": content, "context": context, content,
} context=context,
if self._tags: tags=args.get("tags"),
retain_kwargs["tags"] = self._tags )
logger.debug("Tool hindsight_retain: bank=%s, content_len=%d, context=%s", logger.debug("Tool hindsight_retain: bank=%s, content_len=%d, context=%s",
self._bank_id, len(content), context) self._bank_id, len(content), context)
_run_sync(client.aretain(**retain_kwargs)) _run_sync(client.aretain(**retain_kwargs))

View File

@@ -751,6 +751,11 @@ class AIAgent:
prefill_messages: List[Dict[str, Any]] = None, prefill_messages: List[Dict[str, Any]] = None,
platform: str = None, platform: str = None,
user_id: 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,
gateway_session_key: str = None, gateway_session_key: str = None,
skip_context_files: bool = False, skip_context_files: bool = False,
skip_memory: bool = False, skip_memory: bool = False,
@@ -820,6 +825,11 @@ class AIAgent:
self.ephemeral_system_prompt = ephemeral_system_prompt self.ephemeral_system_prompt = ephemeral_system_prompt
self.platform = platform # "cli", "telegram", "discord", "whatsapp", etc. self.platform = platform # "cli", "telegram", "discord", "whatsapp", etc.
self._user_id = user_id # Platform user identifier (gateway sessions) self._user_id = user_id # Platform user identifier (gateway sessions)
self._user_name = user_name
self._chat_id = chat_id
self._chat_name = chat_name
self._chat_type = chat_type
self._thread_id = thread_id
self._gateway_session_key = gateway_session_key # Stable per-chat key (e.g. agent:main:telegram:dm:123) self._gateway_session_key = gateway_session_key # Stable per-chat key (e.g. agent:main:telegram:dm:123)
# Pluggable print function — CLI replaces this with _cprint so that # Pluggable print function — CLI replaces this with _cprint so that
# raw ANSI status lines are routed through prompt_toolkit's renderer # raw ANSI status lines are routed through prompt_toolkit's renderer
@@ -1471,6 +1481,16 @@ class AIAgent:
# Thread gateway user identity for per-user memory scoping # Thread gateway user identity for per-user memory scoping
if self._user_id: if self._user_id:
_init_kwargs["user_id"] = 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
# Thread gateway session key for stable per-chat Honcho session isolation # Thread gateway session key for stable per-chat Honcho session isolation
if self._gateway_session_key: if self._gateway_session_key:
_init_kwargs["gateway_session_key"] = self._gateway_session_key _init_kwargs["gateway_session_key"] = self._gateway_session_key

View File

@@ -79,6 +79,28 @@ class TestMemoryManagerUserIdThreading:
assert p._init_kwargs.get("platform") == "telegram" assert p._init_kwargs.get("platform") == "telegram"
assert p._init_session_id == "sess-123" 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): def test_no_user_id_when_cli(self):
"""CLI sessions should not have user_id in kwargs.""" """CLI sessions should not have user_id in kwargs."""
mgr = MemoryManager() mgr = MemoryManager()
@@ -334,3 +356,4 @@ class TestAIAgentUserIdPropagation:
agent = object.__new__(AIAgent) agent = object.__new__(AIAgent)
agent._user_id = None agent._user_id = None
assert agent._user_id is None assert agent._user_id is None

View File

@@ -6,6 +6,7 @@ turn counting, tags), and schema completeness.
""" """
import json import json
import re
import threading import threading
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@@ -18,6 +19,7 @@ from plugins.memory.hindsight import (
REFLECT_SCHEMA, REFLECT_SCHEMA,
RETAIN_SCHEMA, RETAIN_SCHEMA,
_load_config, _load_config,
_normalize_retain_tags,
) )
@@ -32,14 +34,30 @@ def _clean_env(monkeypatch):
for key in ( for key in (
"HINDSIGHT_API_KEY", "HINDSIGHT_API_URL", "HINDSIGHT_BANK_ID", "HINDSIGHT_API_KEY", "HINDSIGHT_API_URL", "HINDSIGHT_BANK_ID",
"HINDSIGHT_BUDGET", "HINDSIGHT_MODE", "HINDSIGHT_LLM_API_KEY", "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) monkeypatch.delenv(key, raising=False)
def _make_mock_client(): def _make_mock_client():
"""Create a mock Hindsight client with async methods.""" """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 = MagicMock()
client.aretain = AsyncMock() client.aretain = AsyncMock(side_effect=_aretain)
client.arecall = AsyncMock( client.arecall = AsyncMock(
return_value=SimpleNamespace( return_value=SimpleNamespace(
results=[ results=[
@@ -56,6 +74,14 @@ def _make_mock_client():
return 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() @pytest.fixture()
def provider(tmp_path, monkeypatch): def provider(tmp_path, monkeypatch):
"""Create an initialized HindsightMemoryProvider with a mock client.""" """Create an initialized HindsightMemoryProvider with a mock client."""
@@ -109,6 +135,18 @@ def provider_with_config(tmp_path, monkeypatch):
return _make 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 # Schema tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -118,6 +156,7 @@ class TestSchemas:
def test_retain_schema_has_content(self): def test_retain_schema_has_content(self):
assert RETAIN_SCHEMA["name"] == "hindsight_retain" assert RETAIN_SCHEMA["name"] == "hindsight_retain"
assert "content" in RETAIN_SCHEMA["parameters"]["properties"] assert "content" in RETAIN_SCHEMA["parameters"]["properties"]
assert "tags" in RETAIN_SCHEMA["parameters"]["properties"]
assert "content" in RETAIN_SCHEMA["parameters"]["required"] assert "content" in RETAIN_SCHEMA["parameters"]["required"]
def test_recall_schema_has_query(self): def test_recall_schema_has_query(self):
@@ -160,7 +199,10 @@ class TestConfig:
def test_custom_config_values(self, provider_with_config): def test_custom_config_values(self, provider_with_config):
p = 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=["recall-tag"],
recall_tags_match="all", recall_tags_match="all",
auto_retain=False, auto_retain=False,
@@ -175,6 +217,10 @@ class TestConfig:
bank_mission="Test agent mission", bank_mission="Test agent mission",
) )
assert p._tags == ["tag1", "tag2"] 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 == ["recall-tag"]
assert p._recall_tags_match == "all" assert p._recall_tags_match == "all"
assert p._auto_retain is False assert p._auto_retain is False
@@ -222,11 +268,20 @@ class TestToolHandlers:
assert call_kwargs["content"] == "user likes dark mode" assert call_kwargs["content"] == "user likes dark mode"
def test_retain_with_tags(self, provider_with_config): 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"}) p.handle_tool_call("hindsight_retain", {"content": "likes dark mode"})
call_kwargs = p._client.aretain.call_args.kwargs call_kwargs = p._client.aretain.call_args.kwargs
assert call_kwargs["tags"] == ["pref", "ui"] 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): def test_retain_without_tags(self, provider):
provider.handle_tool_call("hindsight_retain", {"content": "hello"}) provider.handle_tool_call("hindsight_retain", {"content": "hello"})
call_kwargs = provider._client.aretain.call_args.kwargs call_kwargs = provider._client.aretain.call_args.kwargs
@@ -389,38 +444,58 @@ class TestPrefetch:
class TestSyncTurn: class TestSyncTurn:
def _get_retain_kwargs(self, provider): def test_sync_turn_retains_metadata_rich_turn(self, provider_with_config):
"""Helper to get the kwargs from the aretain_batch call.""" p = provider_with_config(
return provider._client.aretain_batch.call_args.kwargs 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 _get_retain_content(self, provider): p.sync_turn("hello", "hi there")
"""Helper to get the raw content string from the first item.""" p._sync_thread.join(timeout=5.0)
kwargs = self._get_retain_kwargs(provider)
return kwargs["items"][0]["content"]
def _get_retain_messages(self, provider): p._client.aretain_batch.assert_called_once()
"""Helper to parse the first turn's messages from retained content. call_kwargs = p._client.aretain_batch.call_args.kwargs
assert call_kwargs["bank_id"] == "test-bank"
Content is a JSON array of turns: [[msgs...], [msgs...], ...] assert call_kwargs["document_id"] == "session-1"
For single-turn tests, returns the first turn's messages. assert call_kwargs["retain_async"] is True
""" assert len(call_kwargs["items"]) == 1
content = self._get_retain_content(provider) item = call_kwargs["items"][0]
turns = json.loads(content) assert item["context"] == "conversation between Hermes Agent and the User"
return turns[0] if len(turns) == 1 else turns assert item["tags"] == ["conv", "session1"]
content = json.loads(item["content"])
def test_sync_turn_retains(self, provider): assert len(content) == 1
provider.sync_turn("hello", "hi there") assert content[0][0]["role"] == "user"
if provider._sync_thread: assert content[0][0]["content"] == "User (fakeusername): hello"
provider._sync_thread.join(timeout=5.0) assert content[0][1]["role"] == "assistant"
provider._client.aretain_batch.assert_called_once() assert content[0][1]["content"] == "Assistant (fakeassistantname): hi there"
messages = self._get_retain_messages(provider) assert item["metadata"]["source"] == "hermes"
assert len(messages) == 2 assert item["metadata"]["session_id"] == "session-1"
assert messages[0]["role"] == "user" assert item["metadata"]["platform"] == "discord"
assert messages[0]["content"] == "hello" assert item["metadata"]["user_id"] == "fakeusername-123"
assert "timestamp" in messages[0] assert item["metadata"]["user_name"] == "fakeusername"
assert messages[1]["role"] == "assistant" assert item["metadata"]["chat_id"] == "1485316232612941897"
assert messages[1]["content"] == "hi there" assert item["metadata"]["chat_name"] == "fakeassistantname-forums"
assert "timestamp" in messages[1] 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_skipped_when_auto_retain_off(self, provider_with_config): def test_sync_turn_skipped_when_auto_retain_off(self, provider_with_config):
p = provider_with_config(auto_retain=False) p = provider_with_config(auto_retain=False)
@@ -428,93 +503,33 @@ class TestSyncTurn:
assert p._sync_thread is None assert p._sync_thread is None
p._client.aretain_batch.assert_not_called() p._client.aretain_batch.assert_not_called()
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"]
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"
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"
def test_sync_turn_custom_context(self, provider_with_config):
p = provider_with_config(retain_context="my-agent")
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"
def test_sync_turn_every_n_turns(self, provider_with_config): 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, retain_async=False)
p = provider_with_config(retain_every_n_turns=3)
p.sync_turn("turn1-user", "turn1-asst") 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") 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") p.sync_turn("turn3-user", "turn3-asst")
assert p._sync_thread is not None # retained!
p._sync_thread.join(timeout=5.0) p._sync_thread.join(timeout=5.0)
p._client.aretain_batch.assert_called_once() p._client.aretain_batch.assert_called_once()
content = p._client.aretain_batch.call_args.kwargs["items"][0]["content"] call_kwargs = p._client.aretain_batch.call_args.kwargs
# 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
assert call_kwargs["document_id"] == "test-session" 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): 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._client.aretain_batch.side_effect = RuntimeError("network error")
provider.sync_turn("hello", "hi") provider.sync_turn("hello", "hi")
if provider._sync_thread: if provider._sync_thread:
provider._sync_thread.join(timeout=5.0) provider._sync_thread.join(timeout=5.0)
# Should not raise
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -555,10 +570,11 @@ class TestConfigSchema:
"mode", "api_url", "api_key", "llm_provider", "llm_api_key", "mode", "api_url", "api_key", "llm_provider", "llm_api_key",
"llm_model", "bank_id", "bank_mission", "bank_retain_mission", "llm_model", "bank_id", "bank_mission", "bank_retain_mission",
"recall_budget", "memory_mode", "recall_prefetch_method", "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", "auto_recall", "auto_retain",
"retain_every_n_turns", "retain_async", "retain_every_n_turns", "retain_async", "retain_context",
"retain_context",
"recall_max_tokens", "recall_max_input_chars", "recall_max_tokens", "recall_max_input_chars",
"recall_prompt_preamble", "recall_prompt_preamble",
} }

View File

@@ -359,7 +359,11 @@ The setup wizard installs dependencies automatically and only installs what's ne
| `auto_retain` | `true` | Automatically retain conversation turns | | `auto_retain` | `true` | Automatically retain conversation turns |
| `auto_recall` | `true` | Automatically recall memories before each turn | | `auto_recall` | `true` | Automatically recall memories before each turn |
| `retain_async` | `true` | Process retain asynchronously on the server | | `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 | | `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. See [plugin README](https://github.com/NousResearch/hermes-agent/blob/main/plugins/memory/hindsight/README.md) for the full configuration reference.