From 0a7c17159c629dce746e36d081f1a9476546341e Mon Sep 17 00:00:00 2001 From: Eli Robbins <51828767+itnkr@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:30:20 -0400 Subject: [PATCH] feat(honcho): add sessionAiPeerPrefix to isolate sessions per AI peer The gateway_session_key branch of resolve_session_name() returns an AI-peer-agnostic name, so multiple AI peers sharing one workspace + peerName + gateway chat key collide on a single Honcho session. Add sessionAiPeerPrefix (symmetric counterpart to sessionPeerPrefix): when set, the resolved session name is prefixed with {ai_peer}- on every resolution path. The prefixed name is re-run through the session-id length cap so the prefix can never exceed Honcho's limit. - config field + host/root parsing in client.py - public resolve_session_name() wraps a new _resolve_session_name_base() - tests covering parsing, the gateway-key case, cross-peer disjointness, the length cap, and a disabled-by-default regression guard - README: config table + resolution notes --- plugins/memory/honcho/README.md | 7 +- plugins/memory/honcho/client.py | 29 +++- plugins/memory/honcho/config_schema.py | 3 + .../test_session_ai_peer_prefix.py | 137 ++++++++++++++++++ 4 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 tests/honcho_plugin/test_session_ai_peer_prefix.py diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index 57c7fd2164..51c84cdae2 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -256,8 +256,9 @@ Pick **[e]** at the prompt to set the three keys directly instead of going throu | Key | Type | Default | Description | |-----|------|---------|-------------| | `sessionStrategy` | string | `"per-directory"` | `"per-directory"`, `"per-session"`, `"per-repo"` (git root), `"global"` | -| `sessionPeerPrefix` | bool | `false` | Prepend peer name to session keys | | `a2aSessions` | bool | `true` | Write DMs from other bots into their own session per sender bot. `false` skips bot-authored turns | +| `sessionPeerPrefix` | bool | `false` | Prepend the user peer name to session keys | +| `sessionAiPeerPrefix` | bool | `false` | Prepend the AI peer (`aiPeer`) to session keys — keeps sessions disjoint when multiple AI peers share a workspace | | `sessions` | object | `{}` | Manual directory-to-session-name mappings | #### Session Name Resolution @@ -276,7 +277,9 @@ The Honcho session name determines which conversation bucket memory lands in. Re Gateway platforms always resolve via priority 3 (per-chat isolation) regardless of `sessionStrategy`. The strategy setting only affects CLI sessions. -If `sessionPeerPrefix` is `true`, the peer name is prepended: `alice-hermes-agent`. +If `sessionPeerPrefix` is `true`, the user peer name is prepended: `alice-hermes-agent`. + +If `sessionAiPeerPrefix` is `true`, the AI peer (`aiPeer`) is prepended to the final name on **every** path — including priority 3. This is the symmetric counterpart to `sessionPeerPrefix` and exists because the gateway session key is AI-peer-agnostic: when several AI peers share one workspace, peer name, and gateway chat key, they would otherwise collide on a single session. With `aiPeer: ivy`, priority 3 becomes `ivy-agent-main-telegram-dm-8439114563`. #### Bot DMs (`a2aSessions`) diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index 0469e62fe2..215325fe79 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -330,6 +330,7 @@ def _behavior_fields(look: _HostLookup, explicitly_configured: bool) -> dict[str "session_strategy": look.pick("sessionStrategy", "per-directory"), "session_peer_prefix": look.pick_set("sessionPeerPrefix", False), "a2a_sessions": look.flag("a2aSessions", default=True), + "session_ai_peer_prefix": look.pick_set("sessionAiPeerPrefix", False), } @@ -394,6 +395,9 @@ class HonchoClientConfig: session_peer_prefix: bool = False # Bot-authored DMs write into their own session per sender bot. a2a_sessions: bool = True + # Symmetric to session_peer_prefix: several AI peers sharing one workspace, peerName and gateway + # chat key would otherwise resolve to the same session name. + session_ai_peer_prefix: bool = False sessions: dict[str, str] = field(default_factory=dict) raw: dict[str, Any] = field(default_factory=dict) # A hosts. block or explicit enabled flag, vs auto-enabled from a stray env key. @@ -485,10 +489,27 @@ class HonchoClientConfig: self, cwd: str | None = None, session_title: str | None = None, session_id: str | None = None, gateway_session_key: str | None = None, ) -> str | None: - """Resolve the Honcho session name. Order: gateway session key (per-chat isolation no - cwd/strategy gives) -> per-session strategy's session_id (authoritative, so a generated - title never remaps a live conversation) -> sessions map override -> /title -> - per-repo (git root name) -> per-directory (basename) -> global (workspace).""" + """Resolve the Honcho session name; with ``session_ai_peer_prefix`` the result is prefixed + ``{ai_peer}-`` on every path, including the AI-peer-agnostic gateway session key.""" + import re + + result = self._resolve_session_name_base(cwd=cwd, session_title=session_title, + session_id=session_id, gateway_session_key=gateway_session_key) + if result and self.session_ai_peer_prefix and self.ai_peer: + ai = re.sub(r'[^a-zA-Z0-9_-]+', '-', self.ai_peer).strip('-') + if ai: + prefixed = f"{ai}-{result}" + return self._enforce_session_id_limit(prefixed, prefixed) + return result + + def _resolve_session_name_base( + self, cwd: str | None = None, session_title: str | None = None, + session_id: str | None = None, gateway_session_key: str | None = None, + ) -> str | None: + """Order: gateway session key (per-chat isolation no cwd/strategy gives) -> per-session + strategy's session_id (authoritative, so a generated title never remaps a live conversation) + -> sessions map override -> /title -> per-repo (git root name) -> per-directory (basename) + -> global (workspace).""" import re def _slug(text: str) -> str: diff --git a/plugins/memory/honcho/config_schema.py b/plugins/memory/honcho/config_schema.py index fdd778d68f..878dcd3a95 100644 --- a/plugins/memory/honcho/config_schema.py +++ b/plugins/memory/honcho/config_schema.py @@ -69,6 +69,9 @@ CONFIG_SCHEMA = ProviderConfigSchema( _field("a2aSessions", "Bot DM sessions", KIND_BOOL, "Write DMs from other bots into their own Honcho session per sender. Off skips bot-authored turns.", default="true", group="Session"), + _field("sessionAiPeerPrefix", "Session AI peer prefix", KIND_BOOL, + "Prefix session names with the AI peer. Keeps sessions disjoint when several AI peers share a workspace.", + default="false", group="Session"), _field("sessions", "Session overrides", KIND_JSON, "Explicit session ID overrides keyed by resolver.", placeholder='{"key": "session-id"}', group="Session", scope="root"), # — Message writing — diff --git a/tests/honcho_plugin/test_session_ai_peer_prefix.py b/tests/honcho_plugin/test_session_ai_peer_prefix.py new file mode 100644 index 0000000000..2ee19f8e88 --- /dev/null +++ b/tests/honcho_plugin/test_session_ai_peer_prefix.py @@ -0,0 +1,137 @@ +"""Tests for the ``sessionAiPeerPrefix`` config flag. + +``sessionPeerPrefix`` prefixes a resolved session name with the *user* peer +(``peerName``). ``sessionAiPeerPrefix`` is the symmetric counterpart for the +*AI* peer (``aiPeer``): when several AI peers share one workspace + peerName + +gateway chat key, the ``gateway_session_key`` branch of +``resolve_session_name`` returns an AI-peer-agnostic name and every peer +collides on the same Honcho session. Setting ``sessionAiPeerPrefix: true`` +prefixes the final name with ``{ai_peer}-`` so the sessions stay disjoint. + +Tests cover config parsing (``client.py::from_global_config``) and the public +resolver wrapper across every resolution path, including the length cap. +""" + +import json + +from plugins.memory.honcho.client import HonchoClientConfig + + +# --------------------------------------------------------------------------- +# Config parsing +# --------------------------------------------------------------------------- + + +class TestSessionAiPeerPrefixConfigParsing: + def test_default_is_false(self): + """Default preserves existing behaviour.""" + config = HonchoClientConfig() + assert config.session_ai_peer_prefix is False + + def test_root_level_true(self, tmp_path, monkeypatch): + config_file = tmp_path / "honcho.json" + config_file.write_text(json.dumps({ + "apiKey": "k", + "peerName": "eli", + "aiPeer": "ivy", + "sessionAiPeerPrefix": True, + })) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "isolated")) + + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.session_ai_peer_prefix is True + assert config.ai_peer == "ivy" + + def test_host_block_true(self, tmp_path, monkeypatch): + config_file = tmp_path / "honcho.json" + config_file.write_text(json.dumps({ + "apiKey": "k", + "peerName": "eli", + "hosts": { + "hermes": {"aiPeer": "ivy", "sessionAiPeerPrefix": True}, + }, + })) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "isolated")) + + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.session_ai_peer_prefix is True + + def test_host_block_overrides_root(self, tmp_path, monkeypatch): + """Host block wins over root — matches every other flag.""" + config_file = tmp_path / "honcho.json" + config_file.write_text(json.dumps({ + "apiKey": "k", + "peerName": "eli", + "sessionAiPeerPrefix": True, + "hosts": { + "hermes": {"sessionAiPeerPrefix": False}, + }, + })) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "isolated")) + + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.session_ai_peer_prefix is False + + +# --------------------------------------------------------------------------- +# Resolver behaviour +# --------------------------------------------------------------------------- + + +class TestSessionAiPeerPrefixResolver: + def _config(self, **overrides): + base = dict( + ai_peer="ivy", + peer_name="eli", + session_ai_peer_prefix=True, + ) + base.update(overrides) + return HonchoClientConfig(**base) + + def test_gateway_key_gets_ai_peer_prefix(self): + """The motivating case: gateway_session_key is AI-peer-agnostic.""" + cfg = self._config() + name = cfg.resolve_session_name( + gateway_session_key="agent:main:telegram:dm:8055661863", + ) + assert name.startswith("ivy-") + assert "telegram-dm-8055661863" in name + + def test_disabled_leaves_name_untouched(self): + """Regression guard — default behaviour is unchanged.""" + cfg = self._config(session_ai_peer_prefix=False) + name = cfg.resolve_session_name( + gateway_session_key="agent:main:telegram:dm:8055661863", + ) + assert not name.startswith("ivy-") + + def test_two_ai_peers_stay_disjoint(self): + """Same workspace + chat key, different aiPeer -> different sessions.""" + key = "agent:main:telegram:dm:8055661863" + ivy = self._config(ai_peer="ivy").resolve_session_name(gateway_session_key=key) + holly = self._config(ai_peer="holly").resolve_session_name(gateway_session_key=key) + assert ivy != holly + assert ivy.startswith("ivy-") + assert holly.startswith("holly-") + + def test_applies_across_strategy_paths(self, tmp_path): + """Prefix is not limited to the gateway-key branch.""" + cfg = self._config(session_strategy="per-directory") + name = cfg.resolve_session_name(cwd=str(tmp_path / "myproject")) + assert name == "ivy-myproject" + + def test_no_ai_peer_no_prefix(self): + """An empty aiPeer must not produce a stray leading hyphen.""" + cfg = self._config(ai_peer="") + name = cfg.resolve_session_name(gateway_session_key="agent:main:dm:1") + assert not name.startswith("-") + + def test_prefix_respects_session_id_length_cap(self): + """A long base + prefix stays within Honcho's 100-char session-id cap, + and remains disjoint per AI peer (hash incorporates the prefix).""" + long_key = "agent:main:telegram:dm:" + "9" * 200 + ivy = self._config(ai_peer="ivy").resolve_session_name(gateway_session_key=long_key) + holly = self._config(ai_peer="holly").resolve_session_name(gateway_session_key=long_key) + assert len(ivy) <= HonchoClientConfig._HONCHO_SESSION_ID_MAX_LEN + assert len(holly) <= HonchoClientConfig._HONCHO_SESSION_ID_MAX_LEN + assert ivy != holly