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
This commit is contained in:
@@ -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`)
|
||||
|
||||
|
||||
@@ -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.<host> 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:
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user