diff --git a/gateway/run.py b/gateway/run.py index 4df123ff042..bb26662fabb 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -15071,6 +15071,26 @@ class GatewayRunner: 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 / 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.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.pin_peer_name"] = None + out["honcho.runtime_peer_prefix"] = None + out["honcho.user_peer_aliases"] = None + return out @staticmethod diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index e24e125ac36..51152581956 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -160,6 +160,8 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a - **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 | diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 61b52c309e5..8dfea1169db 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -465,6 +465,25 @@ def cmd_setup(args) -> None: 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" + if new_shape == "single": hermes_host["pinPeerName"] = True hermes_host.pop("userPeerAliases", None) diff --git a/tests/honcho_plugin/test_cli.py b/tests/honcho_plugin/test_cli.py index 073efe4eda2..b97cdcba6c1 100644 --- a/tests/honcho_plugin/test_cli.py +++ b/tests/honcho_plugin/test_cli.py @@ -386,4 +386,50 @@ class TestSetupWizardDeploymentShape: 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_" \ No newline at end of file + 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 + assert host["userPeerAliases"] == {} + assert host["runtimePeerPrefix"] == "telegram_" diff --git a/tests/honcho_plugin/test_pin_peer_name.py b/tests/honcho_plugin/test_pin_peer_name.py index e1ef5fda082..858836b29f0 100644 --- a/tests/honcho_plugin/test_pin_peer_name.py +++ b/tests/honcho_plugin/test_pin_peer_name.py @@ -681,3 +681,189 @@ class TestPinUserPeerAlias: })) 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"] + + +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