Honcho memory provider cache and setup hardening
NousResearch/hermes-agent · cluster 33381 · agent
Data as of
Nothing open on GitHub. Fix merged in #30077. Check it covers your case.
The short answer
- Known?
- Yes. 7 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #30077. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PRs #30077, #107547. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These items address refactoring cache-busting logic into the memory provider layer, skipping unnecessary disk reads when Honcho is inactive, and hardening self-hosted Honcho setup paths and peer mapping.
Open issues0
Open PRs0
Fixes on record
-
#30077 fix(honcho): runtime peer mapping — correctness follow-ups + setup wizard + docs
files:
agent/agent_init.py,agent/memory_provider.py,gateway/run.py,plugins/memory/honcho/README.md,plugins/memory/honcho/__init__.py,plugins/memory/honcho/cli.py,plugins/memory/honcho/client.py,plugins/memory/honcho/session.py,run_agent.py,scripts/release.py,tests/gateway/test_agent_cache.py,tests/honcho_plugin/test_cli.py,tests/honcho_plugin/test_pin_peer_name.py,tests/honcho_plugin/test_session.py,tests/run_agent/test_memory_provider_init.py,tests/test_honcho_session_context.pyShow the diff
-
agent/agent_init.py@@ -183,6 +183,7 @@ def init_agent( prefill_messages: List[Dict[str, Any]] = None, platform: str = None, user_id: str = None, + user_id_alt: str = None, user_name: str = None, chat_id: str = None, chat_name: str = None, @@ -265,6 +266,7 @@ def init_agent( agent.ephemeral_system_prompt = ephemeral_system_prompt agent.platform = platform # "cli", "telegram", "discord", "whatsapp", etc. agent._user_id = user_id # Platform user identifier (gateway sessions) + agent._user_id_alt = user_id_alt # Optional stable alternate platform identifier agent._user_name = user_name agent._chat_id = chat_id agent._chat_name = chat_name @@ -1089,6 +1091,8 @@ def init_agent( # Thread gateway user identity for per-user memory scoping if agent._user_id: _init_kwargs["user_id"] = agent._user_id + if agent._user_id_alt: + _init_kwargs["user_id_alt"] = agent._user_id_alt if agent._user_name: _init_kwargs["user_name"] = agent._user_name if agent._chat_id: -
agent/memory_provider.py@@ -78,6 +78,7 @@ def initialize(self, session_id: str, **kwargs) -> None: - agent_workspace (str): Shared workspace name (e.g. "hermes"). - parent_session_id (str): For subagents, the parent's session_id. - user_id (str): Platform user identifier (gateway sessions). + - user_id_alt (str): Optional alternate stable platform user identifier. """ def system_prompt_block(self) -> str: -
gateway/run.py@@ -11436,6 +11436,7 @@ def run_sync(): session_id=task_id, platform=platform_key, user_id=source.user_id, + user_id_alt=source.user_id_alt, user_name=source.user_name, chat_id=source.chat_id, chat_name=source.chat_name, @@ -14672,6 +14673,29 @@ def _extract_cache_busting_config(cls, user_config: dict | None) -> dict: out["tools.registry_generation"] = getattr(registry, "_generation", None) except Exception: out["tools.registry_generation"] = None + + # Honcho identity-mapping keys live in honcho.json, not user_config. + # HonchoSessionManager freezes the resolved peer_name / ai_peer / + # pin / aliases / prefix at construction; without busting here, + # mid-flight honcho.json edits go unread until the next unrelated + # cache eviction. + try: + from plugins.memory.honcho.client import HonchoClientConfig + + hcfg = HonchoClientConfig.from_global_config() + out["honcho.peer_name"] = hcfg.peer_name + out["honcho.ai_peer"] = hcfg.ai_peer + out["honcho.pin_peer_name"] = bool(hcfg.pin_peer_name) + out["honcho.runtime_peer_prefix"] = hcfg.runtime_peer_prefix or "" + aliases = hcfg.user_peer_aliases or {} + out["honcho.user_peer_aliases"] = sorted(aliases.items()) if isinstance(aliases, dict) else [] + except Exception: + out["honcho.peer_name"] = None + out["honcho.ai_peer"] = None + out["honcho.pin_peer_name"] = None + out["honcho.runtime_peer_prefix"] = None + out["honcho.user_peer_aliases"] = None + return out @staticmethod @@ -14681,6 +14705,8 @@ def _agent_config_signature( enabled_toolsets: list, ephemeral_prompt: str, cache_keys: dict | None = None, + user_id: str | None = None, + user_id_alt: str | None = None, ) -> str: """Compute a stable string key from agent config values. @@ -14694,6 +14720,20 @@ def _agent_config_signature( the output of ``_extract_cache_busting_config(user_config)`` so edits to model.context_length / compression.* in config.yaml are picked up on the next gateway message without a manual restart. + + ``user_id`` and ``user_id_alt`` are the runtime user identities + carried by the current message's gateway source. They participate + in the cache key because the Honcho memory provider freezes them + into ``HonchoSessionManager`` at first-message init (see + ``plugins/memory/honcho/__init__.py::_do_session_init``). Without + them in the signature, a shared-thread session_key (one in which + ``build_session_key`` intentionally omits the participant ID, + e.g. ``thread_sessions_per_user=False``) would reuse the cached + AIAgent across distinct users, causing the second user's messages + to be attributed to the first user's resolved Honcho peer. This + broke #27371's per-user-peer contract in multi-user gateways. + Per-user agent rebuilds in shared threads trade prompt-cache + warmth for correct memory attribution. """ import hashlib, json as _j @@ -14718,6 +14758,8 @@ def _agent_config_signature( # cached agent and doesn't affect system prompt or tools. ephemeral_prompt or "", _cache_keys_sorted, + str(user_id or ""), + str(user_id_alt or ""), ], sort_keys=True, default=str, @@ -16259,6 +16301,8 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: enabled_toolsets, combined_ephemeral, cache_keys=self._extract_cache_busting_config(user_config), + user_id=getattr(source, "user_id", None), + user_id_alt=getattr(source, "user_id_alt", None), ) agent = None _cache_lock = getattr(self, "_agent_cache_lock", None) @@ -16302,6 +16346,7 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: session_id=session_id, platform=platform_key, user_id=source.user_id, + user_id_alt=source.user_id_alt, user_name=source.user_name, chat_id=source.chat_id, chat_name=source.chat_name, -
plugins/memory/honcho/README.md@@ -127,6 +127,41 @@ For every key, resolution order is: **host block > root > env var > default**. | `peerName` | string | — | User peer identity | | `aiPeer` | string | host key | AI peer identity | +### Identity Mapping (Gateway Multi-User) + +In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a platform-native runtime ID (Telegram UID, Discord snowflake, Slack user). These three keys control how those runtime IDs map to Honcho peers. The resolver is config-driven and deterministic — no automatic merging or runtime inference. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `pinUserPeer` | bool | `false` | When `true`, every gateway runtime user collapses to `peerName`. Single-operator deployments where you want all your platforms (and any other users) to share one peer. Also accepted as `pinPeerName` | +| `pinPeerName` | bool | `false` | Alias for `pinUserPeer`; same effect | +| `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"86701400": "eri"}`). Many-to-one is the intended pattern — alias all your runtime IDs to one peer name. One-to-many is not supported; one runtime ID resolves to exactly one peer | +| `runtimePeerPrefix` | string | `""` | Prepended to unknown runtime IDs to namespace them (e.g. `"telegram_"` → `telegram_86701400`). Used only when no alias matches. Prevents collisions between platforms whose runtime IDs share the same shape | + +**Resolver ladder** (first match wins): + +``` +1. pinUserPeer / pinPeerName=true → return peerName (ignore runtime ID) +2. userPeerAliases[runtime_id] → return aliased peer +3. userPeerAliases[runtime_id_alt] → check alt-ID too (Telegram UID + username, etc.) +4. runtimePeerPrefix + runtime_id → namespaced peer, with sha256 collision escalation +5. raw sanitized runtime_id → fallback peer +6. peerName → no runtime ID at all (CLI/TUI) +7. session-key fallback → no config either +``` + +**Why no `pinAiPeer`?** The AI peer is already pinned by construction — `aiPeer` is the only AI-side identity setting and the resolver never overrides it. Only the user-side peer has the runtime-vs-config tension that `pinUserPeer` resolves. + +**Host vs root semantics.** All three keys are accepted at both root and `hosts.<host>` levels. Host-level wins. For maps and prefixes, host-level *replaces* the root value as a whole (not merge), so a host can intentionally own its identity universe or wipe it with `userPeerAliases: {}` / `runtimePeerPrefix: ""`. + +**Deployment shapes** (`hermes honcho setup` asks one prompt to set these): + +- **Single-operator** — `pinUserPeer: true`. All gateway users → `peerName`. Recommended for personal use where you connect Hermes to your own Telegram/Discord/etc. +- **Multi-user gateway** — `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. Recommended for bots serving many humans. +- **Hybrid** — `pinUserPeer: false`, `userPeerAliases` mapping the operator's runtime IDs to `peerName`. Multi-user gateway where YOU are routed but others stay distinct. + +**Migrating single → multi.** Flipping `pinUserPeer` from `true` to `false` does not migrate data. Memory accumulated under `peerName` while pinned stays there; runtime users now resolve to fresh, empty peers. To preserve your own continuity, use the **hybrid** shape — alias your runtime IDs back to `peerName` so your turns keep landing on the pooled history while other users get their own peers. The setup wizard offers this path automatically when it detects a single → multi transition. + ### Memory & Recall | Key | Type | Default | Description | -
plugins/memory/honcho/__init__.py@@ -321,10 +321,8 @@ def initialize(self, session_id: str, **kwargs) -> None: except Exception as e: logger.debug("Honcho cost-awareness config parse error: %s", e) - # ----- Port #1969: aiPeer sync from SOUL.md — REMOVED ----- - # SOUL.md is persona content, not identity config. aiPeer should - # only come from honcho.json (host block or root) or the default. - # See scratch/memory-plugin-ux-specs.md #10 for rationale. + # aiPeer comes from honcho.json (host block or root) only. + # SOUL.md is persona content, not identity config. # ----- Port #1957: lazy session init for tools-only mode ----- if self._recall_mode == "tools": @@ -360,6 +358,7 @@ def _do_session_init(self, cfg, session_id: str, **kwargs) -> None: config=cfg, context_tokens=[redacted:secret-assignment] runtime_user_peer_name=kwargs.get("user_id") or None, + runtime_user_peer_name_alt=kwargs.get("user_id_alt") or None, ) # ----- B3: resolve_session_name ----- -
plugins/memory/honcho/cli.py@@ -40,12 +40,20 @@ def clone_honcho_for_profile(profile_name: str) -> bool: if new_host in hosts: return False # already exists - # Clone settings from default block, override identity fields + # Clone settings from default block, override identity fields. + # Identity-mapping keys (pinPeerName/pinUserPeer, userPeerAliases, + # runtimePeerPrefix) carry the operator's runtime-to-peer routing + # intent from #27371. Both pin keys are inherited because + # HonchoClientConfig prefers pinUserPeer over pinPeerName — leaving + # the canonical key off this allowlist silently drops the pin on + # cloned profiles when the default uses the newer name. new_block = {} for key in ("recallMode", "writeFrequency", "sessionStrategy", "sessionPeerPrefix", "contextTokens", "dialecticReasoningLevel", "dialecticDynamic", "dialecticMaxChars", "messageMaxChars", - "dialecticMaxInputChars", "saveMessages", "observation"): + "dialecticMaxInputChars", "saveMessages", "observation", + "pinPeerName", "pinUserPeer", "userPeerAliases", + "runtimePeerPrefix"): val = default_block.get(key) if val is not None: new_block[key] = val @@ -308,6 +316,72 @@ def _resolve_api_key(cfg: dict) -> str: return key +_IDENTITY_MAPPING_KEYS = ( + "pinPeerName", + "pinUserPeer", + "userPeerAliases", + "runtimePeerPrefix", +) + + +def _resolve_effective_identity_mapping( + cfg: dict, hermes_host: dict +) -> tuple[bool, dict, str, bool, bool]: + """Resolve the effective identity-mapping state for the active host. + + Matches the precedence used by ``HonchoClientConfig.from_global_config`` + so the wizard reads the same shape the gateway will actually run with. + Without this, root-level overrides and ``pinUserPeer`` (which wins over + ``pinPeerName`` at the same level) are invisible to detection, letting + setup mis-classify the current shape and silently change effective + routing on the next save. + + Returns ``(pin, aliases, prefix, aliases_from_root, prefix_from_root)``. + The ``*_from_root`` flags let the write step skip touching host keys + whose value is actually inherited. + """ + pin = False + for val in ( + hermes_host.get("pinUserPeer"), + hermes_host.get("pinPeerName"), + cfg.get("pinUserPeer"), + cfg.get("pinPeerName"), + ): + if val is not None: + pin = bool(val) + break + + if "userPeerAliases" in hermes_host: + aliases_src = hermes_host.get("userPeerAliases") + aliases_from_root = False + else: + aliases_src = cfg.get("userPeerAliases") + aliases_from_root = aliases_src is not None + aliases = aliases_src if isinstance(aliases_src, dict) else {} + + if "runtimePeerPrefix" in hermes_host: + prefix_src = hermes_host.get("runtimePeerPrefix") + prefix_from_root = False + else: + prefix_src = cfg.get("runtimePeerPrefix") + prefix_from_root = prefix_src is not None + prefix = str(prefix_src or "") + + return pin, aliases, prefix, aliases_from_root, prefix_from_root + + +def _scrub_identity_mapping(hermes_host: dict) -> None: + """Drop every peer-mapping key from the host block. + + Called before the wizard writes a chosen shape so latent precedence + conflicts can't survive — e.g. a stray host ``pinUserPeer: false`` + that would silently outrank a freshly written ``pinPeerName: true`` + (host ``pinUserPeer`` is first in the resolver ladder). + """ + for key in _IDENTITY_MAPPING_KEYS: + hermes_host.pop(key, None) + + def _prompt(label: str, default: str | None = None, secret: bool = False) -> str: suffix = f" [{default}]" if default else "" sys.stdout.write(f" {label}{suffix}: ") @@ -435,6 +509,131 @@ def cmd_setup(args) -> None: if new_workspace: hermes_host["workspace"] = new_workspace + # --- 3b. Deployment shape --- + # Determines how runtime user identities (Telegram UIDs, Discord + # snowflakes, etc.) map to Honcho peers in gateway sessions. Three + # shapes cover the realistic deployments; each writes a different + # combination of pinPeerName / userPeerAliases / runtimePeerPrefix. + # See plugins/memory/honcho/README.md for the resolver ladder. + # + # Detection must mirror the gateway resolver: root-level config and + # ``pinUserPeer`` (which outranks ``pinPeerName`` at the same level) + # both affect effective routing, so reading host-only fields would + # mis-classify a profile that inherits its mapping from root or uses + # the newer canonical key. + ( + current_pin, + current_aliases, + current_prefix, + aliases_from_root, + prefix_from_root, + ) = _resolve_effective_identity_mapping(cfg, hermes_host) + + if current_pin: + current_shape = "single" + elif current_aliases: + current_shape = "hybrid" + else: + current_shape = "multi" + + print("\n Deployment shape (how gateway users map to peers):") + print(" single -- all platforms route to your peer (recommended for personal use)") + print(" multi -- each platform user gets their own peer (multi-user bots)") + print(" hybrid -- multi-user, but YOUR runtime IDs alias to your peer") + print(" skip -- don't touch identity-mapping config") + new_shape = _prompt("Deployment shape", default=current_shape).strip().lower() + + # Transitioning single → multi orphans the peerName pool for runtime users + # (their resolved peers go from peerName to runtime-derived IDs with empty + # history). Steer the operator toward hybrid so their own continuity is + # preserved via alias mappings. + if current_shape == "single" and new_shape == "multi": + peer_target = hermes_host.get("peerName") or current_peer or "user" + print( + f"\n ⚠ Switching from single to multi will orphan memory accumulated\n" + f" under peer '{peer_target}'. Existing runtime users (Telegram,\n" + f" Discord, etc.) will resolve to fresh, empty peers." + ) + print(" To keep your own continuity, choose 'hybrid' and alias your\n" + " runtime IDs back to peerName.") + confirm = _prompt("Continue with multi anyway? (yes/hybrid/no)", default="hybrid").strip().lower() + if confirm in {"hybrid", "h"}: + new_shape = "hybrid" + elif confirm not in {"yes", "y"}: + new_shape = "skip" + + # Each shape branch scrubs every peer-mapping key before writing its own, + # so a stale ``pinUserPeer`` left behind by an earlier setup run can't + # outrank the freshly written ``pinPeerName`` via host-level precedence. + if new_shape == "single": + _scrub_identity_mapping(hermes_host) + hermes_host["pinPeerName"] = True + print(f" pinPeerName=true → all gateway users route to '{hermes_host.get('peerName', '?')}'.") + elif new_shape == "multi": + # Preserve operator-curated, host-level aliases so multi → multi + # re-runs don't drop them. Root-sourced aliases are left to + # cascade naturally and are NOT copied down into the host. + prior_aliases = ( + dict(current_aliases) + if isinstance(current_aliases, dict) and not aliases_from_root + else {} + ) + _scrub_identity_mapping(hermes_host) + hermes_host["pinPeerName"] = False + # Do NOT auto-write ``userPeerAliases: {}``: an empty host map + # would override any root-level ``userPeerAliases`` the operator + # set as a cross-host baseline, silently disabling those aliases. + # Absence is the right "no host opinion" signal. + if prior_aliases: + hermes_host["userPeerAliases"] = prior_aliases + _prefix_default = current_prefix or "" + _new_prefix = _prompt( + "Runtime peer prefix (e.g. 'telegram_', blank for none)", + default=_prefix_default, + ).strip() + # Only write a host-level prefix when the operator typed one that + # diverges from the inherited root value; otherwise let the root + # cascade continue unmodified. + if _new_prefix and not (prefix_from_root and _new_prefix == current_prefix): + hermes_host["runtimePeerPrefix"] = _new_prefix + print(" Multi-user mode: each runtime ID → own peer. Use 'hermes honcho status' to inspect.") + elif new_shape == "hybrid": + # Hybrid encodes operator intent at the host level: collect existing + # entries (host or root) so the wizard never silently drops a known + # alias, then write the combined map. Materialising root entries + # into the host is the right move here — once the operator answers + # the alias prompts for a host, they're declaring "this host owns + # the mapping". + existing_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {} + _scrub_identity_mapping(hermes_host) + hermes_host["pinPeerName"] = False + peer_target = hermes_host.get("peerName") or current_peer or "user" + print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.") + print(" Leave blank to skip a platform. Existing aliases are preserved.") + for platform_label, alias_hint in ( + ("Telegram UID", "e.g. 86701400"), + ("Discord snowflake", "e.g. 491827364"), + ("Slack user ID", "e.g. U04ABCDEF"), + ("Matrix MXID", "e.g. @you:matrix.org"), + ): + entered = _prompt(f" {platform_label} ({alias_hint})", default="").strip() + if entered: + existing_aliases[entered] = peer_target + if existing_aliases: + hermes_host["userPeerAliases"] = existing_aliases + _prefix_default = current_prefix or "" + _new_prefix = _prompt( + "Runtime peer prefix for unknown users (e.g. 'telegram_', blank for none)", + default=_prefix_default, + ).strip() + if _new_prefix and not (prefix_from_root and _new_prefix == current_prefix): + hermes_host["runtimePeerPrefix"] = _new_prefix + print(f" Hybrid mode: your runtime IDs → '{peer_target}', others → own peer.") + elif new_shape == "skip": + pass # leave config untouched + else: + print(f" Unknown shape '{new_shape}' — leaving identity-mapping config untouched.") + # --- 4. Observation mode --- current_obs = hermes_host.get("observationMode") or cfg.get("observationMode", "directional") print("\n Observation mode:") -
plugins/memory/honcho/client.py@@ -91,12 +91,17 @@ def _normalize_recall_mode(val: str) -> str: return val if val in _VALID_RECALL_MODES else "hybrid" -def _resolve_bool(host_val, root_val, *, default: bool) -> bool: - """Resolve a bool config field: host wins, then root, then default.""" - if host_val is not None: - return bool(host_val) - if root_val is not None: - return bool(root_val) +def _resolve_bool(*vals, default: bool) -> bool: + """Resolve a bool config field: first non-None wins, else default. + + Variadic to support aliased keys (e.g. ``pinUserPeer`` shadowing + ``pinPeerName`` for backwards compatibility). Pass values in + precedence order: caller's preferred alias first, then fallback + aliases, in (host, root) interleaving as needed. + """ + for val in vals: + if val is not None: + return bool(val) return default @@ -122,6 +127,34 @@ def _parse_int_config(host_val, root_val, default: int) -> int: return default +def _parse_string_map(host_obj: dict, root_obj: dict, key: str) -> dict[str, str]: + """Parse a string-to-string map with host-level whole-map override.""" + source = host_obj[key] if key in host_obj else root_obj.get(key) + if not isinstance(source, dict): + return {} + + result: dict[str, str] = {} + for raw_key, raw_value in source.items(): + alias_key = str(raw_key).strip() + alias_value = str(raw_value).strip() if raw_value is not None else "" + if alias_key and alias_value: + result[alias_key] = alias_value + return result + + +def _parse_optional_string( + host_obj: dict, root_obj: dict, key: str, default: str = "" +) -> str: + """Parse a string field where host-level empty string can override root.""" + if key in host_obj: + value = host_obj.get(key) + else: + value = root_obj.get(key, default) + if value is None: + return default + return str(value).strip() + + def _parse_dialectic_depth(host_val, root_val) -> int: """Parse dialecticDepth: host wins, then root, then 1. Clamped to 1-3.""" for val in (host_val, root_val): @@ -259,6 +292,12 @@ class HonchoClientConfig: # each platform would fork memory into its own peer (#14984). Default # ``False`` preserves existing multi-user behaviour. pin_peer_name: bool = False + # Map gateway runtime user IDs to stable Honcho user peers. Host-level + # config replaces the root map as a whole so profiles can intentionally + # own their identity mappings. + user_peer_aliases: dict[str, str] = field(default_factory=dict) + # Optional prefix for unknown gateway runtime user IDs, e.g. "telegram_". + runtime_peer_prefix: str = "" # Toggles enabled: bool = False save_messages: bool = True @@ -454,10 +493,28 @@ def from_global_config( peer_name=host_block.get("peerName") or raw.get("peerName"), ai_peer=ai_peer, pin_peer_name=_resolve_bool( + # ``pinUserPeer`` is the clearer name (the resolver pins + # the user-side peer to ``peerName``, ignoring runtime + # identity). ``pinPeerName`` is the original key from + # #14984 and stays accepted for backward compatibility. + # Host-level keys win over root-level; among same-level + # keys, ``pinUserPeer`` wins over ``pinPeerName``. + host_block.get("pinUserPeer"), host_block.get("pinPeerName"), + raw.get("pinUserPeer"), raw.get("pinPeerName"), default=False, ), + user_peer_aliases=_parse_string_map( + host_block, + raw, + "userPeerAliases", + ), + runtime_peer_prefix=_parse_optional_string( + host_block, + raw, + "runtimePeerPrefix", + ), enabled=enabled, save_messages=save_messages, write_frequency=write_frequency, -
plugins/memory/honcho/session.py@@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import queue import re import logging @@ -19,6 +20,8 @@ # Sentinel to signal the async writer thread to shut down _ASYNC_SHUTDOWN = object() +_PEER_ID_HASH_LEN = 8 +_PEER_ID_HASH_ESCALATION_LENGTHS = (_PEER_ID_HASH_LEN, 12, 16, 24, 32, 64) @dataclass @@ -79,6 +82,7 @@ def __init__( context_tokens: int | None = None, config: Any | None = None, runtime_user_peer_name: str | None = None, + runtime_user_peer_name_alt: str | None = None, ): """ Initialize the session manager. @@ -89,11 +93,13 @@ def __init__( config: HonchoClientConfig from global config (provides peer_name, ai_peer, write_frequency, observation, etc.). runtime_user_peer_name: Gateway user identity for per-user memory scoping. + runtime_user_peer_name_alt: Optional stable alternate gateway identity. """ self._honcho = honcho self._context_tokens = [redacted:secret-assignment] self._config = config self._runtime_user_peer_name = runtime_user_peer_name + self._runtime_user_peer_name_alt = runtime_user_peer_name_alt self._cache: dict[str, HonchoSession] = {} self._cache_lock = threading.RLock() self._peers_cache: dict[str, Any] = {} @@ -267,6 +273,90 @@ def _sanitize_id(self, id_str: str) -> str: """Sanitize an ID to match Honcho's pattern: ^[a-zA-Z0-9_-]+""" return re.sub(r'[^a-zA-Z0-9_-]', '-', id_str) + def _runtime_user_ids(self) -> list[str]: + """Return runtime identity candidates in lookup order.""" + candidates: list[str] = [] + for value in (self._runtime_user_peer_name, self._runtime_user_peer_name_alt): + if value is None: + continue + candidate = str(value).strip() + if candidate and candidate not in candidates: + candidates.append(candidate) + return candidates + + def _session_key_fallback_peer_id(self, key: str) -> str: + parts = key.split(":", 1) + channel = parts[0] if len(parts) > 1 else "default" + chat_id = parts[1] if len(parts) > 1 else key + return self._sanitize_id(f"user-{channel}-{chat_id}") + + def _explicit_user_peer_ids(self) -> set[str]: + """Return sanitized user peer IDs that came from explicit config.""" + if self._config is None: + return set() + + explicit_ids: set[str] = set() + peer_name = getattr(self._config, "peer_name", None) + if peer_name: + explicit_ids.add(self._sanitize_id(str(peer_name).strip())) + + aliases = getattr(self._config, "user_peer_aliases", {}) + if isinstance(aliases, dict): + for alias in aliases.values(): + if isinstance(alias, str) and alias.strip(): + explicit_ids.add(self._sanitize_id(alias.strip())) + + return explicit_ids + + def _generated_runtime_peer_id(self, prefix: str, runtime_id: str) -> str: + """Return a stable peer ID for an unknown prefixed runtime user.""" + raw_peer_id = f"{prefix}{runtime_id}" + sanitized_peer_id = self._sanitize_id(raw_peer_id) + explicit_ids = self._explicit_user_peer_ids() + if ( + sanitized_peer_id != raw_peer_id + or sanitized_peer_id in explicit_ids + ): + digest = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest() + for hash_len in _PEER_ID_HASH_ESCALATION_LENGTHS: + candidate = f"{sanitized_peer_id}-{digest[:hash_len]}" + if candidate not in explicit_ids: + return candidate + return f"{sanitized_peer_id}-{digest}" + return sanitized_peer_id + + def _resolve_user_peer_id(self, key: str) -> str: + """Resolve the Honcho user peer ID for this manager/session.""" + pin_peer_name = ( + self._config is not None + and bool(getattr(self._config, "peer_name", None)) + and getattr(self._config, "pin_peer_name", False) is True + ) + if pin_peer_name: + return self._sanitize_id(self._config.peer_name) + + runtime_ids = self._runtime_user_ids() + if runtime_ids: + aliases = getattr(self._config, "user_peer_aliases", {}) if self._config else {} + if not isinstance(aliases, dict): + aliases = {} + for runtime_id in runtime_ids: + alias = aliases.get(runtime_id) + if isinstance(alias, str) and alias.strip(): + return self._sanitize_id(alias.strip()) + + primary_runtime_id = runtime_ids[0] + prefix = getattr(self._config, "runtime_peer_prefix", "") if self._config else "" + prefix = prefix.strip() if isinstance(prefix, str) else "" + if prefix: + return self._generated_runtime_peer_id(prefix, primary_runtime_id) + return self._sanitize_id(primary_runtime_id) + + if self._config and self._config.peer_name: + return self._sanitize_id(self._config.peer_name) + + return self._session_key_fallback_peer_id(key) + def get_or_create(self, key: str) -> HonchoSession: """ Get an existing session or create a new one. @@ -285,31 +375,11 @@ def get_or_create(self, key: str) -> HonchoSession: # Determine peer IDs — no lock needed (read-only, no shared state mutation). # Gateway sessions normally use the runtime user identity (the # platform-native ID: Telegram UID, Discord snowflake, Slack user, - # etc.) so multi-user bots scope memory per user. For a single-user - # deployment the config-supplied ``peer_name`` is an unambiguous - # identity and we should keep it unified across platforms — see - # #14984. Opt into that with ``hosts.<host>.pinPeerName: true`` in - # ``honcho.json`` (or root-level ``pinPeerName: true``). - # `is True` (not `bool(...)`) is deliberate: several multi-user tests - # pass a ``MagicMock`` for ``config`` where ``mock.pin_peer_name`` - # silently returns another MagicMock — truthy by default. Requiring - # strict ``True`` keeps pinning as opt-in even for callers that - # haven't updated their mocks yet; real configs built via - # ``from_global_config`` always produce a proper boolean. - pin_peer_name = ( - self._config is not None - and bool(getattr(self._config, "peer_name", None)) - and getattr(self._config, "pin_peer_name", False) is True - ) - if self._runtime_user_peer_name and not pin_peer_name: - user_peer_id = self._sanitize_id(self._runtime_user_peer_name) - elif self._config and self._config.peer_name: - user_peer_id = self._sanitize_id(self._config.peer_name) - else: - parts = key.split(":", 1) - channel = parts[0] if len(parts) > 1 else "default" - chat_id = parts[1] if len(parts) > 1 else key - user_peer_id = self._sanitize_id(f"user-{channel}-{chat_id}") + # etc.) so multi-user bots scope memory per user. Config can alias + # known runtime IDs or prefix unknown IDs. For a single-user + # deployment, ``pinPeerName`` still pins all runtime identities to + # ``peerName`` (see #14984). + user_peer_id = self._resolve_user_peer_id(key) assistant_peer_id = self._sanitize_id( self._config.ai_peer if self._config else "hermes-assistant" @@ -937,11 +1007,11 @@ def get_session_context(self, session_key: str, peer: str = "user") -> dict[str, return self._fetch_peer_context(peer_id, target=peer_id) try: - peer_id = self._resolve_peer_id(session, peer) + observer_peer_id, target_peer_id = self._resolve_observer_target(session, peer) ctx = honcho_session.context( summary=True, - peer_target=peer_id, - peer_perspective=session.user_peer_id if peer == "user" else session.assistant_peer_id, + peer_target=target_peer_id or observer_peer_id, + peer_perspective=observer_peer_id, ) result: dict[str, Any] = {} @@ -1017,7 +1087,14 @@ def get_peer_card(self, session_key: str, peer: str = "user") -> list[str]: try: observer_peer_id, target_peer_id = self._resolve_observer_target(session, peer) - return self._fetch_peer_card(observer_peer_id, target=target_peer_id) + card = self._fetch_peer_card(observer_peer_id, target=target_peer_id) + if card: + return card + # Some backends store cards directly on the target peer, not the + # observer-target slot. Fall back so honcho_profile still works. + if target_peer_id: + return self._fetch_peer_card(target_peer_id) + return [] except Exception as e: logger.debug("Failed to fetch peer card from Honcho: %s", e) return [] @@ -1164,13 +1241,22 @@ def set_peer_card(self, session_key: str, card: list[str], peer: str = "user") - if not session: return None try: - peer_id = self._resolve_peer_id(session, peer) - if peer_id is None: + observer_peer_id, target_peer_id = self._resolve_observer_target(session, peer) + if observer_peer_id is None: logger.warning("Could not resolve peer '%s' for set_peer_card in session '%s'", peer, session_key) return None - peer_obj = self._get_or_create_peer(peer_id) - result = peer_obj.set_card(card) - logger.info("Updated peer card for %s (%d facts)", peer_id, len(card)) + peer_obj = self._get_or_create_peer(observer_peer_id) + result = ( + peer_obj.set_card(card, target=target_peer_id) + if target_peer_id is not None + else peer_obj.set_card(card) + ) + logger.info( + "Updated peer card observer=%s target=%s (%d facts)", + observer_peer_id, + target_peer_id or observer_peer_id, + len(card), + ) return result except Exception as e: logger.error("Failed to set peer card: %s", e) -
run_agent.py@@ -393,6 +393,7 @@ def __init__( prefill_messages: List[Dict[str, Any]] = None, platform: str = None, user_id: str = None, + user_id_alt: str = None, user_name: str = None, chat_id: str = None, chat_name: str = None, @@ -462,6 +463,7 @@ def __init__( prefill_messages=prefill_messages, platform=platform, user_id=user_id, + user_id_alt=user_id_alt, user_name=user_name, chat_id=chat_id, chat_name=chat_name, -
scripts/release.py@@ -213,6 +213,8 @@ "[redacted:email]": "maciekczech", "[redacted:email]": "LeonSGP43", "[redacted:email]": "LeonSGP43", + "[redacted:email]": "cyb0rgk1tty", + "[redacted:email]": "cmullins70", "[redacted:email]": "zeejaytan", "[redacted:email]": "Aslaaen", "[redacted:email]": "Tkander1715", -
tests/gateway/test_agent_cache.py@@ -1344,3 +1344,71 @@ def test_watchdog_accumulation_across_recursive_turns(self): f"Watchdog would see {idle_secs:.0f}s idle, expected ~{STUCK_FOR}s. " "Inactivity timeout could not fire for a stuck interrupted turn." ) + + +class TestAgentConfigSignatureUserId: + """Shared-thread cache must not reuse an agent across users. + + HonchoSessionManager freezes the resolved runtime user identity at + first-message init. When the gateway session_key omits the participant + ID (``thread_sessions_per_user=False``), a cached AIAgent created by + user A would otherwise be reused for user B, attributing B's writes to + A's resolved peer. Including ``user_id`` / ``user_id_alt`` in the + signature forces per-user agent builds in shared threads. + + Tradeoff: cold prompt cache for each user's first turn in a shared + thread, in exchange for correct memory attribution. + """ + + def test_signature_changes_with_user_id(self): + from gateway.run import GatewayRunner + runtime = {"provider": "anthropic", "api_key": "k", "base_url": "", "api_mode": "chat_completions"} + sig_a = GatewayRunner._agent_config_signature( + "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="86701400" + ) + sig_b = GatewayRunner._agent_config_signature( + "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="491827364" + ) + assert sig_a != sig_b + + def test_signature_stable_with_same_user_id(self): + from gateway.run import GatewayRunner + runtime = {"provider": "anthropic", "api_key": "k", "base_url": "", "api_mode": "chat_completions"} + sig_1 = GatewayRunner._agent_config_signature( + "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="86701400" + ) + sig_2 = GatewayRunner._agent_config_signature( + "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="86701400" + ) + assert sig_1 == sig_2 + + def test_signature_changes_with_user_id_alt(self): + from gateway.run import GatewayRunner + runtime = {"provider": "anthropic", "api_key": "k", "base_url": "", "api_mode": "chat_completions"} + sig_a = GatewayRunner._agent_config_signature( + "claude-sonnet-4", runtime, ["hermes-telegram"], "", + user_id="86701400", user_id_alt="@igor_tg", + ) + sig_b = GatewayRunner._agent_config_signature( + "claude-sonnet-4", runtime, ["hermes-telegram"], "", + user_id="86701400", user_id_alt="@erosika_tg", + ) + assert sig_a != sig_b + + def test_signature_omits_user_id_when_absent(self): + """Default-None user_id must not change signatures vs unset call. + + Callers that pass no user_id kwarg must produce a signature + byte-identical to ``user_id=None`` so in-flight caches survive + the rollout of this fix. + """ + from gateway.run import GatewayRunner + runtime = {"provider": "anthropic", "api_key": "k", "base_url": "", "api_mode": "chat_completions"} + sig_implicit = GatewayRunner._agent_config_signature( + "claude-sonnet-4", runtime, ["hermes-telegram"], "", + ) + sig_explicit_none = GatewayRunner._agent_config_signature( + "claude-sonnet-4", runtime, ["hermes-telegram"], "", + user_id=None, user_id_alt=None, + ) + assert sig_implicit == sig_explicit_none -
tests/honcho_plugin/test_cli.py@@ -153,4 +153,424 @@ def _boom(hcfg, client): out = capsys.readouterr().out assert "FAILED (Invalid API key)" in out - assert "Connection... OK" not in out \ No newline at end of file + assert "Connection... OK" not in out + + +class TestCloneHonchoForProfile: + """Identity-key carryover during profile cloning. + + The host-scoped identity-mapping keys (``userPeerAliases``, + ``runtimePeerPrefix``, ``pinPeerName``) must survive a clone; otherwise + the new profile silently fragments memory by resolving gateway users to + raw runtime IDs instead of operator-declared peers. + """ + + def _setup_clone_env(self, monkeypatch, tmp_path, cfg): + import plugins.memory.honcho.cli as honcho_cli + cfg_path = tmp_path / "config.json" + cfg_path.write_text("{}") + monkeypatch.setattr(honcho_cli, "_read_config", lambda: cfg) + monkeypatch.setattr(honcho_cli, "_config_path", lambda: cfg_path) + monkeypatch.setattr(honcho_cli, "_local_config_path", lambda: cfg_path) + monkeypatch.setattr(honcho_cli, "_ensure_peer_exists", lambda host_key=None: True) + written = {} + def _write(c, path=None): + written["cfg"] = c + monkeypatch.setattr(honcho_cli, "_write_config", _write) + return honcho_cli, written + + def test_user_peer_aliases_carry_into_cloned_profile(self, monkeypatch, tmp_path): + cfg = { + "apiKey": "***", + "hosts": { + "hermes": { + "userPeerAliases": {"86701400": "eri", "discord-491827364": "eri"}, + "peerName": "eri", + }, + }, + } + honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg) + ok = honcho_cli.clone_honcho_for_profile("coder") + assert ok is True + new_block = written["cfg"]["hosts"]["hermes.coder"] + assert new_block["userPeerAliases"] == {"86701400": "eri", "discord-491827364": "eri"} + + def test_runtime_peer_prefix_carries_into_cloned_profile(self, monkeypatch, tmp_path): + cfg = { + "apiKey": "***", + "hosts": { + "hermes": { + "runtimePeerPrefix": "telegram_", + "peerName": "eri", + }, + }, + } + honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg) + ok = honcho_cli.clone_honcho_for_profile("coder") + assert ok is True + new_block = written["cfg"]["hosts"]["hermes.coder"] + assert new_block["runtimePeerPrefix"] == "telegram_" + + def test_pin_peer_name_carries_into_cloned_profile(self, monkeypatch, tmp_path): + cfg = { + "apiKey": "***", + "hosts": { + "hermes": { + "pinPeerName": True, + "peerName": "eri", + }, + }, + } + honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg) + ok = honcho_cli.clone_honcho_for_profile("coder") + assert ok is True + new_block = written["cfg"]["hosts"]["hermes.coder"] + assert new_block["pinPeerName"] is True + + def test_unset_identity_keys_do_not_appear_in_cloned_profile(self, monkeypatch, tmp_path): + cfg = { + "apiKey": "***", + "hosts": {"hermes": {"peerName": "eri"}}, + } + honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg) + ok = honcho_cli.clone_honcho_for_profile("coder") + assert ok is True + new_block = written["cfg"]["hosts"]["hermes.coder"] + assert "userPeerAliases" not in new_block + assert "runtimePeerPrefix" not in new_block + assert "pinPeerName" not in new_block + + +class TestSetupWizardDeploymentShape: + """The deployment-shape step writes pinPeerName / userPeerAliases / + runtimePeerPrefix based on the operator's chosen shape. + + Single-operator deployments collapse all platforms to peerName. + Multi-user gateways leave the resolver to route per-runtime. + Hybrid deployments alias the operator's own runtime IDs only. + + These tests script the interactive _prompt calls and assert the + resulting hermes_host block, so the wizard's deployment-shape + semantics stay locked even as adjacent prompts are added. + """ + + def _run_setup(self, monkeypatch, tmp_path, *, answers, initial_cfg=None): + import plugins.memory.honcho.cli as honcho_cli + + cfg_path = tmp_path / "config.json" + cfg_path.write_text("{}") + cfg = initial_cfg if initial_cfg is not None else {"apiKey": "***"} + + monkeypatch.setattr(honcho_cli, "_read_config", lambda: cfg) + monkeypatch.setattr(honcho_cli, "_config_path", lambda: cfg_path) + monkeypatch.setattr(honcho_cli, "_local_config_path", lambda: cfg_path) + monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes") + monkeypatch.setattr(honcho_cli, "_ensure_sdk_installed", lambda: True) + monkeypatch.setattr(honcho_cli, "_write_config", lambda *a, **k: None) + + # Bypass config.yaml + connection test side effects. + monkeypatch.setattr( + "hermes_cli.config.load_config", lambda: {"memory": {}}, raising=False, + ) + monkeypatch.setattr( + "hermes_cli.config.save_config", lambda c: None, raising=False, + ) + + class _FakeClientCfg: + def resolve_session_name(self): + return "hermes-test" + workspace_id = "hermes" + peer_name = "eri" + ai_peer = "hermetika" + observation_mode = "directional" + write_frequency = "async" + recall_mode = "hybrid" + session_strategy = "per-session" + + monkeypatch.setattr( + "plugins.memory.honcho.client.HonchoClientConfig.from_global_config", + lambda host=None: _FakeClientCfg(), + ) + monkeypatch.setattr( + "plugins.memory.honcho.client.reset_honcho_client", + lambda: None, + ) + monkeypatch.setattr( + "plugins.memory.honcho.client.get_honcho_client", + lambda hcfg: object(), + ) + + # Scripted _prompt: pop answers in order. Default-return for unconsumed prompts. + answer_iter = iter(answers) + def _scripted_prompt(label, default=None, secret=[redacted:secret-assignment] + try: + return next(answer_iter) + except StopIteration: + return default if default is not None else "" + monkeypatch.setattr(honcho_cli, "_prompt", _scripted_prompt) + + honcho_cli.cmd_setup(SimpleNamespace()) + return cfg["hosts"]["hermes"] + + def test_single_shape_sets_pin_peer_name_and_clears_aliases(self, monkeypatch, tmp_path): + answers = [ + "cloud", # deployment + "", # api key (keep) + "eri", # peer name + "hermetika", # ai peer + "hermes", # workspace + "single", # deployment shape ← key answer + # remaining prompts fall through to defaults + ] + initial_cfg = { + "apiKey": "***", + "hosts": {"hermes": { + "userPeerAliases": {"old": "stale"}, + "runtimePeerPrefix": "old_", + }}, + } + host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) + assert host["pinPeerName"] is True + assert "userPeerAliases" not in host + assert "runtimePeerPrefix" not in host + + def test_multi_shape_leaves_pin_false_and_accepts_prefix(self, monkeypatch, tmp_path): + answers = [ + "cloud", # deployment + "", # api key (keep) + "eri", # peer name + "hermetika", # ai peer + "hermes", # workspace + "multi", # deployment shape + "telegram_", # runtime peer prefix + ] + host = self._run_setup(monkeypatch, tmp_path, answers=answers) + assert host["pinPeerName"] is False + # Multi must NOT auto-write ``userPeerAliases: {}``: an empty host + # map would silently override a root-level baseline. Absence is + # the correct "no host opinion" signal. + assert "userPeerAliases" not in host + assert host["runtimePeerPrefix"] == "telegram_" + + def test_hybrid_shape_aliases_operator_runtime_ids_to_peer_name(self, monkeypatch, tmp_path): + answers = [ + "cloud", # deployment + "", # api key (keep) + "eri", # peer name + "hermetika", # ai peer + "hermes", # workspace + "hybrid", # deployment shape + "86701400", # telegram uid + "491827364", # discord snowflake + "", # slack (skip) + "", # matrix (skip) + "", # runtime peer prefix (skip) + ] + host = self._run_setup(monkeypatch, tmp_path, answers=answers) + assert host["pinPeerName"] is False + assert host["userPeerAliases"] == { + "86701400": "eri", + "491827364": "eri", + } + assert "runtimePeerPrefix" not in host + + def test_skip_shape_preserves_existing_identity_config(self, monkeypatch, tmp_path): + initial_cfg = { + "apiKey": "***", + "hosts": {"hermes": { + "pinPeerName": True, + "userPeerAliases": {"keep": "me"}, + "runtimePeerPrefix": "keep_", + }}, + } + answers = [ + "cloud", "", "eri", "hermetika", "hermes", "skip", + ] + host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) + assert host["pinPeerName"] is True + assert host["userPeerAliases"] == {"keep": "me"} + assert host["runtimePeerPrefix"] == "keep_" + + def test_single_to_multi_steers_to_hybrid_by_default(self, monkeypatch, tmp_path): + """Flipping single → multi triggers a warning that auto-steers the + operator to ``hybrid`` (default), so their own runtime IDs keep + landing on peerName instead of orphaning the pinned-pool history. + """ + initial_cfg = { + "apiKey": "***", + "hosts": {"hermes": {"pinPeerName": True, "peerName": "eri"}}, + } + answers = [ + "cloud", # deployment + "", # api key (keep) + "eri", # peer name + "hermetika", # ai peer + "hermes", # workspace + "multi", # deployment shape — triggers the guard + "hybrid", # guard response: accept the steer + "86701400", # telegram uid + "", # discord (skip) + "", # slack (skip) + "", # matrix (skip) + "", # runtime prefix (skip) + ] + host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) + assert host["pinPeerName"] is False + assert host["userPeerAliases"] == {"86701400": "eri"} + + def test_single_to_multi_yes_override_keeps_multi(self, monkeypatch, tmp_path): + """Operator can override the steer by answering ``yes`` and accept + the orphaning consequences. This is the explicit undo-the-pin path. + """ + initial_cfg = { + "apiKey": "***", + "hosts": {"hermes": {"pinPeerName": True, "peerName": "eri"}}, + } + answers = [ + "cloud", "", "eri", "hermetika", "hermes", + "multi", # deployment shape — triggers the guard + "yes", # guard response: confirm multi + "telegram_", # runtime peer prefix + ] + host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) + assert host["pinPeerName"] is False + # See test_multi_shape_leaves_pin_false_and_accepts_prefix. + assert "userPeerAliases" not in host + assert host["runtimePeerPrefix"] == "telegram_" + + def test_host_pin_user_peer_true_is_detected_as_single(self, monkeypatch, tmp_path): + """Host-level ``pinUserPeer: true`` must classify as ``single``. + + Pressing Enter at the shape prompt then preserves the pin instead + of falling through to ``multi`` and orphaning the user's memory + pool — the bug the wizard regressed when ``pinUserPeer`` landed + as a higher-precedence alias. + """ + initial_cfg = { + "apiKey": "***", + "hosts": {"hermes": {"pinUserPeer": True, "peerName": "eri"}}, + } + # Exhaust the iterator before the shape prompt so the scripted + # mock falls through to the prompt's default (which is the + # wizard-detected shape). Scripting an explicit "" would NOT + # exercise that fallthrough — the mock returns it literally. + answers = ["cloud", "", "eri", "hermetika", "hermes"] + host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) + # Scrub-then-write normalises onto pinPeerName and drops the alias + # so resolver precedence can't reintroduce ambiguity. + assert host["pinPeerName"] is True + assert "pinUserPeer" not in host + + def test_host_pin_user_peer_false_overrides_root_pin_peer_name( + self, monkeypatch, tmp_path + ): + """Host ``pinUserPeer: false`` outranks host ``pinPeerName`` in the + resolver. Detection must agree, otherwise the wizard would offer + ``single`` as the default and silently re-pin a profile the + operator explicitly unpinned via the newer key. + """ + initial_cfg = { + "apiKey": "***", + "hosts": {"hermes": { + "pinUserPeer": False, + "pinPeerName": True, + "peerName": "eri", + }}, + } + answers = ["cloud", "", "eri", "hermetika", "hermes"] + host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) + assert host["pinPeerName"] is False + assert "pinUserPeer" not in host + + def test_root_user_peer_aliases_detected_as_hybrid(self, monkeypatch, tmp_path): + """Root-level ``userPeerAliases`` must classify as ``hybrid`` even + when the host block has no aliases of its own. + """ + initial_cfg = { + "apiKey": "***", + "userPeerAliases": {"86701400": "eri"}, + "hosts": {"hermes": {"peerName": "eri"}}, + } + answers = ["cloud", "", "eri", "hermetika", "hermes"] + host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) + assert host["pinPeerName"] is False + # Hybrid materialises the root aliases into the host so subsequent + # operator edits live on the host block they're inspecting. + assert host["userPeerAliases"] == {"86701400": "eri"} + + def test_multi_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_path): + """Explicit ``multi`` must leave the host ``userPeerAliases`` key + absent, preserving any root-level aliases as a cross-host baseline. + + Picking ``multi`` here is an active choice — detection would have + defaulted to ``hybrid`` because root aliases exist — so the + operator's intent is to drop the alias mapping for this host. + We honor that by writing ``pinPeerName: false`` only, and rely + on the host's absence of ``userPeerAliases`` to inherit root. + That inheritance is intentional: a true wipe would require the + operator to delete the root key explicitly. + """ + initial_cfg = { + "apiKey": "***", + "userPeerAliases": {"baseline": "eri"}, + "hosts": {"hermes": {"peerName": "eri"}}, + } + answers = [ + "cloud", "", "eri", "hermetika", "hermes", + "multi", # explicit multi override of detected hybrid + ] + host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) + assert host["pinPeerName"] is False + assert "userPeerAliases" not in host + + def test_single_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path): + """Choosing ``single`` must drop any host-level ``pinUserPeer``, + otherwise an existing ``pinUserPeer: false`` would outrank the + freshly written ``pinPeerName: true`` and leave the profile + effectively unpinned (the P1 latent-precedence regression). + """ + initial_cfg = { + "apiKey": "***", + "hosts": {"hermes": { + "pinUserPeer": False, + "peerName": "eri", + }}, + } + answers = [ + "cloud", "", "eri", "hermetika", "hermes", + "single", + ] + host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) + assert host["pinPeerName"] is True + assert "pinUserPeer" not in host + + +class TestCloneCarriesPinUserPeer: + """``pinUserPeer`` (canonical name for ``pinPeerName``) must survive a + profile clone. Without this, a default profile that uses the newer + key would silently produce cloned profiles without the pin even + though the resolver prefers ``pinUserPeer`` over ``pinPeerName``. + """ + + def test_clone_inherits_host_pin_user_peer(self, monkeypatch, tmp_path): + import plugins.memory.honcho.cli as honcho_cli + + cfg = { + "apiKey": "***", + "hosts": {"hermes": {"pinUserPeer": True, "peerName": "eri"}}, + } + cfg_path = tmp_path / "config.json" + cfg_path.write_text("{}") + monkeypatch.setattr(honcho_cli, "_read_config", lambda: cfg) + monkeypatch.setattr(honcho_cli, "_config_path", lambda: cfg_path) + monkeypatch.setattr(honcho_cli, "_local_config_path", lambda: cfg_path) + monkeypatch.setattr(honcho_cli, "_ensure_peer_exists", lambda host_key=None: True) + written = {} + monkeypatch.setattr( + honcho_cli, "_write_config", lambda c, path=None: written.setdefault("cfg", c), + ) + + ok = honcho_cli.clone_honcho_for_profile("partner") + assert ok is True + new_block = written["cfg"]["hosts"]["hermes.partner"] + assert new_block["pinUserPeer"] is True -
tests/honcho_plugin/test_pin_peer_name.py@@ -1,24 +1,20 @@ -"""Tests for the ``pinPeerName`` config flag (#14984). - -By default, when Hermes runs under a gateway (Telegram, Discord, Slack, ...) -it passes the platform-native user ID as ``runtime_user_peer_name`` into -``HonchoSessionManager``. That ID wins over any configured ``peer_name`` -so multi-user bots scope memory per user. - -For a single-user personal deployment where the user connects over multiple -platforms, that default forks memory into one Honcho peer per platform -(Telegram UID, Discord snowflake, Slack user ID, ...). The user asked for -an opt-in knob that pins the user peer to ``peer_name`` from ``honcho.json`` -so the same person's memory stays unified regardless of which platform the -turn arrived on — ``hosts.<host>.pinPeerName: true`` (or root-level -``pinPeerName: true``). - -These tests exercise both the config parsing (``client.py::from_global_config``) -and the resolution order (``session.py::get_or_create``). We stub the -Honcho API calls so we can assert the chosen ``user_peer_id`` without -touching the network. +"""Tests for the ``pinPeerName`` / ``pinUserPeer`` config flag. + +Under a gateway (Telegram, Discord, Slack, ...) Hermes passes the +platform-native user ID as ``runtime_user_peer_name`` into +``HonchoSessionManager``. By default that ID wins over any configured +``peer_name`` so multi-user bots scope memory per user. + +For single-user deployments connecting over multiple platforms, +``pinUserPeer: true`` pins the user peer to ``peer_name`` so memory stays +unified across platforms. + +Tests cover config parsing (``client.py::from_global_config``) and resolver +order (``session.py::get_or_create``), stubbing Honcho API calls so the +chosen ``user_peer_id`` can be asserted without touching the network. """ +import hashlib import json from unittest.mock import MagicMock @@ -99,6 +95,90 @@ def test_explicit_false_parses(self, tmp_path, monkeypatch): assert config.pin_peer_name is False +class TestRuntimePeerMappingConfigParsing: + def test_defaults_are_empty(self): + config = HonchoClientConfig() + assert config.user_peer_aliases == {} + assert config.runtime_peer_prefix == "" + + def test_root_level_aliases_and_prefix_parse(self, tmp_path): + config_file = tmp_path / "honcho.json" + config_file.write_text(json.dumps({ + "apiKey": "k", + "userPeerAliases": { + " 86701400 ": " Igor ", + "": "ignored", + "empty-value": " ", + "null-value": None, + }, + "runtimePeerPrefix": "telegram_", + })) + + config = HonchoClientConfig.from_global_config(config_path=config_file) + + assert config.user_peer_aliases == {"86701400": "Igor"} + assert config.runtime_peer_prefix == "telegram_" + + def test_host_aliases_override_root_aliases_as_whole_map(self, tmp_path): + config_file = tmp_path / "honcho.json" + config_file.write_text(json.dumps({ + "apiKey": "k", + "userPeerAliases": {"root-user": "root-peer"}, + "hosts": { + "hermes": { + "userPeerAliases": {"host-user": "host-peer"}, + }, + }, + })) + + config = HonchoClientConfig.from_global_config(config_path=config_file) + + assert config.user_peer_aliases == {"host-user": "host-peer"} + + def test_host_empty_aliases_disable_root_aliases(self, tmp_path): + config_file = tmp_path / "honcho.json" + config_file.write_text(json.dumps({ + "apiKey": "k", + "userPeerAliases": {"root-user": "root-peer"}, + "hosts": { + "hermes": { + "userPeerAliases": {}, + }, + }, + })) + + config = HonchoClientConfig.from_global_config(config_path=config_file) + + assert config.user_peer_aliases == {} + + def test_host_empty_prefix_disables_root_prefix(self, tmp_path): + config_file = tmp_path / "honcho.json" + config_file.write_text(json.dumps({ + "apiKey": "k", + "runtimePeerPrefix": "telegram_", + "hosts": { + "hermes": { + "runtimePeerPrefix": "", + }, + }, + })) + + config = HonchoClientConfig.from_global_config(config_path=config_file) + + assert config.runtime_peer_prefix == "" + + def test_malformed_alias_config_is_ignored(self, tmp_path): + config_file = tmp_path / "honcho.json" + config_file.write_text(json.dumps({ + "apiKey": "k", + "userPeerAliases": ["not", "a", "map"], + })) + + config = HonchoClientConfig.from_global_config(config_path=config_file) + + assert config.user_peer_aliases == {} + + # --------------------------------------------------------------------------- # Peer resolution (the actual bug fix) # --------------------------------------------------------------------------- @@ -119,13 +199,24 @@ def _patch_manager_for_resolution_test(mgr: HonchoSessionManager) -> None: class TestPeerResolutionOrder: """Matrix of (runtime_id, pin_peer_name, peer_name) → expected user_peer_id.""" - def _config(self, *, peer_name: str | None, pin_peer_name: bool) -> HonchoClientConfig: + def _config( + self, + *, + peer_name: str | None, + pin_peer_name: bool, + user_peer_aliases: dict[str, str] | None = None, + runtime_peer_prefix: str = "", + session_peer_prefix: bool = False, + ) -> HonchoClientConfig: # The test doesn't need auth / Honcho — disable the provider so # the manager doesn't try to open a real client. return HonchoClientConfig( api_key="[redacted:secret-assignment]", peer_name=peer_name, pin_peer_name=pin_peer_name, + user_peer_aliases=user_peer_aliases or {}, + runtime_peer_prefix=runtime_peer_prefix, + session_peer_prefix=session_peer_prefix, enabled=False, write_frequency="turn", # avoid spawning the async writer thread ) @@ -148,11 +239,177 @@ def test_runtime_wins_when_pin_is_false(self): "bot immediately merges memory across users." ) + def test_alias_wins_for_known_runtime_id(self): + """Known platform IDs can preserve an existing stable Honcho peer.""" + mgr = HonchoSessionManager( + honcho=MagicMock(), + config=self._config( + peer_name="Igor", + pin_peer_name=False, + user_peer_aliases={"86701400": "Igor"}, + runtime_peer_prefix="telegram_", + ), + runtime_user_peer_name="86701400", + ) + _patch_manager_for_resolution_test(mgr) + + session = mgr.get_or_create("telegram:86701400") + assert session.user_peer_id == "Igor" + + def test_unknown_runtime_id_uses_prefix(self): + """Unknown gateway users stay isolated but become platform-scoped.""" + mgr = HonchoSessionManager( + honcho=MagicMock(), + config=self._config( + peer_name="Igor", + pin_peer_name=False, + runtime_peer_prefix="telegram_", + ), + runtime_user_peer_name="86701400", + ) + _patch_manager_for_resolution_test(mgr) + + session = mgr.get_or_create("telegram:86701400") + assert session.user_peer_id == "telegram_86701400" + + def test_prefixed_runtime_id_hashes_when_sanitization_is_lossy(self): + """Generated prefixed IDs avoid merges caused by lossy sanitization.""" + raw_peer_id = "telegram_user:42" + expected_hash = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest()[:8] + mgr = HonchoSessionManager( + honcho=MagicMock(), + config=self._config( + peer_name=None, + pin_peer_name=False, + runtime_peer_prefix="telegram_", + ), + runtime_user_peer_name="user:42", + ) + _patch_manager_for_resolution_test(mgr) + + session = mgr.get_or_create("telegram:user:42") + assert session.user_peer_id == f"telegram_user-42-{expected_hash}" + + def test_prefixed_runtime_id_hashes_when_it_collides_with_peer_name(self): + """Unknown generated peers should not silently merge into peerName.""" + raw_peer_id = "telegram_86701400" + expected_hash = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest()[:8] + mgr = HonchoSessionManager( + honcho=MagicMock(), + config=self._config( + peer_name="telegram_86701400", + pin_peer_name=False, + runtime_peer_prefix="telegram_", + ), + runtime_user_peer_name="86701400", + ) + _patch_manager_for_resolution_test(mgr) + + session = mgr.get_or_create("telegram:86701400") + assert session.user_peer_id == f"telegram_86701400-{expected_hash}" + + def test_prefixed_runtime_id_hashes_when_it_collides_with_alias_target(self): + """Unknown generated peers should not silently merge into alias targets.""" + raw_peer_id = "telegram_86701400" + expected_hash = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest()[:8] + mgr = HonchoSessionManager( + honcho=MagicMock(), + config=self._config( + peer_name=None, + pin_peer_name=False, + user_peer_aliases={"known-user": "telegram_86701400"}, + runtime_peer_prefix="telegram_", + ), + runtime_user_peer_name="86701400", + ) + _patch_manager_for_resolution_test(mgr) + + session = mgr.get_or_create("telegram:86701400") + assert session.user_peer_id == f"telegram_86701400-{expected_hash}" + + def test_prefixed_runtime_id_extends_hash_when_short_hash_collides(self): + raw_peer_id = "telegram_86701400" + digest = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest() + mgr = HonchoSessionManager( + honcho=MagicMock(), + config=self._config( + peer_name=None, + pin_peer_name=False, + user_peer_aliases={ + "known-user": "telegram_86701400", + "reserved-user": f"telegram_86701400-{digest[:8]}", + }, + runtime_peer_prefix="telegram_", + ), + runtime_user_peer_name="86701400", + ) + _patch_manager_for_resolution_test(mgr) + + session = mgr.get_or_create("telegram:86701400") + assert session.user_peer_id == f"telegram_86701400-{digest[:12]}" + + def test_alias_value_is_sanitized_after_selection(self): + mgr = HonchoSessionManager( + honcho=MagicMock(), + config=self._config( + peer_name=None, + pin_peer_name=False, + user_peer_aliases={"86701400": "Alice Smith!"}, + ), + runtime_user_peer_name="86701400", + ) + _patch_manager_for_resolution_test(mgr) + + session = mgr.get_or_create("telegram:86701400") + assert session.user_peer_id == "Alice-Smith-" + + def test_alias_keys_match_raw_runtime_id_before_sanitization(self): + """Alias selection is exact on platform IDs before Honcho ID cleanup.""" + mgr = HonchoSessionManager( + honcho=MagicMock(), + config=self._config( + peer_name=None, + pin_peer_name=False, + user_peer_aliases={ + "user:42": "raw-match", + "user-42": "sanitized-match", + }, + ), + runtime_user_peer_name="user:42", + ) + _patch_manager_for_resolution_test(mgr) + + session = mgr.get_or_create("telegram:user:42") + assert session.user_peer_id == "raw-match" + + def test_session_peer_prefix_is_orthogonal_to_runtime_peer_prefix(self): + """sessionPeerPrefix scopes session IDs; runtimePeerPrefix scopes user peers.""" + mgr = HonchoSessionManager( + honcho=MagicMock(), + config=self._config( + peer_name="Igor", + pin_peer_name=False, + runtime_peer_prefix="telegram_", + session_peer_prefix=True, + ), + runtime_user_peer_name="86701400", + ) + _patch_manager_for_resolution_test(mgr) + + session = mgr.get_or_create("telegram:86701400") + assert session.user_peer_id == "telegram_86701400" + assert session.honcho_session_id == "telegram-86701400" + def test_config_wins_when_pin_is_true(self): - """The #14984 fix: single-user deployments opt into config pinning.""" + """With pin enabled, configured peer_name beats runtime ID.""" mgr = HonchoSessionManager( honcho=MagicMock(), - config=self._config(peer_name="Igor", pin_peer_name=True), + config=self._config( + peer_name="Igor", + pin_peer_name=True, + user_peer_aliases={"86701400": "Alias"}, + runtime_peer_prefix="telegram_", + ), runtime_user_peer_name="86701400", # Telegram pushes this in ) _patch_manager_for_resolution_test(mgr) @@ -167,7 +424,23 @@ def test_config_wins_when_pin_is_true(self): def test_pin_noop_when_peer_name_missing(self): """Safety: pinPeerName alone (no peer_name) must not silently drop the runtime identity. Without a configured peer_name there's - nothing to pin to — fall back to runtime as before.""" + nothing to pin to — fall through to runtime mapping.""" + mgr = HonchoSessionManager( + honcho=MagicMock(), + config=self._config( + peer_name=None, + pin_peer_name=True, + user_peer_aliases={"86701400": "Igor"}, + runtime_peer_prefix="telegram_", + ), + runtime_user_peer_name="86701400", + ) + _patch_manager_for_resolution_test(mgr) + + session = mgr.get_or_create("telegram:86701400") + assert session.user_peer_id == "Igor" + + def test_pin_noop_without_peer_name_or_mapping_preserves_runtime(self): mgr = HonchoSessionManager( honcho=MagicMock(), config=self._config(peer_name=None, pin_peer_name=True), @@ -176,11 +449,42 @@ def test_pin_noop_when_peer_name_missing(self): _patch_manager_for_resolution_test(mgr) session = mgr.get_or_create("telegram:86701400") - assert session.user_peer_id == "86701400", ( - "pin_peer_name=True with no peer_name set must not strip the " - "runtime ID — otherwise the user peer would collapse to the " - "session-key fallback and lose per-user scoping entirely" + assert session.user_peer_id == "86701400" + + def test_alt_runtime_id_can_match_alias_without_changing_raw_fallback(self): + """Stable alternate IDs can map known users while primary ID fallback stays unchanged.""" + mgr = HonchoSessionManager( + honcho=MagicMock(), + config=self._config( + peer_name=None, + pin_peer_name=False, + user_peer_aliases={"union-user": "Igor"}, + runtime_peer_prefix="feishu_", + ), + runtime_user_peer_name="open-id", + runtime_user_peer_name_alt="union-user", ) + _patch_manager_for_resolution_test(mgr) + + session = mgr.get_or_create("feishu:chat") + assert session.user_peer_id == "Igor" + + def test_alt_runtime_id_does_not_replace_primary_prefix_fallback(self): + mgr = HonchoSessionManager( + honcho=MagicMock(), + config=self._config( + peer_name=None, + pin_peer_name=False, + user_peer_aliases={"other-union": "Igor"}, + runtime_peer_prefix="feishu_", + ), + runtime_user_peer_name="open-id", + runtime_user_peer_name_alt="union-user", + ) + _patch_manager_for_resolution_test(mgr) + + session = mgr.get_or_create("feishu:chat") + assert session.user_peer_id == "feishu_open-id" def test_runtime_missing_falls_back_to_peer_name(self): """CLI-mode (no gateway runtime identity) uses config peer_name — @@ -233,9 +537,8 @@ def test_pin_does_not_affect_assistant_peer(self): class TestCrossPlatformMemoryUnification: - """The user-visible outcome of the #14984 fix: the same physical user - talking to Hermes via Telegram AND Discord should land on ONE peer - (not two) when pinPeerName is opted in. + """The same physical user talking to Hermes via Telegram AND Discord + lands on ONE peer when ``pinPeerName`` is opted in. """ def _config_pinned(self) -> HonchoClientConfig: @@ -305,3 +608,277 @@ def test_multiuser_default_keeps_platforms_separate(self): "multi-user default MUST keep users separate — a regression " "here would silently merge unrelated users' memory" ) + + +class TestPinUserPeerAlias: + """``pinUserPeer`` and ``pinPeerName`` both resolve to the same internal + ``pin_peer_name`` field. Precedence when both appear: host pinUserPeer → + host pinPeerName → root pinUserPeer → root pinPeerName → default. + """ + + def test_root_pinUserPeer_true_pins(self, tmp_path): + from plugins.memory.honcho.client import HonchoClientConfig + import json + config_file = tmp_path / "honcho.json" + config_file.write_text(json.dumps({ + "apiKey": "***", + "peerName": "eri", + "pinUserPeer": True, + })) + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.pin_peer_name is True + + def test_host_pinUserPeer_wins_over_root_pinPeerName(self, tmp_path): + from plugins.memory.honcho.client import HonchoClientConfig + import json + config_file = tmp_path / "honcho.json" + config_file.write_text(json.dumps({ + "apiKey": "***", + "peerName": "eri", + "pinPeerName": False, + "hosts": {"hermes": {"pinUserPeer": True}}, + })) + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.pin_peer_name is True + + def test_host_pinUserPeer_false_disables_root_pinPeerName(self, tmp_path): + from plugins.memory.honcho.client import HonchoClientConfig + import json + config_file = tmp_path / "honcho.json" + config_file.write_text(json.dumps({ + "apiKey": "***", + "peerName": "eri", + "pinPeerName": True, + "hosts": {"hermes": {"pinUserPeer": False}}, + })) + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.pin_peer_name is False, ( + "Host-level pinUserPeer=false must override root-level " + "pinPeerName=true so a host can unpin a globally-pinned profile." + ) + + def test_pinPeerName_still_works_unchanged(self, tmp_path): + from plugins.memory.honcho.client import HonchoClientConfig + import json + config_file = tmp_path / "honcho.json" + config_file.write_text(json.dumps({ + "apiKey": "***", + "peerName": "eri", + "hosts": {"hermes": {"pinPeerName": True}}, + })) + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.pin_peer_name is True + + +class TestPinTransition: + """Behavior when honcho.json flips ``pinPeerName`` true → false. + + Covers two contracts: + 1. A freshly-built manager picks up the flipped config and resolves + the same runtime ID to a new peer (no resolver staleness). + 2. The gateway's agent-cache signature reflects honcho identity-mapping + changes, so a config edit busts the cached AIAgent on the next turn. + """ + + def _pinned(self) -> HonchoClientConfig: + return HonchoClientConfig( + api_key="k", + peer_name="Igor", + pin_peer_name=True, + enabled=False, + write_frequency="turn", + ) + + def _unpinned(self) -> HonchoClientConfig: + return HonchoClientConfig( + api_key="k", + peer_name="Igor", + pin_peer_name=False, + enabled=False, + write_frequency="turn", + ) + + def test_fresh_manager_after_flip_resolves_to_runtime(self): + pinned_mgr = HonchoSessionManager( + honcho=MagicMock(), + config=self._pinned(), + runtime_user_peer_name="86701400", + ) + _patch_manager_for_resolution_test(pinned_mgr) + before = pinned_mgr.get_or_create("telegram:86701400") + assert before.user_peer_id == "Igor" + + unpinned_mgr = HonchoSessionManager( + honcho=MagicMock(), + config=self._unpinned(), + runtime_user_peer_name="86701400", + ) + _patch_manager_for_resolution_test(unpinned_mgr) + after = unpinned_mgr.get_or_create("telegram:86701400") + assert after.user_peer_id == "86701400", ( + "After flipping pinPeerName off, the same runtime ID must resolve " + "to its own peer — otherwise multi-user mode silently merges users." + ) + + def test_cached_session_survives_config_flip_in_same_manager(self): + mgr = HonchoSessionManager( + honcho=MagicMock(), + config=self._pinned(), + runtime_user_peer_name="86701400", + ) + _patch_manager_for_resolution_test(mgr) + first = mgr.get_or_create("telegram:86701400") + assert first.user_peer_id == "Igor" + + mgr._config = self._unpinned() + second = mgr.get_or_create("telegram:86701400") + assert second.user_peer_id == "Igor", ( + "The per-key session cache is keyed by session-key, not by " + "resolved peer. In-process flips don't invalidate it — the " + "gateway cache must bust the whole manager instead." + ) + + def test_cache_busting_signature_reflects_pin_peer_name(self, tmp_path, monkeypatch): + """Gateway agent cache must bust when honcho.json's pinPeerName flips.""" + from gateway.run import GatewayRunner + + cfg_path = tmp_path / "honcho.json" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor", "pinPeerName": True})) + sig_pinned = GatewayRunner._extract_cache_busting_config({}) + + cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor", "pinPeerName": False})) + sig_unpinned = GatewayRunner._extract_cache_busting_config({}) + + assert sig_pinned["honcho.pin_peer_name"] != sig_unpinned["honcho.pin_peer_name"] + + def test_cache_busting_signature_reflects_user_peer_aliases(self, tmp_path, monkeypatch): + from gateway.run import GatewayRunner + + cfg_path = tmp_path / "honcho.json" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor"})) + sig_no_aliases = GatewayRunner._extract_cache_busting_config({}) + + cfg_path.write_text(json.dumps({ + "apiKey": "k", + "peerName": "Igor", + "userPeerAliases": {"86701400": "Igor"}, + })) + sig_with_aliases = GatewayRunner._extract_cache_busting_config({}) + + assert sig_no_aliases["honcho.user_peer_aliases"] != sig_with_aliases["honcho.user_peer_aliases"] + + def test_cache_busting_signature_reflects_runtime_peer_prefix(self, tmp_path, monkeypatch): + from gateway.run import GatewayRunner + + cfg_path = tmp_path / "honcho.json" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor"})) + sig_no_prefix = GatewayRunner._extract_cache_busting_config({}) + + cfg_path.write_text(json.dumps({ + "apiKey": "k", + "peerName": "Igor", + "runtimePeerPrefix": "telegram_", + })) + sig_with_prefix = GatewayRunner._extract_cache_busting_config({}) + + assert sig_no_prefix["honcho.runtime_peer_prefix"] != sig_with_prefix["honcho.runtime_peer_prefix"] + + def test_cache_busting_signature_reflects_ai_peer(self, tmp_path, monkeypatch): + """Editing ``aiPeer`` mid-flight must invalidate the cached agent. + + ``HonchoSessionManager`` freezes ``cfg.ai_peer`` at construction — + without busting here, assistant writes keep landing on the old + peer until an unrelated cache eviction. + """ + from gateway.run import GatewayRunner + + cfg_path = tmp_path / "honcho.json" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + cfg_path.write_text(json.dumps({ + "apiKey": "k", + "peerName": "Igor", + "aiPeer": "hermes", + })) + sig_before = GatewayRunner._extract_cache_busting_config({}) + + cfg_path.write_text(json.dumps({ + "apiKey": "k", + "peerName": "Igor", + "aiPeer": "hermetika", + })) + sig_after = GatewayRunner._extract_cache_busting_config({}) + + assert sig_before["honcho.ai_peer"] != sig_after["honcho.ai_peer"] + + +class TestProfilePeerUniqueness: + """Each Hermes profile can pin to its own unique peerName. + + Profile cloning copies host blocks, but operators routinely diverge them + afterwards (e.g. `hermes -p partner` pinned to a different person's peer). + The resolver must honor host-level ``peerName`` so two profiles in the + same workspace stay scoped to different Honcho peers. + """ + + def _pinned_to(self, name: str) -> HonchoClientConfig: + return HonchoClientConfig( + api_key="k", + peer_name=name, + pin_peer_name=True, + enabled=False, + write_frequency="turn", + ) + + def test_two_profiles_pinned_to_different_peer_names_resolve_distinctly(self): + mgr_a = HonchoSessionManager( + honcho=MagicMock(), + config=self._pinned_to("alice"), + runtime_user_peer_name="86701400", + ) + _patch_manager_for_resolution_test(mgr_a) + sess_a = mgr_a.get_or_create("telegram:86701400") + + mgr_b = HonchoSessionManager( + honcho=MagicMock(), + config=self._pinned_to("bob"), + runtime_user_peer_name="86701400", + ) + _patch_manager_for_resolution_test(mgr_b) + sess_b = mgr_b.get_or_create("telegram:86701400") + + assert sess_a.user_peer_id == "alice" + assert sess_b.user_peer_id == "bob" + assert sess_a.user_peer_id != sess_b.user_peer_id, ( + "Profiles pinned to distinct peer names must not collapse to " + "the same Honcho peer — otherwise profile isolation is fictional." + ) + + def test_host_peer_name_overrides_root_when_pinned(self, tmp_path, monkeypatch): + """Host-level peerName wins so each profile can pin uniquely while + sharing a single root-level apiKey and workspace. + """ + config_file = tmp_path / "honcho.json" + config_file.write_text(json.dumps({ + "apiKey": "k", + "peerName": "default-user", + "hosts": { + "hermes.partner": { + "peerName": "partner-user", + "pinPeerName": True, + }, + }, + })) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "isolated")) + + cfg = HonchoClientConfig.from_global_config( + host="hermes.partner", config_path=config_file, + ) + assert cfg.peer_name == "partner-user" + assert cfg.pin_peer_name is True -
tests/honcho_plugin/test_session.py@@ -212,6 +212,39 @@ def test_get_peer_card_uses_direct_peer_lookup(self): assert mgr.get_peer_card(session.key) == ["Name: Robert"] assistant_peer.get_card.assert_called_once_with(target=session.user_peer_id) + def test_get_peer_card_falls_back_to_target_peer_own_card(self): + # When the observer-target card slot is empty (returns None/[]), fall + # back to the target peer's own card. Self-hosted Honcho v3 stores the + # peer card on the peer itself; the observer-target slot is only + # populated when writes also go through that path. + mgr, session = self._make_cached_manager() + assistant_peer = MagicMock() + assistant_peer.get_card.return_value = None # observer-target slot empty + user_peer = MagicMock() + user_peer.get_card.return_value = ["Prefers: dark mode"] + + def _peer(peer_id: str) -> MagicMock: + return assistant_peer if peer_id == session.assistant_peer_id else user_peer + + mgr._get_or_create_peer = MagicMock(side_effect=_peer) + + assert mgr.get_peer_card(session.key) == ["Prefers: dark mode"] + assistant_peer.get_card.assert_called_once_with(target=session.user_peer_id) + user_peer.get_card.assert_called_once_with() + + def test_set_peer_card_uses_observer_target_in_ai_observe_others_mode(self): + # Writes must go to the same observer-target slot that reads check, + # so that a subsequent honcho_profile read returns what was written. + mgr, session = self._make_cached_manager() + assistant_peer = MagicMock() + assistant_peer.set_card.return_value = ["Role: user"] + mgr._get_or_create_peer = MagicMock(return_value=assistant_peer) + + result = mgr.set_peer_card(session.key, ["Role: user"]) + + assert result == ["Role: user"] + assistant_peer.set_card.assert_called_once_with(["Role: user"], target=session.user_peer_id) + def test_search_context_uses_assistant_perspective_with_target(self): mgr, session = self._make_cached_manager() assistant_peer = MagicMock() @@ -573,7 +606,7 @@ class TestToolsModeInitBehavior: """Verify initOnSessionStart controls session init timing in tools mode.""" def _make_provider_with_config(self, recall_mode="tools", init_on_session_start=False, - peer_name=None, user_id=None): + peer_name=None, user_id=None, user_id_alt=None): """Create a HonchoMemoryProvider with mocked config and dependencies.""" from plugins.memory.honcho.client import HonchoClientConfig @@ -598,6 +631,8 @@ def _make_provider_with_config(self, recall_mode="tools", init_on_session_start= init_kwargs = {} if user_id: init_kwargs["user_id"] = user_id + if user_id_alt: + init_kwargs["user_id_alt"] = user_id_alt with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ @@ -655,6 +690,15 @@ def test_user_id_used_when_no_peer_name(self): assert cfg.peer_name is None assert mock_manager_cls.call_args.kwargs["runtime_user_peer_name"] == "8439114563" + def test_user_id_alt_is_passed_to_session_manager(self): + """Gateway alternate user IDs are available for Honcho alias matching.""" + _, _, mock_manager_cls = self._make_provider_with_config( + recall_mode="tools", init_on_session_start=True, + peer_name=None, user_id="open-id", user_id_alt="union-id", + ) + assert mock_manager_cls.call_args.kwargs["runtime_user_peer_name"] == "open-id" + assert mock_manager_cls.call_args.kwargs["runtime_user_peer_name_alt"] == "union-id" + class TestPerSessionMigrateGuard: """Verify migrate_memory_files is skipped under per-session strategy. -
tests/run_agent/test_memory_provider_init.py@@ -4,6 +4,27 @@ from unittest.mock import patch +class RecordingMemoryProvider: + name = "recording" + + def __init__(self): + self.init_kwargs = None + self.init_session_id = None + + def is_available(self): + return True + + def initialize(self, session_id, **kwargs): + self.init_session_id = session_id + self.init_kwargs = dict(kwargs) + + def get_tool_schemas(self): + return [] + + def shutdown(self): + pass + + def test_blank_memory_provider_does_not_auto_enable_honcho(): """Blank memory.provider should remain opt-out even if Honcho fallback looks configured.""" cfg = {"memory": {"provider": ""}, "agent": {}} @@ -37,3 +58,35 @@ def test_blank_memory_provider_does_not_auto_enable_honcho(): load_memory_provider.assert_not_called() save_config.assert_not_called() + +def test_aiagent_forwards_user_id_alt_to_memory_provider(): + provider = RecordingMemoryProvider() + cfg = {"memory": {"provider": "recording"}, "agent": {}} + + with ( + patch("hermes_cli.config.load_config", return_value=cfg), + patch("plugins.memory.load_memory_provider", return_value=provider), + patch("agent.model_metadata.get_model_context_length", return_value=204_800), + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + from run_agent import AIAgent + + agent = AIAgent( + api_key="[redacted:secret-assignment]", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=False, + session_id="sess-alt", + platform="feishu", + user_id="open-id", + user_id_alt="union-id", + ) + + assert agent._memory_manager is not None + assert provider.init_session_id == "sess-alt" + assert provider.init_kwargs["user_id"] == "open-id" + assert provider.init_kwargs["user_id_alt"] == "union-id" + assert provider.init_kwargs["platform"] == "feishu" -
tests/test_honcho_session_context.py@@ -0,0 +1,95 @@ +"""Tests for Honcho session context peer resolution.""" + +from types import SimpleNamespace + +from plugins.memory.honcho.session import HonchoSession, HonchoSessionManager + + +class _FakeSummary: + content = "summary" + + +class _FakeContext: + summary = _FakeSummary() + peer_representation = "representation" + peer_card = ["fact"] + messages = [] + + +class _RecordingHonchoSession: + def __init__(self): + self.calls = [] + + def context(self, **kwargs): + self.calls.append(kwargs) + return _FakeContext() + + +def _manager_with_cached_session(*, ai_observe_others=True): + cfg = SimpleNamespace( + write_frequency="turn", + dialectic_reasoning_level="low", + dialectic_dynamic=True, + dialectic_max_chars=600, + observation_mode="directional", + user_observe_me=True, + user_observe_others=True, + ai_observe_me=True, + ai_observe_others=ai_observe_others, + message_max_chars=25000, + dialectic_max_input_chars=10000, + ) + mgr = HonchoSessionManager(honcho=SimpleNamespace(), config=cfg) + session = HonchoSession( + key="test-session", + user_peer_id="chris", + assistant_peer_id="hermes", + honcho_session_id="test-session", + ) + fake_honcho_session = _RecordingHonchoSession() + mgr._cache[session.key] = session + mgr._sessions_cache[session.honcho_session_id] = fake_honcho_session + return mgr, fake_honcho_session + + +def test_session_context_user_alias_uses_assistant_observer_when_ai_can_observe_others(): + mgr, fake = _manager_with_cached_session(ai_observe_others=True) + + result = mgr.get_session_context("test-session", peer="user") + + assert result["summary"] == "summary" + assert fake.calls == [ + { + "summary": True, + "peer_target": "chris", + "peer_perspective": "hermes", + } + ] + + +def test_session_context_explicit_user_peer_matches_user_alias(): + mgr, fake = _manager_with_cached_session(ai_observe_others=True) + + mgr.get_session_context("test-session", peer="chris") + + assert fake.calls == [ + { + "summary": True, + "peer_target": "chris", + "peer_perspective": "hermes", + } + ] + + +def test_session_context_user_alias_uses_user_self_observer_when_ai_cannot_observe_others(): + mgr, fake = _manager_with_cached_session(ai_observe_others=False) + + mgr.get_session_context("test-session", peer="user") + + assert fake.calls == [ + { + "summary": True, + "peer_target": "chris", + "peer_perspective": "chris", + } + ]
-
-
#35170 fix(honcho): harden self-hosted setup paths
files:
gateway/run.py,hermes_cli/main.py,hermes_cli/memory_setup.py,hermes_cli/profiles.py,optional-skills/autonomous-ai-agents/honcho/SKILL.md,plugins/memory/hindsight/__init__.py,plugins/memory/honcho/README.md,plugins/memory/honcho/__init__.py,plugins/memory/honcho/cli.py,plugins/memory/honcho/client.py,plugins/memory/mem0/__init__.py,plugins/memory/supermemory/__init__.py,tests/gateway/test_agent_cache.py,tests/hermes_cli/test_memory_setup_provider_arg.py,tests/hermes_cli/test_profiles.py,tests/honcho_plugin/test_cli.py,tests/honcho_plugin/test_client.py,tests/honcho_plugin/test_pin_peer_name.py,tests/plugins/memory/test_hindsight_provider.py,tests/plugins/memory/test_mem0_v2.py(+5 more)Show the diff
-
gateway/run.py@@ -15331,8 +15331,52 @@ async def _run_process_watcher(self, watcher: dict) -> None: ("compression", "target_ratio"), ("compression", "protect_last_n"), ("agent", "disabled_toolsets"), + ("memory", "provider"), ) + _HONCHO_CACHE_BUSTING_KEYS = ( + "honcho.peer_name", + "honcho.ai_peer", + "honcho.pin_peer_name", + "honcho.runtime_peer_prefix", + "honcho.user_peer_aliases", + ) + _HONCHO_CACHE_BUSTING_MEMO: dict[tuple[str, int | None], dict[str, Any]] = {} + + @classmethod + def _empty_honcho_cache_busting_config(cls) -> dict[str, Any]: + return {key: None for key in cls._HONCHO_CACHE_BUSTING_KEYS} + + @classmethod + def _extract_honcho_cache_busting_config(cls) -> dict[str, Any]: + """Extract Honcho identity keys, memoized by honcho.json mtime.""" + try: + from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path + + path = resolve_config_path() + try: + mtime_ns = path.stat().st_mtime_ns + except OSError: + mtime_ns = None + memo_key = (str(path), mtime_ns) + cached = cls._HONCHO_CACHE_BUSTING_MEMO.get(memo_key) + if cached is not None: + return dict(cached) + + hcfg = HonchoClientConfig.from_global_config(config_path=path) + aliases = hcfg.user_peer_aliases or {} + values = { + "honcho.peer_name": hcfg.peer_name, + "honcho.ai_peer": hcfg.ai_peer, + "honcho.pin_peer_name": bool(hcfg.pin_peer_name), + "honcho.runtime_peer_prefix": hcfg.runtime_peer_prefix or "", + "honcho.user_peer_aliases": sorted(aliases.items()) if isinstance(aliases, dict) else [], + } + cls._HONCHO_CACHE_BUSTING_MEMO = {memo_key: values} + return dict(values) + except Exception: + return cls._empty_honcho_cache_busting_config() + @classmethod def _extract_cache_busting_config(cls, user_config: dict | None) -> dict: """Pull values that must bust the cached agent. @@ -15363,26 +15407,12 @@ def _extract_cache_busting_config(cls, user_config: dict | None) -> dict: out["tools.registry_generation"] = None # Honcho identity-mapping keys live in honcho.json, not user_config. - # HonchoSessionManager freezes the resolved peer_name / ai_peer / - # pin / aliases / prefix at construction; without busting here, - # mid-flight honcho.json edits go unread until the next unrelated - # cache eviction. - try: - from plugins.memory.honcho.client import HonchoClientConfig - - hcfg = HonchoClientConfig.from_global_config() - out["honcho.peer_name"] = hcfg.peer_name - out["honcho.ai_peer"] = hcfg.ai_peer - out["honcho.pin_peer_name"] = bool(hcfg.pin_peer_name) - out["honcho.runtime_peer_prefix"] = hcfg.runtime_peer_prefix or "" - aliases = hcfg.user_peer_aliases or {} - out["honcho.user_peer_aliases"] = sorted(aliases.items()) if isinstance(aliases, dict) else [] - except Exception: - out["honcho.peer_name"] = None - out["honcho.ai_peer"] = None - out["honcho.pin_peer_name"] = None - out["honcho.runtime_peer_prefix"] = None - out["honcho.user_peer_aliases"] = None + # Only read that file when Honcho is the active memory provider. + provider = cfg_get(cfg, "memory", "provider") + if isinstance(provider, str) and provider.lower() == "honcho": + out.update(cls._extract_honcho_cache_busting_config()) + else: + out.update(cls._empty_honcho_cache_busting_config()) return out -
hermes_cli/main.py@@ -13029,9 +13029,15 @@ def cmd_plugins(args): ), ) memory_sub = memory_parser.add_subparsers(dest="memory_command") - memory_sub.add_parser( + _setup_parser = memory_sub.add_parser( "setup", help="Interactive provider selection and configuration" ) + _setup_parser.add_argument( + "provider", + nargs="?", + default=None, + help="Provider to configure directly (e.g. honcho), skipping the picker", + ) memory_sub.add_parser("status", help="Show current memory provider config") memory_sub.add_parser("off", help="Disable external provider (built-in only)") _reset_parser = memory_sub.add_parser( -
hermes_cli/memory_setup.py@@ -452,7 +452,11 @@ def memory_command(args) -> None: """Route memory subcommands.""" sub = getattr(args, "memory_command", None) if sub == "setup": - cmd_setup(args) + provider = getattr(args, "provider", None) + if provider: + cmd_setup_provider(provider) + else: + cmd_setup(args) elif sub == "status": cmd_status(args) else: -
hermes_cli/profiles.py@@ -1471,8 +1471,9 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path: def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) -> None: """Rename Honcho host blocks for a renamed profile without changing peers.""" - old_host = f"hermes.{old_name}" - new_host = f"hermes.{new_name}" + old_host = f"hermes_{old_name}" + legacy_old_host = f"hermes.{old_name}" + new_host = f"hermes_{new_name}" candidates = [ new_dir / "honcho.json", @@ -1496,18 +1497,24 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) -> continue hosts = raw.get("hosts") - if not isinstance(hosts, dict) or old_host not in hosts: + if not isinstance(hosts, dict): + continue + source_host = old_host if old_host in hosts else legacy_old_host + if source_host not in hosts: continue if new_host in hosts: print(f"⚠ Honcho host block not migrated: {new_host} already exists in {path}") continue - block = hosts[old_host] + block = hosts[source_host] if isinstance(block, dict) and "aiPeer" not in block: - bare = old_host.split(".", 1)[1] if "." in old_host else old_host + if source_host.startswith("hermes_"): + bare = source_host.split("_", 1)[1] + else: + bare = source_host.split(".", 1)[1] if "." in source_host else source_host block["aiPeer"] = bare - hosts[new_host] = hosts.pop(old_host) + hosts[new_host] = hosts.pop(source_host) tmp = path.with_suffix(path.suffix + ".tmp") try: tmp.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") @@ -1519,7 +1526,7 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) -> pass continue - print(f"✓ Honcho host updated: {old_host} → {new_host}") + print(f"✓ Honcho host updated: {source_host} → {new_host}") def rename_profile(old_name: str, new_name: str) -> Path: -
optional-skills/autonomous-ai-agents/honcho/SKILL.md@@ -32,14 +32,14 @@ Honcho provides AI-native cross-session user modeling. It learns who the user is ### Cloud (app.honcho.dev) ```bash -hermes honcho setup +hermes memory setup honcho # select "cloud", paste API key from https://app.honcho.dev ``` ### Self-hosted ```bash -hermes honcho setup +hermes memory setup honcho # select "local", enter base URL (e.g. http://localhost:8000) ``` -
plugins/memory/hindsight/__init__.py@@ -633,7 +633,8 @@ def save_config(self, values, hermes_home): except Exception: pass existing.update(values) - config_path.write_text(json.dumps(existing, indent=2)) + from utils import atomic_json_write + atomic_json_write(config_path, existing, mode=0o600) def post_setup(self, hermes_home: str, config: dict) -> None: """Custom setup wizard — installs only the deps needed for the selected mode.""" -
plugins/memory/honcho/README.md@@ -12,8 +12,8 @@ AI-native cross-session user modeling with multi-pass dialectic reasoning, sessi ## Setup ```bash -hermes honcho setup # full interactive wizard (cloud or local) -hermes memory setup # generic picker, also works +hermes memory setup honcho # configure Honcho directly (works on a fresh install) +hermes memory setup # generic picker, choose Honcho from the list ``` Or manually: @@ -22,6 +22,10 @@ hermes config set memory.provider honcho echo "HONCHO_API_KEY=***" >> ~/.hermes/.env ``` +> `hermes honcho setup` also works, but only **after** Honcho is the active +> memory provider — the `honcho` subcommand is registered for the active +> provider only. On a fresh install, use `hermes memory setup honcho`. + ## Architecture Overview ### Two-Layer Context Injection @@ -109,7 +113,7 @@ Config is read from the first file that exists: | 2 | `~/.hermes/honcho.json` | Default profile (shared host blocks) | | 3 | `~/.honcho/config.json` | Global (cross-app interop) | -Host key is derived from the active Hermes profile: `hermes` (default) or `hermes.<profile>`. +Host key is derived from the active Hermes profile: `hermes` (default) or `hermes_<profile>`. For every key, resolution order is: **host block > root > env var > default**. @@ -154,7 +158,7 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a **Host vs root semantics.** All three keys are accepted at both root and `hosts.<host>` levels. Host-level wins. For maps and prefixes, host-level *replaces* the root value as a whole (not merge), so a host can intentionally own its identity universe or wipe it with `userPeerAliases: {}` / `runtimePeerPrefix: ""`. -**Deployment shapes** (`hermes honcho setup` asks one prompt to set these): +**Deployment shapes** (`hermes memory setup honcho` asks one prompt to set these): - **Single-operator** — `pinUserPeer: true`. All gateway users → `peerName`. Recommended for personal use where you connect Hermes to your own Telegram/Discord/etc. - **Multi-user gateway** — `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. Recommended for bots serving many humans. @@ -225,7 +229,7 @@ Multiple Hermes profiles can share one workspace while maintaining separate AI i "recallMode": "hybrid", "sessionStrategy": "per-directory" }, - "hermes.coder": { + "hermes_coder": { "aiPeer": "coder", "recallMode": "tools", "sessionStrategy": "per-repo" @@ -236,7 +240,7 @@ Multiple Hermes profiles can share one workspace while maintaining separate AI i Both profiles see the same user (`yourname`) in the same shared environment (`hermes`), but each AI peer builds its own observations, conclusions, and behavior patterns. The coder's memory stays code-oriented; the main agent's stays broad. -Host key is derived from the active Hermes profile: `hermes` (default) or `hermes.<profile>` (e.g. `hermes -p coder` → host key `hermes.coder`). +Host key is derived from the active Hermes profile: `hermes` (default) or `hermes_<profile>` (e.g. `hermes -p coder` -> host key `hermes_coder`). Older `hermes.<profile>` host blocks are still read for compatibility and are migrated when the CLI writes profile-scoped Honcho config. ### Dialectic & Reasoning @@ -307,7 +311,8 @@ Presets: | Command | Description | |---------|-------------| -| `hermes honcho setup` | Full interactive setup wizard | +| `hermes memory setup honcho` | Configure Honcho directly — works on a fresh install | +| `hermes honcho setup` | Interactive setup wizard (only registered once Honcho is the active provider; redirects to `hermes memory setup`) | | `hermes honcho status` | Show resolved config for active profile | | `hermes honcho enable` / `disable` | Toggle Honcho for active profile | | `hermes honcho mode <mode>` | Change recall or observation mode | @@ -344,7 +349,7 @@ Presets: "dialecticMaxChars": 600, "saveMessages": true }, - "hermes.coder": { + "hermes_coder": { "enabled": true, "aiPeer": "coder", "sessionStrategy": "per-repo", -
plugins/memory/honcho/__init__.py@@ -249,6 +249,7 @@ def is_available(self) -> bool: def save_config(self, values, hermes_home): """Write config to $HERMES_HOME/honcho.json (Honcho SDK native format).""" import json + import os from pathlib import Path config_path = Path(hermes_home) / "honcho.json" existing = {} @@ -258,7 +259,8 @@ def save_config(self, values, hermes_home): except Exception: pass existing.update(values) - config_path.write_text(json.dumps(existing, indent=2)) + from utils import atomic_json_write + atomic_json_write(config_path, existing, mode=0o600) def get_config_schema(self): return [ -
plugins/memory/honcho/cli.py@@ -11,7 +11,7 @@ from pathlib import Path from hermes_constants import get_hermes_home -from plugins.memory.honcho.client import resolve_active_host, resolve_config_path, HOST +from plugins.memory.honcho.client import _host_block, profile_host_key, resolve_active_host, resolve_config_path, HOST from hermes_cli.config import cfg_get @@ -36,7 +36,7 @@ def clone_honcho_for_profile(profile_name: str) -> bool: if not default_block and not has_key: return False - new_host = f"{HOST}.{profile_name}" + new_host = profile_host_key(profile_name) if new_host in hosts: return False # already exists @@ -192,7 +192,7 @@ def cmd_sync(args) -> None: if p.name == "default": continue if clone_honcho_for_profile(p.name): - print(f" + {p.name} -> hermes.{p.name}") + print(f" + {p.name} -> {profile_host_key(p.name)}") created += 1 else: skipped += 1 @@ -243,7 +243,7 @@ def _host_key() -> str: if _profile_override: if _profile_override in {"default", "custom"}: return HOST - return f"{HOST}.{_profile_override}" + return profile_host_key(_profile_override) return resolve_active_host() @@ -275,10 +275,8 @@ def _read_config() -> dict: def _write_config(cfg: dict, path: Path | None = None) -> None: path = path or _local_config_path() path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - json.dumps(cfg, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) + from utils import atomic_json_write + atomic_json_write(path, cfg, mode=0o600) def _resolve_api_key(cfg: dict) -> str: @@ -292,7 +290,7 @@ def _resolve_api_key(cfg: dict) -> str: config shapes, e.g. ``localhost:8000``) still pass — the Honcho SDK will reject them itself with a clearer error than ours. """ - host_key = ((cfg.get("hosts") or {}).get(_host_key()) or {}).get("apiKey") + host_key = _host_block(cfg, _host_key()).get("apiKey") key = host_key or cfg.get("apiKey", "") or os.environ.get("HONCHO_API_KEY", "") if not key: base_url = cfg.get("baseUrl") or cfg.get("base_url") or os.environ.get("HONCHO_BASE_URL", "") @@ -462,21 +460,58 @@ def cmd_setup(args) -> None: cfg.pop("base_url", None) if is_local: - # --- Local: ask for base URL, skip or clear API key --- + # --- Local: ask for base URL, optionally accept a JWT for auth --- current_url = cfg.get("baseUrl") or "" new_url = _prompt("Base URL", default=current_url or "http://localhost:8000") if new_url: cfg["baseUrl"] = new_url - # For local no-auth, the SDK must not send an API key. - # We keep the key in config (for cloud switching later) but - # the client should skip auth when baseUrl is local. - current_key = cfg.get("apiKey", "") - if current_key: - print(f"\n API key present in config (kept for cloud/hybrid use).") - print(" Local connections will skip auth automatically.") + # Self-hosted Honcho can run with AUTH_USE_AUTH=true and an + # AUTH_JWT_SECRET on the server side. In that case clients must + # send a JWT signed with that secret as the bearer token (the + # Honcho SDK takes it via ``api_key=``). Cloud users got prompted + # for a key already; the local path historically skipped this and + # forced users to disable auth on the server. Offer the prompt + # here too. We store it under the host block (not the top-level + # apiKey) so ``get_honcho_client`` recognises it as an explicit + # local auth opt-in (see ``_host_has_key`` in client.py) and + # cloud/hybrid switching is unaffected. + current_host_key = hermes_host.get("apiKey", "") + masked = ( + f"...{current_host_key[-8:]}" + if len(current_host_key) > 8 + else ("set" if current_host_key else "not set") + ) + print( + "\n Local Honcho auth (JWT signed with the server's " + "AUTH_JWT_SECRET)." + ) + print( + " Leave blank if your server runs with AUTH_USE_AUTH=false. " + f"Current: {masked}" + ) + new_local_key = _prompt( + "Local JWT / bearer token (blank to skip / keep current)", + secret=True, + ) + if new_local_key: + hermes_host["apiKey"] = new_local_key + elif current_host_key: + print(" Keeping existing local JWT.") else: - print("\n No API key set. Local no-auth ready.") + # Surface the top-level key situation for transparency. + top_key = cfg.get("apiKey", "") + if top_key: + print( + "\n Top-level API key present in config (kept for " + "cloud/hybrid use)." + ) + print( + " Local connections will skip auth automatically " + "until a local JWT is set above." + ) + else: + print("\n No local JWT set. Local no-auth ready.") else: # --- Cloud: set default base URL, require API key --- cfg.pop("baseUrl", None) # cloud uses SDK default -
plugins/memory/honcho/client.py@@ -32,6 +32,24 @@ HOST = "hermes" +def profile_host_key(profile: str | None) -> str: + """Return the safe Honcho host key for a Hermes profile.""" + if not profile or profile in {"default", "custom"}: + return HOST + sanitized = "".join(c if c.isalnum() or c in "_-" else "_" for c in profile).strip("_") + return f"{HOST}_{sanitized or 'profile'}" + + +def _host_block(raw: dict, host: str) -> dict: + """Return host config, accepting legacy dot-form profile host keys.""" + hosts = raw.get("hosts") or {} + block = hosts.get(host, {}) + if block or not host.startswith(f"{HOST}_"): + return block + legacy = f"{HOST}.{host[len(HOST) + 1:]}" + return hosts.get(legacy, {}) + + def resolve_active_host() -> str: """Derive the Honcho host key from the active Hermes profile. @@ -47,8 +65,7 @@ def resolve_active_host() -> str: try: from hermes_cli.profiles import get_active_profile_name profile = get_active_profile_name() - if profile and profile not in {"default", "custom"}: - return f"{HOST}.{profile}" + return profile_host_key(profile) except Exception: pass return HOST @@ -406,7 +423,7 @@ def from_global_config( logger.warning("Failed to read %s: %s, falling back to env", path, e) return cls.from_env(host=resolved_host) - host_block = (raw.get("hosts") or {}).get(resolved_host, {}) + host_block = _host_block(raw, resolved_host) # A hosts.hermes block or explicit enabled flag means the user # intentionally configured Honcho for this host. _explicitly_configured = bool(host_block) or raw.get("enabled") is True @@ -811,7 +828,10 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho: or "::1" in resolved_base_url ) if _is_local: - # Check if the host block has its own apiKey (explicit local auth) + # Check if the host block has its own apiKey (explicit local auth). + # Auth-skipping is loopback-only: a stored key is likely a cloud key + # that would break a no-auth local server, so we substitute the SDK's + # required-non-empty placeholder unless the host block opts in. _raw = config.raw or {} _host_block = (_raw.get("hosts") or {}).get(config.host, {}) _host_has_key = bool(_host_block.get("apiKey")) @@ -819,6 +839,18 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho: else: effective_api_key = [redacted:secret-assignment] + # The Honcho SDK's route builders (e.g. routes.workspaces()) already + # include the version prefix (e.g. "/v3/workspaces"). When a user-supplied + # base_url already ends in a version segment (e.g. + # "http://localhost:38000/v3", "https://honcho.my.ts.net/v3"), concatenating + # the two produces "/v3/v3/workspaces" → 404 on every call. This is a pure + # routing concern independent of host, so strip a trailing version segment + # from ANY base_url — loopback, LAN, custom domain, or cloud alike. The + # SDK then appends its own versioned paths correctly. + if resolved_base_url: + import re as _re + resolved_base_url = _re.sub(r"/v\d+/*$", "", resolved_base_url).rstrip("/") + kwargs: dict = { "workspace_id": config.workspace_id, "api_key": effective_api_key, -
plugins/memory/mem0/__init__.py@@ -155,7 +155,8 @@ def save_config(self, values, hermes_home): except Exception: pass existing.update(values) - config_path.write_text(json.dumps(existing, indent=2)) + from utils import atomic_json_write + atomic_json_write(config_path, existing, mode=0o600) def get_config_schema(self): return [ -
plugins/memory/supermemory/__init__.py@@ -152,7 +152,8 @@ def _save_supermemory_config(values: dict, hermes_home: str) -> None: except Exception: existing = {} existing.update(values) - config_path.write_text(json.dumps(existing, indent=2, sort_keys=True) + "\n", encoding="utf-8") + from utils import atomic_json_write + atomic_json_write(config_path, existing, mode=0o600, sort_keys=True) def _detect_category(text: str) -> str: -
tests/gateway/test_agent_cache.py@@ -276,6 +276,111 @@ def test_extract_includes_live_tool_registry_generation(self, monkeypatch): assert out["tools.registry_generation"] == 12345 + + def test_skips_honcho_config_read_when_provider_is_not_honcho(self, monkeypatch): + """Non-Honcho gateways must not read/parse honcho.json on every message.""" + from gateway.run import GatewayRunner + + called = False + + def _boom(): + nonlocal called + called = True + raise AssertionError("should not read Honcho config") + + monkeypatch.setattr(GatewayRunner, "_extract_honcho_cache_busting_config", _boom) + + out = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "mem0"}}) + + assert called is False + assert out["honcho.peer_name"] is None + assert out["honcho.user_peer_aliases"] is None + + def test_reads_honcho_config_only_when_provider_is_honcho(self, monkeypatch): + from gateway.run import GatewayRunner + + calls = [] + + def _fake(): + calls.append(True) + return { + "honcho.peer_name": "eri", + "honcho.ai_peer": "hermes", + "honcho.pin_peer_name": True, + "honcho.runtime_peer_prefix": "tg_", + "honcho.user_peer_aliases": [("123", "eri")], + } + + monkeypatch.setattr(GatewayRunner, "_extract_honcho_cache_busting_config", _fake) + + out = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}}) + + assert calls == [True] + assert out["honcho.peer_name"] == "eri" + assert out["honcho.user_peer_aliases"] == [("123", "eri")] + + def test_memory_provider_change_busts_signature(self, monkeypatch): + """Switching memory.provider must itself change the cache-busting + signature, so the agent is rebuilt when a user swaps providers + mid-gateway (independent of the honcho.json identity keys).""" + from gateway.run import GatewayRunner + + # Neutralize honcho.json reads so the only varying input is the + # provider value itself. + monkeypatch.setattr( + GatewayRunner, + "_extract_honcho_cache_busting_config", + classmethod(lambda cls: cls._empty_honcho_cache_busting_config()), + ) + + sig_honcho = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}}) + sig_mem0 = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "mem0"}}) + + assert sig_honcho["memory.provider"] == "honcho" + assert sig_mem0["memory.provider"] == "mem0" + assert sig_honcho != sig_mem0 + + def test_honcho_cache_busting_config_memoized_by_mtime(self, monkeypatch, tmp_path): + """Repeated Honcho extraction for unchanged honcho.json should reuse parse result.""" + from types import SimpleNamespace + from gateway.run import GatewayRunner + + config_path = tmp_path / "honcho.json" + config_path.write_text("{}") + parse_calls = [] + + class FakeConfig: + peer_name = "eri" + ai_peer = "hermes" + pin_peer_name = False + runtime_peer_prefix = "tg_" + user_peer_aliases = {"123": "eri"} + + @classmethod + def from_global_config(cls, config_path=None): + parse_calls.append(config_path) + return cls() + + fake_client = SimpleNamespace( + HonchoClientConfig=FakeConfig, + resolve_config_path=lambda: config_path, + ) + monkeypatch.setitem(__import__("sys").modules, "plugins.memory.honcho.client", fake_client) + monkeypatch.setattr(GatewayRunner, "_HONCHO_CACHE_BUSTING_MEMO", {}) + + first = GatewayRunner._extract_honcho_cache_busting_config() + second = GatewayRunner._extract_honcho_cache_busting_config() + + assert first == second + assert first["honcho.user_peer_aliases"] == [("123", "eri")] + assert parse_calls == [config_path] + + config_path.write_text("{\n \"changed\": true\n}") + third = GatewayRunner._extract_honcho_cache_busting_config() + + assert third == first + assert parse_calls == [config_path, config_path] + def test_full_round_trip_busts_cache_on_real_edit(self): """End-to-end: simulate a config edit on main and verify the extracted cache_keys change produces a new signature.""" -
tests/hermes_cli/test_memory_setup_provider_arg.py@@ -0,0 +1,50 @@ +"""Tests for `hermes memory setup [provider]` routing. + +The `memory setup` subcommand accepts an optional positional ``provider`` so a +fresh install can configure a specific provider directly (e.g. +``hermes memory setup honcho``) without the interactive picker — which matters +because the per-provider ``hermes <provider>`` subcommand is only registered +once that provider is active. +""" + +from types import SimpleNamespace +from unittest.mock import patch + +from hermes_cli import memory_setup + + +class TestMemorySetupProviderRouting: + def test_setup_with_provider_arg_skips_picker(self): + """`memory setup honcho` routes straight to cmd_setup_provider.""" + args = SimpleNamespace(memory_command="setup", provider="honcho") + with patch.object(memory_setup, "cmd_setup_provider") as direct, \ + patch.object(memory_setup, "cmd_setup") as picker: + memory_setup.memory_command(args) + direct.assert_called_once_with("honcho") + picker.assert_not_called() + + def test_setup_without_provider_runs_picker(self): + """`memory setup` (no provider) runs the interactive picker.""" + args = SimpleNamespace(memory_command="setup", provider=None) + with patch.object(memory_setup, "cmd_setup_provider") as direct, \ + patch.object(memory_setup, "cmd_setup") as picker: + memory_setup.memory_command(args) + picker.assert_called_once_with(args) + direct.assert_not_called() + + def test_setup_with_missing_provider_attr_runs_picker(self): + """A SimpleNamespace lacking `provider` must not crash — fall back to picker.""" + args = SimpleNamespace(memory_command="setup") + with patch.object(memory_setup, "cmd_setup_provider") as direct, \ + patch.object(memory_setup, "cmd_setup") as picker: + memory_setup.memory_command(args) + picker.assert_called_once_with(args) + direct.assert_not_called() + + def test_unknown_provider_reports_and_returns_early(self, capsys): + """An unknown provider name surfaces a helpful message and returns + before any config load/save (the not-found guard precedes those imports).""" + memory_setup.cmd_setup_provider("notaprovider") + out = capsys.readouterr().out + assert "not found" in out + assert "hermes memory setup" in out -
tests/hermes_cli/test_profiles.py@@ -754,8 +754,8 @@ def test_renames_root_honcho_host_without_changing_ai_peer(self, profile_env): cfg = json.loads(honcho_path.read_text()) assert "hermes.ssi_health" not in cfg["hosts"] - assert cfg["hosts"]["hermes.heimdall"]["aiPeer"] == "ssi_health" - assert cfg["hosts"]["hermes.heimdall"]["peerName"] == "user-peer" + assert cfg["hosts"]["hermes_heimdall"]["aiPeer"] == "ssi_health" + assert cfg["hosts"]["hermes_heimdall"]["peerName"] == "user-peer" def test_pins_ai_peer_when_absent_on_honcho_host_rename(self, profile_env): tmp_path = profile_env @@ -772,8 +772,8 @@ def test_pins_ai_peer_when_absent_on_honcho_host_rename(self, profile_env): cfg = json.loads(honcho_path.read_text()) assert "hermes.ssi_health" not in cfg["hosts"] - assert cfg["hosts"]["hermes.heimdall"]["aiPeer"] == "ssi_health" - assert cfg["hosts"]["hermes.heimdall"]["workspace"] == "hermes" + assert cfg["hosts"]["hermes_heimdall"]["aiPeer"] == "ssi_health" + assert cfg["hosts"]["hermes_heimdall"]["workspace"] == "hermes" def test_does_not_overwrite_existing_honcho_host_on_rename(self, profile_env): tmp_path = profile_env @@ -782,7 +782,7 @@ def test_does_not_overwrite_existing_honcho_host_on_rename(self, profile_env): honcho_path.write_text(json.dumps({ "hosts": { "hermes.ssi_health": {"aiPeer": "ssi_health"}, - "hermes.heimdall": {"aiPeer": "heimdall"}, + "hermes_heimdall": {"aiPeer": "heimdall"}, } })) @@ -791,7 +791,7 @@ def test_does_not_overwrite_existing_honcho_host_on_rename(self, profile_env): cfg = json.loads(honcho_path.read_text()) assert cfg["hosts"]["hermes.ssi_health"]["aiPeer"] == "ssi_health" - assert cfg["hosts"]["hermes.heimdall"]["aiPeer"] == "heimdall" + assert cfg["hosts"]["hermes_heimdall"]["aiPeer"] == "heimdall" def test_default_raises_value_error(self, profile_env): with pytest.raises(ValueError, match="default"): -
tests/honcho_plugin/test_cli.py@@ -1,6 +1,7 @@ """Tests for plugins/memory/honcho/cli.py.""" from types import SimpleNamespace +import json class TestResolveApiKey: @@ -100,6 +101,84 @@ def test_accepts_legacy_schemeless_host(self, monkeypatch): f"expected local sentinel for legacy schemeless {legacy!r}" +class TestCmdSetupLocalJwt: + """Local-deployment setup must allow configuring a JWT for AUTH_JWT_SECRET-backed Honcho servers.""" + + def _run_setup(self, monkeypatch, tmp_path, initial_cfg, prompt_answers): + import plugins.memory.honcho.cli as honcho_cli + + # Avoid touching real config / SDK / filesystem. + cfg_path = tmp_path / "honcho.json" + monkeypatch.setattr(honcho_cli, "_read_config", lambda: dict(initial_cfg)) + monkeypatch.setattr(honcho_cli, "_local_config_path", lambda: cfg_path) + monkeypatch.setattr(honcho_cli, "_config_path", lambda: cfg_path) + monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes") + monkeypatch.setattr(honcho_cli, "_ensure_sdk_installed", lambda: True) + + written = {} + + def _capture_write(cfg, path=None): + written["cfg"] = cfg + written["path"] = path + + monkeypatch.setattr(honcho_cli, "_write_config", _capture_write) + + # Feed scripted prompt answers in order. + answers = list(prompt_answers) + + def _fake_prompt(label, default=None, secret=[redacted:secret-assignment] + if not answers: + # Default-through any remaining prompts to keep the wizard moving. + return default or "" + return answers.pop(0) + + monkeypatch.setattr(honcho_cli, "_prompt", _fake_prompt) + + honcho_cli.cmd_setup(SimpleNamespace()) + return written.get("cfg") + + def test_local_setup_stores_jwt_under_host_block(self, monkeypatch, tmp_path): + """Self-hosted users supplying a JWT must have it written under hosts.<host>.apiKey, + not as the top-level cloud apiKey, so cloud/hybrid switching is preserved and + get_honcho_client treats it as an explicit local auth opt-in.""" + cfg = self._run_setup( + monkeypatch, + tmp_path, + initial_cfg={}, + prompt_answers=[ + "local", # deployment + "http://localhost:8000", # base URL + "my-local-jwt-token", # local JWT + ], + ) + assert cfg is not None + assert cfg.get("baseUrl") == "http://localhost:8000" + # Top-level apiKey must remain unset (cloud field). + assert not cfg.get("apiKey") + # The new local JWT belongs under the host block. + host_block = (cfg.get("hosts") or {}).get("hermes") or {} + assert host_block.get("apiKey") == "my-local-jwt-token" + + def test_local_setup_blank_jwt_keeps_local_no_auth(self, monkeypatch, tmp_path): + """Blank JWT prompt response on a fresh local config must not introduce an apiKey + anywhere (local no-auth Honcho deployments must still work out of the box).""" + cfg = self._run_setup( + monkeypatch, + tmp_path, + initial_cfg={}, + prompt_answers=[ + "local", + "http://localhost:8000", + "", # blank JWT + ], + ) + assert cfg is not None + assert cfg.get("baseUrl") == "http://localhost:8000" + assert not cfg.get("apiKey") + host_block = (cfg.get("hosts") or {}).get("hermes") or {} + assert not host_block.get("apiKey") + + class TestCmdStatus: def test_reports_connection_failure_when_session_setup_fails(self, monkeypatch, capsys, tmp_path): import plugins.memory.honcho.cli as honcho_cli @@ -192,7 +271,7 @@ def test_user_peer_aliases_carry_into_cloned_profile(self, monkeypatch, tmp_path honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg) ok = honcho_cli.clone_honcho_for_profile("coder") assert ok is True - new_block = written["cfg"]["hosts"]["hermes.coder"] + new_block = written["cfg"]["hosts"]["hermes_coder"] assert new_block["userPeerAliases"] == {"86701400": "eri", "discord-491827364": "eri"} def test_runtime_peer_prefix_carries_into_cloned_profile(self, monkeypatch, tmp_path): @@ -208,7 +287,7 @@ def test_runtime_peer_prefix_carries_into_cloned_profile(self, monkeypatch, tmp_ honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg) ok = honcho_cli.clone_honcho_for_profile("coder") assert ok is True - new_block = written["cfg"]["hosts"]["hermes.coder"] + new_block = written["cfg"]["hosts"]["hermes_coder"] assert new_block["runtimePeerPrefix"] == "telegram_" def test_pin_peer_name_carries_into_cloned_profile(self, monkeypatch, tmp_path): @@ -224,7 +303,7 @@ def test_pin_peer_name_carries_into_cloned_profile(self, monkeypatch, tmp_path): honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg) ok = honcho_cli.clone_honcho_for_profile("coder") assert ok is True - new_block = written["cfg"]["hosts"]["hermes.coder"] + new_block = written["cfg"]["hosts"]["hermes_coder"] assert new_block["pinPeerName"] is True def test_unset_identity_keys_do_not_appear_in_cloned_profile(self, monkeypatch, tmp_path): @@ -235,7 +314,7 @@ def test_unset_identity_keys_do_not_appear_in_cloned_profile(self, monkeypatch, honcho_cli, written = self._setup_clone_env(monkeypatch, tmp_path, cfg) ok = honcho_cli.clone_honcho_for_profile("coder") assert ok is True - new_block = written["cfg"]["hosts"]["hermes.coder"] + new_block = written["cfg"]["hosts"]["hermes_coder"] assert "userPeerAliases" not in new_block assert "runtimePeerPrefix" not in new_block assert "pinPeerName" not in new_block @@ -572,5 +651,5 @@ def test_clone_inherits_host_pin_user_peer(self, monkeypatch, tmp_path): ok = honcho_cli.clone_honcho_for_profile("partner") assert ok is True - new_block = written["cfg"]["hosts"]["hermes.partner"] + new_block = written["cfg"]["hosts"]["hermes_partner"] assert new_block["pinUserPeer"] is True -
tests/honcho_plugin/test_client.py@@ -13,6 +13,7 @@ from plugins.memory.honcho.client import ( HonchoClientConfig, get_honcho_client, + profile_host_key, reset_honcho_client, resolve_active_host, resolve_config_path, @@ -430,6 +431,10 @@ def test_from_global_config_uses_local_path(self, tmp_path): class TestResolveActiveHost: + def test_profile_host_key_uses_honcho_safe_separator(self): + assert profile_host_key("coder") == "hermes_coder" + assert profile_host_key("default") == "hermes" + def test_default_returns_hermes(self): with patch.dict(os.environ, {}, clear=True): os.environ.pop("HERMES_HONCHO_HOST", None) @@ -444,7 +449,7 @@ def test_profile_name_derives_host(self): with patch.dict(os.environ, {}, clear=False): os.environ.pop("HERMES_HONCHO_HOST", None) with patch("hermes_cli.profiles.get_active_profile_name", return_value="coder"): - assert resolve_active_host() == "hermes.coder" + assert resolve_active_host() == "hermes_coder" def test_default_profile_returns_hermes(self): with patch.dict(os.environ, {}, clear=False): @@ -477,10 +482,10 @@ def test_profiles_import_failure_falls_back(self): class TestProfileScopedConfig: def test_from_env_uses_profile_host(self): with patch.dict(os.environ, {"HONCHO_API_KEY": "key"}): - config = HonchoClientConfig.from_env(host="hermes.coder") - assert config.host == "hermes.coder" + config = HonchoClientConfig.from_env(host="hermes_coder") + assert config.host == "hermes_coder" assert config.workspace_id == "hermes" # shared workspace - assert config.ai_peer == "hermes.coder" + assert config.ai_peer == "hermes_coder" def test_from_env_default_workspace_preserved_for_default_host(self): with patch.dict(os.environ, {"HONCHO_API_KEY": "key"}): @@ -494,19 +499,19 @@ def test_from_global_config_reads_profile_host_block(self, tmp_path): "apiKey": "shared-key", "hosts": { "hermes": {"aiPeer": "hermes", "peerName": "alice"}, - "hermes.coder": { - "aiPeer": "hermes.coder", + "hermes_coder": { + "aiPeer": "hermes_coder", "peerName": "alice-coder", "workspace": "coder-ws", }, }, })) config = HonchoClientConfig.from_global_config( - host="hermes.coder", config_path=config_file, + host="hermes_coder", config_path=config_file, ) - assert config.host == "hermes.coder" + assert config.host == "hermes_coder" assert config.workspace_id == "coder-ws" - assert config.ai_peer == "hermes.coder" + assert config.ai_peer == "hermes_coder" assert config.peer_name == "alice-coder" def test_from_global_config_auto_resolves_host(self, tmp_path): @@ -514,14 +519,30 @@ def test_from_global_config_auto_resolves_host(self, tmp_path): config_file.write_text(json.dumps({ "apiKey": "key", "hosts": { - "hermes.dreamer": {"peerName": "dreamer-user"}, + "hermes_dreamer": {"peerName": "dreamer-user"}, }, })) - with patch("plugins.memory.honcho.client.resolve_active_host", return_value="hermes.dreamer"): + with patch("plugins.memory.honcho.client.resolve_active_host", return_value="hermes_dreamer"): config = HonchoClientConfig.from_global_config(config_path=config_file) - assert config.host == "hermes.dreamer" + assert config.host == "hermes_dreamer" assert config.peer_name == "dreamer-user" + def test_from_global_config_reads_legacy_dot_profile_host_block(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({ + "apiKey": "key", + "hosts": { + "hermes.dreamer": {"peerName": "dreamer-user"}, + }, + })) + config = HonchoClientConfig.from_global_config( + host="hermes_dreamer", + config_path=config_file, + ) + assert config.host == "hermes_dreamer" + assert config.peer_name == "dreamer-user" + assert config.workspace_id == "hermes_dreamer" + class TestObservationModeMigration: """Existing configs without explicit observationMode keep 'unified' default.""" @@ -890,3 +911,176 @@ def test_depth_levels_invalid_values_default_to_low(self, tmp_path): })) config = HonchoClientConfig.from_global_config(config_path=config_file) assert config.dialectic_depth_levels == ["low", "high"] + + +class TestGetHonchoClientBaseUrlDoublePrefixFix: + """Regression tests for #20688 — Honcho SDK double-prefixing of /v3 for + self-hosted instances where base_url already contains a version path.""" + + def teardown_method(self): + reset_honcho_client() + + @pytest.mark.skipif( + not importlib.util.find_spec("honcho"), + reason="honcho SDK not installed" + ) + def test_local_base_url_with_v3_suffix_stripped(self): + """base_url 'http://localhost:38000/v3' must become 'http://localhost:38000' + before passing to the Honcho SDK to avoid double '/v3/v3' prefixing.""" + fake_honcho = MagicMock(name="Honcho") + cfg = HonchoClientConfig( + api_key=None, + base_url="http://localhost:38000/v3", + workspace_id="hermes", + environment="production", + ) + + with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \ + patch("hermes_cli.config.load_config", return_value={}): + get_honcho_client(cfg) + + mock_honcho.assert_called_once() + passed_base_url = mock_honcho.call_args.kwargs.get("base_url") + assert passed_base_url == "http://localhost:38000", ( + f"Expected 'http://localhost:38000', got {passed_base_url!r}" + ) + + @pytest.mark.skipif( + not importlib.util.find_spec("honcho"), + reason="honcho SDK not installed" + ) + def test_local_base_url_without_version_unchanged(self): + """base_url 'http://localhost:38000' (no version) must be passed unchanged.""" + fake_honcho = MagicMock(name="Honcho") + cfg = HonchoClientConfig( + api_key=None, + base_url="http://localhost:38000", + workspace_id="hermes", + environment="production", + ) + + with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \ + patch("hermes_cli.config.load_config", return_value={}): + get_honcho_client(cfg) + + mock_honcho.assert_called_once() + passed_base_url = mock_honcho.call_args.kwargs.get("base_url") + assert passed_base_url == "http://localhost:38000", ( + f"Expected 'http://localhost:38000', got {passed_base_url!r}" + ) + + @pytest.mark.skipif( + not importlib.util.find_spec("honcho"), + reason="honcho SDK not installed" + ) + def test_cloud_base_url_without_version_unchanged(self): + """A cloud base_url with no version segment must pass through untouched.""" + fake_honcho = MagicMock(name="Honcho") + cfg = HonchoClientConfig( + api_key="[redacted:secret-assignment]", + base_url="https://api.honcho.dev", + workspace_id="hermes", + environment="production", + ) + + with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \ + patch("hermes_cli.config.load_config", return_value={}): + get_honcho_client(cfg) + + mock_honcho.assert_called_once() + passed_base_url = mock_honcho.call_args.kwargs.get("base_url") + assert passed_base_url == "https://api.honcho.dev", ( + f"Expected 'https://api.honcho.dev', got {passed_base_url!r}" + ) + + @pytest.mark.skipif( + not importlib.util.find_spec("honcho"), + reason="honcho SDK not installed" + ) + def test_cloud_base_url_with_version_stripped(self): + """A version segment double-prefixes regardless of host, so a cloud + base_url that ends in '/v3' must also be stripped (the SDK re-adds it).""" + fake_honcho = MagicMock(name="Honcho") + cfg = HonchoClientConfig( + api_key="[redacted:secret-assignment]", + base_url="https://api.honcho.dev/v3", + workspace_id="hermes", + environment="production", + ) + + with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \ + patch("hermes_cli.config.load_config", return_value={}): + get_honcho_client(cfg) + + mock_honcho.assert_called_once() + passed_base_url = mock_honcho.call_args.kwargs.get("base_url") + assert passed_base_url == "https://api.honcho.dev", ( + f"Expected 'https://api.honcho.dev', got {passed_base_url!r}" + ) + + @pytest.mark.skipif( + not importlib.util.find_spec("honcho"), + reason="honcho SDK not installed" + ) + @pytest.mark.parametrize( + "raw_url, expected", + [ + # LAN IP self-host + ("http://10.0.0.5:8000/v3", "http://10.0.0.5:8000"), + ("http://192.168.1.20:38000/v3/", "http://192.168.1.20:38000"), + # Tailscale / custom-domain self-host + ("https://honcho.my.ts.net/v3", "https://honcho.my.ts.net"), + ("https://honcho.lab.internal/v3", "https://honcho.lab.internal"), + ("https://honcho.fly.dev/v3", "https://honcho.fly.dev"), + # higher version segments are also stripped + ("https://honcho.lab.internal/v12", "https://honcho.lab.internal"), + # self-host without a version segment is left unchanged + ("https://honcho.my.ts.net", "https://honcho.my.ts.net"), + ("http://10.0.0.5:8000", "http://10.0.0.5:8000"), + ], + ) + def test_self_hosted_base_url_version_stripped(self, raw_url, expected): + """Non-loopback self-hosted instances (LAN IPs, Tailscale, custom + domains) must get the same version-segment stripping as localhost. + Regression for #20688 recurring on any non-loopback self-host.""" + fake_honcho = MagicMock(name="Honcho") + cfg = HonchoClientConfig( + api_key="[redacted:secret-assignment]", + base_url=raw_url, + workspace_id="hermes", + environment="production", + ) + + with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \ + patch("hermes_cli.config.load_config", return_value={}): + get_honcho_client(cfg) + + mock_honcho.assert_called_once() + passed_base_url = mock_honcho.call_args.kwargs.get("base_url") + assert passed_base_url == expected, ( + f"Expected {expected!r}, got {passed_base_url!r}" + ) + + @pytest.mark.skipif( + not importlib.util.find_spec("honcho"), + reason="honcho SDK not installed" + ) + def test_local_base_url_with_trailing_slash_stripped(self): + """base_url 'http://127.0.0.1:38000/v3/' must also be cleaned up.""" + fake_honcho = MagicMock(name="Honcho") + cfg = HonchoClientConfig( + api_key=None, + base_url="http://127.0.0.1:38000/v3/", + workspace_id="hermes", + environment="production", + ) + + with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \ + patch("hermes_cli.config.load_config", return_value={}): + get_honcho_client(cfg) + + mock_honcho.assert_called_once() + passed_base_url = mock_honcho.call_args.kwargs.get("base_url") + assert passed_base_url == "http://127.0.0.1:38000", ( + f"Expected 'http://127.0.0.1:38000', got {passed_base_url!r}" + ) -
tests/honcho_plugin/test_pin_peer_name.py@@ -745,10 +745,10 @@ def test_cache_busting_signature_reflects_pin_peer_name(self, tmp_path, monkeypa monkeypatch.setenv("HERMES_HOME", str(tmp_path)) cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor", "pinPeerName": True})) - sig_pinned = GatewayRunner._extract_cache_busting_config({}) + sig_pinned = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}}) cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor", "pinPeerName": False})) - sig_unpinned = GatewayRunner._extract_cache_busting_config({}) + sig_unpinned = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}}) assert sig_pinned["honcho.pin_peer_name"] != sig_unpinned["honcho.pin_peer_name"] @@ -759,14 +759,14 @@ def test_cache_busting_signature_reflects_user_peer_aliases(self, tmp_path, monk monkeypatch.setenv("HERMES_HOME", str(tmp_path)) cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor"})) - sig_no_aliases = GatewayRunner._extract_cache_busting_config({}) + sig_no_aliases = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}}) cfg_path.write_text(json.dumps({ "apiKey": "k", "peerName": "Igor", "userPeerAliases": {"86701400": "Igor"}, })) - sig_with_aliases = GatewayRunner._extract_cache_busting_config({}) + sig_with_aliases = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}}) assert sig_no_aliases["honcho.user_peer_aliases"] != sig_with_aliases["honcho.user_peer_aliases"] @@ -777,14 +777,14 @@ def test_cache_busting_signature_reflects_runtime_peer_prefix(self, tmp_path, mo monkeypatch.setenv("HERMES_HOME", str(tmp_path)) cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor"})) - sig_no_prefix = GatewayRunner._extract_cache_busting_config({}) + sig_no_prefix = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}}) cfg_path.write_text(json.dumps({ "apiKey": "k", "peerName": "Igor", "runtimePeerPrefix": "telegram_", })) - sig_with_prefix = GatewayRunner._extract_cache_busting_config({}) + sig_with_prefix = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}}) assert sig_no_prefix["honcho.runtime_peer_prefix"] != sig_with_prefix["honcho.runtime_peer_prefix"] @@ -805,14 +805,14 @@ def test_cache_busting_signature_reflects_ai_peer(self, tmp_path, monkeypatch): "peerName": "Igor", "aiPeer": "hermes", })) - sig_before = GatewayRunner._extract_cache_busting_config({}) + sig_before = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}}) cfg_path.write_text(json.dumps({ "apiKey": "k", "peerName": "Igor", "aiPeer": "hermetika", })) - sig_after = GatewayRunner._extract_cache_busting_config({}) + sig_after = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}}) assert sig_before["honcho.ai_peer"] != sig_after["honcho.ai_peer"] -
tests/plugins/memory/test_hindsight_provider.py@@ -6,7 +6,9 @@ """ import json +import os import re +import stat import sys from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock @@ -1570,3 +1572,13 @@ def test_local_embedded_shutdown_closes_inner_async_client_on_shared_loop(self, assert embedded._client is None assert provider._client is None + [redacted:email](os.name == "nt", reason="POSIX mode bits not enforced on Windows") +def test_save_config_sets_owner_only_permissions(tmp_path): + """hindsight/config.json must be written with 0o600 so API key is not world-readable.""" + provider = HindsightMemoryProvider() + provider.save_config({"api_key": "hd-test-key"}, str(tmp_path)) + config_file = tmp_path / "hindsight" / "config.json" + assert config_file.exists() + mode = stat.S_IMODE(config_file.stat().st_mode) + assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}" -
tests/plugins/memory/test_mem0_v2.py@@ -4,6 +4,10 @@ """ import json +import os +import stat + +import pytest from plugins.memory.mem0 import Mem0MemoryProvider @@ -202,6 +206,17 @@ def test_prefetch_dict_response(self, monkeypatch): # --------------------------------------------------------------------------- [redacted:email](os.name == "nt", reason="POSIX mode bits not enforced on Windows") +def test_save_config_sets_owner_only_permissions(tmp_path): + """mem0.json must be written with 0o600 so API key is not world-readable.""" + provider = Mem0MemoryProvider() + provider.save_config({"api_key": "m0-test-key"}, str(tmp_path)) + config_file = tmp_path / "mem0.json" + assert config_file.exists() + mode = stat.S_IMODE(config_file.stat().st_mode) + assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}" + + class TestMem0Defaults: """Ensure we don't break existing users' defaults.""" -
tests/plugins/memory/test_supermemory_provider.py@@ -1,4 +1,6 @@ import json +import os +import stat import threading import pytest @@ -409,3 +411,13 @@ def test_get_config_schema_minimal(): assert len(schema) == 1 assert schema[0]["key"] == "api_key" assert schema[0]["secret"] is True + + [redacted:email](os.name == "nt", reason="POSIX mode bits not enforced on Windows") +def test_save_config_sets_owner_only_permissions(tmp_path): + """supermemory.json must be written with 0o600 so API key is not world-readable.""" + _save_supermemory_config({"api_key": "sm-test-key"}, str(tmp_path)) + config_file = tmp_path / "supermemory.json" + assert config_file.exists() + mode = stat.S_IMODE(config_file.stat().st_mode) + assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}" -
tests/test_honcho_client_config.py@@ -2,9 +2,13 @@ import json import os +import stat +from pathlib import Path +import pytest from plugins.memory.honcho.client import HonchoClientConfig +from plugins.memory.honcho import HonchoMemoryProvider class TestHonchoClientConfigAutoEnable: @@ -100,3 +104,24 @@ def test_falls_back_to_env_when_no_config_file(self, tmp_path, monkeypatch): assert cfg.api_key == "fallback-key" assert cfg.enabled is True # from_env() sets enabled=True + + [redacted:email](os.name == "nt", reason="POSIX mode bits not enforced on Windows") +def test_save_config_sets_owner_only_permissions(tmp_path, monkeypatch): + """honcho.json is created atomically with 0o600, not chmod-after-write.""" + import utils + calls = [] + real_atomic = utils.atomic_json_write + + def spy(path, data, **kwargs): + calls.append(kwargs.get("mode")) + return real_atomic(path, data, **kwargs) + + monkeypatch.setattr(utils, "atomic_json_write", spy) + provider = HonchoMemoryProvider() + provider.save_config({"api_key": "hc-test-key"}, str(tmp_path)) + assert calls == [0o600] + config_file = tmp_path / "honcho.json" + assert config_file.exists() + mode = stat.S_IMODE(config_file.stat().st_mode) + assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}" -
utils.py@@ -87,6 +87,7 @@ def atomic_json_write( data: Any, *, indent: int = 2, + mode: int | None = None, **dump_kwargs: Any, ) -> None: """Write JSON data to a file atomically. @@ -99,13 +100,16 @@ def atomic_json_write( path: Target file path (will be created or overwritten). data: JSON-serializable data to write. indent: JSON indentation (default 2). + mode: Optional final permission mode. When set, the temp file is + created and replaced with this mode, avoiding chmod-after-write + TOCTOU exposure for secret-bearing files. **dump_kwargs: Additional keyword args forwarded to json.dump(), such as default=str for non-native types. """ path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) - original_mode = _preserve_file_mode(path) + original_mode = None if mode is not None else _preserve_file_mode(path) fd, tmp_path = tempfile.mkstemp( dir=str(path.parent), @@ -113,6 +117,8 @@ def atomic_json_write( suffix=".tmp", ) try: + if mode is not None: + os.fchmod(fd, mode) with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump( data, @@ -125,7 +131,13 @@ def atomic_json_write( os.fsync(f.fileno()) # Preserve symlinks — swap in-place on the real file (GitHub #16743). real_path = atomic_replace(tmp_path, path) - _restore_file_mode(real_path, original_mode) + if mode is not None: + try: + os.chmod(real_path, mode) + except OSError: + pass + else: + _restore_file_mode(Path(real_path), original_mode) except BaseException: # Intentionally catch BaseException so temp-file cleanup still runs for # KeyboardInterrupt/SystemExit before re-raising the original signal. -
website/docs/user-guide/features/honcho.md@@ -106,6 +106,10 @@ The auto-injected dialectic scales `dialecticReasoningLevel` by query length: +1 Honcho is configured in `~/.honcho/config.json` (global) or `$HERMES_HOME/honcho.json` (profile-local). The setup wizard handles this for you. +### Self-Hosted Honcho with Authentication + +When pointing Hermes at a self-hosted Honcho server, `hermes honcho setup` (and `hermes memory setup`) ask for a **local JWT / bearer token** after the base URL. Paste a JWT signed with the server's `AUTH_JWT_SECRET` (the Honcho compose env var) to enable authenticated access; leave it blank for servers running with `AUTH_USE_AUTH=false`. The local token is stored under the host block (`hosts.<host>.apiKey` in `honcho.json`), separate from any cloud `apiKey`, so you can flip the `Cloud or local?` prompt back to `cloud` later without losing either credential. + ### Full Config Reference | Key | Default | Description | @@ -199,11 +203,12 @@ When Honcho is active as the memory provider, five tools become available: ## CLI Commands -The `hermes honcho` subcommand is **only registered when Honcho is the active memory provider** (`memory.provider: honcho` in `config.yaml`). Run `hermes memory setup` and pick Honcho first; the subcommand appears on the next invocation. +The `hermes honcho` subcommand is **only registered when Honcho is the active memory provider** (`memory.provider: honcho` in `config.yaml`). On a fresh install, configure Honcho directly with `hermes memory setup honcho` (or run `hermes memory setup` and pick it from the list); the `hermes honcho` subcommand then appears on the next invocation. ```bash +hermes memory setup honcho # Configure Honcho directly (works before activation) hermes honcho status # Connection status, config, and key settings -hermes honcho setup # Redirects to `hermes memory setup` +hermes honcho setup # Redirects to `hermes memory setup` (post-activation alias) hermes honcho strategy # Show or set session strategy (per-session/per-directory/per-repo/global) hermes honcho peer # Show or update peer names + dialectic reasoning level hermes honcho mode # Show or set recall mode (hybrid/context/tools) -
website/docs/user-guide/features/memory-providers.md@@ -66,7 +66,7 @@ AI-native cross-session user modeling with dialectic reasoning, session-scoped c hermes memory setup # select "honcho" — runs the Honcho-specific post-setup ``` -The legacy `hermes honcho setup` command still works (it now redirects to `hermes memory setup`), but is only registered after Honcho is selected as the active memory provider. +On a fresh install, configure Honcho directly with `hermes memory setup honcho`. The legacy `hermes honcho setup` command still works (it now redirects to `hermes memory setup`), but is only registered after Honcho is selected as the active memory provider. **Config:** `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.json` (global). Resolution order: `$HERMES_HOME/honcho.json` > `~/.hermes/honcho.json` > `~/.honcho/config.json`. See the [config reference](https://github.com/NousResearch/hermes-agent/blob/main/plugins/memory/honcho/README.md) and the [Honcho integration guide](https://docs.honcho.dev/v3/guides/integrations/hermes).
-
-
#107547 feat(honcho): each turn is written under its author's peer; bot DMs get their own session (salvage #103890)
files:
agent/turn_author.py,gateway/run.py,gateway/run_agent_cache.py,plugins/memory/honcho/README.md,plugins/memory/honcho/__init__.py,plugins/memory/honcho/client.py,plugins/memory/honcho/config_schema.py,plugins/memory/honcho/session.py,plugins/memory/honcho/session_peers.py,tests/honcho_plugin/test_a2a_sessions.py,tests/honcho_plugin/test_identity_signature.py,tests/honcho_plugin/test_pin_peer_name.py,tests/honcho_plugin/test_turn_author_peers.py,tests/plugins/memory/test_honcho_config_schema.py,tests/test_honcho_startup_fail_open.py,website/docs/user-guide/features/honcho.md
2 PRs closed without merging
From the close comments: done elsewhere 1 · merged via another change 1
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.26: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab