From 122e3e34de5343a8c2c50ee3487d1bc1fb107b73 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Mon, 20 Apr 2026 23:49:02 +0000 Subject: [PATCH 1/2] fix(honcho): prefer configured peer name over runtime user id --- plugins/memory/honcho/session.py | 20 ++++-- tests/honcho_plugin/test_session.py | 94 +++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 4 deletions(-) diff --git a/plugins/memory/honcho/session.py b/plugins/memory/honcho/session.py index 79625b5cd5800..a77a8052646da 100644 --- a/plugins/memory/honcho/session.py +++ b/plugins/memory/honcho/session.py @@ -277,11 +277,23 @@ def get_or_create(self, key: str) -> HonchoSession: logger.debug("Local session cache hit: %s", key) return self._cache[key] - # Gateway sessions should use the runtime user identity when available. - if self._runtime_user_peer_name: - user_peer_id = self._sanitize_id(self._runtime_user_peer_name) - elif self._config and self._config.peer_name: + # Peer resolution precedence: + # 1. config.peer_name — operator explicitly declared "who I am". All + # transports (CLI, Telegram, Discord, Slack, cron, ...) collapse to + # this single peer. This is the single-operator case. + # 2. runtime_user_peer_name — gateway-supplied platform user_id. Used + # only when peer_name is NOT configured, i.e. multi-user bot mode + # where each platform user gets their own peer. + # 3. fallback — derive from session key. + # + # Inverted from the original order so that setting peer_name in honcho + # config actually takes effect across gateways (prior behavior: gateway + # user_id would silently shadow peer_name, fragmenting one human into + # one peer per transport). + if self._config and self._config.peer_name: user_peer_id = self._sanitize_id(self._config.peer_name) + elif self._runtime_user_peer_name: + user_peer_id = self._sanitize_id(self._runtime_user_peer_name) else: # Fallback: derive from session key parts = key.split(":", 1) diff --git a/tests/honcho_plugin/test_session.py b/tests/honcho_plugin/test_session.py index 2542611831205..5205cb4ea8166 100644 --- a/tests/honcho_plugin/test_session.py +++ b/tests/honcho_plugin/test_session.py @@ -191,6 +191,100 @@ def test_list_sessions(self): assert s1_info["message_count"] == 1 +class TestPeerResolutionPrecedence: + """Peer identity resolution in HonchoSessionManager.get_or_create. + + Precedence must be: config.peer_name > runtime_user_peer_name > session-key fallback. + This prevents gateway transports (Telegram, Discord, Slack, ...) from each + minting a separate peer for the same human operator when peer_name is + explicitly configured. + """ + + def _make_manager(self, peer_name=None, ai_peer="hermes", runtime_user_peer_name=None): + """Build a manager with a mocked Honcho client so no network calls happen.""" + honcho = MagicMock() + # honcho.peer(id) returns a peer stub; honcho.session(id) returns a session stub + honcho.peer.side_effect = lambda pid: SimpleNamespace(id=pid) + fake_session = MagicMock() + fake_session.add_peers = MagicMock() + fake_session.get_peer_configuration.side_effect = Exception("skip server sync") + fake_session.context.side_effect = Exception("skip context load") + honcho.session.return_value = fake_session + + config = SimpleNamespace( + peer_name=peer_name, + ai_peer=ai_peer, + write_frequency="session", # avoid spawning async writer thread + 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=True, + message_max_chars=25000, + dialectic_max_input_chars=10000, + ) + return HonchoSessionManager( + honcho=honcho, + config=config, + runtime_user_peer_name=runtime_user_peer_name, + ) + + def test_config_peer_name_wins_over_runtime_user_id(self): + """When peer_name is set, runtime user_id from the gateway is ignored. + + This is the bug being fixed: previously the gateway user_id shadowed + peer_name, causing one human to fragment into one Honcho peer per + transport (CLI peer, Telegram user_id peer, Discord user_id peer, ...). + """ + mgr = self._make_manager( + peer_name="swhitt", + runtime_user_peer_name="7140239264", # e.g. Telegram user_id + ) + session = mgr.get_or_create("agent:main:telegram:dm:7140239264") + assert session.user_peer_id == "swhitt" + assert session.assistant_peer_id == "hermes" + + def test_runtime_user_id_used_when_peer_name_unset(self): + """Multi-user bot path: no peer_name configured → scope per platform user.""" + mgr = self._make_manager( + peer_name=None, + runtime_user_peer_name="8439114563", + ) + session = mgr.get_or_create("agent:main:discord:dm:8439114563") + assert session.user_peer_id == "8439114563" + + def test_session_key_fallback_when_neither_set(self): + """No peer_name, no runtime_user_peer_name → derive from session key.""" + mgr = self._make_manager(peer_name=None, runtime_user_peer_name=None) + session = mgr.get_or_create("telegram:12345") + # fallback format: user--, sanitized + assert session.user_peer_id == "user-telegram-12345" + + def test_peer_name_applied_across_transports(self): + """Same operator hitting multiple transports collapses to one peer.""" + # Telegram + mgr_tg = self._make_manager(peer_name="swhitt", runtime_user_peer_name="7140239264") + s_tg = mgr_tg.get_or_create("agent:main:telegram:dm:7140239264") + # Discord + mgr_dc = self._make_manager(peer_name="swhitt", runtime_user_peer_name="146692017550917632") + s_dc = mgr_dc.get_or_create("agent:main:discord:dm:146692017550917632") + # CLI (no runtime user_id at all) + mgr_cli = self._make_manager(peer_name="swhitt", runtime_user_peer_name=None) + s_cli = mgr_cli.get_or_create("cli:20260420_190000_abcdef") + + assert s_tg.user_peer_id == s_dc.user_peer_id == s_cli.user_peer_id == "swhitt" + + def test_peer_name_is_sanitized(self): + """peer_name containing invalid chars is passed through _sanitize_id.""" + mgr = self._make_manager(peer_name="steve@example.com") + session = mgr.get_or_create("cli:test") + # _sanitize_id replaces non [a-zA-Z0-9_-] with '-' + assert session.user_peer_id == "steve-example-com" + + class TestPeerLookupHelpers: def _make_cached_manager(self): mgr = HonchoSessionManager() From e1bad172f80804a83703ef9da1dcff49db9e0825 Mon Sep 17 00:00:00 2001 From: Steve Whittaker Date: Tue, 21 Apr 2026 00:02:46 +0000 Subject: [PATCH 2/2] docs(honcho): clarify peerName identity semantics --- optional-skills/autonomous-ai-agents/honcho/SKILL.md | 2 +- plugins/memory/honcho/README.md | 2 +- tests/agent/test_memory_user_id.py | 6 +++--- website/docs/user-guide/features/memory-providers.md | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/optional-skills/autonomous-ai-agents/honcho/SKILL.md b/optional-skills/autonomous-ai-agents/honcho/SKILL.md index 1c099ca605f1f..79f67929f58cf 100644 --- a/optional-skills/autonomous-ai-agents/honcho/SKILL.md +++ b/optional-skills/autonomous-ai-agents/honcho/SKILL.md @@ -77,7 +77,7 @@ You do not need to configure this -- it is automatic based on session state. Honcho models conversations as interactions between **peers**. Hermes creates two peers per session: -- **User peer** (`peerName`): represents the human. Honcho builds a user representation from observed messages. +- **User peer** (`peerName`): represents the human. When set, it is the stable human identity across transports; leave it unset in shared multi-user gateway deployments so runtime `user_id` can identify each user. Honcho builds a user representation from observed messages. - **AI peer** (`aiPeer`): represents this Hermes instance. Each profile gets its own AI peer so agents develop independent views. ### Observation diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index 4f8d10ea9ecb5..d06dcde815c64 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -124,7 +124,7 @@ For every key, resolution order is: **host block > root > env var > default**. | `environment` | string | `"production"` | SDK environment mapping | | `enabled` | bool | auto | Master toggle. Auto-enables when `apiKey` or `baseUrl` present | | `workspace` | string | host key | Honcho workspace ID. Shared environment — all profiles in the same workspace can see the same user identity and related memories | -| `peerName` | string | — | User peer identity | +| `peerName` | string | — | User peer identity. When set, this is the stable human peer across transports; leave unset in shared multi-user gateway deployments so runtime `user_id` can identify each user. | | `aiPeer` | string | host key | AI peer identity | ### Memory & Recall diff --git a/tests/agent/test_memory_user_id.py b/tests/agent/test_memory_user_id.py index d33753bd2e1cc..b9321af3ef500 100644 --- a/tests/agent/test_memory_user_id.py +++ b/tests/agent/test_memory_user_id.py @@ -252,8 +252,8 @@ def test_gateway_user_id_is_passed_as_runtime_peer(self): assert mock_cfg.peer_name == "static-user" assert mock_manager_cls.call_args.kwargs["runtime_user_peer_name"] == "discord_user_789" - def test_session_manager_prefers_runtime_user_id_over_config_peer_name(self): - """Session manager should isolate gateway users even when config peer_name is static.""" + def test_session_manager_prefers_config_peer_name_over_runtime_user_id(self): + """Explicit peer_name should remain authoritative over gateway user_id.""" from plugins.memory.honcho.session import HonchoSessionManager mock_cfg = MagicMock() @@ -282,7 +282,7 @@ def test_session_manager_prefers_runtime_user_id_over_config_peer_name(self): ): session = manager.get_or_create("discord:channel-1") - assert session.user_peer_id == "discord_user_789" + assert session.user_peer_id == "static-user" def test_no_user_id_preserves_config_peer_name(self): """Without user_id, the config peer_name should be preserved.""" diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index d11c36657a3e0..bc3ff7e734ebd 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -77,7 +77,7 @@ hermes memory setup # select "honcho" |-----|---------|-------------| | `apiKey` | -- | API key from [app.honcho.dev](https://app.honcho.dev) | | `baseUrl` | -- | Base URL for self-hosted Honcho | -| `peerName` | -- | User peer identity | +| `peerName` | -- | User peer identity. When set, this is the stable human peer across transports; leave unset in shared multi-user gateway deployments so runtime `user_id` can identify each user. | | `aiPeer` | host key | AI peer identity (one per profile) | | `workspace` | host key | Shared workspace ID | | `contextTokens` | `null` (uncapped) | Token budget for auto-injected context per turn. Truncates at word boundaries |