Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion optional-skills/autonomous-ai-agents/honcho/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugins/memory/honcho/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 16 additions & 4 deletions plugins/memory/honcho/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions tests/agent/test_memory_user_id.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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."""
Expand Down
94 changes: 94 additions & 0 deletions tests/honcho_plugin/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-<channel>-<chat_id>, 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()
Expand Down
2 changes: 1 addition & 1 deletion website/docs/user-guide/features/memory-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down